diff --git a/.agents/skills/changelog-entries/SKILL.md b/.agents/skills/changelog-entries/SKILL.md new file mode 100644 index 0000000000..5ccd0fd068 --- /dev/null +++ b/.agents/skills/changelog-entries/SKILL.md @@ -0,0 +1,79 @@ +--- +name: changelog-entries +description: > + Write changelog entries for CHANGELOG.md. Use when work has been completed + and changelog entries need to be added, or when the user asks to write, add, or update changelog + entries, release notes, or document changes. Triggers on requests like "add changelog entry", + "update the changelog", "write release notes", or after completing a feature/fix that needs documenting. +--- + +# Changelog Entries + +Write entries that match the established format in the target changelog file. + +## Determine Target File + +1. If the user specifies a file, use that, otherwise use `CHANGELOG.md`. +2. Read the first ~80 lines of the target file to confirm the current format and find the insertion point. + +## CHANGELOG.md Format + +Versioned release changelog. Entries go under `## Unreleased` or a version header like `## X.Y.Z - YYYY-MM-DD`. + +Each entry is a `- ` prefixed line. No blank lines between entries within a section. + +```markdown +## Unreleased + +- Added `craft\helpers\SomeHelper::someMethod()`. +- Fixed a bug where something wasn't working properly. ([#12345](https://github.com/craftcms/cms/pull/12345)) +- Deprecated `craft\old\Thing`. `craft\new\Thing` should be used instead. +``` + +**Patch releases** use a flat list with no subheaders. + +**Major/minor releases** may group entries under `###` subheaders: +`### Content Management`, `### Accessibility`, `### Administration`, `### Development`, `### Extensibility`, `### System` + +**Warnings** go above the entry list using GitHub callout syntax: +```markdown +> [!WARNING] +> Important note about breaking changes. +``` + +## Writing Rules + +1. **Start with a past-tense verb** — capitalize it: + - `Added` — new classes, methods, features, settings + - `Fixed` — bug fixes: `Fixed a bug where...` or `Fixed an error that...` + - `Deprecated` — include replacement: `` `old\Thing`. `new\Thing` should be used instead. `` + - `Removed` — removed classes/features (with replacement if applicable) + - `Improved` — performance or UX improvements + - Other verbs as appropriate: `Updated`, `Renamed`, `Moved`, `Replaced` + +2. **Backtick all code references** — class names, methods, properties, constants, config keys, Twig variables, CLI commands. Use fully qualified class names (no leading backslash). + +3. **One entry per line** — don't wrap. Each `- ` entry is a single line regardless of length. + +4. **End entries with a period.** + +5. **Link issues/PRs** at the end when applicable, prefer PRs when there is one: + - PRs: `([#12345](https://github.com/craftcms/cms/pull/12345))` + - Issues: `([#12345](https://github.com/craftcms/cms/issues/12345))` + - Security: `(GHSA-xxxx-xxxx-xxxx)` + - External repos: `([craftcms/commerce#4006](https://github.com/craftcms/commerce/issues/4006))` + +6. **Security fixes** include severity level: `` Fixed a [high-severity](https://github.com/craftcms/cms/security/policy#severity--remediation) RCE vulnerability. (GHSA-xxxx-xxxx-xxxx) `` + +7. **Keyboard shortcuts** use `` tags: `Return`. + +## Entry Templates + +For the full set of templates and examples, see `references/entry-templates.md`. + +## Workflow + +1. Identify what changed (from conversation context, git diff, or user description). +2. Determine the target file and section/insertion point. +3. Write entries following the format rules above. +4. Insert entries in the appropriate location in the file. diff --git a/.agents/skills/changelog-entries/references/entry-templates.md b/.agents/skills/changelog-entries/references/entry-templates.md new file mode 100644 index 0000000000..2dd6bdb761 --- /dev/null +++ b/.agents/skills/changelog-entries/references/entry-templates.md @@ -0,0 +1,225 @@ +# Entry Templates + +## Added + +**New class or service:** +``` +- Added `CraftCms\Cms\Namespace\ClassName`. +``` + +**New facade:** +``` +- Added `CraftCms\Cms\Support\Facades\FacadeName`. +``` + +**New enum:** +``` +- Added `CraftCms\Cms\Namespace\EnumName` enum. +``` + +**New event:** +``` +- Added `CraftCms\Cms\Namespace\Events\EventName`. +``` + +**New event with description:** +``` +- Added `CraftCms\Cms\Namespace\Events\EventName` event for customizing X behavior. +``` + +**New method or property:** +``` +- Added `craft\helpers\ElementHelper::cleanseQueryCriteria()`. +- Added `craft\base\ElementTrait::$applyingDraft`. ([#18057](https://github.com/craftcms/cms/pull/18057)) +``` + +**New method macro:** +``` +- Added `Request::isPreview()` macro for detecting preview requests via `x-craft-preview` or `x-craft-live-preview` parameters. +``` + +**New console command:** +``` +- Added `php craft twig:cache` - Precompile Twig views. +``` + +**New config setting:** +``` +- Added the `enableTwigSandbox` config setting. ([#18208](https://github.com/craftcms/cms/pull/18208)) +``` + +**New library:** +``` +- Added the Illuminate Support library. +``` + +**Multiple related additions (with sub-list):** +``` +- Added element-specific authorization policies: + - `CraftCms\Cms\Entry\Policies\EntryPolicy` + - `CraftCms\Cms\Asset\Policies\AssetPolicy` +``` + +## Fixed + +**Bug fix:** +``` +- Fixed a bug where something wasn't working properly. ([#12345](https://github.com/craftcms/cms/pull/12345)) +- Fixed a bug where Matrix fields in Blocks view could lose their existing values when they became editable. +``` + +**Error fix:** +``` +- Fixed an error that could occur when editing an element with a Table field. ([#18408](https://github.com/craftcms/cms/pull/18408)) +- Fixed an error that occurred when creating a new element on multi-site installs. ([#18393](https://github.com/craftcms/cms/pull/18393)) +``` + +**JavaScript fix:** +``` +- Fixed a JavaScript error that occurred if a Matrix field's label was hidden. ([#18366](https://github.com/craftcms/cms/pull/18366)) +- Fixed potential JavaScript errors that could occur if a disclosure menu's trigger was missing. ([#18358](https://github.com/craftcms/cms/pull/18358)) +``` + +**Security fix:** +``` +- Fixed a [high-severity](https://github.com/craftcms/cms/security/policy#severity--remediation) RCE vulnerability. (GHSA-fp5j-j7j4-mcxc) +- Fixed a [moderate-severity](https://github.com/craftcms/cms/security/policy#severity--remediation) SSTI vulnerability. (GHSA-qc86-q28f-ggww) +- Fixed [low-severity](https://github.com/craftcms/cms/security/policy#severity--remediation) XSS vulnerabilities. (GHSA-4mgv-366x-qxvx) +``` + +**Styling fix:** +``` +- Fixed a styling issue with slideouts within Live Preview. ([#18383](https://github.com/craftcms/cms/pull/18383)) +``` + +## Deprecated + +**Class replaced by new class:** +``` +- Deprecated `craft\old\ClassName`. `CraftCms\Cms\New\ClassName` should be used instead. +``` + +**Method replaced by new method:** +``` +- Deprecated `craft\old\Class::oldMethod()`. `CraftCms\Cms\New\Class::newMethod()` should be used instead. +``` + +**Property replaced:** +``` +- Deprecated `GeneralConfig::$oldProp` in favor of Laravel's config.key config value. +``` + +**Constant replaced by enum case:** +``` +- Deprecated `craft\web\View::TEMPLATE_MODE_CP`. `CraftCms\Cms\View\TemplateMode::Cp` should be used instead. +``` + +**Event constant replaced by event class:** +``` +- Deprecated `craft\web\View::EVENT_REGISTER_CP_TEMPLATE_ROOTS`. `CraftCms\Cms\View\Events\RegisterCpTemplateRoots` should be used instead. +``` + +**Twig variable replaced:** +``` +- Deprecated `craft.app.config.general` in Twig. `app.config.craft.general` should be used instead. +``` + +**Class replaced with extra context:** +``` +- Deprecated `Craft::$app->getConfig()->getGeneral()`. `CraftCms\Cms\Config\GeneralConfig` should be used instead. This can be used through dependency injection or through `app(CraftCms\Cms\Config\GeneralConfig::class)`. +``` + +**Config setting deprecated:** +``` +- The `disableGraphqlTransformDirective` config setting is now deprecated. +``` + +**Replaced by Laravel built-in (no direct Craft replacement):** +``` +- Deprecated `craft\filters\BasicHttpAuthLogin`. Use the `auth.basic` middleware instead. +``` + +**Multiple methods with "in favor of" and sub-list:** +``` +- Deprecated `craft\events\WidgetEvent` in favor of the following new events: + - `craft\services\Dashboard::EVENT_BEFORE_SAVE_WIDGET` => `CraftCms\Cms\Dashboard\Events\WidgetSaving` + - `craft\services\Dashboard::EVENT_AFTER_SAVE_WIDGET` => `CraftCms\Cms\Dashboard\Events\WidgetSaved` +``` + +**Multiple methods from same class (sub-list with arrow mapping):** +``` +- Deprecated `craft\helpers\App`. The following classes/methods should be used instead: + - `App:devMode()` --> `app()->hasDebugModeEnabled()` + - `App:parseBooleanEnv()` --> `\CraftCms\Cms\Support\Env::parseBoolean()` +``` + +## Removed + +**Class removed with replacement:** +``` +- Removed `craft\old\Class`. `CraftCms\Cms\New\Class` should be used instead. +``` + +**Class removed with multiple replacements:** +``` +- Removed `craft\controllers\DashboardController`. The following controllers now implement this functionality: + - `CraftCms\Cms\Http\Controllers\Dashboard\DashboardController` + - `CraftCms\Cms\Http\Controllers\Dashboard\WidgetsController` +``` + +**Removed because previously deprecated:** +``` +- Removed `craft\helpers\MigrationHelper` as it was deprecated since 4.0.0. +``` + +**Event removed:** +``` +- Removed `craft\events\UpdateReleaseEvent` in favor of `CraftCms\Cms\Update\Events\CriticalUpdateReleasedEvent`. +``` + +**Database column removed:** +``` +- Removed `verificationCode` and `verificationCodeIssuedDate` columns on the `users` table in favor of the `password_reset_tokens` table. +``` + +## Replaced + +``` +- Replaced `craft\controllers\StructuresController`. `CraftCms\Cms\Http\Controllers\StructuresController`. +- Replaced `craft\controllers\SystemMessagesController` with `CraftCms\Cms\Http\Controllers\Utilities\SystemMessagesController`. +``` + +## Improved + +``` +- Improved the performance of `craft\helpers\Typecast`. ([#18426](https://github.com/craftcms/cms/pull/18426)) +- Improved the accessibility of user permission lists. ([#18290](https://github.com/craftcms/cms/pull/18290)) +- Improved drag-n-drop performance. ([#18019](https://github.com/craftcms/cms/pull/18019)) +``` + +## Updated + +``` +- Updated Yii to 2.0.54. +- Updated Twig to 3.21. ([#17603](https://github.com/craftcms/cms/discussions/17603)) +- Updated Axios to 1.12.2. ([#17988](https://github.com/craftcms/cms/pull/17988)) +``` + +## Behavioral Changes (no action verb prefix) + +``` +- `CraftCms\Cms\User\Elements\User` now implements `Illuminate\Contracts\Auth\Authenticatable`. +- `craft\services\Elements::stopCollectingCacheInfo()` no longer sets the returned duration to the `cacheDuration` config setting if a duration wasn't explicitly declared. ([#16796](https://github.com/craftcms/cms/pull/16796)) +- Element indexes now show "Paste" buttons alongside bulk element action buttons. ([#18427](https://github.com/craftcms/cms/pull/18427)) +- `slug` columns referenced in element queries' `select`, `where`, or `orderBy` expressions now explicitly resolve to `elements_sites.slug`. ([#18416](https://github.com/craftcms/cms/pull/18416)) +- The `maxCachedCloudImageSize` config setting is now set to `0` by default. ([#17997](https://github.com/craftcms/cms/pull/17997)) +``` + +## Feature Descriptions (no code reference) + +``` +- Nested entries' edit screens now have a "Field settings" action menu item. +- Legacy entry index URLs now redirect `content/`. +- Bulk element actions are now available on element indexes for mobile devices. +- Revisions now keep track of which element attributes/fields were modified for the revision. +``` diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml new file mode 100644 index 0000000000..6ebacbe923 --- /dev/null +++ b/.github/actions/run-tests/action.yml @@ -0,0 +1,60 @@ +name: 'Run Tests' +description: 'Setup PHP, install dependencies, and run a test command' + +inputs: + test-command: + description: 'The test command to run' + required: true + php-version: + description: 'PHP version to install' + required: false + default: '8.5' + github-token: + description: 'GitHub token for Composer' + required: false + default: '' + packagist-username: + description: 'Packagist username' + required: false + default: '' + packagist-token: + description: 'Packagist token' + required: false + default: '' + +runs: + using: 'composite' + steps: + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo, ffi + tools: composer:v2 + ini-values: display_errors=On, memory_limit=2G + coverage: none + # Pass an empty token so setup-php doesn't write GITHUB_TOKEN into + # Composer's auth config. Composer 2.9.7's regex rejects the new + # GITHUB_TOKEN format (composer/composer#12849). + github-token: '' + env: + COMPOSER_AUTH_JSON: | + { + "http-basic": { + "repo.packagist.com": { + "username": "${{ inputs.packagist-username }}", + "password": "${{ inputs.packagist-token }}" + } + } + } + + - name: Set version + shell: bash + run: composer config version "6.x-dev" + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run tests + shell: bash + run: ${{ inputs.test-command }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e8c794175..a93f4fc425 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,26 +1,190 @@ name: ci on: workflow_dispatch: - push: - branches: - - '5.x' pull_request: -permissions: - contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true + +permissions: + contents: read + jobs: - ci: - name: ci - uses: craftcms/.github/.github/workflows/ci.yml@v3 - with: - php_version: '["8.2", "8.3"]' - craft_version: '5' - node_version: '20' - jobs: '["ecs", "phpstan", "prettier", "tests", "rector"]' - notify_slack: true - slack_subteam: - secrets: - token: ${{ secrets.GITHUB_TOKEN }} - slack_webhook_url: ${{ secrets.SLACK_COMMERCE_WEBHOOK_URL }} + check-cs: + name: 'Code Quality / ECS' + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + tools: composer:v2 + coverage: none + env: + COMPOSER_AUTH_JSON: | + { + "http-basic": { + "repo.packagist.com": { + "username": "${{ secrets.packagist_username }}", + "password": "${{ secrets.packagist_token }}" + } + } + } + + - name: Set version + run: composer config version "6.x-dev" + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run ECS + run: composer run check-cs + + rector: + name: 'Code Quality / Rector' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + env: + COMPOSER_AUTH_JSON: | + { + "http-basic": { + "repo.packagist.com": { + "username": "${{ secrets.packagist_username }}", + "password": "${{ secrets.packagist_token }}" + } + } + } + + - name: Set version + run: composer config version "6.x-dev" + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Rector Cache + uses: actions/cache@v4 + with: + path: /tmp/rector + key: ${{ runner.os }}-rector-${{ github.run_id }} + restore-keys: ${{ runner.os }}-rector- + + - run: mkdir -p /tmp/rector + + - name: Run Rector + run: vendor/bin/rector process --dry-run --ansi + + phpstan: + name: 'Code Quality / Phpstan' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + tools: composer:v2 + coverage: none + env: + COMPOSER_AUTH_JSON: | + { + "http-basic": { + "repo.packagist.com": { + "username": "${{ secrets.packagist_username }}", + "password": "${{ secrets.packagist_token }}" + } + } + } + + - name: Set version + run: composer config version "6.x-dev" + + - name: Install composer dependencies + uses: ramsey/composer-install@v3 + + - name: PHPStan Cache + uses: actions/cache@v4 + with: + path: /tmp/phpstan + key: ${{ runner.os }}-phpstan-${{ github.run_id }} + restore-keys: ${{ runner.os }}-phpstan- + + - name: Run PHPStan + run: ./vendor/bin/phpstan --error-format=github + + unit-tests: + needs: [check-cs, phpstan, rector] + name: 'Tests / Unit' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run tests + uses: ./.github/actions/run-tests + with: + test-command: ./vendor/bin/pest --ci --compact --testsuite=Unit + github-token: ${{ secrets.GITHUB_TOKEN }} + packagist-username: ${{ secrets.packagist_username }} + packagist-token: ${{ secrets.packagist_token }} + + arch-tests: + needs: [check-cs, phpstan, rector] + name: 'Tests / Arch' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run tests + uses: ./.github/actions/run-tests + with: + test-command: ./vendor/bin/pest --ci --testsuite=Arch + github-token: ${{ secrets.GITHUB_TOKEN }} + packagist-username: ${{ secrets.packagist_username }} + packagist-token: ${{ secrets.packagist_token }} + + feature-tests: + needs: [check-cs, phpstan, rector] + # SQLite only for now — Commerce's stat/catalog-pricing queries have only been verified + # against SQLite so far. Add a mysql/pgsql matrix (see cms-6's laravel-ci.yml for the + # pattern) once those paths have been checked locally. + name: 'Tests / Feature' + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run tests + uses: ./.github/actions/run-tests + with: + test-command: ./vendor/bin/pest --ci --compact --testsuite=Feature + github-token: ${{ secrets.GITHUB_TOKEN }} + packagist-username: ${{ secrets.packagist_username }} + packagist-token: ${{ secrets.packagist_token }} diff --git a/.gitignore b/.gitignore index 4196d67e98..5eb81f123c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,9 @@ *aspnet_client/* node_modules/ /vendor -/tests/_craft/config/project .env +.phpunit.cache *-private.md *-private.html -/claude.md /.claude +/CLAUDE.md diff --git a/CHANGELOG-WIP-first-pass.md b/CHANGELOG-WIP-first-pass.md new file mode 100644 index 0000000000..7c7f006f3d --- /dev/null +++ b/CHANGELOG-WIP-first-pass.md @@ -0,0 +1,2231 @@ +# Release Notes for Craft Commerce 6 WIP + +### Laravel Migration — Stage 12f: Orders Widget & Cleanup (Stage 12 complete) + +Final slice of Stage 12 — `Orders` (the "Recent Orders" widget, the only one of the 11 with no paired `Stat` class — it queries `Order::find()` directly), repointing `src-yii2/Plugin.php::_registerWidgets()`'s imports to the new namespace, and a final import-alphabetization pass. + +- `Dashboard\Widgets\Orders` exposes `storeId`/`orderStatuses`/`limit` settings (no `dateRange` — this widget has no date-range concept at all, unlike every other widget in this stage) via the new Form system; `limit` uses the `Number` control (confirmed same API as `Choice`/`Lightswitch`, matching the real `RecentEntries::settingsForm()` reference usage). +- Repointed all 11 `use craft\commerce\widgets\*` imports in `src-yii2/Plugin.php::_registerWidgets()` to the new `Dashboard\Widgets\*` namespace — the method body itself (`$event->types[] = X::class;`) needed no changes. +- **Confirmed, not fixed**: `_registerWidgets()` only runs when `$request->getIsCpRequest()` is true (gated in `Plugin.php::init()`, unchanged by this migration) — meaning it never fires under `craft exec:exec`'s console context, so the widget-type *registration* itself couldn't be directly observed live. This is pre-existing behavior (true before this migration touched anything here) — verified the `Orders` widget class directly instead (metadata, `settingsForm()`, `getBodyHtml()`), which is unaffected by the registration-gating question. +- Ran `composer run fix-cs` (matching Stage 10n's precedent) across `src/` and `tests/` — 36 fixable issues across 24 files, all pure `use`-line reordering (verified via `git diff`) plus one stylistic `new FormContext` → `new FormContext()` parens normalization from `php-cs-fixer`. Caught leftover import disorder not just from this stage but from Stage 10/11's own bulk-`sed` passes too (several `Http\Controllers\*`/`Order\*`/`Payment\*` files) — confirms import-order drift accumulates across stages until a pass like this one sweeps it, consistent with why this cleanup step exists at the end of every multi-sub-stage effort. + +**Stage 12 (Dashboard Stats & Widgets) is complete.** All 10 stat classes and all 11 widget classes have been migrated from `src-yii2/{stats,widgets}/` to `Stats\*`/`Dashboard\Widgets\*`, with every Yii2 `Query`/`ActiveQuery` usage converted to Laravel's query builder. + +### Laravel Migration — Stage 12e: Chart Widgets + +Fifth slice of Stage 12 — `TotalOrders`, `TotalOrdersByCountry`, `TotalRevenue` (stat + widget pairs), the three chart-bearing widgets. Per the plan's agreed approach, chart rendering keeps the existing server-side Chart.js pipeline (data embedded into the Twig response, same frozen-legacy `StatWidgetsAsset`/`ChartJsAsset` JS) rather than adopting cms-6 core's newer AJAX+D3 pattern. + +- Stats moved to `Stats\{TotalOrders,TotalOrdersByCountry,TotalRevenue}`. `TotalOrders`' plain `COUNT(orders.id)` scalar simplifies to `createStatQuery()->count()` (equivalent since `orders.id` is never null across the query's inner join). `TotalOrdersByCountry`'s "other countries" negation query (only reached once the top-N result set is full) converts to `whereNotIn()`. `TotalRevenue` interpolates `$this->type` (`total` or `totalPaid`) directly into a `SUM(...)` column name — validated against a hardcoded allow-list immediately before use, same safety property preserved from the original. +- Widgets moved to `Dashboard\Widgets\{TotalOrders,TotalOrdersByCountry,TotalRevenue}`. `TotalOrders`' "Show Chart?" and `TotalRevenue`'s "Show Order Count?" boolean settings — missed in the initial pass over `getBodyHtml()`/`getSettingsHtml()` alone, only caught by reading the actual settings Twig template — use the new Form system's `Lightswitch` control, confirmed to have the same `make()`/`value()` fluent API as `Choice`/`Number`. `TotalRevenue::defineRules()` (the one widget in this whole stage with custom Yii validation) becomes `getRules()` returning a Laravel validation array (`Rule::in([...])`) directly, matching the `ConfigurableComponent` contract's expectations — no `parent::getRules()` merge needed, matching the real `RecentEntries::getRules()` reference pattern. +- **Verification**: live via `craft exec:exec` — confirmed all 6 legacy aliases resolve, ran all 3 stats' `get()` (including `TotalOrdersByCountry` for both `shipping`/`billing` types and `TotalRevenue` for both `total`/`totalPaid` plus an invalid-type input confirming the allow-list fallback), and exercised every widget's full metadata/`settingsForm()`/`getBodyHtml()` pipeline — including `TotalOrders` with `showChart` both on and off, since that flag branches its `getTitle()`/`getSubtitle()`/`getBodyHtml()` output. + +### Laravel Migration — Stage 12d: TopProducts + +Fourth slice of Stage 12 — `TopProducts` (stat + widget), the most complex pair: a correlated subquery for per-product tax/discount/shipping adjustment totals, a dynamically-built revenue expression driven by a `revenueOptions` checkbox-group setting, and DB-engine-conditional `IFNULL`/`COALESCE`. + +- `Stats\TopProducts`'s `createAdjustmentsSubQuery()` (per-product `SUM(CASE WHEN type='...' THEN amount END)` aggregates from `commerce_orderadjustments`) converts to a `DB::table()` builder, joined into the main query via `leftJoinSub()` — this migration's first use of that method. `getAdjustmentsSelect()`/`getGroupBy()`/`getOrderBy()` (which conditionally append `+`/`-` terms and `GROUP BY`/`ORDER BY` columns based on which revenue options are selected) keep building plain SQL strings exactly as before, just dropping Yii2's `[[column]]` quoting syntax, then get passed to `selectRaw()`/`groupByRaw()`/`orderByRaw()`. `Craft::$app->getDb()->getIsPgsql()` → `DB::connection()->getDriverName() === 'pgsql'`. +- `Dashboard\Widgets\TopProducts`'s `revenueOptions` setting (previously 4 checkboxes, each with its own instructions text and JS-driven enable/disable tied to the `type` select) becomes a single `Choice::make('revenueOptions')->multiple()` field — the new Form system's `Choice` control doesn't support per-option instructions, so each option's description is folded into its label text instead (e.g. "Discount — Include line item discounts."); the JS-driven conditional enable/disable is dropped along with every other widget's custom JS in this stage (the field is now always visible/editable, only *meaningfully* affecting output when `type` is `revenue` — matching the same class of simplification as the dropped date-range picker). +- **Verification**: live via `craft exec:exec` — confirmed the legacy alias, and ran `getData()` for all three meaningfully-different code paths (`qty` type, `revenue` type with the default 4 options selected, `revenue` type with only 1 option selected — the last one is what actually exercises the `revenue_custom` column and the correlated `leftJoinSub` adjustments query) with zero SQL errors, plus the widget's full `settingsForm()`/`getBodyHtml()` pipeline including the custom-revenue-options variant. + +### Laravel Migration — Stage 12c: Table Widgets + +Third slice of Stage 12 — `TopCustomers`, `TopProductTypes`, `TopPurchasables` (stat + widget pairs), each with a `type` setting toggling between two sort/aggregate modes and rendering their body via the legacy `AdminTableAsset`-based table (kept as-is). + +- Stats moved to `Stats\{TopCustomers,TopProductTypes,TopPurchasables}`, converting their multi-table joins (`li`/`p`/`v`/`pr`/`pt`, plus `users`/`elements_sites` for `TopCustomers`/`TopProductTypes` respectively) to Laravel's query builder. `TopProductTypes`' conditional join (`elements_sites` joined on a column comparison AND a bound-value site-ID filter) uses a join-closure (`leftJoin($table, function($join) {...})`) — this migration's first use of that pattern, needed because Laravel's simple 3-arg `join()` only supports a single column-to-column comparison. +- Widgets moved to `Dashboard\Widgets\{TopCustomers,TopProductTypes,TopPurchasables}`, each adding a `type` `Choice` field (and `TopPurchasables` a second `nameField` choice) on top of `statSettingsFields()`. +- **Verification gap, documented rather than silently skipped**: `TopProductTypes`/`TopPurchasables` both call `Catalog\ProductType\ProductTypes::getViewableProductTypeIds()`, which does `request()->craftUser()->can(...)` with no null-guard — fatal in this unauthenticated console (`craft exec:exec`) context, since real CP usage always has a logged-in user (confirmed as pre-existing and unrelated to Stage 12 — it's Stage 7e code, not touched here). Attempted a quick, bounded `Auth::loginUsingId()` workaround for verification purposes; it didn't hang (good) but failed on an underlying Eloquent-model/ID mismatch, not worth pursuing further. Verified everything else live (class aliases, both stats' `getHandle()`, all widget metadata methods, `settingsForm()` for all three, `TopCustomers`' full `getData()` including its real `users` join) and confirmed the two blocked queries' structure via careful manual comparison against the original — both share the exact same `createStatQuery()` foundation already proven correct by `TopCustomers`' live join execution. + +### Laravel Migration — Stage 12b: Simple Number Widgets + +Second slice of Stage 12 — `AverageOrderTotal`, `NewCustomers`, `RepeatCustomers` (stat + widget pairs), the simplest shape (single/double aggregate query, no charts, no extra `type` setting) — establishes the settings-form pattern every later widget in this stage reuses. + +- Stats moved to `Stats\{AverageOrderTotal,NewCustomers,RepeatCustomers}`, converting their Yii2 `Query` usage (including `NewCustomers`' `NOT IN (subquery)` and `RepeatCustomers`' double-query group-count pattern) to Laravel's query builder. +- Widgets moved to `Dashboard\Widgets\{AverageOrderTotal,NewCustomers,RepeatCustomers}`, each `extends \CraftCms\Cms\Dashboard\Widgets\Widget`. Per the plan's settings-UI decision, `getSettingsHtml()`'s Twig-rendered store-switcher/date-range-picker/order-status-selectize screen is replaced with a `settingsForm()` returning the new `Form` system's `Choice` controls — added `StatWidgetTrait::statSettingsFields()` (store select, date-range select, order-status multi-select) as the shared building block every later widget in this stage will reuse, plus `getDateRangeOptions()` (deliberately excludes `DATE_RANGE_CUSTOM` — the old custom-range JS date picker has no equivalent in the new Form control set, matching the agreed simplification). `getBodyHtml()` keeps rendering the same legacy Twig templates (via the new `template()` helper) and registering the same legacy `StatWidgetsAsset`/`CommerceWidgetsAsset` bundles (via `\Craft::$app->getView()->registerAssetBundle()`) — both confirmed working unchanged from new-namespace code. +- `Craft::$app->getUser()->checkPermission()` → `currentUser()?->can() ?? false`; `init()`'s post-construct setup logic moved into an overridden `__construct()` (the new `Component`/`Widget` base has no `init()` lifecycle hook — confirmed via the real `RecentEntries`/`NewUsers` cms-6 reference widgets, which do the same). +- **Verification**: live via `craft exec:exec` — confirmed all 6 legacy aliases resolve correctly, ran all 3 stats' `get()` against the real dev database, and exercised every widget's `displayName()`/`icon()`/`maxColspan()`/`isSelectable()`/`getTitle()`/`settingsForm()`/`getBodyHtml()` without error. + +### Laravel Migration — Stage 12a: Stat/StatWidget Base Classes + +First slice of Stage 12 (Dashboard Stats & Widgets) — new abstract `Stats\Stat` (port of `src-yii2/Base/Stat.php`) and `Dashboard\Widgets\Concerns\StatWidgetTrait` (port of `Base/StatWidgetTrait.php`), laying the foundation the 10 stat classes and 11 widget classes will build on in subsequent sub-stages. No widgets/stats are wired up to these yet — this slice creates and verifies the base machinery in isolation. + +- `Stats\Stat` implements the already-migrated `Stats\Contracts\StatInterface` directly (no `Component` base needed — it never used config-array construction, just a plain positional constructor, which is preserved unchanged). All Yii2 `Query`-based methods (`createStatQuery()`, `createChartQuery()`, `getChartQueryOptionsByInterval()`, `getFirstCompletedOrderDate()`, `getCacheKey()`) converted to Laravel's query builder (`DB::table()`, `whereIn`/`whereNull`, `selectRaw`/`groupByRaw`/`orderByRaw`, `leftJoinSub`-style patterns to come in later sub-stages). DB-engine branching (MySQL `CONVERT_TZ`/timezone-table check vs. Postgres `AT TIME ZONE`) converted to `DB::connection()->getDriverName()`, keeping the same raw-SQL date-bucketing expressions (just dropping Yii2's `[[column]]` quoting syntax, unneeded on bare unambiguous column references). `Db::prepareDateForDb()`'s UTC-normalization behavior (Craft always stores dates in UTC) replicated explicitly via a small `formatDateForDb()` helper, since Laravel's query builder doesn't accept `DateTime` objects as bound parameters directly. +- **Found and fixed 2 real bugs while live-verifying, both pre-existing in the original Yii2 code**: + 1. `getCacheKey()`'s "when was any relevant order last updated" lookup selected a bare `dateUpdated` column against a query that joins `orders` and `elements` (both of which have a `dateUpdated` column) — ambiguous in both the old and new query builders, but only surfaces as a real SQL error the moment the join is actually exercised against a real ambiguous schema (which is always, once actually run — this was likely never live-tested against a real join before). Fixed by qualifying as `orders.dateUpdated`. + 2. `get()`'s caching logic used `if (!$data)` to decide whether the cache had a value — treating a legitimately cached falsy result (`0`, an empty array) as a cache miss, silently defeating caching for any stat whose real answer happens to be zero/empty. Fixed with an explicit `Cache::has()` existence check instead of a truthiness check. +- **Verification**: live via `craft exec:exec` — instantiated anonymous concrete `Stat` subclasses to exercise the base directly: date-range resolution for `thisMonth` (confirmed first/last day of month) and `past7Days` (confirmed 7-day window including today), `createStatQuery()` executing without SQL errors, `createChartQuery()` producing one bucket per day across a month with correct default-fill behavior, `getDateRangeWording()`/`getDateRangeInterval()`, and a cache round-trip confirming `getData()` is only actually invoked once across two `get()` calls (which is what caught bug #2 above). + +### Laravel Migration — Stage 11e: Collections (Stage 11 complete) + +Final slice of Stage 11 — `src-yii2/collections/{InventoryMovementCollection,UpdateInventoryLevelCollection}` moved to `Inventory\Collections\*`. + +- Both are thin `Illuminate\Support\Collection` subclasses, already typed against migrated `Inventory` models/enums — pure namespace move. +- `UpdateInventoryLevelCollection::make()`'s `\Craft::createObject(UpdateInventoryLevel::class, ['config' => ['attributes' => $item]])` converted to a plain `new UpdateInventoryLevel($item)` — `UpdateInventoryLevel` already extends the new `Component` base, whose constructor (`__construct(array|object $config = [])`) applies the same config-array property assignment Yii2's `createObject()` was doing, just without the DI-container indirection. +- Repointed 7 real consumers, including the still-fully-legacy `src-yii2/elements/Transfer.php` (safe — `class_alias` makes both names fully interchangeable regardless of which side is legacy). +- **Verification**: live via `craft exec:exec` — confirmed both legacy aliases resolve correctly; this dev environment has zero real inventory items configured, so exercised `UpdateInventoryLevelCollection::make()`'s constructor-conversion path directly with a well-formed attribute array (confirming property assignment matches the old `Craft::createObject()` behavior) and confirmed `make()` is idempotent when passed already-constructed instances. + +**Stage 11 (Order Adjusters, Exporters, Exceptions & Loose-End Cleanup) is complete.** + +### Laravel Migration — Stage 11d: Order Exporters + +Fourth slice of Stage 11 — `src-yii2/exports/{Expanded,LineItemExport,OrderExport}` moved to `Order\Exporters\*`. + +- `Expanded` extends cms-6 core's own already-migrated `CraftCms\Cms\Element\Exporters\Expanded`, which changed its `ElementQuery::each()` contract from a `foreach`-able generator to a callback+batch-size form (`$query->each(function($element) {...}, 100)`), added `ComponentHelper::datetimeAttributes()`/`DateTimeHelper::toIso8601()` handling for date fields, and resolves fields via `app(Fields::class)` instead of `Craft::$app->getFields()`. Rebuilt Commerce's override against this new base, keeping the same "identical to parent, plus extra fields" shape (`adjustments`, `billingAddress`, `shippingAddress`, `transactions` — all already valid `Order::extraFields()` entries). +- **Found and fixed a real bug introduced while porting**: the cms-6 reference's `uksort($elementArr, fn($a, $b) => $attributes[$a] <=> $attributes[$b])` step (added by core to preserve attribute ordering) assumes every key in `$elementArr` also exists in `$attributes` — true for core's own version, but false the moment Commerce's override adds `$extraAttributes` to the `toArray()` call, since those keys (e.g. `transactions`) were never in `$attributes`. This threw `Undefined array key "transactions"` the first time it was live-tested. The *original* pre-migration Commerce `Expanded` never had this sort step at all (it's a cms-6-core-only addition), so the fix was to simply not port that step, rather than trying to patch the comparator. +- `LineItemExport`/`OrderExport` convert their Yii2 `craft\db\Query`-based correlated aggregate subqueries (`SUM(amount) WHERE ... = outerTable.column`) to Laravel's query builder using `selectSub($subqueryBuilder, $alias)` + `whereColumn()` for the correlation — first use of this pattern in the migration; verified live that the generated SQL still executes and returns the same computed columns (`totalTax`, `totalTaxIncluded`, `totalShipping`, `totalDiscount`). +- Repointed the 2 real registration sites: `Order\Elements\Order::defineExporters()` (already-migrated) and `src-yii2/Plugin.php::_registerElementExports()` (still-legacy, registers `OrderExport`/`LineItemExport` via the Yii2 `EVENT_REGISTER_EXPORTERS` event). +- **Verification**: live via `craft exec:exec` — confirmed all 3 legacy aliases resolve correctly, confirmed `Order::exporters()` includes the new `Expanded` and excludes the CMS-core default, and ran real `export()` calls against real orders for all three exporters (including one with an actual line item), confirming correct row counts, the `adjustments` extra-field, and all 4 computed aggregate columns. + +### Laravel Migration — Stage 11c: Order Calculation Adjusters + +Third slice of Stage 11 — `src-yii2/adjusters/{Tax,Shipping,Discount}` moved to `Order\Adjuster\{Tax,Shipping,Discount}`. + +- The target namespace was already decided by an earlier stage: `craft\commerce\base\AdjusterInterface` was already a stub pointing at `Order\Adjuster\Contracts\AdjusterInterface`. Updated that interface's `adjust(Order $order): array` signature to type-hint the real `Order\Elements\Order`/`Order\Models\OrderAdjustment` classes directly instead of via their legacy aliases, since every implementer was being touched anyway. +- All three drop the Yii2 `Component` base entirely — they're resolved via Laravel's container (`app($adjuster)`, confirmed at the real call site in `Order::recalculateInternal()`), not `Craft::createObject()`, so no config-array constructor behavior was ever needed. `Craft::t('site'/'commerce', ...)` calls converted to `t(..., category: ...)`; `Craft::error()` to `Log::error()`; `Craft::$app->getCache()` to the `Cache` facade (`Tax`'s VAT-ID validity cache, matching the key format already used by `Tax\Vat::isValidVatId()` — a similar-but-not-identical check, kept separate rather than consolidated since `Vat`'s validator set is fixed while the adjuster's is per-tax-rate). +- **The one real design decision this stage required**: `Discount`'s `EVENT_AFTER_DISCOUNT_ADJUSTMENTS_CREATED` is a genuine third-party extensibility point (documented with a public `Event::on(Discount::class, ...)` example), previously fired via `$this->trigger()` on the `Component`-based adjuster instance. The new class has no such instance-level event system (the new `CraftCms\Cms\Component\Component` base doesn't provide `trigger()`/`hasEventHandlers()` — confirmed by reading its source; that capability is only mixed onto a specific cms-6-curated list of classes via `LegacyBehaviorMixin`, which adjusters aren't on). Since `Component::trigger()` was itself only ever a thin wrapper around the static `yii\base\Event::trigger($class, $name, $event)` (which accepts a class name string directly, doesn't require an instance), the new class calls that static form directly, targeting the *legacy* class name string (`craft\commerce\adjusters\Discount::class`, resolved at compile-time via `::class` — doesn't require the class to be loaded) so any existing `Event::on(craft\commerce\adjusters\Discount::class, ...)` registration keeps working unchanged. Marked with the same `// TODO: migrate event firing to Laravel once event system is bridged` comment used everywhere else in this migration for this exact situation. +- Fixed a `// TODO: update DiscountAdjuster::ADJUSTMENT_TYPE reference when migrated` comment in `Promotion\Discounts::orderCompleteHandler()` — left by an earlier stage anticipating this exact moment. +- **Verification**: live via `craft exec:exec` — confirmed all three adjusters resolve via the container and implement `AdjusterInterface`, confirmed all three legacy `class_alias` stubs resolve to the correct new classes, and ran a real order recalculation (donation line item + an existing shipping method) through the full `Order::recalculateInternal()` pipeline with zero exceptions across all three adjusters. Additionally created a real matching discount and registered a listener on the *legacy* `craft\commerce\adjusters\Discount::class` name — confirmed it fires during recalculation, proving the static-`Event::trigger()`-by-class-name-string approach works end-to-end. + +### Laravel Migration — Stage 11b: Exceptions + +Second slice of Stage 11 — the remaining `src-yii2/errors/*` classes. + +- Of 17 files, 5 were already migrated (`CurrencyException`, `LineItemNotFoundException`, `OrderAdjustmentNotFoundException`, `OrderStatusException`, `ProductTypeNotFoundException`). Migrated the remaining 12 into their natural feature namespace, matching the existing `{Feature}\Exceptions\*` convention: `Email\Exceptions\EmailException`, `Payment\Gateway\Exceptions\GatewayException`, `Order\Exceptions\LineItemException`, `Payment\Exceptions\{PaymentException,PaymentSourceException,PaymentSourceCreatedLaterException,RefundException,TransactionException}`, `Shipping\Exceptions\ShippingMethodException`, `Store\Exceptions\StoreNotFoundException`, `Subscription\Exceptions\SubscriptionException`, and a new top-level `Exceptions\NotImplementedException` (no natural feature home — it's thrown generically by gateways for unsupported operations). +- All are plain `extends \Exception` (matching the established minimal style — no Yii2 `Exception` base, no docblocks beyond the class itself), except `NotImplementedException` (`extends \BadMethodCallException`, preserving its original SPL parent for any broad `catch (\BadMethodCallException)` call sites) and `PaymentSourceCreatedLaterException` (`extends PaymentSourceException`, preserving the original sibling relationship). Dropped `StoreNotFoundException`'s unused `getName()` method (confirmed dead via grep — never called anywhere). +- Repointed every real consumer found via grep (some had 0 real consumers besides their own definition — `GatewayException`, `LineItemException`, `ShippingMethodException` — kept anyway since they're part of the public exception vocabulary third-party gateway plugins may throw): `src-yii2/{queue/jobs/SendEmail,gateways/Manual,services/{Payments,Subscriptions},adjusters/Tax,Base/SubscriptionGateway,Base/SubscriptionGatewayInterface}.php`, `src/{Payment/{Payments,PaymentSources,Transactions},Subscription/Subscriptions,Order/LineItem/Data/LineItem,Http/Controllers/{PaymentsController,OrdersController,SubscriptionsController}}.php`, and 2 unit tests. +- **Found and fixed a bulk-`sed` self-inflicted bug mid-stage**: the first repointing pass matched the legacy FQCN both where intended (real code/type references) and inside each new stub's own `class_alias(..., 'craft\commerce\errors\X')` second-argument string literal, corrupting all 12 stubs' alias targets to point the new namespace at itself instead of the legacy one — silently breaking the backward-compatible alias entirely. Caught via live verification (every stub's `class_exists('craft\commerce\errors\X')` check would have failed) and fixed by hand before committing. +- **Verification**: live via `craft exec:exec` — confirmed all 12 legacy aliases resolve to the correct new class via `ReflectionClass`, confirmed the `PaymentSourceCreatedLaterException`/`PaymentSourceException` inheritance relationship, and exercised the real `Manual` gateway's `deletePaymentSource()` throwing the new `NotImplementedException` class end-to-end. + +### Laravel Migration — Stage 11a: Loose-End Cleanup (Purchasable/PurchasableStore Records, Dead Code) + +First slice of Stage 11 — closing three real gaps discovered while surveying what's left in `src-yii2/` after Stage 10. + +- **Converted `craft\commerce\records\{Purchasable,PurchasableStore}` to Eloquent** (`Purchasable\Records\{Purchasable,PurchasableStore}`) — these were the only two `craft\commerce\records\*` classes still directly used by already-migrated code (`Purchasable\Elements\Purchasable` and `Purchasable\Elements\Donation`), a real violation of the migration's "no legacy records in `src/`" principle left over from Stage 7a. `Purchasable`'s `id` is a foreign key to `elements.id` (needs `$incrementing = false`, same as `Purchasable\Models\Donation`). Converted `findOne()`/`save(false)` calls to their Eloquent equivalents. +- **Both legacy records are fully deleted, not just repointed** — a better outcome than initially planned. `PurchasableStore` `use`s `StoreRecordTrait` (shared with 7 other still-legacy records), so the original plan assumed it had to stay like `Store`/`Email`/`Pdf`/`Customer` before it. Re-verifying turned up zero other consumers anywhere in the codebase (no fixture, no migration, no other legacy class calling its `getPurchasable()` relation) — using a shared trait doesn't by itself require a record to stay if nothing external actually depends on that specific record. Deleted both. +- **Deleted 3 more confirmed-orphaned legacy files**: `src-yii2/elements/db/PurchasableQuery.php` (921 lines — nothing extends it anymore; `Variant`/`Donation`'s query classes both already extend the new `Purchasable\Queries\PurchasableQuery`, `Product`'s extends `ElementQuery` directly), `src-yii2/records/CatalogPricing.php`, `src-yii2/records/OrderStatusEmail.php` (zero references anywhere). +- Removed 2 stale `instanceof PurchasableQuery` defensive branches (and their inaccurate "Product/Variant (not yet migrated)" comments — both were migrated in Stage 7d) in `Purchasable\Purchasables.php` and `Http\Controllers\OrdersController.php`. +- Fixed a stale test assertion in `tests/unit/elements/donation/DonationQueryTest.php` that asserted `Donation::find()` was an instance of the legacy `PurchasableQuery` — it never was (it's always extended the new one) — repointed to the new class. +- **Verification**: live via `craft exec:exec` — a full donation create → verify-via-both-new-Eloquent-records → delete → confirm-gone round trip, which exercises the exact `Purchasable::afterSave()`/`afterDelete()` code paths shared by every `Purchasable` subtype (`Donation` calls `parent::afterSave()`/doesn't override `afterDelete()` at all). + +### Laravel Migration — Stage 10n: Final Import Alphabetization Pass + +Final slice of Stage 10, and the last thing needed to close it out — Stage 10's repeated bulk `sed` namespace-repointing passes (one per cluster, 10a through 10m) inserted or replaced individual `use` lines in place without re-sorting the surrounding block, leaving many files' imports out of alphabetical order. Rather than re-sort the same block after every sub-stage, this was deliberately deferred to a single pass at the end. + +- Ran `composer run fix-cs` (`ecs check --fix`, `craft\ecs\SetList::CRAFT_CMS_4`) across `src/` and `tests/` — 107 files fixed, all via `PhpCsFixer\Fixer\Import\OrderedImportsFixer` (plus `BinaryOperatorSpacesFixer` catching one unrelated pre-existing spacing nit in a test file). Pure formatting — every diff is a `use` line reorder, verified via `git diff` that no non-`use` content changed and `php -l` across all 107 touched files. +- `src/Order/Elements/Order.php` — the single most bulk-sed-touched file across all of Stage 10 (repointed in 10h and again in 10m) — had accumulated the most disorder; now fully sorted along with every other consumer file the stage touched. +- `src/Services/` is now fully empty and has been removed — all 44 services that started this stage in the flat `CraftCms\Commerce\Services` namespace now live in their feature namespace, matching the CMS 6 core convention. **Stage 10 is complete.** + +### Laravel Migration — Stage 10m: Service Namespace & Record Cleanup (Customer/Formula cluster) + +Thirteenth slice of Stage 10, and the last cluster with any remaining `craft\commerce\records\*` usage to convert: `Services\Customers` → `Customer\Customers` (new top-level namespace), `Services\Formulas` → `Formula\Formulas` (new top-level namespace). + +- Replaced `craft\commerce\records\Customer` with a new Eloquent model, `Customer\Records\Customer` — `ensureCustomer()`'s lookup converted from `CustomerRecord::find()->where([...])->one()` to `CustomerRecord::where('customerId', $user->id)->first()`. +- `Formulas` has no record usage at all — pure namespace move, no persistence changes. +- **Legacy `craft\commerce\records\Customer` record stays** — a new reason, distinct from every prior kept-record case this stage: the still-legacy `src-yii2/behaviors/CustomerBehavior.php` (a Yii2 behavior attached to every `User` element) queries it directly via `Customer::find()->where(['customerId' => ...])->one()`. `src-yii2/services/Customers.php`'s legacy wrapper also needed its `use craft\commerce\records\Customer as CustomerRecord;` import repointed to the new Eloquent class (its `ensureCustomer(): CustomerRecord` return type is a real, runtime-checked declaration — leaving it pointed at the legacy class would have thrown a `TypeError` the moment the underlying service started returning the new Eloquent object). +- **Verification**: live via `craft exec:exec` — exercised `Formulas::validateConditionSyntax()`/`evaluateCondition()`, then `ensureCustomer()` (confirming it now returns the new Eloquent `Customer\Records\Customer`), a `where()`-based re-fetch confirming the same row, and a direct record save. Also surfaced (but did not fix, as it's a separate pre-existing integration gap unrelated to this stage's scope) a `User::EVENT_DEFINE_RULES`/`EVENT_DEFINE_FIELDS`-undefined-constant error that fires whenever a dynamic property is set on a `User` element that still has the legacy `CustomerBehavior` attached — a `CustomerBehavior`/new-`User`-element compatibility issue, not a `Customers`/`Formulas` service or record issue. + +### Laravel Migration — Stage 10l: Service Namespace & Record Cleanup (Email/Pdf cluster) + +Twelfth slice of Stage 10: `Services\{Emails,Pdfs}` → `Email\Emails`/`Pdf\Pdfs`. + +- Replaced `craft\commerce\records\{Email,Pdf}` with new Eloquent models under `Email\Records\Email`/`Pdf\Records\Pdf`. Neither soft-deletes. +- **Found the same `getIsNewRecord()` bug (first seen in Stage 10j) twice more in this cluster**: both `Emails::handleChangedEmail()` and `Pdfs::handleChangedPdf()` called `$record->getIsNewRecord()`, a Yii2 `ActiveRecord`-only method with no Eloquent equivalent. Fixed both the same way as 10j: `!$record->exists`. +- `Pdfs::handleChangedPdf()` also had a leftover `updateAll(['isDefault' => false], ['and', ['not', ['id' => $pdfRecord->id]], ['storeId' => $pdfRecord->storeId]])` bulk-toggle (clearing the previous default PDF for a store) — converted to `PdfRecord::where('id', '!=', $pdfRecord->id)->where('storeId', $pdfRecord->storeId)->update(['isDefault' => false])`. +- Both records' `LOCALE_ORDER_LANGUAGE` constants (plus `Email::{TYPE_CUSTOMER,TYPE_CUSTOM}` and `Pdf::{PAPER_ORIENTATION_PORTRAIT,PAPER_ORIENTATION_LANDSCAPE}`) moved onto the new Eloquent classes, repointing 5 constants-only consumers: `Email\Models\Email`, `Pdf\Models\Pdf`, `Http\Controllers\Settings\{EmailsController,PdfsController}`, and `tests/unit/helpers/LocaleHelperTest.php`. +- **Both legacy records stay** — both still `use StoreRecordTrait`, the same broad reason Stage 10j's `Store`/`SiteStore`/`StoreSettings` stayed put; `Email` additionally has `tests/fixtures/EmailsFixture.php` and `tests/fixtures/data/emails.php` as direct dependents. `src/` no longer references either. +- **Verification**: live via `craft exec:exec` — listed emails/pdfs for the primary store, and round-tripped a full PDF create → find-via-Eloquent-record → delete → confirm-gone cycle. + +### Laravel Migration — Stage 10k: Service Namespace & Record Cleanup (Subscription cluster) + +Eleventh slice of Stage 10: `Services\{Subscriptions,Plans}` → `Subscription\*`. + +- Replaced `craft\commerce\records\{Plan,Subscription}` with new Eloquent models under `Subscription\Records\*`. `Subscription` is a special case: the still-legacy `craft\commerce\elements\Subscription` element uses the *same* legacy `craft\commerce\records\Subscription` class for its own element/type-table pairing (the classic Craft "elements + type-specific table" split), so the new Eloquent class is a read/update-only sibling for `src/`, not a replacement for that pairing — documented directly in the new class's docblock so a future reader doesn't try to use it for inserts. +- Found a second real consumer of the legacy `Subscription` record outside the service: `Http\Controllers\SubscriptionsController::subscribe()` updates a subscription's `returnUrl` directly via the record — repointed to the new Eloquent class. +- Both legacy records stay: `Plan` is used by `tests/fixtures/SubscriptionPlansFixture.php`; `Subscription` is used by the legacy `Subscription` element itself (not a fixture this time — a live element/record pairing, the same category of "can't touch this without migrating the whole element" constraint noted for `Store`'s trait in Stage 10j). `src/` no longer references either. +- **Verification**: live via `craft exec:exec` — resolved both services, listed all plans, and exercised both subscription-count queries against the new Eloquent record. + +### Laravel Migration — Stage 10j: Service Namespace & Record Cleanup (Store cluster) + +Tenth slice of Stage 10: `Services\{Stores,StoreSettings}` → `Store\*`. + +- Replaced `craft\commerce\records\{Store,SiteStore,StoreSettings}` with new Eloquent models under `Store\Records\*`. `SiteStore` and `StoreSettings` are both keyed by a foreign column rather than an auto-incrementing `id` (`siteId` for `SiteStore` — one row per site; the owning store's own `id` for `StoreSettings` — one row per store) — both need `protected $primaryKey`/`public $incrementing = false` set explicitly, same class of bug Stage 7d found and fixed on element-backed models. +- Found 2 more real consumers outside the services themselves, both already-migrated business models still reaching into the legacy records: `Store\Models\Store`'s currency-change validator (`craft\commerce\records\Store::findOne(['id' => ..., 'currency' => ...]) === null`) and `Store\Models\StoreSettings::getLocationAddress()`'s `StoreSettingsRecord::updateAll(...)` — both repointed to the new Eloquent classes. +- **Found and fixed a second real bug while converting `handleChangedStore()`**: it called `$storeRecord->getIsNewRecord()`, a Yii2 `ActiveRecord` method with no Eloquent equivalent — silently missing on the new model (Eloquent has no method by that name at all, so this would have been an immediate fatal `Error: Call to undefined method`). Fixed with Eloquent's own new-record check, `!$storeRecord->exists`. +- **All three legacy records stay** — a new, broader reason than any prior stage: `src-yii2/Base/StoreRecordTrait.php` (a shared Yii2 `ActiveRecord`-relation trait, `getStore(): hasOne(Store::class, ...)`) is still `use`d by 8 other legacy records, several of which are themselves staying for their own fixture/migration reasons (`OrderStatus`, `Discount`, `CatalogPricingRule`, `ShippingCategory` from earlier stages, plus `Email`/`Pdf`/`PurchasableStore`/`CatalogPricing` not yet reached) — converting the trait would require converting all of them simultaneously, well outside this stage's scope. `src-yii2/behaviors/StoreLocationBehavior.php` similarly still depends on `StoreSettings` directly. `src/` itself no longer touches any of the three, which is what actually matters for this stage. +- **Verification**: live via `craft exec:exec` — listed stores/site stores, fetched store settings for the primary store, and directly exercised both the new Eloquent `Store` record and the fixed currency-change check (`doesntExist()`) with matching and non-matching currencies. + +### Laravel Migration — Stage 10i: Service Namespace & Record Cleanup (Order cluster) + +Ninth slice of Stage 10, and the biggest by service count: `Services\{Orders,Carts,OrderNotices,OrderHistories,OrderAdjustments,OrderStatuses,LineItemStatuses,LineItems}` → `Order\*` (`LineItems` nested to `Order\LineItem\LineItems`, matching its existing `Data`/`Models`/`Enums` sub-namespace since Stage 7c). + +- `Orders`, `Carts`, `OrderNotices` had zero legacy record usage — pure namespace moves. `LineItems` has been fully Eloquent since Stage 7c — also a pure move. +- `OrderHistories`, `OrderAdjustments`, and `LineItemStatuses` replaced their legacy `craft\commerce\records\{OrderHistory,OrderAdjustment,LineItemStatus}` `ActiveRecord` classes with new Eloquent models under `Order\Records\*`. None soft-delete. +- `OrderStatuses` replaced `craft\commerce\records\OrderStatus` with a new Eloquent model using `SoftDeletes` (same `dateDeleted` shape as Stage 10b/10c/10f) — `findWithTrashed()`/`softDelete()`/`restore()` map to `::withTrashed()`/`->delete()`/`->restore()` respectively, same as the established pattern. +- **All three of `OrderHistory`/`OrderAdjustment`/`LineItemStatus` are now fully unreferenced and deleted.** `OrderStatus` stays — unlike prior stages' fixture-file dependencies, this one is used directly inside two Codeception *fixture data* files (`tests/fixtures/data/{orders,order-statuses}.php`, which run real `::find()` queries against it to resolve seed IDs/UIDs), not just a `Fixture` class — same underlying reasoning as finding #25, just a different shape of dependent. +- **Verification**: live via `craft exec:exec` — listed order statuses/line item statuses/order adjustments/order histories for the primary store, resolved all 8 services, and round-tripped a full order status create → soft-delete → confirm-excluded-from-`find()` cycle through the new Eloquent model. + +### Laravel Migration — Stage 10h: Service Namespace & Record Cleanup (Payment/Gateway cluster) + +Eighth slice of Stage 10: `Services\{Transactions,PaymentSources,Payments,Webhooks,Gateways}` → `Payment\*` (`Gateways` nested to `Payment\Gateway\Gateways`, matching its existing `Contracts`/`Responses` sub-namespace). + +- `Transactions`, `PaymentSources`, and `Gateways` replaced their legacy `craft\commerce\records\{Transaction,PaymentSource,Gateway}` `ActiveRecord` classes with new Eloquent models — `Transaction`/`PaymentSource` under `Payment\Records\*`, `Gateway` under `Payment\Gateway\Records\*` to match the service's own nesting. None soft-delete. +- `Transaction`'s `TYPE_*`/`STATUS_*` constants moved onto the new Eloquent class, repointing 5 other consumers that only ever used them as an enum (never persistence): `Payments` (the service itself), `Order\Elements\Order`, `Http\Controllers\OrdersController`, and 3 unit tests (`OrderMarkAsCompleteTest`, `OrderPaymentAmountTest`, `OrderQueryTest`) — all confirmed constants-only via grep before repointing. +- `Payments` and `Webhooks` had no direct record usage of their own (`Payments` only consumes `Transaction`'s constants; `Webhooks` is pure event/mutex orchestration) — namespace move only for those two. +- **All three legacy records are now fully unreferenced and deleted** — first Stage 10 cluster since 10d where every record could be removed outright, with no lingering fixture/migration/validator dependency anywhere in the codebase (confirmed via grep across `tests/`, `src-yii2/migrations/`, and `src-yii2/validators/` before deleting, per finding #25's lesson from 10b). +- **Verification**: live via `craft exec:exec` — listed gateways, fetched transactions/payment sources for the primary store, confirmed the relocated `TYPE_AUTHORIZE` constant, and round-tripped a full gateway create (via direct Eloquent construction, mirroring what `handleChangedGateway()` does) → find-by-handle → delete → confirm-gone cycle. + +### Laravel Migration — Stage 10g: Service Namespace & Record Cleanup (Catalog/Purchasable cluster) + +Seventh slice of Stage 10, and the first with zero legacy `ActiveRecord` usage to convert — `Products`, `Variants`, and `ProductTypes` (already fully Eloquent since Stage 7e) go to `Catalog\*`; `Purchasables` goes to `Purchasable\Purchasables`. `ProductTypes` in particular was still sitting in the flat `Services\` namespace despite its persistence layer already being Eloquent (`Catalog\ProductType\Models\{ProductType,ProductTypeSite}`) — just needed the namespace move, `ProductTypes` nested to `Catalog\ProductType\ProductTypes` to match the existing `Data`/`Models`/`Exceptions` sub-namespace convention for that domain. + +- Pure namespace relocation, no persistence changes — a welcome break after Stage 10d's size. +- **Verification**: live via `craft exec:exec` — resolved all four services from the container, listed product types, and fetched variants by product ID. + +### Laravel Migration — Stage 10f: Service Namespace & Record Cleanup (Inventory cluster) + +Sixth slice of Stage 10: `Services\{Inventory,InventoryLocations}` → `Inventory\*`. + +- Replaced `craft\commerce\records\InventoryItem` (no soft-delete) and `craft\commerce\records\InventoryLocation` (soft-deletes via `dateDeleted`) with new Eloquent models under `Inventory\Records\*`; `InventoryLocation` uses `SoftDeletes`. +- `InventoryItemRecord` had a second real consumer outside the service itself: `Purchasable\Elements\Purchasable`'s draft-apply logic (transferring an inventory item from a draft's duplicate-of source to the canonical element) — repointed to the new Eloquent class. +- Found and removed another dead unused import while relocating (same shape as Stage 10a/10c): the legacy `src-yii2/services/Inventory.php` wrapper imported `craft\commerce\records\InventoryItem` but only ever used the *business* `Inventory\Models\InventoryItem` class (imported separately, same short name) in its type hints — the record import was never actually referenced. +- `craft\commerce\records\InventoryItem` is now fully unreferenced and deleted. `craft\commerce\records\InventoryLocation` is kept — `src-yii2/migrations/Install.php` constructs one directly to seed the default inventory location on install, the same "frozen migration, not a fixture" reason as Stage 10e's `CatalogPricingRule`/`CatalogPricingQueue`. +- **Verification**: live via `craft exec:exec` — listed inventory locations and fetched an inventory item row, then round-tripped a full inventory-location create (with a real saved `Address` element) → soft-delete → confirm-excluded-from-`find()` cycle through the new Eloquent model. + +### Laravel Migration — Stage 10e: Service Namespace & Record Cleanup (CatalogPricing cluster) + +Fifth slice of Stage 10: `Services\{CatalogPricing,CatalogPricingRules}` → `CatalogPricing\*`. + +- Both replaced their legacy `craft\commerce\records\{CatalogPricingQueue,CatalogPricingRule}` `ActiveRecord` classes with new Eloquent models under `CatalogPricing\Records\*`. +- `CatalogPricingQueue`'s custom `getIds()`/`setIds()` methods (manual `Json::encode()`/`decode()` around a text column) are replaced by a plain `'ids' => 'array'` Eloquent cast — callers now just read/write `$record->ids` directly. +- `CatalogPricingRule`'s `APPLY_BY_PERCENT`/`APPLY_BY_FLAT`/`APPLY_TO_PERCENT`/`APPLY_TO_FLAT`/`APPLY_PRICE_TYPE_PRICE`/`APPLY_PRICE_TYPE_PROMOTIONAL_PRICE` constants moved onto the new Eloquent class, repointing `Catalog\Models\CatalogPricingRule` and `Http\Controllers\Settings\CatalogPricingRulesController`; `CatalogPricingQueue`'s `TYPE_PURCHASABLE`/`TYPE_RULE` constants repointed the still-legacy `src-yii2\queue\jobs\CatalogPricing` job. +- Converted one more real leftover `ActiveRecord` bulk-toggle query to Eloquent, same shape as prior stages: `CatalogPricingRulesController::updateStatus()`. +- **Both legacy records are kept as-is this time** (not deleted, but also not because of a fixture — a new reason): `src-yii2/migrations/Install.php` uses both directly for their table-creation `enum()` column definitions (`CatalogPricingQueue::TYPE_PURCHASABLE`/`TYPE_RULE`, `CatalogPricingRule::APPLY_PRICE_TYPE_*`). Migrations are frozen historical schema-application code and are never touched by this migration effort, so both legacy records stay put — `src/` itself no longer references either, which is what matters. +- **Verification**: live via `craft exec:exec` — listed catalog pricing rules, confirmed the relocated `APPLY_BY_PERCENT` constant, and exercised the full catalog-pricing-queue lifecycle end-to-end: `createCatalogPricingJob()` → `reserveCatalogPricingQueueRow()` (confirming the merged `ids` array cast round-trips correctly) → `releaseCatalogPricingQueueRowById()` → `deleteCatalogPricingQueueRowById()` → confirmed gone. + +### Laravel Migration — Stage 10d: Service Namespace & Record Cleanup (Promotion cluster) + +Fourth slice of Stage 10, and the largest so far: `Services\{Discounts,Sales,Coupons}` → `Promotion\*`. + +- `Discounts` alone touched 6 legacy record classes (`Discount`, `DiscountCategory`, `DiscountPurchasable`, `CustomerDiscountUse`, `EmailDiscountUse`, plus an inline fully-qualified `\craft\commerce\records\Coupon::findOne()` call) — all replaced with new Eloquent models under `Promotion\Records\*`. `Sales` similarly replaced `Sale`, `SaleCategory`, `SalePurchasable`, `SaleUserGroup`. `Coupons` replaced `Coupon`. +- `Discount`'s `TYPE_*`/`CATEGORY_RELATIONSHIP_TYPE_*`/`APPLIED_TO_*` and `Sale`'s `APPLY_*`/`CATEGORY_RELATIONSHIP_TYPE_*` constants moved onto their new Eloquent classes (same treatment as Stage 10b/10c), repointing `Promotion\Models\{Discount,Sale}`, `Http\Controllers\Settings\{DiscountsController,SalesController}`, `Catalog\Queries\VariantQuery`, and the still-legacy `src-yii2\adjusters\Discount`. +- Converted 2 more real leftover `ActiveRecord` bulk-toggle queries to Eloquent, same shape as Stage 10b/10c: `DiscountsController::updateStatus()` and `SalesController::updateStatus()`. +- **Found and fixed a real, independently-shippable bug while live-testing `saveDiscount()`**: `Promotion\Events\{DiscountEvent,MatchOrderEvent,MatchLineItemEvent,DiscountAdjustmentsEvent}` all still type-hinted their `$discount` constructor property against the legacy `craft\commerce\models\Discount`, even though every actual caller (`Discounts` service, and the still-legacy `src-yii2\adjusters\Discount` — which sources its `$discount` from `Plugin::getInstance()->getDiscounts()->getAllActiveDiscounts()`, i.e. the *new* service) has passed the new `Promotion\Models\Discount` since Stage 6b. PHP enforces constructor property type declarations at runtime regardless of docblocks, so this wasn't just a phpstan nit — `saveDiscount()` (and, more seriously, the discount adjuster's `EVENT_AFTER_DISCOUNT_ADJUSTMENTS_CREATED` firing during real order calculation) would throw a `TypeError` on every invocation. Fixed by repointing all four events at `Promotion\Models\Discount`. `Promotion\Events\{SaleEvent,SaleMatchEvent}` were already correct. +- `craft\commerce\records\{DiscountCategory,DiscountPurchasable,CustomerDiscountUse,EmailDiscountUse,Sale,SaleCategory,SalePurchasable,SaleUserGroup}` are now fully unreferenced and deleted (8 files — the biggest single-stage cleanup so far). `craft\commerce\records\{Discount,Coupon}` are kept, same reasoning as prior stages — `Discount` is still used by `tests/fixtures/DiscountsFixture.php` and 2 tests; `Coupon` by that same fixture plus the still-legacy `src-yii2\validators\CouponsValidator` and a test. +- **Verification**: live via `craft exec:exec` — listed discounts for the primary store, round-tripped a full discount create → delete → confirm-gone cycle (which is what surfaced the `DiscountEvent` bug above), generated coupon codes, listed sales, and confirmed both relocated constant sets. + +### Laravel Migration — Stage 10c: Service Namespace & Record Cleanup (Shipping cluster) + +Third slice of Stage 10: `Services\{ShippingCategories,ShippingZones,ShippingMethods,ShippingRules,ShippingRuleCategories}` → `Shipping\*`. + +- All 5 replaced their legacy `craft\commerce\records\*` `ActiveRecord` classes with new thin Eloquent models under `Shipping\Records\*`. `ShippingCategory` needed `SoftDeletes` (same `dateDeleted` shape as Stage 10b's `TaxCategory`); the rest are plain hard-delete rows. +- `ShippingRuleCategory`'s `CONDITION_ALLOW`/`CONDITION_DISALLOW`/`CONDITION_REQUIRE` constants moved onto the new Eloquent class (same treatment as Stage 10b's `TaxRate` constants) and repointed its 2 consumers (`Shipping\Models\ShippingRule`, `Http\Controllers\Settings\ShippingRulesController`). +- Found and converted 2 more genuine leftover persistence usages, same shape as Stage 10b's `TaxRatesController::updateStatus()`: `ShippingMethodsController::updateStatus()` (`ShippingMethodRecord::find()->where(['id' => $ids])->all()` → `whereIn('id', $ids)->get()` in a `DB::transaction()`), and `ShippingRules::saveShippingRule()`'s priority-numbering query (`ShippingRuleRecord::find()->where(['methodId' => ...])->count()` → `ShippingRuleRecord::where('methodId', ...)->count()`). +- Found another dead unused import while relocating (same shape as Stage 10a's `PaymentCurrency` in `Stores.php`): `Stores.php` also imported `craft\commerce\records\ShippingCategory` without ever using it — removed. +- `craft\commerce\records\{ShippingZone,ShippingMethod,ShippingRule,ShippingRuleCategory}` are now fully unreferenced and deleted. `craft\commerce\records\ShippingCategory` is kept, same reasoning as Stage 10b's `TaxCategory` — still used by `tests/fixtures/ShippingCategoryFixture.php`'s Codeception `ActiveFixture` and a test exercising its Yii2-specific soft-delete query methods (`findTrashed()`). +- **Verification**: live via `craft exec:exec` — fetched the default shipping category, round-tripped create → soft-delete → confirm-trashed through the new Eloquent model, fetched zones/methods/rules for the primary store, and confirmed the relocated `CONDITION_ALLOW` constant. + +### Laravel Migration — Stage 10b: Service Namespace & Record Cleanup (Tax cluster) + +Second slice of Stage 10: `Services\{TaxCategories,TaxZones,Taxes,TaxRates,Vat}` → `Tax\*`. + +- `TaxCategories`/`TaxZones`/`TaxRates` each replaced their legacy `craft\commerce\records\*` `ActiveRecord` class with a new thin Eloquent model in `Tax\Records\{TaxCategory,TaxZone,TaxRate}`. `TaxCategory` needed `Illuminate\Database\Eloquent\SoftDeletes` (it soft-deletes via a `dateDeleted` column, matching the legacy `craft\db\SoftDeleteTrait` — Eloquent's default `::find()`/`::delete()` already exclude/soft-delete the same way, `::withTrashed()` is the one place behavior had to be made explicit, to match `updateAll()`'s legacy behavior of clearing the `default` flag on trashed rows too). +- Found 4 more real `craft\commerce\records\TaxRate` consumers outside the service itself, all using it purely as an enum-like constants holder (`TAXABLE_PURCHASABLE`, `TAXABLE_PRICE`, `TAXABLE_SHIPPING`, `TAXABLE_PRICE_SHIPPING`, `TAXABLE_ORDER_TOTAL_SHIPPING`, `TAXABLE_ORDER_TOTAL_PRICE`, `ORDER_TAXABALES`): `Order\LineItem\Data\LineItem`, `Tax\Models\TaxRate`, `Http\Controllers\Settings\TaxRatesController`, and the still-legacy `src-yii2\adjusters\Tax`. Moved the constants onto the new `Tax\Records\TaxRate` Eloquent class and repointed all four. Also found one genuine leftover persistence usage in `TaxRatesController::updateStatus()` (`TaxRateRecord::find()->where(['id' => $ids])->all()`, bulk-toggling `enabled`) — converted to `TaxRateRecord::whereIn('id', $ids)->get()` wrapped in `DB::transaction()`. +- Also found, and simplified away, a dead condition in `TaxRates::saveTaxRate()`: `empty($record->getErrors('taxZoneId'))` was checking a Yii2 `ActiveRecord` error bag that's never populated before this point in the method (no `$record->validate()` call precedes it) — it was always true, so it added nothing. Dropped rather than translated, since there's no Eloquent equivalent that would preserve the (nonexistent) behavior. +- `craft\commerce\records\{TaxZone,TaxRate}` are now fully unreferenced anywhere in the codebase and deleted. `craft\commerce\records\TaxCategory` is **not** deleted — unlike Stage 10a's `PaymentCurrency`, it's still a real, load-bearing dependency of `src-yii2/migrations/Install.php` (inserts the default tax category via `TaxCategory::tableName()`) and `tests/fixtures/TaxCategoryFixture.php` (Codeception's `ActiveFixture` only works with Yii2 `ActiveRecord`, not Eloquent) — both intrinsically tied to Yii2 infrastructure this stage doesn't touch. `src/` itself no longer references it at all, which is the actual goal. +- **Verification**: live via `craft exec:exec` — listed/fetched the default tax category, round-tripped a full create → soft-delete → confirm-excluded-from-normal-find cycle through the new `Tax\Records\TaxCategory` Eloquent model, fetched tax zones/rates for the primary store, resolved the tax engine, and exercised `Vat::isValidVatId()` with an invalid ID. + +### Laravel Migration — Stage 10a: Service Namespace & Record Cleanup (Currencies, PaymentCurrencies) + +First slice of Stage 10: relocating the 44 services flat-namespaced under `CraftCms\Commerce\Services\*` (Stage 6) into their proper feature namespace, and replacing any remaining `craft\commerce\records\*` usage with Eloquent along the way. Doing this domain-by-domain, combining the namespace move and the record conversion in each slice rather than as separate passes. + +- `Services\Currencies` → `Payment\Currencies` (no persistence of its own — pure Money-library ISO/teller wrapper — trivial move). +- `Services\PaymentCurrencies` → `Payment\PaymentCurrencies`. Replaced `craft\commerce\records\PaymentCurrency` (Yii2 `ActiveRecord`) with a new thin Eloquent model, `Payment\Records\PaymentCurrency` (extends `CraftCms\Cms\Shared\BaseModel`), mapped to the same `commerce_paymentcurrencies` table. Kept it in a new `Records/` sub-namespace, distinct from the existing business-object `Payment\Models\PaymentCurrency` (a `Component`-based model, unrelated to persistence) — deliberately not merging the two, since Stage 7c already found that Eloquent's `__get()` doesn't route bare property access through same-named getters the way `Component`/legacy `Model` does, which silently broke adjusters last time this was tried. +- Updated the legacy `src-yii2/services/{Currencies,PaymentCurrencies}.php` wrapper `app()` calls, and every other `src/` call site (`Order\Elements\Order`, `Purchasable\Elements\Purchasable`, `Payment\Models\Transaction`) to the new namespace. +- The legacy `craft\commerce\records\PaymentCurrency` ActiveRecord class is now fully unreferenced and deleted (including its one remaining consumer, `tests/unit/services/PaymentCurrenciesTest.php`, updated to use the new Eloquent model instead) — no `craft\commerce\records\*` class survives for this domain, matching Stage 7's own "no record survives, not even as a stub" principle. +- **Verification**: Codeception and phpstan are both currently broken in the dev environment for reasons unrelated to this change (`craft\test\TestSetup` missing from the composer autoload map; a stale `phpstan.neon` path pointing at a nonexistent yii2-adapter file) — verified live instead via `craft exec:exec`: fetched a currency by ISO, listed/fetched the primary payment currency, and round-tripped a full create → find → delete cycle through the new Eloquent `Payment\Records\PaymentCurrency` model, confirming the row was actually persisted and removed. + +### Laravel Migration — Stage 9k: Controllers & Routes (Email, PDF, Users, Misc) — Stage 9 complete + +Migrated the final six legacy controllers — `EmailsController`, `PdfsController` to +`src/Http/Controllers/Settings/`; `FormulasController`, `EmailPreviewController`, +`DownloadsController` to `src/Http/Controllers/`; `UsersController` to +`src/Http/Controllers/Users/` — completing the full 48-controller Yii2 → Laravel controller +migration started in Stage 9a. `src-yii2/controllers/` has been deleted in its entirety, +including all four base classes (`BaseController`, `BaseCpController`, `BaseAdminController`, +`BaseFrontEndController`) now that nothing extends them. + +- `EmailsController`/`PdfsController` follow the same store-scoped settings-screen pattern as + every other Stage 9 settings controller (`CpScreenResponse`/`RespondsWithFlash`, `readOnly` + from `GeneralConfig::$allowAdminChanges`). `PdfsController::reorder()` was written fresh with + the `abort_unless($request->input('ids'), 400, ...)` guard from Stage 9j's finding rather than + needing a retroactive fix. +- `FormulasController` is a two-action AJAX-only controller (condition/formula syntax + validation) — no permission logic of its own, gated entirely by + `can:accessPlugin-commerce` at the route level. +- `EmailPreviewController` never extended a Commerce base controller (plain `craft\web\ + Controller`, imperative `requireAdmin(false)`) — ported to a route gated by `RequireAdmin` + alone, no `accessPlugin-commerce` check (matching the legacy controller's narrower scope). + **Found and fixed a real query bug during live testing**: the legacy random-order-selection + logic (`$orderQuery->orderBy('RAND()')` / `orderBy('RANDOM()')` for MySQL/Postgres) throws + `Unknown column 'RAND()'` on the new Laravel-based `ElementQuery` — unlike Yii2's query + builder, passing a raw SQL function string to `orderBy()` gets treated as a column identifier + and quoted. Fixed by switching to `orderByRaw()`, which forwards through `ElementQuery::__call()` + to the underlying query builder unchanged. Also found the method's return type was declared + `Response` even though every branch actually returns a bare Twig-rendered `string` — fixed the + signature during testing before it ever shipped. +- `DownloadsController` and `UsersController` both extended the now-deleted + `BaseFrontEndController` — in both cases only for its `allowAnonymous = true` default, since + neither calls the base class's other member (`cartArray()`, already available via the + `HasCartArray` trait for controllers that do). `DownloadsController`'s per-IP + `pdf-challenge` rate limit (1 request/30s, previously a `yii\filters\RateLimiter` + + `IpRateLimitIdentity` behavior scoped to one action) is now a named `PdfChallengeRateLimiter` + (`Limit::perSecond(1, 30)->by($request->ip())`) registered in `Plugin::register()` and applied + via `->middleware('throttle:...')` on just the `pdf-challenge` route — same shape as the + existing `CartChallengeRateLimiter`. +- `UsersController` (adds a "Commerce" tab to the Edit User screen, showing the customer's + orders/carts/subscriptions) required a genuinely different pattern than every other Stage 9 + controller: the legacy `craft\controllers\EditUserTrait`/`UsersController:: + EVENT_DEFINE_EDIT_SCREENS` Yii event no longer drives the Edit User screen at all in the new + system. The new equivalent is `CraftCms\Cms\Http\Controllers\Users\EditUserTrait` (a trait + consumed by dedicated per-screen controllers, not a single monolithic controller with tabs) + plus a plain Laravel event, `CraftCms\Cms\User\Events\EditUserScreensResolving`, fired once + while the screen list is being built. Ported by: (1) a new `UsersController` under + `Http/Controllers/Users/` that also uses the core `EditUserTrait`, resolves the edited user via + `editedUser()`, and returns `asEditUserScreen($user, 'commerce')->contentHtml($content)` — the + `contentHtml()` injection method is unchanged from the legacy screen's approach; (2) a listener + registered in `Plugin::register()` (`Event::listen(EditUserScreensResolving::class, ...)`) + that adds the `commerce` nav entry when `currentUser()?->can('accessPlugin-commerce')`, + replacing the legacy `Event::on(UsersController::class, UsersController:: + EVENT_DEFINE_EDIT_SCREENS, ...)` registration removed from `src-yii2/Plugin.php`. No static + `Cp::elementIndexHtml()` helper exists anymore either — replaced by the injectable + `CraftCms\Cms\Cp\Html\ElementIndexHtml` service (`app(ElementIndexHtml::class)->html($type, + $config)`), constructor-injected into the new controller. +- **Verification**: `route:list` confirmed every new CP/action/myaccount route, including the + `myaccount/commerce` and `users/{userId}/commerce` Edit-User-screen routes. Live + `craft exec:exec` testing exercised `EmailsController::edit()`/`PdfsController::edit()` (both + build a real `CpScreenResponse` against the primary store with no errors), `FormulasController`'s + both actions (valid/empty input), `DownloadsController::pdf()`'s missing-order-number guard, and + `EmailPreviewController::render()`'s missing-email fallback path (which is what surfaced the + `orderByRaw()` and return-type bugs above). + +### Laravel Migration — Stage 9j: Controllers & Routes (Subscriptions) + +Migrated `SubscriptionsController` to `src/Http/Controllers/`, and `PlansController` to +`src/Http/Controllers/Settings/`. + +- `SubscriptionsController` extends the plain Yii2 `BaseController` (no blanket + `accessPlugin-commerce`/permission check at all) and checks authorization per-action, in three + different shapes: `index()` needs `commerce-manageSubscriptions` explicitly; `edit()`/`save()` + only check per-subscription ownership/view permission inline (a subscription's own customer can + reach these without `commerce-manageSubscriptions`); `subscribe()`/`reactivate()`/`switch()`/ + `cancel()` only need a logged-in user (any authenticated customer managing their own + subscription); `completeSubscription()` is a fully anonymous gateway webhook callback; + `deleteSubscriptions(Modal)?()` need `deleteUsers` + a real CP request. Route middleware + replicates each of these exactly rather than defaulting to one blanket permission — see + `routes/cp.php`/`routes/actions.php` for the per-action breakdown. +- Same `FieldLayoutCompiler`/`FormHtmlRenderer` swap as Stages 9f/9i for `edit()`'s custom-fields + form — this file was flagged in advance (Stage 9i's changelog entry) as using only the safe + `createForm()`+`getTabMenu()` subset, confirmed straightforward to port with no `tabIdPrefix`/ + `$form->tabs` complications. +- `Craft::$app->getMutex()` → `Cache::lock()` for `completeSubscription()`'s transaction lock, + same pattern as `CartController`/`PaymentsController`. +- **Found and fixed a systemic gap across 6 already-merged files** (Stages 9d/9e/9f/9i): + `Json::decode($request->input('ids'))` in every `reorder()`-style action + (`DiscountsController`, `SalesController`, `ShippingRulesController`, `OrderStatusesController`, + `LineItemStatusesController`, `StoresController`) was missing the equivalent of legacy's + `getRequiredBodyParam('ids')` check — a missing `ids` param fell through to `Json::decode(null)` + then into a strictly `array`-typed service parameter, producing an ugly `TypeError` instead of + a clean 400. Found while writing the identical `PlansController::reorder()` fresh and confirming + what the *correct* shape should look like, then grepped for the same unguarded pattern + everywhere else it had already shipped. Added `abort_unless($request->input('ids'), 400, ...)` + before the decode in all 7 places (6 retroactive + the new one). +- **Verification**: `route:list` confirmed all new routes and their per-action-specific + permission middleware. Live `craft exec:exec` testing fully exercised `PlansController:: + planIndex()` and `editPlan()` end-to-end with no errors, confirmed `SubscriptionsController:: + index()`/`edit()` correctly gate on the anonymous/console user (403/404 as expected), confirmed + `completeSubscription()`'s missing-param guard, and confirmed the `reorder()` fix converts a + `TypeError` into a clean 400 across a live re-test. + +### Laravel Migration — Stage 9i: Controllers & Routes (Inventory) + +Migrated `InventoryController`, `InventoryLocationsController`, `TransfersController` to +`src/Http/Controllers/`. + +- **Found and fixed a critical, real architectural gap affecting already-merged Stage 9f + code**: `FieldLayout::createForm()` — used to build a Twig-renderable field-layout edit form + with manually-manipulable tabs — no longer exists on the new-system `FieldLayout` class at + all. `InventoryLocationsController::edit()` needs this (to embed an Address's field layout + inside its own screen with injected hidden fields); so, retroactively discovered, does + `OrdersController::updateTemplateVariables()` (merged in Stage 9f) — its two `createForm()` + calls both passed `tabIdPrefix` in the config array, which throws immediately in the new + system's legacy compat shim, meaning **`commerce/orders/{id}` has been throwing a hard + exception on every load since Stage 9f merged**. Root-caused via a dedicated research pass + into cms-6's own already-migrated `EditElementController::prepareEditor()` (the real reference + implementation) and fixed both call sites the same way: dropped the legacy `FieldLayoutForm` + bridge entirely and adopted the actual new pipeline — `CraftCms\Cms\FieldLayout\ + FieldLayoutCompiler::compile($fieldLayout, $element, new FormContext(...))` producing an + immutable `FormPayload`, rendered via `CraftCms\Cms\Form\FormHtmlRenderer::render($payload)`/ + `::tabMenu($payload)`. There's no more `tabIdPrefix` (a single `namespace` now drives both + input names and tab/DOM ids), and the payload can't be mutated the way `$form->tabs` used to + be — `InventoryLocationsController::edit()`'s hidden-field injection moved from PHP-side tab + manipulation into the Twig template directly (`_edit.twig`, rendered around `{{ form|raw }}` + instead of `{{ form.render()|raw }}`), matching how `EditElementController` itself does the + same thing (concatenating `Html::hiddenInput()` around the rendered form content, never + mutating the compiled payload). Left a `TODO` for stripping the address field layout's + redundant title/`LabelField` (a cosmetic UI gap, not a functional one) via the + `FieldLayoutFormResolving` event once that's worth the complexity. + **`SubscriptionsController` (Stage 9j) already uses only the safe `createForm()`+`getTabMenu()` + subset with no `tabIdPrefix`/`tabs` mutation — confirmed unaffected, but will still need the + same `FieldLayoutCompiler` swap when migrated, since the legacy method doesn't exist at all.** +- Confirmed `$element->getFieldLayout()` (the normal way to get an element's field layout) is + exactly the right input to `FieldLayoutCompiler::compile()` — no special accessor needed, + unlike the false lead of trying to route through `Craft::$app->getFields()` for a + bridge-wrapped type (which turned out to return the same bare core class anyway). +- `CpScreenResponse::prepareScreen(callable $value)` — confirmed the real replacement for the + legacy `prepareScreen()` callback (used by `InventoryController::itemEdit()`'s htmx-processing + hook), with the exact same `($screen, $containerId)` callback signature. +- **Verification**: `route:list` confirmed all new routes. Live `craft exec:exec` testing fully + exercised `InventoryLocationsController::index()` and `edit()` end-to-end (including a full + `toResponse()` render of the fixed field-layout form, confirmed to only fail afterward on the + same known console-context `currentUser` limitation seen throughout this migration), and + `OrdersController::updateTemplateVariables()` directly via reflection (bypassing the + `currentUserElement()`-gated `enforceManageOrderPermissions()` check that blocks a full + `editOrder()` call in this console-testing harness) — confirmed it now runs to completion with + no errors. `InventoryController::editLocationLevels()` and `TransfersController::index()` also + verified (the latter hits the same known element-index Twig-context limitation as + `orderIndex()`/`ProductsController::productIndex()` from earlier stages, not a regression). + +### Laravel Migration — Stage 9h: Controllers & Routes (Payments) + +Migrated `PaymentsController`, `PaymentSourcesController` to `src/Http/Controllers/`. +`BaseFrontEndController` stays in `src-yii2/` — `DownloadsController`, `UsersController` still +extend it (Stage 9k). + +- **Extracted `cartArray()` into a shared `HasCartArray` trait** (`src/Http/Controllers/ + Concerns/HasCartArray.php`) — per the plan noted in Stage 9f's tracker entry, now that + `PaymentsController` is a second real consumer alongside `CartController`. `CartController` + updated to use the trait instead of its own copy. +- Same mutex (`Cache::lock()`+`LockTimeoutException`) and rate-limiting-free anonymous-route + pattern as `CartController` — `PaymentsController`'s actions were never in the legacy + `RateLimiter` behavior's `only` list, so no `throttle:` middleware needed here. +- `enableCsrfValidation = false` (legacy `beforeAction()` override for `complete-payment`, since + off-site gateway redirects can't carry a CSRF token) → added `commerce/payments/complete-payment` + to the existing `PreventRequestForgery::except([...])` call in `Plugin::register()`, alongside + the webhook exemption from Stage 9a. +- `craft\commerce\models\PaymentSource` → confirmed real new namespace `CraftCms\Commerce\Payment\ + Models\PaymentSource` (a type-hint-only reference, no behavior change). +- **Verification**: `route:list` confirmed all 5 new routes (anonymous, no `auth`/`can:` + middleware — each action gates its own auth/ownership checks inline, matching the legacy + `BaseFrontEndController`'s `allowAnonymous = true`). Live `craft exec:exec` testing confirmed + `PaymentSourcesController::{add,setPrimaryPaymentSource,delete}` all correctly 401 for an + anonymous/console user, and `PaymentsController::pay()` reaches into the same pre-existing + console-context service limitations already documented for `CartController` (not a regression). + +### Laravel Migration — Stage 9g: Controllers & Routes (Catalog) + +Migrated `ProductsController`, `VariantsController` to `src/Http/Controllers/`, and +`ProductTypesController` to `src/Http/Controllers/Settings/`. + +- `Element::SCENARIO_ESSENTIALS` → `$product->ruleset->useScenario(ElementRules:: + SCENARIO_ESSENTIALS)` — same replacement pattern as `SCENARIO_LIVE` in Stages 9b/9f, confirmed + via cms-6's own `StoreEntryController` (the real "create a new draft entry" reference + implementation) rather than guessed. +- `Craft::$app->getElements()->canSave($product, $user)` → `$product->canSave($user)` — same + authorization-moved-onto-the-element pattern as `canView()`/`canDelete()` found in Stage 9f. +- `Craft::$app->getDrafts()->saveElementAsDraft(...)` kept as the legacy accessor call (confirmed + a real, identically-shaped `CraftCms\Cms\Element\Drafts::saveElementAsDraft()` backs it, per + `StoreEntryController`'s own usage) — no change needed beyond the calls already made through it. +- `craft\helpers\ElementHelper::generateSlug()`/`tempSlug()` → `CraftCms\Cms\Element\ + ElementHelper` (confirmed via explicit `@deprecated` pointer in the legacy shim); + `craft\helpers\DateTimeHelper::now()` → Laravel's own `now()` global (also explicitly + deprecated in favor of it); `craft\helpers\DateTimeHelper::pause()`/`resume()`/`toDateTime()` + → `CraftCms\Cms\Support\DateTimeHelper` (no deprecation pointer, but a real class with matching + methods); `craft\enums\PropagationMethod` → `CraftCms\Cms\Element\Enums\PropagationMethod`. +- **Asset bundles left as-is**: confirmed via `docs/6.x/extend/assets.md` that a newer + `LegacyAssetInterface`/`InternalAssetRegistry` system exists, but the docs themselves flag it as + "deprecated, proactively... a stopgap" on the way to an Inertia-based UI — not worth migrating + Commerce's asset bundles (`CommerceCpAsset`, `ProductIndexAsset`, `EditSectionAsset`, etc.) to a + system that's already superseded. Every `registerAssetBundle()` call throughout this whole + controller migration (Stages 9a-9g) is left on the legacy path, confirmed still working via live + testing every time. +- **New, more reliable console-testing technique found**: `auth()->login($user)`/`auth()-> + setUser($user)` hang indefinitely under `craft exec:exec` (documented in Stage 9f), but + `request()->setUserResolver(fn() => $user)` does not — it satisfies `request()->user()`/ + `request()->craftUser()` (and anything built on those, e.g. `ProductTypes:: + getViewableProductTypeIds()`) without hanging. It does **not** satisfy `currentUser()`/ + `currentUserElement()` (the `CraftCms\Cms` global helpers), which resolve through a different + path (the Auth facade/guard) — so permission gates built on those two helpers specifically are + still only verifiable as an anonymous/403 case through this harness. Use `setUserResolver()` + as the default technique for future stages; it's strictly better than not testing at all. +- **Verification**: `route:list` confirmed all new routes. Live `craft exec:exec` testing (using + the new `setUserResolver()` technique) fully exercised `ProductTypesController:: + productTypeIndex()` and `editProductType()` end-to-end as an authenticated admin with no errors. + `ProductsController::create()` was verified up to its `currentUserElement()` gate (product + type lookup, site resolution, and editable-site fallback all confirmed correct) — blocked only + by the `currentUserElement()` resolution gap just documented, not a defect in the new code. + +### Laravel Migration — Stage 9f (final): Controllers & Routes (Orders) + +Migrated `OrdersController` (2,227 lines, 29 actions — the largest and highest-risk single +controller in the whole migration, covering order editing, transactions/refunds, and customer/ +address management) to `src/Http/Controllers/`. This completes Stage 9f. + +- **Found and fixed a real, repeated bug class specific to this migration**: the legacy file has + no `declare(strict_types=1)`, so PHP silently coerced numeric strings (every raw + `$request->input()`/`query()` value) into the `int`-typed parameters of half a dozen strictly- + typed service methods (`getOrderById`, `getTransactionById`, `getUserById`, `getEmailById`, + `getStoreBySiteId`, `getInventoryLocationById`, `getPurchasableById`, `AdminTable:: + paginationLinks()`). The new file *does* declare `strict_types=1` (per this project's own + convention for new code), so every one of those call sites needed an explicit `(int)` cast — + found via live `craft exec:exec` `TypeError`s on `purchasablesTable()` + (`getStoreBySiteId()`/`paginationLinks()`), then proactively audited and fixed every other + matching call site in the file rather than waiting to hit each one individually. +- **Found and fixed a real, pre-existing bug in `transactionRefund()`**: `$transaction-> + paymentCurrency` was read before the `if (!$transaction)` null check — reordered so the null + check runs first, matching what the code was clearly trying to do. +- **`Craft::$app->getElements()->canView($order)`/`canDelete($order)` → `$order->canView($user)`/ + `canDelete($user)`**: authorization moved from the Elements service onto the element itself and + now takes an explicit `User` in the new element system, rather than implicitly checking the + current session user. Replaced with `($user = currentUserElement()) && $order->canView($user)`. +- **`Element::setScenario(Element::SCENARIO_LIVE)` → `$order->ruleset->useScenario(ElementRules:: + SCENARIO_LIVE)`** — same replacement already established for `CartController` (this stage) and + already used by the already-migrated `Order::validateAddress()`. +- **Dropped `activeAttributes()`-based attribute-scoped validation** in `save()`, same reasoning + and replacement (`validate(null, false)`) as `CartController`'s `_returnCart()`. +- `Craft::$app->getUser()->getIdentity()` → `currentUserElement()`; `Craft::$app->getView()-> + registerJs($js, View::POS_BEGIN)` → `HtmlStack::js($js, Position::BodyBegin)` (confirmed real + facade replacement, `src/Support/Facades/HtmlStack.php` in cms-6); `$this->asCpModal()` → + `new CpModalResponse()` (confirmed real, near-identical fluent API); `$this->requireCpRequest()` + → `RequireCpRequest::class` route middleware (applied to the 4 CP-only actions — + `reassign(Modal)?`, `removeCustomerData(Modal)?` — alongside their `deleteUsers` permission, + nested inside the controller-wide `commerce-manageOrders` group). +- `OrdersController` extends the plain Yii2 `Controller` (not `BaseCpController`/ + `BaseAdminController`) — its own `init()` only ever checked `commerce-manageOrders`, never + `accessPlugin-commerce`. Its action routes in `routes/actions.php` reflect that (no + `accessPlugin-commerce` check), while its CP page routes in `routes/cp.php` sit inside the + existing outer `accessPlugin-commerce` group for consistency with every other CP page route — + harmless in practice since `commerce-manageOrders` can only be granted to a user who already has + plugin access, per Craft's nested-permission model. +- **Testing limitation found and documented (not a bug in the new code)**: `auth()->login($user)` + and `auth()->setUser($user)` both hang indefinitely under `craft exec:exec`'s console context in + this dev environment — meaning the handful of actions that call `enforceManageOrderPermissions()` + (`editOrder`, `save`, `refresh`, `getShippingMethodOptions`, `deleteOrder`) couldn't be exercised + end-to-end as an authenticated user through this harness. Verified everything up to that gate + instead (confirmed each one reaches `enforceManageOrderPermissions()` cleanly with no unrelated + errors, and confirmed the gate itself correctly 403s for the anonymous/console user) — the + downstream `validate(null, false)` + `Elements::saveElement()` logic is identical to + `CartController::returnCart()`'s already-verified full end-to-end success. +- **Verification**: `route:list` confirmed all 58 new routes (29 actions × dual CP/site + registration, plus 3 CP pages) with correct HTTP verbs and the correct compound permission + chains. Live `craft exec:exec` testing fully exercised `purchasablesTable()` (including the + `ModifyPurchasablesTableQueryEvent` dispatch) and `getIndexSourcesBadgeCounts()` end-to-end + successfully after the `strict_types` fixes above. + +### Laravel Migration — Stage 9f (partial): Controllers & Routes (Cart) + +Migrated `CartController` (1,022 lines, the largest front-end controller so far) to +`src/Http/Controllers/`. `BaseFrontEndController` stays in `src-yii2/` — `DownloadsController`, +`PaymentSourcesController`, `PaymentsController`, `UsersController` still extend it. + +- **Mutex → `Cache::lock()`**: Yii2's `Craft::$app->getMutex()->acquire($name, $timeout)` (blocks + up to `$timeout` seconds, returns bool) is replaced by `Cache::lock($name, $ttl)->block($timeout)` + — confirmed real, pervasive pattern (`ElementDraftsController`, `SaveElementController`, + `ProjectConfig`, `Structures`, etc. in cms-6). Key difference: Laravel's `block()` *throws* + `LockTimeoutException` on failure instead of returning `false` — both call sites (`updateCart()`'s + hard failure, `complete()`'s graceful add-error-and-continue) now catch it and reproduce the + original branch behavior explicitly, rather than relying on a bool return that no longer happens. +- **Yii2 `RateLimiter` behavior → named Laravel rate limiters + `throttle:` middleware**: added + `src/Http/RateLimiters/{CartRateLimiter,CartChallengeRateLimiter}.php` (mirroring cms-6's own + `LoginRateLimiter` pattern exactly — a plain class with a `limit(Request): Limit` method), + registered via `RateLimiter::for()` in `Plugin::register()` (per Stage 9a's finding that + `boot()` never fires for a plugin). `CartRateLimiter` returns `Limit::none()` when neither + `number` nor `couponCode` is present, matching the legacy behavior's conditional `user` callback + that only rate-limited requests carrying those params. Applied via `->middleware('throttle:...')` + on the route group in `routes/actions.php`, replacing the controller-level `behaviors()` override. +- **`Element::setScenario(Element::SCENARIO_LIVE)` → `$ruleset->useScenario(ElementRules:: + SCENARIO_LIVE)`**: confirmed via existing precedent in the already-migrated `Order:: + validateAddress()` (`src/Order/Elements/Order.php`). +- **Simplified `_returnCart()`'s attribute-scoped validation**: legacy built an explicit attribute + list via `activeAttributes()` (a Yii2 Model concept with no equivalent under the new Ruleset + validation system) merged with custom-field attributes gated behind a `Composer\Semver` Craft- + version check the code's own comment marked `@TODO Remove ... once Craft >= 4.4 is the minimum + requirement` — now permanently true for Commerce 6. Replaced with a plain `$this->cart-> + validate(null, false)`, which validates the full ruleset (confirmed via direct research into + `CraftCms\RulesetValidation\Ruleset`/`Validates` trait source — passing `null` skips the + `->only()` attribute filter entirely rather than requiring an explicit "everything" list). + Verified end-to-end via a real `updateCart()` round-trip against an actual cart in the dev DB + that reached this exact line and returned a real "Cart updated." success response. +- `Craft::$app->getElements()->saveElement($cart, $runValidation, $propagate, $updateSearchIndex)` + → `Elements::saveElement(...)` facade call — confirmed identical first-4-param order/meaning via + direct signature comparison, new optional trailing params left at their defaults. +- Dropped the Yii2 `$isConsoleRequest`/cookie-based mutex-key fallback branches specific to + console-dispatched web requests — this controller is reachable only via real HTTP routing now + (no `Craft::$app->runAction()`-style console dispatch path exists for it anymore), so those + branches were dead weight, not a behavior change for any real caller. +- `getBodyParam()` call sites were **not** ported to `Request::post()` — despite the naming + symmetry, Laravel's `post()` only reads the raw POST-body `ParameterBag` and does **not** + transparently parse a JSON request body the way Yii2's `getBodyParam()` did. Used `Request:: + input()` (merged query+body, JSON-aware) everywhere instead, accepting the minor superset of + also honoring query-string params on POST-only routes — a low-risk tradeoff against silently + breaking JSON-driven cart AJAX calls, which was the real, load-bearing risk. +- `cartArray()`'s `EVENT_MODIFY_CART_INFO` extension point ports to a plain `event(new + ModifyCartInfoEvent(...))` dispatch — the event class itself (`src/Order/Events/ + ModifyCartInfoEvent.php`) was already migrated in an earlier stage as a plain data class; this + is the first time anything actually dispatches it. +- **Verification**: `route:list` confirmed all 8 routes (dual CP/site-registered) with the correct + HTTP verbs and `throttle:` middleware. Direct `craft exec:exec` testing included a full + `updateCart()` round-trip against a real cart row (mutex acquired via a real `Cache::lock`, + validate+save succeeded, `asModelSuccess()` returned the expected JSON), plus `forgetCart()`, + `cartSent()`, and `emailChallenge()`/404-not-found-cart checks. Remaining failures were the + established console-context limitations inside still-legacy internals the controller calls into + (`craft\console\Request::getUserIP()`/`getCookies()` inside `src-yii2/services/Carts.php`) — not + regressions in the new controller. + +### Laravel Migration — Stage 9f (partial): Controllers & Routes (Order/Line Item Statuses, User Orders) + +Migrated `OrderStatusesController`, `LineItemStatusesController` to +`src/Http/Controllers/Settings/`, and `UserOrdersController` to +`src/Http/Controllers/`. `BaseAdminController`-style (`RequireAdmin` route middleware) for the +first two; `UserOrdersController` is a single anonymous JSON action (matches +`BaseFrontEndController`'s `allowAnonymous = true`), registered in `routes/actions.php` with no +`auth` middleware. `BaseFrontEndController` itself is **not** deleted — `CartController`, +`DownloadsController`, `PaymentSourcesController`, `PaymentsController`, `UsersController` still +extend it (remaining Stage 9f/9h/9k work). + +- Both `index()` templates extend `commerce/_layouts/settings` directly (own crumbs/title) → + `pageTemplate()`; both `_edit` templates are bare content fragments → `CpScreenResponse`. Same + per-template-shape check as every prior sub-stage, this time both patterns appear within the + same controller. +- **Found and fixed a real bug**: `OrderStatusesController::edit()`'s email dropdown (`Plugin:: + getInstance()->getEmails()->getAllEmails()`) now returns `CraftCms\Commerce\Email\Models\Email` + (already migrated to `src/`), not the legacy `craft\commerce\models\Email` the new controller + was initially typed against — a `TypeError` on the `mapWithKeys()` closure's parameter type. + Fixed by importing the new `Email` model instead. +- `getOrderStatuses()`/`reorder()`/`delete()` on `OrderStatusesController` and `reorder()`/ + `archive()` on `LineItemStatusesController` all sit under `BaseAdminController::init()`'s + unconditional `requireAdmin(false)` in the legacy source (not just the index/edit screens) — + replicated by putting every one of these action routes in the existing shared `RequireAdmin` + group in `routes/actions.php`, alongside Gateways/Settings/Stores/OrderSettings. +- **Verification**: `route:list` confirmed all new page and action routes (including the + dual CP/site registration for `user-orders/get-orders`). Direct `craft exec:exec` invocation + of `index()`/`edit()`/`save()`/`reorder()`/`delete()`/`archive()`/`getOrderStatuses()` — a real + `save()` round-trip actually created and then deleted a test order status to confirm the model + save path works end-to-end, not just that it returns a response object. Remaining failures were + the known console-context limitations (`currentUser` null, missing `Accept` header on the + synthetic request) — not regressions. + +### Laravel Migration — Stage 9e: Controllers & Routes (Store & Settings) + +Migrated `StoreManagementController`, `StoresController`, `SettingsController`, +`OrderSettingsController`, `PaymentCurrenciesController` to `src/Http/Controllers/Settings/`. +`BaseStoreManagementController` deleted — nothing extends it anymore. + +- **Found and fixed a real, already-merged authorization gap spanning Stages 9b–9d**: + `BaseStoreManagementController::init()` unconditionally required `commerce-manageStoreSettings` + for *every* subclass, on top of each area's own specific permission + (`commerce-manageShipping`/`commerce-manageTaxes`/`commerce-managePromotions`). The route + middleware added in 9b/9c/9d only checked the specific permission, missing this base check + entirely. Fixed by wrapping the whole `commerce/store-management/*` route group (in both + `routes/cp.php` and `routes/actions.php`) in a `can:commerce-manageStoreSettings` middleware + layer, with each area's specific permission nested inside it — restores the exact compound + check the legacy `init()` enforced. `PaymentCurrenciesController` and `StoreManagementController` + only ever needed the base permission (no area-specific one), consistent with the legacy source. + `PromotionsController`'s bare redirect route is unaffected (it extends `BaseCpController`, not + `BaseStoreManagementController`). +- **Found and fixed a real, pre-existing bug in `src-yii2/services/Transfers.php`**: + `getFieldLayout()` was still typed against the legacy `craft\models\FieldLayout`/ + `craft\models\FieldLayoutTab`, but `Craft::$app->getFields()->getLayoutByType()` now returns the + new `CraftCms\Cms\FieldLayout\FieldLayout` — a `TypeError` on every call. Nothing exercised this + method until `SettingsController::editTransferSettings()` wired it into a route. Fixed by + switching the imports/type hints to the new `FieldLayout`/`FieldLayoutTab` classes, which expose + the same `getTabs()`/`setTabs()`/`isFieldIncluded()`/`setLayout()`/`setElements()` API — confirmed + via the identical tab-injection pattern already in `src/Catalog/ProductType/Data/ProductType.php`. +- `SettingsController::actionSites()` deliberately dropped — its template + (`commerce/settings/sites/_edit.twig`) doesn't exist on disk; the real `commerce/settings/sites` + URL has always pointed at `StoresController::editSiteStores()` instead. Pre-existing dead code, + not a migration regression. +- **Verification**: `route:list` confirmed every new route (index/edit/save/delete pages plus + action endpoints) and the corrected middleware chains (`commerce-manageStoreSettings` alone + for `StoreManagementController`/`PaymentCurrenciesController`; compounded with the area-specific + permission for Shipping/Tax/Promotions). Direct `craft exec:exec` invocation of every new + controller method surfaced only known console-context limitations (`currentUser` null, + `craft\console\Request::getSegments()` missing) — no new regressions once the two real bugs + above were fixed. + +### Laravel Migration — Stage 9d: Controllers & Routes (Promotions) + +Migrated `SalesController`, `DiscountsController`, `CatalogPricingRulesController`, +`CatalogPricingController`, `PromotionsController` to `src/Http/Controllers/Settings/`. + +- **Found and fixed a real correctness gap from Stage 9b**: `pageTemplate()`-based controllers + (as opposed to `CpScreenResponse`-based ones) need `storeSwitcher`/`storeSettingsNav` passed + explicitly — the legacy `BaseStoreManagementController::renderTemplate()` override injected + these into *every* template automatically, a behavior `HasStoreManagementScreen` doesn't + replicate (by design — it only wraps the `CpScreenResponse` path). Retroactively fixed + `ShippingRulesController::edit()` (missed in 9b) to pass `storeSwitcher` explicitly, and did + the same for `SalesController::index()`/`::edit()` (both `pageTemplate()`-based) here. +- `CatalogPricingController` doesn't use `HasStoreManagementScreen` at all — its `index()` + template extends `commerce/_layouts/cp` directly (not `store-management`), and it isn't + store-scoped at the URL level (site is resolved from a query param instead). +- `PromotionsController` is a single-line redirect (`commerce/promotions` → `commerce/promotions/ + sales`) — implemented as a route closure rather than a controller class. Note: this redirect + target doesn't correspond to any registered route (sales now lives at + `commerce/store-management/{storeHandle}/sales`) — this was **already true in the original** + (the redirect predates the store-management URL structure, and the CP nav already bypasses + this controller entirely, linking straight to `commerce/store-management/{store}/discounts`). + Preserved as-is rather than "fixed", since changing 404-vs-not behavior here is out of scope. +- `BaseStoreManagementController` is **not** deleted yet — `PaymentCurrenciesController` and + `StoreManagementController` (Stage 9e) still extend it directly. +- **Verification**: `route:list` confirmed all ~58 new routes/middleware. Direct `craft + exec:exec` invocation of `edit()` on all three permission-gated controllers correctly + triggered a 403 (confirmed `currentUserElement()` is legitimately `null` in a console context + — this demonstrates the authorization gate working, not a bug). `CatalogPricingController:: + index()` and `canUseSales()`/`canUseCatalogPricingRules()` guards verified independently. + +### Laravel Migration — Stage 9c: Controllers & Routes (Tax) + +Migrated `TaxZonesController`, `TaxCategoriesController`, `TaxRatesController` to +`src/Http/Controllers/Settings/`, reusing Stage 9b's `HasStoreManagementScreen` trait +(`can:commerce-manageTaxes` in place of `commerce-manageShipping`). `BaseTaxSettingsController` +and the 3 legacy controllers deleted outright. All 3 templates are bare content fragments (no +`{% extends %}`), so all 3 use `CpScreenResponse` (no `pageTemplate()` needed this time, unlike +9b's `ShippingRulesController`). + +**Found and fixed another instance of the same latent bug from 9b**: `Tax\Models\TaxCategory:: +getUiLabel()` also called the global `t()` helper without importing it (`ShippingMethod`/ +`ShippingAddressZone`/`TaxRate`/`TaxAddressZone` models all correctly import it — only +`ShippingCategory` and now `TaxCategory` were missing it). Proactively grepped every +`getUiLabel()` implementation under `src/` for the same missing-import pattern afterward — no +further instances found. + +**Verification**: `route:list --path=tax -v` confirmed all 27 routes/middleware; direct +`craft exec:exec` invocation of all 3 `edit()` methods confirmed clean `CpScreenResponse` +construction (including exercising the `Cp::chipHtml()`/`getUiLabel()` path that caught the bug +above). + +### Laravel Migration — Stage 9b: Controllers & Routes (Shipping) + +Migrated `ShippingZonesController`, `ShippingMethodsController`, `ShippingRulesController`, +`ShippingCategoriesController` to `src/Http/Controllers/Settings/`, and `BaseShippingSettingsController` +to a new `Http\Controllers\Concerns\HasStoreManagementScreen` trait (`resolveStore()` + +`storeManagementCpScreen()`, ported 1:1 from `BaseStoreManagementController`'s +`asStoreManagementCpScreen()`/`getStoreSwitcher()`/`getStoreSettingsNav()`) — the shared +store-scoped CP screen chrome needed by every one of stages 9b–9e's controllers. Legacy +`src-yii2/controllers/{BaseShippingSettings,ShippingZones,ShippingMethods,ShippingRules, +ShippingCategories}Controller.php` deleted outright. + +- `ShippingRulesController::edit()` is the first controller in this migration confirmed to need + the plain `pageTemplate()` helper rather than `CpScreenResponse` — its template + (`shippingrules/_edit.twig`) `{% extends "commerce/_layouts/store-management" %}` and sets its + own crumbs/tabs in Twig, unlike its sibling `shippingzones|shippingmethods|shippingcategories/ + _edit.twig` (bare `{% block content %}` fragments, no `{% extends %}`) — confirms the + "check every template" rule from Stage 9a is a real, recurring distinction within the same + domain, not a one-off. +- Replaced two Yii2 "return void + `setRouteParams()` + implicit re-render" actions + (`ShippingRulesController::actionSave()`, and `ShippingCategoriesController:: + actionSetDefaultCategory()`'s "return null") with `asModelSuccess()`/`asModelFailure()` or + `asSuccess()`/`asFailure()`, matching the pattern established in Stage 9a. +- **Found and fixed a real pre-existing bug** while live-verifying: `Shipping\Models\ + ShippingCategory::getUiLabel()` calls the global `t()` helper without importing it + (`use function CraftCms\Cms\t;` was missing — present on the sibling `ShippingMethod`/ + `ShippingAddressZone` models, just not this one). Latent since whatever earlier stage migrated + this model, since nothing had exercised `Chippable::getUiLabel()` on a `ShippingCategory` + until this controller's `index()` wired it into `Cp::chipHtml()`. +- **Verification**: `route:list --path=shipping -v` confirmed all 37 routes/middleware; direct + `craft exec:exec` invocation of every controller method confirmed correct + `CpScreenResponse`/string construction with no errors from controller logic (the only failures + were the established, expected console-context limitation — `craft\console\Request` lacking + `getSegments()`/a session store, which any real HTTP request has). Full authenticated-browser + verification not done this pass either, same caveat as Stage 9a. + +### Laravel Migration — Stage 9a: Controllers & Routes (foundation + 3 controllers) + +First slice of the largest remaining migration stage: 48 Yii2 controllers / 214 `action*()` +methods, currently routed entirely through Yii2's automatic convention + one explicit CP +URL-rule map (`src-yii2/plugin/Routes.php`). Craft 6 has no automatic controller routing and no +base controller class at all, so this is a bigger architectural break than prior stages. This +slice covers the foundation plus three controllers chosen to prove out the three distinct +patterns the remaining ~45 controllers will fall into — full details and what's explicitly +deferred are in `laravel-migration-private.md`. + +- Added `routes/{web,cp,actions}.php` — auto-discovered by `CraftCms\Cms\Plugin\Concerns\HasRoutes`. +- Added `Plugin::getPermissions()` (via new `Plugin\Concerns\HasPermissions`), porting the 4 + static permission groups from `src-yii2/Plugin.php::_registerPermissions()` as + `CraftCms\Cms\User\Data\Permission` objects. `_productPermissions()` (dynamic, per-product-type) + deferred to whichever session migrates `ProductTypesController`. The legacy registration stays + active in parallel until every permission is ported. +- Migrated `WebhooksController` (anonymous action-path pattern), `DonationsController` (simple + CP settings screen), `Settings\GatewaysController` (list-based CRUD CP settings screen — the + template for ~15 similar controllers) to `src/Http/Controllers/`. **Legacy `src-yii2/controllers/ + {Webhooks,Donations,Gateways}Controller.php` were deleted outright**, not converted to stubs — + unlike services, nothing in the app resolves controller classes by name anymore (routing is + 100% explicit now), so there's no reason to keep a legacy shim around once a controller is + migrated. +- **Three load-bearing discoveries, made by reading cms-6's actual source (the public CP-screen + docs are incomplete) and confirmed live against the running app — future controller + migrations should rely on these instead of re-deriving them:** + 1. `Plugin::boot()` (standard Laravel `ServiceProvider` lifecycle) is **never called** by + Craft's plugin system, even though `Plugin extends ServiceProvider` — confirmed by direct + test (a debug side-effect in `boot()` never ran; the same one in `register()` did). Craft's + plugin manager calls `register()` and its own internal `bootPlugin()` (which fires the fixed + `bootHasX()` trait sequence), but not `boot()`. Any one-off plugin-level bootstrapping code + (e.g. `PreventRequestForgery::except()` for a CSRF-exempt webhook route) needs to go in + `register()`, not `boot()`. + 2. CP forms built with `actionInput()`/`redirectInput()` (the classic Craft convention, used + everywhere in Commerce's still-legacy Twig templates) **post back to the current page URL** + with an `action` body param — they do **not** post to the literal action-route string. A + global `CraftCms\Cms\Http\Middleware\HandleActionRequest` middleware (registered before + routing, via `ActionRouteResolver`) rewrites the request's effective URI from that `action` + param before Laravel's router ever runs. This means a controller's save/archive/reorder + endpoints reached this way must be registered in `routes/actions.php` (auto-prefixed with + the plugin handle, and — importantly — auto-registered a second time at the anonymous + site-side action URL too, so route-level `auth`/`can:` middleware is what actually protects + them, not the URL), not in `routes/cp.php` at whatever "pretty" URL the page itself lives at. + 3. Two different Twig rendering entry points replace Yii2's `$this->renderTemplate()`, + depending on the template's own shape: `CpScreenResponse::contentTemplate()` for templates + that are bare content fragments with no `{% extends %}` (e.g. `commerce/donation/_edit.twig`); + the plain `pageTemplate()` helper for templates that already `{% extends "commerce/_layouts/ + ..." %}`/build their own full CP page (e.g. `commerce/settings/gateways/{index,_edit}.twig`). + Using the wrong one either double-wraps the page chrome or renders a bare fragment with none. +- **Known gap, not fixed this session**: `RespondsWithFlash::asModelFailure()` redirects back on + a failed save rather than Yii2's inline same-request re-render, and Commerce's legacy Twig edit + templates don't yet read Laravel's `old()`/session-flash data — so a failed `GatewaysController:: + save()` currently redirects back to a blank/default edit form rather than the user's attempted + input. This is an explicitly-flagged architectural difference (see cms-6's own equivalent + `VolumesController`/`FilesystemsController`, which have the exact same behavior), not unique to + this migration — tracked as a follow-up for whichever session addresses form-repopulation UX + broadly, rather than fixed ad hoc per controller. +- **Verification**: `ddev artisan route:list --path=commerce -v` confirmed all routes/middleware + registered as designed. The webhook endpoint was verified end-to-end over real HTTP in all + three reachable forms (pretty URL, site action path, CP action path) — first hit a real CSRF + 419 (fixed via the `register()` discovery above), then a real `TypeError` (`getGatewayById()` + needs `int`, `$request->input()` returns `string` — fixed with an explicit cast), then + confirmed correctly returning 404 for an unknown gateway across all three URLs. Unauthenticated + CP routes correctly redirect to login. `DonationsController`/`GatewaysController`'s controller + logic and Commerce's own content templates were verified error-free via direct invocation + through `craft exec:exec`; full authenticated CP-chrome rendering could not be exercised this + way (Craft's own CP header/layout partials need a real logged-in `currentUser()`, which a + console context doesn't have) — recommend a real authenticated browser pass before treating + Donations/Gateways as production-ready, though the routing/CSRF/permission layer underneath is + proven end-to-end via the webhook test. + +### Laravel Migration — CatalogPricingCondition and its rules + +Migrated `craft\commerce\elements\conditions\purchasables\{CatalogPricingCondition, +CatalogPricingPurchasableConditionRule,CatalogPricingCustomerConditionRule}` to +`CraftCms\Commerce\CatalogPricing\Conditions\*`, alongside `CatalogPricingConditionRuleInterface` +(already relocated to `src/CatalogPricing/Contracts/` in an earlier stage, now fully +implementable). This fixes a real fatal error discovered while live-verifying the Phase 2 +facade cleanup: the legacy `CatalogPricingCondition` overrode `getConfig()`, which Craft 6 made +`final` on the new `CraftCms\Cms\Condition\BaseCondition` — any code path that constructed one +(`CatalogPricing::createCatalogPrices/PricingQuery()`, the catalog pricing CP page, the +purchasable price field's condition builder) fataled outright. + +- `defineRules()` → `getRules()`, Illuminate-style. Critically, `allPrices` had to be added as a + `getRules()` key — under the new `Conditions::createCondition()`, that array doubles as the + allowlist of properties `['class' => ..., 'allPrices' => ...]` config actually gets applied to + (Yii2's `Craft::createObject()` did unfiltered property assignment; the new one filters by + `array_keys($condition->getRules())`). Without this, `allPrices` would have silently stayed + `false` regardless of what was passed in. +- `modifyQuery(craft\db\Query $query)` → `modifyQuery(Illuminate\Database\Query\Builder $query)` + on the condition, both rule classes, and the `CatalogPricingConditionRuleInterface` contract — + the real call site (`src/Services/CatalogPricing.php`) has passed an Illuminate builder for a + while now (via `DB::table(...)`), so this was a second latent `TypeError` waiting behind the + `final`-method fatal. Rewrote the Yii2 array-condition/subquery logic (`andWhere([...])`, + nested `craft\db\Query` subqueries) as Illuminate `where()`/`whereIn()`/closures — verified the + generated SQL is semantically identical to the original for all three cases (no restriction, + "no user-specific rules", "rules for a specific customer"). +- `CatalogPricingPurchasableConditionRule::modifyQuery()`'s `$query->andWhere(['purchasableId' => + $ids])` → `$query->whereIn('purchasableId', $ids)`. +- Dropped the dead `defineRules()` overrides on both rule classes (never called by the new + validation stack) in favor of real `getRules()` overrides. +- `Craft::t('commerce', ...)` → `t(..., category: 'commerce')` on both rule classes' + `getLabel()`; `craft\elements\User` → `CraftCms\Cms\User\Elements\User` in the customer rule. +- Left `craft\helpers\{Html,Cp,ArrayHelper}` and `craft\commerce\Plugin`/`base\PurchasableInterface` + (docblock only) as legacy references — no confirmed-safe new equivalent for the first three + (not `class_alias`-based; see the craft-core-reference cleanup methodology), and the Commerce + namespace ones are still a legitimate exception during the transition. +- Legacy `src-yii2/elements/conditions/purchasables/*.php` files are now thin `class_alias` + stubs. Updated the three still-legacy consumers' imports (`CatalogPricingController.php`, + `PurchasablePriceField.php`, `src-yii2/services/CatalogPricing.php`) to the new namespace. +- Removed the now-stale "TODO: Migrate ... once conditions migrated" comments and + `@phpstan-ignore-next-line` markers in `src/Services/CatalogPricing.php`'s three query-building + methods, now that the types genuinely line up. + +**Verification**: `php -l` on all touched/new files; `Conditions::createCondition()`/ +`createConditionRule()` round-tripped live via `ddev`/`craft exec:exec` (confirmed `allPrices` +and `customerId` are actually applied, not silently dropped); `CatalogPricing:: +createCatalogPricesQuery()` inspected via `->toSql()`/`->getBindings()` for the no-restriction, +"no user rules", and "specific customer" cases, and executed against the real dev database; the +full `getCatalogPrices()` public API path (the one the catalog pricing CP page actually calls) +also verified end-to-end; confirmed the legacy `class_alias` still resolves correctly. + +### Laravel Migration — Craft core reference cleanup, Phase 2 + +Completed the follow-up flagged at the end of Phase 1: swapped `\Craft::$app->getX()->method()` +service-locator calls in already-migrated `src/` code for their real Laravel facade +equivalents, wherever the legacy `craft\services\*` wrapper method was confirmed to be a pure +1:1 delegation. Touched `Catalog/Queries/VariantQuery.php` and 21 files under `Services/`. + +- `Craft::$app->getUser()->getIdentity()` → `CraftCms\Cms\currentUserElement()` (13 call sites + across `Carts`, `Discounts`, `Purchasables`, `Sales`, `Transactions`, `OrderHistories`, + `VariantQuery`) — this is the documented Craft 6 replacement (see `Auth::craftUser()` / + `request()->craftUser()`), using the `?->asElement()`-returning helper since every call site + needed the `User` element itself (`->id`, `->email`, `->getGroups()`, `->can()`, or a typed + `?User` param), not just the `CraftUser` interface. +- `Craft::$app->getElements()->{saveElement,deleteElementById,getElementById,duplicateElement, + createElementQuery,getElementTypeById}()` → `CraftCms\Cms\Support\Facades\Elements` +- `Craft::$app->getElements()->{invalidateCachesForElement,invalidateCachesForElementType}()` → + `CraftCms\Cms\Support\Facades\ElementCaches::{invalidateForElement,invalidateForElementType}()` + — this is the real fix for the trap flagged in Phase 1: the legacy method doesn't call the + `Elements` service at all, it routes through a completely different `ElementCaches` object, so + the correct swap targets a different facade with renamed methods, not `Elements::` directly. +- `Craft::$app->getProjectConfig()->{set,remove,get,processConfigChanges}()` → + `CraftCms\Cms\Support\Facades\ProjectConfig` (also `Craft::$app->projectConfig->...` property + access, same underlying object) +- `Craft::$app->getSites()->{getCurrentSite,getPrimarySite,getAllSites,getSiteById,getSiteByUid, + getHasCurrentSite,setCurrentSite}()` → `CraftCms\Cms\Support\Facades\Sites` — the legacy + read methods actually return a re-wrapped `craft\models\Site` (different object, and `array` + instead of `Collection` for `getAllSites()`), which is normally unsafe to swap blindly, but + every call site in Commerce only ever reads `->id`/`->handle`/`->uid` off the result, which + exist identically on the new `Site\Data\Site`, so the swap is behaviorally identical here. +- `Craft::$app->getIsMultiSite()` → `Sites::isMultiSite()` +- `Craft::$app->getUsers()->{getUserById,assignUserToDefaultGroup}()` → + `CraftCms\Cms\Support\Facades\Users` +- `Craft::$app->getFields()->{deleteLayoutsByType,getLayoutByType,saveLayout}()` → + `CraftCms\Cms\Support\Facades\Fields` +- `Craft::$app->getConditions()->{createCondition,createConditionRule}()` → + `CraftCms\Cms\Support\Facades\Conditions` +- `Craft::$app->getPlugins()->{isPluginInstalled,getStoredPluginInfo}()` → + `CraftCms\Cms\Support\Facades\Plugins` +- `Craft::$app->getPath()->{getTempPath,getCachePath,getLogPath}()` → + `CraftCms\Cms\Support\Facades\Path::{temp,cache,logs}()` (renamed methods) +- `Craft::t('app'/'commerce', ...)` → `t(..., category: 'app'/'commerce')` in `Customers.php` + +**One more confirmed trap, left untouched on purpose**: `Craft::$app->getUsers()-> +sendActivationEmail()` in `Customers.php` does not delegate to the new `Users` service's +method of the same name — the legacy path calls the generic `$user->sendEmailVerificationNotification()`, +while the new service sends a distinct `ActivationNotification` with an explicitly-generated +verification code. Swapping would change which email gets sent, so this one stays on the +legacy service-locator call. + +**Discovered while verifying, unrelated to this change, not fixed**: instantiating the legacy +`craft\commerce\elements\conditions\purchasables\CatalogPricingCondition` (via either the old +`Craft::$app->getConditions()->createCondition()` or the new `Conditions::createCondition()` — +confirmed identical on both paths) currently fatals with "Cannot override final method +`CraftCms\Cms\Condition\BaseCondition::getConfig()`", since Craft 6 made that method `final` and +the legacy condition class still overrides it. This blocks any code path that constructs a +`CatalogPricingCondition`/`CatalogPricingCustomerConditionRule` (e.g. +`CatalogPricing::createCatalogPrices/PricingQuery()`) until those condition classes are migrated +off the legacy base — tracked as part of the already-known "migrate to new element conditions +system" TODOs in that file, not introduced by this change. + +**Verification**: all touched files pass `php -l`; live-verified via ddev (`craft exec:exec`) +that every affected service still boots and its core methods still execute correctly against +real data — `Sites`, `Stores`, `Products`, `Variants`, `Purchasables`, `Sales`, `Discounts`, +`CatalogPricingRules`, `Emails`, `Pdfs`, `Gateways`, `LineItemStatuses`, `OrderStatuses`, +`Subscriptions`, `Inventory`, `Orders`, plus the legacy `Plugin::getInstance()->getX()` alias +paths (to catch alias-timing issues) and a real `ElementCaches`/`Elements` round trip against an +existing order. + +### Laravel Migration — Craft core reference cleanup, Phase 1 + +Fixed the "genuine violations" from the earlier craft-core-reference audit: already-migrated +`src/` files still importing old `craft\*` core classes where a real `CraftCms\Cms\*` +equivalent now exists, across `Catalog/Models/CatalogPricingRule.php`, `Helpers/ProductQuery.php`, +and `Services/{CatalogPricingRules,Customers,Discounts,Emails,Orders,OrderHistories, +Purchasables,Sales,Stores,Subscriptions}.php`: + +- `craft\elements\{User,Address,Entry,ElementCollection}` → `CraftCms\Cms\{User\Elements\User,Address\Elements\Address,Entry\Elements\Entry,Element\ElementCollection}` +- `craft\models\FieldLayout` → `CraftCms\Cms\FieldLayout\FieldLayout` +- `craft\models\Site` → `CraftCms\Cms\Site\Data\Site` +- `craft\errors\{ElementNotFoundException,InvalidElementException,UnsupportedSiteException}` → `CraftCms\Cms\Element\{Queries\Exceptions\ElementNotFoundException,Exceptions\InvalidElementException,Exceptions\UnsupportedSiteException}` +- `craft\helpers\DateTimeHelper::toDateTime()` → `CraftCms\Cms\Support\DateTimeHelper::toDateTime()` +- `craft\helpers\ElementHelper::cleanseQueryCriteria()` → `CraftCms\Cms\Element\ElementHelper::cleanseQueryCriteria()` +- `craft\helpers\Assets::tempFilePath()` → `CraftCms\Cms\Asset\AssetsHelper::tempFilePath()` + +**Two more corrections to the original audit**, on top of the `FieldLayout` one already +caught in Stage 7e: `craft\models\Site` was assumed to alias `Site\Models\Site` but actually +aliases `Site\Data\Site` (confirmed via `yii2-adapter/src/ClassAliases.php`), and +`craft\helpers\Assets` was assumed to have a facade equivalent, but the legacy `Assets` class +is a plain static helper (`class Assets extends CraftCms\Cms\Asset\AssetsHelper`), not a +facade proxy — the actual target is `AssetsHelper`, imported `as Assets` to keep call sites +unchanged. + +**Also discovered the audit's proposed fixes weren't uniformly safe at the method level**: +`craft\helpers\DateTimeHelper` and `ElementHelper` are not `class_alias`-based (unlike the +element/model/exception swaps above, which are genuine aliases and therefore risk-free) — they're +real subclasses in `yii2-adapter/legacy/helpers/` extending the new classes. Some methods are +purely inherited (safe to call via the new class directly — `toDateTime()`, +`cleanseQueryCriteria()`, `tempFilePath()`); others exist *only* on the legacy subclass +(`DateTimeHelper::now()`, `::secondsToInterval()`, `::currentUTCDateTime()`, still used in +`Carts.php`/`Plans.php`) and have no new-class equivalent at all — left untouched rather than +guessed at. + +**Phase 2 not started**: `\Craft::$app->getX()->method()` → Facade swaps (`Elements`, +`ProjectConfig`, `Sites`, `Users`, `Fields`, `Plugins`, `Path`, `Conditions` all have real +facades). This category needs the same per-method verification as above, since the legacy +`craft\services\*` wrapper classes are hand-written compat shims, not simple aliases or +inheritance — confirmed one real trap already: `craft\services\Elements::invalidateCachesForElementType()` +does not delegate to what would be the obvious facade method, it calls a completely different +internal object (`$this->elementCaches()->invalidateForElementType()`). See +`laravel-migration-private.md`'s craft-core-reference follow-up for the full verification +procedure before attempting this phase. + +**Verification**: all touched files pass `php -l`; live-verified via ddev that every affected +service still boots and its core methods still execute correctly (`Sales::getAllSales()`, +`Stores::getAllStores()`, etc.) after the import swaps. + +### Laravel Migration — Events layer: missing-constructor bug fix + +Fixed the cross-cutting bug flagged as a HIGH PRIORITY follow-up during Stage 7d/7e: +of the 56 event classes moved to `src/*/Events/*.php` in Stage 3, only 4 had a real +constructor (the `Catalog\Events\Customize*SnapshotEvent` classes, fixed as part of +Stage 7d because that stage's own code constructed them). The other 51 were bare +classes with no `__construct()` at all — a holdover from dropping the Yii2 `yii\base\Event` +base class (whose `BaseObject` ancestor supplied a config-array constructor) without +replacing the call sites that still constructed events Yii2-style: +`new SomeEvent(['prop' => $val])`. PHP does not error when you pass a constructor +argument to a class with no declared `__construct()` — the array is silently discarded. +A required typed property then throws "must not be accessed before initialization" +the instant any listener reads it; a property with a default just silently keeps that +default forever. This was live in already-shipped code (e.g. every `Order.php` +`LineItemEvent` trigger from Stage 7b). + +**Fix**: every affected event class got a real constructor with PHP 8 promoted +properties, matching its exact original types/nullability/defaults, and every call +site (both the `new X([...])` array-config pattern and the `new X(); $x->prop = ...;` +empty-then-assign pattern) was updated to named-argument construction. ~100 call +sites across ~35 consumer files, plus the 51 event classes themselves. + +Found and fixed 3 more latent issues while verifying: +- `DefaultOrderStatusEvent::$orderStatus` was declared non-nullable `OrderStatus`, but + `OrderStatuses::getDefaultOrderStatusForOrder()` (whose own return type is already + `?OrderStatus`) can legitimately pass `null` when a store has no default order + status configured. Previously this was invisible (the broken constructor silently + dropped it either way); now it's a real, avoidable `TypeError` waiting to happen. + Widened to `?OrderStatus`. +- `SaleEvent`/`SaleMatchEvent`/`TransactionEvent`/`RefundTransactionEvent`/ + `ProcessPaymentEvent`/`PaymentCurrencyRateEvent` imported the legacy aliased + `craft\commerce\models\{Sale,Transaction}` instead of the already-migrated + `CraftCms\Commerce\{Promotion,Payment}\Models\{Sale,Transaction}` — a + Guiding-Principle-9 alias-timing risk, same class of issue as Stage 7c/7d, caught by + phpstan flagging a type mismatch between the constructor's declared param type and + what every real call site actually passes. Updated both files' imports. +- `src/Services/ProductTypes.php` (Stage 7e) constructed `ProductTypeEvent` via its + fully-qualified legacy alias (`new \craft\commerce\events\ProductTypeEvent()`) + instead of importing the new namespace directly — fixed to match this project's + established convention. + +**Verification**: confirmed via reflection that all 51 classes have a real, +parameter-bearing constructor (not an inherited empty one, except `DeleteStoreEvent` +which correctly inherits `StoreEvent`'s). Confirmed end-to-end through the real +dispatch pipeline: registered a Laravel `Event::listen(SaleEvent::class, ...)` +listener, called `Sales::saveSale()`, and confirmed the captured event object carried +a fully-populated, correctly-typed `Sale` instance and correct `isNew` — proving the +fix works through actual application code, not just in isolation. Also discovered +along the way that different already-migrated services use two different event-firing +mechanisms — `Sales.php` dispatches natively via Laravel's `event()` helper, while +`LineItems.php`/`ProductTypes.php` still bridge through the legacy Yii2 component's +`trigger()` (documented as a `// TODO: migrate event firing to Laravel` in each) — +both are equally valid for this fix, but it's worth knowing the two patterns coexist +when doing future event-related work. + +### Laravel Migration — Stage 7e: ProductType + +Migrated `craft\commerce\models\ProductType` and `craft\commerce\records\{ProductType,ProductTypeSite}` +to `CraftCms\Commerce\Catalog\ProductType\{Data,Models}\*`, and `craft\commerce\services\ProductTypes` +to `CraftCms\Commerce\Services\ProductTypes`. This was the piece deliberately deferred out of Stage 7d: +`ProductType` needs **two** independent field layouts (`Product`'s custom fields and `Variant`'s), but +`CraftCms\Cms\FieldLayout\Concerns\HasFieldLayout` only supports one field layout per host class. + +**The dual-field-layout design.** Legacy solved this with two independently-configured Yii2 +`FieldLayoutBehavior` instances attached under different names. Rather than build two small +standalone "provider" objects each using `HasFieldLayout` (which would have required its +closure-based `setFieldLayoutId(callable)` configuration path — confirmed via a full search of +cms-6 to have zero real-world usage anywhere, making it an untested pattern to lean on), the new +`ProductType` hand-rolls two independent getter/setter pairs +(`get/setProductFieldLayout()`, `get/setVariantFieldLayout()`), each resolving its own +`FieldLayout` and setting `$fieldLayout->provider = $this`. This mirrors the trait's own ~15 lines +of real logic closely enough to diff cleanly against the legacy behavior-based version, and avoids +introducing an unproven pattern. `ProductType` implements +`CraftCms\Cms\FieldLayout\Contracts\FieldLayoutProviderInterface` (its `getFieldLayout()` aliases +to `getProductFieldLayout()`, matching the legacy interface method) — this also resolves a +phpstan-only type mismatch from Stage 7d, where `Variant.php` sets `$fieldLayout->provider = +$productType` but legacy `ProductType` only implemented the *old* `craft\base\FieldLayoutProviderInterface`. + +**Data/Model split**, same as `LineItem` (Stage 7c) and cms-6's own `Entry\Data\EntryType` / +`Entry\Models\EntryType`: `Catalog\ProductType\Data\ProductType` (rich `Component`, validation, +computed field layouts, CP URLs) + `Catalog\ProductType\Models\ProductType` (thin Eloquent, +persistence only). `commerce_producttypes.id` is a genuine auto-increment column (unlike +`Product`/`Variant`/`Order`/`Donation`), so no `$incrementing = false` override needed here. +`ProductTypeSite` (migrated in an earlier stage as a Data-only `Component`, with a `// TODO: +migrate to app(ProductTypes::class)...` marker) got its missing thin Eloquent counterpart — +`Catalog\ProductType\Models\ProductTypeSite` — and the TODO was resolved. + +Validation moved from Yii2 `HandleValidator`/`UniqueValidator` configs to the modern pattern +already established in cms-6 (`CraftCms\Cms\Validation\Rules\HandleRule` + +`Illuminate\Validation\Rule::unique(...)->ignore(...)`, matching `CraftCms\Cms\Entry\Validation\EntryTypeRules`) +rather than porting the legacy validator classes. The imperative validators (field layout +validation, preview targets) stayed as plain methods wired through `afterValidate()`, matching the +`OrderRules`/`ProductRules` precedent. + +**Bugs found and fixed while verifying live** (none introduced by this stage — all three were +latent, only surfaced by exercising the actual save-and-persist path): + +- **`getAttributes()` passthrough threw for `ProductTypeSite`.** Unlike `ProductType` itself + (every Eloquent column has a same-named Data property), `ProductTypeSiteRecord`'s attributes + include `dateCreated`/`dateUpdated`/`uid`, none of which the `ProductTypeSite` Data class + declares. `Component::__set()` throws `UnknownPropertyException` for genuinely undeclared + properties (it does **not** silently ignore them the way Yii2's config-array constructor does) — + confirmed live, not just by re-reading `Typecast::configure()`'s source. Fixed by hydrating + `ProductTypeSite` via explicit property assignment, same fix pattern as `LineItem` in Stage 7c. +- **Site-settings records need explicit `dateCreated`/`dateUpdated`.** `ProductTypeSiteRecord` has + `$timestamps = false` (matching the established thin-model convention), so nothing set those + `NOT NULL` columns on insert — the DB rejected the row (`Field 'dateCreated' doesn't have a + default value`). Fixed by setting both explicitly before `save()`, matching how the main + `ProductType` record already did it. +- **`craft\events\ConfigEvent` (legacy), not the new `CraftCms\Cms\ProjectConfig\Events\ConfigEvent`, + is what `handleChangedProductType()`/`handleDeletedProductType()` actually receive at runtime.** + This one is a correction to this project's own tracking, not just this stage's code — see + `laravel-migration-private.md`'s craft-core-reference follow-up for the full explanation: + `Craft::$app->getProjectConfig()` (as called from still-legacy `Plugin.php`) resolves to the + legacy `craft\services\ProjectConfig` wrapper, whose `onAdd()`/`onUpdate()`/`onRemove()` + subscribe to the new project config system internally but **reconstruct a legacy `ConfigEvent`** + before invoking the registered handler. Confirmed live (`TypeError` when type-hinting the new + class) and by cross-checking already-shipped `Gateways::handleChangedGateway()`, which already + uses the old type and works correctly — it was never a bug. + +**Verification.** Live via `php craft exec:exec` (ddev). Confirmed: legacy aliases resolve for the +model, both records, and the exception class; field layout resolution returns real `FieldLayout` +objects with the correct `provider` for both product and variant layouts; `getConfig()`, +`validate()`, `getSiteSettings()`, `getShippingCategories()`/`getTaxCategories()` all work; a full +`saveProductType()` round trip through the real project-config event pipeline (not a direct method +call) correctly persisted a new product type plus **both** independent field layouts (with real, +distinct IDs) and its site settings, then `deleteProductTypeById()` cleanly removed everything with +no orphaned rows. `ProductTypesController.php`'s two `getBehavior(...)->setFieldLayout(...)` call +sites (the only writer of in-memory field layouts) updated to the new `setProductFieldLayout()`/ +`setVariantFieldLayout()` methods. + +### Laravel Migration — Stage 7d: Product & Variant + +Migrated `craft\commerce\elements\{Product,Variant}`, their element queries, +and their records to `CraftCms\Commerce\Catalog\{Elements,Queries,Models}\*`. +`ProductType` stays fully legacy for now (Stage 7e) — Product/Variant only +ever consume it through its public getters, never a hard dependency. + +`Variant` extends the abstract `CraftCms\Commerce\Purchasable\Elements\Purchasable` +base built in Stage 7a for `Donation` — that base turned out to already be a +complete superset of the legacy `craft\commerce\base\Purchasable` (confirmed +by diffing every method name between the two), including special-casing +`NestedElementInterface` in `afterSave()` for exactly the draft/revision +ownership-transfer logic `Variant` needs, even though only non-nested +`Donation` used it until now. No changes were needed to that base file. +`craft\commerce\base\Purchasable` (the legacy 1599-line abstract base) is now +a `class_alias` stub, since `Variant` was its last extender. + +**Bugs found and fixed while verifying the save cycle live** (none were +introduced by this stage — all three predate it and were latent because +nothing had exercised these paths with real data yet): + +- **Eloquent `id` silently reset to `0` after insert.** `commerce_products.id`, + `commerce_variants.id` (and, discovered by extension, `commerce_orders.id` + and `commerce_donations.id`) are foreign keys to `elements.id`, not + auto-increment columns. Eloquent defaults every model to + `$incrementing = true`, so after `INSERT` it overwrites the model's `id` + with `$pdo->lastInsertId()` — which MySQL returns as `0` for a table with no + auto-increment column. `Product::afterSave()` then propagated that `0` back + onto the element itself (`$this->id = $record->id`), corrupting the owner's + ID before nested variant saves ran. `Order`/`Donation` never hit this + because neither reads the record's `id` back after `save()` — same + underlying defect, just never triggered. Fixed by adding + `public $incrementing = false;` to `Catalog\Models\{Product,Variant}`, + `Order\Models\Order`, and `Purchasable\Models\Donation`. +- **`Variant::availableShippingCategories()` called `->pluck()` directly on a + plain `array`.** `ShippingCategories::getShippingCategoriesByProductTypeId()` + returns `array`, not a `Collection` — needed a `collect()` wrapper, matching + the (correct) tax-category equivalent five lines below it. +- **Event classes instantiated with a Yii2 config array silently do nothing.** + `new CustomizeProductSnapshotFieldsEvent(['product' => ..., 'fields' => ...])` + compiles and runs without error, but a plain PHP class with no constructor + ignores constructor arguments entirely — properties keep their defaults, and + reading an uninitialized typed property (e.g. `$event->product`) later + throws. The original Yii2 class extended `yii\base\Event`, which supplied + the config-array constructor; that behavior was lost when the class was + migrated to `src/` in Stage 3, but nothing updated the call site. Fixed the + 4 events `Variant::getSnapshot()` uses + (`Customize{Product,Variant}Snapshot{Fields,Data}Event`) with real + constructors (promoted properties, matching cms-6's own event convention + like `ProjectConfig\Events\ConfigEvent`), and updated the call sites to + named-argument construction. + ⚠️ **This same defect affects the rest of the Events layer** — of the 56 + classes moved to `src/*/Events/` in Stage 3, only the 4 fixed here now have + a real constructor. The rest (`Order\Events\LineItemEvent`, + `Catalog\Events\ProductEvent`, etc.) still silently discard their + constructor array and will throw "must not be accessed before + initialization" the moment a listener reads a required property. This is + **not** new to this stage — it predates it — but wasn't caught until a real + save cycle exercised one of these event triggers. Confirmed already present + in shipped code: `Order.php`'s `EVENT_AFTER_ADD_LINE_ITEM`/ + `EVENT_AFTER_REMOVE_LINE_ITEM`/etc. triggers all construct `LineItemEvent` + the same broken way. Needs a dedicated remediation pass across + `src/*/Events/*.php` plus every call site — tracked as a follow-up in + `laravel-migration-private.md`, not fixed here beyond this stage's own + scope. + +**Verification.** No working Codeception harness in this environment; used +live `php craft exec:exec` calls via ddev. Confirmed: legacy aliases resolve +correctly (`Product`, `Variant`, `base\Purchasable`, records, queries); +`Product::find()`/`Variant::find()` return the new query classes and execute +correctly with 15+ query-parameter combinations; a full save cycle (product + +nested variant, with the CP's `variants` dirty-attribute marker set +manually since there's no CP UI to drive it) round-tripped correctly after +the fixes above, including `defaultVariantId`/`defaultSku`/`defaultPrice` +propagation and SKU-uniqueness/min-max-qty validation; `getSnapshot()` +verified after the event-constructor fix. One save-cycle path (URI generation +against a product type with a real `uriFormat`) is blocked by a pre-existing, +confirmed-unrelated CMS-core bug — `renderObjectTemplate()` reliably throws +"Cannot redeclare class CraftCms\Cms\Section\øSections" on first render in +this environment, reproduced with a bare `Template::renderObjectTemplate()` +call with zero Commerce code involved. Same class of issue noted in Stage 7a; +not fixed here (out of scope, cms-6 core). + +### Laravel Migration — Stage 7c: LineItem + +Migrated `craft\commerce\models\LineItem` (business logic) and +`craft\commerce\records\LineItem` (persistence) to `CraftCms\Commerce\Order\LineItem\*`, +and `craft\commerce\services\LineItems` to `CraftCms\Commerce\Services\LineItems`. + +**Not a unified Eloquent class, unlike `Order`.** The first draft merged both +into one Eloquent model, reasoning that — unlike `Order`, which had to split +into `Elements\Order` + `Models\Order` because an Element can't also extend +Eloquent — `LineItem` isn't an Element, so there's no equivalent forced split. +That draft was wrong: it broke a real behavioral guarantee. Yii2's `Model` +routes bare property access (`$lineItem->price`) through a same-named +`getPrice()` method; Eloquent's `__get()` does not — it only consults real +attributes/casts/accessors, never an arbitrary `getPrice()` method. Legacy +adjusters (`Tax.php`, `Discount.php`, still fully unmigrated) and the +already-migrated `Order::recalculate()` all read computed values like +`$item->salePrice` as bare properties, and `Purchasable`/`Donation`'s +`populateLineItem()` *writes* `$lineItem->price = ...` expecting `setPrice()`'s +side effect (clearing a cached sale price) to run. On Eloquent, both of those +would have silently done the wrong thing — reading/writing the raw DB column +instead of the computed value — with no error to signal it. + +The fix, following the `Entry\Data\EntryType` / `Entry\Models\EntryType` split +in cms-6: `Order\LineItem\Data\LineItem` is the rich business object (a +`Component`, like the legacy model was a `Model` — same bare-property-routes- +through-getter guarantee), and `Order\LineItem\Models\LineItem` is a genuinely +thin Eloquent model used only for persistence. The `LineItems` service bridges +the two: reads hydrate a `Data\LineItem` field-by-field from the Eloquent row +(deliberately *not* passing `$record->getAttributes()` into the constructor — +several persisted columns, like `optionsSignature`/`salePrice`/`subtotal`/ +`total`/`promotionalAmount`, back pure computed getters with no setter on the +Data object, so passing them as constructor config throws "Setting read-only +property"); saves find-or-create the Eloquent row and copy the Data object's +current attributes onto it. + +Other notable decisions: +- FK columns (`orderId`, `purchasableId`, `taxCategoryId`, + `shippingCategoryId`, `lineItemStatusId`) resolve to an Element (`Order`), + other Elements (`Purchasable`/`Donation`), or `Component`-based Models + (`TaxCategory`, `ShippingCategory`, `LineItemStatus`) — none of them plain + single-table Eloquent models — so `getOrder()`/`setOrder()`, + `getPurchasable()`/`setPurchasable()`, `getTaxCategory()`, + `getShippingCategory()`, `getLineItemStatus()`/`setLineItemStatus()` stay + hand-written getters/setters with private memoization caches, exactly like + the legacy model; no native Eloquent relations were attempted anywhere. +- `CurrencyAttributeBehavior` (Yii2, not portable) is replaced with 11 + explicit `get*AsCurrency()` methods, mirroring `Purchasable`/`Order`. +- The thin Eloquent model needs explicit numeric/boolean/JSON casts for every + column — MySQL returns `DECIMAL` columns as strings via PDO by default, and + the Data object's properties are strictly typed (`float`/`int`), so an + uncast `weight`/`price`/etc. throws a `TypeError` the moment a fetched row + gets copied across. +- Dropped 5.x-deprecated API after confirming no call sites anywhere in + `src/` or `src-yii2/`: `LineItem::getSaleAmount()`, `refreshFromPurchasable()`, + `populateFromPurchasable()`, `getOnSale()`, and `LineItems::createLineItem()`. +- Kept legacy: `craft\commerce\errors\StoreNotFoundException` (no migrated + equivalent yet); `craft\commerce\records\TaxRate::TAXABLE_*` constants (the + migrated `Tax\Models\TaxRate` didn't carry these over). Event firing still + bridges through `Plugin::getInstance()->getLineItems()` pending the Laravel + event-system bridge, matching `Purchasables`/`Inventory`/`LineItemStatuses`. + +Found and fixed three more real bugs while wiring this in: +- **A genuine circular class-alias load.** `PurchasableInterface::afterOrderComplete()` + was updated to type-hint the new `Order`/`LineItem` directly, but the legacy + `craft\commerce\base\Purchasable` (still `Variant`'s parent, untouched and + deferred) implements that same interface and still imported the *old* + `Order`/`LineItem` names for its own copy of that method signature. Having + the interface and one of its implementers reference the same class via two + different names sent PHP into "During inheritance of X, while autoloading + Y" — a real fatal, not just a `TypeError` — the moment anything touched + `Variant`. Fixed by pointing the legacy base's imports at the new + namespaces directly (a trivial, behavior-neutral import swap, since both + names are the same class either way). +- **`Order.php`'s `_saveLineItems()`** used `LineItemRecord::find()->where(...)->all()` + (ActiveRecord) to diff previous-vs-current line items and `$previousLineItem->delete()` + to remove stale ones — neither exists on the new Eloquent model. Rewritten + to use `app(LineItems::class)->getAllLineItemsByOrderId()` (which conveniently + already returns rich `Data\LineItem` objects, removing the need for a + second `getLineItemById()` call to build the removal event's payload) and + `DB::table(Table::LINEITEMS)->where('id', ...)->delete()`. +- **The same `class_alias`-timing `TypeError` from Stage 7b's Guiding + Principle 9**, hit again — this time via the legacy `LineItems` service + wrapper's own method signatures (`resolveLineItem(): LineItem` etc.), + which resolved `LineItem`/`Order` through their old aliased names. Fixed + by having the wrapper import the new namespaces directly instead, same as + every other fix of this kind. Confirmed (but did not fix, out of scope) + that most other legacy service wrappers from earlier stages have the same + latent exposure via `use craft\commerce\elements\Order;` — it doesn't + surface in normal request flows because Craft's own bootstrap touches the + real `Order` class early via element-type registration, so the alias is + already warm by the time application code runs; it only bites isolated + scripts that touch a legacy wrapper as the first-ever reference to a given + aliased class in that PHP process. + +Verified live via `php craft exec:exec`: confirmed bare property access +(`$lineItem->price`, `$lineItem->salePrice`) correctly routes through the +computed getters on both a fresh and a refetched `Data\LineItem`; a full +new-order → resolve a line item via both the new and legacy service → +add → save → refetch → totals cycle; removing a line item and resaving to +exercise the rewritten deletion branch; and the legacy `Plugin::getInstance()->getLineItems()` +wrapper end-to-end. `Order` (`Elements\Order`) was the last consumer still on +the legacy `models\LineItem`/`records\LineItem`, exactly as flagged when +Order was migrated in Stage 7b — it's now fully on the new namespace. + +### Laravel Migration — Stage 7b: Order element + +Migrated `craft\commerce\elements\Order` (4026 lines, plus the three traits it +composed — `OrderElementTrait`, `OrderNoticesTrait`, `OrderValidatorsTrait`, +folded directly into the class rather than kept as separate files since +nothing else used them), its query, and its record to +`CraftCms\Commerce\Order\Elements\Order`, +`CraftCms\Commerce\Order\Queries\OrderQuery`, and a new Eloquent +`CraftCms\Commerce\Order\Models\Order` (replacing the ActiveRecord). Also +migrated the 4 small `errors\*` exception classes to `Order\Exceptions\` +(dropping the Yii2 `getName()` mechanism, which incidentally removes +`OrderAdjustmentNotFoundException::getName()`'s copy-paste bug — it returned +"Line Item not found"). + +`LineItem` and its record stay legacy throughout (deferred to the next +stage — Order still type-hints `craft\commerce\models\LineItem`/ +`records\LineItem` everywhere), as does the abstract `base\Gateway` class and +`Plugin::getInstance()->getLineItems()`. Every other `Plugin::getInstance()->getX()` +call was swapped for `app(CraftCms\Commerce\Services\X::class)` since all of +Order's other service dependencies are already migrated (Stage 6). +`CurrencyAttributeBehavior` (a Yii2 behavior, not portable to Laravel) is +replaced with explicit `*AsCurrency` getters, mirroring the pattern already +established on `Purchasable` in Stage 7a; the 9 imperative validators from +`OrderValidatorsTrait` became plain methods wired through `afterValidate()`/ +`prepareForValidation()` rather than forced into declarative Illuminate +rules, since they mutate notices and nested-model errors under dotted +attribute keys. + +Drafted via two parallel agents (element+validation/exceptions, and the +query), then reviewed and fixed by hand. Found and fixed several real bugs +during review and live verification: +- `Order::find()` still returned the **legacy** `OrderQuery` (a stale + import left over from drafting the element before the new `OrderQuery` + existed) — completely defeating the query migration. Fixed to return the + new one. +- `Order`'s own `init()`/`beforeValidate(): bool` overrides used Yii2 + lifecycle hooks that don't exist on the new `Element` base at all (it has + no `init()`; the pre-validation hook is `prepareForValidation(): void`, + Illuminate-style) — both `#[Override]` attributes would have been a hard + compile error. Converted `init()`'s defaulting logic into a `__construct()` + override (run after `parent::__construct($config)`), and renamed + `beforeValidate()` to `prepareForValidation()`. +- Two more instances of the Stage 7a "which `Purchasable` base is this" + bug: `lineItemsByPurchasable()` was typed against the new `Purchasable` + base only (rejecting `Donation` was fine, but would've rejected + `Product`/`Variant` once they're real args) — widened to + `PurchasableInterface`; `afterDelete()`'s stock-cache-refresh check + `instanceof Purchasable` only matched the *new* base, so it silently + stopped updating stock caches for anything still on the legacy base + (i.e. every real Product/Variant order) — widened to check both. +- `Store::getCurrency()` returns a `Money\Currency` object (this changed + when `Store` was migrated in Stage 5k), but `Order::$currency` and + `$_paymentCurrency` are plain `?string` ISO-code properties — the ported + `init()`/`getPaymentCurrency()` logic assigned the object directly, + which would `TypeError` on the very first order without an explicit + currency. Both now call `->getCurrency()?->getCode()`. +- Several already-migrated `Order\Events\*` classes (`DefaultOrderStatusEvent`, + `OrderNoticeEvent`, `DefaultLineItemStatusEvent`, `OrderStatusEmailsEvent`, + `CartEvent`, `OrderStatusEvent`, `ModifyCartInfoEvent`) were migrated back + in Stage 3, before `OrderStatus`/`OrderNotice`/`LineItemStatus`/`Order` + itself existed in the new namespace, and still type-hinted the legacy + classes. Since `class_alias` doesn't reliably satisfy `instanceof`/ + parameter-type checks for a name that's never been referenced yet in a + given PHP process (confirmed independent of these changes — reproduces + identically assigning a freshly-migrated `TaxRate` to a legacy-typed + property elsewhere in the codebase), a real order-completion call threw + a `TypeError` the first time `getDefaultOrderStatusForOrder()` populated + one of these events. Updated all of them to the new namespaces. +- Same latent bug, hit directly rather than via an Event: `src-yii2/adjusters/Tax.php` + (fully legacy, untouched by this migration otherwise) type-hinted the + legacy, aliased `models\TaxRate` while the already-migrated `TaxRates` + service hands it a `CraftCms\Commerce\Tax\Models\TaxRate` instance + directly — same alias-timing TypeError, blocking every order recalculation + that hits a tax rate. Repointed the import at the real class. +- `Customers::orderCompleteHandler()`/`savePrimaryAddressesFromOrder()` + (both already-migrated, Stage 6i) called `OrderRecord::updateAll(...)` — + a Yii2 ActiveRecord static method with no Eloquent equivalent — against + what was about to become the new Eloquent `Order` model. Converted both + to `OrderRecord::query()->where(...)->update([...])`. + +Verified live via `php craft exec:exec` against the dev database: fetch +through the new `OrderQuery`, notice add/clear, a full new-order → add a +real line item (a Stage 7a `Donation`) → save → refetch → totals → +`markAsComplete()` → paid-status cycle, plus confirming the legacy alias and +`Plugin::getInstance()->getOrderStatuses()` paths still resolve correctly. +One piece of `markAsComplete()` — reference-number generation via +`renderObjectTemplate()` — could not be verified in this environment: it +throws a `Cannot redeclare class CraftCms\Cms\Section\øSections` fatal +error, but this reproduces identically calling `renderObjectTemplate()` +against the already-shipped, unrelated `Donation` element, confirming it's +a pre-existing CMS-level bug independent of this work, not something this +stage introduced. + +Also noticed but out of scope for this stage: `TaxRatesController.php` has +the same stale `models\TaxRate` import as the `Tax` adjuster did — not fixed +since it isn't on Order's execution path and wasn't blocking verification. + +### Laravel Migration — Stage 7a: Purchasable & Donation elements + +Migrated `craft\commerce\elements\Donation`, its record, and its query to +`CraftCms\Commerce\Purchasable\Elements\Donation`, +`CraftCms\Commerce\Purchasable\Models\Donation` (Eloquent), and +`CraftCms\Commerce\Purchasable\Queries\DonationQuery`. The abstract +`craft\commerce\base\Purchasable` element also got a new-namespace sibling, +`CraftCms\Commerce\Purchasable\Elements\Purchasable`, along with +`Purchasable\Queries\PurchasableQuery` and `Purchasable\Validation\{PurchasableRules,DonationRules}`. +`Variant` still extends the legacy `craft\commerce\base\Purchasable` (deferred +with `ProductType`/`Product`, see Stage 5l), so the legacy base and its query +are left as full implementations rather than `class_alias` stubs — Donation is +the only current subclass of the new base. + +Found and fixed several latent bugs surfaced by having a real, non-abstract +purchasable in the new namespace for the first time: +- `ElementQuery::applySelectParams()` (cms-6) unwraps any `Expression` column + back into a plain `"column [as alias]"` string and re-wraps it as an + identifier — fine for simple columns, but it mangles anything more complex + (e.g. a `CASE WHEN...END` expression) into invalid SQL. `PurchasableQuery` + avoided this entirely by moving the `salePrice`/`catalogPricingRuleId` + computations into joined subqueries (plain `DB::table()` builders, never + touched by that method) instead of raw expressions in `$this->query`'s own + select list. +- Relatedly, `salePrice` was being selected as an output column at all in both + the catalog-pricing and plain-pricing branches, which threw "Setting + read-only property" on hydration — `salePrice` is a getter-only virtual + attribute (`Purchasable::getSalePrice()`) with no setter. It's no longer + selected for hydration in either branch, only referenced in `whereParam()` + filters and joined-subquery `WHERE`/`GROUP BY` clauses, which don't write it + back to the element. +- `Donation::find()` now returns the new `PurchasableQuery`, not the legacy + one, so the `instanceof \craft\commerce\elements\db\PurchasableQuery` checks + in `Purchasables::getPurchasableById()` and `OrdersController` silently + stopped matching for donations (breaking the `forCustomer()` catalog-pricing + scope). Both now check for either query class. +- `Services\Inventory` and both its legacy wrappers (`src-yii2/services/{Inventory,Purchasables}.php`) + type-hinted their `$purchasable` parameters against the legacy + `craft\commerce\base\Purchasable` class, which threw a `TypeError` the + moment a `Donation` (or any other new-namespace purchasable) was passed in. + Widened to `Purchasable|NewPurchasable` (or, where the method already used + it consistently for its siblings, `PurchasableInterface`). Same fix applied + to the `getPurchasable()` return types on `InventoryItem`, `InventoryLevel`, + `InventoryFulfillmentLevel`, and `InventoryTransaction`. +- `PurchasableQuery::forCustomer()`'s customer-id lookup and + `Purchasable::afterSave()`'s primary-site check still used + `\Craft::$app->getUser()->getIdentity()`/`\Craft::$app->getSites()` — old + Craft-core calls, not permitted in `src/`. Replaced with `currentUser()?->getCraftUserId()` + and `Sites::getPrimarySite()`. + +Verified with a real save → refetch → availability/stock checks → delete +cycle against the dev database via `php craft exec:exec` (Codeception is +currently unable to boot at all in this environment — `craft\test\TestSetup` +no longer exists — a pre-existing, unrelated infrastructure gap, not +something this stage introduced or fixed). + +### Laravel Migration — Stage 6i: Customer & misc services (Stage 6 complete) + +Migrated the last nine services — `Customers`, `Subscriptions`, `Plans`, +`Emails`, `Pdfs`, `Formulas`, `Webhooks`, `Stores`, and `StoreSettings` — +to `src/Services/`, completing Stage 6. Removed the `Store` service +entirely (deprecated since 5.0.0 in favor of `Stores`, zero call sites +beyond its own registration). + +`Subscriptions` and `Plans` turned out tractable despite being tied to +the unmigrated `Subscription` element and `Plan` base class: both stay +as legacy type-hints throughout (the same pattern already established for +`Order` in `Orders`/`Payments`), and `Subscriptions`' own field layout +handlers are single-layout (not the dual-`FieldLayoutBehavior` blocker +that deferred `ProductTypes`), so they only needed the same +`craft\models\FieldLayout` passthrough already used in `Orders`. + +Found and fixed three more latent bugs while migrating: +- `PlanEvent`, `CreateSubscriptionEvent`, and `SubscriptionSwitchPlansEvent` + (Stage 3) all imported a non-existent `craft\commerce\models\Plan` — + there is no `models\Plan`, only `base\Plan`. Fixed all three imports. +- `WebhookEvent::$response` (Stage 3) was typed `Illuminate\Http\Response`, + but `WebhooksController` and the gateway webhook pipeline it runs on are + still entirely Yii2, so the only real value that will ever reach it is a + `yii\web\Response`. Widened to a union of both until that pipeline + migrates. +- `RefundTransactionEvent`-style: `Carts::purgeIncompleteCarts()`'s + Yii2 `Query::count()` can return a numeric string depending on the DB + driver; the original had no return type declared, so the new strict + `int` return type surfaced this only once added. + +`Carts::init()` (Yii2 lifecycle) becomes a constructor. `Carts` is the +checkout-critical path in this stage, so beyond `tinker` checks it was +verified with a real HTTP request through `commerce/cart/get-cart`, +confirming the full new constructor and cart-lookup logic runs correctly +before hitting the same pre-existing `OrderQuery` bug already confirmed +independent of this work in Stage 6g/6h. + +`CartPurgeEvent::$inactiveCartsQuery` is typed `craft\db\Query` +specifically so third-party listeners can extend the purge query, so +`purgeIncompleteCarts()` keeps building it the Yii2 way rather than +switching to the Laravel query builder, to honor that contract. + +Resolved now-unblocked TODOs across `OrderStatus`, `Sale`, `StoreTrait`, +`CatalogPricing`/`CatalogPricingRule`, `ShippingRule`, `Email`, and +`DeactivateInventoryLocation` that were waiting on `Stores`, `Emails`, +`Pdfs`, `Formulas`, `Purchasables`, or `CatalogPricingRules`. + +### Laravel Migration — Stage 6h: Orders & Carts services + +Migrated `Orders`, `Carts`, `OrderNotices`, `OrderHistories`, +`OrderAdjustments`, `OrderStatuses`, and `LineItemStatuses` to +`src/Services/`. Deferred `LineItems`: its entire purpose is CRUD on the +still-unmigrated `craft\commerce\models\LineItem` (deferred in Stage 5n), +same blocker class as `ProductTypes`/`Transfers`. + +`Carts::init()` (a Yii2 component lifecycle method — session-based +pre-Commerce-4.0 cart migration, cookie-name setup) becomes a +constructor, since the new base is a plain `#[Singleton]` class with no +`init()` hook; both run exactly once per resolution, so the timing is +unchanged. Removed the `#[\Deprecated]`-since-4.0 `Carts::getCartName()` +(superseded by `$cartCookie['name']`, zero call sites). +`CartPurgeEvent::$inactiveCartsQuery` is typed `craft\db\Query` (a Stage 3 +event, already migrated) specifically so third-party listeners can extend +the purge query — `Carts::purgeIncompleteCarts()` keeps building that +query the Yii2 way rather than switching to the Laravel query builder, to +honor that contract. + +Found and fixed two real bugs while migrating: +- `OrderStatuses::getOrderCountByStatus()`'s join used + `craft\db\Table::ELEMENTS`, which is the Yii2-prefixed placeholder + string `{{%elements}}` — meaningless to Laravel's query builder, which + has no Yii2 prefix-substitution layer. Fixed to + `CraftCms\Cms\Database\Table::ELEMENTS` (the plain `'elements'` + equivalent). Found and fixed the identical latent bug in + `CatalogPricing::generateCatalogPrices()` (Stage 6b) while auditing for + other occurrences of the same mistake. +- `Carts::purgeIncompleteCarts()` declared a strict `int` return type on + a method that returns `Query::count()`, which Yii2 can return as a + numeric string depending on the DB driver — the original had no return + type declared, so this only surfaced once the migration added one. + +Verified live via `php craft tinker` against the real dev DB, plus a +real HTTP request through `commerce/cart/get-cart` for `Carts` given its +checkout-critical scope — it ran the full new constructor and cart +lookup path and reached the same pre-existing `OrderQuery`/`VariantQuery`/ +`ProductQuery` element-query bugs already confirmed independent of this +work (Stage 7 territory: these queries reference `commerce_orders`/ +`commerce_products`/`commerce_variants` columns in `ORDER BY`/join +clauses without actually joining those tables — reproduces identically +on unmodified `6.x`). + +### Laravel Migration — Stage 6g: Catalog services + +Migrated `Products`, `Variants`, and `Purchasables` to `src/Services/`. +`Products::getProductById()`'s structure-ID lookup and +`Purchasables::updateStoreStockCache()`'s stock update swap their Yii2 +`craft\db\Query`/`createCommand()` for `Illuminate\Support\Facades\DB`, +matching the established pattern. `Products::afterSaveSiteHandler()`'s +`craft\helpers\Queue::push(new craft\queue\jobs\PropagateElements(...))` +becomes `dispatch(new CraftCms\Cms\Element\Jobs\PropagateElements(...))` +— both deprecated in favor of their Laravel-native equivalents — matching +the equivalent call in Craft core's own `Sites::handleChangedSite()`, +including passing `isNewSite: true` for this same "new site with an old +primary site" scenario, which the old Yii2 job had no equivalent flag for. + +Deferred `ProductTypes` (1067 lines) to Stage 7: `saveProductType()` +directly persists the same dual `FieldLayoutBehavior` (Product + Variant) +data that already deferred the `ProductType` model itself in Stage 5l, +plus depends on the unmigrated `craft\models\FieldLayout` and +`craft\services\Structures`. Same blocker class as `Transfers` (Stage 6e). + +### Laravel Migration — Stage 6f: Payment services + +Migrated `Transactions`, `PaymentSources`, `Gateways`, and `Payments` to +`src/Services/`, in that dependency order (Transactions has no +same-stage dependents; Payments depends on all three of the others). +Legacy `src-yii2/services/*.php` stubs now delegate to +`app(CraftCms\Commerce\Services\X::class)` per-method, per the project's +established stub pattern. + +`Transactions` and `PaymentSources` swap their Yii2 `craft\db\Query` +query builders for `Illuminate\Support\Facades\DB` (`DB::table(...)`), +matching the pattern already established in `Coupons`/`ShippingMethods`. +`Gateways` keeps `craft\helpers\Db` (aliased `CraftDb` to avoid a +case-insensitive collision with the `DB` facade import) for +`idByUid()`/`uidsByIds()`/`prepareDateForDb()`, since those have no new +namespace equivalent yet, and keeps `Craft::$app->getProjectConfig()` +directly for the same reason — this is the first migrated service to +touch project config, and no bridge/facade exists for it yet. + +Removed `Gateways::getGatewayOverrides()` (deprecated since 3.3, unused +by anything else, and dependent only on the legacy +`commerce-gateways.php` config-file override mechanism) along with its +`$_overrides` cache and the override-merging branch in `createGateway()`. +Also removed the `#[\Deprecated]`-since-4.0 `Transactions::deleteTransaction()` +(superseded by `deleteTransactionById()`, zero call sites). + +Found and fixed a latent bug while wiring `Payments`: the already-migrated +`TransactionEvent`, `PaymentSourceEvent`, `ProcessPaymentEvent`, and +`RefundTransactionEvent` classes (Stage 3) are plain property bags with +no constructor. Constructing them Yii2-style +(`new TransactionEvent(['transaction' => $x])`) silently discards the +array argument instead of throwing — PHP allows extra constructor +arguments when no `__construct` is defined — so every event fired this +way carried an uninitialized `transaction` property. Fixed the four +call sites across `Transactions`, `PaymentSources`, and `Payments` to +construct-then-assign instead. Also widened +`RefundTransactionEvent::$amount` to `?float` (was non-nullable `float`), +since `Payments::refundTransaction()`'s `null` (= full refund) is a +normal, common input on the only real call path this event has ever had. + +Resolved the `Gateways`-dependent TODOs left in +`Payment\Models\Transaction::getGateway()` and +`Payment\Models\PaymentSource::getGateway()` now that the service they +were waiting on exists in `src/`. + +### Laravel Migration — Stage 8: Plugin.php + ServiceProvider + +`craft\commerce\Plugin` now extends `CraftCms\Commerce\Plugin` (new, +`src/Plugin.php`, extends `CraftCms\Cms\Plugin\Plugin`) instead of the +legacy `craft\base\Plugin` (a `yii\base\Module`). These two plugin +systems are not bridged, so this drops Commerce out of the Yii2 +Module/component-locator system entirely — the only real gap was the +component locator backing the 46 `Plugin::getInstance()->getFoo()` +service getters (838 call sites across `src-yii2/`), ported as +`src/Plugin/Concerns/HasServices.php`, a lazy-instantiate-and-cache +trait mirroring the old getter API exactly. `src-yii2/plugin/Services.php` +is deleted; `Variables.php` is unchanged (never depended on Module-ness). + +`src-yii2/plugin/Routes.php` (the `Event::on(UrlManager::class, +EVENT_REGISTER_CP_URL_RULES, ...)` registrations) turned out to have a +subtler dependency: the URL rules themselves still register and match +fine, but `yii\base\Module::createController()` resolves a matched +route like `commerce/orders/order-index` by looking up +`Craft::$app->getModule('commerce')` for the controller namespace — +which only ever worked because `craft\commerce\Plugin` was itself a +`Module` (via the old `craft\base\Plugin` ancestry). Once it wasn't, +every legacy-dispatched Commerce route 404ed despite matching +correctly. Fixed with `src-yii2/plugin/LegacyRoutingModule.php`, a +minimal `yii\base\Module` (routing-only, no settings/services) +registered via `Craft::$app->setModule('commerce', ...)` in `boot()`. + +Everything else in the old `init()` (event registrations, projectConfig +listeners, GQL, widgets, permissions, etc.) didn't depend on Module-ness +either — it's all static `Event::on()` calls and `$this->getFoo()` getter +calls — so `init()` just became `boot()` with its body otherwise +untouched. + +One real behavioral fix: the new base's `getCpNavItem()` returns a +`NavItem` object instead of an array, and `NavItem::$subnav` defaults to +`false` (not `[]`), so the existing `$ret['subnav']['orders'] = [...]` +pattern needed the object converted to an array (`NavItem::toArray()`) +and `subnav` normalized to `[]` first. + +`is()`/`$edition`/`editions()` needed no porting — already provided by +the new base's `HasEditions` concern with identical semantics. Only +`Plugin::getInstance()->id` (one call site, `Products.php`) needed +fixing, to `->handle`, since the new base has no `id` property. + +### Fix: migrations fatal under Craft 6's Laravel migrate runner + +`ddev artisan craft:migrate/all` fataled with "Cannot redeclare class" +on every Commerce migration. Laravel's `Migrator::getMigrationClass()` +derives a class name from the filename assuming the +`YYYY_MM_DD_HHMMSS_description.php` convention; Commerce's Yii2-style +`mYYMMDD_HHMMSS_description.php` files don't fit, so the derivation +produces a bogus name, the "already loaded" guard misses, and the +Migrator does a second bare `require` on a file already `require_once`'d. + +Renamed and rewrote the 6 migrations that hadn't been applied anywhere +yet as genuine Laravel migrations (`return new class extends +CraftCms\Cms\Database\Migration`, using `Schema`/`DB` facades) — a +Yii2 `Migration` object can never satisfy Laravel's +`MigrationStarted`/`MigrationEnded` events, which hard-type-hint +`Illuminate\Database\Migrations\Migration`. Deleted the other 134: +`Install.php` already represents the full current schema and they're +already applied everywhere that matters, so per the plugin migration +docs' own guidance ("bring Install up to date, then cull the rest") +they no longer need to exist as files. + +Also added `getConnection()`/`withinTransaction` compatibility members +to the yii2-adapter's `craft\db\Migration` (cms-6 repo) — needed +regardless, since `Install.php` (which stays Yii2-style; it's looked up +by hardcoded filename rather than going through the naming-derivation +path) runs through the same Migrator. + +### Dependencies + +- Updated `dompdf/dompdf` to `^3.1.6` (from `^2.0.2`). + +### Bug fix: legacy service stubs must type-hint the new namespace, not the `class_alias` name + +Discovered while verifying Stage 6e: PHP's runtime type enforcement does +**not** treat a `class_alias()`-derived name as interchangeable with its +target for parameter/return/property type declarations, even though +`get_class()`/`instanceof`/`new` all work correctly through the alias. A +legacy stub method declared as `function getFoo(): ?Foo` (where `Foo` is +`use craft\commerce\models\Foo;`, an alias) throws a `TypeError` when it +returns an instance produced by the new service (which returns +`CraftCms\Commerce\X\Models\Foo` directly) — confirmed with a minimal +reproduction, and reproduces identically for parameter types. + +Fixed by importing the new FQCN directly (under the old short name) in +every affected stub file, so the declared type IS the type actually +returned/accepted: +- `TaxRates`, `Taxes` (`getEngine(): TaxEngineInterface` — needed two + imports, since the class's own `implements TaxEngineInterface` must stay + on the legacy interface while `getEngine()`'s return must point at the + new one) +- `Inventory`, `InventoryLocations` +- `Coupons`, `CatalogPricingRules`, `Discounts` (`Coupon`/`Discount` only — + `LineItem`/`PurchasableInterface` correctly stay on the legacy classes, + which aren't migrated), `Sales` + +Stage 6a/6c stubs (`ShippingMethods`, `ShippingRules`, `TaxCategories`, +`TaxZones`, etc.) already used the new-FQCN pattern and needed no changes. + +**Not fixed, flagged for follow-up** (found while verifying, out of scope +for this fix since they're unrelated to the stub pattern): +- `CraftCms\Commerce\Promotion\Events\DiscountEvent::$discount` is still + typed `craft\commerce\models\Discount` (a typed *property*, same root + cause) — throws when `Discounts::saveDiscount()` assigns it. Pre-existing + since Stage 3/6b. +- `CraftCms\Commerce\Inventory\Models\InventoryManualMovement::toLocationAfterQuantity()` + (and `fromLocationAfterQuantity()`) declare `: int` but + `DB::table(...)->value(...)` can return a numeric string from MySQL, + which fails under `strict_types`. Pre-existing since Stage 5c. +- Given parameter types are affected by the same bug, there's likely a + broader population of these across `src-yii2/`/`src/` wherever a + legacy-aliased type hint receives a new-namespace object — this fix only + covers what's proven broken (service stub returns) and the resulting + investigation; a full audit is separate follow-up work. +- `craft\commerce\collections\UpdateInventoryLevelCollection::make()` had + a signature incompatible with the current `Illuminate\Support\Collection::make($items = [], ...$args)` + (missing the variadic `...$args`), causing a compile-time fatal on any + use — fixed alongside this, since it blocked verifying `Inventory::executeUpdateInventoryLevels()`. + +### Laravel Migration — Stage 6e: Inventory services + +`Inventory` and `InventoryLocations` migrated from `craft\commerce\services` to +`CraftCms\Commerce\Services`. Legacy classes become thin Yii2 Component +wrappers delegating via `app()`. + +- `craft\commerce\services\Inventory` → `CraftCms\Commerce\Services\Inventory` +- `craft\commerce\services\InventoryLocations` → `CraftCms\Commerce\Services\InventoryLocations` + +`Transfers` (the service) is **deferred** — it's tied to the `Transfer` +element and Craft's legacy Field Layout/project-config system +(`ConfigEvent`, `craft\models\FieldLayout`, `TransferManagementField`), +none of which are migrated yet. Same blocker class as `ProductType` in +Stage 5. + +Cross-cutting swaps applied: +- All raw SQL (`craft\db\Query`, `[[col]]` quoting, `yii\db\Expression`) + converted to Laravel's query builder, including a subquery join + (`leftJoinSub`) and raw `CASE WHEN` pivots (`selectRaw` with bindings) + in `getInventoryLevelQuery()`. +- `Craft::$app->getDb()->beginTransaction()/commit()/rollBack()` → `DB::beginTransaction()/commit()/rollBack()` +- `Craft::$app->getUser()->getIdentity()?->id` → `request()->craftUser()?->id` +- `Db::prepareDateForDb(new \DateTime())` → `now()->toDateTimeString()` + +`getInventoryLevelQuery()`'s `$limit`/`$offset` params must only call +`->limit()`/`->offset()` when non-null — Laravel's query builder (unlike +Yii2's `Query`) emits a literal `OFFSET 0` for a null offset, which MySQL +rejects without an accompanying `LIMIT`. + +The two `Inventory` events (`EVENT_AFTER_EXECUTE_UPDATE_INVENTORY_LEVEL`, +`EVENT_AFTER_EXECUTE_INVENTORY_MOVEMENT`) still fire through the legacy +`Plugin::getInstance()->getInventory()` component so existing +`Event::on(Inventory::class, ...)` listeners keep working (TODO: migrate +event firing to Laravel once the event system is bridged). The two +`InventoryLocations` element-authorization event handlers +(`authorizeInventoryLocationAddressView`/`Edit`) are still registered +against the legacy component instance in `Plugin.php` for the same reason. + +Also resolved TODOs in `InventoryItemTrait`, `InventoryLocationTrait`, +`InventoryLevel`, `InventoryTransaction`, `InventoryFulfillmentLevel`, +`InventoryLocation`, `DeactivateInventoryLocation`, `TransferDetail`, and +`Store` — they now call the new services directly instead of going +through `Plugin::getInstance()`. + +### Laravel Migration — Stage 6d: Tax services + +All tax services migrated from `craft\commerce\services` to +`CraftCms\Commerce\Services` under the Craft 6 service pattern. + +- `craft\commerce\services\TaxRates` → `CraftCms\Commerce\Services\TaxRates` +- `craft\commerce\services\Taxes` → `CraftCms\Commerce\Services\Taxes` +- `craft\commerce\services\Vat` → `CraftCms\Commerce\Services\Vat` + +Legacy `Plugin::getInstance()->getXxx()` access keeps working — each old service +class is now a thin Yii2 Component wrapper delegating every method via `app()`. + +Two leaf dependencies of `Taxes` were migrated alongside it as bonus work: +- `craft\commerce\engines\Tax` → `CraftCms\Commerce\Tax\Engines\Tax` (legacy class is now a `class_alias` stub) +- `craft\commerce\taxidvalidators\EuVatIdValidator` → `CraftCms\Commerce\Tax\Models\EuVatIdValidator` (legacy class is now a `class_alias` stub); swapped `Craft::createGuzzleClient()` for the `Http` facade and `Craft::error()` for `Log::error()` +- `CraftCms\Commerce\Tax\Events\TaxEngineEvent::$engine` now type-hints the new `CraftCms\Commerce\Tax\Contracts\TaxEngineInterface` instead of the legacy one + +The two `Taxes` events (`EVENT_REGISTER_TAX_ID_VALIDATORS`, `EVENT_REGISTER_TAX_ENGINE`) +still fire through the legacy `Plugin::getInstance()->getTaxes()` component so +existing `Event::on(Taxes::class, ...)` listeners keep working (TODO: migrate +event firing to Laravel once the event system is bridged). + +Also resolved TODOs in `CraftCms\Commerce\Tax\Models\TaxRate`, `TaxCategory`, +and `TaxAddressZone` — they now call the new service classes directly instead +of going through `Plugin::getInstance()`. + +Removed `craft\commerce\services\Vat::getVatValidator()`, deprecated since +5.3.0 in favor of `Taxes::getEnabledTaxIdValidators()`. + +### Laravel Migration — Stage 6c: Shipping services + +All shipping services migrated from `craft\commerce\services` to +`CraftCms\Commerce\Services` under the Craft 6 service pattern. + +- `craft\commerce\services\ShippingMethods` → `CraftCms\Commerce\Services\ShippingMethods` +- `craft\commerce\services\ShippingRules` → `CraftCms\Commerce\Services\ShippingRules` +- `craft\commerce\services\ShippingRuleCategories` → `CraftCms\Commerce\Services\ShippingRuleCategories` + +Legacy `Plugin::getInstance()->getXxx()` access keeps working — each old service +class is now a thin Yii2 Component wrapper delegating every method via `app()`. + +Also resolved TODOs in `CraftCms\Commerce\Shipping\Models\ShippingMethod`, +`ShippingRule`, and `ShippingRuleCategory` — they now call the new service +classes directly instead of going through `Plugin::getInstance()`. + +### Laravel Migration — Stage 6b: Promotions services + +All promotions services migrated from `craft\commerce\services` to +`CraftCms\Commerce\Services` under the Craft 6 service pattern: plain +PHP classes marked with `#[\Illuminate\Container\Attributes\Singleton]`, +accessed via `app(\CraftCms\Commerce\Services\Foo::class)`. + +- `craft\commerce\services\Coupons` → `CraftCms\Commerce\Services\Coupons` +- `craft\commerce\services\CatalogPricingRules` → `CraftCms\Commerce\Services\CatalogPricingRules` +- `craft\commerce\services\CatalogPricing` → `CraftCms\Commerce\Services\CatalogPricing` +- `craft\commerce\services\Discounts` → `CraftCms\Commerce\Services\Discounts` +- `craft\commerce\services\Sales` → `CraftCms\Commerce\Services\Sales` + +Legacy `Plugin::getInstance()->getXxx()` access keeps working — each +old service class is now a thin Yii2 Component that delegates every +method to the new singleton via `app()`. + +Also updated `CraftCms\Commerce\Promotion\Models\Discount` to import +`CraftCms\Commerce\Services\Coupons` instead of `craft\commerce\services\Coupons`. + +Cross-cutting swaps applied throughout: +- `craft\db\Query` → `DB::table()` with fluent builder +- `yii\db\Expression` → `DB::raw()` +- `$this->trigger(self::EVENT_X, new Event([...]))` → `event(new EventClass())` +- `Craft::$app->getDb()->beginTransaction()` → `DB::beginTransaction/commit/rollBack` +- `Craft::$app->getCache()->get/set` → `Cache::get/forever` +- `Craft::info(...)` → `Log::info(...)` +- `Craft::t('commerce', ...)` → `t(..., category: 'commerce')` +- `Craft::$app->getDb()->getIsPgsql()` → `DB::connection()->getDriverName() === 'pgsql'` +- `Craft::$app->getFormatter()->asDecimal()` → `number_format()` +- `StringHelper::randomStringWithChars()` → inlined private `randomStringWithChars()` in `Coupons` +- `QueueHelper::push()` → `dispatch()` (queue jobs — TODO pending) +- `craft\db\Query` returning builder → `\Illuminate\Database\Query\Builder` + +### Laravel Migration — Stage 6a: Store config services + +All store-config services migrated from `craft\commerce\services` to +`CraftCms\Commerce\Services` under the Craft 6 service pattern: plain +PHP classes marked with `#[\Illuminate\Container\Attributes\Singleton]`, +accessed via `app(\CraftCms\Commerce\Services\Foo::class)`. + +- `craft\commerce\services\Currencies` → `CraftCms\Commerce\Services\Currencies` +- `craft\commerce\services\PaymentCurrencies` → `CraftCms\Commerce\Services\PaymentCurrencies` +- `craft\commerce\services\TaxCategories` → `CraftCms\Commerce\Services\TaxCategories` +- `craft\commerce\services\ShippingCategories` → `CraftCms\Commerce\Services\ShippingCategories` +- `craft\commerce\services\TaxZones` → `CraftCms\Commerce\Services\TaxZones` +- `craft\commerce\services\ShippingZones` → `CraftCms\Commerce\Services\ShippingZones` + +Legacy `Plugin::getInstance()->getXxx()` access keeps working — each +old service class is now a thin Yii2 Component that delegates every +method to the new singleton via `app()`. Once all callers move to +`app()` the legacy wrappers can be deleted. + +Cross-cutting swaps applied throughout: +- Yii2 `Query` builder → Laravel `DB::table()` +- `Craft::createObject(['class' => X, 'attributes' => $row])` → `new X((array) $row)` +- `ArrayHelper::firstWhere/firstValue/map/getColumn` → `collect()` equivalents +- `Craft::$app->getDb()->createCommand()->delete()/insert()/update()` → `DB::table()->...` +- `Craft::$app->getQueue()->push(new ResaveElements([...]))` → `dispatch(new \CraftCms\Cms\Element\Jobs\ResaveElements(elementType: ..., criteria: ...))` +- `$db->getSchema()->getTableSchema(X)->getColumn(Y)` → `Schema::hasColumn(X, Y)` +- `yii\base\Exception` / `yii\base\InvalidConfigException` → `\RuntimeException` + +### Removed (deprecated in 5.x) + +- `Settings::VIEW_URI_CUSTOMERS`, `VIEW_URI_PROMOTIONS`, `VIEW_URI_SHIPPING`, `VIEW_URI_TAX` constants — deprecated in 5.0.0. +- `Store::setCountries()`, `getCountries()`, `getCountriesList()`, `getAdministrativeAreasListByCountryCode()`, `getMarketAddressCondition()` — deprecated in 5.0.0; use the equivalents on `Store::getSettings()` (i.e. `StoreSettings`). +- `Discount::setExcludeOnSale()` / `getExcludeOnSale()` and the `excludeOnSale` shim — deprecated in 5.0.0; use `Discount::$excludeOnPromotion`. +- `PaymentCurrencies::convertCurrency()` — deprecated in 5.0.0; use `convert()` or `convertAmount()`. (Kept on the legacy `craft\commerce\services\PaymentCurrencies` wrapper only, until the two remaining `src-yii2/` callers — `Order` element, `OrdersController` — migrate.) + +### Laravel Migration — Stage 5m: Discount + +Migrated `craft\commerce\models\Discount` → `CraftCms\Commerce\Promotion\Models\Discount`. + +Key changes: +- `craft\base\Model` (Yii2) → `CraftCms\Cms\Component\Component` (Laravel) +- Yii2 `new Query()->select()->from()->leftJoin()->where()->column()` → `DB::table()->leftJoin()->where()->pluck()->all()` for the purchasable/category relations loaders +- `Craft::$app->getConditions()->createCondition()` → `Conditions::createCondition()` +- `Craft::$app->getFormatter()->asPercent()` → `I18N::getFormatter()->asPercent()` +- `craft\helpers\Json::decodeIfJson()` → `CraftCms\Cms\Support\Json::decodeIfJson()` +- Yii2 `defineRules()` → Laravel `getRules()`; closure validators rewritten with `$fail()` pattern; `Rule::in()` for `categoryRelationshipType` and `appliedTo` +- `CouponsValidator` retained at the legacy path (covered by closure rule later) +- `craft\elements\conditions\ElementConditionInterface` → `CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface` +- `craft\commerce\elements\conditions\*` (DiscountOrderCondition, DiscountCustomerCondition, DiscountAddressCondition) retained as old namespace references +- `Order` element and `DiscountRecord` retained as old namespace references + +### Laravel Migration — Stage 5k: Store + +Migrated `craft\commerce\models\Store` → `CraftCms\Commerce\Store\Models\Store`. + +Key changes: +- `craft\base\Model` (Yii2) → `CraftCms\Cms\Component\Component` (Laravel) +- `craft\helpers\App::parseEnv()` → `CraftCms\Cms\Support\Env::parse()` +- `craft\helpers\App::parseBooleanEnv()` → `CraftCms\Cms\Support\Env::parseBoolean()` +- `craft\helpers\UrlHelper::cpUrl()` → `CraftCms\Cms\Support\Url::cpUrl()` +- `craft\models\Site` → `CraftCms\Cms\Site\Data\Site` +- `UniqueValidator` → `Illuminate\Validation\Rule::unique()` scoped by id +- Yii2 closure validator for "currency cannot change after orders exist" → Laravel closure rule with `$fail()` +- `Craft::$app->getDeprecator()->log()` → `CraftCms\Cms\Support\Facades\Deprecator::log()` +- `Craft::t('commerce', ...)` → global `t(..., category: 'commerce')` +- Yii2 `attributes()` override (added `name`/`settings`) → `fields()` override under the new serialization layer +- Dropped `EnvAttributeParserBehavior` — the existing `getXxx(bool $parse)` pattern already handles env parsing on every accessor +- `ZoneAddressCondition`, `Order`, and `\craft\commerce\records\Store` retained as old namespace references + +### Laravel Migration — Stage 5j: Settings stub + DummyPlan + +`craft\commerce\models\Settings` already lived at `CraftCms\Commerce\Settings` from Stage 5a, but the legacy `src-yii2/models/Settings.php` still held the full Yii2 implementation. Now: +- `src-yii2/models/Settings.php` replaced with a `class_alias` stub. +- `src/Settings.php` gained the `setAttributes()` override that strips deprecated Commerce-4 settings keys, preserving backward compatibility for project configs that still reference `orderPdfFilenameFormat`, `autoSetNewCartAddresses`, etc. + +Migrated `craft\commerce\models\subscriptions\DummyPlan` → `CraftCms\Commerce\Subscription\Models\DummyPlan`. Still extends the unmigrated `craft\commerce\base\Plan`; switched to the new `CraftCms\Commerce\Subscription\Contracts\PlanInterface` argument type. + +### Bug Fixes + +- Fixed `Table` constants in `src/Database/Table.php` using Yii2 `{{%tablename}}` prefix syntax instead of plain table names, causing "Base table or view not found" MySQL errors when Laravel's query builder passed the literal string to the database. +- Fixed infinite recursion in `ShippingRule::getOptions()` caused by calling `$this->toArray()`, which internally calls `getObjectVars()`, triggering PHP 8.4 property hook getters (`$config`, `$errors`) on nested condition objects and looping back into serialization. Replaced with an explicit scalar property array. +- Fixed infinite recursion in `ShippingMethodOrderCondition`, `ShippingRuleOrderCondition`, and `DiscountOrderCondition` `config()` methods where `$this->toArray(['storeId'])` was calling `getObjectVars()`, triggering the `$config` property hook getter, which called `getConfig()` → `config()` → `toArray()` again. Replaced with `['storeId' => $this->storeId]`. + +### Laravel Migration — Stage 5i: CatalogPricingRule + +Migrated `craft\commerce\models\CatalogPricingRule` → `CraftCms\Commerce\Catalog\Models\CatalogPricingRule`. + +Key changes: +- `craft\base\Model` (Yii2) → `CraftCms\Cms\Component\Component` (Laravel) +- `Craft::$app->getFormatter()->asPercent()` → `I18N::getFormatter()->asPercent()` +- `Craft::$app->getConditions()->createCondition()` → `Conditions::createCondition()` +- `craft\helpers\Json::decodeIfJson()` → `CraftCms\Cms\Support\Json::decodeIfJson()` +- Yii2 `defineRules()` → Laravel `getRules()` with `Rule::in([...])` for the `apply` field +- `craft\elements\conditions\*` condition classes retained as old namespace references (not yet migrated) +- `craft\commerce\elements\Product`, `Variant`, `base\Purchasable`, `records\CatalogPricingRule` retained as old namespace references +- `Plugin::getInstance()->getCurrencies()->getTeller()` retained until `Currencies` service migrated to `src/` + +### Laravel Migration — Stage 5h: ShippingRule + +Migrated `craft\commerce\models\ShippingRule` → `CraftCms\Commerce\Shipping\Models\ShippingRule`. + +Key changes: +- `craft\helpers\Json::decodeIfJson()` → `CraftCms\Cms\Support\Json::decodeIfJson()` +- `Craft::$app->getConditions()->createCondition()` → `Conditions::createCondition()` +- Yii2 attribute-based closure validators (`function($attribute)` + `$this->addError()`) → Laravel closures (`function($attribute, $value, \Closure $fail)` + `$fail()`) +- `validateShippingRuleCategories` method validator → inline closure in `getRules()`; `$this->addModelErrors()` available on new Component via `Validates` trait +- `$this->getAttributes()` in `getOptions()` → `$this->toArray()` +- `craft\commerce\elements\conditions\orders\ShippingRuleOrderCondition` and `customers\ShippingRuleCustomerCondition` retained as old namespace references (not yet migrated) +- `craft\commerce\elements\Order`, `craft\commerce\records\ShippingRuleCategory` retained as old namespace references + +### Laravel Migration — Stage 5g: ShippingMethod, BaseShippingMethod, and ShippingMethodOption + +Migrated the shipping method model hierarchy from `craft\commerce\` to `src/` in the `CraftCms\Commerce\` namespace. + +New class locations: +- `craft\commerce\base\ShippingMethod` (abstract) → `CraftCms\Commerce\Shipping\Models\BaseShippingMethod` +- `craft\commerce\models\ShippingMethod` → `CraftCms\Commerce\Shipping\Models\ShippingMethod` +- `craft\commerce\models\ShippingMethodOption` → `CraftCms\Commerce\Shipping\Models\ShippingMethodOption` + +Key changes: +- `craft\base\Chippable/Colorable/Iconic/Statusable` → `CraftCms\Cms\Component\Contracts\Chippable/Colorable/Iconic/Statusable` +- `craft\enums\Color` → `CraftCms\Cms\Shared\Enums\Color` +- `craft\commerce\errors\NotImplementedException` → `\BadMethodCallException` (inline) +- `UniqueValidator` → `Rule::unique()` (Laravel validation) +- `AttributeTypecastBehavior` — dropped (Yii2-only) +- `CurrencyAttributeBehavior` / `currencyAttributes()` / `getCurrency()` — dropped from `ShippingMethodOption` (Yii2-only) +- `craft\helpers\Json::decodeIfJson()` → `CraftCms\Cms\Support\Json::decodeIfJson()` +- `Craft::$app->getConditions()->createCondition()` → `Conditions::createCondition()` +- `craft\commerce\elements\conditions\orders\ShippingMethodOrderCondition` and `customers\ShippingMethodCustomerCondition` retained as old namespace references (not yet migrated) +- `craft\commerce\elements\Order` retained as old namespace reference (not yet migrated) + +### Laravel Migration — Stage 5f: Interfaces and Sale, StoreSettings, Transaction Models + +Migrated three models and four interfaces from `craft\commerce\` to domain-organized classes/interfaces under `src/` in the `CraftCms\Commerce\` namespace. + +New interface locations: +- `craft\commerce\base\TaxIdValidatorInterface` → `CraftCms\Commerce\Tax\Contracts\TaxIdValidatorInterface` +- `craft\commerce\base\TaxEngineInterface` → `CraftCms\Commerce\Tax\Contracts\TaxEngineInterface` +- `craft\commerce\base\ZoneInterface` → `CraftCms\Commerce\Base\ZoneInterface` +- `craft\commerce\base\SubscriptionResponseInterface` → `CraftCms\Commerce\Subscription\Contracts\SubscriptionResponseInterface` + +New model locations: +- `craft\commerce\models\Sale` → `CraftCms\Commerce\Promotion\Models\Sale` +- `craft\commerce\models\StoreSettings` → `CraftCms\Commerce\Store\Models\StoreSettings` +- `craft\commerce\models\Transaction` → `CraftCms\Commerce\Payment\Models\Transaction` + +Key changes: +- `craft\base\ComponentInterface` → `CraftCms\Cms\Component\Contracts\ComponentInterface` (in `TaxEngineInterface`) +- `new Query()->select()->from()->leftJoin()->where()->column()` → `DB::table()->leftJoin()->where()->pluck()->all()` (in `Sale`) +- `Craft::$app->getFormatter()->asPercent()` → `I18N::getFormatter()->asPercent()` (in `Sale`) +- `craft\helpers\Json::decodeIfJson()` → `CraftCms\Cms\Support\Json::decodeIfJson()` (in `StoreSettings`) +- `craft\helpers\ArrayHelper::firstValue()` → `Arr::first()` (in `StoreSettings`) +- `Address::findOne($id)` → `Elements::getElementById($id, Address::class)` (in `StoreSettings`) +- `Craft::$app->getElements()->saveElement()` → `Elements::saveElement()` (in `StoreSettings`) +- `Craft::$app->getAddresses()->getCountryRepository()->getList(Craft::$app->language)` → `Addresses::getCountryRepository()->getList(app()->getLocale())` (in `StoreSettings`) +- `Craft::$app->getConditions()->createCondition()` → `Conditions::createCondition()` (in `StoreSettings`) +- `CurrencyAttributeBehavior` behavior — dropped (Yii2-only) +- Hash generation moved from `__construct` override to a direct call in the new `__construct` (in `Transaction`) +- `init()` currency defaults moved into `__construct` in `Transaction` +- `craft\commerce\elements\Order` and `craft\commerce\base\Gateway` retained as old namespace references (not yet migrated) + +### Laravel Migration — Stage 5e: OrderStatus, PaymentSource, InventoryLocation, and CatalogPricing + +Migrated four additional models from `craft\commerce\models\` to domain-organized classes under `src/` in the `CraftCms\Commerce\` namespace. + +New model locations: +- `craft\commerce\models\OrderStatus` → `CraftCms\Commerce\Order\Models\OrderStatus` +- `craft\commerce\models\PaymentSource` → `CraftCms\Commerce\Payment\Models\PaymentSource` +- `craft\commerce\models\InventoryLocation` → `CraftCms\Commerce\Inventory\Models\InventoryLocation` +- `craft\commerce\models\CatalogPricing` → `CraftCms\Commerce\Catalog\Models\CatalogPricing` + +Key changes: +- `Yii2 SoftDeleteTrait` — not used in new code; `dateDeleted` property kept inline on `OrderStatus` +- `Cp::statusLabelHtml()` → `app(CraftCms\Cms\Cp\Html\StatusHtml::class)->statusLabelHtml()` +- `Html::encode()` → `htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE)` +- `Db::uidsByIds(Table::EMAILS, $ids)` → `DB::table(Table::EMAILS)->uidsByIds($ids)` (Laravel query builder macro) +- `craft\elements\Address` → `CraftCms\Cms\Address\Elements\Address` (new class has `countryCode`, `addressLine1`, `getCountryCode()`, `title` properties natively) +- `craft\base\Actionable/Chippable/CpEditable` → `CraftCms\Cms\Component\Contracts\Actionable/Chippable/CpEditable` +- `Craft::$app->getUser()->getIdentity()?->can()` → `request()->craftUser()?->can()` (new `CraftUser` auth pattern) +- `HandleValidator` → inline `regex:/^[a-zA-Z][a-zA-Z0-9_]*$/` with reserved-word closure rule +- `CurrencyAttributeBehavior` — dropped entirely (Yii2 behavior, not used in new system) +- `Craft::$app->getDeprecator()->log()` → `CraftCms\Cms\Support\Facades\Deprecator::log()` +- Updated `InventoryLocationTrait`, `InventoryMovementInterface`, `InventoryMovement`, `DeactivateInventoryLocation`, `InventoryLevel`, `InventoryFulfillmentLevel`, `InventoryTransaction` to reference the new `CraftCms\Commerce\Inventory\Models\InventoryLocation` class + +### Laravel Migration — Stage 5d: Email, PDF, OrderAdjustment, TaxRate, Zones, and Inventory Movements + +Migrated email, PDF, order adjustment, tax rate, zone base classes, and all inventory movement models from `craft\commerce\` to domain-organized classes under `src/` in the `CraftCms\Commerce\` namespace. + +New model locations: +- `craft\commerce\models\Email` → `CraftCms\Commerce\Email\Models\Email` +- `craft\commerce\models\Pdf` → `CraftCms\Commerce\Pdf\Models\Pdf` +- `craft\commerce\models\OrderAdjustment` → `CraftCms\Commerce\Order\Models\OrderAdjustment` +- `craft\commerce\models\TaxRate` → `CraftCms\Commerce\Tax\Models\TaxRate` +- `craft\commerce\models\ShippingAddressZone` → `CraftCms\Commerce\Shipping\Models\ShippingAddressZone` +- `craft\commerce\models\TaxAddressZone` → `CraftCms\Commerce\Tax\Models\TaxAddressZone` +- `craft\commerce\base\Zone` (abstract) → `CraftCms\Commerce\Base\Zone` +- `craft\commerce\base\InventoryMovement` (abstract) → `CraftCms\Commerce\Inventory\Models\InventoryMovement` +- `craft\commerce\models\inventory\InventoryManualMovement` → `CraftCms\Commerce\Inventory\Models\InventoryManualMovement` +- `craft\commerce\models\inventory\InventoryCommittedMovement` → `CraftCms\Commerce\Inventory\Models\InventoryCommittedMovement` +- `craft\commerce\models\inventory\InventoryFulfillMovement` → `CraftCms\Commerce\Inventory\Models\InventoryFulfillMovement` +- `craft\commerce\models\inventory\InventoryRestockMovement` → `CraftCms\Commerce\Inventory\Models\InventoryRestockMovement` +- `craft\commerce\models\inventory\InventoryTransferMovement` → `CraftCms\Commerce\Inventory\Models\InventoryTransferMovement` +- `craft\commerce\models\inventory\InventoryLocationDeactivatedMovement` → `CraftCms\Commerce\Inventory\Models\InventoryLocationDeactivatedMovement` +- `craft\commerce\models\inventory\DeactivateInventoryLocation` → `CraftCms\Commerce\Inventory\Models\DeactivateInventoryLocation` + +New shared infrastructure: +- `CraftCms\Commerce\Store\Concerns\StoreTrait` — shared `storeId`/`getStore()` for all store-aware models +- `CraftCms\Commerce\Store\Contracts\HasStoreInterface` — interface for store-aware models (already existed) + +Key changes: +- `craft\helpers\Json::decode()` → `CraftCms\Cms\Support\Json::decode()` +- `craft\helpers\Json::decodeIfJson()` → `CraftCms\Cms\Support\Json::decodeIfJson()` +- `craft\elements\Address` → `CraftCms\Cms\Address\Elements\Address` +- `App::parseEnv()` → `CraftCms\Cms\Support\Env::parse()` +- `App::mailSettings()->fromEmail/fromName` → `CraftCms\Cms\Email\Data\EmailSettings::fromProjectConfig()->fromEmail/fromName` +- `Craft::$app->getSites()->getSiteById()` → `CraftCms\Cms\Support\Facades\Sites::getSiteById()` +- `Craft::$app->getSites()->getPrimarySite()` → `CraftCms\Cms\Support\Facades\Sites::getPrimarySite()` +- `Craft::$app->getConditions()->createCondition()` → `CraftCms\Cms\Support\Facades\Conditions::createCondition()` +- `Craft::$app->getFormatter()->asPercent()` → `CraftCms\Cms\Support\Facades\I18N::getFormatter()->asPercent()` +- `Illuminate\Validation\Rule::unique()` used for handle/name uniqueness scoped by `storeId` +- `new Query()` Yii2 queries → `DB::table()` Laravel fluent builder +- `InventoryMovement::init()` hash preload removed; lazy-initialized in `getInventoryMovementHash()` +- Inventory movement validation closure rules use `$fail('msg')` pattern +- `craft\commerce\base\StoreTrait` (src-yii2) marked `@deprecated`; new `CraftCms\Commerce\Store\Concerns\StoreTrait` used in all migrated models + +### Laravel Migration — Stage 5c: Inventory, Catalog, and Transfer Models + +Migrated inventory, catalog, and transfer models from `craft\commerce\models\` to domain-organized classes under `src/` in the `CraftCms\Commerce\` namespace. + +New model locations: +- `craft\commerce\models\ProductTypeSite` → `CraftCms\Commerce\Catalog\Models\ProductTypeSite` +- `craft\commerce\models\InventoryItem` → `CraftCms\Commerce\Inventory\Models\InventoryItem` +- `craft\commerce\models\InventoryFulfillmentLevel` → `CraftCms\Commerce\Inventory\Models\InventoryFulfillmentLevel` +- `craft\commerce\models\InventoryLevel` → `CraftCms\Commerce\Inventory\Models\InventoryLevel` +- `craft\commerce\models\InventoryTransaction` → `CraftCms\Commerce\Inventory\Models\InventoryTransaction` +- `craft\commerce\models\inventory\UpdateInventoryLevel` → `CraftCms\Commerce\Inventory\Models\UpdateInventoryLevel` +- `craft\commerce\models\inventory\UpdateInventoryLevelInTransfer` → `CraftCms\Commerce\Inventory\Models\UpdateInventoryLevelInTransfer` +- `craft\commerce\models\TransferDetail` → `CraftCms\Commerce\Transfer\Models\TransferDetail` + +New trait locations (for use by migrated models): +- `craft\commerce\base\InventoryItemTrait` → `CraftCms\Commerce\Inventory\Concerns\InventoryItemTrait` +- `craft\commerce\base\InventoryLocationTrait` → `CraftCms\Commerce\Inventory\Concerns\InventoryLocationTrait` + +Key changes: +- `Craft::$app->getElements()->getElementById()` → `CraftCms\Cms\Support\Facades\Elements::getElementById()` +- `craft\elements\User` return type → `CraftCms\Cms\User\Elements\User` +- `craft\helpers\UrlHelper::cpUrl()` → `CraftCms\Cms\Support\Url::cpUrl()` +- `yii\base\InvalidConfigException` → native `\InvalidArgumentException` +- `unique` Yii2 validator with `targetClass`/`targetAttribute` → `Illuminate\Validation\Rule::unique(table, column)` +- `'in'` Yii2 validator with `range` → `Rule::in([...values])` +- `InventoryTransactionType::allowedManualAdjustmentTypes()` returns enum array; mapped to string values via `array_map(fn($t) => $t->value, ...)` +- `TransferDetail::init()` preloading removed; `getTransfer()` now lazy-loads via `Transfer::find()->id()->one()` +- `InventoryLocationTrait` still references `craft\commerce\models\InventoryLocation` (not yet migrated); old trait in `src-yii2/base/` retained for `InventoryMovement` base class + +### Laravel Migration — Stage 5b: Models with Relationships + +Migrated models that have relationship getters (lazy-loading related models via services) from `craft\commerce\models\` to domain-organized classes under `src/` in the `CraftCms\Commerce\` namespace. + +New model locations: +- `craft\commerce\models\OrderNotice` → `CraftCms\Commerce\Order\Models\OrderNotice` +- `craft\commerce\models\OrderHistory` → `CraftCms\Commerce\Order\Models\OrderHistory` +- `craft\commerce\models\SiteStore` → `CraftCms\Commerce\Store\Models\SiteStore` +- `craft\commerce\models\ShippingRuleCategory` → `CraftCms\Commerce\Shipping\Models\ShippingRuleCategory` +- `craft\commerce\models\payments\BasePaymentForm` → `CraftCms\Commerce\Payment\Forms\BasePaymentForm` +- `craft\commerce\models\payments\OffsitePaymentForm` → `CraftCms\Commerce\Payment\Forms\OffsitePaymentForm` +- `craft\commerce\models\payments\CreditCardPaymentForm` → `CraftCms\Commerce\Payment\Forms\CreditCardPaymentForm` +- `craft\commerce\models\payments\DummyPaymentForm` → `CraftCms\Commerce\Payment\Forms\DummyPaymentForm` + +Key changes: +- `Craft::$app->getSites()->getSiteById()` → `CraftCms\Cms\Support\Facades\Sites::getSiteById()` +- `Craft::$app->getUsers()->getUserById()` → `CraftCms\Cms\Support\Facades\Users::getUserById()` +- `craft\helpers\ArrayHelper::firstWhere()` → `collect()->first()` +- `craft\helpers\Db::uidById()` → `DB::table(...)->uidById()` (Craft 6 macro on the query builder) +- `yii\base\NotSupportedException` → native `\LogicException` +- `CreditCardPaymentForm::setAttributes()` now overrides the `Validates` trait's `setAttributes()` for expiry field parsing +- `CreditCardPaymentForm` Luhn check converted from a Yii2 method validator to a Laravel closure rule in `getRules()` + +Also fixed: +- `phpstan.neon` scan file paths for `yii2-adapter/legacy/Craft.php` and `yii2-adapter/lib/yii2/Yii.php` (relative paths replacing missing vendor paths) +- Added `vendor/craftcms/cms/src/helpers.php` to PHPStan scan files so the global `t()` function is recognized +- Added `use function CraftCms\Cms\t;` to files that call `t()` + +### Laravel Migration — Stage 5a: Simple Value Models + +Migrated simple value models (scalar properties, no element ties) from `craft\commerce\models\` to domain-organized classes under `src/` in the `CraftCms\Commerce\` namespace. All new classes extend `CraftCms\Cms\Component\Component` and use `getRules()` with Laravel validation syntax instead of Yii2's `defineRules()`. + +New model locations: +- `craft\commerce\models\Coupon` → `CraftCms\Commerce\Promotion\Models\Coupon` +- `craft\commerce\models\TaxCategory` → `CraftCms\Commerce\Tax\Models\TaxCategory` +- `craft\commerce\models\ShippingCategory` → `CraftCms\Commerce\Shipping\Models\ShippingCategory` +- `craft\commerce\models\LineItemStatus` → `CraftCms\Commerce\Order\Models\LineItemStatus` +- `craft\commerce\models\PaymentCurrency` → `CraftCms\Commerce\Payment\Models\PaymentCurrency` +- `craft\commerce\models\PurchasableStore` → `CraftCms\Commerce\Purchasable\Models\PurchasableStore` +- `craft\commerce\models\Settings` → `CraftCms\Commerce\Settings` +- `craft\commerce\models\subscriptions\CancelSubscriptionForm` → `CraftCms\Commerce\Subscription\Forms\CancelSubscriptionForm` +- `craft\commerce\models\subscriptions\SubscriptionForm` → `CraftCms\Commerce\Subscription\Forms\SubscriptionForm` +- `craft\commerce\models\subscriptions\SwitchPlansForm` → `CraftCms\Commerce\Subscription\Forms\SwitchPlansForm` +- `craft\commerce\models\subscriptions\SubscriptionPayment` → `CraftCms\Commerce\Subscription\Models\SubscriptionPayment` +- `craft\commerce\models\responses\Dummy` → `CraftCms\Commerce\Payment\Gateway\Responses\Dummy` +- `craft\commerce\models\responses\Manual` → `CraftCms\Commerce\Payment\Gateway\Responses\Manual` +- `craft\commerce\models\responses\DummySubscriptionResponse` → `CraftCms\Commerce\Subscription\Responses\DummySubscriptionResponse` + +Key changes: +- `craft\base\Chippable/Colorable/Iconic` → `CraftCms\Cms\Component\Contracts\Chippable/Colorable/Iconic` +- `craft\enums\Color` → `CraftCms\Cms\Shared\Enums\Color` +- `craft\helpers\UrlHelper::cpUrl()` → `CraftCms\Cms\Support\Url::cpUrl()` +- `Craft::t()` → global `t()` +- `craft\helpers\Cp::requestedSite()` → `app(CraftCms\Cms\Cp\RequestedSite::class)->get()` +- `craft\helpers\Cp::statusLabelHtml()` → `app(CraftCms\Cms\Cp\Html\StatusHtml::class)->statusLabelHtml()` +- `craft\helpers\StringHelper::randomString()` → `CraftCms\Cms\Support\Str::random()` +- `craft\helpers\ConfigHelper::localizedValue()` → `CraftCms\Cms\Support\Config::localizedValue()` +- `yii\base\InvalidConfigException` → native `\InvalidArgumentException` +- `craft\helpers\ArrayHelper::getColumn()` → `array_column()` + +### Removed: Yii2 Debug Panel +- Removed the Commerce Yii2 debug panel entirely — it relied on the Yii2 debug module (`craft\debug\Module`), which no longer exists in Craft CMS 6 / Laravel. +- Deleted `src-yii2/debug/CommercePanel.php`. +- Deleted `src-yii2/helpers/DebugPanel.php` and the previously migrated `src/Helpers/DebugPanel.php`. +- Deleted `src-yii2/events/CommerceDebugPanelDataEvent.php` and `src/Cp/Events/CommerceDebugPanelDataEvent.php`. +- Deleted `src-yii2/views/debug/commerce/` (detail, model, summary views). +- Removed `_registerDebugPanels()` method and its `onInit` registration from `src-yii2/Plugin.php`. +- Removed all `DebugPanel::prependOrAppendModelTab()` calls and imports from 19 controllers. + +### Laravel Migration — Stage 3: Events (Call-site Updates) +- All `src-yii2/` event instantiation call sites updated from Yii2 array-config syntax (`new XxxEvent(['prop' => $val])`) to PHP 8 named argument syntax (`new XxxEvent(prop: $val)`). +- All new `CraftCms\Commerce\*\Events\` classes now use constructor property promotion for clean, typed initialization. + **Correction (added during Stage 7d):** this was not actually completed. Of the 56 event classes moved in this stage, only 4 have a real constructor as of Stage 7d (the ones Stage 7d itself fixed). The other ~52 are still bare classes with no constructor at all, and most call sites (including already-shipped ones, e.g. `Order.php`'s `LineItemEvent` triggers from Stage 7b) still construct them with a Yii2-style array, which silently does nothing — see the "Events layer silently discards its constructor data" follow-up in `laravel-migration-private.md`. +- Fixed `phpstan.neon` to work with Craft 6's restructured vendor layout (removed dependency on `craftcms/phpstan.neon`, added correct `scanFiles` and `scanDirectories` for legacy code awareness). +- Fixed `CraftCms\Commerce\Subscription\Events\PlanEvent`, `CreateSubscriptionEvent`, and `SubscriptionSwitchPlansEvent` to import `craft\commerce\base\Plan` (not the non-existent `craft\commerce\models\Plan`). + +### Laravel Migration — Stage 3: Events +- Moved all 56 event classes from `craft\commerce\events\` (`src-yii2/events/`) to domain-organized `CraftCms\Commerce\*\Events\` classes under `src/`: + - `CraftCms\Commerce\Catalog\Events\` — product/variant snapshot, product type, purchase variant, purchasables table query events + - `CraftCms\Commerce\Cp\Events\` — debug panel data event + - `CraftCms\Commerce\Email\Events\` — email and mail events + - `CraftCms\Commerce\Inventory\Events\` — inventory movement and level update events + - `CraftCms\Commerce\Order\Events\` — cart, line item, order status, order notice, purge events + - `CraftCms\Commerce\Payment\Events\` — payment source, process payment, transaction, refund, webhook events + - `CraftCms\Commerce\Pdf\Events\` — PDF and PDF render events + - `CraftCms\Commerce\Promotion\Events\` — discount, sale, and match events + - `CraftCms\Commerce\Purchasable\Events\` — purchasable availability and shipping events + - `CraftCms\Commerce\Report\Events\` — report event + - `CraftCms\Commerce\Shipping\Events\` — register available shipping methods event + - `CraftCms\Commerce\Store\Events\` — store and delete store events + - `CraftCms\Commerce\Subscription\Events\` — subscription lifecycle events + - `CraftCms\Commerce\Tax\Events\` — tax engine and tax ID validator events +- Cancelable events (previously extending `craft\events\CancelableEvent`) now use the `CraftCms\Cms\Shared\Concerns\ValidatableEvent` trait instead. +- Legacy `craft\commerce\events\*` classes replaced with `class_alias` stubs pointing to the new classes. + +### Laravel Migration — Stage 2: Interfaces & Base Contracts +- Moved 11 interfaces from `craft\commerce\base\` (`src-yii2/base/`) to domain-organized `CraftCms\Commerce\*\Contracts\` interfaces under `src/`: + - `CraftCms\Commerce\CatalogPricing\Contracts\CatalogPricingConditionRuleInterface` + - `CraftCms\Commerce\Inventory\Contracts\InventoryMovementInterface` + - `CraftCms\Commerce\Order\Adjuster\Contracts\AdjusterInterface` + - `CraftCms\Commerce\Payment\Gateway\Contracts\GatewayInterface` + - `CraftCms\Commerce\Payment\Gateway\Contracts\RequestResponseInterface` + - `CraftCms\Commerce\Purchasable\Contracts\PurchasableInterface` + - `CraftCms\Commerce\Shipping\Contracts\ShippingMethodInterface` + - `CraftCms\Commerce\Shipping\Contracts\ShippingRuleInterface` + - `CraftCms\Commerce\Stats\Contracts\StatInterface` + - `CraftCms\Commerce\Store\Contracts\HasStoreInterface` + - `CraftCms\Commerce\Subscription\Contracts\PlanInterface` +- Legacy `craft\commerce\base\*` interface files replaced with `class_alias` stubs. + +### Laravel Migration — Stage 1: Constants & Enums +- Moved `craft\commerce\db\Table` to `CraftCms\Commerce\Database\Table`. +- Moved `craft\commerce\enums\InventoryTransactionType` to `CraftCms\Commerce\Inventory\Enums\InventoryTransactionType`. +- Moved `craft\commerce\enums\InventoryUpdateQuantityType` to `CraftCms\Commerce\Inventory\Enums\InventoryUpdateQuantityType`. +- Moved `craft\commerce\enums\LineItemType` to `CraftCms\Commerce\Order\LineItem\Enums\LineItemType`. +- Moved `craft\commerce\enums\TransferStatusType` to `CraftCms\Commerce\Transfer\Enums\TransferStatusType`. +- Legacy enum and constant files replaced with `class_alias` stubs. + +### Craft CMS 6 Compatibility +- Updated `craftcms/cms` requirement to `6.0.0-alpha.1`. +- `craft.commerce` is now registered as a macro on `CraftCms\Cms\Twig\Variables\CraftVariable`, so it works with the new Laravel-based Twig variable. +- Updated `craft\commerce\Plugin` to use the `CraftCms\Cms\Support\Facades\Updates` facade. +- Fixed `craft\commerce\elements\Order::getRecalculationMode()` returning `null` before `init()` had run. +- Fixed `craft\commerce\elements\Order::getLink()` return type to `?\Illuminate\Support\HtmlString`. +- Fixed `craft\commerce\elements\Product::setEagerLoadedElements()`, `Subscription::setEagerLoadedElements()`, and `Variant::setEagerLoadedElements()` method signatures to use `\CraftCms\Cms\Element\Data\EagerLoadPlan`. +- Fixed `craft\commerce\elements\Transfer::prepareEditScreen()` method signature to use `\CraftCms\Cms\Http\Responses\CpScreenResponse|\Symfony\Component\HttpFoundation\Response`. +- Fixed `craft\commerce\models\PaymentCurrency::safeAttributes()` return type declaration to match the parent `array` return type. +- Fixed `craft\commerce\base\Purchasable::__unset()` method signature to add `string` type hint and `void` return type. +- Updated `craft\commerce\elements\VariantCollection::make()` to accept variadic arguments. diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 0000000000..043b4935aa --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1,912 @@ +# Release Notes for Craft Commerce 6.0 (WIP) + +### Catalog + +- Added `CraftCms\Commerce\Catalog\Elements\Product`. +- Added `CraftCms\Commerce\Catalog\Elements\Variant`. +- Added `CraftCms\Commerce\Catalog\Queries\ProductQuery`. +- Added `CraftCms\Commerce\Catalog\Queries\VariantQuery`. +- Added `CraftCms\Commerce\Catalog\Models\Product`. +- Added `CraftCms\Commerce\Catalog\Models\Variant`. +- Added `CraftCms\Commerce\Catalog\Models\ProductTypeSite`. +- Added `CraftCms\Commerce\Catalog\Products`. +- Added `CraftCms\Commerce\Catalog\Variants`. +- Added `CraftCms\Commerce\Catalog\ProductType\Data\ProductType`. +- Added `CraftCms\Commerce\Catalog\ProductType\Models\ProductType`. +- Added `CraftCms\Commerce\Catalog\ProductType\Models\ProductTypeSite`. +- Added `CraftCms\Commerce\Catalog\ProductType\ProductTypes`. +- Added `CraftCms\Commerce\Catalog\Events\CustomizeProductSnapshotDataEvent`. +- Added `CraftCms\Commerce\Catalog\Events\CustomizeProductSnapshotFieldsEvent`. +- Added `CraftCms\Commerce\Catalog\Events\CustomizeVariantSnapshotDataEvent`. +- Added `CraftCms\Commerce\Catalog\Events\CustomizeVariantSnapshotFieldsEvent`. +- Added `CraftCms\Commerce\Catalog\Events\ModifyPurchasablesTableQueryEvent`. +- Added `CraftCms\Commerce\Catalog\Events\ProductEvent`. +- Added `CraftCms\Commerce\Catalog\Events\ProductTypeEvent`. +- Added `CraftCms\Commerce\Catalog\Events\PurchaseVariantEvent`. +- Deprecated `craft\commerce\elements\Product`. `CraftCms\Commerce\Catalog\Elements\Product` should be used instead. +- Deprecated `craft\commerce\elements\Variant`. `CraftCms\Commerce\Catalog\Elements\Variant` should be used instead. +- Deprecated `craft\commerce\elements\db\ProductQuery`. `CraftCms\Commerce\Catalog\Queries\ProductQuery` should be used instead. +- Deprecated `craft\commerce\elements\db\VariantQuery`. `CraftCms\Commerce\Catalog\Queries\VariantQuery` should be used instead. +- Deprecated `craft\commerce\records\Product`. `CraftCms\Commerce\Catalog\Models\Product` should be used instead. +- Deprecated `craft\commerce\records\Variant`. `CraftCms\Commerce\Catalog\Models\Variant` should be used instead. +- Deprecated `craft\commerce\models\ProductType`. `CraftCms\Commerce\Catalog\ProductType\Data\ProductType` should be used instead. +- Deprecated `craft\commerce\models\ProductTypeSite`. `CraftCms\Commerce\Catalog\Models\ProductTypeSite` should be used instead. +- Deprecated `craft\commerce\records\ProductType`. `CraftCms\Commerce\Catalog\ProductType\Models\ProductType` should be used instead. +- Deprecated `craft\commerce\records\ProductTypeSite`. `CraftCms\Commerce\Catalog\ProductType\Models\ProductTypeSite` should be used instead. +- Deprecated `craft\commerce\services\Products`. `CraftCms\Commerce\Catalog\Products` should be used instead. +- Deprecated `craft\commerce\services\Variants`. `CraftCms\Commerce\Catalog\Variants` should be used instead. +- Deprecated `craft\commerce\services\ProductTypes`. `CraftCms\Commerce\Catalog\ProductType\ProductTypes` should be used instead. +- Deprecated `craft\commerce\events\CustomizeProductSnapshotDataEvent`. `CraftCms\Commerce\Catalog\Events\CustomizeProductSnapshotDataEvent` should be used instead. +- Deprecated `craft\commerce\events\CustomizeProductSnapshotFieldsEvent`. `CraftCms\Commerce\Catalog\Events\CustomizeProductSnapshotFieldsEvent` should be used instead. +- Deprecated `craft\commerce\events\CustomizeVariantSnapshotDataEvent`. `CraftCms\Commerce\Catalog\Events\CustomizeVariantSnapshotDataEvent` should be used instead. +- Deprecated `craft\commerce\events\CustomizeVariantSnapshotFieldsEvent`. `CraftCms\Commerce\Catalog\Events\CustomizeVariantSnapshotFieldsEvent` should be used instead. +- Deprecated `craft\commerce\events\ModifyPurchasablesTableQueryEvent`. `CraftCms\Commerce\Catalog\Events\ModifyPurchasablesTableQueryEvent` should be used instead. +- Deprecated `craft\commerce\events\ProductEvent`. `CraftCms\Commerce\Catalog\Events\ProductEvent` should be used instead. +- Deprecated `craft\commerce\events\ProductTypeEvent`. `CraftCms\Commerce\Catalog\Events\ProductTypeEvent` should be used instead. +- Deprecated `craft\commerce\events\PurchaseVariantEvent`. `CraftCms\Commerce\Catalog\Events\PurchaseVariantEvent` should be used instead. +- Removed `craft\commerce\records\ProductTypeShippingCategory` as it was unused; `CraftCms\Commerce\Shipping\ShippingCategories` manages the `commerce_producttypes_shippingcategories` pivot table directly through the query builder. +- Removed `craft\commerce\records\ProductTypeTaxCategory` as it was unused; `CraftCms\Commerce\Tax\TaxCategories` manages the `commerce_producttypes_taxcategories` pivot table directly through the query builder. +- Added `CraftCms\Commerce\Catalog\Conditions\ProductCondition`, `ProductTypeConditionRule`, `ProductVariantSearchConditionRule`, `ProductVariantSkuConditionRule`, `ProductVariantStockConditionRule`, `ProductVariantPriceConditionRule`, and `ProductVariantInventoryTrackedConditionRule`. +- Added `CraftCms\Commerce\Catalog\Conditions\VariantCondition`, `VariantProductConditionRule`, and `VariantConditionRule`. +- Added `CraftCms\Commerce\Catalog\Conditions\CatalogPricingRuleProductCondition`, `CatalogPricingRuleVariantCondition`, and `CatalogPricingRuleVariantConditionRule`. +- Deprecated `craft\commerce\elements\conditions\products\ProductCondition`, `ProductTypeConditionRule`, `ProductVariantSearchConditionRule`, `ProductVariantSkuConditionRule`, `ProductVariantStockConditionRule`, `ProductVariantPriceConditionRule`, `ProductVariantInventoryTrackedConditionRule`, and `CatalogPricingRuleProductCondition`. The `CraftCms\Commerce\Catalog\Conditions` equivalents should be used instead. +- Deprecated `craft\commerce\elements\conditions\variants\VariantCondition`, `ProductConditionRule`, `VariantConditionRule`, `CatalogPricingRuleVariantCondition`, and `CatalogPricingRuleVariantConditionRule`. The `CraftCms\Commerce\Catalog\Conditions` equivalents should be used instead. +- Removed `craft\commerce\elements\conditions\products\ProductVariantHasUnlimitedStockConditionRule`, deprecated since 5.0.0 and already unregistered from `ProductCondition::selectableConditionRules()`. +- Added `CraftCms\Commerce\Catalog\Actions\SetDefaultVariant`. +- Added `CraftCms\Commerce\Catalog\FieldLayoutElements\ProductTitleField`, `VariantTitleField`, and `VariantsField`. +- Added `CraftCms\Commerce\Catalog\Fields\Products` and `Variants`. +- Deprecated `craft\commerce\elements\actions\SetDefaultVariant`. `CraftCms\Commerce\Catalog\Actions\SetDefaultVariant` should be used instead. +- Deprecated `craft\commerce\fieldlayoutelements\ProductTitleField`, `VariantTitleField`, and `VariantsField`. The `CraftCms\Commerce\Catalog\FieldLayoutElements` equivalents should be used instead. +- Deprecated `craft\commerce\fields\Products` and `Variants`. The `CraftCms\Commerce\Catalog\Fields` equivalents should be used instead. +- Removed `craft\commerce\linktypes\Product`, superseded by `CraftCms\Commerce\Catalog\LinkTypes\ProductLinkType`. + +#### Controllers + +- Removed `craft\commerce\controllers\ProductsController`. `CraftCms\Commerce\Http\Controllers\ProductsController` should be used instead. +- Removed `craft\commerce\controllers\VariantsController`. `CraftCms\Commerce\Http\Controllers\VariantsController` should be used instead. +- Removed `craft\commerce\controllers\ProductTypesController`. `CraftCms\Commerce\Http\Controllers\Settings\ProductTypesController` should be used instead. + +### Catalog Pricing + +- Added `CraftCms\Commerce\CatalogPricing\CatalogPricing`. +- Added `CraftCms\Commerce\CatalogPricing\CatalogPricingRules`. +- Added `CraftCms\Commerce\CatalogPricing\Records\CatalogPricingQueue`. +- Added `CraftCms\Commerce\CatalogPricing\Records\CatalogPricingRule`. +- Added `CraftCms\Commerce\Catalog\Models\CatalogPricing`. +- Added `CraftCms\Commerce\Catalog\Models\CatalogPricingRule`. +- Added `CraftCms\Commerce\CatalogPricing\Conditions\CatalogPricingCondition`. +- Added `CraftCms\Commerce\CatalogPricing\Conditions\CatalogPricingPurchasableConditionRule`. +- Added `CraftCms\Commerce\CatalogPricing\Conditions\CatalogPricingCustomerConditionRule`. +- Added `CraftCms\Commerce\CatalogPricing\Contracts\CatalogPricingConditionRuleInterface`. +- Deprecated `craft\commerce\services\CatalogPricing`. `CraftCms\Commerce\CatalogPricing\CatalogPricing` should be used instead. +- Deprecated `craft\commerce\services\CatalogPricingRules`. `CraftCms\Commerce\CatalogPricing\CatalogPricingRules` should be used instead. +- Deprecated `craft\commerce\models\CatalogPricing`. `CraftCms\Commerce\Catalog\Models\CatalogPricing` should be used instead. +- Deprecated `craft\commerce\models\CatalogPricingRule`. `CraftCms\Commerce\Catalog\Models\CatalogPricingRule` should be used instead. +- Deprecated `craft\commerce\records\CatalogPricingRule`. `CraftCms\Commerce\CatalogPricing\Records\CatalogPricingRule` should be used instead. +- Deprecated `craft\commerce\elements\conditions\purchasables\CatalogPricingCondition`. `CraftCms\Commerce\CatalogPricing\Conditions\CatalogPricingCondition` should be used instead. +- Deprecated `craft\commerce\elements\conditions\purchasables\CatalogPricingPurchasableConditionRule`. `CraftCms\Commerce\CatalogPricing\Conditions\CatalogPricingPurchasableConditionRule` should be used instead. +- Deprecated `craft\commerce\elements\conditions\purchasables\CatalogPricingCustomerConditionRule`. `CraftCms\Commerce\CatalogPricing\Conditions\CatalogPricingCustomerConditionRule` should be used instead. +- Deprecated `craft\commerce\base\CatalogPricingConditionRuleInterface`. `CraftCms\Commerce\CatalogPricing\Contracts\CatalogPricingConditionRuleInterface` should be used instead. +- Removed `craft\commerce\records\CatalogPricing` as it was unused. +- Removed `craft\commerce\records\CatalogPricingRuleUser` as it was unused; the `commerce_catalog_pricing_rules_users` pivot table is managed directly through the query builder. +- Removed `craft\commerce\records\CatalogPricingRule`. `CraftCms\Commerce\CatalogPricing\Records\CatalogPricingRule` should be used instead. +- Removed `craft\commerce\records\CatalogPricingQueue`. `CraftCms\Commerce\CatalogPricing\Records\CatalogPricingQueue` should be used instead. +- `CraftCms\Commerce\Catalog\Models\CatalogPricingRule` now uses `CraftCms\Commerce\Customer\Conditions\CatalogPricingRuleCustomerCondition`, `CraftCms\Commerce\Catalog\Conditions\CatalogPricingRuleProductCondition`, `CatalogPricingRuleVariantCondition`, and `CraftCms\Commerce\Purchasable\Conditions\CatalogPricingRulePurchasableCondition`. + +#### Controllers + +- Removed `craft\commerce\controllers\CatalogPricingRulesController`. `CraftCms\Commerce\Http\Controllers\Settings\CatalogPricingRulesController` should be used instead. +- Removed `craft\commerce\controllers\CatalogPricingController`. `CraftCms\Commerce\Http\Controllers\Settings\CatalogPricingController` should be used instead. + +### Console + +- Added `CraftCms\Commerce\Console\Commands\ExampleTemplates\ExampleTemplatesCommand` (`commerce:example-templates`). +- Added `CraftCms\Commerce\Console\Commands\Gateways\GatewaysListCommand` (`commerce:gateways:list`). +- Added `CraftCms\Commerce\Console\Commands\Gateways\GatewaysWebhookUrlCommand` (`commerce:gateways:webhook-url`). +- Added `CraftCms\Commerce\Console\Commands\PricingCatalog\PricingCatalogGenerateCommand` (`commerce:pricing-catalog:generate`). +- Added `CraftCms\Commerce\Console\Commands\ResetData\ResetDataCommand` (`commerce:reset-data`). +- Added `CraftCms\Commerce\Console\Commands\TransferCustomerData\TransferCustomerDataCommand` (`commerce:transfer-customer-data`). +- Removed `craft\commerce\console\controllers\ExampleTemplatesController`, `craft\commerce\console\controllers\GatewaysController`, `craft\commerce\console\controllers\PricingCatalogController`, `craft\commerce\console\controllers\ResetDataController`, and `craft\commerce\console\controllers\TransferCustomerDataController`. Their legacy `commerce/*` CLI routes still work as command aliases (e.g. `craft commerce/gateways/list`). +- Removed `craft\commerce\console\Controller`. + +### Controllers + +- Removed `craft\commerce\controllers\BaseController`, `craft\commerce\controllers\BaseCpController`, `craft\commerce\controllers\BaseAdminController`, and `craft\commerce\controllers\BaseFrontEndController`. +- Removed `craft\commerce\controllers\BaseStoreManagementController`. +- Removed `craft\commerce\controllers\BaseTaxSettingsController`. +- Removed `craft\commerce\controllers\BaseShippingSettingsController`. +- Removed `craft\commerce\controllers\SettingsController`. `CraftCms\Commerce\Http\Controllers\Settings\SettingsController` should be used instead. +- Removed `craft\commerce\controllers\PromotionsController`. The `commerce/promotions` URL now redirects to `commerce/promotions/sales` via a plain route closure. + +### Customers + +- Added `CraftCms\Commerce\Customer\Customers`. +- Added `CraftCms\Commerce\Customer\Records\Customer`. +- Added `CraftCms\Commerce\Customer\Customers::afterSaveUserHandler()` and `afterSaveAddressHandler()`, listening for `CraftCms\Cms\Element\Events\ElementSaved` to persist primary billing/shipping addresses, primary payment sources, and order email syncing. +- Deprecated `craft\commerce\services\Customers`. `CraftCms\Commerce\Customer\Customers` should be used instead. +- Deprecated `craft\commerce\behaviors\CustomerBehavior`. `User::getPrimaryBillingAddressId()`, `getPrimaryShippingAddressId()`, `getPrimaryPaymentSourceId()`, `getActiveCarts()`, `getInactiveCarts()`, and `getOrders()` are now provided via a `Illuminate\Support\Traits\Macroable` macro instead. +- Deprecated `craft\commerce\behaviors\CustomerAddressBehavior`. `Address::getIsPrimaryBilling()` and `getIsPrimaryShipping()` are now provided via a `Illuminate\Support\Traits\Macroable` macro instead. +- Removed `craft\commerce\records\Customer`. `CraftCms\Commerce\Customer\Records\Customer` should be used instead. +- Added `CraftCms\Commerce\Customer\Conditions\DiscountCustomerCondition`, `HasOrdersConditionRule`, `SignedInConditionRule`, `DiscountGroupConditionRule`, `ShippingMethodCustomerCondition`, `ShippingRuleCustomerCondition`, `CatalogPricingRuleCustomerCondition`, and `CatalogPricingRuleCustomerConditionRule`. +- Deprecated `craft\commerce\elements\conditions\customers\DiscountCustomerCondition`, `HasOrdersConditionRule`, `SignedInConditionRule`, `ShippingMethodCustomerCondition`, `ShippingRuleCustomerCondition`, `CatalogPricingRuleCustomerCondition`, and `CatalogPricingRuleCustomerConditionRule`. The `CraftCms\Commerce\Customer\Conditions` equivalents should be used instead. +- Deprecated `craft\commerce\elements\conditions\users\DiscountGroupConditionRule`. `CraftCms\Commerce\Customer\Conditions\DiscountGroupConditionRule` should be used instead. +- Added `CraftCms\Commerce\Customer\FieldLayoutElements\UserAddressSettings`. +- Deprecated `craft\commerce\fieldlayoutelements\UserAddressSettings`. `CraftCms\Commerce\Customer\FieldLayoutElements\UserAddressSettings` should be used instead. + +### Dashboard & Widgets + +- Added `CraftCms\Commerce\Stats\Stat`. +- Added `CraftCms\Commerce\Stats\Contracts\StatInterface`. +- Added `CraftCms\Commerce\Dashboard\Widgets\Concerns\StatWidgetTrait` trait. +- Added `CraftCms\Commerce\Stats\AverageOrderTotal`. +- Added `CraftCms\Commerce\Stats\NewCustomers`. +- Added `CraftCms\Commerce\Stats\RepeatCustomers`. +- Added `CraftCms\Commerce\Stats\TopCustomers`. +- Added `CraftCms\Commerce\Stats\TopProductTypes`. +- Added `CraftCms\Commerce\Stats\TopPurchasables`. +- Added `CraftCms\Commerce\Stats\TopProducts`. +- Added `CraftCms\Commerce\Stats\TotalOrders`. +- Added `CraftCms\Commerce\Stats\TotalOrdersByCountry`. +- Added `CraftCms\Commerce\Stats\TotalRevenue`. +- Added `CraftCms\Commerce\Dashboard\Widgets\AverageOrderTotal`. +- Added `CraftCms\Commerce\Dashboard\Widgets\NewCustomers`. +- Added `CraftCms\Commerce\Dashboard\Widgets\RepeatCustomers`. +- Added `CraftCms\Commerce\Dashboard\Widgets\TopCustomers`. +- Added `CraftCms\Commerce\Dashboard\Widgets\TopProductTypes`. +- Added `CraftCms\Commerce\Dashboard\Widgets\TopPurchasables`. +- Added `CraftCms\Commerce\Dashboard\Widgets\TopProducts`. +- Added `CraftCms\Commerce\Dashboard\Widgets\TotalOrders`. +- Added `CraftCms\Commerce\Dashboard\Widgets\TotalOrdersByCountry`. +- Added `CraftCms\Commerce\Dashboard\Widgets\TotalRevenue`. +- Added `CraftCms\Commerce\Dashboard\Widgets\Orders`. +- Deprecated `craft\commerce\stats\AverageOrderTotal`. `CraftCms\Commerce\Stats\AverageOrderTotal` should be used instead. +- Deprecated `craft\commerce\stats\NewCustomers`. `CraftCms\Commerce\Stats\NewCustomers` should be used instead. +- Deprecated `craft\commerce\stats\RepeatCustomers`. `CraftCms\Commerce\Stats\RepeatCustomers` should be used instead. +- Deprecated `craft\commerce\stats\TopCustomers`. `CraftCms\Commerce\Stats\TopCustomers` should be used instead. +- Deprecated `craft\commerce\stats\TopProductTypes`. `CraftCms\Commerce\Stats\TopProductTypes` should be used instead. +- Deprecated `craft\commerce\stats\TopPurchasables`. `CraftCms\Commerce\Stats\TopPurchasables` should be used instead. +- Deprecated `craft\commerce\stats\TopProducts`. `CraftCms\Commerce\Stats\TopProducts` should be used instead. +- Deprecated `craft\commerce\stats\TotalOrders`. `CraftCms\Commerce\Stats\TotalOrders` should be used instead. +- Deprecated `craft\commerce\stats\TotalOrdersByCountry`. `CraftCms\Commerce\Stats\TotalOrdersByCountry` should be used instead. +- Deprecated `craft\commerce\stats\TotalRevenue`. `CraftCms\Commerce\Stats\TotalRevenue` should be used instead. +- Deprecated `craft\commerce\base\StatInterface`. `CraftCms\Commerce\Stats\Contracts\StatInterface` should be used instead. +- `CraftCms\Commerce\Stats\Stat` and its subclasses now build their queries entirely through the Laravel query builder, using `tpetry/laravel-query-expressions` for cross-database SQL differences instead of manual driver checks. +- `CraftCms\Commerce\Stats\Stat` now implements `CraftCms\Commerce\Store\Contracts\HasStoreInterface`, matching its legacy counterpart (`StoreTrait` already satisfied the interface's contract; the `implements` clause itself had been dropped). +- Added `CraftCms\Commerce\Support\Expressions\LocalTimestamp`, `DateOnly`, `MonthKey`, and `Round` query expressions. +- Deprecated `craft\commerce\widgets\AverageOrderTotal`. `CraftCms\Commerce\Dashboard\Widgets\AverageOrderTotal` should be used instead. +- Deprecated `craft\commerce\widgets\NewCustomers`. `CraftCms\Commerce\Dashboard\Widgets\NewCustomers` should be used instead. +- Deprecated `craft\commerce\widgets\RepeatCustomers`. `CraftCms\Commerce\Dashboard\Widgets\RepeatCustomers` should be used instead. +- Deprecated `craft\commerce\widgets\TopCustomers`. `CraftCms\Commerce\Dashboard\Widgets\TopCustomers` should be used instead. +- Deprecated `craft\commerce\widgets\TopProductTypes`. `CraftCms\Commerce\Dashboard\Widgets\TopProductTypes` should be used instead. +- Deprecated `craft\commerce\widgets\TopPurchasables`. `CraftCms\Commerce\Dashboard\Widgets\TopPurchasables` should be used instead. +- Deprecated `craft\commerce\widgets\TopProducts`. `CraftCms\Commerce\Dashboard\Widgets\TopProducts` should be used instead. +- Deprecated `craft\commerce\widgets\TotalOrders`. `CraftCms\Commerce\Dashboard\Widgets\TotalOrders` should be used instead. +- Deprecated `craft\commerce\widgets\TotalOrdersByCountry`. `CraftCms\Commerce\Dashboard\Widgets\TotalOrdersByCountry` should be used instead. +- Deprecated `craft\commerce\widgets\TotalRevenue`. `CraftCms\Commerce\Dashboard\Widgets\TotalRevenue` should be used instead. +- Deprecated `craft\commerce\widgets\Orders`. `CraftCms\Commerce\Dashboard\Widgets\Orders` should be used instead. +- Deprecated `craft\commerce\base\Stat`. `CraftCms\Commerce\Stats\Stat` should be used instead. +- Deprecated `craft\commerce\base\StatWidgetTrait`. `CraftCms\Commerce\Dashboard\Widgets\Concerns\StatWidgetTrait` should be used instead. +- Deprecated `craft\commerce\base\StatTrait`. Its properties are now declared directly on `CraftCms\Commerce\Stats\Stat`. + +### Email + +- Added `CraftCms\Commerce\Email\Emails`. +- Added `CraftCms\Commerce\Email\Records\Email`. +- Added `CraftCms\Commerce\Email\Models\Email`. +- Added `CraftCms\Commerce\Email\Exceptions\EmailException`. +- Added `CraftCms\Commerce\Email\Events\EmailEvent`. +- Added `CraftCms\Commerce\Email\Events\MailEvent`. +- Deprecated `craft\commerce\services\Emails`. `CraftCms\Commerce\Email\Emails` should be used instead. +- Deprecated `craft\commerce\models\Email`. `CraftCms\Commerce\Email\Models\Email` should be used instead. +- Deprecated `craft\commerce\errors\EmailException`. `CraftCms\Commerce\Email\Exceptions\EmailException` should be used instead. +- Deprecated `craft\commerce\events\EmailEvent`. `CraftCms\Commerce\Email\Events\EmailEvent` should be used instead. +- Deprecated `craft\commerce\events\MailEvent`. `CraftCms\Commerce\Email\Events\MailEvent` should be used instead. +- Removed `craft\commerce\records\Email`. `CraftCms\Commerce\Email\Records\Email` should be used instead. + +#### Controllers + +- Removed `craft\commerce\controllers\EmailsController`. `CraftCms\Commerce\Http\Controllers\Settings\EmailsController` should be used instead. +- Removed `craft\commerce\controllers\EmailPreviewController`. `CraftCms\Commerce\Http\Controllers\EmailPreviewController` should be used instead. + +### Pdf + +- Added `CraftCms\Commerce\Pdf\Pdfs`. +- Added `CraftCms\Commerce\Pdf\Records\Pdf`. +- Added `CraftCms\Commerce\Pdf\Models\Pdf`. +- Added `CraftCms\Commerce\Http\RateLimiters\PdfChallengeRateLimiter`, replacing the per-action `yii\filters\RateLimiter` behavior used to throttle PDF download challenge requests. +- Added `CraftCms\Commerce\Pdf\Events\PdfEvent`. +- Added `CraftCms\Commerce\Pdf\Events\PdfRenderEvent`. +- Added `CraftCms\Commerce\Pdf\Events\PdfRenderOptionsEvent`. +- Deprecated `craft\commerce\services\Pdfs`. `CraftCms\Commerce\Pdf\Pdfs` should be used instead. +- Deprecated `craft\commerce\models\Pdf`. `CraftCms\Commerce\Pdf\Models\Pdf` should be used instead. +- Deprecated `craft\commerce\events\PdfEvent`. `CraftCms\Commerce\Pdf\Events\PdfEvent` should be used instead. +- Deprecated `craft\commerce\events\PdfRenderEvent`. `CraftCms\Commerce\Pdf\Events\PdfRenderEvent` should be used instead. +- Deprecated `craft\commerce\events\PdfRenderOptionsEvent`. `CraftCms\Commerce\Pdf\Events\PdfRenderOptionsEvent` should be used instead. +- Removed `craft\commerce\records\Pdf`. `CraftCms\Commerce\Pdf\Records\Pdf` should be used instead. +- Updated dompdf/dompdf to ^3.1.6 (from ^2.0.2). + +#### Controllers + +- Removed `craft\commerce\controllers\PdfsController`. `CraftCms\Commerce\Http\Controllers\Settings\PdfsController` should be used instead. + +### Formulas + +- Added `CraftCms\Commerce\Formula\Formulas`. +- Deprecated `craft\commerce\services\Formulas`. `CraftCms\Commerce\Formula\Formulas` should be used instead. + +#### Controllers + +- Removed `craft\commerce\controllers\FormulasController`. `CraftCms\Commerce\Http\Controllers\FormulasController` should be used instead. + +### GraphQL + +- Added `CraftCms\Commerce\Gql\Arguments\Elements\Product`. +- Added `CraftCms\Commerce\Gql\Arguments\Elements\Variant`. +- Added `CraftCms\Commerce\Gql\Interfaces\Elements\Product`. +- Added `CraftCms\Commerce\Gql\Interfaces\Elements\Variant`. +- Added `CraftCms\Commerce\Gql\Queries\Product`. +- Added `CraftCms\Commerce\Gql\Queries\Variant`. +- Added `CraftCms\Commerce\Gql\Resolvers\Elements\Product`. +- Added `CraftCms\Commerce\Gql\Resolvers\Elements\Variant`. +- Added `CraftCms\Commerce\Gql\Types\Elements\Product`. +- Added `CraftCms\Commerce\Gql\Types\Elements\Variant`. +- Added `CraftCms\Commerce\Gql\Types\Generators\ProductType`. +- Added `CraftCms\Commerce\Gql\Types\Generators\VariantType`. +- Added `CraftCms\Commerce\Gql\Types\Input\IntFalse`. +- Added `CraftCms\Commerce\Gql\Types\Input\Product`. +- Added `CraftCms\Commerce\Gql\Types\Input\Variant`. +- Added `CraftCms\Commerce\Gql\Types\SaleType`. +- Deprecated `craft\commerce\gql\arguments\elements\Product`. `CraftCms\Commerce\Gql\Arguments\Elements\Product` should be used instead. +- Deprecated `craft\commerce\gql\arguments\elements\Variant`. `CraftCms\Commerce\Gql\Arguments\Elements\Variant` should be used instead. +- Deprecated `craft\commerce\gql\interfaces\elements\Product`. `CraftCms\Commerce\Gql\Interfaces\Elements\Product` should be used instead. +- Deprecated `craft\commerce\gql\interfaces\elements\Variant`. `CraftCms\Commerce\Gql\Interfaces\Elements\Variant` should be used instead. +- Deprecated `craft\commerce\gql\queries\Product`. `CraftCms\Commerce\Gql\Queries\Product` should be used instead. +- Deprecated `craft\commerce\gql\queries\Variant`. `CraftCms\Commerce\Gql\Queries\Variant` should be used instead. +- Deprecated `craft\commerce\gql\resolvers\elements\Product`. `CraftCms\Commerce\Gql\Resolvers\Elements\Product` should be used instead. +- Deprecated `craft\commerce\gql\resolvers\elements\Variant`. `CraftCms\Commerce\Gql\Resolvers\Elements\Variant` should be used instead. +- Deprecated `craft\commerce\gql\types\elements\Product`. `CraftCms\Commerce\Gql\Types\Elements\Product` should be used instead. +- Deprecated `craft\commerce\gql\types\elements\Variant`. `CraftCms\Commerce\Gql\Types\Elements\Variant` should be used instead. +- Deprecated `craft\commerce\gql\types\generators\ProductType`. `CraftCms\Commerce\Gql\Types\Generators\ProductType` should be used instead. +- Deprecated `craft\commerce\gql\types\generators\VariantType`. `CraftCms\Commerce\Gql\Types\Generators\VariantType` should be used instead. +- Deprecated `craft\commerce\gql\types\input\IntFalse`. `CraftCms\Commerce\Gql\Types\Input\IntFalse` should be used instead. +- Deprecated `craft\commerce\gql\types\input\Product`. `CraftCms\Commerce\Gql\Types\Input\Product` should be used instead. +- Deprecated `craft\commerce\gql\types\input\Variant`. `CraftCms\Commerce\Gql\Types\Input\Variant` should be used instead. +- Deprecated `craft\commerce\gql\types\SaleType`. `CraftCms\Commerce\Gql\Types\SaleType` should be used instead. +- Deprecated `craft\commerce\helpers\Gql`. `CraftCms\Commerce\Helpers\Gql` should be used instead. + +### Helpers + +- Added `CraftCms\Commerce\Helpers\Cp`. +- Added `CraftCms\Commerce\Helpers\Currency`. +- Added `CraftCms\Commerce\Helpers\Locale`. +- Added `CraftCms\Commerce\Helpers\Localization`. +- Added `CraftCms\Commerce\Helpers\Order`. +- Added `CraftCms\Commerce\Helpers\ProductQuery`. +- Added `CraftCms\Commerce\Helpers\ProjectConfigData`. +- Added `CraftCms\Commerce\Helpers\Purchasable`. +- Deprecated `craft\commerce\helpers\Cp`. `CraftCms\Commerce\Helpers\Cp` should be used instead. +- Deprecated `craft\commerce\helpers\Currency`. `CraftCms\Commerce\Helpers\Currency` should be used instead. +- Deprecated `craft\commerce\helpers\Locale`. `CraftCms\Commerce\Helpers\Locale` should be used instead. +- Deprecated `craft\commerce\helpers\Localization`. `CraftCms\Commerce\Helpers\Localization` should be used instead. +- Deprecated `craft\commerce\helpers\Order`. `CraftCms\Commerce\Helpers\Order` should be used instead. +- Deprecated `craft\commerce\helpers\ProductQuery`. `CraftCms\Commerce\Helpers\ProductQuery` should be used instead. +- Deprecated `craft\commerce\helpers\ProjectConfigData`. `CraftCms\Commerce\Helpers\ProjectConfigData` should be used instead. +- Deprecated `craft\commerce\helpers\Purchasable`. `CraftCms\Commerce\Helpers\Purchasable` should be used instead. + +### Inventory + +- Added `CraftCms\Commerce\Inventory\Inventory`. +- Added `CraftCms\Commerce\Inventory\InventoryLocations`. +- Added `CraftCms\Commerce\Inventory\Records\InventoryItem`. +- Added `CraftCms\Commerce\Inventory\Records\InventoryLocation`. +- Added `CraftCms\Commerce\Inventory\Collections\InventoryMovementCollection`. +- Added `CraftCms\Commerce\Inventory\Collections\UpdateInventoryLevelCollection`. +- Added `CraftCms\Commerce\Inventory\Models\InventoryLocation`. +- Added `CraftCms\Commerce\Inventory\Models\InventoryMovement`. +- Added `CraftCms\Commerce\Inventory\Models\InventoryManualMovement`. +- Added `CraftCms\Commerce\Inventory\Models\InventoryCommittedMovement`. +- Added `CraftCms\Commerce\Inventory\Models\InventoryFulfillMovement`. +- Added `CraftCms\Commerce\Inventory\Models\InventoryRestockMovement`. +- Added `CraftCms\Commerce\Inventory\Models\InventoryTransferMovement`. +- Added `CraftCms\Commerce\Inventory\Models\InventoryLocationDeactivatedMovement`. +- Added `CraftCms\Commerce\Inventory\Models\DeactivateInventoryLocation`. +- Added `CraftCms\Commerce\Inventory\Models\InventoryItem`. +- Added `CraftCms\Commerce\Inventory\Models\InventoryFulfillmentLevel`. +- Added `CraftCms\Commerce\Inventory\Models\InventoryLevel`. +- Added `CraftCms\Commerce\Inventory\Models\InventoryTransaction`. +- Added `CraftCms\Commerce\Inventory\Models\UpdateInventoryLevel`. +- Added `CraftCms\Commerce\Inventory\Models\UpdateInventoryLevelInTransfer`. +- Added `CraftCms\Commerce\Inventory\Concerns\InventoryItemTrait`. +- Added `CraftCms\Commerce\Inventory\Concerns\InventoryLocationTrait`. +- Added `CraftCms\Commerce\Inventory\Contracts\InventoryMovementInterface`. +- Added `CraftCms\Commerce\Inventory\Enums\InventoryTransactionType` enum. +- Added `CraftCms\Commerce\Inventory\Enums\InventoryUpdateQuantityType` enum. +- Added `CraftCms\Commerce\Inventory\Events\InventoryMovementEvent`. +- Added `CraftCms\Commerce\Inventory\Events\UpdateInventoryLevelEvent`. +- Deprecated `craft\commerce\services\Inventory`. `CraftCms\Commerce\Inventory\Inventory` should be used instead. +- Deprecated `craft\commerce\services\InventoryLocations`. `CraftCms\Commerce\Inventory\InventoryLocations` should be used instead. +- Deprecated `craft\commerce\collections\InventoryMovementCollection`. `CraftCms\Commerce\Inventory\Collections\InventoryMovementCollection` should be used instead. +- Deprecated `craft\commerce\collections\UpdateInventoryLevelCollection`. `CraftCms\Commerce\Inventory\Collections\UpdateInventoryLevelCollection` should be used instead. +- Deprecated `craft\commerce\models\InventoryLocation`. `CraftCms\Commerce\Inventory\Models\InventoryLocation` should be used instead. +- Deprecated `craft\commerce\base\InventoryMovement`. `CraftCms\Commerce\Inventory\Models\InventoryMovement` should be used instead. +- Deprecated `craft\commerce\models\inventory\InventoryManualMovement`. `CraftCms\Commerce\Inventory\Models\InventoryManualMovement` should be used instead. +- Deprecated `craft\commerce\models\inventory\InventoryCommittedMovement`. `CraftCms\Commerce\Inventory\Models\InventoryCommittedMovement` should be used instead. +- Deprecated `craft\commerce\models\inventory\InventoryFulfillMovement`. `CraftCms\Commerce\Inventory\Models\InventoryFulfillMovement` should be used instead. +- Deprecated `craft\commerce\models\inventory\InventoryRestockMovement`. `CraftCms\Commerce\Inventory\Models\InventoryRestockMovement` should be used instead. +- Deprecated `craft\commerce\models\inventory\InventoryTransferMovement`. `CraftCms\Commerce\Inventory\Models\InventoryTransferMovement` should be used instead. +- Deprecated `craft\commerce\models\inventory\InventoryLocationDeactivatedMovement`. `CraftCms\Commerce\Inventory\Models\InventoryLocationDeactivatedMovement` should be used instead. +- Deprecated `craft\commerce\models\inventory\DeactivateInventoryLocation`. `CraftCms\Commerce\Inventory\Models\DeactivateInventoryLocation` should be used instead. +- Deprecated `craft\commerce\models\InventoryItem`. `CraftCms\Commerce\Inventory\Models\InventoryItem` should be used instead. +- Deprecated `craft\commerce\models\InventoryFulfillmentLevel`. `CraftCms\Commerce\Inventory\Models\InventoryFulfillmentLevel` should be used instead. +- Deprecated `craft\commerce\models\InventoryLevel`. `CraftCms\Commerce\Inventory\Models\InventoryLevel` should be used instead. +- Deprecated `craft\commerce\models\InventoryTransaction`. `CraftCms\Commerce\Inventory\Models\InventoryTransaction` should be used instead. +- Deprecated `craft\commerce\models\inventory\UpdateInventoryLevel`. `CraftCms\Commerce\Inventory\Models\UpdateInventoryLevel` should be used instead. +- Deprecated `craft\commerce\models\inventory\UpdateInventoryLevelInTransfer`. `CraftCms\Commerce\Inventory\Models\UpdateInventoryLevelInTransfer` should be used instead. +- Deprecated `craft\commerce\base\InventoryItemTrait`. `CraftCms\Commerce\Inventory\Concerns\InventoryItemTrait` should be used instead. +- Deprecated `craft\commerce\base\InventoryLocationTrait`. `CraftCms\Commerce\Inventory\Concerns\InventoryLocationTrait` should be used instead. +- Deprecated `craft\commerce\base\InventoryMovementInterface`. `CraftCms\Commerce\Inventory\Contracts\InventoryMovementInterface` should be used instead. +- Deprecated `craft\commerce\enums\InventoryTransactionType`. `CraftCms\Commerce\Inventory\Enums\InventoryTransactionType` should be used instead. +- Deprecated `craft\commerce\enums\InventoryUpdateQuantityType`. `CraftCms\Commerce\Inventory\Enums\InventoryUpdateQuantityType` should be used instead. +- Deprecated `craft\commerce\events\InventoryMovementEvent`. `CraftCms\Commerce\Inventory\Events\InventoryMovementEvent` should be used instead. +- Deprecated `craft\commerce\events\UpdateInventoryLevelEvent`. `CraftCms\Commerce\Inventory\Events\UpdateInventoryLevelEvent` should be used instead. +- Removed `craft\commerce\records\InventoryItem`. `CraftCms\Commerce\Inventory\Records\InventoryItem` should be used instead. +- Removed `craft\commerce\records\InventoryLocation`. `CraftCms\Commerce\Inventory\Records\InventoryLocation` should be used instead. + +#### Controllers + +- Removed `craft\commerce\controllers\InventoryController`. `CraftCms\Commerce\Http\Controllers\InventoryController` should be used instead. +- Removed `craft\commerce\controllers\InventoryLocationsController`. `CraftCms\Commerce\Http\Controllers\InventoryLocationsController` should be used instead. + +### Orders + +- Added `CraftCms\Commerce\Order\Elements\Order`. +- Added `CraftCms\Commerce\Order\Queries\OrderQuery`. +- Added `CraftCms\Commerce\Order\Models\Order`. +- Added `CraftCms\Commerce\Order\Models\OrderStatus`. +- Added `CraftCms\Commerce\Order\Models\OrderAdjustment`. +- Added `CraftCms\Commerce\Order\Models\OrderNotice`. +- Added `CraftCms\Commerce\Order\Models\OrderHistory`. +- Added `CraftCms\Commerce\Order\Models\LineItemStatus`. +- Added `CraftCms\Commerce\Order\Records\OrderHistory`. +- Added `CraftCms\Commerce\Order\Records\OrderAdjustment`. +- Added `CraftCms\Commerce\Order\Records\LineItemStatus`. +- Added `CraftCms\Commerce\Order\Records\OrderStatus`. +- Added `CraftCms\Commerce\Order\Records\OrderNotice`. +- Added `CraftCms\Commerce\Order\Exceptions\OrderAdjustmentNotFoundException`. +- Added `CraftCms\Commerce\Order\Exceptions\CurrencyException`. +- Added `CraftCms\Commerce\Order\Exceptions\LineItemNotFoundException`. +- Added `CraftCms\Commerce\Order\Exceptions\OrderStatusException`. +- Added `CraftCms\Commerce\Order\Exceptions\LineItemException`. +- Added `CraftCms\Commerce\Order\LineItem\Data\LineItem`. +- Added `CraftCms\Commerce\Order\LineItem\Models\LineItem`. +- Added `CraftCms\Commerce\Order\LineItem\LineItems`. +- Added `CraftCms\Commerce\Order\LineItem\Enums\LineItemType` enum. +- Added `CraftCms\Commerce\Order\Orders`. +- Added `CraftCms\Commerce\Order\Carts`. +- Added `CraftCms\Commerce\Order\OrderNotices`. +- Added `CraftCms\Commerce\Order\OrderHistories`. +- Added `CraftCms\Commerce\Order\OrderAdjustments`. +- Added `CraftCms\Commerce\Order\OrderStatuses`. +- Added `CraftCms\Commerce\Order\LineItemStatuses`. +- Added `CraftCms\Commerce\Order\Adjuster\Tax`. +- Added `CraftCms\Commerce\Order\Adjuster\Shipping`. +- Added `CraftCms\Commerce\Order\Adjuster\Discount`. +- Added `CraftCms\Commerce\Order\Adjuster\Contracts\AdjusterInterface`. +- Added `CraftCms\Commerce\Order\Adjuster\AdjusterTypes`, a `CraftCms\Cms\Component\TypeRegistry` for registering order adjuster types. +- Added `CraftCms\Commerce\Order\Adjuster\DiscountAdjusterTypes`, a `CraftCms\Cms\Component\TypeRegistry` for registering adjuster types that should be treated as discounts. +- Deprecated `craft\commerce\services\OrderAdjustments::EVENT_REGISTER_ORDER_ADJUSTERS`. `CraftCms\Commerce\Order\Adjuster\AdjusterTypes::register()` should be used instead. +- Deprecated `craft\commerce\services\OrderAdjustments::EVENT_REGISTER_DISCOUNT_ADJUSTERS`. `CraftCms\Commerce\Order\Adjuster\DiscountAdjusterTypes::register()` should be used instead. +- Added `CraftCms\Commerce\Order\Exporters\Expanded`. +- Added `CraftCms\Commerce\Order\Exporters\LineItemExport`. +- Added `CraftCms\Commerce\Order\Exporters\OrderExport`. +- Added `CraftCms\Commerce\Http\Controllers\Concerns\HasCartArray`, a trait shared between `CartController` and `PaymentsController` for building a cart's array representation. +- Added `CraftCms\Commerce\Http\RateLimiters\CartRateLimiter` and `CraftCms\Commerce\Http\RateLimiters\CartChallengeRateLimiter`. +- Added `CraftCms\Commerce\Order\Events\AddLineItemEvent`. +- Added `CraftCms\Commerce\Order\Events\CartEvent`. +- Added `CraftCms\Commerce\Order\Events\CartPurgeEvent`. +- Added `CraftCms\Commerce\Order\Events\DefaultLineItemStatusEvent`. +- Added `CraftCms\Commerce\Order\Events\DefaultOrderStatusEvent`. +- Added `CraftCms\Commerce\Order\Events\LineItemEvent`. +- Added `CraftCms\Commerce\Order\Events\ModifyCartInfoEvent`. +- Added `CraftCms\Commerce\Order\Events\OrderLineItemsRefreshEvent`. +- Added `CraftCms\Commerce\Order\Events\OrderNoticeEvent`. +- Added `CraftCms\Commerce\Order\Events\OrderStatusEmailsEvent`. +- Added `CraftCms\Commerce\Order\Events\OrderStatusEvent`. +- Added `CraftCms\Commerce\Order\Events\PurgeAddressesEvent`. +- Deprecated `craft\commerce\elements\Order`. `CraftCms\Commerce\Order\Elements\Order` should be used instead. +- Deprecated `craft\commerce\elements\db\OrderQuery`. `CraftCms\Commerce\Order\Queries\OrderQuery` should be used instead. +- Deprecated `craft\commerce\records\Order`. `CraftCms\Commerce\Order\Models\Order` should be used instead. +- Deprecated `craft\commerce\models\OrderStatus`. `CraftCms\Commerce\Order\Models\OrderStatus` should be used instead. +- Deprecated `craft\commerce\models\OrderAdjustment`. `CraftCms\Commerce\Order\Models\OrderAdjustment` should be used instead. +- Deprecated `craft\commerce\models\OrderNotice`. `CraftCms\Commerce\Order\Models\OrderNotice` should be used instead. +- Deprecated `craft\commerce\models\OrderHistory`. `CraftCms\Commerce\Order\Models\OrderHistory` should be used instead. +- Deprecated `craft\commerce\models\LineItemStatus`. `CraftCms\Commerce\Order\Models\LineItemStatus` should be used instead. +- Deprecated `craft\commerce\models\LineItem`. `CraftCms\Commerce\Order\LineItem\Data\LineItem` should be used instead. +- Deprecated `craft\commerce\records\LineItem`. `CraftCms\Commerce\Order\LineItem\Models\LineItem` should be used instead. +- Deprecated `craft\commerce\services\LineItems`. `CraftCms\Commerce\Order\LineItem\LineItems` should be used instead. +- Deprecated `craft\commerce\enums\LineItemType`. `CraftCms\Commerce\Order\LineItem\Enums\LineItemType` should be used instead. +- Deprecated `craft\commerce\services\Orders`. `CraftCms\Commerce\Order\Orders` should be used instead. +- Deprecated `craft\commerce\services\Carts`. `CraftCms\Commerce\Order\Carts` should be used instead. +- Deprecated `craft\commerce\services\OrderNotices`. `CraftCms\Commerce\Order\OrderNotices` should be used instead. +- Deprecated `craft\commerce\services\OrderHistories`. `CraftCms\Commerce\Order\OrderHistories` should be used instead. +- Deprecated `craft\commerce\services\OrderAdjustments`. `CraftCms\Commerce\Order\OrderAdjustments` should be used instead. +- Deprecated `craft\commerce\services\OrderStatuses`. `CraftCms\Commerce\Order\OrderStatuses` should be used instead. +- Deprecated `craft\commerce\services\LineItemStatuses`. `CraftCms\Commerce\Order\LineItemStatuses` should be used instead. +- Deprecated `craft\commerce\adjusters\Tax`. `CraftCms\Commerce\Order\Adjuster\Tax` should be used instead. +- Deprecated `craft\commerce\adjusters\Shipping`. `CraftCms\Commerce\Order\Adjuster\Shipping` should be used instead. +- Deprecated `craft\commerce\adjusters\Discount`. `CraftCms\Commerce\Order\Adjuster\Discount` should be used instead. +- Deprecated `craft\commerce\base\AdjusterInterface`. `CraftCms\Commerce\Order\Adjuster\Contracts\AdjusterInterface` should be used instead. +- Deprecated `craft\commerce\exports\Expanded`. `CraftCms\Commerce\Order\Exporters\Expanded` should be used instead. +- Deprecated `craft\commerce\exports\LineItemExport`. `CraftCms\Commerce\Order\Exporters\LineItemExport` should be used instead. +- Deprecated `craft\commerce\exports\OrderExport`. `CraftCms\Commerce\Order\Exporters\OrderExport` should be used instead. +- Deprecated `craft\commerce\errors\OrderAdjustmentNotFoundException`. `CraftCms\Commerce\Order\Exceptions\OrderAdjustmentNotFoundException` should be used instead. +- Deprecated `craft\commerce\errors\CurrencyException`. `CraftCms\Commerce\Order\Exceptions\CurrencyException` should be used instead. +- Deprecated `craft\commerce\errors\LineItemNotFoundException`. `CraftCms\Commerce\Order\Exceptions\LineItemNotFoundException` should be used instead. +- Deprecated `craft\commerce\errors\OrderStatusException`. `CraftCms\Commerce\Order\Exceptions\OrderStatusException` should be used instead. +- Deprecated `craft\commerce\errors\LineItemException`. `CraftCms\Commerce\Order\Exceptions\LineItemException` should be used instead. +- Deprecated `craft\commerce\events\AddLineItemEvent`. `CraftCms\Commerce\Order\Events\AddLineItemEvent` should be used instead. +- Deprecated `craft\commerce\events\CartEvent`. `CraftCms\Commerce\Order\Events\CartEvent` should be used instead. +- Deprecated `craft\commerce\events\CartPurgeEvent`. `CraftCms\Commerce\Order\Events\CartPurgeEvent` should be used instead. +- Deprecated `craft\commerce\events\DefaultLineItemStatusEvent`. `CraftCms\Commerce\Order\Events\DefaultLineItemStatusEvent` should be used instead. +- Deprecated `craft\commerce\events\DefaultOrderStatusEvent`. `CraftCms\Commerce\Order\Events\DefaultOrderStatusEvent` should be used instead. +- Deprecated `craft\commerce\events\LineItemEvent`. `CraftCms\Commerce\Order\Events\LineItemEvent` should be used instead. +- Deprecated `craft\commerce\events\ModifyCartInfoEvent`. `CraftCms\Commerce\Order\Events\ModifyCartInfoEvent` should be used instead. +- Deprecated `craft\commerce\events\OrderLineItemsRefreshEvent`. `CraftCms\Commerce\Order\Events\OrderLineItemsRefreshEvent` should be used instead. +- Deprecated `craft\commerce\events\OrderNoticeEvent`. `CraftCms\Commerce\Order\Events\OrderNoticeEvent` should be used instead. +- Deprecated `craft\commerce\events\OrderStatusEmailsEvent`. `CraftCms\Commerce\Order\Events\OrderStatusEmailsEvent` should be used instead. +- Deprecated `craft\commerce\events\OrderStatusEvent`. `CraftCms\Commerce\Order\Events\OrderStatusEvent` should be used instead. +- Deprecated `craft\commerce\events\PurgeAddressesEvent`. `CraftCms\Commerce\Order\Events\PurgeAddressesEvent` should be used instead. +- Removed `craft\commerce\records\OrderHistory`. `CraftCms\Commerce\Order\Records\OrderHistory` should be used instead. +- Removed `craft\commerce\records\OrderAdjustment`. `CraftCms\Commerce\Order\Records\OrderAdjustment` should be used instead. +- Removed `craft\commerce\records\LineItemStatus`. `CraftCms\Commerce\Order\Records\LineItemStatus` should be used instead. +- Removed `craft\commerce\records\OrderStatus`. `CraftCms\Commerce\Order\Records\OrderStatus` should be used instead. +- Removed `craft\commerce\records\OrderNotice`. `CraftCms\Commerce\Order\Records\OrderNotice` should be used instead. +- Removed `LineItem::getSaleAmount()`, `refreshFromPurchasable()`, and `populateFromPurchasable()` as they had no remaining call sites. +- Removed `LineItems::createLineItem()` as it had no remaining call sites. +- Removed `Carts::getCartName()`. The `cartCookie['name']` config setting should be used instead. +- Added `CraftCms\Commerce\Order\Conditions\OrderCondition`, `CompletedConditionRule`, `CouponCodeConditionRule`, `CustomerConditionRule`, `DateOrderedConditionRule`, `HasAdminNoticesConditionRule`, `PaidConditionRule`, `HasPurchasableConditionRule`, `ContainsPurchasablesConditionRule`, `OrderStatusConditionRule`, `OrderSiteConditionRule`, `PaymentGatewayConditionRule`, `ReferenceConditionRule`, `ShippingMethodConditionRule`, `ShippingAddressZoneConditionRule`, `DiscountedItemSubtotalConditionRule`, `ItemSubtotalConditionRule`, `ItemTotalConditionRule`, `TotalConditionRule`, `TotalDiscountConditionRule`, `TotalPaidConditionRule`, `TotalPriceConditionRule`, `TotalQtyConditionRule`, `TotalTaxConditionRule`, and `TotalWeightConditionRule`. +- Added `CraftCms\Commerce\Order\Conditions\OrderTextValuesAttributeConditionRule`, `OrderValuesAttributeConditionRule`, and `OrderCurrencyValuesAttributeConditionRule`. +- Added `CraftCms\Commerce\Order\Conditions\DiscountOrderCondition`, `GatewayOrderCondition`, `ShippingMethodOrderCondition`, and `ShippingRuleOrderCondition`. +- Deprecated `craft\commerce\elements\conditions\orders\*`. The `CraftCms\Commerce\Order\Conditions` equivalents should be used instead. +- Added `CraftCms\Commerce\Order\Actions\CopyLoadCartUrl`, `DownloadOrderPdfAction`, and `UpdateOrderStatus`. +- Deprecated `craft\commerce\elements\actions\CopyLoadCartUrl`, `DownloadOrderPdfAction`, and `UpdateOrderStatus`. The `CraftCms\Commerce\Order\Actions` equivalents should be used instead. + +#### Controllers + +- Removed `craft\commerce\controllers\OrdersController`. `CraftCms\Commerce\Http\Controllers\OrdersController` should be used instead. +- Removed `craft\commerce\controllers\CartController`. `CraftCms\Commerce\Http\Controllers\CartController` should be used instead. +- Removed `craft\commerce\controllers\OrderStatusesController`. `CraftCms\Commerce\Http\Controllers\Settings\OrderStatusesController` should be used instead. +- Removed `craft\commerce\controllers\LineItemStatusesController`. `CraftCms\Commerce\Http\Controllers\Settings\LineItemStatusesController` should be used instead. +- Removed `craft\commerce\controllers\UserOrdersController`. `CraftCms\Commerce\Http\Controllers\UserOrdersController` should be used instead. +- Removed `craft\commerce\controllers\OrderSettingsController`. `CraftCms\Commerce\Http\Controllers\Settings\OrderSettingsController` should be used instead. +- Removed `craft\commerce\controllers\DownloadsController`. `CraftCms\Commerce\Http\Controllers\DownloadsController` should be used instead. + +### Payments + +- Added `CraftCms\Commerce\Payment\Transactions`. +- Added `CraftCms\Commerce\Payment\PaymentSources`. +- Added `CraftCms\Commerce\Payment\Gateway\Gateways`. +- Added `CraftCms\Commerce\Payment\Payments`. +- Added `CraftCms\Commerce\Payment\Webhooks`. +- Added `CraftCms\Commerce\Payment\Currencies`. +- Added `CraftCms\Commerce\Payment\PaymentCurrencies`. +- Added `CraftCms\Commerce\Payment\Records\Transaction`. +- Added `CraftCms\Commerce\Payment\Records\PaymentSource`. +- Added `CraftCms\Commerce\Payment\Gateway\Records\Gateway`. +- Added `CraftCms\Commerce\Payment\Records\PaymentCurrency`. +- Added `CraftCms\Commerce\Payment\Models\Transaction`. +- Added `CraftCms\Commerce\Payment\Models\PaymentSource`. +- Added `CraftCms\Commerce\Payment\Models\PaymentCurrency`. +- Added `CraftCms\Commerce\Payment\Forms\BasePaymentForm`. +- Added `CraftCms\Commerce\Payment\Forms\OffsitePaymentForm`. +- Added `CraftCms\Commerce\Payment\Forms\CreditCardPaymentForm`. +- Added `CraftCms\Commerce\Payment\Forms\DummyPaymentForm`. +- Added `CraftCms\Commerce\Payment\Gateway\Responses\Dummy`. +- Added `CraftCms\Commerce\Payment\Gateway\Responses\Manual`. +- Added `CraftCms\Commerce\Payment\Exceptions\PaymentException`. +- Added `CraftCms\Commerce\Payment\Exceptions\PaymentSourceException`. +- Added `CraftCms\Commerce\Payment\Exceptions\PaymentSourceCreatedLaterException`. +- Added `CraftCms\Commerce\Payment\Exceptions\RefundException`. +- Added `CraftCms\Commerce\Payment\Exceptions\TransactionException`. +- Added `CraftCms\Commerce\Payment\Gateway\Exceptions\GatewayException`. +- Added `CraftCms\Commerce\Payment\Gateway\Contracts\GatewayInterface`. +- Added `CraftCms\Commerce\Payment\Gateway\Contracts\RequestResponseInterface`. +- Added `CraftCms\Commerce\Payment\Gateway\GatewayTypes`, a `CraftCms\Cms\Component\TypeRegistry` for registering gateway types. +- Deprecated `craft\commerce\services\Gateways::EVENT_REGISTER_GATEWAY_TYPES`. `CraftCms\Commerce\Payment\Gateway\GatewayTypes::register()` should be used instead. +- Added `CraftCms\Commerce\Payment\Gateway\Gateway`. +- Added `CraftCms\Commerce\Payment\Gateway\Types\Dummy`. +- Added `CraftCms\Commerce\Payment\Gateway\Types\Manual`. +- Added `CraftCms\Commerce\Payment\Gateway\Types\MissingGateway`. +- Added `CraftCms\Commerce\Helpers\PaymentForm`. +- Added `CraftCms\Commerce\Payment\Events\PaymentCurrencyRateEvent`. +- Added `CraftCms\Commerce\Payment\Events\PaymentSourceEvent`. +- Added `CraftCms\Commerce\Payment\Events\ProcessPaymentEvent`. +- Added `CraftCms\Commerce\Payment\Events\RefundTransactionEvent`. +- Added `CraftCms\Commerce\Payment\Events\TransactionEvent`. +- Added `CraftCms\Commerce\Payment\Events\UpdatePrimaryPaymentSourceEvent`. +- Added `CraftCms\Commerce\Payment\Events\WebhookEvent`. +- Deprecated `craft\commerce\services\Transactions`. `CraftCms\Commerce\Payment\Transactions` should be used instead. +- Deprecated `craft\commerce\services\PaymentSources`. `CraftCms\Commerce\Payment\PaymentSources` should be used instead. +- Deprecated `craft\commerce\services\Gateways`. `CraftCms\Commerce\Payment\Gateway\Gateways` should be used instead. +- Deprecated `craft\commerce\base\Gateway`. `CraftCms\Commerce\Payment\Gateway\Gateway` should be used instead. +- Deprecated `craft\commerce\base\GatewayTrait`. Its properties and methods are now part of `CraftCms\Commerce\Payment\Gateway\Gateway`. +- Deprecated `craft\commerce\gateways\Dummy`. `CraftCms\Commerce\Payment\Gateway\Types\Dummy` should be used instead. +- Deprecated `craft\commerce\gateways\Manual`. `CraftCms\Commerce\Payment\Gateway\Types\Manual` should be used instead. +- Deprecated `craft\commerce\gateways\MissingGateway`. `CraftCms\Commerce\Payment\Gateway\Types\MissingGateway` should be used instead. +- Deprecated `craft\commerce\helpers\PaymentForm`. `CraftCms\Commerce\Helpers\PaymentForm` should be used instead. +- Deprecated `craft\commerce\services\Payments`. `CraftCms\Commerce\Payment\Payments` should be used instead. +- Deprecated `craft\commerce\services\Webhooks`. `CraftCms\Commerce\Payment\Webhooks` should be used instead. +- Deprecated `craft\commerce\services\Currencies`. `CraftCms\Commerce\Payment\Currencies` should be used instead. +- Deprecated `craft\commerce\services\PaymentCurrencies`. `CraftCms\Commerce\Payment\PaymentCurrencies` should be used instead. +- Deprecated `craft\commerce\models\Transaction`. `CraftCms\Commerce\Payment\Models\Transaction` should be used instead. +- Deprecated `craft\commerce\models\PaymentSource`. `CraftCms\Commerce\Payment\Models\PaymentSource` should be used instead. +- Deprecated `craft\commerce\models\PaymentCurrency`. `CraftCms\Commerce\Payment\Models\PaymentCurrency` should be used instead. +- Deprecated `craft\commerce\models\payments\BasePaymentForm`. `CraftCms\Commerce\Payment\Forms\BasePaymentForm` should be used instead. +- Deprecated `craft\commerce\models\payments\OffsitePaymentForm`. `CraftCms\Commerce\Payment\Forms\OffsitePaymentForm` should be used instead. +- Deprecated `craft\commerce\models\payments\CreditCardPaymentForm`. `CraftCms\Commerce\Payment\Forms\CreditCardPaymentForm` should be used instead. +- Deprecated `craft\commerce\models\payments\DummyPaymentForm`. `CraftCms\Commerce\Payment\Forms\DummyPaymentForm` should be used instead. +- Deprecated `craft\commerce\models\responses\Dummy`. `CraftCms\Commerce\Payment\Gateway\Responses\Dummy` should be used instead. +- Deprecated `craft\commerce\models\responses\Manual`. `CraftCms\Commerce\Payment\Gateway\Responses\Manual` should be used instead. +- Deprecated `craft\commerce\errors\PaymentException`. `CraftCms\Commerce\Payment\Exceptions\PaymentException` should be used instead. +- Deprecated `craft\commerce\errors\PaymentSourceException`. `CraftCms\Commerce\Payment\Exceptions\PaymentSourceException` should be used instead. +- Deprecated `craft\commerce\errors\PaymentSourceCreatedLaterException`. `CraftCms\Commerce\Payment\Exceptions\PaymentSourceCreatedLaterException` should be used instead. +- Deprecated `craft\commerce\errors\RefundException`. `CraftCms\Commerce\Payment\Exceptions\RefundException` should be used instead. +- Deprecated `craft\commerce\errors\TransactionException`. `CraftCms\Commerce\Payment\Exceptions\TransactionException` should be used instead. +- Deprecated `craft\commerce\errors\GatewayException`. `CraftCms\Commerce\Payment\Gateway\Exceptions\GatewayException` should be used instead. +- Deprecated `craft\commerce\base\GatewayInterface`. `CraftCms\Commerce\Payment\Gateway\Contracts\GatewayInterface` should be used instead. +- Deprecated `craft\commerce\base\RequestResponseInterface`. `CraftCms\Commerce\Payment\Gateway\Contracts\RequestResponseInterface` should be used instead. +- Deprecated `craft\commerce\events\PaymentSourceEvent`. `CraftCms\Commerce\Payment\Events\PaymentSourceEvent` should be used instead. +- Deprecated `craft\commerce\events\ProcessPaymentEvent`. `CraftCms\Commerce\Payment\Events\ProcessPaymentEvent` should be used instead. +- Deprecated `craft\commerce\events\RefundTransactionEvent`. `CraftCms\Commerce\Payment\Events\RefundTransactionEvent` should be used instead. +- Deprecated `craft\commerce\events\TransactionEvent`. `CraftCms\Commerce\Payment\Events\TransactionEvent` should be used instead. +- Deprecated `craft\commerce\events\UpdatePrimaryPaymentSourceEvent`. `CraftCms\Commerce\Payment\Events\UpdatePrimaryPaymentSourceEvent` should be used instead. +- Deprecated `craft\commerce\events\WebhookEvent`. `CraftCms\Commerce\Payment\Events\WebhookEvent` should be used instead. +- Removed `craft\commerce\records\Transaction`. `CraftCms\Commerce\Payment\Records\Transaction` should be used instead. +- Removed `craft\commerce\records\PaymentSource`. `CraftCms\Commerce\Payment\Records\PaymentSource` should be used instead. +- Removed `craft\commerce\records\Gateway`. `CraftCms\Commerce\Payment\Gateway\Records\Gateway` should be used instead. +- Removed `craft\commerce\records\PaymentCurrency`. `CraftCms\Commerce\Payment\Records\PaymentCurrency` should be used instead. +- Removed `Gateways::getGatewayOverrides()`. It depended on the `commerce-gateways.php` config-file override mechanism. +- Removed `Transactions::deleteTransaction()`. `deleteTransactionById()` should be used instead. +- Removed `PaymentCurrencies::convertCurrency()`. `convert()` or `convertAmount()` should be used instead. +- Widened `RefundTransactionEvent::$amount` to `?float` to allow `null` for a full refund. +- Widened `WebhookEvent::$response` to accept both `Illuminate\Http\Response` and `yii\web\Response`. +- `craft\commerce\base\Gateway` now uses `CraftCms\Commerce\Order\Conditions\GatewayOrderCondition` and `CraftCms\Commerce\Address\Conditions\GatewayAddressCondition`. + +#### Controllers + +- Removed `craft\commerce\controllers\PaymentsController`. `CraftCms\Commerce\Http\Controllers\PaymentsController` should be used instead. +- Removed `craft\commerce\controllers\PaymentSourcesController`. `CraftCms\Commerce\Http\Controllers\PaymentSourcesController` should be used instead. +- Removed `craft\commerce\controllers\WebhooksController`. `CraftCms\Commerce\Http\Controllers\WebhooksController` should be used instead. +- Removed `craft\commerce\controllers\Settings\GatewaysController`. `CraftCms\Commerce\Http\Controllers\Settings\GatewaysController` should be used instead. +- Removed `craft\commerce\controllers\PaymentCurrenciesController`. `CraftCms\Commerce\Http\Controllers\Settings\PaymentCurrenciesController` should be used instead. + +### Promotions + +- Added `CraftCms\Commerce\Promotion\Discounts`. +- Added `CraftCms\Commerce\Promotion\Sales`. +- Added `CraftCms\Commerce\Promotion\Coupons`. +- Added `CraftCms\Commerce\Promotion\Models\Discount`. +- Added `CraftCms\Commerce\Promotion\Models\Sale`. +- Added `CraftCms\Commerce\Promotion\Models\Coupon`. +- Added `CraftCms\Commerce\Promotion\Records\Discount`. +- Added `CraftCms\Commerce\Promotion\Records\DiscountCategory`. +- Added `CraftCms\Commerce\Promotion\Records\DiscountPurchasable`. +- Added `CraftCms\Commerce\Promotion\Records\CustomerDiscountUse`. +- Added `CraftCms\Commerce\Promotion\Records\EmailDiscountUse`. +- Added `CraftCms\Commerce\Promotion\Records\Sale`. +- Added `CraftCms\Commerce\Promotion\Records\SaleCategory`. +- Added `CraftCms\Commerce\Promotion\Records\SalePurchasable`. +- Added `CraftCms\Commerce\Promotion\Records\SaleUserGroup`. +- Added `CraftCms\Commerce\Promotion\Records\Coupon`. +- Added `CraftCms\Commerce\Promotion\Events\DiscountAdjustmentsEvent`. +- Added `CraftCms\Commerce\Promotion\Events\DiscountEvent`. +- Added `CraftCms\Commerce\Promotion\Events\MatchLineItemEvent`. +- Added `CraftCms\Commerce\Promotion\Events\MatchOrderEvent`. +- Added `CraftCms\Commerce\Promotion\Events\SaleEvent`. +- Added `CraftCms\Commerce\Promotion\Events\SaleMatchEvent`. +- Deprecated `craft\commerce\services\Discounts`. `CraftCms\Commerce\Promotion\Discounts` should be used instead. +- Deprecated `craft\commerce\services\Sales`. `CraftCms\Commerce\Promotion\Sales` should be used instead. +- Deprecated `craft\commerce\services\Coupons`. `CraftCms\Commerce\Promotion\Coupons` should be used instead. +- Deprecated `craft\commerce\models\Discount`. `CraftCms\Commerce\Promotion\Models\Discount` should be used instead. +- Deprecated `craft\commerce\models\Sale`. `CraftCms\Commerce\Promotion\Models\Sale` should be used instead. +- Deprecated `craft\commerce\models\Coupon`. `CraftCms\Commerce\Promotion\Models\Coupon` should be used instead. +- Deprecated `craft\commerce\events\DiscountAdjustmentsEvent`. `CraftCms\Commerce\Promotion\Events\DiscountAdjustmentsEvent` should be used instead. +- Deprecated `craft\commerce\events\DiscountEvent`. `CraftCms\Commerce\Promotion\Events\DiscountEvent` should be used instead. +- Deprecated `craft\commerce\events\MatchLineItemEvent`. `CraftCms\Commerce\Promotion\Events\MatchLineItemEvent` should be used instead. +- Deprecated `craft\commerce\events\MatchOrderEvent`. `CraftCms\Commerce\Promotion\Events\MatchOrderEvent` should be used instead. +- Deprecated `craft\commerce\events\SaleEvent`. `CraftCms\Commerce\Promotion\Events\SaleEvent` should be used instead. +- Deprecated `craft\commerce\events\SaleMatchEvent`. `CraftCms\Commerce\Promotion\Events\SaleMatchEvent` should be used instead. +- Removed `craft\commerce\records\DiscountCategory`. `CraftCms\Commerce\Promotion\Records\DiscountCategory` should be used instead. +- Removed `craft\commerce\records\DiscountPurchasable`. `CraftCms\Commerce\Promotion\Records\DiscountPurchasable` should be used instead. +- Removed `craft\commerce\records\CustomerDiscountUse`. `CraftCms\Commerce\Promotion\Records\CustomerDiscountUse` should be used instead. +- Removed `craft\commerce\records\EmailDiscountUse`. `CraftCms\Commerce\Promotion\Records\EmailDiscountUse` should be used instead. +- Removed `craft\commerce\records\Sale`. `CraftCms\Commerce\Promotion\Records\Sale` should be used instead. +- Removed `craft\commerce\records\SaleCategory`. `CraftCms\Commerce\Promotion\Records\SaleCategory` should be used instead. +- Removed `craft\commerce\records\SalePurchasable`. `CraftCms\Commerce\Promotion\Records\SalePurchasable` should be used instead. +- Removed `craft\commerce\records\SaleUserGroup`. `CraftCms\Commerce\Promotion\Records\SaleUserGroup` should be used instead. +- Removed `craft\commerce\records\Discount`. `CraftCms\Commerce\Promotion\Records\Discount` should be used instead. +- Removed `craft\commerce\records\Coupon`. `CraftCms\Commerce\Promotion\Records\Coupon` should be used instead. +- Removed `craft\commerce\models\Discount::setExcludeOnSale()`/`getExcludeOnSale()` and the `excludeOnSale` shim. `Discount::$excludeOnPromotion` should be used instead. +- `CraftCms\Commerce\Promotion\Models\Discount` now uses `CraftCms\Commerce\Order\Conditions\DiscountOrderCondition`, `CraftCms\Commerce\Customer\Conditions\DiscountCustomerCondition`, and `CraftCms\Commerce\Address\Conditions\DiscountAddressCondition`. +- Added `CraftCms\Commerce\Address\Conditions\DiscountAddressCondition`, `ZoneAddressCondition`, `GatewayAddressCondition`, and `PostalCodeFormulaConditionRule`. +- Deprecated `craft\commerce\elements\conditions\addresses\DiscountAddressCondition`, `ZoneAddressCondition`, `GatewayAddressCondition`, and `PostalCodeFormulaConditionRule`. The `CraftCms\Commerce\Address\Conditions` equivalents should be used instead. +- Added `CraftCms\Commerce\Promotion\Actions\CreateDiscount` and `CreateSale`. +- Deprecated `craft\commerce\elements\actions\CreateDiscount` and `CreateSale`. The `CraftCms\Commerce\Promotion\Actions` equivalents should be used instead. + +#### Controllers + +- Removed `craft\commerce\controllers\SalesController`. `CraftCms\Commerce\Http\Controllers\Settings\SalesController` should be used instead. +- Removed `craft\commerce\controllers\DiscountsController`. `CraftCms\Commerce\Http\Controllers\Settings\DiscountsController` should be used instead. + +### Purchasables + +- Added `CraftCms\Commerce\Purchasable\Elements\Purchasable`. +- Added `CraftCms\Commerce\Purchasable\Elements\Donation`. +- Added `CraftCms\Commerce\Purchasable\Models\Donation`. +- Added `CraftCms\Commerce\Purchasable\Models\PurchasableStore`. +- Added `CraftCms\Commerce\Purchasable\Queries\PurchasableQuery`. +- Added `CraftCms\Commerce\Purchasable\Queries\DonationQuery`. +- Added `CraftCms\Commerce\Purchasable\Records\Purchasable`. +- Added `CraftCms\Commerce\Purchasable\Records\PurchasableStore`. +- Added `CraftCms\Commerce\Purchasable\Validation\PurchasableRules`. +- Added `CraftCms\Commerce\Purchasable\Validation\DonationRules`. +- Added `CraftCms\Commerce\Purchasable\Purchasables`. +- Added `CraftCms\Commerce\Purchasable\Contracts\PurchasableInterface`. +- Added `CraftCms\Commerce\Purchasable\Events\PurchasableAvailableEvent`. +- Added `CraftCms\Commerce\Purchasable\Events\PurchasableOutOfStockPurchasesAllowedEvent`. +- Added `CraftCms\Commerce\Purchasable\Events\PurchasableShippableEvent`. +- Added `CraftCms\Commerce\Purchasable\PurchasableTypes`, a `CraftCms\Cms\Component\TypeRegistry` for registering purchasable element types. +- Deprecated `craft\commerce\services\Purchasables::EVENT_REGISTER_PURCHASABLE_ELEMENT_TYPES`. `CraftCms\Commerce\Purchasable\PurchasableTypes::register()` should be used instead. +- Deprecated `craft\commerce\base\Purchasable`. `CraftCms\Commerce\Purchasable\Elements\Purchasable` should be used instead. +- Deprecated `craft\commerce\elements\Donation`. `CraftCms\Commerce\Purchasable\Elements\Donation` should be used instead. +- Deprecated `craft\commerce\records\Donation`. `CraftCms\Commerce\Purchasable\Models\Donation` should be used instead. +- Deprecated `craft\commerce\models\PurchasableStore`. `CraftCms\Commerce\Purchasable\Models\PurchasableStore` should be used instead. +- Deprecated `craft\commerce\elements\db\DonationQuery`. `CraftCms\Commerce\Purchasable\Queries\DonationQuery` should be used instead. +- Deprecated `craft\commerce\services\Purchasables`. `CraftCms\Commerce\Purchasable\Purchasables` should be used instead. +- Deprecated `craft\commerce\base\PurchasableInterface`. `CraftCms\Commerce\Purchasable\Contracts\PurchasableInterface` should be used instead. +- Deprecated `craft\commerce\events\PurchasableAvailableEvent`. `CraftCms\Commerce\Purchasable\Events\PurchasableAvailableEvent` should be used instead. +- Deprecated `craft\commerce\events\PurchasableOutOfStockPurchasesAllowedEvent`. `CraftCms\Commerce\Purchasable\Events\PurchasableOutOfStockPurchasesAllowedEvent` should be used instead. +- Deprecated `craft\commerce\events\PurchasableShippableEvent`. `CraftCms\Commerce\Purchasable\Events\PurchasableShippableEvent` should be used instead. +- Removed `craft\commerce\records\Purchasable`. `CraftCms\Commerce\Purchasable\Records\Purchasable` should be used instead. +- Removed `craft\commerce\records\PurchasableStore`. `CraftCms\Commerce\Purchasable\Records\PurchasableStore` should be used instead. +- Removed `craft\commerce\elements\db\PurchasableQuery`. `CraftCms\Commerce\Purchasable\Queries\PurchasableQuery` should be used instead. +- Removed `craft\commerce\records\OrderStatusEmail` as it was unused. +- Added `CraftCms\Commerce\Purchasable\Conditions\PurchasableConditionRule`, `PurchasableTypeConditionRule`, `SkuConditionRule`, `CatalogPricingRulePurchasableCategoryConditionRule`, and `CatalogPricingRulePurchasableCondition`. +- Deprecated `craft\commerce\elements\conditions\purchasables\PurchasableConditionRule`, `PurchasableTypeConditionRule`, `SkuConditionRule`, `CatalogPricingRulePurchasableCategoryConditionRule`, and `CatalogPricingRulePurchasableCondition`. The `CraftCms\Commerce\Purchasable\Conditions` equivalents should be used instead. +- Added `CraftCms\Commerce\Purchasable\FieldLayoutElements\PurchasableSkuField`, `PurchasablePriceField`, `PurchasableStockField`, `PurchasableWeightField`, `PurchasableDimensionsField`, `PurchasableAllowedQtyField`, `PurchasableAvailableForPurchaseField`, `PurchasableFreeShippingField`, and `PurchasablePromotableField`. +- Deprecated `craft\commerce\fieldlayoutelements\PurchasableSkuField`, `PurchasablePriceField`, `PurchasableStockField`, `PurchasableWeightField`, `PurchasableDimensionsField`, `PurchasableAllowedQtyField`, `PurchasableAvailableForPurchaseField`, `PurchasableFreeShippingField`, and `PurchasablePromotableField`. The `CraftCms\Commerce\Purchasable\FieldLayoutElements` equivalents should be used instead. + +#### Controllers + +- Removed `craft\commerce\controllers\DonationsController`. `CraftCms\Commerce\Http\Controllers\DonationsController` should be used instead. + +### Shipping + +- Added `CraftCms\Commerce\Shipping\ShippingMethods`. +- Added `CraftCms\Commerce\Shipping\ShippingRules`. +- Added `CraftCms\Commerce\Shipping\ShippingRuleCategories`. +- Added `CraftCms\Commerce\Shipping\ShippingCategories`. +- Added `CraftCms\Commerce\Shipping\ShippingZones`. +- Added `CraftCms\Commerce\Shipping\Models\ShippingRule`. +- Added `CraftCms\Commerce\Shipping\Models\ShippingMethod`. +- Added `CraftCms\Commerce\Shipping\Models\ShippingMethodOption`. +- Added `CraftCms\Commerce\Shipping\Models\BaseShippingMethod`. +- Added `CraftCms\Commerce\Shipping\Models\ShippingAddressZone`. +- Added `CraftCms\Commerce\Shipping\Models\ShippingRuleCategory`. +- Added `CraftCms\Commerce\Shipping\Models\ShippingCategory`. +- Added `CraftCms\Commerce\Shipping\Records\ShippingZone`. +- Added `CraftCms\Commerce\Shipping\Records\ShippingMethod`. +- Added `CraftCms\Commerce\Shipping\Records\ShippingRule`. +- Added `CraftCms\Commerce\Shipping\Records\ShippingRuleCategory`. +- Added `CraftCms\Commerce\Shipping\Records\ShippingCategory`. +- Added `CraftCms\Commerce\Shipping\Contracts\ShippingMethodInterface`. +- Added `CraftCms\Commerce\Shipping\Contracts\ShippingRuleInterface`. +- Added `CraftCms\Commerce\Shipping\Exceptions\ShippingMethodException`. +- Added `CraftCms\Commerce\Shipping\Events\RegisterAvailableShippingMethodsEvent`. +- Deprecated `craft\commerce\services\ShippingMethods`. `CraftCms\Commerce\Shipping\ShippingMethods` should be used instead. +- Deprecated `craft\commerce\services\ShippingRules`. `CraftCms\Commerce\Shipping\ShippingRules` should be used instead. +- Deprecated `craft\commerce\services\ShippingRuleCategories`. `CraftCms\Commerce\Shipping\ShippingRuleCategories` should be used instead. +- Deprecated `craft\commerce\services\ShippingCategories`. `CraftCms\Commerce\Shipping\ShippingCategories` should be used instead. +- Deprecated `craft\commerce\services\ShippingZones`. `CraftCms\Commerce\Shipping\ShippingZones` should be used instead. +- Deprecated `craft\commerce\models\ShippingRule`. `CraftCms\Commerce\Shipping\Models\ShippingRule` should be used instead. +- Deprecated `craft\commerce\models\ShippingMethod`. `CraftCms\Commerce\Shipping\Models\ShippingMethod` should be used instead. +- Deprecated `craft\commerce\models\ShippingMethodOption`. `CraftCms\Commerce\Shipping\Models\ShippingMethodOption` should be used instead. +- Deprecated `craft\commerce\base\ShippingMethod`. `CraftCms\Commerce\Shipping\Models\BaseShippingMethod` should be used instead. +- Deprecated `craft\commerce\models\ShippingAddressZone`. `CraftCms\Commerce\Shipping\Models\ShippingAddressZone` should be used instead. +- Deprecated `craft\commerce\models\ShippingRuleCategory`. `CraftCms\Commerce\Shipping\Models\ShippingRuleCategory` should be used instead. +- Deprecated `craft\commerce\models\ShippingCategory`. `CraftCms\Commerce\Shipping\Models\ShippingCategory` should be used instead. +- Deprecated `craft\commerce\base\ShippingMethodInterface`. `CraftCms\Commerce\Shipping\Contracts\ShippingMethodInterface` should be used instead. +- Deprecated `craft\commerce\base\ShippingRuleInterface`. `CraftCms\Commerce\Shipping\Contracts\ShippingRuleInterface` should be used instead. +- Deprecated `craft\commerce\errors\ShippingMethodException`. `CraftCms\Commerce\Shipping\Exceptions\ShippingMethodException` should be used instead. +- Deprecated `craft\commerce\events\RegisterAvailableShippingMethodsEvent`. `CraftCms\Commerce\Shipping\Events\RegisterAvailableShippingMethodsEvent` should be used instead. +- Removed `craft\commerce\records\ShippingZone`. `CraftCms\Commerce\Shipping\Records\ShippingZone` should be used instead. +- Removed `craft\commerce\records\ShippingMethod`. `CraftCms\Commerce\Shipping\Records\ShippingMethod` should be used instead. +- Removed `craft\commerce\records\ShippingRule`. `CraftCms\Commerce\Shipping\Records\ShippingRule` should be used instead. +- Removed `craft\commerce\records\ShippingRuleCategory`. `CraftCms\Commerce\Shipping\Records\ShippingRuleCategory` should be used instead. +- Removed `craft\commerce\records\ShippingCategory`. `CraftCms\Commerce\Shipping\Records\ShippingCategory` should be used instead. +- `CraftCms\Commerce\Shipping\Models\ShippingRule` and `BaseShippingMethod` now use `CraftCms\Commerce\Order\Conditions\ShippingRuleOrderCondition`, `ShippingMethodOrderCondition`, `CraftCms\Commerce\Customer\Conditions\ShippingRuleCustomerCondition`, and `ShippingMethodCustomerCondition`. +- `CraftCms\Commerce\Base\Zone` and `ZoneInterface` now use `CraftCms\Commerce\Address\Conditions\ZoneAddressCondition`. + +#### Controllers + +- Removed `craft\commerce\controllers\ShippingZonesController`. `CraftCms\Commerce\Http\Controllers\Settings\ShippingZonesController` should be used instead. +- Removed `craft\commerce\controllers\ShippingMethodsController`. `CraftCms\Commerce\Http\Controllers\Settings\ShippingMethodsController` should be used instead. +- Removed `craft\commerce\controllers\ShippingRulesController`. `CraftCms\Commerce\Http\Controllers\Settings\ShippingRulesController` should be used instead. +- Removed `craft\commerce\controllers\ShippingCategoriesController`. `CraftCms\Commerce\Http\Controllers\Settings\ShippingCategoriesController` should be used instead. + +### Stores + +- Added `CraftCms\Commerce\Store\Stores`. +- Added `CraftCms\Commerce\Store\StoreSettings`. +- Added `CraftCms\Commerce\Store\Models\Store`. +- Added `CraftCms\Commerce\Store\Models\StoreSettings`. +- Added `CraftCms\Commerce\Store\Models\SiteStore`. +- Added `CraftCms\Commerce\Store\Records\Store`. +- Added `CraftCms\Commerce\Store\Records\SiteStore`. +- Added `CraftCms\Commerce\Store\Records\StoreSettings`. +- Added `CraftCms\Commerce\Store\Concerns\StoreTrait`. +- Added `CraftCms\Commerce\Store\Contracts\HasStoreInterface`. +- Added `CraftCms\Commerce\Store\Exceptions\StoreNotFoundException`. +- Added `CraftCms\Commerce\Http\Controllers\Concerns\HasStoreManagementScreen`, a trait shared by store-scoped settings controllers for their CP screen chrome. +- Added `CraftCms\Commerce\Store\Events\DeleteStoreEvent`. +- Added `CraftCms\Commerce\Store\Events\StoreEvent`. +- Deprecated `craft\commerce\services\Stores`. `CraftCms\Commerce\Store\Stores` should be used instead. +- Deprecated `craft\commerce\behaviors\StoreBehavior`. `Site::getStore()` is now provided via a `Illuminate\Support\Traits\Macroable` macro instead. +- Deprecated `craft\commerce\services\StoreSettings`. `CraftCms\Commerce\Store\StoreSettings` should be used instead. +- Deprecated `craft\commerce\models\Store`. `CraftCms\Commerce\Store\Models\Store` should be used instead. +- Deprecated `craft\commerce\models\StoreSettings`. `CraftCms\Commerce\Store\Models\StoreSettings` should be used instead. +- Deprecated `craft\commerce\models\SiteStore`. `CraftCms\Commerce\Store\Models\SiteStore` should be used instead. +- Deprecated `craft\commerce\base\StoreTrait`. `CraftCms\Commerce\Store\Concerns\StoreTrait` should be used instead. +- Deprecated `craft\commerce\base\HasStoreInterface`. `CraftCms\Commerce\Store\Contracts\HasStoreInterface` should be used instead. +- Deprecated `craft\commerce\errors\StoreNotFoundException`. `CraftCms\Commerce\Store\Exceptions\StoreNotFoundException` should be used instead. +- Deprecated `craft\commerce\events\DeleteStoreEvent`. `CraftCms\Commerce\Store\Events\DeleteStoreEvent` should be used instead. +- Deprecated `craft\commerce\events\StoreEvent`. `CraftCms\Commerce\Store\Events\StoreEvent` should be used instead. +- Removed `craft\commerce\services\Store`. `CraftCms\Commerce\Store\Stores` should be used instead. +- Removed `craft\commerce\models\Store::setCountries()`, `getCountries()`, `getCountriesList()`, `getAdministrativeAreasListByCountryCode()`, and `getMarketAddressCondition()`. `Store::getSettings()` (returning `CraftCms\Commerce\Store\Models\StoreSettings`) should be used instead. +- Removed `craft\commerce\records\SiteStore`. `CraftCms\Commerce\Store\Records\SiteStore` should be used instead. +- Removed `craft\commerce\records\StoreSettings`. `CraftCms\Commerce\Store\Records\StoreSettings` should be used instead. +- Removed `craft\commerce\records\Store`. `CraftCms\Commerce\Store\Records\Store` should be used instead. +- Removed `craft\commerce\base\StoreRecordTrait` as it was unused. + +#### Controllers + +- Removed `craft\commerce\controllers\StoreManagementController`. `CraftCms\Commerce\Http\Controllers\Settings\StoreManagementController` should be used instead. +- Removed `craft\commerce\controllers\StoresController`. `CraftCms\Commerce\Http\Controllers\Settings\StoresController` should be used instead. + +### Subscriptions + +> [!WARNING] +> Subscription and billing-plan functionality has been removed from Craft Commerce entirely — there is no `Subscription` element type, no subscription field layout, and gateways can no longer implement subscription support. The `commerce_subscriptions`, `commerce_plans`, and related database tables are **not** dropped, so existing data is preserved for a future standalone migration path; only the application code (elements, services, records, models, forms, events, controllers, CP screens, and the gateway subscription interface) has been removed. + +- Removed `craft\commerce\elements\Subscription`, its element query, condition support, and deletion blocker. No replacement. +- Removed `craft\commerce\services\Subscriptions` and `CraftCms\Commerce\Subscription\Subscriptions`. No replacement. +- Removed `craft\commerce\services\Plans` and `CraftCms\Commerce\Subscription\Plans`. No replacement. +- Removed `craft\commerce\records\Subscription` and `CraftCms\Commerce\Subscription\Records\Subscription`. No replacement. +- Removed `craft\commerce\records\Plan` and `CraftCms\Commerce\Subscription\Records\Plan`. No replacement. +- Removed `craft\commerce\base\Plan`, `craft\commerce\base\PlanInterface`, `craft\commerce\base\PlanTrait`, and `CraftCms\Commerce\Subscription\Contracts\PlanInterface`. No replacement. +- Removed `craft\commerce\base\SubscriptionGateway` and `craft\commerce\base\SubscriptionGatewayInterface` — gateways can no longer declare subscription support. `craft\commerce\gateways\Dummy` now extends `craft\commerce\base\Gateway` directly. +- Removed `craft\commerce\base\SubscriptionResponseInterface` and `CraftCms\Commerce\Subscription\Contracts\SubscriptionResponseInterface`. No replacement. +- Removed `craft\commerce\models\subscriptions\DummyPlan` and `CraftCms\Commerce\Subscription\Models\DummyPlan`. No replacement. +- Removed `craft\commerce\models\subscriptions\SubscriptionPayment` and `CraftCms\Commerce\Subscription\Models\SubscriptionPayment`. No replacement. +- Removed `craft\commerce\models\subscriptions\CancelSubscriptionForm`, `SubscriptionForm`, `SwitchPlansForm`, and their `CraftCms\Commerce\Subscription\Forms\*` equivalents. No replacement. +- Removed `craft\commerce\models\responses\DummySubscriptionResponse` and `CraftCms\Commerce\Subscription\Responses\DummySubscriptionResponse`. No replacement. +- Removed `craft\commerce\errors\SubscriptionException` and `CraftCms\Commerce\Subscription\Exceptions\SubscriptionException`. `Payments::refund()` now throws `CraftCms\Commerce\Payment\Exceptions\RefundException` for unsupported-refund cases, which it always should have (fixes a bug where the wrong exception class was thrown). +- Removed `craft\commerce\events\CancelSubscriptionEvent`, `CreateSubscriptionEvent`, `PlanEvent`, `SubscriptionEvent`, `SubscriptionPaymentEvent`, `SubscriptionSwitchPlansEvent`, and their `CraftCms\Commerce\Subscription\Events\*` equivalents. No replacement. +- Removed the `commerce-manageSubscriptions` and `commerce-manageSubscriptionPlans` permissions. +- Removed the "Subscriptions" and "Subscription Plans" Control Panel nav items, the "Subscriptions" tab on the Edit User screen, and the "Subscription Settings" section of the general settings page (including the `updateBillingDetailsUrl` setting and `Settings::VIEW_URI_SUBSCRIPTIONS` constant). +- Removed the `craft.commerce.subscriptions()` Twig variable. No replacement. +- Removed `Gateways::getAllSubscriptionGateways()` (both `craft\commerce\services\Gateways` and `CraftCms\Commerce\Payment\Gateway\Gateways`). + +#### Controllers + +- Removed `craft\commerce\controllers\SubscriptionsController` and `CraftCms\Commerce\Http\Controllers\SubscriptionsController`. No replacement. +- Removed `craft\commerce\controllers\PlansController` and `CraftCms\Commerce\Http\Controllers\Settings\PlansController`. No replacement. + +### Tax + +- Added `CraftCms\Commerce\Tax\TaxCategories`. +- Added `CraftCms\Commerce\Tax\TaxZones`. +- Added `CraftCms\Commerce\Tax\Records\TaxCategory`. +- Added `CraftCms\Commerce\Tax\Records\TaxZone`. +- Added `CraftCms\Commerce\Tax\Records\TaxRate`. +- Added `CraftCms\Commerce\Tax\Models\TaxRate`. +- Added `CraftCms\Commerce\Tax\Models\TaxAddressZone`. +- Added `CraftCms\Commerce\Tax\Models\TaxCategory`. +- Added `CraftCms\Commerce\Tax\Contracts\TaxIdValidatorInterface`. +- Added `CraftCms\Commerce\Tax\Contracts\TaxEngineInterface`. +- Added `CraftCms\Commerce\Tax\Events\TaxEngineEvent`. +- Added `CraftCms\Commerce\Tax\Events\TaxIdValidatorsEvent`. +- Deprecated `craft\commerce\services\TaxCategories`. `CraftCms\Commerce\Tax\TaxCategories` should be used instead. +- Deprecated `craft\commerce\services\TaxZones`. `CraftCms\Commerce\Tax\TaxZones` should be used instead. +- Deprecated `craft\commerce\models\TaxRate`. `CraftCms\Commerce\Tax\Models\TaxRate` should be used instead. +- Deprecated `craft\commerce\models\TaxAddressZone`. `CraftCms\Commerce\Tax\Models\TaxAddressZone` should be used instead. +- Deprecated `craft\commerce\models\TaxCategory`. `CraftCms\Commerce\Tax\Models\TaxCategory` should be used instead. +- Deprecated `craft\commerce\base\TaxIdValidatorInterface`. `CraftCms\Commerce\Tax\Contracts\TaxIdValidatorInterface` should be used instead. +- Deprecated `craft\commerce\base\TaxEngineInterface`. `CraftCms\Commerce\Tax\Contracts\TaxEngineInterface` should be used instead. +- Deprecated `craft\commerce\events\TaxEngineEvent`. `CraftCms\Commerce\Tax\Events\TaxEngineEvent` should be used instead. +- Deprecated `craft\commerce\events\TaxIdValidatorsEvent`. `CraftCms\Commerce\Tax\Events\TaxIdValidatorsEvent` should be used instead. +- Removed `craft\commerce\records\TaxZone`. `CraftCms\Commerce\Tax\Records\TaxZone` should be used instead. +- Removed `craft\commerce\records\TaxRate`. `CraftCms\Commerce\Tax\Records\TaxRate` should be used instead. +- Removed `craft\commerce\records\TaxCategory`. `CraftCms\Commerce\Tax\Records\TaxCategory` should be used instead. + +#### Controllers + +- Removed `craft\commerce\controllers\TaxZonesController`. `CraftCms\Commerce\Http\Controllers\Settings\TaxZonesController` should be used instead. +- Removed `craft\commerce\controllers\TaxCategoriesController`. `CraftCms\Commerce\Http\Controllers\Settings\TaxCategoriesController` should be used instead. +- Removed `craft\commerce\controllers\TaxRatesController`. `CraftCms\Commerce\Http\Controllers\Settings\TaxRatesController` should be used instead. + +### Transfers + +- Added `CraftCms\Commerce\Transfer\Elements\Transfer`. +- Added `CraftCms\Commerce\Transfer\Queries\TransferQuery`. +- Added `CraftCms\Commerce\Transfer\Conditions\TransferCondition`. +- Added `CraftCms\Commerce\Transfer\FieldLayoutElements\TransferManagementField`. +- Added `CraftCms\Commerce\Transfer\Transfers`. +- Added `CraftCms\Commerce\Transfer\Models\TransferDetail`. +- Added `CraftCms\Commerce\Transfer\Records\Transfer`. +- Added `CraftCms\Commerce\Transfer\Records\TransferDetail`. +- Added `CraftCms\Commerce\Transfer\Enums\TransferStatusType` enum. +- Deprecated `craft\commerce\elements\Transfer`. `CraftCms\Commerce\Transfer\Elements\Transfer` should be used instead. +- Deprecated `craft\commerce\elements\db\TransferQuery`. `CraftCms\Commerce\Transfer\Queries\TransferQuery` should be used instead. +- Deprecated `craft\commerce\elements\conditions\transfers\TransferCondition`. `CraftCms\Commerce\Transfer\Conditions\TransferCondition` should be used instead. +- Deprecated `craft\commerce\fieldlayoutelements\TransferManagementField`. `CraftCms\Commerce\Transfer\FieldLayoutElements\TransferManagementField` should be used instead. +- Deprecated `craft\commerce\services\Transfers`. `CraftCms\Commerce\Transfer\Transfers` should be used instead. +- Deprecated `craft\commerce\models\TransferDetail`. `CraftCms\Commerce\Transfer\Models\TransferDetail` should be used instead. +- Deprecated `craft\commerce\enums\TransferStatusType`. `CraftCms\Commerce\Transfer\Enums\TransferStatusType` should be used instead. +- Removed `craft\commerce\records\Transfer`. `CraftCms\Commerce\Transfer\Records\Transfer` should be used instead. +- Removed `craft\commerce\records\TransferDetail`. `CraftCms\Commerce\Transfer\Records\TransferDetail` should be used instead. + +#### Controllers + +- Removed `craft\commerce\controllers\TransfersController`. `CraftCms\Commerce\Http\Controllers\TransfersController` should be used instead. + +### Users + +- Removed `craft\commerce\controllers\UsersController`. `CraftCms\Commerce\Http\Controllers\Users\UsersController` should be used instead. +- The Commerce tab on the Edit User screen is now built from `CraftCms\Cms\Http\Controllers\Users\EditUserTrait`; Commerce listens for `CraftCms\Cms\User\Events\EditUserScreensResolving` instead of the removed `craft\controllers\UsersController::EVENT_DEFINE_EDIT_SCREENS`. + +### Extensibility + +- Added `CraftCms\Commerce\Plugin`, extending `CraftCms\Cms\Plugin\Plugin`. `craft\commerce\Plugin` now extends this class instead of `craft\base\Plugin`. +- Added `Plugin::getPermissions()`, exposing Commerce's permissions as `CraftCms\Cms\User\Data\Permission` objects, including a dynamic `commerce-viewProductType:{uid}` permission (with nested create/save/delete permissions) per product type. +- Added `Plugin::getCpNavItem()`, building the Commerce CP nav item and its permission-gated subnav via `CraftCms\Cms\Cp\Data\NavItem` instead of the legacy array-based `getCpNavItem()` override. +- Added `CraftCms\Commerce\Console\Commands\Resave\ResaveProductsCommand`, `ResaveVariantsCommand`, `ResaveOrdersCommand`, and `ResaveCartsCommand`, registered as `craft:resave:products`, `craft:resave:variants`, `craft:resave:orders`, and `craft:resave:carts` (also picked up automatically by `craft:resave:all`). +- Added `CraftCms\Commerce\Support\ObjectState`, a `WeakMap`-backed per-instance state store used by the `Site`/`User`/`Address` `Macroable` macros above. +- Added GraphQL schema component and eager-loadable field registration via `CraftCms\Cms\Gql\Events\GqlSchemaComponentsResolving` and `GqlEagerLoadableFieldsResolving`. +- Added garbage collection registration via `CraftCms\Cms\GarbageCollection\Events\RunningGarbageCollection`, purging incomplete carts, orphaned variants, and partial Donation/Order/Product/Variant/Transfer elements. +- Added `craft.commerce`, `craft.orders`, `craft.products`, and `craft.variants` Twig variables via `CraftCms\Cms\Twig\Variables\CraftVariable::macro()`. +- Registered Commerce's Twig extension via the `CraftCms\Cms\Support\Facades\Twig` facade. +- Added `CraftCms\Commerce\Order\Elements\Order::defineExporters()`, registering `CraftCms\Commerce\Order\Exporters\OrderExport` and `LineItemExport`. +- Added `CraftCms\Commerce\Base\Zone`. +- Added `CraftCms\Commerce\Base\ZoneInterface`. +- Added `CraftCms\Commerce\Database\Table`. +- Added `CraftCms\Commerce\Settings`. +- Added `CraftCms\Commerce\Exceptions\NotImplementedException`. +- Added `CraftCms\Commerce\Events\UpgradeEvent`. +- Deprecated `craft\commerce\base\Zone`. `CraftCms\Commerce\Base\Zone` should be used instead. +- Deprecated `craft\commerce\base\ZoneInterface`. `CraftCms\Commerce\Base\ZoneInterface` should be used instead. +- Deprecated `craft\commerce\db\Table`. `CraftCms\Commerce\Database\Table` should be used instead. +- Deprecated `craft\commerce\models\Settings`. `CraftCms\Commerce\Settings` should be used instead. +- Deprecated `craft\commerce\errors\NotImplementedException`. `CraftCms\Commerce\Exceptions\NotImplementedException` should be used instead. +- Deprecated `craft\commerce\events\UpgradeEvent`. `CraftCms\Commerce\Events\UpgradeEvent` should be used instead. +- Deprecated `craft\commerce\events\*`. Cancelable Commerce events (previously extending `craft\events\CancelableEvent`) now use the `CraftCms\Cms\Shared\Concerns\ValidatableEvent` trait instead. +- Removed `craft\commerce\models\Settings::VIEW_URI_CUSTOMERS`, `VIEW_URI_PROMOTIONS`, `VIEW_URI_SHIPPING`, and `VIEW_URI_TAX` constants. +- Improved `craft\commerce\Plugin`'s `Plugin::getInstance()->getX()` service getters to be backed by a lazy-instantiate-and-cache trait rather than Yii2's component locator. + +### System + +- Removed the Commerce Yii2 debug panel and all related classes (`CommercePanel`, `DebugPanel` helper, `CommerceDebugPanelDataEvent`, and its Twig views) — the Yii2 debug module they relied on no longer exists in Craft CMS 6. +- Removed `src-yii2/etc/commands.php`, an unused "A&M quick commands" registration file with no references anywhere in the codebase. +- Removed `src-yii2/etc/currencies.php`, a static copy of moneyphp/money's currency data that was superseded by `CraftCms\Commerce\Payment\Currencies`, which now reads currency data directly from the `moneyphp/money` package. +- Deprecated `craft\commerce\base\Model`, an empty pass-through subclass of `craft\base\Model` with no consumers. `CraftCms\Cms\Component\Component` should be used instead, matching every other already-migrated Commerce model. + +### Testing + +- Renamed `tests/` to `tests-yii2/`, mirroring the `src`/`src-yii2` split, so `tests/` is reserved for Pest tests covering the new `src/` codebase. +- Removed the Codeception test runner and harness (`codeception.yml`, suite configs, `_bootstrap.php`, `_craft/`, `_data/`, `_envs/`, `_output/`, `_support/`, env files, and the `codeception/*` Composer dependencies) — it's no longer run. The legacy `unit/`, `gql/`, and `fixtures/` test classes remain under `tests-yii2/` as reference material to port to Pest; the empty `acceptance/` and `functional/` suites were removed outright. +- Added a Pest/Orchestra Testbench harness under `tests/` (`TestCase`, `UnitTestCase`, `Pest.php`, `Feature/`, `Unit/`, `Arch/`) for testing `CraftCms\Commerce\` code in `src/`. Run via `composer run tests`. +- Added `craftcms/yii2-adapter` as a `require-dev` dependency and the standard Testbench `package:discover`/`package:purge-skeleton` Composer scripts — both needed for the plugin to install correctly under the standalone Testbench harness, since neither was ever required before (previously only exercised as part of the full `craft6a` project, which already provides them). +- Added `database/migrations/Install.php` (`CraftCms\Commerce\Database\Migrations\Install`), a Laravel-style port of `src-yii2/migrations/Install.php`, so Commerce can be installed via `CraftCms\Cms\Plugin\Concerns\Installable::install()` (which runs a plugin's install migration through Laravel's `Migrator`, incompatible with the legacy Yii2 migration) — this is the first time Commerce has been installed through that code path, since production sites installed under the old Yii2 installer flow before this migration began. `getMigrationsPath()`'s "conventional path" check (`database/migrations/`) picks it up automatically ahead of the legacy path; no other wiring needed. `down()` is a one-line "cannot be reverted" warning, matching Craft core's own ported install migration. +- Fixed `CraftCms\Commerce\Helpers\Locale::switchAppLanguage()` — it only mutated the legacy Yii2 `Craft::$app` locale state, which `CraftCms\Cms\Translation\I18N::getFormattingLocale()` (used by `Currency::formatAsCurrency()` and friends) doesn't read outside of CP requests; it now also calls `app()->setLocale()` so switching language actually affects number/currency formatting. +- Fixed `CraftCms\Commerce\Store\Stores.php`'s schema-version guard (added for pre-5.0.72 upgrade compatibility) — right after a fresh plugin install, `Plugins::getStoredPluginInfo()` only has `['id', 'enabled']` cached (no `schemaVersion` yet), which threw an "undefined array key" error instead of the intended "assume old schema" fallback. A missing key now correctly means "just installed, definitely has the settings columns," not "pre-5.0.72." +- Fixed `tests/TestCase.php` — `CraftCms\Cms\Plugin\Plugins::loadPlugins()`'s internal singleton guard was set before Commerce had a row in the `plugins` table, permanently preventing it from ever registering `craft\commerce\Plugin` as a Laravel service provider, so `Plugin::register()`/`boot()` (GQL argument handlers, widgets, permissions, CP nav, console commands, event listeners, `Macroable` macros, etc.) silently never ran under `composer run tests`. Forgetting the `Plugins` singleton and reloading it right after install fixes this. diff --git a/codeception.yml b/codeception.yml deleted file mode 100644 index bf3ef1f863..0000000000 --- a/codeception.yml +++ /dev/null @@ -1,46 +0,0 @@ -actor: Tester -paths: - tests: tests - log: tests/_output - output: tests/_output - data: tests/_data - support: tests/_support - envs: tests/_envs -bootstrap: _bootstrap.php -coverage: - enabled: true - include: - - src/* - exclude: - - src/etc/* - - src/migrations/* - - src/templates/* - - src/translations/* - - src/web/assets/* - - docs/* - - templates/* - - tests/* - - vendor/* -params: - - env - - tests/.env -modules: - config: - \craft\test\Craft: - configFile: 'tests/_craft/config/test.php' - entryUrl: 'http://test.craftcms.test/index.php' - projectConfig: {} - migrations: [] - plugins: - commerce: - class: '\craft\commerce\Plugin' - handle: commerce - cleanup: true - transaction: true - dbSetup: {clean: true, setupCraft: true} - fullMock: false -groups: - elements: [tests/unit/elements] - models: [tests/unit/models] - services: [tests/unit/services] - gql: [tests/unit/gql] diff --git a/composer.json b/composer.json index a1b36c7b01..f209490335 100644 --- a/composer.json +++ b/composer.json @@ -21,34 +21,38 @@ "minimum-stability": "dev", "prefer-stable": true, "require": { - "php": "^8.2", - "craftcms/cms": "^5.10.0", - "dompdf/dompdf": "^2.0.2 || ^3.0", + "php": "^8.5", + "craftcms/cms": "6.x-dev", + "dompdf/dompdf": "^3.1.6", "ibericode/vat": "^2.0", "iio/libmergepdf": "^4.0", "moneyphp/money": "^4.2.0" }, "require-dev": { - "codeception/codeception": "^5.0.11", - "codeception/module-asserts": "^3.0.0", - "codeception/module-datafactory": "^3.0.0", - "codeception/module-phpbrowser": "^3.0.0", - "codeception/module-rest": "^3.3.2", - "codeception/module-yii2": "^1.1.9", - "craftcms/ckeditor": "^4.0.0", - "craftcms/redactor": "*", "craftcms/ecs": "dev-main", - "craftcms/phpstan": "dev-main", + "craftcms/yii2-adapter": "6.x-dev", + "dg/bypass-finals": "^1.9", "fakerphp/faker": "^1.19.0", + "larastan/larastan": "^3.4", "league/factory-muffin": "^3.3.0", - "phpstan/phpstan": "^1.10.56", - "vlucas/phpdotenv": "^5.4.1", - "craftcms/rector": "dev-main" + "orchestra/testbench": "^11.0", + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-arch": "^4.0", + "pestphp/pest-plugin-laravel": "^4.0", + "phpstan/phpstan": "^2.1", + "rector/rector": "^2.0", + "vlucas/phpdotenv": "^5.4.1" }, "autoload": { "psr-4": { - "craft\\commerce\\": "src/", - "craftcommercetests\\fixtures\\": "tests/fixtures/" + "craft\\commerce\\": "src-yii2/", + "CraftCms\\Commerce\\": "src/", + "craftcommercetests\\fixtures\\": "tests-yii2/fixtures/" + } + }, + "autoload-dev": { + "psr-4": { + "CraftCms\\Commerce\\Tests\\": "tests/" } }, "extra": { @@ -62,16 +66,23 @@ "check-cs": "ecs check --ansi", "fix-cs": "ecs check --ansi --fix", "phpstan": "phpstan --memory-limit=1G", - "testunit": [ + "tests": [ "Composer\\Config::disableProcessTimeout", - "codecept run unit" - ] + "./vendor/bin/pest --compact" + ], + "post-autoload-dump": [ + "@clear", + "@prepare" + ], + "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi" }, "config": { "sort-packages": true, "allow-plugins": { "yiisoft/yii2-composer": true, - "craftcms/plugin-installer": true + "craftcms/plugin-installer": true, + "pestphp/pest-plugin": true } } } diff --git a/composer.lock b/composer.lock index 4a615231e6..a019bfa886 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cceec5dfa88beb3ef42037526eac053d", + "content-hash": "2f4612d50e83bc3840bdd89cbe9b1227", "packages": [ { "name": "bacon/bacon-qr-code", @@ -63,16 +63,16 @@ }, { "name": "brick/math", - "version": "0.17.2", + "version": "0.18.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818" + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818", - "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818", + "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", "shasum": "" }, "require": { @@ -110,7 +110,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.17.2" + "source": "https://github.com/brick/math/tree/0.18.0" }, "funding": [ { @@ -118,7 +118,7 @@ "type": "github" } ], - "time": "2026-05-25T20:34:43+00:00" + "time": "2026-06-14T18:21:03+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -189,70 +189,6 @@ ], "time": "2024-02-09T16:56:22+00:00" }, - { - "name": "cebe/markdown", - "version": "1.2.1", - "source": { - "type": "git", - "url": "https://github.com/cebe/markdown.git", - "reference": "9bac5e971dd391e2802dca5400bbeacbaea9eb86" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/cebe/markdown/zipball/9bac5e971dd391e2802dca5400bbeacbaea9eb86", - "reference": "9bac5e971dd391e2802dca5400bbeacbaea9eb86", - "shasum": "" - }, - "require": { - "lib-pcre": "*", - "php": ">=5.4.0" - }, - "require-dev": { - "cebe/indent": "*", - "facebook/xhprof": "*@dev", - "phpunit/phpunit": "4.1.*" - }, - "bin": [ - "bin/markdown" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.2.x-dev" - } - }, - "autoload": { - "psr-4": { - "cebe\\markdown\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Carsten Brandt", - "email": "mail@cebe.cc", - "homepage": "http://cebe.cc/", - "role": "Creator" - } - ], - "description": "A super fast, highly extensible markdown parser for PHP", - "homepage": "https://github.com/cebe/markdown#readme", - "keywords": [ - "extensible", - "fast", - "gfm", - "markdown", - "markdown-extra" - ], - "support": { - "issues": "https://github.com/cebe/markdown/issues", - "source": "https://github.com/cebe/markdown" - }, - "time": "2018-03-26T11:24:36+00:00" - }, { "name": "commerceguys/addressing", "version": "v2.2.5", @@ -319,28 +255,29 @@ }, { "name": "composer/pcre", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.10" + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, "type": "library", "extra": { @@ -378,7 +315,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { @@ -388,13 +325,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-11-12T16:29:46+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { "name": "composer/semver", @@ -475,27 +408,30 @@ }, { "name": "craftcms/cms", - "version": "5.10.10", + "version": "6.x-dev", "source": { "type": "git", "url": "https://github.com/craftcms/cms.git", - "reference": "d4c5f814e59de56a3124776da6b3c399cb28d64f" + "reference": "4eaea65efbee654ab7ddf73cd77107e3a8c7af8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/cms/zipball/d4c5f814e59de56a3124776da6b3c399cb28d64f", - "reference": "d4c5f814e59de56a3124776da6b3c399cb28d64f", + "url": "https://api.github.com/repos/craftcms/cms/zipball/4eaea65efbee654ab7ddf73cd77107e3a8c7af8d", + "reference": "4eaea65efbee654ab7ddf73cd77107e3a8c7af8d", "shasum": "" }, "require": { "bacon/bacon-qr-code": "^3.0", "commerceguys/addressing": "^2.1.1", "composer/semver": "^3.3.2", + "craftcms/cms-assets": "self.version", + "craftcms/laravel-aliases": "^2.0", + "craftcms/laravel-dependency-aware-cache": "^1.2.3", + "craftcms/laravel-ruleset-validation": "^1.1", "craftcms/plugin-installer": "~1.6.0", - "craftcms/server-check": "~5.1.0", + "craftcms/server-check": "~6.0.0", "craftcms/url-validator": "^1.0", - "creocoder/yii2-nested-sets": "~0.9.0", - "elvanto/litemoji": "~4.3.0", + "elvanto/litemoji": "^5.2.0", "enshrined/svg-sanitize": "~0.22.0", "ext-bcmath": "*", "ext-curl": "*", @@ -508,72 +444,125 @@ "ext-pdo": "*", "ext-zip": "*", "guzzlehttp/guzzle": "^7.2.0", - "illuminate/collections": "^v10.42.0", - "illuminate/support": "^10.49", + "inertiajs/inertia-laravel": "^3.0", + "intervention/image": "^4.2", + "laravel/framework": "^13.0.0", + "laravel/wayfinder": "^0.1.12", + "league/commonmark": "^2.8", + "league/flysystem-path-prefixing": "^3.31", "league/uri": "^7.0", - "mikehaertl/php-shellcommand": "^1.6.3", "moneyphp/money": "^4.0", - "monolog/monolog": "^3.0", - "php": "^8.2", - "phpdocumentor/reflection-docblock": "^5.3", + "php": "^8.5", "phpoffice/phpspreadsheet": "^5.3", - "pixelandtonic/graphql-php": "~14.11.10.1", - "pixelandtonic/imagine": "~1.5.2.1", - "pragmarx/google2fa": "^8.0", + "pragmarx/google2fa": "^9.0", "pragmarx/recovery": "^0.2.1", - "samdark/yii2-psr-log-target": "^1.1.3", - "seld/cli-prompt": "^1.0.4", - "symfony/css-selector": "^6.0|^7.0", - "symfony/dom-crawler": "^6.0|^7.0", - "symfony/filesystem": "^6.3", - "symfony/http-client": "^6.0.3|^7.0", - "symfony/property-access": "^7.0", - "symfony/property-info": "^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/var-dumper": "^5.0|^6.0|^7.0", - "symfony/yaml": "^5.2.3|^6.0|^7.0", + "symfony/css-selector": "^7.0|^8.0", + "symfony/dom-crawler": "^7.0|^8.0", + "symfony/filesystem": "^7.0|^8.0", + "symfony/html-sanitizer": "^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/yaml": "^7.0|^8.0", "theiconic/name-parser": "^1.2", + "tpetry/laravel-query-expressions": "^1.5", "twig/twig": "~3.27.0", "voku/portable-ascii": "^2.0", "web-auth/webauthn-lib": "~5.3.5", - "yiisoft/yii2": "~2.0.55.0", - "yiisoft/yii2-debug": "~2.1.27.0", - "yiisoft/yii2-queue": "~2.3.2", - "yiisoft/yii2-symfonymailer": "^4.0.0" - }, - "provide": { - "bower-asset/inputmask": "5.0.9", - "bower-asset/jquery": "3.6.1", - "bower-asset/punycode": "^2.2", - "bower-asset/yii2-pjax": "~2.0.1", - "yii2tech/ar-softdelete": "1.0.4" + "webonyx/graphql-php": "~15.33.1", + "yiisoft/arrays": "^3.2", + "yiisoft/html": "^4.1", + "yiisoft/translator": "^3.2", + "yiisoft/translator-message-php": "^1.1" }, "require-dev": { - "codeception/codeception": "^5.2.0", - "codeception/lib-innerbrowser": "4.0.1", - "codeception/module-asserts": "^3.0.0", - "codeception/module-datafactory": "^3.0.0", - "codeception/module-phpbrowser": "^3.0.0", - "codeception/module-rest": "^3.3.2", - "codeception/module-yii2": "^1.1.9", - "craftcms/ecs": "dev-main", + "barryvdh/laravel-debugbar": "^4.2", + "dg/bypass-finals": "^1.9", + "driftingly/rector-laravel": "^2.1", "fakerphp/faker": "^1.19.0", - "league/factory-muffin": "^3.3.0", + "intervention/image-driver-vips": "^4.1", + "larastan/larastan": "^3.4", + "laravel/pao": "^1.0", + "laravel/pint": "^1.22", + "laravel/socialite": "^5.23", + "orchestra/testbench": "^11.0", + "pestphp/pest": "^5.0", + "pestphp/pest-plugin-arch": "^5.0", + "pestphp/pest-plugin-laravel": "^5.0", "phpstan/phpstan": "^2.1", - "rector/rector": "^2.0", - "vlucas/phpdotenv": "^5.4.1", - "yiisoft/yii2-redis": "^2.0" + "rector/rector": "^2.3", + "spatie/laravel-typescript-transformer": "^3.2" }, "suggest": { "ext-exif": "Adds support for parsing image EXIF data.", "ext-iconv": "Adds support for more character encodings than PHP’s built-in mb_convert_encoding() function, which Craft will take advantage of when converting strings to UTF-8.", - "ext-imagick": "Adds support for more image processing formats and options." + "ext-imagick": "Adds support for more image processing formats and options.", + "intervention/image-driver-vips": "Adds support for image processing with libvips (requires ext-ffi and libvips).", + "laravel/socialite": "Adds OAuth login support for providers configured with `GeneralConfig::oauthProviders()`." }, "type": "library", + "extra": { + "laravel": { + "aliases": { + "Gql": "CraftCms\\Cms\\Support\\Facades\\Gql", + "I18N": "CraftCms\\Cms\\Support\\Facades\\I18N", + "Path": "CraftCms\\Cms\\Support\\Facades\\Path", + "Twig": "CraftCms\\Cms\\Support\\Facades\\Twig", + "OAuth": "CraftCms\\Cms\\Support\\Facades\\OAuth", + "Sites": "CraftCms\\Cms\\Support\\Facades\\Sites", + "Users": "CraftCms\\Cms\\Support\\Facades\\Users", + "Assets": "CraftCms\\Cms\\Support\\Facades\\Assets", + "Drafts": "CraftCms\\Cms\\Support\\Facades\\Drafts", + "Fields": "CraftCms\\Cms\\Support\\Facades\\Fields", + "Images": "CraftCms\\Cms\\Support\\Facades\\Images", + "Search": "CraftCms\\Cms\\Support\\Facades\\Search", + "BulkOps": "CraftCms\\Cms\\Support\\Facades\\BulkOps", + "Entries": "CraftCms\\Cms\\Support\\Facades\\Entries", + "Folders": "CraftCms\\Cms\\Support\\Facades\\Folders", + "Plugins": "CraftCms\\Cms\\Support\\Facades\\Plugins", + "Updates": "CraftCms\\Cms\\Support\\Facades\\Updates", + "Volumes": "CraftCms\\Cms\\Support\\Facades\\Volumes", + "Elements": "CraftCms\\Cms\\Support\\Facades\\Elements", + "Markdown": "CraftCms\\Cms\\Support\\Facades\\Markdown", + "Sections": "CraftCms\\Cms\\Support\\Facades\\Sections", + "Security": "CraftCms\\Cms\\Support\\Facades\\Security", + "Template": "CraftCms\\Cms\\Support\\Facades\\Template", + "Addresses": "CraftCms\\Cms\\Support\\Facades\\Addresses", + "HtmlStack": "CraftCms\\Cms\\Support\\Facades\\HtmlStack", + "Revisions": "CraftCms\\Cms\\Support\\Facades\\Revisions", + "Conditions": "CraftCms\\Cms\\Support\\Facades\\Conditions", + "Deprecator": "CraftCms\\Cms\\Support\\Facades\\Deprecator", + "EntryTypes": "CraftCms\\Cms\\Support\\Facades\\EntryTypes", + "SiteGroups": "CraftCms\\Cms\\Support\\Facades\\SiteGroups", + "Structures": "CraftCms\\Cms\\Support\\Facades\\Structures", + "UserGroups": "CraftCms\\Cms\\Support\\Facades\\UserGroups", + "AuthMethods": "CraftCms\\Cms\\Support\\Facades\\AuthMethods", + "Filesystems": "CraftCms\\Cms\\Support\\Facades\\Filesystems", + "JobProgress": "CraftCms\\Cms\\Support\\Facades\\JobProgress", + "AssetIndexer": "CraftCms\\Cms\\Support\\Facades\\AssetIndexer", + "Announcements": "CraftCms\\Cms\\Support\\Facades\\Announcements", + "DeltaRegistry": "CraftCms\\Cms\\Support\\Facades\\DeltaRegistry", + "ElementCaches": "CraftCms\\Cms\\Support\\Facades\\ElementCaches", + "ProjectConfig": "CraftCms\\Cms\\Support\\Facades\\ProjectConfig", + "TemplateHooks": "CraftCms\\Cms\\Support\\Facades\\TemplateHooks", + "ElementActions": "CraftCms\\Cms\\Support\\Facades\\ElementActions", + "ElementSources": "CraftCms\\Cms\\Support\\Facades\\ElementSources", + "HtmlSanitizers": "CraftCms\\Cms\\Support\\Facades\\HtmlSanitizers", + "InputNamespace": "CraftCms\\Cms\\Support\\Facades\\InputNamespace", + "ElementActivity": "CraftCms\\Cms\\Support\\Facades\\ElementActivity", + "ImageTransforms": "CraftCms\\Cms\\Support\\Facades\\ImageTransforms", + "UserPermissions": "CraftCms\\Cms\\Support\\Facades\\UserPermissions", + "ElementExporters": "CraftCms\\Cms\\Support\\Facades\\ElementExporters" + }, + "providers": [ + "CraftCms\\Cms\\Providers\\CraftServiceProvider" + ] + } + }, "autoload": { + "files": [ + "src/helpers.php" + ], "psr-4": { - "craft\\": "src/", - "yii2tech\\ar\\softdelete\\": "lib/ar-softdelete/src/" + "CraftCms\\Cms\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -591,7 +580,7 @@ "keywords": [ "cms", "craftcms", - "yii2" + "laravel" ], "support": { "docs": "https://craftcms.com/docs/5.x/", @@ -601,7 +590,239 @@ "rss": "https://github.com/craftcms/cms/releases.atom", "source": "https://github.com/craftcms/cms" }, - "time": "2026-07-08T22:13:52+00:00" + "time": "2026-08-14T01:56:22+00:00" + }, + { + "name": "craftcms/cms-assets", + "version": "6.x-dev", + "source": { + "type": "git", + "url": "https://github.com/craftcms/cms-assets.git", + "reference": "3a1a3f9c62bd5b0ae721a67637fb45a29395d158" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/craftcms/cms-assets/zipball/3a1a3f9c62bd5b0ae721a67637fb45a29395d158", + "reference": "3a1a3f9c62bd5b0ae721a67637fb45a29395d158", + "shasum": "" + }, + "default-branch": true, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "proprietary" + ], + "description": "Built assets for Craft CMS.", + "support": { + "source": "https://github.com/craftcms/cms-assets/tree/6.x" + }, + "time": "2026-08-14T02:00:46+00:00" + }, + { + "name": "craftcms/laravel-aliases", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/craftcms/laravel-aliases.git", + "reference": "734ea28e7fc7acfc3988f64475b416ecbc46419e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/craftcms/laravel-aliases/zipball/734ea28e7fc7acfc3988f64475b416ecbc46419e", + "reference": "734ea28e7fc7acfc3988f64475b416ecbc46419e", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^12.0||^13.0", + "php": "^8.2", + "yiisoft/aliases": "^3.0" + }, + "require-dev": { + "larastan/larastan": "^2.9||^3.0", + "laravel/pint": "^1.14", + "nunomaduro/collision": "^8.1.1||^7.10.0", + "orchestra/testbench": "^10.0.0||^11.0", + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-arch": "^4.0", + "pestphp/pest-plugin-laravel": "^4.0", + "phpstan/extension-installer": "^1.3||^2.0", + "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", + "phpstan/phpstan-phpunit": "^1.3||^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Aliases": "CraftCms\\Aliases\\Facades\\Aliases" + }, + "providers": [ + "CraftCms\\Aliases\\AliasesServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "CraftCms\\Aliases\\": "src/", + "CraftCms\\Aliases\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Pixel & Tonic", + "homepage": "https://pixelandtonic.com/" + } + ], + "description": "A Laravel wrapper around yiisoft/aliases", + "homepage": "https://github.com/craftcms/laravel-aliases", + "keywords": [ + "craftcms", + "laravel", + "laravel-aliases", + "yii" + ], + "support": { + "issues": "https://github.com/craftcms/laravel-aliases/issues", + "source": "https://github.com/craftcms/laravel-aliases/tree/2.1.0" + }, + "time": "2026-03-17T14:59:00+00:00" + }, + { + "name": "craftcms/laravel-dependency-aware-cache", + "version": "1.2.3", + "source": { + "type": "git", + "url": "https://github.com/craftcms/laravel-dependency-aware-cache.git", + "reference": "5d5352c6a56b901d1c61f87bf912301785c7f350" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/craftcms/laravel-dependency-aware-cache/zipball/5d5352c6a56b901d1c61f87bf912301785c7f350", + "reference": "5d5352c6a56b901d1c61f87bf912301785c7f350", + "shasum": "" + }, + "require": { + "illuminate/cache": "^12.0|^13.0", + "illuminate/contracts": "^12.0||^13.0", + "php": "^8.2" + }, + "require-dev": { + "larastan/larastan": "^2.9||^3.0", + "laravel/pint": "^1.14", + "nunomaduro/collision": "^8.1.1||^7.10.0", + "orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0||^9.0||^10.0||^11.0", + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-arch": "^v4.0.0", + "pestphp/pest-plugin-laravel": "^v4.1.0", + "phpstan/extension-installer": "^1.3||^2.0", + "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", + "phpstan/phpstan-phpunit": "^1.3||^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "DependencyCache": "CraftCms\\DependencyAwareCache\\Facades\\DependencyCache" + }, + "providers": [ + "CraftCms\\DependencyAwareCache\\CacheServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "CraftCms\\DependencyAwareCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pixel & Tonic", + "homepage": "https://pixelandtonic.com/" + } + ], + "description": "A dependency aware cache repository for Laravel", + "homepage": "https://github.com/craftcms/laravel-dependency-aware-cache", + "keywords": [ + "cache", + "craftcms", + "laravel" + ], + "support": { + "issues": "https://github.com/craftcms/laravel-dependency-aware-cache/issues", + "source": "https://github.com/craftcms/laravel-dependency-aware-cache/tree/1.2.3" + }, + "time": "2026-05-01T12:08:11+00:00" + }, + { + "name": "craftcms/laravel-ruleset-validation", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/craftcms/laravel-ruleset-validation.git", + "reference": "5fd54f4643f4746d6ac662bbec13aa225f62edfb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/craftcms/laravel-ruleset-validation/zipball/5fd54f4643f4746d6ac662bbec13aa225f62edfb", + "reference": "5fd54f4643f4746d6ac662bbec13aa225f62edfb", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^13.0", + "illuminate/support": "^13.0", + "illuminate/validation": "^13.0", + "php": "^8.4" + }, + "require-dev": { + "larastan/larastan": "^3.0", + "laravel/pint": "^v1.29", + "nunomaduro/collision": "^8.1.1", + "orchestra/testbench": "^11.0", + "pestphp/pest": "^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "CraftCms\\RulesetValidation\\RulesetValidationServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "CraftCms\\RulesetValidation\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Pixel & Tonic", + "homepage": "https://pixelandtonic.com/" + } + ], + "description": "Validate requests and objects with reusable Laravel rulesets.", + "homepage": "https://github.com/craftcms/laravel-ruleset-validation", + "keywords": [ + "craftcms", + "laravel", + "rulesets", + "validation" + ], + "support": { + "issues": "https://github.com/craftcms/laravel-ruleset-validation/issues", + "source": "https://github.com/craftcms/laravel-ruleset-validation/tree/1.1.0" + }, + "time": "2026-04-22T11:01:37+00:00" }, { "name": "craftcms/plugin-installer", @@ -658,16 +879,16 @@ }, { "name": "craftcms/server-check", - "version": "5.1.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/craftcms/server-check.git", - "reference": "7a4f1720c4fe1f0731254a82e63060a217f51cdc" + "reference": "8628114d482ac2c18747619b82b8d06d337145fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/server-check/zipball/7a4f1720c4fe1f0731254a82e63060a217f51cdc", - "reference": "7a4f1720c4fe1f0731254a82e63060a217f51cdc", + "url": "https://api.github.com/repos/craftcms/server-check/zipball/8628114d482ac2c18747619b82b8d06d337145fb", + "reference": "8628114d482ac2c18747619b82b8d06d337145fb", "shasum": "" }, "type": "library", @@ -696,7 +917,7 @@ "rss": "https://github.com/craftcms/server-check/releases.atom", "source": "https://github.com/craftcms/server-check" }, - "time": "2026-04-07T16:48:35+00:00" + "time": "2026-05-06T18:15:11+00:00" }, { "name": "craftcms/url-validator", @@ -755,98 +976,129 @@ "time": "2026-07-06T20:35:29+00:00" }, { - "name": "creocoder/yii2-nested-sets", - "version": "0.9.0", - "source": { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { "type": "git", - "url": "https://github.com/creocoder/yii2-nested-sets.git", - "reference": "cb8635a459b6246e5a144f096b992dcc30cf9954" + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/creocoder/yii2-nested-sets/zipball/cb8635a459b6246e5a144f096b992dcc30cf9954", - "reference": "cb8635a459b6246e5a144f096b992dcc30cf9954", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", "shasum": "" }, "require": { - "yiisoft/yii2": "*" + "php": ">=7.1 <9.0" }, - "type": "yii2-extension", + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", "autoload": { "psr-4": { - "creocoder\\nestedsets\\": "src" + "DASPRiD\\Enum\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "BSD-2-Clause" ], "authors": [ { - "name": "Alexander Kochetov", - "email": "creocoder@gmail.com" + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" } ], - "description": "The nested sets behavior for the Yii framework", + "description": "PHP 7.1 enum implementation", "keywords": [ - "nested sets", - "yii2" + "enum", + "map" ], "support": { - "issues": "https://github.com/creocoder/yii2-nested-sets/issues", - "source": "https://github.com/creocoder/yii2-nested-sets/tree/master" + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" }, - "time": "2015-01-27T10:53:51+00:00" + "time": "2025-09-16T12:23:56+00:00" }, { - "name": "dasprid/enum", - "version": "1.0.7", + "name": "dflydev/dot-access-data", + "version": "v3.0.3", "source": { "type": "git", - "url": "https://github.com/DASPRiD/Enum.git", - "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", - "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", "shasum": "" }, "require": { - "php": ">=7.1 <9.0" + "php": "^7.1 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", - "squizlabs/php_codesniffer": "*" + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, "autoload": { "psr-4": { - "DASPRiD\\Enum\\": "src/" + "Dflydev\\DotAccessData\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-2-Clause" + "MIT" ], "authors": [ { - "name": "Ben Scholzen 'DASPRiD'", - "email": "mail@dasprids.de", - "homepage": "https://dasprids.de/", - "role": "Developer" + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" } ], - "description": "PHP 7.1 enum implementation", + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", "keywords": [ - "enum", - "map" + "access", + "data", + "dot", + "notation" ], "support": { - "issues": "https://github.com/DASPRiD/Enum/issues", - "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" }, - "time": "2025-09-16T12:23:56+00:00" + "time": "2024-07-08T12:26:09+00:00" }, { "name": "doctrine/collections", @@ -1151,16 +1403,16 @@ }, { "name": "dompdf/dompdf", - "version": "v3.1.5", + "version": "v3.1.6", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", - "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005", "shasum": "" }, "require": { @@ -1209,9 +1461,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.6" }, - "time": "2026-03-03T13:54:37+00:00" + "time": "2026-07-20T12:29:38+00:00" }, { "name": "dompdf/php-font-lib", @@ -1304,6 +1556,70 @@ }, "time": "2026-01-02T16:01:13+00:00" }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, { "name": "egulias/email-validator", "version": "4.0.4", @@ -1373,24 +1689,24 @@ }, { "name": "elvanto/litemoji", - "version": "4.3.0", + "version": "5.2.0", "source": { "type": "git", "url": "https://github.com/elvanto/litemoji.git", - "reference": "f13cf10686f7110a3b17d09de03050d0708840b8" + "reference": "859dbcaa31ac5eb99c0811a981ba9d9ca3dab1c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/elvanto/litemoji/zipball/f13cf10686f7110a3b17d09de03050d0708840b8", - "reference": "f13cf10686f7110a3b17d09de03050d0708840b8", + "url": "https://api.github.com/repos/elvanto/litemoji/zipball/859dbcaa31ac5eb99c0811a981ba9d9ca3dab1c1", + "reference": "859dbcaa31ac5eb99c0811a981ba9d9ca3dab1c1", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=7.3" + "php": ">=7.4" }, "require-dev": { - "milesj/emojibase": "7.0.*", + "milesj/emojibase": "^16.0.3", "phpunit/phpunit": "^9.0" }, "type": "library", @@ -1410,9 +1726,9 @@ ], "support": { "issues": "https://github.com/elvanto/litemoji/issues", - "source": "https://github.com/elvanto/litemoji/tree/4.3.0" + "source": "https://github.com/elvanto/litemoji/tree/5.2.0" }, - "time": "2022-10-28T02:32:19+00:00" + "time": "2025-07-15T04:24:11+00:00" }, { "name": "enshrined/svg-sanitize", @@ -1460,84 +1776,156 @@ "time": "2025-08-12T10:13:48+00:00" }, { - "name": "ezyang/htmlpurifier", - "version": "v4.19.0", + "name": "fruitcake/php-cors", + "version": "v1.4.0", "source": { "type": "git", - "url": "https://github.com/ezyang/htmlpurifier.git", - "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", - "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", "shasum": "" }, "require": { - "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" }, "require-dev": { - "cerdic/css-tidy": "^1.7 || ^2.0", - "simpletest/simpletest": "dev-master" - }, - "suggest": { - "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", - "ext-bcmath": "Used for unit conversion and imagecrash protection", - "ext-iconv": "Converts text to and from non-UTF-8 encodings", - "ext-tidy": "Used for pretty-printing HTML" + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, "autoload": { - "files": [ - "library/HTMLPurifier.composer.php" - ], - "psr-0": { - "HTMLPurifier": "library/" + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" }, - "exclude-from-classmap": [ - "/library/HTMLPurifier/Language/" - ] + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-2.1-or-later" + "MIT" ], "authors": [ { - "name": "Edward Z. Yang", - "email": "admin@htmlpurifier.org", - "homepage": "http://ezyang.com" + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" } ], - "description": "Standards compliant HTML filter written in PHP", - "homepage": "http://htmlpurifier.org/", + "description": "An Implementation Of The Result Type", "keywords": [ - "html" + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" ], "support": { - "issues": "https://github.com/ezyang/htmlpurifier/issues", - "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" }, - "time": "2025-10-17T16:34:55+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.14.0", + "version": "7.15.3", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "aef242412e13128b5049864867bb49fc37dd39de" + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aef242412e13128b5049864867bb49fc37dd39de", - "reference": "aef242412e13128b5049864867bb49fc37dd39de", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.1", - "guzzlehttp/psr7": "^2.12.4", + "guzzlehttp/promises": "^2.5.2", + "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -1550,7 +1938,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.3", - "guzzlehttp/test-server": "^0.6", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1630,7 +2018,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.14.0" + "source": "https://github.com/guzzle/guzzle/tree/7.15.3" }, "funding": [ { @@ -1646,20 +2034,20 @@ "type": "tidelift" } ], - "time": "2026-07-08T22:54:09+00:00" + "time": "2026-08-05T19:48:21+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.1", + "version": "2.5.2", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", + "url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce", "shasum": "" }, "require": { @@ -1714,7 +2102,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.1" + "source": "https://github.com/guzzle/promises/tree/2.5.2" }, "funding": [ { @@ -1730,20 +2118,20 @@ "type": "tidelift" } ], - "time": "2026-07-08T15:48:39+00:00" + "time": "2026-08-05T19:30:54+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.12.4", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c" + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", - "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", "shasum": "" }, "require": { @@ -1833,7 +2221,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.4" + "source": "https://github.com/guzzle/psr7/tree/2.13.0" }, "funding": [ { @@ -1849,34 +2237,120 @@ "type": "tidelift" } ], - "time": "2026-07-08T15:56:20+00:00" + "time": "2026-07-16T22:23:49+00:00" }, { - "name": "ibericode/vat", - "version": "2.1.2", + "name": "guzzlehttp/uri-template", + "version": "v2.0.0", "source": { "type": "git", - "url": "https://github.com/ibericode/vat.git", - "reference": "6abfd3a579946347d9cb66883c75f49244da8f8c" + "url": "https://github.com/guzzle/uri-template.git", + "reference": "516c3bf2af176c532d5b59b3430292f7e9ecccb1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ibericode/vat/zipball/6abfd3a579946347d9cb66883c75f49244da8f8c", - "reference": "6abfd3a579946347d9cb66883c75f49244da8f8c", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/516c3bf2af176c532d5b59b3430292f7e9ecccb1", + "reference": "516c3bf2af176c532d5b59b3430292f7e9ecccb1", "shasum": "" }, "require": { - "ext-curl": "*", - "ext-json": "*", - "php": ">=8.2" + "php": "^7.4 || ^8.0", + "symfony/polyfill-php80": "^1.25" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.54", - "phpunit/phpunit": "^11.1" - }, - "suggest": { - "ext-soap": "Needed to support VIES VAT number validation", - "ibericode/vat-bundle": "Symfony bundle for integrating this package" + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34", + "uri-template/tests": "1.0.2" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v2.0.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-07-20T13:40:43+00:00" + }, + { + "name": "ibericode/vat", + "version": "2.1.2", + "source": { + "type": "git", + "url": "https://github.com/ibericode/vat.git", + "reference": "6abfd3a579946347d9cb66883c75f49244da8f8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ibericode/vat/zipball/6abfd3a579946347d9cb66883c75f49244da8f8c", + "reference": "6abfd3a579946347d9cb66883c75f49244da8f8c", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "php": ">=8.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.54", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "ext-soap": "Needed to support VIES VAT number validation", + "ibericode/vat-bundle": "Symfony bundle for integrating this package" }, "type": "library", "autoload": { @@ -1963,40 +2437,53 @@ "time": "2020-12-07T12:18:49+00:00" }, { - "name": "illuminate/collections", - "version": "v10.49.0", + "name": "inertiajs/inertia-laravel", + "version": "v3.3.1", "source": { "type": "git", - "url": "https://github.com/illuminate/collections.git", - "reference": "6ae9c74fa92d4e1824d1b346cd435e8eacdc3232" + "url": "https://github.com/inertiajs/inertia-laravel.git", + "reference": "7bfd75e352938b703180574b943d81963555a0aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/collections/zipball/6ae9c74fa92d4e1824d1b346cd435e8eacdc3232", - "reference": "6ae9c74fa92d4e1824d1b346cd435e8eacdc3232", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/7bfd75e352938b703180574b943d81963555a0aa", + "reference": "7bfd75e352938b703180574b943d81963555a0aa", "shasum": "" }, "require": { - "illuminate/conditionable": "^10.0", - "illuminate/contracts": "^10.0", - "illuminate/macroable": "^10.0", - "php": "^8.1" + "ext-json": "*", + "laravel/framework": "^11.35|^12.0|^13.0", + "php": "^8.2.0", + "symfony/console": "^7.0|^8.0" + }, + "conflict": { + "laravel/boost": "<2.5.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.15.2|^8.0", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.16", + "mockery/mockery": "^1.3.3", + "orchestra/testbench": "^9.2|^10.0|^11.0", + "phpunit/phpunit": "^11.5|^12.0" }, "suggest": { - "symfony/var-dumper": "Required to use the dump method (^6.2)." + "ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command." }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "10.x-dev" + "laravel": { + "providers": [ + "Inertia\\ServiceProvider" + ] } }, "autoload": { "files": [ - "helpers.php" + "./helpers.php" ], "psr-4": { - "Illuminate\\Support\\": "" + "Inertia\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2005,44 +2492,49 @@ ], "authors": [ { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" + "name": "Jonathan Reinink", + "email": "jonathan@reinink.ca", + "homepage": "https://reinink.ca" } ], - "description": "The Illuminate Collections package.", - "homepage": "https://laravel.com", + "description": "The Laravel adapter for Inertia.js.", + "keywords": [ + "inertia", + "laravel" + ], "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" + "issues": "https://github.com/inertiajs/inertia-laravel/issues", + "source": "https://github.com/inertiajs/inertia-laravel/tree/v3.3.1" }, - "time": "2025-09-08T19:05:53+00:00" + "time": "2026-08-04T21:58:51+00:00" }, { - "name": "illuminate/conditionable", - "version": "v10.49.0", + "name": "intervention/gif", + "version": "5.0.1", "source": { "type": "git", - "url": "https://github.com/illuminate/conditionable.git", - "reference": "47c700320b7a419f0d188d111f3bbed978fcbd3f" + "url": "https://github.com/Intervention/gif.git", + "reference": "bb395af960deffe64d70c976b4df9283f68e762d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/conditionable/zipball/47c700320b7a419f0d188d111f3bbed978fcbd3f", - "reference": "47c700320b7a419f0d188d111f3bbed978fcbd3f", + "url": "https://api.github.com/repos/Intervention/gif/zipball/bb395af960deffe64d70c976b4df9283f68e762d", + "reference": "bb395af960deffe64d70c976b4df9283f68e762d", "shasum": "" }, "require": { - "php": "^8.0.2" + "php": "^8.3" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "10.x-dev" - } + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^4" }, + "type": "library", "autoload": { "psr-4": { - "Illuminate\\Support\\": "" + "Intervention\\Gif\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2051,46 +2543,72 @@ ], "authors": [ { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" } ], - "description": "The Illuminate Conditionable package.", - "homepage": "https://laravel.com", + "description": "PHP GIF Encoder/Decoder", + "homepage": "https://github.com/intervention/gif", + "keywords": [ + "animation", + "gd", + "gif", + "image" + ], "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" + "issues": "https://github.com/Intervention/gif/issues", + "source": "https://github.com/Intervention/gif/tree/5.0.1" }, - "time": "2025-03-24T11:47:24+00:00" + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-05-03T06:04:47+00:00" }, { - "name": "illuminate/contracts", - "version": "v10.49.0", + "name": "intervention/image", + "version": "4.2.1", "source": { "type": "git", - "url": "https://github.com/illuminate/contracts.git", - "reference": "2393ef579e020d88e24283913c815c3e2c143323" + "url": "https://github.com/Intervention/image.git", + "reference": "0a5aa57ad56887f24967d735457471db7c0ea1f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/contracts/zipball/2393ef579e020d88e24283913c815c3e2c143323", - "reference": "2393ef579e020d88e24283913c815c3e2c143323", + "url": "https://api.github.com/repos/Intervention/image/zipball/0a5aa57ad56887f24967d735457471db7c0ea1f5", + "reference": "0a5aa57ad56887f24967d735457471db7c0ea1f5", "shasum": "" }, "require": { - "php": "^8.1", - "psr/container": "^1.1.1|^2.0.1", - "psr/simple-cache": "^1.0|^2.0|^3.0" + "ext-mbstring": "*", + "intervention/gif": "^5", + "php": "^8.3" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "10.x-dev" - } + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^4" + }, + "suggest": { + "ext-exif": "Recommended to be able to read EXIF data properly." }, + "type": "library", "autoload": { "psr-4": { - "Illuminate\\Contracts\\": "" + "Intervention\\Image\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2099,44 +2617,244 @@ ], "authors": [ { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io" } ], - "description": "The Illuminate Contracts package.", - "homepage": "https://laravel.com", + "description": "PHP Image Processing", + "homepage": "https://image.intervention.io", + "keywords": [ + "gd", + "image", + "imagick", + "resize", + "thumbnail", + "watermark" + ], "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/4.2.1" }, - "time": "2025-03-24T11:47:24+00:00" + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-08-08T07:55:25+00:00" }, { - "name": "illuminate/macroable", - "version": "v10.49.0", + "name": "laravel/framework", + "version": "v13.26.1", "source": { "type": "git", - "url": "https://github.com/illuminate/macroable.git", - "reference": "dff667a46ac37b634dcf68909d9d41e94dc97c27" + "url": "https://github.com/laravel/framework.git", + "reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/macroable/zipball/dff667a46ac37b634dcf68909d9d41e94dc97c27", - "reference": "dff667a46ac37b634dcf68909d9d41e94dc97c27", + "url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab", + "reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab", "shasum": "" }, "require": { - "php": "^8.1" + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.9 || ^3.0", + "guzzlehttp/uri-template": "^1.0 || ^2.0", + "laravel/prompts": "^0.3.11", + "laravel/serializable-closure": "^2.0.10", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.10", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/http-message": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/image": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "intervention/image": "^4.0", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^11.0.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "rector/rector": "^2.3", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "intervention/image": "Required to use the image processing features (^4.0).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "10.x-dev" + "dev-master": "13.0.x-dev" } }, "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], "psr-4": { - "Illuminate\\Support\\": "" + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] } }, "notification-url": "https://packagist.org/downloads/", @@ -2149,65 +2867,110 @@ "email": "taylor@laravel.com" } ], - "description": "The Illuminate Macroable package.", + "description": "The Laravel Framework.", "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], "support": { "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2023-06-05T12:46:42+00:00" + "time": "2026-08-18T20:31:28+00:00" }, { - "name": "illuminate/support", - "version": "v10.49.0", + "name": "laravel/prompts", + "version": "v0.3.23", "source": { "type": "git", - "url": "https://github.com/illuminate/support.git", - "reference": "28b505e671dbe119e4e32a75c78f87189d046e39" + "url": "https://github.com/laravel/prompts.git", + "reference": "b7b4c35e5bc47450f6b6238c6cc9c47ba19b2221" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/support/zipball/28b505e671dbe119e4e32a75c78f87189d046e39", - "reference": "28b505e671dbe119e4e32a75c78f87189d046e39", + "url": "https://api.github.com/repos/laravel/prompts/zipball/b7b4c35e5bc47450f6b6238c6cc9c47ba19b2221", + "reference": "b7b4c35e5bc47450f6b6238c6cc9c47ba19b2221", "shasum": "" }, "require": { - "doctrine/inflector": "^2.0", - "ext-ctype": "*", - "ext-filter": "*", + "composer-runtime-api": "^2.2", "ext-mbstring": "*", - "illuminate/collections": "^10.0", - "illuminate/conditionable": "^10.0", - "illuminate/contracts": "^10.0", - "illuminate/macroable": "^10.0", - "nesbot/carbon": "^2.67", "php": "^8.1", - "voku/portable-ascii": "^2.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { - "tightenco/collect": "<5.5.33" + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" }, "suggest": { - "illuminate/filesystem": "Required to use the composer class (^10.0).", - "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.6).", - "ramsey/uuid": "Required to use Str::uuid() (^4.7).", - "symfony/process": "Required to use the composer class (^6.2).", - "symfony/uid": "Required to use Str::ulid() (^6.2).", - "symfony/var-dumper": "Required to use the dd function (^6.2).", - "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.4.1)." + "ext-pcntl": "Required for the spinner to be animated." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "10.x-dev" + "dev-main": "0.3.x-dev" } }, "autoload": { "files": [ - "helpers.php" + "src/helpers.php" ], "psr-4": { - "Illuminate\\Support\\": "" + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.23" + }, + "time": "2026-08-11T18:58:24+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.15", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2218,61 +2981,64 @@ { "name": "Taylor Otwell", "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" } ], - "description": "The Illuminate Support package.", - "homepage": "https://laravel.com", + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" }, - "time": "2025-09-08T19:05:53+00:00" + "time": "2026-07-21T16:49:22+00:00" }, { - "name": "league/uri", - "version": "7.8.1", + "name": "laravel/wayfinder", + "version": "v0.1.21", "source": { "type": "git", - "url": "https://github.com/thephpleague/uri.git", - "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + "url": "https://github.com/laravel/wayfinder.git", + "reference": "a85a996cea189f59cac14854f8b13319a29007f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", - "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "url": "https://api.github.com/repos/laravel/wayfinder/zipball/a85a996cea189f59cac14854f8b13319a29007f3", + "reference": "a85a996cea189f59cac14854f8b13319a29007f3", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.8.1", - "php": "^8.1", - "psr/http-factory": "^1" + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/filesystem": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "phpstan/phpdoc-parser": "^2.3" }, "conflict": { - "league/uri-schemes": "^1.0" + "laravel/boost": "<2.5.0" }, - "suggest": { - "ext-bcmath": "to improve IPV4 host parsing", - "ext-dom": "to convert the URI into an HTML anchor tag", - "ext-fileinfo": "to create Data URI from file contennts", - "ext-gmp": "to improve IPV4 host parsing", - "ext-intl": "to handle IDN host with the best performance", - "ext-uri": "to use the PHP native URI class", - "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", - "league/uri-components": "to provide additional tools to manipulate URI objects components", - "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", - "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", - "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + "require-dev": { + "laravel/pint": "^1.21", + "orchestra/testbench": "^11.0|^10.1|^9.0" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "7.x-dev" + "laravel": { + "providers": [ + "Laravel\\Wayfinder\\WayfinderServiceProvider" + ] } }, "autoload": { "psr-4": { - "League\\Uri\\": "" + "Laravel\\Wayfinder\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2281,240 +3047,264 @@ ], "authors": [ { - "name": "Ignace Nyamagana Butera", - "email": "nyamsprod@gmail.com", - "homepage": "https://nyamsprod.com" + "name": "Taylor Otwell", + "email": "taylor@laravel.com" } ], - "description": "URI manipulation library", - "homepage": "https://uri.thephpleague.com", + "description": "Generate TypeScript representations of your Laravel actions and routes.", + "homepage": "https://github.com/laravel/wayfinder", "keywords": [ - "URN", - "data-uri", - "file-uri", - "ftp", - "hostname", - "http", - "https", - "middleware", - "parse_str", - "parse_url", - "psr-7", - "query-string", - "querystring", - "rfc2141", - "rfc3986", - "rfc3987", - "rfc6570", - "rfc8141", - "uri", - "uri-template", - "url", - "ws" + "laravel", + "php", + "routes", + "typescript" ], "support": { - "docs": "https://uri.thephpleague.com", - "forum": "https://thephpleague.slack.com", - "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.8.1" + "issues": "https://github.com/laravel/wayfinder/issues", + "source": "https://github.com/laravel/wayfinder" }, - "funding": [ - { - "url": "https://github.com/sponsors/nyamsprod", - "type": "github" - } - ], - "time": "2026-03-15T20:22:25+00:00" + "time": "2026-08-04T21:55:43+00:00" }, { - "name": "league/uri-interfaces", - "version": "7.8.1", + "name": "league/commonmark", + "version": "2.10.0", "source": { "type": "git", - "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", - "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4", + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4", "shasum": "" }, "require": { - "ext-filter": "*", - "php": "^8.1", - "psr/http-message": "^1.1 || ^2.0" + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, "suggest": { - "ext-bcmath": "to improve IPV4 host parsing", - "ext-gmp": "to improve IPV4 host parsing", - "ext-intl": "to handle IDN host with the best performance", - "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", - "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + "symfony/yaml": "v2.3+ required if using the Front Matter extension" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "7.x-dev" + "dev-main": "2.11-dev" } }, "autoload": { "psr-4": { - "League\\Uri\\": "" + "League\\CommonMark\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Ignace Nyamagana Butera", - "email": "nyamsprod@gmail.com", - "homepage": "https://nyamsprod.com" + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" } ], - "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", - "homepage": "https://uri.thephpleague.com", + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", "keywords": [ - "data-uri", - "file-uri", - "ftp", - "hostname", - "http", - "https", - "parse_str", - "parse_url", - "psr-7", - "query-string", - "querystring", - "rfc3986", - "rfc3987", - "rfc6570", - "uri", - "url", - "ws" + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" ], "support": { - "docs": "https://uri.thephpleague.com", - "forum": "https://thephpleague.slack.com", - "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" }, "funding": [ { - "url": "https://github.com/sponsors/nyamsprod", + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" } ], - "time": "2026-03-08T20:05:35+00:00" + "time": "2026-08-11T16:06:25+00:00" }, { - "name": "maennchen/zipstream-php", - "version": "3.1.2", + "name": "league/config", + "version": "v1.2.0", "source": { "type": "git", - "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f" + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f", - "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", "shasum": "" }, "require": { - "ext-mbstring": "*", - "ext-zlib": "*", - "php-64bit": "^8.2" + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" }, "require-dev": { - "brianium/paratest": "^7.7", - "ext-zip": "*", - "friendsofphp/php-cs-fixer": "^3.16", - "guzzlehttp/guzzle": "^7.5", - "mikey179/vfsstream": "^1.6", - "php-coveralls/php-coveralls": "^2.5", - "phpunit/phpunit": "^11.0", - "vimeo/psalm": "^6.0" - }, - "suggest": { - "guzzlehttp/psr7": "^2.4", - "psr/http-message": "^2.0" + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, "autoload": { "psr-4": { - "ZipStream\\": "src/" + "League\\Config\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Paul Duncan", - "email": "pabs@pablotron.org" - }, - { - "name": "Jonatan Männchen", - "email": "jonatan@maennchen.ch" - }, - { - "name": "Jesse Donat", - "email": "donatj@gmail.com" - }, - { - "name": "András Kolesár", - "email": "kolesar@kolesar.hu" + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" } ], - "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", "keywords": [ - "stream", - "zip" + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" ], "support": { - "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2" + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" }, "funding": [ { - "url": "https://github.com/maennchen", + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", "type": "github" } ], - "time": "2025-01-27T12:07:53+00:00" + "time": "2022-12-11T20:36:23+00:00" }, { - "name": "markbaker/complex", - "version": "3.0.2", + "name": "league/flysystem", + "version": "3.35.2", "source": { "type": "git", - "url": "https://github.com/MarkBaker/PHPComplex.git", - "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "b277b5dc3d56650b68904117124e79c851e12376" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", - "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", + "reference": "b277b5dc3d56650b68904117124e79c851e12376", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" }, - "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "dev-master", - "phpcompatibility/php-compatibility": "^9.3", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", - "squizlabs/php_codesniffer": "^3.7" + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" }, "type": "library", "autoload": { "psr-4": { - "Complex\\": "classes/src/" + "League\\Flysystem\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2523,53 +3313,54 @@ ], "authors": [ { - "name": "Mark Baker", - "email": "mark@lange.demon.co.uk" + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" } ], - "description": "PHP Class for working with complex numbers", - "homepage": "https://github.com/MarkBaker/PHPComplex", + "description": "File storage abstraction for PHP", "keywords": [ - "complex", - "mathematics" + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" ], "support": { - "issues": "https://github.com/MarkBaker/PHPComplex/issues", - "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" }, - "time": "2022-12-06T16:21:08+00:00" + "time": "2026-07-06T14:42:07+00:00" }, { - "name": "markbaker/matrix", - "version": "3.0.1", + "name": "league/flysystem-local", + "version": "3.31.0", "source": { "type": "git", - "url": "https://github.com/MarkBaker/PHPMatrix.git", - "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", - "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "dev-master", - "phpcompatibility/php-compatibility": "^9.3", - "phpdocumentor/phpdocumentor": "2.*", - "phploc/phploc": "^4.0", - "phpmd/phpmd": "2.*", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", - "sebastian/phpcpd": "^4.0", - "squizlabs/php_codesniffer": "^3.7" + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" }, "type": "library", "autoload": { "psr-4": { - "Matrix\\": "classes/src/" + "League\\Flysystem\\Local\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -2578,53 +3369,45 @@ ], "authors": [ { - "name": "Mark Baker", - "email": "mark@demon-angel.eu" + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" } ], - "description": "PHP Class for working with matrices", - "homepage": "https://github.com/MarkBaker/PHPMatrix", + "description": "Local filesystem adapter for Flysystem.", "keywords": [ - "mathematics", - "matrix", - "vector" + "Flysystem", + "file", + "files", + "filesystem", + "local" ], "support": { - "issues": "https://github.com/MarkBaker/PHPMatrix/issues", - "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" }, - "time": "2022-12-02T22:17:43+00:00" + "time": "2026-01-23T15:30:45+00:00" }, { - "name": "masterminds/html5", - "version": "2.10.1", + "name": "league/flysystem-path-prefixing", + "version": "3.31.0", "source": { "type": "git", - "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fd5018f6815fff903946d0564977b44ce8010e29" + "url": "https://github.com/thephpleague/flysystem-path-prefixing.git", + "reference": "d7f667c2d9d6684b74f30c6ad81ae7a0c23232f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", - "reference": "fd5018f6815fff903946d0564977b44ce8010e29", + "url": "https://api.github.com/repos/thephpleague/flysystem-path-prefixing/zipball/d7f667c2d9d6684b74f30c6ad81ae7a0c23232f3", + "reference": "d7f667c2d9d6684b74f30c6ad81ae7a0c23232f3", "shasum": "" }, "require": { - "ext-dom": "*", - "php": ">=5.3.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" + "league/flysystem": "^3.10.0", + "php": "^8.0.2" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.7-dev" - } - }, "autoload": { "psr-4": { - "Masterminds\\": "src" + "League\\Flysystem\\PathPrefixing\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -2633,59 +3416,49 @@ ], "authors": [ { - "name": "Matt Butcher", - "email": "technosophos@gmail.com" - }, - { - "name": "Matt Farina", - "email": "matt@mattfarina.com" - }, - { - "name": "Asmir Mustafic", - "email": "goetas@gmail.com" + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" } ], - "description": "An HTML5 parser and serializer.", - "homepage": "http://masterminds.github.io/html5-php", + "description": "Path prefixing filesystem adapter for Flysystem.", "keywords": [ - "HTML5", - "dom", - "html", - "parser", - "querypath", - "serializer", - "xml" + "Flysystem", + "filesystem", + "prefix", + "prefixing" ], "support": { - "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" + "source": "https://github.com/thephpleague/flysystem-path-prefixing/tree/3.31.0" }, - "time": "2026-06-23T18:43:15+00:00" + "time": "2026-01-23T15:30:45+00:00" }, { - "name": "mikehaertl/php-shellcommand", - "version": "1.7.0", + "name": "league/mime-type-detection", + "version": "1.17.0", "source": { "type": "git", - "url": "https://github.com/mikehaertl/php-shellcommand.git", - "reference": "e79ea528be155ffdec6f3bf1a4a46307bb49e545" + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mikehaertl/php-shellcommand/zipball/e79ea528be155ffdec6f3bf1a4a46307bb49e545", - "reference": "e79ea528be155ffdec6f3bf1a4a46307bb49e545", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { - "php": ">= 5.3.0" + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" }, "require-dev": { - "phpunit/phpunit": ">4.0 <=9.4" + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { "psr-4": { - "mikehaertl\\shellcommand\\": "src/" + "League\\MimeTypeDetection\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2694,76 +3467,72 @@ ], "authors": [ { - "name": "Michael Härtl", - "email": "haertl.mike@gmail.com" + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" } ], - "description": "An object oriented interface to shell commands", - "keywords": [ - "shell" - ], + "description": "Mime-type detection for Flysystem", "support": { - "issues": "https://github.com/mikehaertl/php-shellcommand/issues", - "source": "https://github.com/mikehaertl/php-shellcommand/tree/1.7.0" + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" }, - "time": "2023-04-19T08:25:22+00:00" + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" }, { - "name": "moneyphp/money", - "version": "v4.9.0", + "name": "league/uri", + "version": "7.8.1", "source": { "type": "git", - "url": "https://github.com/moneyphp/money.git", - "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b" + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/moneyphp/money/zipball/d49ee625c6ba79b9d7a228ce153b02fc1032152b", - "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "ext-bcmath": "*", - "ext-filter": "*", - "ext-json": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" }, - "require-dev": { - "cache/taggable-cache": "^1.1.0", - "doctrine/coding-standard": "^12.0", - "doctrine/instantiator": "^1.5.0 || ^2.0", - "ext-gmp": "*", - "ext-intl": "*", - "florianv/exchanger": "^2.8.1", - "florianv/swap": "^4.3.0", - "moneyphp/crypto-currencies": "^1.1.0", - "moneyphp/iso-currencies": "^3.4", - "php-http/message": "^1.16.0", - "php-http/mock-client": "^1.6.0", - "phpbench/phpbench": "^1.2.5", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1.9", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5.9", - "psr/cache": "^1.0.1 || ^2.0 || ^3.0", - "ticketswap/phpstan-error-formatter": "^1.1" + "conflict": { + "league/uri-schemes": "^1.0" }, "suggest": { - "ext-gmp": "Calculate without integer limits", - "ext-intl": "Format Money objects with intl", - "florianv/exchanger": "Exchange rates library for PHP", - "florianv/swap": "Exchange rates library for PHP", - "psr/cache-implementation": "Used for Currency caching" + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-master": "7.x-dev" } }, "autoload": { "psr-4": { - "Money\\": "src/" + "League\\Uri\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -2772,99 +3541,87 @@ ], "authors": [ { - "name": "Mathias Verraes", - "email": "mathias@verraes.net", - "homepage": "http://verraes.net" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com" - }, - { - "name": "Frederik Bosch", - "email": "f.bosch@genkgo.nl" + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" } ], - "description": "PHP implementation of Fowler's Money pattern", - "homepage": "http://moneyphp.org", + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", "keywords": [ - "Value Object", - "money", - "vo" + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" ], "support": { - "issues": "https://github.com/moneyphp/money/issues", - "source": "https://github.com/moneyphp/money/tree/v4.9.0" + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, - "time": "2026-05-04T20:23:15+00:00" + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" }, { - "name": "monolog/monolog", - "version": "3.10.0", + "name": "league/uri-interfaces", + "version": "7.8.1", "source": { "type": "git", - "url": "https://github.com/Seldaek/monolog.git", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/log": "^2.0 || ^3.0" - }, - "provide": { - "psr/log-implementation": "3.0.0" - }, - "require-dev": { - "aws/aws-sdk-php": "^3.0", - "doctrine/couchdb": "~1.0@dev", - "elasticsearch/elasticsearch": "^7 || ^8", - "ext-json": "*", - "graylog2/gelf-php": "^1.4.2 || ^2.0", - "guzzlehttp/guzzle": "^7.4.5", - "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8 || ^2.0", - "php-amqplib/php-amqplib": "~2.4 || ^3", - "php-console/php-console": "^3.1.8", - "phpstan/phpstan": "^2", - "phpstan/phpstan-deprecation-rules": "^2", - "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "^10.5.17 || ^11.0.7", - "predis/predis": "^1.1 || ^2", - "rollbar/rollbar": "^4.0", - "ruflin/elastica": "^7 || ^8", - "symfony/mailer": "^5.4 || ^6", - "symfony/mime": "^5.4 || ^6" + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" }, "suggest": { - "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", - "doctrine/couchdb": "Allow sending log messages to a CouchDB server", - "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", - "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", - "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", - "ext-mbstring": "Allow to work properly with unicode symbols", - "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", - "ext-openssl": "Required to send log messages using SSL", - "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", - "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", - "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", - "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", - "rollbar/rollbar": "Allow sending log messages to Rollbar", - "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.x-dev" + "dev-master": "7.x-dev" } }, "autoload": { "psr-4": { - "Monolog\\": "src/Monolog" + "League\\Uri\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -2873,96 +3630,83 @@ ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "https://seld.be" + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" } ], - "description": "Sends your logs to files, sockets, inboxes, databases and various web services", - "homepage": "https://github.com/Seldaek/monolog", + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", "keywords": [ - "log", - "logging", - "psr-3" + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" ], "support": { - "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { - "url": "https://github.com/Seldaek", + "url": "https://github.com/sponsors/nyamsprod", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", - "type": "tidelift" } ], - "time": "2026-01-02T08:56:05+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { - "name": "nesbot/carbon", - "version": "2.73.0", + "name": "maennchen/zipstream-php", + "version": "3.2.2", "source": { "type": "git", - "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "9228ce90e1035ff2f0db84b40ec2e023ed802075" + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/9228ce90e1035ff2f0db84b40ec2e023ed802075", - "reference": "9228ce90e1035ff2f0db84b40ec2e023ed802075", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", "shasum": "" }, "require": { - "carbonphp/carbon-doctrine-types": "*", - "ext-json": "*", - "php": "^7.1.8 || ^8.0", - "psr/clock": "^1.0", - "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php80": "^1.16", - "symfony/translation": "^3.4 || ^4.0 || ^5.0 || ^6.0" - }, - "provide": { - "psr/clock-implementation": "1.0" + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" }, "require-dev": { - "doctrine/dbal": "^2.0 || ^3.1.4 || ^4.0", - "doctrine/orm": "^2.7 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.0", - "kylekatarnls/multi-tester": "^2.0", - "ondrejmirtes/better-reflection": "<6", - "phpmd/phpmd": "^2.9", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12.99 || ^1.7.14", - "phpunit/php-file-iterator": "^2.0.5 || ^3.0.6", - "phpunit/phpunit": "^7.5.20 || ^8.5.26 || ^9.5.20", - "squizlabs/php_codesniffer": "^3.4" + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" }, - "bin": [ - "bin/carbon" - ], - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Carbon\\Laravel\\ServiceProvider" - ] - }, - "phpstan": { - "includes": [ - "extension.neon" - ] - }, - "branch-alias": { - "dev-2.x": "2.x-dev", - "dev-master": "3.x-dev" - } + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" }, + "type": "library", "autoload": { "psr-4": { - "Carbon\\": "src/Carbon/" + "ZipStream\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2971,70 +3715,66 @@ ], "authors": [ { - "name": "Brian Nesbitt", - "email": "brian@nesbot.com", - "homepage": "https://markido.com" + "name": "Paul Duncan", + "email": "pabs@pablotron.org" }, { - "name": "kylekatarnls", - "homepage": "https://github.com/kylekatarnls" + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" } ], - "description": "An API extension for DateTime that supports 281 different languages.", - "homepage": "https://carbon.nesbot.com", + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", "keywords": [ - "date", - "datetime", - "time" + "stream", + "zip" ], "support": { - "docs": "https://carbon.nesbot.com/docs", - "issues": "https://github.com/briannesbitt/Carbon/issues", - "source": "https://github.com/briannesbitt/Carbon" + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" }, "funding": [ { - "url": "https://github.com/sponsors/kylekatarnls", + "url": "https://github.com/maennchen", "type": "github" - }, - { - "url": "https://opencollective.com/Carbon#sponsor", - "type": "opencollective" - }, - { - "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", - "type": "tidelift" } ], - "time": "2025-01-08T20:10:23+00:00" + "time": "2026-04-11T18:38:28+00:00" }, { - "name": "paragonie/constant_time_encoding", - "version": "v3.1.3", + "name": "markbaker/complex", + "version": "3.0.2", "source": { "type": "git", - "url": "https://github.com/paragonie/constant_time_encoding.git", - "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", - "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", "shasum": "" }, "require": { - "php": "^8" + "php": "^7.2 || ^8.0" }, "require-dev": { - "infection/infection": "^0", - "nikic/php-fuzzer": "^0", - "phpunit/phpunit": "^9|^10|^11", - "vimeo/psalm": "^4|^5|^6" + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" }, "type": "library", "autoload": { "psr-4": { - "ParagonIE\\ConstantTime\\": "src/" + "Complex\\": "classes/src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3043,66 +3783,53 @@ ], "authors": [ { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com", - "role": "Maintainer" - }, - { - "name": "Steve 'Sc00bz' Thomas", - "email": "steve@tobtu.com", - "homepage": "https://www.tobtu.com", - "role": "Original Developer" + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" } ], - "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", "keywords": [ - "base16", - "base32", - "base32_decode", - "base32_encode", - "base64", - "base64_decode", - "base64_encode", - "bin2hex", - "encoding", - "hex", - "hex2bin", - "rfc4648" + "complex", + "mathematics" ], "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/constant_time_encoding/issues", - "source": "https://github.com/paragonie/constant_time_encoding" + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" }, - "time": "2025-09-24T15:06:41+00:00" + "time": "2022-12-06T16:21:08+00:00" }, { - "name": "phpdocumentor/reflection-common", - "version": "2.2.0", + "name": "markbaker/matrix", + "version": "3.0.1", "source": { "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": "^7.1 || ^8.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.x-dev" - } + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" }, + "type": "library", "autoload": { "psr-4": { - "phpDocumentor\\Reflection\\": "src/" + "Matrix\\": "classes/src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3111,66 +3838,53 @@ ], "authors": [ { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" + "name": "Mark Baker", + "email": "mark@demon-angel.eu" } ], - "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", "keywords": [ - "FQSEN", - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" + "mathematics", + "matrix", + "vector" ], "support": { - "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" }, - "time": "2020-06-27T09:03:43+00:00" + "time": "2022-12-02T22:17:43+00:00" }, { - "name": "phpdocumentor/reflection-docblock", - "version": "5.6.7", + "name": "masterminds/html5", + "version": "2.10.1", "source": { "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "31a105931bc8ffa3a123383829772e832fd8d903" + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", - "reference": "31a105931bc8ffa3a123383829772e832fd8d903", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", "shasum": "" }, "require": { - "doctrine/deprecations": "^1.1", - "ext-filter": "*", - "php": "^7.4 || ^8.0", - "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1 || ^2" + "ext-dom": "*", + "php": ">=5.3.0" }, "require-dev": { - "mockery/mockery": "~1.3.5 || ~1.6.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-mockery": "^1.1", - "phpstan/phpstan-webmozart-assert": "^1.2", - "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.x-dev" + "dev-master": "2.7-dev" } }, "autoload": { "psr-4": { - "phpDocumentor\\Reflection\\": "src" + "Masterminds\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -3179,60 +3893,91 @@ ], "authors": [ { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" + "name": "Matt Butcher", + "email": "technosophos@gmail.com" }, { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" } ], - "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], "support": { - "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" - }, - "time": "2026-03-18T20:47:46+00:00" + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" + }, + "time": "2026-06-23T18:43:15+00:00" }, { - "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "name": "moneyphp/money", + "version": "v4.9.0", "source": { "type": "git", - "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "url": "https://github.com/moneyphp/money.git", + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/moneyphp/money/zipball/d49ee625c6ba79b9d7a228ce153b02fc1032152b", + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b", "shasum": "" }, "require": { - "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", - "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "ext-bcmath": "*", + "ext-filter": "*", + "ext-json": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "ext-tokenizer": "*", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "cache/taggable-cache": "^1.1.0", + "doctrine/coding-standard": "^12.0", + "doctrine/instantiator": "^1.5.0 || ^2.0", + "ext-gmp": "*", + "ext-intl": "*", + "florianv/exchanger": "^2.8.1", + "florianv/swap": "^4.3.0", + "moneyphp/crypto-currencies": "^1.1.0", + "moneyphp/iso-currencies": "^3.4", + "php-http/message": "^1.16.0", + "php-http/mock-client": "^1.6.0", + "phpbench/phpbench": "^1.2.5", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1.9", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.9", + "psr/cache": "^1.0.1 || ^2.0 || ^3.0", + "ticketswap/phpstan-error-formatter": "^1.1" + }, + "suggest": { + "ext-gmp": "Calculate without integer limits", + "ext-intl": "Format Money objects with intl", + "florianv/exchanger": "Exchange rates library for PHP", + "florianv/swap": "Exchange rates library for PHP", + "psr/cache-implementation": "Used for Currency caching" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-master": "3.x-dev" } }, "autoload": { "psr-4": { - "phpDocumentor\\Reflection\\": "src" + "Money\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3241,79 +3986,99 @@ ], "authors": [ { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" + "name": "Mathias Verraes", + "email": "mathias@verraes.net", + "homepage": "http://verraes.net" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + }, + { + "name": "Frederik Bosch", + "email": "f.bosch@genkgo.nl" } ], - "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "description": "PHP implementation of Fowler's Money pattern", + "homepage": "http://moneyphp.org", + "keywords": [ + "Value Object", + "money", + "vo" + ], "support": { - "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "issues": "https://github.com/moneyphp/money/issues", + "source": "https://github.com/moneyphp/money/tree/v4.9.0" }, - "time": "2025-11-21T15:09:14+00:00" + "time": "2026-05-04T20:23:15+00:00" }, { - "name": "phpoffice/phpspreadsheet", - "version": "5.8.0", + "name": "monolog/monolog", + "version": "3.10.0", "source": { "type": "git", - "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "01964d92536edf1a3a874b9580a52824bebf6fbb" + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/01964d92536edf1a3a874b9580a52824bebf6fbb", - "reference": "01964d92536edf1a3a874b9580a52824bebf6fbb", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "shasum": "" }, "require": { - "composer/pcre": "^1||^2||^3", - "ext-ctype": "*", - "ext-dom": "*", - "ext-fileinfo": "*", - "ext-filter": "*", - "ext-gd": "*", - "ext-iconv": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-simplexml": "*", - "ext-xml": "*", - "ext-xmlreader": "*", - "ext-xmlwriter": "*", - "ext-zip": "*", - "ext-zlib": "*", - "maennchen/zipstream-php": "^2.1 || ^3.0", - "markbaker/complex": "^3.0", - "markbaker/matrix": "^3.0", - "php": "^8.1", - "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" }, "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "dev-main", - "dompdf/dompdf": "^2.0 || ^3.0", - "ext-intl": "*", - "friendsofphp/php-cs-fixer": "^3.2", - "mitoteam/jpgraph": "^10.5", - "mpdf/mpdf": "^8.1.1", - "phpcompatibility/php-compatibility": "^9.3", - "phpstan/phpstan": "^1.1 || ^2.0", - "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", - "phpstan/phpstan-phpunit": "^1.0 || ^2.0", - "phpunit/phpunit": "^10.5", - "squizlabs/php_codesniffer": "^3.7", - "tecnickcom/tcpdf": "^6.5" + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" }, "suggest": { - "dompdf/dompdf": "Option for rendering PDF with PDF Writer", - "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()", - "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", - "mpdf/mpdf": "Option for rendering PDF with PDF Writer", - "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, "autoload": { "psr-4": { - "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + "Monolog\\": "src/Monolog" } }, "notification-url": "https://packagist.org/downloads/", @@ -3322,191 +4087,1551 @@ ], "authors": [ { - "name": "Maarten Balliauw", - "homepage": "https://blog.maartenballiauw.be" - }, + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ { - "name": "Mark Baker", - "homepage": "https://markbakeruk.net" + "url": "https://github.com/Seldaek", + "type": "github" }, { - "name": "Franck Lefevre", - "homepage": "https://rootslabs.net" + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.13.2", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] }, - { - "name": "Erik Tilt" + "phpstan": { + "includes": [ + "extension.neon" + ] }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ { - "name": "Adrien Crivelli" + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" }, { - "name": "Owen Leibman" + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" } ], - "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", - "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", "keywords": [ - "OpenXML", - "excel", - "gnumeric", - "ods", + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-08-08T11:40:35+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.6", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "c54350438cd6914616f790a49cb424605f421562" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.6" + }, + "time": "2026-08-16T21:58:41+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", "php", - "spreadsheet", - "xls", - "xlsx" + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpoffice/phpspreadsheet", + "version": "5.9.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339", + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^8.2", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^2.0 || ^3.0", + "ext-intl": "*", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.5", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1 || ^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": "^10.5 || ^11.0", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0" + }, + "time": "2026-07-12T19:17:39+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+00:00" + }, + { + "name": "pragmarx/google2fa", + "version": "v9.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" + }, + "time": "2025-09-19T22:51:08+00:00" + }, + { + "name": "pragmarx/random", + "version": "v0.2.2", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/random.git", + "reference": "daf08a189c5d2d40d1a827db46364d3a741a51b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/random/zipball/daf08a189c5d2d40d1a827db46364d3a741a51b7", + "reference": "daf08a189c5d2d40d1a827db46364d3a741a51b7", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "fzaninotto/faker": "~1.7", + "phpunit/phpunit": "~6.4", + "pragmarx/trivia": "~0.1", + "squizlabs/php_codesniffer": "^2.3" + }, + "suggest": { + "fzaninotto/faker": "Allows you to get dozens of randomized types", + "pragmarx/trivia": "For the trivia database" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Random\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "homepage": "https://antoniocarlosribeiro.com", + "role": "Developer" + } + ], + "description": "Create random chars, numbers, strings", + "homepage": "https://github.com/antonioribeiro/random", + "keywords": [ + "Randomize", + "faker", + "pragmarx", + "random", + "random number", + "random pattern", + "random string" + ], + "support": { + "issues": "https://github.com/antonioribeiro/random/issues", + "source": "https://github.com/antonioribeiro/random/tree/master" + }, + "time": "2017-11-21T05:26:22+00:00" + }, + { + "name": "pragmarx/recovery", + "version": "v0.2.1", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/recovery.git", + "reference": "b5ce4082f059afac6761714a84497816f45271cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/recovery/zipball/b5ce4082f059afac6761714a84497816f45271cc", + "reference": "b5ce4082f059afac6761714a84497816f45271cc", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "pragmarx/random": "~0.1" + }, + "require-dev": { + "phpunit/phpunit": ">=5.4.3", + "squizlabs/php_codesniffer": "^2.3", + "tightenco/collect": "^5.0" + }, + "suggest": { + "tightenco/collect": "Allows to generate recovery codes as collections" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Recovery\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "homepage": "https://antoniocarlosribeiro.com", + "role": "Developer" + } + ], + "description": "Create recovery codes for two factor auth", + "homepage": "https://github.com/antonioribeiro/recovery", + "keywords": [ + "2fa", + "account recovery", + "auth", + "backup codes", + "google2fa", + "pragmarx", + "recovery", + "recovery codes", + "two factor auth" + ], + "support": { + "issues": "https://github.com/antonioribeiro/recovery/issues", + "source": "https://github.com/antonioribeiro/recovery/tree/v0.2.1" + }, + "time": "2021-08-15T12:26:51+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" ], "support": { - "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.8.0" + "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2026-06-07T03:51:10+00:00" + "time": "2024-09-11T13:17:53+00:00" }, { - "name": "phpstan/phpdoc-parser", - "version": "2.3.3", + "name": "psr/simple-cache", + "version": "3.0.0", "source": { "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^5.3.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6", - "symfony/process": "^5.2" + "php": ">=8.0.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, "autoload": { "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] + "Psr\\SimpleCache\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" }, - "time": "2026-07-08T07:01:06+00:00" + "time": "2021-10-29T13:26:27+00:00" }, { - "name": "pixelandtonic/graphql-php", - "version": "v14.11.10.1", + "name": "ralouphie/getallheaders", + "version": "3.0.3", "source": { "type": "git", - "url": "https://github.com/pixelandtonic/graphql-php.git", - "reference": "fdb4a288878fc9ee449245e17209d676fc7c57fd" + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pixelandtonic/graphql-php/zipball/fdb4a288878fc9ee449245e17209d676fc7c57fd", - "reference": "fdb4a288878fc9ee449245e17209d676fc7c57fd", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", "shasum": "" }, "require": { - "ext-json": "*", - "ext-mbstring": "*", - "php": "^7.1 || ^8" + "php": ">=5.6" }, "require-dev": { - "amphp/amp": "^2.3", - "doctrine/coding-standard": "^6.0", - "nyholm/psr7": "^1.2", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "0.12.82", - "phpstan/phpstan-phpunit": "0.12.18", - "phpstan/phpstan-strict-rules": "0.12.9", - "phpunit/phpunit": "^7.2 || ^8.5", - "psr/http-message": "^1.0", - "react/promise": "2.*", - "simpod/php-coveralls-mirror": "^3.0" - }, - "suggest": { - "psr/http-message": "To use standard GraphQL server", - "react/promise": "To leverage async resolving on React PHP platform" + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" }, "type": "library", "autoload": { - "psr-4": { - "GraphQL\\": "src/" - } + "files": [ + "src/getallheaders.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A PHP port of GraphQL reference implementation", - "homepage": "https://github.com/webonyx/graphql-php", - "keywords": [ - "api", - "graphql" - ], - "support": { - "source": "https://github.com/pixelandtonic/graphql-php/tree/v14.11.10.1" - }, - "funding": [ + "authors": [ { - "url": "https://opencollective.com/webonyx-graphql-php", - "type": "open_collective" + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" } ], - "time": "2026-04-14T15:27:52+00:00" + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" }, { - "name": "pixelandtonic/imagine", - "version": "1.5.2.1", + "name": "ramsey/collection", + "version": "2.1.1", "source": { "type": "git", - "url": "https://github.com/pixelandtonic/Imagine.git", - "reference": "8e6c5cf929400142724b31482da51dc556277e15" + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pixelandtonic/Imagine/zipball/8e6c5cf929400142724b31482da51dc556277e15", - "reference": "8e6c5cf929400142724b31482da51dc556277e15", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", "shasum": "" }, "require": { - "php": ">=7.1" + "php": "^8.1" }, "require-dev": { - "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4 || ^9.3" - }, - "suggest": { - "ext-exif": "to read EXIF metadata", - "ext-gd": "to use the GD implementation", - "ext-gmagick": "to use the Gmagick implementation", - "ext-imagick": "to use the Imagick implementation" + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" }, "type": "library", "extra": { - "branch-alias": { - "dev-develop": "1.x-dev" + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" } }, "autoload": { "psr-4": { - "Imagine\\": "src/" + "Ramsey\\Collection\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3515,112 +5640,152 @@ ], "authors": [ { - "name": "Bulat Shakirzyanov", - "email": "mallluhuct@gmail.com", - "homepage": "http://avalanche123.com" + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" } ], - "description": "Image processing for PHP", - "homepage": "http://imagine.readthedocs.org/", + "description": "A PHP library for representing and manipulating collections.", "keywords": [ - "drawing", - "graphics", - "image manipulation", - "image processing" + "array", + "collection", + "hash", + "map", + "queue", + "set" ], "support": { - "source": "https://github.com/pixelandtonic/Imagine/tree/1.5.2.1" + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" }, - "time": "2026-02-25T23:13:43+00:00" + "time": "2025-03-22T05:38:12+00:00" }, { - "name": "pragmarx/google2fa", - "version": "v8.0.3", + "name": "ramsey/uuid", + "version": "4.9.3", "source": { "type": "git", - "url": "https://github.com/antonioribeiro/google2fa.git", - "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad" + "url": "https://github.com/ramsey/uuid.git", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", - "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", "shasum": "" }, "require": { - "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", - "php": "^7.1|^8.0" + "brick/math": ">=0.8.16 <=0.18", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" }, - "require-dev": { - "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." }, "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, "autoload": { + "files": [ + "src/functions.php" + ], "psr-4": { - "PragmaRX\\Google2FA\\": "src/" + "Ramsey\\Uuid\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Antonio Carlos Ribeiro", - "email": "acr@antoniocarlosribeiro.com", - "role": "Creator & Designer" - } - ], - "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", "keywords": [ - "2fa", - "Authentication", - "Two Factor Authentication", - "google2fa" + "guid", + "identifier", + "uuid" ], "support": { - "issues": "https://github.com/antonioribeiro/google2fa/issues", - "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3" + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.3" }, - "time": "2024-09-05T11:56:40+00:00" + "time": "2026-06-18T03:57:49+00:00" }, { - "name": "pragmarx/random", - "version": "v0.2.2", + "name": "sabberworm/php-css-parser", + "version": "v9.4.0", "source": { "type": "git", - "url": "https://github.com/antonioribeiro/random.git", - "reference": "daf08a189c5d2d40d1a827db46364d3a741a51b7" + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antonioribeiro/random/zipball/daf08a189c5d2d40d1a827db46364d3a741a51b7", - "reference": "daf08a189c5d2d40d1a827db46364d3a741a51b7", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", "shasum": "" }, "require": { - "php": ">=7.0" + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" }, "require-dev": { - "fzaninotto/faker": "~1.7", - "phpunit/phpunit": "~6.4", - "pragmarx/trivia": "~0.1", - "squizlabs/php_codesniffer": "^2.3" + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.33 || 2.2.2", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.4.6", + "rector/type-perfect": "1.0.0 || 2.1.3", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" }, "suggest": { - "fzaninotto/faker": "Allows you to get dozens of randomized types", - "pragmarx/trivia": "For the trivia database" + "ext-mbstring": "for parsing UTF-8 CSS" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "9.5.x-dev" } }, "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], "psr-4": { - "PragmaRX\\Random\\": "src" + "Sabberworm\\CSS\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3629,64 +5794,65 @@ ], "authors": [ { - "name": "Antonio Carlos Ribeiro", - "email": "acr@antoniocarlosribeiro.com", - "homepage": "https://antoniocarlosribeiro.com", - "role": "Developer" + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" } ], - "description": "Create random chars, numbers, strings", - "homepage": "https://github.com/antonioribeiro/random", + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", "keywords": [ - "Randomize", - "faker", - "pragmarx", - "random", - "random number", - "random pattern", - "random string" + "css", + "parser", + "stylesheet" ], "support": { - "issues": "https://github.com/antonioribeiro/random/issues", - "source": "https://github.com/antonioribeiro/random/tree/master" + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" }, - "time": "2017-11-21T05:26:22+00:00" + "time": "2026-06-18T15:10:53+00:00" }, { - "name": "pragmarx/recovery", - "version": "v0.2.1", + "name": "setasign/fpdi", + "version": "v2.6.7", "source": { "type": "git", - "url": "https://github.com/antonioribeiro/recovery.git", - "reference": "b5ce4082f059afac6761714a84497816f45271cc" + "url": "https://github.com/Setasign/FPDI.git", + "reference": "388c51e69982a3fc16698710b763e8107a49f510" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antonioribeiro/recovery/zipball/b5ce4082f059afac6761714a84497816f45271cc", - "reference": "b5ce4082f059afac6761714a84497816f45271cc", + "url": "https://api.github.com/repos/Setasign/FPDI/zipball/388c51e69982a3fc16698710b763e8107a49f510", + "reference": "388c51e69982a3fc16698710b763e8107a49f510", "shasum": "" }, "require": { - "php": ">=7.0", - "pragmarx/random": "~0.1" - }, - "require-dev": { - "phpunit/phpunit": ">=5.4.3", - "squizlabs/php_codesniffer": "^2.3", - "tightenco/collect": "^5.0" + "ext-zlib": "*", + "php": ">=7.2 <=8.5.99999" }, - "suggest": { - "tightenco/collect": "Allows to generate recovery codes as collections" + "conflict": { + "setasign/tfpdf": "<1.31" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } + "require-dev": { + "phpunit/phpunit": "^8.5.52", + "setasign/fpdf": "~1.8.6", + "setasign/tfpdf": "~1.33", + "squizlabs/php_codesniffer": "^3.5", + "tecnickcom/tcpdf": "^6.8" }, + "suggest": { + "setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured." + }, + "type": "library", "autoload": { "psr-4": { - "PragmaRX\\Recovery\\": "src" + "setasign\\Fpdi\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3695,52 +5861,68 @@ ], "authors": [ { - "name": "Antonio Carlos Ribeiro", - "email": "acr@antoniocarlosribeiro.com", - "homepage": "https://antoniocarlosribeiro.com", - "role": "Developer" + "name": "Jan Slabon", + "email": "jan.slabon@setasign.com", + "homepage": "https://www.setasign.com" + }, + { + "name": "Maximilian Kresse", + "email": "maximilian.kresse@setasign.com", + "homepage": "https://www.setasign.com" } ], - "description": "Create recovery codes for two factor auth", - "homepage": "https://github.com/antonioribeiro/recovery", + "description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.", + "homepage": "https://www.setasign.com/fpdi", "keywords": [ - "2fa", - "account recovery", - "auth", - "backup codes", - "google2fa", - "pragmarx", - "recovery", - "recovery codes", - "two factor auth" + "fpdf", + "fpdi", + "pdf" ], "support": { - "issues": "https://github.com/antonioribeiro/recovery/issues", - "source": "https://github.com/antonioribeiro/recovery/tree/v0.2.1" + "issues": "https://github.com/Setasign/FPDI/issues", + "source": "https://github.com/Setasign/FPDI/tree/v2.6.7" }, - "time": "2021-08-15T12:26:51+00:00" + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/setasign/fpdi", + "type": "tidelift" + } + ], + "time": "2026-05-13T10:16:22+00:00" }, { - "name": "psr/clock", - "version": "1.0.0", + "name": "spomky-labs/cbor-php", + "version": "3.3.0", "source": { "type": "git", - "url": "https://github.com/php-fig/clock.git", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "013d13da69cf28b1ae501887daceccc850ca1c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/013d13da69cf28b1ae501887daceccc850ca1c76", + "reference": "013d13da69cf28b1ae501887daceccc850ca1c76", "shasum": "" }, "require": { - "php": "^7.0 || ^8.0" + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "ext-mbstring": "*", + "php": ">=8.0" + }, + "require-dev": { + "ext-json": "*", + "roave/security-advisories": "dev-latest", + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", + "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" }, "type": "library", "autoload": { "psr-4": { - "Psr\\Clock\\": "src/" + "CBOR\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3749,51 +5931,82 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" } ], - "description": "Common interface for reading the clock.", - "homepage": "https://github.com/php-fig/clock", + "description": "CBOR Encoder/Decoder for PHP", "keywords": [ - "clock", - "now", - "psr", - "psr-20", - "time" + "Concise Binary Object Representation", + "RFC7049", + "cbor" ], "support": { - "issues": "https://github.com/php-fig/clock/issues", - "source": "https://github.com/php-fig/clock/tree/1.0.0" + "issues": "https://github.com/Spomky-Labs/cbor-php/issues", + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.3.0" }, - "time": "2022-11-25T14:36:26+00:00" + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-07-15T18:56:27+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "spomky-labs/pki-framework", + "version": "1.6.0", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/80778a25426288acd2e3a7cde2def41a3d59cddf", + "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf", "shasum": "" }, "require": { - "php": ">=7.4.0" + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19", + "ext-mbstring": "*", + "php": ">=8.1" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0 || ^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" }, + "type": "library", "autoload": { "psr-4": { - "Psr\\Container\\": "src/" + "SpomkyLabs\\Pki\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3802,52 +6015,93 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.0" }, - "time": "2021-11-05T16:47:00+00:00" + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-08-06T16:21:11+00:00" }, { - "name": "psr/event-dispatcher", - "version": "1.0.0", + "name": "symfony/clock", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": ">=8.4.1", + "psr/clock": "^1.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } + "provide": { + "psr/clock-implementation": "1.0" }, + "type": "library", "autoload": { + "files": [ + "Resources/now.php" + ], "psr-4": { - "Psr\\EventDispatcher\\": "src/" - } + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3855,50 +6109,98 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "description": "Standard interfaces for event handling.", - "keywords": [ - "events", - "psr", - "psr-14" - ], - "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" - }, - "time": "2019-01-08T18:20:26+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "psr/http-client", - "version": "1.0.3", + "name": "symfony/console", + "version": "v8.1.4", "source": { "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + "url": "https://github.com/symfony/console.git", + "reference": "68efa2ebfd9a362951eb5a8b09fd177c66ddec24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "url": "https://api.github.com/repos/symfony/console/zipball/68efa2ebfd9a362951eb5a8b09fd177c66ddec24", + "reference": "68efa2ebfd9a362951eb5a8b09fd177c66ddec24", "shasum": "" }, "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4.6|^8.0.6" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, + "type": "library", "autoload": { "psr-4": { - "Psr\\Http\\Client\\": "src/" - } + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3906,51 +6208,70 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", "keywords": [ - "http", - "http-client", - "psr", - "psr-18" + "cli", + "command-line", + "console", + "terminal" ], "support": { - "source": "https://github.com/php-fig/http-client" + "source": "https://github.com/symfony/console/tree/v8.1.4" }, - "time": "2023-09-23T14:17:50+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-31T12:43:13+00:00" }, { - "name": "psr/http-factory", - "version": "1.1.0", + "name": "symfony/css-selector", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + "url": "https://github.com/symfony/css-selector.git", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", "shasum": "" }, "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" + "php": ">=8.4.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, "autoload": { "psr-4": { - "Psr\\Http\\Message\\": "src/" - } + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3958,53 +6279,74 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", "support": { - "source": "https://github.com/php-fig/http-factory" + "source": "https://github.com/symfony/css-selector/tree/v8.1.0" }, - "time": "2024-04-15T12:06:14+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "psr/http-message", - "version": "2.0", + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": ">=8.1" }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-main": "3.7-dev" } }, "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } + "files": [ + "function.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4012,52 +6354,69 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, - "time": "2023-04-04T09:54:51+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "symfony/dom-crawler", + "version": "v8.1.1", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "1dfadd25537c8fcb6752cce5775f24647d976bdc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/1dfadd25537c8fcb6752cce5775f24647d976bdc", + "reference": "1dfadd25537c8fcb6752cce5775f24647d976bdc", "shasum": "" }, "require": { - "php": ">=8.0.0" + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } + "require-dev": { + "symfony/css-selector": "^7.4|^8.0" }, + "type": "library", "autoload": { "psr-4": { - "Psr\\Log\\": "src" - } + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4065,49 +6424,80 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" + "source": "https://github.com/symfony/dom-crawler/tree/v8.1.1" }, - "time": "2024-09-11T13:17:53+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" }, { - "name": "psr/simple-cache", - "version": "3.0.0", + "name": "symfony/error-handler", + "version": "v8.1.2", "source": { "type": "git", - "url": "https://github.com/php-fig/simple-cache.git", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + "url": "https://github.com/symfony/error-handler.git", + "reference": "dc98404be5e8c949815e23fee1928f5de4f3f5d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/dc98404be5e8c949815e23fee1928f5de4f3f5d3", + "reference": "dc98404be5e8c949815e23fee1928f5de4f3f5d3", "shasum": "" }, "require": { - "php": ">=8.0.0" + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^7.4|^8.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0.x-dev" - } + "conflict": { + "symfony/deprecation-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", "autoload": { "psr-4": { - "Psr\\SimpleCache\\": "src/" - } + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4115,48 +6505,84 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Common interfaces for simple caching", - "keywords": [ - "cache", - "caching", - "psr", - "psr-16", - "simple-cache" - ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", "support": { - "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + "source": "https://github.com/symfony/error-handler/tree/v8.1.2" }, - "time": "2021-10-29T13:26:27+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T15:42:13+00:00" }, { - "name": "ralouphie/getallheaders", - "version": "3.0.3", + "name": "symfony/event-dispatcher", + "version": "v8.1.2", "source": { "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/c14c05a9e6da7f5e375e6efc28952c7e7dbddffb", + "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb", "shasum": "" }, "require": { - "php": ">=5.6" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { - "files": [ - "src/getallheaders.php" + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -4165,65 +6591,70 @@ ], "authors": [ { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "A polyfill for getallheaders.", + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/ralouphie/getallheaders/issues", - "source": "https://github.com/ralouphie/getallheaders/tree/develop" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.2" }, - "time": "2019-03-08T08:55:37+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T15:42:13+00:00" }, { - "name": "sabberworm/php-css-parser", - "version": "v9.4.0", + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", - "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { - "ext-iconv": "*", - "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", - "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" - }, - "require-dev": { - "php-parallel-lint/php-parallel-lint": "1.4.0", - "phpstan/extension-installer": "1.4.3", - "phpstan/phpstan": "1.12.33 || 2.2.2", - "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", - "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", - "phpunit/phpunit": "8.5.52", - "rawr/phpunit-data-provider": "3.3.1", - "rector/rector": "1.2.10 || 2.4.6", - "rector/type-perfect": "1.0.0 || 2.1.3", - "squizlabs/php_codesniffer": "4.0.1", - "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" - }, - "suggest": { - "ext-mbstring": "for parsing UTF-8 CSS" + "php": ">=8.1", + "psr/event-dispatcher": "^1" }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { - "dev-main": "9.5.x-dev" + "dev-main": "3.7-dev" } }, "autoload": { - "files": [ - "src/Rule/Rule.php", - "src/RuleSet/RuleContainer.php" - ], "psr-4": { - "Sabberworm\\CSS\\": "src/" + "Symfony\\Contracts\\EventDispatcher\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -4232,122 +6663,146 @@ ], "authors": [ { - "name": "Raphael Schweikert" - }, - { - "name": "Oliver Klee", - "email": "github@oliverklee.de" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Jake Hotson", - "email": "jake.github@qzdesign.co.uk" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Parser for CSS Files written in PHP", - "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", "keywords": [ - "css", - "parser", - "stylesheet" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, - "time": "2026-06-18T15:10:53+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" }, { - "name": "samdark/yii2-psr-log-target", - "version": "1.1.4", + "name": "symfony/filesystem", + "version": "v8.1.2", "source": { "type": "git", - "url": "https://github.com/samdark/yii2-psr-log-target.git", - "reference": "5f14f21d5ee4294fe9eb3e723ec8a3908ca082ea" + "url": "https://github.com/symfony/filesystem.git", + "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217" }, "dist": { - "type": "zip", - "url": "https://api.github.com/repos/samdark/yii2-psr-log-target/zipball/5f14f21d5ee4294fe9eb3e723ec8a3908ca082ea", - "reference": "5f14f21d5ee4294fe9eb3e723ec8a3908ca082ea", + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/17856b7a222664a26a5ea1cb06ee0721c2438217", + "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217", "shasum": "" }, "require": { - "psr/log": "~1.0.2|~1.1.0|~3.0.0", - "yiisoft/yii2": "~2.0.0" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "phpunit/phpunit": "~4.4|~10.4.2" + "symfony/process": "^7.4|^8.0" }, - "type": "yii2-extension", + "type": "library", "autoload": { "psr-4": { - "samdark\\log\\": "src", - "samdark\\log\\tests\\": "tests" - } + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Alexander Makarov", - "email": "sam@rmcreative.ru" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Yii 2 log target which uses PSR-3 compatible logger", - "homepage": "https://github.com/samdark/yii2-psr-log-target", - "keywords": [ - "extension", - "log", - "psr-3", - "yii" - ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/samdark/yii2-psr-log-target/issues", - "source": "https://github.com/samdark/yii2-psr-log-target" + "source": "https://github.com/symfony/filesystem/tree/v8.1.2" }, "funding": [ { - "url": "https://github.com/samdark", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://www.patreon.com/samdark", - "type": "patreon" + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2023-11-23T14:11:29+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { - "name": "seld/cli-prompt", - "version": "1.0.4", + "name": "symfony/finder", + "version": "v8.1.1", "source": { "type": "git", - "url": "https://github.com/Seldaek/cli-prompt.git", - "reference": "b8dfcf02094b8c03b40322c229493bb2884423c5" + "url": "https://github.com/symfony/finder.git", + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/cli-prompt/zipball/b8dfcf02094b8c03b40322c229493bb2884423c5", - "reference": "b8dfcf02094b8c03b40322c229493bb2884423c5", + "url": "https://api.github.com/repos/symfony/finder/zipball/e2989e762c70f9490fa3a00a0ac0fae5aa97a531", + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531", "shasum": "" }, "require": { - "php": ">=5.3" + "php": ">=8.4.1" }, "require-dev": { - "phpstan/phpstan": "^0.12.63" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, "autoload": { "psr-4": { - "Seld\\CliPrompt\\": "src/" - } + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4355,60 +6810,66 @@ ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Allows you to prompt for user input on the command line, and optionally hide the characters they type", - "keywords": [ - "cli", - "console", - "hidden", - "input", - "prompt" - ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/Seldaek/cli-prompt/issues", - "source": "https://github.com/Seldaek/cli-prompt/tree/1.0.4" + "source": "https://github.com/symfony/finder/tree/v8.1.1" }, - "time": "2020-12-15T21:32:01+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T09:05:56+00:00" }, { - "name": "setasign/fpdi", - "version": "v2.6.8", + "name": "symfony/html-sanitizer", + "version": "v8.1.1", "source": { "type": "git", - "url": "https://github.com/Setasign/FPDI.git", - "reference": "881945be29a4996ad3d008eb18ddc01fa3df890c" + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "09e1f2f9a3c8dcdca072587dc71999c1921c07cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Setasign/FPDI/zipball/881945be29a4996ad3d008eb18ddc01fa3df890c", - "reference": "881945be29a4996ad3d008eb18ddc01fa3df890c", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/09e1f2f9a3c8dcdca072587dc71999c1921c07cb", + "reference": "09e1f2f9a3c8dcdca072587dc71999c1921c07cb", "shasum": "" }, "require": { - "ext-zlib": "*", - "php": ">=7.2 <=8.5.99999" - }, - "conflict": { - "setasign/tfpdf": "<1.31" - }, - "require-dev": { - "phpunit/phpunit": "^8.5.52", - "setasign/fpdf": "^1.9.0", - "setasign/tfpdf": "~1.33", - "squizlabs/php_codesniffer": "^3.5", - "tecnickcom/tcpdf": "^6.8" - }, - "suggest": { - "setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured." + "ext-dom": "*", + "league/uri": "^6.5|^7.0", + "php": ">=8.4.1" }, "type": "library", "autoload": { "psr-4": { - "setasign\\Fpdi\\": "src/" - } + "Symfony\\Component\\HtmlSanitizer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4416,69 +6877,85 @@ ], "authors": [ { - "name": "Jan Slabon", - "email": "jan.slabon@setasign.com", - "homepage": "https://www.setasign.com" + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" }, { - "name": "Maximilian Kresse", - "email": "maximilian.kresse@setasign.com", - "homepage": "https://www.setasign.com" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.", - "homepage": "https://www.setasign.com/fpdi", + "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", + "homepage": "https://symfony.com", "keywords": [ - "fpdf", - "fpdi", - "pdf" + "Purifier", + "html", + "sanitizer" ], "support": { - "issues": "https://github.com/Setasign/FPDI/issues", - "source": "https://github.com/Setasign/FPDI/tree/v2.6.8" + "source": "https://github.com/symfony/html-sanitizer/tree/v8.1.1" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/setasign/fpdi", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2026-06-11T10:37:24+00:00" + "time": "2026-06-06T11:11:44+00:00" }, { - "name": "spomky-labs/cbor-php", - "version": "3.2.3", + "name": "symfony/http-foundation", + "version": "v8.1.4", "source": { "type": "git", - "url": "https://github.com/Spomky-Labs/cbor-php.git", - "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32" + "url": "https://github.com/symfony/http-foundation.git", + "reference": "57e712b75f2d0bc8844edbdb18a81dab6f9d55c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", - "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/57e712b75f2d0bc8844edbdb18a81dab6f9d55c2", + "reference": "57e712b75f2d0bc8844edbdb18a81dab6f9d55c2", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", - "ext-mbstring": "*", - "php": ">=8.0" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" }, - "require-dev": { - "ext-json": "*", - "roave/security-advisories": "dev-latest", - "symfony/error-handler": "^6.4|^7.1|^8.0", - "symfony/var-dumper": "^6.4|^7.1|^8.0" + "conflict": { + "doctrine/dbal": "<4.3" }, - "suggest": { - "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", - "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "CBOR\\": "src/" - } + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4486,84 +6963,109 @@ ], "authors": [ { - "name": "Florent Morselli", - "homepage": "https://github.com/Spomky" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { - "name": "All contributors", - "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "CBOR Encoder/Decoder for PHP", - "keywords": [ - "Concise Binary Object Representation", - "RFC7049", - "cbor" - ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/Spomky-Labs/cbor-php/issues", - "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.3" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.4" }, "funding": [ { - "url": "https://github.com/Spomky", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://www.patreon.com/FlorentMorselli", - "type": "patreon" + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2026-04-01T12:15:20+00:00" + "time": "2026-08-07T15:02:39+00:00" }, { - "name": "spomky-labs/pki-framework", - "version": "1.4.2", + "name": "symfony/http-kernel", + "version": "v8.1.4", "source": { "type": "git", - "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8" + "url": "https://github.com/symfony/http-kernel.git", + "reference": "69d81d8a5dac32a5d94e4eb063b7d97dc42c792a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8", - "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/69d81d8a5dac32a5d94e4eb063b7d97dc42c792a", + "reference": "69d81d8a5dac32a5d94e4eb063b7d97dc42c792a", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", - "ext-mbstring": "*", - "php": ">=8.1", - "psr/clock": "^1.0" + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" }, - "require-dev": { - "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", - "ext-gmp": "*", - "ext-openssl": "*", - "infection/infection": "^0.28|^0.29|^0.31|^0.32", - "php-parallel-lint/php-parallel-lint": "^1.3", - "phpstan/extension-installer": "^1.3|^2.0", - "phpstan/phpstan": "^1.8|^2.0", - "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", - "phpstan/phpstan-phpunit": "^1.1|^2.0", - "phpstan/phpstan-strict-rules": "^1.3|^2.0", - "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0", - "rector/rector": "^1.0|^2.0", - "roave/security-advisories": "dev-latest", - "symfony/string": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symplify/easy-coding-standard": "^12.0|^13.0" + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/flex": "<2.10", + "symfony/http-client-contracts": "<2.5", + "symfony/serializer": "<7.4.15|>=8.0,<8.0.15|>=8.1,<8.1.2", + "symfony/translation-contracts": "<2.5", + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", + "twig/twig": "<3.21" }, - "suggest": { - "ext-bcmath": "For better performance (or GMP)", - "ext-gmp": "For better performance (or BCMath)", - "ext-openssl": "For OpenSSL based cyphering" + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21|^4.0" }, "type": "library", "autoload": { "psr-4": { - "SpomkyLabs\\Pki\\": "src/" - } + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4571,90 +7073,79 @@ ], "authors": [ { - "name": "Joni Eskelinen", - "email": "jonieske@gmail.com", - "role": "Original developer" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { - "name": "Florent Morselli", - "email": "florent.morselli@spomky-labs.com", - "role": "Spomky-Labs PKI Framework developer" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", - "homepage": "https://github.com/spomky-labs/pki-framework", - "keywords": [ - "DER", - "Private Key", - "ac", - "algorithm identifier", - "asn.1", - "asn1", - "attribute certificate", - "certificate", - "certification request", - "cryptography", - "csr", - "decrypt", - "ec", - "encrypt", - "pem", - "pkcs", - "public key", - "rsa", - "sign", - "signature", - "verify", - "x.509", - "x.690", - "x509", - "x690" - ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.4" }, "funding": [ { - "url": "https://github.com/Spomky", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://www.patreon.com/FlorentMorselli", - "type": "patreon" + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2026-03-23T22:56:56+00:00" + "time": "2026-08-07T18:04:54+00:00" }, { - "name": "symfony/clock", - "version": "v7.4.8", + "name": "symfony/mailer", + "version": "v7.4.15", "source": { "type": "git", - "url": "https://github.com/symfony/clock.git", - "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + "url": "https://github.com/symfony/mailer.git", + "reference": "68c1f27c97edd0222eb8d440a6c8c4da5354ab46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", - "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "url": "https://api.github.com/repos/symfony/mailer/zipball/68c1f27c97edd0222eb8d440a6c8c4da5354ab46", + "reference": "68c1f27c97edd0222eb8d440a6c8c4da5354ab46", "shasum": "" }, "require": { + "egulias/email-validator": "^2.1.10|^3|^4", "php": ">=8.2", - "psr/clock": "^1.0", - "symfony/polyfill-php83": "^1.28" + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" }, - "provide": { - "psr/clock-implementation": "1.0" + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { - "files": [ - "Resources/now.php" - ], "psr-4": { - "Symfony\\Component\\Clock\\": "" + "Symfony\\Component\\Mailer\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -4666,23 +7157,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Decouples applications from the system clock", + "description": "Helps sending emails", "homepage": "https://symfony.com", - "keywords": [ - "clock", - "psr20", - "time" - ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.4.8" + "source": "https://github.com/symfony/mailer/tree/v7.4.15" }, "funding": [ { @@ -4702,29 +7188,49 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-07-28T07:33:02+00:00" }, { - "name": "symfony/css-selector", - "version": "v7.4.9", + "name": "symfony/mime", + "version": "v7.4.16", "source": { "type": "git", - "url": "https://github.com/symfony/css-selector.git", - "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" + "url": "https://github.com/symfony/mime.git", + "reference": "20094b76a7106dbe978d31cd3bd7aa1ed248d0e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", - "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", + "url": "https://api.github.com/repos/symfony/mime/zipball/20094b76a7106dbe978d31cd3bd7aa1ed248d0e5", + "reference": "20094b76a7106dbe978d31cd3bd7aa1ed248d0e5", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\CssSelector\\": "" + "Symfony\\Component\\Mime\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -4739,19 +7245,19 @@ "name": "Fabien Potencier", "email": "fabien@symfony.com" }, - { - "name": "Jean-François Simon", - "email": "jeanfrancois.simon@sensiolabs.com" - }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Converts CSS selectors to XPath expressions", + "description": "Allows manipulating MIME messages", "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.9" + "source": "https://github.com/symfony/mime/tree/v7.4.16" }, "funding": [ { @@ -4771,39 +7277,45 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:18:21+00:00" + "time": "2026-08-07T14:56:57+00:00" }, { - "name": "symfony/deprecation-contracts", - "version": "v3.7.1", + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { "files": [ - "function.php" - ] + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4811,18 +7323,24 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "A generic function and convention to trigger deprecation notices", + "description": "Symfony polyfill for ctype functions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -4842,40 +7360,42 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/dom-crawler", - "version": "v7.4.12", + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/dom-crawler.git", - "reference": "b59b59122690976550fd142c23fab62c84738db6" + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/b59b59122690976550fd142c23fab62c84738db6", - "reference": "b59b59122690976550fd142c23fab62c84738db6", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { - "masterminds/html5": "^2.6", - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=7.2" }, - "require-dev": { - "symfony/css-selector": "^6.4|^7.0|^8.0" + "suggest": { + "ext-intl": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\DomCrawler\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4883,18 +7403,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Eases DOM navigation for HTML and XML documents", + "description": "Symfony polyfill for intl's grapheme_* functions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v7.4.12" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -4914,53 +7442,43 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { - "name": "symfony/event-dispatcher", - "version": "v7.4.14", + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", - "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/event-dispatcher-contracts": "^2.5|^3" - }, - "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/service-contracts": "<2.5" + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" }, - "provide": { - "psr/event-dispatcher-implementation": "1.0", - "symfony/event-dispatcher-implementation": "2.0|3.0" + "suggest": { + "ext-intl": "For best performance" }, - "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } }, - "type": "library", "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4968,18 +7486,30 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -4999,40 +7529,45 @@ "type": "tidelift" } ], - "time": "2026-06-06T11:10:32+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { - "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.1", + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", - "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/event-dispatcher": "^1" + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Contracts\\EventDispatcher\\": "" - } + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5048,18 +7583,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to dispatching event", + "description": "Symfony polyfill for intl's Normalizer class and related functions", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -5079,38 +7614,46 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { - "name": "symfony/filesystem", - "version": "v6.4.39", + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", "source": { "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/c507b077756b4e3e09adbbe7975fac81cd3722ca", - "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" + "ext-iconv": "*", + "php": ">=7.2" }, - "require-dev": { - "symfony/process": "^5.4|^6.4|^7.0" + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5118,18 +7661,25 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides basic utilities for the filesystem", + "description": "Symfony polyfill for the Mbstring extension", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.39" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -5149,65 +7699,41 @@ "type": "tidelift" } ], - "time": "2026-05-07T13:11:42+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { - "name": "symfony/http-client", - "version": "v7.4.14", + "name": "symfony/polyfill-php80", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/http-client.git", - "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920" + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", - "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-client-contracts": "~3.4.4|^3.5.2", - "symfony/polyfill-php83": "^1.29", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "amphp/amp": "<2.5", - "amphp/socket": "<1.1", - "php-http/discovery": "<1.15", - "symfony/http-foundation": "<6.4" - }, - "provide": { - "php-http/async-client-implementation": "*", - "php-http/client-implementation": "*", - "psr/http-client-implementation": "1.0", - "symfony/http-client-implementation": "3.0" - }, - "require-dev": { - "amphp/http-client": "^4.2.1|^5.0", - "amphp/http-tunnel": "^1.0|^2.0", - "guzzlehttp/promises": "^1.4|^2.0", - "nyholm/psr7": "^1.0", - "php-http/httplug": "^1.0|^2.0", - "psr/http-client": "^1.0", - "symfony/amphp-http-client-meta": "^1.0|^2.0", - "symfony/cache": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "php": ">=7.2" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\HttpClient\\": "" + "Symfony\\Polyfill\\Php80\\": "" }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -5215,6 +7741,10 @@ "MIT" ], "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -5224,13 +7754,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ - "http" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.14" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -5250,41 +7783,41 @@ "type": "tidelift" } ], - "time": "2026-06-16T11:50:14+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/http-client-contracts", - "version": "v3.7.1", + "name": "symfony/polyfill-php84", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=7.2" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Contracts\\HttpClient\\": "" + "Symfony\\Polyfill\\Php84\\": "" }, - "exclude-from-classmap": [ - "/Test/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -5301,18 +7834,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to HTTP clients", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -5332,51 +7863,41 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "symfony/mailer", - "version": "v7.4.14", + "name": "symfony/polyfill-php85", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/mailer.git", - "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495", - "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { - "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.2", - "psr/event-dispatcher": "^1", - "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/mime": "^7.2|^8.0", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/messenger": "<6.4", - "symfony/mime": "<6.4", - "symfony/twig-bridge": "<6.4" - }, - "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/twig-bridge": "^6.4|^7.0|^8.0" + "php": ">=7.2" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Mailer\\": "" + "Symfony\\Polyfill\\Php85\\": "" }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -5385,18 +7906,24 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Helps sending emails", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.14" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -5416,52 +7943,41 @@ "type": "tidelift" } ], - "time": "2026-06-13T08:51:35+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { - "name": "symfony/mime", - "version": "v7.4.13", + "name": "symfony/polyfill-php86", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/mime.git", - "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", - "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-intl-idn": "^1.10", - "symfony/polyfill-mbstring": "^1.0" - }, - "conflict": { - "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=7", - "phpdocumentor/type-resolver": "<1.5.1", - "symfony/mailer": "<6.4", - "symfony/serializer": "<6.4.3|>7.0,<7.0.3" - }, - "require-dev": { - "egulias/email-validator": "^2.1.10|^3.1|^4", - "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2|^6.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + "php": ">=7.2" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Mime\\": "" + "Symfony\\Polyfill\\Php86\\": "" }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -5470,22 +7986,24 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Allows manipulating MIME messages", + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ - "mime", - "mime-type" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.13" + "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0" }, "funding": [ { @@ -5505,30 +8023,30 @@ "type": "tidelift" } ], - "time": "2026-05-23T16:22:37+00:00" + "time": "2026-07-02T13:42:24+00:00" }, { - "name": "symfony/polyfill-ctype", + "name": "symfony/polyfill-uuid", "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", - "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { "php": ">=7.2" }, "provide": { - "ext-ctype": "*" + "ext-uuid": "*" }, "suggest": { - "ext-ctype": "For best performance" + "ext-uuid": "For best performance" }, "type": "library", "extra": { @@ -5542,7 +8060,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" + "Symfony\\Polyfill\\Uuid\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -5551,24 +8069,24 @@ ], "authors": [ { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for ctype functions", + "description": "Symfony polyfill for uuid functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "ctype", "polyfill", - "portable" + "portable", + "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -5591,39 +8109,100 @@ "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "name": "symfony/process", + "version": "v7.4.13", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "url": "https://github.com/symfony/process.git", + "reference": "f5804be144caceb570f6747519999636b664f24c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", "shasum": "" }, "require": { - "php": ">=7.2" - }, - "suggest": { - "ext-intl": "For best performance" + "php": ">=8.2" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/property-access", + "version": "v8.1.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/1a41232c678972b93ce499a504e19ea09dfcd0b2", + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - } + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5631,26 +8210,29 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's grapheme_* functions", + "description": "Provides functions to read and write from/to an object or array using a simple string notation", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "grapheme", - "intl", - "polyfill", - "portable", - "shim" + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/property-access/tree/v8.1.4" }, "funding": [ { @@ -5670,43 +8252,46 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-30T12:40:56+00:00" }, { - "name": "symfony/polyfill-intl-idn", - "version": "v1.38.1", + "name": "symfony/property-info", + "version": "v8.1.4", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "dc21118016c039a66235cf93d96b435ffb282412" + "url": "https://github.com/symfony/property-info.git", + "reference": "d3b1ba3e69dd9fbfff3e00416d2fc60c600c599d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", - "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "url": "https://api.github.com/repos/symfony/property-info/zipball/d3b1ba3e69dd9fbfff3e00416d2fc60c600c599d", + "reference": "d3b1ba3e69dd9fbfff3e00416d2fc60c600c599d", "shasum": "" }, "require": { - "php": ">=7.2", - "symfony/polyfill-intl-normalizer": "^1.10" + "php": ">=8.4.1", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" }, - "suggest": { - "ext-intl": "For best performance" + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" }, + "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Idn\\": "" - } + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5714,30 +8299,26 @@ ], "authors": [ { - "name": "Laurent Bassin", - "email": "laurent@bassin.info" - }, - { - "name": "Trevor Rowbotham", - "email": "trevor.rowbotham@pm.me" + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "description": "Extracts information about PHP class' properties using metadata of popular sources", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "idn", - "intl", - "polyfill", - "portable", - "shim" + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + "source": "https://github.com/symfony/property-info/tree/v8.1.4" }, "funding": [ { @@ -5757,44 +8338,41 @@ "type": "tidelift" } ], - "time": "2026-05-25T15:22:23+00:00" + "time": "2026-08-07T15:02:39+00:00" }, { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "name": "symfony/routing", + "version": "v8.1.2", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "url": "https://github.com/symfony/routing.git", + "reference": "1058d4e13bb81dd9a6f7565686df7e13b880cdbd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/routing/zipball/1058d4e13bb81dd9a6f7565686df7e13b880cdbd", + "reference": "1058d4e13bb81dd9a6f7565686df7e13b880cdbd", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" }, - "suggest": { - "ext-intl": "For best performance" + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + "Symfony\\Component\\Routing\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -5803,26 +8381,24 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", + "description": "Maps an HTTP request to a set of configuration variables", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "intl", - "normalizer", - "polyfill", - "portable", - "shim" + "router", + "routing", + "uri", + "url" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/routing/tree/v8.1.2" }, "funding": [ { @@ -5842,46 +8418,67 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "name": "symfony/serializer", + "version": "v8.1.4", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "url": "https://github.com/symfony/serializer.git", + "reference": "ec3ae778e49a4cee5b937a779056fa23c3e834fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/serializer/zipball/ec3ae778e49a4cee5b937a779056fa23c3e834fb", + "reference": "ec3ae778e49a4cee5b937a779056fa23c3e834fb", "shasum": "" }, "require": { - "ext-iconv": "*", - "php": ">=7.2" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" }, - "provide": { - "ext-mbstring": "*" + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-access": "<8.1", + "symfony/property-info": "<7.4.15", + "symfony/type-info": "<7.4" }, - "suggest": { - "ext-mbstring": "For best performance" + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^8.1", + "symfony/property-info": "^7.4.15|~8.0.15|^8.1.2", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5889,25 +8486,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/serializer/tree/v8.1.4" }, "funding": [ { @@ -5927,41 +8517,46 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" + "time": "2026-08-06T09:53:08+00:00" }, { - "name": "symfony/polyfill-php80", - "version": "v1.37.0", + "name": "symfony/service-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" + "Symfony\\Contracts\\Service\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -5969,10 +8564,6 @@ "MIT" ], "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -5982,16 +8573,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "description": "Generic abstractions related to writing services", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -6011,41 +8604,49 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { - "name": "symfony/polyfill-php83", - "version": "v1.38.2", + "name": "symfony/string", + "version": "v8.1.2", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + "url": "https://github.com/symfony/string.git", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "conflict": { + "symfony/translation-contracts": "<2.5" }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", "autoload": { "files": [ - "bootstrap.php" + "Resources/functions.php" ], "psr-4": { - "Symfony\\Polyfill\\Php83\\": "" + "Symfony\\Component\\String\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -6062,16 +8663,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + "source": "https://github.com/symfony/string/tree/v8.1.2" }, "funding": [ { @@ -6091,41 +8694,60 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:51:48+00:00" + "time": "2026-07-28T07:35:25+00:00" }, { - "name": "symfony/polyfill-php84", - "version": "v1.38.1", + "name": "symfony/translation", + "version": "v8.1.4", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + "url": "https://github.com/symfony/translation.git", + "reference": "c0955eb4aa417a110e65c8162237b8a5c7d910bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "url": "https://api.github.com/repos/symfony/translation/zipball/c0955eb4aa417a110e65c8162237b8a5c7d910bf", + "reference": "c0955eb4aa417a110e65c8162237b8a5c7d910bf", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" }, + "type": "library", "autoload": { "files": [ - "bootstrap.php" + "Resources/functions.php" ], "psr-4": { - "Symfony\\Polyfill\\Php84\\": "" + "Symfony\\Component\\Translation\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -6134,24 +8756,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + "source": "https://github.com/symfony/translation/tree/v8.1.4" }, "funding": [ { @@ -6171,45 +8787,42 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-07-30T12:40:56+00:00" }, { - "name": "symfony/polyfill-uuid", - "version": "v1.37.0", + "name": "symfony/translation-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", - "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { - "php": ">=7.2" - }, - "provide": { - "ext-uuid": "*" - }, - "suggest": { - "ext-uuid": "For best performance" + "php": ">=8.1" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Uuid\\": "" - } + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -6217,24 +8830,26 @@ ], "authors": [ { - "name": "Grégoire Pineau", - "email": "lyrixx@lyrixx.info" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for uuid functions", + "description": "Generic abstractions related to translation", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "uuid" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -6254,29 +8869,36 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { - "name": "symfony/process", - "version": "v7.4.13", + "name": "symfony/type-info", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "f5804be144caceb570f6747519999636b664f24c" + "url": "https://github.com/symfony/type-info.git", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", - "reference": "f5804be144caceb570f6747519999636b664f24c", + "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Process\\": "" + "Symfony\\Component\\TypeInfo\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -6288,18 +8910,28 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Executes commands in sub-processes", + "description": "Extracts PHP types information.", "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], "support": { - "source": "https://github.com/symfony/process/tree/v7.4.13" + "source": "https://github.com/symfony/type-info/tree/v8.1.0" }, "funding": [ { @@ -6319,34 +8951,33 @@ "type": "tidelift" } ], - "time": "2026-05-23T16:05:06+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/property-access", - "version": "v7.4.8", + "name": "symfony/uid", + "version": "v8.1.4", "source": { "type": "git", - "url": "https://github.com/symfony/property-access.git", - "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc" + "url": "https://github.com/symfony/uid.git", + "reference": "50e98f8bc4c3fcdaf925545a65150fb42cf0caf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", - "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", + "url": "https://api.github.com/repos/symfony/uid/zipball/50e98f8bc4c3fcdaf925545a65150fb42cf0caf2", + "reference": "50e98f8bc4c3fcdaf925545a65150fb42cf0caf2", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/property-info": "^6.4.32|~7.3.10|^7.4.4|^8.0.4" + "php": ">=8.4.1", + "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/cache": "^6.4|^7.0|^8.0", - "symfony/var-exporter": "^6.4.1|^7.0.1|^8.0" + "symfony/console": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\PropertyAccess\\": "" + "Symfony\\Component\\Uid\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -6358,29 +8989,27 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "description": "Provides an object-oriented API to generate and represent UIDs", "homepage": "https://symfony.com", "keywords": [ - "access", - "array", - "extraction", - "index", - "injection", - "object", - "property", - "property-path", - "reflection" + "UID", + "ulid", + "uuid" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v7.4.8" + "source": "https://github.com/symfony/uid/tree/v8.1.4" }, "funding": [ { @@ -6400,46 +9029,47 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-08-02T11:29:46+00:00" }, { - "name": "symfony/property-info", - "version": "v7.4.8", + "name": "symfony/var-dumper", + "version": "v8.1.2", "source": { "type": "git", - "url": "https://github.com/symfony/property-info.git", - "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175" + "url": "https://github.com/symfony/var-dumper.git", + "reference": "865103cf742a039f34645b971fc3ace308d6c167" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/ac5e82528b986c4f7cfccbf7764b5d2e824d6175", - "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/865103cf742a039f34645b971fc3ace308d6c167", + "reference": "865103cf742a039f34645b971fc3ace308d6c167", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/string": "^6.4|^7.0|^8.0", - "symfony/type-info": "^7.4.7|^8.0.7" + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { - "phpdocumentor/reflection-docblock": "<5.2|>=7", - "phpdocumentor/type-resolver": "<1.5.1", - "symfony/cache": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/serializer": "<6.4" + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" }, "require-dev": { - "phpdocumentor/reflection-docblock": "^5.2|^6.0", - "phpstan/phpdoc-parser": "^1.0|^2.0", - "symfony/cache": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12|^4.0" }, + "bin": [ + "Resources/bin/var-dump-server" + ], "type": "library", "autoload": { + "files": [ + "Resources/functions/dump.php" + ], "psr-4": { - "Symfony\\Component\\PropertyInfo\\": "" + "Symfony\\Component\\VarDumper\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -6451,26 +9081,22 @@ ], "authors": [ { - "name": "Kévin Dunglas", - "email": "dunglas@gmail.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Extracts information about PHP class' properties using metadata of popular sources", + "description": "Provides mechanisms for walking through any arbitrary PHP variable", "homepage": "https://symfony.com", "keywords": [ - "doctrine", - "phpdoc", - "property", - "symfony", - "type", - "validator" + "debug", + "dump" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.4.8" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.2" }, "funding": [ { @@ -6490,68 +9116,40 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { - "name": "symfony/serializer", - "version": "v7.4.14", + "name": "symfony/yaml", + "version": "v8.1.2", "source": { "type": "git", - "url": "https://github.com/symfony/serializer.git", - "reference": "55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1" + "url": "https://github.com/symfony/yaml.git", + "reference": "faabdbe998e8c5c599dceffa27aa265b185c0736" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1", - "reference": "55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1", + "url": "https://api.github.com/repos/symfony/yaml/zipball/faabdbe998e8c5c599dceffa27aa265b185c0736", + "reference": "faabdbe998e8c5c599dceffa27aa265b185c0736", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-php84": "^1.30" + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "phpdocumentor/reflection-docblock": "<5.2|>=7", - "phpdocumentor/type-resolver": "<1.5.1", - "symfony/dependency-injection": "<6.4", - "symfony/property-access": "<6.4.31|>=7.0,<7.4.2|>=8.0,<8.0.2", - "symfony/property-info": "<6.4", - "symfony/type-info": "<7.2.5", - "symfony/uid": "<6.4", - "symfony/validator": "<6.4", - "symfony/yaml": "<6.4" + "symfony/console": "<7.4" }, "require-dev": { - "phpdocumentor/reflection-docblock": "^5.2|^6.0", - "phpstan/phpdoc-parser": "^1.0|^2.0", - "seld/jsonlint": "^1.10", - "symfony/cache": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^7.2|^8.0", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/filesystem": "^6.4|^7.0|^8.0", - "symfony/form": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4.31|^7.4.2|^8.0.2", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/translation-contracts": "^2.5|^3", - "symfony/type-info": "^7.2.5|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", - "symfony/validator": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" }, + "bin": [ + "Resources/bin/yaml-lint" + ], "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Serializer\\": "" + "Symfony\\Component\\Yaml\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -6571,10 +9169,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.4.14" + "source": "https://github.com/symfony/yaml/tree/v8.1.2" }, "funding": [ { @@ -6594,245 +9192,258 @@ "type": "tidelift" } ], - "time": "2026-06-27T08:31:18+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { - "name": "symfony/service-contracts", - "version": "v3.7.1", + "name": "tecnickcom/tcpdf", + "version": "6.11.3", "source": { "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "url": "https://github.com/tecnickcom/TCPDF.git", + "reference": "b18f6119161019916c5bb07cb8da5205ae5c1b63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/tecnickcom/TCPDF/zipball/b18f6119161019916c5bb07cb8da5205ae5c1b63", + "reference": "b18f6119161019916c5bb07cb8da5205ae5c1b63", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" + "ext-curl": "*", + "php": ">=7.1.0" }, - "conflict": { - "ext-psr": "<1.1|>=2" + "suggest": { + "ext-gd": "Enables additional image handling in some workflows.", + "ext-imagick": "Enables additional image format support when available.", + "ext-zlib": "Recommended for compressed streams and related features.", + "tecnickcom/tc-lib-pdf": "Modern replacement for TCPDF for new projects." }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } - }, "autoload": { - "psr-4": { - "Symfony\\Contracts\\Service\\": "" - }, - "exclude-from-classmap": [ - "/Test/" + "classmap": [ + "config", + "include", + "tcpdf.php", + "tcpdf_barcodes_1d.php", + "tcpdf_barcodes_2d.php", + "include/tcpdf_colors.php", + "include/tcpdf_filters.php", + "include/tcpdf_font_data.php", + "include/tcpdf_fonts.php", + "include/tcpdf_images.php", + "include/tcpdf_static.php", + "include/barcodes/datamatrix.php", + "include/barcodes/pdf417.php", + "include/barcodes/qrcode.php" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "LGPL-3.0-or-later" ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Nicola Asuni", + "email": "info@tecnick.com", + "role": "lead" } ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", + "description": "Deprecated legacy PDF engine for PHP. For new projects use tecnickcom/tc-lib-pdf.", + "homepage": "https://tcpdf.org", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "PDFD32000-2008", + "TCPDF", + "barcodes", + "datamatrix", + "pdf", + "pdf417", + "qrcode" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "issues": "https://github.com/tecnickcom/TCPDF/issues", + "source": "https://github.com/tecnickcom/TCPDF" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" + "url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ", + "type": "paypal" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-04-21T17:00:18+00:00" }, { - "name": "symfony/string", - "version": "v7.4.13", + "name": "thecodingmachine/safe", + "version": "v3.4.0", "source": { "type": "git", - "url": "https://github.com/symfony/string.git", - "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", - "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.33", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" - }, - "conflict": { - "symfony/translation-contracts": "<2.5" + "php": "^8.1" }, "require-dev": { - "symfony/emoji": "^7.1|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/intl": "^6.4|^7.0|^8.0", - "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0" + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" }, "type": "library", "autoload": { "files": [ - "Resources/functions.php" + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" ], - "psr-4": { - "Symfony\\Component\\String\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", - "homepage": "https://symfony.com", - "keywords": [ - "grapheme", - "i18n", - "string", - "unicode", - "utf-8", - "utf8" - ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", "support": { - "source": "https://github.com/symfony/string/tree/v7.4.13" + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" + "url": "https://github.com/OskarStark", + "type": "github" }, { - "url": "https://github.com/fabpot", + "url": "https://github.com/shish", "type": "github" }, { - "url": "https://github.com/nicolas-grekas", + "url": "https://github.com/silasjoisten", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" + "url": "https://github.com/staabm", + "type": "github" } ], - "time": "2026-05-23T15:23:29+00:00" + "time": "2026-02-04T18:08:13+00:00" }, { - "name": "symfony/translation", - "version": "v6.4.42", + "name": "theiconic/name-parser", + "version": "v1.2.11", "source": { "type": "git", - "url": "https://github.com/symfony/translation.git", - "reference": "fef99cef37890b350976f5f492854faefadd4e15" + "url": "https://github.com/theiconic/name-parser.git", + "reference": "9a54a713bf5b2e7fd990828147d42de16bf8a253" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/fef99cef37890b350976f5f492854faefadd4e15", - "reference": "fef99cef37890b350976f5f492854faefadd4e15", + "url": "https://api.github.com/repos/theiconic/name-parser/zipball/9a54a713bf5b2e7fd990828147d42de16bf8a253", + "reference": "9a54a713bf5b2e7fd990828147d42de16bf8a253", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5|^3.0" - }, - "conflict": { - "symfony/config": "<5.4", - "symfony/console": "<5.4", - "symfony/dependency-injection": "<5.4", - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<5.4", - "symfony/service-contracts": "<2.5", - "symfony/twig-bundle": "<5.4", - "symfony/yaml": "<5.4" - }, - "provide": { - "symfony/translation-implementation": "2.3|3.0" + "php": ">=7.1" }, "require-dev": { - "nikic/php-parser": "^4.18|^5.0", - "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/intl": "^5.4|^6.0|^7.0", - "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^5.4|^6.0|^7.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^5.4|^6.0|^7.0" + "php-coveralls/php-coveralls": "^2.1", + "php-mock/php-mock-phpunit": "^2.1", + "phpunit/phpunit": "^7.0" }, "type": "library", "autoload": { - "files": [ - "Resources/functions.php" - ], "psr-4": { - "Symfony\\Component\\Translation\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "TheIconic\\NameParser\\": [ + "src/", + "tests/" + ] + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -6840,154 +9451,102 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "The Iconic", + "email": "engineering@theiconic.com.au" } ], - "description": "Provides tools to internationalize your application", - "homepage": "https://symfony.com", + "description": "PHP library for parsing a string containing a full name into its parts", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.42" + "issues": "https://github.com/theiconic/name-parser/issues", + "source": "https://github.com/theiconic/name-parser/tree/v1.2.11" }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-05T16:46:18+00:00" + "time": "2019-11-14T14:08:48+00:00" }, { - "name": "symfony/translation-contracts", - "version": "v3.7.1", + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", "source": { "type": "git", - "url": "https://github.com/symfony/translation-contracts.git", - "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", - "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", "shasum": "" }, "require": { - "php": ">=8.1" + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { "branch-alias": { - "dev-main": "3.7-dev" + "dev-master": "2.x-dev" } }, "autoload": { "psr-4": { - "Symfony\\Contracts\\Translation\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" } ], - "description": "Generic abstractions related to translation", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2025-12-02T11:56:42+00:00" }, { - "name": "symfony/type-info", - "version": "v7.4.9", + "name": "tpetry/laravel-query-expressions", + "version": "1.6.0", "source": { "type": "git", - "url": "https://github.com/symfony/type-info.git", - "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6" + "url": "https://github.com/tpetry/laravel-query-expressions.git", + "reference": "e9e6c7e7c570de6820b617868e8073a1fa71f461" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/cafeedbf157b890e94ac5b83eaed85595106d5d6", - "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6", + "url": "https://api.github.com/repos/tpetry/laravel-query-expressions/zipball/e9e6c7e7c570de6820b617868e8073a1fa71f461", + "reference": "e9e6c7e7c570de6820b617868e8073a1fa71f461", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "conflict": { - "phpstan/phpdoc-parser": "<1.30" + "laravel/framework": "^10.13.1|^11.0|^12.0|^13.0", + "php": "^8.1" }, "require-dev": { - "phpstan/phpdoc-parser": "^1.30|^2.0" + "laravel/pint": "^1.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "pestphp/pest": "^2.28.1|^3.0.0", + "pestphp/pest-plugin-laravel": "^2.2.0|^3.0.0", + "phpstan/phpstan": "^1.11|^2.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\TypeInfo\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Tpetry\\QueryExpressions\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -6995,250 +9554,217 @@ ], "authors": [ { - "name": "Mathias Arlaud", - "email": "mathias.arlaud@gmail.com" - }, - { - "name": "Baptiste LEDUC", - "email": "baptiste.leduc@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "tpetry", + "email": "github@tpetry.me", + "role": "Developer" } ], - "description": "Extracts PHP types information.", - "homepage": "https://symfony.com", + "description": "Database-independent Query Expressions as a replacement to DB::raw calls", + "homepage": "https://github.com/tpetry/laravel-query-expressions", "keywords": [ - "PHPStan", - "phpdoc", - "symfony", - "type" + "database", + "expression", + "laravel", + "query" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.4.9" + "issues": "https://github.com/tpetry/laravel-query-expressions/issues", + "source": "https://github.com/tpetry/laravel-query-expressions/tree/1.6.0" }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-04-22T15:21:55+00:00" + "time": "2026-03-13T08:48:18+00:00" }, { - "name": "symfony/uid", - "version": "v7.4.9", + "name": "twig/twig", + "version": "v3.27.1", "source": { "type": "git", - "url": "https://github.com/symfony/uid.git", - "reference": "2676b524340abcfe4d6151ec698463cebafee439" + "url": "https://github.com/twigphp/Twig.git", + "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", - "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-uuid": "^1.15" + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" }, "type": "library", "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], "psr-4": { - "Symfony\\Component\\Uid\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Twig\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Grégoire Pineau", - "email": "lyrixx@lyrixx.info" + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" }, { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Twig Team", + "role": "Contributors" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" } ], - "description": "Provides an object-oriented API to generate and represent UIDs", - "homepage": "https://symfony.com", + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", "keywords": [ - "UID", - "ulid", - "uuid" + "templating" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.9" + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.27.1" }, "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, { "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/twig/twig", "type": "tidelift" } ], - "time": "2026-04-30T15:19:22+00:00" + "time": "2026-05-30T17:09:26+00:00" }, { - "name": "symfony/var-dumper", - "version": "v7.4.14", + "name": "vlucas/phpdotenv", + "version": "v5.6.4", "source": { "type": "git", - "url": "https://github.com/symfony/var-dumper.git", - "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", - "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0" - }, - "conflict": { - "symfony/console": "<6.4" + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." }, - "bin": [ - "Resources/bin/var-dump-server" - ], "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, "autoload": { - "files": [ - "Resources/functions/dump.php" - ], "psr-4": { - "Symfony\\Component\\VarDumper\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Dotenv\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" } ], - "description": "Provides mechanisms for walking through any arbitrary PHP variable", - "homepage": "https://symfony.com", + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", "keywords": [ - "debug", - "dump" + "dotenv", + "env", + "environment" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", + "url": "https://github.com/GrahamCampbell", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", "type": "tidelift" } ], - "time": "2026-06-08T20:24:16+00:00" + "time": "2026-07-06T19:11:50+00:00" }, { - "name": "symfony/yaml", - "version": "v7.4.14", + "name": "voku/portable-ascii", + "version": "2.1.1", "source": { "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc" + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/f8f328665ace2370d1e10645b807ba1646dc7dcc", - "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "symfony/console": "<6.4" + "php": ">=7.1.0" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" }, - "bin": [ - "Resources/bin/yaml-lint" - ], "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "voku\\": "src/voku/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -7246,288 +9772,240 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" } ], - "description": "Loads and dumps YAML files", - "homepage": "https://symfony.com", + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.14" + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" }, "funding": [ { - "url": "https://symfony.com/sponsor", + "url": "https://www.paypal.me/moelleken", "type": "custom" }, { - "url": "https://github.com/fabpot", + "url": "https://github.com/voku", "type": "github" }, { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", "type": "tidelift" } ], - "time": "2026-06-08T20:24:16+00:00" + "time": "2026-04-26T05:33:54+00:00" }, { - "name": "tecnickcom/tcpdf", - "version": "6.11.3", + "name": "web-auth/cose-lib", + "version": "4.6.0", "source": { "type": "git", - "url": "https://github.com/tecnickcom/TCPDF.git", - "reference": "b18f6119161019916c5bb07cb8da5205ae5c1b63" + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/TCPDF/zipball/b18f6119161019916c5bb07cb8da5205ae5c1b63", - "reference": "b18f6119161019916c5bb07cb8da5205ae5c1b63", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3afe04df137baf97c5c3e28c5ee6f05536405148", + "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148", "shasum": "" }, "require": { - "ext-curl": "*", - "php": ">=7.1.0" + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.1", + "spomky-labs/pki-framework": "^1.0" + }, + "require-dev": { + "spomky-labs/cbor-php": "^3.2.2" }, "suggest": { - "ext-gd": "Enables additional image handling in some workflows.", - "ext-imagick": "Enables additional image format support when available.", - "ext-zlib": "Recommended for compressed streams and related features.", - "tecnickcom/tc-lib-pdf": "Modern replacement for TCPDF for new projects." + "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", + "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", + "spomky-labs/cbor-php": "For COSE Signature support" }, "type": "library", "autoload": { - "classmap": [ - "config", - "include", - "tcpdf.php", - "tcpdf_barcodes_1d.php", - "tcpdf_barcodes_2d.php", - "include/tcpdf_colors.php", - "include/tcpdf_filters.php", - "include/tcpdf_font_data.php", - "include/tcpdf_fonts.php", - "include/tcpdf_images.php", - "include/tcpdf_static.php", - "include/barcodes/datamatrix.php", - "include/barcodes/pdf417.php", - "include/barcodes/qrcode.php" - ] + "psr-4": { + "Cose\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-3.0-or-later" + "MIT" ], "authors": [ { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/cose/contributors" } ], - "description": "Deprecated legacy PDF engine for PHP. For new projects use tecnickcom/tc-lib-pdf.", - "homepage": "https://tcpdf.org", + "description": "CBOR Object Signing and Encryption (COSE) For PHP", + "homepage": "https://github.com/web-auth", "keywords": [ - "PDFD32000-2008", - "TCPDF", - "barcodes", - "datamatrix", - "pdf", - "pdf417", - "qrcode" + "COSE", + "RFC8152" ], "support": { - "issues": "https://github.com/tecnickcom/TCPDF/issues", - "source": "https://github.com/tecnickcom/TCPDF" + "issues": "https://github.com/web-auth/cose-lib/issues", + "source": "https://github.com/web-auth/cose-lib/tree/4.6.0" }, "funding": [ { - "url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ", - "type": "paypal" + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" } ], - "time": "2026-04-21T17:00:18+00:00" + "time": "2026-07-16T10:19:49+00:00" }, { - "name": "thecodingmachine/safe", - "version": "v3.4.0", + "name": "web-auth/webauthn-lib", + "version": "5.3.5", "source": { "type": "git", - "url": "https://github.com/thecodingmachine/safe.git", - "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + "url": "https://github.com/web-auth/webauthn-lib.git", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", - "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", "shasum": "" }, "require": { - "php": "^8.1" - }, - "require-dev": { - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpstan/phpstan": "^2", - "phpunit/phpunit": "^10", - "squizlabs/php_codesniffer": "^3.2" - }, - "type": "library", - "autoload": { - "files": [ - "lib/special_cases.php", - "generated/apache.php", - "generated/apcu.php", - "generated/array.php", - "generated/bzip2.php", - "generated/calendar.php", - "generated/classobj.php", - "generated/com.php", - "generated/cubrid.php", - "generated/curl.php", - "generated/datetime.php", - "generated/dir.php", - "generated/eio.php", - "generated/errorfunc.php", - "generated/exec.php", - "generated/fileinfo.php", - "generated/filesystem.php", - "generated/filter.php", - "generated/fpm.php", - "generated/ftp.php", - "generated/funchand.php", - "generated/gettext.php", - "generated/gmp.php", - "generated/gnupg.php", - "generated/hash.php", - "generated/ibase.php", - "generated/ibmDb2.php", - "generated/iconv.php", - "generated/image.php", - "generated/imap.php", - "generated/info.php", - "generated/inotify.php", - "generated/json.php", - "generated/ldap.php", - "generated/libxml.php", - "generated/lzf.php", - "generated/mailparse.php", - "generated/mbstring.php", - "generated/misc.php", - "generated/mysql.php", - "generated/mysqli.php", - "generated/network.php", - "generated/oci8.php", - "generated/opcache.php", - "generated/openssl.php", - "generated/outcontrol.php", - "generated/pcntl.php", - "generated/pcre.php", - "generated/pgsql.php", - "generated/posix.php", - "generated/ps.php", - "generated/pspell.php", - "generated/readline.php", - "generated/rnp.php", - "generated/rpminfo.php", - "generated/rrd.php", - "generated/sem.php", - "generated/session.php", - "generated/shmop.php", - "generated/sockets.php", - "generated/sodium.php", - "generated/solr.php", - "generated/spl.php", - "generated/sqlsrv.php", - "generated/ssdeep.php", - "generated/ssh2.php", - "generated/stream.php", - "generated/strings.php", - "generated/swoole.php", - "generated/uodbc.php", - "generated/uopz.php", - "generated/url.php", - "generated/var.php", - "generated/xdiff.php", - "generated/xml.php", - "generated/xmlrpc.php", - "generated/yaml.php", - "generated/yaz.php", - "generated/zip.php", - "generated/zlib.php" - ], - "classmap": [ - "lib/DateTime.php", - "lib/DateTimeImmutable.php", - "lib/Exceptions/", - "generated/Exceptions/" - ] + "ext-json": "*", + "ext-openssl": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0|^2.0|^3.0", + "spomky-labs/cbor-php": "^3.0", + "spomky-labs/pki-framework": "^1.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^3.2", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "web-auth/cose-lib": "^4.2.3" + }, + "suggest": { + "psr/log-implementation": "Recommended to receive logs from the library", + "symfony/event-dispatcher": "Recommended to use dispatched events", + "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/web-auth/webauthn-framework", + "name": "web-auth/webauthn-framework" + } + }, + "autoload": { + "psr-4": { + "Webauthn\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "PHP core functions that throw exceptions instead of returning FALSE on error", - "support": { - "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" - }, - "funding": [ + "authors": [ { - "url": "https://github.com/OskarStark", - "type": "github" + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" }, { - "url": "https://github.com/shish", - "type": "github" - }, + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-library/contributors" + } + ], + "description": "FIDO2/Webauthn Support For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "FIDO2", + "fido", + "webauthn" + ], + "support": { + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" + }, + "funding": [ { - "url": "https://github.com/silasjoisten", + "url": "https://github.com/Spomky", "type": "github" }, { - "url": "https://github.com/staabm", - "type": "github" + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" } ], - "time": "2026-02-04T18:08:13+00:00" + "time": "2026-05-31T15:00:08+00:00" }, { - "name": "theiconic/name-parser", - "version": "v1.2.11", + "name": "webmozart/assert", + "version": "2.4.1", "source": { "type": "git", - "url": "https://github.com/theiconic/name-parser.git", - "reference": "9a54a713bf5b2e7fd990828147d42de16bf8a253" + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theiconic/name-parser/zipball/9a54a713bf5b2e7fd990828147d42de16bf8a253", - "reference": "9a54a713bf5b2e7fd990828147d42de16bf8a253", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { - "php": ">=7.1" + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" }, - "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "php-mock/php-mock-phpunit": "^2.1", - "phpunit/phpunit": "^7.0" + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, "autoload": { "psr-4": { - "TheIconic\\NameParser\\": [ - "src/", - "tests/" - ] + "Webmozart\\Assert\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -7536,926 +10014,795 @@ ], "authors": [ { - "name": "The Iconic", - "email": "engineering@theiconic.com.au" + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], - "description": "PHP library for parsing a string containing a full name into its parts", + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], "support": { - "issues": "https://github.com/theiconic/name-parser/issues", - "source": "https://github.com/theiconic/name-parser/tree/v1.2.11" + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2019-11-14T14:08:48+00:00" + "time": "2026-06-15T15:31:57+00:00" }, { - "name": "twig/twig", - "version": "v3.27.1", + "name": "webonyx/graphql-php", + "version": "v15.33.1", "source": { "type": "git", - "url": "https://github.com/twigphp/Twig.git", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74" + "url": "https://github.com/webonyx/graphql-php.git", + "reference": "e0f40ce40a527ee27413cceced4825aacbc7de5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/ae2071bffb38f04847fc0864d730c94b9cb8ab74", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/e0f40ce40a527ee27413cceced4825aacbc7de5b", + "reference": "e0f40ce40a527ee27413cceced4825aacbc7de5b", "shasum": "" }, "require": { - "php": ">=8.1.0", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-mbstring": "^1.3" + "ext-json": "*", + "ext-mbstring": "*", + "php": "^7.4 || ^8" }, "require-dev": { - "php-cs-fixer/shim": "^3.0@stable", - "phpstan/phpstan": "^2.0@stable", - "psr/container": "^1.0|^2.0", - "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + "amphp/amp": "^2.6 || ^3", + "amphp/http-server": "^2.1 || ^3", + "dms/phpunit-arraysubset-asserts": "dev-master", + "ergebnis/composer-normalize": "^2.28", + "friendsofphp/php-cs-fixer": "3.95.8", + "mll-lab/php-cs-fixer-config": "5.13.0", + "nyholm/psr7": "^1.5", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "2.2.2", + "phpstan/phpstan-phpunit": "2.0.16", + "phpstan/phpstan-strict-rules": "2.0.11", + "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", + "psr/http-message": "^1 || ^2", + "react/http": "^1.6", + "react/promise": "^2.0 || ^3.0", + "rector/rector": "^2.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6 || ^7 || ^8", + "thecodingmachine/safe": "^1.3 || ^2 || ^3", + "ticketswap/phpstan-error-formatter": "1.3.0" + }, + "suggest": { + "amphp/amp": "To leverage async resolving on AMPHP platform (v3 with AmpFutureAdapter, v2 with AmpPromiseAdapter)", + "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", + "psr/http-message": "To use standard GraphQL server", + "react/promise": "To leverage async resolving on React PHP platform" }, "type": "library", "autoload": { - "files": [ - "src/Resources/core.php", - "src/Resources/debug.php", - "src/Resources/escaper.php", - "src/Resources/string_loader.php" - ], "psr-4": { - "Twig\\": "src/" + "GraphQL\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com", - "homepage": "http://fabien.potencier.org", - "role": "Lead Developer" - }, - { - "name": "Twig Team", - "role": "Contributors" - }, - { - "name": "Armin Ronacher", - "email": "armin.ronacher@active-4.com", - "role": "Project Founder" - } + "MIT" ], - "description": "Twig, the flexible, fast, and secure template language for PHP", - "homepage": "https://twig.symfony.com", + "description": "A PHP port of GraphQL reference implementation", + "homepage": "https://github.com/webonyx/graphql-php", "keywords": [ - "templating" + "api", + "graphql" ], "support": { - "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.27.1" + "issues": "https://github.com/webonyx/graphql-php/issues", + "source": "https://github.com/webonyx/graphql-php/tree/v15.33.1" }, "funding": [ { - "url": "https://github.com/fabpot", + "url": "https://github.com/spawnia", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/twig/twig", - "type": "tidelift" + "url": "https://opencollective.com/webonyx-graphql-php", + "type": "open_collective" } ], - "time": "2026-05-30T17:09:26+00:00" + "time": "2026-06-17T06:05:59+00:00" }, { - "name": "voku/portable-ascii", - "version": "2.1.1", + "name": "yiisoft/aliases", + "version": "3.1.1", "source": { "type": "git", - "url": "https://github.com/voku/portable-ascii.git", - "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + "url": "https://github.com/yiisoft/aliases.git", + "reference": "6f876dbf899f604fd3aefa3b3fd37e2ff2549ead" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", - "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "url": "https://api.github.com/repos/yiisoft/aliases/zipball/6f876dbf899f604fd3aefa3b3fd37e2ff2549ead", + "reference": "6f876dbf899f604fd3aefa3b3fd37e2ff2549ead", "shasum": "" }, "require": { - "php": ">=7.1.0" + "php": "8.1 - 8.5" }, "require-dev": { - "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" - }, - "suggest": { - "ext-intl": "Use Intl for transliterator_transliterate() support" + "bamarni/composer-bin-plugin": "^1.8.3", + "maglnet/composer-require-checker": "^4.7.1", + "phpunit/phpunit": "^10.5.48", + "psr/container": "^2.0.2", + "rector/rector": "^2.1.2", + "spatie/phpunit-watcher": "^1.24.0", + "yiisoft/definitions": "^3.4", + "yiisoft/di": "^1.4", + "yiisoft/test-support": "^3.0.2" }, "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": true, + "target-directory": "tools" + }, + "config-plugin": { + "di": "di.php", + "params": "params.php" + }, + "config-plugin-options": { + "source-directory": "config" + } + }, "autoload": { "psr-4": { - "voku\\": "src/voku/" + "Yiisoft\\Aliases\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" - ], - "authors": [ - { - "name": "Lars Moelleken", - "homepage": "https://www.moelleken.org/" - } + "BSD-3-Clause" ], - "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", - "homepage": "https://github.com/voku/portable-ascii", + "description": "Named paths and URLs storage", + "homepage": "https://www.yiiframework.com/", "keywords": [ - "ascii", - "clean", - "php" + "alias" ], "support": { - "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + "chat": "https://t.me/yii3en", + "forum": "https://www.yiiframework.com/forum/", + "irc": "ircs://irc.libera.chat:6697/yii", + "issues": "https://github.com/yiisoft/aliases/issues?state=open", + "source": "https://github.com/yiisoft/aliases", + "wiki": "https://www.yiiframework.com/wiki/" }, "funding": [ { - "url": "https://www.paypal.me/moelleken", - "type": "custom" - }, - { - "url": "https://github.com/voku", + "url": "https://github.com/sponsors/yiisoft", "type": "github" }, { - "url": "https://opencollective.com/portable-ascii", - "type": "open_collective" - }, - { - "url": "https://www.patreon.com/voku", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", - "type": "tidelift" + "url": "https://opencollective.com/yiisoft", + "type": "opencollective" } ], - "time": "2026-04-26T05:33:54+00:00" + "time": "2025-12-04T12:53:43+00:00" }, { - "name": "web-auth/cose-lib", - "version": "4.5.2", + "name": "yiisoft/arrays", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/web-auth/cose-lib.git", - "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d" + "url": "https://github.com/yiisoft/arrays.git", + "reference": "8efada90e4fd540b3da476779bc1b7bd9319b62f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5b38660f90070a8e45f3dbc9528ade3b608dd77d", - "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d", + "url": "https://api.github.com/repos/yiisoft/arrays/zipball/8efada90e4fd540b3da476779bc1b7bd9319b62f", + "reference": "8efada90e4fd540b3da476779bc1b7bd9319b62f", "shasum": "" }, - "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", - "ext-json": "*", - "ext-openssl": "*", - "php": ">=8.1", - "spomky-labs/pki-framework": "^1.0" - }, - "require-dev": { - "spomky-labs/cbor-php": "^3.2.2" + "require": { + "php": "8.1 - 8.5", + "yiisoft/strings": "^2.6" }, - "suggest": { - "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", - "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", - "spomky-labs/cbor-php": "For COSE Signature support" + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpbench/phpbench": "^1.4.1", + "phpunit/phpunit": "^10.5.48", + "rector/rector": "^2.1.2", + "spatie/phpunit-watcher": "^1.24" }, "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": true, + "target-directory": "tools" + } + }, "autoload": { "psr-4": { - "Cose\\": "src/" + "Yiisoft\\Arrays\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" - ], - "authors": [ - { - "name": "Florent Morselli", - "homepage": "https://github.com/Spomky" - }, - { - "name": "All contributors", - "homepage": "https://github.com/web-auth/cose/contributors" - } + "BSD-3-Clause" ], - "description": "CBOR Object Signing and Encryption (COSE) For PHP", - "homepage": "https://github.com/web-auth", + "description": "Yii Array Helper", + "homepage": "https://www.yiiframework.com/", "keywords": [ - "COSE", - "RFC8152" + "array", + "helper", + "yii" ], "support": { - "issues": "https://github.com/web-auth/cose-lib/issues", - "source": "https://github.com/web-auth/cose-lib/tree/4.5.2" + "chat": "https://t.me/yii3en", + "forum": "https://forum.yiiframework.com/", + "irc": "ircs://irc.libera.chat:6697/yii", + "issues": "https://github.com/yiisoft/arrays/issues?state=open", + "source": "https://github.com/yiisoft/arrays", + "wiki": "https://www.yiiframework.com/wiki/" }, "funding": [ { - "url": "https://github.com/Spomky", + "url": "https://github.com/sponsors/yiisoft", "type": "github" }, { - "url": "https://www.patreon.com/FlorentMorselli", - "type": "patreon" + "url": "https://opencollective.com/yiisoft", + "type": "opencollective" } ], - "time": "2026-05-03T09:49:50+00:00" + "time": "2025-11-26T12:25:21+00:00" }, { - "name": "web-auth/webauthn-lib", - "version": "5.3.5", + "name": "yiisoft/files", + "version": "2.1.0", "source": { "type": "git", - "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" + "url": "https://github.com/yiisoft/files.git", + "reference": "465650fd9e4295669f42ab7e9fec2386700540a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", - "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "url": "https://api.github.com/repos/yiisoft/files/zipball/465650fd9e4295669f42ab7e9fec2386700540a7", + "reference": "465650fd9e4295669f42ab7e9fec2386700540a7", "shasum": "" }, "require": { - "ext-json": "*", - "ext-openssl": "*", - "paragonie/constant_time_encoding": "^2.6|^3.0", - "php": ">=8.2", - "phpdocumentor/reflection-docblock": "^5.3|^6.0", - "psr/clock": "^1.0", - "psr/event-dispatcher": "^1.0", - "psr/log": "^1.0|^2.0|^3.0", - "spomky-labs/cbor-php": "^3.0", - "spomky-labs/pki-framework": "^1.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/deprecation-contracts": "^3.2", - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", - "web-auth/cose-lib": "^4.2.3" + "php": "8.0 - 8.5", + "yiisoft/strings": "^2.0" }, - "suggest": { - "psr/log-implementation": "Recommended to receive logs from the library", - "symfony/event-dispatcher": "Recommended to use dispatched events", - "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.3", + "ext-zlib": "*", + "maglnet/composer-require-checker": "^4.4", + "phpunit/phpunit": "^9.6.22", + "rector/rector": "^2.0.10", + "spatie/phpunit-watcher": "^1.23.6" }, "type": "library", "extra": { - "thanks": { - "url": "https://github.com/web-auth/webauthn-framework", - "name": "web-auth/webauthn-framework" + "bamarni-bin": { + "bin-links": true, + "forward-command": true, + "target-directory": "tools" } }, "autoload": { "psr-4": { - "Webauthn\\": "src/" + "Yiisoft\\Files\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" - ], - "authors": [ - { - "name": "Florent Morselli", - "homepage": "https://github.com/Spomky" - }, - { - "name": "All contributors", - "homepage": "https://github.com/web-auth/webauthn-library/contributors" - } + "BSD-3-Clause" ], - "description": "FIDO2/Webauthn Support For PHP", - "homepage": "https://github.com/web-auth", + "description": "Helper to manage files and directories", + "homepage": "https://www.yiiframework.com/", "keywords": [ - "FIDO2", - "fido", - "webauthn" + "files" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" + "chat": "https://t.me/yii3en", + "forum": "https://www.yiiframework.com/forum/", + "irc": "ircs://irc.libera.chat:6697/yii", + "issues": "https://github.com/yiisoft/files/issues?state=open", + "source": "https://github.com/yiisoft/files", + "wiki": "https://www.yiiframework.com/wiki/" }, "funding": [ { - "url": "https://github.com/Spomky", + "url": "https://github.com/sponsors/yiisoft", "type": "github" }, { - "url": "https://www.patreon.com/FlorentMorselli", - "type": "patreon" + "url": "https://opencollective.com/yiisoft", + "type": "opencollective" } ], - "time": "2026-05-31T15:00:08+00:00" + "time": "2025-12-01T06:30:27+00:00" }, { - "name": "webmozart/assert", - "version": "2.4.1", + "name": "yiisoft/html", + "version": "4.2.0", "source": { "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + "url": "https://github.com/yiisoft/html.git", + "reference": "eeb0bea275c87c4ee0dbc444dbc24fbf44a5aa77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", - "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "url": "https://api.github.com/repos/yiisoft/html/zipball/eeb0bea275c87c4ee0dbc444dbc24fbf44a5aa77", + "reference": "eeb0bea275c87c4ee0dbc444dbc24fbf44a5aa77", "shasum": "" }, "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^8.2" + "php": "8.1 - 8.5", + "yiisoft/arrays": "^2.0 || ^3.0", + "yiisoft/json": "^1.0" }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.90", + "infection/infection": "^0.27.11 || ^0.32", + "maglnet/composer-require-checker": "^4.7.1", + "phpunit/phpunit": "^10.5.46", + "rector/rector": "^2.0.17", + "spatie/phpunit-watcher": "^1.24", + "vimeo/psalm": "^5.26.1 || ^6.12" }, "type": "library", - "extra": { - "psalm": { - "pluginClass": "Webmozart\\Assert\\PsalmPlugin" - }, - "branch-alias": { - "dev-master": "2.0-dev", - "dev-feature/2-0": "2.0-dev" - } - }, "autoload": { "psr-4": { - "Webmozart\\Assert\\": "src/" + "Yiisoft\\Html\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "authors": [ + "description": "Handy library to generate HTML", + "homepage": "https://www.yiiframework.com/", + "keywords": [ + "html" + ], + "support": { + "chat": "https://t.me/yii3en", + "forum": "https://forum.yiiframework.com/", + "irc": "ircs://irc.libera.chat:6697/yii", + "issues": "https://github.com/yiisoft/html/issues?state=open", + "source": "https://github.com/yiisoft/html", + "wiki": "https://www.yiiframework.com/wiki/" + }, + "funding": [ { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" + "url": "https://github.com/sponsors/yiisoft", + "type": "github" }, { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com" + "url": "https://opencollective.com/yiisoft", + "type": "opencollective" } ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.1" - }, - "time": "2026-06-15T15:31:57+00:00" + "time": "2026-06-05T07:38:14+00:00" }, { - "name": "yiisoft/yii2", - "version": "2.0.55", + "name": "yiisoft/i18n", + "version": "1.2.2", "source": { "type": "git", - "url": "https://github.com/yiisoft/yii2-framework.git", - "reference": "b900eecdb225041a4c4e0f5e0e5336f606a23bdb" + "url": "https://github.com/yiisoft/i18n.git", + "reference": "028fbcee0ea772dab150d0a8f40344a32e639d3f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/yiisoft/yii2-framework/zipball/b900eecdb225041a4c4e0f5e0e5336f606a23bdb", - "reference": "b900eecdb225041a4c4e0f5e0e5336f606a23bdb", + "url": "https://api.github.com/repos/yiisoft/i18n/zipball/028fbcee0ea772dab150d0a8f40344a32e639d3f", + "reference": "028fbcee0ea772dab150d0a8f40344a32e639d3f", "shasum": "" }, "require": { - "bower-asset/inputmask": "^5.0.8 ", - "bower-asset/jquery": "3.7.*@stable | 3.6.*@stable | 3.5.*@stable | 3.4.*@stable | 3.3.*@stable | 3.2.*@stable | 3.1.*@stable | 2.2.*@stable | 2.1.*@stable | 1.11.*@stable | 1.12.*@stable", - "bower-asset/punycode": "^2.2", - "bower-asset/yii2-pjax": "~2.0.1", - "cebe/markdown": "~1.0.0 | ~1.1.0 | ~1.2.0", - "ext-ctype": "*", - "ext-mbstring": "*", - "ezyang/htmlpurifier": "^4.17", - "lib-pcre": "*", - "php": ">=7.4.0", - "yiisoft/yii2-composer": "~2.0.4" + "php": "8.0 - 8.5" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.3", + "maglnet/composer-require-checker": "^4.4", + "phpunit/phpunit": "^9.6.22", + "rector/rector": "^2.0.10", + "spatie/phpunit-watcher": "^1.23.6" }, - "bin": [ - "yii" - ], "type": "library", "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" + "bamarni-bin": { + "bin-links": true, + "forward-command": true, + "target-directory": "tools" } }, "autoload": { "psr-4": { - "yii\\": "" + "Yiisoft\\I18n\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], - "authors": [ - { - "name": "Qiang Xue", - "email": "qiang.xue@gmail.com", - "homepage": "https://www.yiiframework.com/", - "role": "Founder and project lead" - }, - { - "name": "Alexander Makarov", - "email": "sam@rmcreative.ru", - "homepage": "https://rmcreative.ru/", - "role": "Core framework development" - }, - { - "name": "Maurizio Domba", - "homepage": "http://mdomba.info/", - "role": "Core framework development" - }, - { - "name": "Carsten Brandt", - "email": "mail@cebe.cc", - "homepage": "https://www.cebe.cc/", - "role": "Core framework development" - }, - { - "name": "Timur Ruziev", - "email": "resurtm@gmail.com", - "homepage": "http://resurtm.com/", - "role": "Core framework development" - }, - { - "name": "Paul Klimov", - "email": "klimov.paul@gmail.com", - "role": "Core framework development" - }, - { - "name": "Dmitry Naumenko", - "email": "d.naumenko.a@gmail.com", - "role": "Core framework development" - }, - { - "name": "Boudewijn Vahrmeijer", - "email": "info@dynasource.eu", - "homepage": "http://dynasource.eu", - "role": "Core framework development" - } - ], - "description": "Yii PHP Framework Version 2", + "description": "Yii Internationalization Library", "homepage": "https://www.yiiframework.com/", "keywords": [ - "framework", - "yii2" + "i18n", + "locale" ], "support": { - "forum": "https://forum.yiiframework.com/", + "chat": "https://t.me/yii3en", + "forum": "https://www.yiiframework.com/forum/", "irc": "ircs://irc.libera.chat:6697/yii", - "issues": "https://github.com/yiisoft/yii2/issues?state=open", - "source": "https://github.com/yiisoft/yii2", - "wiki": "https://www.yiiframework.com/wiki" + "issues": "https://github.com/yiisoft/i18n/issues?state=open", + "source": "https://github.com/yiisoft/i18n", + "wiki": "https://www.yiiframework.com/wiki/" }, "funding": [ { - "url": "https://github.com/yiisoft", + "url": "https://github.com/sponsors/yiisoft", "type": "github" }, - { - "url": "https://opencollective.com/yiisoft", - "type": "open_collective" - }, - { - "url": "https://tidelift.com/funding/github/packagist/yiisoft/yii2", - "type": "tidelift" + { + "url": "https://opencollective.com/yiisoft", + "type": "opencollective" } ], - "time": "2026-05-09T14:50:57+00:00" + "time": "2025-11-29T15:57:19+00:00" }, { - "name": "yiisoft/yii2-composer", - "version": "2.0.11", + "name": "yiisoft/json", + "version": "1.1.0", "source": { "type": "git", - "url": "https://github.com/yiisoft/yii2-composer.git", - "reference": "b684b01ecb119c8287721def726a0e24fec2fef2" + "url": "https://github.com/yiisoft/json.git", + "reference": "6af88ed2c653f4b6cbe3ea9114e4aebc5a463c80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/yiisoft/yii2-composer/zipball/b684b01ecb119c8287721def726a0e24fec2fef2", - "reference": "b684b01ecb119c8287721def726a0e24fec2fef2", + "url": "https://api.github.com/repos/yiisoft/json/zipball/6af88ed2c653f4b6cbe3ea9114e4aebc5a463c80", + "reference": "6af88ed2c653f4b6cbe3ea9114e4aebc5a463c80", "shasum": "" }, "require": { - "composer-plugin-api": "^1.0 | ^2.0" + "ext-json": "*", + "ext-simplexml": "*", + "php": "~7.4.0 || 8.0 - 8.5" }, "require-dev": { - "composer/composer": "^1.0 | ^2.0@dev", - "phpunit/phpunit": "<7" + "bamarni/composer-bin-plugin": "^1.8.2", + "maglnet/composer-require-checker": "^3.8 || ^4.2", + "phpunit/phpunit": "^9.6.22", + "rector/rector": "^2.0.8", + "spatie/phpunit-watcher": "^1.23.6" }, - "type": "composer-plugin", + "type": "library", "extra": { - "class": "yii\\composer\\Plugin", - "branch-alias": { - "dev-master": "2.0.x-dev" + "bamarni-bin": { + "bin-links": true, + "forward-command": true, + "target-directory": "tools" } }, "autoload": { "psr-4": { - "yii\\composer\\": "" + "Yiisoft\\Json\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], - "authors": [ - { - "name": "Qiang Xue", - "email": "qiang.xue@gmail.com" - }, - { - "name": "Carsten Brandt", - "email": "mail@cebe.cc" - } - ], - "description": "The composer plugin for Yii extension installer", + "description": "Yii JSON encoding and decoding", + "homepage": "https://www.yiiframework.com/", "keywords": [ - "composer", - "extension installer", - "yii2" + "json" ], "support": { + "chat": "https://t.me/yii3en", "forum": "https://www.yiiframework.com/forum/", "irc": "ircs://irc.libera.chat:6697/yii", - "issues": "https://github.com/yiisoft/yii2-composer/issues", - "source": "https://github.com/yiisoft/yii2-composer", + "issues": "https://github.com/yiisoft/json/issues?state=open", + "source": "https://github.com/yiisoft/json", "wiki": "https://www.yiiframework.com/wiki/" }, "funding": [ { - "url": "https://github.com/yiisoft", + "url": "https://github.com/sponsors/yiisoft", "type": "github" }, { "url": "https://opencollective.com/yiisoft", - "type": "open_collective" - }, - { - "url": "https://tidelift.com/funding/github/packagist/yiisoft/yii2-composer", - "type": "tidelift" + "type": "opencollective" } ], - "time": "2025-02-13T20:59:36+00:00" + "time": "2025-11-21T19:39:36+00:00" }, { - "name": "yiisoft/yii2-debug", - "version": "2.1.27", + "name": "yiisoft/strings", + "version": "2.7.0", "source": { "type": "git", - "url": "https://github.com/yiisoft/yii2-debug.git", - "reference": "44e158914911ef81cd7111fd6d46b918f65fae7c" + "url": "https://github.com/yiisoft/strings.git", + "reference": "9bc7fea56374619cccd4587848029fe97f98bb33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/yiisoft/yii2-debug/zipball/44e158914911ef81cd7111fd6d46b918f65fae7c", - "reference": "44e158914911ef81cd7111fd6d46b918f65fae7c", + "url": "https://api.github.com/repos/yiisoft/strings/zipball/9bc7fea56374619cccd4587848029fe97f98bb33", + "reference": "9bc7fea56374619cccd4587848029fe97f98bb33", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=5.4", - "yiisoft/yii2": "~2.0.13" + "php": "8.1 - 8.5" }, "require-dev": { - "cweagans/composer-patches": "^1.7", - "phpunit/phpunit": "4.8.34", - "yiisoft/yii2-coding-standards": "~2.0", - "yiisoft/yii2-swiftmailer": "*" + "bamarni/composer-bin-plugin": "^1.8.2", + "maglnet/composer-require-checker": "^4.7.1", + "phpbench/phpbench": "^1.4.1", + "phpunit/phpunit": "^10.5.48", + "rector/rector": "^2.1.2", + "spatie/phpunit-watcher": "^1.24" }, - "type": "yii2-extension", + "type": "library", "extra": { - "patches": { - "phpunit/phpunit": { - "Fix PHP 7 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_php7.patch", - "Fix PHP 8 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_php8.patch", - "Fix PHP 8.1 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_php81.patch" - }, - "phpunit/phpunit-mock-objects": { - "Fix PHP 7 and 8 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_mock_objects.patch" - } - }, - "branch-alias": { - "dev-master": "2.0.x-dev" - }, - "composer-exit-on-patch-failure": true + "bamarni-bin": { + "bin-links": true, + "forward-command": true, + "target-directory": "tools" + } }, "autoload": { "psr-4": { - "yii\\debug\\": "src" + "Yiisoft\\Strings\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], - "authors": [ - { - "name": "Qiang Xue", - "email": "qiang.xue@gmail.com" - }, - { - "name": "Simon Karlen", - "email": "simi.albi@outlook.com" - } - ], - "description": "The debugger extension for the Yii framework", + "description": "Yii Strings Helper", + "homepage": "https://www.yiiframework.com/", "keywords": [ - "debug", - "debugger", - "dev", - "yii2" + "helper", + "string", + "yii" ], "support": { + "chat": "https://t.me/yii3en", "forum": "https://www.yiiframework.com/forum/", "irc": "ircs://irc.libera.chat:6697/yii", - "issues": "https://github.com/yiisoft/yii2-debug/issues", - "source": "https://github.com/yiisoft/yii2-debug", + "issues": "https://github.com/yiisoft/strings/issues?state=open", + "source": "https://github.com/yiisoft/strings", "wiki": "https://www.yiiframework.com/wiki/" }, "funding": [ { - "url": "https://github.com/yiisoft", + "url": "https://github.com/sponsors/yiisoft", "type": "github" }, { "url": "https://opencollective.com/yiisoft", - "type": "open_collective" - }, - { - "url": "https://tidelift.com/funding/github/packagist/yiisoft/yii2-debug", - "type": "tidelift" + "type": "opencollective" } ], - "time": "2025-06-08T13:32:11+00:00" + "time": "2025-11-23T18:00:58+00:00" }, { - "name": "yiisoft/yii2-queue", - "version": "2.3.8", + "name": "yiisoft/translator", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/yiisoft/yii2-queue.git", - "reference": "e0f935e5b868d53347acfb14ec19faaf16085005" + "url": "https://github.com/yiisoft/translator.git", + "reference": "62c64c9009a570597cc31dd42ff951d74049a60b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/yiisoft/yii2-queue/zipball/e0f935e5b868d53347acfb14ec19faaf16085005", - "reference": "e0f935e5b868d53347acfb14ec19faaf16085005", + "url": "https://api.github.com/repos/yiisoft/translator/zipball/62c64c9009a570597cc31dd42ff951d74049a60b", + "reference": "62c64c9009a570597cc31dd42ff951d74049a60b", "shasum": "" }, "require": { - "php": ">=5.5.0", - "symfony/process": "^3.3||^4.0||^5.0||^6.0||^7.0", - "yiisoft/yii2": "~2.0.14" + "php": "8.0 - 8.5", + "psr/event-dispatcher": "1.0.0", + "yiisoft/files": "^1.0 || ^2.0", + "yiisoft/i18n": "^1.0" }, "require-dev": { - "aws/aws-sdk-php": ">=2.4", - "cweagans/composer-patches": "^1.7", - "enqueue/amqp-lib": "^0.8||^0.9.10||^0.10.0", - "enqueue/stomp": "^0.8.39||0.10.19", - "opis/closure": "*", - "pda/pheanstalk": "~3.2.1", - "php-amqplib/php-amqplib": "^2.8.0||^3.0.0", - "phpunit/phpunit": "4.8.34", - "yiisoft/yii2-debug": "~2.1.0", - "yiisoft/yii2-gii": "~2.2.0", - "yiisoft/yii2-redis": "2.0.19" + "bamarni/composer-bin-plugin": "^1.8.3", + "maglnet/composer-require-checker": "^4.4", + "phpunit/phpunit": "^9.6.29", + "rector/rector": "^2.1.7", + "spatie/phpunit-watcher": "^1.23.6", + "yiisoft/di": "^1.2.1" }, "suggest": { - "aws/aws-sdk-php": "Need for aws SQS.", - "enqueue/amqp-lib": "Need for AMQP interop queue.", - "enqueue/stomp": "Need for Stomp queue.", - "ext-gearman": "Need for Gearman queue.", - "ext-pcntl": "Need for process signals.", - "pda/pheanstalk": "Need for Beanstalk queue.", - "php-amqplib/php-amqplib": "Need for AMQP queue.", - "yiisoft/yii2-redis": "Need for Redis queue." + "ext-intl": "Allows using intl message formatter", + "ext-tokenizer": "Allows using message extraction", + "yiisoft/event-dispatcher": "To listen for events about missing categories and messages" }, - "type": "yii2-extension", + "type": "library", "extra": { - "patches": { - "phpunit/phpunit": { - "Fix PHP 7 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_php7.patch", - "Fix PHP 8 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_php8.patch" - }, - "phpunit/phpunit-mock-objects": { - "Fix PHP 7 and 8 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_mock_objects.patch" - } + "bamarni-bin": { + "bin-links": true, + "forward-command": true, + "target-directory": "tools" }, - "branch-alias": { - "dev-master": "2.x-dev" + "config-plugin": { + "di": "di.php", + "params": "params.php" }, - "composer-exit-on-patch-failure": true + "config-plugin-options": { + "source-directory": "config" + } }, "autoload": { "psr-4": { - "yii\\queue\\": "src", - "yii\\queue\\db\\": "src/drivers/db", - "yii\\queue\\sqs\\": "src/drivers/sqs", - "yii\\queue\\amqp\\": "src/drivers/amqp", - "yii\\queue\\file\\": "src/drivers/file", - "yii\\queue\\sync\\": "src/drivers/sync", - "yii\\queue\\redis\\": "src/drivers/redis", - "yii\\queue\\stomp\\": "src/drivers/stomp", - "yii\\queue\\gearman\\": "src/drivers/gearman", - "yii\\queue\\beanstalk\\": "src/drivers/beanstalk", - "yii\\queue\\amqp_interop\\": "src/drivers/amqp_interop" + "Yiisoft\\Translator\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], - "authors": [ - { - "name": "Roman Zhuravlev", - "email": "zhuravljov@gmail.com" - } - ], - "description": "Yii2 Queue Extension which supports queues based on DB, Redis, RabbitMQ, Beanstalk, SQS, and Gearman", + "description": "Yii Message Translator", + "homepage": "https://www.yiiframework.com/", "keywords": [ - "async", - "beanstalk", - "db", - "gearman", - "gii", - "queue", - "rabbitmq", - "redis", - "sqs", - "yii" + "i18n", + "internationalization", + "translation" ], "support": { - "docs": "https://github.com/yiisoft/yii2-queue/blob/master/docs/guide", - "issues": "https://github.com/yiisoft/yii2-queue/issues", - "source": "https://github.com/yiisoft/yii2-queue" + "chat": "https://t.me/yii3en", + "forum": "https://www.yiiframework.com/forum/", + "irc": "ircs://irc.libera.chat:6697/yii", + "issues": "https://github.com/yiisoft/translator/issues?state=open", + "source": "https://github.com/yiisoft/translator", + "wiki": "https://www.yiiframework.com/wiki/" }, "funding": [ { - "url": "https://github.com/yiisoft", + "url": "https://github.com/sponsors/yiisoft", "type": "github" }, { "url": "https://opencollective.com/yiisoft", - "type": "open_collective" - }, - { - "url": "https://tidelift.com/funding/github/packagist/yiisoft/yii2-queue", - "type": "tidelift" + "type": "opencollective" } ], - "time": "2026-01-08T07:52:05+00:00" + "time": "2025-12-06T05:18:44+00:00" }, { - "name": "yiisoft/yii2-symfonymailer", - "version": "4.0.0", + "name": "yiisoft/translator-message-php", + "version": "1.1.2", "source": { "type": "git", - "url": "https://github.com/yiisoft/yii2-symfonymailer.git", - "reference": "21f407239c51fc6d50d369e4469d006afa8c9b2c" + "url": "https://github.com/yiisoft/translator-message-php.git", + "reference": "ef597d4df3d991ba4d7e9feb98d818870e8c747a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/yiisoft/yii2-symfonymailer/zipball/21f407239c51fc6d50d369e4469d006afa8c9b2c", - "reference": "21f407239c51fc6d50d369e4469d006afa8c9b2c", + "url": "https://api.github.com/repos/yiisoft/translator-message-php/zipball/ef597d4df3d991ba4d7e9feb98d818870e8c747a", + "reference": "ef597d4df3d991ba4d7e9feb98d818870e8c747a", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/event-dispatcher": "1.0.0", - "symfony/mailer": "^6.4 || ^7.0", - "symfony/mime": "^6.4 || ^7.0", - "yiisoft/yii2": ">=2.0.4" + "php": "8.0 - 8.5", + "yiisoft/translator": "1.0 - 3" }, "require-dev": { - "maglnet/composer-require-checker": "^4.7", - "phpunit/phpunit": "^10.5", - "roave/infection-static-analysis-plugin": "^1.34", - "symplify/easy-coding-standard": "^12.1", - "vimeo/psalm": "^5.20" - }, - "suggest": { - "yiisoft/yii2-psr-log-source": "Allows routing transport logs to your Yii2 logger" + "bamarni/composer-bin-plugin": "^1.8.3", + "maglnet/composer-require-checker": "^4.4", + "phpunit/phpunit": "^9.6.22", + "rector/rector": "^2.0.10", + "spatie/phpunit-watcher": "^1.23.6" }, - "type": "yii2-extension", + "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.0.x-dev" - }, - "sort-packages": true + "bamarni-bin": { + "bin-links": true, + "forward-command": true, + "target-directory": "tools" + } }, "autoload": { "psr-4": { - "yii\\symfonymailer\\": "src" + "Yiisoft\\Translator\\Message\\Php\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], - "authors": [ - { - "name": "Kirill Petrov", - "email": "archibeardrinker@gmail.com" - } - ], - "description": "The SymfonyMailer integration for the Yii framework", + "description": "Yii Translator PHP Message Storage", + "homepage": "https://www.yiiframework.com/", "keywords": [ - "email", - "mail", - "mailer", - "symfony", - "symfonymailer", - "yii2" + "formatting", + "i18n", + "internationalization", + "message storage" ], "support": { - "forum": "http://www.yiiframework.com/forum/", - "irc": "irc://irc.freenode.net/yii", - "issues": "https://github.com/yiisoft/yii2-symfonymailer/issues", - "source": "https://github.com/yiisoft/yii2-symfonymailer", - "wiki": "http://www.yiiframework.com/wiki/" + "chat": "https://t.me/yii3en", + "forum": "https://www.yiiframework.com/forum/", + "irc": "ircs://irc.libera.chat:6697/yii", + "issues": "https://github.com/yiisoft/translator-message-php/issues?state=open", + "source": "https://github.com/yiisoft/translator-message-php", + "wiki": "https://www.yiiframework.com/wiki/" }, "funding": [ { - "url": "https://github.com/yiisoft", + "url": "https://github.com/sponsors/yiisoft", "type": "github" }, { "url": "https://opencollective.com/yiisoft", - "type": "open_collective" - }, - { - "url": "https://tidelift.com/funding/github/packagist/yiisoft/yii2-symfonymailer", - "type": "tidelift" + "type": "opencollective" } ], - "time": "2024-01-29T14:13:45+00:00" + "time": "2025-12-06T14:38:12+00:00" } ], "packages-dev": [ { - "name": "behat/gherkin", - "version": "v4.17.0", + "name": "brianium/paratest", + "version": "v7.20.0", "source": { "type": "git", - "url": "https://github.com/Behat/Gherkin.git", - "reference": "5c8b3149fac39b5a79942b64eeec59a5ee4001c0" + "url": "https://github.com/paratestphp/paratest.git", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Behat/Gherkin/zipball/5c8b3149fac39b5a79942b64eeec59a5ee4001c0", - "reference": "5c8b3149fac39b5a79942b64eeec59a5ee4001c0", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { - "composer-runtime-api": "^2.2", - "php": ">=8.1 <8.6" - }, - "require-dev": { - "cucumber/gherkin-monorepo": "dev-gherkin-v39.1.0", - "friendsofphp/php-cs-fixer": "^3.77", - "mikey179/vfsstream": "^1.6", - "phpstan/extension-installer": "^1", - "phpstan/phpstan": "^2", - "phpstan/phpstan-phpunit": "^2", - "phpunit/phpunit": "^10.5", - "symfony/yaml": "^5.4 || ^6.4 || ^7.0" - }, - "suggest": { - "symfony/yaml": "If you want to parse features, represented in YAML files" + "ext-dom": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-simplexml": "*", + "fidry/cpu-core-counter": "^1.3.0", + "jean85/pretty-package-versions": "^2.1.1", + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", + "phpunit/php-file-iterator": "^6.0.1 || ^7", + "phpunit/php-timer": "^8 || ^9", + "phpunit/phpunit": "^12.5.14 || ^13.0.5", + "sebastian/environment": "^8.0.3 || ^9", + "symfony/console": "^7.4.7 || ^8.0.7", + "symfony/process": "^7.4.5 || ^8.0.5" + }, + "require-dev": { + "doctrine/coding-standard": "^14.0.0", + "ext-pcntl": "*", + "ext-pcov": "*", + "ext-posix": "*", + "phpstan/phpstan": "^2.1.44", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "symfony/filesystem": "^7.4.6 || ^8.0.6" }, + "bin": [ + "bin/paratest", + "bin/paratest_for_phpstorm" + ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.x-dev" - } - }, "autoload": { "psr-4": { - "Behat\\Gherkin\\": "src/" + "ParaTest\\": [ + "src/" + ] } }, "notification-url": "https://packagist.org/downloads/", @@ -8464,135 +10811,76 @@ ], "authors": [ { - "name": "Konstantin Kudryashov", - "email": "ever.zet@gmail.com", - "homepage": "https://everzet.com" + "name": "Brian Scaturro", + "email": "scaturrob@gmail.com", + "role": "Developer" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com", + "role": "Developer" } ], - "description": "Gherkin DSL parser for PHP", - "homepage": "https://behat.org/", + "description": "Parallel testing for PHP", + "homepage": "https://github.com/paratestphp/paratest", "keywords": [ - "BDD", - "Behat", - "Cucumber", - "DSL", - "gherkin", - "parser" + "concurrent", + "parallel", + "phpunit", + "testing" ], "support": { - "issues": "https://github.com/Behat/Gherkin/issues", - "source": "https://github.com/Behat/Gherkin/tree/v4.17.0" + "issues": "https://github.com/paratestphp/paratest/issues", + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { - "url": "https://github.com/acoulton", + "url": "https://github.com/sponsors/Slamdunk", "type": "github" }, { - "url": "https://github.com/carlos-granados", - "type": "github" - }, - { - "url": "https://github.com/stof", - "type": "github" + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" } ], - "time": "2026-05-18T09:33:47+00:00" + "time": "2026-03-29T15:46:14+00:00" }, { - "name": "codeception/codeception", - "version": "5.3.5", + "name": "cebe/markdown", + "version": "1.2.1", "source": { "type": "git", - "url": "https://github.com/Codeception/Codeception.git", - "reference": "83c2986ec2abe594cee2f706d9ec7aca2878fbe0" + "url": "https://github.com/cebe/markdown.git", + "reference": "9bac5e971dd391e2802dca5400bbeacbaea9eb86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/Codeception/zipball/83c2986ec2abe594cee2f706d9ec7aca2878fbe0", - "reference": "83c2986ec2abe594cee2f706d9ec7aca2878fbe0", + "url": "https://api.github.com/repos/cebe/markdown/zipball/9bac5e971dd391e2802dca5400bbeacbaea9eb86", + "reference": "9bac5e971dd391e2802dca5400bbeacbaea9eb86", "shasum": "" }, "require": { - "behat/gherkin": "^4.12", - "codeception/lib-asserts": "^2.2 | ^3.0.1", - "codeception/stub": "^4.1", - "ext-curl": "*", - "ext-json": "*", - "ext-mbstring": "*", - "php": "^8.2", - "phpunit/php-code-coverage": "^9.2 | ^10.0 | ^11.0 | ^12.0 | ^13.0", - "phpunit/php-text-template": "^2.0 | ^3.0 | ^4.0 | ^5.0 | ^6.0", - "phpunit/php-timer": "^5.0.3 | ^6.0 | ^7.0 | ^8.0 | ^9.0", - "phpunit/phpunit": "^9.5.20 | ^10.0 | ^11.0 | ^12.0 | ^13.0", - "psy/psysh": "^0.11.2 | ^0.12", - "sebastian/comparator": "^4.0.5 | ^5.0 | ^6.0 | ^7.0 | ^8.0", - "sebastian/diff": "^4.0.3 | ^5.0 | ^6.0 | ^7.0 | ^8.0", - "symfony/console": ">=5.4.24 <9.0", - "symfony/css-selector": ">=5.4.24 <9.0", - "symfony/event-dispatcher": ">=5.4.24 <9.0", - "symfony/finder": ">=5.4.24 <9.0", - "symfony/var-dumper": ">=5.4.24 <9.0", - "symfony/yaml": ">=5.4.24 <9.0" - }, - "conflict": { - "codeception/lib-innerbrowser": "<3.1.3", - "codeception/module-filesystem": "<3.0", - "codeception/module-phpbrowser": "<2.5" - }, - "replace": { - "codeception/phpunit-wrapper": "*" - }, - "require-dev": { - "codeception/lib-innerbrowser": "*@dev", - "codeception/lib-web": "*@dev", - "codeception/module-asserts": "dev-master", - "codeception/module-cli": "*@dev", - "codeception/module-db": "*@dev", - "codeception/module-filesystem": "*@dev", - "codeception/module-phpbrowser": "*@dev", - "codeception/module-webdriver": "*@dev", - "codeception/util-universalframework": "*@dev", - "doctrine/orm": "^3.3", - "ext-simplexml": "*", - "jetbrains/phpstorm-attributes": "^1.0", - "laravel-zero/phar-updater": "^1.4", - "php-webdriver/webdriver": "^1.15", - "stecman/symfony-console-completion": "^0.14 || ^0.15", - "symfony/dotenv": ">=5.4.24 <9.0", - "symfony/error-handler": ">=5.4.24 <9.0", - "symfony/process": ">=5.4.24 <9.0", - "vlucas/phpdotenv": "^5.1" + "lib-pcre": "*", + "php": ">=5.4.0" }, - "suggest": { - "codeception/specify": "BDD-style code blocks", - "codeception/verify": "BDD-style assertions", - "ext-simplexml": "For loading params from XML files", - "stecman/symfony-console-completion": "For BASH autocompletion", - "symfony/dotenv": "For loading params from .env files", - "symfony/phpunit-bridge": "For phpunit-bridge support", - "vlucas/phpdotenv": "For loading params from .env files" + "require-dev": { + "cebe/indent": "*", + "facebook/xhprof": "*@dev", + "phpunit/phpunit": "4.1.*" }, "bin": [ - "codecept" + "bin/markdown" ], "type": "library", "extra": { "branch-alias": { - "dev-main": "5.3.x-dev" + "dev-master": "1.2.x-dev" } }, "autoload": { - "files": [ - "functions.php" - ], "psr-4": { - "Codeception\\": "src/Codeception", - "Codeception\\Extension\\": "ext" - }, - "classmap": [ - "src/PHPUnit/TestCase.php" - ] + "cebe\\markdown\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -8600,56 +10888,56 @@ ], "authors": [ { - "name": "Michael Bodnarchuk", - "email": "davert.ua@gmail.com", - "homepage": "https://codeception.com" + "name": "Carsten Brandt", + "email": "mail@cebe.cc", + "homepage": "http://cebe.cc/", + "role": "Creator" } ], - "description": "All-in-one PHP Testing Framework", - "homepage": "https://codeception.com/", + "description": "A super fast, highly extensible markdown parser for PHP", + "homepage": "https://github.com/cebe/markdown#readme", "keywords": [ - "BDD", - "TDD", - "acceptance testing", - "functional testing", - "unit testing" + "extensible", + "fast", + "gfm", + "markdown", + "markdown-extra" ], "support": { - "issues": "https://github.com/Codeception/Codeception/issues", - "source": "https://github.com/Codeception/Codeception/tree/5.3.5" + "issues": "https://github.com/cebe/markdown/issues", + "source": "https://github.com/cebe/markdown" }, - "funding": [ - { - "url": "https://opencollective.com/codeception", - "type": "open_collective" - } - ], - "time": "2026-02-18T06:18:00+00:00" + "time": "2018-03-26T11:24:36+00:00" }, { - "name": "codeception/lib-asserts", - "version": "3.2.0", + "name": "composer/xdebug-handler", + "version": "3.0.5", "source": { "type": "git", - "url": "https://github.com/Codeception/lib-asserts.git", - "reference": "f161e5d3a9e5ae573ca01cfb3b5601ff5303df03" + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/lib-asserts/zipball/f161e5d3a9e5ae573ca01cfb3b5601ff5303df03", - "reference": "f161e5d3a9e5ae573ca01cfb3b5601ff5303df03", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", "shasum": "" }, "require": { - "ext-dom": "*", - "php": "^8.2 || ^8.3 || ^8.4 || ^8.5", - "phpunit/phpunit": "^11.5 || ^12.0 || ^13.0" + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" }, "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -8657,163 +10945,245 @@ ], "authors": [ { - "name": "Michael Bodnarchuk", - "email": "davert@mail.ua", - "homepage": "http://codegyre.com" + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" }, { - "name": "Gintautas Miselis" + "url": "https://github.com/composer", + "type": "github" }, { - "name": "Gustavo Nieves", - "homepage": "https://medium.com/@ganieves" + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" } ], - "description": "Assertion methods used by Codeception core and Asserts module", - "homepage": "https://codeception.com/", - "keywords": [ - "codeception" - ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "craftcms/ecs", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/craftcms/ecs.git", + "reference": "3823f989668e12a85ba681f8c7f3fd8488e23066" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/craftcms/ecs/zipball/3823f989668e12a85ba681f8c7f3fd8488e23066", + "reference": "3823f989668e12a85ba681f8c7f3fd8488e23066", + "shasum": "" + }, + "require": { + "php": "^7.2.5|^8.0.2", + "symplify/easy-coding-standard": "^10.3.3" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "craft\\ecs\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "description": "Easy Coding Standard configurations for Craft CMS projects", "support": { - "issues": "https://github.com/Codeception/lib-asserts/issues", - "source": "https://github.com/Codeception/lib-asserts/tree/3.2.0" + "issues": "https://github.com/craftcms/ecs/issues", + "source": "https://github.com/craftcms/ecs/tree/main" }, - "time": "2026-02-06T15:19:32+00:00" + "time": "2024-08-07T21:54:45+00:00" }, { - "name": "codeception/lib-innerbrowser", - "version": "4.1.1", + "name": "craftcms/yii2-adapter", + "version": "6.x-dev", "source": { "type": "git", - "url": "https://github.com/Codeception/lib-innerbrowser.git", - "reference": "0fa80deaed7da6a92a0cd4117338394c69196ec6" + "url": "https://github.com/craftcms/yii2-adapter.git", + "reference": "b7348d09f8e0b23e81bfdd1339fc731a2ee5d0fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/lib-innerbrowser/zipball/0fa80deaed7da6a92a0cd4117338394c69196ec6", - "reference": "0fa80deaed7da6a92a0cd4117338394c69196ec6", + "url": "https://api.github.com/repos/craftcms/yii2-adapter/zipball/b7348d09f8e0b23e81bfdd1339fc731a2ee5d0fe", + "reference": "b7348d09f8e0b23e81bfdd1339fc731a2ee5d0fe", "shasum": "" }, "require": { - "codeception/codeception": "^5.0.8", - "codeception/lib-web": "^1.0.1 || ^2", + "craftcms/cms": "self.version", + "creocoder/yii2-nested-sets": "~0.9.0", + "ext-bcmath": "*", + "ext-curl": "*", "ext-dom": "*", + "ext-intl": "*", "ext-json": "*", "ext-mbstring": "*", - "php": "^8.1", - "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0 || ^13.0", - "symfony/browser-kit": "^4.4.24 || ^5.4 || ^6.0 || ^7.0 || ^8.0", - "symfony/dom-crawler": "^4.4.30 || ^5.4 || ^6.0 || ^7.0 || ^8.0" + "ext-openssl": "*", + "ext-pcre": "*", + "ext-pdo": "*", + "ext-zip": "*", + "mikehaertl/php-shellcommand": "^1.6.3", + "php": "^8.5", + "samdark/yii2-psr-log-target": "^1.1.3", + "seld/cli-prompt": "^1.0.4", + "thamtech/yii2-ratelimiter-advanced": "^0.5.0", + "voku/portable-ascii": "^2.0", + "yiisoft/yii2": "~2.0.55.0", + "yiisoft/yii2-debug": "~2.1.27.0", + "yiisoft/yii2-queue": "~2.3.2", + "yiisoft/yii2-symfonymailer": "^4.0.0" + }, + "provide": { + "bower-asset/inputmask": "5.0.9", + "bower-asset/jquery": "3.6.1", + "bower-asset/punycode": "^2.2", + "bower-asset/yii2-pjax": "~2.0.1", + "yii2tech/ar-softdelete": "1.0.4" }, "require-dev": { - "codeception/util-universalframework": "^1.0 || ^2.0" + "codeception/codeception": "^5.2.0", + "codeception/lib-innerbrowser": "4.0.6", + "codeception/module-asserts": "^3.0.0", + "codeception/module-datafactory": "^3.0.0", + "codeception/module-phpbrowser": "^3.0.0", + "codeception/module-rest": "^3.3.2", + "codeception/module-yii2": "^1.1.9", + "craftcms/ecs": "dev-main", + "dg/bypass-finals": "^1.9", + "larastan/larastan": "^3.7", + "laravel/socialite": "^5.25", + "league/factory-muffin": "^3.3.0", + "orchestra/testbench": "^11.0", + "pestphp/pest": "^4.0", + "phpstan/phpstan": "^2.1", + "rector/rector": "^2.0", + "vlucas/phpdotenv": "^5.4.1", + "yiisoft/yii2-redis": "^2.0" }, + "default-branch": true, "type": "library", + "extra": { + "laravel": { + "providers": [ + "CraftCms\\Yii2Adapter\\Yii2ServiceProvider" + ] + } + }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "src/Helpers/Queries.php" + ], + "psr-4": { + "craft\\": "legacy/", + "CraftCms\\Cms\\": "constants/", + "CraftCms\\Yii2Adapter\\": "src/", + "yii2tech\\ar\\softdelete\\": "lib/ar-softdelete/src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "proprietary" ], "authors": [ { - "name": "Michael Bodnarchuk", - "email": "davert@mail.ua", - "homepage": "https://codegyre.com" - }, - { - "name": "Gintautas Miselis" + "name": "Pixel & Tonic", + "homepage": "https://pixelandtonic.com/" } ], - "description": "Parent library for all Codeception framework modules and PhpBrowser", - "homepage": "https://codeception.com/", + "description": "Craft CMS Yii2 adapter", + "homepage": "https://craftcms.com", "keywords": [ - "codeception" + "cms", + "craftcms", + "yii2" ], "support": { - "issues": "https://github.com/Codeception/lib-innerbrowser/issues", - "source": "https://github.com/Codeception/lib-innerbrowser/tree/4.1.1" + "docs": "https://craftcms.com/docs/5.x/", + "email": "support@craftcms.com", + "forum": "https://craftcms.stackexchange.com/", + "issues": "https://github.com/craftcms/cms/issues?state=open", + "rss": "https://github.com/craftcms/cms/releases.atom", + "source": "https://github.com/craftcms/cms" }, - "time": "2026-06-26T22:06:27+00:00" + "time": "2026-08-17T18:31:58+00:00" }, { - "name": "codeception/lib-web", - "version": "2.1.0", + "name": "creocoder/yii2-nested-sets", + "version": "0.9.0", "source": { "type": "git", - "url": "https://github.com/Codeception/lib-web.git", - "reference": "a030a3a22fc8e856b5957086794ed5403c7992d9" + "url": "https://github.com/creocoder/yii2-nested-sets.git", + "reference": "cb8635a459b6246e5a144f096b992dcc30cf9954" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/lib-web/zipball/a030a3a22fc8e856b5957086794ed5403c7992d9", - "reference": "a030a3a22fc8e856b5957086794ed5403c7992d9", + "url": "https://api.github.com/repos/creocoder/yii2-nested-sets/zipball/cb8635a459b6246e5a144f096b992dcc30cf9954", + "reference": "cb8635a459b6246e5a144f096b992dcc30cf9954", "shasum": "" }, "require": { - "ext-mbstring": "*", - "guzzlehttp/psr7": "^2.0", - "php": "^8.2", - "phpunit/phpunit": "^11.5 | ^12 | ^13", - "symfony/css-selector": ">=4.4.24 <9.0" - }, - "conflict": { - "codeception/codeception": "<5.0.0-alpha3" - }, - "require-dev": { - "php-webdriver/webdriver": "^1.12" + "yiisoft/yii2": "*" }, - "type": "library", + "type": "yii2-extension", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "creocoder\\nestedsets\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Gintautas Miselis" + "name": "Alexander Kochetov", + "email": "creocoder@gmail.com" } ], - "description": "Library containing files used by module-webdriver and lib-innerbrowser or module-phpbrowser", - "homepage": "https://codeception.com/", + "description": "The nested sets behavior for the Yii framework", "keywords": [ - "codeception" + "nested sets", + "yii2" ], "support": { - "issues": "https://github.com/Codeception/lib-web/issues", - "source": "https://github.com/Codeception/lib-web/tree/2.1.0" + "issues": "https://github.com/creocoder/yii2-nested-sets/issues", + "source": "https://github.com/creocoder/yii2-nested-sets/tree/master" }, - "time": "2026-02-06T15:22:13+00:00" + "time": "2015-01-27T10:53:51+00:00" }, { - "name": "codeception/lib-xml", - "version": "1.1.1", + "name": "dg/bypass-finals", + "version": "v1.10.1", "source": { "type": "git", - "url": "https://github.com/Codeception/lib-xml.git", - "reference": "758a525ed766ad641cc66cd619d96dbb9e887be2" + "url": "https://github.com/dg/bypass-finals.git", + "reference": "62d4ea18f8937af7d794b3358c125ecb52862e98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/lib-xml/zipball/758a525ed766ad641cc66cd619d96dbb9e887be2", - "reference": "758a525ed766ad641cc66cd619d96dbb9e887be2", + "url": "https://api.github.com/repos/dg/bypass-finals/zipball/62d4ea18f8937af7d794b3358c125ecb52862e98", + "reference": "62d4ea18f8937af7d794b3358c125ecb52862e98", "shasum": "" }, "require": { - "codeception/lib-web": "^1.0.6 || ^2", - "ext-dom": "*", - "php": "^8.2", - "symfony/css-selector": ">=4.4.24 <9.0" + "php": ">=7.1" }, - "conflict": { - "codeception/codeception": "<5.0.0-alpha3" + "require-dev": { + "nette/tester": "^2.3", + "phpstan/phpstan": "^0.12" }, "type": "library", "autoload": { @@ -8823,106 +11193,132 @@ }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" ], "authors": [ { - "name": "Gintautas Miselis" + "name": "David Grudl", + "homepage": "https://davidgrudl.com" } ], - "description": "Files used by module-rest and module-soap", - "homepage": "https://codeception.com/", + "description": "Removes final keyword from source code on-the-fly and allows mocking of final methods and classes", "keywords": [ - "codeception" + "finals", + "mocking", + "phpunit", + "testing", + "unit" ], "support": { - "issues": "https://github.com/Codeception/lib-xml/issues", - "source": "https://github.com/Codeception/lib-xml/tree/1.1.1" + "issues": "https://github.com/dg/bypass-finals/issues", + "source": "https://github.com/dg/bypass-finals/tree/v1.10.1" }, - "time": "2025-11-28T08:21:33+00:00" + "time": "2026-06-04T15:48:46+00:00" }, { - "name": "codeception/module-asserts", - "version": "3.3.0", + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", "source": { "type": "git", - "url": "https://github.com/Codeception/module-asserts.git", - "reference": "3b4ec5dc771a135e13c79f7e9a6eacd74779e4ad" + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/module-asserts/zipball/3b4ec5dc771a135e13c79f7e9a6eacd74779e4ad", - "reference": "3b4ec5dc771a135e13c79f7e9a6eacd74779e4ad", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", "shasum": "" }, "require": { - "codeception/codeception": "*@dev", - "codeception/lib-asserts": "^3.1", - "php": "^8.2" + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, - "conflict": { - "codeception/codeception": "<5.0" + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" }, "type": "library", "autoload": { - "classmap": [ - "src/" + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "LGPL-2.1-or-later" ], "authors": [ { - "name": "Michael Bodnarchuk" - }, - { - "name": "Gintautas Miselis" - }, - { - "name": "Gustavo Nieves", - "homepage": "https://medium.com/@ganieves" + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" } ], - "description": "Codeception module containing various assertions", - "homepage": "https://codeception.com/", + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", "keywords": [ - "assertions", - "asserts", - "codeception" + "html" ], "support": { - "issues": "https://github.com/Codeception/module-asserts/issues", - "source": "https://github.com/Codeception/module-asserts/tree/3.3.0" + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" }, - "time": "2025-12-23T21:16:13+00:00" + "time": "2025-10-17T16:34:55+00:00" }, { - "name": "codeception/module-datafactory", - "version": "3.0.0", + "name": "fakerphp/faker", + "version": "v1.24.1", "source": { "type": "git", - "url": "https://github.com/Codeception/module-datafactory.git", - "reference": "90b87b554cc8e254865f5e9dbb86d7ce112c51ab" + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/module-datafactory/zipball/90b87b554cc8e254865f5e9dbb86d7ce112c51ab", - "reference": "90b87b554cc8e254865f5e9dbb86d7ce112c51ab", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", "shasum": "" }, "require": { - "codeception/codeception": "^5.0.0-RC6", - "league/factory-muffin": "^3.3", - "league/factory-muffin-faker": "^2.3", - "php": "^8.0" + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." }, "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Faker\\": "src/Faker/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -8930,61 +11326,54 @@ ], "authors": [ { - "name": "Michael Bodnarchuk" + "name": "François Zaninotto" } ], - "description": "DataFactory module for Codeception", - "homepage": "https://codeception.com/", + "description": "Faker is a PHP library that generates fake data for you.", "keywords": [ - "codeception" + "data", + "faker", + "fixtures" ], "support": { - "issues": "https://github.com/Codeception/module-datafactory/issues", - "source": "https://github.com/Codeception/module-datafactory/tree/3.0.0" + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" }, - "time": "2022-07-18T16:38:21+00:00" + "time": "2024-11-21T13:46:39+00:00" }, { - "name": "codeception/module-phpbrowser", - "version": "3.0.2", + "name": "fidry/cpu-core-counter", + "version": "1.3.0", "source": { "type": "git", - "url": "https://github.com/Codeception/module-phpbrowser.git", - "reference": "460e392c77370f7836012b16e06071eb1607876a" + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/module-phpbrowser/zipball/460e392c77370f7836012b16e06071eb1607876a", - "reference": "460e392c77370f7836012b16e06071eb1607876a", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", "shasum": "" }, "require": { - "codeception/codeception": "*@dev", - "codeception/lib-innerbrowser": "*@dev", - "ext-json": "*", - "guzzlehttp/guzzle": "^7.4", - "php": "^8.1", - "symfony/browser-kit": "^5.4 | ^6.0 | ^7.0" - }, - "conflict": { - "codeception/codeception": "<5.0", - "codeception/lib-innerbrowser": "<3.0" + "php": "^7.2 || ^8.0" }, "require-dev": { - "aws/aws-sdk-php": "^3.199", - "codeception/module-rest": "^2.0 | *@dev", - "ext-curl": "*", - "phpstan/phpstan": "^1.10", - "squizlabs/php_codesniffer": "^3.10" - }, - "suggest": { - "codeception/phpbuiltinserver": "Start and stop PHP built-in web server for your tests" + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" }, "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -8992,63 +11381,64 @@ ], "authors": [ { - "name": "Michael Bodnarchuk" - }, - { - "name": "Gintautas Miselis" + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" } ], - "description": "Codeception module for testing web application over HTTP", - "homepage": "https://codeception.com/", + "description": "Tiny utility to get the number of CPU cores.", "keywords": [ - "codeception", - "functional-testing", - "http" + "CPU", + "core" ], "support": { - "issues": "https://github.com/Codeception/module-phpbrowser/issues", - "source": "https://github.com/Codeception/module-phpbrowser/tree/3.0.2" + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" }, - "time": "2025-09-04T10:45:58+00:00" + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" }, { - "name": "codeception/module-rest", - "version": "3.4.3", + "name": "filp/whoops", + "version": "2.18.4", "source": { "type": "git", - "url": "https://github.com/Codeception/module-rest.git", - "reference": "596817fcb5a603f6f55306f67f9eb84943df8998" + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/module-rest/zipball/596817fcb5a603f6f55306f67f9eb84943df8998", - "reference": "596817fcb5a603f6f55306f67f9eb84943df8998", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", "shasum": "" }, "require": { - "codeception/codeception": "^5.0.8", - "codeception/lib-xml": "^1.0", - "ext-dom": "*", - "ext-json": "*", - "justinrainbow/json-schema": "^5.2.9 || ^6", - "php": "^8.2", - "softcreatr/jsonpath": "^0.8 || ^0.9 || ^0.10 || ^0.11 || ^1.0" + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" }, "require-dev": { - "codeception/lib-innerbrowser": "^3.0 | ^4.0", - "codeception/stub": "^4.0", - "codeception/util-universalframework": "^2.0", - "ext-libxml": "*", - "ext-simplexml": "*" + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" }, "suggest": { - "aws/aws-sdk-php": "For using AWS Auth" + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Whoops\\": "src/Whoops/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -9056,157 +11446,160 @@ ], "authors": [ { - "name": "Gintautas Miselis" + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" } ], - "description": "REST module for Codeception", - "homepage": "https://codeception.com/", + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", "keywords": [ - "codeception", - "rest" + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" ], "support": { - "issues": "https://github.com/Codeception/module-rest/issues", - "source": "https://github.com/Codeception/module-rest/tree/3.4.3" + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" }, - "time": "2025-12-22T14:13:56+00:00" + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" }, { - "name": "codeception/module-yii2", - "version": "1.1.12", + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", "source": { "type": "git", - "url": "https://github.com/Codeception/module-yii2.git", - "reference": "1ebe6bc2a7f307a6c246026a905612a40ef64859" + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/module-yii2/zipball/1ebe6bc2a7f307a6c246026a905612a40ef64859", - "reference": "1ebe6bc2a7f307a6c246026a905612a40ef64859", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", "shasum": "" }, "require": { - "codeception/codeception": "^5.0.8", - "codeception/lib-innerbrowser": "^3.0 | ^4.0", - "php": "^8.0" + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" }, "require-dev": { - "codeception/module-asserts": ">= 3.0", - "codeception/module-filesystem": "> 3.0", - "codeception/verify": "^3.0", - "codemix/yii2-localeurls": "^1.7", - "phpstan/phpstan": "^1.10", - "yiisoft/yii2": "dev-master", - "yiisoft/yii2-app-advanced": "dev-master" + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, "autoload": { "classmap": [ - "src/" + "hamcrest" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" - ], - "authors": [ - { - "name": "Alexander Makarov" - }, - { - "name": "Sam Mouse" - }, - { - "name": "Michael Bodnarchuk" - } + "BSD-3-Clause" ], - "description": "Codeception module for Yii2 framework", - "homepage": "https://codeception.com/", + "description": "This is the PHP port of Hamcrest Matchers", "keywords": [ - "codeception", - "yii2" + "test" ], "support": { - "issues": "https://github.com/Codeception/module-yii2/issues", - "source": "https://github.com/Codeception/module-yii2/tree/1.1.12" + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" }, - "time": "2024-12-09T14:34:26+00:00" + "time": "2025-04-30T06:54:44+00:00" }, { - "name": "codeception/stub", - "version": "4.3.0", + "name": "iamcal/sql-parser", + "version": "v0.7", "source": { "type": "git", - "url": "https://github.com/Codeception/Stub.git", - "reference": "6305b97eaf6ea9bdaed29a5bd4d6f2948f577d8f" + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/Stub/zipball/6305b97eaf6ea9bdaed29a5bd4d6f2948f577d8f", - "reference": "6305b97eaf6ea9bdaed29a5bd4d6f2948f577d8f", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", "shasum": "" }, - "require": { - "php": "^8.1", - "phpunit/phpunit": "^8.4 | ^9.0 | ^10.0 | ^11 | ^12 | ^13" - }, - "conflict": { - "codeception/codeception": "<5.0.6" - }, "require-dev": { - "consolidation/robo": "^4.0" + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" }, "type": "library", "autoload": { "psr-4": { - "Codeception\\": "src/" + "iamcal\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Flexible Stub wrapper for PHPUnit's Mock Builder", + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", "support": { - "issues": "https://github.com/Codeception/Stub/issues", - "source": "https://github.com/Codeception/Stub/tree/4.3.0" + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" }, - "time": "2026-02-06T15:19:04+00:00" + "time": "2026-01-28T22:20:33+00:00" }, { - "name": "composer/ca-bundle", - "version": "1.5.12", + "name": "jean85/pretty-package-versions", + "version": "2.1.1", "source": { "type": "git", - "url": "https://github.com/composer/ca-bundle.git", - "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78" + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", - "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", "shasum": "" }, "require": { - "ext-openssl": "*", - "ext-pcre": "*", - "php": "^7.2 || ^8.0" + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" }, "require-dev": { - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8 || ^9", - "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0" + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "1.x-dev" + "dev-master": "1.x-dev" } }, "autoload": { "psr-4": { - "Composer\\CaBundle\\": "src" + "Jean85\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -9215,164 +11608,237 @@ ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" } ], - "description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.", + "description": "A library to get pretty versions strings of installed dependencies", "keywords": [ - "cabundle", - "cacert", - "certificate", - "ssl", - "tls" + "composer", + "package", + "release", + "versions" ], "support": { - "irc": "irc://irc.freenode.org/composer", - "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.12" + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - } - ], - "time": "2026-05-19T11:26:22+00:00" + "time": "2025-03-19T14:43:43+00:00" }, { - "name": "craftcms/ckeditor", - "version": "4.11.4", + "name": "larastan/larastan", + "version": "v3.10.0", "source": { "type": "git", - "url": "https://github.com/craftcms/ckeditor.git", - "reference": "f33fa49e868c3a26d799d0b2c7c410119599d9a2" + "url": "https://github.com/larastan/larastan.git", + "reference": "2970f83398154178a739609c244577267c7ee8eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/ckeditor/zipball/f33fa49e868c3a26d799d0b2c7c410119599d9a2", - "reference": "f33fa49e868c3a26d799d0b2c7c410119599d9a2", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb", + "reference": "2970f83398154178a739609c244577267c7ee8eb", "shasum": "" }, "require": { - "craftcms/cms": "^5.6.1", - "craftcms/html-field": "^3.4.0", - "embed/embed": "^4.4", - "nystudio107/craft-code-editor": ">=1.0.8 <=1.0.13 || ^1.0.16", - "php": "^8.2" + "ext-json": "*", + "iamcal/sql-parser": "^0.7.0", + "illuminate/console": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/container": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/database": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/http": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", + "php": "^8.2", + "phpstan/phpstan": "^2.2.0" }, "require-dev": { - "craftcms/ecs": "dev-main", - "craftcms/phpstan": "dev-main", - "craftcms/rector": "dev-main", - "vlucas/phpdotenv": "^5.5" + "doctrine/coding-standard": "^14", + "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", + "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.1.8" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" }, - "type": "craft-plugin", + "type": "phpstan-extension", "extra": { - "name": "CKEditor", - "handle": "ckeditor", - "documentationUrl": "https://github.com/craftcms/ckeditor/blob/master/README.md" + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } }, "autoload": { "psr-4": { - "craft\\ckeditor\\": "src/" + "Larastan\\Larastan\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "GPL-3.0-or-later" + "MIT" ], "authors": [ { - "name": "Pixel & Tonic", - "homepage": "https://pixelandtonic.com/" + "name": "Can Vural", + "email": "can9119@gmail.com" } ], - "description": "Edit rich text content in Craft CMS using CKEditor.", + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", "keywords": [ - "CKEditor", - "cms", - "craftcms", - "html", - "yii2" + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" ], "support": { - "docs": "https://github.com/craftcms/ckeditor/blob/master/README.md", - "email": "support@craftcms.com", - "issues": "https://github.com/craftcms/ckeditor/issues?state=open", - "rss": "https://github.com/craftcms/ckeditor/commits/master.atom", - "source": "https://github.com/craftcms/ckeditor" + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.10.0" }, - "time": "2026-03-30T19:13:59+00:00" + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2026-05-28T08:00:58+00:00" }, { - "name": "craftcms/ecs", - "version": "dev-main", + "name": "laravel/pail", + "version": "v1.2.7", "source": { "type": "git", - "url": "https://github.com/craftcms/ecs.git", - "reference": "3823f989668e12a85ba681f8c7f3fd8488e23066" + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/ecs/zipball/3823f989668e12a85ba681f8c7f3fd8488e23066", - "reference": "3823f989668e12a85ba681f8c7f3fd8488e23066", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", "shasum": "" }, "require": { - "php": "^7.2.5|^8.0.2", - "symplify/easy-coding-standard": "^10.3.3" + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" }, - "default-branch": true, "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, "autoload": { "psr-4": { - "craft\\ecs\\": "src" + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" } - }, - "notification-url": "https://packagist.org/downloads/", - "description": "Easy Coding Standard configurations for Craft CMS projects", + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], "support": { - "issues": "https://github.com/craftcms/ecs/issues", - "source": "https://github.com/craftcms/ecs/tree/main" + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" }, - "time": "2024-08-07T21:54:45+00:00" + "time": "2026-05-20T22:24:57+00:00" }, { - "name": "craftcms/html-field", - "version": "3.5.1", + "name": "laravel/tinker", + "version": "v3.0.2", "source": { "type": "git", - "url": "https://github.com/craftcms/html-field.git", - "reference": "b4e1ae3f020d6081cfe3abf653f663da744c4cec" + "url": "https://github.com/laravel/tinker.git", + "reference": "4faba77764bd33411735936acdf30446d058c78b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/html-field/zipball/b4e1ae3f020d6081cfe3abf653f663da744c4cec", - "reference": "b4e1ae3f020d6081cfe3abf653f663da744c4cec", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", "shasum": "" }, "require": { - "craftcms/cms": "^5.5.0", - "league/html-to-markdown": "^5.1", - "php": "^8.2", - "symfony/css-selector": "^6.0|^7.0", - "symfony/dom-crawler": "^6.0|^7.0" + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" }, "require-dev": { - "craftcms/ecs": "dev-main", - "craftcms/phpstan": "dev-main", - "craftcms/rector": "dev-main" + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5|^11.5" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." }, "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.x-dev" + } + }, "autoload": { "psr-4": { - "craft\\htmlfield\\": "src/" + "Laravel\\Tinker\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -9381,328 +11847,397 @@ ], "authors": [ { - "name": "Pixel & Tonic", - "homepage": "https://pixelandtonic.com/" + "name": "Taylor Otwell", + "email": "taylor@laravel.com" } ], - "description": "Base class for Craft CMS field types with HTML values.", + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], "support": { - "docs": "https://github.com/craftcms/html-field/blob/main/README.md", - "email": "support@craftcms.com", - "issues": "https://github.com/craftcms/html-field/issues?state=open", - "rss": "https://github.com/craftcms/html-field/commits/main.atom", - "source": "https://github.com/craftcms/html-field" + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v3.0.2" }, - "time": "2026-03-19T18:11:31+00:00" + "time": "2026-03-17T14:54:13+00:00" }, { - "name": "craftcms/phpstan", - "version": "dev-main", + "name": "league/factory-muffin", + "version": "v3.3.0", "source": { "type": "git", - "url": "https://github.com/craftcms/phpstan.git", - "reference": "b61bba102b5ec8599406e6e29a28a20c915a6abc" + "url": "https://github.com/thephpleague/factory-muffin.git", + "reference": "62c8c31d47667523da14e83df36cc897d34173cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/phpstan/zipball/b61bba102b5ec8599406e6e29a28a20c915a6abc", - "reference": "b61bba102b5ec8599406e6e29a28a20c915a6abc", + "url": "https://api.github.com/repos/thephpleague/factory-muffin/zipball/62c8c31d47667523da14e83df36cc897d34173cd", + "reference": "62c8c31d47667523da14e83df36cc897d34173cd", "shasum": "" }, "require": { - "phpstan/phpstan": "^1.4.6" + "php": ">=5.4.0" + }, + "replace": { + "zizaco/factory-muff": "self.version" + }, + "require-dev": { + "doctrine/orm": "^2.5", + "illuminate/database": "5.0.* || 5.1.* || 5.5.* || ^6.0", + "league/factory-muffin-faker": "^2.3", + "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20" + }, + "suggest": { + "doctrine/orm": "Factory Muffin supports doctrine through the repository store.", + "illuminate/database": "Factory Muffin supports eloquent through the model store.", + "league/factory-muffin-faker": "Factory Muffin is very powerful together with faker." }, - "default-branch": true, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.3-dev" + } + }, + "autoload": { + "psr-4": { + "League\\FactoryMuffin\\": "src/" + } + }, "notification-url": "https://packagist.org/downloads/", - "description": "PHPStan configuration for Craft CMS projects", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "graham@alt-three.com" + }, + { + "name": "Scott Robertson", + "email": "scottymeuk@gmail.com" + } + ], + "description": "The goal of this package is to enable the rapid creation of objects for the purpose of testing.", + "homepage": "http://factory-muffin.thephpleague.com/", + "keywords": [ + "factory", + "testing" + ], "support": { - "issues": "https://github.com/craftcms/phpstan/issues", - "source": "https://github.com/craftcms/phpstan/tree/main" + "issues": "https://github.com/thephpleague/factory-muffin/issues", + "source": "https://github.com/thephpleague/factory-muffin/tree/v3.3.0" }, - "time": "2022-04-12T20:50:18+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/factory-muffin", + "type": "tidelift" + } + ], + "time": "2020-12-13T18:38:47+00:00" }, { - "name": "craftcms/rector", - "version": "dev-main", + "name": "mikehaertl/php-shellcommand", + "version": "1.7.0", "source": { "type": "git", - "url": "https://github.com/craftcms/rector.git", - "reference": "fab0a0ed308aa6cf59968a526f5db635103e5de1" + "url": "https://github.com/mikehaertl/php-shellcommand.git", + "reference": "e79ea528be155ffdec6f3bf1a4a46307bb49e545" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/rector/zipball/fab0a0ed308aa6cf59968a526f5db635103e5de1", - "reference": "fab0a0ed308aa6cf59968a526f5db635103e5de1", + "url": "https://api.github.com/repos/mikehaertl/php-shellcommand/zipball/e79ea528be155ffdec6f3bf1a4a46307bb49e545", + "reference": "e79ea528be155ffdec6f3bf1a4a46307bb49e545", "shasum": "" }, "require": { - "rector/rector": "^1.0.0" + "php": ">= 5.3.0" }, "require-dev": { - "craftcms/cms": "^4.0.0|^5.0.0", - "craftcms/ecs": "dev-main", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.0", - "symfony/var-exporter": "^6.0" + "phpunit/phpunit": ">4.0 <=9.4" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { - "craft\\rector\\": "src" + "mikehaertl\\shellcommand\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", - "description": "Rector sets to automate Craft CMS upgrades", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Härtl", + "email": "haertl.mike@gmail.com" + } + ], + "description": "An object oriented interface to shell commands", + "keywords": [ + "shell" + ], "support": { - "issues": "https://github.com/craftcms/rector/issues", - "source": "https://github.com/craftcms/rector/tree/main" + "issues": "https://github.com/mikehaertl/php-shellcommand/issues", + "source": "https://github.com/mikehaertl/php-shellcommand/tree/1.7.0" }, - "time": "2024-05-17T09:09:56+00:00" + "time": "2023-04-19T08:25:22+00:00" }, { - "name": "craftcms/redactor", - "version": "4.2.0", + "name": "mockery/mockery", + "version": "1.6.12", "source": { "type": "git", - "url": "https://github.com/craftcms/redactor.git", - "reference": "47bc7bc40312b7d02ef5bf4fd36bd2fd62f8043c" + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/redactor/zipball/47bc7bc40312b7d02ef5bf4fd36bd2fd62f8043c", - "reference": "47bc7bc40312b7d02ef5bf4fd36bd2fd62f8043c", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", "shasum": "" }, "require": { - "craftcms/cms": "^5.3.0", - "craftcms/html-field": "^3.0.0", - "php": "^8.2" + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" }, - "require-dev": { - "craftcms/ecs": "dev-main", - "craftcms/phpstan": "dev-main", - "craftcms/rector": "dev-main" + "conflict": { + "phpunit/phpunit": "<8.0" }, - "type": "craft-plugin", - "extra": { - "name": "Redactor", - "handle": "redactor", - "documentationUrl": "https://github.com/craftcms/redactor/blob/v2/README.md" + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" }, + "type": "library", "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], "psr-4": { - "craft\\redactor\\": "src/" + "Mockery\\": "library/Mockery" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Pixel & Tonic", - "homepage": "https://pixelandtonic.com/" + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" } ], - "description": "Edit rich text content in Craft CMS using Redactor by Imperavi.", + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", "keywords": [ - "Redactor", - "cms", - "craftcms", - "html", - "yii2" + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" ], "support": { - "docs": "https://github.com/craftcms/redactor/blob/v2/README.md", - "email": "support@craftcms.com", - "issues": "https://github.com/craftcms/redactor/issues?state=open", - "rss": "https://github.com/craftcms/redactor/commits/v2.atom", - "source": "https://github.com/craftcms/redactor" + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" }, - "abandoned": "craftcms/ckeditor", - "time": "2024-09-03T13:38:27+00:00" + "time": "2024-05-16T03:13:13+00:00" }, { - "name": "embed/embed", - "version": "v4.4.19", + "name": "myclabs/deep-copy", + "version": "1.14.0", "source": { "type": "git", - "url": "https://github.com/php-embed/Embed.git", - "reference": "c45a9007285524350499f3fa70e7e2f0967af470" + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-embed/Embed/zipball/c45a9007285524350499f3fa70e7e2f0967af470", - "reference": "c45a9007285524350499f3fa70e7e2f0967af470", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "composer/ca-bundle": "^1.0", - "ext-curl": "*", - "ext-dom": "*", - "ext-json": "*", - "ext-mbstring": "*", - "ml/json-ld": "^1.1", - "oscarotero/html-parser": "^0.1.4", - "php": "^7.4|^8", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.0|^2.0" + "php": "^8.0" }, - "require-dev": { - "brick/varexporter": "^0.3.1", - "friendsofphp/php-cs-fixer": "^2.0", - "nyholm/psr7": "^1.2", - "oscarotero/php-cs-fixer-config": "^1.0", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.0", - "symfony/css-selector": "^5.0" + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" }, - "suggest": { - "symfony/css-selector": "If you want to get elements using css selectors" + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, "type": "library", "autoload": { "files": [ - "src/functions.php" + "src/DeepCopy/deep_copy.php" ], "psr-4": { - "Embed\\": "src" + "DeepCopy\\": "src/DeepCopy/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Oscar Otero", - "email": "oom@oscarotero.com", - "homepage": "http://oscarotero.com", - "role": "Developer" - } - ], - "description": "PHP library to retrieve page info using oembed, opengraph, etc", - "homepage": "https://github.com/oscarotero/Embed", + "description": "Create deep copies (clones) of your objects", "keywords": [ - "embed", - "embedly", - "oembed", - "opengraph", - "twitter cards" + "clone", + "copy", + "duplicate", + "object", + "object graph" ], "support": { - "email": "oom@oscarotero.com", - "issues": "https://github.com/oscarotero/Embed/issues", - "source": "https://github.com/php-embed/Embed/tree/v4.4.19" + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://paypal.me/oscarotero", - "type": "custom" - }, - { - "url": "https://github.com/oscarotero", + "url": "https://github.com/mnapoli", "type": "github" - }, - { - "url": "https://www.patreon.com/misteroom", - "type": "patreon" } ], - "time": "2026-07-08T19:24:10+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { - "name": "fakerphp/faker", - "version": "v1.24.1", + "name": "nikic/php-parser", + "version": "v5.8.0", "source": { "type": "git", - "url": "https://github.com/FakerPHP/Faker.git", - "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", - "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0", - "psr/container": "^1.0 || ^2.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" - }, - "conflict": { - "fzaninotto/faker": "*" + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.4.1", - "doctrine/persistence": "^1.3 || ^2.0", - "ext-intl": "*", - "phpunit/phpunit": "^9.5.26", - "symfony/phpunit-bridge": "^5.4.16" - }, - "suggest": { - "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", - "ext-curl": "Required by Faker\\Provider\\Image to download images.", - "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", - "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", - "ext-mbstring": "Required for multibyte Unicode string functionality." + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" }, + "bin": [ + "bin/php-parse" + ], "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, "autoload": { "psr-4": { - "Faker\\": "src/Faker/" + "PhpParser\\": "lib/PhpParser" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "François Zaninotto" + "name": "Nikita Popov" } ], - "description": "Faker is a PHP library that generates fake data for you.", + "description": "A PHP parser written in PHP", "keywords": [ - "data", - "faker", - "fixtures" + "parser", + "php" ], "support": { - "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2024-11-21T13:46:39+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { - "name": "graham-campbell/result-type", - "version": "v1.1.4", + "name": "nunomaduro/collision", + "version": "v8.9.4", "source": { "type": "git", - "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + "url": "https://github.com/nunomaduro/collision.git", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5" + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.8 || ^8.0.8" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" }, "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], "psr-4": { - "GrahamCampbell\\ResultType\\": "src/" + "NunoMaduro\\Collision\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -9711,74 +12246,92 @@ ], "authors": [ { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" } ], - "description": "An Implementation Of The Result Type", + "description": "Cli error handling for console/command-line PHP applications.", "keywords": [ - "Graham Campbell", - "GrahamCampbell", - "Result Type", - "Result-Type", - "result" + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" ], "support": { - "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" }, "funding": [ { - "url": "https://github.com/GrahamCampbell", + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", - "type": "tidelift" + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" } ], - "time": "2025-12-27T19:43:20+00:00" + "time": "2026-04-21T14:04:20+00:00" }, { - "name": "justinrainbow/json-schema", - "version": "6.10.0", + "name": "orchestra/canvas", + "version": "v11.0.1", "source": { "type": "git", - "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33" + "url": "https://github.com/orchestral/canvas.git", + "reference": "d240410f4cd89b380d7d89b5bbaf60c32f4fb691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", - "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "url": "https://api.github.com/repos/orchestral/canvas/zipball/d240410f4cd89b380d7d89b5bbaf60c32f4fb691", + "reference": "d240410f4cd89b380d7d89b5bbaf60c32f4fb691", "shasum": "" }, "require": { - "ext-json": "*", - "marc-mabe/php-enum": "^4.4", - "php": "^7.2 || ^8.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "dev-main", - "marc-mabe/php-enum-phpstan": "^2.0", - "phpspec/prophecy": "^1.19", - "phpstan/phpstan": "^1.12", - "phpunit/phpunit": "^8.5" + "composer-runtime-api": "^2.2", + "composer/semver": "^3.0", + "illuminate/console": "^13.0.0", + "illuminate/database": "^13.0.0", + "illuminate/filesystem": "^13.0.0", + "illuminate/support": "^13.0.0", + "orchestra/canvas-core": "^11.0.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "orchestra/testbench-core": "^11.0.0", + "php": "^8.3", + "symfony/yaml": "^7.4.0|^8.0.0" + }, + "require-dev": { + "laravel/framework": "^13.0.0", + "laravel/pint": "^1.24", + "mockery/mockery": "^1.6.10", + "phpstan/phpstan": "^2.1.14", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0" }, "bin": [ - "bin/validate-json" + "canvas" ], "type": "library", "extra": { - "branch-alias": { - "dev-master": "6.x-dev" + "laravel": { + "providers": [ + "Orchestra\\Canvas\\LaravelServiceProvider" + ] } }, "autoload": { "psr-4": { - "JsonSchema\\": "src/JsonSchema/" + "Orchestra\\Canvas\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -9787,74 +12340,63 @@ ], "authors": [ { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" + "name": "Taylor Otwell", + "email": "taylor@laravel.com" }, { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com" } ], - "description": "A library to validate a json schema.", - "homepage": "https://github.com/jsonrainbow/json-schema", - "keywords": [ - "json", - "schema" - ], + "description": "Code Generators for Laravel Applications and Packages", "support": { - "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.10.0" + "issues": "https://github.com/orchestral/canvas/issues", + "source": "https://github.com/orchestral/canvas/tree/v11.0.1" }, - "time": "2026-06-16T20:50:26+00:00" + "time": "2026-03-18T22:46:12+00:00" }, { - "name": "league/factory-muffin", - "version": "v3.3.0", + "name": "orchestra/canvas-core", + "version": "v11.0.0", "source": { "type": "git", - "url": "https://github.com/thephpleague/factory-muffin.git", - "reference": "62c8c31d47667523da14e83df36cc897d34173cd" + "url": "https://github.com/orchestral/canvas-core.git", + "reference": "88d091ff989748e2ca447bca0cd06ab14671ba82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/factory-muffin/zipball/62c8c31d47667523da14e83df36cc897d34173cd", - "reference": "62c8c31d47667523da14e83df36cc897d34173cd", + "url": "https://api.github.com/repos/orchestral/canvas-core/zipball/88d091ff989748e2ca447bca0cd06ab14671ba82", + "reference": "88d091ff989748e2ca447bca0cd06ab14671ba82", "shasum": "" }, "require": { - "php": ">=5.4.0" - }, - "replace": { - "zizaco/factory-muff": "self.version" + "composer-runtime-api": "^2.2", + "composer/semver": "^3.0", + "illuminate/console": "^13.0", + "illuminate/support": "^13.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "php": "^8.3" }, "require-dev": { - "doctrine/orm": "^2.5", - "illuminate/database": "5.0.* || 5.1.* || 5.5.* || ^6.0", - "league/factory-muffin-faker": "^2.3", - "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20" - }, - "suggest": { - "doctrine/orm": "Factory Muffin supports doctrine through the repository store.", - "illuminate/database": "Factory Muffin supports eloquent through the model store.", - "league/factory-muffin-faker": "Factory Muffin is very powerful together with faker." + "laravel/framework": "^13.0", + "laravel/pint": "^1.24", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^11.0", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.3-dev" + "laravel": { + "providers": [ + "Orchestra\\Canvas\\Core\\LaravelServiceProvider" + ] } }, "autoload": { "psr-4": { - "League\\FactoryMuffin\\": "src/" + "Orchestra\\Canvas\\Core\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -9863,66 +12405,61 @@ ], "authors": [ { - "name": "Graham Campbell", - "email": "graham@alt-three.com" + "name": "Taylor Otwell", + "email": "taylor@laravel.com" }, { - "name": "Scott Robertson", - "email": "scottymeuk@gmail.com" + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com" } ], - "description": "The goal of this package is to enable the rapid creation of objects for the purpose of testing.", - "homepage": "http://factory-muffin.thephpleague.com/", - "keywords": [ - "factory", - "testing" - ], + "description": "Code Generators Builder for Laravel Applications and Packages", "support": { - "issues": "https://github.com/thephpleague/factory-muffin/issues", - "source": "https://github.com/thephpleague/factory-muffin/tree/v3.3.0" + "issues": "https://github.com/orchestral/canvas/issues", + "source": "https://github.com/orchestral/canvas-core/tree/v11.0.0" }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/league/factory-muffin", - "type": "tidelift" - } - ], - "time": "2020-12-13T18:38:47+00:00" + "time": "2026-03-16T15:10:50+00:00" }, { - "name": "league/factory-muffin-faker", - "version": "v2.3.0", + "name": "orchestra/sidekick", + "version": "v1.2.20", "source": { "type": "git", - "url": "https://github.com/thephpleague/factory-muffin-faker.git", - "reference": "258068c840e8fdc45d1cb1636a0890e92f2e864a" + "url": "https://github.com/orchestral/sidekick.git", + "reference": "267a71b56cb2fe1a634d69fc99889c671b77ff43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/factory-muffin-faker/zipball/258068c840e8fdc45d1cb1636a0890e92f2e864a", - "reference": "258068c840e8fdc45d1cb1636a0890e92f2e864a", + "url": "https://api.github.com/repos/orchestral/sidekick/zipball/267a71b56cb2fe1a634d69fc99889c671b77ff43", + "reference": "267a71b56cb2fe1a634d69fc99889c671b77ff43", "shasum": "" }, "require": { - "fakerphp/faker": "^1.9.1", - "php": ">=5.4.0" + "composer-runtime-api": "^2.2", + "composer/semver": "^3.0", + "php": "^8.1", + "symfony/polyfill-php83": "^1.32" }, "require-dev": { - "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20" + "fakerphp/faker": "^1.21", + "laravel/framework": "^10.48.29|^11.44.7|^12.1.1|^13.0", + "laravel/pint": "^1.4", + "mockery/mockery": "^1.5.1", + "orchestra/testbench-core": "^8.37.0|^9.14.0|^10.2.0|^11.0", + "phpstan/phpstan": "^2.1.14", + "phpunit/phpunit": "^10.0|^11.0|^12.0", + "symfony/process": "^6.0|^7.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.3-dev" - } - }, "autoload": { + "files": [ + "src/Eloquent/functions.php", + "src/Filesystem/functions.php", + "src/Http/functions.php", + "src/functions.php" + ], "psr-4": { - "League\\FactoryMuffin\\Faker\\": "src/" + "Orchestra\\Sidekick\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -9931,72 +12468,136 @@ ], "authors": [ { - "name": "Graham Campbell", - "email": "graham@alt-three.com" + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com" } ], - "description": "The goal of this package is to wrap faker to make it super easy to use with factory muffin.", - "homepage": "http://factory-muffin.thephpleague.com/", - "keywords": [ - "factory", - "faker", - "testing" - ], + "description": "Packages Toolkit Utilities and Helpers for Laravel", "support": { - "issues": "https://github.com/thephpleague/factory-muffin-faker/issues", - "source": "https://github.com/thephpleague/factory-muffin-faker/tree/v2.3.0" + "issues": "https://github.com/orchestral/sidekick/issues", + "source": "https://github.com/orchestral/sidekick/tree/v1.2.20" }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, + "time": "2026-01-12T11:09:33+00:00" + }, + { + "name": "orchestra/testbench", + "version": "v11.1.0", + "source": { + "type": "git", + "url": "https://github.com/orchestral/testbench.git", + "reference": "997f33e5200c7e8db4756b35a9deb3f5f3086759" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orchestral/testbench/zipball/997f33e5200c7e8db4756b35a9deb3f5f3086759", + "reference": "997f33e5200c7e8db4756b35a9deb3f5f3086759", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "fakerphp/faker": "^1.23", + "laravel/framework": "^13.1.1", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^11.2.0", + "orchestra/workbench": "^11.0.1", + "php": "^8.3", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "symfony/process": "^7.4.5|^8.0.5", + "symfony/yaml": "^7.4|^8.0", + "vlucas/phpdotenv": "^5.6.1" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ { - "url": "https://tidelift.com/funding/github/packagist/league/factory-muffin-faker", - "type": "tidelift" + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com", + "homepage": "https://github.com/crynobone" } ], - "time": "2020-12-13T15:53:28+00:00" + "description": "Laravel Testing Helper for Packages Development", + "homepage": "https://packages.tools/testbench/", + "keywords": [ + "BDD", + "TDD", + "dev", + "laravel", + "laravel-packages", + "testing" + ], + "support": { + "issues": "https://github.com/orchestral/testbench/issues", + "source": "https://github.com/orchestral/testbench/tree/v11.1.0" + }, + "time": "2026-04-09T05:11:06+00:00" }, { - "name": "league/html-to-markdown", - "version": "5.1.1", + "name": "orchestra/testbench-core", + "version": "v11.3.4", "source": { "type": "git", - "url": "https://github.com/thephpleague/html-to-markdown.git", - "reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd" + "url": "https://github.com/orchestral/testbench-core.git", + "reference": "527fe9941b8bdec2914d2a19048b0c40c6c5d87c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/0b4066eede55c48f38bcee4fb8f0aa85654390fd", - "reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd", + "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/527fe9941b8bdec2914d2a19048b0c40c6c5d87c", + "reference": "527fe9941b8bdec2914d2a19048b0c40c6c5d87c", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-xml": "*", - "php": "^7.2.5 || ^8.0" + "composer-runtime-api": "^2.2", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "php": "^8.3", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-php84": "^1.34.0" }, - "require-dev": { - "mikehaertl/php-shellcommand": "^1.1.0", - "phpstan/phpstan": "^1.8.8", - "phpunit/phpunit": "^8.5 || ^9.2", - "scrutinizer/ocular": "^1.6", - "unleashedtech/php-coding-standard": "^2.7 || ^3.0", - "vimeo/psalm": "^4.22 || ^5.0" + "conflict": { + "brianium/paratest": "<7.3.0|>=8.0.0", + "laravel/framework": "<13.10.0|>=14.0.0", + "laravel/serializable-closure": ">=2.0.0 <2.0.10|>=3.0.0", + "nunomaduro/collision": "<8.9.0|>=9.0.0", + "phpunit/phpunit": "<11.5.50|>=12.0.0 <12.5.8|>=13.2.0" + }, + "require-dev": { + "fakerphp/faker": "^1.24", + "laravel/framework": "^13.10.0", + "laravel/pint": "^1.24", + "laravel/serializable-closure": "^2.0.10", + "mockery/mockery": "^1.6.10", + "phpstan/phpstan": "^2.1.38", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "spatie/laravel-ray": "^1.43.6", + "symfony/process": "^7.4.5|^8.0.5", + "symfony/yaml": "^7.4.0|^8.0.0", + "vlucas/phpdotenv": "^5.6.1" + }, + "suggest": { + "brianium/paratest": "Allow using parallel testing (^7.3).", + "ext-pcntl": "Required to use all features of the console signal trapping.", + "fakerphp/faker": "Allow using Faker for testing (^1.23).", + "laravel/framework": "Required for testing (^13.9.0).", + "mockery/mockery": "Allow using Mockery for testing (^1.6).", + "nunomaduro/collision": "Allow using Laravel style tests output and parallel testing (^8.9).", + "orchestra/testbench-dusk": "Allow using Laravel Dusk for testing (^11.0).", + "phpunit/phpunit": "Allow using PHPUnit for testing (^11.5.50|^12.5.8|^13.0.0).", + "symfony/process": "Required to use Orchestra\\Testbench\\remote function (^7.4|^8.0).", + "symfony/yaml": "Required for Testbench CLI (^7.4|^8.0).", + "vlucas/phpdotenv": "Required for Testbench CLI (^5.6.1)." }, "bin": [ - "bin/html-to-markdown" + "testbench" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.2-dev" - } - }, "autoload": { + "files": [ + "src/functions.php" + ], "psr-4": { - "League\\HTMLToMarkdown\\": "src/" + "Orchestra\\Testbench\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -10005,144 +12606,176 @@ ], "authors": [ { - "name": "Colin O'Dell", - "email": "colinodell@gmail.com", - "homepage": "https://www.colinodell.com", - "role": "Lead Developer" - }, - { - "name": "Nick Cernis", - "email": "nick@cern.is", - "homepage": "http://modernnerd.net", - "role": "Original Author" + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com", + "homepage": "https://github.com/crynobone" } ], - "description": "An HTML-to-markdown conversion helper for PHP", - "homepage": "https://github.com/thephpleague/html-to-markdown", + "description": "Testing Helper for Laravel Development", + "homepage": "https://packages.tools/testbench", "keywords": [ - "html", - "markdown" + "BDD", + "TDD", + "dev", + "laravel", + "laravel-packages", + "testing" ], "support": { - "issues": "https://github.com/thephpleague/html-to-markdown/issues", - "source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.1" + "issues": "https://github.com/orchestral/testbench/issues", + "source": "https://github.com/orchestral/testbench-core" }, - "funding": [ - { - "url": "https://www.colinodell.com/sponsor", - "type": "custom" - }, - { - "url": "https://www.paypal.me/colinpodell/10.00", - "type": "custom" - }, - { - "url": "https://github.com/colinodell", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown", - "type": "tidelift" - } - ], - "time": "2023-07-12T21:21:09+00:00" + "time": "2026-06-02T03:43:15+00:00" }, { - "name": "marc-mabe/php-enum", - "version": "v4.7.2", + "name": "orchestra/workbench", + "version": "v11.1.0", "source": { "type": "git", - "url": "https://github.com/marc-mabe/php-enum.git", - "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" + "url": "https://github.com/orchestral/workbench.git", + "reference": "e750c7bcae4405e054ff286475502e23274de04b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", - "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", + "url": "https://api.github.com/repos/orchestral/workbench/zipball/e750c7bcae4405e054ff286475502e23274de04b", + "reference": "e750c7bcae4405e054ff286475502e23274de04b", "shasum": "" }, "require": { - "ext-reflection": "*", - "php": "^7.1 | ^8.0" + "composer-runtime-api": "^2.2", + "fakerphp/faker": "^1.23", + "laravel/framework": "^13.0.0", + "laravel/pail": "^1.2.5", + "laravel/tinker": "^3.0.0", + "nunomaduro/collision": "^8.9", + "orchestra/canvas": "^11.0.1", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "orchestra/testbench-core": "^11.1.0", + "php": "^8.3", + "symfony/process": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "require-dev": { + "laravel/pint": "^1.22.0", + "mockery/mockery": "^1.6.12", + "phpstan/phpstan": "^2.1.33", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "spatie/laravel-ray": "^1.43.6" }, - "require-dev": { - "phpbench/phpbench": "^0.16.10 || ^1.0.4", - "phpstan/phpstan": "^1.3.1", - "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", - "vimeo/psalm": "^4.17.0 | ^5.26.1" + "suggest": { + "ext-pcntl": "Required to use all features of the console signal trapping." }, "type": "library", - "extra": { - "branch-alias": { - "dev-3.x": "3.2-dev", - "dev-master": "4.7-dev" - } - }, "autoload": { "psr-4": { - "MabeEnum\\": "src/" - }, - "classmap": [ - "stubs/Stringable.php" - ] + "Orchestra\\Workbench\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Marc Bennewitz", - "email": "dev@mabe.berlin", - "homepage": "https://mabe.berlin/", - "role": "Lead" + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com" } ], - "description": "Simple and fast implementation of enumerations with native PHP", - "homepage": "https://github.com/marc-mabe/php-enum", + "description": "Workbench Companion for Laravel Packages Development", "keywords": [ - "enum", - "enum-map", - "enum-set", - "enumeration", - "enumerator", - "enummap", - "enumset", - "map", - "set", - "type", - "type-hint", - "typehint" + "dev", + "laravel", + "laravel-packages", + "testing" ], "support": { - "issues": "https://github.com/marc-mabe/php-enum/issues", - "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" + "issues": "https://github.com/orchestral/workbench/issues", + "source": "https://github.com/orchestral/workbench/tree/v11.1.0" }, - "time": "2025-09-14T11:18:39+00:00" + "time": "2026-03-24T23:09:55+00:00" }, { - "name": "ml/iri", - "version": "1.1.4", - "target-dir": "ML/IRI", + "name": "pestphp/pest", + "version": "v4.7.2", "source": { "type": "git", - "url": "https://github.com/lanthaler/IRI.git", - "reference": "cbd44fa913e00ea624241b38cefaa99da8d71341" + "url": "https://github.com/pestphp/pest.git", + "reference": "40b88b62ef8a7c6fcae5fc28f1fa747f601c131b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lanthaler/IRI/zipball/cbd44fa913e00ea624241b38cefaa99da8d71341", - "reference": "cbd44fa913e00ea624241b38cefaa99da8d71341", + "url": "https://api.github.com/repos/pestphp/pest/zipball/40b88b62ef8a7c6fcae5fc28f1fa747f601c131b", + "reference": "40b88b62ef8a7c6fcae5fc28f1fa747f601c131b", "shasum": "" }, "require": { - "lib-pcre": ">=4.0", - "php": ">=5.3.0" + "brianium/paratest": "^7.20.0", + "composer/xdebug-handler": "^3.0.5", + "nunomaduro/collision": "^8.9.4", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest-plugin": "^4.0.0", + "pestphp/pest-plugin-arch": "^4.0.2", + "pestphp/pest-plugin-mutate": "^4.0.1", + "pestphp/pest-plugin-profanity": "^4.2.1", + "php": "^8.3.0", + "phpunit/phpunit": "^12.5.28", + "symfony/process": "^7.4.13|^8.1.0" + }, + "conflict": { + "filp/whoops": "<2.18.3", + "phpunit/phpunit": ">12.5.28", + "sebastian/exporter": "<7.0.0", + "webmozart/assert": "<1.11.0" + }, + "require-dev": { + "mrpunyapal/peststan": "^0.2.10", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-browser": "^4.3.1", + "pestphp/pest-plugin-type-coverage": "^4.0.4", + "psy/psysh": "^0.12.23" + }, + "bin": [ + "bin/pest" + ], + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Mutate\\Plugins\\Mutate", + "Pest\\Plugins\\Configuration", + "Pest\\Plugins\\Bail", + "Pest\\Plugins\\Cache", + "Pest\\Plugins\\Coverage", + "Pest\\Plugins\\Init", + "Pest\\Plugins\\Environment", + "Pest\\Plugins\\Help", + "Pest\\Plugins\\Memory", + "Pest\\Plugins\\Only", + "Pest\\Plugins\\Printer", + "Pest\\Plugins\\ProcessIsolation", + "Pest\\Plugins\\Profile", + "Pest\\Plugins\\Retry", + "Pest\\Plugins\\Snapshot", + "Pest\\Plugins\\Verbose", + "Pest\\Plugins\\Version", + "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Tia", + "Pest\\Plugins\\Parallel" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } }, - "type": "library", "autoload": { - "psr-0": { - "ML\\IRI": "" + "files": [ + "src/Functions.php", + "src/Pest.php" + ], + "psr-4": { + "Pest\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -10151,226 +12784,278 @@ ], "authors": [ { - "name": "Markus Lanthaler", - "email": "mail@markus-lanthaler.com", - "homepage": "http://www.markus-lanthaler.com", - "role": "Developer" + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" } ], - "description": "IRI handling for PHP", - "homepage": "http://www.markus-lanthaler.com", + "description": "The elegant PHP Testing Framework.", "keywords": [ - "URN", - "iri", - "uri", - "url" + "framework", + "pest", + "php", + "test", + "testing", + "unit" ], "support": { - "issues": "https://github.com/lanthaler/IRI/issues", - "source": "https://github.com/lanthaler/IRI/tree/master" + "issues": "https://github.com/pestphp/pest/issues", + "source": "https://github.com/pestphp/pest/tree/v4.7.2" }, - "time": "2014-01-21T13:43:39+00:00" + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-06-01T06:08:59+00:00" }, { - "name": "ml/json-ld", - "version": "1.2.1", + "name": "pestphp/pest-plugin", + "version": "v4.0.0", "source": { "type": "git", - "url": "https://github.com/lanthaler/JsonLD.git", - "reference": "537e68e87a6bce23e57c575cd5dcac1f67ce25d8" + "url": "https://github.com/pestphp/pest-plugin.git", + "reference": "9d4b93d7f73d3f9c3189bb22c220fef271cdf568" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lanthaler/JsonLD/zipball/537e68e87a6bce23e57c575cd5dcac1f67ce25d8", - "reference": "537e68e87a6bce23e57c575cd5dcac1f67ce25d8", + "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/9d4b93d7f73d3f9c3189bb22c220fef271cdf568", + "reference": "9d4b93d7f73d3f9c3189bb22c220fef271cdf568", "shasum": "" }, "require": { - "ext-json": "*", - "ml/iri": "^1.1.1", - "php": ">=5.3.0" + "composer-plugin-api": "^2.0.0", + "composer-runtime-api": "^2.2.2", + "php": "^8.3" + }, + "conflict": { + "pestphp/pest": "<4.0.0" }, "require-dev": { - "json-ld/tests": "1.0", - "phpunit/phpunit": "^4" + "composer/composer": "^2.8.10", + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Pest\\Plugin\\Manager" }, - "type": "library", "autoload": { "psr-4": { - "ML\\JsonLD\\": "" + "Pest\\Plugin\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Markus Lanthaler", - "email": "mail@markus-lanthaler.com", - "homepage": "http://www.markus-lanthaler.com", - "role": "Developer" - } - ], - "description": "JSON-LD Processor for PHP", - "homepage": "http://www.markus-lanthaler.com", + "description": "The Pest plugin manager", "keywords": [ - "JSON-LD", - "jsonld" + "framework", + "manager", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" ], "support": { - "issues": "https://github.com/lanthaler/JsonLD/issues", - "source": "https://github.com/lanthaler/JsonLD/tree/1.2.1" + "source": "https://github.com/pestphp/pest-plugin/tree/v4.0.0" }, - "time": "2022-09-29T08:45:17+00:00" + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2025-08-20T12:35:58+00:00" }, { - "name": "myclabs/deep-copy", - "version": "1.13.4", + "name": "pestphp/pest-plugin-arch", + "version": "v4.0.2", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "url": "https://github.com/pestphp/pest-plugin-arch.git", + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "ta-tikoma/phpunit-architecture-test": "^0.8.7" }, "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + "pestphp/pest": "^4.4.6", + "pestphp/pest-dev-tools": "^4.1.0" }, "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Arch\\Plugin" + ] + } + }, "autoload": { "files": [ - "src/DeepCopy/deep_copy.php" + "src/Autoload.php" ], "psr-4": { - "DeepCopy\\": "src/DeepCopy/" + "Pest\\Arch\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Create deep copies (clones) of your objects", + "description": "The Arch plugin for Pest PHP.", "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" + "arch", + "architecture", + "framework", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" ], "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.2" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-04-10T17:20:19+00:00" }, { - "name": "nikic/php-parser", - "version": "v5.8.0", + "name": "pestphp/pest-plugin-laravel", + "version": "v4.1.0", "source": { "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + "url": "https://github.com/pestphp/pest-plugin-laravel.git", + "reference": "3057a36669ff11416cc0dc2b521b3aec58c488d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/3057a36669ff11416cc0dc2b521b3aec58c488d0", + "reference": "3057a36669ff11416cc0dc2b521b3aec58c488d0", "shasum": "" }, "require": { - "ext-json": "*", - "ext-tokenizer": "*", - "php": ">=7.4" + "laravel/framework": "^11.45.2|^12.52.0|^13.0", + "pestphp/pest": "^4.4.1", + "php": "^8.3.0" }, "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^9.0" + "laravel/dusk": "^8.3.6", + "orchestra/testbench": "^9.13.0|^10.9.0|^11.0", + "pestphp/pest-dev-tools": "^4.1.0" }, - "bin": [ - "bin/php-parse" - ], "type": "library", "extra": { - "branch-alias": { - "dev-master": "5.x-dev" + "pest": { + "plugins": [ + "Pest\\Laravel\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Pest\\Laravel\\PestServiceProvider" + ] } }, "autoload": { + "files": [ + "src/Autoload.php" + ], "psr-4": { - "PhpParser\\": "lib/PhpParser" + "Pest\\Laravel\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Nikita Popov" - } + "MIT" ], - "description": "A PHP parser written in PHP", + "description": "The Pest Laravel Plugin", "keywords": [ - "parser", - "php" + "framework", + "laravel", + "pest", + "php", + "test", + "testing", + "unit" ], "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v4.1.0" }, - "time": "2026-07-04T14:30:18+00:00" + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-02-21T00:29:45+00:00" }, { - "name": "nystudio107/craft-code-editor", - "version": "1.0.29", + "name": "pestphp/pest-plugin-mutate", + "version": "v4.0.1", "source": { "type": "git", - "url": "https://github.com/nystudio107/craft-code-editor.git", - "reference": "5b071512ee2ad2b8004f979f88ff3bf722bbcb4d" + "url": "https://github.com/pestphp/pest-plugin-mutate.git", + "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nystudio107/craft-code-editor/zipball/5b071512ee2ad2b8004f979f88ff3bf722bbcb4d", - "reference": "5b071512ee2ad2b8004f979f88ff3bf722bbcb4d", + "url": "https://api.github.com/repos/pestphp/pest-plugin-mutate/zipball/d9b32b60b2385e1688a68cc227594738ec26d96c", + "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c", "shasum": "" }, "require": { - "craftcms/cms": "^3.0.0 || ^4.0.0 || ^5.0.0", - "phpdocumentor/reflection-docblock": "^5.0.0" + "nikic/php-parser": "^5.6.1", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "psr/simple-cache": "^3.0.0" }, "require-dev": { - "craftcms/ecs": "dev-main", - "craftcms/phpstan": "dev-main", - "craftcms/rector": "dev-main" - }, - "type": "yii2-extension", - "extra": { - "bootstrap": "nystudio107\\codeeditor\\CodeEditor" + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.0" }, + "type": "library", "autoload": { "psr-4": { - "nystudio107\\codeeditor\\": "src/" + "Pest\\Mutate\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -10379,88 +13064,100 @@ ], "authors": [ { - "name": "nystudio107", - "homepage": "https://nystudio107.com" + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + }, + { + "name": "Sandro Gehri", + "email": "sandrogehri@gmail.com" } ], - "description": "Provides a code editor field with Twig & Craft API autocomplete", + "description": "Mutates your code to find untested cases", "keywords": [ - "Craft", - "Monaco", - "cms", - "code", - "craftcms", - "css", - "editor", - "javascript", - "markdown", - "twig" + "framework", + "mutate", + "mutation", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" ], "support": { - "docs": "https://github.com/nystudio107/craft-code-editor/blob/v1/README.md", - "issues": "https://github.com/nystudio107/craft-code-editor/issues", - "source": "https://github.com/nystudio107/craft-code-editor" + "source": "https://github.com/pestphp/pest-plugin-mutate/tree/v4.0.1" }, "funding": [ { - "url": "https://github.com/khalwat", + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/gehrisandro", + "type": "github" + }, + { + "url": "https://github.com/nunomaduro", "type": "github" } ], - "time": "2026-01-30T19:10:22+00:00" + "time": "2025-08-21T20:19:25+00:00" }, { - "name": "oscarotero/html-parser", - "version": "v0.1.8", + "name": "pestphp/pest-plugin-profanity", + "version": "v4.2.1", "source": { "type": "git", - "url": "https://github.com/oscarotero/html-parser.git", - "reference": "10f3219267a365d9433f2f7d1694209c9d436c8d" + "url": "https://github.com/pestphp/pest-plugin-profanity.git", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/oscarotero/html-parser/zipball/10f3219267a365d9433f2f7d1694209c9d436c8d", - "reference": "10f3219267a365d9433f2f7d1694209c9d436c8d", + "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27", "shasum": "" }, "require": { - "php": "^7.2 || ^8" + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.11", - "phpunit/phpunit": "^8.0" + "faissaloux/pest-plugin-inside": "^1.9", + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" }, "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Profanity\\Plugin" + ] + } + }, "autoload": { "psr-4": { - "HtmlParser\\": "src" + "Pest\\Profanity\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Oscar Otero", - "email": "oom@oscarotero.com", - "homepage": "http://oscarotero.com", - "role": "Developer" - } - ], - "description": "Parse html strings to DOMDocument", - "homepage": "https://github.com/oscarotero/html-parser", + "description": "The Pest Profanity Plugin", "keywords": [ - "dom", - "html", - "parser" + "framework", + "pest", + "php", + "plugin", + "profanity", + "test", + "testing", + "unit" ], "support": { - "email": "oom@oscarotero.com", - "issues": "https://github.com/oscarotero/html-parser/issues", - "source": "https://github.com/oscarotero/html-parser/tree/v0.1.8" + "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.1" }, - "time": "2023-11-29T20:28:41+00:00" + "time": "2025-12-08T00:13:17+00:00" }, { "name": "phar-io/manifest", @@ -10580,92 +13277,17 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "phpoption/phpoption", - "version": "1.9.5", - "source": { - "type": "git", - "url": "https://github.com/schmittjoh/php-option.git", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", - "shasum": "" - }, - "require": { - "php": "^7.2.5 || ^8.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - }, - "branch-alias": { - "dev-master": "1.9-dev" - } - }, - "autoload": { - "psr-4": { - "PhpOption\\": "src/PhpOption/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Johannes M. Schmitt", - "email": "schmittjoh@gmail.com", - "homepage": "https://github.com/schmittjoh" - }, - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - } - ], - "description": "Option Type for PHP", - "keywords": [ - "language", - "option", - "php", - "type" - ], - "support": { - "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", - "type": "tidelift" - } - ], - "time": "2025-12-27T19:41:33+00:00" - }, { "name": "phpstan/phpstan", - "version": "1.12.33", + "version": "2.2.8", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", - "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e285254e60f33c21902efef4a926ca0987c06804", + "reference": "e285254e60f33c21902efef4a926ca0987c06804", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^7.4|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -10684,6 +13306,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -10706,20 +13339,20 @@ "type": "github" } ], - "time": "2026-02-28T20:30:03+00:00" + "time": "2026-08-04T22:21:45+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "11.0.12", + "version": "12.5.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + "reference": "186dab580576598076de6818596d12b61801880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", - "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", "shasum": "" }, "require": { @@ -10727,18 +13360,16 @@ "ext-libxml": "*", "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", - "php": ">=8.2", - "phpunit/php-file-iterator": "^5.1.0", - "phpunit/php-text-template": "^4.0.1", - "sebastian/code-unit-reverse-lookup": "^4.0.1", - "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.1", - "sebastian/lines-of-code": "^3.0.1", - "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.3.1" + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^11.5.46" + "phpunit/phpunit": "^12.5.28" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -10747,7 +13378,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "11.0.x-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { @@ -10776,7 +13407,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" }, "funding": [ { @@ -10796,32 +13427,32 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:01:01+00:00" + "time": "2026-06-01T13:24:19+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "5.1.1", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", - "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -10849,7 +13480,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" }, "funding": [ { @@ -10869,28 +13500,28 @@ "type": "tidelift" } ], - "time": "2026-02-02T13:52:54+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", - "version": "5.0.1", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-pcntl": "*" @@ -10898,7 +13529,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -10925,7 +13556,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" }, "funding": [ { @@ -10933,32 +13564,32 @@ "type": "github" } ], - "time": "2024-07-03T05:07:44+00:00" + "time": "2025-02-07T04:58:58+00:00" }, { "name": "phpunit/php-text-template", - "version": "4.0.1", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -10985,7 +13616,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" }, "funding": [ { @@ -10993,32 +13624,32 @@ "type": "github" } ], - "time": "2024-07-03T05:08:43+00:00" + "time": "2025-02-07T04:59:16+00:00" }, { "name": "phpunit/php-timer", - "version": "7.0.1", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -11045,7 +13676,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" }, "funding": [ { @@ -11053,67 +13684,326 @@ "type": "github" } ], - "time": "2024-07-03T05:09:35+00:00" + "time": "2025-02-07T04:59:38+00:00" }, { "name": "phpunit/phpunit", - "version": "11.5.56", + "version": "12.5.28", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" + "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", - "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5895d05f5bf421ed230fbd76e1277e4b8955def4", + "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4", "shasum": "" }, "require": { "ext-dom": "*", - "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", + "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.12", - "phpunit/php-file-iterator": "^5.1.1", - "phpunit/php-invoker": "^5.0.1", - "phpunit/php-text-template": "^4.0.1", - "phpunit/php-timer": "^7.0.1", - "sebastian/cli-parser": "^3.0.2", - "sebastian/code-unit": "^3.0.3", - "sebastian/comparator": "^6.3.3", - "sebastian/diff": "^6.0.2", - "sebastian/environment": "^7.2.1", - "sebastian/exporter": "^6.3.2", - "sebastian/global-state": "^7.0.2", - "sebastian/object-enumerator": "^6.0.1", - "sebastian/recursion-context": "^6.0.3", - "sebastian/type": "^5.1.3", - "sebastian/version": "^5.0.2", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.28" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-05-27T14:01:10+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.24", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files" + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." }, "bin": [ - "phpunit" + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + }, + "time": "2026-06-29T15:41:09+00:00" + }, + { + "name": "rector/rector", + "version": "2.6.3", + "source": { + "type": "git", + "url": "https://github.com/rectorphp/rector.git", + "reference": "7e46709996a4b3dc59e1d6ecbb6a38ace335bd58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/7e46709996a4b3dc59e1d6ecbb6a38ace335bd58", + "reference": "7e46709996a4b3dc59e1d6ecbb6a38ace335bd58", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "phpstan/phpstan": "^2.2.6" + }, + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" + }, + "suggest": { + "ext-dom": "To manipulate phpunit.xml via the custom-rule command" + }, + "bin": [ + "bin/rector" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "homepage": "https://getrector.com/", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" + ], + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/2.6.3" + }, + "funding": [ + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2026-08-18T22:01:18+00:00" + }, + { + "name": "samdark/yii2-psr-log-target", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/samdark/yii2-psr-log-target.git", + "reference": "5f14f21d5ee4294fe9eb3e723ec8a3908ca082ea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/samdark/yii2-psr-log-target/zipball/5f14f21d5ee4294fe9eb3e723ec8a3908ca082ea", + "reference": "5f14f21d5ee4294fe9eb3e723ec8a3908ca082ea", + "shasum": "" + }, + "require": { + "psr/log": "~1.0.2|~1.1.0|~3.0.0", + "yiisoft/yii2": "~2.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.4|~10.4.2" + }, + "type": "yii2-extension", + "autoload": { + "psr-4": { + "samdark\\log\\": "src", + "samdark\\log\\tests\\": "tests" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Alexander Makarov", + "email": "sam@rmcreative.ru" + } + ], + "description": "Yii 2 log target which uses PSR-3 compatible logger", + "homepage": "https://github.com/samdark/yii2-psr-log-target", + "keywords": [ + "extension", + "log", + "psr-3", + "yii" + ], + "support": { + "issues": "https://github.com/samdark/yii2-psr-log-target/issues", + "source": "https://github.com/samdark/yii2-psr-log-target" + }, + "funding": [ + { + "url": "https://github.com/samdark", + "type": "github" + }, + { + "url": "https://www.patreon.com/samdark", + "type": "patreon" + } ], + "time": "2023-11-23T14:11:29+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, "type": "library", "extra": { "branch-alias": { - "dev-main": "11.5-dev" + "dev-main": "4.2-dev" } }, "autoload": { - "files": [ - "src/Framework/Assert/Functions.php" - ], "classmap": [ "src/" ] @@ -11129,188 +14019,208 @@ "role": "lead" } ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", - "keywords": [ - "phpunit", - "testing", - "xunit" - ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { - "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { - "url": "https://phpunit.de/sponsoring.html", - "type": "other" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "time": "2026-07-06T14:52:39+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { - "name": "psy/psysh", - "version": "v0.12.24", + "name": "sebastian/comparator", + "version": "7.1.8", "source": { "type": "git", - "url": "https://github.com/bobthecow/psysh.git", - "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", - "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", "shasum": "" }, "require": { - "ext-json": "*", - "ext-tokenizer": "*", - "nikic/php-parser": "^5.0 || ^4.0", - "php": "^8.0 || ^7.4", - "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" - }, - "conflict": { - "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.2", - "composer/class-map-generator": "^1.6" + "phpunit/phpunit": "^12.5.25" }, "suggest": { - "composer/class-map-generator": "Improved tab completion performance with better class discovery.", - "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", - "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + "ext-bcmath": "For comparing BcMath\\Number objects" }, - "bin": [ - "bin/psysh" - ], "type": "library", "extra": { - "bamarni-bin": { - "bin-links": false, - "forward-command": false - }, "branch-alias": { - "dev-main": "0.12.x-dev" + "dev-main": "7.1-dev" } }, "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Psy\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Justin Hileman", - "email": "justin@justinhileman.info" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" } ], - "description": "An interactive shell for modern PHP.", - "homepage": "https://psysh.org", + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", "keywords": [ - "REPL", - "console", - "interactive", - "shell" + "comparator", + "compare", + "equality" ], "support": { - "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" }, - "time": "2026-06-29T15:41:09+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" }, { - "name": "rector/rector", - "version": "1.2.10", + "name": "sebastian/complexity", + "version": "5.0.0", "source": { "type": "git", - "url": "https://github.com/rectorphp/rector.git", - "reference": "40f9cf38c05296bd32f444121336a521a293fa61" + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/40f9cf38c05296bd32f444121336a521a293fa61", - "reference": "40f9cf38c05296bd32f444121336a521a293fa61", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", "shasum": "" }, "require": { - "php": "^7.2|^8.0", - "phpstan/phpstan": "^1.12.5" - }, - "conflict": { - "rector/rector-doctrine": "*", - "rector/rector-downgrade-php": "*", - "rector/rector-phpunit": "*", - "rector/rector-symfony": "*" + "nikic/php-parser": "^5.0", + "php": ">=8.3" }, - "suggest": { - "ext-dom": "To manipulate phpunit.xml via the custom-rule command" + "require-dev": { + "phpunit/phpunit": "^12.0" }, - "bin": [ - "bin/rector" - ], "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, "autoload": { - "files": [ - "bootstrap.php" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "description": "Instant Upgrade and Automated Refactoring of any PHP code", - "keywords": [ - "automation", - "dev", - "migration", - "refactoring" + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", "support": { - "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/1.2.10" + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" }, "funding": [ { - "url": "https://github.com/tomasvotruba", + "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2024-11-08T13:59:10+00:00" + "time": "2025-02-07T04:55:25+00:00" }, { - "name": "sebastian/cli-parser", - "version": "3.0.2", + "name": "sebastian/diff", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -11325,16 +14235,25 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" } ], - "description": "Library for parsing CLI options", - "homepage": "https://github.com/sebastianbergmann/cli-parser", + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], "support": { - "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" }, "funding": [ { @@ -11342,32 +14261,35 @@ "type": "github" } ], - "time": "2024-07-03T04:41:36+00:00" + "time": "2025-02-07T04:55:46+00:00" }, { - "name": "sebastian/code-unit", - "version": "3.0.3", + "name": "sebastian/environment", + "version": "8.1.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", - "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.5" + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -11382,49 +14304,67 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" } ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "security": "https://github.com/sebastianbergmann/code-unit/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2025-03-19T07:56:08+00:00" + "time": "2026-05-25T13:40:20+00:00" }, { - "name": "sebastian/code-unit-reverse-lookup", - "version": "4.0.1", + "name": "sebastian/exporter", + "version": "7.0.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { - "php": ">=8.2" + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -11440,54 +14380,82 @@ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" } ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-07-03T04:45:54+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { - "name": "sebastian/comparator", - "version": "6.3.3", + "name": "sebastian/global-state", + "version": "8.0.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", - "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/diff": "^6.0", - "sebastian/exporter": "^6.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^11.4" - }, - "suggest": { - "ext-bcmath": "For comparing BcMath\\Number objects" + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.3-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -11503,31 +14471,17 @@ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" } ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "https://github.com/sebastianbergmann/comparator", + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ - "comparator", - "compare", - "equality" + "global state" ], "support": { - "issues": "https://github.com/sebastianbergmann/comparator/issues", - "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" }, "funding": [ { @@ -11543,32 +14497,32 @@ "type": "thanks_dev" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", "type": "tidelift" } ], - "time": "2026-01-24T09:26:40+00:00" + "time": "2026-06-01T15:10:33+00:00" }, { - "name": "sebastian/complexity", + "name": "sebastian/lines-of-code", "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", - "php": ">=8.2" + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -11592,46 +14546,59 @@ "role": "lead" } ], - "description": "Library for calculating the complexity of PHP code units", - "homepage": "https://github.com/sebastianbergmann/complexity", + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { - "issues": "https://github.com/sebastianbergmann/complexity/issues", - "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2024-07-03T04:49:50+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { - "name": "sebastian/diff", - "version": "6.0.2", + "name": "sebastian/object-enumerator", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^11.0", - "symfony/process": "^4.2 || ^5" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -11647,24 +14614,14 @@ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" - }, - { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" - ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { - "issues": "https://github.com/sebastianbergmann/diff/issues", - "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" }, "funding": [ { @@ -11672,35 +14629,32 @@ "type": "github" } ], - "time": "2024-07-03T04:53:05+00:00" + "time": "2025-02-07T04:57:48+00:00" }, { - "name": "sebastian/environment", - "version": "7.2.1", + "name": "sebastian/object-reflector", + "version": "5.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", - "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.3" - }, - "suggest": { - "ext-posix": "*" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.2-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -11718,64 +14672,45 @@ "email": "sebastian@phpunit.de" } ], - "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "https://github.com/sebastianbergmann/environment", - "keywords": [ - "Xdebug", - "environment", - "hhvm" - ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "issues": "https://github.com/sebastianbergmann/environment/issues", - "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", - "type": "tidelift" } ], - "time": "2025-05-21T11:55:47+00:00" + "time": "2025-02-07T04:58:17+00:00" }, { - "name": "sebastian/exporter", - "version": "6.3.2", + "name": "sebastian/recursion-context", + "version": "7.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", - "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { - "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/recursion-context": "^6.0" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.3-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -11796,29 +14731,17 @@ "name": "Jeff Welch", "email": "whatthejeff@gmail.com" }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, { "name": "Adam Harvey", "email": "aharvey@php.net" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" } ], - "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "https://www.github.com/sebastianbergmann/exporter", - "keywords": [ - "export", - "exporter" - ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "issues": "https://github.com/sebastianbergmann/exporter/issues", - "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" }, "funding": [ { @@ -11834,39 +14757,36 @@ "type": "thanks_dev" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", "type": "tidelift" } ], - "time": "2025-09-24T06:12:51+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { - "name": "sebastian/global-state", - "version": "7.0.2", + "name": "sebastian/type", + "version": "6.0.4", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "php": ">=8.3" }, "require-dev": { - "ext-dom": "*", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -11881,52 +14801,58 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Snapshotting of global state", - "homepage": "https://www.github.com/sebastianbergmann/global-state", - "keywords": [ - "global state" - ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "issues": "https://github.com/sebastianbergmann/global-state/issues", - "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2024-07-03T04:57:36+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { - "name": "sebastian/lines-of-code", - "version": "3.0.1", + "name": "sebastian/version", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" + "php": ">=8.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -11945,12 +14871,12 @@ "role": "lead" } ], - "description": "Library for counting the lines of code in PHP source code", - "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" }, "funding": [ { @@ -11958,762 +14884,770 @@ "type": "github" } ], - "time": "2024-07-03T04:58:38+00:00" + "time": "2025-02-07T05:00:38+00:00" }, { - "name": "sebastian/object-enumerator", - "version": "6.0.1", + "name": "seld/cli-prompt", + "version": "1.0.4", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + "url": "https://github.com/Seldaek/cli-prompt.git", + "reference": "b8dfcf02094b8c03b40322c229493bb2884423c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "url": "https://api.github.com/repos/Seldaek/cli-prompt/zipball/b8dfcf02094b8c03b40322c229493bb2884423c5", + "reference": "b8dfcf02094b8c03b40322c229493bb2884423c5", "shasum": "" }, "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "php": ">=5.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpstan/phpstan": "^0.12.63" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-master": "1.x-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Seld\\CliPrompt\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be" } ], - "description": "Traverses array structures and object graphs to enumerate all referenced objects", - "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "description": "Allows you to prompt for user input on the command line, and optionally hide the characters they type", + "keywords": [ + "cli", + "console", + "hidden", + "input", + "prompt" + ], "support": { - "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + "issues": "https://github.com/Seldaek/cli-prompt/issues", + "source": "https://github.com/Seldaek/cli-prompt/tree/1.0.4" }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T05:00:13+00:00" + "time": "2020-12-15T21:32:01+00:00" }, { - "name": "sebastian/object-reflector", - "version": "4.0.1", + "name": "staabm/side-effects-detector", + "version": "1.0.5", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", "shasum": "" }, "require": { - "php": ">=8.2" + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, "autoload": { "classmap": [ - "src/" + "lib/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" ], - "description": "Allows reflection of object attributes, including inherited and non-public ones", - "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/staabm", "type": "github" } ], - "time": "2024-07-03T05:01:32+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { - "name": "sebastian/recursion-context", - "version": "6.0.3", + "name": "symfony/polyfill-php83", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", - "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.3" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "6.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, "classmap": [ - "src/" + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Adam Harvey", - "email": "aharvey@php.net" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "https://github.com/sebastianbergmann/recursion-context", + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" + "url": "https://github.com/fabpot", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-08-13T04:42:22+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { - "name": "sebastian/type", - "version": "5.1.3", + "name": "symplify/easy-coding-standard", + "version": "10.3.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + "url": "https://github.com/easy-coding-standard/ecs.git", + "reference": "c93878b3c052321231519b6540e227380f90be17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", - "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "url": "https://api.github.com/repos/easy-coding-standard/ecs/zipball/c93878b3c052321231519b6540e227380f90be17", + "reference": "c93878b3c052321231519b6540e227380f90be17", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=7.2" }, - "require-dev": { - "phpunit/phpunit": "^11.3" + "conflict": { + "friendsofphp/php-cs-fixer": "<3.0", + "squizlabs/php_codesniffer": "<3.6" }, + "bin": [ + "bin/ecs" + ], "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "10.3-dev" } }, "autoload": { - "classmap": [ - "src/" + "files": [ + "bootstrap.php" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } + "MIT" ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", + "description": "Prefixed scoped version of ECS package", "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + "issues": "https://github.com/easy-coding-standard/ecs/issues", + "source": "https://github.com/easy-coding-standard/ecs/tree/10.3.3" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" + "url": "https://www.paypal.me/rectorphp", + "type": "custom" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/type", - "type": "tidelift" + "url": "https://github.com/tomasvotruba", + "type": "github" } ], - "time": "2025-08-09T06:55:48+00:00" + "time": "2022-06-13T14:03:37+00:00" }, { - "name": "sebastian/version", - "version": "5.0.2", + "name": "ta-tikoma/phpunit-architecture-test", + "version": "0.8.7", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", "shasum": "" }, "require": { - "php": ">=8.2" + "nikic/php-parser": "^4.18.0 || ^5.0.0", + "php": "^8.1.0", + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "5.0-dev" - } + "require-dev": { + "laravel/pint": "^1.13.7", + "phpstan/phpstan": "^1.10.52" }, + "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "PHPUnit\\Architecture\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Ni Shi", + "email": "futik0ma011@gmail.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" } ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", + "description": "Methods for testing application architecture", + "keywords": [ + "architecture", + "phpunit", + "stucture", + "test", + "testing" + ], "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-10-09T05:16:32+00:00" + "time": "2026-02-17T17:25:14+00:00" }, { - "name": "softcreatr/jsonpath", - "version": "0.10.0", + "name": "thamtech/yii2-ratelimiter-advanced", + "version": "0.5", "source": { "type": "git", - "url": "https://github.com/SoftCreatR/JSONPath.git", - "reference": "74f0b330a98135160db947ba7bc65216b64a0c86" + "url": "https://github.com/thamtech/yii2-ratelimiter-advanced.git", + "reference": "2fde10eaa1ec67e689d06babfc9c68d144d35433" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SoftCreatR/JSONPath/zipball/74f0b330a98135160db947ba7bc65216b64a0c86", - "reference": "74f0b330a98135160db947ba7bc65216b64a0c86", + "url": "https://api.github.com/repos/thamtech/yii2-ratelimiter-advanced/zipball/2fde10eaa1ec67e689d06babfc9c68d144d35433", + "reference": "2fde10eaa1ec67e689d06babfc9c68d144d35433", "shasum": "" }, "require": { - "ext-json": "*", - "php": "8.1 - 8.4" - }, - "replace": { - "flow/jsonpath": "*" + "php": ">=5.6.0", + "yiisoft/yii2": ">=2.0.14 <2.1" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.58", - "phpunit/phpunit": "10 - 12", - "squizlabs/php_codesniffer": "^3.10" + "codeception/codeception": "2.0.*", + "codeception/specify": "*", + "codeception/verify": "*", + "flow/jsonpath": "^0.3", + "yiisoft/yii2-codeception": "*", + "yiisoft/yii2-debug": "*", + "yiisoft/yii2-faker": "*" }, - "type": "library", + "type": "yii2-extension", "autoload": { "psr-4": { - "Flow\\JSONPath\\": "src/" + "thamtech\\ratelimiter\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Stephen Frank", - "email": "stephen@flowsa.com", - "homepage": "https://prismaticbytes.com", - "role": "Developer" - }, - { - "name": "Sascha Greuel", - "email": "hello@1-2.dev", - "homepage": "https://1-2.dev", - "role": "Developer" + "name": "Tyler Ham", + "email": "tyler@thamtech.com" } ], - "description": "JSONPath implementation for parsing, searching and flattening arrays", + "description": "An advanced request rate limiter", "support": { - "email": "hello@1-2.dev", - "forum": "https://github.com/SoftCreatR/JSONPath/discussions", - "issues": "https://github.com/SoftCreatR/JSONPath/issues", - "source": "https://github.com/SoftCreatR/JSONPath" + "issues": "https://github.com/thamtech/yii2-ratelimiter-advanced/issues", + "source": "https://github.com/thamtech/yii2-ratelimiter-advanced/tree/master" }, - "funding": [ - { - "url": "https://ecologi.com/softcreatr?r=61212ab3fc69b8eb8a2014f4", - "type": "custom" - }, - { - "url": "https://github.com/softcreatr", - "type": "github" - } - ], - "time": "2025-03-22T00:28:17+00:00" + "time": "2020-08-05T04:29:29+00:00" }, { - "name": "staabm/side-effects-detector", - "version": "1.0.5", + "name": "theseer/tokenizer", + "version": "2.0.1", "source": { "type": "git", - "url": "https://github.com/staabm/side-effects-detector.git", - "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", - "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { + "ext-dom": "*", "ext-tokenizer": "*", - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^1.12.6", - "phpunit/phpunit": "^9.6.21", - "symfony/var-dumper": "^5.4.43", - "tomasvotruba/type-coverage": "1.0.0", - "tomasvotruba/unused-public": "1.0.0" + "ext-xmlwriter": "*", + "php": "^8.1" }, "type": "library", "autoload": { "classmap": [ - "lib/" + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" - ], - "description": "A static analysis tool to detect side effects in PHP code", - "keywords": [ - "static analysis" + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { - "issues": "https://github.com/staabm/side-effects-detector/issues", - "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { - "url": "https://github.com/staabm", + "url": "https://github.com/theseer", "type": "github" } ], - "time": "2024-10-20T05:08:20+00:00" + "time": "2025-12-08T11:19:18+00:00" }, { - "name": "symfony/browser-kit", - "version": "v7.4.14", + "name": "yiisoft/yii2", + "version": "2.0.55", "source": { "type": "git", - "url": "https://github.com/symfony/browser-kit.git", - "reference": "bb28e8761a6c33975972948010f00d4a10f0a634" + "url": "https://github.com/yiisoft/yii2-framework.git", + "reference": "b900eecdb225041a4c4e0f5e0e5336f606a23bdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/bb28e8761a6c33975972948010f00d4a10f0a634", - "reference": "bb28e8761a6c33975972948010f00d4a10f0a634", + "url": "https://api.github.com/repos/yiisoft/yii2-framework/zipball/b900eecdb225041a4c4e0f5e0e5336f606a23bdb", + "reference": "b900eecdb225041a4c4e0f5e0e5336f606a23bdb", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/dom-crawler": "^6.4|^7.0|^8.0" - }, - "require-dev": { - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0" + "bower-asset/inputmask": "^5.0.8 ", + "bower-asset/jquery": "3.7.*@stable | 3.6.*@stable | 3.5.*@stable | 3.4.*@stable | 3.3.*@stable | 3.2.*@stable | 3.1.*@stable | 2.2.*@stable | 2.1.*@stable | 1.11.*@stable | 1.12.*@stable", + "bower-asset/punycode": "^2.2", + "bower-asset/yii2-pjax": "~2.0.1", + "cebe/markdown": "~1.0.0 | ~1.1.0 | ~1.2.0", + "ext-ctype": "*", + "ext-mbstring": "*", + "ezyang/htmlpurifier": "^4.17", + "lib-pcre": "*", + "php": ">=7.4.0", + "yiisoft/yii2-composer": "~2.0.4" }, + "bin": [ + "yii" + ], "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\BrowserKit\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "yii\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Qiang Xue", + "email": "qiang.xue@gmail.com", + "homepage": "https://www.yiiframework.com/", + "role": "Founder and project lead" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Alexander Makarov", + "email": "sam@rmcreative.ru", + "homepage": "https://rmcreative.ru/", + "role": "Core framework development" + }, + { + "name": "Maurizio Domba", + "homepage": "http://mdomba.info/", + "role": "Core framework development" + }, + { + "name": "Carsten Brandt", + "email": "mail@cebe.cc", + "homepage": "https://www.cebe.cc/", + "role": "Core framework development" + }, + { + "name": "Timur Ruziev", + "email": "resurtm@gmail.com", + "homepage": "http://resurtm.com/", + "role": "Core framework development" + }, + { + "name": "Paul Klimov", + "email": "klimov.paul@gmail.com", + "role": "Core framework development" + }, + { + "name": "Dmitry Naumenko", + "email": "d.naumenko.a@gmail.com", + "role": "Core framework development" + }, + { + "name": "Boudewijn Vahrmeijer", + "email": "info@dynasource.eu", + "homepage": "http://dynasource.eu", + "role": "Core framework development" } ], - "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", - "homepage": "https://symfony.com", + "description": "Yii PHP Framework Version 2", + "homepage": "https://www.yiiframework.com/", + "keywords": [ + "framework", + "yii2" + ], "support": { - "source": "https://github.com/symfony/browser-kit/tree/v7.4.14" + "forum": "https://forum.yiiframework.com/", + "irc": "ircs://irc.libera.chat:6697/yii", + "issues": "https://github.com/yiisoft/yii2/issues?state=open", + "source": "https://github.com/yiisoft/yii2", + "wiki": "https://www.yiiframework.com/wiki" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", + "url": "https://github.com/yiisoft", "type": "github" }, { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://opencollective.com/yiisoft", + "type": "open_collective" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/yiisoft/yii2", "type": "tidelift" } ], - "time": "2026-06-08T20:24:16+00:00" + "time": "2026-05-09T14:50:57+00:00" }, { - "name": "symfony/console", - "version": "v7.4.14", + "name": "yiisoft/yii2-composer", + "version": "2.0.11", "source": { "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" + "url": "https://github.com/yiisoft/yii2-composer.git", + "reference": "b684b01ecb119c8287721def726a0e24fec2fef2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", - "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "url": "https://api.github.com/repos/yiisoft/yii2-composer/zipball/b684b01ecb119c8287721def726a0e24fec2fef2", + "reference": "b684b01ecb119c8287721def726a0e24fec2fef2", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" - }, - "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" - }, - "provide": { - "psr/log-implementation": "1.0|2.0|3.0" + "composer-plugin-api": "^1.0 | ^2.0" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "composer/composer": "^1.0 | ^2.0@dev", + "phpunit/phpunit": "<7" + }, + "type": "composer-plugin", + "extra": { + "class": "yii\\composer\\Plugin", + "branch-alias": { + "dev-master": "2.0.x-dev" + } }, - "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Console\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "yii\\composer\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Qiang Xue", + "email": "qiang.xue@gmail.com" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Carsten Brandt", + "email": "mail@cebe.cc" } ], - "description": "Eases the creation of beautiful and testable command line interfaces", - "homepage": "https://symfony.com", + "description": "The composer plugin for Yii extension installer", "keywords": [ - "cli", - "command-line", - "console", - "terminal" + "composer", + "extension installer", + "yii2" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.14" + "forum": "https://www.yiiframework.com/forum/", + "irc": "ircs://irc.libera.chat:6697/yii", + "issues": "https://github.com/yiisoft/yii2-composer/issues", + "source": "https://github.com/yiisoft/yii2-composer", + "wiki": "https://www.yiiframework.com/wiki/" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", + "url": "https://github.com/yiisoft", "type": "github" }, { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://opencollective.com/yiisoft", + "type": "open_collective" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/yiisoft/yii2-composer", "type": "tidelift" } ], - "time": "2026-06-16T11:50:14+00:00" + "time": "2025-02-13T20:59:36+00:00" }, { - "name": "symfony/finder", - "version": "v7.4.14", + "name": "yiisoft/yii2-debug", + "version": "2.1.27", "source": { "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "13b38720174286f55d1761152b575a8d1436fc25" + "url": "https://github.com/yiisoft/yii2-debug.git", + "reference": "44e158914911ef81cd7111fd6d46b918f65fae7c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", - "reference": "13b38720174286f55d1761152b575a8d1436fc25", + "url": "https://api.github.com/repos/yiisoft/yii2-debug/zipball/44e158914911ef81cd7111fd6d46b918f65fae7c", + "reference": "44e158914911ef81cd7111fd6d46b918f65fae7c", "shasum": "" }, "require": { - "php": ">=8.2" + "ext-mbstring": "*", + "php": ">=5.4", + "yiisoft/yii2": "~2.0.13" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "cweagans/composer-patches": "^1.7", + "phpunit/phpunit": "4.8.34", + "yiisoft/yii2-coding-standards": "~2.0", + "yiisoft/yii2-swiftmailer": "*" + }, + "type": "yii2-extension", + "extra": { + "patches": { + "phpunit/phpunit": { + "Fix PHP 7 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_php7.patch", + "Fix PHP 8 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_php8.patch", + "Fix PHP 8.1 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_php81.patch" + }, + "phpunit/phpunit-mock-objects": { + "Fix PHP 7 and 8 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_mock_objects.patch" + } + }, + "branch-alias": { + "dev-master": "2.0.x-dev" + }, + "composer-exit-on-patch-failure": true }, - "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Finder\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "yii\\debug\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Qiang Xue", + "email": "qiang.xue@gmail.com" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Simon Karlen", + "email": "simi.albi@outlook.com" } ], - "description": "Finds files and directories via an intuitive fluent interface", - "homepage": "https://symfony.com", + "description": "The debugger extension for the Yii framework", + "keywords": [ + "debug", + "debugger", + "dev", + "yii2" + ], "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.14" + "forum": "https://www.yiiframework.com/forum/", + "irc": "ircs://irc.libera.chat:6697/yii", + "issues": "https://github.com/yiisoft/yii2-debug/issues", + "source": "https://github.com/yiisoft/yii2-debug", + "wiki": "https://www.yiiframework.com/wiki/" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", + "url": "https://github.com/yiisoft", "type": "github" }, { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://opencollective.com/yiisoft", + "type": "open_collective" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/yiisoft/yii2-debug", "type": "tidelift" } ], - "time": "2026-06-27T08:31:18+00:00" + "time": "2025-06-08T13:32:11+00:00" }, { - "name": "symplify/easy-coding-standard", - "version": "10.3.3", + "name": "yiisoft/yii2-queue", + "version": "2.3.8", "source": { "type": "git", - "url": "https://github.com/ecsphp/ecs.git", - "reference": "c93878b3c052321231519b6540e227380f90be17" + "url": "https://github.com/yiisoft/yii2-queue.git", + "reference": "e0f935e5b868d53347acfb14ec19faaf16085005" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ecsphp/ecs/zipball/c93878b3c052321231519b6540e227380f90be17", - "reference": "c93878b3c052321231519b6540e227380f90be17", + "url": "https://api.github.com/repos/yiisoft/yii2-queue/zipball/e0f935e5b868d53347acfb14ec19faaf16085005", + "reference": "e0f935e5b868d53347acfb14ec19faaf16085005", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=5.5.0", + "symfony/process": "^3.3||^4.0||^5.0||^6.0||^7.0", + "yiisoft/yii2": "~2.0.14" }, - "conflict": { - "friendsofphp/php-cs-fixer": "<3.0", - "squizlabs/php_codesniffer": "<3.6" + "require-dev": { + "aws/aws-sdk-php": ">=2.4", + "cweagans/composer-patches": "^1.7", + "enqueue/amqp-lib": "^0.8||^0.9.10||^0.10.0", + "enqueue/stomp": "^0.8.39||0.10.19", + "opis/closure": "*", + "pda/pheanstalk": "~3.2.1", + "php-amqplib/php-amqplib": "^2.8.0||^3.0.0", + "phpunit/phpunit": "4.8.34", + "yiisoft/yii2-debug": "~2.1.0", + "yiisoft/yii2-gii": "~2.2.0", + "yiisoft/yii2-redis": "2.0.19" }, - "bin": [ - "bin/ecs" - ], - "type": "library", + "suggest": { + "aws/aws-sdk-php": "Need for aws SQS.", + "enqueue/amqp-lib": "Need for AMQP interop queue.", + "enqueue/stomp": "Need for Stomp queue.", + "ext-gearman": "Need for Gearman queue.", + "ext-pcntl": "Need for process signals.", + "pda/pheanstalk": "Need for Beanstalk queue.", + "php-amqplib/php-amqplib": "Need for AMQP queue.", + "yiisoft/yii2-redis": "Need for Redis queue." + }, + "type": "yii2-extension", "extra": { + "patches": { + "phpunit/phpunit": { + "Fix PHP 7 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_php7.patch", + "Fix PHP 8 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_php8.patch" + }, + "phpunit/phpunit-mock-objects": { + "Fix PHP 7 and 8 compatibility": "https://yiisoft.github.io/phpunit-patches/phpunit_mock_objects.patch" + } + }, "branch-alias": { - "dev-main": "10.3-dev" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Prefixed scoped version of ECS package", - "support": { - "issues": "https://github.com/easy-coding-standard/ecs/issues", - "source": "https://github.com/easy-coding-standard/ecs/tree/10.3.3" - }, - "funding": [ - { - "url": "https://www.paypal.me/rectorphp", - "type": "custom" + "dev-master": "2.x-dev" }, - { - "url": "https://github.com/tomasvotruba", - "type": "github" - } - ], - "time": "2022-06-13T14:03:37+00:00" - }, - { - "name": "theseer/tokenizer", - "version": "1.3.1", - "source": { - "type": "git", - "url": "https://github.com/theseer/tokenizer.git", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "composer-exit-on-patch-failure": true }, - "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "yii\\queue\\": "src", + "yii\\queue\\db\\": "src/drivers/db", + "yii\\queue\\sqs\\": "src/drivers/sqs", + "yii\\queue\\amqp\\": "src/drivers/amqp", + "yii\\queue\\file\\": "src/drivers/file", + "yii\\queue\\sync\\": "src/drivers/sync", + "yii\\queue\\redis\\": "src/drivers/redis", + "yii\\queue\\stomp\\": "src/drivers/stomp", + "yii\\queue\\gearman\\": "src/drivers/gearman", + "yii\\queue\\beanstalk\\": "src/drivers/beanstalk", + "yii\\queue\\amqp_interop\\": "src/drivers/amqp_interop" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -12721,68 +15655,85 @@ ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" + "name": "Roman Zhuravlev", + "email": "zhuravljov@gmail.com" } ], - "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "description": "Yii2 Queue Extension which supports queues based on DB, Redis, RabbitMQ, Beanstalk, SQS, and Gearman", + "keywords": [ + "async", + "beanstalk", + "db", + "gearman", + "gii", + "queue", + "rabbitmq", + "redis", + "sqs", + "yii" + ], "support": { - "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + "docs": "https://github.com/yiisoft/yii2-queue/blob/master/docs/guide", + "issues": "https://github.com/yiisoft/yii2-queue/issues", + "source": "https://github.com/yiisoft/yii2-queue" }, "funding": [ { - "url": "https://github.com/theseer", + "url": "https://github.com/yiisoft", "type": "github" + }, + { + "url": "https://opencollective.com/yiisoft", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/yiisoft/yii2-queue", + "type": "tidelift" } ], - "time": "2025-11-17T20:03:58+00:00" + "time": "2026-01-08T07:52:05+00:00" }, { - "name": "vlucas/phpdotenv", - "version": "v5.6.4", + "name": "yiisoft/yii2-symfonymailer", + "version": "4.0.0", "source": { "type": "git", - "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" + "url": "https://github.com/yiisoft/yii2-symfonymailer.git", + "reference": "21f407239c51fc6d50d369e4469d006afa8c9b2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", + "url": "https://api.github.com/repos/yiisoft/yii2-symfonymailer/zipball/21f407239c51fc6d50d369e4469d006afa8c9b2c", + "reference": "21f407239c51fc6d50d369e4469d006afa8c9b2c", "shasum": "" }, "require": { - "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.4", - "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5", - "symfony/polyfill-ctype": "^1.26", - "symfony/polyfill-mbstring": "^1.26", - "symfony/polyfill-php80": "^1.26" + "php": ">=8.1", + "psr/event-dispatcher": "1.0.0", + "symfony/mailer": "^6.4 || ^7.0", + "symfony/mime": "^6.4 || ^7.0", + "yiisoft/yii2": ">=2.0.4" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "ext-filter": "*", - "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + "maglnet/composer-require-checker": "^4.7", + "phpunit/phpunit": "^10.5", + "roave/infection-static-analysis-plugin": "^1.34", + "symplify/easy-coding-standard": "^12.1", + "vimeo/psalm": "^5.20" }, "suggest": { - "ext-filter": "Required to use the boolean validator." + "yiisoft/yii2-psr-log-source": "Allows routing transport logs to your Yii2 logger" }, - "type": "library", + "type": "yii2-extension", "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - }, "branch-alias": { - "dev-master": "5.6-dev" - } + "dev-master": "3.0.x-dev" + }, + "sort-packages": true }, "autoload": { "psr-4": { - "Dotenv\\": "src/" + "yii\\symfonymailer\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -12791,50 +15742,54 @@ ], "authors": [ { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Vance Lucas", - "email": "vance@vancelucas.com", - "homepage": "https://github.com/vlucas" + "name": "Kirill Petrov", + "email": "archibeardrinker@gmail.com" } ], - "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "description": "The SymfonyMailer integration for the Yii framework", "keywords": [ - "dotenv", - "env", - "environment" + "email", + "mail", + "mailer", + "symfony", + "symfonymailer", + "yii2" ], "support": { - "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" + "forum": "http://www.yiiframework.com/forum/", + "irc": "irc://irc.freenode.net/yii", + "issues": "https://github.com/yiisoft/yii2-symfonymailer/issues", + "source": "https://github.com/yiisoft/yii2-symfonymailer", + "wiki": "http://www.yiiframework.com/wiki/" }, "funding": [ { - "url": "https://github.com/GrahamCampbell", + "url": "https://github.com/yiisoft", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "url": "https://opencollective.com/yiisoft", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/yiisoft/yii2-symfonymailer", "type": "tidelift" } ], - "time": "2026-07-06T19:11:50+00:00" + "time": "2024-01-29T14:13:45+00:00" } ], "aliases": [], "minimum-stability": "dev", "stability-flags": { + "craftcms/cms": 20, "craftcms/ecs": 20, - "craftcms/phpstan": 20, - "craftcms/rector": 20 + "craftcms/yii2-adapter": 20 }, "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2" + "php": "^8.5" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/database/migrations/2026_02_26_120000_product_type_permissions.php b/database/migrations/2026_02_26_120000_product_type_permissions.php new file mode 100644 index 0000000000..356a82a1a3 --- /dev/null +++ b/database/migrations/2026_02_26_120000_product_type_permissions.php @@ -0,0 +1,85 @@ +pluck('uid'); + + // Build the permission mapping: oldPermission => [newPermission, ...] + $map = []; + foreach ($productTypeUids as $uid) { + $map[strtolower("commerce-editProductType:$uid")] = [ + strtolower("commerce-viewProductType:$uid"), + strtolower("commerce-saveProductType:$uid"), + ]; + $map[strtolower("commerce-createProducts:$uid")] = [ + strtolower("commerce-createProductType:$uid"), + ]; + $map[strtolower("commerce-deleteProducts:$uid")] = [ + strtolower("commerce-deleteProductType:$uid"), + ]; + } + + // Migrate user permissions in the database + foreach ($map as $oldPermission => $newPermissions) { + $userIds = DB::table(Table::USERPERMISSIONS_USERS . ' as upu') + ->join(Table::USERPERMISSIONS . ' as up', 'up.id', '=', 'upu.permissionId') + ->where('up.name', $oldPermission) + ->pluck('upu.userId') + ->unique() + ->values(); + + if ($userIds->isEmpty()) { + continue; + } + + foreach ($newPermissions as $newPermission) { + // Delete the permission if it already exists + DB::table(Table::USERPERMISSIONS)->where('name', $newPermission)->delete(); + + $newPermissionId = DB::table(Table::USERPERMISSIONS)->insertGetId(['name' => $newPermission]); + + DB::table(Table::USERPERMISSIONS_USERS)->insert( + $userIds->map(fn($userId) => ['permissionId' => $newPermissionId, 'userId' => $userId])->all() + ); + } + } + + // Migrate project config for user groups + $projectConfig = Craft::$app->getProjectConfig(); + + foreach ($projectConfig->get('users.groups') ?? [] as $uid => $group) { + $groupPermissions = array_flip($group['permissions'] ?? []); + $save = false; + + foreach ($map as $oldPermission => $newPermissions) { + if (isset($groupPermissions[$oldPermission])) { + foreach ($newPermissions as $newPermission) { + $groupPermissions[$newPermission] = true; + } + $save = true; + } + } + + if ($save) { + $projectConfig->set("users.groups.$uid.permissions", array_keys($groupPermissions)); + } + } + } + + public function down(): void + { + // Permission migrations are not reversible + } +}; diff --git a/database/migrations/2026_04_07_000000_add_catalog_pricing_queue_table.php b/database/migrations/2026_04_07_000000_add_catalog_pricing_queue_table.php new file mode 100644 index 0000000000..262d3ae340 --- /dev/null +++ b/database/migrations/2026_04_07_000000_add_catalog_pricing_queue_table.php @@ -0,0 +1,36 @@ +id(); + $table->integer('storeId')->nullable(); + $table->enum('type', [CatalogPricingQueue::TYPE_PURCHASABLE, CatalogPricingQueue::TYPE_RULE]); + $table->mediumText('ids')->nullable(); + $table->boolean('reserved')->default(false); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + } + + Schema::table(Table::CATALOG_PRICING_QUEUE, function (Blueprint $table) { + $table->index('reserved'); + $table->index(['storeId', 'type', 'reserved']); + $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()->cascadeOnUpdate(); + }); + } + + public function down(): void + { + $this->output->error('2026_04_07_000000_add_catalog_pricing_queue_table cannot be reverted.'); + } +}; diff --git a/database/migrations/2026_05_05_071943_add_orders_customerDeleted_column.php b/database/migrations/2026_05_05_071943_add_orders_customerDeleted_column.php new file mode 100644 index 0000000000..3c5f180da3 --- /dev/null +++ b/database/migrations/2026_05_05_071943_add_orders_customerDeleted_column.php @@ -0,0 +1,24 @@ +boolean('customerDeleted')->default(false)->after('customerId'); + }); + } + + public function down(): void + { + $this->output->error('2026_05_05_071943_add_orders_customerDeleted_column cannot be reverted.'); + } +}; diff --git a/database/migrations/2026_05_07_000000_subscriptions_nullable_userId.php b/database/migrations/2026_05_07_000000_subscriptions_nullable_userId.php new file mode 100644 index 0000000000..cf7183d137 --- /dev/null +++ b/database/migrations/2026_05_07_000000_subscriptions_nullable_userId.php @@ -0,0 +1,26 @@ +foreign('userId')->references('id')->on(CraftTable::USERS)->cascadeOnDelete(); + }); + } + + public function down(): void + { + $this->output->error('2026_05_07_000000_subscriptions_nullable_userId cannot be reverted.'); + } +}; diff --git a/database/migrations/2026_06_15_000000_add_notice_type_to_order_notices.php b/database/migrations/2026_06_15_000000_add_notice_type_to_order_notices.php new file mode 100644 index 0000000000..3708723f6f --- /dev/null +++ b/database/migrations/2026_06_15_000000_add_notice_type_to_order_notices.php @@ -0,0 +1,26 @@ +string('noticeType')->default('customer'); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn(Table::ORDERNOTICES, 'noticeType')) { + Schema::table(Table::ORDERNOTICES, function (Blueprint $table) { + $table->dropColumn('noticeType'); + }); + } + } +}; diff --git a/database/migrations/2026_06_16_000000_rename_allVariants_changedattributes.php b/database/migrations/2026_06_16_000000_rename_allVariants_changedattributes.php new file mode 100644 index 0000000000..8626d0378a --- /dev/null +++ b/database/migrations/2026_06_16_000000_rename_allVariants_changedattributes.php @@ -0,0 +1,54 @@ +allVariants (which no longer exists), throwing an UnknownPropertyException + * when opening a product with a provisional draft. + * This migration renames any lingering 'allVariants' entries to 'variants' for Product elements. + */ +return new class extends Migration { + public function up(): void + { + // Insert 'variants' rows for Products that have 'allVariants' but no existing 'variants' entry + DB::table(Table::CHANGEDATTRIBUTES)->insertUsing( + ['elementId', 'siteId', 'attribute', 'dateUpdated', 'propagated', 'userId'], + function ($query) { + $query->from(Table::CHANGEDATTRIBUTES . ' as ca') + ->select(['ca.elementId', 'ca.siteId', DB::raw("'variants'"), 'ca.dateUpdated', 'ca.propagated', 'ca.userId']) + ->where('ca.attribute', 'allVariants') + ->whereIn('ca.elementId', $this->productIdsQuery()) + ->whereNotExists(function ($subQuery) { + $subQuery->select(DB::raw(1)) + ->from(Table::CHANGEDATTRIBUTES . ' as ca2') + ->whereColumn('ca2.elementId', 'ca.elementId') + ->whereColumn('ca2.siteId', 'ca.siteId') + ->where('ca2.attribute', 'variants'); + }); + } + ); + + // Delete all 'allVariants' rows for Products + DB::table(Table::CHANGEDATTRIBUTES) + ->where('attribute', 'allVariants') + ->whereIn('elementId', $this->productIdsQuery()) + ->delete(); + } + + public function down(): void + { + $this->output->error('2026_06_16_000000_rename_allVariants_changedattributes cannot be reverted.'); + } + + private function productIdsQuery(): \Illuminate\Database\Query\Builder + { + return DB::table(Table::ELEMENTS) + ->where('type', 'craft\commerce\elements\Product') + ->select('id'); + } +}; diff --git a/database/migrations/Install.php b/database/migrations/Install.php new file mode 100644 index 0000000000..4c2ae2c7a6 --- /dev/null +++ b/database/migrations/Install.php @@ -0,0 +1,1425 @@ +silent) { + $callback(); + + return; + } + + PromptTask::run( + label: str($label)->finish('...')->toString(), + callback: function (Logger $logger) use ($callback, $label) { + $callback($logger); + $logger->label($label); + }, + keepSummary: true, + output: $this->output, + ); + } + + public function up(): void + { + $this->task('Install Craft Commerce', function (?Logger $logger = null) { + $logger?->subLabel('Creating tables...'); + $this->createTables(); + $logger?->success('Tables created.'); + + $logger?->subLabel('Creating indexes...'); + $this->createIndexes(); + $logger?->success('Indexes created.'); + + $logger?->subLabel('Adding foreign keys...'); + $this->addForeignKeys(); + $logger?->success('Foreign keys added.'); + }); + + $this->task('Seed default Craft Commerce data', function (?Logger $logger = null) { + $this->insertDefaultData(); + $logger?->success('Default data seeded.'); + }); + } + + /** + * Creates the tables for Craft Commerce. + */ + public function createTables(): void + { + Schema::create(Table::CATALOG_PRICING_RULES, function (Blueprint $table) { + $table->integer('id', true); + $table->string('name'); + $table->text('description')->nullable(); + $table->integer('storeId'); + $table->dateTime('dateFrom')->nullable(); + $table->dateTime('dateTo')->nullable(); + $table->enum('apply', ['toPercent', 'toFlat', 'byPercent', 'byFlat']); + $table->decimal('applyAmount', 14, 4); + $table->enum('applyPriceType', [CatalogPricingRule::APPLY_PRICE_TYPE_PRICE, CatalogPricingRule::APPLY_PRICE_TYPE_PROMOTIONAL_PRICE]); + $table->text('productCondition')->nullable(); + $table->text('variantCondition')->nullable(); + $table->text('purchasableCondition')->nullable(); + $table->text('customerCondition')->nullable(); + $table->boolean('enabled')->default(true); + $table->boolean('isPromotionalPrice')->default(false); + $table->text('metadata')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::CATALOG_PRICING_RULES_USERS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('catalogPricingRuleId'); + $table->integer('userId'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::CATALOG_PRICING, function (Blueprint $table) { + $table->integer('id', true); + $table->decimal('price', 14, 4)->nullable(); // @TODO Consider storing as string to avoid float-precision issues + $table->integer('purchasableId'); + $table->integer('storeId')->nullable(); + $table->integer('catalogPricingRuleId')->nullable(); + $table->integer('userId')->nullable(); + $table->dateTime('dateFrom')->nullable(); + $table->dateTime('dateTo')->nullable(); + $table->boolean('isPromotionalPrice')->default(false)->nullable(); + $table->boolean('hasUpdatePending')->default(false)->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::CATALOG_PRICING_QUEUE, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('storeId')->nullable(); + $table->enum('type', [CatalogPricingQueue::TYPE_PURCHASABLE, CatalogPricingQueue::TYPE_RULE]); + $table->mediumText('ids')->nullable(); + $table->boolean('reserved')->default(false); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::CUSTOMERS, function (Blueprint $table) { + $table->integer('id', true); // Not used in v4 but is the old customerId + $table->integer('customerId'); // This is the User element ID + $table->integer('primaryBillingAddressId')->nullable(); + $table->integer('primaryShippingAddressId')->nullable(); + $table->integer('primaryPaymentSourceId')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::COUPONS, function (Blueprint $table) { + $table->integer('id', true); + $table->string('code')->nullable(); + $table->integer('discountId'); + $table->integer('uses')->default(0); + $table->integer('maxUses')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::CUSTOMER_DISCOUNTUSES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('discountId'); + $table->integer('customerId'); + $table->unsignedInteger('uses'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::EMAIL_DISCOUNTUSES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('discountId'); + $table->string('email'); + $table->unsignedInteger('uses'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::DISCOUNT_PURCHASABLES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('discountId'); + $table->integer('purchasableId'); + $table->string('purchasableType'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + // @TODO Rename to `discount_entries` table in Commerce 6.0, or remove if the purchasable condition builder fully replaces it + Schema::create(Table::DISCOUNT_CATEGORIES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('discountId'); + $table->integer('categoryId'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::DISCOUNTS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('storeId'); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('couponFormat', 20)->default(Coupons::DEFAULT_COUPON_FORMAT); + $table->text('orderCondition')->nullable(); + $table->text('customerCondition')->nullable(); + $table->text('shippingAddressCondition')->nullable(); + $table->text('billingAddressCondition')->nullable(); + $table->boolean('requireCouponCode')->default(false); + $table->unsignedInteger('perUserLimit')->default(0); + $table->unsignedInteger('perEmailLimit')->default(0); + $table->unsignedInteger('totalDiscountUses')->default(0); + $table->unsignedInteger('totalDiscountUseLimit')->default(0); + $table->dateTime('dateFrom')->nullable(); + $table->dateTime('dateTo')->nullable(); + $table->integer('purchaseQty')->default(0); + $table->decimal('purchaseTotal', 14, 4)->default(0); + $table->integer('maxPurchaseQty')->default(0); + $table->decimal('baseDiscount', 14, 4)->default(0); + $table->decimal('perItemDiscount', 14, 4)->default(0); + $table->decimal('percentDiscount', 14, 4)->default(0); + $table->enum('percentageOffSubject', ['original', 'discounted']); + $table->boolean('excludeOnPromotion')->default(false); + $table->boolean('hasFreeShippingForMatchingItems')->default(false); + $table->boolean('hasFreeShippingForOrder')->default(false); + $table->boolean('allPurchasables')->default(false); + $table->text('purchasableIds')->nullable(); + $table->boolean('allCategories')->default(false); + $table->text('categoryIds')->nullable(); + $table->enum('appliedTo', ['matchingLineItems', 'allLineItems'])->default('matchingLineItems'); + $table->enum('categoryRelationshipType', ['element', 'sourceElement', 'targetElement'])->default('element'); + $table->text('orderConditionFormula')->nullable(); + $table->boolean('enabled')->default(true); + $table->boolean('stopProcessing')->default(false); + $table->boolean('ignorePromotions')->default(false); + $table->integer('sortOrder')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::DONATIONS, function (Blueprint $table) { + $table->integer('id', true); + $table->string('sku'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::EMAILS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('storeId')->nullable(); + $table->string('name'); + $table->string('senderAddress')->nullable(); + $table->string('senderName')->nullable(); + $table->string('subject'); + $table->enum('recipientType', ['customer', 'custom'])->default('custom')->nullable(); + $table->string('to')->nullable(); + $table->string('bcc')->nullable(); + $table->string('cc')->nullable(); + $table->string('replyTo')->nullable(); + $table->boolean('enabled')->default(true); + $table->string('templatePath'); + $table->string('plainTextTemplatePath')->nullable(); + $table->integer('pdfId')->nullable(); + $table->string('language')->nullable(); + $table->integer('renderSiteId')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::PDFS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('storeId')->nullable(); + $table->string('name'); + $table->string('handle'); + $table->string('description')->nullable(); + $table->string('templatePath'); + $table->string('fileNameFormat')->nullable(); + $table->string('paperOrientation')->default('portrait')->nullable(); + $table->string('paperSize')->default('letter')->nullable(); + $table->boolean('enabled')->default(true); + $table->boolean('isDefault')->default(false); + $table->integer('sortOrder')->nullable(); + $table->string('language')->nullable(); + $table->integer('linkExpiry')->default(86400); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::GATEWAYS, function (Blueprint $table) { + $table->integer('id', true); + $table->string('type'); + $table->string('name'); + $table->string('handle'); + $table->text('settings')->nullable(); + $table->enum('paymentType', ['authorize', 'purchase'])->default('purchase'); + $table->string('isFrontendEnabled', 500)->default('1'); + $table->text('orderCondition')->nullable(); + $table->text('shippingAddressCondition')->nullable(); + $table->text('billingAddressCondition')->nullable(); + $table->boolean('isArchived')->default(false); + $table->dateTime('dateArchived')->nullable(); + $table->integer('sortOrder')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::INVENTORYITEMS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('purchasableId'); + $table->string('countryCodeOfOrigin')->nullable(); + $table->string('administrativeAreaCodeOfOrigin')->nullable(); + $table->string('harmonizedSystemCode')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::INVENTORYLOCATIONS, function (Blueprint $table) { + $table->integer('id', true); + $table->string('handle'); + $table->string('name'); + $table->integer('addressId')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->dateTime('dateDeleted')->nullable(); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::INVENTORYLOCATIONS_STORES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('inventoryLocationId'); + $table->integer('storeId'); + $table->integer('sortOrder')->nullable(); // per store + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::INVENTORYTRANSACTIONS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('inventoryLocationId'); + $table->integer('inventoryItemId'); + $table->string('movementHash'); + $table->integer('quantity'); + $table->enum('type', [ + 'incoming', + 'available', + 'committed', + 'reserved', + 'damaged', + 'safety', + 'fulfilled', + 'qualityControl', + ]); + $table->string('note')->nullable(); + $table->integer('transferId')->nullable(); // Can be null + $table->integer('lineItemId')->nullable(); // Can be null + $table->integer('userId')->nullable(); // Can be null + $table->dateTime('dateCreated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::LINEITEMS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('orderId'); + $table->enum('type', ['purchasable', 'custom'])->default('purchasable'); + $table->integer('purchasableId')->nullable(); + $table->integer('taxCategoryId'); + $table->integer('shippingCategoryId'); + $table->text('description')->nullable(); + $table->text('options')->nullable(); + $table->string('optionsSignature'); + $table->decimal('price', 14, 4)->unsigned(); + $table->decimal('promotionalPrice', 14, 4)->unsigned()->nullable(); + $table->decimal('promotionalAmount', 14, 4)->default(0); + $table->decimal('salePrice', 14, 4)->default(0); + $table->string('sku')->nullable(); + $table->decimal('weight', 14, 4)->default(0)->unsigned(); + $table->decimal('height', 14, 4)->default(0)->unsigned(); + $table->decimal('length', 14, 4)->default(0)->unsigned(); + $table->decimal('width', 14, 4)->default(0)->unsigned(); + $table->decimal('subtotal', 14, 4)->default(0)->unsigned(); + $table->decimal('total', 14, 4)->default(0); + $table->unsignedInteger('qty'); + $table->text('note')->nullable(); + $table->text('privateNote')->nullable(); + $table->boolean('hasFreeShipping')->nullable(); + $table->boolean('isPromotable')->nullable(); + $table->boolean('isShippable')->nullable(); + $table->boolean('isTaxable')->nullable(); + $table->longText('snapshot')->nullable(); + $table->integer('lineItemStatusId')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::LINEITEMSTATUSES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('storeId')->nullable(); + $table->string('name'); + $table->string('handle'); + $table->enum('color', ['green', 'orange', 'red', 'blue', 'yellow', 'pink', 'purple', 'turquoise', 'light', 'grey', 'black'])->default('green'); + $table->boolean('isArchived')->default(false); + $table->dateTime('dateArchived')->nullable(); + $table->integer('sortOrder')->nullable(); + $table->boolean('default')->default(false); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::ORDERADJUSTMENTS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('orderId'); + $table->integer('lineItemId')->nullable(); + $table->string('type'); + $table->string('name')->nullable(); + $table->string('description')->nullable(); + $table->decimal('amount', 14, 4); + $table->boolean('included')->default(false); + $table->boolean('isEstimated')->default(false); + $table->longText('sourceSnapshot')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::ORDERNOTICES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('orderId'); + $table->string('type')->nullable(); + $table->string('attribute')->nullable(); + $table->text('message')->nullable(); + $table->string('noticeType')->default('customer'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::ORDERHISTORIES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('orderId'); + $table->integer('userId')->nullable(); + $table->string('userName')->nullable(); + $table->integer('prevStatusId')->nullable(); + $table->integer('newStatusId')->nullable(); + $table->text('message')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::ORDERS, function (Blueprint $table) { + $table->integer('id'); + $table->integer('storeId'); + $table->integer('billingAddressId')->nullable(); + $table->integer('shippingAddressId')->nullable(); + $table->integer('estimatedBillingAddressId')->nullable(); + $table->integer('estimatedShippingAddressId')->nullable(); + $table->integer('sourceShippingAddressId')->nullable(); + $table->integer('sourceBillingAddressId')->nullable(); + $table->integer('gatewayId')->nullable(); + $table->integer('paymentSourceId')->nullable(); + $table->integer('customerId')->nullable(); // Customer ID is a User element ID + $table->boolean('customerDeleted')->default(false); + $table->integer('orderStatusId')->nullable(); + $table->string('number', 32)->nullable(); + $table->string('reference')->nullable(); + $table->string('couponCode')->nullable(); + $table->decimal('itemTotal', 14, 4)->default(0)->nullable(); + $table->decimal('itemSubtotal', 14, 4)->default(0)->nullable(); + $table->unsignedInteger('totalQty')->nullable(); + $table->decimal('totalWeight', 14, 4)->default(0)->unsigned()->nullable(); + $table->decimal('total', 14, 4)->default(0)->nullable(); + $table->decimal('totalPrice', 14, 4)->default(0)->nullable(); + $table->decimal('totalPaid', 14, 4)->default(0)->nullable(); + $table->decimal('totalDiscount', 14, 4)->default(0)->nullable(); + $table->decimal('totalTax', 14, 4)->default(0)->nullable(); + $table->decimal('totalTaxIncluded', 14, 4)->default(0)->nullable(); + $table->decimal('totalShippingCost', 14, 4)->default(0)->nullable(); + $table->enum('paidStatus', ['paid', 'partial', 'unpaid', 'overPaid'])->nullable(); + $table->string('email')->nullable(); + $table->string('orderCompletedEmail')->nullable(); + $table->boolean('isCompleted')->default(false); + $table->dateTime('dateOrdered')->nullable(); + $table->dateTime('datePaid')->nullable(); + $table->dateTime('dateFirstPaid')->nullable(); + $table->dateTime('dateAuthorized')->nullable(); + $table->string('currency')->nullable(); + $table->string('paymentCurrency')->nullable(); + $table->string('lastIp')->nullable(); + $table->string('orderLanguage', 12); + $table->enum('origin', ['web', 'cp', 'remote'])->default('web'); + $table->text('message')->nullable(); + $table->boolean('registerUserOnOrderComplete')->default(false); + $table->boolean('saveBillingAddressOnOrderComplete')->default(false); + $table->boolean('makePrimaryBillingAddress')->default(false); + $table->boolean('saveShippingAddressOnOrderComplete')->default(false); + $table->boolean('makePrimaryShippingAddress')->default(false); + $table->enum('recalculationMode', ['all', 'none', 'adjustmentsOnly'])->default('all'); + $table->text('returnUrl')->nullable(); + $table->text('cancelUrl')->nullable(); + $table->string('shippingMethodHandle')->default(''); + $table->string('shippingMethodName')->default(''); + $table->integer('orderSiteId')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + $table->primary('id'); + }); + + Schema::create(Table::ORDERSTATUS_EMAILS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('orderStatusId'); + $table->integer('emailId'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::ORDERSTATUSES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('storeId')->nullable(); + $table->string('name'); + $table->string('handle'); + $table->enum('color', ['green', 'orange', 'red', 'blue', 'yellow', 'pink', 'purple', 'turquoise', 'light', 'grey', 'black'])->default('green'); + $table->string('description')->nullable(); + $table->dateTime('dateDeleted')->nullable(); + $table->integer('sortOrder')->nullable(); + $table->boolean('default')->default(false); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::PAYMENTCURRENCIES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('storeId'); + $table->string('iso', 3); + $table->boolean('primary')->default(false); + $table->decimal('rate', 14, 4)->default(0); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::PAYMENTSOURCES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('customerId'); + $table->integer('gatewayId'); + $table->string('token'); + $table->string('description')->nullable(); + $table->text('response')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::PLANS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('gatewayId')->nullable(); + $table->integer('planInformationId')->nullable(); + $table->string('name'); + $table->string('handle'); + $table->string('reference'); + $table->boolean('enabled')->default(false); + $table->text('planData')->nullable(); + $table->boolean('isArchived')->default(false); + $table->dateTime('dateArchived')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->integer('sortOrder')->nullable(); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::PRODUCTS, function (Blueprint $table) { + $table->integer('id'); + $table->integer('typeId')->nullable(); + $table->integer('defaultVariantId')->nullable(); + $table->dateTime('postDate')->nullable(); + $table->dateTime('expiryDate')->nullable(); + $table->string('defaultSku')->nullable(); + $table->decimal('defaultPrice', 14, 4)->nullable(); + $table->decimal('defaultHeight', 14, 4)->nullable(); + $table->decimal('defaultLength', 14, 4)->nullable(); + $table->decimal('defaultWidth', 14, 4)->nullable(); + $table->decimal('defaultWeight', 14, 4)->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + $table->primary('id'); + }); + + Schema::create(Table::PRODUCTTYPES, function (Blueprint $table) { + $table->integer('id', true); + $table->boolean('isStructure')->default(false); + $table->unsignedSmallInteger('maxLevels')->nullable(); + $table->enum('defaultPlacement', [ProductType::DEFAULT_PLACEMENT_BEGINNING, ProductType::DEFAULT_PLACEMENT_END])->default('end'); + $table->integer('structureId')->nullable(); + $table->integer('fieldLayoutId')->nullable(); + $table->integer('variantFieldLayoutId')->nullable(); + $table->string('name'); + $table->string('handle'); + $table->boolean('enableVersioning')->default(false); + $table->integer('maxVariants')->nullable(); + $table->boolean('hasDimensions')->default(false); + + // Variant title stuff + $table->boolean('hasVariantTitleField')->default(true); + $table->string('variantTitleFormat'); + $table->string('variantTitleTranslationMethod')->default('site'); + $table->string('variantTitleTranslationKeyFormat')->nullable(); + $table->string('variantUiLabelFormat')->default('{title}'); + + // Product title stuff + $table->boolean('hasProductTitleField')->default(true); + $table->string('productTitleFormat')->nullable(); + $table->string('productTitleTranslationMethod')->default('site'); + $table->string('productTitleTranslationKeyFormat')->nullable(); + $table->string('productUiLabelFormat')->default('{title}'); + + // Slug stuff + $table->boolean('showSlugField')->default(true); + $table->string('slugTranslationMethod')->default('site'); + $table->string('slugTranslationKeyFormat')->nullable(); + + $table->string('propagationMethod')->default(PropagationMethod::All->value); + $table->json('previewTargets')->nullable(); + + $table->string('skuFormat')->nullable(); + $table->string('descriptionFormat')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::PRODUCTTYPES_SITES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('productTypeId'); + $table->integer('siteId'); + $table->text('uriFormat')->nullable(); + $table->string('template', 500)->nullable(); + $table->boolean('hasUrls')->default(false); + $table->boolean('enabledByDefault')->default(true); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::PRODUCTTYPES_SHIPPINGCATEGORIES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('productTypeId'); + $table->integer('shippingCategoryId'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::PRODUCTTYPES_TAXCATEGORIES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('productTypeId'); + $table->integer('taxCategoryId'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::PURCHASABLES, function (Blueprint $table) { + $table->integer('id', true); + $table->string('sku'); + $table->text('description')->nullable(); + $table->decimal('width', 14, 4)->nullable(); + $table->decimal('height', 14, 4)->nullable(); + $table->decimal('length', 14, 4)->nullable(); + $table->decimal('weight', 14, 4)->nullable(); + $table->integer('taxCategoryId'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::PURCHASABLES_STORES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('purchasableId'); + $table->integer('storeId'); + $table->decimal('basePrice', 14, 4)->nullable(); // @TODO Consider storing as string to avoid float-precision issues + $table->decimal('basePromotionalPrice', 14, 4)->nullable(); // @TODO Consider storing as string to avoid float-precision issues + $table->boolean('promotable')->default(false); + $table->boolean('availableForPurchase')->default(true); + $table->boolean('freeShipping')->default(true); + $table->boolean('inventoryTracked')->default(true); + $table->boolean('allowOutOfStockPurchases')->default(false); + $table->integer('stock')->nullable(); // This is a summary value used for searching and sorting + $table->boolean('tracked')->default(false); + $table->integer('minQty')->nullable(); + $table->integer('maxQty')->nullable(); + $table->integer('shippingCategoryId')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::SALE_PURCHASABLES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('saleId'); + $table->integer('purchasableId'); + $table->string('purchasableType'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + // @TODO Rename to `sale_entries` table in Commerce 6.0, or remove if the purchasable condition builder fully replaces it + Schema::create(Table::SALE_CATEGORIES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('saleId'); + $table->integer('categoryId'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::SALE_USERGROUPS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('saleId'); + $table->integer('userGroupId'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::SALES, function (Blueprint $table) { + $table->integer('id', true); + $table->string('name'); + $table->text('description')->nullable(); + $table->dateTime('dateFrom')->nullable(); + $table->dateTime('dateTo')->nullable(); + $table->enum('apply', ['toPercent', 'toFlat', 'byPercent', 'byFlat']); + $table->decimal('applyAmount', 14, 4); + $table->boolean('allGroups')->default(false); + $table->boolean('allPurchasables')->default(false); + $table->boolean('allCategories')->default(false); + $table->enum('categoryRelationshipType', ['element', 'sourceElement', 'targetElement'])->default('element'); + $table->boolean('enabled')->default(true); + $table->boolean('ignorePrevious')->default(false); + $table->boolean('stopProcessing')->default(false); + $table->integer('sortOrder')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::SHIPPINGCATEGORIES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('storeId'); + $table->string('name'); + $table->string('handle'); + $table->string('icon')->nullable(); + $table->string('color')->nullable(); + $table->string('description')->nullable(); + $table->boolean('default')->default(false); + $table->dateTime('dateDeleted')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::SHIPPINGMETHODS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('storeId'); + $table->string('name'); + $table->string('handle'); + $table->string('icon')->nullable(); + $table->string('color')->nullable(); + $table->text('orderCondition')->nullable(); + $table->text('customerCondition')->nullable(); + $table->boolean('enabled')->default(true); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::SHIPPINGRULE_CATEGORIES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('shippingRuleId')->nullable(); + $table->integer('shippingCategoryId')->nullable(); + $table->enum('condition', ['allow', 'disallow', 'require']); + $table->decimal('perItemRate', 14, 4)->nullable(); + $table->decimal('weightRate', 14, 4)->nullable(); + $table->decimal('percentageRate', 14, 4)->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::SHIPPINGRULES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('methodId'); + $table->string('name'); + $table->string('description')->nullable(); + $table->integer('priority')->default(0); + $table->boolean('enabled')->default(true); + $table->text('orderConditionFormula')->nullable(); + $table->text('orderCondition')->nullable(); + $table->text('customerCondition')->nullable(); + $table->decimal('baseRate', 14, 4)->default(0); + $table->decimal('perItemRate', 14, 4)->default(0); + $table->decimal('weightRate', 14, 4)->default(0); + $table->decimal('percentageRate', 14, 4)->default(0); + $table->decimal('minRate', 14, 4)->default(0); + $table->decimal('maxRate', 14, 4)->default(0); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::SHIPPINGZONES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('storeId')->nullable(); + $table->string('name'); + $table->string('description')->nullable(); + $table->text('condition')->nullable(); + $table->boolean('default')->default(false); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::SITESTORES, function (Blueprint $table) { + $table->integer('siteId'); + $table->integer('storeId')->nullable(); // defaults to primary store in app + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + $table->primary('siteId'); + }); + + Schema::create(Table::STORES, function (Blueprint $table) { + $table->integer('id', true); + $table->string('name'); + $table->string('handle'); + $table->boolean('primary'); + $table->string('currency')->default('USD'); + $table->string('autoSetCartShippingMethodOption')->default('false'); + $table->string('autoSetNewCartAddresses')->default('false'); + $table->string('autoSetPaymentSource')->default('false'); + $table->string('allowEmptyCartOnCheckout')->default('false'); + $table->string('allowCheckoutWithoutPayment')->default('false'); + $table->string('allowPartialPaymentOnCheckout')->default('false'); + $table->string('requireShippingAddressAtCheckout')->default('false'); + $table->string('requireBillingAddressAtCheckout')->default('false'); + $table->string('requireShippingMethodSelectionAtCheckout')->default('false'); + $table->string('useBillingAddressForTax')->default('false'); + $table->string('validateOrganizationTaxIdAsVatId')->default('false'); + $table->string('orderReferenceFormat')->nullable(); + $table->string('freeOrderPaymentStrategy')->default('complete')->nullable(); + $table->string('minimumTotalPriceStrategy')->default('default')->nullable(); + $table->integer('sortOrder')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::STORESETTINGS, function (Blueprint $table) { + $table->integer('id'); + $table->integer('locationAddressId')->nullable(); + $table->text('countries')->nullable(); + $table->text('marketAddressCondition')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + $table->primary('id'); + }); + + Schema::create(Table::SUBSCRIPTIONS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('userId'); + $table->integer('planId')->nullable(); + $table->integer('gatewayId')->nullable(); + $table->integer('orderId')->nullable(); + $table->string('reference'); + $table->text('subscriptionData')->nullable(); + $table->integer('trialDays'); + $table->dateTime('nextPaymentDate')->nullable(); + $table->boolean('hasStarted')->default(true); + $table->boolean('isSuspended')->default(false); + $table->dateTime('dateSuspended')->nullable(); + $table->boolean('isCanceled')->default(false); + $table->dateTime('dateCanceled')->nullable(); + $table->boolean('isExpired')->default(false); + $table->text('returnUrl')->nullable(); + $table->dateTime('dateExpired')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::TAXCATEGORIES, function (Blueprint $table) { + $table->integer('id', true); + $table->string('name'); + $table->string('handle'); + $table->string('icon')->nullable(); + $table->string('color')->nullable(); + $table->string('description')->nullable(); + $table->boolean('default')->default(false); + $table->dateTime('dateDeleted')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::TAXRATES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('storeId'); + $table->integer('taxZoneId')->nullable(); + $table->boolean('isEverywhere')->default(true); + $table->integer('taxCategoryId')->nullable(); + $table->string('name'); + $table->string('code')->nullable(); + $table->decimal('rate', 14, 10); + $table->boolean('include')->default(false); + $table->boolean('isVat')->default(false); // @TODO Remove in Commerce 6.0 + $table->text('taxIdValidators')->nullable(); + $table->boolean('removeIncluded')->default(false); + $table->boolean('removeVatIncluded')->default(false); + $table->enum('taxable', ['purchasable', 'price', 'shipping', 'price_shipping', 'order_total_shipping', 'order_total_price']); + $table->boolean('enabled')->default(true); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::TAXZONES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('storeId'); + $table->string('name'); + $table->string('description')->nullable(); + $table->text('condition')->nullable(); + $table->boolean('default')->default(false); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::TRANSACTIONS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('orderId'); + $table->integer('parentId')->nullable(); + $table->integer('gatewayId')->nullable(); + $table->integer('userId')->nullable(); // Stays as userId since it could be a logged-in user or store administrator. So not just a customer. + $table->string('hash', 32)->nullable(); + $table->enum('type', ['authorize', 'capture', 'purchase', 'refund']); + $table->decimal('amount', 14, 4)->nullable(); + $table->decimal('paymentAmount', 14, 4)->nullable(); + $table->string('currency')->nullable(); + $table->string('paymentCurrency')->nullable(); + $table->decimal('paymentRate', 14, 4)->nullable(); + $table->enum('status', ['pending', 'redirect', 'success', 'failed', 'processing']); + $table->string('reference')->nullable(); + $table->string('code')->nullable(); + $table->text('message')->nullable(); + $table->mediumText('note')->nullable(); + $table->text('response')->nullable(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::TRANSFERS, function (Blueprint $table) { + $table->integer('id', true); + $table->enum('transferStatus', [ + 'draft', + 'pending', + 'partial', + 'received', + ]); + $table->integer('originLocationId')->nullable(); + $table->integer('destinationLocationId')->nullable(); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::TRANSFERDETAILS, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('transferId'); + $table->integer('inventoryItemId')->nullable(); + $table->string('inventoryItemDescription'); + $table->integer('quantity'); + $table->integer('quantityAccepted'); + $table->integer('quantityRejected'); + $table->char('uid', 36)->default('0'); + }); + + Schema::create(Table::VARIANTS, function (Blueprint $table) { + $table->integer('id'); + $table->integer('primaryOwnerId')->nullable(); + $table->boolean('isDefault')->default(false); + $table->boolean('deletedWithProduct')->default(false); // @TODO Remove in Commerce 6.0 + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + $table->primary('id'); + }); + } + + /** + * Creates the indexes. + */ + public function createIndexes(): void + { + Schema::createIndex(Table::CATALOG_PRICING, ['catalogPricingRuleId']); + Schema::createIndex(Table::CATALOG_PRICING, ['isPromotionalPrice']); + Schema::createIndex(Table::CATALOG_PRICING, ['purchasableId']); + Schema::createIndex(Table::CATALOG_PRICING, ['storeId']); + Schema::createIndex(Table::CATALOG_PRICING, ['userId']); + Schema::createIndex(Table::CATALOG_PRICING, ['purchasableId', 'storeId', 'isPromotionalPrice', 'price', 'catalogPricingRuleId', 'dateFrom', 'dateTo']); + Schema::createIndex(Table::CATALOG_PRICING, ['purchasableId', 'storeId', 'isPromotionalPrice', 'price']); + Schema::createIndex(Table::CATALOG_PRICING, ['purchasableId', 'storeId']); + Schema::createIndex(Table::CATALOG_PRICING_QUEUE, ['reserved']); + Schema::createIndex(Table::CATALOG_PRICING_QUEUE, ['storeId', 'type', 'reserved']); + Schema::createIndex(Table::CATALOG_PRICING_RULES, ['storeId']); + Schema::createIndex(Table::CATALOG_PRICING_RULES_USERS, ['catalogPricingRuleId']); + Schema::createIndex(Table::CATALOG_PRICING_RULES_USERS, ['userId']); + Schema::createIndex(Table::COUPONS, ['code']); + Schema::createIndex(Table::COUPONS, ['discountId']); + Schema::createIndex(Table::CUSTOMERS, ['customerId'], unique: true); + Schema::createIndex(Table::CUSTOMERS, ['primaryBillingAddressId']); + Schema::createIndex(Table::CUSTOMERS, ['primaryPaymentSourceId']); + Schema::createIndex(Table::CUSTOMERS, ['primaryShippingAddressId']); + Schema::createIndex(Table::CUSTOMER_DISCOUNTUSES, ['discountId']); + Schema::createIndex(Table::CUSTOMER_DISCOUNTUSES, ['customerId', 'discountId'], unique: true); + Schema::createIndex(Table::DISCOUNTS, ['dateFrom']); + Schema::createIndex(Table::DISCOUNTS, ['dateTo']); + Schema::createIndex(Table::DISCOUNT_CATEGORIES, ['categoryId']); + Schema::createIndex(Table::DISCOUNT_CATEGORIES, ['discountId', 'categoryId'], unique: true); + Schema::createIndex(Table::DISCOUNT_PURCHASABLES, ['purchasableId']); + Schema::createIndex(Table::DISCOUNT_PURCHASABLES, ['discountId', 'purchasableId'], unique: true); + Schema::createIndex(Table::EMAILS, ['storeId']); + Schema::createIndex(Table::EMAIL_DISCOUNTUSES, ['discountId']); + Schema::createIndex(Table::EMAIL_DISCOUNTUSES, ['email', 'discountId'], unique: true); + Schema::createIndex(Table::GATEWAYS, ['handle']); + Schema::createIndex(Table::GATEWAYS, ['isArchived']); + Schema::createIndex(Table::INVENTORYITEMS, ['purchasableId'], unique: true); + Schema::createIndex(Table::INVENTORYTRANSACTIONS, ['inventoryItemId']); + Schema::createIndex(Table::INVENTORYTRANSACTIONS, ['lineItemId']); + Schema::createIndex(Table::INVENTORYTRANSACTIONS, ['transferId']); + Schema::createIndex(Table::INVENTORYTRANSACTIONS, ['userId']); + Schema::createIndex(Table::LINEITEMS, ['purchasableId']); + Schema::createIndex(Table::LINEITEMS, ['shippingCategoryId']); + Schema::createIndex(Table::LINEITEMS, ['taxCategoryId']); + Schema::createIndex(Table::LINEITEMS, ['orderId', 'purchasableId', 'optionsSignature'], unique: true); + Schema::createIndex(Table::LINEITEMSTATUSES, ['storeId']); + Schema::createIndex(Table::ORDERADJUSTMENTS, ['orderId']); + Schema::createIndex(Table::ORDERHISTORIES, ['newStatusId']); + Schema::createIndex(Table::ORDERHISTORIES, ['orderId']); + Schema::createIndex(Table::ORDERHISTORIES, ['prevStatusId']); + Schema::createIndex(Table::ORDERHISTORIES, ['userId']); + Schema::createIndex(Table::ORDERNOTICES, ['orderId']); + Schema::createIndex(Table::ORDERS, ['billingAddressId']); + Schema::createIndex(Table::ORDERS, ['customerId']); + Schema::createIndex(Table::ORDERS, ['email']); + Schema::createIndex(Table::ORDERS, ['estimatedBillingAddressId']); + Schema::createIndex(Table::ORDERS, ['estimatedShippingAddressId']); + Schema::createIndex(Table::ORDERS, ['gatewayId']); + Schema::createIndex(Table::ORDERS, ['number'], unique: true); + Schema::createIndex(Table::ORDERS, ['orderStatusId']); + Schema::createIndex(Table::ORDERS, ['reference']); + Schema::createIndex(Table::ORDERS, ['shippingAddressId']); + Schema::createIndex(Table::ORDERS, ['sourceBillingAddressId']); + Schema::createIndex(Table::ORDERS, ['sourceShippingAddressId']); + Schema::createIndex(Table::ORDERS, ['storeId']); + Schema::createIndex(Table::ORDERSTATUSES, ['storeId']); + Schema::createIndex(Table::ORDERSTATUS_EMAILS, ['emailId']); + Schema::createIndex(Table::ORDERSTATUS_EMAILS, ['orderStatusId']); + Schema::createIndex(Table::PAYMENTCURRENCIES, ['iso']); + Schema::createIndex(Table::PDFS, ['handle']); + Schema::createIndex(Table::PDFS, ['storeId']); + Schema::createIndex(Table::PLANS, ['gatewayId']); + Schema::createIndex(Table::PLANS, ['handle'], unique: true); + Schema::createIndex(Table::PLANS, ['reference']); + Schema::createIndex(Table::PRODUCTS, ['expiryDate']); + Schema::createIndex(Table::PRODUCTS, ['postDate']); + Schema::createIndex(Table::PRODUCTS, ['typeId']); + Schema::createIndex(Table::PRODUCTTYPES, ['structureId']); + Schema::createIndex(Table::PRODUCTTYPES, ['fieldLayoutId']); + Schema::createIndex(Table::PRODUCTTYPES, ['handle'], unique: true); + Schema::createIndex(Table::PRODUCTTYPES, ['variantFieldLayoutId']); + Schema::createIndex(Table::PRODUCTTYPES_SHIPPINGCATEGORIES, ['shippingCategoryId']); + Schema::createIndex(Table::PRODUCTTYPES_SHIPPINGCATEGORIES, ['productTypeId', 'shippingCategoryId'], unique: true); + Schema::createIndex(Table::PRODUCTTYPES_SITES, ['siteId']); + Schema::createIndex(Table::PRODUCTTYPES_SITES, ['productTypeId', 'siteId'], unique: true); + Schema::createIndex(Table::PRODUCTTYPES_TAXCATEGORIES, ['taxCategoryId']); + Schema::createIndex(Table::PRODUCTTYPES_TAXCATEGORIES, ['productTypeId', 'taxCategoryId'], unique: true); + Schema::createIndex(Table::PURCHASABLES, ['sku']); // Application layer enforces unique + Schema::createIndex(Table::PURCHASABLES_STORES, ['purchasableId']); // Application layer enforces unique + Schema::createIndex(Table::PURCHASABLES_STORES, ['storeId']); // Application layer enforces unique + Schema::createIndex(Table::SALE_CATEGORIES, ['categoryId']); + Schema::createIndex(Table::SALE_CATEGORIES, ['saleId', 'categoryId'], unique: true); + Schema::createIndex(Table::SALE_PURCHASABLES, ['purchasableId']); + Schema::createIndex(Table::SALE_PURCHASABLES, ['saleId', 'purchasableId'], unique: true); + Schema::createIndex(Table::SALE_USERGROUPS, ['userGroupId']); + Schema::createIndex(Table::SALE_USERGROUPS, ['saleId', 'userGroupId'], unique: true); + Schema::createIndex(Table::SHIPPINGCATEGORIES, ['storeId']); + Schema::createIndex(Table::SHIPPINGMETHODS, ['name']); + Schema::createIndex(Table::SHIPPINGMETHODS, ['storeId']); + Schema::createIndex(Table::SHIPPINGRULES, ['methodId']); + Schema::createIndex(Table::SHIPPINGRULES, ['name']); + Schema::createIndex(Table::SHIPPINGRULE_CATEGORIES, ['shippingCategoryId']); + Schema::createIndex(Table::SHIPPINGRULE_CATEGORIES, ['shippingRuleId']); + Schema::createIndex(Table::SHIPPINGZONES, ['name']); + Schema::createIndex(Table::SHIPPINGZONES, ['storeId']); + Schema::createIndex(Table::SUBSCRIPTIONS, ['dateCreated']); + Schema::createIndex(Table::SUBSCRIPTIONS, ['dateExpired']); + Schema::createIndex(Table::SUBSCRIPTIONS, ['gatewayId']); + Schema::createIndex(Table::SUBSCRIPTIONS, ['nextPaymentDate']); + Schema::createIndex(Table::SUBSCRIPTIONS, ['planId']); + Schema::createIndex(Table::SUBSCRIPTIONS, ['reference'], unique: true); + Schema::createIndex(Table::SUBSCRIPTIONS, ['userId']); + Schema::createIndex(Table::TAXRATES, ['storeId']); + Schema::createIndex(Table::TAXRATES, ['taxCategoryId']); + Schema::createIndex(Table::TAXRATES, ['taxZoneId']); + Schema::createIndex(Table::TAXZONES, ['name']); + Schema::createIndex(Table::TAXZONES, ['storeId']); + Schema::createIndex(Table::TRANSACTIONS, ['gatewayId']); + Schema::createIndex(Table::TRANSACTIONS, ['orderId']); + Schema::createIndex(Table::TRANSACTIONS, ['parentId']); + Schema::createIndex(Table::TRANSACTIONS, ['userId']); + Schema::createIndex(Table::TRANSACTIONS, ['hash']); + Schema::createIndex(Table::TRANSFERS, ['destinationLocationId']); + Schema::createIndex(Table::TRANSFERS, ['originLocationId']); + Schema::createIndex(Table::TRANSFERDETAILS, ['transferId']); + Schema::createIndex(Table::TRANSFERDETAILS, ['inventoryItemId']); + Schema::createIndex(Table::VARIANTS, ['primaryOwnerId']); + } + + /** + * Adds the foreign keys. + */ + public function addForeignKeys(): void + { + Schema::table(Table::CATALOG_PRICING, fn (Blueprint $table) => $table->foreign('catalogPricingRuleId')->references('id')->on(Table::CATALOG_PRICING_RULES)->cascadeOnDelete()); + Schema::table(Table::CATALOG_PRICING, fn (Blueprint $table) => $table->foreign('purchasableId')->references('id')->on(Table::PURCHASABLES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::CATALOG_PRICING, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()); + Schema::table(Table::CATALOG_PRICING, fn (Blueprint $table) => $table->foreign('userId')->references('id')->on(CraftTable::USERS)->cascadeOnDelete()); + Schema::table(Table::CATALOG_PRICING_QUEUE, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::CATALOG_PRICING_RULES, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::CATALOG_PRICING_RULES_USERS, fn (Blueprint $table) => $table->foreign('catalogPricingRuleId')->references('id')->on(Table::CATALOG_PRICING_RULES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::CATALOG_PRICING_RULES_USERS, fn (Blueprint $table) => $table->foreign('userId')->references('id')->on(CraftTable::USERS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::COUPONS, fn (Blueprint $table) => $table->foreign('discountId')->references('id')->on(Table::DISCOUNTS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::CUSTOMERS, fn (Blueprint $table) => $table->foreign('customerId')->references('id')->on(CraftTable::ELEMENTS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::CUSTOMERS, fn (Blueprint $table) => $table->foreign('primaryBillingAddressId')->references('id')->on(CraftTable::ELEMENTS)->nullOnDelete()); + Schema::table(Table::CUSTOMERS, fn (Blueprint $table) => $table->foreign('primaryPaymentSourceId')->references('id')->on(Table::PAYMENTSOURCES)->nullOnDelete()); + Schema::table(Table::CUSTOMERS, fn (Blueprint $table) => $table->foreign('primaryShippingAddressId')->references('id')->on(CraftTable::ELEMENTS)->nullOnDelete()); + Schema::table(Table::CUSTOMER_DISCOUNTUSES, fn (Blueprint $table) => $table->foreign('customerId')->references('id')->on(CraftTable::ELEMENTS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::CUSTOMER_DISCOUNTUSES, fn (Blueprint $table) => $table->foreign('discountId')->references('id')->on(Table::DISCOUNTS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::DISCOUNTS, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::DISCOUNT_CATEGORIES, fn (Blueprint $table) => $table->foreign('categoryId')->references('id')->on(CraftTable::ELEMENTS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::DISCOUNT_CATEGORIES, fn (Blueprint $table) => $table->foreign('discountId')->references('id')->on(Table::DISCOUNTS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::DISCOUNT_PURCHASABLES, fn (Blueprint $table) => $table->foreign('discountId')->references('id')->on(Table::DISCOUNTS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::DISCOUNT_PURCHASABLES, fn (Blueprint $table) => $table->foreign('purchasableId')->references('id')->on(Table::PURCHASABLES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::DONATIONS, fn (Blueprint $table) => $table->foreign('id')->references('id')->on(CraftTable::ELEMENTS)->cascadeOnDelete()); + Schema::table(Table::EMAILS, fn (Blueprint $table) => $table->foreign('pdfId')->references('id')->on(Table::PDFS)->nullOnDelete()); + Schema::table(Table::EMAILS, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::EMAILS, fn (Blueprint $table) => $table->foreign('renderSiteId')->references('id')->on(CraftTable::SITES)->nullOnDelete()); + Schema::table(Table::EMAIL_DISCOUNTUSES, fn (Blueprint $table) => $table->foreign('discountId')->references('id')->on(Table::DISCOUNTS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::INVENTORYITEMS, fn (Blueprint $table) => $table->foreign('purchasableId')->references('id')->on(Table::PURCHASABLES)->cascadeOnDelete()); + Schema::table(Table::INVENTORYLOCATIONS, fn (Blueprint $table) => $table->foreign('addressId')->references('id')->on(CraftTable::ELEMENTS)->cascadeOnDelete()); + Schema::table(Table::INVENTORYLOCATIONS_STORES, fn (Blueprint $table) => $table->foreign('inventoryLocationId')->references('id')->on(Table::INVENTORYLOCATIONS)->cascadeOnDelete()); + Schema::table(Table::INVENTORYLOCATIONS_STORES, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()); + Schema::table(Table::INVENTORYTRANSACTIONS, fn (Blueprint $table) => $table->foreign('inventoryItemId')->references('id')->on(Table::INVENTORYITEMS)->cascadeOnDelete()); + Schema::table(Table::INVENTORYTRANSACTIONS, fn (Blueprint $table) => $table->foreign('inventoryLocationId')->references('id')->on(Table::INVENTORYLOCATIONS)->cascadeOnDelete()); + Schema::table(Table::INVENTORYTRANSACTIONS, fn (Blueprint $table) => $table->foreign('lineItemId')->references('id')->on(Table::LINEITEMS)->cascadeOnDelete()); + // NOTE: the legacy migration added this same FK twice (once here, once further down); only ported once. + Schema::table(Table::INVENTORYTRANSACTIONS, fn (Blueprint $table) => $table->foreign('transferId')->references('id')->on(Table::TRANSFERS)->nullOnDelete()); + Schema::table(Table::INVENTORYTRANSACTIONS, fn (Blueprint $table) => $table->foreign('userId')->references('id')->on(CraftTable::USERS)->nullOnDelete()); + Schema::table(Table::LINEITEMS, fn (Blueprint $table) => $table->foreign('orderId')->references('id')->on(Table::ORDERS)->cascadeOnDelete()); + Schema::table(Table::LINEITEMS, fn (Blueprint $table) => $table->foreign('purchasableId')->references('id')->on(CraftTable::ELEMENTS)->nullOnDelete()->cascadeOnUpdate()); + Schema::table(Table::LINEITEMS, fn (Blueprint $table) => $table->foreign('shippingCategoryId')->references('id')->on(Table::SHIPPINGCATEGORIES)->cascadeOnUpdate()); + Schema::table(Table::LINEITEMS, fn (Blueprint $table) => $table->foreign('taxCategoryId')->references('id')->on(Table::TAXCATEGORIES)->cascadeOnUpdate()); + Schema::table(Table::LINEITEMSTATUSES, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::ORDERADJUSTMENTS, fn (Blueprint $table) => $table->foreign('orderId')->references('id')->on(Table::ORDERS)->cascadeOnDelete()); + Schema::table(Table::ORDERHISTORIES, fn (Blueprint $table) => $table->foreign('newStatusId')->references('id')->on(Table::ORDERSTATUSES)->restrictOnDelete()->cascadeOnUpdate()); + Schema::table(Table::ORDERHISTORIES, fn (Blueprint $table) => $table->foreign('orderId')->references('id')->on(Table::ORDERS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::ORDERHISTORIES, fn (Blueprint $table) => $table->foreign('prevStatusId')->references('id')->on(Table::ORDERSTATUSES)->restrictOnDelete()->cascadeOnUpdate()); + Schema::table(Table::ORDERHISTORIES, fn (Blueprint $table) => $table->foreign('userId')->references('id')->on(CraftTable::ELEMENTS)->nullOnDelete()); + Schema::table(Table::ORDERNOTICES, fn (Blueprint $table) => $table->foreign('orderId')->references('id')->on(Table::ORDERS)->cascadeOnDelete()); + Schema::table(Table::ORDERS, fn (Blueprint $table) => $table->foreign('billingAddressId')->references('id')->on(CraftTable::ELEMENTS)->nullOnDelete()); + Schema::table(Table::ORDERS, fn (Blueprint $table) => $table->foreign('customerId')->references('id')->on(CraftTable::ELEMENTS)->nullOnDelete()); + Schema::table(Table::ORDERS, fn (Blueprint $table) => $table->foreign('estimatedBillingAddressId')->references('id')->on(CraftTable::ELEMENTS)->nullOnDelete()); + Schema::table(Table::ORDERS, fn (Blueprint $table) => $table->foreign('estimatedShippingAddressId')->references('id')->on(CraftTable::ELEMENTS)->nullOnDelete()); + Schema::table(Table::ORDERS, fn (Blueprint $table) => $table->foreign('gatewayId')->references('id')->on(Table::GATEWAYS)->nullOnDelete()); + Schema::table(Table::ORDERS, fn (Blueprint $table) => $table->foreign('id')->references('id')->on(CraftTable::ELEMENTS)->cascadeOnDelete()); + Schema::table(Table::ORDERS, fn (Blueprint $table) => $table->foreign('orderStatusId')->references('id')->on(Table::ORDERSTATUSES)->restrictOnDelete()->cascadeOnUpdate()); + Schema::table(Table::ORDERS, fn (Blueprint $table) => $table->foreign('paymentSourceId')->references('id')->on(Table::PAYMENTSOURCES)->nullOnDelete()); + Schema::table(Table::ORDERS, fn (Blueprint $table) => $table->foreign('shippingAddressId')->references('id')->on(CraftTable::ELEMENTS)->nullOnDelete()); + Schema::table(Table::ORDERS, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::ORDERSTATUSES, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::ORDERSTATUS_EMAILS, fn (Blueprint $table) => $table->foreign('emailId')->references('id')->on(Table::EMAILS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::ORDERSTATUS_EMAILS, fn (Blueprint $table) => $table->foreign('orderStatusId')->references('id')->on(Table::ORDERSTATUSES)->restrictOnDelete()->cascadeOnUpdate()); + Schema::table(Table::PAYMENTCURRENCIES, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::PAYMENTSOURCES, fn (Blueprint $table) => $table->foreign('customerId')->references('id')->on(CraftTable::ELEMENTS)->cascadeOnDelete()); + Schema::table(Table::PAYMENTSOURCES, fn (Blueprint $table) => $table->foreign('gatewayId')->references('id')->on(Table::GATEWAYS)->cascadeOnDelete()); + Schema::table(Table::PDFS, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()); + Schema::table(Table::PLANS, fn (Blueprint $table) => $table->foreign('gatewayId')->references('id')->on(Table::GATEWAYS)->cascadeOnDelete()); + Schema::table(Table::PLANS, fn (Blueprint $table) => $table->foreign('planInformationId')->references('id')->on(CraftTable::ELEMENTS)->nullOnDelete()); + Schema::table(Table::PRODUCTS, fn (Blueprint $table) => $table->foreign('id')->references('id')->on(CraftTable::ELEMENTS)->cascadeOnDelete()); + Schema::table(Table::PRODUCTS, fn (Blueprint $table) => $table->foreign('typeId')->references('id')->on(Table::PRODUCTTYPES)->cascadeOnDelete()); + Schema::table(Table::PRODUCTS, fn (Blueprint $table) => $table->foreign('defaultVariantId')->references('id')->on(CraftTable::ELEMENTS)->nullOnDelete()); + Schema::table(Table::PRODUCTTYPES, fn (Blueprint $table) => $table->foreign('fieldLayoutId')->references('id')->on(CraftTable::FIELDLAYOUTS)->nullOnDelete()); + Schema::table(Table::PRODUCTTYPES, fn (Blueprint $table) => $table->foreign('variantFieldLayoutId')->references('id')->on(CraftTable::FIELDLAYOUTS)->nullOnDelete()); + Schema::table(Table::PRODUCTTYPES, fn (Blueprint $table) => $table->foreign('structureId')->references('id')->on(CraftTable::STRUCTURES)->nullOnDelete()); + Schema::table(Table::PRODUCTTYPES_SHIPPINGCATEGORIES, fn (Blueprint $table) => $table->foreign('productTypeId')->references('id')->on(Table::PRODUCTTYPES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::PRODUCTTYPES_SHIPPINGCATEGORIES, fn (Blueprint $table) => $table->foreign('shippingCategoryId', 'commerce_pts_shippingcategoryid_foreign')->references('id')->on(Table::SHIPPINGCATEGORIES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::PRODUCTTYPES_SITES, fn (Blueprint $table) => $table->foreign('productTypeId')->references('id')->on(Table::PRODUCTTYPES)->cascadeOnDelete()); + Schema::table(Table::PRODUCTTYPES_SITES, fn (Blueprint $table) => $table->foreign('siteId')->references('id')->on(CraftTable::SITES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::PRODUCTTYPES_TAXCATEGORIES, fn (Blueprint $table) => $table->foreign('productTypeId')->references('id')->on(Table::PRODUCTTYPES)->cascadeOnDelete()); + Schema::table(Table::PRODUCTTYPES_TAXCATEGORIES, fn (Blueprint $table) => $table->foreign('taxCategoryId')->references('id')->on(Table::TAXCATEGORIES)->cascadeOnDelete()); + Schema::table(Table::PURCHASABLES, fn (Blueprint $table) => $table->foreign('id')->references('id')->on(CraftTable::ELEMENTS)->cascadeOnDelete()); + Schema::table(Table::PURCHASABLES, fn (Blueprint $table) => $table->foreign('taxCategoryId')->references('id')->on(Table::TAXCATEGORIES)); + // NOTE: the legacy migration added this same FK twice (once with just cascadeOnDelete, once with cascadeOnDelete+cascadeOnUpdate); only the more complete one is ported. + Schema::table(Table::PURCHASABLES_STORES, fn (Blueprint $table) => $table->foreign('purchasableId')->references('id')->on(Table::PURCHASABLES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::PURCHASABLES_STORES, fn (Blueprint $table) => $table->foreign('shippingCategoryId')->references('id')->on(Table::SHIPPINGCATEGORIES)->nullOnDelete()); + Schema::table(Table::PURCHASABLES_STORES, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()); + Schema::table(Table::SALE_CATEGORIES, fn (Blueprint $table) => $table->foreign('categoryId')->references('id')->on(CraftTable::ELEMENTS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::SALE_CATEGORIES, fn (Blueprint $table) => $table->foreign('saleId')->references('id')->on(Table::SALES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::SALE_PURCHASABLES, fn (Blueprint $table) => $table->foreign('purchasableId')->references('id')->on(Table::PURCHASABLES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::SALE_PURCHASABLES, fn (Blueprint $table) => $table->foreign('saleId')->references('id')->on(Table::SALES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::SALE_USERGROUPS, fn (Blueprint $table) => $table->foreign('saleId')->references('id')->on(Table::SALES)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::SALE_USERGROUPS, fn (Blueprint $table) => $table->foreign('userGroupId')->references('id')->on(CraftTable::USERGROUPS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::SHIPPINGCATEGORIES, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()); + Schema::table(Table::SHIPPINGMETHODS, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()); + Schema::table(Table::SHIPPINGRULES, fn (Blueprint $table) => $table->foreign('methodId')->references('id')->on(Table::SHIPPINGMETHODS)->cascadeOnDelete()); + Schema::table(Table::SHIPPINGRULE_CATEGORIES, fn (Blueprint $table) => $table->foreign('shippingCategoryId')->references('id')->on(Table::SHIPPINGCATEGORIES)->cascadeOnDelete()); + Schema::table(Table::SHIPPINGRULE_CATEGORIES, fn (Blueprint $table) => $table->foreign('shippingRuleId')->references('id')->on(Table::SHIPPINGRULES)->cascadeOnDelete()); + Schema::table(Table::SHIPPINGZONES, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()); + Schema::table(Table::STORESETTINGS, fn (Blueprint $table) => $table->foreign('locationAddressId')->references('id')->on(CraftTable::ELEMENTS)->nullOnDelete()); + Schema::table(Table::STORESETTINGS, fn (Blueprint $table) => $table->foreign('id')->references('id')->on(Table::STORES)->cascadeOnDelete()); + Schema::table(Table::SUBSCRIPTIONS, fn (Blueprint $table) => $table->foreign('gatewayId')->references('id')->on(Table::GATEWAYS)->restrictOnDelete()); + Schema::table(Table::SUBSCRIPTIONS, fn (Blueprint $table) => $table->foreign('id')->references('id')->on(CraftTable::ELEMENTS)->cascadeOnDelete()); + Schema::table(Table::SUBSCRIPTIONS, fn (Blueprint $table) => $table->foreign('orderId')->references('id')->on(Table::ORDERS)->nullOnDelete()); + Schema::table(Table::SUBSCRIPTIONS, fn (Blueprint $table) => $table->foreign('planId')->references('id')->on(Table::PLANS)->restrictOnDelete()); + Schema::table(Table::SUBSCRIPTIONS, fn (Blueprint $table) => $table->foreign('userId')->references('id')->on(CraftTable::USERS)->cascadeOnDelete()); + Schema::table(Table::TAXRATES, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()); + Schema::table(Table::TAXRATES, fn (Blueprint $table) => $table->foreign('taxCategoryId')->references('id')->on(Table::TAXCATEGORIES)->cascadeOnUpdate()); + Schema::table(Table::TAXRATES, fn (Blueprint $table) => $table->foreign('taxZoneId')->references('id')->on(Table::TAXZONES)->cascadeOnUpdate()); + Schema::table(Table::TAXZONES, fn (Blueprint $table) => $table->foreign('storeId')->references('id')->on(Table::STORES)->cascadeOnDelete()); + Schema::table(Table::TRANSACTIONS, fn (Blueprint $table) => $table->foreign('gatewayId')->references('id')->on(Table::GATEWAYS)->cascadeOnUpdate()); + Schema::table(Table::TRANSACTIONS, fn (Blueprint $table) => $table->foreign('orderId')->references('id')->on(Table::ORDERS)->cascadeOnDelete()); + Schema::table(Table::TRANSACTIONS, fn (Blueprint $table) => $table->foreign('parentId')->references('id')->on(Table::TRANSACTIONS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::TRANSACTIONS, fn (Blueprint $table) => $table->foreign('userId')->references('id')->on(CraftTable::ELEMENTS)->nullOnDelete()); + Schema::table(Table::TRANSFERS, fn (Blueprint $table) => $table->foreign('id')->references('id')->on(CraftTable::ELEMENTS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::TRANSFERDETAILS, fn (Blueprint $table) => $table->foreign('transferId')->references('id')->on(Table::TRANSFERS)->cascadeOnDelete()->cascadeOnUpdate()); + Schema::table(Table::TRANSFERDETAILS, fn (Blueprint $table) => $table->foreign('inventoryItemId')->references('id')->on(Table::INVENTORYITEMS)->nullOnDelete()->cascadeOnUpdate()); + Schema::table(Table::VARIANTS, fn (Blueprint $table) => $table->foreign('id')->references('id')->on(CraftTable::ELEMENTS)->cascadeOnDelete()); + Schema::table(Table::VARIANTS, fn (Blueprint $table) => $table->foreign('primaryOwnerId')->references('id')->on(Table::PRODUCTS)->cascadeOnDelete()); + } + + /** + * Inserts the default data. + * + * This is a fresh-install migration only (there is no upgrade path through this class), so — unlike the + * legacy Yii2 version — there's no need to guard against a pre-existing project config, patch up an old + * 5.0.0-beta.1 project config bug, or skip re-seeding a store/gateway that already exist. We also insert + * rows directly rather than going through the Commerce services (e.g. Stores::saveStore()), since those + * write through the project config system, which isn't guaranteed to apply synchronously in every context + * this migration runs in (e.g. the Testbench-based test harness). + */ + public function insertDefaultData(): void + { + $now = now()->toDateTimeString(); + + // Default (primary) store + $storeId = DB::table(Table::STORES)->insertGetId([ + 'name' => 'Primary', + 'handle' => 'primary', + 'primary' => true, + 'currency' => 'USD', + 'orderReferenceFormat' => '{{number[:7]}}', + 'sortOrder' => 1, + 'dateCreated' => $now, + 'dateUpdated' => $now, + 'uid' => Str::uuid()->toString(), + ]); + + // Map every existing site to the new store, using the site's own uid (there's only ever one + // site-store mapping row per site, so it can safely share the site's uid). + $sites = DB::table(CraftTable::SITES)->select(['id', 'uid'])->get(); + foreach ($sites as $site) { + DB::table(Table::SITESTORES)->insert([ + 'siteId' => $site->id, + 'storeId' => $storeId, + 'uid' => $site->uid, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + } + + // Default payment currency for the store + DB::table(Table::PAYMENTCURRENCIES)->insert([ + 'storeId' => $storeId, + 'iso' => 'USD', + 'rate' => 1, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + + // Default shipping category for the store + DB::table(Table::SHIPPINGCATEGORIES)->insert([ + 'storeId' => $storeId, + 'name' => 'General', + 'handle' => 'general', + 'default' => true, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + + // Default order status for the store + DB::table(Table::ORDERSTATUSES)->insert([ + 'storeId' => $storeId, + 'name' => 'New', + 'handle' => 'new', + 'color' => 'green', + 'default' => true, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + + // Default (Dummy) gateway + DB::table(Table::GATEWAYS)->insert([ + // TODO: update to the CraftCms\Commerce\... FQCN once the Dummy gateway is migrated to src/ + 'type' => 'craft\\commerce\\gateways\\Dummy', + 'name' => 'Dummy', + 'handle' => 'dummy', + 'isFrontendEnabled' => '1', + 'isArchived' => false, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + + // Default tax category (global, not store-specific) + DB::table(Table::TAXCATEGORIES)->insert([ + 'name' => 'General', + 'handle' => 'general', + 'default' => true, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + + // Default inventory location, assigned to the store + $inventoryLocationId = DB::table(Table::INVENTORYLOCATIONS)->insertGetId([ + 'handle' => 'default', + 'name' => 'Default', + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + + DB::table(Table::INVENTORYLOCATIONS_STORES)->insert([ + 'inventoryLocationId' => $inventoryLocationId, + 'storeId' => $storeId, + 'sortOrder' => 1, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + } + + public function down(): void + { + $this->task('Uninstall Craft Commerce', function (?Logger $logger = null) { + $logger?->subLabel('Dropping tables...'); + $this->dropTables(); + $logger?->success('Tables dropped.'); + + $logger?->subLabel('Removing field layouts...'); + $this->dropFieldLayouts(); + $logger?->success('Field layouts removed.'); + + $logger?->subLabel('Removing project config...'); + ProjectConfig::remove('commerce'); + $logger?->success('Project config removed.'); + }); + } + + /** + * Drops all of Commerce's tables. + */ + public function dropTables(): void + { + Schema::disableForeignKeyConstraints(); + + foreach ($this->allTableNames() as $table) { + Schema::dropIfExists($table); + } + + Schema::enableForeignKeyConstraints(); + } + + /** + * Deletes the field layouts belonging to Commerce's element types, including legacy + * `craft\commerce\*` type strings left behind by installs that predate the Laravel port. + */ + public function dropFieldLayouts(): void + { + DB::table(CraftTable::FIELDLAYOUTS)->whereIn('type', [ + Order::class, + Product::class, + Variant::class, + 'craft\\commerce\\elements\\Order', + 'craft\\commerce\\elements\\Product', + 'craft\\commerce\\elements\\Variant', + // Subscription and Transfer field layouts predate/are pending the Laravel port — + // these strings mirror the FQCNs `craft\commerce\elements\Subscription` (element + // removed in 6.0) and `craft\commerce\elements\Transfer` (not yet ported to src/). + 'craft\\commerce\\elements\\Subscription', + 'craft\\commerce\\elements\\Transfer', + ])->delete(); + } + + /** @return string[] */ + private function allTableNames(): array + { + return array_values((new ReflectionClass(Table::class))->getConstants()); + } +} diff --git a/example-templates/dist/shop/_private/layouts/includes/nav-main.twig b/example-templates/dist/shop/_private/layouts/includes/nav-main.twig index 21c6374319..78ddaf6303 100644 --- a/example-templates/dist/shop/_private/layouts/includes/nav-main.twig +++ b/example-templates/dist/shop/_private/layouts/includes/nav-main.twig @@ -10,10 +10,6 @@ Outputs the site’s global main navigation based on path and included `pages` a label: 'Products', url: 'shop/products' }, - { - label: 'Plans', - url: 'shop/plans' - }, { label: 'Donations', url: 'shop/donations' diff --git a/example-templates/dist/shop/plans/index.twig b/example-templates/dist/shop/plans/index.twig deleted file mode 100644 index a0cc6e42a7..0000000000 --- a/example-templates/dist/shop/plans/index.twig +++ /dev/null @@ -1,164 +0,0 @@ -{% extends 'shop/_private/layouts' %} - -{# @var plans \craft\commerce\base\Plan[] #} -{% set plans = craft.commerce.getPlans().getAllEnabledPlans() %} - -{% block main %} - -

- {{- 'Plans'|t -}} -

- {% if not plans|length %} -

- {{- 'No plans set up.'|t -}} -

- {% endif %} - - {# @var currentUser \craft\elements\User #} - {% if currentUser %} -
- {% set subscriptions = craft.subscriptions.status(null).userId(currentUser.id).all() %} - - {% if subscriptions|length %} - - - - - - - - - - - {% for subscription in subscriptions %} - - - - - - - {% endfor %} - -
{{ 'Plan'|t }}{{ 'Created'|t }}{{ 'Next Payment'|t }} 
- {% set plan = subscription.plan ?? null %} - {% if plan %} -
- {{ plan.name }} - - {# @var information \craft\elements\Entry #} - {% set information = plan.getInformation() ?? null %} - {% if information %} -
-

{{ 'Plan Information Entry'|t }}

-
    -
  • {{ 'ID' }}: {{ information.id }}
  • -
  • {{ 'Title' }}: {{ information.title }}
  • -
-
-
- {% endif %} -
- {% endif %} - - {% if subscription.isCanceled %} -
- - {{- 'Canceled on {date}.'|t({ date: subscription.dateCanceled|date('Y-m-d') }) -}} - -
- {% endif %} -
- {{ subscription.dateCreated|date('Y-m-d') }} - - {{- subscription.isCanceled - ? 'Expires on {date}.'|t({ date: subscription.nextPaymentDate|date('Y-m-d') }) - : '' - -}} - - - {{- 'Manage'|t -}} - - - {% if subscription.isSuspended and subscription.hasBillingIssues %} - - {{- 'Fix Billing'|t -}} - - {% endif %} - -
- {% else %} -

- {{- 'You do not have any active subscriptions.'|t -}} -

- {% endif %} -
- {% endif %} - - {% if currentUser and plans|length %} -
-

- {{ 'Available Plans'|t }} -

- -
- {% for plan in plans %} - {% set paymentSources = craft.commerce.paymentSources.getAllPaymentSourcesByCustomerId(currentUser.id, plan.gateway.id) %} - -
-
-
- {{ csrfInput() }} - {{ actionInput('commerce/subscriptions/subscribe') }} - {{ redirectInput('shop/plans') }} - {{ hiddenInput('planUid', plan.uid|hash) }} - -

- {{- plan.name -}} -

- {% if paymentSources|length %} -
- {% tag 'select' with { - name: 'trialDays', - 'data-plan': plan.id, - class: 'border border-gray-300 hover:border-gray-500 px-4 py-2 leading-tight rounded' - } %} - {% for i in [0, 3, 7, 14] %} - {% if i == 0 %} - {{ tag('option', { - value: (plan.uid ~ ':0')|hash, - text: 'No trial period.'|t - }) }} - {% else %} - {{ tag('option', { - value: (plan.uid ~ ':' ~ i)|hash, - text: 'Trial for {n} days.'|t({ n: i }) - }) }} - {% endif %} - {% endfor %} - {% endtag %} -
- {{ tag('button', { - type: 'submit', - class: 'cursor-pointer rounded px-4 py-2 inline-block bg-blue-500 hover:bg-blue-600 text-white hover:text-white', - text: 'Subscribe'|t - }) }} -
- {% else %} -

- {{- 'You do not have any payment sources set up for {gateway}.'|t({ gateway: plan.gateway.name }) -}} -

- Add Card - {% endif %} -
-
-
-
- {% endfor %} -
-
- {% endif %} -{% endblock %} diff --git a/example-templates/dist/shop/plans/subscription/index.twig b/example-templates/dist/shop/plans/subscription/index.twig deleted file mode 100644 index 606bed76f9..0000000000 --- a/example-templates/dist/shop/plans/subscription/index.twig +++ /dev/null @@ -1,160 +0,0 @@ -{% extends 'shop/_private/layouts' %} - -{# @var subscriptionId string #} -{% set subscriptionId = craft.app.request.param('subscription') %} -{# @var subscription \craft\commerce\elements\Subscription #} -{% set subscription = craft.subscriptions() - .id(subscriptionId) - .one() %} - -{% if not subscription or currentUser is null or subscription.userId != currentUser.id %} - {% redirect 'shop/plans' %} -{% endif %} - -{% block main %} - - -

- {{- 'Manage {plan}'|t({ plan: subscription.plan.name }) -}} -

- - {# @var information \craft\elements\Entry #} - {% set information = subscription.plan.getInformation() ?? null %} -
-
- - {{- 'Subscription Information'|t -}} - -
-
 
- {% if information %} -
- {{- 'Plan Information Entry'|t -}} -
-
-
    -
  • {{ 'ID'|t }}: {{ information.id }}
  • -
  • {{ 'Title'|t }}: {{ information.title }}
  • -
-
- {% endif %} - {% if subscription.isExpired %} -
{{ 'Expired on'|t }}
-
{{ subscription.dateExpired|date('Y-m-d') }}
- {% else %} - {% if subscription.isCanceled %} -
{{ 'Cancelled on'|t }}
-
{{ subscription.dateCanceled|date('Y-m-d') }}
-
{{ 'Expires on'|t }}
-
{{ subscription.nextPaymentDate|date('Y-m-d') }}
- {% if subscription.canReactivate() %} -
-
-
- {{ csrfInput() }} - {{ actionInput('commerce/subscriptions/reactivate') }} - {{ hiddenInput('subscriptionUid', subscription.uid|hash) }} - {{ redirectInput('shop/plans') }} - {{ tag('button', { - type: 'submit', - class: 'cursor-pointer rounded px-4 py-2 inline-block bg-blue-500 hover:bg-blue-600 text-white hover:text-white', - text: 'Reactivate'|t - }) }} -
-
- {% endif %} - {% else %} -
{{ 'Payment Amount'|t }}
-
{{ subscription.getNextPaymentAmount() }}
-
{{ 'Next Payment'|t }}
-
{{ subscription.nextPaymentDate|date('Y-m-d') }}
-
 
-
-
- {{ csrfInput() }} - {{ actionInput('commerce/subscriptions/cancel') }} - {{ hiddenInput('subscriptionUid', subscription.uid|hash) }} - {{ redirectInput('shop/plans') }} - - {{ subscription.plan.getGateway().getCancelSubscriptionFormHtml(subscription)|raw }} - - {{ tag('button', { - type: 'submit', - class: 'cursor-pointer rounded px-4 py-2 inline-block bg-gray-500 hover:bg-gray-600 text-white hover:text-white', - text: 'Unsubscribe'|t - }) }} -
-
- {% endif %} - {% endif %} -
- - {% if not subscription.isCanceled and not subscription.isExpired and subscription.alternativePlans|length %} -
-

- {{- 'Alternative Plans'|t -}} -

- - - - - - - - - {% for plan in subscription.alternativePlans %} - - - - - {% endfor %} - -
{{ 'Plan'|t }} 
{{ plan.name }} -
- {{ csrfInput() }} - {{ actionInput('commerce/subscriptions/switch') }} - {{ hiddenInput('subscriptionUid', subscription.uid|hash) }} - {{ hiddenInput('planUid', plan.uid|hash) }} - {{ redirectInput('shop/plans') }} - - {{ plan.gateway.getSwitchPlansFormHtml(subscription.plan, plan)|raw }} - - {{ tag('button', { - type: 'submit', - class: 'cursor-pointer rounded px-4 py-2 inline-block bg-blue-500 hover:bg-blue-600 text-white hover:text-white', - text: 'Switch'|t - }) }} -
-
-
- {% endif %} - - {# @var payments \craft\commerce\models\subscriptions\SubscriptionPayment[] #} - {% set payments = subscription.getAllPayments() %} - {% if payments|length %} -

- {{- 'Payment History'|t -}} -

- - - - - - - - -
{{ 'Date'|t }}{{ 'Amount'|t }}
- - {% for payment in payments %} - - - {{ payment.paymentDate|date("Y-m-d H:i") }} - - - {{ payment.paymentCurrency }} {{ payment.paymentAmount }} - - - {% endfor %} - - {% endif %} -{% endblock %} diff --git a/example-templates/dist/shop/plans/update-billing-details.twig b/example-templates/dist/shop/plans/update-billing-details.twig deleted file mode 100644 index 4591167ad6..0000000000 --- a/example-templates/dist/shop/plans/update-billing-details.twig +++ /dev/null @@ -1,55 +0,0 @@ -{% extends 'shop/_private/layouts' %} - -{# @var subscriptionUid string #} -{% set subscriptionUid = craft.app.request.getParam('subscription') %} -{# @var subscription \craft\commerce\elements\Subscription #} -{% set subscription = craft.subscriptions() - .status(null) - .uid(subscriptionUid) - .one() %} - -{% block main %} - - - {# @var currentUser \craft\elements\User #} - {% if currentUser is null or not subscriptionUid or not subscription %} - {% exit 404 %} - {% endif %} - - {% if subscription.subscriber.id != currentUser.id %} - {% exit 404 %} - {% endif %} - - {% if subscription.isExpired == true %} - {% exit 404 %} - {% endif %} - - {% if subscription.isCanceled == true %} - {% exit 404 %} - {% endif %} - - {% set planName = subscription.getPlan().name %} - -
-
- {% if subscription.isSuspended and subscription.hasBillingIssues %} -

- {{- 'Billing issue for subscription to {plan}'|t({ plan: planName }) -}} -

- -
{{ subscription.getBillingIssueDescription() }}
- -
-
- {{ redirectInput('shop/plans') }} - {{ subscription.getBillingIssueResolveFormHtml()|raw }} -
-
- {% else %} -

- {{- 'No issues with subscription to {plan}'|t({ plan: planName }) -}} -

- {% endif %} -
-
-{% endblock %} diff --git a/example-templates/src/shop/_private/layouts/includes/nav-main.twig b/example-templates/src/shop/_private/layouts/includes/nav-main.twig index 9c4a3ec453..886f66c2ea 100644 --- a/example-templates/src/shop/_private/layouts/includes/nav-main.twig +++ b/example-templates/src/shop/_private/layouts/includes/nav-main.twig @@ -10,10 +10,6 @@ Outputs the site’s global main navigation based on path and included `pages` a label: 'Products', url: '[[folderName]]/products' }, - { - label: 'Plans', - url: '[[folderName]]/plans' - }, { label: 'Donations', url: '[[folderName]]/donations' diff --git a/example-templates/src/shop/plans/index.twig b/example-templates/src/shop/plans/index.twig deleted file mode 100755 index 0c004d042a..0000000000 --- a/example-templates/src/shop/plans/index.twig +++ /dev/null @@ -1,164 +0,0 @@ -{% extends '[[folderName]]/_private/layouts' %} - -{# @var plans \craft\commerce\base\Plan[] #} -{% set plans = craft.commerce.getPlans().getAllEnabledPlans() %} - -{% block main %} - -

- {{- 'Plans'|t -}} -

- {% if not plans|length %} -

- {{- 'No plans set up.'|t -}} -

- {% endif %} - - {# @var currentUser \craft\elements\User #} - {% if currentUser %} -
- {% set subscriptions = craft.subscriptions.status(null).userId(currentUser.id).all() %} - - {% if subscriptions|length %} - - - - - - - - - - - {% for subscription in subscriptions %} - - - - - - - {% endfor %} - -
{{ 'Plan'|t }}{{ 'Created'|t }}{{ 'Next Payment'|t }} 
- {% set plan = subscription.plan ?? null %} - {% if plan %} -
- {{ plan.name }} - - {# @var information \craft\elements\Entry #} - {% set information = plan.getInformation() ?? null %} - {% if information %} -
-

{{ 'Plan Information Entry'|t }}

-
    -
  • {{ 'ID' }}: {{ information.id }}
  • -
  • {{ 'Title' }}: {{ information.title }}
  • -
-
-
- {% endif %} -
- {% endif %} - - {% if subscription.isCanceled %} -
- - {{- 'Canceled on {date}.'|t({ date: subscription.dateCanceled|date('Y-m-d') }) -}} - -
- {% endif %} -
- {{ subscription.dateCreated|date('Y-m-d') }} - - {{- subscription.isCanceled - ? 'Expires on {date}.'|t({ date: subscription.nextPaymentDate|date('Y-m-d') }) - : '' - -}} - - - {{- 'Manage'|t -}} - - - {% if subscription.isSuspended and subscription.hasBillingIssues %} - - {{- 'Fix Billing'|t -}} - - {% endif %} - -
- {% else %} -

- {{- 'You do not have any active subscriptions.'|t -}} -

- {% endif %} -
- {% endif %} - - {% if currentUser and plans|length %} -
-

- {{ 'Available Plans'|t }} -

- -
- {% for plan in plans %} - {% set paymentSources = craft.commerce.paymentSources.getAllPaymentSourcesByCustomerId(currentUser.id, plan.gateway.id) %} - -
-
-
- {{ csrfInput() }} - {{ actionInput('commerce/subscriptions/subscribe') }} - {{ redirectInput('[[folderName]]/plans') }} - {{ hiddenInput('planUid', plan.uid|hash) }} - -

- {{- plan.name -}} -

- {% if paymentSources|length %} -
- {% tag 'select' with { - name: 'trialDays', - 'data-plan': plan.id, - class: '[[classes.input]]' - } %} - {% for i in [0, 3, 7, 14] %} - {% if i == 0 %} - {{ tag('option', { - value: (plan.uid ~ ':0')|hash, - text: 'No trial period.'|t - }) }} - {% else %} - {{ tag('option', { - value: (plan.uid ~ ':' ~ i)|hash, - text: 'Trial for {n} days.'|t({ n: i }) - }) }} - {% endif %} - {% endfor %} - {% endtag %} -
- {{ tag('button', { - type: 'submit', - class: '[[classes.btn.base]] [[classes.btn.mainColor]]', - text: 'Subscribe'|t - }) }} -
- {% else %} -

- {{- 'You do not have any payment sources set up for {gateway}.'|t({ gateway: plan.gateway.name }) -}} -

- Add Card - {% endif %} -
-
-
-
- {% endfor %} -
-
- {% endif %} -{% endblock %} diff --git a/example-templates/src/shop/plans/subscription/index.twig b/example-templates/src/shop/plans/subscription/index.twig deleted file mode 100755 index dec06f877d..0000000000 --- a/example-templates/src/shop/plans/subscription/index.twig +++ /dev/null @@ -1,160 +0,0 @@ -{% extends '[[folderName]]/_private/layouts' %} - -{# @var subscriptionId string #} -{% set subscriptionId = craft.app.request.param('subscription') %} -{# @var subscription \craft\commerce\elements\Subscription #} -{% set subscription = craft.subscriptions() - .id(subscriptionId) - .one() %} - -{% if not subscription or currentUser is null or subscription.userId != currentUser.id %} - {% redirect '[[folderName]]/plans' %} -{% endif %} - -{% block main %} - - -

- {{- 'Manage {plan}'|t({ plan: subscription.plan.name }) -}} -

- - {# @var information \craft\elements\Entry #} - {% set information = subscription.plan.getInformation() ?? null %} -
-
- - {{- 'Subscription Information'|t -}} - -
-
 
- {% if information %} -
- {{- 'Plan Information Entry'|t -}} -
-
-
    -
  • {{ 'ID'|t }}: {{ information.id }}
  • -
  • {{ 'Title'|t }}: {{ information.title }}
  • -
-
- {% endif %} - {% if subscription.isExpired %} -
{{ 'Expired on'|t }}
-
{{ subscription.dateExpired|date('Y-m-d') }}
- {% else %} - {% if subscription.isCanceled %} -
{{ 'Cancelled on'|t }}
-
{{ subscription.dateCanceled|date('Y-m-d') }}
-
{{ 'Expires on'|t }}
-
{{ subscription.nextPaymentDate|date('Y-m-d') }}
- {% if subscription.canReactivate() %} -
-
-
- {{ csrfInput() }} - {{ actionInput('commerce/subscriptions/reactivate') }} - {{ hiddenInput('subscriptionUid', subscription.uid|hash) }} - {{ redirectInput('[[folderName]]/plans') }} - {{ tag('button', { - type: 'submit', - class: '[[classes.btn.base]] [[classes.btn.mainColor]]', - text: 'Reactivate'|t - }) }} -
-
- {% endif %} - {% else %} -
{{ 'Payment Amount'|t }}
-
{{ subscription.getNextPaymentAmount() }}
-
{{ 'Next Payment'|t }}
-
{{ subscription.nextPaymentDate|date('Y-m-d') }}
-
 
-
-
- {{ csrfInput() }} - {{ actionInput('commerce/subscriptions/cancel') }} - {{ hiddenInput('subscriptionUid', subscription.uid|hash) }} - {{ redirectInput('[[folderName]]/plans') }} - - {{ subscription.plan.getGateway().getCancelSubscriptionFormHtml(subscription)|raw }} - - {{ tag('button', { - type: 'submit', - class: '[[classes.btn.base]] [[classes.btn.grayColor]]', - text: 'Unsubscribe'|t - }) }} -
-
- {% endif %} - {% endif %} -
- - {% if not subscription.isCanceled and not subscription.isExpired and subscription.alternativePlans|length %} -
-

- {{- 'Alternative Plans'|t -}} -

- - - - - - - - - {% for plan in subscription.alternativePlans %} - - - - - {% endfor %} - -
{{ 'Plan'|t }} 
{{ plan.name }} -
- {{ csrfInput() }} - {{ actionInput('commerce/subscriptions/switch') }} - {{ hiddenInput('subscriptionUid', subscription.uid|hash) }} - {{ hiddenInput('planUid', plan.uid|hash) }} - {{ redirectInput('[[folderName]]/plans') }} - - {{ plan.gateway.getSwitchPlansFormHtml(subscription.plan, plan)|raw }} - - {{ tag('button', { - type: 'submit', - class: '[[classes.btn.base]] [[classes.btn.mainColor]]', - text: 'Switch'|t - }) }} -
-
-
- {% endif %} - - {# @var payments \craft\commerce\models\subscriptions\SubscriptionPayment[] #} - {% set payments = subscription.getAllPayments() %} - {% if payments|length %} -

- {{- 'Payment History'|t -}} -

- - - - - - - - -
{{ 'Date'|t }}{{ 'Amount'|t }}
- - {% for payment in payments %} - - - {{ payment.paymentDate|date("Y-m-d H:i") }} - - - {{ payment.paymentCurrency }} {{ payment.paymentAmount }} - - - {% endfor %} - - {% endif %} -{% endblock %} diff --git a/example-templates/src/shop/plans/update-billing-details.twig b/example-templates/src/shop/plans/update-billing-details.twig deleted file mode 100755 index 9ca5b5b6cb..0000000000 --- a/example-templates/src/shop/plans/update-billing-details.twig +++ /dev/null @@ -1,55 +0,0 @@ -{% extends '[[folderName]]/_private/layouts' %} - -{# @var subscriptionUid string #} -{% set subscriptionUid = craft.app.request.getParam('subscription') %} -{# @var subscription \craft\commerce\elements\Subscription #} -{% set subscription = craft.subscriptions() - .status(null) - .uid(subscriptionUid) - .one() %} - -{% block main %} - - - {# @var currentUser \craft\elements\User #} - {% if currentUser is null or not subscriptionUid or not subscription %} - {% exit 404 %} - {% endif %} - - {% if subscription.subscriber.id != currentUser.id %} - {% exit 404 %} - {% endif %} - - {% if subscription.isExpired == true %} - {% exit 404 %} - {% endif %} - - {% if subscription.isCanceled == true %} - {% exit 404 %} - {% endif %} - - {% set planName = subscription.getPlan().name %} - -
-
- {% if subscription.isSuspended and subscription.hasBillingIssues %} -

- {{- 'Billing issue for subscription to {plan}'|t({ plan: planName }) -}} -

- -
{{ subscription.getBillingIssueDescription() }}
- -
-
- {{ redirectInput('[[folderName]]/plans') }} - {{ subscription.getBillingIssueResolveFormHtml()|raw }} -
-
- {% else %} -

- {{- 'No issues with subscription to {plan}'|t({ plan: planName }) -}} -

- {% endif %} -
-
-{% endblock %} diff --git a/phpstan.neon b/phpstan.neon index 39e7ab9091..ce7b2c4d71 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,42 @@ includes: - - vendor/craftcms/phpstan/phpstan.neon + - vendor/larastan/larastan/extension.neon + - vendor/nesbot/carbon/extension.neon parameters: level: 5 + parallel: + # Parallel workers each independently reflect on classes reached through class_alias() + # chains (the legacy src-yii2/ -> src/ stubs); depending on which worker resolves a given + # alias target first, PHPStan sometimes can/can't trace the chain, making `argument.type`/ + # `method.notFound` errors (and their `@phpstan-ignore-next-line` suppressions) on those + # call sites non-deterministic between otherwise-identical runs. Single-process analysis + # is slower but avoids that race entirely. + maximumNumberOfProcesses: 1 paths: - - src \ No newline at end of file + - src + scanDirectories: + - src-yii2 + scanFiles: + - vendor/craftcms/yii2-adapter/legacy/Craft.php + - vendor/craftcms/yii2-adapter/lib/yii2/Yii.php + - vendor/twig/twig/src/Extension/CoreExtension.php + - vendor/craftcms/cms/src/helpers.php + databaseMigrationsPath: + - database/migrations + stubFiles: + - vendor/craftcms/yii2-adapter/stubs/laravel-ruleset-validation.stub + - vendor/craftcms/yii2-adapter/stubs/_generated.stub + - vendor/craftcms/yii2-adapter/stubs/GraphQL/Type/Definition/FieldDefinition.stub + - vendor/craftcms/yii2-adapter/stubs/GraphQL/Type/Definition/ResolveInfo.stub + - vendor/craftcms/yii2-adapter/stubs/samdark/log/PsrTarget.stub + - vendor/craftcms/yii2-adapter/stubs/yii/base/Component.stub + - vendor/craftcms/yii2-adapter/stubs/yii/base/Event.stub + - vendor/craftcms/yii2-adapter/stubs/yii/base/Module.stub + - vendor/craftcms/yii2-adapter/stubs/yii/BaseYii.stub + - vendor/craftcms/yii2-adapter/stubs/yii/db/BaseActiveRecord.stub + - vendor/craftcms/yii2-adapter/stubs/yii/db/Query.stub + - vendor/craftcms/yii2-adapter/stubs/yii/db/Migration.stub + - vendor/craftcms/yii2-adapter/stubs/yii/di/ServiceLocator.stub + - vendor/craftcms/yii2-adapter/stubs/yii/helpers/BaseArrayHelper.stub + - vendor/craftcms/yii2-adapter/stubs/yii/validators/UniqueValidator.stub + - vendor/craftcms/yii2-adapter/stubs/yii/validators/Validator.stub \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000000..8ee86de9bd --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,42 @@ + + + + + ./tests/Feature + + + ./tests/Unit + + + ./tests/Arch + + + + + + + + + + + + + + + + + + + + + ./src + + + diff --git a/rector.php b/rector.php index 83d0991d94..10464479f9 100644 --- a/rector.php +++ b/rector.php @@ -7,12 +7,5 @@ return RectorConfig::configure() ->withPaths([ __DIR__ . '/src', - __DIR__ . '/tests/unit', - ]) - ->withSkip([ - Rector\Php74\Rector\Closure\ClosureToArrowFunctionRector::class => [ - __DIR__ . '/src/console/controllers/GatewaysController.php', - ], - Rector\Php80\Rector\Class_\StringableForToStringRector::class, - ]) - ->withPhpSets(php80: true); + __DIR__ . '/src-yii2', + ]); diff --git a/routes/actions.php b/routes/actions.php new file mode 100644 index 0000000000..6001e67617 --- /dev/null +++ b/routes/actions.php @@ -0,0 +1,271 @@ +group(function () { + Route::get('cart/get-cart', [CartController::class, 'getCart']); + Route::post('cart/update-cart', [CartController::class, 'updateCart']); + Route::match(['get', 'post'], 'cart/load-cart', [CartController::class, 'loadCart']); + Route::post('cart/complete', [CartController::class, 'complete']); +}); + +Route::post('cart/forget-cart', [CartController::class, 'forgetCart']); +Route::get('cart/email-challenge', [CartController::class, 'emailChallenge']); +Route::post('cart/cart-challenge', [CartController::class, 'cartChallenge']) + ->middleware('throttle:' . CartChallengeRateLimiter::NAME); +Route::get('cart/cart-sent', [CartController::class, 'cartSent']); + +// Anonymous by design — guest checkout must be able to pay. Each action gates its own +// order/customer-ownership checks inline (mirrors CartController's own permission model). +Route::post('payments/pay', [PaymentsController::class, 'pay']); +Route::match(['get', 'post'], 'payments/complete-payment', [PaymentsController::class, 'completePayment']); + +Route::post('payment-sources/add', [PaymentSourcesController::class, 'add']); +Route::post('payment-sources/set-primary-payment-source', [PaymentSourcesController::class, 'setPrimaryPaymentSource']); +Route::post('payment-sources/delete', [PaymentSourcesController::class, 'delete']); + +// These are also reachable, unauthenticated, at their site-side action URL (per +// CraftCms\Cms\Plugin\Concerns\HasRoutes::registerActionRoutes()) — the `auth`/`can` +// middleware below is what actually protects them, not the URL prefix. +Route::middleware(['auth', 'can:accessPlugin-commerce', 'can:commerce-manageDonationSettings']) + ->post('donations/save', [DonationsController::class, 'save']); + +Route::middleware(['auth', 'can:accessPlugin-commerce', RequireAdmin::class])->group(function () { + Route::post('gateways/save', [GatewaysController::class, 'save']); + Route::post('gateways/archive', [GatewaysController::class, 'archive']); + Route::post('gateways/reorder', [GatewaysController::class, 'reorder']); + + Route::post('settings/save-settings', [SettingsController::class, 'saveSettings']); + Route::post('settings/save-transfer-settings', [SettingsController::class, 'saveTransferSettings']); + Route::post('order-settings/save', [OrderSettingsController::class, 'save']); + + Route::post('stores/save-store', [StoresController::class, 'saveStore']); + Route::post('stores/delete-store', [StoresController::class, 'deleteStore']); + Route::post('stores/reorder-stores', [StoresController::class, 'reorderStores']); + Route::post('stores/save-site-stores', [StoresController::class, 'saveSiteStores']); + + Route::post('order-statuses/save', [OrderStatusesController::class, 'save']); + Route::match(['get', 'post'], 'order-statuses/get-order-statuses', [OrderStatusesController::class, 'getOrderStatuses']); + Route::post('order-statuses/reorder', [OrderStatusesController::class, 'reorder']); + Route::post('order-statuses/delete', [OrderStatusesController::class, 'delete']); + + Route::post('line-item-statuses/save', [LineItemStatusesController::class, 'save']); + Route::post('line-item-statuses/reorder', [LineItemStatusesController::class, 'reorder']); + Route::post('line-item-statuses/archive', [LineItemStatusesController::class, 'archive']); + + Route::post('product-types/save-product-type', [ProductTypesController::class, 'saveProductType']); + Route::post('product-types/delete-product-type', [ProductTypesController::class, 'deleteProductType']); +}); + +// BaseStoreManagementController::init() always required commerce-manageStoreSettings, on top of +// whichever more specific permission each feature area's own controller adds — every route in +// this group must check both, matching that legacy compound check exactly. +Route::middleware(['auth', 'can:accessPlugin-commerce', 'can:commerce-manageStoreSettings'])->group(function () { + Route::post('store-management/save', [StoreManagementController::class, 'save']); + + Route::post('payment-currencies/save', [PaymentCurrenciesController::class, 'save']); + Route::post('payment-currencies/delete', [PaymentCurrenciesController::class, 'delete']); + + Route::middleware('can:commerce-manageShipping')->group(function () { + Route::post('shipping-zones/save', [ShippingZonesController::class, 'save']); + Route::post('shipping-zones/delete', [ShippingZonesController::class, 'delete']); + Route::post('shipping-zones/test-zip', [ShippingZonesController::class, 'testZip']); + + Route::post('shipping-methods/save', [ShippingMethodsController::class, 'save']); + Route::post('shipping-methods/delete', [ShippingMethodsController::class, 'delete']); + Route::post('shipping-methods/update-status', [ShippingMethodsController::class, 'updateStatus']); + + Route::post('shipping-rules/save', [ShippingRulesController::class, 'save']); + Route::post('shipping-rules/duplicate', [ShippingRulesController::class, 'duplicate']); + Route::post('shipping-rules/reorder', [ShippingRulesController::class, 'reorder']); + Route::post('shipping-rules/delete', [ShippingRulesController::class, 'delete']); + + Route::post('shipping-categories/save', [ShippingCategoriesController::class, 'save']); + Route::post('shipping-categories/delete', [ShippingCategoriesController::class, 'delete']); + Route::post('shipping-categories/set-default-category', [ShippingCategoriesController::class, 'setDefaultCategory']); + }); + + Route::middleware('can:commerce-manageTaxes')->group(function () { + Route::post('tax-zones/save', [TaxZonesController::class, 'save']); + Route::post('tax-zones/delete', [TaxZonesController::class, 'delete']); + Route::post('tax-zones/test-zip', [TaxZonesController::class, 'testZip']); + + Route::post('tax-categories/save', [TaxCategoriesController::class, 'save']); + Route::post('tax-categories/delete', [TaxCategoriesController::class, 'delete']); + Route::post('tax-categories/set-default-category', [TaxCategoriesController::class, 'setDefaultCategory']); + + Route::post('tax-rates/save', [TaxRatesController::class, 'save']); + Route::post('tax-rates/delete', [TaxRatesController::class, 'delete']); + Route::post('tax-rates/update-status', [TaxRatesController::class, 'updateStatus']); + }); + + Route::middleware('can:commerce-managePromotions')->group(function () { + Route::post('sales/save', [SalesController::class, 'save']); + Route::post('sales/reorder', [SalesController::class, 'reorder']); + Route::post('sales/delete', [SalesController::class, 'delete']); + Route::match(['get', 'post'], 'sales/get-all-sales', [SalesController::class, 'getAllSales']); + Route::post('sales/get-sales-by-product-id', [SalesController::class, 'getSalesByProductId']); + Route::post('sales/get-sales-by-purchasable-id', [SalesController::class, 'getSalesByPurchasableId']); + Route::post('sales/add-purchasable-to-sale', [SalesController::class, 'addPurchasableToSale']); + Route::post('sales/update-status', [SalesController::class, 'updateStatus']); + + Route::match(['get', 'post'], 'discounts/table-data', [DiscountsController::class, 'tableData']); + Route::post('discounts/save', [DiscountsController::class, 'save']); + Route::post('discounts/reorder', [DiscountsController::class, 'reorder']); + Route::post('discounts/move-to-page', [DiscountsController::class, 'moveToPage']); + Route::post('discounts/delete', [DiscountsController::class, 'delete']); + Route::post('discounts/clear-discount-uses', [DiscountsController::class, 'clearDiscountUses']); + Route::post('discounts/update-status', [DiscountsController::class, 'updateStatus']); + Route::post('discounts/get-discounts-by-purchasable-id', [DiscountsController::class, 'getDiscountsByPurchasableId']); + Route::post('discounts/generate-coupons', [DiscountsController::class, 'generateCoupons']); + + Route::post('catalog-pricing-rules/save', [CatalogPricingRulesController::class, 'save']); + Route::post('catalog-pricing-rules/delete', [CatalogPricingRulesController::class, 'delete']); + Route::post('catalog-pricing-rules/update-status', [CatalogPricingRulesController::class, 'updateStatus']); + + Route::post('catalog-pricing/filter', [CatalogPricingController::class, 'filter']); + Route::post('catalog-pricing/prices', [CatalogPricingController::class, 'prices']); + Route::get('catalog-pricing/queue-status', [CatalogPricingController::class, 'queueStatus']); + Route::post('catalog-pricing/get-catalog-prices', [CatalogPricingController::class, 'getCatalogPrices']); + }); +}); + +// OrdersController extends the plain Yii2 Controller (not BaseCpController) — its init() only +// ever checked commerce-manageOrders, not accessPlugin-commerce. +Route::middleware(['auth', 'can:commerce-manageOrders'])->group(function () { + Route::post('orders/fulfill', [OrdersController::class, 'fulfill']); + Route::get('orders/fulfillment-modal', [OrdersController::class, 'fulfillmentModal']); + Route::post('orders/save', [OrdersController::class, 'save']); + Route::post('orders/delete-order', [OrdersController::class, 'deleteOrder']); + Route::post('orders/refresh', [OrdersController::class, 'refresh']); + Route::post('orders/get-shipping-method-options', [OrdersController::class, 'getShippingMethodOptions']); + Route::get('orders/user-orders-table', [OrdersController::class, 'userOrdersTable']); + Route::get('orders/purchasables-table', [OrdersController::class, 'purchasablesTable']); + Route::get('orders/customer-search', [OrdersController::class, 'customerSearch']); + Route::get('orders/get-customer-addresses', [OrdersController::class, 'getCustomerAddresses']); + Route::get('orders/get-order-address', [OrdersController::class, 'getOrderAddress']); + Route::post('orders/validate-address', [OrdersController::class, 'validateAddress']); + Route::post('orders/create-customer', [OrdersController::class, 'createCustomer']); + Route::get('orders/get-load-cart-url', [OrdersController::class, 'getLoadCartUrl']); + Route::get('orders/send-email', [OrdersController::class, 'sendEmail']); + Route::get('orders/update-order-address', [OrdersController::class, 'updateOrderAddress']); + Route::get('orders/get-index-sources-badge-counts', [OrdersController::class, 'getIndexSourcesBadgeCounts']); + Route::get('orders/get-payment-modal', [OrdersController::class, 'getPaymentModal']); + Route::post('orders/payment-amount-data', [OrdersController::class, 'paymentAmountData']); + + Route::post('orders/copy-address-to-user', [OrdersController::class, 'copyAddressToUser']) + ->middleware('can:editUsers'); + + Route::middleware('can:commerce-capturePayment') + ->post('orders/transaction-capture', [OrdersController::class, 'transactionCapture']); + Route::middleware('can:commerce-refundPayment') + ->post('orders/transaction-refund', [OrdersController::class, 'transactionRefund']); + + Route::middleware([RequireCpRequest::class, 'can:deleteUsers'])->group(function () { + Route::get('orders/reassign-modal', [OrdersController::class, 'reassignModal']); + Route::post('orders/reassign', [OrdersController::class, 'reassign']); + Route::get('orders/remove-customer-data-modal', [OrdersController::class, 'removeCustomerDataModal']); + Route::post('orders/remove-customer-data', [OrdersController::class, 'removeCustomerData']); + }); +}); + +// InventoryController checks commerce-manageInventoryStockLevels inline on every action +// (not via init()) — replicated here as a route-group-wide permission instead. +Route::middleware(['auth', 'can:commerce-manageInventoryStockLevels'])->group(function () { + Route::post('inventory/item-save', [InventoryController::class, 'itemSave']); + Route::get('inventory/inventory-levels-table-data', [InventoryController::class, 'inventoryLevelsTableData']); + Route::post('inventory/update-levels', [InventoryController::class, 'updateLevels']); + Route::get('inventory/edit-update-levels-modal', [InventoryController::class, 'editUpdateLevelsModal']); + Route::post('inventory/save-inventory-movement', [InventoryController::class, 'saveInventoryMovement']); + Route::get('inventory/edit-movement-modal', [InventoryController::class, 'editMovementModal']); + Route::get('inventory/unfulfilled-orders', [InventoryController::class, 'unfulfilledOrders']); +}); + +Route::middleware(['auth', 'can:commerce-manageInventoryLocations'])->group(function () { + Route::post('inventory-locations/save', [InventoryLocationsController::class, 'save']); + Route::get('inventory-locations/inventory-locations-table-data', [InventoryLocationsController::class, 'inventoryLocationsTableData']); + Route::get('inventory-locations/prepare-delete-modal', [InventoryLocationsController::class, 'prepareDeleteModal']); + Route::post('inventory-locations/deactivate', [InventoryLocationsController::class, 'deactivate']); +}); + +Route::middleware(['auth', 'can:commerce-manageInventoryTransfers'])->group(function () { + Route::get('transfers/create', [TransfersController::class, 'create']); + Route::post('transfers/mark-as-pending', [TransfersController::class, 'markAsPending']); + Route::post('transfers/save-settings', [TransfersController::class, 'saveSettings']); + Route::post('transfers/receive-transfer', [TransfersController::class, 'receiveTransfer']); + Route::get('transfers/receive-transfer-screen', [TransfersController::class, 'receiveTransferScreen']); + Route::get('transfers/render-management', [TransfersController::class, 'renderManagement']); +}); + +Route::middleware(['auth', 'can:accessPlugin-commerce', RequireAdmin::class])->group(function () { + Route::post('emails/save', [EmailsController::class, 'save']); + Route::post('emails/delete', [EmailsController::class, 'delete']); + + Route::post('pdfs/save', [PdfsController::class, 'save']); + Route::post('pdfs/delete', [PdfsController::class, 'delete']); + Route::post('pdfs/reorder', [PdfsController::class, 'reorder']); +}); + +Route::middleware(['auth', 'can:accessPlugin-commerce'])->group(function () { + Route::post('formulas/validate-condition', [FormulasController::class, 'validateCondition']); + Route::post('formulas/validate-formula', [FormulasController::class, 'validateFormula']); +}); + +// Rendered inside an iframe from the email edit screen's preview button — admin-only, matching +// the legacy controller's plain `requireAdmin(false)` (it never extended a Commerce base +// controller, so there was never an accessPlugin-commerce check here either). +Route::middleware(RequireAdmin::class)->get('email-preview/render', [EmailPreviewController::class, 'render']); + +// Anonymous by design — customers download/request order PDFs without being logged in. +// pdf-challenge is rate-limited (not auth-gated) to blunt brute-forcing of order numbers/hashes. +Route::get('downloads/pdf', [DownloadsController::class, 'pdf']); +Route::get('downloads/email-challenge', [DownloadsController::class, 'emailChallenge']); +Route::post('downloads/pdf-challenge', [DownloadsController::class, 'pdfChallenge']) + ->middleware('throttle:' . PdfChallengeRateLimiter::NAME); +Route::get('downloads/pdf-sent', [DownloadsController::class, 'pdfSent']); diff --git a/routes/cp.php b/routes/cp.php new file mode 100644 index 0000000000..dcc121e17c --- /dev/null +++ b/routes/cp.php @@ -0,0 +1,186 @@ +group(function () { + Route::middleware('can:commerce-manageDonationSettings') + ->get('commerce/donations', [DonationsController::class, 'edit']); + + Route::middleware(RequireAdmin::class)->group(function () { + Route::get('commerce/settings/gateways', [GatewaysController::class, 'index']); + Route::get('commerce/settings/gateways/new', [GatewaysController::class, 'edit']); + Route::get('commerce/settings/gateways/{id}', [GatewaysController::class, 'edit'])->whereNumber('id'); + + Route::get('commerce/settings/general', [SettingsController::class, 'edit']); + Route::get('commerce/settings/ordersettings', [OrderSettingsController::class, 'edit']); + Route::get('commerce/settings/transfers', [SettingsController::class, 'editTransferSettings']); + + Route::get('commerce/settings/stores', [StoresController::class, 'storesIndex']); + Route::get('commerce/settings/stores/new', [StoresController::class, 'editStore']); + Route::get('commerce/settings/stores/{storeId}', [StoresController::class, 'editStore'])->whereNumber('storeId'); + Route::get('commerce/settings/sites', [StoresController::class, 'editSiteStores']); + + Route::get('commerce/settings/orderstatuses', [OrderStatusesController::class, 'index']); + Route::get('commerce/settings/orderstatuses/{storeHandle}/new', [OrderStatusesController::class, 'edit']); + Route::get('commerce/settings/orderstatuses/{storeHandle}/{id}', [OrderStatusesController::class, 'edit'])->whereNumber('id'); + + Route::get('commerce/settings/lineitemstatuses', [LineItemStatusesController::class, 'index']); + Route::get('commerce/settings/lineitemstatuses/{storeHandle}/new', [LineItemStatusesController::class, 'edit']); + Route::get('commerce/settings/lineitemstatuses/{storeHandle}/{id}', [LineItemStatusesController::class, 'edit'])->whereNumber('id'); + + Route::get('commerce/settings/producttypes', [ProductTypesController::class, 'productTypeIndex']); + Route::get('commerce/settings/producttypes/new', [ProductTypesController::class, 'editProductType']); + Route::get('commerce/settings/producttypes/{productTypeId}', [ProductTypesController::class, 'editProductType'])->whereNumber('productTypeId'); + + Route::get('commerce/settings/emails', [EmailsController::class, 'index']); + Route::get('commerce/settings/emails/{storeHandle}/new', [EmailsController::class, 'edit']); + Route::get('commerce/settings/emails/{storeHandle}/{id}', [EmailsController::class, 'edit'])->whereNumber('id'); + + Route::get('commerce/settings/pdfs', [PdfsController::class, 'index']); + Route::get('commerce/settings/pdfs/{storeHandle}/new', [PdfsController::class, 'edit']); + Route::get('commerce/settings/pdfs/{storeHandle}/{id}', [PdfsController::class, 'edit'])->whereNumber('id'); + }); + + // ProductsController/VariantsController extend BaseCpController directly (no extra + // permission beyond accessPlugin-commerce) — each additionally guards its own methods with + // an inline "does the user have access to any product type" check. + Route::get('commerce/products/{productType}/new', [ProductsController::class, 'create']); + Route::get('commerce/products/{productTypeHandle?}', [ProductsController::class, 'productIndex']); + Route::get('commerce/variants/{productTypeHandle?}', [VariantsController::class, 'index']); + + // BaseStoreManagementController::init() always required commerce-manageStoreSettings, on + // top of whichever more specific permission each feature area's own controller adds — every + // route in this group must check both, matching that legacy compound check exactly. + Route::middleware('can:commerce-manageStoreSettings')->group(function () { + Route::get('commerce/store-management', [StoreManagementController::class, 'index']); + Route::get('commerce/store-management/{storeHandle}', [StoreManagementController::class, 'edit']); + + Route::middleware('can:commerce-manageShipping') + ->prefix('commerce/store-management/{storeHandle}') + ->group(function () { + Route::get('shippingzones', [ShippingZonesController::class, 'index']); + Route::get('shippingzones/new', [ShippingZonesController::class, 'edit']); + Route::get('shippingzones/{id}', [ShippingZonesController::class, 'edit'])->whereNumber('id'); + + Route::get('shippingcategories', [ShippingCategoriesController::class, 'index']); + Route::get('shippingcategories/new', [ShippingCategoriesController::class, 'edit']); + Route::get('shippingcategories/{id}', [ShippingCategoriesController::class, 'edit'])->whereNumber('id'); + + Route::get('shippingmethods', [ShippingMethodsController::class, 'index']); + Route::get('shippingmethods/new', [ShippingMethodsController::class, 'edit']); + Route::get('shippingmethods/{id}', [ShippingMethodsController::class, 'edit'])->whereNumber('id'); + Route::get('shippingmethods/{methodId}/shippingrules/new', [ShippingRulesController::class, 'edit'])->whereNumber('methodId'); + Route::get('shippingmethods/{methodId}/shippingrules/{ruleId}', [ShippingRulesController::class, 'edit'])->whereNumber(['methodId', 'ruleId']); + }); + + Route::middleware('can:commerce-manageTaxes') + ->prefix('commerce/store-management/{storeHandle}') + ->group(function () { + Route::get('taxcategories', [TaxCategoriesController::class, 'index']); + Route::get('taxcategories/new', [TaxCategoriesController::class, 'edit']); + Route::get('taxcategories/{id}', [TaxCategoriesController::class, 'edit'])->whereNumber('id'); + + Route::get('taxzones', [TaxZonesController::class, 'index']); + Route::get('taxzones/new', [TaxZonesController::class, 'edit']); + Route::get('taxzones/{id}', [TaxZonesController::class, 'edit'])->whereNumber('id'); + + Route::get('taxrates', [TaxRatesController::class, 'index']); + Route::get('taxrates/new', [TaxRatesController::class, 'edit']); + Route::get('taxrates/{id}', [TaxRatesController::class, 'edit'])->whereNumber('id'); + }); + + Route::middleware('can:commerce-managePromotions')->group(function () { + Route::get('commerce/catalog-pricing', [CatalogPricingController::class, 'index']); + + Route::prefix('commerce/store-management/{storeHandle}')->group(function () { + Route::get('sales', [SalesController::class, 'index']); + Route::get('sales/new', [SalesController::class, 'edit']); + Route::get('sales/{id}', [SalesController::class, 'edit'])->whereNumber('id'); + + Route::get('discounts', [DiscountsController::class, 'index']); + Route::get('discounts/new', [DiscountsController::class, 'edit']); + Route::get('discounts/{id}', [DiscountsController::class, 'edit'])->whereNumber('id'); + + Route::get('pricing-rules', [CatalogPricingRulesController::class, 'index']); + Route::get('pricing-rules/new', [CatalogPricingRulesController::class, 'edit']); + Route::get('pricing-rules/{id}', [CatalogPricingRulesController::class, 'edit'])->whereNumber('id'); + }); + }); + + Route::prefix('commerce/store-management/{storeHandle}')->group(function () { + Route::get('payment-currencies', [PaymentCurrenciesController::class, 'index']); + Route::get('payment-currencies/new', [PaymentCurrenciesController::class, 'edit']); + Route::get('payment-currencies/{id}', [PaymentCurrenciesController::class, 'edit'])->whereNumber('id'); + }); + }); + + // PromotionsController extends BaseCpController (not BaseStoreManagementController) — it + // only ever needed accessPlugin-commerce, not commerce-manageStoreSettings. + Route::get('commerce/promotions', fn() => redirect('commerce/promotions/sales')); + + // OrdersController extends the plain Yii2 Controller (not BaseCpController) — its init() + // only ever checked commerce-manageOrders, not accessPlugin-commerce. + Route::middleware('can:commerce-manageOrders')->group(function () { + Route::get('commerce/orders/{orderId}', [OrdersController::class, 'editOrder'])->whereNumber('orderId'); + Route::get('commerce/orders/{storeHandle}/create', [OrdersController::class, 'create']); + Route::get('commerce/orders/{orderStatusHandle?}', [OrdersController::class, 'orderIndex']); + }); + + // InventoryController checks commerce-manageInventoryStockLevels inline on every action + // (not via init()) — replicated here as a route-group-wide permission instead. + Route::middleware('can:commerce-manageInventoryStockLevels')->group(function () { + Route::get('commerce/inventory/item/{inventoryItemId}', [InventoryController::class, 'itemEdit'])->whereNumber('inventoryItemId'); + Route::get('commerce/inventory/levels/{inventoryLocationHandle}', [InventoryController::class, 'editLocationLevels']); + Route::get('commerce/inventory/levels', [InventoryController::class, 'editLocationLevels']); + Route::get('commerce/inventory', [InventoryController::class, 'editLocationLevels']); + }); + + Route::middleware('can:commerce-manageInventoryLocations')->group(function () { + Route::get('commerce/inventory-locations', [InventoryLocationsController::class, 'index']); + Route::get('commerce/inventory-locations/new', [InventoryLocationsController::class, 'edit']); + Route::get('commerce/inventory-locations/{inventoryLocationId}', [InventoryLocationsController::class, 'edit'])->whereNumber('inventoryLocationId'); + }); + + Route::middleware('can:commerce-manageInventoryTransfers') + ->get('commerce/inventory/transfers', [TransfersController::class, 'index']); +}); + +// The Commerce screen on the Edit User screen — permission is enforced inline via the +// EditUserScreensResolving listener in src/Plugin.php (only shows the tab/registers the +// screen if the viewer can access Commerce), matching every other Edit User screen's `auth`-only +// route-level requirement. +Route::middleware('auth')->group(function () { + Route::get('myaccount/commerce', [UsersController::class, 'index']); + Route::get('users/{userId}/commerce', [UsersController::class, 'index'])->whereNumber('userId'); +}); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000000..4fe445d8e4 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,7 @@ +whereNumber('gatewayId'); diff --git a/src-yii2/Plugin.php b/src-yii2/Plugin.php new file mode 100755 index 0000000000..ed1f1f8f29 --- /dev/null +++ b/src-yii2/Plugin.php @@ -0,0 +1,503 @@ + + * @since 2.0 + */ +class Plugin extends BasePlugin +{ + public const EDITION_PRO = 'pro'; + public const EDITION_ENTERPRISE = 'enterprise'; + + public const EDITION_PRO_STORE_LIMIT = 5; + + /** + * Returns the editions for Craft Commerce + * + * @inheritDoc + */ + public static function editions(): array + { + return [ + self::EDITION_PRO, + self::EDITION_ENTERPRISE, + ]; + } + + /** + * @inheritDoc + */ + public string $schemaVersion = '5.7.0.0'; + + /** + * @inheritdoc + */ + public bool $hasCpSettings = true; + + /** + * @inheritdoc + */ + public string $minVersionRequired = '3.4.11'; + + /** + * @inheritdoc + */ + public CmsEdition $minCmsEdition = CmsEdition::Pro; + + /** + * @inheritdoc + */ + public bool $hasReadOnlyCpSettings = true; + + use Variables; + use Routes; + + public function boot(): void + { + parent::boot(); + + // craft\commerce\Plugin no longer extends yii\base\Module (it extends the new + // CraftCms\Commerce\Plugin instead), so Yii2's controller resolution can no longer + // find craft\commerce\controllers\* via Craft::$app->getModule('commerce') on its + // own. Register a minimal module purely for that lookup; the legacy UrlManager + // rules below still route correctly, but without this every one of them 404s. + if (Craft::$app->getModule('commerce') === null) { + Craft::$app->setModule('commerce', new LegacyRoutingModule('commerce')); + } + + $request = Craft::$app->getRequest(); + + $this->_registerCraftEventListeners(); + $this->_registerProjectConfigEventListeners(); + $this->_registerVariables(); + $this->_registerForeignKeysRestore(); + $this->_registerPoweredByHeader(); + $this->_registerGqlInterfaces(); + $this->_registerGqlQueries(); + $this->_registerRelatedToArguments(); + + if ($request->getIsCpRequest()) { + $this->_registerCpRoutes(); + $this->_registerRedactorLinkOptions(); + $this->_registerCKEditorLinkOptions(); + } else { + $this->_registerSiteRoutes(); + } + + Craft::setAlias('@commerceLib', Craft::getAlias('@craft/commerce/../lib')); + } + + public function beforeInstall(): void + { + // Check version before installing + if (version_compare(Craft::$app->getInfo()->version, '5.1.0', '<')) { + throw new Exception('Craft Commerce 5 requires Craft CMS 5.1+ in order to run.'); + } + + if (!defined('PHP_VERSION_ID') || PHP_VERSION_ID < 80200) { + Craft::error('Craft Commerce requires PHP 8.2.0+ in order to run.'); + } + } + + public function getSettingsResponse(): mixed + { + return Craft::$app->getResponse()->redirect(UrlHelper::cpUrl('commerce/settings/general')); + } + + public function getReadOnlySettingsResponse(): mixed + { + return Craft::$app->getResponse()->redirect(UrlHelper::cpUrl('commerce/settings/general')); + } + + protected function createSettingsModel(): ?Model + { + return new Settings(); + } + + + /** + * Register links to product in the redactor rich text field + */ + private function _registerRedactorLinkOptions(): void + { + if (!class_exists(RedactorField::class)) { + return; + } + + Event::on(RedactorField::class, RedactorField::EVENT_REGISTER_LINK_OPTIONS, function(RegisterLinkOptionsEvent $event) { + // Include a Product link option if there are any product types that have URLs + $productSources = []; + + $sites = Craft::$app->getSites()->getAllSites(); + + foreach ($this->getProductTypes()->getAllProductTypes() as $productType) { + foreach ($sites as $site) { + $productTypeSettings = $productType->getSiteSettings(); + if (isset($productTypeSettings[$site->id]) && $productTypeSettings[$site->id]->hasUrls) { + $productSources[] = 'productType:' . $productType->uid; + } + } + } + + $productSources = array_unique($productSources); + + if ($productSources) { + $event->linkOptions[] = [ + 'optionTitle' => Craft::t('commerce', 'Link to a product'), + 'elementType' => Product::class, + 'refHandle' => Product::refHandle(), + 'sources' => $productSources, + ]; + + $event->linkOptions[] = [ + 'optionTitle' => Craft::t('commerce', 'Link to a variant'), + 'elementType' => Variant::class, + 'refHandle' => Variant::refHandle(), + 'sources' => $productSources, + ]; + } + }); + } + + /** + * Register links to product in the ckeditor rich text field + */ + private function _registerCKEditorLinkOptions(): void + { + $ckEditorPlugin = Craft::$app->getPlugins()->getPlugin('ckeditor'); + if (!class_exists(CKEditorField::class) || !$ckEditorPlugin || version_compare($ckEditorPlugin->getVersion(), '3.0', '<')) { + return; + } + + Event::on(CKEditorField::class, CKEditorField::EVENT_DEFINE_LINK_OPTIONS, function(DefineLinkOptionsEvent $event) { + // Include a Product link option if there are any product types that have URLs + $productSources = []; + + $sites = Craft::$app->getSites()->getAllSites(); + + foreach ($this->getProductTypes()->getAllProductTypes() as $productType) { + foreach ($sites as $site) { + $productTypeSettings = $productType->getSiteSettings(); + if (isset($productTypeSettings[$site->id]) && $productTypeSettings[$site->id]->hasUrls) { + $productSources[] = 'productType:' . $productType->uid; + } + } + } + + $productSources = array_unique($productSources); + + if ($productSources) { + $event->linkOptions[] = [ + 'label' => Craft::t('commerce', 'Link to a product'), + 'elementType' => Product::class, + 'refHandle' => Product::refHandle(), + 'sources' => $productSources, + ]; + + $event->linkOptions[] = [ + 'label' => Craft::t('commerce', 'Link to a variant'), + 'elementType' => Variant::class, + 'refHandle' => Variant::refHandle(), + 'sources' => $productSources, + ]; + } + }); + } + + /** + * Register Commerce’s project config event listeners + */ + private function _registerProjectConfigEventListeners(): void + { + $projectConfigService = Craft::$app->getProjectConfig(); + + $gatewayService = $this->getGateways(); + $projectConfigService->onAdd(Gateways::CONFIG_GATEWAY_KEY . '.{uid}', $gatewayService->handleChangedGateway(...)) + ->onUpdate(Gateways::CONFIG_GATEWAY_KEY . '.{uid}', $gatewayService->handleChangedGateway(...)) + ->onRemove(Gateways::CONFIG_GATEWAY_KEY . '.{uid}', $gatewayService->handleArchivedGateway(...)); + + $productTypeService = $this->getProductTypes(); + $projectConfigService->onAdd(ProductTypes::CONFIG_PRODUCTTYPES_KEY . '.{uid}', $productTypeService->handleChangedProductType(...)) + ->onUpdate(ProductTypes::CONFIG_PRODUCTTYPES_KEY . '.{uid}', $productTypeService->handleChangedProductType(...)) + ->onRemove(ProductTypes::CONFIG_PRODUCTTYPES_KEY . '.{uid}', $productTypeService->handleDeletedProductType(...)); + + Event::on(Sites::class, Sites::EVENT_AFTER_DELETE_SITE, function(DeleteSiteEvent $event) use ($productTypeService) { + if (!Craft::$app->getProjectConfig()->getIsApplyingExternalChanges()) { + $productTypeService->pruneDeletedSite($event); + } + }); + + $ordersService = $this->getOrders(); + $projectConfigService->onAdd(OrdersService::CONFIG_FIELDLAYOUT_KEY, $ordersService->handleChangedFieldLayout(...)) + ->onUpdate(OrdersService::CONFIG_FIELDLAYOUT_KEY, $ordersService->handleChangedFieldLayout(...)) + ->onRemove(OrdersService::CONFIG_FIELDLAYOUT_KEY, $ordersService->handleDeletedFieldLayout(...)); + + $transfersService = $this->getTransfers(); + $projectConfigService->onAdd(TransfersService::CONFIG_FIELDLAYOUT_KEY, $transfersService->handleChangedFieldLayout(...)) + ->onUpdate(TransfersService::CONFIG_FIELDLAYOUT_KEY, $transfersService->handleChangedFieldLayout(...)) + ->onRemove(TransfersService::CONFIG_FIELDLAYOUT_KEY, $transfersService->handleDeletedFieldLayout(...)); + + $orderStatusService = $this->getOrderStatuses(); + $projectConfigService->onAdd(OrderStatuses::CONFIG_STATUSES_KEY . '.{uid}', $orderStatusService->handleChangedOrderStatus(...)) + ->onUpdate(OrderStatuses::CONFIG_STATUSES_KEY . '.{uid}', $orderStatusService->handleChangedOrderStatus(...)) + ->onRemove(OrderStatuses::CONFIG_STATUSES_KEY . '.{uid}', $orderStatusService->handleDeletedOrderStatus(...)); + + Event::on(Emails::class, Emails::EVENT_AFTER_DELETE_EMAIL, function(EmailEvent $event) use ($orderStatusService) { + if (!Craft::$app->getProjectConfig()->getIsApplyingExternalChanges()) { + $orderStatusService->pruneDeletedEmail($event); + } + }); + + $lineItemStatusService = $this->getLineItemStatuses(); + $projectConfigService->onAdd(LineItemStatuses::CONFIG_STATUSES_KEY . '.{uid}', $lineItemStatusService->handleChangedLineItemStatus(...)) + ->onUpdate(LineItemStatuses::CONFIG_STATUSES_KEY . '.{uid}', $lineItemStatusService->handleChangedLineItemStatus(...)) + ->onRemove(LineItemStatuses::CONFIG_STATUSES_KEY . '.{uid}', $lineItemStatusService->handleArchivedLineItemStatus(...)); + + $emailService = $this->getEmails(); + $projectConfigService->onAdd(Emails::CONFIG_EMAILS_KEY . '.{uid}', $emailService->handleChangedEmail(...)) + ->onUpdate(Emails::CONFIG_EMAILS_KEY . '.{uid}', $emailService->handleChangedEmail(...)) + ->onRemove(Emails::CONFIG_EMAILS_KEY . '.{uid}', $emailService->handleDeletedEmail(...)); + + $storesService = $this->getStores(); + $projectConfigService->onAdd(Stores::CONFIG_STORES_KEY . '.{uid}', $storesService->handleChangedStore(...)) + ->onUpdate(Stores::CONFIG_STORES_KEY . '.{uid}', $storesService->handleChangedStore(...)) + ->onRemove(Stores::CONFIG_STORES_KEY . '.{uid}', $storesService->handleDeletedStore(...)); + + $projectConfigService->onAdd(Stores::CONFIG_SITESTORES_KEY . '.{uid}', $storesService->handleChangedSiteStore(...)) + ->onUpdate(Stores::CONFIG_SITESTORES_KEY . '.{uid}', $storesService->handleChangedSiteStore(...)) + ->onRemove(Stores::CONFIG_SITESTORES_KEY . '.{uid}', $storesService->handleDeletedSiteStore(...)); + + $pdfService = $this->getPdfs(); + $projectConfigService->onAdd(Pdfs::CONFIG_PDFS_KEY . '.{uid}', $pdfService->handleChangedPdf(...)) + ->onUpdate(Pdfs::CONFIG_PDFS_KEY . '.{uid}', $pdfService->handleChangedPdf(...)) + ->onRemove(Pdfs::CONFIG_PDFS_KEY . '.{uid}', $pdfService->handleDeletedPdf(...)); + + Event::on(ProjectConfig::class, ProjectConfig::EVENT_REBUILD, static function(RebuildConfigEvent $event) { + $event->config['commerce'] = ProjectConfigData::rebuildProjectConfig(); + }); + } + + /** + * Register general event listeners + */ + private function _registerCraftEventListeners(): void + { + // Guard against the case where the Plugin class is loaded during Craft installation due to a project config existing but commerce is not installed. + // Also fixed in core but this is an extra guard: https://github.com/craftcms/cms/commit/369807d9b8da0ff0968e292591eee5f8924b57cc + if (!$this->isInstalled) { + return; + } + + // TODO: UserQuery::EVENT_AFTER_POPULATE_ELEMENTS was removed in Craft 6. + // Re-wire customer attachment (primaryBillingAddressId / primaryShippingAddressId) + // to the new element-loading lifecycle when its equivalent lands. + // Original logic preserved below in a no-op closure so the customer-attach + // code is easy to port once the new hook exists. + // Event::on(UserQuery::class, UserQuery::EVENT_AFTER_POPULATE_ELEMENTS, function(PopulateElementsEvent $event) { + // $users = $event->elements; + // $customerIds = ArrayHelper::getColumn($users, 'id'); + // + // if (empty($customerIds)) { + // return; + // } + // + // $customers = new Query() + // ->select(['customerId', 'primaryBillingAddressId', 'primaryShippingAddressId']) + // ->from([Table::CUSTOMERS]) + // ->where(['customerId' => $customerIds]) + // ->all(); + // + // if (empty($customers)) { + // return; + // } + // + // foreach ($customers as $customer) { + // /** @var User|CustomerBehavior|null $user */ + // $user = ArrayHelper::firstWhere($users, 'id', $customer['customerId']); + // if (!$user) { + // continue; + // } + // + // $user->setPrimaryBillingAddressId($customer['primaryBillingAddressId']); + // $user->setPrimaryShippingAddressId($customer['primaryShippingAddressId']); + // } + // }); + + // Commerce screen on the Edit User screen is now registered in src/Plugin.php + + Event::on(Purchasable::class, Elements::EVENT_BEFORE_RESTORE_ELEMENT, [$this->getPurchasables(), 'beforeRestorePurchasableHandler']); + } + + /** + * Register Commerce’s template variable. + */ + private function _registerVariables(): void + { + // Legacy Yii2 CraftVariable (backward compat) — `craft.commerce`/`craft.orders`/etc. for + // the new Twig variable system are registered via `NewCraftVariable::macro(...)` in + // src/Plugin.php now. + Event::on(CraftVariable::class, CraftVariable::EVENT_INIT, static function(Event $event) { + /** @var CraftVariable $variable */ + $variable = $event->sender; + $variable->attachBehavior('commerce', CraftVariableBehavior::class); + }); + } + + /** + * Register for FK restore plugin + */ + private function _registerForeignKeysRestore(): void + { + if (!class_exists(RestoreController::class)) { + return; + } + + Event::on(RestoreController::class, RestoreController::EVENT_AFTER_RESTORE_FKS, static function() { + // Add default FKs + new Install()->addForeignKeys(); + }); + } + + /** + * Register the powered-by header + */ + private function _registerPoweredByHeader(): void + { + if (!Craft::$app->request->isConsoleRequest) { + $headers = Craft::$app->getResponse()->getHeaders(); + // Send the X-Powered-By header? + if (Craft::$app->getConfig()->getGeneral()->sendPoweredByHeader) { + $original = $headers->get('X-Powered-By'); + $headers->set('X-Powered-By', $original . ($original ? ',' : '') . 'Craft Commerce'); + } else { + // In case PHP is already setting one + header_remove('X-Powered-By'); + } + } + } + + /** + * Register the Gql interfaces + */ + private function _registerGqlInterfaces(): void + { + Event::on(Gql::class, Gql::EVENT_REGISTER_GQL_TYPES, static function(RegisterGqlTypesEvent $event) { + // Add my GraphQL types + $types = $event->types; + $types[] = GqlProductInterface::class; + $types[] = GqlVariantInterface::class; + $event->types = $types; + }); + } + + /** + * Register the Gql queries + */ + private function _registerGqlQueries(): void + { + Event::on(Gql::class, Gql::EVENT_REGISTER_GQL_QUERIES, static function(RegisterGqlQueriesEvent $event) { + // Add my GraphQL queries + $event->queries = array_merge( + $event->queries, + GqlProductQueries::getQueries(), + GqlVariantQueries::getQueries() + ); + }); + } + + /** + * Add relatedToProducts and relatedToVariants arguments to element queries. + * + * The handlers for these arguments themselves are registered via + * CraftCms\Commerce\Plugin's boot() using the new GqlArguments registry. + */ + private function _registerRelatedToArguments(): void + { + Event::on(Gql::class, Gql::EVENT_REGISTER_GQL_QUERIES, static function(RegisterGqlQueriesEvent $event) { + $relatedToProductsArg = [ + 'name' => 'relatedToProducts', + 'type' => \GraphQL\Type\Definition\Type::listOf(ProductRelation::getType()), + 'description' => 'Narrows the query results to elements that relate to a product list defined with this argument.', + ]; + $relatedToVariantsArg = [ + 'name' => 'relatedToVariants', + 'type' => \GraphQL\Type\Definition\Type::listOf(VariantRelation::getType()), + 'description' => 'Narrows the query results to elements that relate to a variant list defined with this argument.', + ]; + + // Add the arguments to all relevant queries + foreach ($event->queries as $queryName => &$queryConfig) { + if (isset($queryConfig['args']) && is_array($queryConfig['args'])) { + $queryConfig['args']['relatedToProducts'] = $relatedToProductsArg; + $queryConfig['args']['relatedToVariants'] = $relatedToVariantsArg; + } + } + }); + } + +} diff --git a/src-yii2/adjusters/Discount.php b/src-yii2/adjusters/Discount.php new file mode 100644 index 0000000000..bdab1d29e9 --- /dev/null +++ b/src-yii2/adjusters/Discount.php @@ -0,0 +1,11 @@ +fields = array_merge($fields, $this->currencyFields()); } - /** - * @inheritdoc - */ public function __call($name, $params) { if (StringHelper::endsWith($name, 'AsCurrency', false)) { @@ -117,9 +111,6 @@ public function __call($name, $params) return parent::__call($name, $params); } - /** - * @inheritdoc - */ public function hasMethod($name): bool { if (StringHelper::endsWith($name, 'AsCurrency', false)) { @@ -131,9 +122,6 @@ public function hasMethod($name): bool return parent::hasMethod($name); } - /** - * @inheritdoc - */ public function __isset($name) { if (StringHelper::endsWith($name, 'AsCurrency', false)) { @@ -146,9 +134,6 @@ public function __isset($name) return parent::__isset($name); } - /** - * @inheritdoc - */ public function __get($name) { if (StringHelper::endsWith($name, 'AsCurrency', false)) { @@ -162,9 +147,6 @@ public function __get($name) return parent::__get($name); } - /** - * @inheritdoc - */ public function canGetProperty($name, $checkVars = true): bool { if (StringHelper::endsWith($name, 'AsCurrency', false)) { diff --git a/src/behaviors/CustomerAddressBehavior.php b/src-yii2/behaviors/CustomerAddressBehavior.php similarity index 99% rename from src/behaviors/CustomerAddressBehavior.php rename to src-yii2/behaviors/CustomerAddressBehavior.php index c1181ef801..6cd08eb332 100644 --- a/src/behaviors/CustomerAddressBehavior.php +++ b/src-yii2/behaviors/CustomerAddressBehavior.php @@ -41,9 +41,6 @@ class CustomerAddressBehavior extends Behavior */ private bool $_isPrimaryShipping; - /** - * @inheritdoc - */ public function events(): array { return [ diff --git a/src/behaviors/CustomerBehavior.php b/src-yii2/behaviors/CustomerBehavior.php similarity index 91% rename from src/behaviors/CustomerBehavior.php rename to src-yii2/behaviors/CustomerBehavior.php index 0b3be9fef3..e2ec19493e 100644 --- a/src/behaviors/CustomerBehavior.php +++ b/src-yii2/behaviors/CustomerBehavior.php @@ -10,16 +10,15 @@ use Craft; use craft\commerce\db\Table; use craft\commerce\elements\Order; -use craft\commerce\elements\Subscription; use craft\commerce\models\PaymentSource; use craft\commerce\Plugin; -use craft\commerce\records\Customer; use craft\elements\Address; use craft\elements\User; use craft\events\DefineFieldsEvent; use craft\events\DefineRulesEvent; use craft\events\ModelEvent; use craft\helpers\ArrayHelper; +use CraftCms\Commerce\Customer\Records\Customer; use RuntimeException; use yii\base\Behavior; use yii\base\InvalidConfigException; @@ -32,7 +31,6 @@ * @property-read array $inactiveCarts * @property null|int $primaryShippingAddressId * @property-read null|Address $primaryBillingAddress - * @property-read Subscription[] $subscriptions * @property null|int $primaryBillingAddressId * @property-read Order[] $orders * @property-read Address[] $addresses @@ -60,20 +58,12 @@ class CustomerBehavior extends Behavior */ private ?int $_primaryPaymentSourceId = null; - /** - * @var array|null - */ - private ?array $_subscriptions = null; - /** * @var Customer|null * @since 4.2 */ private ?Customer $_customer = null; - /** - * @inheritdoc - */ public function attach($owner) { if (!$owner instanceof User) { @@ -94,9 +84,6 @@ public function defineFields(DefineFieldsEvent $event): void $event->fields['primaryShippingAddressId'] = 'primaryShippingAddressId'; } - /** - * @inheritdoc - */ public function events(): array { return [ @@ -191,23 +178,6 @@ public function getOrders(): array ->all(); } - /** - * Returns the subscription elements associated with this customer. - * - * @return Subscription[] - */ - public function getSubscriptions(): array - { - if (null === $this->_subscriptions) { - $this->_subscriptions = Subscription::find() - ->user($this->owner) - ->status(null) - ->all(); - } - - return $this->_subscriptions ?? []; - } - /** * @return int|null */ @@ -335,7 +305,7 @@ private function _getCustomerRecord(): ?Customer { if (!$this->_customer instanceof Customer) { /** @var Customer|null $customer */ - $customer = Customer::find()->where(['customerId' => $this->owner->id])->one(); + $customer = Customer::where('customerId', $this->owner->id)->first(); $this->_customer = $customer; } diff --git a/src/behaviors/StoreBehavior.php b/src-yii2/behaviors/StoreBehavior.php similarity index 100% rename from src/behaviors/StoreBehavior.php rename to src-yii2/behaviors/StoreBehavior.php diff --git a/src/behaviors/StoreLocationBehavior.php b/src-yii2/behaviors/StoreLocationBehavior.php similarity index 83% rename from src/behaviors/StoreLocationBehavior.php rename to src-yii2/behaviors/StoreLocationBehavior.php index 4b8c6eb476..ffa6094aa6 100644 --- a/src/behaviors/StoreLocationBehavior.php +++ b/src-yii2/behaviors/StoreLocationBehavior.php @@ -2,8 +2,8 @@ namespace craft\commerce\behaviors; -use craft\commerce\records\StoreSettings; use craft\elements\Address; +use CraftCms\Commerce\Store\Records\StoreSettings; use craft\events\AuthorizationCheckEvent; use craft\events\ModelEvent; use RuntimeException; @@ -14,9 +14,6 @@ class StoreLocationBehavior extends Behavior /** @var Address */ public $owner; - /** - * @inheritdoc - */ public function attach($owner) { if (!$owner instanceof Address) { @@ -26,9 +23,6 @@ public function attach($owner) parent::attach($owner); } - /** - * @inheritdoc - */ public function events(): array { return [ @@ -54,7 +48,7 @@ public function saveStoreLocation(ModelEvent $event): void { $address = $event->sender; /** @var StoreSettings $store */ - $store = StoreSettings::find()->one(); // we only have one store right now, and we assume it is the first one + $store = StoreSettings::first(); // we only have one store right now, and we assume it is the first one $store->locationAddressId = $address->id; $store->save(); } diff --git a/src/behaviors/ValidateOrganizationTaxIdBehavior.php b/src-yii2/behaviors/ValidateOrganizationTaxIdBehavior.php similarity index 95% rename from src/behaviors/ValidateOrganizationTaxIdBehavior.php rename to src-yii2/behaviors/ValidateOrganizationTaxIdBehavior.php index 491b2749c1..9de4ddcfb0 100644 --- a/src/behaviors/ValidateOrganizationTaxIdBehavior.php +++ b/src-yii2/behaviors/ValidateOrganizationTaxIdBehavior.php @@ -15,9 +15,6 @@ class ValidateOrganizationTaxIdBehavior extends Behavior /** @var Address */ public $owner; - /** - * @inheritdoc - */ public function attach($owner) { if (!$owner instanceof Address) { @@ -27,9 +24,6 @@ public function attach($owner) parent::attach($owner); } - /** - * @inheritdoc - */ public function events(): array { return [ diff --git a/src-yii2/collections/InventoryMovementCollection.php b/src-yii2/collections/InventoryMovementCollection.php new file mode 100644 index 0000000000..29a59dee8b --- /dev/null +++ b/src-yii2/collections/InventoryMovementCollection.php @@ -0,0 +1,11 @@ +getModule('commerce')` returns null and every legacy-dispatched Commerce + * controller 404s, even though the URL rules themselves still match correctly. + * + * @since 6.0.0 + */ +class LegacyRoutingModule extends Module +{ + public $controllerNamespace = 'craft\commerce\controllers'; +} diff --git a/src-yii2/plugin/Routes.php b/src-yii2/plugin/Routes.php new file mode 100644 index 0000000000..19f64bcc71 --- /dev/null +++ b/src-yii2/plugin/Routes.php @@ -0,0 +1,79 @@ + + * @since 2.0 + */ +trait Routes +{ + /** + * @since 3.1.10 + * @deprecated the webhook route is now registered in routes/web.php and routes/actions.php. + */ + private function _registerSiteRoutes(): void + { + } + + /** + * @since 2.0 + */ + private function _registerCpRoutes(): void + { + Event::on(UrlManager::class, UrlManager::EVENT_REGISTER_CP_URL_RULES, function(RegisterUrlRulesEvent $event) { + $event->rules['commerce'] = ['template' => 'commerce/index']; + + // User edit screen ("myaccount/commerce" / "users//commerce") is now registered + // in routes/cp.php + + // Products / Variants — index and create are now registered in routes/cp.php; the + // element-edit rules below are Craft core's own generic element-editing route, not a + // Commerce controller, so they stay here. + $event->rules['commerce/variants/'] = 'elements/edit'; + $event->rules['commerce/products//'] = 'elements/edit'; + + // Product Types are now registered in routes/cp.php + + // Orders are now registered in routes/cp.php + + // Settings + + // commerce/settings/stores* and commerce/settings/sites are now registered in routes/cp.php + + // commerce/settings/general, ordersettings, and transfers are now registered in routes/cp.php + + // commerce/settings/gateways* is now registered in routes/cp.php + + // Emails and PDFs are now registered in routes/cp.php + + // Order Statuses and Line Item Statuses are now registered in routes/cp.php + + // Store Settings and Payment Currencies are now registered in routes/cp.php + + // Shipping is now registered in routes/cp.php + + // Taxes are now registered in routes/cp.php + + // Sales, Discounts, and Pricing Rules are now registered in routes/cp.php + + // Inventory, Inventory Locations, and Transfers index/edit are now registered in + // routes/cp.php — the element-edit rule below is Craft core's own generic + // element-editing route, not a Commerce controller, so it stays here. + $event->rules['commerce/inventory/transfers/'] = 'elements/edit'; + + // commerce/donations is now registered in routes/cp.php + }); + } +} diff --git a/src/plugin/Variables.php b/src-yii2/plugin/Variables.php similarity index 100% rename from src/plugin/Variables.php rename to src-yii2/plugin/Variables.php diff --git a/src-yii2/queue/jobs/CatalogPricing.php b/src-yii2/queue/jobs/CatalogPricing.php new file mode 100644 index 0000000000..8f99ff1b67 --- /dev/null +++ b/src-yii2/queue/jobs/CatalogPricing.php @@ -0,0 +1,90 @@ +getCatalogPricing(); + $isConsolidatedJob = $this->storeId === null && $this->purchasableIds === null && $this->catalogPricingRuleIds === null; + $catalogPricingRules = null; + $reservedRowId = null; + + // @TODO: remove these properties and behaviour at next breaking change + $storeId = $this->storeId; + $purchasableIds = $this->purchasableIds; + $catalogPricingRuleIds = $this->catalogPricingRuleIds; + + if ($isConsolidatedJob) { + // New method of processing catalog pricing via queue table: reserve a row and process based on its type and IDs + $reservedRecord = $catalogPricingService->reserveCatalogPricingQueueRow(); + + if (!$reservedRecord) { + return; + } + + $reservedRowId = $reservedRecord->id; + $storeId = $reservedRecord->storeId; + + if ($reservedRecord->type === CatalogPricingQueueRecord::TYPE_PURCHASABLE) { + // Specific purchasable IDs: regenerate against all applicable rules + $purchasableIds = $reservedRecord->ids; + } elseif ($reservedRecord->type === CatalogPricingQueueRecord::TYPE_RULE) { + $catalogPricingRuleIds = $reservedRecord->ids; + } else { + throw new \UnexpectedValueException("Unrecognized catalog pricing queue row type: {$reservedRecord->type}"); + } + } + + if (!empty($catalogPricingRuleIds)) { + $catalogPricingRules = Plugin::getInstance()->getCatalogPricingRules() + ->getAllCatalogPricingRules($storeId) + ->whereIn('id', $catalogPricingRuleIds) + ->all(); + } + + try { + $catalogPricingService->generateCatalogPrices($purchasableIds, $catalogPricingRules, queue: $queue); + + if ($reservedRowId) { + $catalogPricingService->deleteCatalogPricingQueueRowById($reservedRowId); + } + } catch (\Throwable $e) { + if ($reservedRowId) { + $catalogPricingService->releaseCatalogPricingQueueRowById($reservedRowId); + } + + throw $e; + } + } + + protected function defaultDescription(): ?string + { + return 'Generating catalog pricing.'; + } +} diff --git a/src/queue/jobs/ResaveProductVariants.php b/src-yii2/queue/jobs/ResaveProductVariants.php similarity index 95% rename from src/queue/jobs/ResaveProductVariants.php rename to src-yii2/queue/jobs/ResaveProductVariants.php index 28bd63c636..6a6171c0f9 100644 --- a/src/queue/jobs/ResaveProductVariants.php +++ b/src-yii2/queue/jobs/ResaveProductVariants.php @@ -24,9 +24,6 @@ class ResaveProductVariants extends BaseJob */ public int $productId; - /** - * @inheritdoc - */ public function execute($queue): void { $product = Product::find() @@ -50,9 +47,6 @@ public function execute($queue): void } } - /** - * @inheritdoc - */ protected function defaultDescription(): ?string { $product = Product::find() diff --git a/src/queue/jobs/SendEmail.php b/src-yii2/queue/jobs/SendEmail.php similarity index 97% rename from src/queue/jobs/SendEmail.php rename to src-yii2/queue/jobs/SendEmail.php index 1ed9deee25..a3fc1c73f2 100644 --- a/src/queue/jobs/SendEmail.php +++ b/src-yii2/queue/jobs/SendEmail.php @@ -8,7 +8,7 @@ namespace craft\commerce\queue\jobs; use craft\commerce\elements\Order; -use craft\commerce\errors\EmailException; +use CraftCms\Commerce\Email\Exceptions\EmailException; use craft\commerce\Plugin; use craft\queue\BaseJob; use yii\base\InvalidConfigException; diff --git a/src-yii2/records/Donation.php b/src-yii2/records/Donation.php new file mode 100644 index 0000000000..80daf6b064 --- /dev/null +++ b/src-yii2/records/Donation.php @@ -0,0 +1,11 @@ +cartCookie; + } + + public function setCartCookie(array $value): void + { + app(\CraftCms\Commerce\Order\Carts::class)->cartCookie = $value; + } + + /** + * @see \CraftCms\Commerce\Order\Carts::$cartCookieDuration + */ + public function getCartCookieDuration(): int + { + return app(\CraftCms\Commerce\Order\Carts::class)->cartCookieDuration; + } + + public function setCartCookieDuration(int $value): void + { + app(\CraftCms\Commerce\Order\Carts::class)->cartCookieDuration = $value; + } + + public function getCart(bool $forceSave = false): Order + { + return app(\CraftCms\Commerce\Order\Carts::class)->getCart($forceSave); + } + + public function peekCart(): ?Order + { + return app(\CraftCms\Commerce\Order\Carts::class)->peekCart(); + } + + public function forgetCart(): void + { + app(\CraftCms\Commerce\Order\Carts::class)->forgetCart(); + } + + public function generateCartNumber(): string + { + return app(\CraftCms\Commerce\Order\Carts::class)->generateCartNumber(); + } + + public function getActiveCartEdgeDuration(): string + { + return app(\CraftCms\Commerce\Order\Carts::class)->getActiveCartEdgeDuration(); + } + + public function getHasSessionCartNumber(): bool + { + return app(\CraftCms\Commerce\Order\Carts::class)->getHasSessionCartNumber(); + } + + public function setSessionCartNumber(string $cartNumber): void + { + app(\CraftCms\Commerce\Order\Carts::class)->setSessionCartNumber($cartNumber); + } + + public function getLoadCartUrl(Order $cart): string + { + return app(\CraftCms\Commerce\Order\Carts::class)->getLoadCartUrl($cart); + } + + public function restorePreviousCartForCurrentUser(): void + { + app(\CraftCms\Commerce\Order\Carts::class)->restorePreviousCartForCurrentUser(); + } + + public function purgeIncompleteCarts(): int + { + return app(\CraftCms\Commerce\Order\Carts::class)->purgeIncompleteCarts(); + } +} diff --git a/src-yii2/services/CatalogPricing.php b/src-yii2/services/CatalogPricing.php new file mode 100755 index 0000000000..6379038e58 --- /dev/null +++ b/src-yii2/services/CatalogPricing.php @@ -0,0 +1,89 @@ +generateCatalogPrices($purchasableIds, $catalogPricingRules, $showConsoleOutput, $queue); + } + + public function getCatalogPrice(int $purchasableId, ?int $storeId = null, ?int $userId = null, bool $isPromotionalPrice = false): ?float + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->getCatalogPrice($purchasableId, $storeId, $userId, $isPromotionalPrice); + } + + public function getCatalogPricesByPurchasableId(int $purchasableId, ?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->getCatalogPricesByPurchasableId($purchasableId, $storeId); + } + + public function getCatalogPrices(int $storeId, ?CatalogPricingCondition $conditionBuilder = null, bool $includeBasePrices = true, ?string $searchText = null, ?int $limit = null, ?int $offset = null): Collection + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->getCatalogPrices($storeId, $conditionBuilder, $includeBasePrices, $searchText, $limit, $offset); + } + + public function getCatalogPricesPageInfo(int $storeId, ?CatalogPricingCondition $conditionBuilder = null, bool $includeBasePrices = true, ?string $searchText = null, int $limit = 100, int $offset = 0): mixed + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->getCatalogPricesPageInfo($storeId, $conditionBuilder, $includeBasePrices, $searchText, $limit, $offset); + } + + public function markPricesAsUpdatePending(int|array|null $catalogPricingRuleId = null, int|array|null $purchasableId = null, int|array|null $storeId = null): void + { + app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->markPricesAsUpdatePending($catalogPricingRuleId, $purchasableId, $storeId); + } + + public function afterSavePurchasableHandler(ModelEvent $event): void + { + app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->afterSavePurchasableHandler($event); + } + + public function createCatalogPricingJob(array $config = [], int $priority = 100): void + { + app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->createCatalogPricingJob($config, $priority); + } + + public function areCatalogPricingJobsRunning(): bool + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->areCatalogPricingJobsRunning(); + } + + public function reserveCatalogPricingQueueRow(): ?CatalogPricingQueueRecord + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->reserveCatalogPricingQueueRow(); + } + + public function releaseCatalogPricingQueueRowById(int $id): void + { + app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->releaseCatalogPricingQueueRowById($id); + } + + public function deleteCatalogPricingQueueRowById(int $id): void + { + app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->deleteCatalogPricingQueueRowById($id); + } + + // TODO: return type will differ (Builder vs craft\db\Query) — update callers when migrated + public function createCatalogPricingQuery(?int $userId = null, int|string|null $storeId = null, ?bool $isPromotionalPrice = null, bool $allPrices = false, ?CatalogPricingCondition $condition = null): mixed + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->createCatalogPricingQuery($userId, $storeId, $isPromotionalPrice, $allPrices, $condition); + } + + // TODO: return type will differ (Builder vs craft\db\Query) — update callers when migrated + public function createCatalogPricesQuery(?int $userId = null, int|string|null $storeId = null, bool $allPrices = false, ?CatalogPricingCondition $condition = null): mixed + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->createCatalogPricesQuery($userId, $storeId, $allPrices, $condition); + } +} diff --git a/src-yii2/services/CatalogPricingRules.php b/src-yii2/services/CatalogPricingRules.php new file mode 100644 index 0000000000..df4a0e4b41 --- /dev/null +++ b/src-yii2/services/CatalogPricingRules.php @@ -0,0 +1,83 @@ +hasCatalogPricingRules(); + } + + public function canUseCatalogPricingRules(): bool + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricingRules::class)->canUseCatalogPricingRules(); + } + + public function getCatalogPricingRuleById(int $id, ?int $storeId = null): ?CatalogPricingRule + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricingRules::class)->getCatalogPricingRuleById($id, $storeId); + } + + /** + * @return Collection + */ + public function getAllCatalogPricingRules(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricingRules::class)->getAllCatalogPricingRules($storeId); + } + + /** + * @return Collection + */ + public function getAllCatalogPricingRulesByPurchasableId(int $purchasableId, ?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricingRules::class)->getAllCatalogPricingRulesByPurchasableId($purchasableId, $storeId); + } + + /** + * @return Collection + */ + public function getAllEnabledCatalogPricingRules(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricingRules::class)->getAllEnabledCatalogPricingRules($storeId); + } + + /** + * @return Collection + */ + public function getAllActiveCatalogPricingRules(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricingRules::class)->getAllActiveCatalogPricingRules($storeId); + } + + /** + * @return Collection + */ + public function getAllCatalogPricingRulesWithUserConditions(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricingRules::class)->getAllCatalogPricingRulesWithUserConditions($storeId); + } + + public function generateRulePriceFromPrice(?float $basePrice, ?float $basePromotionalPrice, CatalogPricingRule $catalogPricingRule): ?float + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricingRules::class)->generateRulePriceFromPrice($basePrice, $basePromotionalPrice, $catalogPricingRule); + } + + public function saveCatalogPricingRule(CatalogPricingRule $catalogPricingRule, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricingRules::class)->saveCatalogPricingRule($catalogPricingRule, $runValidation); + } + + public function deleteCatalogPricingRuleById(int $id): bool + { + return app(\CraftCms\Commerce\CatalogPricing\CatalogPricingRules::class)->deleteCatalogPricingRuleById($id); + } +} diff --git a/src-yii2/services/Coupons.php b/src-yii2/services/Coupons.php new file mode 100644 index 0000000000..6e64d30323 --- /dev/null +++ b/src-yii2/services/Coupons.php @@ -0,0 +1,63 @@ +getAllCodes(); + } + + public function getCouponByCode(string $code): ?Coupon + { + return app(\CraftCms\Commerce\Promotion\Coupons::class)->getCouponByCode($code); + } + + /** + * @return Coupon[] + */ + public function getCouponsByDiscountId(int $discountId): array + { + return app(\CraftCms\Commerce\Promotion\Coupons::class)->getCouponsByDiscountId($discountId); + } + + /** + * @param string[] $existingCodes + * @return string[] + * @throws \Exception + */ + public function generateCouponCodes(int $count = 1, string $format = self::DEFAULT_COUPON_FORMAT, array $existingCodes = []): array + { + return app(\CraftCms\Commerce\Promotion\Coupons::class)->generateCouponCodes($count, $format, $existingCodes); + } + + public function deleteCouponById(int $id): bool + { + return app(\CraftCms\Commerce\Promotion\Coupons::class)->deleteCouponById($id); + } + + public function saveDiscountCoupons(Discount $discount): bool + { + return app(\CraftCms\Commerce\Promotion\Coupons::class)->saveDiscountCoupons($discount); + } + + public function saveCoupon(Coupon $coupon, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Promotion\Coupons::class)->saveCoupon($coupon, $runValidation); + } +} diff --git a/src-yii2/services/Currencies.php b/src-yii2/services/Currencies.php new file mode 100644 index 0000000000..034a021b87 --- /dev/null +++ b/src-yii2/services/Currencies.php @@ -0,0 +1,47 @@ +getTeller($currency); + } + + public function getCurrencyByIso(string $iso): ?Currency + { + return app(\CraftCms\Commerce\Payment\Currencies::class)->getCurrencyByIso($iso); + } + + /** + * @return Collection + */ + public function getAllCurrencies(): Collection + { + return app(\CraftCms\Commerce\Payment\Currencies::class)->getAllCurrencies(); + } + + public function getAllCurrenciesList(): array + { + return app(\CraftCms\Commerce\Payment\Currencies::class)->getAllCurrenciesList(); + } + + public function getSubunitFor(Currency|string $currency): int + { + return app(\CraftCms\Commerce\Payment\Currencies::class)->getSubunitFor($currency); + } + + public function numericCodeFor(Currency|string $currency): int + { + return app(\CraftCms\Commerce\Payment\Currencies::class)->numericCodeFor($currency); + } +} diff --git a/src-yii2/services/Customers.php b/src-yii2/services/Customers.php new file mode 100644 index 0000000000..2c4b6b9d58 --- /dev/null +++ b/src-yii2/services/Customers.php @@ -0,0 +1,64 @@ +savePrimaryShippingAddressId($user, $addressId); + } + + public function savePrimaryBillingAddressId(User $user, ?int $addressId): bool + { + return app(\CraftCms\Commerce\Customer\Customers::class)->savePrimaryBillingAddressId($user, $addressId); + } + + public function savePrimaryPaymentSourceId(User $user, ?int $paymentSourceId): bool + { + return app(\CraftCms\Commerce\Customer\Customers::class)->savePrimaryPaymentSourceId($user, $paymentSourceId); + } + + public function loginHandler(): void + { + app(\CraftCms\Commerce\Customer\Customers::class)->loginHandler(); + } + + public function orderCompleteHandler(Order $order): void + { + app(\CraftCms\Commerce\Customer\Customers::class)->orderCompleteHandler($order); + } + + /** + * @param Order[] $orders + * @return Order[] + */ + public function eagerLoadCustomerForOrders(array $orders): array + { + return app(\CraftCms\Commerce\Customer\Customers::class)->eagerLoadCustomerForOrders($orders); + } + + public function ensureCustomer(User $user): CustomerRecord + { + return app(\CraftCms\Commerce\Customer\Customers::class)->ensureCustomer($user); + } + + /** + * @throws ElementNotFoundException + */ + public function transferCustomerData(User $fromCustomer, User $toCustomer): bool + { + return app(\CraftCms\Commerce\Customer\Customers::class)->transferCustomerData($fromCustomer, $toCustomer); + } +} diff --git a/src-yii2/services/Discounts.php b/src-yii2/services/Discounts.php new file mode 100644 index 0000000000..57c823e1cb --- /dev/null +++ b/src-yii2/services/Discounts.php @@ -0,0 +1,121 @@ +getDiscountById($id, $storeId); + } + + /** + * @return Collection + */ + public function getAllDiscounts(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Promotion\Discounts::class)->getAllDiscounts($storeId); + } + + /** + * @return Discount[] + */ + public function getAllActiveDiscounts(?Order $order = null): array + { + return app(\CraftCms\Commerce\Promotion\Discounts::class)->getAllActiveDiscounts($order); + } + + public function orderCouponAvailable(Order $order, ?string &$explanation = null): bool + { + return app(\CraftCms\Commerce\Promotion\Discounts::class)->orderCouponAvailable($order, $explanation); + } + + public function getDiscountByCode(?string $code, ?int $storeId = null): ?Discount + { + return app(\CraftCms\Commerce\Promotion\Discounts::class)->getDiscountByCode($code, $storeId); + } + + /** + * @return Discount[] + */ + public function getDiscountsRelatedToPurchasable(PurchasableInterface $purchasable): array + { + return app(\CraftCms\Commerce\Promotion\Discounts::class)->getDiscountsRelatedToPurchasable($purchasable); + } + + public function matchLineItem(LineItem $lineItem, Discount $discount, bool $matchOrder = false): bool + { + return app(\CraftCms\Commerce\Promotion\Discounts::class)->matchLineItem($lineItem, $discount, $matchOrder); + } + + public function matchOrder(Order $order, Discount $discount): bool + { + return app(\CraftCms\Commerce\Promotion\Discounts::class)->matchOrder($order, $discount); + } + + public function saveDiscount(Discount $model, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Promotion\Discounts::class)->saveDiscount($model, $runValidation); + } + + public function deleteDiscountById(int $id): bool + { + return app(\CraftCms\Commerce\Promotion\Discounts::class)->deleteDiscountById($id); + } + + public function ensureSortOrder(?int $storeId = null): void + { + app(\CraftCms\Commerce\Promotion\Discounts::class)->ensureSortOrder($storeId); + } + + public function clearCustomerUsageHistoryById(int $id): void + { + app(\CraftCms\Commerce\Promotion\Discounts::class)->clearCustomerUsageHistoryById($id); + } + + public function clearEmailUsageHistoryById(int $id): void + { + app(\CraftCms\Commerce\Promotion\Discounts::class)->clearEmailUsageHistoryById($id); + } + + public function clearDiscountUsesById(int $id): void + { + app(\CraftCms\Commerce\Promotion\Discounts::class)->clearDiscountUsesById($id); + } + + public function reorderDiscounts(array $ids): bool + { + return app(\CraftCms\Commerce\Promotion\Discounts::class)->reorderDiscounts($ids); + } + + public function appendCouponCode(int $discountId, string|Coupon $coupon, ?int $maxUses = null): bool + { + return app(\CraftCms\Commerce\Promotion\Discounts::class)->appendCouponCode($discountId, $coupon, $maxUses); + } + + public function getEmailUsageStatsById(int $id): array + { + return app(\CraftCms\Commerce\Promotion\Discounts::class)->getEmailUsageStatsById($id); + } + + public function getCustomerUsageStatsById(int $id): array + { + return app(\CraftCms\Commerce\Promotion\Discounts::class)->getCustomerUsageStatsById($id); + } + + public function orderCompleteHandler(Order $order): void + { + app(\CraftCms\Commerce\Promotion\Discounts::class)->orderCompleteHandler($order); + } +} diff --git a/src-yii2/services/Emails.php b/src-yii2/services/Emails.php new file mode 100644 index 0000000000..c8ce2fc4ef --- /dev/null +++ b/src-yii2/services/Emails.php @@ -0,0 +1,96 @@ +getEmailById($id, $storeId); + } + + /** + * @return Collection + */ + public function getAllEmails(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Email\Emails::class)->getAllEmails($storeId); + } + + /** + * @return Collection + */ + public function getAllEnabledEmails(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Email\Emails::class)->getAllEnabledEmails($storeId); + } + + public function saveEmail(Email $email, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Email\Emails::class)->saveEmail($email, $runValidation); + } + + /** + * @throws Throwable if reasons + */ + public function handleChangedEmail(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Email\Emails::class)->handleChangedEmail($event); + } + + public function deleteEmailById(int $id): bool + { + return app(\CraftCms\Commerce\Email\Emails::class)->deleteEmailById($id); + } + + /** + * @throws Throwable + */ + public function handleDeletedEmail(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Email\Emails::class)->handleDeletedEmail($event); + } + + /** + * @throws Exception + * @throws Throwable + */ + public function sendEmail(Email $email, Order $order, ?OrderHistory $orderHistory = null, ?array $orderData = null, string &$error = ''): bool + { + return app(\CraftCms\Commerce\Email\Emails::class)->sendEmail($email, $order, $orderHistory, $orderData, $error); + } + + /** + * @return Email[] + */ + public function getAllEmailsByOrderStatusId(int $id): array + { + return app(\CraftCms\Commerce\Email\Emails::class)->getAllEmailsByOrderStatusId($id); + } +} diff --git a/src-yii2/services/Formulas.php b/src-yii2/services/Formulas.php new file mode 100644 index 0000000000..f2e7800209 --- /dev/null +++ b/src-yii2/services/Formulas.php @@ -0,0 +1,41 @@ +validateConditionSyntax($condition, $params); + } + + public function validateFormulaSyntax(string $formula, array $params): bool + { + return app(\CraftCms\Commerce\Formula\Formulas::class)->validateFormulaSyntax($formula, $params); + } + + /** + * @throws SyntaxError + * @throws LoaderError + */ + public function evaluateCondition(string $formula, array $params, string $name = 'Evaluate Condition'): bool + { + return app(\CraftCms\Commerce\Formula\Formulas::class)->evaluateCondition($formula, $params, $name); + } + + /** + * @throws SyntaxError + * @throws LoaderError + */ + public function evaluateFormula(string $formula, array $params, ?string $setType = null, ?string $name = 'Inline formula'): mixed + { + return app(\CraftCms\Commerce\Formula\Formulas::class)->evaluateFormula($formula, $params, $setType, $name); + } +} diff --git a/src-yii2/services/Gateways.php b/src-yii2/services/Gateways.php new file mode 100644 index 0000000000..19bf474238 --- /dev/null +++ b/src-yii2/services/Gateways.php @@ -0,0 +1,118 @@ +register()` instead. */ + public const EVENT_REGISTER_GATEWAY_TYPES = 'registerGatewayTypes'; + + public const CONFIG_GATEWAY_KEY = \CraftCms\Commerce\Payment\Gateway\Gateways::CONFIG_GATEWAY_KEY; + + /** + * @return string[] + */ + public function getAllGatewayTypes(): array + { + return app(\CraftCms\Commerce\Payment\Gateway\Gateways::class)->getAllGatewayTypes(); + } + + /** + * @return Collection + */ + public function getAllCustomerEnabledGateways(): Collection + { + return app(\CraftCms\Commerce\Payment\Gateway\Gateways::class)->getAllCustomerEnabledGateways(); + } + + /** + * @return Collection + */ + public function getAllCustomerEnabledGatewaysAndAvailableForUseWithOrder(Order $order): Collection + { + return app(\CraftCms\Commerce\Payment\Gateway\Gateways::class)->getAllCustomerEnabledGatewaysAndAvailableForUseWithOrder($order); + } + + /** + * @return Collection + */ + public function getAllGateways(): Collection + { + return app(\CraftCms\Commerce\Payment\Gateway\Gateways::class)->getAllGateways(); + } + + /** + * @return Gateway[] + */ + public function getAllArchivedGateways(): array + { + return app(\CraftCms\Commerce\Payment\Gateway\Gateways::class)->getAllArchivedGateways(); + } + + public function archiveGatewayById(int $id): bool + { + return app(\CraftCms\Commerce\Payment\Gateway\Gateways::class)->archiveGatewayById($id); + } + + public function getGatewayById(int $id): ?Gateway + { + return app(\CraftCms\Commerce\Payment\Gateway\Gateways::class)->getGatewayById($id); + } + + public function getGatewayByHandle(string $handle): ?Gateway + { + return app(\CraftCms\Commerce\Payment\Gateway\Gateways::class)->getGatewayByHandle($handle); + } + + public function saveGateway(Gateway $gateway, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Payment\Gateway\Gateways::class)->saveGateway($gateway, $runValidation); + } + + /** + * @throws Throwable if reasons + */ + public function handleChangedGateway(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Payment\Gateway\Gateways::class)->handleChangedGateway($event); + } + + /** + * @throws Throwable if reasons + */ + public function handleArchivedGateway(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Payment\Gateway\Gateways::class)->handleArchivedGateway($event); + } + + /** + * @param int[] $ids + */ + public function reorderGateways(array $ids): bool + { + return app(\CraftCms\Commerce\Payment\Gateway\Gateways::class)->reorderGateways($ids); + } + + public function createGateway(string|array $config): Gateway + { + return app(\CraftCms\Commerce\Payment\Gateway\Gateways::class)->createGateway($config); + } + + /** @internal */ + public static function finalizeRegistrationEvents(): void + { + TypeRegistryCompatibility::reconcile(app(GatewayTypes::class), \craft\commerce\Plugin::getInstance()->getGateways(), self::EVENT_REGISTER_GATEWAY_TYPES); + } +} diff --git a/src-yii2/services/Inventory.php b/src-yii2/services/Inventory.php new file mode 100644 index 0000000000..e45a761eb3 --- /dev/null +++ b/src-yii2/services/Inventory.php @@ -0,0 +1,154 @@ + + */ + public function getInventoryLevelsForPurchasable(Purchasable|NewPurchasable $purchasable): Collection + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->getInventoryLevelsForPurchasable($purchasable); + } + + public function getInventoryItemByPurchasable(Purchasable|NewPurchasable $purchasable): InventoryItem + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->getInventoryItemByPurchasable($purchasable); + } + + public function ensureInventoryItemRecord(Purchasable|NewPurchasable $purchasable): ?InventoryItemRecord + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->ensureInventoryItemRecord($purchasable); + } + + public function getInventoryItemById(int $id): InventoryItem + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->getInventoryItemById($id); + } + + /** + * @param array $ids + * @return Collection + */ + public function getInventoryItemsByIds(array $ids): Collection + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->getInventoryItemsByIds($ids); + } + + public function getInventoryLevel(InventoryItem|int $inventoryItem, InventoryLocation|int $inventoryLocation, bool $withTrashed = false): ?InventoryLevel + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->getInventoryLevel($inventoryItem, $inventoryLocation, $withTrashed); + } + + public function saveInventoryItem(InventoryItem $inventoryItem, bool $validate = true): bool + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->saveInventoryItem($inventoryItem); + } + + /** + * @return Collection + */ + public function getInventoryLocationLevels(InventoryLocation $inventoryLocation, bool $withTrashed = false): Collection + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->getInventoryLocationLevels($inventoryLocation, $withTrashed); + } + + public function getInventoryLevelQuery(?int $limit = null, ?int $offset = null, bool $withTrashed = false): \Illuminate\Database\Query\Builder + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->getInventoryLevelQuery($limit, $offset, $withTrashed); + } + + public function getInventoryItemQuery(): \Illuminate\Database\Query\Builder + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->getInventoryItemQuery(); + } + + public function executeUpdateInventoryLevels(UpdateInventoryLevelCollection $updateInventoryLevels): bool + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->executeUpdateInventoryLevels($updateInventoryLevels); + } + + /** + * @param array $updateInventoryLevelAttributes + */ + public function updateInventoryLevel(int $inventoryItemId, int $quantity, array $updateInventoryLevelAttributes = []): void + { + app(\CraftCms\Commerce\Inventory\Inventory::class)->updateInventoryLevel($inventoryItemId, $quantity, $updateInventoryLevelAttributes); + } + + /** + * @param array $updateInventoryLevelAttributes + */ + public function updatePurchasableInventoryLevel(Purchasable|NewPurchasable $purchasable, int $quantity, array $updateInventoryLevelAttributes = []): void + { + app(\CraftCms\Commerce\Inventory\Inventory::class)->updatePurchasableInventoryLevel($purchasable, $quantity, $updateInventoryLevelAttributes); + } + + public function executeInventoryMovements(InventoryMovementCollection $inventoryMovements): bool + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->executeInventoryMovements($inventoryMovements); + } + + public function getMovementHash(): string + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->getMovementHash(); + } + + public function getUnfulfilledOrders(InventoryItem|int $inventoryItem, InventoryLocation|int $inventoryLocation): array + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->getUnfulfilledOrders($inventoryItem, $inventoryLocation); + } + + public function getTransactionQuery(): \Illuminate\Database\Query\Builder + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->getTransactionQuery(); + } + + /** + * @return Collection + */ + public function getInventoryTransactions(InventoryItem $inventoryItem, InventoryLocation $inventoryLocation): Collection + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->getInventoryTransactions($inventoryItem, $inventoryLocation); + } + + /** + * @return Collection + */ + public function getInventoryFulfillmentLevels(Order $order): Collection + { + return app(\CraftCms\Commerce\Inventory\Inventory::class)->getInventoryFulfillmentLevels($order); + } + + public function orderCompleteHandler(Order $order): void + { + app(\CraftCms\Commerce\Inventory\Inventory::class)->orderCompleteHandler($order); + } +} diff --git a/src-yii2/services/InventoryLocations.php b/src-yii2/services/InventoryLocations.php new file mode 100644 index 0000000000..7de52a2bd8 --- /dev/null +++ b/src-yii2/services/InventoryLocations.php @@ -0,0 +1,61 @@ + + */ + public function getAllInventoryLocations(bool $withTrashed = false): Collection + { + return app(\CraftCms\Commerce\Inventory\InventoryLocations::class)->getAllInventoryLocations($withTrashed); + } + + public function getAllInventoryLocationsAsList(bool $withTrashed = false): array + { + return app(\CraftCms\Commerce\Inventory\InventoryLocations::class)->getAllInventoryLocationsAsList($withTrashed); + } + + public function getInventoryLocationById(int $id, bool $withTrashed = false): ?InventoryLocation + { + return app(\CraftCms\Commerce\Inventory\InventoryLocations::class)->getInventoryLocationById($id, $withTrashed); + } + + /** + * @return Collection + */ + public function getInventoryLocations(?int $storeId = null, bool $withTrashed = false): Collection + { + return app(\CraftCms\Commerce\Inventory\InventoryLocations::class)->getInventoryLocations($storeId, $withTrashed); + } + + public function saveStoreInventoryLocations(Store $store, array $inventoryLocationIds): bool + { + return app(\CraftCms\Commerce\Inventory\InventoryLocations::class)->saveStoreInventoryLocations($store, $inventoryLocationIds); + } + + public function executeDeactivateInventoryLocation(DeactivateInventoryLocation $deactivateInventoryLocation): bool + { + return app(\CraftCms\Commerce\Inventory\InventoryLocations::class)->executeDeactivateInventoryLocation($deactivateInventoryLocation); + } + + public function getInventoryLocationByHandle(string $handle): ?InventoryLocation + { + return app(\CraftCms\Commerce\Inventory\InventoryLocations::class)->getInventoryLocationByHandle($handle); + } + + public function saveInventoryLocation(InventoryLocation $inventoryLocation, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Inventory\InventoryLocations::class)->saveInventoryLocation($inventoryLocation, $runValidation); + } +} diff --git a/src-yii2/services/LineItemStatuses.php b/src-yii2/services/LineItemStatuses.php new file mode 100644 index 0000000000..e6ad203173 --- /dev/null +++ b/src-yii2/services/LineItemStatuses.php @@ -0,0 +1,90 @@ +getLineItemStatusByHandle($handle, $storeId); + } + + public function getDefaultLineItemStatusId(?int $storeId = null): ?int + { + return app(\CraftCms\Commerce\Order\LineItemStatuses::class)->getDefaultLineItemStatusId($storeId); + } + + public function getDefaultLineItemStatus(?int $storeId = null): ?LineItemStatus + { + return app(\CraftCms\Commerce\Order\LineItemStatuses::class)->getDefaultLineItemStatus($storeId); + } + + public function getDefaultLineItemStatusForLineItem(LineItem $lineItem): ?LineItemStatus + { + return app(\CraftCms\Commerce\Order\LineItemStatuses::class)->getDefaultLineItemStatusForLineItem($lineItem); + } + + public function saveLineItemStatus(LineItemStatus $lineItemStatus, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Order\LineItemStatuses::class)->saveLineItemStatus($lineItemStatus, $runValidation); + } + + /** + * @throws Throwable if reasons + */ + public function handleChangedLineItemStatus(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Order\LineItemStatuses::class)->handleChangedLineItemStatus($event); + } + + /** + * @throws Throwable + */ + public function archiveLineItemStatusById(int $id, ?int $storeId = null): bool + { + return app(\CraftCms\Commerce\Order\LineItemStatuses::class)->archiveLineItemStatusById($id, $storeId); + } + + /** + * @throws Throwable if reasons + */ + public function handleArchivedLineItemStatus(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Order\LineItemStatuses::class)->handleArchivedLineItemStatus($event); + } + + /** + * @return Collection + */ + public function getAllLineItemStatuses(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Order\LineItemStatuses::class)->getAllLineItemStatuses($storeId); + } + + public function getLineItemStatusById(int $id, ?int $storeId = null): ?LineItemStatus + { + return app(\CraftCms\Commerce\Order\LineItemStatuses::class)->getLineItemStatusById($id, $storeId); + } + + /** + * @param int[] $ids + */ + public function reorderLineItemStatuses(array $ids): bool + { + return app(\CraftCms\Commerce\Order\LineItemStatuses::class)->reorderLineItemStatuses($ids); + } +} diff --git a/src-yii2/services/LineItems.php b/src-yii2/services/LineItems.php new file mode 100644 index 0000000000..af1da6891b --- /dev/null +++ b/src-yii2/services/LineItems.php @@ -0,0 +1,74 @@ +getAllLineItemsByOrderId($orderId); + } + + public function resolveLineItem(Order $order, int $purchasableId, array $options = [], array $params = []): LineItem + { + return app(\CraftCms\Commerce\Order\LineItem\LineItems::class)->resolveLineItem($order, $purchasableId, $options, $params); + } + + public function resolveCustomLineItem(Order $order, string $sku, array $options = []): LineItem + { + return app(\CraftCms\Commerce\Order\LineItem\LineItems::class)->resolveCustomLineItem($order, $sku, $options); + } + + public function saveLineItem(LineItem $lineItem, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Order\LineItem\LineItems::class)->saveLineItem($lineItem, $runValidation); + } + + public function getLineItemById(int $id): ?LineItem + { + return app(\CraftCms\Commerce\Order\LineItem\LineItems::class)->getLineItemById($id); + } + + public function create(Order $order, array $params = [], LineItemType $type = LineItemType::Purchasable): LineItem + { + return app(\CraftCms\Commerce\Order\LineItem\LineItems::class)->create($order, $params, $type); + } + + public function deleteAllLineItemsByOrderId(int $orderId): bool + { + return app(\CraftCms\Commerce\Order\LineItem\LineItems::class)->deleteAllLineItemsByOrderId($orderId); + } + + /** + * @param Order[] $orders + * @return Order[] + */ + public function eagerLoadLineItemsForOrders(array $orders): array + { + return app(\CraftCms\Commerce\Order\LineItem\LineItems::class)->eagerLoadLineItemsForOrders($orders); + } + + public function orderCompleteHandler(LineItem $lineItem, Order $order): void + { + app(\CraftCms\Commerce\Order\LineItem\LineItems::class)->orderCompleteHandler($lineItem, $order); + } +} diff --git a/src-yii2/services/OrderAdjustments.php b/src-yii2/services/OrderAdjustments.php new file mode 100644 index 0000000000..6316b410e9 --- /dev/null +++ b/src-yii2/services/OrderAdjustments.php @@ -0,0 +1,85 @@ +register()` instead. */ + public const EVENT_REGISTER_ORDER_ADJUSTERS = 'registerOrderAdjusters'; + + /** @deprecated in 6.0.0. Use `app(\CraftCms\Commerce\Order\Adjuster\DiscountAdjusterTypes::class)->register()` instead. */ + public const EVENT_REGISTER_DISCOUNT_ADJUSTERS = 'registerDiscountAdjusters'; + + /** + * @return class-string[] + */ + public function getAdjusters(): array + { + return app(\CraftCms\Commerce\Order\OrderAdjustments::class)->getAdjusters(); + } + + public function getOrderAdjustmentById(int $id): ?OrderAdjustment + { + return app(\CraftCms\Commerce\Order\OrderAdjustments::class)->getOrderAdjustmentById($id); + } + + /** + * @return OrderAdjustment[] + */ + public function getAllOrderAdjustmentsByOrderId(int $orderId): array + { + return app(\CraftCms\Commerce\Order\OrderAdjustments::class)->getAllOrderAdjustmentsByOrderId($orderId); + } + + public function saveOrderAdjustment(OrderAdjustment $orderAdjustment, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Order\OrderAdjustments::class)->saveOrderAdjustment($orderAdjustment, $runValidation); + } + + public function deleteAllOrderAdjustmentsByOrderId(int $orderId): bool + { + return app(\CraftCms\Commerce\Order\OrderAdjustments::class)->deleteAllOrderAdjustmentsByOrderId($orderId); + } + + public function deleteOrderAdjustmentByAdjustmentId(int $adjustmentId): bool + { + return app(\CraftCms\Commerce\Order\OrderAdjustments::class)->deleteOrderAdjustmentByAdjustmentId($adjustmentId); + } + + /** + * @param Order[] $orders + * @return Order[] + */ + public function eagerLoadOrderAdjustmentsForOrders(array $orders): array + { + return app(\CraftCms\Commerce\Order\OrderAdjustments::class)->eagerLoadOrderAdjustmentsForOrders($orders); + } + + /** + * @return class-string[] + */ + public function getDiscountAdjusters(): array + { + return app(\CraftCms\Commerce\Order\OrderAdjustments::class)->getDiscountAdjusters(); + } + + /** @internal */ + public static function finalizeRegistrationEvents(): void + { + $plugin = \craft\commerce\Plugin::getInstance()->getOrderAdjustments(); + + TypeRegistryCompatibility::reconcile(app(AdjusterTypes::class), $plugin, self::EVENT_REGISTER_ORDER_ADJUSTERS); + TypeRegistryCompatibility::reconcile(app(DiscountAdjusterTypes::class), $plugin, self::EVENT_REGISTER_DISCOUNT_ADJUSTERS); + } +} diff --git a/src-yii2/services/OrderHistories.php b/src-yii2/services/OrderHistories.php new file mode 100644 index 0000000000..8d1d6cdfb9 --- /dev/null +++ b/src-yii2/services/OrderHistories.php @@ -0,0 +1,43 @@ +getOrderHistoryById($id); + } + + /** + * @return OrderHistory[] + */ + public function getAllOrderHistoriesByOrderId(int $id): array + { + return app(\CraftCms\Commerce\Order\OrderHistories::class)->getAllOrderHistoriesByOrderId($id); + } + + public function createOrderHistoryFromOrder(Order $order, ?int $oldStatusId): bool + { + return app(\CraftCms\Commerce\Order\OrderHistories::class)->createOrderHistoryFromOrder($order, $oldStatusId); + } + + public function saveOrderHistory(OrderHistory $model, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Order\OrderHistories::class)->saveOrderHistory($model, $runValidation); + } + + public function deleteOrderHistoryById(int $id): bool + { + return app(\CraftCms\Commerce\Order\OrderHistories::class)->deleteOrderHistoryById($id); + } +} diff --git a/src-yii2/services/OrderNotices.php b/src-yii2/services/OrderNotices.php new file mode 100644 index 0000000000..138a1cc48a --- /dev/null +++ b/src-yii2/services/OrderNotices.php @@ -0,0 +1,23 @@ +eagerLoadOrderNoticesForOrders($orders); + } +} diff --git a/src-yii2/services/OrderStatuses.php b/src-yii2/services/OrderStatuses.php new file mode 100644 index 0000000000..20b3727357 --- /dev/null +++ b/src-yii2/services/OrderStatuses.php @@ -0,0 +1,114 @@ + + */ + public function getAllOrderStatuses(?int $storeId = null, bool $withTrashed = false): Collection + { + return app(\CraftCms\Commerce\Order\OrderStatuses::class)->getAllOrderStatuses($storeId, $withTrashed); + } + + public function getOrderStatusById(int $id, ?int $storeId = null): ?OrderStatus + { + return app(\CraftCms\Commerce\Order\OrderStatuses::class)->getOrderStatusById($id, $storeId); + } + + public function getOrderStatusByUid(string $uid, ?int $storeId = null): ?OrderStatus + { + return app(\CraftCms\Commerce\Order\OrderStatuses::class)->getOrderStatusByUid($uid, $storeId); + } + + public function getOrderStatusByHandle(string $handle, ?int $storeId = null): ?OrderStatus + { + return app(\CraftCms\Commerce\Order\OrderStatuses::class)->getOrderStatusByHandle($handle, $storeId); + } + + public function getDefaultOrderStatus(?int $storeId = null): ?OrderStatus + { + return app(\CraftCms\Commerce\Order\OrderStatuses::class)->getDefaultOrderStatus($storeId); + } + + public function getDefaultOrderStatusId(?int $storeId = null): ?int + { + return app(\CraftCms\Commerce\Order\OrderStatuses::class)->getDefaultOrderStatusId($storeId); + } + + public function getDefaultOrderStatusForOrder(Order $order): ?OrderStatus + { + return app(\CraftCms\Commerce\Order\OrderStatuses::class)->getDefaultOrderStatusForOrder($order); + } + + public function getOrderCountByStatus(?int $storeId = null): array + { + return app(\CraftCms\Commerce\Order\OrderStatuses::class)->getOrderCountByStatus($storeId); + } + + public function saveOrderStatus(OrderStatus $orderStatus, array $emailIds = [], bool $runValidation = true, bool $force = false): bool + { + return app(\CraftCms\Commerce\Order\OrderStatuses::class)->saveOrderStatus($orderStatus, $emailIds, $runValidation, $force); + } + + /** + * @throws Throwable if reasons + */ + public function handleChangedOrderStatus(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Order\OrderStatuses::class)->handleChangedOrderStatus($event); + } + + /** + * @throws Throwable + */ + public function deleteOrderStatusById(int $id, ?int $storeId = null): bool + { + return app(\CraftCms\Commerce\Order\OrderStatuses::class)->deleteOrderStatusById($id, $storeId); + } + + /** + * @throws Throwable if reasons + */ + public function handleDeletedOrderStatus(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Order\OrderStatuses::class)->handleDeletedOrderStatus($event); + } + + public function pruneDeletedEmail(EmailEvent $event): void + { + app(\CraftCms\Commerce\Order\OrderStatuses::class)->pruneDeletedEmail($event); + } + + public function statusChangeHandler(Order $order, OrderHistory $orderHistory): void + { + app(\CraftCms\Commerce\Order\OrderStatuses::class)->statusChangeHandler($order, $orderHistory); + } + + /** + * @param int[] $ids + */ + public function reorderOrderStatuses(array $ids): bool + { + return app(\CraftCms\Commerce\Order\OrderStatuses::class)->reorderOrderStatuses($ids); + } +} diff --git a/src-yii2/services/Orders.php b/src-yii2/services/Orders.php new file mode 100644 index 0000000000..ca8cdf0895 --- /dev/null +++ b/src-yii2/services/Orders.php @@ -0,0 +1,77 @@ +handleChangedFieldLayout($event); + } + + public function handleDeletedFieldLayout(): void + { + app(\CraftCms\Commerce\Order\Orders::class)->handleDeletedFieldLayout(); + } + + public function getOrderById(int $id): ?Order + { + return app(\CraftCms\Commerce\Order\Orders::class)->getOrderById($id); + } + + public function getOrderByNumber(string $number): ?Order + { + return app(\CraftCms\Commerce\Order\Orders::class)->getOrderByNumber($number); + } + + /** + * @return Order[]|null + */ + public function getOrdersByCustomer(User|int $customer): ?array + { + return app(\CraftCms\Commerce\Order\Orders::class)->getOrdersByCustomer($customer); + } + + /** + * @return Order[]|null + */ + public function getOrdersByEmail(string $email): ?array + { + return app(\CraftCms\Commerce\Order\Orders::class)->getOrdersByEmail($email); + } + + /** + * @param Order[] $orders + * @return Order[] + */ + public function eagerLoadAddressesForOrders(array $orders): array + { + return app(\CraftCms\Commerce\Order\Orders::class)->eagerLoadAddressesForOrders($orders); + } + + /** + * @param int|int[] $oldUserId + */ + public function reassignOrders(int|array $oldUserId, int $newUserId): int + { + return app(\CraftCms\Commerce\Order\Orders::class)->reassignOrders($oldUserId, $newUserId); + } + + /** + * @param int|int[] $orderIds + */ + public function removeCustomerData(int|array $orderIds, array $dataToRemove = ['customerId', 'email']): int + { + return app(\CraftCms\Commerce\Order\Orders::class)->removeCustomerData($orderIds, $dataToRemove); + } +} diff --git a/src-yii2/services/PaymentCurrencies.php b/src-yii2/services/PaymentCurrencies.php new file mode 100644 index 0000000000..826fbc9a41 --- /dev/null +++ b/src-yii2/services/PaymentCurrencies.php @@ -0,0 +1,114 @@ +getRateFor($currency, $transaction); + } + + public function getPaymentCurrencyById(int $id, ?int $storeId = null): ?PaymentCurrency + { + return app(\CraftCms\Commerce\Payment\PaymentCurrencies::class)->getPaymentCurrencyById($id, $storeId); + } + + /** + * @return Collection + */ + public function getAllPaymentCurrencies(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Payment\PaymentCurrencies::class)->getAllPaymentCurrencies($storeId); + } + + public function getPaymentCurrencyByIso(string $iso, ?int $storeId = null): ?PaymentCurrency + { + return app(\CraftCms\Commerce\Payment\PaymentCurrencies::class)->getPaymentCurrencyByIso($iso, $storeId); + } + + public function getPrimaryPaymentCurrencyIso(?int $storeId = null): string + { + return app(\CraftCms\Commerce\Payment\PaymentCurrencies::class)->getPrimaryPaymentCurrencyIso($storeId); + } + + public function getPrimaryPaymentCurrency(?int $storeId = null): ?PaymentCurrency + { + return app(\CraftCms\Commerce\Payment\PaymentCurrencies::class)->getPrimaryPaymentCurrency($storeId); + } + + /** + * @return Collection + */ + public function getNonPrimaryPaymentCurrencies(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Payment\PaymentCurrencies::class)->getNonPrimaryPaymentCurrencies($storeId); + } + + public function convert(float $amount, string $currency): float + { + return app(\CraftCms\Commerce\Payment\PaymentCurrencies::class)->convert($amount, $currency); + } + + /** + * Legacy convertCurrency for src-yii2 callers (Order element, OrdersController). + * The new service drops this; these callers will be updated when their + * classes migrate to src/. Logic ported verbatim from Commerce 5.x. + * + * @deprecated 6.0.0 use convertAmount() or convert() instead. + */ + public function convertCurrency(float $amount, string $fromCurrency, string $toCurrency, bool $round = false): float + { + $svc = app(\CraftCms\Commerce\Payment\PaymentCurrencies::class); + $from = $svc->getPaymentCurrencyByIso($fromCurrency); + $to = $svc->getPaymentCurrencyByIso($toCurrency); + + if (!$from || !$to) { + throw new \RuntimeException('Currency not found: ' . ($from ? $toCurrency : $fromCurrency)); + } + + $primary = $svc->getPrimaryPaymentCurrency(); + if ($primary && $primary->iso !== $fromCurrency) { + // amount is not in primary currency; normalize back to primary first + $amount /= $svc->getRateFor($from); + } + + $result = $amount * $svc->getRateFor($to); + + if ($round) { + return \craft\commerce\helpers\Currency::round($result, $to); + } + + return $result; + } + + public function savePaymentCurrency(PaymentCurrency $model, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Payment\PaymentCurrencies::class)->savePaymentCurrency($model, $runValidation); + } + + public function deletePaymentCurrencyById(int $id): bool + { + return app(\CraftCms\Commerce\Payment\PaymentCurrencies::class)->deletePaymentCurrencyById($id); + } + + public function convertAmount(Money $amount, Currency|string $currency, ?int $storeId = null): Money + { + return app(\CraftCms\Commerce\Payment\PaymentCurrencies::class)->convertAmount($amount, $currency, $storeId); + } +} diff --git a/src-yii2/services/PaymentSources.php b/src-yii2/services/PaymentSources.php new file mode 100644 index 0000000000..a0b1c4038c --- /dev/null +++ b/src-yii2/services/PaymentSources.php @@ -0,0 +1,75 @@ + + */ + public function getAllPaymentSourcesByCustomerId(?int $customerId = null, ?int $gatewayId = null): Collection + { + return app(\CraftCms\Commerce\Payment\PaymentSources::class)->getAllPaymentSourcesByCustomerId($customerId, $gatewayId); + } + + /** + * @return Collection + */ + public function getAllPaymentSourcesByGatewayId(?int $gatewayId = null): Collection + { + return app(\CraftCms\Commerce\Payment\PaymentSources::class)->getAllPaymentSourcesByGatewayId($gatewayId); + } + + /** + * @return Collection + */ + public function getAllGatewayPaymentSourcesByCustomerId(?int $gatewayId = null, ?int $customerId = null): Collection + { + return app(\CraftCms\Commerce\Payment\PaymentSources::class)->getAllGatewayPaymentSourcesByCustomerId($gatewayId, $customerId); + } + + public function getPaymentSourceByTokenAndGatewayId(string $token, int $gatewayId): ?PaymentSource + { + return app(\CraftCms\Commerce\Payment\PaymentSources::class)->getPaymentSourceByTokenAndGatewayId($token, $gatewayId); + } + + public function getPaymentSourceById(int $sourceId): ?PaymentSource + { + return app(\CraftCms\Commerce\Payment\PaymentSources::class)->getPaymentSourceById($sourceId); + } + + public function getPaymentSourceByIdAndUserId(int $sourceId, int $userId): ?PaymentSource + { + return app(\CraftCms\Commerce\Payment\PaymentSources::class)->getPaymentSourceByIdAndUserId($sourceId, $userId); + } + + public function createPaymentSource(int $customerId, GatewayInterface $gateway, BasePaymentForm $paymentForm, ?string $sourceDescription = null, bool $makePrimarySource = false): PaymentSource + { + return app(\CraftCms\Commerce\Payment\PaymentSources::class)->createPaymentSource($customerId, $gateway, $paymentForm, $sourceDescription, $makePrimarySource); + } + + public function savePaymentSource(PaymentSource $paymentSource, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Payment\PaymentSources::class)->savePaymentSource($paymentSource, $runValidation); + } + + public function deletePaymentSourceById(int $id): bool + { + return app(\CraftCms\Commerce\Payment\PaymentSources::class)->deletePaymentSourceById($id); + } +} diff --git a/src-yii2/services/Payments.php b/src-yii2/services/Payments.php new file mode 100644 index 0000000000..3f8fe28a80 --- /dev/null +++ b/src-yii2/services/Payments.php @@ -0,0 +1,63 @@ +processPayment($order, $form, $redirect, $transaction, $redirectData); + } + + /** + * @throws TransactionException if something went wrong when saving the transaction + */ + public function captureTransaction(Transaction $transaction): Transaction + { + return app(\CraftCms\Commerce\Payment\Payments::class)->captureTransaction($transaction); + } + + /** + * @throws RefundException if something went wrong during the refund. + */ + public function refundTransaction(Transaction $transaction, ?float $amount = null, string $note = ''): Transaction + { + return app(\CraftCms\Commerce\Payment\Payments::class)->refundTransaction($transaction, $amount, $note); + } + + public function completePayment(Transaction $transaction, ?string &$customError): bool + { + return app(\CraftCms\Commerce\Payment\Payments::class)->completePayment($transaction, $customError); + } +} diff --git a/src-yii2/services/Pdfs.php b/src-yii2/services/Pdfs.php new file mode 100644 index 0000000000..ae387fd515 --- /dev/null +++ b/src-yii2/services/Pdfs.php @@ -0,0 +1,107 @@ + + */ + public function getAllPdfs(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Pdf\Pdfs::class)->getAllPdfs($storeId); + } + + public function getHasEnabledPdf(?int $storeId = null): bool + { + return app(\CraftCms\Commerce\Pdf\Pdfs::class)->getHasEnabledPdf($storeId); + } + + /** + * @return Collection + */ + public function getAllEnabledPdfs(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Pdf\Pdfs::class)->getAllEnabledPdfs($storeId); + } + + public function getDefaultPdf(?int $storeId = null): ?Pdf + { + return app(\CraftCms\Commerce\Pdf\Pdfs::class)->getDefaultPdf($storeId); + } + + public function getPdfByHandle(string $handle, ?int $storeId = null): ?Pdf + { + return app(\CraftCms\Commerce\Pdf\Pdfs::class)->getPdfByHandle($handle, $storeId); + } + + public function getPdfById(int $id, ?int $storeId = null): ?Pdf + { + return app(\CraftCms\Commerce\Pdf\Pdfs::class)->getPdfById($id, $storeId); + } + + public function savePdf(Pdf $pdf, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Pdf\Pdfs::class)->savePdf($pdf, $runValidation); + } + + public function handleChangedPdf(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Pdf\Pdfs::class)->handleChangedPdf($event); + } + + public function deletePdfById(int $id): bool + { + return app(\CraftCms\Commerce\Pdf\Pdfs::class)->deletePdfById($id); + } + + /** + * @throws Throwable + */ + public function handleDeletedPdf(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Pdf\Pdfs::class)->handleDeletedPdf($event); + } + + /** + * @param int[] $ids + */ + public function reorderPdfs(array $ids): bool + { + return app(\CraftCms\Commerce\Pdf\Pdfs::class)->reorderPdfs($ids); + } + + public function getPdfUrl(Order $order, ?string $option = null, ?string $pdfHandle = null, bool $inline = false): string + { + return app(\CraftCms\Commerce\Pdf\Pdfs::class)->getPdfUrl($order, $option, $pdfHandle, $inline); + } + + public function renderPdfForOrder(Order $order, string $option = '', ?string $templatePath = null, array $variables = [], ?Pdf $pdf = null): string + { + return app(\CraftCms\Commerce\Pdf\Pdfs::class)->renderPdfForOrder($order, $option, $templatePath, $variables, $pdf); + } +} diff --git a/src-yii2/services/ProductTypes.php b/src-yii2/services/ProductTypes.php new file mode 100755 index 0000000000..c2ea506df3 --- /dev/null +++ b/src-yii2/services/ProductTypes.php @@ -0,0 +1,129 @@ +getViewableProductTypes(); + } + + public function getViewableProductTypeIds(bool $anySite = false): array + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->getViewableProductTypeIds($anySite); + } + + public function getCreatableProductTypeIds(): array + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->getCreatableProductTypeIds(); + } + + /** + * @return ProductType[] + */ + public function getCreatableProductTypes(): array + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->getCreatableProductTypes(); + } + + public function getAllProductTypeIds(): array + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->getAllProductTypeIds(); + } + + /** + * @return ProductType[] + */ + public function getAllProductTypes(): array + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->getAllProductTypes(); + } + + public function getProductTypeByHandle(string $handle): ?ProductType + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->getProductTypeByHandle($handle); + } + + public function getProductTypeById(int $productTypeId): ?ProductType + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->getProductTypeById($productTypeId); + } + + public function getProductTypeByUid(string $uid): ?ProductType + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->getProductTypeByUid($uid); + } + + /** + * @return ProductType[] + */ + public function getProductTypesByTaxCategoryId(int $taxCategoryId): array + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->getProductTypesByTaxCategoryId($taxCategoryId); + } + + /** + * @return ProductType[] + */ + public function getProductTypesByShippingCategoryId(int $shippingCategoryId): array + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->getProductTypesByShippingCategoryId($shippingCategoryId); + } + + /** + * @return ProductTypeSite[] + */ + public function getProductTypeSites(int $productTypeId): array + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->getProductTypeSites($productTypeId); + } + + public function saveProductType(ProductType $productType, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->saveProductType($productType, $runValidation); + } + + public function handleChangedProductType(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->handleChangedProductType($event); + } + + public function deleteProductTypeById(int $id): bool + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->deleteProductTypeById($id); + } + + public function handleDeletedProductType(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->handleDeletedProductType($event); + } + + public function pruneDeletedSite(DeleteSiteEvent $event): void + { + app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->pruneDeletedSite($event); + } + + public function isProductTypeTemplateValid(ProductType $productType, int $siteId): bool + { + return app(\CraftCms\Commerce\Catalog\ProductType\ProductTypes::class)->isProductTypeTemplateValid($productType, $siteId); + } +} diff --git a/src-yii2/services/Products.php b/src-yii2/services/Products.php new file mode 100644 index 0000000000..34e276d56b --- /dev/null +++ b/src-yii2/services/Products.php @@ -0,0 +1,20 @@ +getProductById($id, $siteId, $criteria); + } +} diff --git a/src-yii2/services/Purchasables.php b/src-yii2/services/Purchasables.php new file mode 100644 index 0000000000..8efc44feb7 --- /dev/null +++ b/src-yii2/services/Purchasables.php @@ -0,0 +1,76 @@ +register()` instead. */ + public const EVENT_REGISTER_PURCHASABLE_ELEMENT_TYPES = 'registerPurchasableElementTypes'; + + /** + * @throws Throwable + */ + public function isPurchasableOutOfStockPurchasingAllowed(PurchasableInterface $purchasable, ?Order $order = null, ?User $currentUser = null): bool + { + return app(\CraftCms\Commerce\Purchasable\Purchasables::class)->isPurchasableOutOfStockPurchasingAllowed($purchasable, $order, $currentUser); + } + + public function isPurchasableAvailable(PurchasableInterface $purchasable, ?Order $order = null, ?User $currentUser = null): bool + { + return app(\CraftCms\Commerce\Purchasable\Purchasables::class)->isPurchasableAvailable($purchasable, $order, $currentUser); + } + + public function isPurchasableShippable(PurchasableInterface $purchasable, ?Order $order = null, ?User $currentUser = null): bool + { + return app(\CraftCms\Commerce\Purchasable\Purchasables::class)->isPurchasableShippable($purchasable, $order, $currentUser); + } + + public function updateStoreStockCache(PurchasableInterface $purchasable, bool $allSites = false): void + { + app(\CraftCms\Commerce\Purchasable\Purchasables::class)->updateStoreStockCache($purchasable, $allSites); + } + + /** + * @throws Throwable + */ + public function deletePurchasableById(int $purchasableId): bool + { + return app(\CraftCms\Commerce\Purchasable\Purchasables::class)->deletePurchasableById($purchasableId); + } + + public function getPurchasableById(int $purchasableId, ?int $siteId = null, int|false|null $forCustomer = null): ?PurchasableInterface + { + return app(\CraftCms\Commerce\Purchasable\Purchasables::class)->getPurchasableById($purchasableId, $siteId, $forCustomer); + } + + /** + * @return string[] + */ + public function getAllPurchasableElementTypes(): array + { + return app(\CraftCms\Commerce\Purchasable\Purchasables::class)->getAllPurchasableElementTypes(); + } + + /** @internal */ + public static function finalizeRegistrationEvents(): void + { + TypeRegistryCompatibility::reconcile(app(PurchasableTypes::class), \craft\commerce\Plugin::getInstance()->getPurchasables(), self::EVENT_REGISTER_PURCHASABLE_ELEMENT_TYPES); + } +} diff --git a/src-yii2/services/Sales.php b/src-yii2/services/Sales.php new file mode 100644 index 0000000000..4fae73f52c --- /dev/null +++ b/src-yii2/services/Sales.php @@ -0,0 +1,73 @@ +canUseSales(); + } + + public function getSaleById(int $id): ?Sale + { + return app(\CraftCms\Commerce\Promotion\Sales::class)->getSaleById($id); + } + + /** + * @return Sale[] + */ + public function getAllSales(): array + { + return app(\CraftCms\Commerce\Promotion\Sales::class)->getAllSales(); + } + + /** + * @return Sale[] + */ + public function getSalesForPurchasable(PurchasableInterface $purchasable, ?Order $order = null): array + { + return app(\CraftCms\Commerce\Promotion\Sales::class)->getSalesForPurchasable($purchasable, $order); + } + + /** + * @return Sale[] + */ + public function getSalesRelatedToPurchasable(PurchasableInterface $purchasable): array + { + return app(\CraftCms\Commerce\Promotion\Sales::class)->getSalesRelatedToPurchasable($purchasable); + } + + public function getSalePriceForPurchasable(PurchasableInterface $purchasable, ?Order $order = null): float + { + return app(\CraftCms\Commerce\Promotion\Sales::class)->getSalePriceForPurchasable($purchasable, $order); + } + + public function matchPurchasableAndSale(PurchasableInterface $purchasable, Sale $sale, ?Order $order = null): bool + { + return app(\CraftCms\Commerce\Promotion\Sales::class)->matchPurchasableAndSale($purchasable, $sale, $order); + } + + public function saveSale(Sale $model, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Promotion\Sales::class)->saveSale($model, $runValidation); + } + + public function reorderSales(array $ids): bool + { + return app(\CraftCms\Commerce\Promotion\Sales::class)->reorderSales($ids); + } + + public function deleteSaleById(int $id): bool + { + return app(\CraftCms\Commerce\Promotion\Sales::class)->deleteSaleById($id); + } +} diff --git a/src-yii2/services/ShippingCategories.php b/src-yii2/services/ShippingCategories.php new file mode 100644 index 0000000000..c9b0379b26 --- /dev/null +++ b/src-yii2/services/ShippingCategories.php @@ -0,0 +1,67 @@ + + */ + public function getAllShippingCategories(?int $storeId = null, bool $withTrashed = false): Collection + { + return app(\CraftCms\Commerce\Shipping\ShippingCategories::class)->getAllShippingCategories($storeId, $withTrashed); + } + + /** + * @return array + */ + public function getAllShippingCategoriesAsList(?int $storeId = null): array + { + return app(\CraftCms\Commerce\Shipping\ShippingCategories::class)->getAllShippingCategoriesAsList($storeId); + } + + public function getShippingCategoryById(int $shippingCategoryId, ?int $storeId = null): ?ShippingCategory + { + return app(\CraftCms\Commerce\Shipping\ShippingCategories::class)->getShippingCategoryById($shippingCategoryId, $storeId); + } + + public function getShippingCategoryByHandle(string $shippingCategoryHandle, ?int $storeId = null): ?ShippingCategory + { + return app(\CraftCms\Commerce\Shipping\ShippingCategories::class)->getShippingCategoryByHandle($shippingCategoryHandle, $storeId); + } + + public function getDefaultShippingCategory(int $storeId): ShippingCategory + { + return app(\CraftCms\Commerce\Shipping\ShippingCategories::class)->getDefaultShippingCategory($storeId); + } + + public function saveShippingCategory(ShippingCategory $shippingCategory, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Shipping\ShippingCategories::class)->saveShippingCategory($shippingCategory, $runValidation); + } + + public function deleteShippingCategoryById(int $id): bool + { + return app(\CraftCms\Commerce\Shipping\ShippingCategories::class)->deleteShippingCategoryById($id); + } + + /** + * @return array + */ + public function getShippingCategoriesByProductTypeId(int $productTypeId): array + { + return app(\CraftCms\Commerce\Shipping\ShippingCategories::class)->getShippingCategoriesByProductTypeId($productTypeId); + } + + public function clearCaches(): void + { + app(\CraftCms\Commerce\Shipping\ShippingCategories::class)->clearCaches(); + } +} diff --git a/src-yii2/services/ShippingMethods.php b/src-yii2/services/ShippingMethods.php new file mode 100644 index 0000000000..0bf224dc4a --- /dev/null +++ b/src-yii2/services/ShippingMethods.php @@ -0,0 +1,69 @@ + + */ + public function getAllShippingMethods(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Shipping\ShippingMethods::class)->getAllShippingMethods($storeId); + } + + public function getShippingMethodByHandle(string $handle, ?int $storeId = null): ?ShippingMethod + { + return app(\CraftCms\Commerce\Shipping\ShippingMethods::class)->getShippingMethodByHandle($handle, $storeId); + } + + public function getShippingMethodById(int $id, ?int $storeId = null): ?ShippingMethod + { + return app(\CraftCms\Commerce\Shipping\ShippingMethods::class)->getShippingMethodById($id, $storeId); + } + + /** + * @return array + */ + public function getMatchingShippingMethods(Order $order): array + { + return app(\CraftCms\Commerce\Shipping\ShippingMethods::class)->getMatchingShippingMethods($order); + } + + public function getSerializedOrderForMatchingRules(Order $order): array + { + return app(\CraftCms\Commerce\Shipping\ShippingMethods::class)->getSerializedOrderForMatchingRules($order); + } + + public function getMatchingShippingRule(Order $order, ShippingMethodInterface $method): ?ShippingRuleInterface + { + return app(\CraftCms\Commerce\Shipping\ShippingMethods::class)->getMatchingShippingRule($order, $method); + } + + public function saveShippingMethod(ShippingMethod $model, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Shipping\ShippingMethods::class)->saveShippingMethod($model, $runValidation); + } + + public function deleteShippingMethodById(int $id): bool + { + return app(\CraftCms\Commerce\Shipping\ShippingMethods::class)->deleteShippingMethodById($id); + } + + public function clearCache(): void + { + app(\CraftCms\Commerce\Shipping\ShippingMethods::class)->clearCache(); + } +} diff --git a/src-yii2/services/ShippingRuleCategories.php b/src-yii2/services/ShippingRuleCategories.php new file mode 100644 index 0000000000..6b85e53cd9 --- /dev/null +++ b/src-yii2/services/ShippingRuleCategories.php @@ -0,0 +1,39 @@ + + */ + public function getShippingRuleCategoriesByRuleId(int $ruleId): array + { + return app(\CraftCms\Commerce\Shipping\ShippingRuleCategories::class)->getShippingRuleCategoriesByRuleId($ruleId); + } + + /** + * @param int[] $ruleIds + * @return array> + */ + public function getShippingRuleCategoriesByRuleIds(array $ruleIds): array + { + return app(\CraftCms\Commerce\Shipping\ShippingRuleCategories::class)->getShippingRuleCategoriesByRuleIds($ruleIds); + } + + public function createShippingRuleCategory(ShippingRuleCategory $model, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Shipping\ShippingRuleCategories::class)->createShippingRuleCategory($model, $runValidation); + } + + public function deleteShippingRuleCategoryById(int $id): bool + { + return app(\CraftCms\Commerce\Shipping\ShippingRuleCategories::class)->deleteShippingRuleCategoryById($id); + } +} diff --git a/src-yii2/services/ShippingRules.php b/src-yii2/services/ShippingRules.php new file mode 100644 index 0000000000..52d44c35b2 --- /dev/null +++ b/src-yii2/services/ShippingRules.php @@ -0,0 +1,49 @@ + + */ + public function getAllShippingRules(): Collection + { + return app(\CraftCms\Commerce\Shipping\ShippingRules::class)->getAllShippingRules(); + } + + /** + * @return Collection + */ + public function getAllShippingRulesByShippingMethodId(int $methodId): Collection + { + return app(\CraftCms\Commerce\Shipping\ShippingRules::class)->getAllShippingRulesByShippingMethodId($methodId); + } + + public function getShippingRuleById(int $id): ?ShippingRule + { + return app(\CraftCms\Commerce\Shipping\ShippingRules::class)->getShippingRuleById($id); + } + + public function saveShippingRule(ShippingRule $model, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Shipping\ShippingRules::class)->saveShippingRule($model, $runValidation); + } + + public function reorderShippingRules(array $ids): bool + { + return app(\CraftCms\Commerce\Shipping\ShippingRules::class)->reorderShippingRules($ids); + } + + public function deleteShippingRuleById(int $id): bool + { + return app(\CraftCms\Commerce\Shipping\ShippingRules::class)->deleteShippingRuleById($id); + } +} diff --git a/src-yii2/services/ShippingZones.php b/src-yii2/services/ShippingZones.php new file mode 100644 index 0000000000..05805371ba --- /dev/null +++ b/src-yii2/services/ShippingZones.php @@ -0,0 +1,36 @@ + + */ + public function getAllShippingZones(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Shipping\ShippingZones::class)->getAllShippingZones($storeId); + } + + public function getShippingZoneById(int $id, ?int $storeId = null): ?ShippingAddressZone + { + return app(\CraftCms\Commerce\Shipping\ShippingZones::class)->getShippingZoneById($id, $storeId); + } + + public function saveShippingZone(ShippingAddressZone $model, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Shipping\ShippingZones::class)->saveShippingZone($model, $runValidation); + } + + public function deleteShippingZoneById(int $id): bool + { + return app(\CraftCms\Commerce\Shipping\ShippingZones::class)->deleteShippingZoneById($id); + } +} diff --git a/src-yii2/services/StoreSettings.php b/src-yii2/services/StoreSettings.php new file mode 100644 index 0000000000..2fcb5c1b5a --- /dev/null +++ b/src-yii2/services/StoreSettings.php @@ -0,0 +1,35 @@ +getStoreSettingsById($id); + } + + /** + * @return Collection + */ + public function getAllStoreSettings(): Collection + { + return app(\CraftCms\Commerce\Store\StoreSettings::class)->getAllStoreSettings(); + } + + /** + * @throws InvalidConfigException + */ + public function saveStoreSettings(StoreSettingsModel $storeSettings): bool + { + return app(\CraftCms\Commerce\Store\StoreSettings::class)->saveStoreSettings($storeSettings); + } +} diff --git a/src-yii2/services/Stores.php b/src-yii2/services/Stores.php new file mode 100644 index 0000000000..f8ae82590b --- /dev/null +++ b/src-yii2/services/Stores.php @@ -0,0 +1,166 @@ +getCurrentStore(); + } + + /** + * @return Collection + */ + public function getAllStores(): Collection + { + return app(\CraftCms\Commerce\Store\Stores::class)->getAllStores(); + } + + public function getStoreById(int $id): ?Store + { + return app(\CraftCms\Commerce\Store\Stores::class)->getStoreById($id); + } + + public function getStoreByUid(string $uid): ?Store + { + return app(\CraftCms\Commerce\Store\Stores::class)->getStoreByUid($uid); + } + + public function getStoreBySiteId(int $siteId): ?Store + { + return app(\CraftCms\Commerce\Store\Stores::class)->getStoreBySiteId($siteId); + } + + public function getStoreByHandle(string $handle): ?Store + { + return app(\CraftCms\Commerce\Store\Stores::class)->getStoreByHandle($handle); + } + + /** + * @return Collection + */ + public function getStoresByUserId(int $userId): Collection + { + return app(\CraftCms\Commerce\Store\Stores::class)->getStoresByUserId($userId); + } + + public function saveStore(Store $store, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Store\Stores::class)->saveStore($store, $runValidation); + } + + public function deleteStoreById(int $storeId): bool + { + return app(\CraftCms\Commerce\Store\Stores::class)->deleteStoreById($storeId); + } + + public function deleteStore(Store $store): bool + { + return app(\CraftCms\Commerce\Store\Stores::class)->deleteStore($store); + } + + /** + * @throws Throwable + */ + public function handleChangedStore(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Store\Stores::class)->handleChangedStore($event); + } + + /** + * @throws Throwable + */ + public function handleDeletedStore(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Store\Stores::class)->handleDeletedStore($event); + } + + public function refreshStores(): void + { + app(\CraftCms\Commerce\Store\Stores::class)->refreshStores(); + } + + public function getPrimaryStore(): ?Store + { + return app(\CraftCms\Commerce\Store\Stores::class)->getPrimaryStore(); + } + + /** + * @param int[] $ids + */ + public function reorderStores(array $ids): bool + { + return app(\CraftCms\Commerce\Store\Stores::class)->reorderStores($ids); + } + + /** + * @return Collection + */ + public function getAllSitesForStore(Store $store): Collection + { + return app(\CraftCms\Commerce\Store\Stores::class)->getAllSitesForStore($store); + } + + /** + * @return Collection + */ + public function getAllSiteStores(): Collection + { + return app(\CraftCms\Commerce\Store\Stores::class)->getAllSiteStores(); + } + + public function getSiteIdsAvailableForAssignmentToNewStores(): array + { + return app(\CraftCms\Commerce\Store\Stores::class)->getSiteIdsAvailableForAssignmentToNewStores(); + } + + /** + * @throws Throwable + */ + public function saveSiteStore(SiteStore $siteStore, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Store\Stores::class)->saveSiteStore($siteStore, $runValidation); + } + + /** + * @throws Throwable + */ + public function handleChangedSiteStore(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Store\Stores::class)->handleChangedSiteStore($event); + } + + /** + * @throws Throwable + */ + public function handleDeletedSiteStore(ConfigEvent $event): void + { + app(\CraftCms\Commerce\Store\Stores::class)->handleDeletedSiteStore($event); + } + +} diff --git a/src-yii2/services/TaxCategories.php b/src-yii2/services/TaxCategories.php new file mode 100644 index 0000000000..c15b4b7296 --- /dev/null +++ b/src-yii2/services/TaxCategories.php @@ -0,0 +1,61 @@ +getAllTaxCategories($withTrashed); + } + + public function getTaxCategoryById(int $taxCategoryId): ?TaxCategory + { + return app(\CraftCms\Commerce\Tax\TaxCategories::class)->getTaxCategoryById($taxCategoryId); + } + + public function getTaxCategoryByHandle(string $taxCategoryHandle): ?TaxCategory + { + return app(\CraftCms\Commerce\Tax\TaxCategories::class)->getTaxCategoryByHandle($taxCategoryHandle); + } + + /** + * @return array + */ + public function getAllTaxCategoriesAsList(): array + { + return app(\CraftCms\Commerce\Tax\TaxCategories::class)->getAllTaxCategoriesAsList(); + } + + public function getDefaultTaxCategory(): TaxCategory + { + return app(\CraftCms\Commerce\Tax\TaxCategories::class)->getDefaultTaxCategory(); + } + + public function saveTaxCategory(TaxCategory $taxCategory, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Tax\TaxCategories::class)->saveTaxCategory($taxCategory, $runValidation); + } + + public function deleteTaxCategoryById(int $id): bool + { + return app(\CraftCms\Commerce\Tax\TaxCategories::class)->deleteTaxCategoryById($id); + } + + /** + * @return array + */ + public function getTaxCategoriesByProductTypeId(int $productTypeId): array + { + return app(\CraftCms\Commerce\Tax\TaxCategories::class)->getTaxCategoriesByProductTypeId($productTypeId); + } +} diff --git a/src-yii2/services/TaxRates.php b/src-yii2/services/TaxRates.php new file mode 100644 index 0000000000..52573cf228 --- /dev/null +++ b/src-yii2/services/TaxRates.php @@ -0,0 +1,52 @@ + + */ + public function getAllTaxRates(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Tax\TaxRates::class)->getAllTaxRates($storeId); + } + + /** + * @return Collection + */ + public function getAllEnabledTaxRates(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Tax\TaxRates::class)->getAllEnabledTaxRates($storeId); + } + + /** + * @return Collection + */ + public function getTaxRatesByTaxZoneId(int $taxZoneId, ?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Tax\TaxRates::class)->getTaxRatesByTaxZoneId($taxZoneId, $storeId); + } + + public function getTaxRateById(int $id, ?int $storeId = null): ?TaxRate + { + return app(\CraftCms\Commerce\Tax\TaxRates::class)->getTaxRateById($id, $storeId); + } + + public function saveTaxRate(TaxRate $model, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Tax\TaxRates::class)->saveTaxRate($model, $runValidation); + } + + public function deleteTaxRateById(int $id): bool + { + return app(\CraftCms\Commerce\Tax\TaxRates::class)->deleteTaxRateById($id); + } +} diff --git a/src-yii2/services/TaxZones.php b/src-yii2/services/TaxZones.php new file mode 100644 index 0000000000..617cc98a4a --- /dev/null +++ b/src-yii2/services/TaxZones.php @@ -0,0 +1,36 @@ + + */ + public function getAllTaxZones(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Tax\TaxZones::class)->getAllTaxZones($storeId); + } + + public function getTaxZoneById(int $id, ?int $storeId = null): ?TaxAddressZone + { + return app(\CraftCms\Commerce\Tax\TaxZones::class)->getTaxZoneById($id, $storeId); + } + + public function saveTaxZone(TaxAddressZone $model, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Tax\TaxZones::class)->saveTaxZone($model, $runValidation); + } + + public function deleteTaxZoneById(int $id): bool + { + return app(\CraftCms\Commerce\Tax\TaxZones::class)->deleteTaxZoneById($id); + } +} diff --git a/src-yii2/services/Taxes.php b/src-yii2/services/Taxes.php new file mode 100644 index 0000000000..4c98dd23c2 --- /dev/null +++ b/src-yii2/services/Taxes.php @@ -0,0 +1,131 @@ + + */ + public function getTaxIdValidators(): Collection + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->getTaxIdValidators(); + } + + /** + * @return Collection + */ + public function getEnabledTaxIdValidators(): Collection + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->getEnabledTaxIdValidators(); + } + + public function getEngine(): NewTaxEngineInterface + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->getEngine(); + } + + public function taxAdjusterClass(): string + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->taxAdjusterClass(); + } + + public function viewTaxCategories(): bool + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->viewTaxCategories(); + } + + public function createTaxCategories(): bool + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->createTaxCategories(); + } + + public function editTaxCategories(): bool + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->editTaxCategories(); + } + + public function deleteTaxCategories(): bool + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->deleteTaxCategories(); + } + + public function taxCategoryActionHtml(): string + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->taxCategoryActionHtml(); + } + + public function viewTaxZones(): bool + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->viewTaxZones(); + } + + public function editTaxZones(): bool + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->editTaxZones(); + } + + public function viewTaxRates(): bool + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->viewTaxRates(); + } + + public function editTaxRates(): bool + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->editTaxRates(); + } + + public function cpTaxNavSubItems(): array + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->cpTaxNavSubItems(); + } + + public function createTaxZones(): bool + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->createTaxZones(); + } + + public function deleteTaxZones(): bool + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->deleteTaxZones(); + } + + public function taxZoneActionHtml(): string + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->taxZoneActionHtml(); + } + + public function createTaxRates(): bool + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->createTaxRates(); + } + + public function deleteTaxRates(): bool + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->deleteTaxRates(); + } + + public function taxRateActionHtml(): string + { + return app(\CraftCms\Commerce\Tax\Taxes::class)->taxRateActionHtml(); + } +} diff --git a/src-yii2/services/Transactions.php b/src-yii2/services/Transactions.php new file mode 100644 index 0000000000..d8163e83a5 --- /dev/null +++ b/src-yii2/services/Transactions.php @@ -0,0 +1,105 @@ +canCaptureTransaction($transaction); + } + + public function canRefundTransaction(Transaction $transaction): bool + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->canRefundTransaction($transaction); + } + + public function refundableAmountForTransaction(Transaction $transaction): float + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->refundableAmountForTransaction($transaction); + } + + public function createTransaction(?Order $order = null, ?Transaction $parentTransaction = null, ?string $typeOverride = null): Transaction + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->createTransaction($order, $parentTransaction, $typeOverride); + } + + public function deleteTransactionById(int $id): bool + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->deleteTransactionById($id); + } + + /** + * @return Transaction[] + */ + public function getAllTopLevelTransactionsByOrderId(int $orderId): array + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->getAllTopLevelTransactionsByOrderId($orderId); + } + + /** + * @return Transaction[] + */ + public function getAllTransactionsByOrderId(int $orderId): array + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->getAllTransactionsByOrderId($orderId); + } + + /** + * @return Transaction[] + */ + public function getChildrenByTransactionId(int $transactionId): array + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->getChildrenByTransactionId($transactionId); + } + + public function getTransactionByHash(string $hash): ?Transaction + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->getTransactionByHash($hash); + } + + public function getTransactionByReferenceAndStatus(string $reference, string $status): ?Transaction + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->getTransactionByReferenceAndStatus($reference, $status); + } + + public function getTransactionByReference(string $reference): ?Transaction + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->getTransactionByReference($reference); + } + + public function getTransactionById(int $id): ?Transaction + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->getTransactionById($id); + } + + public function isTransactionSuccessful(Transaction $transaction): bool + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->isTransactionSuccessful($transaction); + } + + public function saveTransaction(Transaction $model, bool $runValidation = true): bool + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->saveTransaction($model, $runValidation); + } + + /** + * @param Order[] $orders + * @return Order[] + */ + public function eagerLoadTransactionsForOrders(array $orders): array + { + return app(\CraftCms\Commerce\Payment\Transactions::class)->eagerLoadTransactionsForOrders($orders); + } +} diff --git a/src-yii2/services/Transfers.php b/src-yii2/services/Transfers.php new file mode 100644 index 0000000000..e5340d6e23 --- /dev/null +++ b/src-yii2/services/Transfers.php @@ -0,0 +1,11 @@ +getAllVariantsByProductId($productId, $siteId, $includeDisabled); + } + + public function getVariantById(int $variantId, ?int $siteId = null): ?Variant + { + return app(\CraftCms\Commerce\Catalog\Variants::class)->getVariantById($variantId, $siteId); + } + + /** + * @throws InvalidConfigException + */ + public function getVariantGqlContentArguments(): array + { + return app(\CraftCms\Commerce\Catalog\Variants::class)->getVariantGqlContentArguments(); + } +} diff --git a/src-yii2/services/Vat.php b/src-yii2/services/Vat.php new file mode 100644 index 0000000000..48ee575f0d --- /dev/null +++ b/src-yii2/services/Vat.php @@ -0,0 +1,16 @@ +isValidVatId($vatId); + } +} diff --git a/src-yii2/services/Webhooks.php b/src-yii2/services/Webhooks.php new file mode 100644 index 0000000000..b2c6c04ce6 --- /dev/null +++ b/src-yii2/services/Webhooks.php @@ -0,0 +1,25 @@ +processWebhook($gateway); + } +} diff --git a/src-yii2/stats/AverageOrderTotal.php b/src-yii2/stats/AverageOrderTotal.php new file mode 100644 index 0000000000..f0c9c7e546 --- /dev/null +++ b/src-yii2/stats/AverageOrderTotal.php @@ -0,0 +1,11 @@ + 0 %} + {% redirect 'commerce/products' %} +{% endif %} + +{% if currentUser.can('commerce-manageOrders') %} + {% redirect 'commerce/orders' %} +{% endif %} + +{% if craft.commerce.productTypes.viewableProductTypes|length > 0 %} + {% redirect 'commerce/products' %} +{% endif %} + +{% if currentUser.can('commerce-manageStoreSettings') %} + {% redirect "commerce/store-management" %} +{% endif %} + +{% if currentUser.can('commerce-manageInventoryStockLevels') %} + {% redirect "commerce/inventory" %} +{% endif %} + +{% if currentUser.can('commerce-managePromotions') %} + {% redirect "commerce/store-management/#{primaryStore.handle}/discounts" %} +{% endif %} + +{% if currentUser.can('commerce-manageShipping') %} + {% redirect "commerce/store-management/#{primaryStore.handle}/shippingmethods" %} +{% endif %} + +{% if currentUser.can('commerce-manageTaxes') %} + {% redirect "commerce/store-management/#{primaryStore.handle}/taxrates" %} +{% endif %} + +{% exit 403 %} diff --git a/src/templates/inventory-locations/_deleteModal.twig b/src-yii2/templates/inventory-locations/_deleteModal.twig similarity index 100% rename from src/templates/inventory-locations/_deleteModal.twig rename to src-yii2/templates/inventory-locations/_deleteModal.twig diff --git a/src-yii2/templates/inventory-locations/_edit.twig b/src-yii2/templates/inventory-locations/_edit.twig new file mode 100644 index 0000000000..1c5735c784 --- /dev/null +++ b/src-yii2/templates/inventory-locations/_edit.twig @@ -0,0 +1,11 @@ +{% namespace 'inventoryLocationAddress' %} +{{ extraFieldsHtml|raw }} +{% endnamespace %} + +{{ form|raw }} + +{% hook "cp.commerce.inventoryLocation.edit" %} + +{% if not inventoryLocation.id %} +{% js "new Craft.HandleGenerator('##{'name'|namespaceInputId}', '##{'handle'|namespaceInputId}');" %} +{% endif %} diff --git a/src/templates/inventory-locations/_index.twig b/src-yii2/templates/inventory-locations/_index.twig similarity index 100% rename from src/templates/inventory-locations/_index.twig rename to src-yii2/templates/inventory-locations/_index.twig diff --git a/src/templates/inventory-locations/_sidebar.twig b/src-yii2/templates/inventory-locations/_sidebar.twig similarity index 100% rename from src/templates/inventory-locations/_sidebar.twig rename to src-yii2/templates/inventory-locations/_sidebar.twig diff --git a/src/templates/inventory/item/_edit.twig b/src-yii2/templates/inventory/item/_edit.twig similarity index 100% rename from src/templates/inventory/item/_edit.twig rename to src-yii2/templates/inventory/item/_edit.twig diff --git a/src/templates/inventory/levels/_index.twig b/src-yii2/templates/inventory/levels/_index.twig similarity index 100% rename from src/templates/inventory/levels/_index.twig rename to src-yii2/templates/inventory/levels/_index.twig diff --git a/src/templates/inventory/levels/_inventoryMovementModal.twig b/src-yii2/templates/inventory/levels/_inventoryMovementModal.twig similarity index 100% rename from src/templates/inventory/levels/_inventoryMovementModal.twig rename to src-yii2/templates/inventory/levels/_inventoryMovementModal.twig diff --git a/src/templates/inventory/levels/_inventoryMovementPreview.twig b/src-yii2/templates/inventory/levels/_inventoryMovementPreview.twig similarity index 100% rename from src/templates/inventory/levels/_inventoryMovementPreview.twig rename to src-yii2/templates/inventory/levels/_inventoryMovementPreview.twig diff --git a/src/templates/inventory/levels/_unfulfilledOrdersModal.twig b/src-yii2/templates/inventory/levels/_unfulfilledOrdersModal.twig similarity index 100% rename from src/templates/inventory/levels/_unfulfilledOrdersModal.twig rename to src-yii2/templates/inventory/levels/_unfulfilledOrdersModal.twig diff --git a/src/templates/inventory/levels/_updateInventoryLevelModal.twig b/src-yii2/templates/inventory/levels/_updateInventoryLevelModal.twig similarity index 100% rename from src/templates/inventory/levels/_updateInventoryLevelModal.twig rename to src-yii2/templates/inventory/levels/_updateInventoryLevelModal.twig diff --git a/src/templates/inventory/levels/_updateInventoryLevelPreview.twig b/src-yii2/templates/inventory/levels/_updateInventoryLevelPreview.twig similarity index 100% rename from src/templates/inventory/levels/_updateInventoryLevelPreview.twig rename to src-yii2/templates/inventory/levels/_updateInventoryLevelPreview.twig diff --git a/src/templates/inventory/transfers/_index.twig b/src-yii2/templates/inventory/transfers/_index.twig similarity index 100% rename from src/templates/inventory/transfers/_index.twig rename to src-yii2/templates/inventory/transfers/_index.twig diff --git a/src/templates/orders/_edit.twig b/src-yii2/templates/orders/_edit.twig similarity index 100% rename from src/templates/orders/_edit.twig rename to src-yii2/templates/orders/_edit.twig diff --git a/src/templates/orders/_history.twig b/src-yii2/templates/orders/_history.twig similarity index 100% rename from src/templates/orders/_history.twig rename to src-yii2/templates/orders/_history.twig diff --git a/src/templates/orders/_index.twig b/src-yii2/templates/orders/_index.twig similarity index 100% rename from src/templates/orders/_index.twig rename to src-yii2/templates/orders/_index.twig diff --git a/src/templates/orders/_paymentForms.twig b/src-yii2/templates/orders/_paymentForms.twig similarity index 100% rename from src/templates/orders/_paymentForms.twig rename to src-yii2/templates/orders/_paymentForms.twig diff --git a/src/templates/orders/_paymentmodal.twig b/src-yii2/templates/orders/_paymentmodal.twig similarity index 100% rename from src/templates/orders/_paymentmodal.twig rename to src-yii2/templates/orders/_paymentmodal.twig diff --git a/src/templates/orders/_transactions.twig b/src-yii2/templates/orders/_transactions.twig similarity index 100% rename from src/templates/orders/_transactions.twig rename to src-yii2/templates/orders/_transactions.twig diff --git a/src/templates/orders/includes/_capture.twig b/src-yii2/templates/orders/includes/_capture.twig similarity index 100% rename from src/templates/orders/includes/_capture.twig rename to src-yii2/templates/orders/includes/_capture.twig diff --git a/src/templates/orders/includes/_refund.twig b/src-yii2/templates/orders/includes/_refund.twig similarity index 100% rename from src/templates/orders/includes/_refund.twig rename to src-yii2/templates/orders/includes/_refund.twig diff --git a/src/templates/orders/modals/_fulfillmentModal.twig b/src-yii2/templates/orders/modals/_fulfillmentModal.twig similarity index 100% rename from src/templates/orders/modals/_fulfillmentModal.twig rename to src-yii2/templates/orders/modals/_fulfillmentModal.twig diff --git a/src/templates/prices/_index.twig b/src-yii2/templates/prices/_index.twig similarity index 100% rename from src/templates/prices/_index.twig rename to src-yii2/templates/prices/_index.twig diff --git a/src/templates/prices/_polling.twig b/src-yii2/templates/prices/_polling.twig similarity index 100% rename from src/templates/prices/_polling.twig rename to src-yii2/templates/prices/_polling.twig diff --git a/src/templates/prices/_status.twig b/src-yii2/templates/prices/_status.twig similarity index 100% rename from src/templates/prices/_status.twig rename to src-yii2/templates/prices/_status.twig diff --git a/src/templates/prices/_table.twig b/src-yii2/templates/prices/_table.twig similarity index 100% rename from src/templates/prices/_table.twig rename to src-yii2/templates/prices/_table.twig diff --git a/src/templates/products/_index.twig b/src-yii2/templates/products/_index.twig similarity index 100% rename from src/templates/products/_index.twig rename to src-yii2/templates/products/_index.twig diff --git a/src/templates/promotions/index.twig b/src-yii2/templates/promotions/index.twig similarity index 100% rename from src/templates/promotions/index.twig rename to src-yii2/templates/promotions/index.twig diff --git a/src-yii2/templates/promotions/sales/_edit.twig b/src-yii2/templates/promotions/sales/_edit.twig new file mode 100644 index 0000000000..bd8eec9c68 --- /dev/null +++ b/src-yii2/templates/promotions/sales/_edit.twig @@ -0,0 +1,361 @@ +{% extends "commerce/_layouts/store-management" %} +{% set isIndex = false %} + +{% set crumbs = [ + { label: 'Commerce'|t('commerce'), url: url('commerce') }, + { label: "Store Management"|t('commerce'), url: url('commerce/store-management/#{storeHandle}') }, + { label: "Sales"|t('commerce'), url: url("commerce/store-management/#{storeHandle}/sales") }, +] %} + +{% set fullPageForm = true %} + +{% import "_includes/forms" as forms %} +{% import "commerce/_includes/forms/commerceForms" as commerceForms %} + +{% set mainFormAttributes = { + id: 'saleform', + method: 'post', + 'accept-charset': 'UTF-8' +} %} + +{% set formActions = [{ + label: 'Save and continue editing'|t('app'), + redirect: (isNewSale ? "commerce/store-management/#{storeHandle}/sales/{id}" : sale.getCpEditUrl())|hash, + retainScroll: true, + shortcut: true, +}] %} + +{% set actionClasses = "" %} +{% if (sale.getErrors('applyAmount') or sale.getErrors('apply')) %} + {% set actionClasses = "error" %} +{% endif %} + +{% set matchingItemsClasses = "" %} +{% if false %} + {% set matchingItemsClasses = "error" %} +{% endif %} + +{% set saleClasses = "" %} +{% if(sale.getErrors('name')) %} + {% set saleClasses = "error" %} +{% endif %} + +{% set tabs = { + sale: {'label':'Sale'|t('commerce'),'url':'#sale','class': saleClasses}, + matchingItems: {'label':'Matching Items'|t('commerce'),'url':'#matching-items'}, + conditions: {'label':'Conditions'|t('commerce'),'url':'#conditions'}, + actions: {'label':'Actions'|t('commerce'),'url':'#actions','class': actionClasses} +} %} + +{% hook "cp.commerce.sales.edit" %} + +{% block details %} + +
+ {{ forms.lightSwitchField({ + label: "Enable this sale"|t('commerce'), + id: 'enabled', + name: 'enabled', + value: 1, + on: sale.enabled, + checked: sale.enabled, + errors: sale.getErrors('enabled'), + instructions: 'Whether this sale should be available for use, regardless of other conditions.'|t('commerce') + }) }} +
+ + {% if sale and sale.id %} +
+
+
{{ "Created at"|t('app') }}
+
{{ sale.dateCreated|datetime('short') }}
+
+
+
{{ "Updated at"|t('app') }}
+
{{ sale.dateUpdated|datetime('short') }}
+
+
+ {% endif %} + + {% hook "cp.commerce.sales.edit.details" %} +{% endblock %} + +{% block content %} + + {{ redirectInput("commerce/store-management/#{storeHandle}/sales") }} + {% if sale.id %} + + + {% endif %} + +
+ {{ forms.textField({ + first: true, + label: "Name"|t('commerce'), + instructions: "What this sale will be called in the control panel."|t('commerce'), + id: 'name', + name: 'name', + value: sale.name, + errors: sale.getErrors('name'), + autofocus: true, + required: true, + }) }} + + {{ forms.textField({ + label: "Description"|t('commerce'), + instructions: "Sale description."|t('commerce'), + id: 'description', + name: 'description', + value: sale.description, + errors: sale.getErrors('description'), + }) }} + +
+ + + + + + + + {% hook "cp.commerce.sales.edit.content" %} +{% endblock %} + +{% js %} +$(function() { + $('#groups, #productTypes').selectize({ + plugins: ['remove_button'], + dropdownParent: 'body' + }); + + $("form").submit(function() { + $("input[name=ignorePrevious]").prop('disabled', false); + if ($("input[name=ignorePrevious]").prop('checked') == true) { + $("#ignorePrevious-field").css('opacity', 0.25); + } + }); + + $('select[name=apply]').change(function() { + + if (this.value == 'byPercent' || this.value == 'toPercent') { + $('#applyAmount-percent-symbol').removeClass('hidden'); + $('#applyAmount-currency-symbol').addClass('hidden'); + }else{ + $('#applyAmount-percent-symbol').addClass('hidden'); + $('#applyAmount-currency-symbol').removeClass('hidden'); + } + + if (this.value == 'toFlat' || this.value == 'toPercent') { + $('input[name=ignorePrevious]').prop('disabled', true); + $('#ignorePrevious').prop('disabled', true); + $('#ignorePrevious').addClass('disabled', true); + } + if (this.value != 'toFlat' && this.value != 'toPercent') { + $('input[name=ignorePrevious]').prop('disabled', false); + $('#ignorePrevious').prop('disabled', false); + $('#ignorePrevious').removeClass('disabled', true); + } + }); +}); +{% endjs %} diff --git a/src/templates/promotions/sales/index.twig b/src-yii2/templates/promotions/sales/index.twig similarity index 100% rename from src/templates/promotions/sales/index.twig rename to src-yii2/templates/promotions/sales/index.twig diff --git a/src/templates/settings/emails/_edit.twig b/src-yii2/templates/settings/emails/_edit.twig similarity index 100% rename from src/templates/settings/emails/_edit.twig rename to src-yii2/templates/settings/emails/_edit.twig diff --git a/src/templates/settings/emails/_previewError.twig b/src-yii2/templates/settings/emails/_previewError.twig similarity index 100% rename from src/templates/settings/emails/_previewError.twig rename to src-yii2/templates/settings/emails/_previewError.twig diff --git a/src/templates/settings/emails/index.twig b/src-yii2/templates/settings/emails/index.twig similarity index 100% rename from src/templates/settings/emails/index.twig rename to src-yii2/templates/settings/emails/index.twig diff --git a/src/templates/settings/gateways/_edit.twig b/src-yii2/templates/settings/gateways/_edit.twig similarity index 100% rename from src/templates/settings/gateways/_edit.twig rename to src-yii2/templates/settings/gateways/_edit.twig diff --git a/src/templates/settings/gateways/index.twig b/src-yii2/templates/settings/gateways/index.twig similarity index 100% rename from src/templates/settings/gateways/index.twig rename to src-yii2/templates/settings/gateways/index.twig diff --git a/src-yii2/templates/settings/general/index.twig b/src-yii2/templates/settings/general/index.twig new file mode 100644 index 0000000000..feeda0e7d6 --- /dev/null +++ b/src-yii2/templates/settings/general/index.twig @@ -0,0 +1,65 @@ +{# @var settings \craft\commerce\models\Settings #} +{% extends "commerce/_layouts/settings" %} + +{% set selectedTab = 'settings' %} +{% set fullPageForm = not readOnly %} + +{% set crumbs = [ + { label: 'Commerce'|t('commerce'), url: url('commerce') }, +] %} + +{% import "_includes/forms" as forms %} + +{% from _self import configWarning %} + +{% block content %} +

{{ "General Settings"|t('commerce') }}

+ +
+ + {% if not readOnly %} + {{ actionInput('commerce/settings/save-settings') }} + {{ redirectInput('commerce/settings/general') }} + {% endif %} + +

{{ 'Units'|t('commerce') }}

+ {{ forms.selectField({ + label: "Weight Unit"|t('commerce'), + instructions: "The unit of measurement that should be used when specifying product weights."|t('commerce'), + name: 'settings[weightUnits]', + value: settings.weightUnits, + options: settings.getWeightUnitsOptions(), + errors: settings.getErrors('weightUnits'), + required: true, + disabled: readOnly, + warning: configWarning('weightUnits', 'commerce'), + }) }} + + {{ forms.selectField({ + label: "Dimension Unit"|t('commerce'), + instructions: "The unit of measurement that should be used when specifying product dimensions."|t('commerce'), + name: 'settings[dimensionUnits]', + value: settings.dimensionUnits, + options: settings.getDimensionUnits(), + errors: settings.getErrors('dimensionUnits'), + required: true, + disabled: readOnly, + warning: configWarning('dimensionUnits', 'commerce'), + }) }} + +
+

{{ 'Control Panel Settings'|t('commerce') }}

+ {{ forms.selectField({ + label: "Default View"|t('commerce'), + instructions: "Default Commerce control panel view. If the user does not have permission it will fall back to a location they can access."|t('commerce'), + name: 'settings[defaultView]', + value: settings.defaultView, + options: settings.getDefaultViewOptions(), + errors: settings.getErrors('defaultView'), + disabled: readOnly, + required: true, + warning: configWarning('defaultView', 'commerce'), + }) }} +
+ +{% endblock %} diff --git a/src/templates/settings/index.twig b/src-yii2/templates/settings/index.twig similarity index 100% rename from src/templates/settings/index.twig rename to src-yii2/templates/settings/index.twig diff --git a/src/templates/settings/lineitemstatuses/_edit.twig b/src-yii2/templates/settings/lineitemstatuses/_edit.twig similarity index 100% rename from src/templates/settings/lineitemstatuses/_edit.twig rename to src-yii2/templates/settings/lineitemstatuses/_edit.twig diff --git a/src/templates/settings/lineitemstatuses/index.twig b/src-yii2/templates/settings/lineitemstatuses/index.twig similarity index 100% rename from src/templates/settings/lineitemstatuses/index.twig rename to src-yii2/templates/settings/lineitemstatuses/index.twig diff --git a/src/templates/settings/ordersettings/_edit.twig b/src-yii2/templates/settings/ordersettings/_edit.twig similarity index 100% rename from src/templates/settings/ordersettings/_edit.twig rename to src-yii2/templates/settings/ordersettings/_edit.twig diff --git a/src/templates/settings/orderstatuses/_edit.twig b/src-yii2/templates/settings/orderstatuses/_edit.twig similarity index 100% rename from src/templates/settings/orderstatuses/_edit.twig rename to src-yii2/templates/settings/orderstatuses/_edit.twig diff --git a/src/templates/settings/orderstatuses/index.twig b/src-yii2/templates/settings/orderstatuses/index.twig similarity index 100% rename from src/templates/settings/orderstatuses/index.twig rename to src-yii2/templates/settings/orderstatuses/index.twig diff --git a/src/templates/settings/pdfs/_edit.twig b/src-yii2/templates/settings/pdfs/_edit.twig similarity index 100% rename from src/templates/settings/pdfs/_edit.twig rename to src-yii2/templates/settings/pdfs/_edit.twig diff --git a/src/templates/settings/pdfs/index.twig b/src-yii2/templates/settings/pdfs/index.twig similarity index 100% rename from src/templates/settings/pdfs/index.twig rename to src-yii2/templates/settings/pdfs/index.twig diff --git a/src/templates/settings/producttypes/_edit.twig b/src-yii2/templates/settings/producttypes/_edit.twig similarity index 100% rename from src/templates/settings/producttypes/_edit.twig rename to src-yii2/templates/settings/producttypes/_edit.twig diff --git a/src/templates/settings/producttypes/index.twig b/src-yii2/templates/settings/producttypes/index.twig similarity index 100% rename from src/templates/settings/producttypes/index.twig rename to src-yii2/templates/settings/producttypes/index.twig diff --git a/src/templates/settings/stores/_edit.twig b/src-yii2/templates/settings/stores/_edit.twig similarity index 100% rename from src/templates/settings/stores/_edit.twig rename to src-yii2/templates/settings/stores/_edit.twig diff --git a/src/templates/settings/stores/_siteStore.twig b/src-yii2/templates/settings/stores/_siteStore.twig similarity index 100% rename from src/templates/settings/stores/_siteStore.twig rename to src-yii2/templates/settings/stores/_siteStore.twig diff --git a/src/templates/settings/stores/index.twig b/src-yii2/templates/settings/stores/index.twig similarity index 100% rename from src/templates/settings/stores/index.twig rename to src-yii2/templates/settings/stores/index.twig diff --git a/src/templates/settings/transfers/_edit.twig b/src-yii2/templates/settings/transfers/_edit.twig similarity index 100% rename from src/templates/settings/transfers/_edit.twig rename to src-yii2/templates/settings/transfers/_edit.twig diff --git a/src-yii2/templates/store-management/discounts/_edit.twig b/src-yii2/templates/store-management/discounts/_edit.twig new file mode 100644 index 0000000000..58aa41a035 --- /dev/null +++ b/src-yii2/templates/store-management/discounts/_edit.twig @@ -0,0 +1,655 @@ + +{% set fullPageForm = true %} + +{% import "_includes/forms" as forms %} +{% import "commerce/_includes/forms/commerceForms" as commerceForms %} + +{% set mainFormAttributes = { + id: 'discountform', + method: 'post', + 'accept-charset': 'UTF-8' +} %} + +{% set formActions = [ + { + label: 'Save and continue editing'|t('app'), + redirect: (isNewDiscount ? 'commerce/store-management/#{storeHandle}/discounts/{id}' : discount.getCpEditUrl())|hash, + retainScroll: true, + shortcut: true, + }] +%} + +{% set couponsTable = { + name: 'coupons', + id: 'coupons-table', + cols: { + id: { + type: 'singleline', + heading: 'id'|t('app'), + class: 'hidden', + }, + code: { + type: 'singleline', + heading: 'Code'|t('commerce'), + }, + uses: { + type: 'singleline', + heading: 'Uses'|t('commerce'), + }, + maxUses: { + type: 'singleline', + heading: 'Max Uses'|t('commerce'), + info: 'Leave blank for unlimited uses.'|t('commerce'), + }, + }, + defaultValues: { uses: 0 } +} %} + +{% hook "cp.commerce.discounts.edit" %} + +{% block content %} + {% set formAttributes = { + id: 'discountform', + method: 'post', + 'accept-charset': 'UTF-8', + data: { + saveshortcut: true, + 'saveshortcut-redirect': "commerce/store-management/#{storeHandle}/discounts"|hash, + 'confirm-unload': true + }, + } %} + + {{ hiddenInput('storeId', discount.storeId) }} + {% if discount.id %} + + + {% endif %} + +
+ {{ forms.textField({ + first: true, + label: "Name"|t('commerce'), + instructions: "What this discount will be called in the control panel."|t('commerce'), + id: 'name', + name: 'name', + value: discount.name, + errors: discount.getErrors('name'), + autofocus: true, + required: true, + }) }} + + {{ forms.textField({ + label: "Description"|t('commerce'), + instructions: "Discount description."|t('commerce'), + id: 'description', + name: 'description', + value: discount.description, + errors: discount.getErrors('description'), + }) }} + + {% hook "cp.commerce.discount.edit" %} +
+ + + + + + + + + + {% hook "cp.commerce.discounts.edit.content" %} +{% endblock %} + +{% js %} +$(function() { + + $('#code').on('keyup blur', function(event) { + if (this.value.length === 0) { + $('#coupon-fields').addClass('hidden'); + } else { + $('#coupon-fields').removeClass('hidden'); + } + }); + + function disableShippingSwitch() { + $('#hasFreeShippingForMatchingItems').data('lightswitch').turnOff(); + $('input[name="hasFreeShippingForMatchingItems"]').prop("disabled", true); + $('#hasFreeShippingForMatchingItems').prop("disabled", true); + $("#hasFreeShippingForMatchingItems").addClass("disabled"); + } + + function enableShippingSwitch() { + $('input[name="hasFreeShippingForMatchingItems"]').prop("disabled", false); + $('#hasFreeShippingForMatchingItems').prop("disabled", false); + $("#hasFreeShippingForMatchingItems").removeClass("disabled"); + } + + if ($('input[name="hasFreeShippingForOrder"]').val() == 1) { + disableShippingSwitch(); + } + + $('#hasFreeShippingForOrder').click(function() { + if ($('input[name="hasFreeShippingForOrder"]').val() == 1) { + disableShippingSwitch(); + } else { + enableShippingSwitch(); + } + }); + + $('.clear-btn.discount-clear-use').click(function(event) { + var $this = $(this); + var $spinner = $($this.data('spinner')); + var $field = $($this.data('field')); + var type = $this.data('type'); + var r = confirm(Craft.t('commerce', 'Are you sure you want to clear this discount usage counter?')); + + if (r == true) { + $spinner.toggleClass('hidden'); + $.ajax({ + type: "POST", + dataType: 'json', + headers: { + "X-CSRF-Token": '{{ craft.app.request.csrfToken }}', + }, + url: '', + data: { + 'action' : 'commerce/discounts/clear-discount-uses', + 'id': '{{ discount.id ?? '' }}', + 'type': type + }, + success: function(data){ + $spinner.toggleClass('hidden'); + $field.val(''); + Craft.cp.displayNotice(Craft.t('commerce', 'Counter has been cleared.')); + $this.attr('disabled', 'disabled').prop('disabled', 'disabled'); + } + }); + } + }); + + new Craft.Commerce.Coupons('#commerce-coupons', { + couponFormat: "{{ discount.couponFormat|e('js') }}", + table: { + name: "{{ couponsTable.name|namespaceInputName|e('js') }}", + cols: {{ couponsTable.cols|json_encode|raw }}, + defaultValues: {{ couponsTable.defaultValues|json_encode|raw }} + }, + }); +}); +{% endjs %} diff --git a/src/templates/store-management/discounts/_sidebar.twig b/src-yii2/templates/store-management/discounts/_sidebar.twig similarity index 100% rename from src/templates/store-management/discounts/_sidebar.twig rename to src-yii2/templates/store-management/discounts/_sidebar.twig diff --git a/src/templates/store-management/discounts/index.twig b/src-yii2/templates/store-management/discounts/index.twig similarity index 100% rename from src/templates/store-management/discounts/index.twig rename to src-yii2/templates/store-management/discounts/index.twig diff --git a/src/templates/store-management/general/_edit.twig b/src-yii2/templates/store-management/general/_edit.twig similarity index 100% rename from src/templates/store-management/general/_edit.twig rename to src-yii2/templates/store-management/general/_edit.twig diff --git a/src/templates/store-management/paymentcurrencies/_edit.twig b/src-yii2/templates/store-management/paymentcurrencies/_edit.twig similarity index 100% rename from src/templates/store-management/paymentcurrencies/_edit.twig rename to src-yii2/templates/store-management/paymentcurrencies/_edit.twig diff --git a/src/templates/store-management/paymentcurrencies/index.twig b/src-yii2/templates/store-management/paymentcurrencies/index.twig similarity index 100% rename from src/templates/store-management/paymentcurrencies/index.twig rename to src-yii2/templates/store-management/paymentcurrencies/index.twig diff --git a/src/templates/store-management/pricing-rules/_actions-fields.twig b/src-yii2/templates/store-management/pricing-rules/_actions-fields.twig similarity index 100% rename from src/templates/store-management/pricing-rules/_actions-fields.twig rename to src-yii2/templates/store-management/pricing-rules/_actions-fields.twig diff --git a/src/templates/store-management/pricing-rules/_edit.twig b/src-yii2/templates/store-management/pricing-rules/_edit.twig similarity index 100% rename from src/templates/store-management/pricing-rules/_edit.twig rename to src-yii2/templates/store-management/pricing-rules/_edit.twig diff --git a/src/templates/store-management/pricing-rules/_sidebar.twig b/src-yii2/templates/store-management/pricing-rules/_sidebar.twig similarity index 100% rename from src/templates/store-management/pricing-rules/_sidebar.twig rename to src-yii2/templates/store-management/pricing-rules/_sidebar.twig diff --git a/src/templates/store-management/pricing-rules/_slideout.twig b/src-yii2/templates/store-management/pricing-rules/_slideout.twig similarity index 100% rename from src/templates/store-management/pricing-rules/_slideout.twig rename to src-yii2/templates/store-management/pricing-rules/_slideout.twig diff --git a/src/templates/store-management/pricing-rules/index.twig b/src-yii2/templates/store-management/pricing-rules/index.twig similarity index 100% rename from src/templates/store-management/pricing-rules/index.twig rename to src-yii2/templates/store-management/pricing-rules/index.twig diff --git a/src/templates/store-management/shipping/index.twig b/src-yii2/templates/store-management/shipping/index.twig similarity index 100% rename from src/templates/store-management/shipping/index.twig rename to src-yii2/templates/store-management/shipping/index.twig diff --git a/src/templates/store-management/shipping/shippingcategories/_edit.twig b/src-yii2/templates/store-management/shipping/shippingcategories/_edit.twig similarity index 100% rename from src/templates/store-management/shipping/shippingcategories/_edit.twig rename to src-yii2/templates/store-management/shipping/shippingcategories/_edit.twig diff --git a/src/templates/store-management/shipping/shippingcategories/_fields.twig b/src-yii2/templates/store-management/shipping/shippingcategories/_fields.twig similarity index 100% rename from src/templates/store-management/shipping/shippingcategories/_fields.twig rename to src-yii2/templates/store-management/shipping/shippingcategories/_fields.twig diff --git a/src/templates/store-management/shipping/shippingmethods/_edit.twig b/src-yii2/templates/store-management/shipping/shippingmethods/_edit.twig similarity index 100% rename from src/templates/store-management/shipping/shippingmethods/_edit.twig rename to src-yii2/templates/store-management/shipping/shippingmethods/_edit.twig diff --git a/src/templates/store-management/shipping/shippingrules/_edit.twig b/src-yii2/templates/store-management/shipping/shippingrules/_edit.twig similarity index 100% rename from src/templates/store-management/shipping/shippingrules/_edit.twig rename to src-yii2/templates/store-management/shipping/shippingrules/_edit.twig diff --git a/src/templates/store-management/shipping/shippingzones/_edit.twig b/src-yii2/templates/store-management/shipping/shippingzones/_edit.twig similarity index 100% rename from src/templates/store-management/shipping/shippingzones/_edit.twig rename to src-yii2/templates/store-management/shipping/shippingzones/_edit.twig diff --git a/src/templates/store-management/shipping/shippingzones/_fields.twig b/src-yii2/templates/store-management/shipping/shippingzones/_fields.twig similarity index 100% rename from src/templates/store-management/shipping/shippingzones/_fields.twig rename to src-yii2/templates/store-management/shipping/shippingzones/_fields.twig diff --git a/src/templates/store-management/tax/taxcategories/_edit.twig b/src-yii2/templates/store-management/tax/taxcategories/_edit.twig similarity index 100% rename from src/templates/store-management/tax/taxcategories/_edit.twig rename to src-yii2/templates/store-management/tax/taxcategories/_edit.twig diff --git a/src/templates/store-management/tax/taxcategories/_fields.twig b/src-yii2/templates/store-management/tax/taxcategories/_fields.twig similarity index 100% rename from src/templates/store-management/tax/taxcategories/_fields.twig rename to src-yii2/templates/store-management/tax/taxcategories/_fields.twig diff --git a/src/templates/store-management/tax/taxrates/_edit.twig b/src-yii2/templates/store-management/tax/taxrates/_edit.twig similarity index 100% rename from src/templates/store-management/tax/taxrates/_edit.twig rename to src-yii2/templates/store-management/tax/taxrates/_edit.twig diff --git a/src/templates/store-management/tax/taxrates/_fields.twig b/src-yii2/templates/store-management/tax/taxrates/_fields.twig similarity index 100% rename from src/templates/store-management/tax/taxrates/_fields.twig rename to src-yii2/templates/store-management/tax/taxrates/_fields.twig diff --git a/src/templates/store-management/tax/taxrates/_sidebar.twig b/src-yii2/templates/store-management/tax/taxrates/_sidebar.twig similarity index 100% rename from src/templates/store-management/tax/taxrates/_sidebar.twig rename to src-yii2/templates/store-management/tax/taxrates/_sidebar.twig diff --git a/src/templates/store-management/tax/taxzones/_edit.twig b/src-yii2/templates/store-management/tax/taxzones/_edit.twig similarity index 100% rename from src/templates/store-management/tax/taxzones/_edit.twig rename to src-yii2/templates/store-management/tax/taxzones/_edit.twig diff --git a/src/templates/store-management/tax/taxzones/_fields.twig b/src-yii2/templates/store-management/tax/taxzones/_fields.twig similarity index 100% rename from src/templates/store-management/tax/taxzones/_fields.twig rename to src-yii2/templates/store-management/tax/taxzones/_fields.twig diff --git a/src/templates/variants/_index.twig b/src-yii2/templates/variants/_index.twig similarity index 100% rename from src/templates/variants/_index.twig rename to src-yii2/templates/variants/_index.twig diff --git a/src/translations/de/commerce.php b/src-yii2/translations/de/commerce.php similarity index 100% rename from src/translations/de/commerce.php rename to src-yii2/translations/de/commerce.php diff --git a/src/translations/en-GB/commerce.php b/src-yii2/translations/en-GB/commerce.php similarity index 100% rename from src/translations/en-GB/commerce.php rename to src-yii2/translations/en-GB/commerce.php diff --git a/src/translations/en/commerce.php b/src-yii2/translations/en/commerce.php similarity index 100% rename from src/translations/en/commerce.php rename to src-yii2/translations/en/commerce.php diff --git a/src/translations/fr-CA/commerce.php b/src-yii2/translations/fr-CA/commerce.php similarity index 100% rename from src/translations/fr-CA/commerce.php rename to src-yii2/translations/fr-CA/commerce.php diff --git a/src/translations/fr/commerce.php b/src-yii2/translations/fr/commerce.php similarity index 100% rename from src/translations/fr/commerce.php rename to src-yii2/translations/fr/commerce.php diff --git a/src/translations/it/commerce.php b/src-yii2/translations/it/commerce.php similarity index 100% rename from src/translations/it/commerce.php rename to src-yii2/translations/it/commerce.php diff --git a/src/translations/ja/commerce.php b/src-yii2/translations/ja/commerce.php similarity index 100% rename from src/translations/ja/commerce.php rename to src-yii2/translations/ja/commerce.php diff --git a/src/translations/nb/commerce.php b/src-yii2/translations/nb/commerce.php similarity index 100% rename from src/translations/nb/commerce.php rename to src-yii2/translations/nb/commerce.php diff --git a/src/translations/nl/commerce.php b/src-yii2/translations/nl/commerce.php similarity index 100% rename from src/translations/nl/commerce.php rename to src-yii2/translations/nl/commerce.php diff --git a/src/translations/pt/commerce.php b/src-yii2/translations/pt/commerce.php similarity index 100% rename from src/translations/pt/commerce.php rename to src-yii2/translations/pt/commerce.php diff --git a/src/translations/sk/commerce.php b/src-yii2/translations/sk/commerce.php similarity index 100% rename from src/translations/sk/commerce.php rename to src-yii2/translations/sk/commerce.php diff --git a/src/validators/CouponsValidator.php b/src-yii2/validators/CouponsValidator.php similarity index 92% rename from src/validators/CouponsValidator.php rename to src-yii2/validators/CouponsValidator.php index 07a3dae441..13ae346f67 100644 --- a/src/validators/CouponsValidator.php +++ b/src-yii2/validators/CouponsValidator.php @@ -9,7 +9,6 @@ use Craft; use craft\commerce\db\Table; -use craft\commerce\records\Coupon; use craft\db\Query; use craft\helpers\ArrayHelper; use yii\validators\Validator; @@ -36,13 +35,13 @@ public function validateAttribute($model, $attribute): void } // Case-insensitive check for duplicates in the same set of codes - if (array_intersect_key($codes, array_unique(array_map('strtolower', $codes))) !== $codes) { + if (array_intersect_key($codes, array_unique(array_map(strtolower(...), $codes))) !== $codes) { $this->addError($model, $attribute, Craft::t('commerce', 'Coupon codes must be unique.')); return; } // Check other codes in the DB - $query = (new Query()) + $query = new Query() ->select([ 'coupons.code', 'discounts.name', diff --git a/src/web/assets/catalogpricing/CatalogPricingAsset.php b/src-yii2/web/assets/catalogpricing/CatalogPricingAsset.php similarity index 93% rename from src/web/assets/catalogpricing/CatalogPricingAsset.php rename to src-yii2/web/assets/catalogpricing/CatalogPricingAsset.php index ff2d63bf69..41938ebae3 100644 --- a/src/web/assets/catalogpricing/CatalogPricingAsset.php +++ b/src-yii2/web/assets/catalogpricing/CatalogPricingAsset.php @@ -19,9 +19,6 @@ */ class CatalogPricingAsset extends AssetBundle { - /** - * @inheritdoc - */ public function init(): void { $this->sourcePath = __DIR__ . '/dist'; @@ -37,9 +34,6 @@ public function init(): void parent::init(); } - /** - * @inheritdoc - */ public function registerAssetFiles($view): void { parent::registerAssetFiles($view); diff --git a/src/web/assets/catalogpricing/dist/CatalogPricing.js b/src-yii2/web/assets/catalogpricing/dist/CatalogPricing.js similarity index 100% rename from src/web/assets/catalogpricing/dist/CatalogPricing.js rename to src-yii2/web/assets/catalogpricing/dist/CatalogPricing.js diff --git a/src/web/assets/catalogpricing/dist/CatalogPricing.js.map b/src-yii2/web/assets/catalogpricing/dist/CatalogPricing.js.map similarity index 100% rename from src/web/assets/catalogpricing/dist/CatalogPricing.js.map rename to src-yii2/web/assets/catalogpricing/dist/CatalogPricing.js.map diff --git a/src/web/assets/catalogpricing/dist/css/CatalogPricing.css b/src-yii2/web/assets/catalogpricing/dist/css/CatalogPricing.css similarity index 100% rename from src/web/assets/catalogpricing/dist/css/CatalogPricing.css rename to src-yii2/web/assets/catalogpricing/dist/css/CatalogPricing.css diff --git a/src/web/assets/catalogpricing/dist/css/CatalogPricing.css.map b/src-yii2/web/assets/catalogpricing/dist/css/CatalogPricing.css.map similarity index 100% rename from src/web/assets/catalogpricing/dist/css/CatalogPricing.css.map rename to src-yii2/web/assets/catalogpricing/dist/css/CatalogPricing.css.map diff --git a/src/web/assets/catalogpricing/src/css/catalogpricing.scss b/src-yii2/web/assets/catalogpricing/src/css/catalogpricing.scss similarity index 100% rename from src/web/assets/catalogpricing/src/css/catalogpricing.scss rename to src-yii2/web/assets/catalogpricing/src/css/catalogpricing.scss diff --git a/src/web/assets/catalogpricing/src/js/CatalogPricing.js b/src-yii2/web/assets/catalogpricing/src/js/CatalogPricing.js similarity index 100% rename from src/web/assets/catalogpricing/src/js/CatalogPricing.js rename to src-yii2/web/assets/catalogpricing/src/js/CatalogPricing.js diff --git a/src/web/assets/catalogpricing/webpack.config.js b/src-yii2/web/assets/catalogpricing/webpack.config.js similarity index 100% rename from src/web/assets/catalogpricing/webpack.config.js rename to src-yii2/web/assets/catalogpricing/webpack.config.js diff --git a/src/web/assets/chartjs/ChartJsAsset.php b/src-yii2/web/assets/chartjs/ChartJsAsset.php similarity index 94% rename from src/web/assets/chartjs/ChartJsAsset.php rename to src-yii2/web/assets/chartjs/ChartJsAsset.php index c26d31a506..39fb0872ad 100644 --- a/src/web/assets/chartjs/ChartJsAsset.php +++ b/src-yii2/web/assets/chartjs/ChartJsAsset.php @@ -17,9 +17,6 @@ */ class ChartJsAsset extends AssetBundle { - /** - * @inheritdoc - */ public function init(): void { $this->sourcePath = __DIR__ . '/dist'; diff --git a/src/web/assets/chartjs/dist/Chart.bundle.min.js b/src-yii2/web/assets/chartjs/dist/Chart.bundle.min.js similarity index 100% rename from src/web/assets/chartjs/dist/Chart.bundle.min.js rename to src-yii2/web/assets/chartjs/dist/Chart.bundle.min.js diff --git a/src/web/assets/chartjs/dist/Chart.bundle.min.js.LICENSE.txt b/src-yii2/web/assets/chartjs/dist/Chart.bundle.min.js.LICENSE.txt similarity index 100% rename from src/web/assets/chartjs/dist/Chart.bundle.min.js.LICENSE.txt rename to src-yii2/web/assets/chartjs/dist/Chart.bundle.min.js.LICENSE.txt diff --git a/src/web/assets/chartjs/dist/chartjs-adapter-moment.min.js b/src-yii2/web/assets/chartjs/dist/chartjs-adapter-moment.min.js similarity index 100% rename from src/web/assets/chartjs/dist/chartjs-adapter-moment.min.js rename to src-yii2/web/assets/chartjs/dist/chartjs-adapter-moment.min.js diff --git a/src/web/assets/chartjs/dist/chartjs-adapter-moment.min.js.LICENSE.txt b/src-yii2/web/assets/chartjs/dist/chartjs-adapter-moment.min.js.LICENSE.txt similarity index 100% rename from src/web/assets/chartjs/dist/chartjs-adapter-moment.min.js.LICENSE.txt rename to src-yii2/web/assets/chartjs/dist/chartjs-adapter-moment.min.js.LICENSE.txt diff --git a/src/web/assets/chartjs/dist/moment-with-locales.min.js b/src-yii2/web/assets/chartjs/dist/moment-with-locales.min.js similarity index 100% rename from src/web/assets/chartjs/dist/moment-with-locales.min.js rename to src-yii2/web/assets/chartjs/dist/moment-with-locales.min.js diff --git a/src/web/assets/chartjs/webpack.config.js b/src-yii2/web/assets/chartjs/webpack.config.js similarity index 100% rename from src/web/assets/chartjs/webpack.config.js rename to src-yii2/web/assets/chartjs/webpack.config.js diff --git a/src/web/assets/commercecp/CommerceCpAsset.php b/src-yii2/web/assets/commercecp/CommerceCpAsset.php similarity index 95% rename from src/web/assets/commercecp/CommerceCpAsset.php rename to src-yii2/web/assets/commercecp/CommerceCpAsset.php index b9eb6158ac..25b6837fc8 100644 --- a/src/web/assets/commercecp/CommerceCpAsset.php +++ b/src-yii2/web/assets/commercecp/CommerceCpAsset.php @@ -9,13 +9,14 @@ use Craft; use craft\commerce\behaviors\StoreBehavior; -use craft\commerce\models\ProductType; use craft\commerce\Plugin; use craft\helpers\Json; use craft\models\Site; use craft\web\AssetBundle; use craft\web\assets\cp\CpAsset; use craft\web\View; +use CraftCms\Cms\Support\Facades\Sites; +use CraftCms\Commerce\Catalog\ProductType\Data\ProductType; use yii\web\JqueryAsset; /** @@ -26,9 +27,6 @@ */ class CommerceCpAsset extends AssetBundle { - /** - * @inheritdoc - */ public function init(): void { $this->sourcePath = __DIR__ . '/dist'; @@ -45,9 +43,6 @@ public function init(): void parent::init(); } - /** - * @inheritdoc - */ public function registerAssetFiles($view): void { parent::registerAssetFiles($view); @@ -109,7 +104,7 @@ public function registerAssetFiles($view): void private function _commerceData(): array { $sitesStores = []; - foreach (Craft::$app->getSites()->getAllSites() as $site) { + foreach (Sites::getAllSites() as $site) { /** @var Site|StoreBehavior $site */ $sitesStores[$site->id] = $site->getStore()->id; } diff --git a/src/web/assets/commercecp/dist/commercecp.js b/src-yii2/web/assets/commercecp/dist/commercecp.js similarity index 100% rename from src/web/assets/commercecp/dist/commercecp.js rename to src-yii2/web/assets/commercecp/dist/commercecp.js diff --git a/src/web/assets/commercecp/dist/commercecp.js.LICENSE.txt b/src-yii2/web/assets/commercecp/dist/commercecp.js.LICENSE.txt similarity index 100% rename from src/web/assets/commercecp/dist/commercecp.js.LICENSE.txt rename to src-yii2/web/assets/commercecp/dist/commercecp.js.LICENSE.txt diff --git a/src/web/assets/commercecp/dist/commercecp.js.map b/src-yii2/web/assets/commercecp/dist/commercecp.js.map similarity index 100% rename from src/web/assets/commercecp/dist/commercecp.js.map rename to src-yii2/web/assets/commercecp/dist/commercecp.js.map diff --git a/src/web/assets/commercecp/dist/css/commercecp.css b/src-yii2/web/assets/commercecp/dist/css/commercecp.css similarity index 100% rename from src/web/assets/commercecp/dist/css/commercecp.css rename to src-yii2/web/assets/commercecp/dist/css/commercecp.css diff --git a/src/web/assets/commercecp/dist/css/commercecp.css.map b/src-yii2/web/assets/commercecp/dist/css/commercecp.css.map similarity index 100% rename from src/web/assets/commercecp/dist/css/commercecp.css.map rename to src-yii2/web/assets/commercecp/dist/css/commercecp.css.map diff --git a/src/web/assets/commercecp/dist/images/error.png b/src-yii2/web/assets/commercecp/dist/images/error.png similarity index 100% rename from src/web/assets/commercecp/dist/images/error.png rename to src-yii2/web/assets/commercecp/dist/images/error.png diff --git a/src/web/assets/commercecp/dist/images/promotional_price.png b/src-yii2/web/assets/commercecp/dist/images/promotional_price.png similarity index 100% rename from src/web/assets/commercecp/dist/images/promotional_price.png rename to src-yii2/web/assets/commercecp/dist/images/promotional_price.png diff --git a/src/web/assets/commercecp/dist/images/spinner_big.gif b/src-yii2/web/assets/commercecp/dist/images/spinner_big.gif similarity index 100% rename from src/web/assets/commercecp/dist/images/spinner_big.gif rename to src-yii2/web/assets/commercecp/dist/images/spinner_big.gif diff --git a/src/web/assets/commercecp/src/commercecp.js b/src-yii2/web/assets/commercecp/src/commercecp.js similarity index 87% rename from src/web/assets/commercecp/src/commercecp.js rename to src-yii2/web/assets/commercecp/src/commercecp.js index 72d4b6b2ad..9230c4e23e 100644 --- a/src/web/assets/commercecp/src/commercecp.js +++ b/src-yii2/web/assets/commercecp/src/commercecp.js @@ -6,7 +6,6 @@ import './scss/purchasables.scss'; import './scss/prices.scss'; import './scss/registration.scss'; import './scss/stores.scss'; -import './scss/subscriptions.scss'; // JS import './js/Commerce'; @@ -15,7 +14,6 @@ import './js/CommerceOrderIndex'; import './js/CommercePaymentModal'; import './js/CommerceProductSalesModal'; import './js/CommerceProductSelectInput'; -import './js/CommerceSubscriptionIndex'; import './js/CommerceUpdateOrderStatusModal'; import './js/DownloadOrderPdf'; import './js/TableRowAdditionalInfoIcon'; diff --git a/src/web/assets/commercecp/src/images/error.png b/src-yii2/web/assets/commercecp/src/images/error.png similarity index 100% rename from src/web/assets/commercecp/src/images/error.png rename to src-yii2/web/assets/commercecp/src/images/error.png diff --git a/src/web/assets/commercecp/src/images/promotional_price.png b/src-yii2/web/assets/commercecp/src/images/promotional_price.png similarity index 100% rename from src/web/assets/commercecp/src/images/promotional_price.png rename to src-yii2/web/assets/commercecp/src/images/promotional_price.png diff --git a/src/web/assets/commercecp/src/images/spinner_big.gif b/src-yii2/web/assets/commercecp/src/images/spinner_big.gif similarity index 100% rename from src/web/assets/commercecp/src/images/spinner_big.gif rename to src-yii2/web/assets/commercecp/src/images/spinner_big.gif diff --git a/src/web/assets/commercecp/src/js/Commerce.js b/src-yii2/web/assets/commercecp/src/js/Commerce.js similarity index 100% rename from src/web/assets/commercecp/src/js/Commerce.js rename to src-yii2/web/assets/commercecp/src/js/Commerce.js diff --git a/src/web/assets/commercecp/src/js/CommerceOrderEdit.js b/src-yii2/web/assets/commercecp/src/js/CommerceOrderEdit.js similarity index 100% rename from src/web/assets/commercecp/src/js/CommerceOrderEdit.js rename to src-yii2/web/assets/commercecp/src/js/CommerceOrderEdit.js diff --git a/src/web/assets/commercecp/src/js/CommerceOrderIndex.js b/src-yii2/web/assets/commercecp/src/js/CommerceOrderIndex.js similarity index 100% rename from src/web/assets/commercecp/src/js/CommerceOrderIndex.js rename to src-yii2/web/assets/commercecp/src/js/CommerceOrderIndex.js diff --git a/src/web/assets/commercecp/src/js/CommercePaymentModal.js b/src-yii2/web/assets/commercecp/src/js/CommercePaymentModal.js similarity index 100% rename from src/web/assets/commercecp/src/js/CommercePaymentModal.js rename to src-yii2/web/assets/commercecp/src/js/CommercePaymentModal.js diff --git a/src/web/assets/commercecp/src/js/CommerceProductSalesModal.js b/src-yii2/web/assets/commercecp/src/js/CommerceProductSalesModal.js similarity index 100% rename from src/web/assets/commercecp/src/js/CommerceProductSalesModal.js rename to src-yii2/web/assets/commercecp/src/js/CommerceProductSalesModal.js diff --git a/src/web/assets/commercecp/src/js/CommerceProductSelectInput.js b/src-yii2/web/assets/commercecp/src/js/CommerceProductSelectInput.js similarity index 100% rename from src/web/assets/commercecp/src/js/CommerceProductSelectInput.js rename to src-yii2/web/assets/commercecp/src/js/CommerceProductSelectInput.js diff --git a/src/web/assets/commercecp/src/js/CommerceUpdateOrderStatusModal.js b/src-yii2/web/assets/commercecp/src/js/CommerceUpdateOrderStatusModal.js similarity index 100% rename from src/web/assets/commercecp/src/js/CommerceUpdateOrderStatusModal.js rename to src-yii2/web/assets/commercecp/src/js/CommerceUpdateOrderStatusModal.js diff --git a/src/web/assets/commercecp/src/js/DownloadOrderPdf.js b/src-yii2/web/assets/commercecp/src/js/DownloadOrderPdf.js similarity index 100% rename from src/web/assets/commercecp/src/js/DownloadOrderPdf.js rename to src-yii2/web/assets/commercecp/src/js/DownloadOrderPdf.js diff --git a/src/web/assets/commercecp/src/js/TableRowAdditionalInfoIcon.js b/src-yii2/web/assets/commercecp/src/js/TableRowAdditionalInfoIcon.js similarity index 100% rename from src/web/assets/commercecp/src/js/TableRowAdditionalInfoIcon.js rename to src-yii2/web/assets/commercecp/src/js/TableRowAdditionalInfoIcon.js diff --git a/src/web/assets/commercecp/src/scss/addresses.scss b/src-yii2/web/assets/commercecp/src/scss/addresses.scss similarity index 100% rename from src/web/assets/commercecp/src/scss/addresses.scss rename to src-yii2/web/assets/commercecp/src/scss/addresses.scss diff --git a/src/web/assets/commercecp/src/scss/commerce.scss b/src-yii2/web/assets/commercecp/src/scss/commerce.scss similarity index 100% rename from src/web/assets/commercecp/src/scss/commerce.scss rename to src-yii2/web/assets/commercecp/src/scss/commerce.scss diff --git a/src/web/assets/commercecp/src/scss/order.scss b/src-yii2/web/assets/commercecp/src/scss/order.scss similarity index 100% rename from src/web/assets/commercecp/src/scss/order.scss rename to src-yii2/web/assets/commercecp/src/scss/order.scss diff --git a/src/web/assets/commercecp/src/scss/prices.scss b/src-yii2/web/assets/commercecp/src/scss/prices.scss similarity index 100% rename from src/web/assets/commercecp/src/scss/prices.scss rename to src-yii2/web/assets/commercecp/src/scss/prices.scss diff --git a/src/web/assets/commercecp/src/scss/purchasables.scss b/src-yii2/web/assets/commercecp/src/scss/purchasables.scss similarity index 100% rename from src/web/assets/commercecp/src/scss/purchasables.scss rename to src-yii2/web/assets/commercecp/src/scss/purchasables.scss diff --git a/src/web/assets/commercecp/src/scss/registration.scss b/src-yii2/web/assets/commercecp/src/scss/registration.scss similarity index 100% rename from src/web/assets/commercecp/src/scss/registration.scss rename to src-yii2/web/assets/commercecp/src/scss/registration.scss diff --git a/src/web/assets/commercecp/src/scss/stores.scss b/src-yii2/web/assets/commercecp/src/scss/stores.scss similarity index 100% rename from src/web/assets/commercecp/src/scss/stores.scss rename to src-yii2/web/assets/commercecp/src/scss/stores.scss diff --git a/src/web/assets/commercecp/webpack.config.js b/src-yii2/web/assets/commercecp/webpack.config.js similarity index 100% rename from src/web/assets/commercecp/webpack.config.js rename to src-yii2/web/assets/commercecp/webpack.config.js diff --git a/src/web/assets/commerceui/.env.example b/src-yii2/web/assets/commerceui/.env.example similarity index 100% rename from src/web/assets/commerceui/.env.example rename to src-yii2/web/assets/commerceui/.env.example diff --git a/src/web/assets/commerceui/.gitignore b/src-yii2/web/assets/commerceui/.gitignore similarity index 100% rename from src/web/assets/commerceui/.gitignore rename to src-yii2/web/assets/commerceui/.gitignore diff --git a/src/web/assets/commerceui/CommerceOrderAsset.php b/src-yii2/web/assets/commerceui/CommerceOrderAsset.php similarity index 100% rename from src/web/assets/commerceui/CommerceOrderAsset.php rename to src-yii2/web/assets/commerceui/CommerceOrderAsset.php diff --git a/src/web/assets/commerceui/CommerceUiAsset.php b/src-yii2/web/assets/commerceui/CommerceUiAsset.php similarity index 96% rename from src/web/assets/commerceui/CommerceUiAsset.php rename to src-yii2/web/assets/commerceui/CommerceUiAsset.php index cbb86bfd74..0c3da75c3d 100644 --- a/src/web/assets/commerceui/CommerceUiAsset.php +++ b/src-yii2/web/assets/commerceui/CommerceUiAsset.php @@ -21,9 +21,6 @@ */ abstract class CommerceUiAsset extends AssetBundle { - /** - * @inheritdoc - */ public function init() { $this->sourcePath = __DIR__ . '/dist/'; diff --git a/src/web/assets/commerceui/dist/css/order.css b/src-yii2/web/assets/commerceui/dist/css/order.css similarity index 100% rename from src/web/assets/commerceui/dist/css/order.css rename to src-yii2/web/assets/commerceui/dist/css/order.css diff --git a/src/web/assets/commerceui/dist/css/order.css.map b/src-yii2/web/assets/commerceui/dist/css/order.css.map similarity index 100% rename from src/web/assets/commerceui/dist/css/order.css.map rename to src-yii2/web/assets/commerceui/dist/css/order.css.map diff --git a/src/web/assets/commerceui/dist/js/app.js b/src-yii2/web/assets/commerceui/dist/js/app.js similarity index 100% rename from src/web/assets/commerceui/dist/js/app.js rename to src-yii2/web/assets/commerceui/dist/js/app.js diff --git a/src/web/assets/commerceui/dist/js/app.js.LICENSE.txt b/src-yii2/web/assets/commerceui/dist/js/app.js.LICENSE.txt similarity index 100% rename from src/web/assets/commerceui/dist/js/app.js.LICENSE.txt rename to src-yii2/web/assets/commerceui/dist/js/app.js.LICENSE.txt diff --git a/src/web/assets/commerceui/dist/js/app.js.map b/src-yii2/web/assets/commerceui/dist/js/app.js.map similarity index 100% rename from src/web/assets/commerceui/dist/js/app.js.map rename to src-yii2/web/assets/commerceui/dist/js/app.js.map diff --git a/src/web/assets/commerceui/dist/manifest.json b/src-yii2/web/assets/commerceui/dist/manifest.json similarity index 100% rename from src/web/assets/commerceui/dist/manifest.json rename to src-yii2/web/assets/commerceui/dist/manifest.json diff --git a/src/web/assets/commerceui/src/js/base/components/BtnLink.vue b/src-yii2/web/assets/commerceui/src/js/base/components/BtnLink.vue similarity index 100% rename from src/web/assets/commerceui/src/js/base/components/BtnLink.vue rename to src-yii2/web/assets/commerceui/src/js/base/components/BtnLink.vue diff --git a/src/web/assets/commerceui/src/js/base/components/Field.vue b/src-yii2/web/assets/commerceui/src/js/base/components/Field.vue similarity index 100% rename from src/web/assets/commerceui/src/js/base/components/Field.vue rename to src-yii2/web/assets/commerceui/src/js/base/components/Field.vue diff --git a/src/web/assets/commerceui/src/js/base/components/Lightswitch.vue b/src-yii2/web/assets/commerceui/src/js/base/components/Lightswitch.vue similarity index 100% rename from src/web/assets/commerceui/src/js/base/components/Lightswitch.vue rename to src-yii2/web/assets/commerceui/src/js/base/components/Lightswitch.vue diff --git a/src/web/assets/commerceui/src/js/base/components/Modal.vue b/src-yii2/web/assets/commerceui/src/js/base/components/Modal.vue similarity index 100% rename from src/web/assets/commerceui/src/js/base/components/Modal.vue rename to src-yii2/web/assets/commerceui/src/js/base/components/Modal.vue diff --git a/src/web/assets/commerceui/src/js/base/components/SelectInput.vue b/src-yii2/web/assets/commerceui/src/js/base/components/SelectInput.vue similarity index 100% rename from src/web/assets/commerceui/src/js/base/components/SelectInput.vue rename to src-yii2/web/assets/commerceui/src/js/base/components/SelectInput.vue diff --git a/src/web/assets/commerceui/src/js/base/filters/craft.js b/src-yii2/web/assets/commerceui/src/js/base/filters/craft.js similarity index 100% rename from src/web/assets/commerceui/src/js/base/filters/craft.js rename to src-yii2/web/assets/commerceui/src/js/base/filters/craft.js diff --git a/src/web/assets/commerceui/src/js/order/api/addresses.js b/src-yii2/web/assets/commerceui/src/js/order/api/addresses.js similarity index 100% rename from src/web/assets/commerceui/src/js/order/api/addresses.js rename to src-yii2/web/assets/commerceui/src/js/order/api/addresses.js diff --git a/src/web/assets/commerceui/src/js/order/api/orders.js b/src-yii2/web/assets/commerceui/src/js/order/api/orders.js similarity index 100% rename from src/web/assets/commerceui/src/js/order/api/orders.js rename to src-yii2/web/assets/commerceui/src/js/order/api/orders.js diff --git a/src/web/assets/commerceui/src/js/order/app.js b/src-yii2/web/assets/commerceui/src/js/order/app.js similarity index 100% rename from src/web/assets/commerceui/src/js/order/app.js rename to src-yii2/web/assets/commerceui/src/js/order/app.js diff --git a/src/web/assets/commerceui/src/js/order/apps/OrderActions.vue b/src-yii2/web/assets/commerceui/src/js/order/apps/OrderActions.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/apps/OrderActions.vue rename to src-yii2/web/assets/commerceui/src/js/order/apps/OrderActions.vue diff --git a/src/web/assets/commerceui/src/js/order/apps/OrderCustomer.vue b/src-yii2/web/assets/commerceui/src/js/order/apps/OrderCustomer.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/apps/OrderCustomer.vue rename to src-yii2/web/assets/commerceui/src/js/order/apps/OrderCustomer.vue diff --git a/src/web/assets/commerceui/src/js/order/apps/OrderDetails.vue b/src-yii2/web/assets/commerceui/src/js/order/apps/OrderDetails.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/apps/OrderDetails.vue rename to src-yii2/web/assets/commerceui/src/js/order/apps/OrderDetails.vue diff --git a/src/web/assets/commerceui/src/js/order/apps/OrderErrors.vue b/src-yii2/web/assets/commerceui/src/js/order/apps/OrderErrors.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/apps/OrderErrors.vue rename to src-yii2/web/assets/commerceui/src/js/order/apps/OrderErrors.vue diff --git a/src/web/assets/commerceui/src/js/order/apps/OrderMeta.vue b/src-yii2/web/assets/commerceui/src/js/order/apps/OrderMeta.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/apps/OrderMeta.vue rename to src-yii2/web/assets/commerceui/src/js/order/apps/OrderMeta.vue diff --git a/src/web/assets/commerceui/src/js/order/apps/OrderNotices.vue b/src-yii2/web/assets/commerceui/src/js/order/apps/OrderNotices.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/apps/OrderNotices.vue rename to src-yii2/web/assets/commerceui/src/js/order/apps/OrderNotices.vue diff --git a/src/web/assets/commerceui/src/js/order/apps/OrderSecondaryActions.vue b/src-yii2/web/assets/commerceui/src/js/order/apps/OrderSecondaryActions.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/apps/OrderSecondaryActions.vue rename to src-yii2/web/assets/commerceui/src/js/order/apps/OrderSecondaryActions.vue diff --git a/src/web/assets/commerceui/src/js/order/components/InputError.vue b/src-yii2/web/assets/commerceui/src/js/order/components/InputError.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/InputError.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/InputError.vue diff --git a/src/web/assets/commerceui/src/js/order/components/OrderBlock.vue b/src-yii2/web/assets/commerceui/src/js/order/components/OrderBlock.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/OrderBlock.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/OrderBlock.vue diff --git a/src/web/assets/commerceui/src/js/order/components/OrderTitle.vue b/src-yii2/web/assets/commerceui/src/js/order/components/OrderTitle.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/OrderTitle.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/OrderTitle.vue diff --git a/src/web/assets/commerceui/src/js/order/components/actions/OptionShortcutLabel.vue b/src-yii2/web/assets/commerceui/src/js/order/components/actions/OptionShortcutLabel.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/actions/OptionShortcutLabel.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/actions/OptionShortcutLabel.vue diff --git a/src/web/assets/commerceui/src/js/order/components/actions/UpdateOrderBtn.vue b/src-yii2/web/assets/commerceui/src/js/order/components/actions/UpdateOrderBtn.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/actions/UpdateOrderBtn.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/actions/UpdateOrderBtn.vue diff --git a/src/web/assets/commerceui/src/js/order/components/customer/AddressEdit.vue b/src-yii2/web/assets/commerceui/src/js/order/components/customer/AddressEdit.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/customer/AddressEdit.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/customer/AddressEdit.vue diff --git a/src/web/assets/commerceui/src/js/order/components/customer/AddressSelect.vue b/src-yii2/web/assets/commerceui/src/js/order/components/customer/AddressSelect.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/customer/AddressSelect.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/customer/AddressSelect.vue diff --git a/src/web/assets/commerceui/src/js/order/components/customer/Customer.vue b/src-yii2/web/assets/commerceui/src/js/order/components/customer/Customer.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/customer/Customer.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/customer/Customer.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/AddLineItem.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/AddLineItem.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/AddLineItem.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/AddLineItem.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/Adjustment.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/Adjustment.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/Adjustment.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/Adjustment.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/Adjustments.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/Adjustments.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/Adjustments.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/Adjustments.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/LineItem.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/LineItem.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/LineItem.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/LineItem.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/LineItemAdjustments.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/LineItemAdjustments.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/LineItemAdjustments.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/LineItemAdjustments.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/LineItemNotes.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/LineItemNotes.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/LineItemNotes.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/LineItemNotes.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/LineItemOptions.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/LineItemOptions.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/LineItemOptions.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/LineItemOptions.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/LineItemProperty.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/LineItemProperty.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/LineItemProperty.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/LineItemProperty.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/LineItemStatus.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/LineItemStatus.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/LineItemStatus.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/LineItemStatus.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/LineItemStatusInput.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/LineItemStatusInput.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/LineItemStatusInput.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/LineItemStatusInput.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/LineItems.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/LineItems.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/LineItems.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/LineItems.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/OrderAdjustments.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/OrderAdjustments.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/OrderAdjustments.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/OrderAdjustments.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/QtyInput.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/QtyInput.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/QtyInput.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/QtyInput.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/Snapshot.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/Snapshot.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/Snapshot.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/Snapshot.vue diff --git a/src/web/assets/commerceui/src/js/order/components/details/Total.vue b/src-yii2/web/assets/commerceui/src/js/order/components/details/Total.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/details/Total.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/details/Total.vue diff --git a/src/web/assets/commerceui/src/js/order/components/meta/CustomerSelect.vue b/src-yii2/web/assets/commerceui/src/js/order/components/meta/CustomerSelect.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/meta/CustomerSelect.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/meta/CustomerSelect.vue diff --git a/src/web/assets/commerceui/src/js/order/components/meta/DateOrderedInput.vue b/src-yii2/web/assets/commerceui/src/js/order/components/meta/DateOrderedInput.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/meta/DateOrderedInput.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/meta/DateOrderedInput.vue diff --git a/src/web/assets/commerceui/src/js/order/components/meta/OpenIndicator.vue b/src-yii2/web/assets/commerceui/src/js/order/components/meta/OpenIndicator.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/meta/OpenIndicator.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/meta/OpenIndicator.vue diff --git a/src/web/assets/commerceui/src/js/order/components/meta/OrderSite.vue b/src-yii2/web/assets/commerceui/src/js/order/components/meta/OrderSite.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/meta/OrderSite.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/meta/OrderSite.vue diff --git a/src/web/assets/commerceui/src/js/order/components/meta/OrderStatus.vue b/src-yii2/web/assets/commerceui/src/js/order/components/meta/OrderStatus.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/meta/OrderStatus.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/meta/OrderStatus.vue diff --git a/src/web/assets/commerceui/src/js/order/components/meta/ShippingMethod.vue b/src-yii2/web/assets/commerceui/src/js/order/components/meta/ShippingMethod.vue similarity index 100% rename from src/web/assets/commerceui/src/js/order/components/meta/ShippingMethod.vue rename to src-yii2/web/assets/commerceui/src/js/order/components/meta/ShippingMethod.vue diff --git a/src/web/assets/commerceui/src/js/order/helpers/utils.js b/src-yii2/web/assets/commerceui/src/js/order/helpers/utils.js similarity index 100% rename from src/web/assets/commerceui/src/js/order/helpers/utils.js rename to src-yii2/web/assets/commerceui/src/js/order/helpers/utils.js diff --git a/src/web/assets/commerceui/src/js/order/mixins/index.js b/src-yii2/web/assets/commerceui/src/js/order/mixins/index.js similarity index 100% rename from src/web/assets/commerceui/src/js/order/mixins/index.js rename to src-yii2/web/assets/commerceui/src/js/order/mixins/index.js diff --git a/src/web/assets/commerceui/src/js/order/store/index.js b/src-yii2/web/assets/commerceui/src/js/order/store/index.js similarity index 100% rename from src/web/assets/commerceui/src/js/order/store/index.js rename to src-yii2/web/assets/commerceui/src/js/order/store/index.js diff --git a/src/web/assets/commerceui/src/sass/base/_common.scss b/src-yii2/web/assets/commerceui/src/sass/base/_common.scss similarity index 100% rename from src/web/assets/commerceui/src/sass/base/_common.scss rename to src-yii2/web/assets/commerceui/src/sass/base/_common.scss diff --git a/src/web/assets/commerceui/src/sass/base/_craft-classes.scss b/src-yii2/web/assets/commerceui/src/sass/base/_craft-classes.scss similarity index 100% rename from src/web/assets/commerceui/src/sass/base/_craft-classes.scss rename to src-yii2/web/assets/commerceui/src/sass/base/_craft-classes.scss diff --git a/src/web/assets/commerceui/src/sass/base/_vue-select.scss b/src-yii2/web/assets/commerceui/src/sass/base/_vue-select.scss similarity index 100% rename from src/web/assets/commerceui/src/sass/base/_vue-select.scss rename to src-yii2/web/assets/commerceui/src/sass/base/_vue-select.scss diff --git a/src/web/assets/commerceui/src/sass/order/_modal.scss b/src-yii2/web/assets/commerceui/src/sass/order/_modal.scss similarity index 100% rename from src/web/assets/commerceui/src/sass/order/_modal.scss rename to src-yii2/web/assets/commerceui/src/sass/order/_modal.scss diff --git a/src/web/assets/commerceui/src/sass/order/app.scss b/src-yii2/web/assets/commerceui/src/sass/order/app.scss similarity index 100% rename from src/web/assets/commerceui/src/sass/order/app.scss rename to src-yii2/web/assets/commerceui/src/sass/order/app.scss diff --git a/src/web/assets/commerceui/webpack.config.js b/src-yii2/web/assets/commerceui/webpack.config.js similarity index 100% rename from src/web/assets/commerceui/webpack.config.js rename to src-yii2/web/assets/commerceui/webpack.config.js diff --git a/src/web/assets/commercewidgets/CommerceWidgetsAsset.php b/src-yii2/web/assets/commercewidgets/CommerceWidgetsAsset.php similarity index 94% rename from src/web/assets/commercewidgets/CommerceWidgetsAsset.php rename to src-yii2/web/assets/commercewidgets/CommerceWidgetsAsset.php index db6c932019..b96cdc5e90 100644 --- a/src/web/assets/commercewidgets/CommerceWidgetsAsset.php +++ b/src-yii2/web/assets/commercewidgets/CommerceWidgetsAsset.php @@ -17,9 +17,6 @@ */ class CommerceWidgetsAsset extends AssetBundle { - /** - * @inheritdoc - */ public function init(): void { $this->sourcePath = __DIR__ . '/dist'; diff --git a/src/web/assets/commercewidgets/dist/CommerceWidgets.js b/src-yii2/web/assets/commercewidgets/dist/CommerceWidgets.js similarity index 100% rename from src/web/assets/commercewidgets/dist/CommerceWidgets.js rename to src-yii2/web/assets/commercewidgets/dist/CommerceWidgets.js diff --git a/src/web/assets/commercewidgets/dist/CommerceWidgets.js.map b/src-yii2/web/assets/commercewidgets/dist/CommerceWidgets.js.map similarity index 100% rename from src/web/assets/commercewidgets/dist/CommerceWidgets.js.map rename to src-yii2/web/assets/commercewidgets/dist/CommerceWidgets.js.map diff --git a/src/web/assets/commercewidgets/src/CommerceWidgets.js b/src-yii2/web/assets/commercewidgets/src/CommerceWidgets.js similarity index 100% rename from src/web/assets/commercewidgets/src/CommerceWidgets.js rename to src-yii2/web/assets/commercewidgets/src/CommerceWidgets.js diff --git a/src/web/assets/commercewidgets/webpack.config.js b/src-yii2/web/assets/commercewidgets/webpack.config.js similarity index 100% rename from src/web/assets/commercewidgets/webpack.config.js rename to src-yii2/web/assets/commercewidgets/webpack.config.js diff --git a/src/web/assets/coupons/CouponsAsset.php b/src-yii2/web/assets/coupons/CouponsAsset.php similarity index 94% rename from src/web/assets/coupons/CouponsAsset.php rename to src-yii2/web/assets/coupons/CouponsAsset.php index d89f70cc95..c4453eb806 100644 --- a/src/web/assets/coupons/CouponsAsset.php +++ b/src-yii2/web/assets/coupons/CouponsAsset.php @@ -19,9 +19,6 @@ */ class CouponsAsset extends AssetBundle { - /** - * @inheritdoc - */ public function init(): void { $this->sourcePath = __DIR__ . '/dist'; @@ -37,9 +34,6 @@ public function init(): void parent::init(); } - /** - * @inheritdoc - */ public function registerAssetFiles($view): void { parent::registerAssetFiles($view); diff --git a/src/web/assets/coupons/dist/coupons.js b/src-yii2/web/assets/coupons/dist/coupons.js similarity index 100% rename from src/web/assets/coupons/dist/coupons.js rename to src-yii2/web/assets/coupons/dist/coupons.js diff --git a/src/web/assets/coupons/dist/coupons.js.map b/src-yii2/web/assets/coupons/dist/coupons.js.map similarity index 100% rename from src/web/assets/coupons/dist/coupons.js.map rename to src-yii2/web/assets/coupons/dist/coupons.js.map diff --git a/src/web/assets/coupons/dist/css/coupons.css b/src-yii2/web/assets/coupons/dist/css/coupons.css similarity index 100% rename from src/web/assets/coupons/dist/css/coupons.css rename to src-yii2/web/assets/coupons/dist/css/coupons.css diff --git a/src/web/assets/coupons/dist/css/coupons.css.map b/src-yii2/web/assets/coupons/dist/css/coupons.css.map similarity index 100% rename from src/web/assets/coupons/dist/css/coupons.css.map rename to src-yii2/web/assets/coupons/dist/css/coupons.css.map diff --git a/src/web/assets/coupons/src/css/coupons.scss b/src-yii2/web/assets/coupons/src/css/coupons.scss similarity index 100% rename from src/web/assets/coupons/src/css/coupons.scss rename to src-yii2/web/assets/coupons/src/css/coupons.scss diff --git a/src/web/assets/coupons/src/js/coupons.js b/src-yii2/web/assets/coupons/src/js/coupons.js similarity index 100% rename from src/web/assets/coupons/src/js/coupons.js rename to src-yii2/web/assets/coupons/src/js/coupons.js diff --git a/src/web/assets/coupons/webpack.config.js b/src-yii2/web/assets/coupons/webpack.config.js similarity index 100% rename from src/web/assets/coupons/webpack.config.js rename to src-yii2/web/assets/coupons/webpack.config.js diff --git a/src/web/assets/deepmerge/DeepMergeAsset.php b/src-yii2/web/assets/deepmerge/DeepMergeAsset.php similarity index 93% rename from src/web/assets/deepmerge/DeepMergeAsset.php rename to src-yii2/web/assets/deepmerge/DeepMergeAsset.php index 4509beaace..bfa6039e84 100644 --- a/src/web/assets/deepmerge/DeepMergeAsset.php +++ b/src-yii2/web/assets/deepmerge/DeepMergeAsset.php @@ -17,9 +17,6 @@ */ class DeepMergeAsset extends AssetBundle { - /** - * @inheritdoc - */ public function init(): void { $this->sourcePath = __DIR__ . '/dist'; diff --git a/src/web/assets/deepmerge/dist/umd.js b/src-yii2/web/assets/deepmerge/dist/umd.js similarity index 100% rename from src/web/assets/deepmerge/dist/umd.js rename to src-yii2/web/assets/deepmerge/dist/umd.js diff --git a/src/web/assets/deepmerge/webpack.config.js b/src-yii2/web/assets/deepmerge/webpack.config.js similarity index 100% rename from src/web/assets/deepmerge/webpack.config.js rename to src-yii2/web/assets/deepmerge/webpack.config.js diff --git a/src/web/assets/inventory/InventoryAsset.php b/src-yii2/web/assets/inventory/InventoryAsset.php similarity index 95% rename from src/web/assets/inventory/InventoryAsset.php rename to src-yii2/web/assets/inventory/InventoryAsset.php index 3eddfbe674..8ff973c935 100644 --- a/src/web/assets/inventory/InventoryAsset.php +++ b/src-yii2/web/assets/inventory/InventoryAsset.php @@ -21,9 +21,6 @@ */ class InventoryAsset extends AssetBundle { - /** - * @inheritdoc - */ public function init(): void { $this->sourcePath = __DIR__ . '/dist'; @@ -41,9 +38,6 @@ public function init(): void parent::init(); } - /** - * @inheritdoc - */ public function registerAssetFiles($view): void { parent::registerAssetFiles($view); diff --git a/src/web/assets/inventory/dist/css/inventory.css b/src-yii2/web/assets/inventory/dist/css/inventory.css similarity index 100% rename from src/web/assets/inventory/dist/css/inventory.css rename to src-yii2/web/assets/inventory/dist/css/inventory.css diff --git a/src/web/assets/inventory/dist/css/inventory.css.map b/src-yii2/web/assets/inventory/dist/css/inventory.css.map similarity index 100% rename from src/web/assets/inventory/dist/css/inventory.css.map rename to src-yii2/web/assets/inventory/dist/css/inventory.css.map diff --git a/src/web/assets/inventory/dist/inventory.js b/src-yii2/web/assets/inventory/dist/inventory.js similarity index 100% rename from src/web/assets/inventory/dist/inventory.js rename to src-yii2/web/assets/inventory/dist/inventory.js diff --git a/src/web/assets/inventory/dist/inventory.js.map b/src-yii2/web/assets/inventory/dist/inventory.js.map similarity index 100% rename from src/web/assets/inventory/dist/inventory.js.map rename to src-yii2/web/assets/inventory/dist/inventory.js.map diff --git a/src/web/assets/inventory/src/css/inventory.scss b/src-yii2/web/assets/inventory/src/css/inventory.scss similarity index 100% rename from src/web/assets/inventory/src/css/inventory.scss rename to src-yii2/web/assets/inventory/src/css/inventory.scss diff --git a/src/web/assets/inventory/src/inventory.js b/src-yii2/web/assets/inventory/src/inventory.js similarity index 100% rename from src/web/assets/inventory/src/inventory.js rename to src-yii2/web/assets/inventory/src/inventory.js diff --git a/src/web/assets/inventory/src/js/InventoryLevelsManager.js b/src-yii2/web/assets/inventory/src/js/InventoryLevelsManager.js similarity index 100% rename from src/web/assets/inventory/src/js/InventoryLevelsManager.js rename to src-yii2/web/assets/inventory/src/js/InventoryLevelsManager.js diff --git a/src/web/assets/inventory/src/js/InventoryMovementModal.js b/src-yii2/web/assets/inventory/src/js/InventoryMovementModal.js similarity index 100% rename from src/web/assets/inventory/src/js/InventoryMovementModal.js rename to src-yii2/web/assets/inventory/src/js/InventoryMovementModal.js diff --git a/src/web/assets/inventory/src/js/UpdateInventoryLevelModal.js b/src-yii2/web/assets/inventory/src/js/UpdateInventoryLevelModal.js similarity index 100% rename from src/web/assets/inventory/src/js/UpdateInventoryLevelModal.js rename to src-yii2/web/assets/inventory/src/js/UpdateInventoryLevelModal.js diff --git a/src/web/assets/inventory/webpack.config.js b/src-yii2/web/assets/inventory/webpack.config.js similarity index 100% rename from src/web/assets/inventory/webpack.config.js rename to src-yii2/web/assets/inventory/webpack.config.js diff --git a/src/web/assets/orderswidget/OrdersWidgetAsset.php b/src-yii2/web/assets/orderswidget/OrdersWidgetAsset.php similarity index 95% rename from src/web/assets/orderswidget/OrdersWidgetAsset.php rename to src-yii2/web/assets/orderswidget/OrdersWidgetAsset.php index 2ae169c3ed..4f9886e089 100644 --- a/src/web/assets/orderswidget/OrdersWidgetAsset.php +++ b/src-yii2/web/assets/orderswidget/OrdersWidgetAsset.php @@ -20,9 +20,6 @@ */ class OrdersWidgetAsset extends AssetBundle { - /** - * @inheritdoc - */ public function init(): void { $this->sourcePath = __DIR__ . '/dist'; diff --git a/src/web/assets/orderswidget/dist/OrdersWidgetSettings.js b/src-yii2/web/assets/orderswidget/dist/OrdersWidgetSettings.js similarity index 100% rename from src/web/assets/orderswidget/dist/OrdersWidgetSettings.js rename to src-yii2/web/assets/orderswidget/dist/OrdersWidgetSettings.js diff --git a/src/web/assets/orderswidget/dist/OrdersWidgetSettings.js.map b/src-yii2/web/assets/orderswidget/dist/OrdersWidgetSettings.js.map similarity index 100% rename from src/web/assets/orderswidget/dist/OrdersWidgetSettings.js.map rename to src-yii2/web/assets/orderswidget/dist/OrdersWidgetSettings.js.map diff --git a/src/web/assets/orderswidget/src/OrdersWidgetSettings.js b/src-yii2/web/assets/orderswidget/src/OrdersWidgetSettings.js similarity index 100% rename from src/web/assets/orderswidget/src/OrdersWidgetSettings.js rename to src-yii2/web/assets/orderswidget/src/OrdersWidgetSettings.js diff --git a/src/web/assets/orderswidget/webpack.config.js b/src-yii2/web/assets/orderswidget/webpack.config.js similarity index 100% rename from src/web/assets/orderswidget/webpack.config.js rename to src-yii2/web/assets/orderswidget/webpack.config.js diff --git a/src/web/assets/productindex/ProductIndexAsset.php b/src-yii2/web/assets/productindex/ProductIndexAsset.php similarity index 95% rename from src/web/assets/productindex/ProductIndexAsset.php rename to src-yii2/web/assets/productindex/ProductIndexAsset.php index 55f2ca2afc..1c75327b0b 100644 --- a/src/web/assets/productindex/ProductIndexAsset.php +++ b/src-yii2/web/assets/productindex/ProductIndexAsset.php @@ -18,9 +18,6 @@ */ class ProductIndexAsset extends AssetBundle { - /** - * @inheritdoc - */ public function init(): void { $this->sourcePath = __DIR__ . '/dist'; diff --git a/src/web/assets/productindex/dist/CommerceProductIndex.js b/src-yii2/web/assets/productindex/dist/CommerceProductIndex.js similarity index 100% rename from src/web/assets/productindex/dist/CommerceProductIndex.js rename to src-yii2/web/assets/productindex/dist/CommerceProductIndex.js diff --git a/src/web/assets/productindex/dist/CommerceProductIndex.js.map b/src-yii2/web/assets/productindex/dist/CommerceProductIndex.js.map similarity index 100% rename from src/web/assets/productindex/dist/CommerceProductIndex.js.map rename to src-yii2/web/assets/productindex/dist/CommerceProductIndex.js.map diff --git a/src/web/assets/productindex/src/CommerceProductIndex.js b/src-yii2/web/assets/productindex/src/CommerceProductIndex.js similarity index 100% rename from src/web/assets/productindex/src/CommerceProductIndex.js rename to src-yii2/web/assets/productindex/src/CommerceProductIndex.js diff --git a/src/web/assets/productindex/webpack.config.js b/src-yii2/web/assets/productindex/webpack.config.js similarity index 100% rename from src/web/assets/productindex/webpack.config.js rename to src-yii2/web/assets/productindex/webpack.config.js diff --git a/src/web/assets/purchasablepricefield/PurchasablePriceFieldAsset.php b/src-yii2/web/assets/purchasablepricefield/PurchasablePriceFieldAsset.php similarity index 93% rename from src/web/assets/purchasablepricefield/PurchasablePriceFieldAsset.php rename to src-yii2/web/assets/purchasablepricefield/PurchasablePriceFieldAsset.php index 190e94bee3..595f2519af 100644 --- a/src/web/assets/purchasablepricefield/PurchasablePriceFieldAsset.php +++ b/src-yii2/web/assets/purchasablepricefield/PurchasablePriceFieldAsset.php @@ -19,9 +19,6 @@ */ class PurchasablePriceFieldAsset extends AssetBundle { - /** - * @inheritdoc - */ public function init(): void { $this->sourcePath = __DIR__ . '/dist'; @@ -35,9 +32,6 @@ public function init(): void parent::init(); } - /** - * @inheritdoc - */ public function registerAssetFiles($view): void { parent::registerAssetFiles($view); diff --git a/src/web/assets/purchasablepricefield/dist/purchasablepricefield.js b/src-yii2/web/assets/purchasablepricefield/dist/purchasablepricefield.js similarity index 100% rename from src/web/assets/purchasablepricefield/dist/purchasablepricefield.js rename to src-yii2/web/assets/purchasablepricefield/dist/purchasablepricefield.js diff --git a/src/web/assets/purchasablepricefield/dist/purchasablepricefield.js.map b/src-yii2/web/assets/purchasablepricefield/dist/purchasablepricefield.js.map similarity index 100% rename from src/web/assets/purchasablepricefield/dist/purchasablepricefield.js.map rename to src-yii2/web/assets/purchasablepricefield/dist/purchasablepricefield.js.map diff --git a/src/web/assets/purchasablepricefield/src/js/PurchasablePriceField.js b/src-yii2/web/assets/purchasablepricefield/src/js/PurchasablePriceField.js similarity index 100% rename from src/web/assets/purchasablepricefield/src/js/PurchasablePriceField.js rename to src-yii2/web/assets/purchasablepricefield/src/js/PurchasablePriceField.js diff --git a/src/web/assets/purchasablepricefield/webpack.config.js b/src-yii2/web/assets/purchasablepricefield/webpack.config.js similarity index 100% rename from src/web/assets/purchasablepricefield/webpack.config.js rename to src-yii2/web/assets/purchasablepricefield/webpack.config.js diff --git a/src/web/assets/statwidgets/StatWidgetsAsset.php b/src-yii2/web/assets/statwidgets/StatWidgetsAsset.php similarity index 97% rename from src/web/assets/statwidgets/StatWidgetsAsset.php rename to src-yii2/web/assets/statwidgets/StatWidgetsAsset.php index 3b24888dbc..2add62d98d 100644 --- a/src/web/assets/statwidgets/StatWidgetsAsset.php +++ b/src-yii2/web/assets/statwidgets/StatWidgetsAsset.php @@ -22,9 +22,6 @@ */ class StatWidgetsAsset extends AssetBundle { - /** - * @inheritdoc - */ public function init(): void { $this->sourcePath = __DIR__ . '/dist'; diff --git a/src/web/assets/statwidgets/dist/CommerceChart.js b/src-yii2/web/assets/statwidgets/dist/CommerceChart.js similarity index 100% rename from src/web/assets/statwidgets/dist/CommerceChart.js rename to src-yii2/web/assets/statwidgets/dist/CommerceChart.js diff --git a/src/web/assets/statwidgets/dist/CommerceChart.js.map b/src-yii2/web/assets/statwidgets/dist/CommerceChart.js.map similarity index 100% rename from src/web/assets/statwidgets/dist/CommerceChart.js.map rename to src-yii2/web/assets/statwidgets/dist/CommerceChart.js.map diff --git a/src/web/assets/statwidgets/dist/css/CommerceChart.css b/src-yii2/web/assets/statwidgets/dist/css/CommerceChart.css similarity index 100% rename from src/web/assets/statwidgets/dist/css/CommerceChart.css rename to src-yii2/web/assets/statwidgets/dist/css/CommerceChart.css diff --git a/src/web/assets/statwidgets/dist/css/CommerceChart.css.map b/src-yii2/web/assets/statwidgets/dist/css/CommerceChart.css.map similarity index 100% rename from src/web/assets/statwidgets/dist/css/CommerceChart.css.map rename to src-yii2/web/assets/statwidgets/dist/css/CommerceChart.css.map diff --git a/src/web/assets/statwidgets/src/CommerceChart.js b/src-yii2/web/assets/statwidgets/src/CommerceChart.js similarity index 100% rename from src/web/assets/statwidgets/src/CommerceChart.js rename to src-yii2/web/assets/statwidgets/src/CommerceChart.js diff --git a/src/web/assets/statwidgets/src/scss/stat-widgets.scss b/src-yii2/web/assets/statwidgets/src/scss/stat-widgets.scss similarity index 100% rename from src/web/assets/statwidgets/src/scss/stat-widgets.scss rename to src-yii2/web/assets/statwidgets/src/scss/stat-widgets.scss diff --git a/src/web/assets/statwidgets/webpack.config.js b/src-yii2/web/assets/statwidgets/webpack.config.js similarity index 100% rename from src/web/assets/statwidgets/webpack.config.js rename to src-yii2/web/assets/statwidgets/webpack.config.js diff --git a/src/web/assets/transfers/TransfersAsset.php b/src-yii2/web/assets/transfers/TransfersAsset.php similarity index 93% rename from src/web/assets/transfers/TransfersAsset.php rename to src-yii2/web/assets/transfers/TransfersAsset.php index e83d3efc33..cdd9a0b600 100644 --- a/src/web/assets/transfers/TransfersAsset.php +++ b/src-yii2/web/assets/transfers/TransfersAsset.php @@ -20,9 +20,6 @@ */ class TransfersAsset extends AssetBundle { - /** - * @inheritdoc - */ public function init(): void { $this->sourcePath = __DIR__ . '/dist'; @@ -39,9 +36,6 @@ public function init(): void parent::init(); } - /** - * @inheritdoc - */ public function registerAssetFiles($view): void { parent::registerAssetFiles($view); diff --git a/src/web/assets/transfers/dist/css/transfers.css b/src-yii2/web/assets/transfers/dist/css/transfers.css similarity index 100% rename from src/web/assets/transfers/dist/css/transfers.css rename to src-yii2/web/assets/transfers/dist/css/transfers.css diff --git a/src/web/assets/transfers/dist/transfers.js b/src-yii2/web/assets/transfers/dist/transfers.js similarity index 100% rename from src/web/assets/transfers/dist/transfers.js rename to src-yii2/web/assets/transfers/dist/transfers.js diff --git a/src/web/assets/transfers/dist/transfers.js.map b/src-yii2/web/assets/transfers/dist/transfers.js.map similarity index 100% rename from src/web/assets/transfers/dist/transfers.js.map rename to src-yii2/web/assets/transfers/dist/transfers.js.map diff --git a/src/web/assets/transfers/src/css/transfers.scss b/src-yii2/web/assets/transfers/src/css/transfers.scss similarity index 100% rename from src/web/assets/transfers/src/css/transfers.scss rename to src-yii2/web/assets/transfers/src/css/transfers.scss diff --git a/src/web/assets/transfers/src/js/ReceiveTransferScreen.js b/src-yii2/web/assets/transfers/src/js/ReceiveTransferScreen.js similarity index 100% rename from src/web/assets/transfers/src/js/ReceiveTransferScreen.js rename to src-yii2/web/assets/transfers/src/js/ReceiveTransferScreen.js diff --git a/src/web/assets/transfers/src/js/TransferEdit.js b/src-yii2/web/assets/transfers/src/js/TransferEdit.js similarity index 100% rename from src/web/assets/transfers/src/js/TransferEdit.js rename to src-yii2/web/assets/transfers/src/js/TransferEdit.js diff --git a/src/web/assets/transfers/src/transfers.js b/src-yii2/web/assets/transfers/src/transfers.js similarity index 100% rename from src/web/assets/transfers/src/transfers.js rename to src-yii2/web/assets/transfers/src/transfers.js diff --git a/src/web/assets/transfers/webpack.config.js b/src-yii2/web/assets/transfers/webpack.config.js similarity index 100% rename from src/web/assets/transfers/webpack.config.js rename to src-yii2/web/assets/transfers/webpack.config.js diff --git a/src/web/twig/CraftVariableBehavior.php b/src-yii2/web/twig/CraftVariableBehavior.php similarity index 81% rename from src/web/twig/CraftVariableBehavior.php rename to src-yii2/web/twig/CraftVariableBehavior.php index 2101f66b20..2fcab4e1a7 100644 --- a/src/web/twig/CraftVariableBehavior.php +++ b/src-yii2/web/twig/CraftVariableBehavior.php @@ -10,11 +10,9 @@ use Craft; use craft\commerce\elements\db\OrderQuery; use craft\commerce\elements\db\ProductQuery; -use craft\commerce\elements\db\SubscriptionQuery; use craft\commerce\elements\db\VariantQuery; use craft\commerce\elements\Order; use craft\commerce\elements\Product; -use craft\commerce\elements\Subscription; use craft\commerce\elements\Variant; use craft\commerce\Plugin; use yii\base\Behavior; @@ -53,19 +51,6 @@ public function orders(array $criteria = []): OrderQuery return $query; } - /** - * Returns a new SubscriptionQuery instance. - * - * @param array $criteria - * @return SubscriptionQuery - */ - public function subscriptions(array $criteria = []): SubscriptionQuery - { - $query = Subscription::find(); - Craft::configure($query, $criteria); - return $query; - } - /** * Returns a new ProductQuery instance. * diff --git a/src/web/twig/Extension.php b/src-yii2/web/twig/Extension.php similarity index 83% rename from src/web/twig/Extension.php rename to src-yii2/web/twig/Extension.php index 2a48532769..654ad34e16 100644 --- a/src/web/twig/Extension.php +++ b/src-yii2/web/twig/Extension.php @@ -10,7 +10,7 @@ use Craft; use craft\commerce\behaviors\StoreBehavior; use craft\commerce\helpers\Currency; -use craft\commerce\helpers\PaymentForm; +use CraftCms\Commerce\Helpers\PaymentForm; use craft\errors\SiteNotFoundException; use craft\models\Site; use Twig\Extension\AbstractExtension; @@ -31,14 +31,11 @@ public function getName(): string return 'Craft Commerce Twig Extension'; } - /** - * @inheritdoc - */ public function getFilters(): array { return [ - new TwigFilter('commerceCurrency', [Currency::class, 'formatAsCurrency']), - new TwigFilter('commercePaymentFormNamespace', [PaymentForm::class, 'getPaymentFormNamespace']), + new TwigFilter('commerceCurrency', Currency::formatAsCurrency(...)), + new TwigFilter('commercePaymentFormNamespace', PaymentForm::getPaymentFormNamespace(...)), ]; } diff --git a/src-yii2/widgets/AverageOrderTotal.php b/src-yii2/widgets/AverageOrderTotal.php new file mode 100644 index 0000000000..fd692dabf6 --- /dev/null +++ b/src-yii2/widgets/AverageOrderTotal.php @@ -0,0 +1,11 @@ + parent::getBuilderHtml()) ?? ''; + } + + return parent::getBuilderHtml(); + } +} diff --git a/src/Address/Conditions/PostalCodeFormulaConditionRule.php b/src/Address/Conditions/PostalCodeFormulaConditionRule.php new file mode 100644 index 0000000000..8b1c918236 --- /dev/null +++ b/src/Address/Conditions/PostalCodeFormulaConditionRule.php @@ -0,0 +1,75 @@ +value; + $postalCode = $element->postalCode; + + try { + return (bool)app(Formulas::class)->evaluateCondition($formula, ['postalCode' => $postalCode], 'Postal code formula matching address'); + } catch (Throwable) { + Log::error('Error evaluating postal code formula: ' . $formula); + + return false; + } + } + + #[\Override] + protected function operators(): array + { + return [ + self::OPERATOR_EQ, + ]; + } + + #[\Override] + protected function inputHtml(): string + { + return Html::hiddenLabel($this->getLabel(), 'value') . + Cp::textareaHtml([ + 'type' => $this->inputType(), + 'id' => 'value', + 'name' => 'value', + 'code' => 'value', + 'value' => $this->value, + 'autocomplete' => false, + 'class' => 'fullwidth code', + ]); + } +} diff --git a/src/Address/Conditions/ZoneAddressCondition.php b/src/Address/Conditions/ZoneAddressCondition.php new file mode 100644 index 0000000000..cae4e250f8 --- /dev/null +++ b/src/Address/Conditions/ZoneAddressCondition.php @@ -0,0 +1,31 @@ +_condition ?? new ZoneAddressCondition(Address::class); + } + + public function setCondition(ZoneAddressCondition|string|array|null $condition): void + { + if ($condition === null) { + $condition = new ZoneAddressCondition(Address::class); + } + + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof ZoneAddressCondition) { + $condition['class'] = ZoneAddressCondition::class; + $condition['elementType'] = Address::class; + + /** @var ZoneAddressCondition $condition */ + $condition = Conditions::createCondition($condition); + } + + $condition->forProjectConfig = false; + + $this->_condition = $condition; + } + + #[\Override] + public function getRules(): array + { + return [ + 'name' => ['required', 'string'], + 'condition' => ['required'], + 'storeId' => ['required', 'integer'], + ]; + } + + #[\Override] + public function validationData(): array + { + return array_merge(parent::validationData(), [ + 'condition' => $this->_condition, + ]); + } +} diff --git a/src/Base/ZoneInterface.php b/src/Base/ZoneInterface.php new file mode 100644 index 0000000000..919d1d836f --- /dev/null +++ b/src/Base/ZoneInterface.php @@ -0,0 +1,16 @@ +one(); + if (!$variant) { + $this->setMessage(t('Unable to find variant.', category: 'commerce')); + return false; + } + + $product = $variant->getOwner(); + if (!$product) { + $this->setMessage(t('Variant has no product.', category: 'commerce')); + return false; + } + + DB::table(Table::PRODUCTS) + ->where('id', $product->id) + ->update([ + 'defaultVariantId' => $variant->id, + 'defaultSku' => $variant->sku, + 'defaultPrice' => $variant->getBasePrice(), + 'defaultHeight' => $variant->height, + 'defaultLength' => $variant->length, + 'defaultWidth' => $variant->width, + 'defaultWeight' => $variant->weight, + ]); + + if ($product->getIsCanonical()) { + // Remove previous default + DB::table(Table::VARIANTS) + ->where('primaryOwnerId', $product->id) + ->update(['isDefault' => false]); + + // Add new default + DB::table(Table::VARIANTS) + ->where('id', $variant->id) + ->update(['isDefault' => true]); + } + + ElementCaches::invalidateForElement($product); + ElementCaches::invalidateForElement($variant); + + $this->setMessage(t('Default variant updated.', category: 'commerce')); + return true; + } +} diff --git a/src/Catalog/Conditions/CatalogPricingRuleProductCondition.php b/src/Catalog/Conditions/CatalogPricingRuleProductCondition.php new file mode 100644 index 0000000000..270006e41c --- /dev/null +++ b/src/Catalog/Conditions/CatalogPricingRuleProductCondition.php @@ -0,0 +1,9 @@ +getAllProductTypes()) + ->map(fn(ProductType $productType) => ['value' => $productType->uid, 'label' => $productType->name]) + ->all(); + } + + public function getExclusiveQueryParams(): array + { + return ['type']; + } + + public function modifyQuery(ElementQueryInterface $query): void + { + $productTypes = app(ProductTypes::class)->getAllProductTypes(); + + $value = $this->paramValue(fn(string $value) => collect($productTypes)->firstWhere('uid', $value)?->handle); + + /** @var ProductQuery $query */ + $query->type($value); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Product $element */ + return $this->matchValue($element->getType()->uid); + } +} diff --git a/src/Catalog/Conditions/ProductVariantInventoryTrackedConditionRule.php b/src/Catalog/Conditions/ProductVariantInventoryTrackedConditionRule.php new file mode 100644 index 0000000000..cb6286ec50 --- /dev/null +++ b/src/Catalog/Conditions/ProductVariantInventoryTrackedConditionRule.php @@ -0,0 +1,51 @@ +select(['commerce_variants.primaryOwnerId as id']); + $variantQuery->inventoryTracked($this->value); + + $query->whereIn('elements.id', $variantQuery->getQuery()); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Product $product */ + $product = $element; + + foreach ($product->getVariants() as $variant) { + if ($this->matchValue($variant->inventoryTracked)) { + // Skip out early if we have a match + return true; + } + } + + return false; + } +} diff --git a/src/Catalog/Conditions/ProductVariantPriceConditionRule.php b/src/Catalog/Conditions/ProductVariantPriceConditionRule.php new file mode 100644 index 0000000000..1920555e47 --- /dev/null +++ b/src/Catalog/Conditions/ProductVariantPriceConditionRule.php @@ -0,0 +1,51 @@ +select(['commerce_variants.primaryOwnerId as id']); + $variantQuery->price($this->paramValue()); + + $query->whereIn('elements.id', $variantQuery->getQuery()); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Product $product */ + $product = $element; + + foreach ($product->getVariants() as $variant) { + if ($this->matchValue($variant->getPrice())) { + // Skip out early if we have a match + return true; + } + } + + return false; + } +} diff --git a/src/Catalog/Conditions/ProductVariantSearchConditionRule.php b/src/Catalog/Conditions/ProductVariantSearchConditionRule.php new file mode 100644 index 0000000000..5609b9db69 --- /dev/null +++ b/src/Catalog/Conditions/ProductVariantSearchConditionRule.php @@ -0,0 +1,71 @@ +value); + } + + public function modifyQuery(ElementQueryInterface $query): void + { + $variantQuery = Variant::find(); + $variantQuery->select(['commerce_variants.primaryOwnerId as id']); + $variantQuery->search($this->searchValue()); + + $query->whereIn('elements.id', $variantQuery->getQuery()); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Product $element */ + $variantIds = $element->getVariants()->pluck('id')->all(); + if (empty($variantIds)) { + return false; + } + + // Perform a variant query search to ensure it is the same process as modifyQuery() + $variantQuery = Variant::find(); + $variantQuery->search($this->searchValue()); + $variantQuery->id($variantIds); + + return $variantQuery->count() > 0; + } +} diff --git a/src/Catalog/Conditions/ProductVariantSkuConditionRule.php b/src/Catalog/Conditions/ProductVariantSkuConditionRule.php new file mode 100644 index 0000000000..3d7bd2a7e7 --- /dev/null +++ b/src/Catalog/Conditions/ProductVariantSkuConditionRule.php @@ -0,0 +1,51 @@ +select(['commerce_variants.primaryOwnerId as id']) + ->sku($this->paramValue()); + + $query->whereIn('elements.id', $variantQuery->getQuery()); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Product $product */ + $product = $element; + + foreach ($product->getVariants() as $variant) { + if ($this->matchValue($variant->sku)) { + // Skip out early if we have a match + return true; + } + } + + return false; + } +} diff --git a/src/Catalog/Conditions/ProductVariantStockConditionRule.php b/src/Catalog/Conditions/ProductVariantStockConditionRule.php new file mode 100644 index 0000000000..8400e0621e --- /dev/null +++ b/src/Catalog/Conditions/ProductVariantStockConditionRule.php @@ -0,0 +1,56 @@ +select(['commerce_variants.primaryOwnerId as id']); + $variantQuery->inventoryTracked(true); + $variantQuery->stock($this->paramValue()); + + $query->whereIn('elements.id', $variantQuery->getQuery()); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Product $product */ + $product = $element; + + foreach ($product->getVariants() as $variant) { + if (!$variant::hasInventory()) { + return true; + } + + if ($variant->inventoryTracked === true && $this->matchValue($variant->getStock())) { + // Skip out early if we have a match + return true; + } + } + + return false; + } +} diff --git a/src/Catalog/Conditions/VariantCondition.php b/src/Catalog/Conditions/VariantCondition.php new file mode 100644 index 0000000000..33926c49bf --- /dev/null +++ b/src/Catalog/Conditions/VariantCondition.php @@ -0,0 +1,25 @@ +id($this->getElementIds()); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Variant $element */ + return $this->matchValue($element->id); + } + + #[Override] + protected function allowMultiple(): bool + { + return true; + } + + #[Override] + protected function elementSelectConfig(): array + { + return array_merge(parent::elementSelectConfig(), [ + 'showSiteMenu' => true, + ]); + } +} diff --git a/src/Catalog/Conditions/VariantProductConditionRule.php b/src/Catalog/Conditions/VariantProductConditionRule.php new file mode 100644 index 0000000000..233b9600f4 --- /dev/null +++ b/src/Catalog/Conditions/VariantProductConditionRule.php @@ -0,0 +1,60 @@ +ownerId($this->getElementIds()); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Variant $element */ + return $this->matchValue($element->getOwnerId()); + } + + #[Override] + protected function allowMultiple(): bool + { + return true; + } + + #[Override] + protected function elementSelectConfig(): array + { + return array_merge(parent::elementSelectConfig(), [ + 'showSiteMenu' => true, + ]); + } +} diff --git a/src/Catalog/Elements/Product.php b/src/Catalog/Elements/Product.php new file mode 100644 index 0000000000..515bc99a59 --- /dev/null +++ b/src/Catalog/Elements/Product.php @@ -0,0 +1,2035 @@ + t('Live', category: 'commerce'), + self::STATUS_PENDING => t('Pending', category: 'commerce'), + self::STATUS_EXPIRED => t('Expired', category: 'commerce'), + self::STATUS_DISABLED => t('Disabled', category: 'commerce'), + ]; + } + + /** + * @return ProductQuery The newly created ProductQuery instance. + */ + #[Override] + public static function find(): ProductQuery + { + return new ProductQuery(); + } + + /** + * @return ProductCondition + */ + #[Override] + public static function createCondition(): ElementConditionInterface + { + return new ProductCondition(static::class); + } + + #[Override] + protected static function defineSources(string $context): array + { + // TODO: migrate to app(ProductTypes::class) once service migrated to src/ + $productTypesService = app(ProductTypes::class); + + if ($context == 'index') { + $productTypes = $productTypesService->getViewableProductTypes(); + $editable = true; + } else { + $productTypes = $productTypesService->getAllProductTypes(); + $editable = null; + } + + $productTypeIds = []; + + foreach ($productTypes as $productType) { + $productTypeIds[] = $productType->id; + } + + $sources = [ + [ + 'key' => '*', + 'label' => t('All products', category: 'commerce'), + 'criteria' => [ + 'typeId' => $productTypeIds, + 'editable' => $editable, + ], + 'defaultSort' => ['postDate', 'desc'], + ], + ]; + + $sources[] = ['heading' => t('Product Types', category: 'commerce')]; + + $user = currentUser(); + + foreach ($productTypes as $productType) { + $key = 'productType:' . $productType->uid; + $canSaveProducts = $user && $user->can('commerce-saveProductType:' . $productType->uid); + + $sources[$key] = [ + 'key' => $key, + 'label' => t($productType->name, category: 'site'), + 'data' => [ + 'handle' => $productType->handle, + 'editable' => $canSaveProducts, + ], + 'criteria' => [ + 'typeId' => $productType->id, + 'editable' => $editable, + ], + // Get site ids enabled for this product type + 'sites' => $productType->getSiteIds(), + ]; + + if ($productType->isStructure) { + $sources[$key]['defaultSort'] = ['structure', 'asc']; + $sources[$key]['structureId'] = $productType->structureId; + $sources[$key]['structureEditable'] = $canSaveProducts; + } else { + $sources[$key]['defaultSort'] = ['postDate', 'desc']; + } + } + + return $sources; + } + + #[Override] + public static function modifyCustomSource(array $config): array + { + try { + /** @var ProductCondition $condition */ + $condition = Conditions::createCondition($config['condition']); + } catch (\RuntimeException) { + return $config; + } + + $rules = $condition->getConditionRules(); + + // see if it's limited to one product type + /** @var ProductTypeConditionRule|null $productTypeRule */ + $productTypeRule = collect($rules)->first(fn($rule) => $rule instanceof ProductTypeConditionRule); + $productTypeOptions = $productTypeRule?->getValues(); + + if ($productTypeOptions && count($productTypeOptions) === 1) { + // TODO: migrate to app(ProductTypes::class)->getProductTypeByUid() once service migrated to src/ + $productType = app(ProductTypes::class)->getProductTypeByUid(reset($productTypeOptions)); + if ($productType) { + $config['data']['handle'] = $productType->handle; + } + } + + return $config; + } + + #[Override] + protected static function defineFieldLayouts(?string $source): array + { + // TODO: migrate to app(ProductTypes::class) once service migrated to src/ + $productTypesService = app(ProductTypes::class); + + if ($source === null || $source === '*') { + $productTypes = $productTypesService->getAllProductTypes(); + } else { + $productTypes = []; + if (preg_match('/^productType:(.+)$/', $source, $matches)) { + $productType = $productTypesService->getProductTypeByUid($matches[1]); + if ($productType) { + $productTypes[] = $productType; + } + } + } + + return array_map(fn(ProductType $productType) => $productType->getFieldLayout(), $productTypes); + } + + #[Override] + protected static function defineActions(string $source): array + { + // Get the selected site + $elementQuery = app(CurrentElementIndex::class)->isActive() + ? app(CurrentElementIndex::class)->query() + : null; + $site = $elementQuery && $elementQuery->siteId + ? Sites::getSiteById($elementQuery->siteId) + : Sites::getCurrentSite(); + + // TODO: migrate to app(ProductTypes::class) once service migrated to src/ + $productTypesService = app(ProductTypes::class); + + // Get the product type(s) we need to check permissions on + $productTypes = []; + + if ($source === '*') { + $productTypes = $productTypesService->getViewableProductTypes(); + } elseif (preg_match('/^productType:(\d+)$/', $source, $matches)) { + $productType = $productTypesService->getProductTypeById((int)$matches[1]); + + if ($productType) { + $productTypes = [$productType]; + } + } elseif (preg_match('/^productType:(.+)$/', $source, $matches)) { + $productType = $productTypesService->getProductTypeByUid($matches[1]); + + if ($productType) { + $productTypes = [$productType]; + } + } + + $actions = []; + + // Copy Reference Tag + $actions[] = ElementActions::createAction([ + 'type' => CopyReferenceTag::class, + ], static::class); + + // Restore + $actions[] = ElementActions::createAction([ + 'type' => Restore::class, + 'successMessage' => t('Products restored.', category: 'commerce'), + 'partialSuccessMessage' => t('Some products restored.', category: 'commerce'), + 'failMessage' => t('Products not restored.', category: 'commerce'), + ], static::class); + + if ($source === '*') { + // Delete + $actions[] = Delete::class; + } elseif (!empty($productTypes)) { + $currentUser = currentUser(); + + foreach ($productTypes as $productType) { + $canDelete = $currentUser?->can('commerce-deleteProductType:' . $productType->uid); + $canCreate = $currentUser?->can('commerce-createProductType:' . $productType->uid); + $canSave = $currentUser?->can('commerce-saveProductType:' . $productType->uid); + + if ($canCreate && $canSave) { + // Duplicate + $actions[] = [ + 'type' => Duplicate::class, + 'asDrafts' => true, + ]; + } + + if ($canDelete) { + // Allow deletion + $actions[] = ElementActions::createAction([ + 'type' => Delete::class, + 'confirmationMessage' => t('Are you sure you want to delete the selected product and its variants?', category: 'commerce'), + 'successMessage' => t('Products and Variants deleted.', category: 'commerce'), + ], static::class); + } + + if ($canSave) { + $actions[] = SetStatus::class; + } + + if ($productType->isStructure && $canCreate) { + if ($productType->maxLevels != 1) { + $actions[] = [ + 'type' => Duplicate::class, + 'asDrafts' => true, + 'deep' => true, + ]; + } + + $newProductUrl = 'commerce/products/' . $productType->handle . '/new'; + + if (Sites::isMultiSite()) { + $newProductUrl .= '?site=' . $site->handle; + } + + $actions[] = ElementActions::createAction([ + 'type' => NewSiblingBefore::class, + 'newSiblingUrl' => $newProductUrl, + ], static::class); + + $actions[] = ElementActions::createAction([ + 'type' => NewSiblingAfter::class, + 'newSiblingUrl' => $newProductUrl, + ], static::class); + + if ($productType->maxLevels != 1) { + $actions[] = ElementActions::createAction([ + 'type' => NewChild::class, + 'maxLevels' => $productType->maxLevels, + 'newChildUrl' => $newProductUrl, + ], static::class); + } + } + } + + if ($currentUser?->can('commerce-managePromotions')) { + // TODO: migrate to app(Sales::class)->canUseSales() once the Sales element actions are migrated to src/ + if (app(Sales::class)->canUseSales()) { + $actions[] = CreateSale::class; + } + + $actions[] = CreateDiscount::class; + } + } + + return $actions; + } + + #[Override] + protected function safeActionMenuItems(): array + { + $actions = parent::safeActionMenuItems(); + + if ( + app(ElementRequest::class)->element === $this && + currentUser()?->isAdmin() && + Cms::config()->allowAdminChanges + ) { + // Product type settings + $productTypeEditId = sprintf('edit-product-type-%s', mt_rand()); + $actions[] = [ + 'id' => $productTypeEditId, + 'icon' => 'gear', + 'label' => t('Product type settings', category: 'commerce'), + ]; + + HtmlStack::jsWithVars(fn($id, $params) => << { + $('#' + $id).on('activate', function() { + const params = $params; + new Craft.CpScreenSlideout('commerce/product-types/edit-product-type', {params}); + }); +})(); +JS, [ + InputNamespace::namespaceId($productTypeEditId), + ['productTypeId' => $this->typeId], + ]); + } + + return $actions; + } + + #[Override] + protected static function includeSetStatusAction(): bool + { + return true; + } + + #[Override] + protected static function defineSortOptions(): array + { + return [ + 'title' => t('Title', category: 'commerce'), + [ + 'label' => t('Post Date', category: 'commerce'), + 'orderBy' => 'postDate', + 'defaultDir' => 'desc', + ], + [ + 'label' => t('Expiry Date', category: 'commerce'), + 'orderBy' => 'expiryDate', + 'defaultDir' => 'desc', + ], + 'promotable' => t('Promotable?', category: 'commerce'), + 'defaultPrice' => t('Price', category: 'commerce'), + 'defaultSku' => t('SKU', category: 'commerce'), + [ + 'label' => t('Date Created'), + 'orderBy' => 'elements.dateCreated', + 'attribute' => 'dateCreated', + 'defaultDir' => 'desc', + ], + [ + 'label' => t('Date Updated'), + 'orderBy' => 'elements.dateUpdated', + 'attribute' => 'dateUpdated', + 'defaultDir' => 'desc', + ], + [ + 'label' => t('ID'), + 'orderBy' => 'elements.id', + 'attribute' => 'id', + ], + ]; + } + + #[Override] + protected static function defineTableAttributes(): array + { + return [ + 'title' => ['label' => t('Product', category: 'commerce')], + 'status' => ['label' => t('Status', category: 'commerce')], + 'id' => ['label' => t('ID', category: 'commerce')], + 'type' => ['label' => t('Type', category: 'commerce')], + 'slug' => ['label' => t('Slug', category: 'commerce')], + 'uri' => ['label' => t('URI', category: 'commerce')], + 'postDate' => ['label' => t('Post Date', category: 'commerce')], + 'expiryDate' => ['label' => t('Expiry Date', category: 'commerce')], + 'stock' => ['label' => t('Stock', category: 'commerce')], + 'link' => ['label' => t('Link', category: 'commerce'), 'icon' => 'world'], + 'dateCreated' => ['label' => t('Date Created', category: 'commerce')], + 'dateUpdated' => ['label' => t('Date Updated', category: 'commerce')], + 'defaultPrice' => ['label' => t('Price', category: 'commerce')], + 'defaultPromotionalPrice' => ['label' => t('Promotional Price', category: 'commerce')], + 'defaultSku' => ['label' => t('SKU', category: 'commerce')], + 'defaultWeight' => ['label' => t('Weight', category: 'commerce')], + 'defaultLength' => ['label' => t('Length', category: 'commerce')], + 'defaultWidth' => ['label' => t('Width', category: 'commerce')], + 'defaultHeight' => ['label' => t('Height', category: 'commerce')], + 'variants' => ['label' => t('Variants', category: 'commerce')], + ]; + } + + #[Override] + protected static function defineDefaultTableAttributes(string $source): array + { + $attributes = []; + + if ($source == '*') { + $attributes[] = 'type'; + } + + $attributes[] = 'status'; + $attributes[] = 'postDate'; + $attributes[] = 'expiryDate'; + $attributes[] = 'defaultPrice'; + $attributes[] = 'defaultSku'; + $attributes[] = 'link'; + + return $attributes; + } + + #[Override] + public static function attributePreviewHtml(array $attribute): mixed + { + return match ($attribute['value']) { + 'defaultSku' => $attribute['placeholder'], + default => parent::attributePreviewHtml($attribute) + }; + } + + #[Override] + protected static function defineCardAttributes(): array + { + return array_merge(parent::defineCardAttributes(), [ + 'defaultPrice' => [ + 'label' => t('Price', category: 'commerce'), + 'placeholder' => '¤' . I18N::getFormatter()->asDecimal(123.99), + ], + 'defaultPromotionalPrice' => [ + 'label' => t('Promotional Price', category: 'commerce'), + 'placeholder' => '¤' . I18N::getFormatter()->asDecimal(123.99), + ], + 'defaultSku' => [ + 'label' => t('SKU', category: 'commerce'), + 'placeholder' => Html::tag('code', 'SKU123'), + ], + ]); + } + + #[Override] + protected static function defineDefaultCardAttributes(): array + { + return array_merge(parent::defineDefaultCardAttributes(), [ + 'defaultSku', + 'defaultPrice', + ]); + } + + #[Override] + public static function eagerLoadingMap(array $sourceElements, string $handle): array|null|false + { + if ($handle == 'variants') { + $sourceElementIds = array_filter(array_map(fn(ElementInterface $element) => $element->id, $sourceElements)); + + $map = DB::table(CraftTable::ELEMENTS_OWNERS) + ->select(['ownerId as source', 'elementId as target']) + ->whereIn('ownerId', $sourceElementIds) + ->orderBy('sortOrder') + ->get() + ->map(fn(object $row) => (array)$row) + ->all(); + + return [ + 'elementType' => Variant::class, + 'map' => $map, + ]; + } + + return parent::eagerLoadingMap($sourceElements, $handle); + } + + public static function gqlTypeNameByContext(mixed $context): string + { + /** @var ProductType $context */ + return $context->handle . '_Product'; + } + + #[Override] + public static function gqlScopesByContext(mixed $context): array + { + /** @var ProductType $context */ + return ['productTypes.' . $context->uid]; + } + + #[Override] + protected static function prepElementQueryForTableAttribute(ElementQueryInterface $elementQuery, string $attribute): void + { + // Only eager load variants for attributes that actually need them. + // Other variant-related attributes (defaultPrice, defaultSku, etc.) are already + // fetched via SQL JOINs in ProductQuery + if (in_array($attribute, ['variants', 'stock'], true)) { + $elementQuery->andWith('variants'); + } else { + parent::prepElementQueryForTableAttribute($elementQuery, $attribute); + } + } + + /** + * The attributes on the product that should be made available as formatted currency. + */ + public function currencyAttributes(): array + { + return ['defaultPrice', 'defaultBasePrice', 'defaultBasePromotionalPrice']; + } + + public function getDefaultPriceAsCurrency(): string + { + return $this->currencyAttributeAsCurrency('defaultPrice'); + } + + public function getDefaultBasePriceAsCurrency(): string + { + return $this->currencyAttributeAsCurrency('defaultBasePrice'); + } + + public function getDefaultBasePromotionalPriceAsCurrency(): string + { + return $this->currencyAttributeAsCurrency('defaultBasePromotionalPrice'); + } + + /** + * The legacy element used the `CurrencyAttributeBehavior` Yii behavior to generate + * `AsCurrency` magic getters; the new base has no behaviors, so those getters are + * declared explicitly (same approach as `Order` and `Purchasable`). + */ + private function currencyAttributeAsCurrency(string $attribute): string + { + $amount = $this->$attribute ?? 0; + return \CraftCms\Commerce\Helpers\Currency::formatAsCurrency($amount, $this->getStore()->getCurrency()); + } + + #[Override] + public function fields(): array + { + $fields = parent::fields(); + + foreach ($this->currencyAttributes() as $attribute) { + $fields[$attribute . 'AsCurrency'] = $attribute . 'AsCurrency'; + } + + return $fields; + } + + public function setDefaultPrice(?float $defaultPrice): void + { + $this->_defaultPrice = $defaultPrice; + } + + public function getDefaultPrice(): ?float + { + return $this->_defaultPrice ?? $this->getDefaultVariant()?->price; + } + + #[Override] + public function canCreateDrafts(\CraftCms\Cms\User\Elements\User $user): bool + { + // Everyone with view permissions can create drafts + return true; + } + + #[Override] + public function hasRevisions(): bool + { + return $this->getType()->enableVersioning; + } + + #[Override] + public function getPostEditUrl(): ?string + { + return Url::cpUrl('commerce/products'); + } + + #[Override] + protected function cpRevisionsUrl(): ?string + { + return sprintf('%s/revisions', $this->cpEditUrl()); + } + + #[Override] + public function getIsTitleTranslatable(): bool + { + return $this->getType()->productTitleTranslationMethod !== TranslationMethod::None->value; + } + + #[Override] + public function getTitleTranslationDescription(): ?string + { + /** @phpstan-ignore-next-line nullsafe.neverNull (productTitleTranslationMethod is an uncast, free-form DB string column - tryFrom() genuinely can return null) */ + return TranslationMethod::tryFrom($this->getType()->productTitleTranslationMethod)?->description(); + } + + #[Override] + public function getTitleTranslationKey(): string + { + $type = $this->getType(); + + // productTitleTranslationMethod is an uncast, free-form DB string column - tryFrom() genuinely can return null + /** @phpstan-ignore-next-line nullCoalesce.expr */ + return TranslationMethod::tryFrom($type->productTitleTranslationMethod) + ?->elementKey($this, $type->productTitleTranslationKeyFormat) /** @phpstan-ignore-line */ + ?? (string)$this->siteId; + } + + #[Override] + public function getIsSlugTranslatable(): bool + { + return $this->getType()->slugTranslationMethod !== TranslationMethod::None->value; + } + + #[Override] + public function getSlugTranslationDescription(): ?string + { + /** @phpstan-ignore-next-line nullsafe.neverNull (slugTranslationMethod is an uncast, free-form DB string column - tryFrom() genuinely can return null) */ + return TranslationMethod::tryFrom($this->getType()->slugTranslationMethod)?->description(); + } + + #[Override] + public function getSlugTranslationKey(): string + { + $type = $this->getType(); + + // slugTranslationMethod is an uncast, free-form DB string column - tryFrom() genuinely can return null + /** @phpstan-ignore-next-line nullCoalesce.expr */ + return TranslationMethod::tryFrom($type->slugTranslationMethod) + ?->elementKey($this, $type->slugTranslationKeyFormat) /** @phpstan-ignore-line */ + ?? (string)$this->siteId; + } + + #[Override] + public function __toString(): string + { + return (string)$this->title; + } + + #[Override] + public function canView(\CraftCms\Cms\User\Elements\User $user): bool + { + if (parent::canView($user)) { + return true; + } + + try { + $productType = $this->getType(); + } catch (\Exception) { + return false; + } + + return $user->can('commerce-viewProductType:' . $productType->uid); + } + + #[Override] + public function canSave(\CraftCms\Cms\User\Elements\User $user): bool + { + if (parent::canSave($user)) { + return true; + } + + try { + $productType = $this->getType(); + } catch (\Exception) { + return false; + } + + if ($this->getIsDraft()) { + return $this->canCreateDrafts($user); + } + + // New products require create permission + if (!$this->id) { + return $user->can('commerce-createProductType:' . $productType->uid); + } + + return $user->can('commerce-saveProductType:' . $productType->uid); + } + + #[Override] + public function canDuplicate(\CraftCms\Cms\User\Elements\User $user): bool + { + if (parent::canDuplicate($user)) { + return true; + } + + try { + $productType = $this->getType(); + } catch (\Exception) { + return false; + } + + return $user->can('commerce-createProductType:' . $productType->uid) + && $user->can('commerce-saveProductType:' . $productType->uid); + } + + #[Override] + public function canDelete(\CraftCms\Cms\User\Elements\User $user): bool + { + if (parent::canDelete($user)) { + return true; + } + + try { + $productType = $this->getType(); + } catch (\Exception) { + return false; + } + + return $user->can('commerce-deleteProductType:' . $productType->uid); + } + + /** + * Products can be deleted for a single site by anyone who can delete the product. The legacy + * element deferred to `Elements::canDelete()`; the new Elements service has no such method + * (authorization is policy-based now), so the element's own check is used directly. + */ + #[Override] + public function canDeleteForSite(\CraftCms\Cms\User\Elements\User $user): bool + { + return $this->canDelete($user); + } + + #[Override] + public function createAnother(): ?ElementInterface + { + return null; + } + + #[Override] + protected function crumbs(): array + { + $productType = $this->getType(); + + // TODO: migrate to app(ProductTypes::class)->getViewableProductTypes() once service migrated to src/ + $productTypes = Collection::make(app(ProductTypes::class)->getViewableProductTypes()); + + $productTypeOptions = $productTypes + ->map(fn(ProductType $t) => [ + 'label' => t($t->name, category: 'site'), + 'url' => "commerce/products/$t->handle", + 'selected' => $t->id === $productType->id, + ]); + + return [ + [ + 'label' => t('Products', category: 'commerce'), + 'url' => 'commerce/products', + ], + [ + 'menu' => [ + 'label' => t('Select product type', category: 'commerce'), + 'items' => $productTypeOptions->all(), + ], + ], + ]; + } + + #[Override] + protected function uiLabel(): ?string + { + // This method is called in a few places before the product type is set + // If there isn't a type then fall back to the title + if ($this->typeId) { + $uiLabelFormat = $this->getType()->productUiLabelFormat; + if ($uiLabelFormat !== '{title}') { + $uiLabel = renderSandboxedObjectTemplate($uiLabelFormat, $this); + if ($uiLabel !== '') { + return $uiLabel; + } + } + } + + if (!isset($this->title) || trim($this->title) === '') { + return t('Untitled {type}', [ + 'type' => self::lowerDisplayName(), + ]); + } + + return null; + } + + /** + * Returns the product's product type. + * + * @throws \RuntimeException + */ + public function getType(): ProductType + { + if ($this->typeId === null) { + throw new \RuntimeException('Product is missing its product type ID'); + } + + // TODO: migrate to app(ProductTypes::class)->getProductTypeById() once service migrated to src/ + $productType = app(ProductTypes::class)->getProductTypeById($this->typeId); + + if ($productType === null) { + throw new \RuntimeException('Invalid product type ID: ' . $this->typeId); + } + + return $productType; + } + + public function getName(): ?string + { + return $this->title; + } + + #[Override] + protected function cacheTags(): array + { + return [ + "productType:$this->typeId", + ]; + } + + #[Override] + public function getUriFormat(): ?string + { + $productTypeSiteSettings = $this->getType()->getSiteSettings(); + + if (!isset($productTypeSiteSettings[$this->siteId])) { + throw new \RuntimeException('The "' . $this->getType()->name . '" product type is not enabled for the "' . $this->getSite()->name . '" site.'); + } + + return $productTypeSiteSettings[$this->siteId]->uriFormat; + } + + #[Override] + protected function cpEditUrl(): ?string + { + $productType = $this->getType(); + + $path = sprintf('commerce/products/%s/%s', $productType->handle, $this->getCanonicalId()); + + // Ignore homepage/temp slugs + if ($this->slug && !str_starts_with($this->slug, '__')) { + $path .= sprintf('-%s', str_replace('/', '-', $this->slug)); + } + + return $path; + } + + /** + * Returns the default variant. + * + * @throws \RuntimeException + */ + public function getDefaultVariant(bool $includeDisabled = false): ?Variant + { + $defaultVariant = $this->getVariants($includeDisabled)->firstWhere('id', $this->defaultVariantId); + + return $defaultVariant ?: $this->getVariants($includeDisabled)->first(); + } + + /** + * Return the cheapest variant. + * + * @throws \RuntimeException + */ + public function getCheapestVariant(bool $includeDisabled = false): ?Variant + { + return $this->getVariants($includeDisabled)->cheapest(); + } + + /** + * Returns a collection of the product's variants. + * + * @throws \RuntimeException + */ + public function getVariants(?bool $includeDisabled = null): VariantCollection + { + if ($this->_variants === null) { + if (!$this->id) { + return VariantCollection::make(); + } + + /** @var self|null $duplicatingProduct */ + $duplicatingProduct = $this->duplicateOf; + if ($duplicatingProduct) { + $query = self::createVariantQuery($duplicatingProduct)->status(null); + } else { + $query = self::createVariantQuery($this)->status(null); + } + + $variants = $query->collect(); + + // Don't memoize empty collections in favour of a new query next time + if ($variants->isEmpty()) { + return $variants; + } + + $this->_variants = $variants; + $this->_variants->map(function(Variant $v) { + if (!$this->id) { + return $v; + } + + if ($v->primaryOwnerId === $this->id) { + $v->setPrimaryOwner($this); + } + + if ($v->ownerId === $this->id) { + $v->setOwner($this); + } + + return $v; + }); + } + + // When reordering variants we need to make sure disabled variants are included when calculating sort order + // @TODO Remove this controller-based default in Commerce 6.0 when `getVariants()` is updated to return an element query instance + $includeDisabled ??= ltrim((string)request()->route()?->getControllerClass(), '\\') === NestedElementsController::class; + + return $this->_variants->filter(fn(Variant $variant) => $includeDisabled || ($variant->getStatus() === self::STATUS_ENABLED)); + } + + #[Override] + public function getSupportedSites(): array + { + if (!isset($this->typeId)) { + throw new \RuntimeException('Require `typeId` must be set on the product.'); + } + + $productType = $this->getType(); + /** @var Collection $allSites */ + $allSites = Sites::getAllSites(true)->keyBy('id'); + $sites = []; + $currentSites = []; + + // If the product type is leaving it up to products to decide which sites to be propagated to, + // figure out which sites the product is currently saved in + if ( + ($this->duplicateOf->id ?? $this->id) && + $productType->propagationMethod === PropagationMethod::Custom + ) { + if ($this->id) { + $currentSites = self::find() + ->status(null) + ->id($this->id) + ->site('*') + ->drafts(null) + ->provisionalDrafts(null) + ->revisions($this->getIsRevision()) + ->pluck('siteId') + ->all(); + } + + // If this is being duplicated from another element (e.g. a draft), include any sites the source element is saved to as well + if (!empty($this->duplicateOf->id)) { + array_push($currentSites, ...self::find() + ->status(null) + ->id($this->duplicateOf->id) + ->site('*') + ->drafts(null) + ->provisionalDrafts(null) + ->revisions($this->duplicateOf->getIsRevision()) + ->pluck('siteId') + ->all() + ); + } + + $currentSites = array_flip($currentSites); + } + + foreach ($productType->getSiteSettings() as $siteSettings) { + switch ($productType->propagationMethod) { + case PropagationMethod::None: + $include = $siteSettings->siteId == $this->siteId; + $propagate = true; + break; + case PropagationMethod::SiteGroup: + $include = $allSites[$siteSettings->siteId]->groupId == $allSites[$this->siteId]->groupId; + $propagate = true; + break; + case PropagationMethod::Language: + $include = $allSites[$siteSettings->siteId]->language == $allSites[$this->siteId]->language; + $propagate = true; + break; + case PropagationMethod::Custom: + $include = true; + // Only actually propagate to this site if it's the current site, or the product has been assigned + // a status for this site, or the product already exists for this site + $propagate = ( + $siteSettings->siteId == $this->siteId || + $this->getEnabledForSite($siteSettings->siteId) !== null || + isset($currentSites[$siteSettings->siteId]) + ); + break; + default: + $include = $propagate = true; + break; + } + + if ($include) { + $sites[] = [ + 'siteId' => $siteSettings->siteId, + 'propagate' => $propagate, + 'enabledByDefault' => $siteSettings->enabledByDefault, + ]; + } + } + + return $sites; + } + + /** + * Sets the variants on the product. Accepts an array of variant data keyed by variant ID or the string 'new'. + * + * @param VariantCollection|VariantQuery|array $variants + */ + public function setVariants(VariantCollection|VariantQuery|array $variants): void + { + if ($variants instanceof VariantQuery) { + // just unset our existing records + $this->_variants = null; + return; + } + + // Make sure each variant has an owner set in case of mass assignment of product and variants + if (is_array($variants)) { + foreach ($variants as &$variant) { + if ($variant instanceof Variant) { + continue; + } + + if (is_array($variant) && !isset($variant['owner'])) { + $variant = ['owner' => $this] + $variant; + } + } + } + + $this->_variants = $variants instanceof VariantCollection ? $variants : VariantCollection::make($variants); + } + + /** + * Returns a nested element manager for the product’s variants. + */ + public function getVariantManager(): NestedElementManager + { + if (!isset($this->_variantManager)) { + $this->_variantManager = new NestedElementManager( + Variant::class, + // @phpstan-ignore argument.type (will always be a Product) + fn(ElementInterface $product): VariantQuery => self::createVariantQuery($product), + [ + 'attribute' => 'variants', // dont change this: https://github.com/craftcms/commerce/issues/4314#issuecomment-4715539955 + 'propagationMethod' => $this->getType()->propagationMethod, + 'valueGetter' => fn() => $this->getVariants(true), + 'valueSetter' => $this->setVariants(...), + ], + ); + } + + return $this->_variantManager; + } + + #[Override] + public function getStatus(): ?string + { + $status = parent::getStatus(); + + if ($status == self::STATUS_ENABLED && $this->postDate) { + $currentTime = time(); + $postDate = $this->postDate->getTimestamp(); + $expiryDate = $this->expiryDate?->getTimestamp(); + + if ($postDate <= $currentTime && ($expiryDate === null || $expiryDate > $currentTime)) { + return self::STATUS_LIVE; + } + + if ($postDate > $currentTime) { + return self::STATUS_PENDING; + } + + return self::STATUS_EXPIRED; + } + + return $status; + } + + /** + * @throws \RuntimeException + */ + public function getTotalStock(bool $includeDisabled = false): int + { + $stock = 0; + foreach ($this->getVariants($includeDisabled) as $variant) { + $stock += $variant->getStock(); + } + + return $stock; + } + + #[Override] + public function getGqlTypeName(): string + { + return static::gqlTypeNameByContext($this->getType()); + } + + #[Override] + public function setEagerLoadedElements(string $handle, array $elements, EagerLoadPlan $plan): void + { + if ($handle == 'variants') { + /** @var Variant[] $elements */ + $this->setVariants($elements); + } else { + parent::setEagerLoadedElements($handle, $elements, $plan); + } + } + + #[Override] + protected function metaFieldsHtml(bool $static): string + { + $fields = []; + $productType = $this->getType(); + + // Slug + if ($productType->showSlugField) { + $fields[] = $this->slugFieldHtml($static); + } + + if ($productType->isStructure && $productType->maxLevels !== 1) { + $fields[] = (function() use ($static, $productType) { + if ($parentId = $this->getParentId()) { + // TODO: migrate to app(Products::class)->getProductById() signature once it accepts criteria + $parent = app(Products::class)->getProductById($parentId, $this->siteId, [ + 'drafts' => null, + 'draftOf' => false, + ]); + } else { + // If the product already has structure data, use it. Otherwise, use its canonical product + /** @var self|null $parent */ + $parent = self::find() + ->siteId($this->siteId) + ->ancestorOf($this->lft ? $this : ($this->getIsCanonical() ? $this->id : $this->getCanonical(true))) + ->ancestorDist(1) + ->drafts(null) + ->draftOf(false) + ->status(null) + ->one(); + } + + return FormFields::elementSelectFieldHtml([ + 'label' => t('Parent'), + 'id' => 'parentId', + 'name' => 'parentId', + 'elementType' => self::class, + 'selectionLabel' => t('Choose'), + 'sources' => ["productType:$productType->uid"], + 'criteria' => $this->parentOptionCriteria($productType), + 'limit' => 1, + 'elements' => $parent ? [$parent] : [], + 'disabled' => $static, + 'describedBy' => 'parentId-label', + 'errors' => $this->errors()->get('parentId'), + ]); + })(); + } + + DeltaRegistry::withActive(true, function() { + DeltaRegistry::registerName('postDate'); + DeltaRegistry::registerName('expiryDate'); + }); + + // Post Date + $fields[] = FormFields::dateTimeFieldHtml([ + 'status' => $this->getAttributeStatus('postDate'), + 'label' => t('Post Date'), + 'id' => 'postDate', + 'name' => 'postDate', + 'value' => $this->userPostDate(), + 'errors' => $this->errors()->get('postDate'), + 'disabled' => $static, + ]); + + // Expiry Date + $fields[] = FormFields::dateTimeFieldHtml([ + 'status' => $this->getAttributeStatus('expiryDate'), + 'label' => t('Expiry Date'), + 'id' => 'expiryDate', + 'name' => 'expiryDate', + 'value' => $this->expiryDate, + 'errors' => $this->errors()->get('expiryDate'), + 'disabled' => $static, + ]); + + $fields[] = parent::metaFieldsHtml($static); + + return implode("\n", $fields); + } + + /** @return array */ + private function parentOptionCriteria(ProductType $productType): array + { + $parentOptionCriteria = [ + 'siteId' => $this->siteId, + 'typeId' => $productType->id, + 'status' => null, + 'drafts' => null, + 'draftOf' => false, + ]; + + // Prevent the current product, or any of its descendants, from being selected as a parent + if ($this->id) { + $excludeIds = self::find() + ->descendantOf($this) + ->drafts(null) + ->draftOf(false) + ->status(null) + ->ids(); + $excludeIds[] = $this->getCanonicalId(); + $parentOptionCriteria['id'] = array_merge(['not'], $excludeIds); + } + + if ($productType->maxLevels) { + if ($this->id) { + // Figure out how deep the ancestors go + $maxDepth = self::find() + ->select('level') + ->descendantOf($this) + ->status(null) + ->leaves() + ->value('level'); + $depth = 1 + ($maxDepth ?: $this->level) - $this->level; + } else { + $depth = 1; + } + + $parentOptionCriteria['level'] = sprintf('<=%s', $productType->maxLevels - $depth); + } + + // Fire a 'defineParentSelectionCriteria' event + if ($this->hasEventHandlers(self::EVENT_DEFINE_PARENT_SELECTION_CRITERIA)) { + $event = new ElementCriteriaEvent(['criteria' => $parentOptionCriteria]); + $this->trigger(self::EVENT_DEFINE_PARENT_SELECTION_CRITERIA, $event); + return $event->criteria; + } + + return $parentOptionCriteria; + } + + /** + * Returns the Post Date value that should be shown on the edit form. + */ + private function userPostDate(): ?DateTime + { + if (!$this->postDate || ($this->getIsUnpublishedDraft() && $this->postDate == $this->dateCreated)) { + // Pretend the post date hasn't been set yet, even if it has + return null; + } + + return $this->postDate; + } + + #[Override] + public function getMetadata(): array + { + $metadata = parent::getMetadata(); + + if (array_key_exists(t('Status'), $metadata)) { + unset($metadata[t('Status')]); + } + + return $metadata; + } + + #[Override] + protected function searchKeywords(string $attribute): string + { + if ($attribute === 'sku') { + return $this->getVariants() + ->pluck('sku') + ->filter(fn(?string $sku) => $sku && !PurchasableHelper::isTempSku($sku)) + ->implode(' '); + } + + return parent::searchKeywords($attribute); + } + + #[Override] + public function afterSave(bool $isNew): void + { + if (!$this->propagating) { + $productType = $this->getType(); + + if (!$isNew) { + $record = ProductRecord::query()->find($this->id); + + if (!$record) { + throw new \Exception('Invalid product ID: ' . $this->id); + } + } else { + $record = new ProductRecord(); + $record->id = $this->id; + } + + $record->postDate = Query::prepareDateForDb($this->postDate); + $record->expiryDate = Query::prepareDateForDb($this->expiryDate); + $record->typeId = $this->typeId; + + $defaultVariant = $this->getDefaultVariant(); + $record->defaultVariantId = $defaultVariant->id ?? null; + $record->defaultSku = $defaultVariant?->getSkuAsText() ?? ''; + $record->defaultPrice = $defaultVariant?->getBasePrice() ?? 0.0; + $record->defaultHeight = $defaultVariant->height ?? 0.0; + $record->defaultLength = $defaultVariant->length ?? 0.0; + $record->defaultWidth = $defaultVariant->width ?? 0.0; + $record->defaultWeight = $defaultVariant->weight ?? 0.0; + + // Make sure to update the object + $this->defaultVariantId = $defaultVariant->id ?? null; + $this->defaultSku = $defaultVariant?->getSkuAsText(); + $this->defaultPrice = $defaultVariant?->getBasePrice() ?? 0.0; + $this->defaultHeight = $defaultVariant->height ?? 0; + $this->defaultLength = $defaultVariant->length ?? 0; + $this->defaultWidth = $defaultVariant->width ?? 0; + $this->defaultWeight = $defaultVariant->weight ?? 0; + + // We want to always have the same date as the element table, based on the logic for updating these in the element service i.e resaving + $record->dateUpdated = Query::prepareDateForDb($this->dateUpdated); + $record->dateCreated = Query::prepareDateForDb($this->dateCreated); + + // Capture the dirty attributes from the record + $dirtyAttributes = array_keys($record->getDirty()); + $record->save(); + + $this->id = $record->id; + + $this->setDirtyAttributes($dirtyAttributes); + + if ($this->getIsCanonical() && + isset($this->typeId) && + $productType->isStructure + ) { + // Has the parent changed? + if ($this->hasNewParent()) { + $this->placeInStructure($isNew, $productType); + } + + // Update the product's descendants, who may be using this product's URI in their own URIs + if (!$isNew) { + Elements::updateDescendantSlugsAndUris($this, true, true); + } + } + + // Queue job to resave variants if the variant title format references the product + if ($this->getIsCanonical() && + isset($this->typeId) && + !$productType->hasVariantTitleField && + $productType->variantTitleFormat && + str($productType->variantTitleFormat)->contains(['product.', 'owner.', 'primaryOwner.']) + ) { + // TODO: migrate to a Laravel job once the ResaveProductVariants job is migrated to src/ + Queue::push(new ResaveProductVariants([ + 'productId' => $this->id, + ])); + } + } + + parent::afterSave($isNew); + } + + private function placeInStructure(bool $isNew, ProductType $productType): void + { + $parentId = $this->getParentId(); + + // If this is a provisional draft and its new parent matches the canonical product’s, just drop it from the structure + if ($this->isProvisionalDraft) { + $canonicalParentId = self::find() + ->select(['elements.id']) + ->ancestorOf($this->getCanonicalId()) + ->ancestorDist(1) + ->status(null) + ->value('id'); + + if ($parentId == $canonicalParentId) { + Structures::remove($this->structureId, $this); + return; + } + } + + $mode = $isNew ? StructureMode::Insert : StructureMode::Auto; + + if (!$parentId) { + if ($productType->defaultPlacement === ProductType::DEFAULT_PLACEMENT_BEGINNING) { + Structures::prependToRoot($this->structureId, $this, $mode); + } else { + Structures::appendToRoot($this->structureId, $this, $mode); + } + } else { + if ($productType->defaultPlacement === ProductType::DEFAULT_PLACEMENT_BEGINNING) { + Structures::prepend($this->structureId, $this, $this->getParent(), $mode); + } else { + Structures::append($this->structureId, $this, $this->getParent(), $mode); + } + } + } + + /** + * Updates the product's title, if its product type has a dynamic title format. + */ + public function updateTitle(): void + { + $productType = $this->getType(); + + if (!$productType->hasProductTitleField) { + // Set Craft to the product's site's language, in case the title format has any static translations + $language = $this->getSite()->getLanguage(); + $title = I18N::withLocale( + $language, + $language, + fn() => renderSandboxedObjectTemplate($productType->productTitleFormat, $this), + ); + + if ($title !== '') { + $this->title = $title; + } + } + } + + /** + * The new validation system has no `beforeValidate(): bool` hook — the equivalent + * pre-validation mutation point is `prepareForValidation()` (Illuminate-style, runs before + * rules are applied, no return value). + */ + #[Override] + public function prepareForValidation(): void + { + // We need to generate all variant sku formats before validating the product, + // since the product validates the uniqueness of all variants in memory. + $type = $this->getType(); + + foreach ($this->getVariants(true) as $variant) { + if ($variant->sku || !$type->skuFormat) { + continue; + } + + try { + $variant->sku = renderSandboxedObjectTemplate($type->skuFormat, $variant); + } catch (\Exception $e) { + Log::error('Craft Commerce could not generate the supplied SKU format: ' . $e->getMessage()); + $variant->sku = ''; + } + + if (!$variant->sku) { + continue; + } + + // Ensure there isn't a clash with an existing SKU when using auto formats + if ($this->skuExists($variant->sku, $variant->id)) { + // If there is a clash, we need to append a number to the end. + $baseSku = $variant->sku; + do { + $seq = Sequence::next('sku::' . $baseSku); + $newSku = $baseSku . '-' . $seq; + } while ($this->skuExists($newSku, $variant->id)); + + $variant->sku = $newSku; + } + } + } + + private function skuExists(string $sku, ?int $id): bool + { + return DB::table(Table::PURCHASABLES) + ->where('sku', $sku) + // Make sure it isn't for the purchasable we are currently saving + ->when($id, fn($query) => $query->where('id', '!=', $id)) + ->exists(); + } + + /** + * Runs the imperative validators that used to be wired up via `defineRules()`'s + * `[[attributes], callable]` syntax. {@see ProductRules} keeps only the plain declarative + * rules; the variant checks below add errors to the `variants` attribute rather than validating + * a value of their own, so they live here. This is invoked automatically by + * {@see \CraftCms\Cms\Validation\Ruleset::after()}. + */ + #[Override] + public function afterValidate(?Validator $validator = null): void + { + if ($this->ruleset->inScenarios(ProductRules::SCENARIO_LIVE)) { + $this->validateHasVariants(); + $this->validateVariantSkusAreUnique(); + $this->validateVariantSkusAreSet(); + } + + $this->validateMaxVariants(); + } + + public function validateHasVariants(): void + { + if ($this->getVariants(true)->isEmpty()) { + $this->errors()->add('variants', t('Must have at least one variant.', category: 'commerce')); + } + } + + public function validateVariantSkusAreUnique(): void + { + $skus = []; + + foreach ($this->getVariants(true) as $variant) { + if (isset($skus[$variant->sku])) { + $this->errors()->add('variants', t('Not all SKUs are unique.', category: 'commerce')); + break; + } + + $skus[$variant->sku] = true; + } + } + + public function validateVariantSkusAreSet(): void + { + foreach ($this->getVariants(true) as $variant) { + if (!$variant->sku || PurchasableHelper::isTempSku($variant->sku)) { + $this->errors()->add('variants', t('All variants must have a SKU.', category: 'commerce')); + break; + } + } + } + + public function validateMaxVariants(): void + { + $maxVariants = $this->getType()->maxVariants; + + if ($maxVariants && count($this->getVariants(true)) > $maxVariants) { + $this->errors()->add('variants', t('Too many variants for this product.', category: 'commerce')); + } + } + + #[Override] + public function beforeDelete(): bool + { + if (!parent::beforeDelete()) { + return false; + } + + $this->getVariantManager()->deleteNestedElements($this, $this->hardDelete); + + return true; + } + + #[Override] + public function afterRestore(): void + { + $this->getVariantManager()->restoreNestedElements($this); + + parent::afterRestore(); + } + + #[Override] + public function setAttributesFromRequest(array $values): void + { + // this is needed for Craft.NestedElementManager::markAsDirty() + if (isset($values['variants']) && $values['variants'] === '*') { + $this->setDirtyAttributes(['variants']); + unset($values['variants']); + } + + parent::setAttributesFromRequest($values); + } + + #[Override] + public function getFieldLayout(): ?FieldLayout + { + try { + return $this->getType()->getProductFieldLayout(); + } catch (\RuntimeException) { + // The product type was probably deleted + return null; + } + } + + #[Override] + public function beforeSave(bool $isNew): bool + { + // Make sure the product has at least one revision if the product type has versioning enabled + if ($this->shouldSaveRevision()) { + $hasRevisions = self::find() + ->revisionOf($this) + ->site('*') + ->status(null) + ->exists(); + if (!$hasRevisions) { + /** @var self|null $currentProduct */ + $currentProduct = self::find() + ->id($this->id) + ->site('*') + ->status(null) + ->one(); + + // May be null if the product is currently stored as an unpublished draft + if ($currentProduct) { + $revisionNotes = 'Revision from ' . I18N::getFormatter()->asDatetime($currentProduct->dateUpdated); + app(Revisions::class)->createRevision($currentProduct, notes: $revisionNotes); + } + } + } + + $productType = $this->getType(); + // Set the structure ID for Element::attributes() and afterSave() + if ($productType->isStructure) { + $this->structureId = $productType->structureId; + + // Has the product been assigned to a new parent? + if (!$this->duplicateOf && $this->hasNewParent()) { + if ($parentId = $this->getParentId()) { + $parentProduct = app(Products::class)->getProductById($parentId, '*', [ + 'preferSites' => [$this->siteId], + 'drafts' => null, + 'draftOf' => false, + ]); + + if (!$parentProduct) { + throw new \RuntimeException("Invalid parent ID: $parentId"); + } + } else { + $parentProduct = null; + } + + $this->setParent($parentProduct); + } + } + + // Make sure the field layout is set correctly + $this->fieldLayoutId = $this->getType()->fieldLayoutId; + + if ($this->enabled && !$this->postDate) { + // Default the post date to the current date/time + $this->postDate = new DateTime(); + // ...without the seconds + $this->postDate->setTimestamp($this->postDate->getTimestamp() - ($this->postDate->getTimestamp() % 60)); + } + + $this->updateTitle(); + + return parent::beforeSave($isNew); + } + + #[Override] + protected static function defineSearchableAttributes(): array + { + return [ + 'defaultSku', + 'sku', + ]; + } + + private static function createVariantQuery(Product $product): VariantQuery + { + $query = Variant::find() + ->productId($product->id) + ->siteId($product->siteId) + ->orderBy('sortOrder'); + + if ($product->getIsRevision()) { + $query->revisions(null)->trashed(null); + } + + return $query; + } + + #[Override] + protected function route(): array|string|null + { + // Make sure that the product is actually live + if (!$this->previewing && $this->getStatus() != self::STATUS_LIVE) { + return null; + } + + // Make sure the product type is set to have URLs for this site + $siteId = Sites::getCurrentSite()->id; + $productTypeSiteSettings = $this->getType()->getSiteSettings(); + + if (!isset($productTypeSiteSettings[$siteId]) || !$productTypeSiteSettings[$siteId]->hasUrls) { + return null; + } + + return [ + 'templates/render', [ + 'template' => $productTypeSiteSettings[$siteId]->template, + 'variables' => [ + 'product' => $this, + ], + ], + ]; + } + + #[Override] + protected function previewTargets(): array + { + return array_map(function($previewTarget) { + $previewTarget['label'] = t($previewTarget['label'], category: 'site'); + return $previewTarget; + }, $this->getType()->previewTargets ?? []); + } + + #[Override] + protected function attributeHtml(string $attribute): string + { + $productType = $this->getType(); + + switch ($attribute) { + case 'type': + { + return t(Html::encode($productType->name), category: 'site'); + } + case 'defaultSku': + { + if ($this->defaultSku === null) { + return ''; + } + + return Html::tag('code', PurchasableHelper::isTempSku($this->defaultSku) ? '' : Html::encode($this->defaultSku)); + } + case 'defaultPrice': + { + return $this->defaultBasePrice ? $this->getDefaultBasePriceAsCurrency() : ''; + } + case 'defaultPromotionalPrice': + { + return $this->defaultBasePromotionalPrice ? $this->getDefaultBasePromotionalPriceAsCurrency() : ''; + } + case 'stock': + { + $stock = 0; + $hasUnlimited = false; + + foreach ($this->getVariants(true) as $variant) { + $stock += $variant->getStock(); + if (!$variant->inventoryTracked) { + $hasUnlimited = true; + } + } + return $hasUnlimited ? '∞' . ($stock ? ' & ' . $stock : '') : (string)($stock ?: '0'); + } + case 'defaultWeight': + { + if ($productType->hasDimensions) { + return I18N::getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->weightUnits; + } + + return ''; + } + case 'defaultLength': + case 'defaultWidth': + case 'defaultHeight': + { + if ($productType->hasDimensions) { + return I18N::getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits; + } + + return ''; + } + case 'variants': + { + $value = $this->getVariants(true); + /** @var Variant|null $first */ + $first = $value->first(); + $html = $first ? app(ElementHtml::class)->elementChipHtml($first) : ''; + + if ($value->isNotEmpty() && $value->count() > 1) { + $otherItems = $value->filter(fn($v, $k) => $k > 0); + $otherHtml = $otherItems->map(fn($v) => app(ElementHtml::class)->elementChipHtml($v))->join(''); + + $html .= Html::tag('span', '+' . I18N::getFormatter()->asInteger($otherItems->count()), [ + 'title' => $otherItems->map(fn($v) => $v->title)->join(', '), + 'class' => 'btn small', + 'role' => 'button', + 'onclick' => 'jQuery(this).replaceWith(' . Json::encode($otherHtml) . ')', + ]); + } + + return $html; + } + default: + { + return parent::attributeHtml($attribute); + } + } + } + + #[Override] + public function afterPropagate(bool $isNew): void + { + $this->getVariantManager()->maintainNestedElements($this, $isNew); + parent::afterPropagate($isNew); + + // @TODO Collate purchasable IDs updated across the request and queue a single catalog pricing job, rather than one per product propagate + if (!$this->getIsDraft()) { + app(CatalogPricing::class)->createCatalogPricingJob([ + 'purchasableIds' => $this->getVariants()->pluck('id')->all(), + 'storeId' => $this->storeId, + ]); + } + + // Save a new revision? + if ($this->shouldSaveRevision()) { + app(Revisions::class)->createRevision($this, notes: $this->revisionNotes); + } + } + + /** + * Returns whether the product should be saving revisions on save. + */ + private function shouldSaveRevision(): bool + { + return ( + $this->id && + !$this->propagating && + !$this->resaving && + !$this->getIsDraft() && + !$this->getIsRevision() && + $this->getType()->enableVersioning + ); + } +} diff --git a/src/Catalog/Elements/Variant.php b/src/Catalog/Elements/Variant.php new file mode 100644 index 0000000000..cfe5884d54 --- /dev/null +++ b/src/Catalog/Elements/Variant.php @@ -0,0 +1,1300 @@ +variant; + * // @var array|null $fields + * $fields = $event->fields; + * + * // Add every custom field to the snapshot + * if (($fieldLayout = $variant->getFieldLayout()) !== null) { + * foreach ($fieldLayout->getFields() as $field) { + * $fields[] = $field->handle; + * } + * } + * + * $event->fields = $fields; + * } + * ); + * ``` + */ + public const string EVENT_BEFORE_CAPTURE_VARIANT_SNAPSHOT = 'beforeCaptureVariantSnapshot'; + + /** + * @event CustomizeVariantSnapshotDataEvent The event that is triggered after a variant’s field data is captured. This makes it possible to customize, extend, or redact the data to be persisted on the variant instance. + */ + public const string EVENT_AFTER_CAPTURE_VARIANT_SNAPSHOT = 'afterCaptureVariantSnapshot'; + + /** + * @event CustomizeProductSnapshotFieldsEvent The event that is triggered before a product’s field data is captured. This makes it possible to customize which fields are included in the snapshot. Custom fields are not included by default. + * + * ::: warning + * Add with care! A huge amount of custom fields/data will increase your database size. + * ::: + */ + public const string EVENT_BEFORE_CAPTURE_PRODUCT_SNAPSHOT = 'beforeCaptureProductSnapshot'; + + /** + * @event CustomizeProductSnapshotDataEvent The event that is triggered after a product’s field data is captured, which can be used to customize, extend, or redact the data to be persisted on the product instance. + */ + public const string EVENT_AFTER_CAPTURE_PRODUCT_SNAPSHOT = 'afterCaptureProductSnapshot'; + + public bool $isDefault = false; + + /** + * @see getProductSlug() + * @see setProductSlug() + */ + private ?string $_productSlug = null; + + /** + * @see getProductTypeHandle() + * @see setProductTypeHandle() + */ + private ?string $_productTypeHandle = null; + + #[Override] + public function safeAttributes(): array + { + $attributes = parent::safeAttributes(); + $attributes[] = 'productId'; + + return $attributes; + } + + #[Override] + protected function uiLabel(): ?string + { + $owner = $this->getOwner(); + + if ($owner) { + $uiLabelFormat = $owner->getType()->variantUiLabelFormat; + if ($uiLabelFormat !== '{title}') { + $uiLabel = renderSandboxedObjectTemplate($uiLabelFormat, $this); + if ($uiLabel !== '') { + return $uiLabel; + } + } + } + + return null; + } + + #[Override] + public static function displayName(): string + { + return t('Product Variant', category: 'commerce'); + } + + #[Override] + public static function lowerDisplayName(): string + { + return t('product variant', category: 'commerce'); + } + + #[Override] + public static function pluralDisplayName(): string + { + return t('Product Variants', category: 'commerce'); + } + + #[Override] + public static function pluralLowerDisplayName(): string + { + return t('product variants', category: 'commerce'); + } + + #[Override] + public static function refHandle(): ?string + { + return 'variant'; + } + + #[Override] + public function getIsTitleTranslatable(): bool + { + return $this->getOwner()->getType()->variantTitleTranslationMethod !== TranslationMethod::None->value; + } + + #[Override] + public function getTitleTranslationDescription(): ?string + { + /** @phpstan-ignore-next-line nullsafe.neverNull (variantTitleTranslationMethod is an uncast, free-form DB string column - tryFrom() genuinely can return null) */ + return TranslationMethod::tryFrom($this->getOwner()->getType()->variantTitleTranslationMethod)?->description(); + } + + #[Override] + public function getTitleTranslationKey(): string + { + $type = $this->getOwner()->getType(); + + // variantTitleTranslationMethod is an uncast, free-form DB string column - tryFrom() genuinely can return null + /** @phpstan-ignore-next-line nullCoalesce.expr */ + return TranslationMethod::tryFrom($type->variantTitleTranslationMethod) + ?->elementKey($this, $type->variantTitleTranslationKeyFormat) /** @phpstan-ignore-line */ + ?? (string)$this->siteId; + } + + #[Override] + public function canSave(\CraftCms\Cms\User\Elements\User $user): bool + { + if (parent::canSave($user)) { + return true; + } + + $product = $this->getOwner(); + if ($product === null) { + return false; + } + + return $product->canSave($user); + } + + #[Override] + public function canCopy(\CraftCms\Cms\User\Elements\User $user): bool + { + return true; + } + + #[Override] + public function canDelete(\CraftCms\Cms\User\Elements\User $user): bool + { + if (parent::canDelete($user)) { + return true; + } + + return $this->canSave($user); + } + + #[Override] + public function canDuplicate(\CraftCms\Cms\User\Elements\User $user): bool + { + if (parent::canDuplicate($user)) { + return true; + } + + return $this->canSave($user); + } + + #[Override] + protected static function includeSetStatusAction(): bool + { + return true; + } + + /** + * @throws \RuntimeException + */ + #[Override] + public function getIsAvailable(): bool + { + if ($this->getIsRevision()) { + return false; + } + + if ($this->getIsDraft()) { + return false; + } + + if ($this->getPrimaryOwner()->getIsDraft()) { + return false; + } + + if ($this->getPrimaryOwner()->getStatus() != Product::STATUS_LIVE) { + return false; + } + + return parent::getIsAvailable(); + } + + /** + * @return VariantCondition + */ + #[Override] + public static function createCondition(): ElementConditionInterface + { + return new VariantCondition(static::class); + } + + /** + * Runs the custom validators that used to be declared in `defineRules()` using the + * `[[attribute], 'methodName']` syntax. {@see VariantRules} keeps the declarative rules; these + * are invoked automatically by {@see \CraftCms\Cms\Validation\Ruleset::after()}. + */ + #[Override] + public function afterValidate(?Validator $validator = null): void + { + if ($this->ruleset->inScenarios(VariantRules::SCENARIO_LIVE)) { + $this->validatePrice(); + } + + $this->validateMinQtyRange(); + $this->validateMaxQtyRange(); + } + + /** + * The base price is required for a live variant. + */ + public function validatePrice(): void + { + if ($this->getBasePrice() === null) { + $this->errors()->add('price', t('{attribute} cannot be blank.', [ + 'attribute' => $this->getAttributeLabel('price'), + ])); + } + } + + public function validateMinQtyRange(): void + { + if ($this->minQty && $this->maxQty && $this->minQty > $this->maxQty) { + $this->errors()->add('minQty', t('Min quantity must be less than max.', category: 'commerce')); + } + } + + public function validateMaxQtyRange(): void + { + if ($this->minQty && $this->maxQty && $this->maxQty < $this->minQty) { + $this->errors()->add('maxQty', t('Max quantity must greater than min.', category: 'commerce')); + } + } + + #[Override] + public function extraFields(): array + { + $names = $this->nestedExtraFields(); + $names[] = 'product'; + + return $names; + } + + #[Override] + public function getFieldLayout(): ?FieldLayout + { + $fieldLayout = parent::getFieldLayout(); + + // If we have a field layout, try to set its provider from product type + if ($fieldLayout) { + // TODO: migrate to app(ProductTypes::class)->getAllProductTypes() once service migrated to src/ + $productTypes = app(ProductTypes::class)->getAllProductTypes(); + $productType = collect($productTypes)->firstWhere('variantFieldLayoutId', $fieldLayout->id); + + if ($productType) { + $fieldLayout->provider = $productType; + return $fieldLayout; + } + } + + // Try to get field layout from owner's product type + try { + $owner = $this->getOwner(); + + return $owner === null + ? $fieldLayout + : $owner->getType()->getVariantFieldLayout(); + } catch (\RuntimeException) { + // Product type was likely deleted + return null; + } + } + + #[Override] + protected function metadata(): array + { + $metadata = parent::metadata(); + + $product = $this->getOwner(); + + if ($product) { + $metadata[t('Product', category: 'commerce')] = app(ElementHtml::class)->elementChipHtml($product, ['showActionMenu' => true]); + } + + return $metadata; + } + + #[\Deprecated(message: 'in 5.0.0. Use [[setOwnerId()]] instead.')] + public function setProductId(?int $productId): void + { + $this->setOwnerId($productId); + } + + /** + * @throws \RuntimeException + */ + #[\Deprecated(message: 'in 5.0.0. Use [[getOwnerId()]] instead.')] + public function getProductId(): ?int + { + return $this->getOwnerId(); + } + + public function setPrimaryOwner(?ElementInterface $owner): void + { + if (!$owner instanceof Product) { + throw new \InvalidArgumentException('Product variants can only be assigned to products.'); + } + + if ($owner->siteId) { + $this->siteId = $owner->siteId; + } + + $this->fieldLayoutId = $owner->getType()->variantFieldLayoutId; + + $this->nestedSetPrimaryOwner($owner); + } + + public function setOwner(?ElementInterface $owner): void + { + if (!$owner instanceof Product) { + throw new \InvalidArgumentException('Product variants can only be assigned to products.'); + } + + if ($owner->siteId) { + $this->siteId = $owner->siteId; + } + + $this->fieldLayoutId = $owner->getType()->variantFieldLayoutId; + + $this->nestedSetOwner($owner); + } + + /** + * Returns the product associated with this variant. + */ + #[\Deprecated(message: 'in 5.0.0. Use [[getOwner()]] instead.')] + public function getProduct(): ?Product + { + /** @var Product|null */ + return $this->getOwner(); + } + + /** + * Sets the product associated with this variant. + */ + #[\Deprecated(message: 'in 5.0.0. Use [[setOwner()]] instead.')] + public function setProduct(Product $product): void + { + $this->setOwner($product); + } + + public function setProductSlug(?string $productSlug): void + { + $this->_productSlug = $productSlug; + } + + /** + * @throws \RuntimeException + */ + public function getProductSlug(): ?string + { + if ($this->_productSlug === null) { + $product = $this->getOwner(); + + /** @phpstan-ignore-next-line nullsafe.neverNull (getOwner() genuinely returns ?ElementInterface) */ + $this->_productSlug = $product?->slug ?? null; + } + + return $this->_productSlug; + } + + public function setProductTypeHandle(?string $productTypeHandle): void + { + $this->_productTypeHandle = $productTypeHandle; + } + + /** + * @throws \RuntimeException + */ + public function getProductTypeHandle(): ?string + { + if ($this->_productTypeHandle === null) { + $product = $this->getOwner(); + + $this->_productTypeHandle = $product?->getType()->handle; + } + + return $this->_productTypeHandle; + } + + /** + * Returns the product title and variants title together for variable products. + * + * @throws \Exception + * @throws \RuntimeException + * @throws Throwable + */ + #[Override] + public function getDescription(): string + { + $description = $this->title; + + if ($format = $this->getOwner()->getType()->descriptionFormat) { + if ($rendered = renderSandboxedObjectTemplate($format, $this)) { + $description = $rendered; + } + } + + // If title is not set yet default to blank string + return (string)$description; + } + + /** + * Updates the title based on titleFormat, or sets it to the same title as the product. + * + * @throws \Exception + * @throws \RuntimeException + * @throws Throwable + */ + public function updateTitle(Product $product): void + { + $type = $product->getType(); + + // Use the product type's titleFormat if the title field is not shown + if (!$type->hasVariantTitleField && $type->variantTitleFormat) { + // Set Craft to the product's site's language, in case the title format has any static translations + $language = $this->getSite()->getLanguage(); + $this->title = I18N::withLocale( + $language, + $language, + fn() => renderSandboxedObjectTemplate($type->variantTitleFormat, $this), + ); + } + } + + /** + * @throws Throwable + */ + public function updateSku(Product $product): void + { + $type = $product->getType(); + + // If we have a blank SKU, generate from product type’s skuFormat + if (!$this->sku && $type->skuFormat) { + // Set Craft to the product’s site’s language, in case the SKU format has any static translations + $language = $this->getSite()->getLanguage(); + $this->sku = I18N::withLocale( + $language, + $language, + fn() => renderSandboxedObjectTemplate($type->skuFormat, $this), + ); + + // Ensure there isn't a clash with an existing SKU when using auto formats + if ($this->skuExists($this->getSku(), $this->id)) { + // If there is a clash, we need to append a number to the end. + do { + $seq = Sequence::next('sku::' . $this->sku); + $newSku = $this->sku . '-' . $seq; + } while ($this->skuExists($newSku, $this->id)); + + $this->sku = $newSku; + } + } + } + + private function skuExists(string $sku, ?int $id): bool + { + return DB::table(Table::PURCHASABLES) + ->where('sku', $sku) + // Make sure it isn't for the purchasable we are currently saving + ->when($id, fn($query) => $query->where('id', '!=', $id)) + ->exists(); + } + + #[Override] + protected function cacheTags(): array + { + $tags = []; + + if ($primaryOwnerId = $this->getPrimaryOwnerId()) { + $tags[] = "element::{$primaryOwnerId}"; + $tags[] = "product:{$primaryOwnerId}"; + } + + $ownerId = $this->getOwnerId(); + if ($ownerId && $ownerId !== $primaryOwnerId) { + $tags[] = "element::{$ownerId}"; + } + + return $tags; + } + + #[Override] + public function canView(\CraftCms\Cms\User\Elements\User $user): bool + { + if (parent::canView($user)) { + return true; + } + + $product = $this->getOwner(); + if ($product === null) { + return false; + } + + return $product->canView($user); + } + + #[Override] + public function getUrl(): ?string + { + if ($url = parent::getUrl()) { + return $url; + } + + // Default URL is the product's URL with the variant ID as a query parameter + $productUrl = $this->getOwner()?->getUrl(); + return $productUrl ? Url::urlWithParams($productUrl, ['variant' => $this->id]) : null; + } + + /** + * @throws \RuntimeException + */ + #[Override] + public function getSnapshot(): array + { + $data = parent::getSnapshot(); + $data['cpEditUrl'] = $this->getCpEditUrl(); + + // Default Product custom field handles + $productFields = []; + $productFieldsEvent = new CustomizeProductSnapshotFieldsEvent( + product: $this->getOwner(), + fields: $productFields, + ); + + // Allow plugins to modify Product fields to be fetched + if ($this->hasEventHandlers(self::EVENT_BEFORE_CAPTURE_PRODUCT_SNAPSHOT)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $this->trigger(self::EVENT_BEFORE_CAPTURE_PRODUCT_SNAPSHOT, $productFieldsEvent); + } + + // Product Attributes + if ($product = $this->getOwner()) { + // `ruleset` (the #[Ruleset]-attribute validation object) holds a back-reference to + // its subject, so including it here would make the product a circular reference and + // crash the snapshot's JSON encoding. + $productAttributes = array_values(array_diff($product->attributes(), ['ruleset', 'eagerLoadInfo'])); + + // Remove custom fields + if (($fieldLayout = $product->getFieldLayout()) !== null) { + foreach ($fieldLayout->getCustomFields() as $field) { + $productAttributes = array_values(array_filter( + $productAttributes, + fn(string $attribute) => $attribute !== $field->handle, + )); + } + } + + // Add back the custom fields they want + foreach ($productFieldsEvent->fields as $field) { + $productAttributes[] = $field; + } + + $data['product'] = $product->toArray($productAttributes, [], false); + + $productDataEvent = new CustomizeProductSnapshotDataEvent( + product: $product, + fieldData: $data['product'], + ); + } else { + $productDataEvent = new CustomizeProductSnapshotDataEvent( + product: $this->getOwner(), + fieldData: [], + ); + } + + // Allow plugins to modify captured Product data + if ($this->hasEventHandlers(self::EVENT_AFTER_CAPTURE_PRODUCT_SNAPSHOT)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $this->trigger(self::EVENT_AFTER_CAPTURE_PRODUCT_SNAPSHOT, $productDataEvent); + } + + $data['product'] = $productDataEvent->fieldData; + + // Default Variant custom field handles + $variantFields = []; + $variantFieldsEvent = new CustomizeVariantSnapshotFieldsEvent( + variant: $this, + fields: $variantFields, + ); + + // Allow plugins to modify fields to be fetched + if ($this->hasEventHandlers(self::EVENT_BEFORE_CAPTURE_VARIANT_SNAPSHOT)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $this->trigger(self::EVENT_BEFORE_CAPTURE_VARIANT_SNAPSHOT, $variantFieldsEvent); + } + + // See the matching `ruleset` exclusion above for product attributes: it holds a + // back-reference to its subject (this variant), which would crash JSON encoding. + $variantAttributes = array_values(array_diff($this->attributes(), ['ruleset', 'eagerLoadInfo'])); + + // Remove custom fields + if (($fieldLayout = $this->getFieldLayout()) !== null) { + foreach ($fieldLayout->getCustomFields() as $field) { + $variantAttributes = array_values(array_filter( + $variantAttributes, + fn(string $attribute) => $attribute !== $field->handle, + )); + } + } + + // Add back the custom fields they want + foreach ($variantFieldsEvent->fields as $field) { + $variantAttributes[] = $field; + } + + $variantData = $this->toArray($variantAttributes, [], false); + + $variantDataEvent = new CustomizeVariantSnapshotDataEvent( + variant: $this, + fieldData: $variantData, + ); + + // Allow plugins to modify captured Variant data + if ($this->hasEventHandlers(self::EVENT_AFTER_CAPTURE_VARIANT_SNAPSHOT)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $this->trigger(self::EVENT_AFTER_CAPTURE_VARIANT_SNAPSHOT, $variantDataEvent); + } + + return array_merge($variantDataEvent->fieldData, $data); + } + + /** + * @throws \RuntimeException + */ + #[Override] + public function hasFreeShipping(): bool + { + $isShippable = $this->getIsShippable(); // Same as app(Purchasables::class)->isPurchasableShippable since this has no context + return $isShippable && $this->freeShipping; + } + + /** + * @return VariantQuery The newly created VariantQuery instance. + */ + #[Override] + public static function find(): VariantQuery + { + return new VariantQuery(); + } + + #[Override] + public static function hasStatuses(): bool + { + return true; + } + + #[Override] + public static function eagerLoadingMap(array $sourceElements, string $handle): array|null|false + { + switch ($handle) { + case 'product': + $sourceElementIds = array_filter(array_map(fn(ElementInterface $element) => $element->id, $sourceElements)); + + $map = DB::table(Table::VARIANTS) + ->select(['id as source', 'primaryOwnerId as target']) + ->whereIn('id', $sourceElementIds) + ->get() + ->map(fn(object $row) => (array)$row) + ->all(); + + return [ + 'elementType' => Product::class, + 'map' => $map, + 'criteria' => [ + 'status' => null, + ], + ]; + case 'owner': + case 'primaryOwner': + return array_merge( + self::nestedEagerLoadingMap($sourceElements, $handle), + ['elementType' => Product::class], + ); + default: + return self::nestedEagerLoadingMap($sourceElements, $handle); + } + } + + /** + * Returns a promotion category related to this element if the category is related to the product OR the variant. + * + * @throws \RuntimeException + */ + #[Override] + public function getPromotionRelationSource(): array + { + return [$this->id, $this->getOwner()->id]; + } + + /** + * @throws \RuntimeException + */ + #[Override] + public function getGqlTypeName(): string + { + $product = $this->getOwner(); + + if (!$product) { + return 'Variant'; + } + + try { + $productType = $product->getType(); + } catch (\Exception) { + return 'Variant'; + } + + return static::gqlTypeNameByContext($productType); + } + + public static function gqlTypeNameByContext(mixed $context): string + { + return $context->handle . '_Variant'; + } + + #[Override] + public static function gqlScopesByContext(mixed $context): array + { + /** @var ProductType $context */ + return ['productTypes.' . $context->uid]; + } + + #[Override] + public function getSupportedSites(): array + { + $owner = $this->getOwner(); + + if (!$owner) { + return [Sites::getPrimarySite()->id]; + } + + return $owner->getSupportedSites(); + } + + /** + * @throws \Exception + */ + #[Override] + public function afterSave(bool $isNew): void + { + $ownerId = $this->getOwnerId(); + + if (!$this->propagating) { + if (!$isNew) { + $record = VariantRecord::query()->find($this->id); + + if (!$record) { + throw new \Exception('Invalid variant ID: ' . $this->id); + } + } else { + $record = new VariantRecord(); + $record->id = $this->id; + } + + $record->primaryOwnerId = $this->getPrimaryOwnerId(); + + if ($this->getOwner()->getIsCanonical()) { + $record->isDefault = $this->isDefault; + } + + // We want to always have the same date as the element table, based on the logic for updating these in the element service i.e resaving + $record->dateUpdated = Query::prepareDateForDb($this->dateUpdated); + $record->dateCreated = Query::prepareDateForDb($this->dateCreated); + + $record->save(); + + if ($ownerId && $this->saveOwnership) { + if (!isset($this->sortOrder) && (!$isNew || $this->duplicateOf)) { + // figure out if we should proceed this way + // if we're dealing with an element that's being duplicated, and it has a draftId + // it means we're creating a draft of something + // if we're duplicating element via duplicate action - draftId would be empty + // Same as https://github.com/craftcms/cms/pull/14497/files + $elementId = null; + if ($this->duplicateOf) { + if ($this->draftId) { + $elementId = $this->duplicateOf->id; + } + } else { + // if we're not duplicating - use element's id + $elementId = $this->id; + } + if ($elementId) { + $this->sortOrder = DB::table(CraftTable::ELEMENTS_OWNERS) + ->where('elementId', $elementId) + ->where('ownerId', $ownerId) + ->value('sortOrder') ?: null; + } + } + + if (!isset($this->sortOrder)) { + $max = DB::table(CraftTable::ELEMENTS_OWNERS . ' as eo') + ->join(Table::VARIANTS . ' as v', 'v.id', '=', 'eo.elementId') + ->where('eo.ownerId', $ownerId) + ->max('eo.sortOrder'); + $this->sortOrder = $max ? $max + 1 : 1; + } + + $ownerIds = array_unique([ + $ownerId, + $this->getPrimaryOwnerId(), + ]); + + if (!$isNew) { + DB::table(CraftTable::ELEMENTS_OWNERS) + ->where('elementId', $this->id) + ->whereIn('ownerId', $ownerIds) + ->delete(); + } + + foreach ($ownerIds as $ownerIdToSave) { + DB::table(CraftTable::ELEMENTS_OWNERS)->insert([ + 'elementId' => $this->id, + 'ownerId' => $ownerIdToSave, + 'sortOrder' => $this->sortOrder, + ]); + } + } + } + + parent::afterSave($isNew); + + if (!$this->propagating && $this->isDefault && $ownerId && $this->duplicateOf === null) { + // @TODO Remove this denormalized default-variant data write in Commerce 6.0; the product query now joins this data directly + $defaultData = [ + 'defaultVariantId' => $this->id, + 'defaultSku' => $this->getSkuAsText(), + 'defaultPrice' => $this->getBasePrice(), + 'defaultHeight' => $this->height, + 'defaultLength' => $this->length, + 'defaultWidth' => $this->width, + 'defaultWeight' => $this->weight, + ]; + // Update the product that owns this variant + DB::table(Table::PRODUCTS)->where('id', $ownerId)->update($defaultData); + // Update any other product that references this variant as its default (split from the above to avoid deadlocks from non-deterministic lock ordering with OR-clauses) + DB::table(Table::PRODUCTS) + ->where('defaultVariantId', $this->id) + ->where('id', '!=', $ownerId) + ->update($defaultData); + } + } + + #[Override] + public function setEagerLoadedElements(string $handle, array $elements, EagerLoadPlan $plan): void + { + if (in_array($handle, ['product', 'owner', 'primaryOwner'])) { + $product = $elements[0] ?? null; + if ($product instanceof Product) { + if ($handle == 'primaryOwner') { + $this->setPrimaryOwner($product); + } else { + $this->setOwner($product); + } + } + } else { + $this->nestedSetEagerLoadedElements($handle, $elements, $plan); + } + } + + #[Override] + public static function hasTitles(): bool + { + return true; + } + + #[Override] + public static function isSelectable(): bool + { + return true; + } + + #[Override] + public static function isLocalized(): bool + { + return true; + } + + /** + * The new validation system has no `beforeValidate(): bool` hook — the equivalent + * pre-validation mutation point is `prepareForValidation()`. + * + * @throws Throwable + * @throws \RuntimeException + */ + #[Override] + public function prepareForValidation(): void + { + $product = $this->getOwner(); + + // hold off on updating the title and SKU if we are creating the shell of the variant ready for editing + if ($product && (!$this->getIsDraft() || !$this->ruleset->inScenarios(VariantRules::SCENARIO_ESSENTIALS))) { + $this->updateTitle($product); + $this->updateSku($product); + } + + if (!$this->sku && $this->ruleset->inScenarios(VariantRules::SCENARIO_DEFAULT)) { + $this->setSku(PurchasableHelper::tempSku()); + } + } + + /** + * @throws \RuntimeException + */ + #[Override] + public function beforeSave(bool $isNew): bool + { + $product = $this->getOwner(); + + // hold off on updating the title and SKU if we are creating the shell of the variant ready for editing + if ($product && (!$this->getIsDraft() || !$this->ruleset->inScenarios(VariantRules::SCENARIO_ESSENTIALS))) { + $this->updateTitle($product); + $this->updateSku($product); + } + + // Set the field layout + $productType = $product->getType(); + $this->fieldLayoutId = $productType->variantFieldLayoutId; + + // Validate shipping category ID is available for this product type + $availableShippingCategoryIds = collect($this->availableShippingCategories())->pluck('id')->all(); + + // If the current shipping category ID is not in the available categories, set it to the default one + if (!in_array($this->getShippingCategoryId(), $availableShippingCategoryIds)) { + $defaultShippingCategory = app(ShippingCategories::class)->getDefaultShippingCategory($this->getStoreId()); + $this->setShippingCategoryId($defaultShippingCategory->id); + } + + return parent::beforeSave($isNew); + } + + #[Override] + public function afterAssignedId(): void + { + if (ElementHelper::isDraftOrRevision($this)) { + return; + } + + $product = $this->getOwner(); + + if ($product) { + $this->updateTitle($product); + } + } + + #[Override] + public function beforeRestore(): bool + { + if (!parent::beforeRestore()) { + return false; + } + + // Check to see if any other purchasable has the same SKU and update this one before restore + $found = DB::table(Table::PURCHASABLES . ' as p') + ->leftJoin(CraftTable::ELEMENTS . ' as e', 'p.id', '=', 'e.id') + ->whereNull('e.dateDeleted') + ->where('p.sku', $this->getSku()) + ->where('e.id', '!=', $this->getId()) + ->count(); + + if ($found) { + // Set new SKU in memory + $this->sku = $this->getSku() . '-1'; + + // Update purchasable table with new SKU + DB::table(Table::PURCHASABLES) + ->where('id', $this->getId()) + ->update(['sku' => $this->sku]); + } + + return true; + } + + /** + * @throws \RuntimeException + */ + #[Override] + public function getSearchKeywords(string $attribute): string + { + if ($attribute == 'productTitle') { + return $this->getOwner()->title ?? ''; + } + + return parent::getSearchKeywords($attribute); + } + + /** + * @return ShippingCategory[] + */ + #[Override] + protected function availableShippingCategories(): array + { + $allAvailableShippingCategories = parent::availableShippingCategories(); + + $productTypeId = $this->getPrimaryOwner()?->getType()->id; + + if (!$productTypeId) { + return [app(ShippingCategories::class)->getDefaultShippingCategory($this->storeId)]; + } + + // Limit to only those for this product type + $categoryIds = collect(app(ShippingCategories::class)->getShippingCategoriesByProductTypeId($productTypeId))->pluck('id')->all(); + $available = collect($allAvailableShippingCategories)->filter(fn(ShippingCategory $category) => in_array($category->id, $categoryIds)); + + if ($available->isEmpty()) { + return [app(ShippingCategories::class)->getDefaultShippingCategory($this->storeId)]; + } + + return $available->values()->all(); + } + + /** + * @return TaxCategory[] + */ + #[Override] + protected function availableTaxCategories(): array + { + $allAvailableTaxCategories = parent::availableTaxCategories(); + + $productTypeId = $this->getPrimaryOwner()?->getType()->id; + + if (!$productTypeId) { + return [app(TaxCategories::class)->getDefaultTaxCategory()]; + } + + // Limit to only those for this product type + $categoryIds = collect(app(TaxCategories::class)->getTaxCategoriesByProductTypeId($productTypeId))->pluck('id')->all(); + $available = collect($allAvailableTaxCategories)->filter(fn(TaxCategory $category) => in_array($category->id, $categoryIds)); + + if ($available->isEmpty()) { + return [app(TaxCategories::class)->getDefaultTaxCategory()]; + } + + return $available->values()->all(); + } + + #[Override] + protected static function defineSources(string $context): array + { + $sources = Product::sources($context); + + // Ensure we don't inherit any product structure things from products. + foreach ($sources as $key => $source) { + $sources[$key]['defaultSort'] = ['postDate', 'desc']; + foreach (['structureId', 'structureEditable'] as $unsetKey) { + if (isset($sources[$key][$unsetKey])) { + unset($sources[$key][$unsetKey]); + } + } + } + + return $sources; + } + + #[Override] + protected static function defineActions(string $source): array + { + $actions = parent::defineActions($source); + + // Restore + $actions[] = ElementActions::createAction([ + 'type' => Restore::class, + 'successMessage' => t('Variants restored.', category: 'commerce'), + 'partialSuccessMessage' => t('Some variants restored.', category: 'commerce'), + 'failMessage' => t('Variants not restored.', category: 'commerce'), + ], static::class); + + if ($source === '__IMP__') { + $actions[] = ['type' => SetDefaultVariant::class]; + } + + $actions[] = ['type' => Copy::class]; + + return $actions; + } + + #[Override] + protected static function defineTableAttributes(): array + { + return array_merge(parent::defineTableAttributes(), [ + 'product' => ['label' => t('Product', category: 'commerce')], + 'isDefault' => ['label' => t('Default', category: 'commerce')], + 'promotable' => ['label' => t('Promotable', category: 'commerce')], + ]); + } + + #[Override] + protected static function defineDefaultTableAttributes(string $source): array + { + // Only add product as a `product` if we are viewing an implicit table + $extras = ['isDefault']; + + if ($source !== '__IMP__') { + $extras[] = 'product'; + } + + return [...parent::defineDefaultTableAttributes($source), ...$extras]; + } + + #[Override] + protected static function defineSearchableAttributes(): array + { + return [...parent::defineSearchableAttributes(), ...['productTitle']]; + } + + #[Override] + protected static function defineCardAttributes(): array + { + return array_merge(parent::defineCardAttributes(), [ + 'product' => [ + 'label' => t('Product', category: 'commerce'), + ], + 'isDefault' => [ + 'label' => t('Default', category: 'commerce'), + ], + 'promotable' => [ + 'label' => t('Promotable', category: 'commerce'), + ], + ]); + } + + #[Override] + protected function attributeHtml(string $attribute): string + { + if ($attribute === 'product') { + $product = $this->getOwner(); + if (!$product) { + return ''; + } + + return sprintf(' %s', $product->getStatus(), Html::encode($product->title)); + } + + if ($attribute === 'isDefault') { + if ($this->isDefault) { + $isDefault = Html::tag('span', '', [ + 'class' => 'checkbox-icon', + 'role' => 'img', + 'title' => t('Enabled'), + 'aria' => [ + 'label' => t('Enabled'), + ], + ]); + return $isDefault . Html::tag('span', ' ' . t('Default', category: 'commerce'), [ + 'class' => 'card-only-label', + 'style' => 'display:none;', + ]) . Html::tag('style', '.card-content .card-only-label { display: inline !important; }'); + } + } + + if ($attribute === 'promotable') { + if ($this->promotable) { + $promotable = Html::tag('span', '', [ + 'class' => 'checkbox-icon', + 'role' => 'img', + 'title' => t('Enabled'), + 'aria' => [ + 'label' => t('Enabled'), + ], + ]); + return $promotable . Html::tag('span', ' ' . t('Promotable', category: 'commerce'), [ + 'class' => 'card-only-label', + 'style' => 'display:none;', + ]) . Html::tag('style', '.card-content .card-only-label { display: inline !important; }'); + } + } + + return parent::attributeHtml($attribute); + } + + /** + * Variants are always owned by products. + */ + protected function ownerType(): ?string + { + return Product::class; + } +} diff --git a/src/Catalog/Events/CustomizeProductSnapshotDataEvent.php b/src/Catalog/Events/CustomizeProductSnapshotDataEvent.php new file mode 100644 index 0000000000..cac2728e4d --- /dev/null +++ b/src/Catalog/Events/CustomizeProductSnapshotDataEvent.php @@ -0,0 +1,16 @@ + ['fld-product-title-field-icon', 'fld-field-hidden', 'hidden'], + ]) . + parent::selectorInnerHtml(); + } + + #[Override] + protected function translatable(?ElementInterface $element = null, bool $static = false): bool + { + if (!$element instanceof Product) { + throw new InvalidArgumentException(sprintf('%s can only be used in product field layouts.', self::class)); + } + + return $element->getType()->productTitleTranslationMethod !== TranslationMethod::None->value; + } + + #[Override] + protected function translationDescription(?ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Product) { + throw new InvalidArgumentException(sprintf('%s can only be used in product field layouts.', self::class)); + } + + /** @phpstan-ignore-next-line nullsafe.neverNull (productTitleTranslationMethod is an uncast, free-form DB string column - tryFrom() genuinely can return null) */ + return TranslationMethod::tryFrom($element->getType()->productTitleTranslationMethod)?->description(); + } + + protected function inputHtml(?ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Product) { + throw new InvalidArgumentException('ProductTitleField can only be used in product field layouts.'); + } + + if (!$element->getType()->hasProductTitleField) { + return null; + } + + return parent::inputHtml($element, $static); + } +} diff --git a/src/Catalog/FieldLayoutElements/VariantTitleField.php b/src/Catalog/FieldLayoutElements/VariantTitleField.php new file mode 100644 index 0000000000..c053bb146a --- /dev/null +++ b/src/Catalog/FieldLayoutElements/VariantTitleField.php @@ -0,0 +1,63 @@ + ['fld-variant-title-field-icon', 'fld-field-hidden', 'hidden'], + ]) . + parent::selectorInnerHtml(); + } + + #[Override] + protected function translatable(?ElementInterface $element = null, bool $static = false): bool + { + if (!$element instanceof Variant) { + throw new InvalidArgumentException(sprintf('%s can only be used in variant field layouts.', self::class)); + } + + return $element->getOwner()->getType()->variantTitleTranslationMethod !== TranslationMethod::None->value; + } + + #[Override] + protected function translationDescription(?ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Variant) { + throw new InvalidArgumentException(sprintf('%s can only be used in variant field layouts.', self::class)); + } + + /** @phpstan-ignore-next-line nullsafe.neverNull (variantTitleTranslationMethod is an uncast, free-form DB string column - tryFrom() genuinely can return null) */ + return TranslationMethod::tryFrom($element->getOwner()->getType()->variantTitleTranslationMethod)?->description(); + } + + protected function inputHtml(?ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Variant) { + throw new InvalidArgumentException('VariantTitleField can only be used in variant field layouts.'); + } + + if (!$element->getOwner()->getType()->hasVariantTitleField) { + return null; + } + + return parent::inputHtml($element, $static); + } +} diff --git a/src/Catalog/FieldLayoutElements/VariantsField.php b/src/Catalog/FieldLayoutElements/VariantsField.php new file mode 100644 index 0000000000..6d696771f6 --- /dev/null +++ b/src/Catalog/FieldLayoutElements/VariantsField.php @@ -0,0 +1,59 @@ +attribute()); + + $maxVariants = $element->getType()->maxVariants; + + return $element->getVariantManager()->getIndexHtml($element, [ + 'canCreate' => !$static, + 'canPaste' => !$static, + 'minElements' => 0, + 'maxElements' => $maxVariants ?? null, + 'allowedViewModes' => [ElementIndexViewMode::Cards, ElementIndexViewMode::Table], + 'sortable' => !$static, + 'fieldLayouts' => [$element->getType()->getVariantFieldLayout()], + ]); + } +} diff --git a/src/Catalog/Fields/Products.php b/src/Catalog/Fields/Products.php new file mode 100644 index 0000000000..c06c55f8eb --- /dev/null +++ b/src/Catalog/Fields/Products.php @@ -0,0 +1,73 @@ + $config */ + public function __construct(array $config = []) + { + // Never needed and allows us to instantiate the field while ignoring old setting until the Product field migration has run. + unset($config['targetLocale']); + parent::__construct($config); + } + + #[Override] + public static function icon(): string + { + return 'tag'; + } + + #[Override] + public static function displayName(): string + { + return t('Commerce Products', category: 'commerce'); + } + + #[Override] + public static function defaultSelectionLabel(): string + { + return t('Add a product', category: 'commerce'); + } + + #[Override] + public function getContentGqlType(): array|Type + { + return [ + 'name' => $this->handle, + 'type' => Type::listOf(ProductInterface::getType()), + 'args' => ProductArguments::getArguments(), + 'resolve' => ProductResolver::class . '::resolve', + 'complexity' => GqlHelper::relatedArgumentComplexity(GqlService::GRAPHQL_COMPLEXITY_EAGER_LOAD), + ]; + } + + public static function elementType(): string + { + return Product::class; + } +} diff --git a/src/Catalog/Fields/Variants.php b/src/Catalog/Fields/Variants.php new file mode 100644 index 0000000000..af498f2467 --- /dev/null +++ b/src/Catalog/Fields/Variants.php @@ -0,0 +1,55 @@ + $this->handle, + 'type' => Type::listOf(VariantInterface::getType()), + 'args' => VariantArguments::getArguments(), + 'resolve' => VariantResolver::class . '::resolve', + 'complexity' => GqlHelper::relatedArgumentComplexity(GqlService::GRAPHQL_COMPLEXITY_EAGER_LOAD), + ]; + } + + public static function elementType(): string + { + return Variant::class; + } +} diff --git a/src/Catalog/LinkTypes/ProductLinkType.php b/src/Catalog/LinkTypes/ProductLinkType.php new file mode 100644 index 0000000000..ce26db0752 --- /dev/null +++ b/src/Catalog/LinkTypes/ProductLinkType.php @@ -0,0 +1,45 @@ +getAllProductTypes(); + $sites = Sites::getAllSites(); + + foreach ($productTypes as $productType) { + $siteSettings = $productType->getSiteSettings(); + foreach ($sites as $site) { + if (isset($siteSettings[$site->id]) && $siteSettings[$site->id]->hasUrls) { + $sources[] = "productType:$productType->uid"; + break; + } + } + } + + $sources = array_values(array_unique($sources)); + + if (!empty($sources)) { + array_unshift($sources, '*'); + } + + return $sources; + } +} diff --git a/src/Catalog/Models/CatalogPricing.php b/src/Catalog/Models/CatalogPricing.php new file mode 100644 index 0000000000..90e207b0a2 --- /dev/null +++ b/src/Catalog/Models/CatalogPricing.php @@ -0,0 +1,77 @@ +_purchasable !== null) { + return $this->_purchasable; + } + + if ($this->purchasableId === null || $this->storeId === null) { + return null; + } + + if (!$store = app(Stores::class)->getStoreById($this->storeId)) { + throw new \InvalidArgumentException('Invalid store ID: ' . $this->storeId); + } + + $site = $store->getSites()->first(); + + $this->_purchasable = app(Purchasables::class)->getPurchasableById($this->purchasableId, $site->id); + + return $this->_purchasable; + } + + public function getCatalogPricingRule(): ?CatalogPricingRule + { + if ($this->_catalogPricingRule !== null) { + return $this->_catalogPricingRule; + } + + if (!$this->catalogPricingRuleId) { + return null; + } + + $this->_catalogPricingRule = app(CatalogPricingRules::class)->getCatalogPricingRuleById($this->catalogPricingRuleId, $this->storeId); + + return $this->_catalogPricingRule; + } +} diff --git a/src/Catalog/Models/CatalogPricingRule.php b/src/Catalog/Models/CatalogPricingRule.php new file mode 100644 index 0000000000..7c5be6e224 --- /dev/null +++ b/src/Catalog/Models/CatalogPricingRule.php @@ -0,0 +1,347 @@ + ['required', Rule::in([ + PricingCatalogRuleRecord::APPLY_TO_PERCENT, + PricingCatalogRuleRecord::APPLY_TO_FLAT, + PricingCatalogRuleRecord::APPLY_BY_PERCENT, + PricingCatalogRuleRecord::APPLY_BY_FLAT, + ])], + 'enabled' => ['boolean'], + 'name' => ['required', 'string'], + ]; + } + + public function getCpEditUrl(): string + { + return $this->getStore()->getStoreSettingsUrl('pricing-rules/' . $this->id); + } + + public function getApplyAmountAsPercent(): string + { + return I18N::getFormatter()->asPercent(-($this->applyAmount ?? 0.0)); + } + + public function getApplyAmountAsFlat(): string + { + return $this->applyAmount !== null ? (string)($this->applyAmount * -1) : '0'; + } + + public function setMetadata(string|array $metadata): void + { + $metadata = Json::decodeIfJson($metadata); + + if (!is_array($metadata)) { + $metadata = []; + } + + $this->_metadata = $metadata; + } + + public function getMetadata(): array + { + return $this->_metadata; + } + + public function getPurchasableIds(): ?array + { + if ($this->_purchasableIds === null) { + $siteIds = $this->getStore()->getSites()->map(fn($site) => $site->id)->all(); + $productVariantIds = null; + + if (!empty($this->getProductCondition()->getConditionRules())) { + $productQuery = Product::find(); + $productQuery->siteId($siteIds); + $productCondition = $this->getProductCondition(); + $productCondition->modifyQuery($productQuery); + + $productVariantIds = []; + if ($productIds = $productQuery->ids()) { + $productVariantIdsQuery = Variant::find() + ->siteId($siteIds) + ->productId($productIds); + + if ($this->isPromotionalPrice) { + $productVariantIdsQuery->andWhere(Db::parseBooleanParam('purchasables_stores.promotable', true)); + } + + $productVariantIds = $productVariantIdsQuery->ids(); + } + } + + if ($productVariantIds === []) { + $this->_purchasableIds = []; + return $this->_purchasableIds; + } + + $this->_purchasableIds = $productVariantIds; + + $variantIds = $productVariantIds; + if (!empty($this->getVariantCondition()->getConditionRules())) { + $variantQuery = Variant::find(); + $variantQuery->siteId($siteIds); + $variantCondition = $this->getVariantCondition(); + $variantCondition->modifyQuery($variantQuery); + + if ($this->isPromotionalPrice) { + $variantQuery->andWhere(Db::parseBooleanParam('purchasables_stores.promotable', true)); + } + + if ($productVariantIds !== null) { + $variantQuery->andWhere(['commerce_variants.id' => $productVariantIds]); + } + + $variantIds = $variantQuery->ids(); + } + + if ($variantIds === []) { + $this->_purchasableIds = []; + return $this->_purchasableIds; + } + + $this->_purchasableIds = $variantIds; + + if (!empty($this->getPurchasableCondition()->getConditionRules())) { + $purchasableQuery = Purchasable::find(); + $purchasableCondition = $this->getPurchasableCondition(); + $purchasableCondition->modifyQuery($purchasableQuery); + + if ($variantIds !== null) { + $purchasableQuery->andWhere(['id' => $variantIds]); + } + + if ($this->isPromotionalPrice) { + $purchasableQuery->andWhere(Db::parseBooleanParam('purchasables_stores.promotable', true)); + } + + /** @phpstan-ignore-next-line */ + $purchasableQuery->on(ElementQuery::EVENT_AFTER_PREPARE, $this->afterPreparePurchasableQuery(...), ['siteIds' => $siteIds]); + $this->_purchasableIds = $purchasableQuery->ids(); + /** @phpstan-ignore-next-line */ + $purchasableQuery->off(ElementQuery::EVENT_AFTER_PREPARE, $this->afterPreparePurchasableQuery(...)); + } + + $this->_purchasableIds = $this->_purchasableIds !== null ? array_unique($this->_purchasableIds) : null; + } + + return $this->_purchasableIds; + } + + public function afterPreparePurchasableQuery(CancelableEvent $event): void + { + foreach ($event->sender->subQuery->where as &$value) { + if (is_array($value) && isset($value['elements_sites.siteId'])) { + $value['elements_sites.siteId'] = $event->data['siteIds']; + } + } + + $event->sender->subQuery->join[] = ['LEFT JOIN', ['sitestores' => Table::SITESTORES], '[[elements_sites.siteId]] = [[sitestores.siteId]]']; + $event->sender->subQuery->join[] = ['LEFT JOIN', ['purchasables_stores' => Table::PURCHASABLES_STORES], '[[purchasables_stores.storeId]] = [[sitestores.storeId]] AND [[purchasables_stores.purchasableId]] = [[elements.id]]']; + } + + public function getCustomerCondition(): ElementConditionInterface + { + $condition = $this->_customerCondition ?? new CatalogPricingRuleCustomerCondition(); + $condition->mainTag = 'div'; + $condition->name = 'customerCondition'; + + return $condition; + } + + public function setCustomerCondition(ElementConditionInterface|string|array $condition): void + { + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof ElementConditionInterface) { + $condition['class'] = CatalogPricingRuleCustomerCondition::class; + $condition = Conditions::createCondition($condition); + } + $condition->forProjectConfig = false; + /** @phpstan-ignore-next-line */ + $this->_customerCondition = $condition; + } + + public function getPurchasableCondition(): ElementConditionInterface + { + $condition = $this->_purchasableCondition ?? new CatalogPricingRulePurchasableCondition(); + $condition->mainTag = 'div'; + $condition->name = 'purchasableCondition'; + + return $condition; + } + + public function setPurchasableCondition(ElementConditionInterface|string|array $condition): void + { + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof ElementConditionInterface) { + $condition['class'] = CatalogPricingRulePurchasableCondition::class; + $condition = Conditions::createCondition($condition); + } + $condition->forProjectConfig = false; + /** @phpstan-ignore-next-line */ + $this->_purchasableCondition = $condition; + } + + public function getProductCondition(): ElementConditionInterface + { + $condition = $this->_productCondition ?? new CatalogPricingRuleProductCondition(); + $condition->mainTag = 'div'; + $condition->name = 'productCondition'; + $condition->elementType = Product::class; + + return $condition; + } + + public function setProductCondition(ElementConditionInterface|string|array $condition): void + { + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof ElementConditionInterface) { + $condition['class'] = CatalogPricingRuleProductCondition::class; + $condition = Conditions::createCondition($condition); + } + $condition->forProjectConfig = false; + /** @phpstan-ignore-next-line */ + $this->_productCondition = $condition; + } + + public function getVariantCondition(): ElementConditionInterface + { + $condition = $this->_variantCondition ?? new CatalogPricingRuleVariantCondition(); + $condition->mainTag = 'div'; + $condition->name = 'variantCondition'; + $condition->elementType = Variant::class; + + return $condition; + } + + public function setVariantCondition(ElementConditionInterface|string|array $condition): void + { + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof ElementConditionInterface) { + $condition['class'] = CatalogPricingRuleVariantCondition::class; + $condition = Conditions::createCondition($condition); + } + $condition->forProjectConfig = false; + /** @phpstan-ignore-next-line */ + $this->_variantCondition = $condition; + } + + public function getUserIds(): ?array + { + if ($this->_userIds === null && !empty($this->getCustomerCondition()->getConditionRules())) { + $userQuery = User::find(); + $this->getCustomerCondition()->modifyQuery($userQuery); + $this->_userIds = $userQuery->ids(); + } + + return $this->_userIds; + } + + public function getRulePriceFromPrice(float $price): float + { + $price = match ($this->apply) { + PricingCatalogRuleRecord::APPLY_BY_PERCENT => $price * (1 + $this->applyAmount), + PricingCatalogRuleRecord::APPLY_BY_FLAT => $price + $this->applyAmount, + PricingCatalogRuleRecord::APPLY_TO_PERCENT => $price * -$this->applyAmount, + PricingCatalogRuleRecord::APPLY_TO_FLAT => -$this->applyAmount, + default => $price, + }; + + // TODO: migrate to app(Currencies::class) once service migrated to src/ + $price = (float)app(Currencies::class)->getTeller($this->getStore()->getCurrency())->convertToString($price); + + return max($price, 0); + } +} diff --git a/src/Catalog/Models/Product.php b/src/Catalog/Models/Product.php new file mode 100644 index 0000000000..cdff8558ee --- /dev/null +++ b/src/Catalog/Models/Product.php @@ -0,0 +1,29 @@ +_productType !== null) { + return $this->_productType; + } + + if (!$this->productTypeId) { + throw new \InvalidArgumentException('Product type site is missing its product type ID'); + } + + if (($this->_productType = app(ProductTypes::class)->getProductTypeById($this->productTypeId)) === null) { + throw new \InvalidArgumentException('Invalid product type ID: ' . $this->productTypeId); + } + + return $this->_productType; + } + + public function setProductType(ProductType $productType): void + { + $this->_productType = $productType; + } + + public function getSite(): Site + { + if ($this->_site !== null) { + return $this->_site; + } + + if (!$this->siteId) { + throw new \InvalidArgumentException('Product type site is missing its site ID'); + } + + if (($this->_site = Sites::getSiteById($this->siteId)) === null) { + throw new \InvalidArgumentException('Invalid site ID: ' . $this->siteId); + } + + return $this->_site; + } + + #[\Override] + public function getRules(): array + { + if ($this->uriFormatIsRequired) { + return ['uriFormat' => ['required']]; + } + + return []; + } +} diff --git a/src/Catalog/Models/Variant.php b/src/Catalog/Models/Variant.php new file mode 100644 index 0000000000..668d3fafbb --- /dev/null +++ b/src/Catalog/Models/Variant.php @@ -0,0 +1,29 @@ +previewTargets)) { + $this->previewTargets = [ + [ + 'label' => t('Primary {type} page', [ + 'type' => Product::lowerDisplayName(), + ], category: 'app'), + 'urlFormat' => '{url}', + ], + ]; + } + + if ($this->productTitleTranslationKeyFormat === '') { + $this->productTitleTranslationKeyFormat = null; + } + + if ($this->variantTitleTranslationKeyFormat === '') { + $this->variantTitleTranslationKeyFormat = null; + } + + if ($this->slugTranslationKeyFormat === '') { + $this->slugTranslationKeyFormat = null; + } + } + + public function __toString(): string + { + return (string)$this->handle; + } + + public function getHandle(): ?string + { + return $this->handle; + } + + #[\Override] + public function afterValidate(?Validator $validator = null): void + { + $this->validateFieldLayout(); + $this->validateVariantFieldLayout(); + $this->validatePreviewTargets(); + + if (empty($this->getSiteSettings())) { + $this->errors()->add('siteSettings', t('At least one site must be enabled for the product type.', category: 'commerce')); + } + } + + public function getCpEditUrl(): string + { + return Url::cpUrl('commerce/settings/producttypes/' . $this->id); + } + + public function getCpEditVariantUrl(): string + { + return Url::cpUrl('commerce/settings/producttypes/' . $this->id . '/variant'); + } + + /** + * @return int[] + */ + public function getSiteIds(): array + { + return array_keys($this->getSiteSettings()); + } + + /** + * @return ProductTypeSite[] + */ + public function getSiteSettings(): array + { + if (isset($this->_siteSettings)) { + return $this->_siteSettings; + } + + if (!$this->id) { + return []; + } + + $this->setSiteSettings(Arr::keyBy(app(ProductTypes::class)->getProductTypeSites($this->id), 'siteId')); + + return $this->_siteSettings; + } + + /** + * @param ProductTypeSite[] $siteSettings + */ + public function setSiteSettings(array $siteSettings): void + { + $this->_siteSettings = $siteSettings; + + foreach ($this->_siteSettings as $settings) { + $settings->setProductType($this); + } + } + + /** + * @return ShippingCategory[] + */ + public function getShippingCategories(): array + { + if ($this->_shippingCategories === null && $this->id) { + $this->_shippingCategories = app(ShippingCategories::class)->getShippingCategoriesByProductTypeId($this->id); + } + + return $this->_shippingCategories ?? []; + } + + /** + * @param int[]|ShippingCategory[] $shippingCategories + */ + public function setShippingCategories(array $shippingCategories): void + { + $categories = []; + foreach ($shippingCategories as $category) { + if (is_numeric($category)) { + if ($category = app(ShippingCategories::class)->getShippingCategoryById($category)) { + $categories[$category->id] = $category; + } + } elseif ($category instanceof ShippingCategory) { + if ($category = app(ShippingCategories::class)->getShippingCategoryById($category->id)) { + $categories[$category->id] = $category; + } + } + } + + $this->_shippingCategories = $categories; + } + + /** + * @return TaxCategory[] + */ + public function getTaxCategories(): array + { + if ($this->_taxCategories === null && $this->id) { + $this->_taxCategories = app(TaxCategories::class)->getTaxCategoriesByProductTypeId($this->id); + } + + return $this->_taxCategories ?? []; + } + + /** + * @param int[]|TaxCategory[] $taxCategories + */ + public function setTaxCategories(array $taxCategories): void + { + $categories = []; + foreach ($taxCategories as $category) { + if (is_numeric($category)) { + if ($category = app(TaxCategories::class)->getTaxCategoryById($category)) { + $categories[$category->id] = $category; + } + } elseif ($category instanceof TaxCategory) { + if ($category = app(TaxCategories::class)->getTaxCategoryById($category->id)) { + $categories[$category->id] = $category; + } + } + } + + $this->_taxCategories = $categories; + } + + public function getFieldLayout(): FieldLayout + { + return $this->getProductFieldLayout(); + } + + public function getProductFieldLayout(): FieldLayout + { + if (isset($this->_productFieldLayout)) { + return $this->_productFieldLayout; + } + + $fieldLayout = $this->_resolveFieldLayout($this->fieldLayoutId, Product::class); + + // If this product type has variants, make sure the Variants field is in the layout somewhere + if (!$fieldLayout->isFieldIncluded('variants')) { + $layoutTabs = $fieldLayout->getTabs(); + $variantTabName = t('Variants', category: 'commerce'); + if (Arr::contains($layoutTabs, 'name', $variantTabName)) { + $variantTabName .= ' ' . Str::random(10); + } + + $contentTab = new FieldLayoutTab(); + $contentTab->setLayout($fieldLayout); + $contentTab->name = $variantTabName; + $contentTab->setElements([ + ['type' => VariantsField::class], + ]); + + $layoutTabs[] = $contentTab; + $fieldLayout->setTabs($layoutTabs); + } + + return $this->_productFieldLayout = $fieldLayout; + } + + public function setProductFieldLayout(FieldLayout $fieldLayout): void + { + $this->_productFieldLayout = $fieldLayout; + } + + public function getVariantFieldLayout(): FieldLayout + { + if (isset($this->_variantFieldLayout)) { + return $this->_variantFieldLayout; + } + + return $this->_variantFieldLayout = $this->_resolveFieldLayout($this->variantFieldLayoutId, Variant::class); + } + + public function setVariantFieldLayout(FieldLayout $fieldLayout): void + { + $this->_variantFieldLayout = $fieldLayout; + } + + /** + * @param class-string $elementType + */ + private function _resolveFieldLayout(?int $id, string $elementType): FieldLayout + { + if ($id) { + $fieldLayout = Fields::getLayoutById($id, true); + if (!$fieldLayout) { + throw new \RuntimeException('Invalid field layout ID: ' . $id); + } + } else { + $fieldLayout = new FieldLayout([ + 'type' => $elementType, + ]); + } + + $fieldLayout->provider = $this; + + return $fieldLayout; + } + + public function validateFieldLayout(): void + { + $fieldLayout = $this->getFieldLayout(); + + $fieldLayout->reservedFieldHandles = [ + 'cheapestVariant', + 'defaultVariant', + 'variants', + ]; + + if (!$fieldLayout->validate()) { + $this->addModelErrors($fieldLayout, 'fieldLayout'); + } + } + + public function validateVariantFieldLayout(): void + { + $variantFieldLayout = $this->getVariantFieldLayout(); + + $variantFieldLayout->reservedFieldHandles = [ + 'availableForPurchase', + 'description', + 'freeShipping', + 'hasUnlimitedStock', + 'height', + 'length', + 'maxQty', + 'minQty', + 'price', + 'product', + 'promotable', + 'promotionalPrice', + 'sku', + 'stock', + 'weight', + 'width', + ]; + + if (!$variantFieldLayout->validate()) { + $this->addModelErrors($variantFieldLayout, 'variantFieldLayout'); + } + } + + public function validatePreviewTargets(): void + { + $hasErrors = false; + + foreach ($this->previewTargets as &$target) { + $target['label'] = trim((string)$target['label']); + $target['urlFormat'] = trim((string)$target['urlFormat']); + + if ($target['label'] === '') { + $target['label'] = ['value' => $target['label'], 'hasErrors' => true]; + $hasErrors = true; + } + } + unset($target); + + if ($hasErrors) { + $this->errors()->add('previewTargets', t('All targets must have a label.', category: 'app')); + } + } + + #[\Deprecated(message: 'in 4.0.0. Use `ProductType::variantTitleFormat` instead.')] + public function getTitleFormat(): string + { + Deprecator::log('craft\commerce\models\ProductType::titleFormat', 'Getting `ProductType::titleFormat` has been deprecated. Use `ProductType::variantTitleFormat` instead.'); + return $this->variantTitleFormat; + } + + #[\Deprecated(message: 'in 4.0.0. Use `ProductType::variantTitleFormat` instead.')] + public function setTitleFormat(string $titleFormat): void + { + Deprecator::log('craft\commerce\models\ProductType::titleFormat', 'Setting `ProductType::titleFormat` has been deprecated. Use `ProductType::variantTitleFormat` instead.'); + $this->variantTitleFormat = $titleFormat; + } + + public function extraFields(): array + { + return ['taxCategories', 'shippingCategories', 'siteSettings']; + } + + public function getConfig(): array + { + $config = [ + 'name' => $this->name, + 'handle' => $this->handle, + 'enableVersioning' => $this->enableVersioning, + 'hasDimensions' => $this->hasDimensions, + 'maxVariants' => $this->maxVariants, + + 'hasVariantTitleField' => $this->hasVariantTitleField, + 'variantTitleFormat' => $this->variantTitleFormat, + 'variantTitleTranslationMethod' => $this->variantTitleTranslationMethod, + 'variantTitleTranslationKeyFormat' => $this->variantTitleTranslationKeyFormat, + 'variantUiLabelFormat' => $this->variantUiLabelFormat, + + 'hasProductTitleField' => $this->hasProductTitleField, + 'productTitleFormat' => $this->productTitleFormat, + 'productTitleTranslationMethod' => $this->productTitleTranslationMethod, + 'productTitleTranslationKeyFormat' => $this->productTitleTranslationKeyFormat, + 'productUiLabelFormat' => $this->productUiLabelFormat, + + 'showSlugField' => $this->showSlugField, + 'slugTranslationMethod' => $this->slugTranslationMethod, + 'slugTranslationKeyFormat' => $this->slugTranslationKeyFormat, + + 'propagationMethod' => $this->propagationMethod->value, + + 'skuFormat' => $this->skuFormat, + 'descriptionFormat' => $this->descriptionFormat, + 'siteSettings' => [], + + 'isStructure' => $this->isStructure, + 'maxLevels' => $this->maxLevels, + 'defaultPlacement' => $this->defaultPlacement, + ]; + + if (!empty($this->previewTargets)) { + $config['previewTargets'] = ProjectConfigHelper::packAssociativeArray(array_values($this->previewTargets)); + } + + if ($this->isStructure) { + $config['structure'] = [ + 'uid' => $this->structureId ? Db::uidById(CraftTable::STRUCTURES, $this->structureId) : (string)Str::uuid(), + ]; + } + + $generateLayoutConfig = function(FieldLayout $fieldLayout): array { + $fieldLayoutConfig = $fieldLayout->getConfig(); + + if ($fieldLayoutConfig) { + if (empty($fieldLayout->id)) { + $layoutUid = (string)Str::uuid(); + $fieldLayout->uid = $layoutUid; + } else { + $layoutUid = Db::uidById(CraftTable::FIELDLAYOUTS, $fieldLayout->id); + } + + return [$layoutUid => $fieldLayoutConfig]; + } + + return []; + }; + + $config['productFieldLayouts'] = $generateLayoutConfig($this->getFieldLayout()); + $config['variantFieldLayouts'] = $generateLayoutConfig($this->getVariantFieldLayout()); + + $allSiteSettings = $this->getSiteSettings(); + + foreach ($allSiteSettings as $siteId => $settings) { + $siteUid = Db::uidById(CraftTable::SITES, $siteId); + $config['siteSettings'][$siteUid] = [ + 'hasUrls' => $settings->hasUrls, + 'enabledByDefault' => $settings->enabledByDefault, + 'uriFormat' => $settings->uriFormat, + 'template' => $settings->template, + ]; + } + + return $config; + } +} diff --git a/src/Catalog/ProductType/Exceptions/ProductTypeNotFoundException.php b/src/Catalog/ProductType/Exceptions/ProductTypeNotFoundException.php new file mode 100644 index 0000000000..c8fb7aa769 --- /dev/null +++ b/src/Catalog/ProductType/Exceptions/ProductTypeNotFoundException.php @@ -0,0 +1,9 @@ + 'boolean', + 'maxLevels' => 'integer', + 'structureId' => 'integer', + 'fieldLayoutId' => 'integer', + 'variantFieldLayoutId' => 'integer', + 'enableVersioning' => 'boolean', + 'maxVariants' => 'integer', + 'hasDimensions' => 'boolean', + 'hasVariantTitleField' => 'boolean', + 'hasProductTitleField' => 'boolean', + 'showSlugField' => 'boolean', + 'previewTargets' => 'array', + ]; +} diff --git a/src/Catalog/ProductType/Models/ProductTypeSite.php b/src/Catalog/ProductType/Models/ProductTypeSite.php new file mode 100644 index 0000000000..a7fc28b25f --- /dev/null +++ b/src/Catalog/ProductType/Models/ProductTypeSite.php @@ -0,0 +1,31 @@ + 'integer', + 'siteId' => 'integer', + 'hasUrls' => 'boolean', + 'enabledByDefault' => 'boolean', + ]; +} diff --git a/src/Catalog/ProductType/ProductTypes.php b/src/Catalog/ProductType/ProductTypes.php new file mode 100644 index 0000000000..3e03990121 --- /dev/null +++ b/src/Catalog/ProductType/ProductTypes.php @@ -0,0 +1,767 @@ + */ + private array $_siteSettingsByProductId = []; + + /** @var array interim storage for product types being saved via control panel */ + private array $_savingProductTypes = []; + + /** + * @return ProductType[] An array of all the viewable product types. + */ + public function getViewableProductTypes(): array + { + if (app()->runningInConsole()) { + return $this->getAllProductTypes(); + } + + $user = request()->craftUser(); + + if (!$user) { + return []; + } + + $viewableProductTypeIds = $this->getViewableProductTypeIds(); + + return collect($this->getAllProductTypes()) + ->filter(fn(ProductType $productType) => in_array($productType->id, $viewableProductTypeIds)) + ->values() + ->all(); + } + + /** + * @return array An array of all the viewable product types' IDs. + */ + public function getViewableProductTypeIds(bool $anySite = false): array + { + $allProductTypes = $this->getAllProductTypes(); + + if (app()->runningInConsole()) { + return collect($allProductTypes)->pluck('id')->all(); + } + + $user = request()->craftUser(); + + if (!$user) { + return []; + } + + $viewableIds = []; + $cpSite = Cp::requestedSite(); + + foreach ($allProductTypes as $productType) { + if (!$user->can('commerce-viewProductType:' . $productType->uid)) { + continue; + } + + if (!$anySite && $cpSite && !isset($productType->getSiteSettings()[$cpSite->id])) { + continue; + } + + $viewableIds[] = $productType->id; + } + + return $viewableIds; + } + + /** + * @return array An array of all the product type IDs that are creatable by the current user. + */ + public function getCreatableProductTypeIds(): array + { + $allProductTypes = $this->getAllProductTypes(); + + if (app()->runningInConsole()) { + return collect($allProductTypes)->pluck('id')->all(); + } + + $user = request()->craftUser(); + + if (!$user) { + return []; + } + + $creatableIds = []; + + foreach ($allProductTypes as $productType) { + if ($user->can('commerce-createProductType:' . $productType->uid)) { + $creatableIds[] = $productType->id; + } + } + + return $creatableIds; + } + + /** + * @return ProductType[] + */ + public function getCreatableProductTypes(): array + { + $creatableProductTypeIds = $this->getCreatableProductTypeIds(); + + return collect($this->getAllProductTypes()) + ->filter(fn(ProductType $productType) => in_array($productType->id, $creatableProductTypeIds)) + ->values() + ->all(); + } + + /** + * @return int[] An array of all the product types' IDs. + */ + public function getAllProductTypeIds(): array + { + return collect($this->getAllProductTypes())->pluck('id')->all(); + } + + /** + * @return ProductType[] An array of all product types. + */ + public function getAllProductTypes(): array + { + if ($this->_allProductTypes !== null) { + return $this->_allProductTypes; + } + + return $this->_allProductTypes = ProductTypeRecord::query() + ->get() + ->map(fn(ProductTypeRecord $record) => $this->_toData($record)) + ->all(); + } + + public function getProductTypeByHandle(string $handle): ?ProductType + { + return collect($this->getAllProductTypes())->where('handle', $handle)->first(); + } + + public function getProductTypeById(int $productTypeId): ?ProductType + { + return collect($this->getAllProductTypes())->where('id', $productTypeId)->first(); + } + + public function getProductTypeByUid(string $uid): ?ProductType + { + return collect($this->getAllProductTypes())->where('uid', $uid)->first(); + } + + /** + * @return ProductType[] + */ + public function getProductTypesByTaxCategoryId(int $taxCategoryId): array + { + $ids = DB::table(Table::PRODUCTTYPES_TAXCATEGORIES) + ->where('taxCategoryId', $taxCategoryId) + ->pluck('productTypeId'); + + return collect($this->getAllProductTypes()) + ->filter(fn(ProductType $productType) => $ids->contains($productType->id)) + ->keyBy('id') + ->all(); + } + + /** + * @return ProductType[] + */ + public function getProductTypesByShippingCategoryId(int $shippingCategoryId): array + { + $ids = DB::table(Table::PRODUCTTYPES_SHIPPINGCATEGORIES) + ->where('shippingCategoryId', $shippingCategoryId) + ->pluck('productTypeId'); + + return collect($this->getAllProductTypes()) + ->filter(fn(ProductType $productType) => $ids->contains($productType->id)) + ->keyBy('id') + ->all(); + } + + /** + * @return ProductTypeSite[] The product type's site-specific settings. + */ + public function getProductTypeSites(int $productTypeId): array + { + if (!isset($this->_siteSettingsByProductId[$productTypeId])) { + $this->_siteSettingsByProductId[$productTypeId] = ProductTypeSiteRecord::query() + ->where('productTypeId', $productTypeId) + ->get() + ->map(fn(ProductTypeSiteRecord $record) => $this->_toSiteData($record)) + ->all(); + } + + return $this->_siteSettingsByProductId[$productTypeId]; + } + + /** + * @throws Throwable + */ + public function saveProductType(ProductType $productType, bool $runValidation = true): bool + { + $isNewProductType = !$productType->id; + + // TODO: migrate event firing to Laravel once event system is bridged + $legacyService = Plugin::getInstance()->getProductTypes(); + + if ($legacyService->hasEventHandlers(self::EVENT_BEFORE_SAVE_PRODUCTTYPE)) { + $event = new ProductTypeEvent( + productType: $productType, + isNew: $isNewProductType, + ); + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $legacyService->trigger(self::EVENT_BEFORE_SAVE_PRODUCTTYPE, $event); + } + + if ($runValidation && !$productType->validate()) { + Log::info('Product type not saved due to validation error.'); + + return false; + } + + if ($isNewProductType) { + $productType->uid = (string)Str::uuid(); + } else { + $existingRecord = ProductTypeRecord::query()->find($productType->id); + + if (!$existingRecord) { + throw new ProductTypeNotFoundException("No product type exists with the ID '$productType->id'"); + } + + $productType->uid = $existingRecord->uid; + } + + $this->_savingProductTypes[$productType->uid] = $productType; + + $configData = $productType->getConfig(); + $configPath = self::CONFIG_PRODUCTTYPES_KEY . '.' . $productType->uid; + ProjectConfig::set($configPath, $configData); + + if ($isNewProductType) { + $productType->id = CraftDb::idByUid(Table::PRODUCTTYPES, $productType->uid); + } + + return true; + } + + /** + * @throws Throwable + */ + public function handleChangedProductType(ConfigEvent $event): void + { + $productTypeUid = $event->tokenMatches[0]; + $data = $event->newValue; + $shouldResaveProducts = false; + + ProjectConfigHelper::ensureAllSitesProcessed(); + ProjectConfigHelper::ensureAllFieldsProcessed(); + + DB::beginTransaction(); + + try { + $siteData = $data['siteSettings']; + + $record = $this->_getRecord($productTypeUid); + $isNewProductType = !$record->exists; + + $record->uid = $productTypeUid; + $record->name = $data['name']; + $record->handle = $data['handle']; + $record->enableVersioning = $data['enableVersioning'] ?? false; + $record->hasDimensions = $data['hasDimensions']; + + $record->productTitleTranslationMethod = $data['productTitleTranslationMethod'] ?? 'site'; + $record->productTitleTranslationKeyFormat = $data['productTitleTranslationKeyFormat'] ?? ''; + + $record->propagationMethod = $data['propagationMethod'] ?? PropagationMethod::All->value; + + if ($record->propagationMethod !== $record->getOriginal('propagationMethod')) { + $shouldResaveProducts = true; + } + + $record->variantTitleTranslationMethod = $data['variantTitleTranslationMethod'] ?? 'site'; + $record->variantTitleTranslationKeyFormat = $data['variantTitleTranslationKeyFormat'] ?? ''; + + $hasVariantTitleField = $data['hasVariantTitleField']; + $variantTitleFormat = $data['variantTitleFormat'] ?? '{product.title}'; + if ($record->variantTitleFormat != $variantTitleFormat || $record->hasVariantTitleField != $hasVariantTitleField) { + $shouldResaveProducts = true; + } + $record->variantTitleFormat = $variantTitleFormat; + $record->hasVariantTitleField = $hasVariantTitleField; + $record->variantUiLabelFormat = $data['variantUiLabelFormat'] ?? '{title}'; + + $hasProductTitleField = $data['hasProductTitleField']; + $productTitleFormat = $data['productTitleFormat'] ?? 'Title'; + if ($record->productTitleFormat != $productTitleFormat || $record->hasProductTitleField != $hasProductTitleField) { + $shouldResaveProducts = true; + } + $record->productTitleFormat = $productTitleFormat; + $record->hasProductTitleField = $hasProductTitleField; + $record->productUiLabelFormat = $data['productUiLabelFormat'] ?? '{title}'; + + $record->showSlugField = $data['showSlugField'] ?? true; + $record->slugTranslationMethod = $data['slugTranslationMethod'] ?? 'site'; + $record->slugTranslationKeyFormat = $data['slugTranslationKeyFormat'] ?? null; + + if ($record->maxVariants != $data['maxVariants']) { + $shouldResaveProducts = true; + } + $record->maxVariants = $data['maxVariants']; + + $skuFormat = $data['skuFormat'] ?? ''; + if ($record->skuFormat != $skuFormat) { + $shouldResaveProducts = true; + } + $record->skuFormat = $skuFormat; + + $descriptionFormat = $data['descriptionFormat'] ?? ''; + if ($record->descriptionFormat != $descriptionFormat) { + $shouldResaveProducts = true; + } + $record->descriptionFormat = $descriptionFormat; + + $wasStructure = (bool)$record->isStructure; + $record->isStructure = $data['isStructure'] ?? false; + $record->maxLevels = $data['maxLevels'] ?? null; + $record->defaultPlacement = $data['defaultPlacement'] ?? ProductType::DEFAULT_PLACEMENT_BEGINNING; + if ($record->isStructure != $wasStructure) { + $shouldResaveProducts = true; + } + + if (!empty($data['previewTargets'])) { + $record->previewTargets = ProjectConfigHelper::unpackAssociativeArray($data['previewTargets']); + } else { + $record->previewTargets = null; + } + + if (!empty($data['productFieldLayouts']) && !empty($config = reset($data['productFieldLayouts']))) { + $layout = FieldLayout::createFromConfig($config); + $layout->id = $record->fieldLayoutId; + $layout->type = Product::class; + $layout->uid = key($data['productFieldLayouts']); + Fields::saveLayout($layout, false); + $record->fieldLayoutId = $layout->id; + } elseif ($record->fieldLayoutId) { + Fields::deleteLayoutById($record->fieldLayoutId); + $record->fieldLayoutId = null; + } + + if (!empty($data['variantFieldLayouts']) && !empty($config = reset($data['variantFieldLayouts']))) { + $layout = FieldLayout::createFromConfig($config); + $layout->id = $record->variantFieldLayoutId; + $layout->type = Variant::class; + $layout->uid = key($data['variantFieldLayouts']); + Fields::saveLayout($layout, false); + $record->variantFieldLayoutId = $layout->id; + } elseif ($record->variantFieldLayoutId) { + Fields::deleteLayoutById($record->variantFieldLayoutId); + $record->variantFieldLayoutId = null; + } + + $isNewStructure = false; + if ($record->isStructure) { + $structureUid = $data['structure']['uid']; + $structure = Structures::getStructureByUid($structureUid, true) ?? new Structure(['uid' => $structureUid]); + $isNewStructure = empty($structure->id); + $structure->maxLevels = $data['maxLevels'] ?? null; + Structures::saveStructure($structure); + $record->structureId = $structure->id; + } else { + if ($record->structureId) { + Structures::deleteStructureById($record->structureId); + } + + $record->structureId = null; + } + + $record->dateUpdated = now()->toDateTimeString(); + if ($isNewProductType) { + $record->dateCreated = $record->dateUpdated; + } + + $record->save(); + + // Update the site settings + $sitesNowWithoutUrls = []; + $sitesWithNewUriFormats = []; + $allOldSiteSettingsRecords = []; + + if (!$isNewProductType) { + $allOldSiteSettingsRecords = ProductTypeSiteRecord::query() + ->where('productTypeId', $record->id) + ->get() + ->keyBy('siteId') + ->all(); + } + + $siteIdMap = CraftDb::idsByUids('{{%sites}}', array_keys($siteData)); + + foreach ($siteData as $siteUid => $siteSettings) { + $siteId = $siteIdMap[$siteUid]; + + if (!$isNewProductType && isset($allOldSiteSettingsRecords[$siteId])) { + $siteSettingsRecord = $allOldSiteSettingsRecords[$siteId]; + $wasNew = false; + $hadUrls = (bool)$siteSettingsRecord->hasUrls; + $oldUriFormat = $siteSettingsRecord->uriFormat; + } else { + $siteSettingsRecord = new ProductTypeSiteRecord(); + $siteSettingsRecord->productTypeId = $record->id; + $siteSettingsRecord->siteId = $siteId; + $wasNew = true; + $hadUrls = false; + $oldUriFormat = null; + } + + $siteSettingsRecord->enabledByDefault = (bool)($siteSettings['enabledByDefault'] ?? true); + + if ($siteSettingsRecord->hasUrls = $siteSettings['hasUrls']) { + $siteSettingsRecord->uriFormat = $siteSettings['uriFormat']; + $siteSettingsRecord->template = $siteSettings['template']; + } else { + $siteSettingsRecord->uriFormat = null; + $siteSettingsRecord->template = null; + } + + if (!$wasNew) { + if ($hadUrls && !$siteSettings['hasUrls']) { + $sitesNowWithoutUrls[] = $siteId; + } + + if ($siteSettings['hasUrls'] && $oldUriFormat !== $siteSettingsRecord->uriFormat) { + $sitesWithNewUriFormats[] = $siteId; + } + } + + $siteSettingsRecord->dateUpdated = now()->toDateTimeString(); + if ($wasNew) { + $siteSettingsRecord->dateCreated = $siteSettingsRecord->dateUpdated; + } + + $siteSettingsRecord->save(); + } + + if (!$isNewProductType) { + $affectedSiteUids = array_keys($siteData); + + foreach ($allOldSiteSettingsRecords as $siteId => $siteSettingsRecord) { + $siteUid = array_search($siteId, $siteIdMap, false); + if (!in_array($siteUid, $affectedSiteUids, false)) { + $siteSettingsRecord->delete(); + $shouldResaveProducts = true; + } + } + } + + if ($record->isStructure && !$isNewProductType && $isNewStructure) { + $this->_populateNewStructure($record); + } + + if (!$isNewProductType) { + $productIds = Product::find() + ->typeId($record->id) + ->status(null) + ->limit(null) + ->ids(); + + if (!empty($siteData)) { + if (!empty($sitesNowWithoutUrls)) { + DB::table('elements_sites') + ->whereIn('elementId', $productIds) + ->whereIn('siteId', $sitesNowWithoutUrls) + ->update(['uri' => null]); + } elseif (!empty($sitesWithNewUriFormats)) { + foreach ($productIds as $productId) { + foreach ($sitesWithNewUriFormats as $siteId) { + $product = Product::find() + ->id($productId) + ->siteId($siteId) + ->status(null) + ->one(); + + if ($product) { + Elements::updateElementSlugAndUri($product, false, false); + } + } + } + } + } + } + + DB::commit(); + + if ($shouldResaveProducts) { + dispatch(new ResaveElements( + elementType: Product::class, + criteria: [ + 'siteId' => '*', + 'status' => null, + 'typeId' => $record->id, + ], + )); + } + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + + $this->_allProductTypes = null; + unset($this->_siteSettingsByProductId[$record->id]); + + // TODO: migrate event firing to Laravel once event system is bridged + $legacyService = Plugin::getInstance()->getProductTypes(); + if ($legacyService->hasEventHandlers(self::EVENT_AFTER_SAVE_PRODUCTTYPE)) { + $event = new ProductTypeEvent( + productType: $this->getProductTypeById($record->id), + isNew: empty($this->_savingProductTypes[$productTypeUid]), + ); + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $legacyService->trigger(self::EVENT_AFTER_SAVE_PRODUCTTYPE, $event); + } + } + + public function deleteProductTypeById(int $id): bool + { + $productType = $this->getProductTypeById($id); + ProjectConfig::remove(self::CONFIG_PRODUCTTYPES_KEY . '.' . $productType->uid); + return true; + } + + /** + * @throws Throwable + */ + public function handleDeletedProductType(ConfigEvent $event): void + { + $uid = $event->tokenMatches[0]; + $record = $this->_getRecord($uid); + + if (!$record->id) { + return; + } + + DB::beginTransaction(); + + try { + $products = Product::find() + ->typeId($record->id) + ->status(null) + ->limit(null) + ->all(); + + foreach ($products as $product) { + Elements::deleteElement($product); + } + + $fieldLayoutId = $record->fieldLayoutId; + $variantFieldLayoutId = $record->variantFieldLayoutId; + Fields::deleteLayoutById($fieldLayoutId); + + if ($variantFieldLayoutId) { + Fields::deleteLayoutById($variantFieldLayoutId); + } + + $record->delete(); + DB::commit(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + + $this->_allProductTypes = null; + unset($this->_siteSettingsByProductId[$record->id]); + } + + /** + * Prune a deleted site from product type site settings. + */ + public function pruneDeletedSite(DeleteSiteEvent $event): void + { + $siteUid = $event->site->uid; + $productTypes = ProjectConfig::get(self::CONFIG_PRODUCTTYPES_KEY); + + if (is_array($productTypes)) { + foreach ($productTypes as $productTypeUid => $productType) { + ProjectConfig::remove(self::CONFIG_PRODUCTTYPES_KEY . '.' . $productTypeUid . '.siteSettings.' . $siteUid); + } + } + } + + public function isProductTypeTemplateValid(ProductType $productType, int $siteId): bool + { + $productTypeSiteSettings = $productType->getSiteSettings(); + + if (isset($productTypeSiteSettings[$siteId]) && $productTypeSiteSettings[$siteId]->hasUrls && $productTypeSiteSettings[$siteId]->template) { + return app(TemplateResolver::class)->exists($productTypeSiteSettings[$siteId]->template, TemplateMode::Site); + } + + return false; + } + + /** + * Adds a new product type setting row when a Site is added to Craft. + */ + public function afterSaveSiteHandler(SiteSaved $event): void + { + if ($event->isNew && isset($event->oldPrimarySiteId)) { + $oldPrimarySiteUid = CraftDb::uidById('{{%sites}}', $event->oldPrimarySiteId); + $existingProductTypeSettings = ProjectConfig::get(self::CONFIG_PRODUCTTYPES_KEY); + + if (!ProjectConfig::isApplyingExternalChanges() && is_array($existingProductTypeSettings)) { + foreach ($existingProductTypeSettings as $productTypeUid => $settings) { + $primarySiteSettings = $settings['siteSettings'][$oldPrimarySiteUid] ?? null; + if ($primarySiteSettings === null) { + continue; + } + + $configPath = self::CONFIG_PRODUCTTYPES_KEY . '.' . $productTypeUid . '.siteSettings.' . $event->site->uid; + ProjectConfig::set($configPath, $primarySiteSettings); + } + } + } + } + + /** + * Adds existing products to a newly-created structure, when a product type is converted to Orderable. + */ + private function _populateNewStructure(ProductTypeRecord $record): void + { + $query = Product::find() + ->typeId($record->id) + ->drafts(null) + ->draftOf(false) + ->site('*') + ->unique() + ->status(null) + ->orderBy(['id' => SORT_ASC]) + ->withStructure(false); + + $query->cursor()->each( + fn(Product $product) => Structures::appendToRoot($record->structureId, $product, Mode::Insert) + ); + } + + private function _getRecord(string $uid): ProductTypeRecord + { + return ProductTypeRecord::query()->where('uid', $uid)->first() ?? new ProductTypeRecord(); + } + + /** + * Hydrates a rich {@see ProductType} data object from a persisted {@see ProductTypeRecord} row. + */ + private function _toData(ProductTypeRecord $record): ProductType + { + $productType = new ProductType(); + $productType->id = $record->id; + $productType->name = $record->name; + $productType->handle = $record->handle; + $productType->enableVersioning = (bool)$record->enableVersioning; + $productType->hasDimensions = (bool)$record->hasDimensions; + $productType->maxVariants = $record->maxVariants; + $productType->hasVariantTitleField = (bool)$record->hasVariantTitleField; + $productType->variantTitleFormat = $record->variantTitleFormat; + $productType->variantUiLabelFormat = $record->variantUiLabelFormat ?? '{title}'; + $productType->variantTitleTranslationMethod = $record->variantTitleTranslationMethod ?? 'site'; + $productType->variantTitleTranslationKeyFormat = $record->variantTitleTranslationKeyFormat; + $productType->hasProductTitleField = (bool)$record->hasProductTitleField; + $productType->productTitleFormat = $record->productTitleFormat ?? ''; + $productType->productUiLabelFormat = $record->productUiLabelFormat ?? '{title}'; + $productType->productTitleTranslationMethod = $record->productTitleTranslationMethod ?? 'site'; + $productType->productTitleTranslationKeyFormat = $record->productTitleTranslationKeyFormat; + $productType->showSlugField = (bool)$record->showSlugField; + $productType->slugTranslationMethod = $record->slugTranslationMethod ?? 'site'; + $productType->slugTranslationKeyFormat = $record->slugTranslationKeyFormat; + $productType->skuFormat = $record->skuFormat; + $productType->descriptionFormat = $record->descriptionFormat; + $productType->isStructure = (bool)$record->isStructure; + $productType->maxLevels = $record->maxLevels; + $productType->defaultPlacement = $record->defaultPlacement; + $productType->structureId = $record->structureId; + $productType->fieldLayoutId = $record->fieldLayoutId; + $productType->variantFieldLayoutId = $record->variantFieldLayoutId; + $productType->uid = $record->uid; + $productType->previewTargets = $record->previewTargets; + $productType->propagationMethod = PropagationMethod::from($record->propagationMethod); + + return $productType; + } + + /** + * Hydrates a {@see ProductTypeSite} data object from a persisted {@see ProductTypeSiteRecord} row. + * + * Built via explicit property assignment rather than passing `$record->getAttributes()` into the + * constructor's config array — the Eloquent record's attributes include `dateCreated`/`dateUpdated`/ + * `uid`, none of which `ProductTypeSite` declares, and `Component::__set()` throws + * `UnknownPropertyException` for genuinely undeclared properties rather than silently ignoring them. + */ + private function _toSiteData(ProductTypeSiteRecord $record): ProductTypeSite + { + $site = new ProductTypeSite(); + $site->id = $record->id; + $site->productTypeId = $record->productTypeId; + $site->siteId = $record->siteId; + $site->hasUrls = (bool)$record->hasUrls; + $site->uriFormat = $record->uriFormat; + $site->template = $record->template; + $site->enabledByDefault = (bool)$record->enabledByDefault; + + return $site; + } +} diff --git a/src/Catalog/ProductType/Validation/ProductTypeRules.php b/src/Catalog/ProductType/Validation/ProductTypeRules.php new file mode 100644 index 0000000000..9e69f192f2 --- /dev/null +++ b/src/Catalog/ProductType/Validation/ProductTypeRules.php @@ -0,0 +1,55 @@ +afterValidate($validator)` + * when it exists. This ruleset covers only the plain declarative rules from the legacy + * `defineRules()`. + * + * @extends Ruleset + */ +class ProductTypeRules extends Ruleset +{ + public function rules(): array + { + $rules = [ + 'id' => ['nullable', 'integer'], + 'fieldLayoutId' => ['nullable', 'integer'], + 'variantFieldLayoutId' => ['nullable', 'integer'], + 'structureId' => ['nullable', 'integer'], + 'name' => ['required', 'string', 'max:255'], + 'handle' => [ + 'required', + 'string', + 'max:255', + new HandleRule(['id', 'dateCreated', 'dateUpdated', 'uid', 'title']), + ], + 'descriptionFormat' => ['nullable', 'string', 'max:255'], + 'maxVariants' => ['nullable', 'integer', 'min:1'], + 'variantTitleFormat' => [ + Rule::requiredIf(fn() => !$this->subject->hasVariantTitleField), + ], + 'productTitleFormat' => [ + Rule::requiredIf(fn() => !$this->subject->hasProductTitleField), + ], + ]; + + if ($this->subject->validateHandleUniqueness) { + $rules['handle'][] = Rule::unique(Table::PRODUCTTYPES, 'handle')->ignore($this->subject->id); + } + + return $rules; + } +} diff --git a/src/Catalog/Products.php b/src/Catalog/Products.php new file mode 100644 index 0000000000..23a5ed20c2 --- /dev/null +++ b/src/Catalog/Products.php @@ -0,0 +1,66 @@ +join(Table::PRODUCTTYPES . ' as productTypes', 'productTypes.id', '=', 'products.typeId') + ->where('products.id', $id) + ->value('productTypes.structureId'); + } + + /** @var Product|null $product */ + $product = Elements::getElementById($id, Product::class, $siteId, $criteria); + + return $product; + } + + /** + * Handle a Site being saved. + */ + public function afterSaveSiteHandler(SiteSaved $event): void + { + if ( + $event->isNew && + isset($event->oldPrimarySiteId) && + Plugins::isPluginInstalled(Plugin::getInstance()->handle) + ) { + dispatch(new PropagateElements( + elementType: Product::class, + criteria: [ + 'siteId' => $event->oldPrimarySiteId, + 'status' => null, + ], + siteId: $event->site->id, + isNewSite: true, + )); + } + } +} diff --git a/src/Catalog/Queries/ProductQuery.php b/src/Catalog/Queries/ProductQuery.php new file mode 100644 index 0000000000..450a2581ad --- /dev/null +++ b/src/Catalog/Queries/ProductQuery.php @@ -0,0 +1,584 @@ + + */ +class ProductQuery extends ElementQuery +{ + #[Override] + protected string $table = Table::PRODUCTS; + + /** + * Products are structure-aware by default (product types may be structures), mirroring + * {@see \CraftCms\Cms\Element\Queries\EntryQuery::$withStructure}. + */ + #[Override] + public bool $withStructure { + get { + if (!isset($this->withStructure)) { + $this->withStructure = true; + } + + return $this->withStructure; + } + } + + /** @var array */ + #[Override] + protected array $defaultOrderBy = [ + 'commerce_products.postDate' => SORT_DESC, + 'elements.id' => SORT_DESC, + ]; + + /** + * Whether to only return products that the user has permission to view. + */ + public ?bool $editable = null; + + /** + * Whether to only return products that the user has permission to save. + */ + public ?bool $savable = null; + + public mixed $expiryDate = null; + + public mixed $defaultPrice = null; + + public mixed $defaultHeight = null; + + public mixed $defaultLength = null; + + public mixed $defaultWidth = null; + + public mixed $defaultWeight = null; + + public mixed $defaultSku = null; + + /** + * Only return products that match the resulting variant query. + */ + public mixed $hasVariant = null; + + public mixed $postDate = null; + + public mixed $typeId = null; + + /** + * The reference code(s) used to identify the product(s), e.g. `{product:productTypeHandle/slug}`. + */ + public mixed $ref = null; + + /** @param array $config */ + public function __construct(array $config = []) + { + // Default status + if (!isset($config['status'])) { + $config['status'] = [Product::STATUS_LIVE]; + } + + parent::__construct(Product::class, $config); + + $this->query->addSelect([ + 'commerce_products.typeId', + 'commerce_products.postDate', + 'commerce_products.expiryDate', + 'commerce_products.defaultVariantId', + 'purchasables.sku as defaultSku', + 'purchasables.weight as defaultWeight', + 'purchasables.length as defaultLength', + 'purchasables.width as defaultWidth', + 'purchasables.height as defaultHeight', + 'purchasablesstores.basePrice as defaultBasePrice', + 'purchasablesstores.basePromotionalPrice as defaultBasePromotionalPrice', + 'sitestores.storeId', + ]); + + // Join in site stores to get the product's store for the current request + $this->query->leftJoin(new Alias(Table::SITESTORES, 'sitestores'), 'elements_sites.siteId', '=', 'sitestores.siteId'); + $this->query->leftJoin(new Alias(Table::PURCHASABLES, 'purchasables'), 'purchasables.id', '=', 'commerce_products.defaultVariantId'); + $this->query->leftJoin(new Alias(Table::PURCHASABLES_STORES, 'purchasablesstores'), function(JoinClause $join) { + $join->on('purchasablesstores.purchasableId', '=', 'commerce_products.defaultVariantId') + ->on('purchasablesstores.storeId', '=', 'sitestores.storeId'); + }); + + // Tailor the query based on whether there are catalog pricing rules. + // The legacy Yii2 query staged this through the element query's `subQuery`; the new + // architecture is a single query, so the catalog prices are joined in directly here. + $hasCatalogPricingRules = app(CatalogPricingRules::class)->hasCatalogPricingRules(); + + if ($hasCatalogPricingRules) { + $catalogPricesQuery = app(CatalogPricing::class) + ->createCatalogPricesQuery(userId: currentUser()?->getCraftUserId()) + ->addSelect(['cp.purchasableId', 'cp.storeId']); + + $this->query->leftJoinSub($catalogPricesQuery, 'catalogprices', function(JoinClause $join) { + $join->on('catalogprices.purchasableId', '=', 'commerce_products.defaultVariantId') + ->on('catalogprices.storeId', '=', 'sitestores.storeId'); + }); + + $this->query->addSelect(['catalogprices.price as defaultPrice']); + } else { + $this->query->addSelect(['purchasablesstores.basePrice as defaultPrice']); + } + + $this->beforeQuery(function(self $query) use ($hasCatalogPricingRules) { + $query->normalizeTypeId(); + + // See if 'type' was set to an invalid handle + if ($query->typeId === []) { + throw new QueryAbortedException(); + } + + if (isset($query->defaultPrice)) { + $query->whereParam( + $hasCatalogPricingRules ? 'catalogprices.price' : 'purchasablesstores.basePrice', + $query->defaultPrice, + ); + } + + if (isset($query->postDate)) { + $query->whereDateParam('commerce_products.postDate', $query->postDate); + } + + if (isset($query->expiryDate)) { + $query->whereDateParam('commerce_products.expiryDate', $query->expiryDate); + } + + if (isset($query->defaultHeight)) { + $query->whereParam('purchasables.height', $query->defaultHeight); + } + + if (isset($query->defaultLength)) { + $query->whereParam('purchasables.length', $query->defaultLength); + } + + if (isset($query->defaultWidth)) { + $query->whereParam('purchasables.width', $query->defaultWidth); + } + + if (isset($query->defaultWeight)) { + $query->whereParam('purchasables.weight', $query->defaultWeight); + } + + if (isset($query->defaultSku)) { + $query->whereParam('purchasables.sku', $query->defaultSku); + } + + $query->applyProductTypeIdParam(); + $query->applyHasVariantParam(); + // Mirrors EntryQuery: "editable" means accessible in the editing UI (view permission), + // not necessarily savable. Use ->savable() to filter by save permission. + $query->applyPermissionParam($query->editable, 'commerce-viewProductType'); + $query->applyPermissionParam($query->savable, 'commerce-saveProductType'); + $query->applyRefParam(); + }); + } + + /** + * Narrows the query results based on the products’ default variant price. + */ + public function defaultPrice(mixed $value): static + { + $this->defaultPrice = $value; + return $this; + } + + /** + * Narrows the query results based on the products’ default variant height. + */ + public function defaultHeight(mixed $value): static + { + $this->defaultHeight = $value; + return $this; + } + + /** + * Narrows the query results based on the products’ default variant length. + */ + public function defaultLength(mixed $value): static + { + $this->defaultLength = $value; + return $this; + } + + /** + * Narrows the query results based on the products’ default variant width. + */ + public function defaultWidth(mixed $value): static + { + $this->defaultWidth = $value; + return $this; + } + + /** + * Narrows the query results based on the products’ default variant weight. + */ + public function defaultWeight(mixed $value): static + { + $this->defaultWeight = $value; + return $this; + } + + /** + * Narrows the query results based on the products’ default variant SKU. + */ + public function defaultSku(mixed $value): static + { + $this->defaultSku = $value; + return $this; + } + + /** + * Narrows the query results based on the products’ types. + * + * @param ProductType|string|string[]|null $value + */ + public function type(mixed $value): static + { + // TODO: migrate to app(ProductTypes::class)->getProductTypeByHandle() once service migrated to src/ + if (is_string($value) && ($productType = app(ProductTypes::class)->getProductTypeByHandle($value))) { + $value = $productType; + } + + if ($value instanceof ProductType) { + $this->typeId = [$value->id]; + } elseif ($value !== null) { + $this->typeId = DB::table(Table::PRODUCTTYPES) + ->whereParam('handle', $value) + ->pluck('id') + ->all(); + } else { + $this->typeId = null; + } + + return $this; + } + + /** + * Narrows the query results to only products that were posted before a certain date. + */ + public function before(DateTime|string $value): static + { + if ($value instanceof DateTime) { + $value = $value->format(DateTime::W3C); + } + + $this->postDate = Arr::wrap($this->postDate); + $this->postDate[] = '<' . $value; + + return $this; + } + + /** + * Narrows the query results to only products that were posted on or after a certain date. + */ + public function after(DateTime|string $value): static + { + if ($value instanceof DateTime) { + $value = $value->format(DateTime::W3C); + } + + $this->postDate = Arr::wrap($this->postDate); + $this->postDate[] = '>=' . $value; + + return $this; + } + + public function editable(?bool $value = true): static + { + $this->editable = $value; + return $this; + } + + public function savable(?bool $value = true): static + { + $this->savable = $value; + return $this; + } + + /** + * Narrows the query results based on the products’ types, per the types’ IDs. + */ + public function typeId(mixed $value): static + { + $this->typeId = $value; + return $this; + } + + /** + * Narrows the query results to only products that have certain variants. + * + * @param VariantQuery|array $value + */ + public function hasVariant(mixed $value): static + { + $this->hasVariant = $value; + return $this; + } + + /** + * Narrows the query results based on the products’ post dates. + */ + public function postDate(mixed $value): static + { + $this->postDate = $value; + return $this; + } + + /** + * Narrows the query results based on the products’ expiry dates. + */ + public function expiryDate(mixed $value): static + { + $this->expiryDate = $value; + return $this; + } + + /** + * Narrows the query results based on a reference string. + */ + public function ref(mixed $value): static + { + $this->ref = $value; + return $this; + } + + #[Override] + protected function statusCondition(string $status): Closure + { + // Always consider “now” to be the current time @ 59 seconds into the minute, to keep + // product queries cacheable (mirrors EntryQuery). + $currentTime = now()->endOfMinute()->setTimezone('UTC'); + + return match ($status) { + Product::STATUS_LIVE => fn(Builder $query) => $query + ->whereBool('elements.enabled', true) + ->whereBool('elements_sites.enabled', true) + ->where('commerce_products.postDate', '<=', $currentTime) + ->where(function(Builder $query) use ($currentTime) { + $query->whereNull('commerce_products.expiryDate') + ->orWhere('commerce_products.expiryDate', '>', $currentTime); + } + ), + Product::STATUS_PENDING => fn(Builder $query) => $query + ->whereBool('elements.enabled', true) + ->whereBool('elements_sites.enabled', true) + ->where('commerce_products.postDate', '>', $currentTime), + Product::STATUS_EXPIRED => fn(Builder $query) => $query + ->whereBool('elements.enabled', true) + ->whereBool('elements_sites.enabled', true) + ->whereNotNull('commerce_products.expiryDate') + ->where('commerce_products.expiryDate', '<=', $currentTime), + default => parent::statusCondition($status), + }; + } + + /** + * Normalizes the typeId param to an array of IDs or null. + */ + private function normalizeTypeId(): void + { + if (empty($this->typeId)) { + $this->typeId = is_array($this->typeId) ? [] : null; + } elseif (is_numeric($this->typeId)) { + $this->typeId = [$this->typeId]; + } elseif (!is_array($this->typeId) || !Arr::isNumeric($this->typeId)) { + $this->typeId = DB::table(Table::PRODUCTTYPES) + ->whereParam('id', $this->typeId) + ->pluck('id') + ->all(); + } + } + + /** + * Applies the 'typeId' param to the query being prepared. + */ + private function applyProductTypeIdParam(): void + { + if (!$this->typeId) { + return; + } + + $this->whereIn('commerce_products.typeId', $this->typeId); + + // Should we set the structureId param? + if ( + $this->withStructure !== false && + !isset($this->structureId) && + count($this->typeId) === 1 + ) { + // TODO: migrate to app(ProductTypes::class)->getProductTypeById() once service migrated to src/ + $productType = app(ProductTypes::class)->getProductTypeById((int)reset($this->typeId)); + + if ($productType && $productType->isStructure) { + $this->structureId = $productType->structureId; + } else { + $this->withStructure = false; + } + } + } + + /** + * Applies the 'hasVariant' param to the query being prepared. + * + * @throws QueryAbortedException + */ + private function applyHasVariantParam(): void + { + if ($this->hasVariant === null) { + return; + } + + if ($this->hasVariant instanceof VariantQuery) { + $variantQuery = $this->hasVariant; + } elseif (is_array($this->hasVariant)) { + $variantQuery = Variant::find(); + self::configure($variantQuery, $this->hasVariant); + } else { + throw new QueryAbortedException('Invalid param used. ProductQuery::hasVariant param only expects a variant query or variant query config.'); + } + + $variantQuery->limit(null); + $variantQuery->select('commerce_variants.primaryOwnerId as primaryOwnerId'); + $variantQuery->whereNotNull('commerce_variants.primaryOwnerId'); + + // The legacy query correlated a nested EXISTS subquery against `commerce_products.id`; + // the resulting SQL is equivalent to (and simpler as) an IN subquery here. + $variantQuery->applyBeforeQueryCallbacks(); + $this->whereIn('commerce_products.id', $variantQuery->getQuery()); + } + + /** + * Applies an authorization param to the query being prepared. + * + * @throws QueryAbortedException + */ + private function applyPermissionParam(?bool $value, string $permissionPrefix): void + { + if ($value === null) { + return; + } + + $user = currentUser(); + + if (!$user) { + throw new QueryAbortedException(); + } + + // TODO: migrate to app(ProductTypes::class)->getAllProductTypes() once service migrated to src/ + $productTypes = app(ProductTypes::class)->getAllProductTypes(); + + if (empty($productTypes)) { + return; + } + + $authorizedTypeIds = []; + + foreach ($productTypes as $productType) { + if ($user->can("$permissionPrefix:$productType->uid")) { + $authorizedTypeIds[] = $productType->id; + } + } + + if (count($authorizedTypeIds) === count($productTypes)) { + // They have access to everything + if (!$value) { + throw new QueryAbortedException(); + } + return; + } + + if (empty($authorizedTypeIds)) { + // They don't have access to anything + if ($value) { + throw new QueryAbortedException(); + } + return; + } + + if ($value) { + $this->whereIn('commerce_products.typeId', $authorizedTypeIds); + } else { + $this->whereNotIn('commerce_products.typeId', $authorizedTypeIds); + } + } + + /** + * Applies the 'ref' param to the query being prepared. + */ + private function applyRefParam(): void + { + if (!$this->ref) { + return; + } + + $refs = Arr::wrap($this->ref); + $joinProductTypes = false; + + $this->where(function(Builder $query) use (&$joinProductTypes, $refs) { + foreach ($refs as $ref) { + $parts = array_filter(explode('/', (string)$ref), static fn(string $part) => $part !== ''); + + if (empty($parts)) { + continue; + } + + if (count($parts) === 1) { + $query->orWhereParam('elements_sites.slug', reset($parts)); + continue; + } + + $parts = array_values($parts); + + $query->orWhere(function(Builder $query) use ($parts) { + $query->whereParam('commerce_producttypes.handle', $parts[0]) + ->whereParam('elements_sites.slug', $parts[1]); + }); + + $joinProductTypes = true; + } + }); + + if ($joinProductTypes) { + $this->join(new Alias(Table::PRODUCTTYPES, 'commerce_producttypes'), 'commerce_producttypes.id', '=', 'commerce_products.typeId'); + } + } + + #[Override] + protected function cacheTags(): array + { + $tags = []; + + if ($this->typeId) { + foreach (Arr::wrap($this->typeId) as $typeId) { + $tags[] = "productType:$typeId"; + } + } + + return $tags; + } +} diff --git a/src/Catalog/Queries/VariantQuery.php b/src/Catalog/Queries/VariantQuery.php new file mode 100644 index 0000000000..63780017ed --- /dev/null +++ b/src/Catalog/Queries/VariantQuery.php @@ -0,0 +1,803 @@ + + */ +class VariantQuery extends PurchasableQuery implements NestedElementQueryInterface +{ + /** + * The `QueriesNestedElements` concern is used for its param API (`owner()`, `ownerId()`, + * `primaryOwner()`, `primaryOwnerId()`, `field()`, `fieldId()`, `allowOwnerDrafts()`, + * `allowOwnerRevisions()`) and its cache tags. Its own `initQueriesNestedElements()` and + * `fieldLayouts()` are replaced below: those two methods route through private helpers whose + * signatures are hard-typed to core's `AddressQuery|ContentBlockQuery|EntryQuery` union, so + * calling them with a commerce query would throw a `TypeError`. Variants also need a bespoke + * `elements_owners` join anyway (they're not stored in a field, so there's no `fieldId`). + */ + use QueriesNestedElements { + cacheTags as nestedTraitCacheTags; + } + + /** @var array */ + #[Override] + protected array $defaultOrderBy = ['elements_owners.sortOrder' => SORT_ASC]; + + /** + * Whether to only return variants that the user has permission to view. + */ + public ?bool $editable = null; + + /** + * Whether to only return variants that the user has permission to save. + */ + public ?bool $savable = null; + + public ?bool $hasSales = null; + + /** + * Only return variants that match the resulting product query. + */ + public mixed $hasProduct = null; + + public ?bool $isDefault = null; + + /** + * The status the owner product must have. + * + * @var array|string|null + */ + public array|string|null $productStatus = null; + + public mixed $typeId = null; + + public mixed $minQty = null; + + public mixed $maxQty = null; + + /** @param array $config */ + public function __construct(array $config = []) + { + // Default status + if (!isset($config['status'])) { + $config['status'] = [Element::STATUS_ENABLED]; + } + + parent::__construct(Variant::class, $config); + + $this->query->join(new Alias(Table::VARIANTS, 'commerce_variants'), 'commerce_variants.id', '=', 'elements.id'); + + $this->query->addSelect([ + 'commerce_variants.primaryOwnerId', + ]); + + $this->beforeQuery(function(self $query) { + $query->joinOwners(); + + if ($query->primaryOwnerId) { + $query->whereIn('commerce_variants.primaryOwnerId', $query->primaryOwnerId); + } + + if (isset($query->typeId)) { + $query->whereParam('commerce_products.typeId', $query->typeId); + } + + if (isset($query->isDefault)) { + if ($query->isDefault) { + $query->whereColumn('commerce_variants.id', '=', 'commerce_products.defaultVariantId'); + } else { + $query->where(function(Builder $subQuery) { + $subQuery->whereNull('commerce_products.defaultVariantId') + ->orWhereColumn('commerce_variants.id', '!=', 'commerce_products.defaultVariantId'); + }); + } + } + + // `minQty`/`maxQty` live on the purchasable's per-store row, not on `commerce_variants` + // (the legacy query filtered `commerce_variants.minQty`/`maxQty`, which no longer exist + // as columns — those params raised a SQL error). + if (isset($query->minQty)) { + $query->whereParam('purchasables_stores.minQty', $query->minQty); + } + + if (isset($query->maxQty)) { + $query->whereParam('purchasables_stores.maxQty', $query->maxQty); + } + + // If width, height or length is specified in the query we should only be looking for products that + // have a type which supports dimensions + if ($query->width !== false || $query->height !== false || $query->length !== false || $query->weight !== false) { + $query->whereParam('commerce_producttypes.hasDimensions', true); + } + + $query->applyProductStatusParam(); + $query->applyHasSalesParam(); + $query->applyHasProductParam(); + $query->applyPermissionParam($query->editable, 'commerce-viewProductType'); + $query->applyPermissionParam($query->savable, 'commerce-saveProductType'); + }); + } + + public function getFieldIdColumn(): string + { + // Variants aren't stored in a custom field, so there is no `fieldId` column. The primary + // owner column is returned here for parity with the legacy query. + return 'commerce_variants.primaryOwnerId'; + } + + public function getPrimaryOwnerIdColumn(): string + { + return 'commerce_variants.primaryOwnerId'; + } + + /** + * Replaces {@see QueriesNestedElements::initQueriesNestedElements()} — see the trait import + * note on this class. The `elements_owners` join (and the product/product type joins that + * hang off it) are set up by {@see joinOwners()} instead. + */ + protected function initQueriesNestedElements(): void + { + } + + /** @return Collection */ + #[Override] + protected function fieldLayouts(): Collection + { + // Bypasses QueriesNestedElements::fieldLayouts(), which normalizes the `fieldId` param via + // a core-only-typed helper. Variants get their field layouts from their product types, + // which are registered against this element type. + return parent::fieldLayouts(); + } + + /** + * Narrows the query results based on the variants’ product. + */ + public function product(mixed $value): static + { + if ($value instanceof ElementInterface) { + $this->ownerId = [$value->id]; + } else { + $this->ownerId = $value; + } + + return $this; + } + + /** + * Narrows the query results based on the variants’ owner. + * + * Widened from {@see QueriesNestedElements::owner()} to also accept owner IDs, matching the + * legacy query's behavior. + */ + public function owner(mixed $value): static + { + /** @phpstan-ignore-next-line instanceof.alwaysTrue (widened to also accept owner IDs - PHPStan appears to be using NestedElementQueryInterface::owner()'s stricter ElementInterface param type here, not this override's mixed) */ + if ($value instanceof ElementInterface) { + $this->ownerId = [$value->id]; + } else { + $this->ownerId = $value; + } + + return $this; + } + + /** + * Narrows the query results based on the variants’ primary owner. + * + * Widened from {@see QueriesNestedElements::primaryOwner()} to also accept owner IDs, matching + * the legacy query's behavior. + */ + public function primaryOwner(mixed $value): static + { + /** @phpstan-ignore-next-line instanceof.alwaysTrue (widened to also accept owner IDs - PHPStan appears to be using NestedElementQueryInterface::primaryOwner()'s stricter ElementInterface param type here, not this override's mixed) */ + if ($value instanceof ElementInterface) { + $this->primaryOwnerId = [$value->id]; + } else { + $this->primaryOwnerId = $value; + } + + return $this; + } + + /** + * Narrows the query results based on the variants’ products’ IDs. + */ + public function productId(mixed $value): static + { + $this->ownerId = $value; + return $this; + } + + /** + * Narrows the query results based on the variants’ products’ statuses. + * + * @param string|string[]|null $value + */ + public function productStatus(array|string|null $value): static + { + $this->productStatus = $value; + return $this; + } + + /** + * Narrows the query results based on the variants’ product types, per their IDs. + */ + public function typeId(mixed $value): static + { + $this->typeId = $value; + return $this; + } + + /** + * Narrows the query results to only default variants. + */ + public function isDefault(?bool $value = true): static + { + $this->isDefault = $value; + return $this; + } + + /** + * Narrows the query results to only variants that are on sale. + */ + public function hasSales(?bool $value = true): static + { + $this->hasSales = $value; + return $this; + } + + /** + * Narrows the query results to only variants for certain products. + * + * @param ProductQuery|array $value + */ + public function hasProduct(mixed $value = []): static + { + $this->hasProduct = $value; + return $this; + } + + /** + * Narrows the query results based on the variants’ min quantity. + */ + public function minQty(mixed $value): static + { + $this->minQty = $value; + return $this; + } + + /** + * Narrows the query results based on the variants’ max quantity. + */ + public function maxQty(mixed $value): static + { + $this->maxQty = $value; + return $this; + } + + public function editable(?bool $value = true): static + { + $this->editable = $value; + return $this; + } + + public function savable(?bool $value = true): static + { + $this->savable = $value; + return $this; + } + + #[Override] + public function collect(): VariantCollection + { + return VariantCollection::make(parent::collect()->all()); + } + + /** + * Joins the `elements_owners` table, plus the owner product, its product type, and the owner + * product's site settings — all of which the variant's selected columns and params rely on. + */ + private function joinOwners(): void + { + $this->primaryOwnerId = $this->normalizeOwnerIdParam($this->primaryOwnerId, 'primaryOwnerId'); + $this->ownerId = $this->normalizeOwnerIdParam($this->ownerId, 'ownerId'); + + $ownerId = $this->ownerId; + + $this->query + ->addSelect([ + 'elements_owners.ownerId as ownerId', + 'elements_owners.sortOrder as sortOrder', + ]) + ->join(new Alias(CraftTable::ELEMENTS_OWNERS, 'elements_owners'), function(JoinClause $join) use ($ownerId) { + $join->on('elements_owners.elementId', '=', 'elements.id'); + + if ($ownerId) { + $join->whereIn('elements_owners.ownerId', $ownerId); + } else { + $join->whereColumn('elements_owners.ownerId', 'commerce_variants.primaryOwnerId'); + } + }); + + $this->query->leftJoin(new Alias(Table::PRODUCTS, 'commerce_products'), 'commerce_products.id', '=', 'elements_owners.ownerId'); + $this->query->leftJoin(new Alias(Table::PRODUCTTYPES, 'commerce_producttypes'), 'commerce_producttypes.id', '=', 'commerce_products.typeId'); + $this->query->leftJoin(new Alias(CraftTable::ELEMENTS_SITES, 'commerce_products_elements_sites'), function(JoinClause $join) { + $join->on('commerce_products_elements_sites.elementId', '=', 'elements_owners.ownerId') + ->on('commerce_products_elements_sites.siteId', '=', 'elements_sites.siteId'); + }); + + $this->query->addSelect([ + 'commerce_products_elements_sites.slug as productSlug', + 'commerce_producttypes.handle as productTypeHandle', + ]); + + // Whether this variant is its owner product's default variant. + // + // The legacy query derived this with a `CASE WHEN … END` select expression; the new element + // query re-wraps any select expression as an identifier (see the note in PurchasableQuery), + // so the same comparison is expressed as a joined subquery whose column is either the + // variant's ID (truthy → default) or `null`. + $this->query->leftJoinSub( + DB::table(Table::PRODUCTS) + ->select(['id as defaultForProductId', 'defaultVariantId']) + ->whereNotNull('defaultVariantId'), + 'commerce_default_variants', + function(JoinClause $join) { + $join->on('commerce_default_variants.defaultVariantId', '=', 'commerce_variants.id') + ->on('commerce_default_variants.defaultForProductId', '=', 'commerce_products.id'); + }, + ); + + $this->query->addSelect(['commerce_default_variants.defaultVariantId as isDefault']); + } + + /** + * Normalizes an owner ID param to an array of IDs or null. + * + * @return int[]|null + * + * @throws QueryAbortedException if the param value isn't a valid ID or set of IDs + */ + private function normalizeOwnerIdParam(mixed $value, string $param): ?array + { + $normalized = $this->normalizeOwnerId($value); + + if ($normalized === false) { + throw new QueryAbortedException("Invalid $param param value"); + } + + return $normalized; + } + + /** + * Applies the 'productStatus' param to the query being prepared. + */ + private function applyProductStatusParam(): void + { + if (!$this->productStatus) { + return; + } + + // The owner product's element rows are only needed for this param + $this->query->leftJoin(new Alias(CraftTable::ELEMENTS, 'product_elements'), 'product_elements.id', '=', 'commerce_variants.primaryOwnerId'); + $this->query->leftJoin(new Alias(CraftTable::ELEMENTS_SITES, 'product_elements_sites'), function(JoinClause $join) { + $join->on('product_elements_sites.elementId', '=', 'commerce_variants.primaryOwnerId') + ->on('product_elements_sites.siteId', '=', 'elements_sites.siteId'); + }); + + // Normalize the product status param + $statuses = is_array($this->productStatus) + ? array_merge($this->productStatus) + : str($this->productStatus)->explode(',')->all(); + + $firstVal = strtolower((string)reset($statuses)); + if (in_array($firstVal, ['not', 'or'])) { + $glue = $firstVal; + array_shift($statuses); + if (!$statuses) { + return; + } + } else { + $glue = 'or'; + } + + $negate = $glue === 'not'; + + $this->where(function(Builder $query) use ($statuses, $negate) { + foreach ($statuses as $status) { + $condition = $this->productStatusCondition(strtolower((string)$status)); + + if ($condition === null) { + throw new QueryAbortedException('Unsupported status: ' . $status); + } + + if ($negate) { + $query->whereNot($condition); + } else { + $query->orWhere($condition); + } + } + }); + } + + /** + * Returns the condition that the owner product must match for a given status. + */ + private function productStatusCondition(string $status): ?Closure + { + $currentTime = now()->endOfMinute()->setTimezone('UTC'); + + return match ($status) { + Product::STATUS_LIVE => fn(Builder $query) => $query + ->whereBool('product_elements.enabled', true) + ->whereBool('product_elements_sites.enabled', true) + ->where('commerce_products.postDate', '<=', $currentTime) + ->where(function(Builder $query) use ($currentTime) { + $query->whereNull('commerce_products.expiryDate') + ->orWhere('commerce_products.expiryDate', '>', $currentTime); + } + ), + Product::STATUS_PENDING => fn(Builder $query) => $query + ->whereBool('product_elements.enabled', true) + ->whereBool('product_elements_sites.enabled', true) + ->where('commerce_products.postDate', '>', $currentTime), + Product::STATUS_EXPIRED => fn(Builder $query) => $query + ->whereBool('product_elements.enabled', true) + ->whereBool('product_elements_sites.enabled', true) + ->whereNotNull('commerce_products.expiryDate') + ->where('commerce_products.expiryDate', '<=', $currentTime), + Element::STATUS_ENABLED => fn(Builder $query) => $query + ->whereBool('product_elements.enabled', true) + ->whereBool('product_elements_sites.enabled', true), + Element::STATUS_DISABLED => fn(Builder $query) => $query + ->whereBool('product_elements.enabled', false) + ->orWhere(fn(Builder $query) => $query->whereBool('product_elements_sites.enabled', false)), + Element::STATUS_ARCHIVED => fn(Builder $query) => $query->whereBool('product_elements.archived', true), + default => null, + }; + } + + /** + * Applies the 'hasProduct' param to the query being prepared. + */ + private function applyHasProductParam(): void + { + if (!isset($this->hasProduct)) { + return; + } + + if ($this->hasProduct instanceof ProductQuery) { + $productQuery = $this->hasProduct; + } elseif (is_array($this->hasProduct)) { + $productQuery = Product::find(); + self::configure($productQuery, ProductQueryHelper::cleanseQueryCriteria($this->hasProduct)); + } else { + return; + } + + $productQuery->limit(null); + $productQuery->select('commerce_products.id as id'); + $productQuery->whereNotNull('commerce_products.id'); + $productQuery->applyBeforeQueryCallbacks(); + + $this->whereIn('commerce_variants.primaryOwnerId', $productQuery->getQuery()); + } + + /** + * Applies the 'hasSales' param to the query being prepared. + * + * @throws QueryAbortedException + */ + private function applyHasSalesParam(): void + { + if (!isset($this->hasSales)) { + return; + } + + if (!app(Sales::class)->canUseSales()) { + Deprecator::log('VariantQuery::hasSales', 'The `hasSales` parameter and Sales have been deprecated, use Pricing Rules instead.'); + throw new QueryAbortedException(); + } + + $nowDb = Query::prepareDateForDb(new DateTime()); + + /** @var array> $activeSales */ + $activeSales = DB::table(Table::SALES . ' as sales') + ->select([ + 'sales.id', + 'sales.allGroups', + 'sales.allPurchasables', + 'sales.allCategories', + 'sales.categoryRelationshipType', + ]) + ->where(function(Builder $query) use ($nowDb) { + $query + // Only a from date + ->where(fn(Builder $query) => $query + ->whereNull('dateTo') + ->whereNotNull('dateFrom') + ->where('dateFrom', '<=', $nowDb)) + // Only a to date + ->orWhere(fn(Builder $query) => $query + ->whereNull('dateFrom') + ->whereNotNull('dateTo') + ->where('dateTo', '>=', $nowDb)) + // No dates + ->orWhere(fn(Builder $query) => $query + ->whereNull('dateFrom') + ->whereNull('dateTo')) + // To and from dates + ->orWhere(fn(Builder $query) => $query + ->whereNotNull('dateFrom') + ->whereNotNull('dateTo') + ->where('dateFrom', '<=', $nowDb) + ->where('dateTo', '>=', $nowDb)); + }) + ->where('enabled', true) + ->orderBy('sortOrder') + ->get() + ->map(fn(object $row) => (array)$row) + ->all(); + + foreach ($activeSales as $activeSale) { + // A sale that matches every group, purchasable and category matches every variant, + // so there's nothing left to narrow by + if ($activeSale['allGroups'] && $activeSale['allPurchasables'] && $activeSale['allCategories']) { + if ($this->hasSales) { + $this->whereBool('purchasables_stores.promotable', true); + } + + return; + } + } + + $activeSaleIds = array_column($activeSales, 'id'); + + // Only force user group restriction on site requests + if (!request()->isCpRequest()) { + $userGroupIds = []; + + // TODO: migrate to the new user API once the User element's groups are available on CraftUser + if ($user = currentUserElement()) { + $userGroupIds = array_column($user->getGroups(), 'id'); + } + + // If the user doesn't belong to any groups, remove sales that + // restrict by user group as these would never match + if (empty($userGroupIds)) { + foreach ($activeSales as $activeSale) { + if (!$activeSale['allGroups']) { + $activeSaleIds = array_values(array_diff($activeSaleIds, [$activeSale['id']])); + break; + } + } + } else { + // Exclude any sales that have a user group restriction that the current user is not part of + $userGroupSalesIds = DB::table(Table::SALES . ' as sales') + ->select('sales.id') + ->leftJoin(Table::SALE_USERGROUPS . ' as su', 'su.saleId', '=', 'sales.id') + ->whereIn('sales.id', $activeSaleIds) + ->whereIn('userGroupId', $userGroupIds) + ->pluck('id') + ->all(); + + foreach ($activeSales as $activeSale) { + if (!$activeSale['allGroups'] && !in_array($activeSale['id'], $userGroupSalesIds, false)) { + $activeSaleIds = array_values(array_diff($activeSaleIds, [$activeSale['id']])); + } + } + } + } + + $activeSales = array_values(array_filter( + $activeSales, + fn(array $sale) => in_array($sale['id'], $activeSaleIds, false), + )); + + // Check to see if we have any sales that match all products and categories + // so we can skip extra processing if needed + $allProductsAndCategoriesSales = array_filter( + $activeSales, + fn(array $sale) => $sale['allPurchasables'] && $sale['allCategories'], + ); + + /** @var array $hasSalesConditions */ + $hasSalesConditions = []; + + if (empty($allProductsAndCategoriesSales)) { + $purchasableRestrictedSaleIds = array_column( + array_filter($activeSales, fn(array $sale) => !$sale['allPurchasables']), + 'id', + ); + $categoryRestrictedSales = array_filter($activeSales, fn(array $sale) => !$sale['allCategories']); + + $hasSalesConditions[] = fn(Builder $query) => $query->whereIn( + 'commerce_variants.id', + DB::table(Table::SALE_PURCHASABLES . ' as sp') + ->select('purchasableId') + ->whereIn('saleId', $purchasableRestrictedSaleIds), + ); + + if (!empty($categoryRestrictedSales)) { + $sourceSaleIds = array_column(array_filter($categoryRestrictedSales, fn(array $sale) => in_array($sale['categoryRelationshipType'], [ + SaleRecord::CATEGORY_RELATIONSHIP_TYPE_SOURCE, + SaleRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH, + ], true)), 'id'); + + $targetSaleIds = array_column(array_filter($categoryRestrictedSales, fn(array $sale) => in_array($sale['categoryRelationshipType'], [ + SaleRecord::CATEGORY_RELATIONSHIP_TYPE_TARGET, + SaleRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH, + ], true)), 'id'); + + // Source relationships + if (!empty($sourceSaleIds)) { + $hasSalesConditions[] = fn(Builder $query) => $query->whereIn( + 'commerce_variants.primaryOwnerId', + $this->saleCategoryRelationsQuery($sourceSaleIds, 'sourceId', 'targetId', Product::class), + ); + + $hasSalesConditions[] = fn(Builder $query) => $query->whereIn( + 'commerce_variants.id', + $this->saleCategoryRelationsQuery($sourceSaleIds, 'sourceId', 'targetId', Variant::class), + ); + } + + // Target relationships + if (!empty($targetSaleIds)) { + $hasSalesConditions[] = fn(Builder $query) => $query->whereIn( + 'commerce_variants.primaryOwnerId', + $this->saleCategoryRelationsQuery($targetSaleIds, 'targetId', 'sourceId', Product::class), + ); + + $hasSalesConditions[] = fn(Builder $query) => $query->whereIn( + 'commerce_variants.id', + $this->saleCategoryRelationsQuery($targetSaleIds, 'targetId', 'sourceId', Variant::class), + ); + } + } + } + + if ($this->hasSales) { + $this->whereBool('purchasables_stores.promotable', true); + + if (!empty($hasSalesConditions)) { + $this->where(function(Builder $query) use ($hasSalesConditions) { + foreach ($hasSalesConditions as $condition) { + $query->orWhere($condition); + } + }); + } + } elseif (!empty($hasSalesConditions)) { + $this->whereNot(function(Builder $query) use ($hasSalesConditions) { + foreach ($hasSalesConditions as $condition) { + $query->orWhere($condition); + } + }); + } + } + + /** + * Returns a query for the IDs of elements of the given type that are related to any of the + * given sales' categories. + * + * @param array $saleIds + * @param class-string $elementType + */ + private function saleCategoryRelationsQuery(array $saleIds, string $selectColumn, string $joinColumn, string $elementType): Builder + { + return DB::table(Table::SALE_CATEGORIES . ' as sc') + ->select("rel.$selectColumn") + ->leftJoin(CraftTable::RELATIONS . ' as rel', "rel.$joinColumn", '=', 'sc.categoryId') + ->leftJoin(CraftTable::ELEMENTS . ' as sale_elements', 'sale_elements.id', '=', "rel.$selectColumn") + ->leftJoin(CraftTable::ELEMENTS_SITES . ' as sale_elements_sites', 'sale_elements_sites.elementId', '=', 'sc.categoryId') + ->whereIn('sc.saleId', $saleIds) + ->where('sale_elements.type', $elementType) + ->when($this->siteId !== null, fn(Builder $query) => $query->whereParam('sale_elements_sites.siteId', $this->siteId)) + ->whereBool('sale_elements_sites.enabled', true); + } + + /** + * Applies an authorization param to the query being prepared. + * + * @throws QueryAbortedException + */ + private function applyPermissionParam(?bool $value, string $permissionPrefix): void + { + if ($value === null) { + return; + } + + $user = currentUser(); + + if (!$user) { + throw new QueryAbortedException(); + } + + // TODO: migrate to app(ProductTypes::class)->getAllProductTypes() once service migrated to src/ + $productTypes = app(ProductTypes::class)->getAllProductTypes(); + + if (empty($productTypes)) { + return; + } + + $authorizedTypeIds = []; + + foreach ($productTypes as $productType) { + if ($user->can("$permissionPrefix:$productType->uid")) { + $authorizedTypeIds[] = $productType->id; + } + } + + if (count($authorizedTypeIds) === count($productTypes)) { + // They have access to everything + if (!$value) { + throw new QueryAbortedException(); + } + return; + } + + if (empty($authorizedTypeIds)) { + // They don't have access to anything + if ($value) { + throw new QueryAbortedException(); + } + return; + } + + if ($value) { + $this->whereIn('commerce_products.typeId', $authorizedTypeIds); + } else { + $this->whereNotIn('commerce_products.typeId', $authorizedTypeIds); + } + } + + #[Override] + protected function cacheTags(): array + { + $tags = []; + + if ($this->ownerId) { + foreach (Arr::wrap($this->ownerId) as $ownerId) { + $tags[] = "product:$ownerId"; + } + } + + array_push($tags, ...$this->nestedTraitCacheTags()); + + return $tags; + } +} diff --git a/src/Catalog/Validation/ProductRules.php b/src/Catalog/Validation/ProductRules.php new file mode 100644 index 0000000000..cafd2eabf7 --- /dev/null +++ b/src/Catalog/Validation/ProductRules.php @@ -0,0 +1,30 @@ +afterValidate($validator)` when it exists. This ruleset therefore only covers the + * handful of attributes that were plain type rules in the legacy `defineRules()`. + */ +class ProductRules extends ElementRules +{ + public function rules(): array + { + $rules = parent::rules(); + + $rules['typeId'] = ['nullable', 'integer']; + $rules['postDate'] = ['nullable', 'date']; + $rules['expiryDate'] = ['nullable', 'date']; + + return $rules; + } +} diff --git a/src/Catalog/Validation/VariantRules.php b/src/Catalog/Validation/VariantRules.php new file mode 100644 index 0000000000..8b84f61af7 --- /dev/null +++ b/src/Catalog/Validation/VariantRules.php @@ -0,0 +1,33 @@ +afterValidate($validator)` when it exists. + */ +class VariantRules extends PurchasableRules +{ + public function rules(): array + { + $rules = parent::rules(); + + $rules['stock'] = ['nullable', 'integer']; + $rules['fieldId'] = ['nullable', 'integer']; + $rules['ownerId'] = ['nullable', 'integer']; + $rules['primaryOwnerId'] = ['nullable', 'integer']; + $rules['sortOrder'] = ['nullable', 'integer']; + + return $rules; + } +} diff --git a/src/Catalog/Variants.php b/src/Catalog/Variants.php new file mode 100644 index 0000000000..21379e195d --- /dev/null +++ b/src/Catalog/Variants.php @@ -0,0 +1,83 @@ +productId($productId) + ->limit(null) + ->siteId($siteId); + + if ($includeDisabled) { + $variantQuery->status(null); + } + + return $variantQuery->all(); + } + + /** + * Returns a variant by its ID. + * + * @param int|null $siteId The site ID for which to fetch the variant. Defaults to `null` which is current site. + */ + public function getVariantById(int $variantId, ?int $siteId = null): ?Variant + { + /** @var Variant|null $variant */ + $variant = Elements::getElementById($variantId, Variant::class, $siteId); + + return $variant; + } + + /** + * @throws \RuntimeException + */ + public function getVariantGqlContentArguments(): array + { + if (empty($this->contentFieldCache)) { + $contentArguments = []; + + foreach (app(ProductTypes::class)->getAllProductTypes() as $productType) { + if (!GqlCommerceHelper::isSchemaAwareOf(Variant::gqlScopesByContext($productType))) { + continue; + } + + $fieldLayout = $productType->getVariantFieldLayout(); + foreach ($fieldLayout->getCustomFields() as $contentField) { + if (!$contentField instanceof GqlInlineFragmentFieldInterface) { + $contentArguments[$contentField->handle] = [ + 'name' => $contentField->handle, + 'type' => Type::listOf(QueryArgument::getType()), + ]; + } + } + } + + $this->contentFieldCache = $contentArguments; + } + + return $this->contentFieldCache; + } +} diff --git a/src/CatalogPricing/CatalogPricing.php b/src/CatalogPricing/CatalogPricing.php new file mode 100644 index 0000000000..440dd58542 --- /dev/null +++ b/src/CatalogPricing/CatalogPricing.php @@ -0,0 +1,649 @@ +setQueueProgress($queue, 10, 'Retrieving purchasables'); + + $isAllPurchasables = $purchasableIds === null; + + if ($isAllPurchasables) { + $purchasableIds = DB::table(Table::PURCHASABLES . ' as purchasables') + ->join(\CraftCms\Cms\Database\Table::ELEMENTS . ' as e', 'e.id', '=', 'purchasables.id') + ->whereNull('e.revisionId') + ->whereNull('e.draftId') + ->pluck('purchasables.id') + ->all(); + } else { + $allowedPurchasableIds = []; + foreach (array_chunk($purchasableIds, 2000) as $chunk) { + $allowed = DB::table(Table::PURCHASABLES . ' as purchasables') + ->join(\CraftCms\Cms\Database\Table::ELEMENTS . ' as e', 'e.id', '=', 'purchasables.id') + ->whereNull('e.revisionId') + ->whereNull('e.draftId') + ->whereIn('purchasables.id', $chunk) + ->pluck('purchasables.id') + ->all(); + $allowedPurchasableIds = array_merge($allowedPurchasableIds, $allowed); + } + $purchasableIds = $allowedPurchasableIds; + } + + if (empty($purchasableIds)) { + return; + } + + $cprWithUserIds = DB::table(Table::CATALOG_PRICING_RULES_USERS) + ->groupBy('catalogPricingRuleId') + ->pluck('catalogPricingRuleId') + ->all(); + + $cprStartTime = microtime(true); + if ($showConsoleOutput) { + // TODO: Migrate to Laravel console output + Console::stdout(PHP_EOL . 'Generating price data from catalog pricing rules... '); + } + + $this->setQueueProgress($queue, 20, 'Generating catalog pricing data'); + $catalogPricing = []; + + // TODO: Migrate to app(Stores::class)->getAllStores() once Stores service migrated + foreach (app(Stores::class)->getAllStores() as $store) { + $priceByPurchasableId = DB::table(Table::PURCHASABLES_STORES) + ->select(['purchasableId', 'basePrice', 'basePromotionalPrice']) + ->where('storeId', $store->id) + ->get() + ->keyBy('purchasableId') + ->all(); + + // TODO: Migrate to app(CatalogPricingRules::class)->getAllActiveCatalogPricingRules() once registered + $runCatalogPricingRules = $catalogPricingRules ?? app(CatalogPricingRules::class)->getAllActiveCatalogPricingRules($store->id)->all(); + + foreach ($runCatalogPricingRules as $catalogPricingRule) { + if ($catalogPricingRule->storeId !== $store->id || !$catalogPricingRule->enabled) { + continue; + } + + if (!empty($catalogPricingRule->getCustomerCondition()->getConditionRules()) && !in_array($catalogPricingRule->id, $cprWithUserIds, true)) { + continue; + } + + if ($catalogPricingRule->getPurchasableIds() === null) { + $applyPurchasableIds = $purchasableIds; + } else { + $applyPurchasableIds = $isAllPurchasables + ? $catalogPricingRule->getPurchasableIds() + : array_intersect($catalogPricingRule->getPurchasableIds(), $purchasableIds); + } + + if (empty($applyPurchasableIds)) { + continue; + } + + foreach ($applyPurchasableIds as $purchasableId) { + if (!isset($priceByPurchasableId[$purchasableId])) { + continue; + } + + $row = $priceByPurchasableId[$purchasableId]; + + // TODO: migrate to app(CatalogPricingRules::class)->generateRulePriceFromPrice() once registered + $catalogPrice = app(CatalogPricingRules::class)->generateRulePriceFromPrice( + $row->basePrice, + $row->basePromotionalPrice, + $catalogPricingRule + ); + + if ($catalogPrice === null) { + continue; + } + + $catalogPricing[] = [ + $purchasableId, + $catalogPrice, + $store->id, + $catalogPricingRule->isPromotionalPrice, + $catalogPricingRule->id, + // TODO: migrate to Laravel date helper once CraftDb::prepareDateForDb is replaced + $catalogPricingRule->dateFrom ? CraftDb::prepareDateForDb($catalogPricingRule->dateFrom) : null, + $catalogPricingRule->dateTo ? CraftDb::prepareDateForDb($catalogPricingRule->dateTo) : null, + false, + ]; + } + } + } + + $cprExecutionLength = microtime(true) - $cprStartTime; + if ($showConsoleOutput) { + Console::stdout('done!'); + Console::stdout(PHP_EOL . 'Created ' . count($catalogPricing) . ' rule price data in ' . round($cprExecutionLength, 2) . ' seconds' . PHP_EOL); + } + + $this->setQueueProgress($queue, 40, 'Clearing existing catalog prices'); + + DB::beginTransaction(); + + if (!$isAllPurchasables || !empty($catalogPricingRules)) { + foreach (array_chunk($purchasableIds, 1000) as $chunk) { + $where = ['purchasableId' => $chunk]; + $query = DB::table(Table::CATALOG_PRICING)->whereIn('purchasableId', $chunk); + + if (!empty($catalogPricingRules)) { + $ruleIds = array_column($catalogPricingRules, 'id'); + $query->whereIn('catalogPricingRuleId', $ruleIds); + } + + $query->delete(); + } + } else { + DB::table(Table::CATALOG_PRICING)->truncate(); + } + + if (empty($catalogPricingRules)) { + $this->setQueueProgress($queue, 60, 'Copying base prices to catalog pricing'); + $total = count($purchasableIds); + $baseStateTime = microtime(true); + $count = 1; + + $uuidFunction = Sql::uuidSql(); + $nowFunction = Sql::nowSql(); + + $cpTable = Table::CATALOG_PRICING; + $psTable = Table::PURCHASABLES_STORES; + + foreach (array_chunk($purchasableIds, $chunkSize) as $chunk) { + $fromCount = number_format($count, 0); + $toCount = ($count + ($chunkSize - 1)) > $total ? $total : number_format($count + count($chunk) - 1, 0); + + if ($showConsoleOutput) { + Console::stdout(PHP_EOL . sprintf('Generating base prices rows for purchasables %s to %s of %s... ', $fromCount, $toCount, $total)); + } + + $idList = implode(',', array_map('intval', $chunk)); + + DB::statement(" + INSERT INTO {$cpTable} (price, purchasableId, storeId, uid, dateCreated, dateUpdated) + SELECT basePrice, purchasableId, storeId, {$uuidFunction}, {$nowFunction}, {$nowFunction} + FROM {$psTable} + WHERE purchasableId IN ({$idList}) + "); + + DB::statement(" + INSERT INTO {$cpTable} (price, purchasableId, storeId, isPromotionalPrice, uid, dateCreated, dateUpdated) + SELECT basePromotionalPrice, purchasableId, storeId, true, {$uuidFunction}, {$nowFunction}, {$nowFunction} + FROM {$psTable} + WHERE basePromotionalPrice IS NOT NULL AND purchasableId IN ({$idList}) + "); + + if ($showConsoleOutput) { + Console::stdout('done!'); + } + $count += $chunkSize; + } + + $baseExecutionLength = microtime(true) - $baseStateTime; + if ($showConsoleOutput) { + Console::stdout(PHP_EOL . 'Generated ' . $total . ' base prices in ' . round($baseExecutionLength, 2) . ' seconds' . PHP_EOL); + } + } + + $this->setQueueProgress($queue, 80, 'Inserting catalog pricing'); + + if (!empty($catalogPricing)) { + $count = 1; + $startTime = microtime(true); + $total = count($catalogPricing); + + foreach (array_chunk($catalogPricing, $chunkSize) as $chunk) { + $fromCount = number_format($count, 0); + $toCount = ($count + ($chunkSize - 1)) > $total ? number_format($total, 0) : number_format($count + count($chunk) - 1, 0); + + if ($showConsoleOutput) { + Console::stdout(PHP_EOL . sprintf('Inserting catalog pricing rule prices rows %s to %s of %s... ', $fromCount, $toCount, number_format($total, 0))); + } + + DB::table(Table::CATALOG_PRICING)->insert(array_map(fn($row) => [ + 'purchasableId' => $row[0], + 'price' => $row[1], + 'storeId' => $row[2], + 'isPromotionalPrice' => $row[3], + 'catalogPricingRuleId' => $row[4], + 'dateFrom' => $row[5], + 'dateTo' => $row[6], + 'hasUpdatePending' => $row[7], + ], $chunk)); + + $count += $chunkSize; + + if ($showConsoleOutput) { + Console::stdout('done!'); + } + } + + $executionLength = microtime(true) - $startTime; + if ($showConsoleOutput) { + Console::stdout(PHP_EOL . 'Generated ' . number_format($total, 0) . ' prices in ' . round($executionLength, 2) . ' seconds' . PHP_EOL); + } + } + + DB::commit(); + + $this->setQueueProgress($queue, 100); + } + + public function getCatalogPrice(int $purchasableId, ?int $storeId = null, ?int $userId = null, bool $isPromotionalPrice = false): ?float + { + // TODO: migrate to app(Stores::class)->getCurrentStore()->id once Stores service migrated + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + $userKey = $userId ?? 'all'; + $promoKey = $isPromotionalPrice ? 'promo' : 'standard'; + $key = 'catalog-price-' . implode('-', [$storeId, $userKey, $promoKey]); + + if ($this->allCatalogPrices === null || !isset($this->allCatalogPrices[$key])) { + $result = $this->createCatalogPricesQuery($userId, $storeId) + ->addSelect(['purchasableId']) + ->get() + ->keyBy('purchasableId'); + + $this->allCatalogPrices[$key] = $result->pluck( + $isPromotionalPrice ? 'promotionalPrice' : 'price', + 'purchasableId' + )->all(); + } + + return $this->allCatalogPrices[$key][$purchasableId] ?? null; + } + + /** + * @return Collection + */ + public function getCatalogPricesByPurchasableId(int $purchasableId, ?int $storeId = null): Collection + { + // TODO: migrate to app(Stores::class)->getCurrentStore()->id once Stores service migrated + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + $rows = $this->createCatalogPricesQuery(storeId: $storeId, allPrices: true) + ->select(['id', 'price', 'purchasableId', 'storeId', 'isPromotionalPrice', 'catalogPricingRuleId', 'dateFrom', 'dateTo', 'uid']) + ->where('cp.purchasableId', $purchasableId) + ->whereNotNull('cp.catalogPricingRuleId') + ->get() + ->all(); + + return collect($rows)->map(fn($row) => new CatalogPricingModel((array) $row)); + } + + /** + * @return Collection + */ + public function getCatalogPrices(int $storeId, ?CatalogPricingCondition $conditionBuilder = null, bool $includeBasePrices = true, ?string $searchText = null, ?int $limit = null, ?int $offset = null): Collection + { + $rows = $this->buildCatalogPricesQuery($storeId, $conditionBuilder, $includeBasePrices, $searchText, $limit, $offset) + ->select(['price', 'purchasableId', 'storeId', 'isPromotionalPrice', 'catalogPricingRuleId', 'dateFrom', 'dateTo', 'cp.uid']) + ->orderBy('purchasableId') + ->orderBy('catalogPricingRuleId') + ->get() + ->all(); + + return collect($rows)->map(fn($row) => new CatalogPricingModel((array) $row)); + } + + public function getCatalogPricesPageInfo(int $storeId, ?CatalogPricingCondition $conditionBuilder = null, bool $includeBasePrices = true, ?string $searchText = null, int $limit = 100, int $offset = 0): array + { + $total = $this->buildCatalogPricesQuery($storeId, $conditionBuilder, $includeBasePrices, $searchText) + ->groupBy('purchasableId') + ->getCountForPagination(['purchasableId']); + + return [ + 'first' => $offset + 1, + 'last' => $offset + $limit, + 'total' => $total, + 'prevUrl' => null, + 'nextUrl' => null, + ]; + } + + public function markPricesAsUpdatePending(int|array|null $catalogPricingRuleId = null, int|array|null $purchasableId = null, int|array|null $storeId = null): void + { + $query = DB::table(Table::CATALOG_PRICING); + + if ($catalogPricingRuleId !== null) { + is_array($catalogPricingRuleId) ? $query->whereIn('catalogPricingRuleId', $catalogPricingRuleId) : $query->where('catalogPricingRuleId', $catalogPricingRuleId); + } + if ($purchasableId !== null) { + is_array($purchasableId) ? $query->whereIn('purchasableId', $purchasableId) : $query->where('purchasableId', $purchasableId); + } + if ($storeId !== null) { + is_array($storeId) ? $query->whereIn('storeId', $storeId) : $query->where('storeId', $storeId); + } + + $query->update(['hasUpdatePending' => true]); + } + + /** + * @deprecated in 5.5.0 + * TODO: remove when callers have been migrated + */ + public function afterSavePurchasableHandler(mixed $event): void + { + // TODO: update to new Purchasable element API once migrated + $purchasable = $event->sender; + if ($purchasable->propagating || $purchasable->getIsDraft() || $purchasable->getIsRevision()) { + return; + } + + $this->createCatalogPricingJob(['purchasableIds' => [$purchasable->id], 'storeId' => $purchasable->storeId]); + } + + /** + * TODO: Migrate to Laravel queue once queue job class is migrated + */ + public function createCatalogPricingJob(array $config = [], int $priority = 100): void + { + $catalogPricingRuleIds = $this->_normalizeIds($config['catalogPricingRuleIds'] ?? null); + $purchasableIds = $this->_normalizeIds($config['purchasableIds'] ?? null); + + if ($catalogPricingRuleIds === [] && $purchasableIds === []) { + return; + } + + $storeId = $config['storeId'] ?? null; + $this->markPricesAsUpdatePending($catalogPricingRuleIds, $purchasableIds, $storeId); + + // Queue purchasable-based and rule-based work into separate rows so they are never cross-contaminated. + // Catalog pricing rules determine which purchasables are relevant, so the two must be processed independently. + + if (!empty($purchasableIds) || ($purchasableIds === null && empty($catalogPricingRuleIds))) { + // Specific purchasable IDs: these will be regenerated against all applicable rules + $this->_queueCatalogPricingIds($storeId, CatalogPricingQueueRecord::TYPE_PURCHASABLE, $purchasableIds); + } + + if (!empty($catalogPricingRuleIds)) { + $this->_queueCatalogPricingIds($storeId, CatalogPricingQueueRecord::TYPE_RULE, $catalogPricingRuleIds); + } + + // TODO: Migrate to Laravel queue dispatch once CatalogPricingJob is migrated + \craft\helpers\Queue::push(\Craft::createObject(CatalogPricingJob::class), $priority); + } + + public function areCatalogPricingJobsRunning(): bool + { + return DB::table(Table::CATALOG_PRICING_QUEUE)->exists(); + } + + /** + * Reserves one pending queue row for processing. + */ + public function reserveCatalogPricingQueueRow(): ?CatalogPricingQueueRecord + { + $lock = Cache::lock('catalogpricingqueue', 30); + + // Use the same lock as the write methods so that reservation and inserts/merges are fully serialised. + // Non-blocking: if a write operation is currently holding the lock, return null and let the next + // queue job execution pick up the row instead. + if (!$lock->get()) { + return null; + } + + try { + $pendingId = DB::table(Table::CATALOG_PRICING_QUEUE) + ->where('reserved', false) + ->orderBy('id') + ->value('id'); + + if (!$pendingId) { + return null; + } + + $record = CatalogPricingQueueRecord::where('id', (int)$pendingId) + ->where('reserved', false) + ->first(); + + if (!$record) { + return null; + } + + $record->reserved = true; + $record->save(); + + return $record; + } finally { + $lock->release(); + } + } + + public function releaseCatalogPricingQueueRowById(int $id): void + { + $record = CatalogPricingQueueRecord::find($id); + if ($record) { + $record->reserved = false; + $record->save(); + } + } + + public function deleteCatalogPricingQueueRowById(int $id): void + { + CatalogPricingQueueRecord::where('id', $id)->delete(); + } + + /** + * Queues catalog pricing regeneration IDs by row type, merging into any existing unreserved row + * for the same store and type. + * + * @throws \RuntimeException if the queue mutex cannot be acquired + */ + private function _queueCatalogPricingIds(?int $storeId, string $type, ?array $ids): void + { + $lock = Cache::lock('catalogpricingqueue', 30); + try { + $lock->block(5); + } catch (LockTimeoutException) { + throw new \RuntimeException('Unable to acquire the catalog pricing queue mutex.'); + } + + try { + // Merge into an existing unreserved row for the same store and type. + $pendingRecord = CatalogPricingQueueRecord::where('storeId', $storeId) + ->where('type', $type) + ->where('reserved', false) + ->first(); + + if ($pendingRecord) { + // Merge IDs, preserving null to represent the broader "all IDs" scope. + $pendingIds = $pendingRecord->ids; + $ids = ($pendingIds === null || $ids === null) + ? null + : $this->_normalizeIds(array_merge($pendingIds, $ids)); + + $pendingRecord->ids = $ids; + $pendingRecord->save(); + + return; + } + + $record = new CatalogPricingQueueRecord(); + $record->storeId = $storeId; + $record->type = $type; + $record->ids = $ids; + $record->reserved = false; + $record->save(); + } finally { + $lock->release(); + } + } + + private function _normalizeIds(?array $ids): ?array + { + if ($ids === null) { + return null; + } + + $ids = array_map(fn(mixed $id) => (int)$id, $ids); + $ids = array_values(array_unique(array_filter($ids, fn(int $id) => $id > 0))); + sort($ids, SORT_NUMERIC); + + return $ids; + } + + /** + * Creates a query for catalog prices, selecting price/promotionalPrice/salePrice columns. + */ + public function createCatalogPricesQuery(?int $userId = null, int|string|null $storeId = null, bool $allPrices = false, ?CatalogPricingCondition $condition = null): \Illuminate\Database\Query\Builder + { + $query = DB::table(Table::CATALOG_PRICING . ' as cp') + ->select([ + DB::raw('MIN(CASE WHEN isPromotionalPrice = FALSE THEN price END) AS price'), + DB::raw('MIN(CASE WHEN isPromotionalPrice = TRUE THEN price END) AS promotionalPrice'), + DB::raw('MIN(price) AS salePrice'), + ]); + + $condition ??= Conditions::createCondition([ + 'class' => CatalogPricingCondition::class, + 'allPrices' => $allPrices, + ]); + + if ($userId) { + $condition->addConditionRule(Conditions::createConditionRule([ + 'class' => CatalogPricingCustomerConditionRule::class, + 'customerId' => $userId, + ])); + } + + /** @var CatalogPricingCondition $condition */ + $condition->modifyQuery($query); + + $query->where(function($q) { + $q->whereNull('dateFrom')->orWhereRaw('dateFrom <= ?', [CraftDb::prepareDateForDb(new DateTime())]); + })->where(function($q) { + $q->whereNull('dateTo')->orWhereRaw('dateTo >= ?', [CraftDb::prepareDateForDb(new DateTime())]); + }); + + if (!$allPrices) { + $query->groupBy(['purchasableId', 'storeId']); + } + + if ($storeId) { + $query->where('storeId', $storeId); + } + + return $query; + } + + /** + * @deprecated in 5.1.0. Use createCatalogPricesQuery() instead. + */ + public function createCatalogPricingQuery(?int $userId = null, int|string|null $storeId = null, ?bool $isPromotionalPrice = null, bool $allPrices = false, ?CatalogPricingCondition $condition = null): \Illuminate\Database\Query\Builder + { + $query = DB::table(Table::CATALOG_PRICING . ' as cp') + ->select([DB::raw('MIN(price) as price')]); + + $condition ??= Conditions::createCondition([ + 'class' => CatalogPricingCondition::class, + 'allPrices' => $allPrices, + ]); + + if ($userId) { + $condition->addConditionRule(Conditions::createConditionRule([ + 'class' => CatalogPricingCustomerConditionRule::class, + 'customerId' => $userId, + ])); + } + + /** @var CatalogPricingCondition $condition */ + $condition->modifyQuery($query); + + $query->where(function($q) { + $q->whereNull('dateFrom')->orWhereRaw('dateFrom <= ?', [CraftDb::prepareDateForDb(new DateTime())]); + })->where(function($q) { + $q->whereNull('dateTo')->orWhereRaw('dateTo >= ?', [CraftDb::prepareDateForDb(new DateTime())]); + })->orderBy('purchasableId')->orderBy('price'); + + if (!$allPrices) { + $query->groupBy(['purchasableId', 'storeId']); + } + + if ($storeId) { + $query->where('storeId', $storeId); + } + + if ($isPromotionalPrice !== null) { + $query->where('isPromotionalPrice', $isPromotionalPrice); + } + + return $query; + } + + private function buildCatalogPricesQuery(int $storeId, ?CatalogPricingCondition $conditionBuilder = null, bool $includeBasePrices = true, ?string $searchText = null, ?int $limit = null, ?int $offset = null): \Illuminate\Database\Query\Builder + { + $query = $this->createCatalogPricesQuery(storeId: $storeId, allPrices: true, condition: $conditionBuilder); + + if (!$includeBasePrices) { + $query->whereNotNull('catalogPricingRuleId'); + } + + $subQuery = DB::table(Table::PURCHASABLES)->select('id'); + + if ($limit) { + $subQuery->limit($limit); + } + if ($offset) { + $subQuery->offset($offset); + } + + if ($searchText) { + $likeOp = DB::connection()->getDriverName() === 'pgsql' ? 'ilike' : 'like'; + $subQuery->where('description', $likeOp, '%' . $searchText . '%'); + } + + $query->joinSub($subQuery, 'purchasables', 'purchasables.id', '=', 'cp.purchasableId'); + + if ($conditionBuilder !== null) { + $conditionBuilder->modifyQuery($query); + } + + return $query; + } + + private function setQueueProgress(mixed $queue, float $progress, ?string $label = null): void + { + // TODO: migrate to Laravel queue progress interface once queue system migrated + if (is_object($queue) && method_exists($queue, 'setProgress')) { + $queue->setProgress((int) $progress, $label); + } + } +} diff --git a/src/CatalogPricing/CatalogPricingRules.php b/src/CatalogPricing/CatalogPricingRules.php new file mode 100644 index 0000000000..88f2ebe04e --- /dev/null +++ b/src/CatalogPricing/CatalogPricingRules.php @@ -0,0 +1,323 @@ +>|null */ + private ?array $allCatalogPricingRules = null; + + public function hasCatalogPricingRules(): bool + { + if (!$this->canUseCatalogPricingRules()) { + return false; + } + + if ($this->hasCatalogPricingRulesCache === null) { + $this->hasCatalogPricingRulesCache = $this->query()->exists(); + } + + return (bool) $this->hasCatalogPricingRulesCache; + } + + public function canUseCatalogPricingRules(): bool + { + // TODO: migrate to app(Sales::class)->getAllSales() once Sales service migrated + if (!empty(app(Sales::class)->getAllSales())) { + return false; + } + + return true; + } + + public function getCatalogPricingRuleById(int $id, ?int $storeId = null): ?CatalogPricingRule + { + return $this->getAllCatalogPricingRules($storeId)->firstWhere('id', $id); + } + + /** + * @return Collection + */ + public function getAllCatalogPricingRules(?int $storeId = null): Collection + { + // TODO: migrate to app(Stores::class)->getCurrentStore()->id once Stores service migrated + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + if ($this->allCatalogPricingRules === null || !isset($this->allCatalogPricingRules[$storeId])) { + $rows = $this->query()->where('storeId', $storeId)->get()->all(); + + $this->allCatalogPricingRules ??= []; + $this->allCatalogPricingRules[$storeId] = $this->createModels($rows)->keyBy('id'); + } + + return $this->allCatalogPricingRules[$storeId]; + } + + /** + * @return Collection + */ + public function getAllCatalogPricingRulesByPurchasableId(int $purchasableId, ?int $storeId = null): Collection + { + // TODO: migrate to app(Stores::class)->getCurrentStore()->id once Stores service migrated + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + $rows = $this->query() + ->whereIn('id', function($sub) use ($purchasableId) { + $sub->select('catalogPricingRuleId') + ->from(Table::CATALOG_PRICING) + ->where('purchasableId', $purchasableId); + }) + ->where('storeId', $storeId) + ->get() + ->all(); + + return $this->createModels($rows); + } + + /** + * @return Collection + */ + public function getAllEnabledCatalogPricingRules(?int $storeId = null): Collection + { + return $this->getAllCatalogPricingRules($storeId)->filter(fn(CatalogPricingRule $r) => $r->enabled); + } + + /** + * @return Collection + */ + public function getAllActiveCatalogPricingRules(?int $storeId = null): Collection + { + return $this->getAllEnabledCatalogPricingRules($storeId)->filter(fn(CatalogPricingRule $r) => + ($r->dateFrom === null || $r->dateFrom->getTimestamp() <= time()) && + ($r->dateTo === null || $r->dateTo->getTimestamp() >= time()) + ); + } + + /** + * @return Collection + */ + public function getAllCatalogPricingRulesWithUserConditions(?int $storeId = null): Collection + { + return $this->getAllCatalogPricingRules($storeId)->filter( + fn(CatalogPricingRule $r) => !empty($r->getCustomerCondition()->getConditionRules()) + ); + } + + public function generateRulePriceFromPrice(?float $basePrice, ?float $basePromotionalPrice, CatalogPricingRule $catalogPricingRule): ?float + { + $price = null; + + if ($catalogPricingRule->applyPriceType === CatalogPricingRuleRecord::APPLY_PRICE_TYPE_PRICE) { + $price = $basePrice; + } elseif ($catalogPricingRule->applyPriceType === CatalogPricingRuleRecord::APPLY_PRICE_TYPE_PROMOTIONAL_PRICE) { + if ($basePromotionalPrice === null) { + return null; + } + $price = $basePromotionalPrice; + } + + if ($price === null) { + return null; + } + + return $catalogPricingRule->getRulePriceFromPrice($price); + } + + public function afterSaveUserHandler(ElementSaved|UserAssignedToGroups $event): void + { + if ($event instanceof ElementSaved && !$event->element instanceof User) { + return; + } + + // TODO: migrate to app(Stores::class)->getAllStores() once Stores service migrated + $stores = app(Stores::class)->getAllStores(); + + foreach ($stores as $store) { + $rules = $this->getAllCatalogPricingRulesWithUserConditions($store->id); + if ($rules->isEmpty()) { + continue; + } + + /** @var User $user */ + $user = $event instanceof ElementSaved ? $event->element : Users::getUserById($event->userId); + + $rules->each(function(CatalogPricingRule $rule) use ($user) { + $customerCondition = $rule->getCustomerCondition(); + if ($customerCondition->matchElement($user)) { + $exists = DB::table(Table::CATALOG_PRICING_RULES_USERS) + ->where('userId', $user->id) + ->where('catalogPricingRuleId', $rule->id) + ->exists(); + + if (!$exists) { + $now = now()->toDateTimeString(); + DB::table(Table::CATALOG_PRICING_RULES_USERS)->insert([ + 'userId' => $user->id, + 'catalogPricingRuleId' => $rule->id, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + } + } else { + DB::table(Table::CATALOG_PRICING_RULES_USERS) + ->where('userId', $user->id) + ->where('catalogPricingRuleId', $rule->id) + ->delete(); + } + }); + } + } + + public function saveCatalogPricingRule(CatalogPricingRule $catalogPricingRule, bool $runValidation = true): bool + { + $isNew = !$catalogPricingRule->id; + + if ($isNew) { + $record = new CatalogPricingRuleRecord(); + } else { + $record = CatalogPricingRuleRecord::find($catalogPricingRule->id); + + if (!$record) { + throw new \RuntimeException(t('No catalog pricing rule exists with the ID "{id}"', ['id' => $catalogPricingRule->id], category: 'commerce')); + } + } + + if ($runValidation && !$catalogPricingRule->validate()) { + Log::info('Catalog pricing rule not saved due to validation error.'); + return false; + } + + $record->apply = $catalogPricingRule->apply; + $record->applyAmount = $catalogPricingRule->applyAmount; + $record->applyPriceType = $catalogPricingRule->applyPriceType; + $record->dateFrom = $catalogPricingRule->dateFrom ? Carbon::instance($catalogPricingRule->dateFrom) : null; + $record->dateTo = $catalogPricingRule->dateTo ? Carbon::instance($catalogPricingRule->dateTo) : null; + $record->description = $catalogPricingRule->description; + $record->enabled = $catalogPricingRule->enabled; + $record->isPromotionalPrice = $catalogPricingRule->isPromotionalPrice; + $record->name = $catalogPricingRule->name; + $record->storeId = $catalogPricingRule->storeId; + $record->metadata = $catalogPricingRule->getMetadata(); + $record->customerCondition = $catalogPricingRule->getCustomerCondition()->getConfig(); + $record->productCondition = $catalogPricingRule->getProductCondition()->getConfig(); + $record->variantCondition = $catalogPricingRule->getVariantCondition()->getConfig(); + $record->purchasableCondition = $catalogPricingRule->getPurchasableCondition()->getConfig(); + + DB::beginTransaction(); + + try { + $record->save(); + $catalogPricingRule->id = $record->id; + + DB::table(Table::CATALOG_PRICING_RULES_USERS) + ->where('catalogPricingRuleId', $catalogPricingRule->id) + ->delete(); + + foreach (array_chunk($catalogPricingRule->getUserIds() ?? [], 1000) as $chunk) { + $rows = array_map(fn($userId) => [ + 'catalogPricingRuleId' => $catalogPricingRule->id, + 'userId' => $userId, + ], $chunk); + + DB::table(Table::CATALOG_PRICING_RULES_USERS)->insert($rows); + } + + DB::commit(); + + // TODO: migrate to app(CatalogPricing::class)->createCatalogPricingJob() once CatalogPricing service is registered + app(CatalogPricing::class)->createCatalogPricingJob([ + 'catalogPricingRuleIds' => [$catalogPricingRule->id], + 'storeId' => $catalogPricingRule->storeId, + ]); + + $this->clearCaches(); + + return true; + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + } + + public function deleteCatalogPricingRuleById(int $id): bool + { + $record = CatalogPricingRuleRecord::find($id); + + if (!$record) { + return false; + } + + $this->clearCaches(); + + return (bool) $record->delete(); + } + + private function clearCaches(): void + { + $this->allCatalogPricingRules = null; + $this->hasCatalogPricingRulesCache = null; + } + + private function query(): \Illuminate\Database\Query\Builder + { + return DB::table(Table::CATALOG_PRICING_RULES) + ->select([ + 'apply', + 'applyAmount', + 'applyPriceType', + 'customerCondition', + 'dateCreated', + 'dateFrom', + 'dateTo', + 'dateUpdated', + 'description', + 'enabled', + 'id', + 'isPromotionalPrice', + 'metadata', + 'name', + 'productCondition', + 'purchasableCondition', + 'storeId', + 'variantCondition', + ]); + } + + /** + * @param array $rows + * @return Collection + */ + private function createModels(array $rows): Collection + { + return collect($rows)->map(function($row) { + $data = (array) $row; + $data['customerCondition'] ??= ''; + $data['productCondition'] ??= ''; + $data['purchasableCondition'] ??= ''; + $data['variantCondition'] ??= ''; + + return new CatalogPricingRule($data); + }); + } +} diff --git a/src/CatalogPricing/Conditions/CatalogPricingCondition.php b/src/CatalogPricing/Conditions/CatalogPricingCondition.php new file mode 100644 index 0000000000..08be3d506d --- /dev/null +++ b/src/CatalogPricing/Conditions/CatalogPricingCondition.php @@ -0,0 +1,131 @@ + ['boolean'], + ]); + } + + #[Override] + protected function selectableConditionRules(): array + { + return [ + CatalogPricingPurchasableConditionRule::class, + CatalogPricingCustomerConditionRule::class, + ]; + } + + #[Override] + protected function isConditionRuleSelectable(ConditionRuleInterface $rule): bool + { + if (!parent::isConditionRuleSelectable($rule)) { + return false; + } + + // Make sure the rule doesn't conflict with the existing params + $queryParams = array_merge($this->queryParams); + foreach ($this->getConditionRules() as $existingRule) { + /** @var CatalogPricingConditionRuleInterface $existingRule */ + array_push($queryParams, ...$existingRule->getExclusiveQueryParams()); + } + + $queryParams = array_flip($queryParams); + + if (method_exists($rule, 'getExclusiveQueryParams')) { + foreach ($rule->getExclusiveQueryParams() as $param) { + if (isset($queryParams[$param])) { + return false; + } + } + } + + return true; + } + + #[Override] + public function config(): array + { + $config = parent::config(); + $config['allPrices'] = $this->allPrices; + + return $config; + } + + public function modifyQuery(Builder $query): void + { + $rules = $this->getConditionRules(); + + /** @var CatalogPricingCustomerConditionRule|null $customerRule */ + $customerRule = Arr::first($rules, fn(ConditionRuleInterface $rule) => $rule instanceof CatalogPricingCustomerConditionRule); + + if ($customerRule) { + foreach ($rules as $key => $rule) { + if ($rule instanceof CatalogPricingCustomerConditionRule) { + unset($rules[$key]); + + // Can break here because there is only one customer condition rule + break; + } + } + } + + $hasRestriction = !$this->allPrices || $customerRule !== null; + + if ($hasRestriction) { + $query->where(function(Builder $q) use ($customerRule) { + // If we are looking for all prices, we don't need to worry about the user's table + if (!$this->allPrices) { + $q->orWhereNull('catalogPricingRuleId'); + $q->orWhereIn('catalogPricingRuleId', function(Builder $sub) { + $sub->select('cpr.id') + ->from(Table::CATALOG_PRICING_RULES . ' as cpr') + ->leftJoin(Table::CATALOG_PRICING_RULES_USERS . ' as cpru', 'cpr.id', '=', 'cpru.catalogPricingRuleId') + ->whereNull('cpru.id') + ->groupBy('cpr.id'); + }); + } + + if ($customerRule) { + // Sub query to figure out which catalog pricing rules are using user conditions + $q->orWhereIn('catalogPricingRuleId', function(Builder $sub) use ($customerRule) { + $sub->select('cpr.id') + ->from(Table::CATALOG_PRICING_RULES . ' as cpr') + ->leftJoin(Table::CATALOG_PRICING_RULES_USERS . ' as cpru', 'cpr.id', '=', 'cpru.catalogPricingRuleId') + ->where('cpru.userId', $customerRule->customerId) + ->whereNotNull('cpru.id') + ->groupBy('cpr.id'); + }); + } + }); + } + + // Apply the rest of the rules + foreach ($rules as $rule) { + /** @var CatalogPricingConditionRuleInterface $rule */ + $rule->modifyQuery($query); + } + } +} diff --git a/src/CatalogPricing/Conditions/CatalogPricingCustomerConditionRule.php b/src/CatalogPricing/Conditions/CatalogPricingCustomerConditionRule.php new file mode 100644 index 0000000000..9f05e3146e --- /dev/null +++ b/src/CatalogPricing/Conditions/CatalogPricingCustomerConditionRule.php @@ -0,0 +1,74 @@ + $this->customerId, + ]); + } + + #[Override] + public function getRules(): array + { + return array_merge(parent::getRules(), [ + 'customerId' => ['nullable', 'integer'], + ]); + } + + #[Override] + protected function inputHtml(): string + { + return Html::hiddenLabel($this->getLabel(), 'customer') . + Html::tag('div', + Cp::elementSelectHtml([ + 'name' => 'customerId', + 'elements' => array_filter([$this->customerId]), + 'elementType' => User::class, + 'sources' => null, + 'criteria' => null, + 'single' => true, + ]), + [ + 'class' => ['flex', 'flex-start'], + ] + ); + } + + #[Override] + public function getExclusiveQueryParams(): array + { + return ['customer']; + } + + #[Override] + public function modifyQuery(Builder $query): void + { + // Doesn't modify the query as the modification + // of the query happens in `CatalogPricingCondition::modifyQuery()` for this rule + } +} diff --git a/src/CatalogPricing/Conditions/CatalogPricingPurchasableConditionRule.php b/src/CatalogPricing/Conditions/CatalogPricingPurchasableConditionRule.php new file mode 100644 index 0000000000..3cca421c34 --- /dev/null +++ b/src/CatalogPricing/Conditions/CatalogPricingPurchasableConditionRule.php @@ -0,0 +1,124 @@ +_elementIds = $value; + } + + public function getElementIds(): ?array + { + if ($this->_elementIds === null) { + return null; + } + + $elementIds = []; + foreach ($this->_elementIds as $ids) { + $elementIds = array_merge($elementIds, $ids); + } + + return $elementIds; + } + + #[Override] + public function getConfig(): array + { + return array_merge(parent::getConfig(), [ + 'elementIds' => $this->_elementIds, + ]); + } + + #[Override] + public function getRules(): array + { + return array_merge(parent::getRules(), [ + 'elementIds' => ['nullable', 'array'], + ]); + } + + #[Override] + protected function inputHtml(): string + { + $id = 'purchasable'; + + $html = ''; + foreach (app(Purchasables::class)->getAllPurchasableElementTypes() as $purchasableType) { + /** @var PurchasableInterface|string $purchasableType */ + $elements = null; + if (!empty($this->_elementIds) && isset($this->_elementIds[$purchasableType]) && !empty($this->_elementIds[$purchasableType])) { + $elements = $purchasableType::find() + ->id($this->_elementIds[$purchasableType]) + ->status(null) + ->all(); + } + + $html .= Html::tag('div', + Html::beginTag('div') . + Html::tag('strong', $purchasableType::displayName()) . + Html::endTag('div') . + Cp::elementSelectHtml([ + 'name' => Html::namespaceInputName($purchasableType, 'elementIds'), + 'elements' => $elements, + 'elementType' => $purchasableType, + 'sources' => null, + 'criteria' => null, + 'single' => false, + ]) + ); + } + + return Html::hiddenLabel($this->getLabel(), $id) . + Html::tag('div', + $html, + [ + 'class' => ['flex', 'flex-start'], + ] + ); + } + + #[Override] + public function getExclusiveQueryParams(): array + { + return ['id']; + } + + #[Override] + public function modifyQuery(Builder $query): void + { + $ids = $this->getElementIds(); + if ($ids === null) { + return; + } + + $query->whereIn('purchasableId', $ids); + } +} diff --git a/src/CatalogPricing/Contracts/CatalogPricingConditionRuleInterface.php b/src/CatalogPricing/Contracts/CatalogPricingConditionRuleInterface.php new file mode 100644 index 0000000000..44a33bea48 --- /dev/null +++ b/src/CatalogPricing/Contracts/CatalogPricingConditionRuleInterface.php @@ -0,0 +1,16 @@ + 'integer', + 'ids' => 'array', + 'reserved' => 'boolean', + ]; +} diff --git a/src/CatalogPricing/Records/CatalogPricingRule.php b/src/CatalogPricing/Records/CatalogPricingRule.php new file mode 100644 index 0000000000..6b98ea97bd --- /dev/null +++ b/src/CatalogPricing/Records/CatalogPricingRule.php @@ -0,0 +1,50 @@ + 'integer', + 'applyAmount' => 'float', + 'dateFrom' => 'datetime', + 'dateTo' => 'datetime', + 'enabled' => 'boolean', + 'isPromotionalPrice' => 'boolean', + 'customerCondition' => 'array', + 'productCondition' => 'array', + 'variantCondition' => 'array', + 'purchasableCondition' => 'array', + 'metadata' => 'array', + ]; +} diff --git a/src/Console/Commands/ExampleTemplates/ExampleTemplatesCommand.php b/src/Console/Commands/ExampleTemplates/ExampleTemplatesCommand.php new file mode 100644 index 0000000000..60d19eb706 --- /dev/null +++ b/src/Console/Commands/ExampleTemplates/ExampleTemplatesCommand.php @@ -0,0 +1,174 @@ + */ + private array $replacementData = []; + + public function handle(): int + { + $devBuild = (bool)$this->option('dev-build'); + $overwrite = (bool)$this->option('overwrite'); + $folderName = $devBuild ? 'shop' : (string)($this->option('folder-name') ?: ''); + + if ($devBuild) { + $overwrite = true; + } + + $exampleTemplatesSource = FileHelper::normalizePath(Path::vendor('craftcms/commerce/example-templates/src/shop')); + + if ($folderName === '') { + $this->line('A folder will be copied to your templates directory.'); + $folderName = (string)$this->ask('Choose folder name', 'shop'); + } + + if ($folderName === '') { + $this->components->error('No destination folder name provided.'); + + return self::FAILURE; + } + + $this->replacementData = ['[[folderName]]' => $folderName]; + $this->addCssClassesToReplacementData((string)$this->option('base-color')); + $this->addResourceAssetsToReplacementData(); + + $tempDestination = Path::temp('commerce_example_templates_' . Str::random(20)); + + try { + File::copyDirectory($exampleTemplatesSource, $tempDestination); + + $files = collect(File::allFiles($tempDestination)) + ->filter(fn($file) => in_array($file->getExtension(), ['twig', 'html', 'svg', 'css'], true)); + + foreach ($files as $file) { + $contents = str_replace( + array_keys($this->replacementData), + array_values($this->replacementData), + $file->getContents(), + ); + File::put($file->getPathname(), $contents); + } + } catch (Throwable $e) { + $this->components->error('Could not generate templates: ' . $e->getMessage()); + + return self::FAILURE; + } + + if (!is_dir($tempDestination)) { + $this->components->error('Could not generate templates.'); + + return self::FAILURE; + } + + if ($devBuild) { + $destination = FileHelper::normalizePath(Path::vendor('craftcms/commerce/example-templates/dist/' . $folderName)); + } else { + $templatesPath = Path::siteTemplates(); + + if (!$templatesPath) { + $this->components->error('Can not determine the site template path.'); + + return self::FAILURE; + } + + if (!File::isWritable($templatesPath)) { + $this->components->error('Site template path is not writable.'); + + return self::FAILURE; + } + + $destination = rtrim($templatesPath, '/\\') . DIRECTORY_SEPARATOR . $folderName; + } + + $destinationExists = is_dir($destination); + + if ($destinationExists && $overwrite) { + $this->line('Overwriting...'); + File::deleteDirectory($destination); + } elseif ($destinationExists) { + $this->components->error("The \"$folderName\" directory already exists. Pass --overwrite to replace it."); + + return self::FAILURE; + } + + try { + $this->line('Copying...'); + File::copyDirectory($tempDestination, $destination); + } catch (Throwable $e) { + $this->components->error($e->getMessage()); + + return self::FAILURE; + } finally { + File::deleteDirectory($tempDestination); + } + + $this->components->info('Done!'); + + return self::SUCCESS; + } + + /** + * Adds CSS key-value replacements to the array, where the key is our special `[[ ]]` template notation and + * the value is what it'll be replaced with. + */ + private function addCssClassesToReplacementData(string $mainColor): void + { + $dangerColor = $mainColor === 'red' ? 'purple' : 'red'; + + $this->replacementData = [...$this->replacementData, + '[[color]]' => $mainColor, + '[[dangerColor]]' => $dangerColor, + '[[classes.text.color]]' => "text-$mainColor-500", + '[[classes.text.dangerColor]]' => "text-$dangerColor-500", + '[[classes.a]]' => "text-$mainColor-500 hover:text-$mainColor-600", + '[[classes.docs]]' => 'text-gray-400 hover:text-gray-600 hover:underline', + '[[classes.input]]' => 'border border-gray-300 hover:border-gray-500 px-4 py-2 leading-tight rounded', + '[[classes.box.base]]' => "bg-gray-100 border-$mainColor-300 border-b-2 p-6", + '[[classes.box.selection]]' => "border-$mainColor-300 border-b-2 px-6 py-4 rounded-md shadow-md hover:shadow-lg", + '[[classes.box.error]]' => "bg-$dangerColor-100 border-$dangerColor-500 border-b-2 p-6", + '[[classes.btn.base]]' => 'cursor-pointer rounded px-4 py-2 inline-block', + '[[classes.btn.small]]' => 'cursor-pointer rounded px-2 py-1 text-sm inline-block', + '[[classes.btn.mainColor]]' => "bg-$mainColor-500 hover:bg-$mainColor-600 text-white hover:text-white", + '[[classes.btn.grayColor]]' => 'bg-gray-500 hover:bg-gray-600 text-white hover:text-white', + '[[classes.btn.grayLightColor]]' => 'bg-gray-300 hover:bg-gray-400 text-gray-600 hover:text-white', + ]; + } + + /** + * Adds external resource key-value replacements to the array, where the key is our special `[[ ]]` template + * notation and the value is what it'll be replaced with. + */ + private function addResourceAssetsToReplacementData(): void + { + $this->replacementData['[[resourceTags]]'] = ''; + } +} diff --git a/src/Console/Commands/Gateways/GatewaysListCommand.php b/src/Console/Commands/Gateways/GatewaysListCommand.php new file mode 100644 index 0000000000..28e377ed5b --- /dev/null +++ b/src/Console/Commands/Gateways/GatewaysListCommand.php @@ -0,0 +1,42 @@ +getAllGateways() + ->map(fn($gateway) => [ + $gateway->id, + $gateway->name, + $gateway->handle, + $gateway->getIsFrontendEnabled() ? 'Yes' : 'No', + $gateway::class, + $gateway->uid, + ]) + ->all(); + + $this->table(['ID', 'Name', 'Handle', 'Enabled', 'Type', 'UUID'], $rows); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/Gateways/GatewaysWebhookUrlCommand.php b/src/Console/Commands/Gateways/GatewaysWebhookUrlCommand.php new file mode 100644 index 0000000000..8a72201e6c --- /dev/null +++ b/src/Console/Commands/Gateways/GatewaysWebhookUrlCommand.php @@ -0,0 +1,41 @@ +argument('handle'); + $gateway = $gateways->getGatewayByHandle($handle); + + if (!$gateway) { + $this->components->error("A gateway with handle `$handle` does not exist."); + + return self::FAILURE; + } + + $this->line("Webhook URL for the {$gateway->name} gateway:"); + $this->line("{$gateway->getWebhookUrl()}"); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/PricingCatalog/PricingCatalogGenerateCommand.php b/src/Console/Commands/PricingCatalog/PricingCatalogGenerateCommand.php new file mode 100644 index 0000000000..15db292d74 --- /dev/null +++ b/src/Console/Commands/PricingCatalog/PricingCatalogGenerateCommand.php @@ -0,0 +1,35 @@ +line('Generating catalog pricing... '); + + $catalogPricing->generateCatalogPrices(showConsoleOutput: true); + + $this->line('Done!'); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/Resave/ResaveCartsCommand.php b/src/Console/Commands/Resave/ResaveCartsCommand.php new file mode 100644 index 0000000000..5184e034ab --- /dev/null +++ b/src/Console/Commands/Resave/ResaveCartsCommand.php @@ -0,0 +1,39 @@ +validateResaveOptions()) { + return self::FAILURE; + } + + if (!empty($this->resolvedWithFields) && !$this->hasTheFields(Fields::getLayoutByType(Order::class))) { + $this->components->warn('The order field layout does not satisfy `--with-fields`.'); + + return self::FAILURE; + } + + return $this->resaveElements(Order::class, [ + 'isCompleted' => false, + ]); + } +} diff --git a/src/Console/Commands/Resave/ResaveOrdersCommand.php b/src/Console/Commands/Resave/ResaveOrdersCommand.php new file mode 100644 index 0000000000..7a02f5a770 --- /dev/null +++ b/src/Console/Commands/Resave/ResaveOrdersCommand.php @@ -0,0 +1,39 @@ +validateResaveOptions()) { + return self::FAILURE; + } + + if (!empty($this->resolvedWithFields) && !$this->hasTheFields(Fields::getLayoutByType(Order::class))) { + $this->components->warn('The order field layout does not satisfy `--with-fields`.'); + + return self::FAILURE; + } + + return $this->resaveElements(Order::class, [ + 'isCompleted' => true, + ]); + } +} diff --git a/src/Console/Commands/Resave/ResaveProductsCommand.php b/src/Console/Commands/Resave/ResaveProductsCommand.php new file mode 100644 index 0000000000..f7ba0c98b4 --- /dev/null +++ b/src/Console/Commands/Resave/ResaveProductsCommand.php @@ -0,0 +1,65 @@ +validateResaveOptions()) { + return self::FAILURE; + } + + $criteria = []; + + if ($this->option('type')) { + $criteria['type'] = str($this->option('type')) + ->explode(',') + ->all(); + } + + $withFields = $this->resolvedWithFields; + + if (!empty($withFields)) { + $handles = collect($productTypes->getAllProductTypes()) + ->filter(fn(ProductType $productType) => $this->hasTheFields($productType->getProductFieldLayout())) + ->map(fn(ProductType $productType) => $productType->handle) + ->all(); + + if (isset($criteria['type'])) { + $criteria['type'] = array_intersect($criteria['type'], $handles); + } else { + $criteria['type'] = $handles; + } + + if (empty($criteria['type'])) { + $this->components->warn('No product types satisfy `--with-fields`.'); + + return self::FAILURE; + } + } + + return $this->resaveElements(Product::class, $criteria); + } +} diff --git a/src/Console/Commands/Resave/ResaveVariantsCommand.php b/src/Console/Commands/Resave/ResaveVariantsCommand.php new file mode 100644 index 0000000000..c77935cb87 --- /dev/null +++ b/src/Console/Commands/Resave/ResaveVariantsCommand.php @@ -0,0 +1,77 @@ +validateResaveOptions()) { + return self::FAILURE; + } + + $criteria = []; + + if ($this->option('type')) { + $criteria['type'] = str($this->option('type')) + ->explode(',') + ->all(); + } + + $withFields = $this->resolvedWithFields; + + if (!empty($withFields)) { + $handles = collect($productTypes->getAllProductTypes()) + ->filter(fn(ProductType $productType) => $this->hasTheFields($productType->getVariantFieldLayout())) + ->map(fn(ProductType $productType) => $productType->handle) + ->all(); + + if (isset($criteria['type'])) { + $criteria['type'] = array_intersect($criteria['type'], $handles); + } else { + $criteria['type'] = $handles; + } + + if (empty($criteria['type'])) { + $this->components->warn('No variant types satisfy `--with-fields`.'); + + return self::FAILURE; + } + } + + // Convert type handles to type IDs for the variant query + if (!empty($criteria['type'])) { + $criteria['typeId'] = DB::table(Table::PRODUCTTYPES) + ->whereParam('handle', $criteria['type']) + ->pluck('id') + ->all(); + + unset($criteria['type']); + } + + return $this->resaveElements(Variant::class, $criteria); + } +} diff --git a/src/Console/Commands/ResetData/ResetDataCommand.php b/src/Console/Commands/ResetData/ResetDataCommand.php new file mode 100644 index 0000000000..3bad263bf1 --- /dev/null +++ b/src/Console/Commands/ResetData/ResetDataCommand.php @@ -0,0 +1,75 @@ +confirmToProceed( + 'Resetting Commerce data will permanently delete all orders, subscriptions, and payment sources, and reset discount usages.', + fn() => true, + ); + + if (!$confirmed) { + return self::SUCCESS; + } + + try { + DB::transaction(function() { + $this->components->task('Deleting orders', function() { + $ids = DB::table(Table::ORDERS)->pluck('id')->all(); + DB::table(CraftTable::ELEMENTS)->whereIn('id', $ids)->delete(); + }); + + $this->components->task('Deleting subscriptions', function() { + $ids = DB::table(Table::SUBSCRIPTIONS)->pluck('id')->all(); + DB::table(CraftTable::ELEMENTS)->whereIn('id', $ids)->delete(); + // These should really be deleted with a cascade + DB::table(Table::SUBSCRIPTIONS)->delete(); + }); + + $this->components->task('Deleting payment sources', function() { + DB::table(Table::PAYMENTSOURCES)->delete(); + }); + + $this->components->task('Resetting discount usage data', function() { + DB::table(Table::CUSTOMER_DISCOUNTUSES)->delete(); + DB::table(Table::EMAIL_DISCOUNTUSES)->delete(); + DB::table(Table::DISCOUNTS)->update(['totalDiscountUses' => 0]); + }); + }); + } catch (Throwable $e) { + $this->components->error($e->getMessage()); + + return self::FAILURE; + } + + $this->components->info('Finished resetting Commerce data.'); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/TransferCustomerData/TransferCustomerDataCommand.php b/src/Console/Commands/TransferCustomerData/TransferCustomerDataCommand.php new file mode 100644 index 0000000000..177ffc6594 --- /dev/null +++ b/src/Console/Commands/TransferCustomerData/TransferCustomerDataCommand.php @@ -0,0 +1,82 @@ +line('This command will transfer all commerce data from one user to another.'); + + $fromUserIdentifier = $this->option('from-user') ?: $this->ask('Move Commerce data from user (email or username)'); + $toUserIdentifier = $this->option('to-user') ?: $this->ask('To user (email or username)'); + + if (!$fromUserIdentifier || !$toUserIdentifier) { + $this->components->error('You must specify both a "to" and "from" user.'); + + return self::FAILURE; + } + + $fromUser = Users::getUserByUsernameOrEmail($fromUserIdentifier); + $toUser = Users::getUserByUsernameOrEmail($toUserIdentifier); + + if ($fromUser === null) { + $this->components->error("No user found with a username or email of `$fromUserIdentifier`."); + + return self::FAILURE; + } + + if ($toUser === null) { + $this->components->error("No user found with a username or email of `$toUserIdentifier`."); + + return self::FAILURE; + } + + if ($fromUser->id === $toUser->id) { + $this->components->error('The transfer must happen between different users.'); + + return self::FAILURE; + } + + if (!$this->confirm("Are you sure you want to move all Commerce data from user: $fromUserIdentifier to user: $toUserIdentifier?")) { + $this->components->warn('No data will be moved.'); + + return self::SUCCESS; + } + + try { + $customers->transferCustomerData($fromUser, $toUser); + } catch (Throwable $e) { + $this->components->error('Failed: ' . $e->getMessage()); + + return self::FAILURE; + } + + $this->components->info('Done!'); + + return self::SUCCESS; + } +} diff --git a/src/Customer/Conditions/CatalogPricingRuleCustomerCondition.php b/src/Customer/Conditions/CatalogPricingRuleCustomerCondition.php new file mode 100644 index 0000000000..b22faa4083 --- /dev/null +++ b/src/Customer/Conditions/CatalogPricingRuleCustomerCondition.php @@ -0,0 +1,32 @@ + !in_array(is_string($type) ? $type : $type['class'], [ + // Remove rules that don't make sense in this context + LastLoginDateConditionRule::class, + SiteConditionRule::class, + ], true) + )), + // Add additional rules + [ + CatalogPricingRuleCustomerConditionRule::class, + ] + ); + } +} diff --git a/src/Customer/Conditions/CatalogPricingRuleCustomerConditionRule.php b/src/Customer/Conditions/CatalogPricingRuleCustomerConditionRule.php new file mode 100644 index 0000000000..c657ac0b18 --- /dev/null +++ b/src/Customer/Conditions/CatalogPricingRuleCustomerConditionRule.php @@ -0,0 +1,52 @@ +id($this->getElementIds()); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var User $element */ + return $this->matchValue($element->getId()); + } + + #[Override] + protected function allowMultiple(): bool + { + return true; + } +} diff --git a/src/Customer/Conditions/DiscountCustomerCondition.php b/src/Customer/Conditions/DiscountCustomerCondition.php new file mode 100644 index 0000000000..6b93183afa --- /dev/null +++ b/src/Customer/Conditions/DiscountCustomerCondition.php @@ -0,0 +1,25 @@ + t('is in all of'), + default => parent::operatorLabel($operator), + }; + } + + #[Override] + public function modifyQuery(ElementQueryInterface $query): void + { + throw new RuntimeException('Discount user group rule does not support element queries.'); + } + + #[Override] + public function getExclusiveQueryParams(): array + { + return []; + } + + #[Override] + protected function matchValue(array|string|null $value): bool + { + if (!$this->getValues()) { + return true; + } + + if ($value === '' || $value === null) { + $value = []; + } else { + $value = (array)$value; + } + + return match ($this->operator) { + self::OPERATOR_IN => !empty(array_intersect($value, $this->getValues())), + self::OPERATOR_NOT_IN => empty(array_intersect($value, $this->getValues())), + self::OPERATOR_IN_ALL => empty(array_diff($this->getValues(), $value)), + default => throw new RuntimeException("Invalid operator: $this->operator"), + }; + } +} diff --git a/src/Customer/Conditions/HasOrdersConditionRule.php b/src/Customer/Conditions/HasOrdersConditionRule.php new file mode 100644 index 0000000000..15a3c216f5 --- /dev/null +++ b/src/Customer/Conditions/HasOrdersConditionRule.php @@ -0,0 +1,136 @@ + */ + private static array $_orderConditionResults = []; + + #[Override] + public function getConfig(): array + { + return array_merge(parent::getConfig(), [ + 'orderCondition' => $this->getOrderCondition()->getConfig(), + ]); + } + + #[Override] + public function getRules(): array + { + return array_merge(parent::getRules(), [ + 'orderCondition' => ['nullable'], + ]); + } + + public function getLabel(): string + { + return t('Has Orders', category: 'commerce'); + } + + public function getExclusiveQueryParams(): array + { + return ['hasOrders']; + } + + public function modifyQuery(ElementQueryInterface $query): void + { + throw new RuntimeException('Has orders condition rule does not support queries'); + } + + #[Override] + public function getHtml(): string + { + $html = Html::tag('label', t('Total Orders', category: 'commerce'), [ + 'style' => [ + 'padding-top' => '0.25rem', + 'padding-bottom' => '0.5rem', + 'font-weight' => 'bold', + 'color' => '#596673', + 'display' => 'block', + ], + ]); + $html .= parent::getHtml(); + $html .= Html::tag('div', t('Match Orders', category: 'commerce'), [ + 'style' => [ + 'margin-top' => '1rem', + 'font-weight' => 'bold', + 'color' => '#596673', + ], + ]); + $html .= Html::tag('div', $this->getOrderCondition()->getBuilderHtml(), ['style' => ['margin-top' => '0.5rem']]); + + return $html; + } + + public function matchElement(ElementInterface $element): bool + { + $orderQuery = Order::find()->customerId($element->id); + $this->getOrderCondition()->modifyQuery($orderQuery); + $key = md5(implode('||', [ + $element->id, + Json::encode($this->getConfig()), + ])); + + if (!isset(self::$_orderConditionResults[$key])) { + self::$_orderConditionResults[$key] = $this->matchValue($orderQuery->count()); + } + + return self::$_orderConditionResults[$key]; + } + + public function getOrderCondition(): OrderCondition + { + if ($this->_orderCondition === null) { + /** @var OrderCondition $orderCondition */ + $orderCondition = Conditions::createCondition(['class' => OrderCondition::class]); + $this->_orderCondition = $orderCondition; + + // Set default rules + /** @var CompletedConditionRule $completedConditionRule */ + $completedConditionRule = Conditions::createConditionRule([ + 'class' => CompletedConditionRule::class, + ]); + $completedConditionRule->value = true; + + $this->_orderCondition->addConditionRule($completedConditionRule); + } elseif (is_array($this->_orderCondition)) { + /** @var OrderCondition $orderCondition */ + $orderCondition = Conditions::createCondition($this->_orderCondition); + $this->_orderCondition = $orderCondition; + } + + $this->_orderCondition->id = 'hasOrdersOrderCondition'; + $this->_orderCondition->mainTag = 'div'; + $this->_orderCondition->name = 'orderCondition'; + // Exclude unwanted condition rules + $this->_orderCondition->queryParams = ['customerId']; + + return $this->_orderCondition; + } + + public function setOrderCondition(OrderCondition|array|null $condition): void + { + $this->_orderCondition = $condition; + } +} diff --git a/src/Customer/Conditions/ShippingMethodCustomerCondition.php b/src/Customer/Conditions/ShippingMethodCustomerCondition.php new file mode 100644 index 0000000000..c2f626e144 --- /dev/null +++ b/src/Customer/Conditions/ShippingMethodCustomerCondition.php @@ -0,0 +1,11 @@ +can('accessCp') && $currentUser->can('commerce-editOrders'); + + // If the current user is a store admin, and they are editing an order + if ($isStoreAdministrator) { + if ($this->value && $element->getIsCredentialed()) { + return true; + } + + if (!$this->value && !$element->getIsCredentialed()) { + return true; + } + + return false; + } + + if (!$this->value && !$currentUser) { + return true; + } + + if ($this->value && $currentUser && $currentUser->id === $element->id) { + return true; + } + + return false; + } +} diff --git a/src/Customer/Customers.php b/src/Customer/Customers.php new file mode 100644 index 0000000000..1c7844a439 --- /dev/null +++ b/src/Customer/Customers.php @@ -0,0 +1,496 @@ +ensureCustomer($user); + $customerRecord->primaryShippingAddressId = $addressId; + /** @phpstan-ignore-next-line method.notFound (setPrimaryShippingAddressId() is added to User via a Macroable macro registered in Plugin::registerCustomerMacros(), not visible to static analysis) */ + $user->setPrimaryShippingAddressId($addressId); + return $customerRecord->save(); + } + + public function savePrimaryBillingAddressId(User $user, ?int $addressId): bool + { + $customerRecord = $this->ensureCustomer($user); + $customerRecord->primaryBillingAddressId = $addressId; + /** @phpstan-ignore-next-line method.notFound (setPrimaryBillingAddressId() is added to User via a Macroable macro registered in Plugin::registerCustomerMacros(), not visible to static analysis) */ + $user->setPrimaryBillingAddressId($addressId); + return $customerRecord->save(); + } + + public function savePrimaryPaymentSourceId(User $user, ?int $paymentSourceId): bool + { + $customerRecord = $this->ensureCustomer($user); + + $originalPaymentSourceId = $customerRecord->primaryPaymentSourceId; + + // Only save customer record if the source is not already primary + if ($customerRecord->primaryPaymentSourceId == $paymentSourceId) { + return true; + } + + $customerRecord->primaryPaymentSourceId = $paymentSourceId; + + if (!$customerRecord->save()) { + return false; + } + + /** @phpstan-ignore-next-line method.notFound (setPrimaryPaymentSourceId() is added to User via a Macroable macro registered in Plugin::registerCustomerMacros(), not visible to static analysis) */ + $user->setPrimaryPaymentSourceId($paymentSourceId); + + if ($originalPaymentSourceId != $paymentSourceId) { + $event = new UpdatePrimaryPaymentSourceEvent( + customer: $user, + previousPrimaryPaymentSourceId: $originalPaymentSourceId, + newPrimaryPaymentSourceId: $paymentSourceId, + ); + + // TODO: migrate event firing to Laravel once event system is bridged + $legacyService = Plugin::getInstance()->getCustomers(); + if ($legacyService->hasEventHandlers(self::EVENT_UPDATE_PRIMARY_PAYMENT_SOURCE)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $legacyService->trigger(self::EVENT_UPDATE_PRIMARY_PAYMENT_SOURCE, $event); + } + } + + return true; + } + + /** + * Handle user login. + */ + public function loginHandler(): void + { + $impersonating = app(Impersonation::class)->isImpersonating(); + // Don't allow transition of current cart to a user that is being impersonated. + if ($impersonating) { + app(Carts::class)->forgetCart(); + } + + app(Carts::class)->restorePreviousCartForCurrentUser(); + } + + /** + * Persists any primary billing/shipping address or payment source that was set on the user + * this request, and keeps orders' `email` column in sync with the user's email address. + * + * Replaces `craft\commerce\behaviors\CustomerBehavior::afterSaveUserHandler()`. + */ + public function afterSaveUserHandler(ElementSaved $event): void + { + /** @var User $user */ + $user = $event->element; + + /** @phpstan-ignore-next-line method.notFound (getPrimaryBillingAddressId() is added to User via a Macroable macro registered in Plugin::registerCustomerMacros(), not visible to static analysis) */ + if ($user->getPrimaryBillingAddressId()) { + /** @phpstan-ignore-next-line method.notFound (getPrimaryBillingAddressId() is added to User via a Macroable macro registered in Plugin::registerCustomerMacros(), not visible to static analysis) */ + $this->savePrimaryBillingAddressId($user, $user->getPrimaryBillingAddressId()); + } + + /** @phpstan-ignore-next-line method.notFound (getPrimaryShippingAddressId() is added to User via a Macroable macro registered in Plugin::registerCustomerMacros(), not visible to static analysis) */ + if ($user->getPrimaryShippingAddressId()) { + /** @phpstan-ignore-next-line method.notFound (getPrimaryShippingAddressId() is added to User via a Macroable macro registered in Plugin::registerCustomerMacros(), not visible to static analysis) */ + $this->savePrimaryShippingAddressId($user, $user->getPrimaryShippingAddressId()); + } + + /** @phpstan-ignore-next-line method.notFound (getPrimaryPaymentSourceId() is added to User via a Macroable macro registered in Plugin::registerCustomerMacros(), not visible to static analysis) */ + if ($user->getPrimaryPaymentSourceId()) { + /** @phpstan-ignore-next-line method.notFound (getPrimaryPaymentSourceId() is added to User via a Macroable macro registered in Plugin::registerCustomerMacros(), not visible to static analysis) */ + $this->savePrimaryPaymentSourceId($user, $user->getPrimaryPaymentSourceId()); + } + + if ($user->email && $user->id) { + DB::table(Table::ORDERS) + ->where('customerId', $user->id) + ->update(['email' => $user->email]); + } + } + + /** + * Syncs an address's primary billing/shipping flags onto its owning user's customer record, + * if the flags were set on the address this request. + * + * Replaces `craft\commerce\behaviors\CustomerAddressBehavior::afterPropagate()`. + */ + public function afterSaveAddressHandler(ElementSaved $event): void + { + /** @var Address $address */ + $address = $event->element; + + if ($address->getIsDraft()) { + return; + } + + $owner = $address->getPrimaryOwner(); + + if (!$owner instanceof User) { + return; + } + + if ($address->getIsDerivative()) { + return; + } + + $customer = $this->ensureCustomer($owner); + + /** @phpstan-ignore-next-line method.notFound (hasIsPrimaryBillingBeenSet()/getIsPrimaryBilling() are added to Address via Macroable macros registered in Plugin::registerCustomerAddressMacros(), not visible to static analysis) */ + if ($address->hasIsPrimaryBillingBeenSet() && ($address->getIsPrimaryBilling() || $customer->primaryBillingAddressId === $address->id)) { + /** @phpstan-ignore-next-line method.notFound (getIsPrimaryBilling() is added to Address via a Macroable macro registered in Plugin::registerCustomerAddressMacros(), not visible to static analysis) */ + $this->savePrimaryBillingAddressId($owner, $address->getIsPrimaryBilling() ? $address->id : null); + } + + /** @phpstan-ignore-next-line method.notFound (hasIsPrimaryShippingBeenSet()/getIsPrimaryShipping() are added to Address via Macroable macros registered in Plugin::registerCustomerAddressMacros(), not visible to static analysis) */ + if ($address->hasIsPrimaryShippingBeenSet() && ($address->getIsPrimaryShipping() || $customer->primaryShippingAddressId === $address->id)) { + /** @phpstan-ignore-next-line method.notFound (getIsPrimaryShipping() is added to Address via a Macroable macro registered in Plugin::registerCustomerAddressMacros(), not visible to static analysis) */ + $this->savePrimaryShippingAddressId($owner, $address->getIsPrimaryShipping() ? $address->id : null); + } + } + + /** + * Sets the last used addresses on the customer on order completion. + * + * Consolidates any other orders using the same email address. + * + * Duplicates the address records used for the order so they are independent to the + * customers address book. + */ + public function orderCompleteHandler(Order $order): void + { + // Create a user account if requested + if ($order->registerUserOnOrderComplete) { + $this->activateUserFromOrder($order); + } + + // Did they want to save addresses to the customers address book? + if ($order->saveBillingAddressOnOrderComplete || $order->saveShippingAddressOnOrderComplete) { + $this->saveAddressesFromOrder($order); + } + + // clear the primary address flags if they were set as it only applies to the cart + if ($order->makePrimaryBillingAddress || $order->makePrimaryShippingAddress) { + OrderRecord::query()->where('id', $order->id)->update([ + 'makePrimaryBillingAddress' => false, + 'makePrimaryShippingAddress' => false, + ]); + } + } + + /** + * @param Order[] $orders + * @return Order[] + */ + public function eagerLoadCustomerForOrders(array $orders): array + { + $customerIds = collect($orders)->pluck('customerId')->filter()->all(); + /** @var User[] $users */ + $users = User::find()->id($customerIds)->limit(null)->indexBy('id')->all(); + + foreach ($orders as $key => $order) { + $customerId = $order->getCustomerId(); + if (isset($users[$customerId])) { + $order->setCustomer($users[$customerId]); + $orders[$key] = $order; + } + } + + return $orders; + } + + /** + * Returns a customer record by a user element, creating one if none already exists. + */ + public function ensureCustomer(User $user): CustomerRecord + { + $customerRecord = CustomerRecord::where('customerId', $user->id)->first(); + if (!$customerRecord) { + $customerRecord = new CustomerRecord(); + $customerRecord->customerId = $user->id; + $customerRecord->save(); + } + + return $customerRecord; + } + + /** + * @return bool Whether the data moved successfully + * @throws ElementNotFoundException + */ + public function transferCustomerData(User $fromCustomer, User $toCustomer): bool + { + $fromId = $fromCustomer->id; + $toId = $toCustomer->id; + + /** @var User|null $fromUser */ + $fromUser = User::find()->id($fromId)->one(); + /** @var User|null $toUser */ + $toUser = User::find()->id($toId)->one(); + + if ($fromUser === null) { + throw new ElementNotFoundException('User ID:', $fromId); + } + + if ($toUser === null) { + throw new ElementNotFoundException('User ID:', $toId); + } + + $userRefs = [ + Table::ORDERHISTORIES => 'userId', + Table::SUBSCRIPTIONS => 'userId', + Table::TRANSACTIONS => 'userId', + Table::ORDERS => 'customerId', + Table::PAYMENTSOURCES => 'customerId', + ]; + + foreach ($userRefs as $table => $column) { + DB::table($table)->where($column, $fromId)->update([$column => $toId]); + } + + $previousUses = DB::table(Table::CUSTOMER_DISCOUNTUSES)->where('customerId', $fromId)->pluck('uses', 'discountId'); + $toUses = DB::table(Table::CUSTOMER_DISCOUNTUSES)->where('customerId', $toId)->pluck('uses', 'discountId'); + + foreach ($previousUses as $discountId => $uses) { + if ($toUses->has($discountId)) { + DB::table(Table::CUSTOMER_DISCOUNTUSES) + ->where('customerId', $toId) + ->where('discountId', $discountId) + ->increment('uses', $uses); + } else { + $now = now()->toDateTimeString(); + DB::table(Table::CUSTOMER_DISCOUNTUSES)->insert([ + 'uses' => $uses, + 'customerId' => $toId, + 'discountId' => $discountId, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + } + + // Remove uses from fromCustomer + DB::table(Table::CUSTOMER_DISCOUNTUSES) + ->where('customerId', $fromId) + ->where('discountId', $discountId) + ->update(['uses' => 0]); + } + + $fromEmail = $fromUser->email; + $toEmail = $toUser->email; + + DB::table(Table::ORDERS)->where('email', $fromEmail)->update(['email' => $toEmail]); + + return true; + } + + /** + * @throws InvalidElementException + * @throws UnsupportedSiteException + */ + private function saveAddressesFromOrder(Order $order): void + { + // Only for completed orders + if ($order->isCompleted === false) { + return; + } + + // Check for a credentialed user + if ($order->getCustomer() === null || !$order->getCustomer()->getIsCredentialed()) { + return; + } + + $saveBillingAddress = $order->saveBillingAddressOnOrderComplete && $order->sourceBillingAddressId === null && $order->billingAddressId; + $saveShippingAddress = $order->saveShippingAddressOnOrderComplete && $order->sourceShippingAddressId === null && $order->shippingAddressId; + $newSourceBillingAddressId = null; + $newSourceShippingAddressId = null; + + if ($saveBillingAddress && $saveShippingAddress && $order->hasMatchingAddresses()) { + // Only save one address if they are matching + $newAddress = Elements::duplicateElement( + $order->getBillingAddress(), + [ + 'primaryOwner' => $order->getCustomer(), + 'owner' => $order->getCustomer(), + ] + ); + $newSourceBillingAddressId = $newAddress->id; + $newSourceShippingAddressId = $newAddress->id; + } else { + if ($saveBillingAddress) { + $newBillingAddress = Elements::duplicateElement($order->getBillingAddress(), + [ + 'primaryOwner' => $order->getCustomer(), + 'owner' => $order->getCustomer(), + ] + ); + $newSourceBillingAddressId = $newBillingAddress->id; + } + + if ($saveShippingAddress) { + $newShippingAddress = Elements::duplicateElement( + $order->getShippingAddress(), + [ + 'primaryOwner' => $order->getCustomer(), + 'owner' => $order->getCustomer(), + ] + ); + $newSourceShippingAddressId = $newShippingAddress->id; + } + } + + if ($newSourceBillingAddressId) { + $order->sourceBillingAddressId = $newSourceBillingAddressId; + } + + if ($newSourceShippingAddressId) { + $order->sourceShippingAddressId = $newSourceShippingAddressId; + } + + // Since we saved the primary addresses, we can now set the primary if they chose that also + if ($order->makePrimaryShippingAddress && $order->sourceShippingAddressId) { + $this->savePrimaryShippingAddressId($order->getCustomer(), $order->sourceShippingAddressId); + } + + if ($order->makePrimaryBillingAddress && $order->sourceBillingAddressId) { + $this->savePrimaryBillingAddressId($order->getCustomer(), $order->sourceBillingAddressId); + } + + // Manually update the order DB record to avoid looped element saves + if ($newSourceBillingAddressId || $newSourceShippingAddressId) { + OrderRecord::query()->where('id', $order->id)->update([ + 'sourceBillingAddressId' => $order->sourceBillingAddressId, + 'sourceShippingAddressId' => $order->sourceShippingAddressId, + ]); + } + } + + /** + * Makes sure the user has an email address and sets them to pending and sends the activation email. + */ + private function activateUserFromOrder(Order $order): void + { + $user = $order->getCustomer(); + if (!$user || $user->active || $user->locked || $user->suspended) { + return; + } + + $billingAddress = $order->getBillingAddress(); + $shippingAddress = $order->getShippingAddress(); + + if (!$user->fullName) { + /** @phpstan-ignore-next-line nullsafe.neverNull (getBillingAddress()/getShippingAddress() genuinely return ?AddressElement) */ + $user->fullName = $billingAddress?->fullName ?? $shippingAddress?->fullName ?? ''; + } + + $user->username = $order->getEmail(); + $user->pending = true; + $user->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); + + $user->affiliatedSiteId = $order->orderSiteId; + + if (Elements::saveElement($user)) { + Users::assignUserToDefaultGroup($user); + + $setActivationEmailSiteId = function(MailEvent $event) use ($user, &$setActivationEmailSiteId) { + Event::off(Mailer::class, Mailer::EVENT_BEFORE_PREP, $setActivationEmailSiteId); + + if (!$event->message instanceof Message) { + return; + } + + if ($event->message->key !== 'account_activation') { + return; + } + + if ($event->message->siteId === null && $user->affiliatedSiteId) { + $event->message->siteId = $user->affiliatedSiteId; + } + }; + Event::on(Mailer::class, Mailer::EVENT_BEFORE_PREP, $setActivationEmailSiteId); + + $emailSent = Users::sendActivationEmail($user); + + if (!$emailSent) { + Log::warning('"registerUserOnOrderComplete" used to create the user, but couldn\'t send an activation email. Check your email settings.'); + } + + if ($billingAddress || $shippingAddress) { + $newAttributes = [ + 'owner' => $user, + 'primaryOwner' => $user, + ]; + + // If there is only one address make sure we don't add duplicates to the user + if ($order->hasMatchingAddresses()) { + $newAttributes['title'] = t('Address', category: 'app'); + $shippingAddress = null; + } + + // Copy addresses to user + if ($billingAddress) { + $newBillingAddress = Elements::duplicateElement($billingAddress, $newAttributes); + + /** + * Because we are cloning from an order address the `CustomerAddressBehavior` hasn't been instantiated + * therefore we are unable to simply set the `isPrimaryBilling` property when specifying the new attributes during duplication. + */ + if (!$newBillingAddress->hasErrors()) { + $this->savePrimaryBillingAddressId($user, $newBillingAddress->id); + + if ($order->hasMatchingAddresses()) { + $this->savePrimaryShippingAddressId($user, $newBillingAddress->id); + } + } + } + + if ($shippingAddress) { + $newShippingAddress = Elements::duplicateElement($shippingAddress, $newAttributes); + + /** + * Because we are cloning from an order address the `CustomerAddressBehavior` hasn't been instantiated + * therefore we are unable to simply set the `isPrimaryShipping` property when specifying the new attributes during duplication. + */ + if (!$newShippingAddress->hasErrors()) { + $this->savePrimaryShippingAddressId($user, $newShippingAddress->id); + } + } + } + } else { + $errors = $user->getErrors(); + Log::warning('Could not create user on order completion.', ['errors' => $errors]); + } + } +} diff --git a/src/Customer/FieldLayoutElements/UserAddressSettings.php b/src/Customer/FieldLayoutElements/UserAddressSettings.php new file mode 100644 index 0000000000..ac25309bd6 --- /dev/null +++ b/src/Customer/FieldLayoutElements/UserAddressSettings.php @@ -0,0 +1,74 @@ +getPrimaryOwner(); + + if (!$owner instanceof User) { + return null; + } + + return + FormFields::lightswitchFieldHtml([ + 'fieldLabel' => t('Use as the primary billing address', category: 'commerce'), + 'name' => 'isPrimaryBilling', + /** @phpstan-ignore-next-line method.notFound (getIsPrimaryBilling() is a macro registered in Plugin::registerCustomerAddressMacros(), not visible to static analysis) */ + 'on' => $element->getIsPrimaryBilling(), + ]) . + FormFields::lightswitchFieldHtml([ + 'fieldLabel' => t('Use as the primary shipping address', category: 'commerce'), + 'name' => 'isPrimaryShipping', + /** @phpstan-ignore-next-line method.notFound (getIsPrimaryShipping() is a macro registered in Plugin::registerCustomerAddressMacros(), not visible to static analysis) */ + 'on' => $element->getIsPrimaryShipping(), + ]); + } +} diff --git a/src/Customer/Records/Customer.php b/src/Customer/Records/Customer.php new file mode 100644 index 0000000000..66aa9444ed --- /dev/null +++ b/src/Customer/Records/Customer.php @@ -0,0 +1,28 @@ + 'integer', + 'primaryBillingAddressId' => 'integer', + 'primaryShippingAddressId' => 'integer', + 'primaryPaymentSourceId' => 'integer', + ]; +} diff --git a/src/Dashboard/Widgets/AverageOrderTotal.php b/src/Dashboard/Widgets/AverageOrderTotal.php new file mode 100644 index 0000000000..8b21e3919a --- /dev/null +++ b/src/Dashboard/Widgets/AverageOrderTotal.php @@ -0,0 +1,98 @@ + $config */ + public function __construct(array $config = []) + { + parent::__construct($config); + + if (!$this->storeId) { + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $this->storeId = Cp::requestedSite()->getStore()->id; + } + + $this->stat = new AverageOrderTotalStat( + $this->dateRange, + DateTimeHelper::toDateTime($this->startDate, true), + DateTimeHelper::toDateTime($this->endDate, true), + $this->storeId, + ); + + if (!empty($this->orderStatuses)) { + $this->stat->setOrderStatuses($this->orderStatuses); + } + } + + #[\Override] + public static function isSelectable(): bool + { + return currentUser()?->can('commerce-manageOrders') ?? false; + } + + #[\Override] + public static function displayName(): string + { + return t('Average Order Total', category: 'commerce'); + } + + #[\Override] + public static function icon(): ?string + { + return \Craft::getAlias('@craft/commerce/icon-mask.svg'); + } + + #[\Override] + public static function maxColspan(): ?int + { + return 1; + } + + #[\Override] + public function getTitle(): ?string + { + return ''; + } + + #[\Override] + public function getBodyHtml(): ?string + { + $number = $this->stat->get(); + $timeFrame = $this->stat->getDateRangeWording(); + + \Craft::$app->getView()->registerAssetBundle(StatWidgetsAsset::class); + + return template('commerce/_components/widgets/orders/average/body', compact('number', 'timeFrame'), TemplateMode::Cp); + } + + #[\Override] + public function settingsForm(FormContext $context = new FormContext()): ?Form + { + \Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); + + return Form::make($this->statSettingsFields()); + } +} diff --git a/src/Dashboard/Widgets/Concerns/StatWidgetTrait.php b/src/Dashboard/Widgets/Concerns/StatWidgetTrait.php new file mode 100644 index 0000000000..cff5727fea --- /dev/null +++ b/src/Dashboard/Widgets/Concerns/StatWidgetTrait.php @@ -0,0 +1,95 @@ + + */ + public function getOrderStatusOptions(): array + { + $orderStatuses = []; + + foreach (app(OrderStatuses::class)->getAllOrderStatuses($this->storeId) as $orderStatus) { + $orderStatuses[] = [ + 'label' => $orderStatus->name, + 'value' => $orderStatus->uid, + ]; + } + + return $orderStatuses; + } + + /** + * @return array + */ + public function getStoreOptions(): array + { + return app(Stores::class)->getAllStores()->map(fn($store) => [ + 'label' => $store->getName(), + 'value' => $store->id, + ])->all(); + } + + /** + * Preset date-range options. `DATE_RANGE_CUSTOM` is intentionally not offered here -- the + * legacy custom-range JS date picker isn't part of the new Form control set, so only the + * fixed presets are configurable going forward. + * + * @return array + */ + public function getDateRangeOptions(): array + { + return [ + ['label' => t('Today', category: 'commerce'), 'value' => StatInterface::DATE_RANGE_TODAY], + ['label' => t('This week', category: 'commerce'), 'value' => StatInterface::DATE_RANGE_THISWEEK], + ['label' => t('This month', category: 'commerce'), 'value' => StatInterface::DATE_RANGE_THISMONTH], + ['label' => t('This year', category: 'commerce'), 'value' => StatInterface::DATE_RANGE_THISYEAR], + ['label' => t('Past {num} days', ['num' => 7], category: 'commerce'), 'value' => StatInterface::DATE_RANGE_PAST7DAYS], + ['label' => t('Past {num} days', ['num' => 30], category: 'commerce'), 'value' => StatInterface::DATE_RANGE_PAST30DAYS], + ['label' => t('Past {num} days', ['num' => 90], category: 'commerce'), 'value' => StatInterface::DATE_RANGE_PAST90DAYS], + ['label' => t('Past year', category: 'commerce'), 'value' => StatInterface::DATE_RANGE_PASTYEAR], + ['label' => t('All', category: 'commerce'), 'value' => StatInterface::DATE_RANGE_ALL], + ]; + } + + /** + * The store/date-range/order-statuses fields shared by every stat-backed widget's settings form. + * + * @return Field[] + */ + protected function statSettingsFields(): array + { + return [ + Field::make(t('Store', category: 'commerce')) + ->control(Choice::make('storeId')->value($this->storeId)->options($this->getStoreOptions())), + Field::make(t('Date Range', category: 'app')) + ->control(Choice::make('dateRange')->value($this->dateRange)->options($this->getDateRangeOptions())), + Field::make(t('Order Statuses', category: 'commerce')) + ->instructions(t('Only orders with the following order statuses will be included. Leave blank to include all statuses.', category: 'commerce')) + ->control(Choice::make('orderStatuses')->multiple()->value($this->orderStatuses)->options($this->getOrderStatusOptions())), + ]; + } +} diff --git a/src/Dashboard/Widgets/NewCustomers.php b/src/Dashboard/Widgets/NewCustomers.php new file mode 100644 index 0000000000..c42955c956 --- /dev/null +++ b/src/Dashboard/Widgets/NewCustomers.php @@ -0,0 +1,98 @@ + $config */ + public function __construct(array $config = []) + { + parent::__construct($config); + + if (!$this->storeId) { + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $this->storeId = Cp::requestedSite()->getStore()->id; + } + + $this->stat = new NewCustomersStat( + $this->dateRange, + DateTimeHelper::toDateTime($this->startDate, true), + DateTimeHelper::toDateTime($this->endDate, true), + $this->storeId, + ); + + if (!empty($this->orderStatuses)) { + $this->stat->setOrderStatuses($this->orderStatuses); + } + } + + #[\Override] + public static function isSelectable(): bool + { + return currentUser()?->can('commerce-manageCustomers') ?? false; + } + + #[\Override] + public static function displayName(): string + { + return t('New Customers', category: 'commerce'); + } + + #[\Override] + public static function icon(): ?string + { + return \Craft::getAlias('@craft/commerce/icon-mask.svg'); + } + + #[\Override] + public static function maxColspan(): ?int + { + return 1; + } + + #[\Override] + public function getTitle(): ?string + { + return ''; + } + + #[\Override] + public function getBodyHtml(): ?string + { + $number = $this->stat->get(); + $timeFrame = $this->stat->getDateRangeWording(); + + \Craft::$app->getView()->registerAssetBundle(StatWidgetsAsset::class); + + return template('commerce/_components/widgets/customers/new/body', compact('number', 'timeFrame'), TemplateMode::Cp); + } + + #[\Override] + public function settingsForm(FormContext $context = new FormContext()): ?Form + { + \Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); + + return Form::make($this->statSettingsFields()); + } +} diff --git a/src/Dashboard/Widgets/Orders.php b/src/Dashboard/Widgets/Orders.php new file mode 100644 index 0000000000..3257ba05ba --- /dev/null +++ b/src/Dashboard/Widgets/Orders.php @@ -0,0 +1,132 @@ + $config */ + public function __construct(array $config = []) + { + parent::__construct($config); + + if (!$this->storeId) { + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $this->storeId = Cp::requestedSite()->getStore()->id; + } + } + + #[\Override] + public static function isSelectable(): bool + { + return currentUser()?->can('commerce-manageOrders') ?? false; + } + + #[\Override] + public static function displayName(): string + { + return t('Recent Orders', category: 'commerce'); + } + + #[\Override] + public static function icon(): ?string + { + return \Craft::getAlias('@craft/commerce/icon-mask.svg'); + } + + #[\Override] + public function getTitle(): ?string + { + if (!empty($this->orderStatuses) && count($this->orderStatuses) === 1) { + $orderStatus = app(OrderStatuses::class)->getOrderStatusByUid(Arr::first($this->orderStatuses), $this->storeId); + + if ($orderStatus) { + return t('Recent Orders', category: 'commerce') . ' – ' . t($orderStatus->name, category: 'commerce'); + } + } + + return parent::getTitle(); + } + + #[\Override] + public function getBodyHtml(): ?string + { + $orders = $this->getOrders(); + + $id = 'recent-orders-settings-' . Str::random(); + $namespaceId = InputNamespace::namespaceId($id); + + return template('commerce/_components/widgets/orders/recent/body', [ + 'orders' => $orders, + 'showStatuses' => !empty($this->orderStatuses) && count($this->orderStatuses) > 1, + 'id' => $id, + 'namespaceId' => $namespaceId, + ], TemplateMode::Cp); + } + + #[\Override] + public function settingsForm(FormContext $context = new FormContext()): ?Form + { + \Craft::$app->getView()->registerAssetBundle(OrdersWidgetAsset::class); + \Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); + + return Form::make([ + Field::make(t('Store', category: 'commerce')) + ->control(Choice::make('storeId')->value($this->storeId)->options($this->getStoreOptions())), + Field::make(t('Order Statuses', category: 'commerce')) + ->instructions(t('Only orders with the following order statuses will be included. Leave blank to include all statuses.', category: 'commerce')) + ->control(Choice::make('orderStatuses')->multiple()->value($this->orderStatuses)->options($this->getOrderStatusOptions())), + Field::make(t('Limit', category: 'commerce')) + ->control(Number::make('limit')->value($this->limit)->min(1)), + ]); + } + + /** + * Returns the recent entries, based on the widget settings and user permissions. + * + * @return Order[] + */ + private function getOrders(): array + { + $query = Order::find(); + $query->isCompleted(true); + $query->dateOrdered(':notempty:'); + $query->limit($this->limit); + $query->storeId($this->storeId); + $query->orderBy('dateOrdered DESC'); + + if (!empty($this->orderStatuses)) { + $orderStatusIds = app(OrderStatuses::class)->getAllOrderStatuses($this->storeId) + ->filter(fn($orderStatus) => in_array($orderStatus->uid, $this->orderStatuses))->map(fn($os) => $os->id)->all(); + $query->orderStatusId($orderStatusIds); + } + + return $query->all(); + } +} diff --git a/src/Dashboard/Widgets/RepeatCustomers.php b/src/Dashboard/Widgets/RepeatCustomers.php new file mode 100644 index 0000000000..f4062227e5 --- /dev/null +++ b/src/Dashboard/Widgets/RepeatCustomers.php @@ -0,0 +1,101 @@ + $config */ + public function __construct(array $config = []) + { + parent::__construct($config); + + if (!$this->storeId) { + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $this->storeId = Cp::requestedSite()->getStore()->id; + } + + $this->dateRange = $this->dateRange ?: StatInterface::DATE_RANGE_TODAY; + + $this->stat = new RepeatingCustomersStat( + $this->dateRange, + DateTimeHelper::toDateTime($this->startDate, true), + DateTimeHelper::toDateTime($this->endDate, true), + $this->storeId, + ); + + if (!empty($this->orderStatuses)) { + $this->stat->setOrderStatuses($this->orderStatuses); + } + } + + #[\Override] + public static function isSelectable(): bool + { + return currentUser()?->can('commerce-manageCustomers') ?? false; + } + + #[\Override] + public static function displayName(): string + { + return t('Repeat Customers', category: 'commerce'); + } + + #[\Override] + public static function icon(): ?string + { + return \Craft::getAlias('@craft/commerce/icon-mask.svg'); + } + + #[\Override] + public static function maxColspan(): ?int + { + return 1; + } + + #[\Override] + public function getTitle(): ?string + { + return ''; + } + + #[\Override] + public function getBodyHtml(): ?string + { + $numbers = $this->stat->get(); + $timeFrame = $this->stat->getDateRangeWording(); + + \Craft::$app->getView()->registerAssetBundle(StatWidgetsAsset::class); + + return template('commerce/_components/widgets/customers/repeat/body', compact('numbers', 'timeFrame'), TemplateMode::Cp); + } + + #[\Override] + public function settingsForm(FormContext $context = new FormContext()): ?Form + { + \Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); + + return Form::make($this->statSettingsFields()); + } +} diff --git a/src/Dashboard/Widgets/TopCustomers.php b/src/Dashboard/Widgets/TopCustomers.php new file mode 100644 index 0000000000..0ed785e659 --- /dev/null +++ b/src/Dashboard/Widgets/TopCustomers.php @@ -0,0 +1,144 @@ + */ + private array $typeOptions; + + /** @param array $config */ + public function __construct(array $config = []) + { + parent::__construct($config); + + if (!$this->storeId) { + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $this->storeId = Cp::requestedSite()->getStore()->id; + } + + $this->typeOptions = [ + 'total' => t('Total', category: 'commerce'), + 'average' => t('Average', category: 'commerce'), + ]; + + $this->title = match ($this->type) { + 'average' => t('Top Customers by Average Order', category: 'commerce'), + 'total' => t('Top Customers by Total Revenue', category: 'commerce'), + default => t('Top Customers', category: 'commerce'), + }; + + $this->dateRange = $this->dateRange ?: StatInterface::DATE_RANGE_TODAY; + + $this->stat = new TopCustomersStat( + $this->dateRange, + $this->type, + DateTimeHelper::toDateTime($this->startDate, true), + DateTimeHelper::toDateTime($this->endDate, true), + $this->storeId, + ); + + if (!empty($this->orderStatuses)) { + $this->stat->setOrderStatuses($this->orderStatuses); + } + } + + #[\Override] + public static function isSelectable(): bool + { + /** @phpstan-ignore-next-line nullsafe.neverNull, nullCoalesce.expr (currentUser() genuinely returns ?CraftUser) */ + return (currentUser()?->can('commerce-manageOrders') ?? false) && (currentUser()?->can('commerce-manageCustomers') ?? false); + } + + #[\Override] + public static function displayName(): string + { + return t('Top Customers', category: 'commerce'); + } + + #[\Override] + public static function icon(): ?string + { + return \Craft::getAlias('@craft/commerce/icon-mask.svg'); + } + + #[\Override] + public function getTitle(): ?string + { + return $this->title; + } + + #[\Override] + public function getSubtitle(): ?string + { + return $this->stat->getDateRangeWording(); + } + + #[\Override] + public function getBodyHtml(): ?string + { + $stats = $this->stat->get(); + + if (empty($stats)) { + return Html::tag('p', t('No stats available.', category: 'commerce'), ['class' => 'zilch']); + } + + \Craft::$app->getView()->registerAssetBundle(StatWidgetsAsset::class); + \Craft::$app->getView()->registerAssetBundle(AdminTableAsset::class); + + return template('commerce/_components/widgets/customers/top/body', [ + 'stats' => $stats, + 'type' => $this->type, + 'typeLabel' => $this->typeOptions[$this->type] ?? '', + 'id' => 'top-customers' . Str::random(), + ], TemplateMode::Cp); + } + + #[\Override] + public function settingsForm(FormContext $context = new FormContext()): ?Form + { + \Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); + + return Form::make([ + Field::make(t('Type', category: 'commerce')) + ->control(Choice::make('type')->value($this->type)->options( + collect($this->typeOptions)->map(fn($label, $value) => ['label' => $label, 'value' => $value])->values()->all() + )), + ...$this->statSettingsFields(), + ]); + } +} diff --git a/src/Dashboard/Widgets/TopProductTypes.php b/src/Dashboard/Widgets/TopProductTypes.php new file mode 100644 index 0000000000..7029c953cb --- /dev/null +++ b/src/Dashboard/Widgets/TopProductTypes.php @@ -0,0 +1,143 @@ + */ + private array $typeOptions; + + /** @param array $config */ + public function __construct(array $config = []) + { + parent::__construct($config); + + if (!$this->storeId) { + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $this->storeId = Cp::requestedSite()->getStore()->id; + } + + $this->typeOptions = [ + 'qty' => t('Qty', category: 'commerce'), + 'revenue' => t('Revenue', category: 'commerce'), + ]; + + $this->title = match ($this->type) { + 'revenue' => t('Top Product Types by Revenue', category: 'commerce'), + 'qty' => t('Top Product Types by Qty Sold', category: 'commerce'), + default => t('Top Product Types', category: 'commerce'), + }; + + $this->dateRange = $this->dateRange ?: StatInterface::DATE_RANGE_TODAY; + + $this->stat = new TopProductTypesStat( + $this->dateRange, + $this->type, + DateTimeHelper::toDateTime($this->startDate, true), + DateTimeHelper::toDateTime($this->endDate, true), + $this->storeId, + ); + + if (!empty($this->orderStatuses)) { + $this->stat->setOrderStatuses($this->orderStatuses); + } + } + + #[\Override] + public static function isSelectable(): bool + { + return currentUser()?->can('commerce-manageOrders') ?? false; + } + + #[\Override] + public static function displayName(): string + { + return t('Top Product Types', category: 'commerce'); + } + + #[\Override] + public static function icon(): ?string + { + return \Craft::getAlias('@craft/commerce/icon-mask.svg'); + } + + #[\Override] + public function getTitle(): ?string + { + return $this->title; + } + + #[\Override] + public function getSubtitle(): ?string + { + return $this->stat->getDateRangeWording(); + } + + #[\Override] + public function getBodyHtml(): ?string + { + $stats = $this->stat->get(); + + if (empty($stats)) { + return Html::tag('p', t('No stats available.', category: 'commerce'), ['class' => 'zilch']); + } + + \Craft::$app->getView()->registerAssetBundle(StatWidgetsAsset::class); + \Craft::$app->getView()->registerAssetBundle(AdminTableAsset::class); + + return template('commerce/_components/widgets/producttypes/top/body', [ + 'stats' => $stats, + 'type' => $this->type, + 'typeLabel' => $this->typeOptions[$this->type] ?? '', + 'id' => 'top-product-types' . Str::random(), + ], TemplateMode::Cp); + } + + #[\Override] + public function settingsForm(FormContext $context = new FormContext()): ?Form + { + \Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); + + return Form::make([ + Field::make(t('Type', category: 'commerce')) + ->control(Choice::make('type')->value($this->type)->options( + collect($this->typeOptions)->map(fn($label, $value) => ['label' => $label, 'value' => $value])->values()->all() + )), + ...$this->statSettingsFields(), + ]); + } +} diff --git a/src/Dashboard/Widgets/TopProducts.php b/src/Dashboard/Widgets/TopProducts.php new file mode 100644 index 0000000000..65dd48d186 --- /dev/null +++ b/src/Dashboard/Widgets/TopProducts.php @@ -0,0 +1,177 @@ + */ + private array $typeOptions; + + /** @var array */ + private array $revenueCheckboxOptions; + + /** @param array $config */ + public function __construct(array $config = []) + { + parent::__construct($config); + + if (!$this->storeId) { + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $this->storeId = Cp::requestedSite()->getStore()->id; + } + + $this->typeOptions = [ + TopProductsStat::TYPE_QTY => t('Qty', category: 'commerce'), + TopProductsStat::TYPE_REVENUE => t('Revenue', category: 'commerce'), + ]; + + $this->revenueCheckboxOptions = [ + ['value' => TopProductsStat::REVENUE_OPTION_DISCOUNT, 'label' => t('Discount', category: 'commerce') . ' — ' . t('Include line item discounts.', category: 'commerce')], + ['value' => TopProductsStat::REVENUE_OPTION_TAX_INCLUDED, 'label' => t('Tax (inc)', category: 'commerce') . ' — ' . t('Include built-in line item tax.', category: 'commerce')], + ['value' => TopProductsStat::REVENUE_OPTION_TAX, 'label' => t('Tax', category: 'commerce') . ' — ' . t('Include separate line item tax.', category: 'commerce')], + ['value' => TopProductsStat::REVENUE_OPTION_SHIPPING, 'label' => t('Shipping', category: 'commerce') . ' — ' . t('Include line item shipping costs.', category: 'commerce')], + ]; + + $this->title = match ($this->type) { + 'revenue' => t('Top Products by Revenue', category: 'commerce'), + 'qty' => t('Top Products by Qty Sold', category: 'commerce'), + default => t('Top Products', category: 'commerce'), + }; + + $this->dateRange = $this->dateRange ?: StatInterface::DATE_RANGE_TODAY; + + $this->stat = new TopProductsStat( + $this->dateRange, + $this->type, + DateTimeHelper::toDateTime($this->startDate, true), + DateTimeHelper::toDateTime($this->endDate, true), + $this->revenueOptions, + $this->storeId, + ); + + if (!empty($this->orderStatuses)) { + $this->stat->setOrderStatuses($this->orderStatuses); + } + } + + #[\Override] + public static function isSelectable(): bool + { + return currentUser()?->can('commerce-manageOrders') ?? false; + } + + #[\Override] + public static function displayName(): string + { + return t('Top Products', category: 'commerce'); + } + + #[\Override] + public static function icon(): ?string + { + return \Craft::getAlias('@craft/commerce/icon-mask.svg'); + } + + #[\Override] + public function getTitle(): ?string + { + return $this->title; + } + + #[\Override] + public function getSubtitle(): ?string + { + return $this->stat->getDateRangeWording(); + } + + #[\Override] + public function getBodyHtml(): ?string + { + $stats = $this->stat->get(); + + if (empty($stats)) { + return Html::tag('p', t('No stats available.', category: 'commerce'), ['class' => 'zilch']); + } + + \Craft::$app->getView()->registerAssetBundle(StatWidgetsAsset::class); + \Craft::$app->getView()->registerAssetBundle(AdminTableAsset::class); + + $defaultRevenueOptions = [ + TopProductsStat::REVENUE_OPTION_DISCOUNT, + TopProductsStat::REVENUE_OPTION_TAX_INCLUDED, + TopProductsStat::REVENUE_OPTION_TAX, + TopProductsStat::REVENUE_OPTION_SHIPPING, + ]; + $revenueColumnHandle = 'revenue'; + if ($this->type === TopProductsStat::TYPE_REVENUE && count(array_intersect($defaultRevenueOptions, $this->revenueOptions)) !== count($defaultRevenueOptions)) { + $revenueColumnHandle = 'revenue_custom'; + } + + return template('commerce/_components/widgets/products/top/body', [ + 'stats' => $stats, + 'revenueColumnHandle' => $revenueColumnHandle, + 'type' => $this->type, + 'typeLabel' => $this->typeOptions[$this->type] ?? '', + 'id' => 'top-products' . Str::random(), + ], TemplateMode::Cp); + } + + #[\Override] + public function settingsForm(FormContext $context = new FormContext()): ?Form + { + \Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); + + return Form::make([ + Field::make(t('Type', category: 'commerce')) + ->control(Choice::make('type')->value($this->type)->options( + collect($this->typeOptions)->map(fn($label, $value) => ['label' => $label, 'value' => $value])->values()->all() + )), + Field::make(t('Revenue Options', category: 'commerce')) + ->instructions(t('Which values should be included when calculating revenue?', category: 'commerce')) + ->control(Choice::make('revenueOptions')->multiple()->value($this->revenueOptions)->options($this->revenueCheckboxOptions)), + ...$this->statSettingsFields(), + ]); + } +} diff --git a/src/Dashboard/Widgets/TopPurchasables.php b/src/Dashboard/Widgets/TopPurchasables.php new file mode 100644 index 0000000000..ca8c49e55c --- /dev/null +++ b/src/Dashboard/Widgets/TopPurchasables.php @@ -0,0 +1,162 @@ + */ + private array $typeOptions; + + /** @var array */ + private array $nameFieldOptions; + + /** @param array $config */ + public function __construct(array $config = []) + { + parent::__construct($config); + + if (!$this->storeId) { + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $this->storeId = Cp::requestedSite()->getStore()->id; + } + + $this->nameFieldOptions = [ + 'description' => t('Description', category: 'commerce'), + 'sku' => t('SKU', category: 'commerce'), + ]; + + $this->typeOptions = [ + 'qty' => t('Qty', category: 'commerce'), + 'revenue' => t('Revenue', category: 'commerce'), + ]; + + $this->title = match ($this->type) { + 'revenue' => t('Top Purchasables by Revenue', category: 'commerce'), + 'qty' => t('Top Purchasables by Qty Sold', category: 'commerce'), + default => t('Top Purchasables', category: 'commerce'), + }; + + $this->dateRange = $this->dateRange ?: StatInterface::DATE_RANGE_TODAY; + + $this->stat = new TopPurchasablesStat( + $this->dateRange, + $this->type, + DateTimeHelper::toDateTime($this->startDate, true), + DateTimeHelper::toDateTime($this->endDate, true), + $this->storeId, + ); + + if (!empty($this->orderStatuses)) { + $this->stat->setOrderStatuses($this->orderStatuses); + } + } + + #[\Override] + public static function isSelectable(): bool + { + return currentUser()?->can('commerce-manageOrders') ?? false; + } + + #[\Override] + public static function displayName(): string + { + return t('Top Purchasables', category: 'commerce'); + } + + #[\Override] + public static function icon(): ?string + { + return \Craft::getAlias('@craft/commerce/icon-mask.svg'); + } + + #[\Override] + public function getTitle(): ?string + { + return $this->title; + } + + #[\Override] + public function getSubtitle(): ?string + { + return $this->stat->getDateRangeWording(); + } + + #[\Override] + public function getBodyHtml(): ?string + { + $stats = $this->stat->get(); + + if (empty($stats)) { + return Html::tag('p', t('No stats available.', category: 'commerce'), ['class' => 'zilch']); + } + + \Craft::$app->getView()->registerAssetBundle(StatWidgetsAsset::class); + \Craft::$app->getView()->registerAssetBundle(AdminTableAsset::class); + + return template('commerce/_components/widgets/purchasables/top/body', [ + 'stats' => $stats, + 'type' => $this->type, + 'nameField' => $this->nameField, + 'nameFieldLabel' => $this->nameFieldOptions[$this->nameField] ?? '', + 'typeLabel' => $this->typeOptions[$this->type] ?? '', + 'id' => 'top-purchasables' . Str::random(), + ], TemplateMode::Cp); + } + + #[\Override] + public function settingsForm(FormContext $context = new FormContext()): ?Form + { + \Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); + + return Form::make([ + Field::make(t('Type', category: 'commerce')) + ->control(Choice::make('type')->value($this->type)->options( + collect($this->typeOptions)->map(fn($label, $value) => ['label' => $label, 'value' => $value])->values()->all() + )), + Field::make(t('Name Field', category: 'commerce')) + ->control(Choice::make('nameField')->value($this->nameField)->options( + collect($this->nameFieldOptions)->map(fn($label, $value) => ['label' => $label, 'value' => $value])->values()->all() + )), + ...$this->statSettingsFields(), + ]); + } +} diff --git a/src/Dashboard/Widgets/TotalOrders.php b/src/Dashboard/Widgets/TotalOrders.php new file mode 100644 index 0000000000..bc41e95393 --- /dev/null +++ b/src/Dashboard/Widgets/TotalOrders.php @@ -0,0 +1,155 @@ + $config */ + public function __construct(array $config = []) + { + parent::__construct($config); + + if (!$this->storeId) { + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $this->storeId = Cp::requestedSite()->getStore()->id; + } + + $this->dateRange = $this->dateRange ?: StatInterface::DATE_RANGE_TODAY; + + $this->stat = new TotalOrdersStat( + $this->dateRange, + DateTimeHelper::toDateTime($this->startDate, true), + DateTimeHelper::toDateTime($this->endDate, true), + $this->storeId, + ); + + if (!empty($this->orderStatuses)) { + $this->stat->setOrderStatuses($this->orderStatuses); + } + } + + #[\Override] + public static function isSelectable(): bool + { + return currentUser()?->can('commerce-manageOrders') ?? false; + } + + #[\Override] + public static function displayName(): string + { + return t('Total Orders', category: 'commerce'); + } + + #[\Override] + public static function icon(): ?string + { + return \Craft::getAlias('@craft/commerce/icon-mask.svg'); + } + + #[\Override] + public static function maxColspan(): ?int + { + return 1; + } + + #[\Override] + public function getTitle(): ?string + { + if (!$this->showChart) { + return ''; + } + + $stats = $this->stat->get(); + $total = $stats['total'] ?? 0; + $total = I18N::getFormatter()->asInteger($total); + + return t('{total} orders', ['total' => $total], category: 'commerce'); + } + + #[\Override] + public function getSubtitle(): ?string + { + if (!$this->showChart) { + return ''; + } + + return $this->stat->getDateRangeWording(); + } + + #[\Override] + public function getBodyHtml(): ?string + { + $showChart = $this->showChart; + $stats = $this->stat->get(); + + if (empty($stats)) { + return Html::tag('p', t('No stats available.', category: 'commerce'), ['class' => 'zilch']); + } + + $number = $stats['total'] ?? 0; + $chart = $stats['chart'] ?? []; + + $labels = Arr::pluck($chart, 'datekey'); + $data = Arr::pluck($chart, 'total'); + + $timeFrame = $this->stat->getDateRangeWording(); + $number = I18N::getFormatter()->asInteger($number); + + $id = 'total-orders' . Str::random(); + $namespaceId = InputNamespace::namespaceId($id); + + \Craft::$app->getView()->registerAssetBundle(StatWidgetsAsset::class); + + return template('commerce/_components/widgets/orders/total/body', compact( + 'namespaceId', + 'number', + 'timeFrame', + 'labels', + 'data', + 'showChart', + ), TemplateMode::Cp); + } + + #[\Override] + public function settingsForm(FormContext $context = new FormContext()): ?Form + { + \Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); + + return Form::make([ + Field::make(t('Show Chart?', category: 'commerce')) + ->control(Lightswitch::make('showChart')->value((bool)$this->showChart)), + ...$this->statSettingsFields(), + ]); + } +} diff --git a/src/Dashboard/Widgets/TotalOrdersByCountry.php b/src/Dashboard/Widgets/TotalOrdersByCountry.php new file mode 100644 index 0000000000..d2dcdaeaf2 --- /dev/null +++ b/src/Dashboard/Widgets/TotalOrdersByCountry.php @@ -0,0 +1,150 @@ + */ + private array $typeOptions; + + /** @param array $config */ + public function __construct(array $config = []) + { + parent::__construct($config); + + if (!$this->storeId) { + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $this->storeId = Cp::requestedSite()->getStore()->id; + } + + $this->typeOptions = [ + 'billing' => t('Billing', category: 'commerce'), + 'shipping' => t('Shipping', category: 'commerce'), + ]; + + if (isset($this->type) && $this->type == 'billing') { + $this->title = t('Total Orders by Billing Country', category: 'commerce'); + } else { + $this->title = t('Total Orders by Shipping Country', category: 'commerce'); + $this->type = 'shipping'; + } + + $this->dateRange = $this->dateRange ?: StatInterface::DATE_RANGE_TODAY; + + $this->stat = new TotalOrdersByCountryStat( + $this->dateRange, + $this->type, + DateTimeHelper::toDateTime($this->startDate, true), + DateTimeHelper::toDateTime($this->endDate, true), + $this->storeId, + ); + + if (!empty($this->orderStatuses)) { + $this->stat->setOrderStatuses($this->orderStatuses); + } + } + + #[\Override] + public function getTitle(): ?string + { + return $this->title; + } + + #[\Override] + public function getSubtitle(): ?string + { + return $this->stat->getDateRangeWording(); + } + + #[\Override] + public static function isSelectable(): bool + { + return currentUser()?->can('commerce-manageOrders') ?? false; + } + + #[\Override] + public static function displayName(): string + { + return t('Total Orders by Country', category: 'commerce'); + } + + #[\Override] + public static function icon(): ?string + { + return \Craft::getAlias('@craft/commerce/icon-mask.svg'); + } + + #[\Override] + public function getBodyHtml(): ?string + { + $stats = $this->stat->get(); + + if (empty($stats)) { + return Html::tag('p', t('No stats available.', category: 'commerce'), ['class' => 'zilch']); + } + + \Craft::$app->getView()->registerAssetBundle(StatWidgetsAsset::class); + + $id = 'total-orders-by-country' . Str::random(); + $namespaceId = InputNamespace::namespaceId($id); + + $labels = Arr::pluck($stats, 'name'); + $totalOrders = Arr::pluck($stats, 'total'); + + return template('commerce/_components/widgets/orders/country/body', compact( + 'stats', + 'namespaceId', + 'labels', + 'totalOrders', + ), TemplateMode::Cp); + } + + #[\Override] + public function settingsForm(FormContext $context = new FormContext()): ?Form + { + \Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); + + return Form::make([ + Field::make(t('Type', category: 'commerce')) + ->control(Choice::make('type')->value($this->type)->options( + collect($this->typeOptions)->map(fn($label, $value) => ['label' => $label, 'value' => $value])->values()->all() + )), + ...$this->statSettingsFields(), + ]); + } +} diff --git a/src/Dashboard/Widgets/TotalRevenue.php b/src/Dashboard/Widgets/TotalRevenue.php new file mode 100644 index 0000000000..2765aa80b4 --- /dev/null +++ b/src/Dashboard/Widgets/TotalRevenue.php @@ -0,0 +1,175 @@ + $config */ + public function __construct(array $config = []) + { + parent::__construct($config); + + if (!$this->storeId) { + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $this->storeId = Cp::requestedSite()->getStore()->id; + } + + $this->dateRange = $this->dateRange ?: StatInterface::DATE_RANGE_TODAY; + + $this->stat = new TotalRevenueStat( + $this->dateRange, + DateTimeHelper::toDateTime($this->startDate, true), + DateTimeHelper::toDateTime($this->endDate, true), + $this->storeId, + ); + + if (!empty($this->orderStatuses)) { + $this->stat->setOrderStatuses($this->orderStatuses); + } + + $this->stat->type = $this->type; + } + + #[\Override] + public function getRules(): array + { + return [ + 'type' => ['required', Rule::in([TotalRevenueStat::TYPE_TOTAL, TotalRevenueStat::TYPE_TOTAL_PAID])], + ]; + } + + #[\Override] + public static function isSelectable(): bool + { + return currentUser()?->can('commerce-manageOrders') ?? false; + } + + #[\Override] + public static function displayName(): string + { + return t('Total Revenue', category: 'commerce'); + } + + #[\Override] + public function getTitle(): ?string + { + $stats = $this->stat->get(); + $revenue = Arr::pluck($stats, 'revenue'); + $total = round(array_sum($revenue), 0, \RoundingMode::HalfTowardsZero); + + $formattedTotal = Currency::formatAsCurrency($total, $this->getStore()->getCurrency()->getCode(), false, true, true); + + return t('{total} in total revenue', ['total' => $formattedTotal], category: 'commerce'); + } + + #[\Override] + public function getSubtitle(): ?string + { + return $this->stat->getDateRangeWording(); + } + + #[\Override] + public static function icon(): ?string + { + return \Craft::getAlias('@craft/commerce/icon-mask.svg'); + } + + #[\Override] + public function getBodyHtml(): ?string + { + $stats = $this->stat->get(); + $timeFrame = $this->stat->getDateRangeWording(); + $chartInterval = $this->stat->getDateRangeInterval(); + + \Craft::$app->getView()->registerAssetBundle(StatWidgetsAsset::class); + + $id = 'total-revenue' . Str::random(); + $namespaceId = InputNamespace::namespaceId($id); + + if (empty($stats)) { + return Html::tag('p', t('No stats available.', category: 'commerce'), ['class' => 'zilch']); + } + + $labels = Arr::pluck($stats, 'datekey'); + if ($chartInterval == 'month') { + $labels = array_map(static function($label) { + [$year, $month] = explode('-', $label); + $month = $month < 10 ? '0' . $month : $month; + return implode('-', [$year, $month, '01']); + }, $labels); + } elseif ($chartInterval == 'week') { + $labels = array_map(static function($label) { + $year = substr($label, 0, 4); + $week = substr($label, -2); + return $year . 'W' . $week; + }, $labels); + } + + $revenue = Arr::pluck($stats, 'revenue'); + $orderCount = Arr::pluck($stats, 'count'); + $widget = $this; + + return template('commerce/_components/widgets/orders/revenue/body', compact( + 'widget', + 'stats', + 'timeFrame', + 'namespaceId', + 'labels', + 'revenue', + 'orderCount', + 'chartInterval', + ), TemplateMode::Cp); + } + + #[\Override] + public function settingsForm(FormContext $context = new FormContext()): ?Form + { + \Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); + + return Form::make([ + Field::make(t('Type', category: 'commerce')) + ->control(Choice::make('type')->value($this->type)->options([ + ['label' => t('Total', category: 'commerce'), 'value' => TotalRevenueStat::TYPE_TOTAL], + ['label' => t('Total Paid', category: 'commerce'), 'value' => TotalRevenueStat::TYPE_TOTAL_PAID], + ])), + Field::make(t('Show Order Count?', category: 'commerce')) + ->control(Lightswitch::make('showOrderCount')->value($this->showOrderCount)), + ...$this->statSettingsFields(), + ]); + } +} diff --git a/src/Database/Table.php b/src/Database/Table.php new file mode 100644 index 0000000000..40a66e4dbc --- /dev/null +++ b/src/Database/Table.php @@ -0,0 +1,127 @@ +>|null + */ + private ?array $allEmails = null; + + /** + * Get an email by its ID. + */ + public function getEmailById(int $id, ?int $storeId = null): ?Email + { + return $this->getAllEmails($storeId)->firstWhere('id', $id); + } + + /** + * Get all emails. + * + * @return Collection + */ + public function getAllEmails(?int $storeId = null): Collection + { + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + if ($this->allEmails === null || !isset($this->allEmails[$storeId])) { + $results = $this->query()->where('emails.storeId', $storeId)->get(); + + $this->allEmails ??= []; + + foreach ($results as $result) { + $email = new Email((array)$result); + + $this->allEmails[$email->storeId] ??= collect(); + $this->allEmails[$email->storeId]->push($email); + } + } + + return $this->allEmails[$storeId] ?? collect(); + } + + /** + * Get all emails that are enabled. + * + * @return Collection + */ + public function getAllEnabledEmails(?int $storeId = null): Collection + { + return $this->getAllEmails($storeId)->where('enabled', true); + } + + /** + * Save an email. + */ + public function saveEmail(Email $email, bool $runValidation = true): bool + { + $isNewEmail = !(bool)$email->id; + + // Raise 'beforeSaveEmail' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getEmails()->hasEventHandlers(self::EVENT_BEFORE_SAVE_EMAIL)) { + $beforeEvent = new EmailEvent( + email: $email, + isNew: $isNewEmail, + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getEmails()->trigger(self::EVENT_BEFORE_SAVE_EMAIL, $beforeEvent); + } + + if ($runValidation && !$email->validate()) { + Log::info('Email not saved due to validation error(s).'); + return false; + } + + if ($isNewEmail) { + $email->uid = Str::uuid()->toString(); + } + + $configPath = self::CONFIG_EMAILS_KEY . '.' . $email->uid; + $configData = $email->getConfig(); + ProjectConfig::set($configPath, $configData); + + if ($isNewEmail) { + $email->id = CraftDb::idByUid(Table::EMAILS, $email->uid); + } + + return true; + } + + /** + * Handle email status change. + * + * @throws Throwable if reasons + */ + public function handleChangedEmail(ConfigEvent $event): void + { + ProjectConfigData::ensureAllStoresProcessed(); + + $emailUid = $event->tokenMatches[0]; + $data = $event->newValue; + + $pdfUid = $data['pdf'] ?? null; + if ($pdfUid) { + ProjectConfig::processConfigChanges(Pdfs::CONFIG_PDFS_KEY . '.' . $pdfUid); + } + + DB::beginTransaction(); + try { + $emailRecord = $this->getEmailRecord($emailUid); + $isNewEmail = !$emailRecord->exists; + $store = app(Stores::class)->getStoreByUid($data['store']); + $renderSite = array_key_exists('renderSite', $data) && $data['renderSite'] !== null ? Sites::getSiteByUid($data['renderSite']) : null; + + $emailRecord->storeId = $store->id; + $emailRecord->name = $data['name']; + $emailRecord->subject = $data['subject']; + $emailRecord->recipientType = $data['recipientType']; + $emailRecord->to = $data['to']; + $emailRecord->bcc = $data['bcc']; + $emailRecord->cc = $data['cc'] ?? null; + $emailRecord->replyTo = $data['replyTo'] ?? null; + $emailRecord->enabled = $data['enabled']; + $emailRecord->senderAddress = $data['senderAddress']; + $emailRecord->senderName = $data['senderName']; + $emailRecord->templatePath = $data['templatePath']; + $emailRecord->plainTextTemplatePath = $data['plainTextTemplatePath'] ?? null; + $emailRecord->uid = $emailUid; + $emailRecord->pdfId = $pdfUid ? CraftDb::idByUid(Table::PDFS, $pdfUid) : null; + $emailRecord->language = $data['language'] ?? EmailRecord::LOCALE_ORDER_LANGUAGE; + /** @phpstan-ignore-next-line nullsafe.neverNull ($renderSite is genuinely nullable - it's null whenever 'renderSite' isn't present in $data) */ + $emailRecord->renderSiteId = $renderSite?->id ?? null; + + $emailRecord->save(); + + DB::commit(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + + // Raise 'afterSaveEmail' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getEmails()->hasEventHandlers(self::EVENT_AFTER_SAVE_EMAIL)) { + $afterEvent = new EmailEvent( + email: $this->getEmailById($emailRecord->id, $emailRecord->storeId), + isNew: $isNewEmail, + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getEmails()->trigger(self::EVENT_AFTER_SAVE_EMAIL, $afterEvent); + } + + $this->clearCache(); + } + + /** + * Delete an email by its ID. + */ + public function deleteEmailById(int $id): bool + { + $email = EmailRecord::find($id); + + if ($email) { + // Raise 'beforeDeleteEmail' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getEmails()->hasEventHandlers(self::EVENT_BEFORE_DELETE_EMAIL)) { + $event = new EmailEvent( + email: $this->getEmailById($id, $email->storeId), + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getEmails()->trigger(self::EVENT_BEFORE_DELETE_EMAIL, $event); + } + + ProjectConfig::remove(self::CONFIG_EMAILS_KEY . '.' . $email->uid); + } + + return true; + } + + /** + * Handle email getting deleted. + * + * @throws Throwable + */ + public function handleDeletedEmail(ConfigEvent $event): void + { + $uid = $event->tokenMatches[0]; + $emailRecord = $this->getEmailRecord($uid); + + if (!$emailRecord->id) { + return; + } + + $email = $this->getEmailById($emailRecord->id, $emailRecord->storeId); + $emailRecord->delete(); + + // Raise 'afterDeleteEmail' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getEmails()->hasEventHandlers(self::EVENT_AFTER_DELETE_EMAIL)) { + $afterEvent = new EmailEvent( + email: $email, + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getEmails()->trigger(self::EVENT_AFTER_DELETE_EMAIL, $afterEvent); + } + + $this->clearCache(); + } + + /** + * Send a commerce email. + * + * @param array|null $orderData Since the order may have changed by the time the email sends. + * @param string $error The reason this method failed. + * @throws \Exception + * @throws Throwable + */ + public function sendEmail(Email $email, Order $order, ?OrderHistory $orderHistory = null, ?array $orderData = null, string &$error = ''): bool + { + if (!$email->enabled) { + $error = t('Email is not enabled.', category: 'commerce'); + return false; + } + + if ($email->storeId !== $order->getStore()->id) { + $error = t('Email unavailable.', category: 'commerce'); + return false; + } + + $option = 'email'; + $generalConfig = Cms::config(); + // Temporarily disable lazy transform generation + $generateTransformsBeforePageLoad = $generalConfig->generateTransformsBeforePageLoad; + $generalConfig->generateTransformsBeforePageLoad = true; + + //sending emails + $renderVariables = compact('order', 'orderHistory', 'option', 'orderData'); + + $newEmail = new Message(); + + $originalLanguage = \Craft::$app->language; + $originalFormattingLanguage = \Craft::$app->formattingLocale; + $emailLanguage = $email->getRenderLanguage($order); + $emailSite = $email->getRenderSite($order); + + Locale::switchAppLanguage($emailLanguage); + + $fromEmail = $email->getSenderAddress(); + $fromName = $email->getSenderName(); + + if ($fromEmail) { + $newEmail->setFrom($fromEmail); + } + + if ($fromName && $fromEmail) { + $newEmail->setFrom([$fromEmail => $fromName]); + } + + if ($email->recipientType == EmailRecord::TYPE_CUSTOMER) { + if ($order->getCustomer()) { + $newEmail->setTo($order->getEmail()); + } + } + + if ($email->recipientType == EmailRecord::TYPE_CUSTOM) { + // To: + try { + $emails = Template::renderSandboxedString($email->getTo(), $renderVariables, TemplateMode::Site); + $emails = preg_split('/[\s,]+/', (string)$emails); + + $newEmail->setTo($emails); + } catch (\Exception $e) { + $error = t('Email template parse error for custom email "{email}" in "To:". Order: "{order}". Template error: "{message}" {file}:{line}', [ + 'email' => $email->name, + 'order' => $order->getShortNumber(), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], category: 'commerce'); + Log::error($error); + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + } + + if (!$newEmail->getTo()) { + $error = t('Email error. No email address found for order. Order: "{order}"', ['order' => $order->getShortNumber()], category: 'commerce'); + Log::error($error); + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + + // BCC: + if ($bccSetting = $email->getBcc()) { + try { + $bcc = Template::renderSandboxedString($bccSetting, $renderVariables, TemplateMode::Site); + $bcc = str_replace(';', ',', $bcc); + $bcc = preg_split('/[\s,]+/', $bcc); + + if (array_filter($bcc)) { + $newEmail->setBcc($bcc); + } + } catch (\Exception $e) { + $error = t('Email template parse error for email "{email}" in "BCC:". Order: "{order}". Template error: "{message}" {file}:{line}', [ + 'email' => $email->name, + 'order' => $order->getShortNumber(), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], category: 'commerce'); + Log::error($error); + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + } + + // CC: + if ($ccSetting = $email->getCc()) { + try { + $cc = Template::renderSandboxedString($ccSetting, $renderVariables, TemplateMode::Site); + $cc = str_replace(';', ',', $cc); + $cc = preg_split('/[\s,]+/', $cc); + + if (array_filter($cc)) { + $newEmail->setCc($cc); + } + } catch (\Exception $e) { + $error = t('Email template parse error for email "{email}" in "CC:". Order: "{order}". Template error: "{message}" {file}:{line}', [ + 'email' => $email->name, + 'order' => $order->getShortNumber(), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], category: 'commerce'); + Log::error($error); + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + } + + if ($email->replyTo) { + // Reply To: + try { + $newEmail->setReplyTo(Template::renderSandboxedString($email->replyTo, $renderVariables, TemplateMode::Site)); + } catch (\Exception $e) { + $error = t('Email template parse error for email "{email}" in "ReplyTo:". Order: "{order}". Template error: "{message}" {file}:{line}', [ + 'email' => $email->name, + 'order' => $order->getShortNumber(), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], category: 'commerce'); + Log::error($error); + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + } + + // Subject: + try { + $newEmail->setSubject(Template::renderSandboxedString($email->subject, $renderVariables, TemplateMode::Site)); + } catch (\Exception $e) { + $error = t('Email template parse error for email "{email}" in "Subject:". Order: "{order}". Template error: "{message}" {file}:{line}', [ + 'email' => $email->name, + 'order' => $order->getShortNumber(), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], category: 'commerce'); + Log::error($error); + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + + // Template Path + try { + $templatePath = Template::renderSandboxedString($email->templatePath, $renderVariables, TemplateMode::Site); + } catch (\Exception $e) { + $error = t('Email template path parse error for email "{email}" in "Template Path". Order: "{order}". Template error: "{message}" {file}:{line}', [ + 'email' => $email->name, + 'order' => $order->getShortNumber(), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], category: 'commerce'); + Log::error($error); + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + + // Email Body + if (!app(TemplateResolver::class)->exists($templatePath, TemplateMode::Site)) { + $error = t('Email template does not exist at "{templatePath}" which resulted in "{templateParsedPath}" for email "{email}". Order: "{order}".', [ + 'templatePath' => $email->templatePath, + 'templateParsedPath' => $templatePath, + 'email' => $email->name, + 'order' => $order->getShortNumber(), + ], category: 'commerce'); + Log::error($error); + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + // Plain Text Template Path + $plainTextTemplatePath = null; + + if ($email->plainTextTemplatePath) { + try { + $plainTextTemplatePath = Template::renderSandboxedString($email->plainTextTemplatePath, $renderVariables, TemplateMode::Site); + } catch (\Exception $e) { + $error = t('Email plain text template path parse error for email "{email}" in "Template Path". Order: "{order}". Template error: "{message}" {file}:{line}', [ + 'email' => $email->name, + 'order' => $order->getShortNumber(), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], category: 'commerce'); + Log::error($error); + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + + // Plain Text Body + if ($plainTextTemplatePath && !app(TemplateResolver::class)->exists($plainTextTemplatePath, TemplateMode::Site)) { + $error = t('Email plain text template does not exist at "{templatePath}" which resulted in "{templateParsedPath}" for email "{email}". Order: "{order}".', [ + 'templatePath' => $email->plainTextTemplatePath, + 'templateParsedPath' => $plainTextTemplatePath, + 'email' => $email->name, + 'order' => $order->getShortNumber(), + ], category: 'commerce'); + Log::error($error); + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + } + + if ($pdf = $email->getPdf()) { + // Email Body + if (!app(TemplateResolver::class)->exists($pdf->templatePath, TemplateMode::Site)) { + $error = t('Email PDF template does not exist at "{templatePath}" for email "{email}". Order: "{order}".', [ + 'templatePath' => $pdf->templatePath, + 'email' => $email->name, + 'order' => $order->getShortNumber(), + ], category: 'commerce'); + Log::error($error); + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + + try { + $renderedPdf = app(Pdfs::class)->renderPdfForOrder($order, 'email', null, [], $pdf); + + $tempPath = Assets::tempFilePath('pdf'); + + file_put_contents($tempPath, $renderedPdf); + + $fileName = ''; + $defaultFileName = $pdf->handle . '-' . $order->number; + if ($pdf->fileNameFormat) { + try { + $fileName = Template::renderSandboxedObjectTemplate($pdf->fileNameFormat, $order, [], TemplateMode::Site); + } catch (\Throwable) { + $fileName = $defaultFileName; + } + } + + if (!$fileName) { + $fileName = $defaultFileName; + } + + // Attachment information + $options = ['fileName' => $fileName . '.pdf', 'contentType' => 'application/pdf']; + $newEmail->attach($tempPath, $options); + } catch (\Exception $e) { + $error = t('Email PDF generation error for email "{email}". Order: "{order}". PDF Template error: "{message}" {file}:{line}', [ + 'email' => $email->name, + 'order' => $order->getShortNumber(), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], category: 'commerce'); + Log::error($error); + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + } + + $originalSiteId = Sites::getCurrentSite()->id; + Sites::setCurrentSite($emailSite); + + // Render HTML body + try { + $body = Template::renderTemplate($templatePath, $renderVariables, TemplateMode::Site); + $newEmail->setHtmlBody($body); + } catch (\Exception $e) { + $error = t('Email template parse error for email "{email}". Order: "{order}". Template error: "{message}" {file}:{line}', [ + 'email' => $email->name, + 'order' => $order->getShortNumber(), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], category: 'commerce'); + Log::error($error); + + Sites::setCurrentSite($originalSiteId); + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + + // Render Plain Text body + if ($plainTextTemplatePath) { + try { + $plainTextBody = Template::renderTemplate($plainTextTemplatePath, $renderVariables, TemplateMode::Site); + $newEmail->setTextBody($plainTextBody); + } catch (\Exception $e) { + $error = t('Email plain text template parse error for email "{email}". Order: "{order}". Template error: "{message}" {file}:{line}', [ + 'email' => $email->name, + 'order' => $order->getShortNumber(), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], category: 'commerce'); + Log::error($error); + + Sites::setCurrentSite($originalSiteId); + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + } + + try { + // Raise 'beforeSendEmail' event + $event = new MailEvent( + craftEmail: $newEmail, + commerceEmail: $email, + order: $order, + orderHistory: $orderHistory, + orderData: $orderData, + ); + + // TODO: migrate event firing to Laravel once event system is bridged + $legacyService = Plugin::getInstance()->getEmails(); + if ($legacyService->hasEventHandlers(self::EVENT_BEFORE_SEND_MAIL)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $legacyService->trigger(self::EVENT_BEFORE_SEND_MAIL, $event); + } + + if (!$event->isValid) { + $notice = t('Email "{email}" for order {order} was cancelled.', [ + 'email' => $email->name, + 'order' => $order->getShortNumber(), + ], category: 'commerce'); + + Log::info($notice); + + Sites::setCurrentSite($originalSiteId); + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + // Plugins that stop a email being sent should not declare that the sending failed, just that it would blocking of the send. + // The blocking of the send will still be logged as an error though for now. + // @TODO Clean up this behavior in Commerce 6.0 so plugins that block a send can signal "blocked" distinctly from "failed" without it being logged as an error #COM-49 + // https://github.com/craftcms/commerce/issues/1842 + return true; + } + + app('mail.manager')->mailer()->getSymfonyTransport()->send($newEmail->getSymfonyEmail()); + } catch (\Exception $e) { + $error = t('Email "{email}" could not be sent for order "{order}". Error: {error} {file}:{line}', [ + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'email' => $email->name, + 'order' => $order->getShortNumber(), + ], category: 'commerce'); + + Log::error($error); + + Sites::setCurrentSite($originalSiteId); + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + return false; + } + + // Raise 'afterSendEmail' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getEmails()->hasEventHandlers(self::EVENT_AFTER_SEND_MAIL)) { + $afterEvent = new MailEvent( + craftEmail: $newEmail, + commerceEmail: $email, + order: $order, + orderHistory: $orderHistory, + orderData: $orderData, + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getEmails()->trigger(self::EVENT_AFTER_SEND_MAIL, $afterEvent); + } + + Sites::setCurrentSite($originalSiteId); + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; + + // Clear out the temp PDF file if it was created. + if (!empty($tempPath)) { + unlink($tempPath); + } + + return true; + } + + /** + * Get all emails by an order status ID. + * + * @return Email[] + */ + public function getAllEmailsByOrderStatusId(int $id): array + { + $results = $this->query() + ->join(Table::ORDERSTATUS_EMAILS . ' as statusEmails', 'emails.id', '=', 'statusEmails.emailId') + ->join(Table::ORDERSTATUSES . ' as orderStatuses', 'statusEmails.orderStatusId', '=', 'orderStatuses.id') + ->where('orderStatuses.id', $id) + ->get(); + + $emails = []; + + foreach ($results as $row) { + $emails[] = new Email((array)$row); + } + + return $emails; + } + + private function query(): Builder + { + return DB::table(Table::EMAILS . ' as emails') + ->select([ + 'emails.bcc', + 'emails.cc', + 'emails.enabled', + 'emails.id', + 'emails.language', + 'emails.name', + 'emails.pdfId', + 'emails.plainTextTemplatePath', + 'emails.recipientType', + 'emails.renderSiteId', + 'emails.replyTo', + 'emails.senderAddress', + 'emails.senderName', + 'emails.storeId', + 'emails.subject', + 'emails.templatePath', + 'emails.to', + 'emails.uid', + ]) + ->orderBy('emails.name'); + } + + /** + * Gets an email record by uid. + */ + private function getEmailRecord(string $uid): EmailRecord + { + if ($email = EmailRecord::where('uid', $uid)->first()) { + return $email; + } + + return new EmailRecord(); + } + + protected function clearCache(): void + { + $this->allEmails = null; + } +} diff --git a/src/Email/Events/EmailEvent.php b/src/Email/Events/EmailEvent.php new file mode 100644 index 0000000000..937f753891 --- /dev/null +++ b/src/Email/Events/EmailEvent.php @@ -0,0 +1,16 @@ +language; + + if ($order === null && $language === EmailRecord::LOCALE_ORDER_LANGUAGE) { + throw new \InvalidArgumentException('Can not get language for this email without providing an order'); + } + + if ($order && $language === EmailRecord::LOCALE_ORDER_LANGUAGE) { + $language = $order->orderLanguage; + } + + return $language; + } + + public function getRenderSite(?Order $order = null): Site + { + $renderSiteId = $this->renderSiteId ?? $order?->orderSiteId; + + if ($renderSiteId !== null) { + return Sites::getSiteById($renderSiteId); + } + + return Sites::getPrimarySite(); + } + + #[\Override] + public function getRules(): array + { + return [ + 'subject' => ['required', 'string'], + 'name' => ['required', 'string'], + 'templatePath' => ['required', 'string'], + 'language' => ['required', 'string'], + 'recipientType' => ['required', 'in:' . EmailRecord::TYPE_CUSTOMER . ',' . EmailRecord::TYPE_CUSTOM], + 'to' => ['required_if:recipientType,' . EmailRecord::TYPE_CUSTOM], + ]; + } + + #[\Override] + public function validationData(): array + { + return array_merge(parent::validationData(), [ + 'to' => $this->getTo(false), + ]); + } + + public function getPdf(): ?PdfModel + { + if (!$this->pdfId) { + return null; + } + + return app(Pdfs::class)->getPdfById($this->pdfId, $this->storeId); + } + + public function setSenderAddress(?string $senderAddress): void + { + $this->_senderAddress = $senderAddress; + } + + public function getSenderAddress(bool $parse = true): ?string + { + if (!$parse) { + return $this->_senderAddress; + } + + if (!$senderAddress = Env::parse($this->_senderAddress)) { + $senderAddress = Env::parse(\CraftCms\Cms\Email\Data\EmailSettings::fromProjectConfig()->fromEmail); + } + + return $senderAddress; + } + + public function setBcc(?string $bcc): void + { + $this->_bcc = $bcc; + } + + public function getBcc(bool $parse = true): ?string + { + if (!$parse) { + return $this->_bcc; + } + + return Env::parse($this->_bcc); + } + + public function setCc(?string $cc): void + { + $this->_cc = $cc; + } + + public function getCc(bool $parse = true): ?string + { + if (!$parse) { + return $this->_cc; + } + + return Env::parse($this->_cc); + } + + public function setTo(?string $to): void + { + $this->_to = $to; + } + + public function getTo(bool $parse = true): ?string + { + if (!$parse) { + return $this->_to; + } + + return Env::parse($this->_to); + } + + public function setSenderName(?string $senderName): void + { + $this->_senderName = $senderName; + } + + public function getSenderName(bool $parse = true): ?string + { + if (!$parse) { + return $this->_senderName; + } + + if (!$senderName = Env::parse($this->_senderName)) { + $senderName = Env::parse(\CraftCms\Cms\Email\Data\EmailSettings::fromProjectConfig()->fromName); + } + + return $senderName; + } + + public function getConfig(): array + { + return [ + 'bcc' => $this->getBcc(false) ?: null, + 'cc' => $this->getCc(false) ?: null, + 'senderAddress' => $this->getSenderAddress(false) ?: null, + 'senderName' => $this->getSenderName(false) ?: null, + 'enabled' => $this->enabled, + 'language' => $this->language, + 'name' => $this->name, + 'pdf' => $this->getPdf()?->uid, + 'plainTextTemplatePath' => $this->plainTextTemplatePath ?? null, + 'recipientType' => $this->recipientType, + 'renderSite' => $this->renderSiteId ? Sites::getSiteById($this->renderSiteId)?->uid : null, + 'replyTo' => $this->replyTo ?: null, + 'store' => $this->getStore()->uid, + 'subject' => $this->subject, + 'templatePath' => $this->templatePath ?: null, + 'to' => $this->getTo(false) ?: null, + ]; + } + + public function getCpEditUrl(): string + { + return Url::cpUrl('commerce/settings/emails/' . $this->getStore()->handle . '/' . $this->id); + } +} diff --git a/src/Email/Records/Email.php b/src/Email/Records/Email.php new file mode 100644 index 0000000000..9fd03922e1 --- /dev/null +++ b/src/Email/Records/Email.php @@ -0,0 +1,36 @@ + 'integer', + 'renderSiteId' => 'integer', + 'pdfId' => 'integer', + 'enabled' => 'boolean', + ]; +} diff --git a/src/Events/UpgradeEvent.php b/src/Events/UpgradeEvent.php new file mode 100644 index 0000000000..c17e70cc1a --- /dev/null +++ b/src/Events/UpgradeEvent.php @@ -0,0 +1,14 @@ + Request-level cache for condition evaluation results, keyed by formula+params hash. + */ + private array $conditionResults = []; + + public function __construct() + { + $tags = $this->getTags(); + $filters = $this->getFilters(); + $functions = $this->getFunctions(); + $methods = $this->getMethods(); + $properties = $this->getProperties(); + + $policy = new SecurityPolicy($tags, $filters, $methods, $properties, $functions); + $loader = new FilesystemLoader(); + $sandbox = new SandboxExtension($policy, true); + + $this->twigEnv = new Environment($loader); + $this->twigEnv->addExtension($sandbox); + } + + /** + * @param array $params data passed into the formula + */ + public function validateConditionSyntax(string $condition, array $params): bool + { + try { + $this->evaluateCondition($condition, $params, t('Validating condition syntax', category: 'commerce')); + } catch (Exception) { + return false; + } + + return true; + } + + /** + * @param array $params data passed into the formula + */ + public function validateFormulaSyntax(string $formula, array $params): bool + { + try { + $this->evaluateFormula($formula, $params, null, t('Validating formula syntax', category: 'commerce')); + } catch (Exception) { + return false; + } + + return true; + } + + /** + * @param array $params data passed into the condition + * @param string $name The name of the formula, useful for locating template errors in logs and exceptions + * @throws SyntaxError + * @throws LoaderError + */ + public function evaluateCondition(string $formula, array $params, string $name = 'Evaluate Condition'): bool + { + if ($this->hasDisallowedStrings($formula, ['{%', '%}', '{{', '}}'])) { + throw new SyntaxError('Tags are not allowed in a condition formula.'); + } + + $formulaHash = md5($formula); + $paramsHash = md5(Json::encode($params)); + $requestKey = $formulaHash . $paramsHash; + + if (isset($this->conditionResults[$requestKey])) { + return $this->conditionResults[$requestKey]; + } + + $cachedResult = Cache::get($requestKey); + if ($cachedResult !== null) { + return $this->conditionResults[$requestKey] = $cachedResult; + } + + $twigCode = '{% if '; + $twigCode .= $formula; + $twigCode .= ' %}TRUE{% else %}FALSE{% endif %}'; + + $template = $this->twigEnv->createTemplate($twigCode, $name); + $output = $template->render($params) === 'TRUE'; + + Cache::put($requestKey, $output, now()->addDay()); + + return $this->conditionResults[$requestKey] = $output; + } + + /** + * @param array $params data passed into the condition + * @param string|null $setType the type of the response data, passing nothing will leave as a string. Uses \settype(). + * @param string|null $name The name of the formula, useful for locating template errors in logs and exceptions + * @throws SyntaxError + * @throws LoaderError + */ + public function evaluateFormula(string $formula, array $params, ?string $setType = null, ?string $name = 'Inline formula'): mixed + { + $formula = trim($formula); + + $template = $this->twigEnv->createTemplate($formula, $name); + $result = $template->render($params); + + if ($setType === null) { + return $result; + } + + settype($result, $setType); + return $result; + } + + private function hasDisallowedStrings(string $code, array $disallowedStrings = []): bool + { + return array_any($disallowedStrings, fn($disallowedString) => stripos($code, (string)$disallowedString) !== false); + } + + private function getTags(): array + { + return [ + 'for', + 'if', + 'set', + ]; + } + + private function getFilters(): array + { + return [ + 'abs', + 'capitalize', + 'date', + 'filter', + 'first', + 'join', + 'keys', + 'last', + 'length', + 'map', + 'merge', + 'reduce', + 'replace', + 'reverse', + 'round', + 'slice', + 'sort', + 'split', + 'trim', + 'upper', + ]; + } + + private function getFunctions(): array + { + return [ + 'date', + 'max', + 'min', + 'random', + 'range', + ]; + } + + private function getMethods(): array + { + return []; + } + + private function getProperties(): array + { + return []; + } +} diff --git a/src/Gql/Arguments/Elements/Product.php b/src/Gql/Arguments/Elements/Product.php new file mode 100644 index 0000000000..64cc8bca8e --- /dev/null +++ b/src/Gql/Arguments/Elements/Product.php @@ -0,0 +1,85 @@ + [ + 'name' => 'defaultSku', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the default SKU on the product.', + ], + 'defaultPrice' => [ + 'name' => 'defaultPrice', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the default price on the product.', + ], + 'defaultHeight' => [ + 'name' => 'defaultHeight', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the default height on the product.', + ], + 'defaultLength' => [ + 'name' => 'defaultLength', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the default length on the product.', + ], + 'defaultWidth' => [ + 'name' => 'defaultWidth', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the default width on the product.', + ], + 'defaultWeight' => [ + 'name' => 'defaultWeight', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the default weight on the product.', + ], + 'editable' => [ + 'name' => 'editable', + 'type' => Type::boolean(), + 'description' => 'Whether to only return products that the user has permission to edit.', + ], + 'type' => [ + 'name' => 'type', + 'type' => Type::listOf(Type::string()), + 'description' => 'Narrows the query results based on the product type the products belong to per the product type\'s handles.', + ], + 'typeId' => [ + 'name' => 'typeId', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the product types the products belong to, per the product type IDs.', + ], + 'hasVariant' => [ + 'name' => 'hasVariant', + 'type' => Variant::getType(), + 'description' => 'Narrows the query results to only products that have certain variants.', + ], + ]); + } + + #[Override] + public static function getContentArguments(): array + { + $productTypeFieldArguments = Gql::getContentArguments( + contexts: app(ProductTypes::class)->getAllProductTypes(), + elementType: ProductElement::class, + ); + + return array_merge(parent::getContentArguments(), $productTypeFieldArguments); + } +} diff --git a/src/Gql/Arguments/Elements/Variant.php b/src/Gql/Arguments/Elements/Variant.php new file mode 100644 index 0000000000..42934fa68b --- /dev/null +++ b/src/Gql/Arguments/Elements/Variant.php @@ -0,0 +1,151 @@ + [ + 'name' => 'promotable', + 'type' => Type::boolean(), + 'description' => 'Whether to only return products that are promotable.', + ], + 'availableForPurchase' => [ + 'name' => 'availableForPurchase', + 'type' => Type::boolean(), + 'description' => 'Whether to only return products that are available to purchase.', + ], + 'freeShipping' => [ + 'name' => 'freeShipping', + 'type' => Type::boolean(), + 'description' => 'Whether to only return products that have free shipping.', + ], + 'hasProduct' => [ + 'name' => 'hasProduct', + 'type' => Product::getType(), + 'description' => 'Narrows the query results to only variants for certain products.', + ], + 'hasSales' => [ + 'name' => 'hasSales', + 'type' => Type::boolean(), + 'description' => 'Narrows the query results based on whether the variant has sales applied.', + ], + 'hasStock' => [ + 'name' => 'hasStock', + 'type' => Type::boolean(), + 'description' => 'Narrows the query results based on whether the variant has stock available.', + ], + 'isDefault' => [ + 'name' => 'isDefault', + 'type' => Type::boolean(), + 'description' => 'Narrows the query results based on the variants default status.', + ], + 'maxQty' => [ + 'name' => 'maxQty', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the variant\'s maximum allowed quantity to be purchased.', + ], + 'minQty' => [ + 'name' => 'minQty', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the variant\'s minimum allowed quantity to be purchased.', + ], + 'price' => [ + 'name' => 'price', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the variant\'s price.', + ], + 'promotionalPrice' => [ + 'name' => 'promotionalPrice', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the variant\'s promotional price.', + ], + 'onPromotion' => [ + 'name' => 'onPromotion', + 'type' => Type::boolean(), + 'description' => 'Narrows the query results based on whether the variant has a promotional price.', + ], + 'forCustomer' => [ + 'name' => 'forCustomer', + 'type' => IntFalse::getType(), + 'description' => 'Narrows the pricing query results to only prices related for the specified customer.', + ], + 'productId' => [ + 'name' => 'productId', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the variant\'s product ID.', + ], + 'sku' => [ + 'name' => 'sku', + 'type' => Type::listOf(Type::string()), + 'description' => 'Narrows the query results based on the variant SKU.', + ], + 'stock' => [ + 'name' => 'stock', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on variant stock level.', + ], + 'typeId' => [ + 'name' => 'typeId', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the variant\'s product\'s type ID.', + ], + 'width' => [ + 'name' => 'width', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the variant\'s width dimension.', + ], + 'height' => [ + 'name' => 'height', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the variant\'s height dimension.', + ], + 'length' => [ + 'name' => 'length', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the variant\'s length dimension.', + ], + 'weight' => [ + 'name' => 'weight', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the variant\'s weight dimension.', + ], + ]); + } + + #[Override] + public static function getContentArguments(): array + { + return array_merge(parent::getContentArguments(), app(Variants::class)->getVariantGqlContentArguments()); + } + + #[Override] + public static function getStatusArguments(): array + { + $statusArguments = parent::getStatusArguments(); + + if (Gql::canQueryInactiveElements()) { + $statusArguments['productStatus'] = [ + 'name' => 'productStatus', + 'type' => Type::listOf(Type::string()), + 'description' => 'Narrows the query results based on the variants\' product\'s statuses.', + ]; + } + + return $statusArguments; + } +} diff --git a/src/Gql/Handlers/HasProduct.php b/src/Gql/Handlers/HasProduct.php new file mode 100644 index 0000000000..fa42b5d348 --- /dev/null +++ b/src/Gql/Handlers/HasProduct.php @@ -0,0 +1,23 @@ +argumentManager->prepareArguments($argumentValue); + } + + return $argumentValue; + } +} diff --git a/src/Gql/Handlers/HasVariant.php b/src/Gql/Handlers/HasVariant.php new file mode 100644 index 0000000000..98e8f693fd --- /dev/null +++ b/src/Gql/Handlers/HasVariant.php @@ -0,0 +1,23 @@ +argumentManager->prepareArguments($argumentValue); + } + + return $argumentValue; + } +} diff --git a/src/Gql/Handlers/RelatedProducts.php b/src/Gql/Handlers/RelatedProducts.php new file mode 100644 index 0000000000..b9597832b2 --- /dev/null +++ b/src/Gql/Handlers/RelatedProducts.php @@ -0,0 +1,21 @@ +getIds(Product::class, $argumentValue); + } +} diff --git a/src/Gql/Handlers/RelatedVariants.php b/src/Gql/Handlers/RelatedVariants.php new file mode 100644 index 0000000000..ce7a18ba8f --- /dev/null +++ b/src/Gql/Handlers/RelatedVariants.php @@ -0,0 +1,21 @@ +getIds(Variant::class, $argumentValue); + } +} diff --git a/src/Gql/Interfaces/Elements/Product.php b/src/Gql/Interfaces/Elements/Product.php new file mode 100644 index 0000000000..ec4f197b78 --- /dev/null +++ b/src/Gql/Interfaces/Elements/Product.php @@ -0,0 +1,163 @@ + static::getName(), + 'fields' => self::class . '::getFieldDefinitions', + 'description' => 'This is the interface implemented by all products.', + 'resolveType' => self::class . '::resolveElementTypeName', + ])); + + ProductType::generateTypes(); + + return $type; + } + + #[Override] + public static function getName(): string + { + return 'ProductInterface'; + } + + #[Override] + public static function getFieldDefinitions(): array + { + $productArguments = ProductArguments::getArguments(); + $structureProductTypeFieldArguments = [...$productArguments]; + + foreach (GqlCommerceHelper::getSchemaContainedProductTypes() as $productType) { + $productTypeArguments = Gql::getFieldLayoutArguments($productType->getProductFieldLayout()); + if ($productType->isStructure) { + $structureProductTypeFieldArguments += $productTypeArguments; + } + } + + return Gql::prepareFieldDefinitions(array_merge(parent::getFieldDefinitions(), [ + 'defaultSku' => [ + 'name' => 'defaultSku', + 'type' => Type::string(), + 'description' => 'The SKU of the default variant for the product.', + ], + 'defaultPrice' => [ + 'name' => 'defaultPrice', + 'type' => Type::float(), + 'description' => 'The price of the default variant for the product.', + ], + 'defaultPriceAsCurrency' => [ + 'name' => 'defaultPriceAsCurrency', + 'type' => Type::string(), + 'description' => 'The formatted price of the default variant for the product.', + ], + 'defaultHeight' => [ + 'name' => 'defaultHeight', + 'type' => Type::float(), + 'description' => 'The height of the default variant for the product.', + ], + 'defaultLength' => [ + 'name' => 'defaultLength', + 'type' => Type::float(), + 'description' => 'The length of the default variant for the product.', + ], + 'defaultWidth' => [ + 'name' => 'defaultWidth', + 'type' => Type::float(), + 'description' => 'The width of the default variant for the product.', + ], + 'defaultWeight' => [ + 'name' => 'defaultWeight', + 'type' => Type::float(), + 'description' => 'The weight of the default variant for the product.', + ], + 'defaultVariant' => [ + 'name' => 'defaultVariant', + 'type' => Variant::getType(), + 'description' => 'The default variant for the product.', + ], + 'productTypeId' => [ + 'name' => 'productTypeId', + 'type' => Type::int(), + 'description' => 'The ID of the product type that contains the product.', + ], + 'productTypeHandle' => [ + 'name' => 'productTypeHandle', + 'type' => Type::string(), + 'description' => 'The handle of the product type that contains the product.', + ], + 'url' => [ + 'name' => 'url', + 'type' => Type::string(), + 'description' => 'The product\'s full URL', + ], + 'variants' => [ + 'name' => 'variants', + 'type' => Type::listOf(Variant::getType()), + 'description' => 'The product\'s variants.', + ], + 'localized' => [ + 'name' => 'localized', + 'args' => $productArguments, + 'type' => Type::nonNull(Type::listOf(Type::nonNull(static::getType()))), + 'description' => 'The same element in other locales.', + 'complexity' => GqlHelper::eagerLoadComplexity(), + ], + 'children' => [ + 'name' => 'children', + 'args' => $structureProductTypeFieldArguments, + 'type' => Type::nonNull(Type::listOf(Type::nonNull(static::getType()))), + 'description' => 'The products\'s children, if the product type is a structure. Accepts the same arguments as the `products` query.', + 'complexity' => GqlHelper::relatedArgumentComplexity(GqlService::GRAPHQL_COMPLEXITY_EAGER_LOAD), + ], + 'descendants' => [ + 'name' => 'descendants', + 'args' => $structureProductTypeFieldArguments, + 'type' => Type::nonNull(Type::listOf(Type::nonNull(static::getType()))), + 'description' => 'The products\'s descendants, if the product type is a structure. Accepts the same arguments as the `products` query.', + 'complexity' => GqlHelper::relatedArgumentComplexity(GqlService::GRAPHQL_COMPLEXITY_EAGER_LOAD), + ], + 'parent' => [ + 'name' => 'parent', + 'args' => $structureProductTypeFieldArguments, + 'type' => static::getType(), + 'description' => 'The products\'s parent, if the product type is a structure.', + 'complexity' => GqlHelper::relatedArgumentComplexity(GqlService::GRAPHQL_COMPLEXITY_EAGER_LOAD), + ], + 'ancestors' => [ + 'name' => 'ancestors', + 'args' => $structureProductTypeFieldArguments, + 'type' => Type::nonNull(Type::listOf(Type::nonNull(static::getType()))), + 'description' => 'The products\'s ancestors, if the product type is a structure. Accepts the same arguments as the `products` query.', + 'complexity' => GqlHelper::relatedArgumentComplexity(GqlService::GRAPHQL_COMPLEXITY_EAGER_LOAD), + ], + ]), self::getName()); + } +} diff --git a/src/Gql/Interfaces/Elements/Variant.php b/src/Gql/Interfaces/Elements/Variant.php new file mode 100644 index 0000000000..6d509e4d99 --- /dev/null +++ b/src/Gql/Interfaces/Elements/Variant.php @@ -0,0 +1,195 @@ + static::getName(), + 'fields' => self::class . '::getFieldDefinitions', + 'description' => 'This is the interface implemented by all variants.', + 'resolveType' => self::class . '::resolveElementTypeName', + ])); + + VariantType::generateTypes(); + + return $type; + } + + #[Override] + public static function getName(): string + { + return 'VariantInterface'; + } + + #[Override] + public static function getFieldDefinitions(): array + { + return Gql::prepareFieldDefinitions(array_merge(parent::getFieldDefinitions(), [ + 'isDefault' => [ + 'name' => 'isDefault', + 'type' => Type::boolean(), + 'description' => 'If the variant is the default for the product.', + ], + 'isAvailable' => [ + 'name' => 'isAvailable', + 'type' => Type::boolean(), + 'description' => 'If the variant is available to be purchased.', + ], + 'price' => [ + 'name' => 'price', + 'type' => Type::float(), + 'description' => 'The price of the variant.', + ], + 'priceAsCurrency' => [ + 'name' => 'priceAsCurrency', + 'type' => Type::string(), + 'description' => 'The formatted price of the variant.', + ], + 'promotionalPrice' => [ + 'name' => 'promotionalPrice', + 'type' => Type::float(), + 'description' => 'The promotional price of the variant.', + ], + 'promotionalPriceAsCurrency' => [ + 'name' => 'promotionalPriceAsCurrency', + 'type' => Type::string(), + 'description' => 'The formatted promotional price of the variant.', + ], + 'salePrice' => [ + 'name' => 'salePrice', + 'type' => Type::float(), + 'description' => 'The sale price of the variant. CAUTION: This will not take into account sales that utilize user group conditions.', + ], + 'salePriceAsCurrency' => [ + 'name' => 'salePriceAsCurrency', + 'type' => Type::string(), + 'description' => 'The formatted sale price of the variant. CAUTION: This will not take into account sales that utilize user group conditions.', + ], + 'sales' => [ + 'name' => 'sales', + 'type' => Type::listOf(SaleType::getType()), + 'description' => 'The sales that apply to the variant. CAUTION: This will not take into account sales that utilize user group conditions.', + ], + 'sortOrder' => [ + 'name' => 'sortOrder', + 'type' => Type::int(), + 'description' => 'The sort order of the variant.', + ], + 'width' => [ + 'name' => 'width', + 'type' => Type::float(), + 'description' => 'The width of the variant.', + ], + 'height' => [ + 'name' => 'height', + 'type' => Type::float(), + 'description' => 'The height of the variant.', + ], + 'length' => [ + 'name' => 'length', + 'type' => Type::float(), + 'description' => 'The length of the variant.', + ], + 'weight' => [ + 'name' => 'weight', + 'type' => Type::float(), + 'description' => 'The weight of the variant.', + ], + 'stock' => [ + 'name' => 'stock', + 'type' => Type::int(), + 'description' => 'The stock level of the variant.', + ], + 'hasUnlimitedStock' => [ + 'name' => 'hasUnlimitedStock', + 'type' => Type::boolean(), + 'description' => 'If the variant has unlimited stock.', + ], + 'minQty' => [ + 'name' => 'minQty', + 'type' => Type::int(), + 'description' => 'The minimum allowed quantity to be purchased.', + ], + 'maxQty' => [ + 'name' => 'maxQty', + 'type' => Type::int(), + 'description' => 'The maximum allowed quantity to be purchased.', + ], + 'promotable' => [ + 'name' => 'promotable', + 'type' => Type::boolean(), + 'description' => 'If the product is promotable.', + ], + 'availableForPurchase' => [ + 'name' => 'availableForPurchase', + 'type' => Type::boolean(), + 'description' => 'If the product is available for purchase.', + ], + 'freeShipping' => [ + 'name' => 'freeShipping', + 'type' => Type::boolean(), + 'description' => 'If the product has free shipping.', + ], + 'shippingCategoryId' => [ + 'name' => 'shippingCategoryId', + 'type' => Type::int(), + 'description' => 'The ID of the variants\'s shipping category.', + ], + 'productId' => [ + 'name' => 'productId', + 'type' => Type::int(), + 'description' => 'The ID of the variant\'s parent product.', + ], + 'product' => [ + 'name' => 'product', + 'type' => Product::getType(), + 'description' => 'The variant\'s parent product.', + ], + 'productTitle' => [ + 'name' => 'productTitle', + 'type' => Type::string(), + 'description' => 'The title of the variant\'s parent product.', + ], + 'productTypeId' => [ + 'name' => 'productTypeId', + 'type' => Type::int(), + 'description' => 'The product type ID of the variant\'s parent product.', + ], + 'sku' => [ + 'name' => 'sku', + 'type' => Type::string(), + 'description' => 'The SKU of the variant.', + ], + 'storeId' => [ + 'name' => 'storeId', + 'type' => Type::int(), + 'description' => 'The ID of the variant\'s store.', + ], + ]), self::getName()); + } +} diff --git a/src/Gql/Queries/Product.php b/src/Gql/Queries/Product.php new file mode 100644 index 0000000000..606bdc4c71 --- /dev/null +++ b/src/Gql/Queries/Product.php @@ -0,0 +1,43 @@ + [ + 'type' => Type::listOf(ProductInterface::getType()), + 'args' => ProductArguments::getArguments(), + 'resolve' => ProductResolver::class . '::resolve', + 'description' => 'This query is used to query for products.', + ], + 'productCount' => [ + 'type' => Type::nonNull(Type::int()), + 'args' => ProductArguments::getArguments(), + 'resolve' => ProductResolver::class . '::resolveCount', + 'description' => 'This query is used to return the number of products.', + ], + 'product' => [ + 'type' => ProductInterface::getType(), + 'args' => ProductArguments::getArguments(), + 'resolve' => ProductResolver::class . '::resolveOne', + 'description' => 'This query is used to query for a product.', + ], + ]; + } +} diff --git a/src/Gql/Queries/Variant.php b/src/Gql/Queries/Variant.php new file mode 100644 index 0000000000..5963ee9b11 --- /dev/null +++ b/src/Gql/Queries/Variant.php @@ -0,0 +1,43 @@ + [ + 'type' => Type::listOf(VariantInterface::getType()), + 'args' => VariantArguments::getArguments(), + 'resolve' => VariantResolver::class . '::resolve', + 'description' => 'This query is used to query for variants.', + ], + 'variantCount' => [ + 'type' => Type::nonNull(Type::int()), + 'args' => VariantArguments::getArguments(), + 'resolve' => VariantResolver::class . '::resolveCount', + 'description' => 'This query is used to return the number of variants.', + ], + 'variant' => [ + 'type' => VariantInterface::getType(), + 'args' => VariantArguments::getArguments(), + 'resolve' => VariantResolver::class . '::resolveOne', + 'description' => 'This query is used to query for a variant.', + ], + ]; + } +} diff --git a/src/Gql/Resolvers/Elements/Product.php b/src/Gql/Resolvers/Elements/Product.php new file mode 100644 index 0000000000..930b597bfe --- /dev/null +++ b/src/Gql/Resolvers/Elements/Product.php @@ -0,0 +1,54 @@ +$fieldName; + } + + // If it's preloaded, it's preloaded. + if (!$query instanceof ElementQueryInterface) { + return $query; + } + + foreach ($arguments as $key => $value) { + try { + $query->$key($value); + } catch (BadMethodCallException $e) { + if ($value !== null) { + throw $e; + } + } + } + + GqlHelper::extractAllowedEntitiesFromSchema(); + + if (!GqlCommerceHelper::canQueryProducts()) { + return ElementCollection::empty(); + } + + $productTypeIds = array_map(fn($productType) => $productType->id, GqlCommerceHelper::getSchemaContainedProductTypes()); + $query->whereIn(Table::PRODUCTS . '.typeId', $productTypeIds); + + return $query; + } +} diff --git a/src/Gql/Resolvers/Elements/Variant.php b/src/Gql/Resolvers/Elements/Variant.php new file mode 100644 index 0000000000..122b1763bd --- /dev/null +++ b/src/Gql/Resolvers/Elements/Variant.php @@ -0,0 +1,62 @@ +$fieldName; + } + + // If it's preloaded, it's preloaded. + if (!$query instanceof ElementQueryInterface) { + return $query; + } + + foreach ($arguments as $key => $value) { + try { + $query->$key($value); + } catch (BadMethodCallException $e) { + if ($value !== null) { + throw $e; + } + } + } + + GqlHelper::extractAllowedEntitiesFromSchema(); + + if (!GqlCommerceHelper::canQueryProducts()) { + return ElementCollection::empty(); + } + + // For variant queries make sure we are only return those that have live products + // unless the schema allows querying inactive elements + if (!GqlHelper::canQueryInactiveElements() && $query instanceof VariantQuery) { + $query->productStatus(ProductElement::STATUS_LIVE); + } + + $productTypeIds = array_map(fn($productType) => $productType->id, GqlCommerceHelper::getSchemaContainedProductTypes()); + $query->whereIn(Table::PRODUCTS . '.typeId', $productTypeIds); + + return $query; + } +} diff --git a/src/Gql/Types/Elements/Product.php b/src/Gql/Types/Elements/Product.php new file mode 100644 index 0000000000..e28b565dcf --- /dev/null +++ b/src/Gql/Types/Elements/Product.php @@ -0,0 +1,35 @@ +fieldName; + return match ($fieldName) { + 'productTypeHandle' => $source->getType()->handle, + 'productTypeId' => $source->getType()->id, + default => parent::resolve($source, $arguments, $context, $resolveInfo), + }; + } +} diff --git a/src/Gql/Types/Elements/Variant.php b/src/Gql/Types/Elements/Variant.php new file mode 100644 index 0000000000..37a73e1bea --- /dev/null +++ b/src/Gql/Types/Elements/Variant.php @@ -0,0 +1,36 @@ +fieldName; + $product = $source->getOwner(); + return match ($fieldName) { + 'productTitle' => $product->title ?? '', + 'productTypeId' => $product->typeId ?? null, + default => parent::resolve($source, $arguments, $context, $resolveInfo), + }; + } +} diff --git a/src/Gql/Types/Generators/ProductType.php b/src/Gql/Types/Generators/ProductType.php new file mode 100644 index 0000000000..bac30970c0 --- /dev/null +++ b/src/Gql/Types/Generators/ProductType.php @@ -0,0 +1,54 @@ +getAllProductTypes(); + $gqlTypes = []; + + foreach ($productTypes as $productType) { + $requiredContexts = ProductElement::gqlScopesByContext($productType); + + if (!CommerceGqlHelper::isSchemaAwareOf($requiredContexts)) { + continue; + } + + $type = static::generateType($productType); + $gqlTypes[$type->name] = $type; + } + + return $gqlTypes; + } + + public static function generateType(mixed $context): ObjectType + { + $typeName = ProductElement::gqlTypeNameByContext($context); + + return GqlEntityRegistry::getOrCreate($typeName, fn() => new ProductTypeElement([ + 'name' => $typeName, + 'fields' => function() use ($context, $typeName) { + $contentFieldGqlTypes = self::getContentFields($context->getProductFieldLayout()); + $productFields = array_merge(ProductInterface::getFieldDefinitions(), $contentFieldGqlTypes); + + return Gql::prepareFieldDefinitions($productFields, $typeName); + }, + ])); + } +} diff --git a/src/Gql/Types/Generators/VariantType.php b/src/Gql/Types/Generators/VariantType.php new file mode 100644 index 0000000000..612a188a8c --- /dev/null +++ b/src/Gql/Types/Generators/VariantType.php @@ -0,0 +1,54 @@ +getAllProductTypes(); + $gqlTypes = []; + + foreach ($productTypes as $productType) { + $requiredContexts = VariantElement::gqlScopesByContext($productType); + + if (!CommerceGqlHelper::isSchemaAwareOf($requiredContexts)) { + continue; + } + + $type = static::generateType($productType); + $gqlTypes[$type->name] = $type; + } + + return $gqlTypes; + } + + public static function generateType(mixed $context): ObjectType + { + $typeName = VariantElement::gqlTypeNameByContext($context); + + return GqlEntityRegistry::getOrCreate($typeName, fn() => new Variant([ + 'name' => $typeName, + 'fields' => function() use ($context, $typeName) { + $contentFieldGqlTypes = self::getContentFields($context->getVariantFieldLayout()); + $fields = array_merge(VariantInterface::getFieldDefinitions(), $contentFieldGqlTypes); + + return Gql::prepareFieldDefinitions($fields, $typeName); + }, + ])); + } +} diff --git a/src/Gql/Types/Input/Criteria/ProductRelation.php b/src/Gql/Types/Input/Criteria/ProductRelation.php new file mode 100644 index 0000000000..8ee0942e06 --- /dev/null +++ b/src/Gql/Types/Input/Criteria/ProductRelation.php @@ -0,0 +1,30 @@ + new InputObjectType([ + 'name' => $typeName, + 'fields' => fn() => [ + ...ProductArguments::getArguments(), + ...ProductArguments::getContentArguments(), + ...RelationCriteria::getArguments(), + ], + ])); + } +} diff --git a/src/Gql/Types/Input/Criteria/VariantRelation.php b/src/Gql/Types/Input/Criteria/VariantRelation.php new file mode 100644 index 0000000000..7ace2277e8 --- /dev/null +++ b/src/Gql/Types/Input/Criteria/VariantRelation.php @@ -0,0 +1,30 @@ + new InputObjectType([ + 'name' => $typeName, + 'fields' => fn() => [ + ...VariantArguments::getArguments(), + ...VariantArguments::getContentArguments(), + ...RelationCriteria::getArguments(), + ], + ])); + } +} diff --git a/src/Gql/Types/Input/IntFalse.php b/src/Gql/Types/Input/IntFalse.php new file mode 100644 index 0000000000..fdd4bffbe1 --- /dev/null +++ b/src/Gql/Types/Input/IntFalse.php @@ -0,0 +1,74 @@ +_intType = new IntType(); + + parent::__construct($config); + } + + /** + * Returns a singleton instance to ensure one type per schema. + */ + public static function getType(): IntFalse + { + return GqlEntityRegistry::getOrCreate(static::getName(), fn() => new self()); + } + + public static function getName(): string + { + return 'IntFalse'; + } + + public function serialize($value) + { + if (is_bool($value) && $value === false) { + return false; + } + + return $this->_intType->serialize($value); + } + + public function parseValue($value): int|false + { + if (is_bool($value) && $value === false) { + return false; + } + + return $this->_intType->parseValue($value); + } + + public function parseLiteral($valueNode, ?array $variables = null) + { + if ($valueNode instanceof BooleanValueNode) { + $val = $valueNode->value; + if ($val === false) { + return false; + } + + throw new Error(); + } + + return $this->_intType->parseLiteral($valueNode, $variables); + } +} diff --git a/src/Gql/Types/Input/Product.php b/src/Gql/Types/Input/Product.php new file mode 100644 index 0000000000..d370c2cc7e --- /dev/null +++ b/src/Gql/Types/Input/Product.php @@ -0,0 +1,22 @@ + $typeName, + 'fields' => ProductArguments::getArguments(...), + ])); + } +} diff --git a/src/Gql/Types/Input/Variant.php b/src/Gql/Types/Input/Variant.php new file mode 100644 index 0000000000..d38b70fd94 --- /dev/null +++ b/src/Gql/Types/Input/Variant.php @@ -0,0 +1,22 @@ + $typeName, + 'fields' => VariantArguments::getArguments(...), + ])); + } +} diff --git a/src/Gql/Types/SaleType.php b/src/Gql/Types/SaleType.php new file mode 100644 index 0000000000..fcdf0cb12e --- /dev/null +++ b/src/Gql/Types/SaleType.php @@ -0,0 +1,78 @@ + static::getName(), + 'fields' => self::class . '::getFieldDefinitions', + 'description' => '', + ])); + } + + public static function getFieldDefinitions(): array + { + return Gql::prepareFieldDefinitions([ + 'name' => [ + 'name' => 'name', + 'type' => Type::string(), + 'description' => 'The name of the sale as described in the control panel.', + ], + 'description' => [ + 'name' => 'description', + 'type' => Type::string(), + 'description' => 'Description of the sale.', + ], + 'apply' => [ + 'name' => 'apply', + 'type' => Type::string(), + 'description' => 'How the sale should be applied.', + ], + 'applyAmount' => [ + 'name' => 'applyAmount', + 'type' => Type::float(), + 'description' => 'The amount applied used by the apply option.', + ], + 'applyAmountAsPercent' => [ + 'name' => 'applyAmountAsPercent', + 'type' => Type::string(), + 'description' => 'The amount applied used by the apply option.', + ], + 'applyAmountAsFlat' => [ + 'name' => 'applyAmountAsFlat', + 'type' => Type::float(), + 'description' => 'The amount applied used by the apply option.', + ], + 'dateFrom' => [ + 'name' => 'dateFrom', + 'type' => DateTime::getType(), + 'description' => 'Start date of the sale.', + ], + 'dateTo' => [ + 'name' => 'dateTo', + 'type' => DateTime::getType(), + 'description' => 'Start date of the sale.', + ], + ], self::getName()); + } +} diff --git a/src/Helpers/Cp.php b/src/Helpers/Cp.php new file mode 100644 index 0000000000..c119e27143 --- /dev/null +++ b/src/Helpers/Cp.php @@ -0,0 +1,34 @@ +getCurrentStore()->getCurrency(); + } + + if ($currency instanceof PaymentCurrency) { + $currency = new MoneyCurrency($currency->getAlphabeticCode()); + } + + if (is_string($currency)) { + $currency = new MoneyCurrency($currency); + } + + $moneyFormatter = new DecimalMoneyFormatter(new ISOCurrencies()); + return (float)$moneyFormatter->format(app(Currencies::class)->getTeller($currency)->convertToMoney($amount)); + } + + /** + * @throws CurrencyException + * @throws \InvalidArgumentException + */ + public static function defaultDecimals(): int + { + return app(PaymentCurrencies::class)->getPrimaryPaymentCurrency()->getSubUnit(); + } + + /** + * @throws CurrencyException + * @throws \InvalidArgumentException + */ + public static function formatAsCurrency($amount, mixed $currency = null, bool $convert = false, bool $format = true, bool $stripZeros = false): string + { + if (!$convert && !$format) { + return $amount; + } + + $currencyIso = app(Stores::class)->getCurrentStore()->getCurrency(); + + if (is_string($currency)) { + $currencyIso = $currency; + } + + if ($currency instanceof PaymentCurrency) { + $currencyIso = $currency->iso; + } + + if ($convert) { + $currency = app(PaymentCurrencies::class)->getPaymentCurrencyByIso($currencyIso); + if (!$currency) { + throw new \BadMethodCallException('Trying to convert to a currency that is not configured'); + } + } + + if ($convert && $currencyIso !== app(Stores::class)->getCurrentStore()->getCurrency()) { + $amount = app(PaymentCurrencies::class)->convert((float)$amount, $currencyIso); + } + + if ($format) { + $numberFormatter = new \NumberFormatter((string)I18N::getFormattingLocale(), \NumberFormatter::CURRENCY); + + if ($stripZeros && (int)$amount == $amount) { + $numberFormatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, 0); + $numberFormatter->setAttribute(\NumberFormatter::MIN_FRACTION_DIGITS, 0); + } + + $moneyFormatter = new IntlMoneyFormatter($numberFormatter, new ISOCurrencies()); + $money = app(Currencies::class)->getTeller($currencyIso)->convertToMoney($amount); + + return $moneyFormatter->format($money); + } + + return (string)$amount; + } + + /** + * @throws \InvalidArgumentException + * @throws TemplateLoaderException + */ + public static function moneyInputHtml(mixed $value, array $config = []): string + { + $config += [ + 'showCurrency' => true, + 'size' => 6, + 'decimals' => 2, + 'value' => $value, + ]; + + if (isset($config['currency'])) { + $config['decimals'] = app(Currencies::class)->getSubunitFor($config['currency']); + } + + return FormFields::moneyInputHtml($config); + } +} diff --git a/src/Helpers/Gql.php b/src/Helpers/Gql.php new file mode 100644 index 0000000000..3edd1d9584 --- /dev/null +++ b/src/Helpers/Gql.php @@ -0,0 +1,30 @@ +getAllProductTypes(), + fn(ProductType $productType) => static::isSchemaAwareOf("productTypes.$productType->uid", $schema), + ); + } +} diff --git a/src/Helpers/LineItem.php b/src/Helpers/LineItem.php new file mode 100644 index 0000000000..5df65d1562 --- /dev/null +++ b/src/Helpers/LineItem.php @@ -0,0 +1,19 @@ +language = $toLanguage; + $locale = I18N::getLocaleById($toLanguage); + Craft::$app->set('locale', $locale); + + if ($formattingLocale !== null) { + $locale = I18N::getLocaleById($formattingLocale); + } + + Craft::$app->set('formattingLocale', $locale); + + // The Laravel-side app locale drives CraftCms\Cms\Translation\I18N::getFormattingLocale()'s + // fallback (used by number/currency formatting outside CP requests) — the Craft::$app + // assignments above don't reach it, so it needs to be set here too. + app()->setLocale($toLanguage); + } + + public static function getSiteAndOtherLanguages(): array + { + $pdfLanguageOptions['siteLanguages']['optgroup'] = t('Site Languages', category: 'commerce'); + + $siteLanguageOptions = []; + foreach (Sites::getAllSites() as $site) { + $locale = I18N::getLocaleById($site->language); + $siteLanguageOptions[$locale->getLanguageID()] = $site->name . ' - ' . $locale->getDisplayName(); + } + + $pdfLanguageOptions = array_merge($pdfLanguageOptions, $siteLanguageOptions); + $pdfLanguageOptions['otherLanguages']['optgroup'] = t('Other Languages', category: 'commerce'); + + $allLocales = I18N::getAppLocales()->keyBy('id')->sortBy('displayName'); + + $allLocaleOptions = []; + foreach ($allLocales as $locale) { + $allLocaleOptions[$locale->id] = $locale->getDisplayName(); + } + + $otherLocaleOptions = array_diff_key($allLocaleOptions, $siteLanguageOptions); + + return array_merge($pdfLanguageOptions, $otherLocaleOptions); + } +} diff --git a/src/Helpers/Localization.php b/src/Helpers/Localization.php new file mode 100644 index 0000000000..05fc34217d --- /dev/null +++ b/src/Helpers/Localization.php @@ -0,0 +1,31 @@ +getNumberSymbol(Locale::SYMBOL_PERCENT); + $number = trim($number, "$pct \t\n\r\0\x0B"); + + if ($number === '') { + return 0.0; + } + + return (float)I18N::normalizeNumber($number) / 100; + } +} diff --git a/src/Helpers/Order.php b/src/Helpers/Order.php new file mode 100644 index 0000000000..07ab22fb76 --- /dev/null +++ b/src/Helpers/Order.php @@ -0,0 +1,93 @@ +getLineItems(); + $lineItemsByKey = []; + + foreach ($lineItems as $lineItem) { + if ($lineItem->type === LineItemType::Purchasable) { + $key = $lineItem->orderId . '-' . LineItemType::Purchasable->value . '-' . $lineItem->purchasableId . '-' . $lineItem->getOptionsSignature(); + } else { + $key = $lineItem->orderId . '-' . LineItemType::Custom->value . '-' . $lineItem->getSku() . '-' . $lineItem->getOptionsSignature(); + } + + if (!isset($lineItemsByKey[$key])) { + $lineItemsByKey[$key] = $lineItem; + continue; + } + + $lineItemsByKey[$key]->qty += $lineItem->qty; + $lineItemsByKey[$key]->note = trim(($lineItemsByKey[$key]->note ? $lineItemsByKey[$key]->note . ' - ' : '') . $lineItem->note, ' -'); + } + + $order->setLineItems(array_values($lineItemsByKey)); + + return count($lineItems) > count($lineItemsByKey); + } + + public static function normalizeLineItemPurchasableAvailability(OrderElement $order): void + { + if ($order->isCompleted) { + return; + } + + foreach ($order->getLineItems() as $lineItem) { + if ($lineItem->type !== LineItemType::Purchasable) { + continue; + } + + /** @var PurchasableInterface|null $purchasable */ + $purchasable = $lineItem->getPurchasable(); + if (!$purchasable || !app(Purchasables::class)->isPurchasableAvailable($purchasable, $order)) { + $message = t('{description} is no longer available.', ['description' => $lineItem->getDescription()], category: 'commerce'); + /** @var OrderNotice $notice */ + $notice = Craft::createObject([ + 'class' => OrderNotice::class, + 'attributes' => [ + 'message' => $message, + 'type' => 'lineItemRemoved', + 'attribute' => 'lineItems', + ], + ]); + $order->addNotice($notice); + $order->removeLineItem($lineItem); + } elseif ($purchasable instanceof Purchasable && + $purchasable::hasInventory() && + !$purchasable->getIsOutOfStockPurchasingAllowed() && + $purchasable->inventoryTracked && + ($lineItem->qty > $purchasable->getStock()) && + $purchasable->getStock() > 0 + ) { + $message = t('{description} only has {stock} in stock.', ['description' => $lineItem->getDescription(), 'stock' => $purchasable->getStock()], category: 'commerce'); + /** @var OrderNotice $notice */ + $notice = Craft::createObject([ + 'class' => OrderNotice::class, + 'attributes' => [ + 'type' => 'lineItemSalePriceChanged', + 'attribute' => "lineItems.$lineItem->id.qty", + 'message' => $message, + ], + ]); + $order->addNotice($notice); + $lineItem->qty = $purchasable->getStock(); + } + } + } +} diff --git a/src/Helpers/PaymentForm.php b/src/Helpers/PaymentForm.php new file mode 100644 index 0000000000..57d043e155 --- /dev/null +++ b/src/Helpers/PaymentForm.php @@ -0,0 +1,20 @@ +setTime((int)$now->format('H'), (int)$now->format('i'), 59); + $currentTimeDb = Query::prepareDateForDb($now); + + return match ($status) { + Product::STATUS_LIVE => [ + 'and', + [ + $tablePrefix . 'elements.enabled' => true, + $tablePrefix . 'elements_sites.enabled' => true, + ], + ['<=', 'commerce_products.postDate', $currentTimeDb], + [ + 'or', + ['commerce_products.expiryDate' => null], + ['>', 'commerce_products.expiryDate', $currentTimeDb], + ], + ], + Product::STATUS_PENDING => [ + 'and', + [ + $tablePrefix . 'elements.enabled' => true, + $tablePrefix . 'elements_sites.enabled' => true, + ], + ['>', 'commerce_products.postDate', $currentTimeDb], + ], + Product::STATUS_EXPIRED => [ + 'and', + [ + $tablePrefix . 'elements.enabled' => true, + $tablePrefix . 'elements_sites.enabled' => true, + ], + ['not', ['commerce_products.expiryDate' => null]], + ['<=', 'commerce_products.expiryDate', $currentTimeDb], + ], + Element::STATUS_ENABLED => [ + $tablePrefix . 'elements.enabled' => true, + $tablePrefix . 'elements_sites.enabled' => true, + ], + Element::STATUS_DISABLED => [ + 'or', + [$tablePrefix . 'elements.enabled' => false], + [$tablePrefix . 'elements_sites.enabled' => false], + ], + Element::STATUS_ARCHIVED => [$tablePrefix . 'elements.archived' => true], + default => false, + }; + } + + public static function cleanseQueryCriteria(array $criteria): array + { + $controllerClass = request()->route()?->getControllerClass(); + if ($controllerClass === ElementIndexController::class || $controllerClass === SearchController::class) { + $criteria = ElementHelper::cleanseQueryCriteria($criteria); + } + + return $criteria; + } +} diff --git a/src/Helpers/ProjectConfigData.php b/src/Helpers/ProjectConfigData.php new file mode 100644 index 0000000000..4e7b4311b8 --- /dev/null +++ b/src/Helpers/ProjectConfigData.php @@ -0,0 +1,149 @@ + $storeData) { + ProjectConfig::processConfigChanges(Stores::CONFIG_STORES_KEY . '.' . $uid, $force); + } + } + + public static function rebuildProjectConfig(): array + { + $output = []; + + $output[self::_getProjectConfigKey(Emails::CONFIG_EMAILS_KEY)] = self::_getEmailData(); + $output[self::_getProjectConfigKey(Pdfs::CONFIG_PDFS_KEY)] = self::_getPdfData(); + $output[self::_getProjectConfigKey(Gateways::CONFIG_GATEWAY_KEY)] = self::_rebuildGatewayProjectConfig(); + $output[self::_getProjectConfigKey(Stores::CONFIG_STORES_KEY)] = self::_getStoresData(); + $output[self::_getProjectConfigKey(Stores::CONFIG_SITESTORES_KEY)] = self::_getSiteStoresData(); + + $orderFieldLayout = Fields::getLayoutByType(OrderElement::class); + + if ($orderFieldLayoutConfig = $orderFieldLayout->getConfig()) { + $output['orders'] = [ + 'fieldLayouts' => [ + $orderFieldLayout->uid => $orderFieldLayoutConfig, + ], + ]; + } + + $output[self::_getProjectConfigKey(OrderStatuses::CONFIG_STATUSES_KEY)] = self::_getStatusData(); + $output[self::_getProjectConfigKey(LineItemStatuses::CONFIG_STATUSES_KEY)] = self::_getLineItemStatusData(); + $output[self::_getProjectConfigKey(ProductTypes::CONFIG_PRODUCTTYPES_KEY)] = self::_getProductTypeData(); + + return array_filter($output); + } + + private static function _getProjectConfigKey(string $key): string + { + return substr($key, strlen('commerce.')); + } + + private static function _rebuildGatewayProjectConfig(): array + { + $data = []; + foreach (app(Gateways::class)->getAllGateways() as $gateway) { + $data[$gateway->uid] = $gateway->getConfig(); + } + return $data; + } + + private static function _getStoresData(): array + { + $data = []; + foreach (app(Stores::class)->getAllStores() as $store) { + $data[$store->uid] = $store->getConfig(); + } + return $data; + } + + private static function _getSiteStoresData(): array + { + $data = []; + foreach (app(Stores::class)->getAllSiteStores() as $siteStore) { + $data[$siteStore->uid] = $siteStore->getConfig(); + } + return $data; + } + + private static function _getProductTypeData(): array + { + $data = []; + foreach (app(ProductTypes::class)->getAllProductTypes() as $productType) { + $data[$productType->uid] = $productType->getConfig(); + } + return $data; + } + + private static function _getEmailData(): array + { + $data = []; + app(Stores::class)->getAllStores()->each(function(Store $store) use (&$data) { + foreach (app(Emails::class)->getAllEmails($store->id) as $email) { + $data[$email->uid] = $email->getConfig(); + } + }); + return $data; + } + + private static function _getPdfData(): array + { + $data = []; + app(Stores::class)->getAllStores()->each(function(Store $store) use (&$data) { + foreach (app(Pdfs::class)->getAllPdfs($store->id) as $pdf) { + $data[$pdf->uid] = $pdf->getConfig(); + } + }); + return $data; + } + + private static function _getLineItemStatusData(): array + { + $data = []; + app(Stores::class)->getAllStores()->each(function(Store $store) use (&$data) { + foreach (app(LineItemStatuses::class)->getAllLineItemStatuses($store->id) as $status) { + $data[$status->uid] = $status->getConfig(); + } + }); + return $data; + } + + private static function _getStatusData(): array + { + $data = []; + app(Stores::class)->getAllStores()->each(function(Store $store) use (&$data) { + foreach (app(OrderStatuses::class)->getAllOrderStatuses($store->id) as $status) { + $data[$status->uid] = $status->getConfig(); + } + }); + return $data; + } +} diff --git a/src/Helpers/Purchasable.php b/src/Helpers/Purchasable.php new file mode 100644 index 0000000000..349a8eb474 --- /dev/null +++ b/src/Helpers/Purchasable.php @@ -0,0 +1,76 @@ +getCatalogPricesByPurchasableId($purchasableId, $storeId); + $catalogPricingRules = app(CatalogPricingRules::class)->getAllCatalogPricingRulesByPurchasableId($purchasableId, $storeId); + + if ($catalogPricingRules->isEmpty()) { + return ''; + } + + return template('commerce/prices/_table', [ + 'catalogPrices' => $catalogPricing, + 'showPurchasable' => false, + 'removeMargin' => true, + ], templateMode: TemplateMode::Cp); + } + + public static function skuInputHtml(?string $value = null, array $config = []): string + { + $config += [ + 'id' => 'sku', + 'name' => 'sku', + 'value' => $value, + 'placeholder' => t('Enter SKU', category: 'commerce'), + 'class' => 'code', + ]; + + return FormFields::textHtml($config); + } + + public static function availableForPurchaseInputHtml(bool $value, array $config = []): string + { + $config += [ + 'id' => 'available-for-purchase', + 'name' => 'availableForPurchase', + 'small' => true, + 'on' => $value, + ]; + + return FormFields::lightswitchFromConfig($config)->toHtml(); + } +} diff --git a/src/Helpers/Sql.php b/src/Helpers/Sql.php new file mode 100644 index 0000000000..0b4202e47e --- /dev/null +++ b/src/Helpers/Sql.php @@ -0,0 +1,37 @@ +getDriverName()) { + 'pgsql' => 'gen_random_uuid()', + // SQLite (test suite only) has no UUID builtin; assemble a v4-shaped one from random blobs. + 'sqlite' => "(lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))), 2) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6))))", + default => 'UUID()', + }; + } + + /** + * Returns a raw SQL expression for the current timestamp, for the current connection's + * database driver. + */ + public static function nowSql(): string + { + return DB::connection()->getDriverName() === 'sqlite' ? "datetime('now')" : 'NOW()'; + } +} diff --git a/src/Http/Controllers/CartController.php b/src/Http/Controllers/CartController.php new file mode 100644 index 0000000000..6cce448880 --- /dev/null +++ b/src/Http/Controllers/CartController.php @@ -0,0 +1,795 @@ +cartVariable = Plugin::getInstance()->getSettings()->cartVariable; + } + + public function getCart(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + if ($request->input('peek')) { + $cart = app(Carts::class)->peekCart(); + return $this->asSuccess(data: [ + $this->cartVariable => $cart ? $this->cartArray($cart) : null, + ]); + } + + $this->cart = $this->resolveCart($request); + + return $this->asSuccess(data: [ + $this->cartVariable => $this->cartArray($this->cart), + ]); + } + + public function updateCart(Request $request): ?Response + { + $currentUser = currentUserElement(); + + $useMutex = $request->input('number') || app(Carts::class)->getHasSessionCartNumber(); + + if ($useMutex) { + $lockOrderNumber = $request->input('number') ?: $request->cookie(app(Carts::class)->cartCookie['name']); + + if ($lockOrderNumber) { + $this->mutexLockName = "order:$lockOrderNumber"; + $this->mutex = Cache::lock($this->mutexLockName, 5); + + try { + $this->mutex->block(5); + } catch (LockTimeoutException) { + abort(500, "Unable to acquire a lock for saving of Order: $lockOrderNumber"); + } + } + } + + // Get the cart from the request or from the session. + $this->cart = $this->resolveCart($request); + + // When we are about to update the cart, we consider it a real cart at this point, and want to actually create it in the DB. + if ($this->cart->id === null) { + // Make sure we have a fully saved cart before attempting any mutations. + $this->cart = $this->resolveCart($request, true); + } + + // Can clear line items when updating the cart + $clearLineItems = $request->input('clearLineItems'); + if ($clearLineItems) { + $this->cart->setLineItems([]); + } + + // Can clear notices when updating the cart + if ($request->input('clearNotices') !== null) { + $this->cart->clearNotices(); + } + + // Set the custom fields submitted + $this->cart->setFieldValuesFromRequest('fields'); + + // Backwards compatible way of adding to the cart + if ($purchasableId = $request->input('purchasableId')) { + $note = $request->input('note', ''); + $options = $request->input('options', []); // @TODO Restrict `options` to key/value pairs only in Commerce 6.0 #COM-55 + $qty = (int)$request->input('qty', 1); + + $params = compact('qty', 'note', 'purchasableId', 'options'); + + if ($qty > 0) { + // We only want a new line item if they cleared the cart + if ($clearLineItems) { + $lineItem = app(LineItems::class)->create($this->cart, params: $params); + } else { + // we are passing everything into params but need to pass purchasableId and options for now until we refactor + $lineItem = app(LineItems::class)->resolveLineItem($this->cart, $params['purchasableId'], $params['options'], params: $params); + } + + // New line items already have a qty of one. + if ($lineItem->id) { + $lineItem->qty += $qty; + } else { + $lineItem->qty = $qty; + } + + $lineItem->note = $note; + + $this->cart->addLineItem($lineItem); + } + } + + // Add multiple items to the cart + if ($purchasables = $request->input('purchasables')) { + // Initially combine same purchasables + $purchasablesByKey = []; + foreach ($purchasables as $key => $purchasable) { + $purchasableId = $request->input("purchasables.$key.id"); + $note = $request->input("purchasables.$key.note", ''); + $options = $request->input("purchasables.$key.options", []); + $qty = (int)$request->input("purchasables.$key.qty", 1); + + $purchasable = []; + $purchasable['id'] = $purchasableId; + $purchasable['options'] = is_array($options) ? $options : []; + $purchasable['note'] = $note; + $purchasable['qty'] = $qty; + + $key = $purchasableId . '-' . LineItemHelper::generateOptionsSignature($purchasable['options']); + if (isset($purchasablesByKey[$key])) { + $purchasablesByKey[$key]['qty'] += $purchasable['qty']; + } else { + $purchasablesByKey[$key] = $purchasable; + } + } + + foreach ($purchasablesByKey as $purchasable) { + if ($purchasable['id'] == null) { + continue; + } + + // Ignore zero value qty for multi-add forms https://github.com/craftcms/commerce/issues/330#issuecomment-384533139 + if ($purchasable['qty'] > 0) { + $params = [ + 'purchasableId' => $purchasable['id'], + 'options' => $purchasable['options'], + 'note' => $purchasable['note'], + 'qty' => $purchasable['qty'], + ]; + + // We only want a new line item if they cleared the cart + if ($clearLineItems) { + $lineItem = app(LineItems::class)->create($this->cart, params: $params); + } else { + $lineItem = app(LineItems::class)->resolveLineItem($this->cart, $params['purchasableId'], $params['options'], $params); + } + + // New line items already have a qty of one. + if ($lineItem->id) { + $lineItem->qty += $purchasable['qty']; + } else { + $lineItem->qty = $purchasable['qty']; + } + + $lineItem->note = $purchasable['note']; + $this->cart->addLineItem($lineItem); + } + } + } + + // Update multiple line items in the cart + if ($lineItems = $request->input('lineItems')) { + foreach ($lineItems as $key => $lineItem) { + $lineItem = $this->getCartLineItemById((int)$key); + if ($lineItem) { + $lineItem->qty = (int)$request->input("lineItems.$key.qty", $lineItem->qty); + $lineItem->note = $request->input("lineItems.$key.note", $lineItem->note); + $lineItem->setOptions($request->input("lineItems.$key.options", $lineItem->getOptions())); + + $removeLine = $request->input("lineItems.$key.remove", false); + if ($lineItem->qty == 0 || $removeLine) { + $this->cart->removeLineItem($lineItem); + } else { + $this->cart->addLineItem($lineItem); + } + } + } + } + + $this->setAddresses($request, $currentUser); + + // Setting email only allowed for guest customers + if (!$currentUser) { + // Set guest email address onto guest customers order. + $email = $request->input('email'); + if ($email && ($this->cart->getEmail() === null || $this->cart->getEmail() != $email)) { + try { + $user = Users::ensureUserByEmail($email); + $this->cart->setCustomer($user); + if ($user->getIsCredentialed()) { + session()->put('commerce:anonymousCartWithCredentialedCustomer:' . $this->cart->number, true); + } + } catch (\Exception $e) { + $this->cart->addError('email', $e->getMessage()); + } + } + } else { + session()->forget('commerce:anonymousCartWithCredentialedCustomer:' . $this->cart->number); + } + + // Set if the customer should be registered on order completion + $registerUserOnOrderComplete = $request->input('registerUserOnOrderComplete'); + if ($registerUserOnOrderComplete !== null) { + $this->cart->registerUserOnOrderComplete = (bool)$registerUserOnOrderComplete; + } + + $saveBillingAddressOnOrderComplete = $request->input('saveBillingAddressOnOrderComplete'); + if ($saveBillingAddressOnOrderComplete !== null) { + $this->cart->saveBillingAddressOnOrderComplete = (bool)$saveBillingAddressOnOrderComplete; + } + + $saveShippingAddressOnOrderComplete = $request->input('saveShippingAddressOnOrderComplete'); + if ($saveShippingAddressOnOrderComplete !== null) { + $this->cart->saveShippingAddressOnOrderComplete = (bool)$saveShippingAddressOnOrderComplete; + } + + $saveAddressesOnOrderComplete = $request->input('saveAddressesOnOrderComplete'); + if ($saveAddressesOnOrderComplete !== null) { + $this->cart->saveBillingAddressOnOrderComplete = (bool)$saveAddressesOnOrderComplete; + $this->cart->saveShippingAddressOnOrderComplete = (bool)$saveAddressesOnOrderComplete; + } + + // Set payment currency on cart + if ($currency = $request->input('paymentCurrency')) { + $this->cart->paymentCurrency = $currency; + } + + // Set Coupon on Cart. Allow blank string to remove coupon + if (($couponCode = $request->input('couponCode')) !== null) { + $this->cart->couponCode = trim($couponCode) ?: null; + } + + // Set Payment Gateway on cart + if ($gatewayId = $request->input('gatewayId')) { + $gatewayId = (int)$gatewayId; + if (app(Gateways::class)->getGatewayById($gatewayId)) { + $this->cart->setGatewayId($gatewayId); + } + } + + // Submit payment source on cart + if (($paymentSourceId = $request->input('paymentSourceId')) !== null) { + if ($paymentSourceId && $paymentSource = app(PaymentSources::class)->getPaymentSourceById((int)$paymentSourceId)) { + // The payment source can only be used by the same user as the cart's user. + $cartCustomerId = $this->cart->getCustomer()?->id; + $paymentSourceCustomerId = $paymentSource->getCustomer()?->id; + $allowedToUsePaymentSource = ($cartCustomerId && $paymentSourceCustomerId && $currentUser && !$request->isCpRequest() && ($paymentSourceCustomerId == $cartCustomerId)); + if ($allowedToUsePaymentSource) { + $this->cart->setPaymentSource($paymentSource); + } + } else { + $this->cart->setPaymentSource(null); + } + } + + // Set Shipping method on cart. + if ($shippingMethodHandle = $request->input('shippingMethodHandle')) { + $this->cart->shippingMethodHandle = $shippingMethodHandle; + } + + return $this->returnCart($request); + } + + public function forgetCart(): Response + { + app(Carts::class)->forgetCart(); + + return $this->asSuccess(t('Cart forgotten.', category: 'commerce')); + } + + public function loadCart(Request $request): ?Response + { + $carts = app(Carts::class); + $number = $request->input('number'); + $token = $request->input('code'); + $loadCartRedirectUrl = Plugin::getInstance()->getSettings()->loadCartRedirectUrl ?? ''; + $redirect = Url::siteUrl($loadCartRedirectUrl); + + if (!$number) { + $error = t('A cart number must be specified.', category: 'commerce'); + if ($request->expectsJson()) { + return $this->asFailure($error); + } + return $request->isMethod('get') ? redirect($redirect) : null; + } + + $cart = Order::find()->number($number)->isCompleted(false)->one(); + + if (!$cart) { + $error = t('Unable to retrieve cart.', category: 'commerce'); + if ($request->expectsJson()) { + return $this->asFailure($error); + } + return $request->isMethod('get') ? redirect($redirect) : null; + } + + // Carts without email or addresses don't need token validation + $hasEmail = (bool)$cart->getEmail(); + $hasAddresses = $cart->billingAddressId || $cart->shippingAddressId; + + if ($hasEmail || $hasAddresses) { + $currentUser = currentUserElement(); + $hasValidToken = false; + + // Check token if provided + if ($token) { + $tokenData = app(RouteTokens::class)->getTokenRoute($token); + + if (!$tokenData || !isset($tokenData[1]['cartNumber']) || $tokenData[1]['cartNumber'] !== $number) { + $error = t('The cart recovery link is invalid. Please request a new one.', category: 'commerce'); + $challengeUrl = Url::actionUrl('commerce/cart/email-challenge', ['number' => $number]); + if ($request->expectsJson()) { + return $this->asFailure($error, ['challengeUrl' => $challengeUrl]); + } + return redirect($challengeUrl); + } + + $hasValidToken = true; + } + + // Check permissions if no valid token + if (!$hasValidToken) { + $challengeUrl = Url::actionUrl('commerce/cart/email-challenge', ['number' => $number]); + if ($currentUser) { + $isCartCustomer = $cart->getCustomer() && $cart->getCustomer()->id === $currentUser->id; + if (!$isCartCustomer) { + if ($request->expectsJson()) { + return $this->asFailure( + t('You do not have permission to load this cart.', category: 'commerce'), + ['challengeUrl' => $challengeUrl] + ); + } + return redirect($challengeUrl); + } + } else { + if ($request->expectsJson()) { + return $this->asFailure( + t('You must be logged in or provide a valid token to load this cart.', category: 'commerce'), + ['challengeUrl' => $challengeUrl] + ); + } + return redirect($challengeUrl); + } + } + } + + $redirect = Url::siteUrl(path: $loadCartRedirectUrl, siteId: $cart->orderSiteId); + $carts->forgetCart(); + $carts->setSessionCartNumber($number); + + // Reaching this point means the cart was loaded via a valid token or by an authorized user. + // Authorize this session to use the cart even if it belongs to a credentialed user who isn't + // (yet) logged in. If the loader is logged in, Carts::getCart() will acquire the cart to their + // account on the next retrieval. + session()->put('commerce:anonymousCartWithCredentialedCustomer:' . $number, true); + + if ($request->expectsJson()) { + return $this->asSuccess(); + } + + return $request->isMethod('get') ? redirect($redirect) : $this->redirectToPostedUrl(); + } + + public function complete(Request $request): ?Response + { + $this->cart = $this->resolveCart($request); + $errors = []; + + abort_unless($this->cart->getStore()->getAllowCheckoutWithoutPayment(), 401, t('You must make a payment to complete the order.', category: 'commerce')); + + $lock = Cache::lock('completeOrder', 10); + + try { + $lock->block(10); + } catch (LockTimeoutException) { + $this->cart->addError('isComplete', t('Unable to complete order: another request is already in progress.', category: 'commerce')); + return $this->returnCart($request); + } + + // Check email address exists on order. + if (empty($this->cart->email)) { + $errors['email'] = t('No customer email address exists on this cart.', category: 'commerce'); + } + + if ($this->cart->getStore()->getAllowEmptyCartOnCheckout() && $this->cart->getIsEmpty()) { + $errors['lineItems'] = t('Order can not be empty.', category: 'commerce'); + } + + if ($this->cart->getStore()->getRequireShippingMethodSelectionAtCheckout() && !$this->cart->shippingMethodHandle) { + $errors['shippingMethodHandle'] = t('There is no shipping method selected for this order.', category: 'commerce'); + } + + if ($this->cart->getStore()->getRequireBillingAddressAtCheckout() && !$this->cart->billingAddressId) { + $errors['billingAddressId'] = t('Billing address required.', category: 'commerce'); + } + + if ($this->cart->getStore()->getRequireShippingAddressAtCheckout() && !$this->cart->shippingAddressId) { + $errors['shippingAddressId'] = t('Shipping address required.', category: 'commerce'); + } + + // Set if the customer should be registered on order completion + if ($request->input('registerUserOnOrderComplete')) { + $this->cart->registerUserOnOrderComplete = true; + } + + if ($request->input('registerUserOnOrderComplete') === 'false') { + $this->cart->registerUserOnOrderComplete = false; + } + + if (!empty($errors)) { + $this->cart->addErrors($errors); + } + + if (empty($errors)) { + try { + $completedSuccess = $this->cart->markAsComplete(); + } catch (\Exception) { + $completedSuccess = false; + } + + if (!$completedSuccess) { + $this->cart->addError('isComplete', t('Completing order failed.', category: 'commerce')); + } + } + + $lock->release(); + + return $this->returnCart($request); + } + + public function emailChallenge(Request $request): string + { + $number = $request->query('number'); + abort_unless($number !== null, 400, 'Cart number required'); + + $cart = Order::find()->number($number)->isCompleted(false)->one(); + abort_if(!$cart || !$cart->getEmail(), 404, 'Cart not found'); + + return $this->renderCartEmailChallenge($cart, $number); + } + + public function cartChallenge(Request $request): string|Response + { + $cartNumberHash = $request->input('cartNumberHash'); + abort_unless($cartNumberHash, 400, 'Cart number hash is required'); + + try { + $cartNumber = Crypt::decrypt($cartNumberHash); + } catch (DecryptException) { + $cartNumber = false; + } + abort_if($cartNumber === false, 400, 'Invalid cart number hash'); + + $cart = Order::find()->number($cartNumber)->isCompleted(false)->one(); + abort_if(!$cart, 404, 'Cart not found'); + + $loadCartUrl = app(Carts::class)->getLoadCartUrl($cart); + + try { + $sent = Mail::to($cart->email)->send(new SystemMessageMailable( + key: 'commerce_cart_recovery', + variables: [ + 'link' => $loadCartUrl, + 'cart' => $cart, + ], + )); + } catch (Throwable) { + $sent = null; + } + + if ($sent === null) { + session()->flash('error', t('Failed to send email. Please try again.', category: 'commerce')); + return $this->renderCartEmailChallenge($cart, $cartNumber); + } + + return redirect(Url::actionUrl('commerce/cart/cart-sent', ['hash' => $cartNumberHash])); + } + + public function cartSent(Request $request): string + { + $cartNumberHash = $request->query('hash'); + abort_unless($cartNumberHash !== null, 400, 'Hash parameter required'); + + try { + $cartNumber = Crypt::decrypt($cartNumberHash); + } catch (DecryptException) { + $cartNumber = false; + } + abort_if($cartNumber === false, 400, 'Invalid hash parameter'); + + $cart = Order::find()->number($cartNumber)->isCompleted(false)->one(); + abort_if(!$cart, 404, 'Cart not found'); + + return pageTemplate('commerce/_cart/email-sent', [ + 'email' => $cart->getMaskedEmail(), + ], TemplateMode::Cp); + } + + private function getCartLineItemById(?int $lineItemId): ?LineItem + { + foreach ($this->cart->getLineItems() as $item) { + if ($item->id && $item->id == $lineItemId) { + return $item; + } + } + + return null; + } + + private function returnCart(Request $request): Response + { + $updateCartSearchIndexes = Plugin::getInstance()->getSettings()->updateCartSearchIndexes; + + // Do not clear errors, as errors could be added to the cart before returnCart is called. + if (!$this->cart->validate(null, false) || !Elements::saveElement($this->cart, false, false, $updateCartSearchIndexes)) { + $error = t('Unable to update cart.', category: 'commerce'); + $message = $request->input('failMessage') ?? $error; + + $this->mutex?->release(); + + $data = [ + $this->cartVariable => $this->cartArray($this->cart), + ]; + + $originalCart = Order::find()->id($this->cart->id)->isCompleted(null)->one(); + + if ($originalCart && $this->cart->number == $originalCart->number) { + $data['original' . ucfirst($this->cartVariable)] = $this->cartArray($originalCart); + } + + return $this->asModelFailure( + $this->cart, + $message, + 'cart', + $data, + ); + } + + $cartUpdatedMessage = t('Cart updated.', category: 'commerce'); + $message = $request->input('successMessage') ?? $cartUpdatedMessage; + + $this->mutex?->release(); + + return $this->asModelSuccess( + $this->cart, + $message, + 'cart', + [ + $this->cartVariable => $this->cartArray($this->cart), + ] + ); + } + + private function resolveCart(Request $request, bool $forceSave = false): Order + { + $orderNumber = $request->input('number'); + + if ($orderNumber) { + $cart = Order::find()->number($orderNumber)->isCompleted(false)->one(); + abort_if($cart === null, 404, 'Cart not found'); + + return $cart; + } + + $doForceSave = $forceSave || (bool)$request->input('forceSave'); + + return $this->cart = app(Carts::class)->getCart($doForceSave); + } + + private function setAddresses(Request $request, ?User $currentUser): void + { + $setShippingAddress = true; + if ($request->input('clearShippingAddress') !== null) { + $this->cart->setShippingAddress(null); + $this->cart->sourceShippingAddressId = null; + $setShippingAddress = false; + } + + $setBillingAddress = true; + if ($request->input('clearBillingAddress') !== null) { + $this->cart->setBillingAddress(null); + $this->cart->sourceBillingAddressId = null; + $setBillingAddress = false; + } + + if ($request->input('clearAddresses') !== null) { + $this->cart->setShippingAddress(null); + $this->cart->sourceShippingAddressId = null; + $this->cart->setBillingAddress(null); + $this->cart->sourceBillingAddressId = null; + $setBillingAddress = false; + $setShippingAddress = false; + } + + // Copy address options + $shippingIsBilling = $request->input('shippingAddressSameAsBilling'); + $billingIsShipping = $request->input('billingAddressSameAsShipping'); + $estimatedBillingIsShipping = $request->input('estimatedBillingAddressSameAsShipping'); + + $shippingAddress = $request->input('shippingAddress'); + $estimatedShippingAddress = $request->input('estimatedShippingAddress'); + $billingAddress = $request->input('billingAddress'); + $estimatedBillingAddress = $request->input('estimatedBillingAddress'); + + // Use an address ID from the customer address book to populate the address + $shippingAddressId = $request->input('shippingAddressId'); + $billingAddressId = $request->input('billingAddressId'); + + if ($setShippingAddress) { + // Shipping address + if ($shippingAddressId && !$shippingIsBilling) { + /** @var Address|null $userShippingAddress */ + $userShippingAddress = collect($currentUser?->getAddresses())->firstWhere('id', $shippingAddressId); + + // If a user's address ID has been submitted duplicate the address to the order + if ($userShippingAddress) { + $this->cart->sourceShippingAddressId = $shippingAddressId; + $validShippingAddress = $userShippingAddress->validate(); + + if (!$validShippingAddress) { + $this->cart->addModelErrors($userShippingAddress, 'shippingAddress'); + } else { + /** @var Address $cartShippingAddress */ + $cartShippingAddress = Elements::duplicateElement($userShippingAddress, [ + 'primaryOwner' => $this->cart, + 'owner' => $this->cart, + ]); + $this->cart->setShippingAddress($cartShippingAddress); + } + + if ($billingIsShipping) { + $this->cart->sourceBillingAddressId = $userShippingAddress->id; + + if ($validShippingAddress) { + $this->cart->setBillingAddress($cartShippingAddress); + } + } + } + } elseif ($shippingAddress && !$shippingIsBilling) { + $this->cart->sourceShippingAddressId = null; + $this->cart->setShippingAddress($shippingAddress); + + if (!empty($shippingAddress['fields']) && $this->cart->getShippingAddress()) { + $this->cart->getShippingAddress()->setFieldValues($shippingAddress['fields']); + } + + if ($billingIsShipping) { + $this->cart->sourceBillingAddressId = null; + $this->cart->setBillingAddress($this->cart->getShippingAddress()); + } + } + } + + // Billing address + if ($setBillingAddress) { + if ($billingAddressId && !$billingIsShipping) { + /** @var Address|null $userBillingAddress */ + $userBillingAddress = collect($currentUser?->getAddresses())->firstWhere('id', $billingAddressId); + + // If a user's address ID has been submitted duplicate the address to the order + if ($userBillingAddress) { + $this->cart->sourceBillingAddressId = $billingAddressId; + $validBillingAddress = $userBillingAddress->validate(); + + if (!$validBillingAddress) { + $this->cart->addModelErrors($userBillingAddress, 'billingAddress'); + } else { + /** @var Address $cartBillingAddress */ + $cartBillingAddress = Elements::duplicateElement($userBillingAddress, [ + 'primaryOwner' => $this->cart, + 'owner' => $this->cart, + ]); + $this->cart->setBillingAddress($cartBillingAddress); + } + + if ($shippingIsBilling) { + $this->cart->sourceShippingAddressId = $userBillingAddress->id; + + if ($validBillingAddress) { + $this->cart->setShippingAddress($cartBillingAddress); + } + } + } + } elseif ($billingAddress && !$billingIsShipping) { + $this->cart->sourceBillingAddressId = null; + $this->cart->setBillingAddress($billingAddress); + + if (!empty($billingAddress['fields']) && $this->cart->getBillingAddress()) { + $this->cart->getBillingAddress()->setFieldValues($billingAddress['fields']); + } + + if ($shippingIsBilling) { + $this->cart->sourceShippingAddressId = null; + $this->cart->setShippingAddress($this->cart->getBillingAddress()); + } + } + } + + // Estimated Shipping Address + if ($estimatedShippingAddress) { + if ($this->cart->estimatedShippingAddressId) { + if ($address = Address::findOne($this->cart->estimatedShippingAddressId)) { + $address->setAttributes($estimatedShippingAddress); + $estimatedShippingAddress = $address; + } + } + + $this->cart->setEstimatedShippingAddress($estimatedShippingAddress); + } + + // Estimated Billing Address + if ($estimatedBillingAddress) { + if ($this->cart->estimatedBillingAddressId) { + if ($address = Address::findOne($this->cart->estimatedBillingAddressId)) { + $address->setAttributes($estimatedBillingAddress); + $estimatedBillingAddress = $address; + } + } + + $this->cart->setEstimatedBillingAddress($estimatedBillingAddress); + } + + $this->cart->billingSameAsShipping = (bool)$billingIsShipping; + $this->cart->shippingSameAsBilling = (bool)$shippingIsBilling; + $this->cart->estimatedBillingSameAsShipping = (bool)$estimatedBillingIsShipping; + + // Set primary addresses + if ($setShippingAddress) { + $makePrimaryShippingAddress = $request->input('makePrimaryShippingAddress'); + if ($makePrimaryShippingAddress !== null) { + $this->cart->makePrimaryShippingAddress = (bool)$makePrimaryShippingAddress; + } + } + if ($setBillingAddress) { + $makePrimaryBillingAddress = $request->input('makePrimaryBillingAddress'); + if ($makePrimaryBillingAddress !== null) { + $this->cart->makePrimaryBillingAddress = (bool)$makePrimaryBillingAddress; + } + } + } + + private function renderCartEmailChallenge(Order $cart, string $cartNumber): string + { + return pageTemplate('commerce/_cart/email-challenge', [ + 'cart' => $cart, + 'cartNumber' => $cartNumber, + ], TemplateMode::Cp); + } +} diff --git a/src/Http/Controllers/Concerns/HasCartArray.php b/src/Http/Controllers/Concerns/HasCartArray.php new file mode 100644 index 0000000000..126eecfc02 --- /dev/null +++ b/src/Http/Controllers/Concerns/HasCartArray.php @@ -0,0 +1,33 @@ +toArray([], $extraFields); + + $event = new ModifyCartInfoEvent( + cartInfo: $cartInfo, + cart: $cart, + ); + + event($event); + + return $event->cartInfo; + } +} diff --git a/src/Http/Controllers/Concerns/HasStoreManagementScreen.php b/src/Http/Controllers/Concerns/HasStoreManagementScreen.php new file mode 100644 index 0000000000..5f27c67bea --- /dev/null +++ b/src/Http/Controllers/Concerns/HasStoreManagementScreen.php @@ -0,0 +1,205 @@ +getStoreByHandle($storeHandle)) { + $store = app(Stores::class)->getPrimaryStore(); + } + + return $store; + } + + protected function storeManagementCpScreen(?string $storeHandle, bool $isIndex = true, bool $hasStoreSwitcher = true): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + $storeHandle = $store->handle; + + $screen = new CpScreenResponse(); + + $screen->crumbs(array_filter([ + ['label' => t('Commerce', category: 'commerce'), 'url' => 'commerce'], + $hasStoreSwitcher ? $this->getStoreSwitcher($storeHandle) : null, + ])); + + if ($isIndex) { + // Most index pages need the admin table asset bundle + \Craft::$app->getView()->registerAssetBundle(AdminTableAsset::class); + + $segments = request()->segments(); + $selectedItem = count($segments) >= 4 ? $segments[3] : 'general'; + + $screen->pageSidebarTemplate('commerce/_includes/_storeManagementNav', [ + 'storeSettingsNav' => $this->getStoreSettingsNav(), + 'store' => $store, + 'selectedItem' => $selectedItem, + ]); + } + + $screen->title(t('Store Management', category: 'commerce')); + $screen->selectedSubnavItem('store-management'); + + return $screen; + } + + protected function getStoreSwitcher(?string $storeHandle = null): array + { + $stores = app(Stores::class)->getAllStores(); + + $store = $storeHandle ? app(Stores::class)->getStoreByHandle($storeHandle) : null; + + $storeItems = $stores->filter(function(Store $s) { + foreach ($s->getSites() as $site) { + if (currentUser()?->can('editSite:' . $site->uid)) { + return true; + } + } + + return false; + })->map(function(Store $s) use ($storeHandle) { + $segments = request()->segments(); + $storeSubSection = count($segments) >= 4 ? $segments[3] : null; + + return [ + 'status' => null, + 'label' => t($s->getName(), category: 'site'), + 'url' => 'commerce/store-management/' . $s->handle . ($storeSubSection ? '/' . $storeSubSection : ''), + 'selected' => $storeHandle === $s->handle, + 'attributes' => [ + 'data' => [ + 'store-handle' => $s->handle, + ], + ], + ]; + })->all(); + + return [ + 'id' => 'site-crumb', + 'iconAltText' => t('Store', category: 'commerce'), + 'icon' => 'store', + 'label' => $store?->getName() ?? t('Store Management', category: 'commerce'), + 'menu' => [ + 'label' => t('Select site'), + 'items' => $storeItems, + ], + ]; + } + + protected function getStoreSettingsNav(): array + { + $storeSettingsNav = []; + + $storeSettingsNav['general'] = [ + 'label' => t('General', category: 'commerce'), + 'path' => '', + 'disabled' => !currentUser()?->can('commerce-manageGeneralStoreSettings'), + ]; + + $storeSettingsNav['payment-currencies'] = [ + 'label' => t('Payment Currencies', category: 'commerce'), + 'path' => 'payment-currencies', + 'disabled' => !currentUser()?->can('commerce-managePaymentCurrencies'), + ]; + + $managePromotions = (bool)currentUser()?->can('commerce-managePromotions'); + $storeSettingsNav['pricing-heading'] = [ + 'heading' => t('Pricing', category: 'commerce'), + ]; + + $storeSettingsNav['discounts'] = [ + 'label' => t('Discounts', category: 'commerce'), + 'path' => 'discounts', + 'disabled' => !$managePromotions, + ]; + + if (app(CatalogPricingRules::class)->canUseCatalogPricingRules()) { + $storeSettingsNav['pricing-rules'] = [ + 'label' => t('Pricing Rules', category: 'commerce'), + 'path' => 'pricing-rules', + 'disabled' => !$managePromotions, + ]; + } else { + $storeSettingsNav['sales'] = [ + 'label' => t('Sales', category: 'commerce'), + 'path' => 'sales', + 'disabled' => !$managePromotions, + ]; + } + + $storeSettingsNav['shipping-header'] = [ + 'heading' => t('Shipping', category: 'commerce'), + ]; + + $manageShipping = (bool)currentUser()?->can('commerce-manageShipping'); + $storeSettingsNav['shippingmethods'] = [ + 'label' => t('Shipping Methods', category: 'commerce'), + 'path' => 'shippingmethods', + 'disabled' => !$manageShipping, + ]; + + $storeSettingsNav['shippingzones'] = [ + 'label' => t('Shipping Zones', category: 'commerce'), + 'path' => 'shippingzones', + 'disabled' => !$manageShipping, + ]; + + $storeSettingsNav['shippingcategories'] = [ + 'label' => t('Shipping Categories', category: 'commerce'), + 'path' => 'shippingcategories', + 'disabled' => !$manageShipping, + ]; + + $storeSettingsNav['tax'] = [ + 'heading' => t('Tax', category: 'commerce'), + ]; + + $manageTaxes = (bool)currentUser()?->can('commerce-manageTaxes'); + if (app(Taxes::class)->viewTaxRates()) { + $storeSettingsNav['taxrates'] = [ + 'label' => t('Tax Rates', category: 'commerce'), + 'path' => 'taxrates', + 'disabled' => !$manageTaxes, + ]; + } + + if (app(Taxes::class)->viewTaxZones()) { + $storeSettingsNav['taxzones'] = [ + 'label' => t('Tax Zones', category: 'commerce'), + 'path' => 'taxzones', + 'disabled' => !$manageTaxes, + ]; + } + + if (app(Taxes::class)->viewTaxCategories()) { + $storeSettingsNav['taxcategories'] = [ + 'label' => t('Tax Categories', category: 'commerce'), + 'path' => 'taxcategories', + 'disabled' => !$manageTaxes, + ]; + } + + return $storeSettingsNav; + } +} diff --git a/src/Http/Controllers/DonationsController.php b/src/Http/Controllers/DonationsController.php new file mode 100644 index 0000000000..6152fbac36 --- /dev/null +++ b/src/Http/Controllers/DonationsController.php @@ -0,0 +1,68 @@ +status(null)->one(); + + if ($donation === null) { + $primaryStore = app(Stores::class)->getPrimaryStore(); + $donation = new Donation(); + $donation->siteId = Sites::getPrimarySite()->id; + $donation->sku = 'DONATION-CC5'; + $donation->availableForPurchase = false; + $donation->taxCategoryId = app(TaxCategories::class)->getDefaultTaxCategory()->id; + $donation->shippingCategoryId = app(ShippingCategories::class)->getDefaultShippingCategory($primaryStore->id)->id; + Elements::saveElement($donation); + } + + return new CpScreenResponse() + ->title(t('Donation Settings', category: 'commerce')) + ->addCrumb(t('Commerce', category: 'commerce'), 'commerce') + ->selectedSubnavItem('donations') + ->action('commerce/donations/save') + ->submitButtonLabel(t('Save')) + ->redirectUrl('commerce/donations') + ->contentTemplate('commerce/donation/_edit.twig', ['donation' => $donation]); + } + + public function save(Request $request): Response + { + $donation = Donation::find()->status(null)->one(); + + if ($donation === null) { + $donation = new Donation(); + $donation->siteId = Sites::getPrimarySite()->id; + } + + $donation->sku = $request->input('sku'); + $donation->availableForPurchase = (bool)$request->input('availableForPurchase'); + $donation->enabled = (bool)$request->input('enabled'); + + if (!Elements::saveElement($donation)) { + return $this->asModelFailure($donation, t('Couldn\'t save donation settings.', category: 'commerce'), 'donation'); + } + + return $this->asSuccess(t('Donation settings saved.', category: 'commerce'), redirect: 'commerce/donations'); + } +} diff --git a/src/Http/Controllers/DownloadsController.php b/src/Http/Controllers/DownloadsController.php new file mode 100644 index 0000000000..120e909339 --- /dev/null +++ b/src/Http/Controllers/DownloadsController.php @@ -0,0 +1,205 @@ +query('number'); + $pdfHandle = $request->query('pdfHandle'); + $option = $request->query('option', ''); + $inline = (bool)$request->query('inline', false); + $token = $request->query('code') ?? $request->query('token'); + + abort_unless($number !== null, 400, 'Order number required'); + + $order = app(Orders::class)->getOrderByNumber($number); + abort_if(!$order || !$order->getEmail(), 404, 'Order not found'); + + $currentUser = currentUserElement(); + $hasValidToken = false; + + if ($token) { + $tokenData = app(RouteTokens::class)->getTokenRoute($token); + + if (!$tokenData || !isset($tokenData[1]['orderNumber']) || $tokenData[1]['orderNumber'] !== $number) { + session()->flash('error', t('The download link has expired. Please request a new one.', category: 'commerce')); + return redirect(Url::actionUrl('commerce/downloads/email-challenge', [ + 'number' => $number, + 'pdfHandle' => $pdfHandle, + 'option' => $option, + 'inline' => $inline, + ])); + } + + $hasValidToken = true; + } + + if (!$hasValidToken) { + $challengeUrl = Url::actionUrl('commerce/downloads/email-challenge', [ + 'number' => $number, + 'pdfHandle' => $pdfHandle, + 'option' => $option, + 'inline' => $inline, + ]); + + if ($currentUser) { + $isOrderCustomer = $order->getCustomer() && $order->getCustomer()->id === $currentUser->id; + $hasPermission = $currentUser->admin || $order->canView($currentUser); + + if (!($isOrderCustomer || $hasPermission)) { + return redirect($challengeUrl); + } + } else { + return redirect($challengeUrl); + } + } + + if ($pdfHandle) { + $pdf = app(Pdfs::class)->getPdfByHandle($pdfHandle, $order->storeId); + abort_if(!$pdf, 500, 'Can not find the PDF to render based on the handle supplied.'); + } else { + $pdf = app(Pdfs::class)->getDefaultPdf($order->storeId); + } + + abort_if(!$pdf, 500, 'Can not find a PDF to render.'); + + $originalLanguage = \Craft::$app->language; + $originalFormattingLocale = \Craft::$app->formattingLocale; + + $language = $pdf->getRenderLanguage($order); + Locale::switchAppLanguage($language); + + $renderedPdf = app(Pdfs::class)->renderPdfForOrder($order, $option, null, [], $pdf); + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLocale->id); + + $fileName = renderSandboxedObjectTemplate((string)$pdf->fileNameFormat, $order) ?: ($pdf->handle . '-' . $order->number); + + $disposition = ($inline ? 'inline' : 'attachment') . '; filename="' . str_replace('"', '', $fileName . '.pdf') . '"'; + + return response($renderedPdf, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => $disposition, + ]); + } + + public function emailChallenge(Request $request): string + { + $number = $request->query('number'); + abort_unless($number !== null, 400, 'Order number required'); + + $order = app(Orders::class)->getOrderByNumber($number); + abort_if(!$order || !$order->getEmail(), 404, 'Order not found'); + + return $this->renderEmailChallenge( + $order, + $number, + $request->query('pdfHandle'), + $request->query('option', ''), + (bool)$request->query('inline', false), + ); + } + + public function pdfChallenge(Request $request): string|Response + { + $orderNumberHash = $request->input('orderNumberHash'); + $pdfHandle = $request->input('pdfHandle'); + $option = $request->input('option', ''); + $inline = (bool)$request->input('inline', false); + + abort_unless($orderNumberHash, 400, 'Order number hash is required'); + + try { + $orderNumber = Crypt::decrypt($orderNumberHash); + } catch (DecryptException) { + $orderNumber = false; + } + abort_if($orderNumber === false, 400, 'Invalid order number hash'); + + $order = app(Orders::class)->getOrderByNumber($orderNumber); + abort_if(!$order, 404, 'Order not found'); + + $downloadUrl = app(Pdfs::class)->getPdfUrl($order, $option, $pdfHandle, $inline); + + try { + $sent = Mail::to($order->email)->send(new SystemMessageMailable( + key: 'commerce_pdf_download', + variables: [ + 'link' => $downloadUrl, + 'order' => $order, + ], + )); + } catch (Throwable) { + $sent = null; + } + + if ($sent === null) { + session()->flash('error', t('Failed to send email. Please try again.', category: 'commerce')); + return $this->renderEmailChallenge($order, $orderNumber, $pdfHandle, $option, $inline); + } + + session()->flash('notice', t('A new download link has been sent to {email}', ['email' => $order->getMaskedEmail()], category: 'commerce')); + + return redirect(Url::actionUrl('commerce/downloads/pdf-sent', ['hash' => $orderNumberHash])); + } + + public function pdfSent(Request $request): string + { + $orderNumberHash = $request->query('hash'); + abort_unless($orderNumberHash !== null, 400, 'Hash parameter required'); + + try { + $orderNumber = Crypt::decrypt($orderNumberHash); + } catch (DecryptException) { + $orderNumber = false; + } + abort_if($orderNumber === false, 400, 'Invalid hash parameter'); + + $order = app(Orders::class)->getOrderByNumber($orderNumber); + abort_if(!$order, 404, 'Order not found'); + + return pageTemplate('commerce/_downloads/email-sent', [ + 'email' => $order->getMaskedEmail(), + ], TemplateMode::Cp); + } + + private function renderEmailChallenge( + Order $order, + string $orderNumber, + ?string $pdfHandle, + string $option, + bool $inline, + ): string { + return pageTemplate('commerce/_downloads/email-challenge', [ + 'order' => $order, + 'orderNumber' => $orderNumber, + 'pdfHandle' => $pdfHandle, + 'option' => $option, + 'inline' => $inline, + ], TemplateMode::Cp); + } +} diff --git a/src/Http/Controllers/EmailPreviewController.php b/src/Http/Controllers/EmailPreviewController.php new file mode 100644 index 0000000000..ea62b9255a --- /dev/null +++ b/src/Http/Controllers/EmailPreviewController.php @@ -0,0 +1,65 @@ +input('email'); + $emailId = (int)preg_split('/\s*:\s*/', $email, -1, PREG_SPLIT_NO_EMPTY)[0]; + $storeId = (int)preg_split('/\s*:\s*/', $email, -1, PREG_SPLIT_NO_EMPTY)[1]; + $email = app(Emails::class)->getEmailById($emailId, $storeId); + + $orderNumber = $request->input('number'); + + if ($orderNumber) { + $order = Order::find()->shortNumber(substr((string)$orderNumber, 0, 7))->one(); + } else { + $orderQuery = Order::find()->isCompleted(true); + + if (DB::connection()->getDriverName() === 'pgsql') { + $orderQuery->orderByRaw('RANDOM()'); + } else { + $orderQuery->orderByRaw('RAND()'); + } + + $order = $orderQuery->one(); + } + + $order ??= new Order(); + + if ($email && $templatePath = $email->templatePath) { + $emailLanguage = $email->getRenderLanguage($order); + + Locale::switchAppLanguage($emailLanguage); + + $orderHistory = Arr::first($order->getHistories()) ?: new OrderHistory(); + $orderData = $order->toArray(); + $option = 'email'; + + return template($templatePath, compact('order', 'orderHistory', 'option', 'orderData'), TemplateMode::Site); + } + + $errors = []; + if (!$email) { + $errors[] = t('Could not find the email or template.', category: 'commerce'); + } + + return template('commerce/settings/emails/_previewError', compact('errors'), TemplateMode::Cp); + } +} diff --git a/src/Http/Controllers/FormulasController.php b/src/Http/Controllers/FormulasController.php new file mode 100644 index 0000000000..b95acb2ebb --- /dev/null +++ b/src/Http/Controllers/FormulasController.php @@ -0,0 +1,53 @@ +expectsJson(), 400); + + $condition = $request->input('condition'); + $params = $request->input('params'); + + if ($condition == '') { + return $this->asSuccess(); + } + + if (!app(Formulas::class)->validateConditionSyntax($condition, $params)) { + return $this->asFailure(t('Invalid condition syntax', category: 'commerce')); + } + + return $this->asSuccess(); + } + + public function validateFormula(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $formula = $request->input('formula'); + $params = $request->input('params'); + + if ($formula == '') { + return $this->asSuccess(); + } + + if (!app(Formulas::class)->validateFormulaSyntax($formula, $params)) { + return $this->asFailure(t('Invalid formula syntax', category: 'commerce')); + } + + return $this->asSuccess(); + } +} diff --git a/src/Http/Controllers/InventoryController.php b/src/Http/Controllers/InventoryController.php new file mode 100644 index 0000000000..a3bf93e397 --- /dev/null +++ b/src/Http/Controllers/InventoryController.php @@ -0,0 +1,668 @@ +getView()->registerAssetBundle(HtmxAsset::class); + + abort_if($inventoryItemId === null, 404, 'Inventory Item not found'); + + $inventoryItem = app(Inventory::class)->getInventoryItemById($inventoryItemId); + + return new CpScreenResponse() + ->title('Inventory Item') + ->action('commerce/inventory/item-save') + ->submitButtonLabel(t('Save')) + ->redirectUrl('commerce/inventory') + ->contentTemplate('commerce/inventory/item/_edit.twig', ['inventoryItem' => $inventoryItem]) + ->addCrumb(t('Inventory', category: 'commerce'), 'commerce/inventory') + ->tabs([ + 'details' => [ + 'label' => t('Details', category: 'commerce'), + 'url' => '#details', + ], + 'history' => [ + 'label' => t('History', category: 'commerce'), + 'url' => '#history', + ], + ]) + ->prepareScreen(function($screen, string $containerId) { + HtmlStack::js('htmx.process(document.getElementById("' . $containerId . '"));'); + }); + } + + public function itemSave(Request $request): Response + { + $inventoryItemId = $request->input('inventoryItemId'); + abort_if(!$inventoryItemId, 404); + + $inventoryItem = app(Inventory::class)->getInventoryItemById((int)$inventoryItemId); + + $inventoryItem->countryCodeOfOrigin = $request->input('countryCodeOfOrigin', $inventoryItem->countryCodeOfOrigin); + $inventoryItem->administrativeAreaCodeOfOrigin = $request->input('administrativeAreaCodeOfOrigin', $inventoryItem->administrativeAreaCodeOfOrigin); + $inventoryItem->harmonizedSystemCode = $request->input('harmonizedSystemCode', $inventoryItem->harmonizedSystemCode); + + $success = app(Inventory::class)->saveInventoryItem($inventoryItem); + + if (!$success) { + return $this->asModelFailure($inventoryItem, t('Couldn\'t save inventory item.'), 'inventoryItem'); + } + + return $this->asModelSuccess($inventoryItem, t('Inventory Item saved.'), 'inventoryItem'); + } + + public function editLocationLevels(Request $request, ?string $inventoryLocationHandle = null): Response|CpScreenResponse + { + \Craft::$app->getView()->registerAssetBundle(InventoryAsset::class); + + $inventoryItemId = $request->query('inventoryItemId'); // Used for quick link to manage stock + $inventoryLocations = app(InventoryLocations::class)->getAllInventoryLocations(); + + if (!$inventoryLocationHandle) { + $inventoryLocationHandle = $request->input('inventoryLocationHandle'); + + if (!$inventoryLocationHandle) { + return redirect($inventoryLocations[0]->getCpManageInventoryUrl()); + } + } + + $search = $request->query('search'); + + $currentLocation = app(InventoryLocations::class)->getInventoryLocationByHandle($inventoryLocationHandle); + $selectedItem = 'manage-' . $currentLocation->handle; + $title = $currentLocation->getUiLabel() . ' ' . t('Inventory', category: 'commerce'); + + $locationMenuItems = []; + + foreach ($inventoryLocations as $location) { + $locationMenuItems[] = [ + 'label' => $location->getUiLabel(), + 'url' => $location->getCpManageInventoryUrl(), + 'selected' => $location->handle === $inventoryLocationHandle, + ]; + } + $crumbs = [ + [ + 'label' => t('Inventory', category: 'commerce'), + 'url' => 'commerce/inventory', + ], + ]; + + if (count($locationMenuItems) > 1) { + $crumbs[] = [ + 'icon' => 'warehouse', + 'menu' => [ + 'label' => t('Select section'), + 'items' => $locationMenuItems, + ], + ]; + } else { + $crumbs[] = [ + 'label' => $currentLocation->getUiLabel(), + 'url' => $currentLocation->getCpManageInventoryUrl(), + ]; + } + + return new CpScreenResponse() + ->title($title) + ->site(Cp::requestedSite()) + ->selectableSites(Sites::getEditableSites()->all()) + ->action(null) + ->crumbs($crumbs) + ->contentTemplate('commerce/inventory/levels/_index', compact( + 'inventoryLocations', + 'currentLocation', + 'inventoryItemId', + 'selectedItem', + 'search', + )) + ->selectedSubnavItem('inventory'); + } + + public function inventoryLevelsTableData(Request $request): Response + { + $currentUser = currentUserElement(); + $inventoryLevelsManagerContainerId = $request->input('containerId'); + abort_if(!$inventoryLevelsManagerContainerId, 400, 'Missing containerId'); + + $inventoryItemId = $request->input('inventoryItemId'); // Used for quick link to manage stock + $page = (int)$request->input('page', 1); + $limit = (int)$request->input('per_page', 15); + $offset = ($page - 1) * $limit; + $inventoryLocationId = (int)$request->input('inventoryLocationId'); + $search = $request->input('search'); + + $inventoryQuery = app(Inventory::class)->getInventoryLevelQuery(limit: $limit, offset: $offset, inventoryLocationId: $inventoryLocationId) + ->where('inventoryLocationId', $inventoryLocationId); + + if ($inventoryItemId) { + $inventoryQuery->where('inventoryItemId', $inventoryItemId); + } + + $inventoryQuery->addSelect(['purchasables.description', 'purchasables.sku']); + $inventoryQuery->leftJoin(Table::PURCHASABLES . ' as purchasables', 'ii.purchasableId', '=', 'purchasables.id'); + $inventoryQuery->groupBy('purchasables.description', 'purchasables.sku'); + + $inventoryQuery->whereNotNull('elements.id'); + + if ($search) { + $likeOperator = DB::connection()->getDriverName() === 'pgsql' ? 'ilike' : 'like'; + $inventoryQuery->where(function($q) use ($likeOperator, $search) { + $q->where('purchasables.description', $likeOperator, "%$search%") + ->orWhere('purchasables.sku', $likeOperator, "%$search%"); + }); + } + + $sort = $request->input('sort'); + if ($sort) { + $field = $sort[0]['sortField']; + $direction = $sort[0]['direction']; + + // Validate the sorting inputs + if ( + !in_array($direction, ['asc', 'desc']) || + !in_array($field, [ + 'item', + 'sku', + 'reservedTotal', + 'damagedTotal', + 'safetyTotal', + 'qualityControlTotal', + 'committedTotal', + 'availableTotal', + 'onHandTotal', + 'incomingTotal', + ]) + ) { + $field = null; + $direction = null; + } + + if ($field && $direction) { + if ($field == 'sku') { + $field = 'purchasables.sku'; + } + + if ($field == 'item') { + $field = 'purchasables.description'; + } + $inventoryQuery->orderBy($field, $direction); + } + } + + $inventoryTableData = $inventoryQuery->get(); + + $total = $inventoryQuery->getCountForPagination(); + + // Batch-load all purchasables for this page in one query per element type, + // rather than one getElementById call per row. + $requestedSite = Cp::requestedSite(); + $purchasableIds = $inventoryTableData->pluck('purchasableId')->filter()->unique()->all(); + $purchasablesMap = []; + if ($purchasableIds) { + $elementTypes = new Query() + ->select(['id', 'type']) + ->from(CraftTable::ELEMENTS) + ->where(['id' => $purchasableIds]) + ->pairs(); + $byType = []; + foreach ($elementTypes as $id => $type) { + /** @var class-string<\craft\base\Element> $type */ + $byType[$type][] = $id; + } + foreach ($byType as $type => $ids) { + foreach ($type::find()->id($ids)->siteId($requestedSite->id)->all() as $element) { + $purchasablesMap[$element->id] = $element; + } + } + } + + $time = microtime(true); + foreach ($inventoryTableData as $key => &$inventoryLevel) { + $id = $inventoryLevel['inventoryItemId']; + /** @var ?Purchasable $purchasable */ + $purchasable = $purchasablesMap[$inventoryLevel['purchasableId']] ?? null; + $inventoryItemDomId = sprintf("edit-$id-link-%s", mt_rand()); + if ($purchasable) { + // When providing the `labelHtml` option we need to encode it ourselves + $inventoryLevel['purchasable'] = Cp::chipHtml($purchasable, ['labelHtml' => Html::encode($purchasable->getDescription()), 'showActionMenu' => !$purchasable->getIsDraft() && $purchasable->canSave($currentUser)]); + } else { + $inventoryLevel['purchasable'] = Html::encode($inventoryLevel['description']); + } + if (PurchasableHelper::isTempSku($inventoryLevel['sku'])) { + $inventoryLevel['sku'] = ''; + } + + // Ensure encoded SKU + $inventoryLevel['sku'] = Html::tag('span', Html::a(Html::encode($inventoryLevel['sku']), "#", ['id' => "$inventoryItemDomId", 'class' => 'code'])); + $inventoryLevel['id'] = $id; + + HtmlStack::jsWithVars(fn($id, $params, $inventoryLevelsManagerContainerId) => << { + e.preventDefault(); + const slideout = new Craft.CpScreenSlideout('commerce/inventory/item-edit', $params); + slideout.on('close', (e) => { + $($inventoryLevelsManagerContainerId).data('inventoryLevelsManager').adminTable.reload(); + }); +}); +JS, [ + $inventoryItemDomId, + ['params' => ['inventoryItemId' => $id]], + $inventoryLevelsManagerContainerId, + ]); + + // @TODO Reduce the number of per-row modal click listeners registered here for inventory level columns + $columnTypes = [...InventoryTransactionType::values(), 'onHand']; + $columnTypes = array_filter($columnTypes, fn($type) => $type !== 'fulfilled'); + foreach ($columnTypes as $type) { + $items = []; + $id = $inventoryLevel['id']; + + $showOrderLinks = ( + $type == InventoryTransactionType::COMMITTED->value && + $inventoryLevel['committedTotal'] > 0 + ); + + if ($showOrderLinks) { + $showOrderLinksId = sprintf("$type-show-$id-order-links-%s", mt_rand()); + $items['orderLinks'] = [ + 'type' => MenuItemType::Button, + 'id' => $showOrderLinksId, + 'label' => t('See Orders', category: 'commerce'), + 'icon' => 'cart-shopping', + ]; + + HtmlStack::jsWithVars(fn($id, $params, $inventoryLevelsManagerContainerId) => << { + e.preventDefault(); + let modal = new Craft.CpModal('commerce/inventory/unfulfilled-orders', { + containerElement: 'div', + showSubmitButton: false, + params: $params + }) + modal.on('close', (e) => { + $($inventoryLevelsManagerContainerId).data('inventoryLevelsManager').adminTable.reload(); + }); +}); +JS, [ + $showOrderLinksId, + [ + 'inventoryItemId' => $inventoryLevel['inventoryItemId'], + 'inventoryLocationId' => $inventoryLevel['inventoryLocationId'], + ], + $inventoryLevelsManagerContainerId, + ]); + } + + $showSet = ( + $type == 'onHand' || + in_array(InventoryTransactionType::from($type), InventoryTransactionType::allowedManualAdjustmentTypes()) + ); + + if ($showSet) { + $setId = sprintf("$type-update-level-$id-set-%s", mt_rand()); + $items['set'] = [ + 'type' => MenuItemType::Button, + 'id' => $setId, + 'label' => t('Set Quantity', category: 'commerce'), + 'icon' => 'bullseye', + ]; + + HtmlStack::jsWithVars(fn($id, $params, $inventoryLevelsManagerContainerId) => << { + e.preventDefault(); + let modal = new Craft.Commerce.UpdateInventoryLevelModal({ + params: $params, + showHeader: true + }) + modal.on('submit', (e) => { + $($inventoryLevelsManagerContainerId).data('inventoryLevelsManager').adminTable.reload(); + }); +}); +JS, [ + $setId, + [ + 'ids' => [$inventoryLevel['inventoryItemId']], + 'inventoryLocationId' => $inventoryLevel['inventoryLocationId'], + 'updateAction' => InventoryUpdateQuantityType::SET->value, + 'type' => $type, + ], + $inventoryLevelsManagerContainerId, + ]); + } + + // Leave as it until we add more conditions for showing an adjustment + $showAdjust = $showSet; + + if ($showAdjust) { + $adjustId = sprintf("$type-update-level-$id-adjust-%s", mt_rand()); + $items['adjust'] = [ + 'type' => MenuItemType::Button, + 'id' => $adjustId, + 'icon' => 'arrow-trend-up', + 'label' => t('Adjust Quantity', category: 'commerce'), + ]; + + HtmlStack::jsWithVars(fn($id, $params, $inventoryLevelsManagerContainerId) => << { + e.preventDefault(); + let modal = new Craft.Commerce.UpdateInventoryLevelModal({ + params: $params, + showHeader: true + }) + modal.on('submit', (e) => { + $($inventoryLevelsManagerContainerId).data('inventoryLevelsManager').adminTable.reload(); + }); +}); +JS, [ + $adjustId, + [ + 'ids' => [$inventoryLevel['inventoryItemId']], + 'inventoryLocationId' => $inventoryLevel['inventoryLocationId'], + 'updateAction' => InventoryUpdateQuantityType::ADJUST->value, + 'type' => $type, + ], + $inventoryLevelsManagerContainerId, + ]); + } + + $showMovement = ( + $type !== 'onHand' && + in_array(InventoryTransactionType::from($type), InventoryTransactionType::allowedManualMoveTransactionTypes()) && + $inventoryLevel[$type . 'Total'] > 0); + + if ($showMovement) { + $movementId = sprintf("$type-inventory-movement-$id-%s", mt_rand()); + $items['movement'] = [ + 'type' => MenuItemType::Button, + 'id' => $movementId, + 'icon' => 'arrow-right', + 'label' => t('Move Inventory', category: 'commerce'), + ]; + + HtmlStack::jsWithVars(fn($id, $params, $inventoryLevelsManagerContainerId) => << { + e.preventDefault(); + let modal = new Craft.Commerce.InventoryMovementModal({ + params: $params, + showHeader: true + }) + modal.on('submit', (e) => { + console.log(e); + $($inventoryLevelsManagerContainerId).data('inventoryLevelsManager').adminTable.reload(); + }); +}); +JS, [ + $movementId, + [ + 'inventoryMovement' => [ + 'note' => '', + 'fromInventoryTransactionType' => $type, + 'quantity' => '0', + 'inventoryItemId' => $inventoryLevel['inventoryItemId'], + 'fromInventoryLocationId' => $inventoryLevel['inventoryLocationId'], + ], + ], + $inventoryLevelsManagerContainerId, + ]); + } + + $config = [ + 'class' => '', + 'hiddenLabel' => t('Actions'), + 'buttonAttributes' => [ + 'class' => ['action-btn'], + 'data' => [ + 'icon' => 'ellipsis', + 'inventoryItemId' => $inventoryLevel['inventoryItemId'], + 'inventoryLocationId' => $inventoryLocationId, + 'type' => $type, + ], + ], + ]; + $valueDiv = $inventoryLevel[$type . 'Total']; + $actionButton = Cp::disclosureMenu($items, $config); + $inventoryLevel[$type] = $valueDiv . (count($items) ? $actionButton : ''); + } + } + unset($inventoryLevel); + + return response()->json([ + 'pagination' => AdminTable::paginationLinks($page, (int)$total, $limit), + 'data' => $inventoryTableData, + 'headHtml' => HtmlStack::headHtml(), + 'bodyHtml' => HtmlStack::bodyHtml(), + ]); + } + + public function updateLevels(Request $request): Response + { + $updateAction = InventoryUpdateQuantityType::from($request->input('updateAction')); + $quantity = (int)$request->input('quantity'); + $note = $request->input('note'); + $inventoryLocationId = (int)$request->input('inventoryLocationId'); + $inventoryItemIds = $request->input('ids'); + $type = $request->input('type'); + + // We don't add zero amounts as transactions movements + if ($updateAction === InventoryUpdateQuantityType::ADJUST && $quantity == 0) { + return $this->asFailure(t('No inventory changes made.', category: 'commerce')); + } + + $errors = []; + $updateInventoryLevels = UpdateInventoryLevelCollection::make(); + foreach ($inventoryItemIds as $inventoryItemId) { + // Verbosely set property to show usages + $updateInventoryLevel = new UpdateInventoryLevel(); + $updateInventoryLevel->type = $type; + $updateInventoryLevel->updateAction = $updateAction; + $updateInventoryLevel->inventoryItemId = (int)$inventoryItemId; + $updateInventoryLevel->inventoryLocationId = $inventoryLocationId; + $updateInventoryLevel->quantity = $quantity; + $updateInventoryLevel->note = $note; + + $updateInventoryLevels->push($updateInventoryLevel); + } + + if (!app(Inventory::class)->executeUpdateInventoryLevels($updateInventoryLevels)) { + $errors['updateQuantities'] = [t('Inventory could not be set.', category: 'commerce')]; + } + + if (count($errors) > 0) { + return $this->asFailure(t('Inventory was not updated.', category: 'commerce'), ['errors' => $errors]); + } + + $resultingInventoryLevels = []; + foreach ($updateInventoryLevels as $updateInventoryLevel) { + /** @var UpdateInventoryLevel $updateInventoryLevel */ + $resultingInventoryLevels[] = app(Inventory::class)->getInventoryLevel($updateInventoryLevel->inventoryItemId, $updateInventoryLevel->inventoryLocationId); + } + + return $this->asSuccess(t('Inventory updated.', category: 'commerce'), [ + 'updatedItems' => collect($resultingInventoryLevels)->toArray(), + ]); + } + + public function editUpdateLevelsModal(Request $request): CpModalResponse|JsonResponse + { + $inventoryLocationId = (int)$request->input('inventoryLocationId'); + $note = $request->input('note', ''); + $inventoryItemIds = (array)$request->input('ids', []); // param needs to be 'ids' to be compatible with admin table + $updateAction = $request->input('updateAction', 'adjust'); + $quantity = (int)$request->input('quantity', 0); + $type = $request->input('type'); + abort_if(!$type, 400, 'Missing type'); + + $inventoryLevels = []; + foreach ($inventoryItemIds as $inventoryItemId) { + $inventoryLevels[] = app(Inventory::class)->getInventoryLevel((int)$inventoryItemId, $inventoryLocationId); + } + + $params = [ + 'inventoryLocationId' => $inventoryLocationId, + 'inventoryItemIds' => $inventoryItemIds, + 'inventoryLevels' => $inventoryLevels, + 'updateAction' => $updateAction, + 'inventoryLocationOptions' => app(InventoryLocations::class)->getAllInventoryLocations()->mapWithKeys(fn($location) => [$location->id => $location->getUiLabel()])->all(), + 'type' => $type, + 'quantity' => $quantity, + 'note' => $note, + ]; + + // Live preview refresh only swaps the preview region, leaving the form inputs untouched. + if ($request->input('preview')) { + return response()->json([ + 'previewHtml' => template('commerce/inventory/levels/_updateInventoryLevelPreview', $params, TemplateMode::Cp), + ]); + } + + return new CpModalResponse() + ->action('commerce/inventory/update-levels') + ->submitButtonLabel(t('Update', category: 'commerce')) + ->contentTemplate('commerce/inventory/levels/_updateInventoryLevelModal', $params); + } + + public function saveInventoryMovement(Request $request): Response + { + $fromInventoryLocationId = (int)$request->input('inventoryMovement.fromInventoryLocationId'); + $toInventoryLocationId = (int)$request->input('inventoryMovement.toInventoryLocationId'); + $note = $request->input('inventoryMovement.note'); + $fromInventoryTransactionType = $request->input('inventoryMovement.fromInventoryTransactionType'); + $toInventoryTransactionType = $request->input('inventoryMovement.toInventoryTransactionType'); + $inventoryItemId = $request->input('inventoryMovement.inventoryItemId'); + $quantity = (int)$request->input('inventoryMovement.quantity'); + + if ($quantity == 0) { + return $this->asSuccess(t('No inventory movements made.', category: 'commerce')); + } + + $inventoryMovement = new InventoryManualMovement(); + $inventoryMovement->inventoryItemId = (int)$inventoryItemId; + $inventoryMovement->fromInventoryLocation = app(InventoryLocations::class)->getInventoryLocationById($fromInventoryLocationId); + $inventoryMovement->toInventoryLocation = app(InventoryLocations::class)->getInventoryLocationById($toInventoryLocationId); + $inventoryMovement->fromInventoryTransactionType = InventoryTransactionType::from($fromInventoryTransactionType); + $inventoryMovement->toInventoryTransactionType = InventoryTransactionType::from($toInventoryTransactionType); + $inventoryMovement->quantity = $quantity; + $inventoryMovement->note = $note; + + if ($inventoryMovement->validate()) { + /** @var InventoryMovementCollection $inventoryMovementCollection */ + $inventoryMovementCollection = InventoryMovementCollection::make()->push($inventoryMovement); + if (!app(Inventory::class)->executeInventoryMovements($inventoryMovementCollection)) { + return $this->asFailure(t('Inventory movement could not be saved.', category: 'commerce')); + } + } + + return $this->asSuccess(t('Inventory movement saved.', category: 'commerce')); + } + + public function editMovementModal(Request $request): CpModalResponse|JsonResponse + { + $fromInventoryLocationId = (int)$request->input('inventoryMovement.fromInventoryLocationId'); + $toInventoryLocationId = (int)$request->input('inventoryMovement.toInventoryLocationId', $fromInventoryLocationId); + $note = $request->input('inventoryMovement.note', ''); + $fromInventoryTransactionType = $request->input('inventoryMovement.fromInventoryTransactionType'); + $toInventoryTransactionType = $request->input('inventoryMovement.toInventoryTransactionType'); + $inventoryItemId = $request->input('inventoryMovement.inventoryItemId'); + $quantity = (int)$request->input('inventoryMovement.quantity', 0); + + $movableTo = collect(InventoryTransactionType::allowedManualMoveTransactionTypes()) + ->filter(fn($type) => $type->value !== $fromInventoryTransactionType) + ->mapWithKeys(fn($type) => [$type->value => $type->typeAsLabel()]); + + $toInventoryTransactionType = InventoryTransactionType::tryFrom($toInventoryTransactionType); + if (!$toInventoryTransactionType) { + $toInventoryTransactionType = $movableTo->keys()->first(); + } else { + $toInventoryTransactionType = $toInventoryTransactionType->value; + } + + $inventoryMovement = new InventoryManualMovement(); + $inventoryMovement->inventoryItemId = (int)$inventoryItemId; + $inventoryMovement->fromInventoryLocation = app(InventoryLocations::class)->getInventoryLocationById($fromInventoryLocationId); + $inventoryMovement->toInventoryLocation = app(InventoryLocations::class)->getInventoryLocationById($toInventoryLocationId); + $inventoryMovement->fromInventoryTransactionType = InventoryTransactionType::from($fromInventoryTransactionType); + $inventoryMovement->toInventoryTransactionType = InventoryTransactionType::from($toInventoryTransactionType); + $inventoryMovement->quantity = $quantity; + $inventoryMovement->note = $note; + + $fromLevel = app(Inventory::class)->getInventoryLevel($inventoryMovement->inventoryItemId, $inventoryMovement->fromInventoryLocation); + $fromTotal = $fromLevel->{$fromInventoryTransactionType . 'Total'}; + + $movableTo = $movableTo->toArray(); + $params = [ + 'inventoryMovement' => $inventoryMovement, + 'toInventoryTransactionTypes' => $movableTo, + 'maxFromQuantity' => $fromTotal, + ]; + + // Live preview refresh only swaps the preview region, leaving the form inputs untouched. + if ($request->input('preview')) { + return response()->json([ + 'previewHtml' => template('commerce/inventory/levels/_inventoryMovementPreview', $params, TemplateMode::Cp), + ]); + } + + return new CpModalResponse() + ->action('commerce/inventory/save-inventory-movement') + ->submitButtonLabel(t('Move', category: 'commerce')) + ->contentTemplate('commerce/inventory/levels/_inventoryMovementModal', $params); + } + + public function unfulfilledOrders(Request $request): CpModalResponse + { + $inventoryLocationId = (int)$request->input('inventoryLocationId'); + $inventoryItemId = (int)$request->input('inventoryItemId'); + + $orders = app(Inventory::class)->getUnfulfilledOrders($inventoryItemId, $inventoryLocationId); + + $title = t('{count} Unfulfilled Orders', ['count' => count($orders)], category: 'commerce'); + + return new CpModalResponse() + ->contentTemplate('commerce/inventory/levels/_unfulfilledOrdersModal', compact( + 'title', + 'orders' + )); + } +} diff --git a/src/Http/Controllers/InventoryLocationsController.php b/src/Http/Controllers/InventoryLocationsController.php new file mode 100644 index 0000000000..c88bb4f5bb --- /dev/null +++ b/src/Http/Controllers/InventoryLocationsController.php @@ -0,0 +1,330 @@ +getAllInventoryLocations(); + $currentUser = currentUser(); + + $screen = new CpScreenResponse() + ->title(t('Inventory Locations', category: 'commerce')) + ->addCrumb(t('Commerce', category: 'commerce'), 'commerce') + ->selectedSubnavItem('inventory-locations') + ->contentTemplate('commerce/inventory-locations/_index', []); + + $locationCount = count($inventoryLocations); + $showNewButton = $locationCount < Plugin::EDITION_PRO_STORE_LIMIT; + $userCanCreate = $currentUser?->can('commerce-createLocations'); + + if ($userCanCreate && $showNewButton) { + $button = Html::a( + t('New location', category: 'commerce'), + 'commerce/inventory-locations/new', + ['class' => 'btn submit add icon'] + ); + $screen->additionalButtonsHtml($button); + } + + return $screen; + } + + public function edit(?int $inventoryLocationId = null): CpScreenResponse + { + if ($inventoryLocationId !== null) { + $inventoryLocation = app(InventoryLocations::class)->getInventoryLocationById($inventoryLocationId); + abort_if(!$inventoryLocation, 404, 'Inventory location not found'); + + $title = trim((string)$inventoryLocation->getUiLabel()) ?: t('Edit Inventory Location'); + } else { + $inventoryLocation = new InventoryLocation(); + $title = t('Create a new inventory location'); + } + + InputNamespace::set('inventoryLocationAddress'); + + $address = $inventoryLocation->getAddress(); + $fieldLayout = $address->getFieldLayout(); + + // The legacy FieldLayout::createForm()/FieldLayoutForm API is gone — form building now + // goes through FieldLayoutCompiler (produces an immutable FormPayload) + FormHtmlRenderer + // (renders that payload to HTML/tab data), matching cms-6's own EditElementController:: + // prepareEditor(). There's no more tabIdPrefix (namespace alone drives both input names + // and DOM ids), and the payload can't be mutated the way $form->tabs used to be. + // TODO: strip the address field layout's own title/LabelField (currently just rendered + // alongside our own explicit Name field below) via CraftCms\Cms\FieldLayout\Events\ + // FieldLayoutFormResolving once that's worth the complexity — it fires inside + // FieldLayoutCompiler::form() with a mutable Form the listener can filter nodes from. + $payload = app(FieldLayoutCompiler::class)->compile( + $fieldLayout, + $address, + new FormContext( + namespace: 'inventoryLocationAddress', + errors: $address->errors()->getMessages(), + mode: ControlMode::Editable, + refreshable: true, + ), + ); + $renderer = app(FormHtmlRenderer::class); + $form = $renderer->render($payload); + $tabs = $renderer->tabMenu($payload); + + // These used to be injected directly into the compiled form's first tab; they're rendered + // ahead of the form's own HTML instead now, namespaced to match (see _edit.twig). + $extraFieldsHtml = + Html::hiddenInput('inventoryLocationId', (string)$inventoryLocationId) . + \craft\helpers\Cp::textFieldHtml([ + 'name' => 'name', + 'id' => 'name', + 'value' => $inventoryLocation->name, + 'required' => true, + 'label' => t('Name', category: 'commerce'), + 'errors' => $inventoryLocation->getErrors('name'), + ]) . + \craft\helpers\Cp::textFieldHtml([ + 'name' => 'handle', + 'id' => 'handle', + 'value' => $inventoryLocation->handle, + 'required' => true, + 'label' => t('Handle', category: 'commerce'), + 'errors' => $inventoryLocation->getErrors('handle'), + ]) . + Html::hiddenInput('id', (string)$address->id) . + Html::tag('hr'); + + $variables = [ + 'inventoryLocationId' => $inventoryLocationId, + 'inventoryLocation' => $inventoryLocation, + 'typeName' => t('Inventory Location', category: 'commerce'), + 'lowerTypeName' => t('inventory location', category: 'commerce'), + 'locationFieldHtml' => '', + 'addressField' => new AddressField(), + 'extraFieldsHtml' => $extraFieldsHtml, + 'form' => $form, + 'countries' => Addresses::getCountryRepository()->getList(\Craft::$app->language), + ]; + + return new CpScreenResponse() + ->title($title) + ->tabs($tabs) + ->addCrumb(t('Commerce', category: 'commerce'), 'commerce') + ->addCrumb(t('Inventory Locations', category: 'commerce'), 'commerce/inventory-locations') + ->action('commerce/inventory-locations/save') + ->redirectUrl('commerce/inventory-locations') + ->selectedSubnavItem('inventory-locations') + ->contentTemplate('commerce/inventory-locations/_edit', $variables); + } + + public function save(Request $request): ?Response + { + // find the inventory location or make a new one + $inventoryLocationId = $request->input('inventoryLocationAddress.inventoryLocationId'); + $inventoryLocation = null; + + if ($inventoryLocationId) { + $inventoryLocation = app(InventoryLocations::class)->getInventoryLocationById((int)$inventoryLocationId); + } + + $inventoryLocation ??= new InventoryLocation(); + + $inventoryLocation->name = $request->input('inventoryLocationAddress.name'); + $inventoryLocation->handle = $request->input('inventoryLocationAddress.handle'); + + // Pre-validate the inventory location so that we don't save the address if the rest isn't valid + // This is to avoid orphaned addresses + $isValid = $inventoryLocation->validate(); + + if ($inventoryLocationAddress = $request->input('inventoryLocationAddress')) { + // Remove the non-address fields from the post data + unset($inventoryLocationAddress['name'], $inventoryLocationAddress['handle'], $inventoryLocationAddress['inventoryLocationId']); + + $inventoryLocationAddress['title'] = $inventoryLocation->name; + if ($isValid) { + $addressId = $inventoryLocationAddress['id'] ?: null; + /** @var Address|null $address */ + $address = $addressId ? Elements::getElementById((int)$addressId, Address::class) : null; + $address ??= new Address(); + + $address->id = $addressId; + } else { + $address = new Address(); + } + + $address->setAttributes($inventoryLocationAddress); + + if (isset($inventoryLocationAddress['fields'])) { + $address->setFieldValues($inventoryLocationAddress['fields']); + } + + // Only try and save if the inventory location is valid + $hasAddressErrors = false; + if ($isValid && !Elements::saveElement($address)) { + $hasAddressErrors = $address->hasErrors(); + } else { + // If we aren't saving the address let's validate it to show any potential errors + if (!$address->validate()) { + $hasAddressErrors = $address->hasErrors(); + } + } + + if ($hasAddressErrors) { + $inventoryLocation->addModelErrors($address, 'address'); + } + + $inventoryLocation->setAddress($address); + } + + $inventoryLocation->addressId = $inventoryLocation->getAddress()->id; + + if ($inventoryLocation->hasErrors() || !app(InventoryLocations::class)->saveInventoryLocation($inventoryLocation)) { + return $this->asModelFailure( + model: $inventoryLocation, + message: t('Couldn\'t save inventory location.', category: 'commerce'), + modelName: 'inventoryLocation' + ); + } + + return $this->asModelSuccess( + model: $inventoryLocation, + message: t('Inventory location saved.', category: 'commerce'), + modelName: 'inventoryLocation' + ); + } + + public function inventoryLocationsTableData(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $inventoryLocations = app(InventoryLocations::class)->getAllInventoryLocations(); + + $data = []; + foreach ($inventoryLocations as $inventoryLocation) { + $id = $inventoryLocation->id; + $deleteButtonId = sprintf("deleteButton-$id-%s", mt_rand()); + + $deleteButton = Html::a('', '#', [ + 'role' => 'button', + 'title' => t('Delete', category: 'commerce'), + 'class' => 'delete icon', + 'id' => $deleteButtonId, + ]); + + HtmlStack::jsWithVars(fn($id, $settings) => << { + e.preventDefault(); + const slideout = new Craft.CpModal('commerce/inventory-locations/prepare-delete-modal', $settings); + slideout.on('close', (e) => { + window.InventoryLocationsAdminTable.reload(); + }); +}); +JS, [ + $deleteButtonId, + ['params' => ['inventoryLocationId' => $id]], + ]); + + /** @var InventoryLocation $inventoryLocation */ + $data[] = [ + 'id' => $inventoryLocation->id, + 'title' => $inventoryLocation->getUiLabel(), + 'handle' => $inventoryLocation->handle, + 'address' => Html::encode($inventoryLocation->getAddressLine()), + 'url' => $inventoryLocation->getCpEditUrl(), + 'delete' => $inventoryLocations->count() > 1 ? $deleteButton : '', + ]; + } + + return response()->json([ + 'data' => $data, + 'headHtml' => HtmlStack::headHtml(), + 'bodyHtml' => HtmlStack::bodyHtml(), + ]); + } + + public function prepareDeleteModal(Request $request): CpModalResponse + { + abort_unless($request->expectsJson(), 400); + + $inventoryLocationId = $request->input('inventoryLocationId'); + abort_if(!$inventoryLocationId, 400, 'Missing inventoryLocationId'); + + $inventoryLocation = app(InventoryLocations::class)->getInventoryLocationById((int)$inventoryLocationId); + $allInventoryLocations = app(InventoryLocations::class)->getAllInventoryLocations(); + + $destinationInventoryLocations = $allInventoryLocations + ->filter(fn($location) => $location->id != $inventoryLocation->id); + + $destinationInventoryLocationsOptions = $destinationInventoryLocations + ->map(fn($location) => ['value' => $location->id, 'label' => $location->getUiLabel()])->all(); + + abort_if(empty($destinationInventoryLocationsOptions), 400, 'Can not delete last inventory location.'); + + $deactivateInventoryLocation = new DeactivateInventoryLocation([ + 'inventoryLocation' => $inventoryLocation, + 'destinationInventoryLocation' => $destinationInventoryLocations->first(), + ]); + + return new CpModalResponse() + ->action('commerce/inventory-locations/deactivate') + ->submitButtonLabel(t('Delete')) + ->errorSummary('Can not delete inventory location.') + ->contentTemplate('commerce/inventory-locations/_deleteModal', [ + 'deactivateInventoryLocation' => $deactivateInventoryLocation, + 'inventoryLocationOptions' => $destinationInventoryLocationsOptions, + ]); + } + + public function deactivate(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $inventoryLocationId = $request->input('inventoryLocation'); + $destinationInventoryLocationId = $request->input('destinationInventoryLocation'); + abort_if(!$inventoryLocationId || !$destinationInventoryLocationId, 400, 'Missing inventoryLocation or destinationInventoryLocation'); + + $inventoryLocation = app(InventoryLocations::class)->getInventoryLocationById((int)$inventoryLocationId); + $destinationInventoryLocation = app(InventoryLocations::class)->getInventoryLocationById((int)$destinationInventoryLocationId); + + $deactivateInventoryLocation = new DeactivateInventoryLocation([ + 'inventoryLocation' => $inventoryLocation, + 'destinationInventoryLocation' => $destinationInventoryLocation, + ]); + + if (!app(InventoryLocations::class)->executeDeactivateInventoryLocation($deactivateInventoryLocation)) { + return $this->asFailure(t('Inventory was not updated.', category: 'commerce'), [ + 'errors' => $deactivateInventoryLocation->getErrors(), + ]); + } + + return response()->json(['success' => true]); + } +} diff --git a/src/Http/Controllers/OrdersController.php b/src/Http/Controllers/OrdersController.php new file mode 100644 index 0000000000..9481e9e4d4 --- /dev/null +++ b/src/Http/Controllers/OrdersController.php @@ -0,0 +1,1903 @@ +getView()->registerAssetBundle(CommerceCpAsset::class); + + $site = \craft\helpers\Cp::requestedSite(); + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $store = $site->getStore(); + + HtmlStack::js('window.orderEdit = {};', Position::BodyBegin); + $permissions = [ + 'commerce-manageOrders' => (bool)currentUser()?->can('commerce-manageOrders'), + 'commerce-editOrders' => (bool)currentUser()?->can('commerce-editOrders'), + 'commerce-deleteOrders' => (bool)currentUser()?->can('commerce-deleteOrders'), + ]; + + HtmlStack::js('window.orderEdit.currentUserPermissions = ' . \CraftCms\Cms\Support\Json::encode($permissions) . ';', Position::BodyBegin); + + return pageTemplate('commerce/orders/_index', compact('orderStatusHandle', 'store'), TemplateMode::Cp); + } + + public function create(Request $request, string $storeHandle): Response + { + $store = app(Stores::class)->getStoreByHandle($storeHandle); + abort_unless($store !== null, 400, "Invalid store handle: $storeHandle"); + + $userId = $request->input('customerId'); + $user = $userId ? Users::getUserById((int)$userId) : null; + abort_if($userId && !$user, 400, "Invalid user ID: $userId"); + + $attributes = [ + 'number' => app(Carts::class)->generateCartNumber(), + 'origin' => Order::ORIGIN_CP, + 'storeId' => $store->id, + ]; + if ($user) { + $attributes['customer'] = $user; + } + + $order = \Craft::createObject([ + 'class' => Order::class, + 'attributes' => $attributes, + ]); + + if ($user) { + // Try to set defaults + $order->autoSetAddresses(); + $order->autoSetShippingMethod(); + } + + abort_unless(Elements::saveElement($order, false), 500, t('Can not create a new order', category: 'commerce')); + + return redirect('commerce/orders/' . $order->id); + } + + public function editOrder(int $orderId): string + { + $order = app(Orders::class)->getOrderById($orderId); + abort_if(!$order, 404, t('Can not find order.', category: 'commerce')); + + $this->enforceManageOrderPermissions($order); + + $variables = [ + 'order' => $order, + 'paymentForm' => null, + 'orderId' => $order->id, + ]; + + $transactions = $order->getTransactions(); + $variables['orderTransactions'] = $this->getTransactionsWithLevelsTableArray($transactions); + + $this->updateTemplateVariables($variables); + $this->registerJavascript($variables); + + return pageTemplate('commerce/orders/_edit', $variables, TemplateMode::Cp); + } + + public function fulfill(Request $request): Response + { + $fulfillments = $request->input('fulfillment'); + $movements = []; + foreach ($fulfillments as $fulfillment) { + $qty = (int)$fulfillment['quantity']; + if ($qty != 0) { + $inventoryLocation = app(InventoryLocations::class)->getInventoryLocationById((int)$fulfillment['inventoryLocationId']); + + $movement = new InventoryFulfillMovement(); + $movement->fromInventoryLocation = $inventoryLocation; + $movement->inventoryItemId = (int)$fulfillment['inventoryItemId']; + $movement->toInventoryLocation = $inventoryLocation; + $movement->fromInventoryTransactionType = InventoryTransactionType::COMMITTED; + $movement->toInventoryTransactionType = InventoryTransactionType::FULFILLED; + $movement->lineItemId = (int)$fulfillment['lineItemId']; + $movement->quantity = $qty; + $movement->userId = currentUser()?->getCraftUserId(); + $movements[] = $movement; + } + } + + foreach ($movements as $movement) { + if (!$movement->isValid()) { + return $this->asFailure(t('Invalid inventory movements.', category: 'commerce'), [ + 'errors' => ['fulfillment' => $movement->getErrors()], + ]); + } + } + + /** @var InventoryMovementCollection $movements */ + $movements = InventoryMovementCollection::make($movements); + + if (!app(Inventory::class)->executeInventoryMovements($movements)) { + return $this->asFailure(t('Invalid inventory movements.', category: 'commerce')); + } + + return $this->asSuccess(t('Updated committed stock successfully.', category: 'commerce')); + } + + public function fulfillmentModal(Request $request): CpModalResponse + { + abort_unless($request->expectsJson(), 400); + + $orderId = $request->input('orderId'); + abort_if(!$orderId, 400, 'Missing order id'); + + $order = app(Orders::class)->getOrderById((int)$orderId); + $inventoryFulfillmentLevels = app(Inventory::class)->getInventoryFulfillmentLevels($order)->groupBy('inventoryLocationId'); + + return new CpModalResponse() + ->action('commerce/orders/fulfill') + ->submitButtonLabel(t('Update')) + ->contentTemplate('commerce/orders/modals/_fulfillmentModal', [ + 'inventoryFulfillmentLevels' => $inventoryFulfillmentLevels, + 'order' => $order, + ])->prepareModal(function() { + HtmlStack::jsWithVars(fn() => <<{ + const el = e.target || e + if(el.type == "number" && el.max && el.min ){ + let value = parseInt(el.value) + el.value = value // for 000 like input cleanup to 0 + let max = parseInt(el.max) + let min = parseInt(el.min) + if ( value > max ) el.value = el.max + if ( value < min ) el.value = el.min + } +}); +JS, []); + }); + } + + public function save(Request $request): ?Response + { + $data = $request->input('orderData'); + $orderRequestData = \CraftCms\Cms\Support\Json::decodeIfJson($data); + + $order = app(Orders::class)->getOrderById((int)$orderRequestData['order']['id']); + abort_if(!$order, 400, t('Invalid Order ID', category: 'commerce')); + + $this->enforceManageOrderPermissions($order); + + // Set custom field values + $order->setFieldValuesFromRequest('fields'); + + $alreadyCompleted = $order->isCompleted; + // Set data from request to the order + $this->updateOrder($order, $orderRequestData, false); + $markAsComplete = !$alreadyCompleted && $order->isCompleted; + + // We don't want to save it as completed yet since we will markAsComplete() after saving the cart + if ($markAsComplete) { + $order->isCompleted = false; + $order->dateOrdered = null; + $order->orderStatusId = null; + } + + $order->ruleset->useScenario(ElementRules::SCENARIO_LIVE); + $valid = $order->validate(null, false); + + if (!$valid || !Elements::saveElement($order, false)) { + // Recalculation mode should always return to none, unless it is still a cart + $order->setRecalculationMode(Order::RECALCULATION_MODE_NONE); + if (!$order->isCompleted) { + $order->setRecalculationMode(Order::RECALCULATION_MODE_ALL); + } + + return $this->asFailure(t('Couldn\'t save order.', category: 'commerce')); + } + + // This request is marking the order as complete + if ($markAsComplete) { + $order->markAsComplete(); + } + + return $this->redirectToPostedUrl(); + } + + public function deleteOrder(Request $request): ?Response + { + $orderId = (int)$request->input('orderId'); + $order = app(Orders::class)->getOrderById($orderId); + abort_if(!$order, 404, t('Can not find order.', category: 'commerce')); + + abort_unless(($user = currentUserElement()) && $order->canDelete($user), 403, 'User not authorized to view this address.'); + + if (!Elements::deleteElementById($order->id)) { + return $this->asFailure(); + } + + return $this->asSuccess(t('Order deleted.', category: 'commerce')); + } + + public function refresh(Request $request): Response + { + $data = $request->getContent(); + $orderRequestData = \CraftCms\Cms\Support\Json::decodeIfJson($data); + + $order = app(Orders::class)->getOrderById((int)$orderRequestData['order']['id']); + + if (!$order) { + return $this->asFailure(t('Invalid Order ID', category: 'commerce')); + } + + $this->enforceManageOrderPermissions($order); + + $this->updateOrder($order, $orderRequestData); + + if ($order->validate(null, false) && $order->getRecalculationMode() == Order::RECALCULATION_MODE_ALL) { + $order->recalculate(); // dont save, just recalculate + } + + // Recalculation mode should always return to none, unless it is still a cart + $order->setRecalculationMode(Order::RECALCULATION_MODE_NONE); + if (!$order->isCompleted) { + $order->setRecalculationMode(Order::RECALCULATION_MODE_ALL); + } + + if ($order->hasErrors()) { + return $this->asModelFailure( + $order, + t('The order is not valid.', category: 'commerce'), + 'order', + [ + 'order' => $this->orderToArray($order), + ] + ); + } + + return $this->asSuccess(data: [ + 'order' => $this->orderToArray($order), + ]); + } + + public function getShippingMethodOptions(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $data = $request->getContent(); + $orderRequestData = \CraftCms\Cms\Support\Json::decodeIfJson($data); + + $order = app(Orders::class)->getOrderById((int)$orderRequestData['order']['id']); + + if (!$order) { + return $this->asFailure(t('Invalid Order ID', category: 'commerce')); + } + + $this->enforceManageOrderPermissions($order); + + $this->updateOrder($order, $orderRequestData); + + if ($order->validate(null, false) && $order->getRecalculationMode() == Order::RECALCULATION_MODE_ALL) { + $order->recalculate(); + } + + return $this->asSuccess(data: [ + 'shippingMethodOptions' => $order->toArray([], ['availableShippingMethodOptions'])['availableShippingMethodOptions'], + ]); + } + + public function userOrdersTable(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $page = (int)$request->input('page', 1); + $sort = $request->input('sort'); + $limit = (int)$request->input('per_page', 10); + $search = $request->input('search'); + $offset = ($page - 1) * $limit; + + $customerId = $request->query('customerId'); + + if (!$customerId) { + return $this->asFailure(t('Customer ID is required.', category: 'commerce')); + } + + $customer = Users::getUserById((int)$customerId); + + if (!$customer) { + return $this->asFailure(t('Unable to retrieve customer.', category: 'commerce')); + } + + $orderQuery = Order::find() + ->customer($customer) + ->withAll() // eager-load all related data + ->isCompleted(); + + if ($search) { + $orderQuery->search($search); + } + + $orderQuery->orderBy('dateOrdered DESC'); + if ($sort) { + if (is_array($sort)) { + $field = $sort[0]['sortField']; + $direction = $sort[0]['direction']; + } else { + [$field, $direction] = explode('|', (string)$sort); + } + + // Validate sorting + if ( + !in_array($direction, ['asc', 'desc']) || + !in_array($field, [ + 'reference', + 'dateOrdered', + 'totalPrice', + ]) + ) { + $field = null; + $direction = null; + } + + if ($field && $direction) { + $orderQuery->orderBy($field . ' ' . $direction); + } + } + + $total = $orderQuery->count(); + + $orderQuery->offset($offset); + $orderQuery->limit($limit); + $orders = $orderQuery->all(); + + $rows = []; + foreach ($orders as $order) { + $rows[] = [ + 'id' => $order->id, + 'title' => $order->reference, + 'url' => $order->getCpEditUrl(), + 'date' => $order->dateOrdered->format('D jS M Y'), + 'total' => $order->totalAsCurrency, + 'orderStatus' => $order->getOrderStatusHtml(), + ]; + } + + return $this->asSuccess(data: [ + 'pagination' => AdminTable::paginationLinks($page, (int)$total, $limit), + 'data' => $rows, + ]); + } + + public function purchasablesTable(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $page = (int)$request->input('page', 1); + $sort = $request->input('sort'); + $limit = (int)$request->input('per_page', 10); + $search = $request->input('search'); + $siteId = $request->query('siteId'); + $customerId = $request->query('customerId', false); + $customerId = $customerId !== false ? (int)$customerId : false; + + abort_unless($siteId !== null, 400, 'siteId is required'); + $siteId = (int)$siteId; + + $store = app(Stores::class)->getStoreBySiteId($siteId); + abort_unless($store !== null, 400, 'Store not found'); + + $offset = ($page - 1) * $limit; + + // Prepare purchasables query + $likeOperator = DB::connection()->getDriverName() === 'pgsql' ? 'ILIKE' : 'LIKE'; + $sqlQuery = new Query() + ->select(['purchasables.id', 'pstores.basePrice', 'purchasables.description', 'purchasables.sku', 'elements.type']) + ->leftJoin(['elements' => CraftTable::ELEMENTS], [ + 'and', + '[[elements.id]] = [[purchasables.id]]', + ]) + // Make sure this purchasable is enabled for the site + ->innerJoin(['es' => CraftTable::ELEMENTS_SITES], [ + 'and', + '[[es.elementId]] = [[purchasables.id]]', + '[[es.siteId]] = :siteId', + ], [ + ':siteId' => $siteId, + ]) + ->innerJoin(Table::PURCHASABLES_STORES . ' pstores', '[[purchasables.id]] = [[pstores.purchasableId]]') + ->where(['elements.enabled' => true]) + ->andWhere(['pstores.storeId' => $store->id]) + ->andWhere(['elements.revisionId' => null]) + ->andWhere(['elements.draftId' => null]) + ->from(['purchasables' => Table::PURCHASABLES]); + + // Are they searching for a SKU or purchasable description? + if ($search) { + $sqlQuery->andwhere([ + 'or', + [$likeOperator, 'purchasables.description', '%' . str_replace(' ', '%', $search) . '%', false], + [$likeOperator, 'purchasables.sku', $search], + ]); + } + + // Do not return any purchasables with temp SKUs + $sqlQuery->andWhere(new \yii\db\Expression("LEFT([[purchasables.sku]], " . strlen(Purchasable::TEMPORARY_SKU_PREFIX) . ") != '" . Purchasable::TEMPORARY_SKU_PREFIX . "'")); + + // Do not return soft deleted purchasables + $sqlQuery->andWhere(['elements.dateDeleted' => null]); + + // Apply sorting if required + if ($sort && strpos((string)$sort, '|')) { + [$column, $direction] = explode('|', (string)$sort); + + if (!in_array($column, [ + 'description', + 'sku', + 'price', + ])) { + $column = null; + } + + if ($column && in_array($direction, ['asc', 'desc'], true)) { + $sqlQuery->orderBy([$column => $direction == 'asc' ? SORT_ASC : SORT_DESC]); + } + } else { + $sqlQuery->orderBy(['id' => 'asc']); + } + + // Trigger event before working out the total and limiting the results for pagination + $event = new ModifyPurchasablesTableQueryEvent( + query: $sqlQuery, + search: $search, + ); + event($event); + $sqlQuery = $event->query; + + $total = $sqlQuery->count(); + + $sqlQuery->limit($limit); + $sqlQuery->offset($offset); + + $result = $sqlQuery->all(); + + return $this->asSuccess(data: [ + 'pagination' => AdminTable::paginationLinks($page, (int)$total, $limit), + 'data' => $this->addLivePurchasableInfo($result, $siteId, $customerId), + ]); + } + + public function customerSearch(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $query = $request->query('query'); + + $limit = 30; + + if ($query === null) { + return response()->json([]); + } + + $userQuery = User::find()->status(null)->limit($limit); + + if ($query) { + $userQuery->search(urldecode((string)$query)); + } + + $customers = $userQuery->collect()->map(fn(User $user) => $this->customerToArray($user)); + + return $this->asSuccess(data: compact('customers')); + } + + public function getCustomerAddresses(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + abort_if(!$id, 400, 'Missing user id'); + + $page = (int)$request->input('page', 1); + $limit = (int)$request->input('per_page', 10); + $offset = ($page - 1) * $limit; + + $user = Users::getUserById((int)$id); + + if (!$user) { + return $this->asFailure(t('User not found.', category: 'commerce')); + } + + $addressElements = Address::find() + ->ownerId($user->id) + ->limit($limit) + ->offset($offset) + ->collect(); + + $total = $addressElements->count(); + + $addresses = $addressElements->map(fn(Address $address) => $address->toArray() + [ + 'html' => \craft\helpers\Cp::elementCardHtml($address), + ]); + + return $this->asSuccess(data: compact('addresses', 'total')); + } + + public function getOrderAddress(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $orderId = $request->input('orderId'); + $addressId = $request->input('addressId'); + abort_if(!$orderId || !$addressId, 400, 'Missing orderId or addressId'); + + $order = app(Orders::class)->getOrderById((int)$orderId); + + if (!$order) { + return $this->asFailure(t('Order not found.', category: 'commerce')); + } + + /** @var Address|null $address */ + $address = Address::find() + ->ownerId($order->id) + ->id($addressId) + ->one(); + + if (!$address) { + return $this->asFailure(t('Address not found.', category: 'commerce')); + } + + return $this->asSuccess(data: [ + 'address' => $address->toArray() + [ + 'html' => \craft\helpers\Cp::elementCardHtml($address), + ], + ]); + } + + public function validateAddress(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $attributes = $request->input('address'); + abort_if(!$attributes, 400, 'Missing address'); + + $attributes += ['class' => Address::class]; + + $address = \Craft::createObject($attributes); + + if (!$address->validate()) { + return $this->asModelFailure(model: $address, message: t('Unable to validate address.', category: 'commerce'), modelName: 'address'); + } + + return $this->asSuccess(); + } + + public function createCustomer(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $email = $request->input('email'); + abort_if(!$email, 400, 'Missing email'); + + try { + $user = Users::ensureUserByEmail($email); + $user = $this->customerToArray($user); + } catch (\Exception $e) { + return $this->asFailure(message: $e->getMessage()); + } + + return $this->asSuccess(data: compact('user')); + } + + public function getLoadCartUrl(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + abort_unless(currentUser()?->can('commerce-manageOrders'), 403); + + $number = $request->input('number'); + abort_if(!$number, 400, 'Missing number'); + + $cart = Order::find()->number($number)->isCompleted(false)->one(); + + if (!$cart) { + abort(404, 'Cart not found.'); + } + + return $this->asSuccess(data: [ + 'url' => app(Carts::class)->getLoadCartUrl($cart), + ]); + } + + public function sendEmail(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + $orderId = $request->input('orderId'); + + if ($id === null || $orderId === null) { + return $this->asFailure(t('Bad Request', category: 'commerce')); + } + + $order = Order::find()->id($orderId)->one(); + if ($order === null) { + return $this->asFailure(t('Can not find order', category: 'commerce')); + } + + $email = app(Emails::class)->getEmailById((int)$id, $order->storeId); + if ($email === null || !$email->enabled) { + return $this->asFailure(t('Can not find enabled email.', category: 'commerce')); + } + + $originalLanguage = \Craft::$app->language; + $originalFormattingLocale = \Craft::$app->formattingLocale; + + // Set language by email's set locale + $language = $email->getRenderLanguage($order); + Locale::switchAppLanguage($language); + + $orderData = $order->toArray(); + + $success = true; + $error = ''; + try { + if (!app(Emails::class)->sendEmail($email, $order, null, $orderData, $error)) { + $success = false; + } + } catch (\Exception) { + $success = false; + } + + // Set previous language back + Locale::switchAppLanguage($originalLanguage, $originalFormattingLocale->id); + + if (!$success) { + $error = $error ?: t('Could not send email', category: 'commerce'); + return $this->asFailure($error); + } + + return $this->asSuccess(); + } + + public function updateOrderAddress(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $orderId = $request->input('orderId'); + $addressId = $request->input('addressId'); + $type = $request->input('addressType'); + + // Validate Address Type + if (!in_array($type, ['shippingAddress', 'billingAddress'], true)) { + return $this->asFailure(t('Not a valid address type', category: 'commerce')); + } + + $order = app(Orders::class)->getOrderById((int)$orderId); + if (!$order) { + return $this->asFailure(t('Bad order ID.', category: 'commerce')); + } + + // Return early if the address is already set. + if ($order->{$type . 'Id'} == $addressId) { + return $this->asSuccess(); + } + + // Validate Address Id + $address = $addressId ? Address::find()->id($addressId)->one() : null; + if (!$address) { + return $this->asFailure(t('Bad address ID.', category: 'commerce')); + } + + $order->{$type . 'Id'} = $address->id; + + if (!Elements::saveElement($order)) { + return $this->asFailure(t('Could not update orders address.', category: 'commerce')); + } + + return $this->asSuccess(); + } + + public function copyAddressToUser(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $addressId = $request->input('addressId'); + $userId = $request->input('userId'); + abort_if(!$addressId || !$userId, 400, 'Missing addressId or userId'); + + $address = Address::find()->id($addressId)->one(); + + if (!$address) { + return $this->asFailure(t('Address not found.', category: 'commerce')); + } + + $user = Users::getUserById((int)$userId); + + if (!$user || !$user->getIsCredentialed()) { + return $this->asFailure(t('Invalid user.', category: 'commerce')); + } + + try { + // Clone the address + $newAddress = Elements::duplicateElement($address, [ + 'owner' => $user, + 'primaryOwner' => $user, + ]); + } catch (\Exception $exception) { + return $this->asFailure($exception->getMessage()); + } + + return $this->asSuccess(data: [ + 'address' => $newAddress->toArray(), + ]); + } + + public function getIndexSourcesBadgeCounts(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $site = \craft\helpers\Cp::requestedSite(); + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $storeId = $site?->getStore()?->id; + + $counts = app(OrderStatuses::class)->getOrderCountByStatus($storeId); + + $total = array_reduce($counts, static fn($sum, $thing) => $sum + (int)$thing['orderCount'], 0); + + return $this->asSuccess(data: compact('counts', 'total')); + } + + public function getPaymentModal(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $orderId = $request->input('orderId'); + $paymentFormData = $request->input('paymentForm'); + + $order = app(Orders::class)->getOrderById((int)$orderId); + abort_unless($order !== null, 404, 'Order not found'); + $gateways = app(Gateways::class)->getAllGateways(); + + if ($paymentAmount = $request->input('paymentAmount')) { + $order->setPaymentAmount($paymentAmount); + } + if ($paymentCurrency = $request->input('paymentCurrency')) { + $order->setPaymentCurrency($paymentCurrency); + } + + $formHtml = ''; + /** @var Gateway $gateway */ + foreach ($gateways as $key => $gateway) { + // If gateway adapter does no support backend cp payments. + if ($gateway->availableForUseWithOrder($order) === false || !$gateway->cpPaymentsEnabled() || $gateway instanceof MissingGateway) { + unset($gateways[$key]); + continue; + } + + // Add the errors and data back to the current form model. + if ($gateway->id == $order->gatewayId) { + $paymentFormModel = $gateway->getPaymentFormModel(); + + if ($paymentFormData) { + // Re-add submitted data to payment form model + if (isset($paymentFormData['attributes'])) { + $paymentFormModel->setAttributes($paymentFormData['attributes']); + } + + // Re-add errors to payment form model + if (isset($paymentFormData['errors'])) { + $paymentFormModel->addErrors($paymentFormData['errors']); + } + } + } else { + $paymentFormModel = $gateway->getPaymentFormModel(); + } + + $paymentFormHtml = $gateway->getPaymentFormHtml([ + 'paymentForm' => $paymentFormModel, + 'order' => $order, + ]); + + $paymentFormHtml = Html::namespaceInputs($paymentFormHtml, PaymentForm::getPaymentFormNamespace($gateway->handle)); + + $paymentFormHtml = template('commerce/_components/gateways/_modalWrapper', [ + 'formHtml' => $paymentFormHtml, + 'gateway' => $gateway, + 'paymentForm' => $paymentFormModel, + 'order' => $order, + ], TemplateMode::Cp); + + $formHtml .= $paymentFormHtml; + } + + \Craft::$app->getView()->registerAssetBundle(InputmaskAsset::class); + + $modalHtml = template('commerce/orders/_paymentmodal', [ + 'gateways' => $gateways, + 'order' => $order, + 'paymentForms' => $formHtml, + ], TemplateMode::Cp); + + return $this->asSuccess(data: [ + 'modalHtml' => $modalHtml, + 'headHtml' => HtmlStack::headHtml(), + 'footHtml' => HtmlStack::bodyHtml(), + ]); + } + + public function transactionCapture(Request $request): Response + { + $id = $request->input('id'); + $transaction = app(Transactions::class)->getTransactionById((int)$id); + + if ($transaction->canCapture()) { + // capture transaction and display result + $child = app(Payments::class)->captureTransaction($transaction); + + $message = $child->message ? ' (' . $child->message . ')' : ''; + + if ($child->status == TransactionRecord::STATUS_SUCCESS) { + $child->order?->updateOrderPaidInformation(); + return $this->asSuccess(t('Transaction captured successfully: {message}', ['message' => $message], category: 'commerce')); + } + + return $this->asFailure(t('Couldn\'t capture transaction: {message}', ['message' => $message], category: 'commerce')); + } + + return $this->asFailure(t('Couldn\'t capture transaction.', category: 'commerce')); + } + + public function transactionRefund(Request $request): Response + { + $id = $request->input('id'); + + $transaction = app(Transactions::class)->getTransactionById((int)$id); + + if (!$transaction) { + return $this->asFailure(t('Can not find the transaction to refund', category: 'commerce')); + } + + $amount = $request->input('amount'); + $amount = Money::toMoney(array_merge($amount, ['currency' => $transaction->paymentCurrency])); + $amount = Money::toDecimal($amount); + + $note = $request->input('note'); + abort_if($note === null, 400, 'Missing note'); + + if (!$amount || $amount <= 0) { + $amount = $transaction->getRefundableAmount(); + } + + if ($amount <= 0 || $amount > $transaction->getRefundableAmount()) { + $error = t('Can not refund amount greater than the remaining amount', category: 'commerce'); + return $this->asFailure($error); + } + + if ($transaction->canRefund()) { + try { + // refund transaction and display result + $child = app(Payments::class)->refundTransaction($transaction, $amount, $note); + + $message = $child->message ? ' (' . $child->message . ')' : ''; + + if ($child->status == TransactionRecord::STATUS_SUCCESS || $child->status == TransactionRecord::STATUS_PROCESSING) { + $child->order?->updateOrderPaidInformation(); + return $this->asSuccess(t('Transaction refunded successfully: {message}', ['message' => $message], category: 'commerce')); + } + + return $this->asFailure(t('Couldn\'t refund transaction: {message}', ['message' => $message], category: 'commerce')); + } catch (RefundException $exception) { + return $this->asFailure($exception->getMessage()); + } + } + + return $this->asFailure(t('Couldn\'t refund transaction.', category: 'commerce')); + } + + public function paymentAmountData(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + // NOTE: `PaymentCurrencies::convertCurrency()` was not carried over to the migrated + // service (only `convert()`/`convertAmount()` were), so the legacy + // `Plugin::getInstance()->getPaymentCurrencies()` facade is used deliberately here. + $paymentCurrencies = Plugin::getInstance()->getPaymentCurrencies(); + $paymentCurrency = $request->input('paymentCurrency'); + $paymentAmount = $request->input('paymentAmount'); + $locale = $request->input('locale'); + $orderId = $request->input('orderId'); + abort_if(!$paymentCurrency || !$paymentAmount || !$locale || !$orderId, 400, 'Missing required param'); + + /** @var Order $order */ + $order = Order::find()->id($orderId)->one(); + $baseCurrency = $order->currency; + + $paymentAmount = Money::toMoney(['value' => $paymentAmount, 'currency' => $baseCurrency, 'locale' => $locale]); + $paymentAmount = Money::toDecimal($paymentAmount); + + $baseCurrencyPaymentAmount = $paymentCurrencies->convertCurrency((float)$paymentAmount, $paymentCurrency, $baseCurrency); + $baseCurrencyPaymentAmountAsCurrency = t('Pay {amount} of {currency} on the order.', ['amount' => Currency::formatAsCurrency($baseCurrencyPaymentAmount, $baseCurrency), 'currency' => $baseCurrency], category: 'commerce'); + + $outstandingBalance = $order->outstandingBalance; + $outstandingBalanceAsCurrency = $order->outstandingBalanceAsCurrency; + + $message = ''; + if (Currency::round($baseCurrencyPaymentAmount) > Currency::round($outstandingBalance)) { + $baseCurrencyPaymentAmount = $outstandingBalance; + $baseCurrencyPaymentAmountAsCurrency = t('Pay {amount} of {currency} on the order.', ['amount' => $outstandingBalanceAsCurrency, 'currency' => $baseCurrency], category: 'commerce'); + $message = t('Order payment balance is {outstandingBalanceAsCurrency}. This is the maximum value that will be charged.', ['outstandingBalanceAsCurrency' => $outstandingBalanceAsCurrency], category: 'commerce'); + } + + return $this->asSuccess($message, data: [ + 'paymentCurrency' => $paymentCurrency, + 'paymentAmount' => $paymentAmount, + 'outstandingBalance' => $outstandingBalance, + 'outstandingBalanceAsCurrency' => $outstandingBalanceAsCurrency, + 'baseCurrencyPaymentAmountAsCurrency' => $baseCurrencyPaymentAmountAsCurrency, + 'baseCurrencyPaymentAmount' => $baseCurrencyPaymentAmount, + ]); + } + + public function reassignModal(Request $request): CpModalResponse + { + abort_unless($request->expectsJson(), 400); + + $oldUserIds = $request->input('oldUserIds'); + abort_if(!$oldUserIds, 400, 'Missing oldUserIds'); + + return new CpModalResponse() + ->action('commerce/orders/reassign') + ->contentHtml(fn() => \craft\helpers\Cp::elementSelectFieldHtml([ + 'label' => t('Choose a new customer', category: 'commerce'), + 'name' => 'newUserId', + 'elementType' => User::class, + 'criteria' => [ + 'id' => array_map(fn($id) => "not $id", $oldUserIds), + ], + 'single' => true, + ]) . + implode('', array_map(fn($id) => Html::hiddenInput('oldUserIds[]', $id), $oldUserIds))) + ->submitButtonLabel(t('Reassign')); + } + + public function reassign(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $oldUserIds = array_map(fn($id) => (int)$id, $request->input('oldUserIds')); + $newUserId = (int)$request->input('newUserId'); + + if (!$newUserId) { + return $this->asFailure(t('No new customer selected.', category: 'commerce')); + } + + try { + $count = app(Orders::class)->reassignOrders($oldUserIds, $newUserId); + } catch (\Exception) { + return $this->asFailure(t('Unable to reassign orders.', category: 'commerce')); + } + + return $this->asSuccess(t('{type} reassigned.', [ + 'type' => $count === 1 ? Order::displayName() : Order::pluralDisplayName(), + ])); + } + + public function removeCustomerDataModal(Request $request): CpModalResponse + { + abort_unless($request->expectsJson(), 400); + + $orderIds = array_map(fn($id) => (int)$id, $request->input('orderIds')); + + return new CpModalResponse() + ->action('commerce/orders/remove-customer-data') + ->contentHtml(fn() => Html::tag('p', t('Remove customer association and email from the {numOrders, plural, =1{order} other{orders}}. Optionally select additional customer data to remove below', [ + 'numOrders' => count($orderIds), + ], category: 'commerce')) . + Html::beginTag('div') . + \craft\helpers\Cp::checkboxSelectFieldHtml([ + 'label' => t('Customer data', category: 'commerce'), + 'name' => 'customerData', + 'options' => [ + 'billingAddressId' => t('Billing Address', category: 'commerce'), + 'shippingAddressId' => t('Shipping Address', category: 'commerce'), + 'orderCompletedEmail' => t('Completed Email', category: 'commerce'), + ], + 'values' => null, + 'showAllOption' => true, + ]) . + Html::endTag('div') . + implode('', array_map(fn($id) => Html::hiddenInput('orderIds[]', (string)$id), $orderIds))) + ->submitButtonLabel(t('Remove customer data', category: 'commerce')); + } + + public function removeCustomerData(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $orderIds = array_map(fn($id) => (int)$id, $request->input('orderIds')); + $customerData = $request->input('customerData', []); + $customerData = $customerData === '' ? [] : $customerData; + + $customerData = $customerData === '*' ? ['billingAddressId', 'shippingAddressId', 'orderCompletedEmail'] : $customerData; + + $dataToRemove = array_merge(['customerId', 'email'], $customerData); + + try { + app(Orders::class)->removeCustomerData($orderIds, $dataToRemove); + } catch (\Exception) { + return $this->asFailure(t('Unable to remove order data.', category: 'commerce')); + } + + return $this->asSuccess(t('Order customer data removed.', category: 'commerce')); + } + + private function orderToArray(Order $order): array + { + // Remove custom fields + $orderFields = array_keys($order->fields()); + + sort($orderFields); + + // Remove unneeded fields + $removeProps = [ + 'hasDescendants', + 'makePrimaryShippingAddress', + 'shippingSameAsBilling', + 'billingSameAsShipping', + 'tempId', + 'resaving', + 'duplicateOf', + 'totalDescendants', + 'fieldLayoutId', + 'contentId', + 'trashed', + 'structureId', + 'url', + 'ref', + 'title', + 'slug', + ]; + foreach ($removeProps as $removeProp) { + $orderFields = array_filter($orderFields, fn($value) => $value !== $removeProp); + } + + foreach ($order->getFieldLayout()->getCustomFields() as $field) { + /** @var Field $field */ + $orderFields = array_filter($orderFields, fn($value) => $value !== $field->handle); + } + + $extraFields = [ + 'lineItems.snapshot', + 'billingAddress', + 'shippingAddress', + 'orderSite', + 'notices', + 'adminNotices', + 'loadCartUrl', + 'store', + 'totalCommittedStock', + 'lineItems.fulfilledTotalQuantity', + ]; + + $lineItems = $order->getLineItems(); + $purchasableCpEditUrlByPurchasableId = []; + foreach ($lineItems as $lineItem) { + if ($lineItem->type === LineItemType::Custom) { + continue; + } + + /** @var Purchasable|\CraftCms\Commerce\Purchasable\Elements\Purchasable|null $purchasable */ + $purchasable = $lineItem->getPurchasable(); + if (!$purchasable || isset($purchasableCpEditUrlByPurchasableId[$purchasable->id])) { + continue; + } + + if ($purchasable instanceof Variant) { + $product = $purchasable->getOwner(); + $purchasableCpEditUrlByPurchasableId[$purchasable->id] = $product?->getCpEditUrl() ?? null; + } else { + $purchasableCpEditUrlByPurchasableId[$purchasable->id] = $purchasable->getCpEditUrl(); + } + } + + $purchasableCpEditUrlByPurchasableId = array_filter($purchasableCpEditUrlByPurchasableId); + + $billingAddress = $order->getBillingAddress(); + $shippingAddress = $order->getShippingAddress(); + + $subUnit = app(Currencies::class)->getSubunitFor($order->currency); + + $orderArray = $order->toArray($orderFields, $extraFields); + + if ($orderArray['customer'] && $orderArray['customer']['id'] && $customer = Users::getUserById($orderArray['customer']['id'])) { + $orderArray['customer'] = $this->customerToArray($customer); + } + + if ($billingAddress) { + $orderArray['billingAddressHtml'] = \craft\helpers\Cp::elementCardHtml($billingAddress, [ + 'showEditButton' => false, + ]); + } + + if ($shippingAddress) { + $orderArray['shippingAddressHtml'] = \craft\helpers\Cp::elementCardHtml($shippingAddress, [ + 'showEditButton' => false, + ]); + } + + if (!empty($orderArray['lineItems'])) { + foreach ($orderArray['lineItems'] as &$lineItem) { + $lineItem['price'] = $lineItem['price'] !== null ? I18N::getFormatter()->asDecimal($lineItem['price'], $subUnit) : null; + $lineItem['promotionalPrice'] = $lineItem['promotionalPrice'] !== null ? I18N::getFormatter()->asDecimal($lineItem['promotionalPrice'], $subUnit) : null; + + $options = $lineItem['options']; + $isAssociativeOptions = is_array($options) && !empty($options) && count(array_filter(array_keys($options), 'is_string')) === count($options); + $lineItem['showForm'] = $isAssociativeOptions || (is_array($options) && empty($options)); + $lineItem['purchasableCpEditUrl'] = $purchasableCpEditUrlByPurchasableId[$lineItem['purchasableId']] ?? null; + } + unset($lineItem); + } + + return $orderArray; + } + + private function updateTemplateVariables(array &$variables): void + { + /** @var Order $order */ + $order = $variables['order']; + + $variables['ordersBodyClass'] = ' commerceorders-post-57'; + + $variables['title'] = t('Order', category: 'commerce') . ' ' . $order->reference; + + if (!$order->isCompleted && $order->origin == Order::ORIGIN_CP) { + $variables['title'] = t('New Order', category: 'commerce'); + } + + if (!$order->isCompleted && $order->origin == Order::ORIGIN_WEB) { + $variables['title'] = t('Cart {number}', ['number' => $order->getShortNumber()], category: 'commerce'); + } + + $fieldLayout = $order->getFieldLayout(); + // The legacy FieldLayout::createForm()/FieldLayoutForm API is gone — form building now + // goes through FieldLayoutCompiler (produces an immutable FormPayload) + FormHtmlRenderer + // (renders that payload to HTML/tab data), matching cms-6's own EditElementController:: + // prepareEditor(). There's no more tabIdPrefix (namespace alone drives both input names + // and DOM ids) — the static (read-only) form gets its own namespace so its tab ids never + // collide with the dynamic form's; the dynamic (editable) form is left unnamespaced so its + // submitted field names still match what setFieldValuesFromRequest('fields') expects. + $renderer = app(FormHtmlRenderer::class); + + $staticPayload = app(FieldLayoutCompiler::class)->compile( + $fieldLayout, + $order, + new FormContext( + namespace: 'static_fields', + mode: ControlMode::ReadOnly, + ), + ); + $dynamicPayload = app(FieldLayoutCompiler::class)->compile( + $fieldLayout, + $order, + new FormContext( + errors: $order->errors()->getMessages(), + mode: ControlMode::Editable, + refreshable: true, + ), + ); + + $variables['staticFieldsHtml'] = $renderer->render($staticPayload); + $variables['dynamicFieldsHtml'] = $renderer->render($dynamicPayload); + + $variables['tabs'] = []; + + $variables['tabs']['order-details'] = [ + 'label' => t('Order Details', category: 'commerce'), + 'url' => '#orderDetailsTab', + 'class' => null, + ]; + + foreach ($renderer->tabMenu($staticPayload) as $tabId => $tab) { + $tab['class'] .= ' custom-tab static'; + $variables['tabs'][$tabId] = $tab; + } + + foreach ($renderer->tabMenu($dynamicPayload) as $tabId => $tab) { + $tab['class'] .= ' custom-tab'; + $variables['tabs'][$tabId] = $tab; + } + + $variables['tabs']['order-transactions'] = [ + 'label' => t('Transactions', category: 'commerce'), + 'url' => '#transactionsTab', + 'class' => null, + ]; + + $variables['tabs']['order-history'] = [ + 'label' => t('Status History', category: 'commerce'), + 'url' => '#orderHistoryTab', + 'class' => null, + ]; + + $variables['fullPageForm'] = true; + + $variables['paymentMethodsAvailable'] = false; + + if (empty($variables['paymentForm'])) { + $gateway = $order->getGateway(); + + if ($gateway && !$gateway instanceof MissingGateway) { + $variables['paymentForm'] = $gateway->getPaymentFormModel(); + } else { + $gateway = app(Gateways::class)->getAllGateways()->first(); + + if ($gateway && !$gateway instanceof MissingGateway) { + $variables['paymentForm'] = $gateway->getPaymentFormModel(); + } + } + + if ($gateway instanceof MissingGateway) { + $variables['paymentMethodsAvailable'] = false; + } + } + } + + private function registerJavascript(array $variables): void + { + /** @var Order $order */ + $order = $variables['order']; + \Craft::$app->getView()->registerAssetBundle(CommerceOrderAsset::class); + // Include the input mask asset for use in pricing fields + \Craft::$app->getView()->registerAssetBundle(MoneyAsset::class); + + HtmlStack::js('window.orderEdit = {};', Position::BodyBegin); + + HtmlStack::js('window.orderEdit.autoSetNewCartAddresses = ' . \CraftCms\Cms\Support\Json::encode($order->getStore()->getAutoSetNewCartAddresses()) . ';', Position::BodyBegin); + + HtmlStack::js('window.orderEdit.orderId = ' . $order->id . ';', Position::BodyBegin); + + $orderStatuses = app(OrderStatuses::class)->getAllOrderStatuses($order->storeId) + ->map(fn(OrderStatus $orderStatus) => $orderStatus->toArray(expand: ['uiLabel'])) + ->all(); + HtmlStack::js('window.orderEdit.orderStatuses = ' . \CraftCms\Cms\Support\Json::encode($orderStatuses) . ';', Position::BodyBegin); + + $orderSites = $order->getStore()->getSites()->all(); + HtmlStack::js('window.orderEdit.orderSites = ' . \CraftCms\Cms\Support\Json::encode(array_values($orderSites)) . ';', Position::BodyBegin); + + $lineItemStatuses = app(LineItemStatuses::class)->getAllLineItemStatuses($order->storeId) + ->map(fn(LineItemStatus $lineItemStatus) => $lineItemStatus->toArray(expand: ['uiLabel'])) + ->all(); + + HtmlStack::js('window.orderEdit.lineItemStatuses = ' . \CraftCms\Cms\Support\Json::encode($lineItemStatuses) . ';', Position::BodyBegin); + + $lineItemTypes = LineItemType::types(); + + HtmlStack::js('window.orderEdit.lineItemTypes = ' . \CraftCms\Cms\Support\Json::encode($lineItemTypes) . ';', Position::BodyBegin); + + $taxCategories = app(TaxCategories::class)->getAllTaxCategoriesAsList(); + HtmlStack::js('window.orderEdit.taxCategories = ' . \CraftCms\Cms\Support\Json::encode(Arr::toArray($taxCategories)) . ';', Position::BodyBegin); + + $defaultTaxCategoryId = app(TaxCategories::class)->getDefaultTaxCategory()->id; + HtmlStack::js('window.orderEdit.defaultTaxCategoryId = ' . \CraftCms\Cms\Support\Json::encode($defaultTaxCategoryId) . ';', Position::BodyBegin); + + $shippingCategories = app(ShippingCategories::class)->getAllShippingCategoriesAsList($order->storeId); + HtmlStack::js('window.orderEdit.shippingCategories = ' . \CraftCms\Cms\Support\Json::encode(Arr::toArray($shippingCategories)) . ';', Position::BodyBegin); + + $defaultShippingCategoryId = app(ShippingCategories::class)->getDefaultShippingCategory($order->storeId)->id; + HtmlStack::js('window.orderEdit.defaultShippingCategoryId = ' . \CraftCms\Cms\Support\Json::encode($defaultShippingCategoryId) . ';', Position::BodyBegin); + + $currentUser = currentUserElement(); + + $permissions = Arr::mapWithKeys([ + 'editUsers', + 'commerce-manageOrders', + 'commerce-editOrders', + 'commerce-deleteOrders', + ], fn($permission) => [$permission => (bool)$currentUser?->can($permission)]); + + HtmlStack::js('window.orderEdit.currentUserPermissions = ' . \CraftCms\Cms\Support\Json::encode($permissions) . ';', Position::BodyBegin); + HtmlStack::js('window.orderEdit.currentUserId = ' . \CraftCms\Cms\Support\Json::encode($currentUser?->id) . ';', Position::BodyBegin); + + HtmlStack::js('window.orderEdit.ordersIndexUrl = "' . Url::cpUrl('commerce/orders') . '"', Position::BodyBegin); + HtmlStack::js('window.orderEdit.ordersIndexUrlHashed = "' . Crypt::encrypt('commerce/orders') . '"', Position::BodyBegin); + HtmlStack::js('window.orderEdit.continueEditingUrl = "' . $order->cpEditUrl . '"', Position::BodyBegin); + HtmlStack::js('window.orderEdit.userPhotoFallback = "' . \Craft::$app->getAssetManager()->getPublishedUrl('@app/web/assets/cp/dist', true, 'images/user.svg') . '"', Position::BodyBegin); + + // Pad the decimal mask with `#` to match the number of decimal places in the currency + $subUnit = app(Currencies::class)->getSubunitFor($order->currency); + $formattingLocale = I18N::getFormattingLocale(); + + $currencyConfig = [ + 'currency' => $order->currency, + 'decimals' => $subUnit, + 'decimalSeparator' => $formattingLocale->getNumberSymbol($formattingLocale::SYMBOL_DECIMAL_SEPARATOR), + 'groupSeparator' => $formattingLocale->getNumberSymbol($formattingLocale::SYMBOL_GROUPING_SEPARATOR), + ]; + + HtmlStack::js('window.orderEdit.currencyConfig = ' . \CraftCms\Cms\Support\Json::encode($currencyConfig), Position::BodyBegin); + + $customer = $order->customerId ? $order->getCustomer() : null; + if ($customer) { + $customer = $this->customerToArray($customer); + } + + HtmlStack::js('window.orderEdit.originalCustomer = ' . \CraftCms\Cms\Support\Json::encode($customer, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT), Position::BodyBegin); + + $pdfUrls = app(Pdfs::class)->getAllEnabledPdfs($order->storeId)->map(fn(Pdf $pdf) => [ + 'name' => $pdf->name, + 'url' => $order->getPdfUrl(null, $pdf->handle), + ])->all(); + + HtmlStack::js('window.orderEdit.pdfUrls = ' . \CraftCms\Cms\Support\Json::encode($pdfUrls) . ';', Position::BodyBegin); + + $emails = app(Emails::class)->getAllEnabledEmails($order->storeId); + // Reset keys in case any have been removed, so the JS doesn't think it is an object + $emails = array_values($emails->all()); + HtmlStack::js('window.orderEdit.emailTemplates = ' . \CraftCms\Cms\Support\Json::encode(Arr::toArray($emails)) . ';', Position::BodyBegin); + + $response = []; + $response['order'] = $this->orderToArray($order); + + if ($order->hasErrors()) { + $response['order']['errors'] = $order->getErrors(); + $response['errors'] = $order->getErrors(); + $response['error'] = t('The order is not valid.', category: 'commerce'); + } + + HtmlStack::js('window.orderEdit.data = ' . \CraftCms\Cms\Support\Json::encode($response, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT) . ';', Position::BodyBegin); + + $forceEdit = ($order->hasErrors() || !$order->isCompleted); + + HtmlStack::js('window.orderEdit.forceEdit = ' . \CraftCms\Cms\Support\Json::encode($forceEdit) . ';', Position::BodyBegin); + + $store = $order->getStore(); + HtmlStack::js('window.orderEdit.store = ' . \CraftCms\Cms\Support\Json::encode($store->toArray([], ['settings.locationAddress'])) . ';', Position::BodyBegin); + } + + private function updateOrder(Order $order, $orderRequestData, bool $tryAutoSet = true): void + { + $order->setRecalculationMode($orderRequestData['order']['recalculationMode']); + $order->reference = $orderRequestData['order']['reference']; + + $hasSetCustomer = false; + $customerId = $orderRequestData['order']['customerId'] ?? null; + if ($customerId && $customer = Users::getUserById((int)$customerId)) { + $hasSetCustomer = true; + $order->setCustomer($customer); + } else { + $order->setCustomer(); + } + $order->couponCode = $orderRequestData['order']['couponCode']; + $order->isCompleted = $orderRequestData['order']['isCompleted']; + $order->orderStatusId = $orderRequestData['order']['orderStatusId']; + $order->orderSiteId = $orderRequestData['order']['orderSiteId']; + + // Set the order language based on the `orderSiteId` + if ($site = Sites::getSiteById($order->orderSiteId)) { + $order->orderLanguage = $site->language; + } + + $order->message = $orderRequestData['order']['message']; + $order->shippingMethodHandle = $orderRequestData['order']['shippingMethodHandle']; + $order->suppressEmails = $orderRequestData['order']['suppressEmails'] ?? false; + + $submittedBillingAddress = $orderRequestData['order']['billingAddress'] ?? null; + $submittedShippingAddress = $orderRequestData['order']['shippingAddress'] ?? null; + + if ($tryAutoSet && $hasSetCustomer && $submittedShippingAddress === null && $submittedBillingAddress === null) { + // Try and auto set addresses if the customer has changed and no address data is submitted + // Remove any lingering addresses from previous saves + if (!$order->isCompleted) { + $order->setBillingAddress(null); + $order->setShippingAddress(null); + } + + $order->autoSetAddresses(); + } else { + $getAddress = static function($address, Order $order, $title) { + if ($address && ($address['id'] && ($address['ownerId'] != $order->id || isset($address['_copy'])))) { + if (isset($address['_copy'])) { + unset($address['_copy']); + } + $address = Elements::getElementById($address['id'], Address::class); + $address = Elements::duplicateElement($address, [ + 'owner' => $order, + 'primaryOwner' => $order, + 'title' => $title, + ]); + } elseif ($address && ($address['id'] && $address['ownerId'] == $order->id)) { + /** @var Address|null $address */ + $address = Address::find()->ownerId($address['ownerId'])->id($address['id'])->one(); + } + + return $address; + }; + $billingAddress = $getAddress($submittedBillingAddress, $order, t('Billing Address', category: 'commerce')); + $order->setBillingAddress($billingAddress); + + $shippingAddress = $getAddress($submittedShippingAddress, $order, t('Shipping Address', category: 'commerce')); + $order->setShippingAddress($shippingAddress); + + if (array_key_exists('sourceBillingAddressId', $orderRequestData['order'])) { + $order->sourceBillingAddressId = $orderRequestData['order']['sourceBillingAddressId']; + } + + if (array_key_exists('sourceShippingAddressId', $orderRequestData['order'])) { + $order->sourceShippingAddressId = $orderRequestData['order']['sourceShippingAddressId']; + } + } + + if (!$order->shippingMethodHandle) { + // If no shipping method or it is being removed nullify the name + $order->shippingMethodName = null; + } elseif (!empty($orderRequestData['order']['shippingMethodName'])) { + // If the shipping method name is being submitted, use it. + // This is particularly useful for custom shipping methods as they can't be retrieved from the DB via their handle + $order->shippingMethodName = $orderRequestData['order']['shippingMethodName']; + } else { + // Fallback to attempting to retrieve the shipping method + $shippingMethod = app(ShippingMethods::class)->getShippingMethodByHandle($order->shippingMethodHandle); + if ($shippingMethod) { + $order->shippingMethodName = $shippingMethod->name ?? null; + } + } + + // CP save has full control over all notices including admin ones + $order->clearNotices(noticeTypes: [OrderNoticeType::Customer, OrderNoticeType::Admin]); + + // Create Notices on Order + $notices = []; + foreach ($orderRequestData['order']['notices'] ?? [] as $notice) { + $notices[] = \Craft::createObject([ + 'class' => OrderNotice::class, + 'attributes' => array_merge($notice, ['noticeType' => OrderNoticeType::Customer]), + ]); + } + foreach ($orderRequestData['order']['adminNotices'] ?? [] as $notice) { + $notices[] = \Craft::createObject([ + 'class' => OrderNotice::class, + 'attributes' => array_merge($notice, ['noticeType' => OrderNoticeType::Admin]), + ]); + } + $order->addNotices($notices); + + $dateOrdered = $orderRequestData['order']['dateOrdered']; + if ($dateOrdered !== null) { + if ($orderRequestData['order']['dateOrdered']['time'] == '') { + $dateTime = new DateTime('now', new DateTimeZone($dateOrdered['timezone'])); + $dateOrdered['time'] = $dateTime->format('H:i'); + } + + if ($orderRequestData['order']['dateOrdered']['date'] == '' && $orderRequestData['order']['dateOrdered']['time'] == '') { + $order->dateOrdered = null; + } else { + $newDateOrdered = DateTimeHelper::toDateTime($dateOrdered) ?: null; + $order->dateOrdered = match (true) { + $newDateOrdered === null, $newDateOrdered instanceof DateTime => $newDateOrdered, + default => DateTime::createFromInterface($newDateOrdered), + }; + } + } + + if ($dateOrdered === null && $order->isCompleted) { + $order->dateOrdered = null; + } + + // If the customer was changed, the payment source or gateway may not be valid on the order for the new customer and we should unset it. + try { + $order->getPaymentSource(); + $order->getGateway(); + } catch (\Exception) { + $order->paymentSourceId = null; + $order->gatewayId = null; + } + + $lineItems = []; + $adjustments = []; + + foreach ($orderRequestData['order']['lineItems'] as $lineItemData) { + // Normalize data + $type = $lineItemData['type'] ?? LineItemType::Purchasable; + if (is_string($type)) { + $type = LineItemType::from($type); + } elseif (is_array($type) && isset($type['value'])) { + $type = LineItemType::from($type['value']); + } + + $description = $lineItemData['description'] ?? null; + $sku = $lineItemData['sku'] ?? null; + $lineItemId = $lineItemData['id'] ?? null; + $note = $lineItemData['note'] ?? ''; + $privateNote = $lineItemData['privateNote'] ?? ''; + $purchasableId = $lineItemData['purchasableId']; + $lineItemStatusId = $lineItemData['lineItemStatusId']; + $options = $lineItemData['options'] ?? []; + $qty = $lineItemData['qty'] ?? 1; + $shippingCategoryId = $lineItemData['shippingCategoryId'] ?? null; + $taxCategoryId = $lineItemData['taxCategoryId'] ?? null; + $hasFreeShipping = $lineItemData['hasFreeShipping'] ?? null; + $isPromotable = $lineItemData['isPromotable'] ?? null; + $isShippable = $lineItemData['isShippable'] ?? null; + $isTaxable = $lineItemData['isTaxable'] ?? null; + $uid = $lineItemData['uid'] ?? (string)\CraftCms\Cms\Support\Str::uuid(); + + if ($lineItemId) { + $lineItem = app(LineItems::class)->getLineItemById($lineItemId); + } else { + try { + $params = compact('options', 'qty', 'note', 'uid'); + if ($type === LineItemType::Purchasable) { + $params['purchasableId'] = $purchasableId; + } + + $lineItem = app(LineItems::class)->create($order, $params, $type); + } catch (\Exception $exception) { + $order->addError('lineItems', $exception->getMessage()); + continue; + } + } + + $lineItem->type = $type; + + $lineItem->purchasableId = $purchasableId; + $lineItem->qty = $qty; + $lineItem->note = $note; + $lineItem->privateNote = $privateNote; + $lineItem->lineItemStatusId = $lineItemStatusId; + $lineItem->setOptions($options); + $lineItem->uid = $uid; + + $lineItem->setOrder($order); + + if ($lineItem->type === LineItemType::Custom) { + if ($description) { + $lineItem->setDescription($description); + } + + if ($sku) { + $lineItem->setSku($sku); + } + + if ($shippingCategoryId) { + $lineItem->shippingCategoryId = $shippingCategoryId; + } + + if ($taxCategoryId) { + $lineItem->taxCategoryId = $taxCategoryId; + } + + if ($hasFreeShipping !== null) { + $lineItem->setHasFreeShipping($hasFreeShipping); + } + + if ($isPromotable !== null) { + $lineItem->setIsPromotable($isPromotable); + } + + if ($isShippable !== null) { + $lineItem->setIsShippable($isShippable); + } + + if ($isTaxable !== null) { + $lineItem->setIsTaxable($isTaxable); + } + } + + // Deleted a purchasable while we had a purchasable ID in memory on the order edit page, unset it. + $customerIdForPurchasable = $orderRequestData['order']['customerId'] ?? false; + if ($lineItem->type === LineItemType::Purchasable && $purchasableId && !app(Purchasables::class)->getPurchasableById((int)$purchasableId, (int)$orderRequestData['order']['orderSiteId'], $customerIdForPurchasable !== false ? (int)$customerIdForPurchasable : false)) { + $lineItem->purchasableId = null; + } + + if ($order->getRecalculationMode() == Order::RECALCULATION_MODE_NONE || $lineItem->type === LineItemType::Custom) { + $promotionalPrice = $lineItemData['promotionalPrice'] ? Localization::normalizeNumber($lineItemData['promotionalPrice']) : null; + $price = $lineItemData['price'] ? Localization::normalizeNumber($lineItemData['price']) : 0; + + $lineItem->setPromotionalPrice($promotionalPrice); + $lineItem->setPrice($price); + } + + if ($qty > 0) { + $lineItems[] = $lineItem; + } + + if ($order->getRecalculationMode() == Order::RECALCULATION_MODE_NONE) { + foreach ($lineItemData['adjustments'] as $adjustmentData) { + $id = $adjustmentData['id']; + + $adjustment = null; + if ($id) { + $adjustment = app(OrderAdjustments::class)->getOrderAdjustmentById($id); + } + if ($adjustment === null) { + $adjustment = new OrderAdjustment(); + } + + $adjustment->setOrder($order); + $adjustment->setLineItem($lineItem); + $adjustment->amount = $adjustmentData['amount']; + $adjustment->type = $adjustmentData['type']; + $adjustment->name = $adjustmentData['name']; + $adjustment->description = $adjustmentData['description']; + $adjustment->included = $adjustmentData['included']; + $adjustment->setSourceSnapshot($adjustmentData['sourceSnapshot']); + + $adjustments[] = $adjustment; + } + } + } + + $order->setLineItems($lineItems); + + // Only update the adjustments if the recalculation mode is none (manually updating adjustments) + if ($order->getRecalculationMode() == Order::RECALCULATION_MODE_NONE) { + foreach ($orderRequestData['order']['orderAdjustments'] as $adjustmentData) { + $id = $adjustmentData['id']; + + $adjustment = null; + if ($id) { + $adjustment = app(OrderAdjustments::class)->getOrderAdjustmentById($id); + } + if ($adjustment === null) { + $adjustment = new OrderAdjustment(); + } + + $adjustment->setOrder($order); + $adjustment->amount = $adjustmentData['amount']; + $adjustment->type = $adjustmentData['type']; + $adjustment->name = $adjustmentData['name']; + $adjustment->description = $adjustmentData['description']; + $adjustment->included = $adjustmentData['included']; + $adjustment->setSourceSnapshot($adjustmentData['sourceSnapshot']); + + $adjustments[] = $adjustment; + } + + // add all the updated adjustments to the order + $order->setAdjustments($adjustments); + } + } + + private function getTransactionsWithLevelsTableArray(array $transactions, int $level = 0): array + { + $return = []; + $user = currentUserElement(); + foreach ($transactions as $transaction) { + if (!Arr::contains($return, 'id', $transaction->id)) { + $refundCapture = ''; + if ($user?->can('commerce-capturePayment') && $transaction->canCapture()) { + $refundCapture = template( + 'commerce/orders/includes/_capture', + [ + 'currentUser' => $user, + 'transaction' => $transaction, + ], + TemplateMode::Cp, + ); + } elseif ($user?->can('commerce-refundPayment') && $transaction->canRefund()) { + $refundCapture = template( + 'commerce/orders/includes/_refund', + [ + 'currentUser' => $user, + 'transaction' => $transaction, + ], + TemplateMode::Cp, + ); + } + + $transactionResponse = \CraftCms\Cms\Support\Json::decodeIfJson($transaction->response); + if (is_array($transactionResponse)) { + $transactionResponse = \CraftCms\Cms\Support\Json::encode($transactionResponse, JSON_UNESCAPED_UNICODE | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS); + } + + $transactionMessage = \CraftCms\Cms\Support\Json::decodeIfJson($transaction->message); + $transactionMessage = \CraftCms\Cms\Support\Json::encode($transactionMessage, JSON_UNESCAPED_UNICODE | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS); + + $return[] = [ + 'id' => $transaction->id, + 'level' => $level, + 'type' => [ + 'label' => Html::encode(t(\CraftCms\Cms\Support\Str::title($transaction->type), category: 'commerce')), + 'level' => $level, + ], + 'status' => [ + 'key' => $transaction->status, + 'label' => Html::encode(t(\CraftCms\Cms\Support\Str::title($transaction->status), category: 'commerce')), + ], + 'paymentAmount' => $transaction->paymentAmountAsCurrency, + 'amount' => $transaction->amountAsCurrency, + 'gateway' => Html::encode($transaction->gateway->name ?? t('Missing Gateway', category: 'commerce')), + 'date' => $transaction->dateUpdated ? $transaction->dateUpdated->format('H:i:s (jS M Y)') : '', + 'info' => [ + ['label' => Html::encode(t('Transaction ID', category: 'commerce')), 'type' => 'code', 'value' => $transaction->id], + ['label' => Html::encode(t('Transaction Hash', category: 'commerce')), 'type' => 'code', 'value' => $transaction->hash], + ['label' => Html::encode(t('Gateway Reference', category: 'commerce')), 'type' => 'code', 'value' => $transaction->reference], + ['label' => Html::encode(t('Gateway Message', category: 'commerce')), 'type' => 'text', 'value' => $transactionMessage], + ['label' => Html::encode(t('Note', category: 'commerce')), 'type' => 'text', 'value' => Html::encode($transaction->note)], + ['label' => Html::encode(t('Gateway Code', category: 'commerce')), 'type' => 'code', 'value' => $transaction->code], + ['label' => Html::encode(t('Converted Price', category: 'commerce')), 'type' => 'text', 'value' => $transaction->paymentAmountAsCurrency . ' (1 ' . $transaction->currency . ' = ' . $transaction->paymentRate . ' ' . $transaction->paymentCurrency . ')'], + ['label' => Html::encode(t('Gateway Response', category: 'commerce')), 'type' => 'response', 'value' => $transactionResponse], + ], + 'actions' => $refundCapture, + ]; + + if (!empty($transaction->childTransactions)) { + $childTransactions = $this->getTransactionsWithLevelsTableArray($transaction->childTransactions, $level + 1); + + foreach ($childTransactions as $childTransaction) { + $return[] = $childTransaction; + } + } + } + } + + return $return; + } + + private function addLivePurchasableInfo(array $results, int $siteId, int|false|null $customerId = null): array + { + $purchasables = []; + $store = app(Stores::class)->getStoreBySiteId($siteId); + $baseCurrency = $store->getCurrency(); + + $elementIdsByType = []; + foreach ($results as $r) { + if (!array_key_exists((string)$r['type'], $elementIdsByType)) { + $elementIdsByType[$r['type']] = []; + } + $elementIdsByType[$r['type']][] = $r['id']; + } + + $purchasablesById = []; + foreach ($elementIdsByType as $type => $ids) { + if (!class_exists($type)) { + continue; + } + + /** @var ElementQuery $query */ + $query = $type::find(); + + if ($type::isLocalized()) { + $query->siteId($siteId); + } + + $query->status(null); + + if ($query instanceof PurchasableQuery) { + $query->forCustomer($customerId); + } + + $purchasablesById = [...$purchasablesById, ...$query->id($ids)->all()]; + } + + foreach ($results as $row) { + /** @var PurchasableInterface|null $purchasable */ + $purchasable = Arr::first($purchasablesById, fn($p) => $p->id == $row['id']); + if ($purchasable) { + // @TODO Revisit purchasable price lookup once per-store currency handling is finalized + $row['price'] = $purchasable->getSalePrice(); + $row['promotionalPrice'] = $purchasable->getPromotionalPrice(); + $row['priceAsCurrency'] = Money::toString(Money::toMoney(['value' => $purchasable->getSalePrice(), 'currency' => $baseCurrency])); + $row['isAvailable'] = app(Purchasables::class)->isPurchasableAvailable($purchasable); + $row['detail'] = [ + 'title' => t('Information', category: 'commerce'), + 'content' => $purchasable->getSnapshot(), + 'showAsList' => true, + ]; + $row['newLineItemUid'] = (string)\CraftCms\Cms\Support\Str::uuid(); + $row['newLineItemOptionsSignature'] = LineItemHelper::generateOptionsSignature([]); + $row['description'] = Html::encode($row['description']); + $row['sku'] = Html::encode($row['sku']); + $row['qty'] = ''; + $purchasables[] = $row; + } + } + + return $purchasables; + } + + private function customerToArray(User $customer): array + { + $totalAddresses = Address::find()->ownerId($customer->id)->count(); + + return $customer->toArray(expand: ['photo']) + [ + 'cpEditUrl' => $customer->getCpEditUrl(), + 'totalAddresses' => $totalAddresses, + 'photoThumbHtml' => $customer->getThumbHtml(100), + + // @TODO Remove `photoThumbUrl` once the order edit Vue UI is updated to use `photoThumbHtml` instead + 'photoThumbUrl' => '', + ]; + } + + protected function enforceManageOrderPermissions(Order $order): void + { + abort_unless(($user = currentUserElement()) && $order->canView($user), 403, 'User not authorized to view this order.'); + } +} diff --git a/src/Http/Controllers/PaymentSourcesController.php b/src/Http/Controllers/PaymentSourcesController.php new file mode 100644 index 0000000000..bda044a2a7 --- /dev/null +++ b/src/Http/Controllers/PaymentSourcesController.php @@ -0,0 +1,127 @@ +input('gatewayId'); + abort_if(!$gatewayId, 400, 'Missing gatewayId'); + $gatewayId = (int)$gatewayId; + + $isPrimaryPaymentSource = $request->input('isPrimaryPaymentSource', false); + + $gateway = app(Gateways::class)->getGatewayById($gatewayId); + + if (!$gateway || !$gateway->supportsPaymentSources()) { + return $this->asFailure(t('There is no gateway selected that supports payment sources.', category: 'commerce')); + } + + // Get the payment method' gateway adapter's expected form model + $paymentForm = $gateway->getPaymentFormModel(); + $paymentFormParams = $request->input(PaymentForm::getPaymentFormParamName($gateway->handle), []); + $paymentForm->setAttributes($paymentFormParams); + $description = (string)$request->input('description'); + + try { + $paymentSource = app(PaymentSources::class)->createPaymentSource($customer->id, $gateway, $paymentForm, $description, $isPrimaryPaymentSource); + } catch (Throwable $exception) { + Log::error($exception->getMessage(), ['exception' => $exception]); + return $this->asModelFailure( + $paymentForm, + t('Could not create the payment source.', category: 'commerce'), + 'paymentForm', + ['paymentFormErrors' => $paymentForm->getErrors()] + ); + } + + if ($isPrimaryPaymentSource) { + app(Customers::class)->savePrimaryPaymentSourceId($customer, $paymentSource->id); + } + + return $this->asModelSuccess( + $paymentSource, + t('Payment source created.', category: 'commerce'), + 'paymentSource' + ); + } + + public function setPrimaryPaymentSource(Request $request): ?Response + { + $user = currentUserElement(); + abort_unless($user !== null, 401, t('You must be signed in to set a primary payment source.', category: 'commerce')); + + $paymentSourceId = $request->input('id'); + abort_if(!$paymentSourceId, 400, 'Missing id'); + + // Check payment source exists and belongs to the user + $paymentSource = app(PaymentSources::class)->getPaymentSourceByIdAndUserId((int)$paymentSourceId, $user->id); + if (!$paymentSource) { + return $this->asFailure( + t('Unable to retrieve payment source.', category: 'commerce'), + ['paymentSourceId' => $paymentSourceId], + ); + } + + if (!app(Customers::class)->savePrimaryPaymentSourceId($user, $paymentSource->id)) { + return $this->asFailure( + t('Unable to set primary payment source.', category: 'commerce'), + ['paymentSourceId' => $paymentSourceId], + ); + } + + return $this->asSuccess(t('Primary payment source updated.', category: 'commerce')); + } + + public function delete(Request $request): ?Response + { + $currentUser = currentUserElement(); + abort_unless($currentUser !== null, 401); + + $id = $request->input('id'); + abort_if(!$id, 400, 'Missing id'); + + $paymentSources = app(PaymentSources::class); + $paymentSource = $paymentSources->getPaymentSourceById((int)$id); + + if (!$paymentSource) { + return null; + } + + if ($paymentSource->getCustomer()?->id != $currentUser->id && !$currentUser->can('commerce-manageOrders')) { + return null; + } + + $result = $paymentSources->deletePaymentSourceById((int)$id); + + if ($result) { + return $this->asModelSuccess($paymentSource, t('Payment source deleted.', category: 'commerce')); + } + + return $this->asModelFailure($paymentSource, t('Couldn\'t delete the payment source.', category: 'commerce')); + } +} diff --git a/src/Http/Controllers/PaymentsController.php b/src/Http/Controllers/PaymentsController.php new file mode 100644 index 0000000000..1d1833cfa6 --- /dev/null +++ b/src/Http/Controllers/PaymentsController.php @@ -0,0 +1,513 @@ +isCpRequest(); + $isCpRequest = $request->isCpRequest(); + + $number = $request->input('number'); + + $useMutex = $number || (!$isCpRequest && app(Carts::class)->getHasSessionCartNumber()); + + $mutex = null; + + if ($useMutex) { + $lockOrderNumber = $number ?: (!$isCpRequest ? $request->cookie(app(Carts::class)->cartCookie['name']) : null); + + if ($lockOrderNumber) { + $mutex = Cache::lock("order:$lockOrderNumber", 5); + + try { + $mutex->block(5); + } catch (LockTimeoutException) { + abort(500, "Unable to acquire a lock for saving of Order: $lockOrderNumber"); + } + } + } + + $cartVariableName = $plugin->getSettings()->cartVariable; + + if ($number !== null) { + $order = app(Orders::class)->getOrderByNumber($number); + + if (!$order) { + $error = t('Can not find an order to pay.', category: 'commerce'); + + if ($request->expectsJson()) { + return $this->asFailure($error, data: [ + $cartVariableName => null, + ]); + } + + return $this->asFailure($error); + } + + // @TODO Fix the response variable name in Commerce 6.0: use `order` when completed and `cartVariableName` when not completed #COM-36 + $cartVariableName = 'order'; // can not override the name of the order cart in json responses for orders + } else { + $order = app(Carts::class)->getCart(); + } + + /** + * Payments on completed orders can only be made if the order number and email + * address are passed to the payments controller. If this is via the control panel, + * it requires the user have the correct permission. + */ + $isSiteRequestAndAllowed = $isSiteRequest && $order->getEmail() == $request->input('email'); + $isCpAndAllowed = $isCpRequest && $currentUser && $currentUser->can('commerce-manageOrders'); + $checkPaymentCanBeMade = $number && ($isSiteRequestAndAllowed || $isCpAndAllowed); + + if (!$order->getIsActiveCart() && !$checkPaymentCanBeMade) { + return $this->asFailure(t('Email required to make payments on a completed order.', category: 'commerce')); + } + + // Paying by order number + email is an anonymous flow: it doesn't prove the requester is + // logged in as the order's own customer. If the order already has a payment source on file + // (e.g. attached earlier by the credentialed customer), clear it unless the current user + // actually is that customer, so it can't be charged anonymously. + if ($number !== null && $isSiteRequestAndAllowed && $order->paymentSourceId) { + $orderCustomer = $order->getCustomer(); + $isLoggedInAsOrderCustomer = $currentUser && $orderCustomer && $currentUser->id == $orderCustomer->id; + if (!$isLoggedInAsOrderCustomer) { + $order->setPaymentSource(null); + } + } + + if ($order->getStore()->getRequireShippingAddressAtCheckout() && !$order->shippingAddressId) { + return $this->asFailure(t('Shipping address required.', category: 'commerce'), data: [ + $cartVariableName => $this->cartArray($order), + ]); + } + + if ($order->getStore()->getRequireBillingAddressAtCheckout() && !$order->billingAddressId) { + return $this->asFailure(t('Billing address required.', category: 'commerce'), data: [ + $cartVariableName => $this->cartArray($order), + ]); + } + + if (!$order->getStore()->getAllowEmptyCartOnCheckout() && $order->getIsEmpty()) { + return $this->asFailure(t('Order can not be empty.', category: 'commerce'), data: [ + $cartVariableName => $this->cartArray($order), + ]); + } + + // Set if the customer should be registered on order completion + $registerUserOnOrderComplete = $request->input('registerUserOnOrderComplete'); + if ($registerUserOnOrderComplete !== null) { + $order->registerUserOnOrderComplete = (bool)$registerUserOnOrderComplete; + } + + $saveBillingAddressOnOrderComplete = $request->input('saveBillingAddressOnOrderComplete'); + if ($saveBillingAddressOnOrderComplete !== null) { + $order->saveBillingAddressOnOrderComplete = (bool)$saveBillingAddressOnOrderComplete; + } + + $saveShippingAddressOnOrderComplete = $request->input('saveShippingAddressOnOrderComplete'); + if ($saveShippingAddressOnOrderComplete !== null) { + $order->saveShippingAddressOnOrderComplete = (bool)$saveShippingAddressOnOrderComplete; + } + + $saveAddressesOnOrderComplete = $request->input('saveAddressesOnOrderComplete'); + if ($saveAddressesOnOrderComplete !== null) { + $order->saveBillingAddressOnOrderComplete = (bool)$saveAddressesOnOrderComplete; + $order->saveShippingAddressOnOrderComplete = (bool)$saveAddressesOnOrderComplete; + } + + // These are used to compare if the order changed during its final + // recalculation before payment. + $originalTotalPrice = $order->getOutstandingBalance(); + $originalTotalQty = $order->getTotalQty(); + $originalTotalAdjustments = count($order->getAdjustments()); + + // Set guest email address onto guest customer and order. + if ($paymentCurrency = $request->input('paymentCurrency')) { + try { + $order->setPaymentCurrency($paymentCurrency); + } catch (CurrencyException $exception) { + Log::error($exception->getMessage(), ['exception' => $exception]); + + $order->addError('paymentCurrency', $exception->getMessage()); + + return $this->asFailure($exception->getMessage(), data: [ + $cartVariableName => $this->cartArray($order), + ]); + } + } + + // Set Payment Gateway on cart + // Same as CartController::updateCart() + if ($gatewayId = $request->input('gatewayId')) { + $gatewayId = (int)$gatewayId; + if (app(Gateways::class)->getGatewayById($gatewayId)) { + $order->setGatewayId($gatewayId); + } + } + + // Submit payment source on cart + // See CartController::updateCart() + if ($paymentSourceId = $request->input('paymentSourceId')) { + $paymentSourceId = (int)$paymentSourceId; + if ($paymentSource = app(PaymentSources::class)->getPaymentSourceById($paymentSourceId)) { + // The payment source can only be used by the same user as the cart's user. + $cartUserId = $order->getCustomer()?->id; + $paymentSourceUserId = $paymentSource->getCustomer()?->id; + $allowedToUsePaymentSource = ($cartUserId && $paymentSourceUserId && $currentUser && $isSiteRequest && ($paymentSourceUserId == $cartUserId)); + if ($allowedToUsePaymentSource) { + $order->setPaymentSource($paymentSource); + } + } + } + + // This will return the gateway to be used. The orders gateway ID could be null, but it will know the gateway from the paymentSource ID + $gateway = $order->getGateway(); + + /** @phpstan-ignore-next-line method.notFound (getIsFrontendEnabled() is declared on legacy craft\commerce\base\GatewayTrait, which legacy craft\commerce\base\Gateway implements via the class_alias chain, which PHPStan can't trace) */ + if (!$gateway || !$gateway->availableForUseWithOrder($order) || (!$gateway->getIsFrontendEnabled() && !$isCpRequest)) { + $error = t('There is no gateway or payment source available for use with this order.', category: 'commerce'); + + if ($order->gatewayId) { + $order->addError('gatewayId', $error); + } + + if ($order->paymentSourceId) { + $order->addError('paymentSourceId', $error); + } + + return $this->asFailure($error, data: [ + $cartVariableName => $this->cartArray($order), + ]); + } + + // We need the payment form whether we are populating it from the request or from the payment source. + $paymentForm = $gateway->getPaymentFormModel(); + + /** + * + * Are we paying with: + * + * 1) The current order paymentSourceId + * OR + * 2) The current order gatewayId and a payment form populated from the request + * + */ + + // 1) Paying with the current order paymentSourceId + if ($order->paymentSourceId) { + /** @var PaymentSource $paymentSource */ + $paymentSource = $order->getPaymentSource(); + if ($gateway->supportsPaymentSources()) { + $paymentForm->populateFromPaymentSource($paymentSource); + } + } + + // 2) Paying with the current order gatewayId and a payment form populated from the request + if ($order->gatewayId && !$order->paymentSourceId) { + // Populate the payment form from the params + /** @phpstan-ignore-next-line property.notFound ($handle is declared on legacy craft\commerce\base\GatewayTrait, which legacy craft\commerce\base\Gateway implements via the class_alias chain, which PHPStan can't trace) */ + $paymentFormParams = $request->input(PaymentForm::getPaymentFormParamName($gateway->handle)); + + if ($paymentFormParams) { + $paymentForm->setAttributes($paymentFormParams); + } + + // Does the user want to save this card as a payment source? + if ($currentUser && $request->input('savePaymentSource') && $gateway->supportsPaymentSources()) { + $sourceCreated = false; + try { + $paymentSource = app(PaymentSources::class)->createPaymentSource($currentUser->id, $gateway, $paymentForm); + + // Last line of try block we have a successful payment source creation + $sourceCreated = true; + } catch (PaymentSourceCreatedLaterException) { + if (property_exists($paymentForm, 'paymentSource')) { + $paymentForm->savePaymentSource = true; + } + } catch (PaymentSourceException $exception) { + Log::error($exception->getMessage(), ['exception' => $exception]); + + return $this->asModelFailure( + $paymentForm, + $exception->getMessage(), + 'paymentForm', + [ + $cartVariableName => $this->cartArray($order), + 'paymentFormErrors' => $paymentForm->getErrors(), + ], + ); + } + + if ($sourceCreated) { + $order->setPaymentSource($paymentSource); + $paymentForm->populateFromPaymentSource($paymentSource); + } + } + } + + // Allowed to update order's custom fields? + if ($order->getIsActiveCart() || currentUser()?->can('commerce-manageOrders')) { + $order->setFieldValuesFromRequest('fields'); + } + + // Check email address exists on order. + if (!$order->email) { + return $this->asModelFailure( + $paymentForm, + t('No customer email address exists on this cart.', category: 'commerce'), + 'paymentForm', + [ + $cartVariableName => $this->cartArray($order), + 'paymentFormErrors' => $paymentForm->getErrors(), + ], + ); + } + + // Does the order require shipping + if ($order->hasShippableItems() && $order->getStore()->getRequireShippingMethodSelectionAtCheckout() && !$order->shippingMethodHandle) { + return $this->asModelFailure( + $paymentForm, + t('There is no shipping method selected for this order.', category: 'commerce'), + 'paymentForm', + [ + $cartVariableName => $this->cartArray($order), + ], + ); + } + + // Save the return and cancel URLs to the order + $returnUrl = $request->input('redirect'); + if ($returnUrl !== null) { + $order->returnUrl = renderSandboxedObjectTemplate($returnUrl, $order); + } + + $cancelUrl = $request->input('cancelUrl'); + if ($cancelUrl !== null) { + $order->cancelUrl = renderSandboxedObjectTemplate($cancelUrl, $order); + } + + // Do one final save to confirm the price does not change out from under the customer. Also removes any out of stock items etc. + // This also confirms the products are available and discounts are current. + $order->recalculate(); + // Save the orders new values. + + $totalPriceChanged = $originalTotalPrice != $order->getOutstandingBalance(); + $totalQtyChanged = $originalTotalQty != $order->getTotalQty(); + $totalAdjustmentsChanged = $originalTotalAdjustments != count($order->getAdjustments()); + + $updateCartSearchIndexes = Plugin::getInstance()->getSettings()->updateCartSearchIndexes; + $updateSearchIndex = ($order->isCompleted || $updateCartSearchIndexes); + + if (Elements::saveElement($order, true, false, $updateSearchIndex)) { + // Has the order changed in a significant way? + if ($totalPriceChanged || $totalQtyChanged || $totalAdjustmentsChanged) { + if ($totalPriceChanged) { + $order->addError('totalPrice', t('The total price of the order changed.', category: 'commerce')); + } + + if ($totalQtyChanged) { + $order->addError('totalQty', t('The total quantity of items within the order changed.', category: 'commerce')); + } + + if ($totalAdjustmentsChanged) { + $order->addError('totalAdjustments', t('The total number of order adjustments changed.', category: 'commerce')); + } + + $mutex?->release(); + + return $this->asModelFailure( + $paymentForm, + t('Something changed with the order before payment, please review your order and submit payment again.', category: 'commerce'), + 'paymentForm', + [ + $cartVariableName => $this->cartArray($order), + 'paymentFormErrors' => $paymentForm->getErrors(), + ], + ); + } + } + + $mutex?->release(); + + $redirect = ''; + $redirectData = []; + $transaction = null; + $paymentForm->validate(); + + // Make sure during this payment request the order does not recalculate. + // We don't want to save the order in this mode in case the payment fails. The customer should still be able to edit and recalculate the cart. + // When the order is marked as complete from a payment later, the order will be set to 'recalculate none' mode permanently. + $order->setRecalculationMode(Order::RECALCULATION_MODE_NONE); + + // set a partial payment amount on the order in the orders currency (not payment currency) + $partialAllowed = ($isSiteRequest && $order->getStore()->getAllowPartialPaymentOnCheckout()) || $isCpRequest; + + if ($partialAllowed) { + if ($isCpAndAllowed) { + // Payment amount in the CP accepts number based in the user's formatting locale + $cpPaymentAmount = $request->input('paymentAmount'); + + if (is_array($cpPaymentAmount)) { + $cpPaymentAmount = $cpPaymentAmount['value']; + } + $cpPaymentAmount = (float)I18N::normalizeNumber($cpPaymentAmount); + + $order->setPaymentAmount($cpPaymentAmount); + } elseif ($request->input('paymentAmount')) { + $paymentAmount = (float)$request->input('paymentAmount'); + $order->setPaymentAmount($paymentAmount); + } + } + + if ((!$partialAllowed || !$gateway->supportsPartialPayment()) && $order->isPaymentAmountPartial()) { + if (!$order->isCompleted) { + $order->setRecalculationMode(Order::RECALCULATION_MODE_ALL); + } + + return $this->asModelFailure( + $paymentForm, + t('Partial payment not allowed.', category: 'commerce'), + 'paymentForm', + [ + $cartVariableName => $this->cartArray($order), + 'paymentFormErrors' => $paymentForm->getErrors(), + ], + ); + } + + $error = ''; + + if (!$paymentForm->hasErrors() && !$order->hasErrors()) { + try { + app(Payments::class)->processPayment($order, $paymentForm, $redirect, $transaction, $redirectData); + $success = true; + } catch (PaymentException $exception) { + $error = $exception->getMessage(); + $success = false; + } + } else { + $error = t('Invalid payment or order. Please review.', category: 'commerce'); + $success = false; + } + + if (!$success) { + // Reset so the cart can still be edited and recalculated after a failed payment. + if (!$order->isCompleted) { + $order->setRecalculationMode(Order::RECALCULATION_MODE_ALL); + } + + // Keep old paymentFormErrors as is. + $originalPaymentFormErrors = $paymentForm->getErrors(); + + // Adds the order errors to the payment form errors. + $paymentForm->addModelErrors($order, $cartVariableName); + + return $this->asModelFailure( + $paymentForm, + $error, + 'paymentForm', + [ + $cartVariableName => $this->cartArray($order), + // @TODO Remove the legacy `paymentFormErrors` key in Commerce 6.0 + 'paymentFormErrors' => $originalPaymentFormErrors, + ], + ); + } + + // If the gateway did not give us a redirect URL, use the order's return URL. + if (!$redirect) { + // Can be set from the redirect body param + $redirect = $order->returnUrl; + } + + if ($request->expectsJson()) { + return $this->asModelSuccess( + $paymentForm, + null, + 'paymentForm', + [ + $cartVariableName => $this->cartArray($order), + 'redirect' => $redirect, + 'redirectData' => $redirectData, + 'transactionId' => $transaction->reference ?? null, + 'transactionHash' => $transaction->hash ?? null, + ], + $redirect, + ); + } + + return redirect($redirect); + } + + public function completePayment(Request $request): Response + { + $hash = $request->input('commerceTransactionHash'); + abort_if(!$hash, 400, 'Missing commerceTransactionHash'); + + $transaction = app(Transactions::class)->getTransactionByHash($hash); + abort_unless($transaction !== null, 400, t('Can not complete payment for missing transaction.', category: 'commerce')); + + $error = ''; + $success = app(Payments::class)->completePayment($transaction, $error); + + if (!$success) { + $errorMessage = t('Payment error: {message}', ['message' => $error], category: 'commerce'); + + if ($request->expectsJson()) { + return $this->asFailure($errorMessage, data: [ + 'url' => $transaction->order->cancelUrl, + ]); + } + + session()->flash('error', $errorMessage); + + return redirect($transaction->order->cancelUrl); + } + + if ($request->expectsJson()) { + return $this->asSuccess(data: [ + 'url' => $transaction->order->returnUrl, + ]); + } + + return redirect($transaction->order->returnUrl); + } +} diff --git a/src/Http/Controllers/ProductsController.php b/src/Http/Controllers/ProductsController.php new file mode 100644 index 0000000000..3d488a6efd --- /dev/null +++ b/src/Http/Controllers/ProductsController.php @@ -0,0 +1,166 @@ +getViewableProductTypeIds(true)), 403, 'User is not permitted to view any product types.'); + } + + public function productIndex(?string $productTypeHandle = null): string + { + \Craft::$app->getView()->registerAssetBundle(ProductIndexAsset::class); + + return pageTemplate('commerce/products/_index', [ + 'productTypeHandle' => $productTypeHandle, + ], TemplateMode::Cp); + } + + public function create(Request $request, ?string $productType = null): Response + { + $productTypeHandle = $productType ?? $request->input('productType'); + abort_if(!$productTypeHandle, 400, 'Missing productType'); + + $productType = app(ProductTypes::class)->getProductTypeByHandle($productTypeHandle); + abort_unless($productType !== null, 400, "Invalid product type handle: $productTypeHandle"); + + $siteId = $request->input('siteId'); + + if ($siteId) { + $site = Sites::getSiteById((int)$siteId); + abort_unless($site !== null, 400, "Invalid site ID: $siteId"); + } else { + $site = \craft\helpers\Cp::requestedSite(); + abort_unless($site !== null, 403, 'User not authorized to edit content in any sites.'); + } + + $editableSiteIds = Sites::getEditableSiteIds(); + if (!$editableSiteIds->contains($site->id)) { + // Go with the first one + $site = Sites::getSiteById($editableSiteIds->first()); + } + + $user = currentUserElement(); + abort_unless($user !== null, 401); + + // Create & populate the draft + $product = \Craft::createObject(Product::class); + $product->siteId = $site->id; + $product->typeId = $productType->id; + $product->enabled = true; + + // Structure parent + if ( + $productType->isStructure && + (int)$productType->maxLevels !== 1 + ) { + // Set the initially selected parent + $product->setParentId($request->input('parentId')); + } + + // Make sure the user is allowed to create this entry + abort_unless($product->canSave($user), 403, 'User not authorized to create this product.'); + + // Title & slug + $product->title = $request->input('title'); + $product->slug = $request->input('slug'); + if ($product->title && !$product->slug) { + $product->slug = ElementHelper::generateSlug($product->title, null, $site->language); + } + if (!$product->slug) { + $product->slug = ElementHelper::tempSlug(); + } + + // Pause time so postDate will definitely be equal to dateCreated, if not explicitly defined + DateTimeHelper::pause(); + + // Post & expiry dates + if (($postDate = $request->input('postDate')) !== null && $postDateTime = DateTimeHelper::toDateTime($postDate)) { + $product->postDate = $postDateTime instanceof DateTime ? $postDateTime : DateTime::createFromInterface($postDateTime); + } else { + $product->postDate = now(); + } + + if (($expiryDate = $request->input('expiryDate')) !== null && $expiryDateTime = DateTimeHelper::toDateTime($expiryDate)) { + $product->expiryDate = $expiryDateTime instanceof DateTime ? $expiryDateTime : DateTime::createFromInterface($expiryDateTime); + } + + // Custom fields + foreach ($product->getFieldLayout()->getCustomFields() as $field) { + if (($value = $request->input($field->handle)) !== null) { + $product->setFieldValue($field->handle, $value); + } + } + + // Save it + $product->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); + $success = Drafts::saveElementAsDraft($product, $user->id, markAsSaved: false); + + // Resume time + DateTimeHelper::resume(); + + if (!$success) { + return $this->asModelFailure($product, t('Couldn\'t create {type}.', [ + 'type' => Product::lowerDisplayName(), + ]), 'product'); + } + + // Set its position in the structure if a before/after param was passed + if ($productType->isStructure) { + if ($nextId = $request->input('before')) { + $nextEntry = app(Products::class)->getProductById((int)$nextId, $site->id, [ + 'structureId' => $productType->structureId, + ]); + app(Structures::class)->moveBefore($productType->structureId, $product, $nextEntry); + } elseif ($prevId = $request->input('after')) { + $prevEntry = app(Products::class)->getProductById((int)$prevId, $site->id, [ + 'structureId' => $productType->structureId, + ]); + app(Structures::class)->moveAfter($productType->structureId, $product, $prevEntry); + } + } + + $editUrl = $product->getCpEditUrl(); + + $response = $this->asModelSuccess($product, t('{type} created.', [ + 'type' => Product::displayName(), + ]), 'product', array_filter([ + 'cpEditUrl' => $request->isCpRequest() ? $editUrl : null, + ])); + + if (!$request->expectsJson()) { + return redirect(Url::urlWithParams($editUrl, [ + 'fresh' => 1, + ])); + } + + return $response; + } +} diff --git a/src/Http/Controllers/Settings/CatalogPricingController.php b/src/Http/Controllers/Settings/CatalogPricingController.php new file mode 100644 index 0000000000..09bff579d4 --- /dev/null +++ b/src/Http/Controllers/Settings/CatalogPricingController.php @@ -0,0 +1,197 @@ +canUseCatalogPricingRules(), 403, 'Unable to use catalog pricing rules while sales exist.'); + } + + public function index(Request $request): string + { + $this->guard(); + + $siteHandle = $request->query('site'); + $site = $siteHandle === null ? Sites::getPrimarySite() : Sites::getSiteByHandle($siteHandle); + abort_if($site === null, 404, 'Site not found'); + + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $store = $site->getStore(); + + $purchasableId = $request->query('purchasableId') ? (int)$request->query('purchasableId') : null; + /** @var CatalogPricingCondition $conditionBuilder */ + $conditionBuilder = Conditions::createCondition([ + 'class' => CatalogPricingCondition::class, + 'allPrices' => true, + ]); + + if ($purchasableId && $purchasableElementType = Elements::getElementTypeById($purchasableId)) { + $purchasableConditionRule = Conditions::createConditionRule([ + 'class' => CatalogPricingPurchasableConditionRule::class, + 'elementIds' => [$purchasableElementType => [$purchasableId]], + ]); + + $conditionBuilder->addConditionRule($purchasableConditionRule); + } + + $catalogPrices = app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->getCatalogPrices($store->id, $conditionBuilder, limit: 100, offset: 0); + $pageInfo = app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->getCatalogPricesPageInfo($store->id, $conditionBuilder); + + \Craft::$app->getView()->registerAssetBundle(HtmxAsset::class); + \Craft::$app->getView()->registerAssetBundle(CatalogPricingAsset::class); + + return pageTemplate('commerce/prices/_index', [ + 'catalogPrices' => $catalogPrices->all(), + 'pageInfo' => $pageInfo, + 'condition' => $conditionBuilder, + 'areCatalogPricingJobsRunning' => app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->areCatalogPricingJobsRunning(), + ], TemplateMode::Cp); + } + + public function filter(Request $request): JsonResponse + { + $this->guard(); + + $condition = $request->input('condition') ?? ['class' => CatalogPricingCondition::class]; + $conditionBuilder = Conditions::createCondition($condition); + $conditionBuilderHtml = $conditionBuilder->getBuilderHtml(); + + $view = \Craft::$app->getView(); + + return response()->json([ + 'condition' => $conditionBuilder->getConfig(), + 'hudHtml' => $conditionBuilderHtml, + 'headHtml' => $view->getHeadHtml(), + 'bodyHtml' => $view->getBodyHtml(), + ]); + } + + public function prices(Request $request): JsonResponse + { + $this->guard(); + + $siteId = $request->input('siteId'); + abort_if($siteId === null, 400, 'siteId is required'); + $siteId = (int)$siteId; + $condition = $request->input('condition'); + $searchText = $request->input('searchText'); + $limit = $request->input('limit'); + $limit = $limit !== null ? (int)$limit : null; + $offset = $request->input('offset', 0); + $offset = $offset !== null ? (int)$offset : null; + $includeBasePrices = $request->input('includeBasePrices', true); + $forPurchasable = $request->input('forPurchasable', false); + + $conditionBuilder = null; + if ($condition && isset($condition['condition'])) { + /** @var CatalogPricingCondition $conditionBuilder */ + $conditionBuilder = Conditions::createCondition($condition['condition']); + } + + $site = Sites::getSiteById($siteId); + abort_if($site === null, 400, 'Invalid site ID: ' . $siteId); + + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $catalogPrices = app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->getCatalogPrices($site->getStore()->id, $conditionBuilder, $includeBasePrices, $searchText, $limit, $offset); + $catalogPricesPageInfo = null; + if ($limit !== null && $offset !== null) { + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $catalogPricesPageInfo = app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->getCatalogPricesPageInfo($site->getStore()->id, $conditionBuilder, $includeBasePrices, $searchText, $limit, $offset); + } + + $view = \Craft::$app->getView(); + + $tableHtml = template('commerce/prices/_table', [ + 'catalogPrices' => $catalogPrices->all(), + 'showPurchasable' => !$forPurchasable, + 'removeMargin' => $forPurchasable, + ]); + + return response()->json([ + 'headHtml' => $view->getHeadHtml(), + 'bodyHtml' => $view->getBodyHtml(), + 'tableHtml' => $tableHtml, + 'pageInfo' => $catalogPricesPageInfo, + ]); + } + + public function queueStatus(): string + { + $this->guard(); + + $site = Cp::requestedSite(); + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $storeHandle = $site?->getStore()->handle ?? null; + + return template('commerce/prices/_polling', [ + 'areCatalogPricingJobsRunning' => app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->areCatalogPricingJobsRunning(), + 'storeHandle' => $storeHandle, + ]); + } + + public function getCatalogPrices(Request $request): ?string + { + $this->guard(); + + // @TODO Remove this action once the catalog pricing UI refactor lands and no longer needs this endpoint + $purchasableId = $request->input('purchasableId'); + $storeId = $request->input('storeId'); + + if ($purchasableId === null || $storeId === null) { + return Html::tag('div', t('Purchasable ID is required.', category: 'commerce'), ['class' => 'error']); + } + + $purchasableId = (int)$purchasableId; + $storeId = (int)$storeId; + + $isPriceRecalculation = $request->has('basePrice') || $request->has('basePromotionalPrice'); + + if (!$isPriceRecalculation) { + return Purchasable::catalogPricingRulesTableByPurchasableId($purchasableId, $storeId); + } + + $basePrice = $request->input('basePrice'); + $basePromotionalPrice = $request->input('basePromotionalPrice'); + + $basePrice = $basePrice ? (float)$basePrice : null; + $basePromotionalPrice = $basePromotionalPrice ? (float)$basePromotionalPrice : null; + + $allPurchasableRules = app(CatalogPricingRules::class)->getAllCatalogPricingRulesByPurchasableId($purchasableId, $storeId); + $catalogPricing = app(\CraftCms\Commerce\CatalogPricing\CatalogPricing::class)->getCatalogPricesByPurchasableId($purchasableId); + + $catalogPricing->each(function(CatalogPricing $cp) use ($basePrice, $basePromotionalPrice, $allPurchasableRules) { + $rule = $allPurchasableRules->firstWhere('id', $cp->catalogPricingRuleId); + if (!$rule) { + return; + } + + $cp->price = app(CatalogPricingRules::class)->generateRulePriceFromPrice($basePrice, $basePromotionalPrice, $rule); + }); + + return Purchasable::catalogPricingRulesTableByPurchasableId($purchasableId, $storeId, $catalogPricing); + } +} diff --git a/src/Http/Controllers/Settings/CatalogPricingRulesController.php b/src/Http/Controllers/Settings/CatalogPricingRulesController.php new file mode 100644 index 0000000000..f72da362ce --- /dev/null +++ b/src/Http/Controllers/Settings/CatalogPricingRulesController.php @@ -0,0 +1,380 @@ +resolveStore($storeHandle); + + $catalogPricingRules = app(CatalogPricingRules::class)->getAllCatalogPricingRules($store->id); + + $actionButtonHtml = currentUserElement()?->can('commerce-createCatalogPricingRules') ? + Html::a(t('New catalog pricing rule', category: 'commerce'), + $store->getStoreSettingsUrl('pricing-rules/new'), + ['class' => 'btn submit add icon']) + : ''; + + $tableData = []; + $catalogPricingRules->each(function(CatalogPricingRule $pcr) use (&$tableData, $store) { + $effect = $pcr->apply === CatalogPricingRuleRecord::APPLY_BY_PERCENT || $pcr->apply === CatalogPricingRuleRecord::APPLY_TO_PERCENT + ? $pcr->applyAmountAsPercent . ' ' . ($pcr->apply === CatalogPricingRuleRecord::APPLY_BY_PERCENT + ? t('(off original price)', category: 'commerce') + : t('(of original price)', category: 'commerce')) + : Currency::formatAsCurrency($pcr->applyAmountAsFlat, app(PaymentCurrencies::class)->getPrimaryPaymentCurrency($store->id)->iso, true) . ' ' . ($pcr->apply === CatalogPricingRuleRecord::APPLY_BY_FLAT + ? t('(off original price)', category: 'commerce') + : t('(new price)', category: 'commerce')); + + $dateRange = ($pcr->dateFrom ? I18N::getFormatter()->asDatetime($pcr->dateFrom, 'short') : '∞') . ' - ' . ($pcr->dateTo ? I18N::getFormatter()->asDatetime($pcr->dateTo, 'short') : '∞'); + $dateRange = !$pcr->dateFrom && !$pcr->dateTo ? '∞' : $dateRange; + + $tableData[] = [ + 'id' => $pcr->id, + 'title' => t($pcr->name, category: 'site'), + 'url' => $pcr->getCpEditUrl(), + 'status' => $pcr->enabled ? true : false, + 'duration' => $dateRange, + 'effect' => $effect, + 'isPromotionalPrice' => $pcr->isPromotionalPrice, + ]; + }); + + $tableData = Json::encode($tableData); + + $actions = []; + if (currentUserElement()?->can('commerce-editCatalogPricingRules')) { + $actions[] = [ + 'label' => t('Set status', category: 'commerce'), + 'actions' => [ + [ + 'label' => t('Enabled', category: 'commerce'), + 'action' => 'commerce/catalog-pricing-rules/update-status', + 'param' => 'status', + 'value' => 'enabled', + 'status' => 'enabled', + ], + [ + 'label' => t('Disabled', category: 'commerce'), + 'action' => 'commerce/catalog-pricing-rules/update-status', + 'param' => 'status', + 'value' => 'disabled', + 'status' => 'disabled', + ], + ], + ]; + } + + $deleteAction = null; + if (currentUserElement()?->can('commerce-deleteCatalogPricingRules')) { + $actions[] = [ + 'label' => t('Delete', category: 'commerce'), + 'action' => 'commerce/catalog-pricing-rules/delete', + 'error' => true, + ]; + $deleteAction = '"commerce/catalog-pricing-rules/delete"'; + } + + $actions = Json::encode($actions); + + $js = <<'; + } + } + }, +]; + +new Craft.VueAdminTable({ + actions: actions, + checkboxes: true, + columns: columns, + fullPane: false, + container: '#pcr-vue-admin-table', + deleteAction: {$deleteAction}, + emptyMessage: Craft.t('commerce', 'No catalog pricing rules exist yet.'), + padded: true, + tableData: {$tableData} +}); +JS; + + HtmlStack::js($js, Position::BodyEnd); + + return $this->storeManagementCpScreen($storeHandle) + ->additionalButtonsHtml($actionButtonHtml) + ->contentTemplate('commerce/store-management/pricing-rules/index'); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + abort_unless(currentUserElement()?->can($id === null ? 'commerce-createCatalogPricingRules' : 'commerce-editCatalogPricingRules'), 403); + + $store = $this->resolveStore($storeHandle); + $storeHandle = $store->handle; + + if ($id) { + $catalogPricingRule = app(CatalogPricingRules::class)->getCatalogPricingRuleById($id, $store->id); + abort_if($catalogPricingRule === null || $catalogPricingRule->storeId !== $store->id, 404); + } else { + $catalogPricingRule = \Craft::createObject([ + 'class' => CatalogPricingRule::class, + 'storeId' => $store->id, + ]); + + $purchasableId = request()->input('purchasableId') ? (int)request()->input('purchasableId') : null; + if ($purchasableId && $purchasableType = Elements::getElementTypeById($purchasableId)) { + $purchasable = Elements::getElementById($purchasableId, $purchasableType, Cp::requestedSite()->id); + + if ($purchasable && $purchasable->title) { + $catalogPricingRule->name = t('{name} catalog price', ['name' => $purchasable->title], category: 'commerce'); + } + + $rule = Conditions::createConditionRule([ + 'class' => PurchasableConditionRule::class, + 'elementIds' => [$purchasableType => [$purchasableId]], + ]); + + /** @var CatalogPricingRulePurchasableCondition $purchasableCondition */ + $purchasableCondition = Conditions::createCondition(CatalogPricingRulePurchasableCondition::class); + $purchasableCondition->addConditionRule($rule); + $catalogPricingRule->setPurchasableCondition($purchasableCondition); + } + } + + $variables = $this->populateVariables(['id' => $id, 'catalogPricingRule' => $catalogPricingRule, 'storeHandle' => $storeHandle]); + + return $this->storeManagementCpScreen($storeHandle, false) + ->title(t('Catalog Pricing Rule', category: 'commerce')) + ->addCrumb(t('Pricing Rules', category: 'commerce'), $store->getStoreSettingsUrl('pricing-rules')) + ->action('commerce/catalog-pricing-rules/save') + ->redirectUrl('commerce/store-management/' . $store->handle . '/pricing-rules') + ->metaSidebarTemplate('commerce/store-management/pricing-rules/_sidebar', $variables) + ->tabs([ + 'rule' => [ + 'label' => t('Rule', category: 'commerce'), + 'url' => '#rule', + 'class' => array_filter([$variables['catalogPricingRule']->getErrors() ? 'error' : null]), + ], + 'conditions' => [ + 'label' => t('Conditions', category: 'commerce'), + 'url' => '#conditions', + ], + 'actions' => [ + 'label' => t('Actions', category: 'commerce'), + 'url' => '#actions', + 'class' => array_filter([($variables['catalogPricingRule']->getErrors('applyAmount') || $variables['catalogPricingRule']->getErrors('apply')) ? 'error' : null]), + ], + ]) + ->contentTemplate('commerce/store-management/pricing-rules/_edit', $variables); + } + + public function save(Request $request): Response + { + $id = $request->input('id') ? (int)$request->input('id') : null; + $storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; + + if ($id) { + $catalogPricingRule = app(CatalogPricingRules::class)->getCatalogPricingRuleById($id, $storeId); + abort_if($catalogPricingRule === null, 404, 'Catalog Pricing Rule not found'); + } else { + $catalogPricingRule = \Craft::createObject(CatalogPricingRule::class); + } + + abort_unless(currentUserElement()?->can($catalogPricingRule->id === null ? 'commerce-createCatalogPricingRules' : 'commerce-editCatalogPricingRules'), 403); + + $catalogPricingRule->storeId = $storeId; + $catalogPricingRule->name = $request->input('name'); + $catalogPricingRule->description = $request->input('description'); + $catalogPricingRule->apply = $request->input('apply'); + $catalogPricingRule->enabled = (bool)$request->input('enabled'); + $catalogPricingRule->isPromotionalPrice = (bool)$request->input('isPromotionalPrice'); + $catalogPricingRule->applyPriceType = $request->input('applyPriceType'); + + if (($date = $request->input('dateFrom')) !== null && $dateFrom = DateTimeHelper::toDateTime($date)) { + $catalogPricingRule->dateFrom = $dateFrom instanceof DateTime ? $dateFrom : DateTime::createFromInterface($dateFrom); + } + if (($date = $request->input('dateTo')) !== null && $dateTo = DateTimeHelper::toDateTime($date)) { + $catalogPricingRule->dateTo = $dateTo instanceof DateTime ? $dateTo : DateTime::createFromInterface($dateTo); + } + + $applyAmount = $request->input('applyAmount'); + + if ($catalogPricingRule->apply == CatalogPricingRuleRecord::APPLY_BY_PERCENT || $catalogPricingRule->apply == CatalogPricingRuleRecord::APPLY_TO_PERCENT) { + $applyAmount = Localization::normalizeNumber($applyAmount); + $catalogPricingRule->applyAmount = (float)$applyAmount / -100; + } else { + if (is_array($applyAmount)) { + $applyAmount += ['currency' => $catalogPricingRule->getStore()->getCurrency()]; + $applyAmount = Money::toDecimal(Money::toMoney($applyAmount)); + } + $catalogPricingRule->applyAmount = (float)$applyAmount * -1; + } + + $productCondition = $request->input('productCondition') ?? Conditions::createCondition([ + 'class' => CatalogPricingRuleProductCondition::class, + ]); + $catalogPricingRule->setProductCondition($productCondition); + + $variantCondition = $request->input('variantCondition') ?? Conditions::createCondition([ + 'class' => CatalogPricingRuleVariantCondition::class, + ]); + $catalogPricingRule->setVariantCondition($variantCondition); + + $purchasableCondition = $request->input('purchasableCondition') ?? Conditions::createCondition([ + 'class' => CatalogPricingRulePurchasableCondition::class, + ]); + $catalogPricingRule->setPurchasableCondition($purchasableCondition); + + $catalogPricingRule->setCustomerCondition($request->input('customerCondition')); + + if (app(CatalogPricingRules::class)->saveCatalogPricingRule($catalogPricingRule)) { + return $this->asSuccess(t('Catalog pricing rule saved.', category: 'commerce')); + } + + $variables = $this->populateVariables(['catalogPricingRule' => $catalogPricingRule]); + + return $this->asFailure(t('Couldn\'t save catalog pricing rule.', category: 'commerce'), $variables); + } + + public function delete(Request $request): Response + { + abort_unless(currentUserElement()?->can('commerce-deleteCatalogPricingRules'), 403); + + $id = $request->input('id'); + $ids = $request->input('ids'); + + abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); + + if ($id) { + abort_unless($request->expectsJson(), 400); + $ids = [$id]; + } + + foreach ($ids as $deleteId) { + app(CatalogPricingRules::class)->deleteCatalogPricingRuleById((int)$deleteId); + } + + if ($request->expectsJson()) { + return $this->asSuccess(); + } + + return $this->asSuccess(t('Catalog pricing rules deleted.', category: 'commerce'), redirect: url()->previous()); + } + + public function updateStatus(Request $request): Response + { + abort_unless(currentUserElement()?->can('commerce-editCatalogPricingRules'), 403); + + $ids = $request->input('ids'); + $status = $request->input('status'); + + abort_if(empty($ids), 400, 'Missing ids'); + + $storeId = null; + + DB::transaction(function() use ($ids, $status, &$storeId) { + $rules = CatalogPricingRuleRecord::whereIn('id', $ids)->get(); + + foreach ($rules as $rule) { + $storeId ??= $rule->storeId; + $rule->enabled = ($status == 'enabled'); + $rule->save(); + } + }); + + app(CatalogPricing::class)->createCatalogPricingJob([ + 'catalogPricingRuleIds' => $ids, + 'storeId' => $storeId, + ]); + + return $this->asSuccess(t('Catalog pricing rules updated.', category: 'commerce')); + } + + private function populateVariables(array $variables): array + { + /** @var CatalogPricingRule $catalogPricingRule */ + $catalogPricingRule = $variables['catalogPricingRule']; + + $variables['title'] = $catalogPricingRule->id ? $catalogPricingRule->name : t('Create a new catalog pricing rule', category: 'commerce'); + + $groups = UserGroups::getAllGroups(); + $variables['groups'] = $groups->mapWithKeys(fn($group) => [$group->id => $group->name])->all(); + + $variables['percentSymbol'] = I18N::getFormattingLocale()->getNumberSymbol(Locale::SYMBOL_PERCENT); + $primaryCurrencyIso = app(PaymentCurrencies::class)->getPrimaryPaymentCurrencyIso(); + $variables['currencySymbol'] = I18N::getLocale()->getCurrencySymbol($primaryCurrencyIso); + + $variables['applyAmount'] = ''; + if ($catalogPricingRule->applyAmount !== null) { + if ($catalogPricingRule->apply == CatalogPricingRuleRecord::APPLY_BY_PERCENT || $catalogPricingRule->apply == CatalogPricingRuleRecord::APPLY_TO_PERCENT) { + $amount = -(float)$catalogPricingRule->applyAmount * 100; + $variables['applyAmount'] = I18N::getFormatter()->asDecimal($amount); + } else { + $variables['applyAmount'] = I18N::getFormatter()->asDecimal(-(float)$catalogPricingRule->applyAmount); + } + } + + $variables['applyOptions'] = [ + ['optgroup' => t('Reduce price', category: 'commerce')], + ['label' => t('Reduce the price by a percentage of the original price', category: 'commerce'), 'value' => CatalogPricingRuleRecord::APPLY_BY_PERCENT], + ['label' => t('Reduce the price by a fixed amount', category: 'commerce'), 'value' => CatalogPricingRuleRecord::APPLY_BY_FLAT], + ['optgroup' => t('Set price', category: 'commerce')], + ['label' => t('Set the price to a percentage of the original price', category: 'commerce'), 'value' => CatalogPricingRuleRecord::APPLY_TO_PERCENT], + ['label' => t('Set the price to a flat amount', category: 'commerce'), 'value' => CatalogPricingRuleRecord::APPLY_TO_FLAT], + ]; + + $variables['applyPriceTypeOptions'] = [ + ['label' => t('Original price', category: 'commerce'), 'value' => 'price'], + ['label' => t('Original promotional price', category: 'commerce'), 'value' => 'promotionalPrice'], + ]; + + return $variables; + } +} diff --git a/src/Http/Controllers/Settings/DiscountsController.php b/src/Http/Controllers/Settings/DiscountsController.php new file mode 100644 index 0000000000..c0498950ed --- /dev/null +++ b/src/Http/Controllers/Settings/DiscountsController.php @@ -0,0 +1,713 @@ +resolveStore($storeHandle); + + $actionButtonHtml = currentUserElement()?->can('commerce-createDiscounts') + ? Html::a(t('New discount', category: 'commerce'), $store->getStoreSettingsUrl('discounts/new'), ['class' => 'btn submit add icon']) + : ''; + + $actions = []; + if (currentUserElement()?->can('commerce-editDiscounts')) { + $actions[] = [ + 'label' => t('Set status', category: 'commerce'), + 'actions' => [ + [ + 'label' => t('Enabled', category: 'commerce'), + 'action' => 'commerce/discounts/update-status', + 'param' => 'status', + 'value' => 'enabled', + 'status' => 'enabled', + ], + [ + 'label' => t('Disabled', category: 'commerce'), + 'action' => 'commerce/discounts/update-status', + 'param' => 'status', + 'value' => 'disabled', + 'status' => 'disabled', + ], + ], + ]; + } + + $deleteAction = null; + if (currentUserElement()?->can('commerce-deleteDiscounts')) { + $actions[] = [ + 'label' => t('Delete', category: 'commerce'), + 'action' => 'commerce/discounts/delete', + 'error' => true, + ]; + $deleteAction = '"commerce/discounts/delete"'; + } + + $actions = Json::encode($actions); + + $tableDataEndpoint = Url::actionUrl('commerce/discounts/table-data', ['storeId' => $store->id]); + + $js = <<'; + } + + return ''; + } + }, + { name: 'duration', title: Craft.t('commerce', 'Duration') }, + { name: 'timesUsed', title: Craft.t('commerce', 'Times Used') }, + { name: 'stop', title: Craft.t('commerce', 'Stops Processing?'), + callback: function(value) { + if (value) { + return ''; + } + + return ''; + } + }, + { name: 'ignore', title: Craft.t('commerce', 'Ignore Promotions?'), + callback: function(value) { + if (value) { + return ''; + } + + return ''; + } + }, + ]; + + new Craft.VueAdminTable({ + actions: actions, + checkboxes: true, + columns: columns, + fullPane: false, + container: '#discounts-vue-admin-table', + allowMultipleDeletions: true, + deleteAction: {$deleteAction}, + emptyMessage: Craft.t('commerce', 'No discounts exist yet.'), + padded: true, + paginatedReorderAction: 'commerce/discounts/reorder', + moveToPageAction: 'commerce/discounts/move-to-page', + reorderSuccessMessage: Craft.t('commerce', 'Discounts reordered.') , + reorderFailMessage: Craft.t('commerce', 'Couldn\'t reorder discounts.'), + tableDataEndpoint: '{$tableDataEndpoint}', + search: true, + perPage: 100, + }); +JS; + + HtmlStack::js($js, Position::BodyEnd); + + return $this->storeManagementCpScreen($storeHandle) + ->additionalButtonsHtml($actionButtonHtml) + ->contentTemplate('commerce/store-management/discounts/index'); + } + + public function tableData(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $storeId = $request->input('storeId'); + $store = app(Stores::class)->getStoreById($storeId); + abort_if($store === null, 400, 'Invalid store.'); + + $page = (int)$request->input('page', 1); + $limit = (int)$request->input('per_page', 100); + $search = $request->input('search'); + $offset = ($page - 1) * $limit; + + $sqlQuery = new Query() + ->from(['discounts' => Table::DISCOUNTS]) + ->select([ + 'discounts.id', + 'discounts.name', + 'discounts.enabled', + 'discounts.dateFrom', + 'discounts.dateTo', + 'discounts.totalDiscountUses', + 'discounts.ignorePromotions', + 'discounts.requireCouponCode', + 'discounts.stopProcessing', + 'discounts.sortOrder', + ]) + ->where(['discounts.storeId' => $storeId]) + ->orderBy(['sortOrder' => SORT_ASC]); + + if ($search) { + $likeOperator = DB::connection()->getDriverName() === 'pgsql' ? 'ILIKE' : 'LIKE'; + $sqlQuery + ->andWhere([ + 'or', + [$likeOperator, 'discounts.name', '%' . str_replace(' ', '%', $search) . '%', false], + [$likeOperator, 'discounts.description', '%' . str_replace(' ', '%', $search) . '%', false], + ['discounts.id' => new Query() + ->from(Table::COUPONS) + ->select('discountId') + ->where([$likeOperator, 'code', '%' . str_replace(' ', '%', $search) . '%', false]), + ], + ]); + } + + $total = $sqlQuery->count(); + + $sqlQuery->limit($limit); + $sqlQuery->offset($offset); + + $result = $sqlQuery->all(); + + $tableData = []; + $dateFormat = I18N::getFormattingLocale()->getDateTimeFormat('short', Locale::FORMAT_PHP); + foreach ($result as $item) { + $dateFrom = $item['dateFrom'] ? DateTimeHelper::toDateTime($item['dateFrom']) : null; + $dateTo = $item['dateTo'] ? DateTimeHelper::toDateTime($item['dateTo']) : null; + $dateRange = ($dateFrom ? $dateFrom->format($dateFormat) : '∞') . ' - ' . ($dateTo ? $dateTo->format($dateFormat) : '∞'); + + $dateRange = !$dateFrom && !$dateTo ? '∞' : $dateRange; + + $tableData[] = [ + 'id' => $item['id'], + 'title' => t($item['name'], category: 'site'), + 'url' => Url::cpUrl('commerce/store-management/' . $store->handle . '/discounts/' . $item['id']), + 'status' => (bool)$item['enabled'], + 'duration' => $dateRange, + 'timesUsed' => $item['totalDiscountUses'], + 'requireCouponCode' => (bool)$item['requireCouponCode'], + 'ignore' => (bool)$item['ignorePromotions'], + 'stop' => (bool)$item['stopProcessing'], + ]; + } + + return $this->asSuccess(data: [ + 'pagination' => AdminTable::paginationLinks($page, $total, $limit), + 'data' => $tableData, + ]); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + abort_unless(currentUserElement()?->can($id === null ? 'commerce-createDiscounts' : 'commerce-editDiscounts'), 403); + + $variables = ['id' => $id, 'isNewDiscount' => false]; + + $store = $this->resolveStore($storeHandle); + + $variables['siteIds'] = $store->getSites()->pluck('id')->all(); + $variables['storeHandle'] = $store->handle; + $variables['currency'] = $store->getCurrency(); + $variables['decimals'] = app(Currencies::class)->getSubunitFor($store->getCurrency()); + + if ($id) { + $discount = app(Discounts::class)->getDiscountById($id, $store->id); + abort_if($discount === null, 404); + } else { + $discount = \Craft::createObject([ + 'class' => Discount::class, + 'attributes' => [ + 'allCategories' => true, + 'allPurchasables' => true, + 'storeId' => $store->id, + ], + ]); + $variables['isNewDiscount'] = true; + } + $variables['discount'] = $discount; + + $this->populateVariables($variables); + $variables['percentSymbol'] = I18N::getFormattingLocale()->getNumberSymbol(Locale::SYMBOL_PERCENT); + \Craft::$app->getView()->registerAssetBundle(CouponsAsset::class); + + $variables['coupons'] = collect($discount->getCoupons()) + ->map(fn(Coupon $coupon) => $coupon->toArray()) + ->all(); + + $tabs = [ + 'discount' => [ + 'label' => t('Discount', category: 'commerce'), + 'url' => '#discount', + 'class' => $discount->getErrors('name') ? 'error' : '', + ], + 'coupons' => [ + 'label' => t('Coupons', category: 'commerce'), + 'url' => '#coupons', + 'class' => $discount->getErrors('code') ? 'error' : '', + ], + 'matchingItems' => [ + 'label' => t('Matching Items', category: 'commerce'), + 'url' => '#matching-items', + ], + 'conditions' => [ + 'label' => t('Conditions', category: 'commerce'), + 'url' => '#conditions', + 'class' => $discount->getErrors('startDate') || $discount->getErrors('endDate') ? 'error' : '', + ], + 'actions' => [ + 'label' => t('Actions', category: 'commerce'), + 'url' => '#actions', + 'class' => $discount->getErrors('startDate') || $discount->getErrors('endDate') ? 'error' : '', + ], + ]; + + return $this->storeManagementCpScreen($storeHandle, false) + ->title($variables['title']) + ->tabs($tabs) + ->addCrumb(t('Discounts', category: 'commerce'), $store->getStoreSettingsUrl('discounts')) + ->metaSidebarTemplate('commerce/store-management/discounts/_sidebar', $variables) + ->action('commerce/discounts/save') + ->redirectUrl($store->getStoreSettingsUrl('discounts')) + ->contentTemplate('commerce/store-management/discounts/_edit', $variables); + } + + public function save(Request $request): Response + { + $discount = new Discount(); + + $discount->id = $request->input('id'); + + abort_unless(currentUserElement()?->can($discount->id === null ? 'commerce-createDiscounts' : 'commerce-editDiscounts'), 403); + + $discount->storeId = $request->input('storeId'); + $discount->name = $request->input('name'); + $discount->description = $request->input('description'); + $discount->enabled = (bool)$request->input('enabled'); + $discount->setOrderCondition($request->input('orderCondition')); + $discount->setCustomerCondition($request->input('customerCondition')); + $discount->setShippingAddressCondition($request->input('shippingAddressCondition')); + $discount->setBillingAddressCondition($request->input('billingAddressCondition')); + $discount->requireCouponCode = (bool)$request->input('requireCouponCode'); + $discount->stopProcessing = (bool)$request->input('stopProcessing'); + $discount->purchaseQty = $request->input('purchaseQty'); + $discount->maxPurchaseQty = $request->input('maxPurchaseQty'); + $discount->percentageOffSubject = $request->input('percentageOffSubject'); + $discount->hasFreeShippingForMatchingItems = (bool)$request->input('hasFreeShippingForMatchingItems'); + $discount->hasFreeShippingForOrder = (bool)$request->input('hasFreeShippingForOrder'); + $discount->excludeOnPromotion = (bool)$request->input('excludeOnPromotion'); + $discount->couponFormat = $request->input('couponFormat', Coupons::DEFAULT_COUPON_FORMAT); + $discount->perUserLimit = (int)$request->input('perUserLimit'); + $discount->perEmailLimit = (int)$request->input('perEmailLimit'); + $discount->totalDiscountUseLimit = (int)$request->input('totalDiscountUseLimit'); + $discount->ignorePromotions = (bool)$request->input('ignorePromotions'); + $discount->categoryRelationshipType = $request->input('categoryRelationshipType', $discount->categoryRelationshipType); + $discount->appliedTo = $request->input('appliedTo') ?: DiscountRecord::APPLIED_TO_MATCHING_LINE_ITEMS; + $discount->orderConditionFormula = $request->input('orderConditionFormula'); + + $moneyInputAttributes = ['baseDiscount', 'perItemDiscount', 'purchaseTotal']; + foreach ($moneyInputAttributes as $attr) { + $attrValue = $request->input($attr) ?: ['value' => '0']; + $attrValue['value'] = preg_replace('/[^0-9\.\-\,]/', '', (string)$attrValue['value']); + $attrValue += ['currency' => $discount->getStore()->getCurrency()]; + $attrValue = Money::toDecimal(Money::toMoney($attrValue)); + + if ($attr !== 'purchaseTotal') { + $attrValue = (float)$attrValue; + if ($attrValue > 0) { + $attrValue *= -1; + } + } + + $discount->{$attr} = (float)$attrValue; + } + + $date = $request->input('dateFrom'); + if ($date && $dateFrom = DateTimeHelper::toDateTime($date)) { + $discount->dateFrom = $dateFrom instanceof DateTime ? $dateFrom : DateTime::createFromInterface($dateFrom); + } + + $date = $request->input('dateTo'); + if ($date && $dateTo = DateTimeHelper::toDateTime($date)) { + $discount->dateTo = $dateTo instanceof DateTime ? $dateTo : DateTime::createFromInterface($dateTo); + } + + $percentDiscount = $request->input('percentDiscount', 0); + $percentDiscount = preg_replace('/[^0-9\.\-\,]/', '', (string)$percentDiscount); + $discount->percentDiscount = -Localization::normalizePercentage($percentDiscount); + + $allPurchasables = !$request->input('allPurchasables', false); + if ($discount->allPurchasables = $allPurchasables) { + $discount->setPurchasableIds([]); + } else { + $purchasables = []; + $purchasableGroups = $request->input('purchasables') ?: []; + foreach ($purchasableGroups as $group) { + if (is_array($group)) { + array_push($purchasables, ...$group); + } + } + $discount->setPurchasableIds(array_unique($purchasables)); + } + + $allCategories = !$request->input('allCategories', false); + if ($discount->allCategories = $allCategories) { + $discount->setCategoryIds([]); + } else { + $relatedElements = []; + $relatedElementByType = $request->input('relatedElements') ?: []; + foreach ($relatedElementByType as $type) { + if (is_array($type)) { + array_push($relatedElements, ...$type); + } + } + $discount->setCategoryIds(array_unique($relatedElements)); + } + + $coupons = $request->input('coupons') ?: []; + $this->setCouponsOnDiscount(coupons: $coupons, discount: $discount); + + if (app(Discounts::class)->saveDiscount($discount)) { + return $this->asModelSuccess($discount, t('Discount saved.', category: 'commerce'), 'discount'); + } + + return $this->asModelFailure($discount, t('Couldn\'t save discount.', category: 'commerce'), 'discount'); + } + + private function setCouponsOnDiscount(array $coupons, Discount $discount): void + { + if (empty($coupons)) { + $discount->setCoupons([]); + return; + } + + $discountCoupons = []; + + foreach ($coupons as $c) { + $discountCoupons[] = \Craft::createObject(Coupon::class, [ + 'config' => [ + 'attributes' => [ + 'id' => $c['id'] ?: null, + 'discountId' => null, + 'code' => $c['code'], + 'uses' => $c['uses'] ?: 0, + 'maxUses' => is_numeric($c['maxUses']) ? (int)$c['maxUses'] : null, + ], + ], + ]); + } + + $discount->setCoupons($discountCoupons); + } + + public function reorder(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + abort_unless($request->input('ids'), 400, 'Missing ids'); + + $ids = Json::decode($request->input('ids')); + $key = (int)$request->input('startPosition'); + + $idsOrdered = []; + foreach ($ids as $id) { + // Temporary -1 because the `reorderDiscounts()` method will increment the key before saving. + $idsOrdered[$key - 1] = $id; + $key++; + } + + if (!app(Discounts::class)->reorderDiscounts($idsOrdered)) { + return $this->asFailure(t('Couldn\'t reorder discounts.', category: 'commerce')); + } + + return $this->asSuccess(); + } + + public function moveToPage(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + $page = $request->input('page'); + $perPage = $request->input('perPage'); + + if (AdminTable::moveToPage(Table::DISCOUNTS, $id, $page, $perPage)) { + return $this->asSuccess(t('Discounts reordered.', category: 'commerce')); + } + + return $this->asFailure(t('Couldn\'t reorder discounts.', category: 'commerce')); + } + + public function delete(Request $request): Response + { + abort_unless(currentUserElement()?->can('commerce-deleteDiscounts'), 403); + + $id = $request->input('id'); + $ids = $request->input('ids'); + + abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); + + if ($id) { + abort_unless($request->expectsJson(), 400); + $ids = [$id]; + } + + foreach ($ids as $deleteId) { + app(Discounts::class)->deleteDiscountById($deleteId); + } + + if ($request->expectsJson()) { + return $this->asSuccess(); + } + + return $this->asSuccess(t('Discounts deleted.', category: 'commerce'), redirect: url()->previous()); + } + + public function clearDiscountUses(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + $type = $request->input('type', 'total'); + $types = [self::DISCOUNT_COUNTER_TYPE_TOTAL, self::DISCOUNT_COUNTER_TYPE_CUSTOMER, self::DISCOUNT_COUNTER_TYPE_EMAIL]; + + if (!in_array($type, $types, true)) { + return $this->asFailure(t('Type not in allowed options.', category: 'commerce')); + } + + match ($type) { + self::DISCOUNT_COUNTER_TYPE_EMAIL => app(Discounts::class)->clearEmailUsageHistoryById($id), + self::DISCOUNT_COUNTER_TYPE_CUSTOMER => app(Discounts::class)->clearCustomerUsageHistoryById($id), + self::DISCOUNT_COUNTER_TYPE_TOTAL => app(Discounts::class)->clearDiscountUsesById($id), + }; + + return $this->asSuccess(); + } + + public function updateStatus(Request $request): Response + { + abort_unless(currentUserElement()?->can('commerce-editDiscounts'), 403); + + $ids = $request->input('ids'); + $status = $request->input('status'); + + abort_if(empty($ids), 400, 'Missing ids'); + + DB::transaction(function() use ($ids, $status) { + $discounts = DiscountRecord::whereIn('id', $ids)->get(); + + foreach ($discounts as $discount) { + $discount->enabled = ($status == 'enabled'); + $discount->save(); + } + }); + + return $this->asSuccess(t('Discounts updated.', category: 'commerce')); + } + + public function getDiscountsByPurchasableId(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + $id = $request->input('id'); + + if (!$id) { + return $this->asFailure(t('Purchasable ID is required.', category: 'commerce')); + } + + $purchasable = app(Purchasables::class)->getPurchasableById($id); + + if (!$purchasable) { + return $this->asFailure(t('No purchasable available.', category: 'commerce')); + } + + $discounts = []; + $purchasableDiscounts = app(Discounts::class)->getDiscountsRelatedToPurchasable($purchasable); + foreach ($purchasableDiscounts as $discount) { + if (!Arr::contains($discounts, 'id', $discount->id)) { + $discountArray = $discount->toArray(); + $discountArray['cpEditUrl'] = $discount->getCpEditUrl(); + $discounts[] = $discountArray; + } + } + + return $this->asSuccess(data: ['discounts' => $discounts]); + } + + private function populateVariables(array &$variables): void + { + $discount = $variables['discount']; + + $variables['title'] = $discount->id ? $discount->name : t('Create a Discount', category: 'commerce'); + + if (Edition::get() === Edition::Pro) { + $groups = UserGroups::getAllGroups(); + $variables['groups'] = $groups->mapWithKeys(fn($group) => [$group->id => $group->name])->all(); + } else { + $variables['groups'] = []; + } + + $flipNegativeNumberAttributes = ['baseDiscount', 'perItemDiscount']; + foreach ($flipNegativeNumberAttributes as $attr) { + if (!isset($discount->{$attr})) { + continue; + } + + if ($discount->{$attr} < 0) { + $discount->{$attr} *= -1; + } elseif ($discount->{$attr} == 0) { + $discount->{$attr} = 0; + } + } + + $variables['counterTypeTotal'] = self::DISCOUNT_COUNTER_TYPE_TOTAL; + $variables['counterTypeEmail'] = self::DISCOUNT_COUNTER_TYPE_EMAIL; + $variables['counterTypeUser'] = self::DISCOUNT_COUNTER_TYPE_CUSTOMER; + + if ($discount->id) { + $variables['emailUsage'] = app(Discounts::class)->getEmailUsageStatsById($discount->id); + $variables['customerUsage'] = app(Discounts::class)->getCustomerUsageStatsById($discount->id); + } else { + $variables['emailUsage'] = 0; + $variables['customerUsage'] = 0; + } + + $variables['categoryElementType'] = Category::class; + $variables['entryElementType'] = Entry::class; + + $categories = []; + $entries = []; + + $request = request(); + if (empty($variables['id']) && $request->input('categoryIds')) { + $categoryIds = explode('|', (string)$request->input('categoryIds')); + } else { + $categoryIds = $discount->getCategoryIds(); + } + + foreach ($categoryIds as $categoryId) { + $elementId = (int)$categoryId; + $element = Elements::getElementById($elementId, siteId: '*'); + + if ($element instanceof Category) { + $categories[] = $element; + } elseif ($element instanceof Entry) { + $entries[] = $element; + } + } + + $variables['categories'] = $categories; + $variables['entries'] = $entries; + + $variables['elementRelationshipTypeOptions'] = [ + DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_SOURCE => t('The purchasable defines the relationship', category: 'commerce'), + DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_TARGET => t('The purchasable is related by another element', category: 'commerce'), + DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH => t('Either way', category: 'commerce'), + ]; + + $variables['appliedTo'] = [ + DiscountRecord::APPLIED_TO_MATCHING_LINE_ITEMS => t('Discount the matching items only', category: 'commerce'), + DiscountRecord::APPLIED_TO_ALL_LINE_ITEMS => t('Discount all line items', category: 'commerce'), + ]; + + $purchasableIds = []; + if (empty($variables['id']) && $request->input('purchasableIds')) { + $purchasableIdsFromUrl = explode('|', (string)$request->input('purchasableIds')); + foreach ($purchasableIdsFromUrl as $purchasableId) { + $purchasable = Elements::getElementById((int)$purchasableId, siteId: $variables['siteIds']); + if ($purchasable instanceof Product) { + $purchasableIds[] = $purchasable->defaultVariantId; + } else { + $purchasableIds[] = $purchasableId; + } + } + $discount->allPurchasables = false; + } else { + $purchasableIds = $discount->getPurchasableIds(); + } + + $purchasableIds = array_filter($purchasableIds); + + $purchasables = []; + foreach ($purchasableIds as $purchasableId) { + $purchasable = Elements::getElementById((int)$purchasableId, siteId: $variables['siteIds']); + if ($purchasable instanceof PurchasableInterface) { + $class = $purchasable::class; + $purchasables[$class] ??= []; + $purchasables[$class][] = $purchasable; + } + } + $variables['purchasables'] = $purchasables; + + $variables['purchasableTypes'] = []; + $purchasableTypes = app(Purchasables::class)->getAllPurchasableElementTypes(); + + /** @var Purchasable $purchasableType */ + foreach ($purchasableTypes as $purchasableType) { + $variables['purchasableTypes'][] = [ + 'name' => $purchasableType::displayName(), + 'elementType' => $purchasableType, + ]; + } + } + + public function generateCoupons(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $count = (int)$request->input('count', 0); + $format = $request->input('format', Coupons::DEFAULT_COUPON_FORMAT); + $existingCodes = $request->input('existingCodes', []); + + try { + $coupons = app(Coupons::class)->generateCouponCodes(count: $count, format: $format, existingCodes: $existingCodes); + } catch (\Exception $e) { + return $this->asFailure(message: t('Unable to generate coupon codes: {message}', ['message' => $e->getMessage()], category: 'commerce')); + } + + return $this->asSuccess(data: ['coupons' => $coupons]); + } +} diff --git a/src/Http/Controllers/Settings/EmailsController.php b/src/Http/Controllers/Settings/EmailsController.php new file mode 100644 index 0000000000..cd1c11f251 --- /dev/null +++ b/src/Http/Controllers/Settings/EmailsController.php @@ -0,0 +1,167 @@ +readOnly = !$generalConfig->allowAdminChanges; + } + + public function index(): string + { + $emails = []; + $stores = app(Stores::class)->getAllStores(); + + $stores->each(function(Store $store) use (&$emails) { + $emails[$store->handle] = app(Emails::class)->getAllEmails($store->id); + }); + + return pageTemplate('commerce/settings/emails/index', [ + 'stores' => $stores->all(), + 'emails' => $emails, + 'readOnly' => $this->readOnly, + ], TemplateMode::Cp); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + if ($storeHandle === null || !$store = app(Stores::class)->getStoreByHandle($storeHandle)) { + $store = app(Stores::class)->getPrimaryStore(); + } + + if ($id) { + $email = app(Emails::class)->getEmailById($id, $store->id); + abort_if($email === null, 404); + } else { + $email = \Craft::createObject([ + 'class' => Email::class, + 'attributes' => ['storeId' => $store->id], + ]); + } + + $title = $email->id ? $email->name : t('Create a new email', category: 'commerce'); + + $pdfs = app(Pdfs::class)->getAllPdfs($email->storeId); + $pdfList = ['' => t('Do not attach a PDF to this email', category: 'commerce')]; + $pdfList = Arr::merge($pdfList, $pdfs->mapWithKeys(fn(Pdf $pdf) => [$pdf->id => $pdf->name])->all()); + $senderAddressPlaceholder = App::mailSettings()->fromEmail; + $senderNamePlaceholder = App::mailSettings()->fromName; + + $emailLanguageOptions = [ + EmailRecord::LOCALE_ORDER_LANGUAGE => t('The language the order was made in.', category: 'commerce'), + ]; + + $emailLanguageOptions = array_merge($emailLanguageOptions, LocaleHelper::getSiteAndOtherLanguages()); + + $emailRenderSiteOptions = [ + '' => t('The site the order was made in.', category: 'commerce'), + ['optgroup' => t('Sites', category: 'commerce')], + ] + collect(Sites::getAllSites())->mapWithKeys(fn(Site $site) => [$site->id => $site->name])->all(); + + return new CpScreenResponse() + ->title($title) + ->crumbs([ + ['label' => t('Commerce', category: 'commerce'), 'url' => 'commerce'], + ['label' => t('Settings'), 'url' => 'commerce/settings', 'ariaLabel' => t('Commerce Settings', category: 'commerce')], + ['label' => t('Emails', category: 'commerce'), 'url' => 'commerce/settings/emails'], + ]) + ->selectedSubnavItem('settings') + ->action('commerce/emails/save') + ->redirectUrl('commerce/settings/emails') + ->contentTemplate('commerce/settings/emails/_edit', [ + 'email' => $email, + 'pdfList' => $pdfList, + 'senderAddressPlaceholder' => $senderAddressPlaceholder, + 'senderNamePlaceholder' => $senderNamePlaceholder, + 'emailLanguageOptions' => $emailLanguageOptions, + 'emailRenderSiteOptions' => $emailRenderSiteOptions, + 'readOnly' => $this->readOnly, + ]); + } + + public function save(Request $request): Response + { + $emailsService = app(Emails::class); + $emailId = $request->input('emailId') ? (int)$request->input('emailId') : null; + $storeId = $request->input('storeId'); + abort_if(!$storeId, 400, "Invalid store ID: $storeId"); + $storeId = (int)$storeId; + + if ($emailId) { + $email = $emailsService->getEmailById($emailId, $storeId); + abort_if($email === null, 400, "Invalid email ID: $emailId"); + } else { + $email = new Email(); + } + + $renderSiteId = $request->input('renderSiteId'); + + $email->storeId = $storeId; + $email->name = $request->input('name'); + $email->subject = $request->input('subject'); + $email->recipientType = $request->input('recipientType'); + $email->setTo($request->input('to')); + $email->setBcc($request->input('bcc')); + $email->setCc($request->input('cc')); + $email->replyTo = $request->input('replyTo'); + $email->enabled = (bool)$request->input('enabled'); + $email->templatePath = $request->input('templatePath'); + $email->plainTextTemplatePath = $request->input('plainTextTemplatePath'); + $pdfId = $request->input('pdfId'); + $email->pdfId = $pdfId ? (int)$pdfId : null; + $email->language = $request->input('language'); + $email->renderSiteId = $renderSiteId ? (int)$renderSiteId : null; + $email->setSenderAddress($request->input('senderAddress')); + $email->setSenderName($request->input('senderName')); + + if (!$emailsService->saveEmail($email)) { + return $this->asModelFailure($email, t('Couldn\'t save email.', category: 'commerce'), 'email'); + } + + return $this->asModelSuccess($email, t('Email saved.', category: 'commerce'), 'email'); + } + + public function delete(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + abort_if(!$id, 400, 'Missing email id'); + + if (!app(Emails::class)->deleteEmailById((int)$id)) { + return $this->asFailure(t('Couldn\'t delete email.', category: 'commerce')); + } + + return $this->asSuccess(); + } +} diff --git a/src/Http/Controllers/Settings/GatewaysController.php b/src/Http/Controllers/Settings/GatewaysController.php new file mode 100644 index 0000000000..822f1f7d0c --- /dev/null +++ b/src/Http/Controllers/Settings/GatewaysController.php @@ -0,0 +1,194 @@ +readOnly = !$generalConfig->allowAdminChanges; + } + + public function index(): string + { + $gateways = app(Gateways::class)->getAllGateways(); + $archivedGateways = app(Gateways::class)->getAllArchivedGateways(); + + if (!empty($archivedGateways)) { + $gatewayIdsWithTransactions = DB::table(Table::TRANSACTIONS) + ->select('gatewayId') + ->groupBy('gatewayId') + ->pluck('gatewayId') + ->all(); + + foreach ($archivedGateways as &$gateway) { + $missing = $gateway instanceof MissingGateway; + $gateway = [ + 'id' => $gateway->id, + 'title' => Html::encode(t($gateway->name, category: 'site')), + 'handle' => Html::encode($gateway->handle), + 'type' => [ + 'missing' => $missing, + 'name' => Html::encode($missing ? $gateway->expectedType : $gateway->displayName()), + ], + 'hasTransactions' => in_array($gateway->id, $gatewayIdsWithTransactions), + ]; + } + } + + return pageTemplate('commerce/settings/gateways/index', [ + 'gateways' => $gateways, + 'archivedGateways' => array_values($archivedGateways), + 'readOnly' => $this->readOnly, + ], TemplateMode::Cp); + } + + public function edit(?int $id = null): string + { + $gatewayService = app(Gateways::class); + + if ($id) { + $gateway = $gatewayService->getGatewayById($id); + abort_if($gateway === null, 404, 'Gateway not found'); + } else { + $gateway = $gatewayService->createGateway(['type' => Dummy::class]); + } + + /** @var string[] $allGatewayTypes */ + $allGatewayTypes = $gatewayService->getAllGatewayTypes(); + + // Make sure the selected gateway class is in there + if (!in_array($gateway::class, $allGatewayTypes, true)) { + $allGatewayTypes[] = $gateway::class; + } + + $gatewayInstances = []; + $gatewayOptions = []; + + foreach ($allGatewayTypes as $class) { + if ($class === $gateway::class || $class::isSelectable()) { + $gatewayInstances[$class] = $gatewayService->createGateway($class); + + $gatewayOptions[] = [ + 'value' => $class, + 'label' => $class::displayName(), + ]; + } + } + + return pageTemplate('commerce/settings/gateways/_edit', [ + 'id' => $id, + 'gateway' => $gateway, + 'gatewayTypes' => $allGatewayTypes, + 'gatewayInstances' => $gatewayInstances, + 'gatewayOptions' => $gatewayOptions, + 'title' => $gateway->id ? $gateway->name : t('Create a new gateway', category: 'commerce'), + 'readOnly' => $this->readOnly, + ], TemplateMode::Cp); + } + + public function save(Request $request): Response + { + $gatewayService = app(Gateways::class); + + $type = $request->input('type'); + abort_if($type === null, 400, 'Missing gateway type'); + $gatewayId = $request->input('id'); + + $config = [ + 'id' => $gatewayId, + 'type' => $type, + 'name' => $request->input('name'), + 'handle' => $request->input('handle'), + 'paymentType' => $request->input('paymentTypes.' . $type . '.paymentType'), + 'isFrontendEnabled' => $request->input('isFrontendEnabled'), + 'settings' => $request->input('types.' . $type), + ]; + + // Handle order condition if it's in the request + $orderCondition = $request->input('orderCondition'); + if ($orderCondition !== null) { + $config['orderCondition'] = $orderCondition; + } + + // Handle billing address condition if it's in the request + $billingAddressCondition = $request->input('billingAddressCondition'); + if ($billingAddressCondition !== null) { + $config['billingAddressCondition'] = $billingAddressCondition; + } + + // Handle shipping address condition if it's in the request + $shippingAddressCondition = $request->input('shippingAddressCondition'); + if ($shippingAddressCondition !== null) { + $config['shippingAddressCondition'] = $shippingAddressCondition; + } + + // For new gateway avoid NULL value. + if (!$request->input('id')) { + $config['isArchived'] = false; + } + + // If this is an existing gateway, populate with properties unchangeable by this action. + if ($gatewayId) { + $savedGateway = $gatewayService->getGatewayById((int)$gatewayId); + $config['uid'] = $savedGateway->uid; + $config['sortOrder'] = $savedGateway->sortOrder; + } + + $gateway = $gatewayService->createGateway($config); + + if (!$gatewayService->saveGateway($gateway)) { + return $this->asModelFailure($gateway, t('Couldn\'t save gateway.', category: 'commerce'), 'gateway'); + } + + return $this->asModelSuccess($gateway, t('Gateway saved.', category: 'commerce'), 'gateway'); + } + + public function archive(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + abort_if(!$id, 400, 'Missing gateway id'); + + if (!app(Gateways::class)->archiveGatewayById((int)$id)) { + return $this->asFailure(t('Could not archive gateway.', category: 'commerce')); + } + + return $this->asSuccess(); + } + + public function reorder(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $ids = json_decode($request->input('ids'), true); + + if (!app(Gateways::class)->reorderGateways($ids)) { + return $this->asFailure(t('Couldn\'t reorder gateways.', category: 'commerce')); + } + + return $this->asSuccess(); + } +} diff --git a/src/Http/Controllers/Settings/LineItemStatusesController.php b/src/Http/Controllers/Settings/LineItemStatusesController.php new file mode 100644 index 0000000000..e524b3978a --- /dev/null +++ b/src/Http/Controllers/Settings/LineItemStatusesController.php @@ -0,0 +1,153 @@ +readOnly = !$generalConfig->allowAdminChanges; + } + + public function index(): string + { + $lineItemStatuses = []; + $stores = app(Stores::class)->getAllStores(); + + $stores->each(function(Store $store) use (&$lineItemStatuses) { + $lineItemStatuses[$store->handle] = app(LineItemStatuses::class)->getAllLineItemStatuses($store->id); + }); + + return pageTemplate('commerce/settings/lineitemstatuses/index', [ + 'lineItemStatuses' => $lineItemStatuses, + 'stores' => $stores->all(), + 'readOnly' => $this->readOnly, + ], TemplateMode::Cp); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + if ($storeHandle === null || !$store = app(Stores::class)->getStoreByHandle($storeHandle)) { + $store = app(Stores::class)->getPrimaryStore(); + } + + if ($id) { + $lineItemStatus = app(LineItemStatuses::class)->getLineItemStatusById($id, $store->id); + abort_if($lineItemStatus === null, 404); + } else { + $lineItemStatus = \Craft::createObject([ + 'class' => LineItemStatus::class, + 'storeId' => $store->id, + ]); + } + + $statusColors = ['green', 'orange', 'red', 'blue', 'yellow', 'pink', 'purple', 'turquoise', 'light', 'grey', 'black']; + $nextAvailableColor = null; + + if ($lineItemStatus->id) { + $title = $lineItemStatus->name; + } else { + $title = t('Create a new line item status', category: 'commerce'); + + $availableColors = $statusColors; + app(LineItemStatuses::class)->getAllLineItemStatuses($store->id)->each(function(LineItemStatus $status) use (&$availableColors) { + $key = array_search($status->color, $availableColors, true); + if ($key !== false) { + unset($availableColors[$key]); + } + }); + + $nextAvailableColor = !empty($availableColors) ? array_shift($availableColors) : 'green'; + } + + return new CpScreenResponse() + ->title($title) + ->crumbs([ + ['label' => t('Commerce', category: 'commerce'), 'url' => 'commerce'], + ['label' => t('Settings'), 'url' => 'commerce/settings', 'ariaLabel' => t('Commerce Settings', category: 'commerce')], + ['label' => t('Line Item Statuses', category: 'commerce'), 'url' => 'commerce/settings/lineitemstatuses'], + ]) + ->selectedSubnavItem('settings') + ->action('commerce/line-item-statuses/save') + ->redirectUrl('commerce/settings/lineitemstatuses') + ->contentTemplate('commerce/settings/lineitemstatuses/_edit', [ + 'lineItemStatus' => $lineItemStatus, + 'statusColors' => $statusColors, + 'nextAvailableColor' => $nextAvailableColor, + 'readOnly' => $this->readOnly, + ]); + } + + public function save(Request $request): Response + { + $id = $request->input('id') ? (int)$request->input('id') : null; + $storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; + $lineItemStatus = $id ? app(LineItemStatuses::class)->getLineItemStatusById($id, $storeId) : null; + $lineItemStatus ??= new LineItemStatus(); + + $lineItemStatus->storeId = $storeId; + $lineItemStatus->name = $request->input('name'); + $lineItemStatus->handle = $request->input('handle'); + $lineItemStatus->color = $request->input('color'); + $lineItemStatus->default = (bool)$request->input('default'); + + if (!app(LineItemStatuses::class)->saveLineItemStatus($lineItemStatus)) { + return $this->asModelFailure($lineItemStatus, t('Couldn\'t save line item status.', category: 'commerce'), 'lineItemStatus'); + } + + return $this->asModelSuccess($lineItemStatus, t('Order status saved.', category: 'commerce'), 'lineItemStatus'); + } + + public function reorder(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + abort_unless($request->input('ids'), 400, 'Missing ids'); + + $ids = Json::decode($request->input('ids')); + + if (!app(LineItemStatuses::class)->reorderLineItemStatuses($ids)) { + return $this->asFailure(t('Couldn\'t reorder Line Item Statuses.', category: 'commerce')); + } + + return $this->asSuccess(); + } + + public function archive(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $lineItemStatusId = $request->input('id'); + abort_if(!$lineItemStatusId, 400, 'Missing line item status id'); + + $storeId = DB::table(Table::LINEITEMSTATUSES)->where('id', $lineItemStatusId)->value('storeId'); + + if (!$storeId || !app(LineItemStatuses::class)->archiveLineItemStatusById((int)$lineItemStatusId, $storeId)) { + return $this->asFailure(t('Couldn\'t archive Line Item Status.', category: 'commerce')); + } + + return $this->asSuccess(); + } +} diff --git a/src/Http/Controllers/Settings/OrderSettingsController.php b/src/Http/Controllers/Settings/OrderSettingsController.php new file mode 100644 index 0000000000..910214cfad --- /dev/null +++ b/src/Http/Controllers/Settings/OrderSettingsController.php @@ -0,0 +1,73 @@ +readOnly = !$generalConfig->allowAdminChanges; + } + + public function edit(): string + { + $fieldLayout = Fields::getLayoutByType(Order::class); + + return pageTemplate('commerce/settings/ordersettings/_edit', [ + 'fieldLayout' => $fieldLayout, + 'title' => t('Order Settings', category: 'commerce'), + 'readOnly' => $this->readOnly, + ], TemplateMode::Cp); + } + + public function save(): Response + { + $fieldLayout = Fields::assembleLayoutFromPost(); + + $fieldLayout->reservedFieldHandles = [ + 'billingAddress', + 'customer', + 'estimatedBillingAddress', + 'estimatedShippingAddress', + 'paymentAmount', + 'paymentCurrency', + 'paymentSource', + 'recalculationMode', + 'shippingAddress', + ]; + + if (!$fieldLayout->validate()) { + return $this->asFailure(t('Couldn\'t save order fields.', category: 'commerce')); + } + + if ($currentOrderFieldLayout = ProjectConfig::get(Orders::CONFIG_FIELDLAYOUT_KEY)) { + $uid = array_key_first($currentOrderFieldLayout); + } else { + $uid = (string)Str::uuid(); + } + + $configData = [$uid => $fieldLayout->getConfig()]; + ProjectConfig::set(Orders::CONFIG_FIELDLAYOUT_KEY, $configData); + + return $this->asSuccess(t('Order fields saved.', category: 'commerce')); + } +} diff --git a/src/Http/Controllers/Settings/OrderStatusesController.php b/src/Http/Controllers/Settings/OrderStatusesController.php new file mode 100644 index 0000000000..0d49bf5358 --- /dev/null +++ b/src/Http/Controllers/Settings/OrderStatusesController.php @@ -0,0 +1,189 @@ +readOnly = !$generalConfig->allowAdminChanges; + } + + public function index(): string + { + $orderStatuses = []; + $stores = app(Stores::class)->getAllStores(); + + $stores->each(function(Store $store) use (&$orderStatuses) { + $orderStatuses[$store->handle] = app(OrderStatuses::class)->getAllOrderStatuses($store->id); + }); + + return pageTemplate('commerce/settings/orderstatuses/index', [ + 'orderStatuses' => $orderStatuses, + 'stores' => $stores->all(), + 'readOnly' => $this->readOnly, + ], TemplateMode::Cp); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + if ($storeHandle === null || !$store = app(Stores::class)->getStoreByHandle($storeHandle)) { + $store = app(Stores::class)->getPrimaryStore(); + } + + if ($id) { + $orderStatus = app(OrderStatuses::class)->getOrderStatusById($id, $store->id); + abort_if($orderStatus === null, 404); + } else { + $orderStatus = \Craft::createObject([ + 'class' => OrderStatus::class, + 'attributes' => ['storeId' => $store->id], + ]); + } + + $statusColors = ['green', 'orange', 'red', 'blue', 'yellow', 'pink', 'purple', 'turquoise', 'light', 'grey', 'black']; + $nextAvailableColor = null; + + if ($orderStatus->id) { + $title = $orderStatus->name; + } else { + $title = t('Create a new order status', category: 'commerce'); + + $availableColors = $statusColors; + app(OrderStatuses::class)->getAllOrderStatuses($store->id)->each(function(OrderStatus $status) use (&$availableColors) { + $key = array_search($status->color, $availableColors, true); + if ($key !== false) { + unset($availableColors[$key]); + } + }); + + $nextAvailableColor = !empty($availableColors) ? array_shift($availableColors) : 'green'; + } + + $emails = app(Emails::class)->getAllEmails($store->id)->mapWithKeys(fn(Email $email) => [$email->id => $email->name])->all(); + + return new CpScreenResponse() + ->title($title) + ->crumbs([ + ['label' => t('Commerce', category: 'commerce'), 'url' => 'commerce'], + ['label' => t('Settings'), 'url' => 'commerce/settings', 'ariaLabel' => t('Commerce Settings', category: 'commerce')], + ['label' => t('Order Statuses', category: 'commerce'), 'url' => 'commerce/settings/orderstatuses'], + ]) + ->selectedSubnavItem('settings') + ->action('commerce/order-statuses/save') + ->redirectUrl('commerce/settings/orderstatuses') + ->contentTemplate('commerce/settings/orderstatuses/_edit', [ + 'orderStatus' => $orderStatus, + 'statusColors' => $statusColors, + 'nextAvailableColor' => $nextAvailableColor, + 'emails' => $emails, + 'readOnly' => $this->readOnly, + ]); + } + + public function save(Request $request): Response + { + $id = $request->input('id') ? (int)$request->input('id') : null; + $storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; + $orderStatus = $id ? app(OrderStatuses::class)->getOrderStatusById($id, $storeId) : null; + $orderStatus ??= new OrderStatus(); + + $orderStatus->storeId = $storeId; + $orderStatus->name = $request->input('name'); + $orderStatus->handle = $request->input('handle'); + $orderStatus->color = $request->input('color'); + $orderStatus->description = $request->input('description'); + $orderStatus->default = (bool)$request->input('default'); + $emailIds = $request->input('emails', []) ?: []; + + if (!$id) { + $orderStatus->sortOrder = new Query() + ->from(Table::ORDERSTATUSES) + ->where(['storeId' => $storeId]) + ->max('[[sortOrder]]') + 1; + } + + if (!app(OrderStatuses::class)->saveOrderStatus($orderStatus, $emailIds)) { + return $this->asModelFailure($orderStatus, t('Couldn\'t save order status.', category: 'commerce'), 'orderStatus'); + } + + return $this->asModelSuccess($orderStatus, t('Order status saved.', category: 'commerce'), 'orderStatus'); + } + + public function getOrderStatuses(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $storeId = $request->input('storeId'); + abort_if(!$storeId, 400, 'Missing store id'); + $storeId = (int)$storeId; + + $store = app(Stores::class)->getStoreById($storeId); + $allowableStoreIds = app(Stores::class)->getStoresByUserId(currentUser()?->getCraftUserId())->map(fn(Store $s) => $s->id)->all(); + + if (!$store || !in_array($store->id, $allowableStoreIds)) { + return $this->asFailure(t('Invalid store.', category: 'commerce')); + } + + $orderStatuses = app(OrderStatuses::class)->getAllOrderStatuses($storeId)->all(); + + return $this->asSuccess(data: ['orderStatuses' => $orderStatuses]); + } + + public function reorder(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + abort_unless($request->input('ids'), 400, 'Missing ids'); + + $ids = Json::decode($request->input('ids')); + + if (!app(OrderStatuses::class)->reorderOrderStatuses($ids)) { + return $this->asFailure(t('Couldn\'t reorder Order Statuses.', category: 'commerce')); + } + + return $this->asSuccess(); + } + + public function delete(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $orderStatusId = $request->input('id'); + abort_if(!$orderStatusId, 400, 'Missing order status id'); + + $storeId = DB::table(Table::ORDERSTATUSES)->where('id', $orderStatusId)->value('storeId'); + + if (!$storeId || !app(OrderStatuses::class)->deleteOrderStatusById((int)$orderStatusId, $storeId)) { + return $this->asFailure(t('Couldn\'t archive Order Status.', category: 'commerce')); + } + + return $this->asSuccess(); + } +} diff --git a/src/Http/Controllers/Settings/PaymentCurrenciesController.php b/src/Http/Controllers/Settings/PaymentCurrenciesController.php new file mode 100644 index 0000000000..05bcfedecd --- /dev/null +++ b/src/Http/Controllers/Settings/PaymentCurrenciesController.php @@ -0,0 +1,118 @@ +resolveStore($storeHandle); + $storeHandle = $store->handle; + + $currencies = app(PaymentCurrencies::class)->getAllPaymentCurrencies($store->id); + + return $this->storeManagementCpScreen($storeHandle) + ->additionalButtonsHtml(Html::a( + t('New currency', category: 'commerce'), "commerce/store-management/$storeHandle/payment-currencies/new", + ['class' => 'btn submit add icon'] + )) + ->contentTemplate('commerce/store-management/paymentcurrencies/index', ['currencies' => $currencies, 'store' => $store]); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + $storeHandle = $store->handle; + + if ($id) { + $currency = app(PaymentCurrencies::class)->getPaymentCurrencyById($id, $store->id); + abort_if($currency === null || $currency->storeId !== $store->id, 404); + } else { + $currency = \Craft::createObject([ + 'class' => PaymentCurrency::class, + 'storeId' => $store->id, + ]); + } + + // @TODO Use the full currency name instead of the ISO code for the page title + $title = $currency->id ? $currency->iso : t('Create a new currency', category: 'commerce'); + + $storeCurrency = app(PaymentCurrencies::class)->getPrimaryPaymentCurrencyIso(); + $currencyOptions = app(Currencies::class)->getAllCurrenciesList(); + $hasCompletedOrders = Order::find()->isCompleted(true)->exists(); + + $formatter = I18N::getFormatter(); + + $metaSidebarHtml = $currency->id ? Cp::metadataHtml([ + t('Created at') => $formatter->asDateTime($currency->dateCreated, Formatter::FORMAT_WIDTH_SHORT), + t('Updated at') => $formatter->asDateTime($currency->dateUpdated, Formatter::FORMAT_WIDTH_SHORT), + ]) : ''; + + return $this->storeManagementCpScreen($storeHandle, false) + ->addCrumb(t('Payment Currencies', category: 'commerce'), "commerce/store-management/$storeHandle/payment-currencies") + ->metaSidebarHtml($metaSidebarHtml) + ->action('commerce/payment-currencies/save') + ->redirectUrl("commerce/store-management/$storeHandle/payment-currencies") + ->submitButtonLabel(t('Save')) + ->contentTemplate('commerce/store-management/paymentcurrencies/_edit', [ + 'id' => $id, + 'currency' => $currency, + 'title' => $title, + 'storeCurrency' => $storeCurrency, + 'currencyOptions' => $currencyOptions, + 'store' => $store, + 'hasCompletedOrders' => $hasCompletedOrders, + ]); + } + + public function save(Request $request): Response + { + $currency = new PaymentCurrency(); + + $currency->id = $request->input('currencyId') ? (int)$request->input('currencyId') : null; + $currency->storeId = (int)$request->input('storeId'); + $currency->iso = $request->input('iso'); + $currency->rate = (float)$request->input('rate', 1); + + if (app(PaymentCurrencies::class)->savePaymentCurrency($currency)) { + return $this->asModelSuccess($currency, t('Currency saved.', category: 'commerce'), 'currency'); + } + + return $this->asModelFailure($currency, t('Couldn\'t save currency.', category: 'commerce'), 'currency'); + } + + public function delete(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + abort_if(!$id, 400, 'Missing currency id'); + + if (!app(PaymentCurrencies::class)->deletePaymentCurrencyById((int)$id)) { + return $this->asFailure(); + } + + return $this->asSuccess(); + } +} diff --git a/src/Http/Controllers/Settings/PdfsController.php b/src/Http/Controllers/Settings/PdfsController.php new file mode 100644 index 0000000000..ea2f53b821 --- /dev/null +++ b/src/Http/Controllers/Settings/PdfsController.php @@ -0,0 +1,157 @@ +readOnly = !$generalConfig->allowAdminChanges; + } + + public function index(): string + { + $pdfs = []; + $stores = app(Stores::class)->getAllStores(); + + $stores->each(function(Store $store) use (&$pdfs) { + $pdfs[$store->handle] = app(Pdfs::class)->getAllPdfs($store->id); + }); + + return pageTemplate('commerce/settings/pdfs/index', [ + 'pdfs' => $pdfs, + 'stores' => $stores->all(), + 'readOnly' => $this->readOnly, + ], TemplateMode::Cp); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + if ($storeHandle === null || !$store = app(Stores::class)->getStoreByHandle($storeHandle)) { + $store = app(Stores::class)->getPrimaryStore(); + } + + $pdfLanguageOptions = [ + PdfRecord::LOCALE_ORDER_LANGUAGE => t('The language the order was made in.', category: 'commerce'), + ]; + + $pdfLanguageOptions = array_merge($pdfLanguageOptions, LocaleHelper::getSiteAndOtherLanguages()); + + if ($id) { + $pdf = app(Pdfs::class)->getPdfById($id, $store->id); + abort_if($pdf === null, 404); + } else { + $pdf = \Craft::createObject([ + 'class' => Pdf::class, + 'attributes' => ['storeId' => $store->id], + ]); + } + + $title = $pdf->id ? $pdf->name : t('Create a new PDF', category: 'commerce'); + + $isDefault = app(Pdfs::class)->getAllPdfs($pdf->storeId)->count() === 0 || $pdf->isDefault; + $paperOrientationOptions = Pdf::getPaperOrientationOptions(); + $paperSizeOptions = Pdf::getPaperSizeOptions(); + + return new CpScreenResponse() + ->title($title) + ->crumbs([ + ['label' => t('Commerce', category: 'commerce'), 'url' => 'commerce'], + ['label' => t('Settings'), 'url' => 'commerce/settings', 'ariaLabel' => t('Commerce Settings', category: 'commerce')], + ['label' => t('PDFs', category: 'commerce'), 'url' => 'commerce/settings/pdfs'], + ]) + ->selectedSubnavItem('settings') + ->action('commerce/pdfs/save') + ->redirectUrl('commerce/settings/pdfs') + ->contentTemplate('commerce/settings/pdfs/_edit', [ + 'pdf' => $pdf, + 'pdfLanguageOptions' => $pdfLanguageOptions, + 'isDefault' => $isDefault, + 'paperOrientationOptions' => $paperOrientationOptions, + 'paperSizeOptions' => $paperSizeOptions, + 'readOnly' => $this->readOnly, + ]); + } + + public function save(Request $request): Response + { + $pdfsService = app(Pdfs::class); + $pdfId = $request->input('id') ? (int)$request->input('id') : null; + $storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; + + if ($pdfId) { + $pdf = $pdfsService->getPdfById($pdfId, $storeId); + abort_if($pdf === null, 400, "Invalid PDF ID: $pdfId"); + } else { + $pdf = new Pdf(); + } + + $pdf->storeId = $storeId; + $pdf->name = $request->input('name'); + $pdf->handle = $request->input('handle'); + $pdf->description = $request->input('description'); + $pdf->templatePath = $request->input('templatePath'); + $pdf->fileNameFormat = $request->input('fileNameFormat'); + $pdf->enabled = (bool)$request->input('enabled'); + $pdf->isDefault = (bool)$request->input('isDefault'); + $pdf->language = $request->input('language'); + $pdf->linkExpiry = (int)$request->input('linkExpiry'); + $pdf->paperSize = $request->input('paperSize'); + $pdf->paperOrientation = $request->input('paperOrientation'); + + if (!$pdfsService->savePdf($pdf)) { + return $this->asModelFailure($pdf, t('Couldn\'t save PDF.', category: 'commerce'), 'pdf'); + } + + return $this->asModelSuccess($pdf, t('PDF saved.', category: 'commerce'), 'pdf'); + } + + public function delete(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + abort_if(!$id, 400, 'Missing PDF id'); + + app(Pdfs::class)->deletePdfById((int)$id); + + return $this->asSuccess(); + } + + public function reorder(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + abort_unless($request->input('ids'), 400, 'Missing ids'); + + $ids = Json::decode($request->input('ids')); + + if (!app(Pdfs::class)->reorderPdfs($ids)) { + return $this->asFailure(t('Couldn\'t reorder PDFs.', category: 'commerce')); + } + + return $this->asSuccess(); + } +} diff --git a/src/Http/Controllers/Settings/ProductTypesController.php b/src/Http/Controllers/Settings/ProductTypesController.php new file mode 100644 index 0000000000..4ede728601 --- /dev/null +++ b/src/Http/Controllers/Settings/ProductTypesController.php @@ -0,0 +1,194 @@ +getAllProductTypes(); + + return new CpScreenResponse() + ->contentTemplate('commerce/settings/producttypes/index', [ + 'productTypes' => $productTypes, + ]); + } + + public function editProductType(?int $productTypeId = null): CpScreenResponse + { + $brandNewProductType = false; + + if ($productTypeId) { + $productType = app(ProductTypes::class)->getProductTypeById($productTypeId); + abort_if(!$productType, 404); + } else { + $productType = new ProductType(); + $brandNewProductType = true; + } + + $title = $productTypeId ? $productType->name : t('Create a new product type', category: 'commerce'); + + \Craft::$app->getView()->registerAssetBundle(EditSectionAsset::class); + + return new CpScreenResponse() + ->title($title) + ->crumbs([ + ['label' => t('Commerce', category: 'commerce'), 'url' => 'commerce'], + ['label' => t('Settings'), 'url' => 'commerce/settings', 'ariaLabel' => t('Commerce Settings', category: 'commerce')], + ['label' => t('Product Types', category: 'commerce'), 'url' => 'commerce/settings/producttypes'], + ]) + ->tabs([ + 'productTypeSettings' => [ + 'label' => t('Settings'), + 'url' => '#product-type-settings', + ], + 'taxAndShipping' => [ + 'label' => t('Tax & Shipping', category: 'commerce'), + 'url' => '#tax-and-shipping', + ], + 'productFields' => [ + 'label' => t('Product Fields', category: 'commerce'), + 'url' => '#product-fields', + ], + 'variantFields' => [ + 'label' => t('Variant Fields', category: 'commerce'), + 'url' => '#variant-fields', + ], + ]) + ->selectedSubnavItem('settings') + ->action('commerce/product-types/save-product-type') + ->submitButtonLabel(t('Save')) + ->redirectUrl('commerce/settings/producttypes') + ->contentTemplate('commerce/settings/producttypes/_edit', [ + 'productTypeId' => $productTypeId, + 'productType' => $productType, + 'brandNewProductType' => $brandNewProductType, + 'title' => $title, + 'selectedTab' => 'productTypeSettings', + ]); + } + + public function saveProductType(Request $request): ?Response + { + abort_unless(currentUser()?->can('manageCommerce'), 403, t('This action is not allowed for the current user.', category: 'commerce')); + + $productTypeId = $request->input('productTypeId') ? (int)$request->input('productTypeId') : null; + + if ($productTypeId) { + $productType = app(ProductTypes::class)->getProductTypeById($productTypeId); + abort_unless($productType !== null, 400, "Invalid section ID: $productTypeId"); + } else { + $productType = new ProductType(); + } + + // Shared attributes + $productType->id = $productTypeId; + $productType->name = $request->input('name'); + $productType->handle = $request->input('handle'); + $productType->enableVersioning = $request->input('enableVersioning') ?? $productType->enableVersioning; + $productType->hasDimensions = (bool)$request->input('hasDimensions'); + $productType->hasProductTitleField = (bool)$request->input('hasProductTitleField'); + $productType->productTitleFormat = $request->input('productTitleFormat'); + $productType->productUiLabelFormat = $request->input('productUiLabelFormat'); + $productType->productTitleTranslationMethod = $request->input('productTitleTranslationMethod', $productType->productTitleTranslationMethod); + $productType->productTitleTranslationKeyFormat = $request->input('productTitleTranslationKeyFormat', $productType->productTitleTranslationKeyFormat); + $productType->showSlugField = (bool)$request->input('showSlugField', $productType->showSlugField); + $productType->slugTranslationMethod = $request->input('slugTranslationMethod', $productType->slugTranslationMethod); + $productType->slugTranslationKeyFormat = $request->input('slugTranslationKeyFormat', $productType->slugTranslationKeyFormat); + $maxVariants = $request->input('maxVariants'); + $productType->maxVariants = $maxVariants ? (int)$maxVariants : null; + $productType->hasVariantTitleField = $request->input('hasVariantTitleField', false); + $productType->variantTitleFormat = $request->input('variantTitleFormat'); + $productType->variantUiLabelFormat = $request->input('variantUiLabelFormat'); + $productType->variantTitleTranslationMethod = $request->input('variantTitleTranslationMethod', $productType->variantTitleTranslationMethod); + $productType->variantTitleTranslationKeyFormat = $request->input('variantTitleTranslationKeyFormat', $productType->variantTitleTranslationKeyFormat); + $productType->skuFormat = $request->input('skuFormat'); + $productType->descriptionFormat = $request->input('descriptionFormat'); + $productType->propagationMethod = PropagationMethod::tryFrom($request->input('propagationMethod') ?? '') ?? PropagationMethod::All; + $productType->isStructure = $request->input('isStructure'); + $maxLevels = (int)$request->input('maxLevels'); + $productType->maxLevels = $maxLevels ?: null; // zero should be null + $productType->defaultPlacement = $request->input('defaultPlacement'); + $productType->previewTargets = $request->input('previewTargets') ?: []; + + // Site-specific settings + $allSiteSettings = []; + + foreach (Sites::getAllSites() as $site) { + $postedSettings = $request->input('sites.' . $site->handle); + + // Skip disabled sites if this is a multi-site install + if (Sites::isMultiSite() && empty($postedSettings['enabled'])) { + continue; + } + + $siteSettings = new ProductTypeSite(); + $siteSettings->siteId = $site->id; + $siteSettings->hasUrls = !empty($postedSettings['uriFormat']); + + $siteSettings->enabledByDefault = (bool)$postedSettings['enabledByDefault']; + + if ($siteSettings->hasUrls) { + $siteSettings->uriFormat = $postedSettings['uriFormat']; + $siteSettings->template = $postedSettings['template']; + } else { + $siteSettings->uriFormat = null; + $siteSettings->template = null; + } + + $allSiteSettings[$site->id] = $siteSettings; + } + + $productType->setSiteSettings($allSiteSettings); + + // Set the product type field layout + $fieldLayout = Fields::assembleLayoutFromPost(); + $fieldLayout->type = Product::class; + $productType->setProductFieldLayout($fieldLayout); + + // Set the variant field layout + $variantFieldLayout = Fields::assembleLayoutFromPost('variant-layout'); + $variantFieldLayout->type = Variant::class; + $productType->setVariantFieldLayout($variantFieldLayout); + + // Save it + if (app(ProductTypes::class)->saveProductType($productType)) { + return $this->asSuccess(t('Product type saved.', category: 'commerce')); + } + + return $this->asModelFailure($productType, t('Couldn\'t save product type.', category: 'commerce'), 'productType'); + } + + public function deleteProductType(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $productTypeId = $request->input('id'); + abort_if(!$productTypeId, 400, 'Missing product type id'); + + app(ProductTypes::class)->deleteProductTypeById((int)$productTypeId); + + return $this->asSuccess(); + } +} diff --git a/src/Http/Controllers/Settings/SalesController.php b/src/Http/Controllers/Settings/SalesController.php new file mode 100644 index 0000000000..86b9375547 --- /dev/null +++ b/src/Http/Controllers/Settings/SalesController.php @@ -0,0 +1,437 @@ +canUseSales(), 403, 'Unable to use sales while using multi store or pricing rules.'); + } + + public function index(?string $storeHandle = null): Response|string + { + $this->guard(); + + $store = $this->resolveStore($storeHandle); + $sales = app(Sales::class)->getAllSales(); + if (empty($sales)) { + return redirect('commerce/store-management/' . $store->handle . '/pricing-rules'); + } + + return pageTemplate('commerce/promotions/sales/index', [ + 'sales' => $sales, + 'storeHandle' => $store->handle, + 'storeSwitcher' => $this->getStoreSwitcher($store->handle), + 'storeSettingsNav' => $this->getStoreSettingsNav(), + ], TemplateMode::Cp); + } + + public function edit(?string $storeHandle = null, ?int $id = null): string + { + $this->guard(); + + abort_unless(currentUserElement()?->can($id === null ? 'commerce-createSales' : 'commerce-editSales'), 403); + + $store = $this->resolveStore($storeHandle); + + $isNewSale = false; + if ($id) { + $sale = app(Sales::class)->getSaleById($id); + abort_if($sale === null, 404); + } else { + $sale = new Sale(); + $isNewSale = true; + $sale->allCategories = true; + $sale->allPurchasables = true; + $sale->allGroups = true; + } + + $variables = $this->populateVariables($id, $sale, $store->handle); + $variables['isNewSale'] = $isNewSale; + + return pageTemplate('commerce/promotions/sales/_edit', $variables, TemplateMode::Cp); + } + + public function save(Request $request): Response + { + $sale = new Sale(); + + abort_unless(currentUserElement()?->can($sale->id === null ? 'commerce-createSales' : 'commerce-editSales'), 403); + + $sale->id = $request->input('id'); + $sale->name = $request->input('name'); + $sale->description = $request->input('description'); + $sale->apply = $request->input('apply'); + $sale->enabled = (bool)$request->input('enabled'); + + foreach (['dateFrom', 'dateTo'] as $field) { + if (($date = $request->input($field)) !== null && $dateValue = DateTimeHelper::toDateTime($date)) { + $sale->$field = $dateValue instanceof DateTime ? $dateValue : DateTime::createFromInterface($dateValue); + } + } + + $applyAmount = Localization::normalizeNumber($request->input('applyAmount')); + $sale->sortOrder = (int)$request->input('sortOrder'); + $sale->ignorePrevious = (bool)$request->input('ignorePrevious'); + $sale->stopProcessing = (bool)$request->input('stopProcessing'); + $sale->categoryRelationshipType = $request->input('categoryRelationshipType', $sale->categoryRelationshipType); + + if ($sale->apply == SaleRecord::APPLY_BY_PERCENT || $sale->apply == SaleRecord::APPLY_TO_PERCENT) { + if ((float)$applyAmount >= 1) { + $sale->applyAmount = (float)$applyAmount / -100; + } else { + $sale->applyAmount = -(float)$applyAmount; + } + } else { + $sale->applyAmount = (float)$applyAmount * -1; + } + + $allPurchasables = !$request->input('allPurchasables', false); + if ($sale->allPurchasables = $allPurchasables) { + $sale->setPurchasableIds([]); + } else { + $purchasables = []; + $purchasableGroups = $request->input('purchasables') ?: []; + foreach ($purchasableGroups as $group) { + if (is_array($group)) { + array_push($purchasables, ...$group); + } + } + $sale->setPurchasableIds($purchasables); + } + + $allCategories = !$request->input('allCategories', false); + if ($sale->allCategories = $allCategories) { + $sale->setCategoryIds([]); + } else { + $relatedElements = []; + $relatedElementByType = $request->input('relatedElements') ?: []; + foreach ($relatedElementByType as $type) { + if (is_array($type)) { + array_push($relatedElements, ...$type); + } + } + $sale->setCategoryIds(array_unique($relatedElements)); + } + + if ($sale->allGroups = (bool)$request->input('allGroups', true)) { + $sale->setUserGroupIds([]); + } else { + $sale->setUserGroupIds($request->input('groups', []) ?: []); + } + + if (app(Sales::class)->saveSale($sale)) { + return $this->asModelSuccess($sale, t('Sale saved.', category: 'commerce'), 'sale'); + } + + return $this->asModelFailure($sale, t('Couldn\'t save sale.', category: 'commerce'), 'sale'); + } + + public function reorder(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + abort_unless($request->input('ids'), 400, 'Missing ids'); + + $ids = Json::decode($request->input('ids')); + if (!app(Sales::class)->reorderSales($ids)) { + return $this->asFailure(t('Couldn\'t reorder sales.', category: 'commerce')); + } + + return $this->asSuccess(); + } + + public function delete(Request $request): Response + { + abort_unless(currentUserElement()?->can('commerce-deleteSales'), 403); + + $id = $request->input('id'); + $ids = $request->input('ids'); + + abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); + + if ($id) { + abort_unless($request->expectsJson(), 400); + $ids = [$id]; + } + + foreach ($ids as $deleteId) { + app(Sales::class)->deleteSaleById($deleteId); + } + + if ($request->expectsJson()) { + return $this->asSuccess(); + } + + return $this->asSuccess(t('Sales deleted.', category: 'commerce'), redirect: url()->previous()); + } + + public function getAllSales(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + $sales = app(Sales::class)->getAllSales(); + + return response()->json(array_values($sales)); + } + + public function getSalesByProductId(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + $id = $request->input('id'); + + if (!$id) { + return $this->asFailure(t('Product ID is required.', category: 'commerce')); + } + + $product = app(Products::class)->getProductById($id); + + if (!$product) { + return $this->asFailure(t('No product available.', category: 'commerce')); + } + + $sales = []; + foreach ($product->getVariants(true) as $variant) { + $variantSales = app(Sales::class)->getSalesRelatedToPurchasable($variant); + foreach ($variantSales as $sale) { + if (!Arr::contains($sales, 'id', $sale->id)) { + $saleArray = $sale->toArray(); + $saleArray['cpEditUrl'] = $sale->getCpEditUrl(); + $sales[] = $saleArray; + } + } + } + + return $this->asSuccess(data: ['sales' => $sales]); + } + + public function getSalesByPurchasableId(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + $id = $request->input('id'); + + if (!$id) { + return $this->asFailure(t('Purchasable ID is required.', category: 'commerce')); + } + + $purchasable = app(Purchasables::class)->getPurchasableById($id); + + if (!$purchasable) { + return $this->asFailure(t('No purchasable available.', category: 'commerce')); + } + + $sales = []; + $purchasableSales = app(Sales::class)->getSalesRelatedToPurchasable($purchasable); + foreach ($purchasableSales as $sale) { + if (!Arr::contains($sales, 'id', $sale->id)) { + $saleArray = $sale->toArray(); + $saleArray['cpEditUrl'] = $sale->getCpEditUrl(); + $sales[] = $saleArray; + } + } + + return $this->asSuccess(data: ['sales' => $sales]); + } + + public function addPurchasableToSale(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + $ids = $request->input('ids', []); + $saleId = $request->input('saleId'); + + if (empty($ids) || !$saleId) { + return $this->asFailure(t('Purchasable ID and Sale ID are required.', category: 'commerce')); + } + + $purchasables = []; + foreach ($ids as $id) { + $purchasables[] = app(Purchasables::class)->getPurchasableById($id); + } + + $sale = app(Sales::class)->getSaleById($saleId); + + if (empty($purchasables) || count($purchasables) != count($ids) || !$sale) { + return $this->asFailure(t('Unable to retrieve Sale and Purchasable.', category: 'commerce')); + } + + $salePurchasableIds = $sale->getPurchasableIds(); + + array_push($salePurchasableIds, ...$ids); + if (!empty($salePurchasableIds)) { + $sale->allPurchasables = false; + } + $sale->setPurchasableIds(array_unique($salePurchasableIds)); + + if (!app(Sales::class)->saveSale($sale)) { + return $this->asFailure(t('Couldn\'t save sale.', category: 'commerce')); + } + + return $this->asSuccess(); + } + + public function updateStatus(Request $request): Response + { + abort_unless(currentUserElement()?->can('commerce-editSales'), 403); + + $ids = $request->input('ids'); + $status = $request->input('status'); + + abort_if(empty($ids), 400, 'Missing ids'); + + DB::transaction(function() use ($ids, $status) { + $sales = SaleRecord::whereIn('id', $ids)->get(); + + foreach ($sales as $sale) { + $sale->enabled = ($status == 'enabled'); + $sale->save(); + } + }); + + return $this->asSuccess(t('Sales updated.', category: 'commerce')); + } + + private function populateVariables(?int $id, Sale $sale, string $storeHandle): array + { + $variables = [ + 'id' => $id, + 'sale' => $sale, + 'storeHandle' => $storeHandle, + 'storeSwitcher' => $this->getStoreSwitcher($storeHandle), + ]; + + $variables['title'] = $sale->id ? $sale->name : t('Create a new sale', category: 'commerce'); + + if (Edition::get() === Edition::Pro) { + $groups = UserGroups::getAllGroups(); + $variables['groups'] = $groups->mapWithKeys(fn($group) => [$group->id => $group->name])->all(); + } else { + $variables['groups'] = []; + } + + $variables['percentSymbol'] = I18N::getFormattingLocale()->getNumberSymbol(Locale::SYMBOL_PERCENT); + $primaryCurrencyIso = app(PaymentCurrencies::class)->getPrimaryPaymentCurrencyIso(); + $variables['currencySymbol'] = I18N::getLocale()->getCurrencySymbol($primaryCurrencyIso); + + $variables['saleApplyAmount'] = ''; + if ($sale->applyAmount !== null) { + if ($sale->apply == SaleRecord::APPLY_BY_PERCENT || $sale->apply == SaleRecord::APPLY_TO_PERCENT) { + $amount = -(float)$sale->applyAmount * 100; + $variables['saleApplyAmount'] = I18N::getFormatter()->asDecimal($amount); + } else { + $variables['saleApplyAmount'] = I18N::getFormatter()->asDecimal(-(float)$sale->applyAmount); + } + } + + $variables['categoryElementType'] = Category::class; + $variables['entryElementType'] = Entry::class; + + $categories = []; + $entries = []; + + $request = request(); + if (empty($id) && $request->input('categoryIds')) { + $categoryIds = explode('|', (string)$request->input('categoryIds')); + } else { + $categoryIds = $sale->getCategoryIds(); + } + + foreach ($categoryIds as $categoryId) { + $elementId = (int)$categoryId; + $element = Elements::getElementById($elementId); + + if ($element instanceof Category) { + $categories[] = $element; + } elseif ($element instanceof Entry) { + $entries[] = $element; + } + } + + $variables['categories'] = $categories; + $variables['entries'] = $entries; + + $variables['elementRelationshipTypeOptions'] = [ + SaleRecord::CATEGORY_RELATIONSHIP_TYPE_SOURCE => t('The purchasable defines the relationship', category: 'commerce'), + SaleRecord::CATEGORY_RELATIONSHIP_TYPE_TARGET => t('The purchasable is related by another element', category: 'commerce'), + SaleRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH => t('Either way', category: 'commerce'), + ]; + + $purchasables = []; + + if (empty($id) && $request->input('purchasableIds')) { + $purchasableIdsFromUrl = explode('|', (string)$request->input('purchasableIds')); + $purchasableIds = []; + foreach ($purchasableIdsFromUrl as $purchasableId) { + $purchasable = Elements::getElementById((int)$purchasableId); + if ($purchasable instanceof Product) { + foreach ($purchasable->getVariants(true) as $variant) { + $purchasableIds[] = $variant->getId(); + } + } else { + $purchasableIds[] = $purchasableId; + } + } + $sale->allPurchasables = false; + } else { + $purchasableIds = $sale->getPurchasableIds(); + } + + foreach ($purchasableIds as $purchasableId) { + $purchasable = Elements::getElementById((int)$purchasableId); + if ($purchasable instanceof PurchasableInterface) { + $class = $purchasable::class; + $purchasables[$class] ??= []; + $purchasables[$class][] = $purchasable; + } + } + $variables['purchasables'] = $purchasables; + + $variables['purchasableTypes'] = []; + $purchasableTypes = app(Purchasables::class)->getAllPurchasableElementTypes(); + + /** @var Purchasable $purchasableType */ + foreach ($purchasableTypes as $purchasableType) { + $variables['purchasableTypes'][] = [ + 'name' => $purchasableType::displayName(), + 'elementType' => $purchasableType, + ]; + } + + return $variables; + } +} diff --git a/src/Http/Controllers/Settings/SettingsController.php b/src/Http/Controllers/Settings/SettingsController.php new file mode 100644 index 0000000000..c17a3e8d99 --- /dev/null +++ b/src/Http/Controllers/Settings/SettingsController.php @@ -0,0 +1,98 @@ +readOnly = !$generalConfig->allowAdminChanges; + } + + public function edit(): string + { + return pageTemplate('commerce/settings/general', [ + 'settings' => Plugin::getInstance()->getSettings(), + 'readOnly' => $this->readOnly, + ], TemplateMode::Cp); + } + + public function saveSettings(Request $request): Response|string + { + $plugin = Plugin::getInstance(); + $settings = $request->input('settings'); + $pluginSettingsSaved = Plugins::savePluginSettings($plugin, $settings); + + if (!$pluginSettingsSaved) { + return pageTemplate('commerce/settings/general/index', ['settings' => $plugin->getSettings()], TemplateMode::Cp); + } + + return $this->asSuccess(t('Settings saved.', category: 'commerce')); + } + + public function saveTransferSettings(): Response + { + $fieldLayout = Fields::assembleLayoutFromPost(); + + $fieldLayout->reservedFieldHandles = [ + 'originLocationId', + 'originLocation', + 'destinationLocationId', + 'destinationLocation', + ]; + + $fieldLayout->type = Transfer::class; + + if (!$fieldLayout->validate()) { + return $this->asFailure(t('Couldn\'t save transfer fields.', category: 'commerce')); + } + + if ($currentTransfersFieldLayout = ProjectConfig::get(Transfers::CONFIG_FIELDLAYOUT_KEY)) { + $uid = array_key_first($currentTransfersFieldLayout); + } else { + $uid = (string)Str::uuid(); + } + + $configData = [$uid => $fieldLayout->getConfig()]; + $result = ProjectConfig::set(Transfers::CONFIG_FIELDLAYOUT_KEY, $configData, force: true); + + if (!$result) { + return $this->asFailure(t('Couldn\'t save transfer fields.')); + } + + return $this->asSuccess(t('Transfer fields saved.', category: 'commerce')); + } + + public function editTransferSettings(): string + { + $fieldLayout = app(Transfers::class)->getFieldLayout(); + + return pageTemplate('commerce/settings/transfers/_edit', [ + 'fieldLayout' => $fieldLayout, + 'title' => t('Transfer Settings', category: 'commerce'), + 'readOnly' => $this->readOnly, + ], TemplateMode::Cp); + } +} diff --git a/src/Http/Controllers/Settings/ShippingCategoriesController.php b/src/Http/Controllers/Settings/ShippingCategoriesController.php new file mode 100644 index 0000000000..5e58b93538 --- /dev/null +++ b/src/Http/Controllers/Settings/ShippingCategoriesController.php @@ -0,0 +1,258 @@ +resolveStore($storeHandle); + $storeHandle = $store->handle; + + $shippingCategories = app(ShippingCategories::class)->getAllShippingCategories($store->id); + + $tableData = []; + foreach ($shippingCategories as $shippingCategory) { + $label = NewHtml::encode(t($shippingCategory->name, category: 'site')); + $tableData[] = [ + 'id' => $shippingCategory->id, + 'title' => $label, + 'chip' => Cp::chipHtml($shippingCategory, [ + 'labelHtml' => NewHtml::a($label, $shippingCategory->getCpEditUrl(), [ + 'class' => ['chip-label', 'cell-bold'], + ]), + ]), + 'url' => $shippingCategory->getCpEditUrl(), + 'handle' => $shippingCategory->handle, + 'description' => NewHtml::encode(t($shippingCategory->description, category: 'site')), + 'default' => $shippingCategory->default, + '_showDelete' => (count($shippingCategories) > 1 && !$shippingCategory->default), + ]; + } + + $tableData = Json::encode($tableData); + + $js = <<'; + } + } + }, + ]; + + new Craft.VueAdminTable({ + actions: [ + { + label: '', + icon: 'settings', + actions: [ + { + label: Craft.t('commerce', 'Set Default Category'), + action: 'commerce/shipping-categories/set-default-category', + param: 'storeHandle', + value: '{$storeHandle}', + allowMultiple: false + } + ] + } + ], + checkboxes: true, + columns: columns, + container: '#shipping-vue-admin-table', + deleteAction: 'commerce/shipping-categories/delete', + padded: true, + tableData: {$tableData}, + }); +JS; + + HtmlStack::js($js, Position::BodyEnd); + + return $this->storeManagementCpScreen($storeHandle) + ->additionalButtonsHtml(NewHtml::a( + t('New shipping category', category: 'commerce'), + $store->getStoreSettingsUrl('shippingcategories/new'), + ['class' => 'btn submit add icon'] + )) + ->contentHtml(NewHtml::tag('div', '', ['id' => 'shipping-vue-admin-table'])); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + $storeHandle = $store->handle; + + if ($id) { + $shippingCategory = app(ShippingCategories::class)->getShippingCategoryById($id, $store->id); + abort_if($shippingCategory === null, 404); + } else { + $shippingCategory = \Craft::createObject([ + 'class' => ShippingCategory::class, + 'attributes' => ['storeId' => $store->id], + ]); + } + + $title = $shippingCategory->id ? $shippingCategory->name : t('Create a new shipping category', category: 'commerce'); + + $productTypes = app(ProductTypes::class)->getAllProductTypes(); + $productTypesOptions = []; + if (!empty($productTypes)) { + $productTypesOptions = Arr::mapWithKeys($productTypes, fn($row) => [$row->id => ['label' => $row->name, 'value' => $row->id]]); + } + + $allShippingCategories = app(ShippingCategories::class)->getAllShippingCategories($store->id); + $isDefaultAndOnlyCategory = $id && $allShippingCategories->count() === 1 && $allShippingCategories->firstWhere('id', $id); + + $metaSidebar = ''; + if ($shippingCategory->id) { + $metaSidebar = Cp::metadataHtml([ + t('Created at') => I18N::getFormatter()->asDatetime($shippingCategory->dateCreated, 'short'), + t('Updated at') => I18N::getFormatter()->asDatetime($shippingCategory->dateUpdated, 'short'), + ]); + } + + return $this->storeManagementCpScreen($storeHandle, false) + ->title($title) + ->addCrumb(t('Shipping Categories', category: 'commerce'), $store->getStoreSettingsUrl('shippingcategories')) + ->action('commerce/shipping-categories/save') + ->redirectUrl($store->getStoreSettingsUrl('shippingcategories')) + ->metaSidebarHtml($metaSidebar) + ->contentTemplate('commerce/store-management/shipping/shippingcategories/_edit', [ + 'id' => $id, + 'shippingCategory' => $shippingCategory, + 'productTypes' => $productTypes, + 'storeHandle' => $storeHandle, + 'title' => $title, + 'productTypesOptions' => $productTypesOptions, + 'isDefaultAndOnlyCategory' => $isDefaultAndOnlyCategory, + ]); + } + + public function save(Request $request): Response + { + $shippingCategory = new ShippingCategory(); + + $shippingCategoryId = $request->input('shippingCategoryId'); + $shippingCategory->id = $shippingCategoryId ? (int)$shippingCategoryId : null; + $storeId = $request->input('storeId'); + $shippingCategory->storeId = $storeId ? (int)$storeId : null; + $shippingCategory->name = $request->input('name'); + $shippingCategory->handle = $request->input('handle'); + $shippingCategory->icon = $request->input('icon'); + $shippingCategory->color = $request->input('color'); + $shippingCategory->description = $request->input('description'); + $shippingCategory->default = (bool)$request->input('default'); + + if ($shippingCategory->default) { + $productTypes = app(ProductTypes::class)->getAllProductTypes(); + } else { + $postedProductTypes = $request->input('productTypes', []) ?: []; + $productTypes = []; + foreach ($postedProductTypes as $productTypeId) { + if ($productTypeId && $productType = app(ProductTypes::class)->getProductTypeById((int)$productTypeId)) { + $productTypes[] = $productType; + } + } + } + $shippingCategory->setProductTypes($productTypes); + + if (!app(ShippingCategories::class)->saveShippingCategory($shippingCategory)) { + return $this->asModelFailure( + $shippingCategory, + t('Couldn\'t save shipping category.', category: 'commerce'), + 'shippingCategory' + ); + } + + return $this->asModelSuccess( + $shippingCategory, + t('Shipping category saved.', category: 'commerce'), + 'shippingCategory', + data: [ + 'id' => $shippingCategory->id, + 'name' => $shippingCategory->name, + ] + ); + } + + public function delete(Request $request): Response + { + $id = $request->input('id'); + $ids = $request->input('ids'); + + abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); + + if ($id) { + abort_unless($request->expectsJson(), 400); + $ids = [$id]; + } + + $failedIds = []; + foreach ($ids as $deleteId) { + if (!app(ShippingCategories::class)->deleteShippingCategoryById((int)$deleteId)) { + $failedIds[] = $deleteId; + } + } + + if (!empty($failedIds)) { + return $this->asFailure(t('Could not delete {count, number} shipping {count, plural, one{category} other{categories}}.', [ + 'count' => count($failedIds), + ], category: 'commerce')); + } + + return $this->asSuccess(t('Shipping categories deleted.', category: 'commerce')); + } + + public function setDefaultCategory(Request $request): Response + { + $ids = $request->input('ids'); + $storeHandle = $request->input('storeHandle'); + $store = $storeHandle ? app(Stores::class)->getStoreByHandle($storeHandle) : null; + abort_if(!$storeHandle || $store === null, 400, 'Invalid store.'); + + if (!empty($ids)) { + $id = Arr::first($ids); + + $shippingCategory = app(ShippingCategories::class)->getShippingCategoryById((int)$id, $store->id); + if ($shippingCategory) { + $shippingCategory->default = true; + if (app(ShippingCategories::class)->saveShippingCategory($shippingCategory)) { + return $this->asSuccess(t('Shipping category updated.', category: 'commerce')); + } + } + } + + return $this->asFailure(t('Unable to set default shipping category.', category: 'commerce')); + } +} diff --git a/src/Http/Controllers/Settings/ShippingMethodsController.php b/src/Http/Controllers/Settings/ShippingMethodsController.php new file mode 100644 index 0000000000..bf3dd73401 --- /dev/null +++ b/src/Http/Controllers/Settings/ShippingMethodsController.php @@ -0,0 +1,228 @@ +resolveStore($storeHandle); + + $shippingMethods = app(ShippingMethods::class)->getAllShippingMethods($store->id); + + $tableData = []; + foreach ($shippingMethods as $shippingMethod) { + $label = NewHtml::encode(t($shippingMethod->name, category: 'site')); + $tableData[] = [ + 'id' => $shippingMethod->id, + 'title' => $label, + 'chip' => Cp::chipHtml($shippingMethod, [ + 'showStatus' => true, + 'showThumb' => true, + 'labelHtml' => NewHtml::a($label, $shippingMethod->getCpEditUrl(), [ + 'class' => ['chip-label', 'cell-bold'], + ]), + ]), + 'url' => $shippingMethod->getCpEditUrl(), + 'handle' => $shippingMethod->handle, + 'type' => $shippingMethod->getType(), + 'status' => $shippingMethod->enabled, + ]; + } + + $tableData = Json::encode($tableData); + + $js = <<storeManagementCpScreen($storeHandle) + ->additionalButtonsHtml(NewHtml::a(t('New shipping method', category: 'commerce'), $store->getStoreSettingsUrl('shippingmethods/new'), ['class' => 'btn submit add icon'])) + ->contentHtml(NewHtml::tag('div', '', ['id' => 'shipping-vue-admin-table'])); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + $storeHandle = $store->handle; + + if ($id) { + $shippingMethod = app(ShippingMethods::class)->getShippingMethodById($id, $store->id); + abort_if($shippingMethod === null, 404); + } else { + $shippingMethod = \Craft::createObject([ + 'class' => ShippingMethod::class, + 'attributes' => ['storeId' => $store->id], + ]); + } + + $title = $shippingMethod->id ? $shippingMethod->name : t('Create a new shipping method', category: 'commerce'); + + $shippingRules = $shippingMethod->id !== null + ? app(ShippingRules::class)->getAllShippingRulesByShippingMethodId($shippingMethod->id) + : []; + + $metaDataHtml = NewHtml::beginTag('div', ['class' => 'meta']) . + Cp::lightswitchFieldHtml([ + 'label' => t('Enable this shipping method on the front end', category: 'commerce'), + 'id' => 'enabled', + 'name' => 'enabled', + 'on' => $shippingMethod->enabled, + 'errors' => $shippingMethod->getErrors('enabled'), + ]) . + NewHtml::endTag('div'); + + if ($shippingMethod->id) { + $metaDataHtml .= Cp::metadataHtml([ + t('Created at') => I18N::getFormatter()->asDatetime($shippingMethod->dateCreated, 'short'), + t('Updated at') => I18N::getFormatter()->asDatetime($shippingMethod->dateUpdated, 'short'), + ]); + } + + return $this->storeManagementCpScreen($storeHandle, false) + ->title($title) + ->action('commerce/shipping-methods/save') + ->redirectUrl($store->getStoreSettingsUrl('shippingmethods/{id}#rules')) + ->addCrumb(t('Shipping Methods', category: 'commerce'), $store->getStoreSettingsUrl('shippingmethods')) + ->metaSidebarHtml($metaDataHtml) + ->submitButtonLabel($shippingMethod->id ? t('Save and set rules', category: 'commerce') : t('Save')) + ->contentTemplate('commerce/store-management/shipping/shippingmethods/_edit', [ + 'shippingMethod' => $shippingMethod, + 'shippingRules' => $shippingRules, + 'store' => $store, + 'storeHandle' => $storeHandle, + ]); + } + + public function save(Request $request): Response + { + $shippingMethod = new ShippingMethod(); + + $shippingMethodId = $request->input('shippingMethodId'); + $shippingMethod->id = $shippingMethodId ? (int)$shippingMethodId : null; + $shippingMethod->name = $request->input('name'); + $shippingMethod->handle = $request->input('handle'); + $shippingMethod->icon = $request->input('icon'); + $shippingMethod->color = $request->input('color'); + $storeId = $request->input('storeId'); + $shippingMethod->storeId = $storeId ? (int)$storeId : null; + $shippingMethod->setOrderCondition($request->input('orderCondition')); + $shippingMethod->setCustomerCondition($request->input('customerCondition')); + $shippingMethod->enabled = (bool)$request->input('enabled'); + + if (!app(ShippingMethods::class)->saveShippingMethod($shippingMethod)) { + return $this->asModelFailure($shippingMethod, t('Couldn\'t save shipping method.', category: 'commerce'), 'shippingMethod'); + } + + return $this->asModelSuccess($shippingMethod, t('Shipping method saved.', category: 'commerce'), 'shippingMethod'); + } + + public function delete(Request $request): Response + { + $id = $request->input('id'); + $ids = $request->input('ids'); + + abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); + + if ($id) { + abort_unless($request->expectsJson(), 400); + $ids = [$id]; + } + + $failedIds = []; + foreach ($ids as $deleteId) { + if (!app(ShippingMethods::class)->deleteShippingMethodById((int)$deleteId)) { + $failedIds[] = $deleteId; + } + } + + if (!empty($failedIds)) { + return $this->asFailure(t('Could not delete {count, number} shipping {count, plural, one{method} other{methods}} and rules.', [ + 'count' => count($failedIds), + ], category: 'commerce')); + } + + return $this->asSuccess(t('Shipping methods and rules deleted.', category: 'commerce')); + } + + public function updateStatus(Request $request): Response + { + $ids = $request->input('ids'); + $status = $request->input('status'); + + abort_if(empty($ids), 400, 'Missing ids'); + + DB::transaction(function() use ($ids, $status) { + $shippingMethods = ShippingMethodRecord::whereIn('id', $ids)->get(); + + foreach ($shippingMethods as $shippingMethod) { + $shippingMethod->enabled = ($status == 'enabled'); + $shippingMethod->save(); + } + }); + + return $this->asSuccess(t('Shipping methods updated.', category: 'commerce')); + } +} diff --git a/src/Http/Controllers/Settings/ShippingRulesController.php b/src/Http/Controllers/Settings/ShippingRulesController.php new file mode 100644 index 0000000000..66b4525dd0 --- /dev/null +++ b/src/Http/Controllers/Settings/ShippingRulesController.php @@ -0,0 +1,200 @@ +resolveStore($storeHandle); + + $shippingMethod = app(ShippingMethods::class)->getShippingMethodById($methodId, $store->id); + abort_if($shippingMethod === null, 404); + + if ($ruleId) { + $shippingRule = app(ShippingRules::class)->getShippingRuleById($ruleId); + abort_if($shippingRule === null, 404); + } else { + $shippingRule = new ShippingRule(); + $shippingRule->methodId = $shippingMethod->id; + $shippingRule->storeId = $shippingMethod->storeId; + } + + InputNamespace::set('new'); + HtmlStack::startJsBuffer(); + + $newZone = new ShippingAddressZone(); + $condition = $newZone->getCondition(); + $condition->mainTag = 'div'; + $condition->name = 'condition'; + $condition->id = 'condition'; + + $newShippingZoneFields = InputNamespace::namespaceInputs( + Template::renderTemplate('commerce/store-management/shipping/shippingzones/_fields', ['condition' => $condition]) + ); + $newShippingZoneJs = HtmlStack::clearJsBuffer(false); + InputNamespace::set(null); + + $title = $ruleId ? $shippingRule->name : t('Create a new shipping rule', category: 'commerce'); + + $shippingZones = app(ShippingZones::class)->getAllShippingZones($store->id)->all(); + $shippingZoneOptions = []; + $shippingZoneOptions[] = t('Anywhere', category: 'commerce'); + foreach ($shippingZones as $model) { + $shippingZoneOptions[$model->id] = $model->name; + } + + $categoryShippingOptions = [ + ['label' => t('Allow', category: 'commerce'), 'value' => ShippingRuleCategoryRecord::CONDITION_ALLOW], + ['label' => t('Disallow', category: 'commerce'), 'value' => ShippingRuleCategoryRecord::CONDITION_DISALLOW], + ['label' => t('Require', category: 'commerce'), 'value' => ShippingRuleCategoryRecord::CONDITION_REQUIRE], + ]; + + return pageTemplate('commerce/store-management/shipping/shippingrules/_edit', [ + 'methodId' => $methodId, + 'ruleId' => $ruleId, + 'shippingRule' => $shippingRule, + 'shippingMethod' => $shippingMethod, + 'newShippingZoneFields' => $newShippingZoneFields, + 'newShippingZoneJs' => $newShippingZoneJs, + 'title' => $title, + 'shippingZones' => $shippingZoneOptions, + 'categoryShippingOptions' => $categoryShippingOptions, + 'storeId' => $store->id, + 'storeHandle' => $store->handle, + 'storeSwitcher' => $this->getStoreSwitcher($store->handle), + ], TemplateMode::Cp); + } + + public function duplicate(Request $request): Response + { + return $this->save($request, duplicate: true); + } + + public function save(Request $request, bool $duplicate = false): Response + { + $shippingRule = new ShippingRule(); + + if (!$duplicate) { + $shippingRule->id = $request->input('id') ? (int)$request->input('id') : null; + } + $shippingRule->storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; + + $moneyInputs = [ + 'baseRate', + 'maxRate', + 'minRate', + 'perItemRate', + 'weightRate', + ]; + + foreach ($moneyInputs as $moneyInput) { + $input = $request->input($moneyInput); + $input += [ + 'currency' => $shippingRule->getStore()->getCurrency(), + ]; + $shippingRule->$moneyInput = (float)Money::toDecimal(Money::toMoney($input)); + } + + $shippingRule->name = $request->input('name'); + $shippingRule->description = $request->input('description'); + $shippingRule->methodId = $request->input('methodId') ? (int)$request->input('methodId') : null; + $shippingRule->enabled = (bool)$request->input('enabled'); + $shippingRule->orderConditionFormula = trim((string)$request->input('orderConditionFormula', '')); + $shippingRule->percentageRate = (float)Localization::normalizeNumber($request->input('percentageRate')); + $shippingRule->setOrderCondition($request->input('orderCondition')); + $shippingRule->setCustomerCondition($request->input('customerCondition')); + + $ruleCategories = []; + $allRulesCategories = $request->input('ruleCategories'); + foreach ($allRulesCategories as $key => $ruleCategory) { + $perItemRate = $ruleCategory['perItemRate']; + $weightRate = $ruleCategory['weightRate']; + $percentageRate = $ruleCategory['percentageRate']; + $ruleCategory['perItemRate'] = (!isset($perItemRate) || trim((string)$perItemRate['value']) === '') + ? null + : Money::toDecimal(Money::toMoney(array_merge([ + 'currency' => $shippingRule->getStore()->getCurrency(), + ], $perItemRate))); + $ruleCategory['weightRate'] = (!isset($weightRate) || trim((string)$weightRate['value']) === '') + ? null + : Money::toDecimal(Money::toMoney(array_merge([ + 'currency' => $shippingRule->getStore()->getCurrency(), + ], $weightRate))); + $ruleCategory['percentageRate'] = (!isset($percentageRate) || trim((string)$percentageRate) === '') ? null : Localization::normalizeNumber($percentageRate); + + $ruleCategories[$key] = new ShippingRuleCategory($ruleCategory); + $ruleCategories[$key]->shippingCategoryId = $key; + } + + $shippingRule->setShippingRuleCategories($ruleCategories); + + if (!app(ShippingRules::class)->saveShippingRule($shippingRule)) { + return $this->asModelFailure($shippingRule, t('Couldn\'t save shipping rule.', category: 'commerce'), 'shippingRule'); + } + + return $this->asModelSuccess($shippingRule, t('Shipping rule saved.', category: 'commerce'), 'shippingRule'); + } + + public function reorder(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + abort_unless($request->input('ids'), 400, 'Missing ids'); + + $ids = Json::decode($request->input('ids')); + app(ShippingRules::class)->reorderShippingRules($ids); + + return $this->asSuccess(); + } + + public function delete(Request $request): Response + { + if ($request->ajax()) { + abort_unless($request->expectsJson(), 400); + } + + $id = $request->input('id'); + abort_if(!$id, 400, 'Shipping rule ID not submitted'); + $id = (int)$id; + + $rule = app(ShippingRules::class)->getShippingRuleById($id); + abort_if($rule === null, 400, 'Cannot find shipping rule to delete'); + + if (!app(ShippingRules::class)->deleteShippingRuleById($id)) { + return $this->asFailure(t('Could not delete shipping rule', category: 'commerce')); + } + + if ($request->ajax()) { + return $this->asSuccess(); + } + + return $this->redirectToPostedUrl($rule); + } +} diff --git a/src/Http/Controllers/Settings/ShippingZonesController.php b/src/Http/Controllers/Settings/ShippingZonesController.php new file mode 100644 index 0000000000..e6a641ec5a --- /dev/null +++ b/src/Http/Controllers/Settings/ShippingZonesController.php @@ -0,0 +1,169 @@ +resolveStore($storeHandle); + + $shippingZones = app(ShippingZones::class)->getAllShippingZones($store->id); + + $tableData = []; + foreach ($shippingZones as $shippingZone) { + $label = NewHtml::encode(t($shippingZone->name, category: 'site')); + $tableData[] = [ + 'id' => $shippingZone->id, + 'title' => NewHtml::a($label, $shippingZone->getCpEditUrl()), + 'url' => $shippingZone->getCpEditUrl(), + 'description' => NewHtml::encode(t($shippingZone->description, category: 'site')), + ]; + } + + $tableData = Json::encode($tableData); + + $js = <<storeManagementCpScreen($storeHandle) + ->additionalButtonsHtml(NewHtml::a(t('New shipping zone', category: 'commerce'), $store->getStoreSettingsUrl('shippingzones/new'), ['class' => 'btn submit add icon'])) + ->contentHtml(NewHtml::tag('div', '', ['id' => 'shipping-vue-admin-table'])); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + $storeHandle = $store->handle; + + if ($id) { + $shippingZone = app(ShippingZones::class)->getShippingZoneById($id, $store->id); + abort_if($shippingZone === null, 404); + } else { + $shippingZone = \Craft::createObject([ + 'class' => ShippingAddressZone::class, + 'attributes' => ['storeId' => $store->id], + ]); + } + + $title = $shippingZone->id ? $shippingZone->name : t('Create a shipping zone', category: 'commerce'); + + $condition = $shippingZone->getCondition(); + $condition->mainTag = 'div'; + $condition->name = 'condition'; + $condition->id = 'condition'; + + $metadata = []; + if ($shippingZone->id) { + $metadata = [ + t('Created at') => I18N::getFormatter()->asDatetime($shippingZone->dateCreated, 'short'), + t('Updated at') => I18N::getFormatter()->asDatetime($shippingZone->dateUpdated, 'short'), + ]; + } + + return $this->storeManagementCpScreen($storeHandle, false) + ->title($title) + ->addCrumb(t('Shipping Zones', category: 'commerce'), $store->getStoreSettingsUrl('shippingzones')) + ->action('commerce/shipping-zones/save') + ->redirectUrl($store->getStoreSettingsUrl('shippingzones')) + ->metaSidebarHtml(\craft\helpers\Cp::metadataHtml($metadata)) + ->contentTemplate('commerce/store-management/shipping/shippingzones/_edit', [ + 'shippingZone' => $shippingZone, + 'condition' => $condition, + 'store' => $store, + ]); + } + + public function save(Request $request): Response + { + $shippingZone = new ShippingAddressZone(); + + $shippingZone->id = $request->input('shippingZoneId') ? (int)$request->input('shippingZoneId') : null; + $shippingZone->storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; + $shippingZone->name = $request->input('name'); + $shippingZone->description = $request->input('description'); + $shippingZone->setCondition($request->input('condition')); + + if ($shippingZone->validate() && app(ShippingZones::class)->saveShippingZone($shippingZone)) { + return $this->asModelSuccess( + $shippingZone, + t('Shipping zone saved.', category: 'commerce'), + 'shippingZone', + data: [ + 'id' => $shippingZone->id, + 'name' => $shippingZone->name, + ] + ); + } + + return $this->asModelFailure( + $shippingZone, + t('Couldn\'t save shipping zone.', category: 'commerce'), + 'shippingZone' + ); + } + + public function delete(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + abort_if(!$id, 400, 'Missing shipping zone id'); + + if (!app(ShippingZones::class)->deleteShippingZoneById((int)$id)) { + return $this->asFailure(t('Could not delete shipping zone', category: 'commerce')); + } + + return $this->asSuccess(); + } + + public function testZip(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $zipCodeFormula = (string)$request->input('zipCodeConditionFormula'); + $testZipCode = (string)$request->input('testZipCode'); + + $params = ['zipCode' => $testZipCode]; + + if (!app(Formulas::class)->evaluateCondition($zipCodeFormula, $params)) { + return $this->asFailure('failed'); + } + + return $this->asSuccess(); + } +} diff --git a/src/Http/Controllers/Settings/StoreManagementController.php b/src/Http/Controllers/Settings/StoreManagementController.php new file mode 100644 index 0000000000..aae7770370 --- /dev/null +++ b/src/Http/Controllers/Settings/StoreManagementController.php @@ -0,0 +1,206 @@ +getStore(); + + if (currentUser()?->can('commerce-manageGeneralStoreSettings')) { + return redirect($store->getStoreSettingsUrl()); + } + + if (currentUser()?->can('commerce-managePaymentCurrencies')) { + return redirect($store->getStoreSettingsUrl('payment-currencies')); + } + + if (currentUser()?->can('commerce-managePromotions')) { + return redirect($store->getStoreSettingsUrl('discounts')); + } + + if (currentUser()?->can('commerce-manageShipping')) { + return redirect($store->getStoreSettingsUrl('shipping')); + } + + if (currentUser()?->can('commerce-manageTaxes')) { + return redirect($store->getStoreSettingsUrl('taxrates')); + } + + return $this->storeManagementCpScreen($store->handle) + ->contentHtml(Html::tag( + 'p', + t('No access given to any specific store management features.', category: 'commerce') + )); + } + + public function edit(?string $storeHandle = null): Response|CpScreenResponse + { + abort_unless(currentUser()?->can('commerce-manageGeneralStoreSettings'), 403); + + if ($storeHandle) { + $store = app(Stores::class)->getStoreByHandle($storeHandle); + abort_if($store === null, 404); + $storeSettings = $store->getSettings(); + } else { + $site = Cp::requestedSite(); + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + return redirect($site->getStore()->getStoreSettingsUrl()); + } + + $allCountries = Addresses::getCountryRepository()->getList(\Craft::$app->language); + + $locationFieldHtml = Cp::elementCardHtml($storeSettings->getLocationAddress(), [ + 'context' => 'field', + 'inputName' => 'locationAddressId', + 'showActionMenu' => true, + ]); + + $condition = $storeSettings->getMarketAddressCondition(); + $condition->mainTag = 'div'; + $condition->name = 'marketAddressCondition'; + $condition->id = 'marketAddressCondition'; + $marketAddressConditionFieldHtml = Cp::fieldHtml($condition->getBuilderHtml(), [ + 'label' => t('Order Address Condition'), + 'instructions' => t('Only allow orders with addresses that match the following rules:'), + ]); + + $countriesField = Cp::selectizeFieldHtml([ + 'label' => t('Country List', category: 'commerce'), + 'instructions' => t('The countries that orders are allowed to be placed from.', category: 'commerce'), + 'id' => 'countries', + 'name' => 'countries', + 'multi' => true, + 'values' => $storeSettings->getCountries(), + 'options' => $allCountries, + 'errors' => $storeSettings->getErrors('countries'), + 'allowEmptyOption' => true, + ]); + + $inventoryLocations = app(InventoryLocations::class)->getInventoryLocations($store->id); + $allInventoryLocations = app(InventoryLocations::class)->getAllInventoryLocations(); + $currentUser = currentUserElement(); + + $locationsCount = count($allInventoryLocations); + $userCanCreate = $currentUser?->can('commerce-manageInventoryLocations'); + $inventoryLocationsField = ''; + + if ($userCanCreate) { + $canCreate = false; + + $limit = Plugin::EDITION_PRO_STORE_LIMIT; + if ($locationsCount < $limit) { + $canCreate = true; + } + + if (Plugin::getInstance()->is(Plugin::EDITION_ENTERPRISE, '=')) { + $limit = null; + $canCreate = true; + } + + $config = [ + 'label' => t('Inventory Locations', category: 'commerce'), + 'instructions' => t('The inventory locations this store uses.', category: 'commerce'), + 'id' => 'inventoryLocations', + 'name' => 'inventoryLocations[]', + 'values' => $inventoryLocations, + 'create' => $canCreate, + ]; + + if ($limit !== null) { + $config['limit'] = $limit; + } + + $inventoryLocationsField = CommerceCp::inventoryLocationFieldHtml($config); + } + + return $this->storeManagementCpScreen($storeHandle) + ->action('commerce/store-management/save') + ->redirectUrl($store->getStoreSettingsUrl()) + ->submitButtonLabel(t('Save')) + ->contentTemplate('commerce/store-management/general/_edit', [ + 'store' => $store, + 'storeHandle' => $storeHandle, + 'storeSettings' => $storeSettings, + 'marketAddressConditionField' => $marketAddressConditionFieldHtml, + 'countriesField' => $countriesField, + 'locationField' => $locationFieldHtml, + 'inventoryLocationsField' => $inventoryLocationsField, + ]); + } + + public function save(Request $request): Response + { + abort_unless(currentUser()?->can('commerce-manageGeneralStoreSettings'), 403); + + $storeId = (int)$request->input('id'); + $store = app(Stores::class)->getStoreById($storeId); + $storeSettings = app(StoreSettings::class)->getStoreSettingsById($storeId); + $currentUser = currentUserElement(); + + if ($locationAddressId = $request->input('locationAddressId')) { + $locationAddress = Address::find()->id($locationAddressId)->one(); + if ($locationAddress) { + $storeSettings->setLocationAddress($locationAddress); + } + } + $marketAddressCondition = $request->input('marketAddressCondition') ?? new ZoneAddressCondition(); + $storeSettings->setMarketAddressCondition($marketAddressCondition); + $countries = $request->input('countries') ?: []; + $storeSettings->setCountries($countries); + + if ($currentUser?->can('commerce-manageInventoryLocations')) { + $inventoryLocations = $request->input('inventoryLocations'); + + if (!$inventoryLocations) { + return $this->asFailure(t('Missing a default inventory location.', category: 'commerce')); + } + + if (!app(InventoryLocations::class)->saveStoreInventoryLocations($store, $inventoryLocations)) { + return $this->asFailure(t('Inventory locations not saved.', category: 'commerce')); + } + } + + if (!$storeSettings->validate() || !app(StoreSettings::class)->saveStoreSettings($storeSettings)) { + return $this->asModelFailure( + model: $storeSettings, + message: t('Couldn\'t save store.', category: 'commerce'), + modelName: 'storeSettings', + ); + } + + return $this->asModelSuccess( + model: $storeSettings, + message: t('Store saved.', category: 'commerce'), + modelName: 'storeSettings', + ); + } +} diff --git a/src/Http/Controllers/Settings/StoresController.php b/src/Http/Controllers/Settings/StoresController.php new file mode 100644 index 0000000000..53f666be7a --- /dev/null +++ b/src/Http/Controllers/Settings/StoresController.php @@ -0,0 +1,273 @@ +readOnly = !$generalConfig->allowAdminChanges; + } + + public function editStore(?int $storeId = null): string + { + $storesService = app(Stores::class); + + $brandNewStore = false; + $allowCurrencyChange = false; + + if ($storeId !== null) { + $storeModel = $storesService->getStoreById($storeId); + abort_if($storeModel === null, 404, 'Store not found'); + + $title = trim((string)$storeModel->getName()) ?: t('Edit Store'); + } else { + $storeModel = new Store(); + $brandNewStore = true; + $allowCurrencyChange = true; + + $title = t('Create a new Store'); + } + + $crumbs = [ + ['label' => t('Commerce', category: 'commerce'), 'url' => Url::url('commerce')], + ['label' => t('Settings', category: 'commerce'), 'url' => Url::url('commerce/settings')], + ['label' => t('Stores'), 'url' => Url::url('commerce/settings/stores')], + ]; + + $hasOrders = $storeModel->id && Order::find() + ->trashed(null) + ->storeId($storeModel->id) + ->exists(); + + if (!$hasOrders) { + $allowCurrencyChange = true; + } + + $availableSiteOptions = collect(Sites::getAllSites())->map(function($site) { + $availableForAssignmentToNewStores = app(Stores::class)->getSiteIdsAvailableForAssignmentToNewStores(); + return [ + 'label' => $site->name, + 'value' => $site->id, + 'disabled' => collect($availableForAssignmentToNewStores)->contains($site->id) === false, + ]; + })->all(); + + $currencyOptions = app(Currencies::class)->getAllCurrenciesList(); + + return pageTemplate('commerce/settings/stores/_edit', [ + 'brandNewStore' => $brandNewStore, + 'allowCurrencyChange' => $allowCurrencyChange, + 'title' => $title, + 'crumbs' => $crumbs, + 'store' => $storeModel, + 'currencyOptions' => $currencyOptions, + 'availableSiteOptions' => $availableSiteOptions, + 'freeOrderPaymentStrategyOptions' => $storeModel->getFreeOrderPaymentStrategyOptions(), + 'minimumTotalPriceStrategyOptions' => $storeModel->getMinimumTotalPriceStrategyOptions(), + 'readOnly' => $this->readOnly, + ], TemplateMode::Cp); + } + + public function saveStore(Request $request): Response + { + $storesService = app(Stores::class); + $storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; + + if ($storeId) { + $store = $storesService->getStoreById($storeId); + abort_if($store === null, 400, "Invalid store ID: $storeId"); + } else { + $store = new Store(); + } + + $store->setName($request->input('name')); + $store->handle = $request->input('handle'); + $store->setAutoSetNewCartAddresses($request->input('autoSetNewCartAddresses')); + $store->setAutoSetCartShippingMethodOption($request->input('autoSetCartShippingMethodOption')); + $store->setAutoSetPaymentSource($request->input('autoSetPaymentSource')); + $store->setAllowEmptyCartOnCheckout($request->input('allowEmptyCartOnCheckout')); + $store->setAllowCheckoutWithoutPayment($request->input('allowCheckoutWithoutPayment')); + $store->setAllowPartialPaymentOnCheckout($request->input('allowPartialPaymentOnCheckout')); + $store->setRequireShippingAddressAtCheckout($request->input('requireShippingAddressAtCheckout')); + $store->setRequireBillingAddressAtCheckout($request->input('requireBillingAddressAtCheckout')); + $store->setRequireShippingMethodSelectionAtCheckout($request->input('requireShippingMethodSelectionAtCheckout')); + $store->setUseBillingAddressForTax($request->input('useBillingAddressForTax')); + $store->setValidateOrganizationTaxIdAsVatId($request->input('validateOrganizationTaxIdAsVatId')); + $store->setOrderReferenceFormat($request->input('orderReferenceFormat')); + $store->setFreeOrderPaymentStrategy($request->input('freeOrderPaymentStrategy')); + $store->setMinimumTotalPriceStrategy($request->input('minimumTotalPriceStrategy')); + $store->primary = (bool)$request->input('primary', $store->primary); + + if ($currency = $request->input('currency')) { + $store->setCurrency($currency); + } + + if ($storeId && $savedStore = $storesService->getStoreById($storeId)) { + $store->uid = $savedStore->uid; + $store->sortOrder = $savedStore->sortOrder; + } elseif (!$storeId) { + $store->sortOrder = new Query()->from(Table::STORES)->max('[[sortOrder]]') + 1; + } + + if (!$store->validate() || !$storesService->saveStore($store)) { + return $this->asModelFailure($store, t('Couldn\'t save the store.'), 'store'); + } + + if ($siteId = $request->input('siteId')) { + $siteStore = collect($storesService->getAllSiteStores())->where('siteId', $siteId)->first(); + $siteStore->storeId = $store->id; + $storesService->saveSiteStore($siteStore); + } + + return $this->asModelSuccess($store, t('Store saved.'), 'store'); + } + + public function storesIndex(): string + { + $stores = app(Stores::class)->getAllStores(); + + $crumbs = [ + ['label' => t('Commerce', category: 'commerce'), 'url' => Url::url('commerce')], + ]; + + $menuItems = []; + $stores->each(function(Store $s) use (&$menuItems) { + $m = []; + $m[] = ['label' => t('Payment Currencies', category: 'commerce'), 'url' => Url::cpUrl('commerce/store-management/' . $s->handle . '/payment-currencies')]; + $m[] = ['label' => t('Discounts', category: 'commerce'), 'url' => Url::cpUrl('commerce/store-management/' . $s->handle . '/discounts')]; + + if (app(CatalogPricingRules::class)->canUseCatalogPricingRules()) { + $m[] = ['label' => t('Pricing Rules', category: 'commerce'), 'url' => Url::cpUrl('commerce/store-management/' . $s->handle . '/pricing-rules')]; + } else { + $m[] = ['label' => t('Sales', category: 'commerce'), 'url' => Url::cpUrl('commerce/store-management/' . $s->handle . '/sales')]; + } + + $m[] = ['label' => t('Shipping Methods', category: 'commerce'), 'url' => Url::cpUrl('commerce/store-management/' . $s->handle . '/shippingmethods')]; + $m[] = ['label' => t('Shipping Zones', category: 'commerce'), 'url' => Url::cpUrl('commerce/store-management/' . $s->handle . '/shippingzones')]; + $m[] = ['label' => t('Shipping Categories', category: 'commerce'), 'url' => Url::cpUrl('commerce/store-management/' . $s->handle . '/shippingcategories')]; + $m[] = ['label' => t('Tax Rates', category: 'commerce'), 'url' => Url::cpUrl('commerce/store-management/' . $s->handle . '/taxrates')]; + $m[] = ['label' => t('Tax Zones', category: 'commerce'), 'url' => Url::cpUrl('commerce/store-management/' . $s->handle . '/taxzones')]; + $m[] = ['label' => t('Tax Categories', category: 'commerce'), 'url' => Url::cpUrl('commerce/store-management/' . $s->handle . '/taxcategories')]; + + $menuItems[$s->handle] = $m; + }); + + return pageTemplate('commerce/settings/stores/index', [ + 'stores' => $stores, + 'crumbs' => $crumbs, + 'sitesStores' => app(Stores::class)->getAllSiteStores(), + 'primaryStoreId' => app(Stores::class)->getPrimaryStore()->id, + 'menuItems' => $menuItems, + 'readOnly' => $this->readOnly, + ], TemplateMode::Cp); + } + + public function deleteStore(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $siteId = $request->input('id'); + abort_if(!$siteId, 400, 'Missing store id'); + + app(Stores::class)->deleteStoreById($siteId); + + return $this->asSuccess(); + } + + public function reorderStores(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + abort_unless($request->input('ids'), 400, 'Missing ids'); + + $ids = Json::decode($request->input('ids')); + + if (!app(Stores::class)->reorderStores($ids)) { + return $this->asFailure(t('Couldn\'t reorder stores.', category: 'commerce')); + } + + return $this->asSuccess(); + } + + public function editSiteStores(): string + { + $crumbs = [ + ['label' => t('Commerce', category: 'commerce'), 'url' => Url::url('commerce')], + ]; + + return pageTemplate('commerce/settings/stores/_siteStore', [ + 'crumbs' => $crumbs, + 'stores' => app(Stores::class)->getAllStores(), + 'sites' => Sites::getAllSites(), + 'sitesStores' => app(Stores::class)->getAllSiteStores(), + 'primaryStoreId' => app(Stores::class)->getPrimaryStore()->id, + 'readOnly' => $this->readOnly, + ], TemplateMode::Cp); + } + + public function saveSiteStores(Request $request): Response + { + $siteStoresData = $request->input('siteStores', []); + $sitesStores = app(Stores::class)->getAllSiteStores(); + $stores = app(Stores::class)->getAllStores(); + + foreach ($sitesStores as $siteStore) { + if (isset($siteStoresData[$siteStore->siteId])) { + $siteStore->storeId = $siteStoresData[$siteStore->siteId]['storeId']; + } + } + + $unassignedStores = []; + foreach ($stores as $store) { + $storeAssigned = false; + foreach ($sitesStores as $siteStore) { + if ($siteStore->storeId == $store->id) { + $storeAssigned = true; + } + } + if (!$storeAssigned) { + $unassignedStores[] = $store->getName(); + } + } + if ($unassignedStores) { + return $this->asFailure( + t('{storeNames} {num, plural, =1{has} other{have}} not been assigned to a site.', [ + 'storeNames' => implode(', ', $unassignedStores), + 'num' => count($unassignedStores), + ], category: 'commerce'), + data: ['sitesStores' => collect($sitesStores)] + ); + } + + foreach ($sitesStores as $siteStore) { + app(Stores::class)->saveSiteStore($siteStore); + } + + return $this->asSuccess(t('Site store mapping saved.', category: 'commerce')); + } +} diff --git a/src/Http/Controllers/Settings/TaxCategoriesController.php b/src/Http/Controllers/Settings/TaxCategoriesController.php new file mode 100644 index 0000000000..d8c219cdb5 --- /dev/null +++ b/src/Http/Controllers/Settings/TaxCategoriesController.php @@ -0,0 +1,252 @@ +resolveStore($storeHandle); + + $taxCategories = app(TaxCategories::class)->getAllTaxCategories(); + + $tableData = []; + foreach ($taxCategories as $taxCategory) { + $label = NewHtml::encode(t($taxCategory->name, category: 'site')); + $taxRates = $taxCategory->getTaxRates($store->id); + $tableData[] = [ + 'id' => $taxCategory->id, + 'title' => $label, + 'chip' => Cp::chipHtml($taxCategory, [ + 'labelHtml' => NewHtml::a($label, $taxCategory->getCpEditUrl($store->id), [ + 'class' => ['chip-label', 'cell-bold'], + ]), + ]), + 'url' => $taxCategory->getCpEditUrl($store->id), + 'handle' => $taxCategory->handle, + 'description' => NewHtml::encode(t($taxCategory->description, category: 'site')), + 'default' => $taxCategory->default, + '_showDelete' => $taxRates->isEmpty() && (count($taxCategories) > 1 && !$taxCategory->default), + ]; + } + + $buttons = app(Taxes::class)->taxCategoryActionHtml(); + if (app(Taxes::class)->createTaxCategories()) { + $buttons .= NewHtml::a(t('New tax category', category: 'commerce'), $store->getStoreSettingsUrl('taxcategories/new'), [ + 'class' => ['btn', 'submit', 'add', 'icon'], + ]); + } + + $tableData = Json::encode($tableData); + $deleteAction = app(Taxes::class)->deleteTaxCategories() ? "'commerce/tax-categories/delete'" : 'null'; + + $js = <<'; + } + } + }, + ]; + + var actions = [ + { + label: '', + icon: 'settings', + actions: [ + { + label: Craft.t('commerce', 'Set default category'), + action: 'commerce/tax-categories/set-default-category', + param: 'default', + value: 1, + allowMultiple: false + } + ] + } + ]; + + new Craft.VueAdminTable({ + columns: columns, + checkboxes: true, + actions: actions, + padded: true, + container: '#tax-vue-admin-table', + deleteAction: {$deleteAction}, + tableData: {$tableData}, + }); +JS; + + HtmlStack::js($js, Position::BodyEnd); + + return $this->storeManagementCpScreen($storeHandle, hasStoreSwitcher: false) + ->additionalButtonsHtml($buttons) + ->contentHtml(NewHtml::tag('div', '', ['id' => 'tax-vue-admin-table'])); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + $storeHandle = $store->handle; + + $productTypes = app(ProductTypes::class)->getAllProductTypes(); + + if ($id) { + $taxCategory = app(TaxCategories::class)->getTaxCategoryById($id); + abort_if($taxCategory === null, 404); + } else { + $taxCategory = new TaxCategory(); + } + + $title = $taxCategory->id ? $taxCategory->name : t('Create a new tax category', category: 'commerce'); + + $productTypesOptions = []; + if (!empty($productTypes)) { + $productTypesOptions = Arr::mapWithKeys($productTypes, fn($row) => [$row->id => ['label' => $row->name, 'value' => $row->id]]); + } + + $allTaxCategoryIds = array_keys(app(TaxCategories::class)->getAllTaxCategories()); + $isDefaultAndOnlyCategory = $id && count($allTaxCategoryIds) === 1 && in_array($id, $allTaxCategoryIds); + + $taxRates = collect(); + app(Stores::class)->getAllStores()->each(fn(Store $s) => $taxRates->push(...app(TaxRates::class)->getAllTaxRates($s->id)->all())); + + $metaSidebar = ''; + if ($taxCategory->id) { + $metaSidebar = Cp::metadataHtml([ + t('Created at') => I18N::getFormatter()->asDatetime($taxCategory->dateCreated, 'short'), + t('Updated at') => I18N::getFormatter()->asDatetime($taxCategory->dateUpdated, 'short'), + ]); + } + + return $this->storeManagementCpScreen($storeHandle, false, false) + ->title($title) + ->addCrumb(t('Tax Categories', category: 'commerce'), $store->getStoreSettingsUrl('taxcategories')) + ->action('commerce/tax-categories/save') + ->redirectUrl($store->getStoreSettingsUrl('taxcategories')) + ->metaSidebarHtml($metaSidebar) + ->contentTemplate('commerce/store-management/tax/taxcategories/_edit', [ + 'taxCategory' => $taxCategory, + 'productTypes' => $productTypes, + 'productTypesOptions' => $productTypesOptions, + 'isDefaultAndOnlyCategory' => $isDefaultAndOnlyCategory, + 'taxRates' => $taxRates, + 'store' => $store, + ]); + } + + public function save(Request $request): Response + { + $taxCategory = new TaxCategory(); + + $taxCategory->id = $request->input('taxCategoryId') ? (int)$request->input('taxCategoryId') : null; + $taxCategory->name = $request->input('name'); + $taxCategory->handle = $request->input('handle'); + $taxCategory->icon = $request->input('icon'); + $taxCategory->color = $request->input('color'); + $taxCategory->description = $request->input('description'); + $taxCategory->default = (bool)$request->input('default'); + + $postedProductTypes = $request->input('productTypes', []) ?: []; + $productTypes = []; + foreach ($postedProductTypes as $productTypeId) { + if ($productTypeId && $productType = app(ProductTypes::class)->getProductTypeById((int)$productTypeId)) { + $productTypes[] = $productType; + } + } + $taxCategory->setProductTypes($productTypes); + + if (!app(TaxCategories::class)->saveTaxCategory($taxCategory)) { + return $this->asModelFailure( + $taxCategory, + t('Couldn\'t save tax category.', category: 'commerce'), + 'taxCategory' + ); + } + + return $this->asModelSuccess( + $taxCategory, + t('Tax category saved.', category: 'commerce'), + 'taxCategory' + ); + } + + public function delete(Request $request): Response + { + $id = $request->input('id'); + $ids = $request->input('ids'); + + abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); + + if ($id) { + abort_unless($request->expectsJson(), 400); + $ids = [$id]; + } + + $failedIds = []; + foreach ($ids as $deleteId) { + if (!app(TaxCategories::class)->deleteTaxCategoryById((int)$deleteId)) { + $failedIds[] = $deleteId; + } + } + + if (!empty($failedIds)) { + return $this->asFailure(t('Could not delete {count, number} tax {count, plural, one{category} other{categories}}.', [ + 'count' => count($failedIds), + ], category: 'commerce')); + } + + return $this->asSuccess(t('Tax categories deleted.', category: 'commerce')); + } + + public function setDefaultCategory(Request $request): Response + { + $ids = $request->input('ids'); + abort_if(empty($ids), 400, 'Missing ids'); + + $id = Arr::first($ids); + + $taxCategory = app(TaxCategories::class)->getTaxCategoryById((int)$id); + if ($taxCategory) { + $taxCategory->default = true; + if (app(TaxCategories::class)->saveTaxCategory($taxCategory)) { + return $this->asSuccess(t('Tax category updated.', category: 'commerce')); + } + } + + return $this->asFailure(t('Unable to set default tax category.', category: 'commerce')); + } +} diff --git a/src/Http/Controllers/Settings/TaxRatesController.php b/src/Http/Controllers/Settings/TaxRatesController.php new file mode 100644 index 0000000000..25eeb7eaa6 --- /dev/null +++ b/src/Http/Controllers/Settings/TaxRatesController.php @@ -0,0 +1,280 @@ +resolveStore($storeHandle); + $storeHandle = $store->handle; + + $taxRates = app(TaxRates::class)->getAllTaxRates($store->id); + + // Preload all zone and category data for listing. + app(TaxZones::class)->getAllTaxZones($store->id); + app(TaxCategories::class)->getAllTaxCategories(); + + $tableData = []; + foreach ($taxRates as $taxRate) { + $label = NewHtml::encode(t($taxRate->name, category: 'site')); + $tableData[] = [ + 'id' => $taxRate->id, + 'status' => $taxRate->enabled, + 'title' => NewHtml::a($label, $taxRate->getCpEditUrl()), + 'url' => $taxRate->getCpEditUrl(), + 'rate' => $taxRate->getRateAsPercent(), + 'included' => $taxRate->include, + 'removeIncluded' => $taxRate->removeIncluded, + 'vat' => $taxRate->hasTaxIdValidators(), + 'zone' => $taxRate->getIsEverywhere() ? t('Everywhere', category: 'commerce') : ($taxRate->getTaxZone() ? NewHtml::encode($taxRate->getTaxZone()->name) : ''), + 'category' => $taxRate->getTaxCategory() ? Cp::chipHtml($taxRate->getTaxCategory()) : '', + ]; + } + + $buttonsHtml = app(Taxes::class)->taxRateActionHtml(); + + if (app(Taxes::class)->createTaxRates()) { + $buttonsHtml .= NewHtml::a(t('New tax rate', category: 'commerce'), "commerce/store-management/$storeHandle/taxrates/new", [ + 'class' => 'btn submit add icon', + ]); + } + + $tableData = Json::encode($tableData, JSON_UNESCAPED_UNICODE); + $deleteAction = app(Taxes::class)->deleteTaxRates() ? 'commerce/tax-rates/delete' : null; + + $js = <<'; + } + } }, + { name: 'removeIncluded', title: Craft.t('commerce', 'Remove from price?'), callback: function(value) { + if (value) { + return ''; + } + } }, + { name: 'zone', title: Craft.t('commerce', 'Tax Zone') }, + { name: 'category', title: Craft.t('commerce', 'Tax Category') } +]; + +var actions = [ + { + label: Craft.t('commerce', 'Set status'), + actions: [ + { + label: Craft.t('commerce', 'Enabled'), + action: 'commerce/tax-rates/update-status', + param: 'status', + value: 'enabled', + status: 'enabled' + }, + { + label: Craft.t('commerce', 'Disabled'), + action: 'commerce/tax-rates/update-status', + param: 'status', + value: 'disabled', + status: 'disabled' + } + ] + } +]; + +new Craft.VueAdminTable({ + columns: columns, + actions: actions, + checkboxes: true, + container: '#taxrate-vue-admin-table', + deleteAction: '{$deleteAction}', + tableData: {$tableData}, +}); +JS; + + HtmlStack::js($js, Position::BodyEnd); + + return $this->storeManagementCpScreen($storeHandle) + ->additionalButtonsHtml($buttonsHtml) + ->contentHtml(NewHtml::tag('div', '', ['id' => 'taxrate-vue-admin-table'])); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + abort_unless(app(Taxes::class)->viewTaxRates(), 403, 'Tax engine does not permit you to perform this action'); + + $store = $this->resolveStore($storeHandle); + $storeHandle = $store->handle; + $percentSymbol = I18N::getFormattingLocale()->getNumberSymbol(Locale::SYMBOL_PERCENT); + + + if ($id) { + $taxRate = app(TaxRates::class)->getTaxRateById($id, $store->id); + abort_if($taxRate === null, 404); + } else { + $taxRate = \Craft::createObject([ + 'class' => TaxRate::class, + 'storeId' => $store->id, + ]); + } + + $title = $taxRate->id ? $taxRate->name : t('Create a new tax rate', category: 'commerce'); + + $variables = compact('taxRate', 'store', 'storeHandle', 'percentSymbol'); + + $taxZone = null; + if ($taxRate->taxZoneId) { + $taxZone = app(TaxZones::class)->getTaxZoneById($taxRate->taxZoneId, $store->id); + } + + $taxCategory = null; + if ($taxRate->taxCategoryId) { + $taxCategory = app(TaxCategories::class)->getTaxCategoryById($taxRate->taxCategoryId); + } + + $variables['taxZoneField'] = CommerceCp::taxZoneFieldHtml([ + 'label' => t('Tax Zone', category: 'commerce'), + 'instructions' => t('Select a tax zone. If empty, this rate will match anywhere.', category: 'commerce'), + 'id' => 'taxZoneId', + 'name' => 'taxZoneId', + 'value' => $taxZone, + 'errors' => $taxRate->getErrors('taxZoneId'), + 'required' => false, + 'limit' => 1, + 'storeId' => $store->id, + 'storeHandle' => $storeHandle, + ]); + + $variables['taxCategoryField'] = CommerceCp::taxCategoryFieldHtml([ + 'label' => t('Tax Category', category: 'commerce'), + 'instructions' => t('Select a tax category.', category: 'commerce'), + 'id' => 'taxCategoryId', + 'name' => 'taxCategoryId', + 'value' => $taxCategory, + 'errors' => $taxRate->getErrors('taxCategoryId'), + 'required' => true, + 'limit' => 1, + 'storeHandle' => $storeHandle, + ]); + + $taxable = []; + $taxable[TaxRateRecord::TAXABLE_PURCHASABLE] = t('Unit price (minus discounts)', category: 'commerce'); + $taxable[TaxRateRecord::TAXABLE_PRICE] = t('Line item price (minus discounts)', category: 'commerce'); + $taxable[TaxRateRecord::TAXABLE_SHIPPING] = t('Line item shipping cost', category: 'commerce'); + $taxable[TaxRateRecord::TAXABLE_PRICE_SHIPPING] = t('Both (Line item price + Line item shipping costs)', category: 'commerce'); + $taxable[TaxRateRecord::TAXABLE_ORDER_TOTAL_SHIPPING] = t('Order total shipping cost', category: 'commerce'); + $taxable[TaxRateRecord::TAXABLE_ORDER_TOTAL_PRICE] = t('Order total taxable price (Line item subtotal + Total discounts + Total shipping)', category: 'commerce'); + $variables['taxables'] = $taxable; + $variables['taxablesNoTaxCategory'] = TaxRateRecord::ORDER_TAXABALES; + + $variables['hideTaxCategory'] = false; + if ($variables['taxRate']->id && in_array($variables['taxRate']->taxable, $variables['taxablesNoTaxCategory'], false)) { + $variables['hideTaxCategory'] = true; + } + + $variables['taxIdValidators'] = []; + $taxIdValidators = app(Taxes::class)->getEnabledTaxIdValidators(); + foreach ($taxIdValidators as $validator) { + $variables['taxIdValidators'][] = $validator; + } + + return $this->storeManagementCpScreen($storeHandle, false) + ->title($title) + ->addCrumb(t('Tax Rates', category: 'commerce'), $store->getStoreSettingsUrl('taxrates')) + ->selectedSubnavItem('store-management') + ->action('commerce/tax-rates/save') + ->redirectUrl($store->getStoreSettingsUrl('taxrates')) + ->metaSidebarTemplate('commerce/store-management/tax/taxrates/_sidebar', $variables) + ->contentTemplate('commerce/store-management/tax/taxrates/_edit', $variables); + } + + public function save(Request $request): Response + { + abort_unless(app(Taxes::class)->editTaxRates(), 403, 'Tax engine does not permit you to perform this action'); + + $taxRate = new TaxRate(); + + $taxRate->id = $request->input('taxRateId') ? (int)$request->input('taxRateId') : null; + $taxRate->storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; + $taxRate->name = $request->input('name'); + $taxRate->code = $request->input('code'); + $taxRate->include = (bool)$request->input('include'); + $taxRate->removeIncluded = (bool)$request->input('removeIncluded'); + $taxRate->removeVatIncluded = (bool)$request->input('removeVatIncluded'); + $taxRate->taxable = $request->input('taxable'); + $taxRate->taxCategoryId = (int)$request->input('taxCategoryId') ?: null; + $taxRate->taxZoneId = (int)$request->input('taxZoneId') ?: null; + $taxRate->rate = Localization::normalizePercentage($request->input('rate')); + $taxRate->enabled = (bool)$request->input('enabled'); + + $validators = collect($request->input('taxIdValidators'))->filter(fn($enabled) => (bool)$enabled)->keys(); + $taxRate->taxIdValidators = $validators->toArray(); + + if (!app(TaxRates::class)->saveTaxRate($taxRate)) { + return $this->asModelFailure($taxRate, t('Couldn\'t save tax rate.', category: 'commerce'), 'taxRate'); + } + + return $this->asModelSuccess($taxRate, t('Tax rate saved.', category: 'commerce'), 'taxRate'); + } + + public function delete(Request $request): Response + { + abort_unless(app(Taxes::class)->deleteTaxRates(), 403, 'Tax engine does not permit you to perform this action'); + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + abort_if(!$id, 400, 'Missing tax rate id'); + + app(TaxRates::class)->deleteTaxRateById((int)$id); + return $this->asSuccess(); + } + + public function updateStatus(Request $request): Response + { + $ids = $request->input('ids'); + $status = $request->input('status'); + + abort_if(empty($ids), 400, 'Missing ids'); + + DB::transaction(function() use ($ids, $status) { + $taxRates = TaxRateRecord::whereIn('id', $ids)->get(); + + foreach ($taxRates as $taxRate) { + $taxRate->enabled = ($status == 'enabled'); + $taxRate->save(); + } + }); + + return $this->asSuccess(t('Tax rates updated.', category: 'commerce')); + } +} diff --git a/src/Http/Controllers/Settings/TaxZonesController.php b/src/Http/Controllers/Settings/TaxZonesController.php new file mode 100644 index 0000000000..26f22a6bc1 --- /dev/null +++ b/src/Http/Controllers/Settings/TaxZonesController.php @@ -0,0 +1,177 @@ +resolveStore($storeHandle); + + $taxZones = app(TaxZones::class)->getAllTaxZones($store->id); + + $tableData = []; + foreach ($taxZones as $taxZone) { + $label = NewHtml::encode(t($taxZone->name, category: 'site')); + $tableData[] = [ + 'id' => $taxZone->id, + 'title' => NewHtml::a($label, $taxZone->getCpEditUrl()), + 'url' => $taxZone->getCpEditUrl(), + 'description' => NewHtml::encode(t($taxZone->description, category: 'site')), + 'default' => $taxZone->default, + ]; + } + + $tableData = Json::encode($tableData); + + $js = <<'; + } + } + }, +]; + +new Craft.VueAdminTable({ + columns: columns, + container: '#tax-vue-admin-table', + deleteAction: 'commerce/tax-zones/delete', + tableData: {$tableData}, + }); +JS; + HtmlStack::js($js, Position::BodyEnd); + + return $this->storeManagementCpScreen($storeHandle) + ->additionalButtonsHtml(NewHtml::a(t('New tax zone', category: 'commerce'), $store->getStoreSettingsUrl('taxzones/new'), ['class' => 'btn submit add icon'])) + ->contentHtml(NewHtml::tag('div', '', ['id' => 'tax-vue-admin-table'])); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + $storeHandle = $store->handle; + + if ($id) { + $taxZone = app(TaxZones::class)->getTaxZoneById($id, $store->id); + abort_if($taxZone === null, 404); + } else { + $taxZone = \Craft::createObject([ + 'class' => TaxAddressZone::class, + 'storeId' => $store->id, + ]); + } + + $title = $taxZone->id ? $taxZone->name : t('Create a tax zone', category: 'commerce'); + + $condition = $taxZone->getCondition(); + $condition->mainTag = 'div'; + $condition->name = 'condition'; + $condition->id = 'condition'; + + $metaSidebar = ''; + if ($taxZone->id) { + $metaSidebar = Cp::metadataHtml([ + t('Created at') => I18N::getFormatter()->asDatetime($taxZone->dateCreated, 'short'), + t('Updated at') => I18N::getFormatter()->asDatetime($taxZone->dateUpdated, 'short'), + ]); + } + + return $this->storeManagementCpScreen($storeHandle, false) + ->title($title) + ->addCrumb(t('Tax Zones', category: 'commerce'), $store->getStoreSettingsUrl('taxzones')) + ->selectedSubnavItem('store-management') + ->action('commerce/tax-zones/save') + ->redirectUrl($store->getStoreSettingsUrl('taxzones')) + ->metaSidebarHtml($metaSidebar) + ->contentTemplate('commerce/store-management/tax/taxzones/_edit', [ + 'taxZone' => $taxZone, + 'store' => $store, + 'condition' => $condition, + ]); + } + + public function save(Request $request): Response + { + $taxZone = new TaxAddressZone(); + + $taxZone->id = $request->input('taxZoneId') ? (int)$request->input('taxZoneId') : null; + $taxZone->storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; + $taxZone->name = $request->input('name'); + $taxZone->description = $request->input('description'); + $taxZone->default = (bool)$request->input('default'); + $taxZone->setCondition($request->input('condition')); + + if ($taxZone->validate() && app(TaxZones::class)->saveTaxZone($taxZone)) { + return $this->asModelSuccess( + $taxZone, + t('Tax zone saved.', category: 'commerce'), + 'taxZone', + data: [ + 'id' => $taxZone->id, + 'name' => $taxZone->name, + ] + ); + } + + return $this->asModelFailure( + $taxZone, + t('Couldn\'t save tax zone.', category: 'commerce'), + 'taxZone' + ); + } + + public function delete(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + abort_if(!$id, 400, 'Missing tax zone id'); + + app(TaxZones::class)->deleteTaxZoneById((int)$id); + return $this->asSuccess(); + } + + public function testZip(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $zipCodeFormula = (string)$request->input('zipCodeConditionFormula'); + $testZipCode = (string)$request->input('testZipCode'); + + $params = ['zipCode' => $testZipCode]; + if (!app(Formulas::class)->evaluateCondition($zipCodeFormula, $params)) { + return $this->asFailure('failed'); + } + + return $this->asSuccess(); + } +} diff --git a/src/Http/Controllers/TransfersController.php b/src/Http/Controllers/TransfersController.php new file mode 100644 index 0000000000..d298dbd163 --- /dev/null +++ b/src/Http/Controllers/TransfersController.php @@ -0,0 +1,317 @@ +canSave($user), 403, 'User not authorized to save this transfer.'); + + $transfer->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); + $success = Drafts::saveElementAsDraft($transfer, $user->id, null, null, false); + + if (!$success) { + return $this->asModelFailure($transfer, t('Couldn\'t create {type}.', [ + 'type' => Transfer::lowerDisplayName(), + ]), 'transfer'); + } + + $editUrl = $transfer->getCpEditUrl(); + + $response = $this->asModelSuccess($transfer, t('{type} created.', [ + 'type' => Transfer::displayName(), + ]), 'transfer', array_filter([ + 'cpEditUrl' => request()->isCpRequest() ? $editUrl : null, + ])); + + if (!request()->expectsJson()) { + return redirect(\CraftCms\Cms\Support\Url::urlWithParams($editUrl, [ + 'fresh' => 1, + ])); + } + + return $response; + } + + public function index(): string + { + return pageTemplate('commerce/inventory/transfers/_index', [], TemplateMode::Cp); + } + + public function markAsPending(Request $request): Response + { + $transferId = $request->input('transferId'); + abort_if(!$transferId, 400, 'Missing transferId'); + + $transfer = Transfer::findOne($transferId); + $transfer->transferStatus = TransferStatusType::PENDING; + + if (!Elements::saveElement($transfer)) { + return $this->asFailure(t('Couldn\'t mark transfer as pending.')); + } + + return $this->asSuccess(t('Transfer marked as pending.')); + } + + public function saveSettings(): Response + { + $fieldLayout = Fields::assembleLayoutFromPost(); + + $fieldLayout->reservedFieldHandles = [ + 'originLocationId', + 'originLocation', + 'destinationLocationId', + 'destinationLocation', + ]; + + $fieldLayout->type = Transfer::class; + + if (!$fieldLayout->validate()) { + return $this->asFailure(t('Couldn\'t save transfer fields.', category: 'commerce')); + } + + if ($currentTransfersFieldLayout = ProjectConfig::get(Transfers::CONFIG_FIELDLAYOUT_KEY)) { + $uid = array_key_first($currentTransfersFieldLayout); + } else { + $uid = (string)\CraftCms\Cms\Support\Str::uuid(); + } + + $configData = [$uid => $fieldLayout->getConfig()]; + $result = ProjectConfig::set(Transfers::CONFIG_FIELDLAYOUT_KEY, $configData, force: true); + + if (!$result) { + return $this->asFailure(t('Couldn\'t save transfer fields.')); + } + + return $this->asSuccess(t('Transfer fields saved.', category: 'commerce')); + } + + public function receiveTransfer(Request $request): Response + { + $details = $request->input('details', []); + $transferId = $request->input('transferId'); + abort_if(!$transferId, 400, 'Missing transferId'); + + /** @var Transfer $transfer */ + $transfer = Transfer::find()->id($transferId)->one(); + + $inventoryMovementCollection = new InventoryMovementCollection(); + $inventoryUpdateCollection = new UpdateInventoryLevelCollection(); + + $transferDetails = $transfer->getDetails(); + + foreach ($transferDetails as $detail) { + if ($acceptedAmount = $details[$detail->uid]['accept'] ?? null) { + // Update the total accepted + $detail->quantityAccepted += $acceptedAmount; + + $inventoryAcceptedMovement = new InventoryTransferMovement(); + $inventoryAcceptedMovement->quantity = (int)$acceptedAmount; + $inventoryAcceptedMovement->transferId = $transfer->id; + $inventoryAcceptedMovement->setInventoryItem($detail->getInventoryItem()); + $inventoryAcceptedMovement->toInventoryLocation = $transfer->getDestinationLocation(); + $inventoryAcceptedMovement->fromInventoryLocation = $transfer->getDestinationLocation(); // we are moving from incoming to available + $inventoryAcceptedMovement->toInventoryTransactionType = InventoryTransactionType::AVAILABLE; + $inventoryAcceptedMovement->fromInventoryTransactionType = InventoryTransactionType::INCOMING; + + $inventoryMovementCollection->push($inventoryAcceptedMovement); + } + + if ($rejectedAmount = $details[$detail->uid]['reject'] ?? null) { + // Update the total rejected + $detail->quantityRejected += $rejectedAmount; + + $inventoryRejectedMovement = new UpdateInventoryLevel(); + $inventoryRejectedMovement->quantity = $rejectedAmount * -1; + $inventoryRejectedMovement->updateAction = InventoryUpdateQuantityType::ADJUST; + $inventoryRejectedMovement->inventoryItemId = $detail->inventoryItemId; + $inventoryRejectedMovement->transferId = $transfer->id; + $inventoryRejectedMovement->setInventoryLocation($transfer->getDestinationLocation()); + $inventoryRejectedMovement->type = InventoryTransactionType::INCOMING->value; + + $inventoryUpdateCollection->push($inventoryRejectedMovement); + } + } + + $transfer->setDetails($transferDetails); + + try { + // Accepted movement + app(Inventory::class)->executeInventoryMovements($inventoryMovementCollection); + // Rejected updates + app(Inventory::class)->executeUpdateInventoryLevels($inventoryUpdateCollection); + Elements::saveElement($transfer, false); + } catch (\Throwable $e) { + Log::error('Failed to save transfer details: ' . $e->getMessage(), ['exception' => $e]); + return $this->asFailure(t('Failed to receive transfer: {error}', ['error' => $e->getMessage()], category: 'commerce')); + } + + return $this->asSuccess(t('Updated', category: 'commerce')); + } + + public function receiveTransferScreen(Request $request): CpScreenResponse + { + $transferId = $request->input('transferId'); + abort_if(!$transferId, 400, 'Missing transferId'); + + /** @var ?Transfer $transfer */ + $transfer = Transfer::find()->id($transferId)->one(); + + if (!$transfer) { + return new CpScreenResponse() + ->contentHtml('Cant find transfer'); + } + + $html = Html::beginTag('div', [ + 'hx' => [ + 'action' => 'commerce/transfers/receive-transfer-modal-content', + ], + ]); + + $html .= Html::tag('h2', t('Receive Transfer', category: 'commerce')); + + $html .= Html::hiddenInput('transferId', $transferId); + + // @TODO Add shortcut links to accept-all and reject-all unreceived items in the receive-transfer modal + // $html .= Html::a(t('Accept All Unreceived', category: 'commerce'), '#'); + // $html .= Html::a(t('Reject All Unreceived', category: 'commerce'), '#'); + + $tableRows = ''; + foreach ($transfer->getDetails() as $detail) { + $deleted = $detail->inventoryItemId == null; + $key = $detail->uid; + $purchasable = $detail->getInventoryItem()?->getPurchasable(Sites::getCurrentSite()->id); + $label = $purchasable ? app(ElementHtml::class)->elementChipHtml($purchasable) : $detail->inventoryItemDescription; + $tableRows .= Html::beginTag('tr'); + $tableRows .= Html::tag('td', $label); + $tableRows .= Html::tag('td', (string)$detail->quantityAccepted, ['class' => 'rightalign']); + $tableRows .= Html::tag('td', + Html::tag('input', '', [ + 'type' => 'number', + 'name' => 'details[' . $key . '][accept]', + 'value' => '', + 'class' => 'text fullwidth', + 'disabled' => $deleted, + 'placeholder' => $deleted ? t('"{name}" deleted.', ['name' => $detail->inventoryItemDescription]) : '', + ]) + ); + $tableRows .= Html::tag('td', (string)$detail->quantityRejected, ['class' => 'rightalign']); + $tableRows .= Html::tag('td', + Html::tag('input', '', [ + 'type' => 'number', + 'name' => 'details[' . $key . '][reject]', + 'value' => '', + 'class' => 'text fullwidth', + 'disabled' => $deleted, + 'placeholder' => $deleted ? t('"{name}" deleted.', ['name' => $detail->inventoryItemDescription]) : '', + ]) + ); + } + + $html .= Html::tag('table', + Html::tag('thead', + Html::tag('tr', + Html::tag('th', t('Item', category: 'commerce')) . + Html::tag('th', t('Accepted', category: 'commerce'), ['class' => 'rightalign']) . + Html::tag('th', t('Accept', category: 'commerce')) . + Html::tag('th', t('Rejected', category: 'commerce'), ['class' => 'rightalign']) . + Html::tag('th', t('Reject', category: 'commerce')) + ) + ) . + $tableRows, + ['class' => 'data fullwidth']); + + $html .= Html::endTag('div'); + + return new CpScreenResponse() + ->action('commerce/transfers/receive-transfer') + ->submitButtonLabel(t('Receive', category: 'commerce')) + ->contentHtml($html); + } + + public function renderManagement(Request $request): string + { + $transferId = $request->input('transferId'); + abort_if(!$transferId, 400, 'Missing transferId'); + + /** @var ?Transfer $transfer */ + $transfer = Transfer::find()->id($transferId)->drafts(null)->one(); + + // We will only change the transfer if it is a draft. + if ($transfer && $transfer->isTransferDraft()) { + $allLocations = app(InventoryLocations::class)->getAllInventoryLocations(); + $defaultFirstLocationId = $allLocations->first()->id; + $defaultSecondLocationId = $allLocations->skip(1)->first()->id; + + $originLocationId = (int)$request->input('originLocationId', $defaultFirstLocationId); + $destinationLocationId = (int)$request->input('destinationLocationId', $defaultSecondLocationId); + + $transfer->originLocationId = $originLocationId; + $transfer->destinationLocationId = $destinationLocationId; + + $details = $request->input('details', []); + $transfer->setDetails($details); + + $details = $request->input('details', []); + + if ($request->input('removeInventoryItemUid')) { + $details = array_filter($details, fn($detail) => $detail['uid'] !== $request->input('removeInventoryItemUid')); + } + $transfer->setDetails($details); + + $addItem = $request->input('addItem', false); + $addInventoryItemId = $request->input('newInventoryItemId', null); + if ($addItem && $addInventoryItemId) { + $transfer->addDetail(new TransferDetail([ + 'uid' => (string)\CraftCms\Cms\Support\Str::uuid(), + 'inventoryItemId' => $addInventoryItemId, + 'quantity' => 1, + ])); + } + } + + return TransferManagementField::renderFieldHtml($transfer); + } +} diff --git a/src/Http/Controllers/UserOrdersController.php b/src/Http/Controllers/UserOrdersController.php new file mode 100644 index 0000000000..78e7949632 --- /dev/null +++ b/src/Http/Controllers/UserOrdersController.php @@ -0,0 +1,31 @@ +expectsJson(), 400); + + $user = currentUserElement(); + + if (!$user) { + return $this->asFailure(t('No user authenticated.', category: 'commerce')); + } + + /** @phpstan-ignore-next-line method.notFound (getOrders() is added to User via a Macroable macro registered in Plugin::registerCustomerMacros(), not visible to static analysis) */ + return $this->asSuccess(data: ['orders' => $user->getOrders()]); + } +} diff --git a/src/Http/Controllers/Users/UsersController.php b/src/Http/Controllers/Users/UsersController.php new file mode 100644 index 0000000000..40e5b3c7da --- /dev/null +++ b/src/Http/Controllers/Users/UsersController.php @@ -0,0 +1,103 @@ +editedUser($userId); + + $response = $this->asEditUserScreen($user, self::SCREEN_COMMERCE); + + \Craft::$app->getView()->registerAssetBundle(CommerceCpAsset::class); + + $config = [ + 'context' => 'embedded-index', + 'sources' => false, + 'showSiteMenu' => true, + 'jsSettings' => [ + 'criteria' => ['customerId' => $user->id], + ], + ]; + + $edge = app(Carts::class)->getActiveCartEdgeDuration(); + + $content = ''; + $key = 'Commerce-Users-element-indexes-%s'; + + if (currentUser()?->can('commerce-manageOrders')) { + $completedOrdersKey = sprintf($key, 'completed-orders'); + $activeCartsKey = sprintf($key, 'active-carts'); + $inactiveCartsKey = sprintf($key, 'inactive-carts'); + + $content .= Html::tag('h2', t('Orders', category: 'commerce')) . + Html::beginTag('div', ['class' => 'commerce-user-orders']) . + $this->elementIndexHtml->html(Order::class, Arr::merge($config, [ + 'id' => $completedOrdersKey, + 'jsSettings' => [ + 'criteria' => ['isCompleted' => true], + 'storageKey' => $completedOrdersKey, + ], + ])) . + Html::endTag('div') . + + Html::tag('hr') . + + Html::tag('h2', t('Active Carts', category: 'commerce')) . + Html::beginTag('div', ['class' => 'commerce-user-active-carts']) . + $this->elementIndexHtml->html(Order::class, Arr::merge($config, [ + 'id' => $activeCartsKey, + 'jsSettings' => [ + 'criteria' => [ + 'isCompleted' => false, + 'dateUpdated' => '>= ' . $edge, + ], + 'storageKey' => $activeCartsKey, + ], + ])) . + Html::endTag('div') . + + Html::tag('hr') . + + Html::tag('h2', t('Inactive Carts', category: 'commerce')) . + Html::beginTag('div', ['class' => 'commerce-user-active-carts']) . + $this->elementIndexHtml->html(Order::class, Arr::merge($config, [ + 'id' => $inactiveCartsKey, + 'jsSettings' => [ + 'criteria' => [ + 'isCompleted' => false, + 'dateUpdated' => '< ' . $edge, + ], + 'storageKey' => $inactiveCartsKey, + ], + ])) . + Html::endTag('div'); + } + + return $response->contentHtml($content); + } +} diff --git a/src/Http/Controllers/VariantsController.php b/src/Http/Controllers/VariantsController.php new file mode 100644 index 0000000000..0ce5c1b529 --- /dev/null +++ b/src/Http/Controllers/VariantsController.php @@ -0,0 +1,20 @@ +getViewableProductTypeIds(true)), 403, 'User is not permitted to view any product types.'); + + return pageTemplate('commerce/variants/_index', [], TemplateMode::Cp); + } +} diff --git a/src/Http/Controllers/WebhooksController.php b/src/Http/Controllers/WebhooksController.php new file mode 100644 index 0000000000..42efe66488 --- /dev/null +++ b/src/Http/Controllers/WebhooksController.php @@ -0,0 +1,25 @@ +input('gateway'); + + abort_if(!$gatewayId, 400, 'Invalid gateway ID: ' . $gatewayId); + + $gateway = app(Gateways::class)->getGatewayById((int)$gatewayId); + abort_if($gateway === null, 404, 'Gateway not found'); + + return app(Webhooks::class)->processWebhook($gateway); + } +} diff --git a/src/Http/RateLimiters/CartChallengeRateLimiter.php b/src/Http/RateLimiters/CartChallengeRateLimiter.php new file mode 100644 index 0000000000..c62a129f01 --- /dev/null +++ b/src/Http/RateLimiters/CartChallengeRateLimiter.php @@ -0,0 +1,18 @@ +by($request->ip() ?? 'unknown'); + } +} diff --git a/src/Http/RateLimiters/CartRateLimiter.php b/src/Http/RateLimiters/CartRateLimiter.php new file mode 100644 index 0000000000..9491e27132 --- /dev/null +++ b/src/Http/RateLimiters/CartRateLimiter.php @@ -0,0 +1,30 @@ +contains(fn($param) => $request->input($param)); + + if (!$isActive) { + return Limit::none(); + } + + return Limit::perSecond(1)->by($request->ip() ?? 'unknown'); + } +} diff --git a/src/Http/RateLimiters/PdfChallengeRateLimiter.php b/src/Http/RateLimiters/PdfChallengeRateLimiter.php new file mode 100644 index 0000000000..d7b14e2677 --- /dev/null +++ b/src/Http/RateLimiters/PdfChallengeRateLimiter.php @@ -0,0 +1,18 @@ +by($request->ip() ?? 'unknown'); + } +} diff --git a/src/Inventory/Collections/InventoryMovementCollection.php b/src/Inventory/Collections/InventoryMovementCollection.php new file mode 100644 index 0000000000..1f32ec701a --- /dev/null +++ b/src/Inventory/Collections/InventoryMovementCollection.php @@ -0,0 +1,24 @@ + + * @method static self make($items = []) + */ +class InventoryMovementCollection extends Collection +{ + public function getPurchasables(): array + { + return $this->map(fn(InventoryMovementInterface $updateInventoryLevel) => $updateInventoryLevel->getInventoryItem()->getPurchasable())->all(); + } +} diff --git a/src/Inventory/Collections/UpdateInventoryLevelCollection.php b/src/Inventory/Collections/UpdateInventoryLevelCollection.php new file mode 100644 index 0000000000..7b17470506 --- /dev/null +++ b/src/Inventory/Collections/UpdateInventoryLevelCollection.php @@ -0,0 +1,38 @@ +map(fn(UpdateInventoryLevel|UpdateInventoryLevelInTransfer $updateInventoryLevel) => $updateInventoryLevel->getInventoryItem()->getPurchasable())->filter()->all(); + } +} diff --git a/src/Inventory/Concerns/InventoryItemTrait.php b/src/Inventory/Concerns/InventoryItemTrait.php new file mode 100644 index 0000000000..d74cd04ec8 --- /dev/null +++ b/src/Inventory/Concerns/InventoryItemTrait.php @@ -0,0 +1,35 @@ +_inventoryItem = $inventoryItem; + $this->inventoryItemId = $inventoryItem?->id; + } + + public function getInventoryItem(): ?InventoryItem + { + if (isset($this->_inventoryItem)) { + return $this->_inventoryItem; + } + + if ($this->inventoryItemId) { + $this->_inventoryItem = app(Inventory::class)->getInventoryItemById($this->inventoryItemId); + return $this->_inventoryItem; + } + + return null; + } +} diff --git a/src/Inventory/Concerns/InventoryLocationTrait.php b/src/Inventory/Concerns/InventoryLocationTrait.php new file mode 100644 index 0000000000..ae3c95f569 --- /dev/null +++ b/src/Inventory/Concerns/InventoryLocationTrait.php @@ -0,0 +1,35 @@ +_inventoryLocation = $inventoryLocation; + $this->inventoryLocationId = $inventoryLocation?->id; + } + + public function getInventoryLocation(): ?InventoryLocation + { + if (isset($this->_inventoryLocation)) { + return $this->_inventoryLocation; + } + + if ($this->inventoryLocationId) { + $this->_inventoryLocation = app(InventoryLocations::class)->getInventoryLocationById($this->inventoryLocationId); + return $this->_inventoryLocation; + } + + return null; + } +} diff --git a/src/Inventory/Contracts/InventoryMovementInterface.php b/src/Inventory/Contracts/InventoryMovementInterface.php new file mode 100644 index 0000000000..1ff5de040b --- /dev/null +++ b/src/Inventory/Contracts/InventoryMovementInterface.php @@ -0,0 +1,36 @@ + t('any', category: 'commerce'), + self::All => t('all', category: 'commerce'), + self::Only => t('only', category: 'commerce'), + }; + } +} diff --git a/src/Inventory/Enums/InventoryTransactionType.php b/src/Inventory/Enums/InventoryTransactionType.php new file mode 100644 index 0000000000..32813df73d --- /dev/null +++ b/src/Inventory/Enums/InventoryTransactionType.php @@ -0,0 +1,128 @@ + t('Available', category: 'commerce'), + self::RESERVED => t('Reserved', category: 'commerce'), + self::DAMAGED => t('Damaged', category: 'commerce'), + self::SAFETY => t('Safety', category: 'commerce'), + self::QUALITY_CONTROL => t('Quality Control', category: 'commerce'), + self::COMMITTED => t('Committed', category: 'commerce'), + self::INCOMING => t('Incoming', category: 'commerce'), + self::FULFILLED => t('Fulfilled', category: 'commerce'), + }; + } + + /** + * Can this transaction type go into the negative sum? + */ + public function canBeNegative(): bool + { + return $this === self::AVAILABLE || $this === self::COMMITTED || $this === self::INCOMING; + } + + /** + * @return InventoryTransactionType[] + */ + public static function onHand(): array + { + return array_merge( + self::unavailable(), + self::available(), + self::committed(), + ); + } + + /** + * @return InventoryTransactionType[] + */ + public static function unavailable(): array + { + return [ + self::RESERVED, + self::DAMAGED, + self::SAFETY, + self::QUALITY_CONTROL, + ]; + } + + /** + * @return InventoryTransactionType[] + */ + public static function available(): array + { + return [self::AVAILABLE]; + } + + /** + * @return InventoryTransactionType[] + */ + public static function incoming(): array + { + return [self::INCOMING]; + } + + /** + * @return InventoryTransactionType[] + */ + public static function committed(): array + { + return [self::COMMITTED]; + } + + /** + * Types that can be manually moved between (outside a transfer, purchase order, or fulfillment). + * + * @return InventoryTransactionType[] + */ + public static function allowedManualMoveTransactionTypes(): array + { + return [ + ...self::unavailable(), + ...self::available(), + ]; + } + + /** + * Types that can be manually adjusted (outside a transfer, purchase order, or fulfillment). + * + * @return InventoryTransactionType[] + */ + public static function allowedManualAdjustmentTypes(): array + { + return [ + ...self::unavailable(), + ...self::available(), + ]; + } +} diff --git a/src/Inventory/Enums/InventoryUpdateQuantityType.php b/src/Inventory/Enums/InventoryUpdateQuantityType.php new file mode 100644 index 0000000000..03fbfccc79 --- /dev/null +++ b/src/Inventory/Enums/InventoryUpdateQuantityType.php @@ -0,0 +1,15 @@ + + */ + public function getInventoryLevelsForPurchasable(NewPurchasable $purchasable): Collection + { + $inventoryLevels = collect(); + + if (!$purchasable->id) { + return $inventoryLevels; + } + + // Self-heal a missing inventory item id so callers get accurate levels + // even when the purchasable was loaded before its row was created. + if (!$purchasable->inventoryItemId && $purchasable::hasInventory()) { + $this->getInventoryItemByPurchasable($purchasable); + } + + if (!$purchasable->inventoryItemId) { + return $inventoryLevels; + } + + $storeId = $purchasable->getStore()->id; + $storeInventoryLocations = app(InventoryLocations::class)->getInventoryLocations($storeId); + + foreach ($storeInventoryLocations as $inventoryLocation) { + $inventoryLevel = $this->getInventoryLevel($purchasable->inventoryItemId, $inventoryLocation->id); + + if (!$inventoryLevel) { + continue; + } + $inventoryLevels->push($inventoryLevel); + } + + return $inventoryLevels; + } + + public function getInventoryItemByPurchasable(NewPurchasable $purchasable): InventoryItem + { + // Self-heal: if the purchasable has somehow ended up without an associated + // inventory item (e.g. due to a draft-apply or duplicate path that didn't + // create one), find or create one before returning. + if (!$purchasable->inventoryItemId && $purchasable->id) { + $record = $this->ensureInventoryItemRecord($purchasable); + if ($record) { + $purchasable->inventoryItemId = $record->id; + } + } + + return $this->getInventoryItemById($purchasable->inventoryItemId); + } + + /** + * Finds or creates the inventory item record for the given purchasable, always + * keyed by its canonical id so drafts and revisions resolve to the same row as + * their canonical. Returns null if the purchasable type does not track inventory + * or there is no canonical id yet. + */ + public function ensureInventoryItemRecord(NewPurchasable $purchasable): ?InventoryItemRecord + { + if (!$purchasable::hasInventory()) { + return null; + } + + $canonicalId = $purchasable->getCanonicalId(); + if (!$canonicalId) { + return null; + } + + $record = InventoryItemRecord::where('purchasableId', $canonicalId)->first(); + + if (!$record) { + $record = new InventoryItemRecord(); + $record->purchasableId = $canonicalId; + $record->countryCodeOfOrigin = ''; + $record->administrativeAreaCodeOfOrigin = ''; + $record->harmonizedSystemCode = ''; + $record->save(); + } + + return $record; + } + + public function getInventoryItemById(int $id): InventoryItem + { + $inventoryItem = $this->getInventoryItemQuery() + ->where('id', $id) + ->first(); + + return $this->populateInventoryItem((array) $inventoryItem); + } + + /** + * @param int[] $ids + * @return Collection + */ + public function getInventoryItemsByIds(array $ids): Collection + { + $inventoryItemsResults = $this->getInventoryItemQuery() + ->whereIn('id', $ids) + ->get(); + + $inventoryItems = collect(); + foreach ($inventoryItemsResults as $inventoryItem) { + $inventoryItems->push($this->populateInventoryItem((array) $inventoryItem)); + } + + return $inventoryItems; + } + + /** + * Returns an inventory level model which is the sum of all inventory movements types for an item in a location. + */ + public function getInventoryLevel(InventoryItem|int $inventoryItem, InventoryLocation|int $inventoryLocation, bool $withTrashed = false): ?InventoryLevel + { + $inventoryItemId = $inventoryItem instanceof InventoryItem ? $inventoryItem->id : $inventoryItem; + $inventoryLocationId = $inventoryLocation instanceof InventoryLocation ? $inventoryLocation->id : $inventoryLocation; + + $result = $this->getInventoryLevelQuery(withTrashed: $withTrashed, inventoryLocationId: $inventoryLocationId) + ->where('it.inventoryLocationId', $inventoryLocationId) + ->where('it.inventoryItemId', $inventoryItemId) + ->first(); + + if (!$result) { + return null; + } + + return $this->populateInventoryLevel((array) $result); + } + + public function saveInventoryItem(InventoryItem $inventoryItem): bool + { + $inventoryItemRecord = InventoryItemRecord::find($inventoryItem->id); + + if ($inventoryItemRecord === null) { + throw new RuntimeException('No inventory item exists with the ID “' . $inventoryItem->id . '”'); + } + + $inventoryItemRecord->purchasableId = $inventoryItem->purchasableId; + $inventoryItemRecord->countryCodeOfOrigin = $inventoryItem->countryCodeOfOrigin; + $inventoryItemRecord->administrativeAreaCodeOfOrigin = $inventoryItem->administrativeAreaCodeOfOrigin; + $inventoryItemRecord->harmonizedSystemCode = $inventoryItem->harmonizedSystemCode; + + return $inventoryItemRecord->save(); + } + + private function populateInventoryItem(array $data): InventoryItem + { + return new InventoryItem($data); + } + + private function populateInventoryTransaction(array $data): InventoryTransaction + { + return new InventoryTransaction($data); + } + + private function populateInventoryLevel(array $data): InventoryLevel + { + unset($data['purchasableId']); + return new InventoryLevel($data); + } + + private function populateInventoryFulfillmentLevel(array $data): InventoryFulfillmentLevel + { + return new InventoryFulfillmentLevel($data); + } + + /** + * @return Collection + */ + public function getInventoryLocationLevels(InventoryLocation $inventoryLocation, bool $withTrashed = false): Collection + { + $levels = $this->getInventoryLevelQuery(withTrashed: $withTrashed, inventoryLocationId: $inventoryLocation->id) + ->where('it.inventoryLocationId', $inventoryLocation->id) + ->whereNotNull('elements.id') + ->get(); + + $inventoryItems = $this->getInventoryItemsByIds($levels->pluck('inventoryItemId')->unique()->toArray()); + + return $levels->map(function($level) use ($inventoryItems) { + $inventoryLevel = $this->populateInventoryLevel((array) $level); + if ($item = $inventoryItems->firstWhere('id', $level->inventoryItemId)) { + $inventoryLevel->setInventoryItem($item); + } + return $inventoryLevel; + }); + } + + /** + * Returns the totals for inventory items grouped by location and purchasable/inventoryItem. + */ + public function getInventoryLevelQuery(?int $limit = null, ?int $offset = null, bool $withTrashed = false, ?int $inventoryLocationId = null): Builder + { + $inventoryTotals = DB::table(Table::INVENTORYLOCATIONS . ' as il') + ->select([ + 'il.id as inventoryLocationId', + 'ii.id as inventoryItemId', + 'it.type as type', + ]) + ->selectRaw('COALESCE(SUM(it.quantity), 0) as quantity') + ->crossJoin(Table::INVENTORYITEMS . ' as ii') + ->leftJoin(Table::INVENTORYTRANSACTIONS . ' as it', function($join) { + $join->on('il.id', '=', 'it.inventoryLocationId') + ->on('ii.id', '=', 'it.inventoryItemId'); + }) + ->groupBy('il.id', 'ii.id', 'it.type'); + + // Scoping the location in the subquery prevents the CROSS JOIN from expanding + // to all locations × all items before the outer WHERE can filter it down. + if ($inventoryLocationId !== null) { + $inventoryTotals->where('il.id', $inventoryLocationId); + } + + $query = DB::table(Table::INVENTORYITEMS . ' as ii') + ->selectRaw('ii.id as inventoryItemId') + ->selectRaw('ii.purchasableId as purchasableId') + ->selectRaw('it.inventoryLocationId as inventoryLocationId') + ->selectRaw("SUM(CASE WHEN it.type = 'available' THEN it.quantity ELSE 0 END) as availableTotal") + ->selectRaw("SUM(CASE WHEN it.type = 'committed' THEN it.quantity ELSE 0 END) as committedTotal") + ->selectRaw("SUM(CASE WHEN it.type = 'reserved' THEN it.quantity ELSE 0 END) as reservedTotal") + ->selectRaw("SUM(CASE WHEN it.type = 'damaged' THEN it.quantity ELSE 0 END) as damagedTotal") + ->selectRaw("SUM(CASE WHEN it.type = 'safety' THEN it.quantity ELSE 0 END) as safetyTotal") + ->selectRaw("SUM(CASE WHEN it.type = 'qualityControl' THEN it.quantity ELSE 0 END) as qualityControlTotal") + ->selectRaw("SUM(CASE WHEN it.type = 'incoming' THEN it.quantity ELSE 0 END) as incomingTotal") + ->selectRaw("SUM(CASE WHEN it.type IN ('qualityControl','safety','damaged','reserved') THEN it.quantity ELSE 0 END) as unavailableTotal") + ->selectRaw("SUM(CASE WHEN it.type IN ('qualityControl','safety','damaged','reserved','available','committed') THEN it.quantity ELSE 0 END) as onHandTotal") + ->leftJoinSub($inventoryTotals, 'it', function($join) { + $join->on('it.inventoryItemId', '=', 'ii.id'); + }) + ->leftJoin(CraftTable::ELEMENTS . ' as elements', function($join) { + $join->on('ii.purchasableId', '=', 'elements.id') + ->whereNull('elements.draftId') + ->whereNull('elements.revisionId'); + }) + ->groupBy('ii.id', 'ii.purchasableId', 'it.inventoryLocationId'); + + if ($limit !== null) { + $query->limit($limit); + } + + if ($offset !== null) { + $query->offset($offset); + } + + if (!$withTrashed) { + $query->whereNull('elements.dateDeleted'); + } + + return $query; + } + + public function getInventoryItemQuery(): Builder + { + return DB::table(Table::INVENTORYITEMS) + ->select([ + 'id', + 'purchasableId', + 'countryCodeOfOrigin', + 'administrativeAreaCodeOfOrigin', + 'harmonizedSystemCode', + ]); + } + + public function executeUpdateInventoryLevels(UpdateInventoryLevelCollection $updateInventoryLevels): bool + { + if ($updateInventoryLevels->count() < 1) { + return true; + } + + DB::beginTransaction(); + + try { + foreach ($updateInventoryLevels as $updateInventoryLevel) { + if ($updateInventoryLevel->updateAction === InventoryUpdateQuantityType::SET) { + $this->setInventoryLevel($updateInventoryLevel); + } else { + $this->adjustInventoryLevel($updateInventoryLevel); + } + } + + DB::commit(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + + // TODO: Potentially move this to a job in the queue + // Update all purchasables stock + foreach ($updateInventoryLevels->getPurchasables() as $purchasable) { + app(Purchasables::class)->updateStoreStockCache($purchasable, true); + } + + // TODO: migrate event firing to Laravel once the event system is bridged + foreach ($updateInventoryLevels as $updateInventoryLevel) { + if (Plugin::getInstance()->getInventory()->hasEventHandlers(self::EVENT_AFTER_EXECUTE_UPDATE_INVENTORY_LEVEL)) { + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getInventory()->trigger(self::EVENT_AFTER_EXECUTE_UPDATE_INVENTORY_LEVEL, new UpdateInventoryLevelEvent( + updateInventoryLevel: $updateInventoryLevel, + )); + } + } + + return true; + } + + /** + * @param array $updateInventoryLevelAttributes + */ + public function updateInventoryLevel(int $inventoryItemId, int $quantity, array $updateInventoryLevelAttributes = []): void + { + $updateInventoryLevelAttributes += [ + 'quantity' => $quantity, + 'updateAction' => InventoryUpdateQuantityType::SET, + 'inventoryLocationId' => app(InventoryLocations::class)->getAllInventoryLocations()->first()->id, + 'type' => InventoryTransactionType::AVAILABLE->value, + ]; + + $updateInventoryLevel = new UpdateInventoryLevel($updateInventoryLevelAttributes); + $updateInventoryLevel->inventoryItemId = $inventoryItemId; + + $updateInventoryLevels = UpdateInventoryLevelCollection::make(); + $updateInventoryLevels->push($updateInventoryLevel); + + $this->executeUpdateInventoryLevels($updateInventoryLevels); + } + + /** + * @param array $updateInventoryLevelAttributes + */ + public function updatePurchasableInventoryLevel(NewPurchasable $purchasable, int $quantity, array $updateInventoryLevelAttributes = []): void + { + $inventoryLocation = $purchasable->getStore()->getInventoryLocations()->first(); + + if (!$inventoryLocation) { + // If no inventory location exists, we can't update inventory + // TODO change method to return false or throw an exception + return; + } + + $updateInventoryLevelAttributes += [ + 'quantity' => $quantity, + 'updateAction' => InventoryUpdateQuantityType::SET, + 'inventoryItemId' => $purchasable->inventoryItemId, + 'inventoryLocationId' => $inventoryLocation->id, + 'type' => InventoryTransactionType::AVAILABLE->value, + ]; + + $this->updateInventoryLevel($purchasable->inventoryItemId, $quantity, $updateInventoryLevelAttributes); + + // Clear the stock cache for the class instance + unset($purchasable->stock); + } + + private function setInventoryLevel(UpdateInventoryLevel|UpdateInventoryLevelInTransfer $updateInventoryLevel): bool + { + $tableName = Table::INVENTORYTRANSACTIONS; + + if ($updateInventoryLevel->type === 'onHand') { + $types = collect(InventoryTransactionType::onHand())->pluck('value')->all(); + } else { + $types = [$updateInventoryLevel->type]; + } + + $quantityQuery = DB::table($tableName) + ->whereIn('type', $types) + ->where('inventoryItemId', $updateInventoryLevel->inventoryItemId) + ->where('inventoryLocationId', $updateInventoryLevel->inventoryLocationId) + ->selectRaw('? - COALESCE(SUM(quantity), 0) as remaining', [$updateInventoryLevel->quantity]) + ->value('remaining'); + + $type = $updateInventoryLevel->type; + if ($updateInventoryLevel->type === 'onHand') { + $type = InventoryTransactionType::AVAILABLE->value; + } + + $data = [ + 'quantity' => $quantityQuery, + 'type' => $type, + 'inventoryItemId' => $updateInventoryLevel->inventoryItemId, + 'inventoryLocationId' => $updateInventoryLevel->inventoryLocationId, + 'note' => $updateInventoryLevel->note, + 'movementHash' => $this->getMovementHash(), + 'dateCreated' => now()->toDateTimeString(), + 'userId' => request()->craftUser()?->getCraftUserId(), + ]; + + if ($updateInventoryLevel instanceof UpdateInventoryLevelInTransfer) { + $data['transfer'] = $updateInventoryLevel->transferId; + } + + DB::table($tableName)->insert($data); + + return true; + } + + private function adjustInventoryLevel(UpdateInventoryLevel|UpdateInventoryLevelInTransfer $updateInventoryLevel): bool + { + $tableName = Table::INVENTORYTRANSACTIONS; + + $type = $updateInventoryLevel->type; + if ($updateInventoryLevel->type === 'onHand') { + $type = 'available'; + } + + DB::table($tableName)->insert([ + 'quantity' => $updateInventoryLevel->quantity, + 'type' => $type, + 'inventoryItemId' => $updateInventoryLevel->inventoryItemId, + 'inventoryLocationId' => $updateInventoryLevel->inventoryLocationId, + 'movementHash' => $this->getMovementHash(), + 'dateCreated' => now()->toDateTimeString(), + 'note' => $updateInventoryLevel->note, + ]); + + return true; + } + + public function executeInventoryMovements(InventoryMovementCollection $inventoryMovements): bool + { + $tableName = Table::INVENTORYTRANSACTIONS; + + DB::beginTransaction(); + + try { + foreach ($inventoryMovements as $inventoryMovement) { + if (!$inventoryMovement->isValid()) { + DB::rollBack(); + return false; + } + + $movementDate = now()->toDateTimeString(); + + $fromInsertResult = DB::table($tableName)->insert([ + 'quantity' => -$inventoryMovement->getQuantity(), + 'type' => $inventoryMovement->getFromInventoryTransactionType()->value, + 'inventoryItemId' => $inventoryMovement->getInventoryItem()->id, + 'inventoryLocationId' => $inventoryMovement->getFromInventoryLocation()->id, + 'movementHash' => $inventoryMovement->getInventoryMovementHash(), + 'dateCreated' => $movementDate, + 'transferId' => $inventoryMovement->getTransferId(), + 'lineItemId' => $inventoryMovement->getLineItemId(), + 'userId' => $inventoryMovement->getUserId(), + 'note' => $inventoryMovement->getNote(), + ]); + + if (!$fromInsertResult) { + DB::rollBack(); + return false; + } + + $toInsertResult = DB::table($tableName)->insert([ + 'quantity' => $inventoryMovement->getQuantity(), + 'type' => $inventoryMovement->getToInventoryTransactionType()->value, + 'inventoryItemId' => $inventoryMovement->getInventoryItem()->id, + 'inventoryLocationId' => $inventoryMovement->getToInventoryLocation()->id, + 'movementHash' => $inventoryMovement->getInventoryMovementHash(), + 'dateCreated' => $movementDate, + 'transferId' => $inventoryMovement->getTransferId(), + 'lineItemId' => $inventoryMovement->getLineItemId(), + 'userId' => $inventoryMovement->getUserId(), + 'note' => $inventoryMovement->getNote(), + ]); + + if (!$toInsertResult) { + DB::rollBack(); + return false; + } + } + + DB::commit(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + + // TODO: Potentially move this to a job in the queue + foreach ($inventoryMovements as $inventoryMovement) { + // Update all purchasables stock + $purchasable = $inventoryMovement->getInventoryItem()->getPurchasable(); + if ($purchasable) { + app(Purchasables::class)->updateStoreStockCache($purchasable, true); + } + } + + // TODO: migrate event firing to Laravel once the event system is bridged + foreach ($inventoryMovements as $inventoryMovement) { + if (Plugin::getInstance()->getInventory()->hasEventHandlers(self::EVENT_AFTER_EXECUTE_INVENTORY_MOVEMENT)) { + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getInventory()->trigger(self::EVENT_AFTER_EXECUTE_INVENTORY_MOVEMENT, new InventoryMovementEvent( + inventoryMovement: $inventoryMovement, + )); + } + } + + return true; + } + + public function getMovementHash(): string + { + return md5(uniqid((string) mt_rand(), true)); + } + + /** @return Order[] */ + public function getUnfulfilledOrders(InventoryItem|int $inventoryItem, InventoryLocation|int $inventoryLocation): array + { + $inventoryItemId = $inventoryItem instanceof InventoryItem ? $inventoryItem->id : $inventoryItem; + $inventoryLocationId = $inventoryLocation instanceof InventoryLocation ? $inventoryLocation->id : $inventoryLocation; + + $inventoryLevel = $this->getInventoryLevel($inventoryItemId, $inventoryLocationId); + + if ($inventoryLevel->committedTotal <= 0) { + return []; + } + + // Get orders that have line items for this inventory level item + $orderIds = DB::table(Table::LINEITEMS . ' as lineItems') + ->select('lineItems.orderId') + ->addSelect('lineItems.qty') + ->leftJoin(Table::ORDERS . ' as orders', 'lineItems.orderId', '=', 'orders.id') + ->leftJoin(Table::INVENTORYTRANSACTIONS . ' as it', 'it.lineItemId', '=', 'lineItems.id') + ->where('orders.isCompleted', true) + ->where('it.inventoryItemId', $inventoryItemId) + ->where('it.inventoryLocationId', $inventoryLocationId) + ->where('it.type', InventoryTransactionType::COMMITTED->value) + ->groupBy('lineItems.orderId', 'lineItems.id', 'lineItems.qty') + ->havingRaw('SUM(it.quantity) >= lineItems.qty') + ->pluck('orderId') + ->all(); + + return Order::find() + ->id($orderIds) + ->all(); + } + + public function getTransactionQuery(): Builder + { + return DB::table(Table::INVENTORYTRANSACTIONS) + ->select([ + 'inventoryLocationId', + 'inventoryItemId', + 'movementHash', + 'quantity', + 'type', + 'note', + 'transferId', + 'lineItemId', + 'userId', + 'dateCreated', + ]) + ->orderByDesc('dateCreated'); + } + + /** + * @return Collection + */ + public function getInventoryTransactions(InventoryItem $inventoryItem, InventoryLocation $inventoryLocation): Collection + { + $transactions = $this->getTransactionQuery() + ->where('inventoryItemId', $inventoryItem->id) + ->where('inventoryLocationId', $inventoryLocation->id) + ->get(); + + return $transactions->map(fn($transaction) => $this->populateInventoryTransaction((array) $transaction)); + } + + /** + * @return Collection + */ + public function getInventoryFulfillmentLevels(Order $order): Collection + { + // We don't limit this to the order's store locations since we want to show + // all locations that have historical inventory for the order. + $locations = app(InventoryLocations::class)->getAllInventoryLocations(); + + $inventoryFulfillmentLevels = []; + foreach ($locations as $location) { + $data = DB::table(Table::INVENTORYTRANSACTIONS . ' as it') + ->selectRaw('it.lineItemId, it.inventoryItemId, it.inventoryLocationId') + ->selectRaw( + "SUM(CASE WHEN ((it.type = ? AND quantity > 0) OR (it.type = ? AND quantity < 0)) THEN quantity ELSE 0 END) AS committedQuantity", + [InventoryTransactionType::COMMITTED->value, InventoryTransactionType::FULFILLED->value], + ) + ->selectRaw("SUM(CASE WHEN it.type = ? THEN quantity ELSE 0 END) AS outstandingCommittedQuantity", [InventoryTransactionType::COMMITTED->value]) + ->selectRaw("SUM(CASE WHEN it.type = ? THEN quantity ELSE 0 END) AS fulfilledQuantity", [InventoryTransactionType::FULFILLED->value]) + ->join(Table::LINEITEMS . ' as li', 'li.id', '=', 'it.lineItemId') + ->where('li.orderId', $order->id) + ->where('it.inventoryLocationId', $location->id) + ->where(function($query) { + $query->where('it.type', InventoryTransactionType::COMMITTED->value) + ->orWhere('it.type', InventoryTransactionType::FULFILLED->value); + }) + ->groupBy('it.lineItemId', 'it.inventoryItemId', 'it.inventoryLocationId') + ->get(); + + foreach ($data as $row) { + $inventoryFulfillmentLevels[] = $this->populateInventoryFulfillmentLevel((array) $row); + } + } + + return collect($inventoryFulfillmentLevels); + } + + public function orderCompleteHandler(Order $order): void + { + /** @var array> $allInventoryLevels */ + $allInventoryLevels = []; + $qtyLineItem = []; + foreach ($order->getLineItems() as $lineItem) { + if ($lineItem->type === LineItemType::Custom) { + // Skip custom line items + continue; + } + + $purchasable = $lineItem->getPurchasable(); + // Don't reduce stock of unlimited items. + + if (!$purchasable instanceof NewPurchasable || !$purchasable::hasInventory()) { + continue; + } + + if ($purchasable->inventoryTracked) { + if (!isset($qtyLineItem[$purchasable->id])) { + $qtyLineItem[$purchasable->id] = 0; + } + $qtyLineItem[$purchasable->id] += $lineItem->qty; + $allInventoryLevels[$purchasable->id] = $purchasable->getInventoryLevels(); + } + } + + $selectedInventoryLevelForItem = []; + /** + * @var int $purchasableId + * @var Collection $inventoryLevels + */ + foreach ($allInventoryLevels as $purchasableId => $inventoryLevels) { + foreach ($inventoryLevels as $level) { + if (!isset($selectedInventoryLevelForItem[$purchasableId])) { + $selectedInventoryLevelForItem[$purchasableId] = $level; + + if ($level->availableTotal >= $qtyLineItem[$purchasableId]) { + break; + } + continue; + } + + if ($level->availableTotal >= $qtyLineItem[$purchasableId]) { + $selectedInventoryLevelForItem[$purchasableId] = $level; + break; + } + } + } + + $movements = InventoryMovementCollection::make(); + + $reserveAmountByPurchasableId = []; + $availableTotalByPurchasableIdAndLocationId = []; + + // Loop through line items and create committed movements for the selected inventory location + foreach ($order->getLineItems() as $lineItem) { + if (isset($selectedInventoryLevelForItem[$lineItem->purchasableId])) { + $level = $selectedInventoryLevelForItem[$lineItem->purchasableId]; + + if (!isset($reserveAmountByPurchasableId[$lineItem->purchasableId])) { + $availableTotalByPurchasableIdAndLocationId[$lineItem->purchasableId . '-' . $level->inventoryLocationId] = $level->availableTotal; + $reserveAmountByPurchasableId[$lineItem->purchasableId] = []; + } + + if ($lineItem->qty > $availableTotalByPurchasableIdAndLocationId[$lineItem->purchasableId . '-' . $level->inventoryLocationId]) { + $totalToReserveForLineItem = $lineItem->qty - $availableTotalByPurchasableIdAndLocationId[$lineItem->purchasableId . '-' . $level->inventoryLocationId]; + $reserveAmountByPurchasableId[$lineItem->purchasableId][$lineItem->id] = $totalToReserveForLineItem; + $availableTotalByPurchasableIdAndLocationId[$lineItem->purchasableId . '-' . $level->inventoryLocationId] = 0; + } else { + $availableTotalByPurchasableIdAndLocationId[$lineItem->purchasableId . '-' . $level->inventoryLocationId] -= $lineItem->qty; + } + + $inventoryCommittedMovement = new InventoryCommittedMovement(); + $inventoryCommittedMovement->inventoryItemId = $level->inventoryItemId; + $inventoryCommittedMovement->fromInventoryLocation = $level->getInventoryLocation(); + $inventoryCommittedMovement->toInventoryLocation = $level->getInventoryLocation(); + $inventoryCommittedMovement->fromInventoryTransactionType = InventoryTransactionType::AVAILABLE; + $inventoryCommittedMovement->toInventoryTransactionType = InventoryTransactionType::COMMITTED; + $inventoryCommittedMovement->quantity = $lineItem->qty; + $inventoryCommittedMovement->lineItemId = $lineItem->id; + + $movements->push($inventoryCommittedMovement); + } + } + + // Loop through reserve amounts to reserve the remaining stock in the other inventory locations + foreach ($reserveAmountByPurchasableId as $purchasableId => $r) { + foreach ($r as $lineItemId => $qty) { + foreach ($allInventoryLevels[$purchasableId] as $level) { + if ($level === $selectedInventoryLevelForItem[$purchasableId]) { + continue; + } + + if (!isset($availableTotalByPurchasableIdAndLocationId[$purchasableId . '-' . $level->inventoryLocationId])) { + $availableTotalByPurchasableIdAndLocationId[$purchasableId . '-' . $level->inventoryLocationId] = $level->availableTotal; + } + + $canReserveFullQty = $qty <= $availableTotalByPurchasableIdAndLocationId[$purchasableId . '-' . $level->inventoryLocationId]; + $qtyToReserve = $canReserveFullQty ? $qty : $availableTotalByPurchasableIdAndLocationId[$purchasableId . '-' . $level->inventoryLocationId]; + + if ($qtyToReserve < 1) { + break; + } + + $availableTotalByPurchasableIdAndLocationId[$purchasableId . '-' . $level->inventoryLocationId] -= $qtyToReserve; + + $inventoryManualMovement = new InventoryManualMovement(); + $inventoryManualMovement->inventoryItemId = $level->inventoryItemId; + $inventoryManualMovement->fromInventoryLocation = $level->getInventoryLocation(); + $inventoryManualMovement->toInventoryLocation = $level->getInventoryLocation(); + $inventoryManualMovement->fromInventoryTransactionType = InventoryTransactionType::AVAILABLE; + $inventoryManualMovement->toInventoryTransactionType = InventoryTransactionType::RESERVED; + $inventoryManualMovement->quantity = $qtyToReserve; + $inventoryManualMovement->lineItemId = $lineItemId; + + $movements->push($inventoryManualMovement); + + $qty -= $qtyToReserve; + if ($qty <= 0) { + break; + } + } + } + } + + $this->executeInventoryMovements($movements); + + foreach ($selectedInventoryLevelForItem as $key => $inventoryLevel) { + if ($purchasable = Elements::getElementById($key)) { + if ($purchasable instanceof NewPurchasable) { + app(Purchasables::class)->updateStoreStockCache($purchasable, true); + + // If the purchasable doesn't allow out of stock purchases, check whether the movement + // pushed available stock below zero (e.g. due to concurrent orders). + if (!$purchasable->allowOutOfStockPurchases) { + $freshLevel = $this->getInventoryLevel($inventoryLevel->inventoryItemId, $inventoryLevel->inventoryLocationId); + if ($freshLevel && $freshLevel->availableTotal < 0) { + $notice = new OrderNotice([ + 'type' => 'inventoryBelowZero', + 'attribute' => 'lineItems', + 'message' => t('Available inventory for "{description}" has gone below zero.', [ + 'description' => $purchasable->getDescription(), + ], category: 'commerce'), + 'noticeType' => OrderNoticeType::Admin, + ]); + $order->addNotice($notice); + } + } + } + } + } + } +} diff --git a/src/Inventory/InventoryLocations.php b/src/Inventory/InventoryLocations.php new file mode 100644 index 0000000000..36e819d277 --- /dev/null +++ b/src/Inventory/InventoryLocations.php @@ -0,0 +1,286 @@ +|null */ + private ?Collection $allLocations = null; + + /** @var Collection|null */ + private ?Collection $allLocationsWithTrashed = null; + + /** @var array> Inventory location IDs for a store, indexed by store ID. */ + private array $inventoryLocationIdsByStore = []; + + /** + * @return Collection + */ + public function getAllInventoryLocations(bool $withTrashed = false): Collection + { + return $this->fetchAllInventoryLocations($withTrashed); + } + + /** @return array */ + public function getAllInventoryLocationsAsList(bool $withTrashed = false): array + { + return $this->getAllInventoryLocations($withTrashed)->mapWithKeys(fn(InventoryLocation $location) => [$location->id => $location->getUiLabel()])->toArray(); + } + + public function getInventoryLocationById(int $id, bool $withTrashed = false): ?InventoryLocation + { + return $this->fetchAllInventoryLocations($withTrashed)->firstWhere('id', $id); + } + + /** + * Gets all inventory locations for a store in order of configuration. + * + * @return Collection + */ + public function getInventoryLocations(?int $storeId = null, bool $withTrashed = false): Collection + { + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + if (!isset($this->inventoryLocationIdsByStore[$storeId])) { + $this->inventoryLocationIdsByStore[$storeId] = DB::table(Table::INVENTORYLOCATIONS_STORES) + ->select('inventoryLocationId') + ->where('storeId', $storeId) + ->orderBy('sortOrder') + ->pluck('inventoryLocationId') + ->all(); + } + + $locationIds = $this->inventoryLocationIdsByStore[$storeId]; + + // Keep the order of the locationIds + return $this->fetchAllInventoryLocations($withTrashed)->whereIn('id', $locationIds)->sortBy(fn($inventoryLocation) => array_search($inventoryLocation->id, $locationIds)); + } + + /** + * Stores the relationship between a Store and its Inventory Locations, ordered by preference. + * + * @param int[] $inventoryLocationIds + */ + public function saveStoreInventoryLocations(Store $store, array $inventoryLocationIds): bool + { + DB::beginTransaction(); + + try { + DB::table(Table::INVENTORYLOCATIONS_STORES)->where('storeId', $store->id)->delete(); + + $order = 1; + $now = now()->toDateTimeString(); + foreach ($inventoryLocationIds as $inventoryLocationId) { + DB::table(Table::INVENTORYLOCATIONS_STORES)->insert([ + 'storeId' => $store->id, + 'inventoryLocationId' => $inventoryLocationId, + 'sortOrder' => $order++, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + } + + DB::commit(); + + // Clear memoization cache + $this->inventoryLocationIdsByStore = []; + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + + return true; + } + + public function executeDeactivateInventoryLocation(DeactivateInventoryLocation $deactivateInventoryLocation): bool + { + // This will ensure that the location has no committed stock or incoming stock before deactivating it. + if (!$deactivateInventoryLocation->validate()) { + return false; + } + + DB::beginTransaction(); + + try { + $inventoryLocationRecord = InventoryLocationRecord::find($deactivateInventoryLocation->inventoryLocation->id); + + // TODO: Add draft purchase order swapping + + $inventoryLevels = app(Inventory::class)->getInventoryLocationLevels($deactivateInventoryLocation->inventoryLocation); + /** @var InventoryLevel $inventoryLevel */ + foreach ($inventoryLevels as $inventoryLevel) { + $movements = new InventoryMovementCollection(); + foreach (InventoryTransactionType::allowedManualMoveTransactionTypes() as $type) { + if ($inventoryLevel->getTotal($type) > 0) { + $inventoryMovement = new InventoryLocationDeactivatedMovement(); + $inventoryMovement->fromInventoryLocation = $deactivateInventoryLocation->inventoryLocation; + $inventoryMovement->toInventoryLocation = $deactivateInventoryLocation->destinationInventoryLocation; + $inventoryMovement->inventoryItemId = $inventoryLevel->inventoryItemId; + $inventoryMovement->quantity = $inventoryLevel->getTotal($type); + $inventoryMovement->fromInventoryTransactionType = $type; + $inventoryMovement->toInventoryTransactionType = $type; + $inventoryMovement->userId = request()->craftUser()?->getCraftUserId(); + $inventoryMovement->note = t('Movement from deactivated inventory location', category: 'commerce'); + $movements->add($inventoryMovement); + } + } + + if ($movements->count() > 0) { + if (!app(Inventory::class)->executeInventoryMovements($movements)) { + throw new RuntimeException('Failed to move inventory from deactivated location'); + } + } + } + + // Finally soft delete it now that it's all migrated + $inventoryLocationRecord->delete(); + + DB::commit(); + + $this->clearCache(); + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + + return true; + } + + public function getInventoryLocationByHandle(string $handle): ?InventoryLocation + { + return $this->getAllInventoryLocations()->firstWhere('handle', $handle); + } + + public function saveInventoryLocation(InventoryLocation $inventoryLocation, bool $runValidation = true): bool + { + $isNewLocation = !$inventoryLocation->id; + + if ($runValidation && !$inventoryLocation->validate()) { + Log::info('Inventory Location not saved due to validation error.'); + return false; + } + + DB::beginTransaction(); + + try { + $locationRecord = InventoryLocationRecord::find($inventoryLocation->id); + + if ($locationRecord === null) { + $locationRecord = new InventoryLocationRecord(); + } + + $locationRecord->name = $inventoryLocation->name; + $locationRecord->handle = $inventoryLocation->handle; + $locationRecord->addressId = $inventoryLocation->getAddress()->id; + + $locationRecord->save(); + + if ($isNewLocation) { + $inventoryLocation->id = $locationRecord->id; + } + + DB::commit(); + + $this->clearCache(); + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + + return true; + } + + public function authorizeInventoryLocationAddressView(ElementAuthorizing $event): void + { + if (!$event->element instanceof Address) { + return; + } + + if ($this->getAllInventoryLocations(true)->firstWhere('addressId', $event->element->getCanonicalId()) === null) { + return; + } + + $event->authorized = true; + } + + public function authorizeInventoryLocationAddressEdit(ElementAuthorizing $event): void + { + if (!$event->element instanceof Address) { + return; + } + + if ($this->getAllInventoryLocations(true)->firstWhere('addressId', $event->element->getCanonicalId()) === null) { + return; + } + + $event->authorized = true; + } + + private function clearCache(): void + { + $this->allLocations = null; + $this->allLocationsWithTrashed = null; + } + + private function query(bool $withTrashed = false): \Illuminate\Database\Query\Builder + { + $query = DB::table(Table::INVENTORYLOCATIONS) + ->select([ + 'id', + 'name', + 'handle', + 'addressId', + 'dateCreated', + 'dateUpdated', + ]) + ->orderBy('name'); + + if (!$withTrashed) { + $query->whereNull('dateDeleted'); + } + + return $query; + } + + /** + * @return Collection + */ + private function fetchAllInventoryLocations(bool $withTrashed = false): Collection + { + if ($withTrashed) { + if ($this->allLocationsWithTrashed === null) { + $this->allLocationsWithTrashed = $this->query(true)->get()->map(fn($row) => new InventoryLocation((array) $row)); + } + + return $this->allLocationsWithTrashed; + } + + if ($this->allLocations === null) { + $this->allLocations = $this->query(false)->get()->map(fn($row) => new InventoryLocation((array) $row)); + } + + return $this->allLocations; + } +} diff --git a/src/Inventory/Models/DeactivateInventoryLocation.php b/src/Inventory/Models/DeactivateInventoryLocation.php new file mode 100644 index 0000000000..82198c1825 --- /dev/null +++ b/src/Inventory/Models/DeactivateInventoryLocation.php @@ -0,0 +1,75 @@ + [ + 'required', + function(string $attribute, mixed $value, \Closure $fail) { + $exists = DB::table(Table::INVENTORYLOCATIONS) + ->where('id', $this->inventoryLocation->id) + ->whereNull('dateDeleted') + ->exists(); + + if (!$exists) { + $fail(t('Inventory location is already deactivated.', category: 'commerce')); + } + }, + function(string $attribute, mixed $value, \Closure $fail) { + $stores = app(Stores::class)->getAllStores(); + foreach ($stores as $store) { + $locations = $store->getInventoryLocations(); + if ($locations->count() == 1 && $locations->contains('id', $this->inventoryLocation->id)) { + $fail(t('This is the last location for the {store} store.', ['store' => $store->getName()], category: 'commerce')); + } + } + }, + function(string $attribute, mixed $value, \Closure $fail) { + if ($this->hasOutStandingCommittedStock()) { + $fail(t('Inventory location has committed stock, the order(s) must first be fulfilled.', category: 'commerce')); + } + }, + function(string $attribute, mixed $value, \Closure $fail) { + if ($this->hasOutStandingIncomingStock()) { + $fail(t('Inventory location has incoming stock, the transfer(s) must first be completed.', category: 'commerce')); + } + }, + ], + 'destinationInventoryLocation' => ['required'], + ]; + } + + public function hasOutStandingCommittedStock(): bool + { + $committedTotal = app(Inventory::class)->getInventoryLocationLevels($this->inventoryLocation) + ->sum('committedTotal'); + + return $committedTotal > 0; + } + + public function hasOutStandingIncomingStock(): bool + { + $incomingTotal = app(Inventory::class)->getInventoryLocationLevels($this->inventoryLocation) + ->sum('incomingTotal'); + + return $incomingTotal > 0; + } +} diff --git a/src/Inventory/Models/InventoryCommittedMovement.php b/src/Inventory/Models/InventoryCommittedMovement.php new file mode 100644 index 0000000000..2850c90902 --- /dev/null +++ b/src/Inventory/Models/InventoryCommittedMovement.php @@ -0,0 +1,28 @@ + [ + function(string $attribute, mixed $value, \Closure $fail) { + if ($this->fromInventoryTransactionType !== InventoryTransactionType::AVAILABLE || $this->toInventoryTransactionType !== InventoryTransactionType::COMMITTED) { + $fail('Invalid committed transaction types'); + } + + if ($this->fromInventoryLocation->id !== $this->toInventoryLocation->id) { + $fail('The from and to inventory locations must be the same.'); + } + }, + ], + ]; + } +} diff --git a/src/Inventory/Models/InventoryFulfillMovement.php b/src/Inventory/Models/InventoryFulfillMovement.php new file mode 100644 index 0000000000..cf37e2a246 --- /dev/null +++ b/src/Inventory/Models/InventoryFulfillMovement.php @@ -0,0 +1,28 @@ + [ + function(string $attribute, mixed $value, \Closure $fail) { + if ($this->fromInventoryTransactionType !== InventoryTransactionType::COMMITTED || $this->toInventoryTransactionType !== InventoryTransactionType::FULFILLED) { + $fail('Invalid Restock transaction type'); + } + + if ($this->fromInventoryLocation->id !== $this->toInventoryLocation->id) { + $fail('The from and to inventory locations must be the same.'); + } + }, + ], + ]; + } +} diff --git a/src/Inventory/Models/InventoryFulfillmentLevel.php b/src/Inventory/Models/InventoryFulfillmentLevel.php new file mode 100644 index 0000000000..cac7a41267 --- /dev/null +++ b/src/Inventory/Models/InventoryFulfillmentLevel.php @@ -0,0 +1,59 @@ +getInventoryItemById($this->inventoryItemId); + } + + public function getInventoryLocation(): InventoryLocation + { + return app(InventoryLocations::class)->getInventoryLocationById($this->inventoryLocationId); + } + + public function getLineItem(): LineItem + { + if (!$this->lineItemId) { + throw new \InvalidArgumentException('InventoryFulfillmentLevel is not associated with a line item'); + } + + return app(LineItems::class)->getLineItemById($this->lineItemId); + } + + public function getOrder(): Order + { + /** @var Order $order */ + $order = Order::find()->id($this->getLineItem()->orderId)->status(null)->one(); + return $order; + } + + public function getPurchasable(null|string|int $siteId = null): PurchasableInterface + { + return $this->getInventoryItem()->getPurchasable($siteId); + } +} diff --git a/src/Inventory/Models/InventoryItem.php b/src/Inventory/Models/InventoryItem.php new file mode 100644 index 0000000000..e7e1da3b69 --- /dev/null +++ b/src/Inventory/Models/InventoryItem.php @@ -0,0 +1,58 @@ +_purchasable !== null) { + return $this->_purchasable; + } + + /** @phpstan-ignore-next-line */ + $this->_purchasable = Elements::getElementById($this->purchasableId, siteId: $siteId); + + return $this->_purchasable; + } + + public function getSku(): string + { + return $this->getPurchasable('*')->sku; + } + + #[\Override] + public function getRules(): array + { + return [ + 'purchasableId' => ['required', 'integer', Rule::unique(Table::INVENTORYITEMS, 'purchasableId')], + ]; + } +} diff --git a/src/Inventory/Models/InventoryLevel.php b/src/Inventory/Models/InventoryLevel.php new file mode 100644 index 0000000000..a563e9e755 --- /dev/null +++ b/src/Inventory/Models/InventoryLevel.php @@ -0,0 +1,75 @@ +{$type->value . 'Total'}; + } + + public function getCpEditUrl(): string + { + return Url::cpUrl('commerce/inventory/levels'); + } + + public function getInventoryItem(): InventoryItem + { + if ($this->_inventoryItem === null) { + $this->_inventoryItem = app(Inventory::class)->getInventoryItemById($this->inventoryItemId); + } + return $this->_inventoryItem; + } + + public function setInventoryItem(InventoryItem $inventoryItem): void + { + $this->_inventoryItem = $inventoryItem; + $this->inventoryItemId = $inventoryItem->id; + } + + public function getInventoryLocation(): InventoryLocation + { + return app(InventoryLocations::class)->getInventoryLocationById($this->inventoryLocationId); + } + + public function getPurchasable(null|string|int $siteId = null): PurchasableInterface + { + return $this->getInventoryItem()->getPurchasable($siteId); + } +} diff --git a/src/Inventory/Models/InventoryLocation.php b/src/Inventory/Models/InventoryLocation.php new file mode 100644 index 0000000000..927b54fe53 --- /dev/null +++ b/src/Inventory/Models/InventoryLocation.php @@ -0,0 +1,150 @@ +getInventoryLocationById($id); + } + + #[\Override] + public function getUiLabel(): string + { + return t($this->name, category: 'site'); + } + + public function getAddress(): Address + { + if (!isset($this->_address)) { + if ($id = $this->addressId) { + /** @var Address $address */ + $address = Elements::getElementById($id); + $this->_address = $address; + } else { + $this->_address = new Address(); + $this->_address->countryCode = 'US'; + } + } + + $this->_address->title = $this->name; + + return $this->_address; + } + + public function setAddress(Address $address): void + { + $this->setAddressId($address->id); + $this->_address = $address; + } + + public function getAddressLine(): string + { + if (!$this->addressId) { + return ''; + } + + $address = $this->getAddress(); + return ($address->addressLine1 ?? '') . ' ' . $address->getCountryCode(); + } + + public function setAddressId(?int $id): void + { + $this->addressId = $id; + } + + public function getAddressId(): ?int + { + return $this->addressId; + } + + #[\Override] + public function getCpEditUrl(): string + { + return Url::cpUrl('commerce/inventory-locations/' . $this->id); + } + + public function getCpManageInventoryUrl(): string + { + return Url::cpUrl('commerce/inventory/levels/' . $this->handle); + } + + #[\Override] + public function getRules(): array + { + return [ + 'name' => [ + 'required', + 'string', + Rule::unique(Table::INVENTORYLOCATIONS, 'name')->ignore($this->id), + ], + 'handle' => [ + 'required', + 'string', + 'regex:/^[a-zA-Z][a-zA-Z0-9_]*$/', + Rule::unique(Table::INVENTORYLOCATIONS, 'handle')->ignore($this->id), + function($attribute, $value, $fail) { + $reserved = ['id', 'dateCreated', 'dateUpdated', 'uid', 'title', 'create']; + if (in_array($value, $reserved, true)) { + $fail(t('"{value}" is a reserved word.', ['value' => $value], category: 'commerce')); + } + }, + ], + ]; + } + + #[\Override] + public function getId(): ?int + { + return $this->id; + } + + #[\Override] + public function getActionMenuItems(): array + { + $canManage = request()->craftUser()?->can('commerce-manageInventoryLocations') ?? false; + if (!$canManage) { + return []; + } + + return [ + [ + 'label' => t('Edit', category: 'commerce'), + 'url' => $this->getCpEditUrl(), + 'icon' => 'edit', + ], + ]; + } +} diff --git a/src/Inventory/Models/InventoryLocationDeactivatedMovement.php b/src/Inventory/Models/InventoryLocationDeactivatedMovement.php new file mode 100644 index 0000000000..d0c9b5b302 --- /dev/null +++ b/src/Inventory/Models/InventoryLocationDeactivatedMovement.php @@ -0,0 +1,33 @@ + [ + function(string $attribute, mixed $value, \Closure $fail) { + if ($this->fromInventoryLocation->id === $this->toInventoryLocation->id) { + $fail(t('The from and to inventory locations must be different.', category: 'commerce')); + } + + if (!in_array($this->fromInventoryTransactionType, InventoryTransactionType::allowedManualMoveTransactionTypes(), true)) { + $fail('Can not move between these inventory types.'); + } + + if (!in_array($this->toInventoryTransactionType, InventoryTransactionType::allowedManualMoveTransactionTypes(), true)) { + $fail('Can not move between these inventory types.'); + } + }, + ], + ]; + } +} diff --git a/src/Inventory/Models/InventoryManualMovement.php b/src/Inventory/Models/InventoryManualMovement.php new file mode 100644 index 0000000000..a3bbb5df88 --- /dev/null +++ b/src/Inventory/Models/InventoryManualMovement.php @@ -0,0 +1,77 @@ + [ + function(string $attribute, mixed $value, \Closure $fail) { + if (!$this->fromInventoryTransactionType->canBeNegative() && $this->fromLocationAfterQuantity() < 0) { + $fail(sprintf( + "The %s inventory location's %s stock would drop below zero.", + $this->fromInventoryLocation->getUiLabel(), + $this->fromInventoryTransactionType->typeAsLabel(), + )); + } + }, + ], + 'toInventoryTransactionType' => [ + function(string $attribute, mixed $value, \Closure $fail) { + if (!$this->toInventoryTransactionType->canBeNegative() && $this->toLocationAfterQuantity() < 0) { + $fail(sprintf( + 'The %s inventory location stock of %s would drop below zero.', + $this->toInventoryLocation->getUiLabel(), + $this->toInventoryTransactionType->typeAsLabel(), + )); + } + + if ($this->fromInventoryLocation->id !== $this->toInventoryLocation->id) { + $fail('The from and to inventory locations must be the same.'); + } + + if ($this->isManualMovement() && ( + !in_array($this->fromInventoryTransactionType, InventoryTransactionType::allowedManualMoveTransactionTypes()) || + !in_array($this->toInventoryTransactionType, InventoryTransactionType::allowedManualMoveTransactionTypes()) + )) { + $fail('Can not move between these inventory types.'); + } + }, + ], + ]; + } + + public function fromLocationAfterQuantity(): int + { + return DB::table(Table::INVENTORYTRANSACTIONS) + ->selectRaw('COALESCE(SUM(quantity), 0) - ? AS quantity', [$this->quantity]) + ->where('type', $this->fromInventoryTransactionType->value) + ->where('inventoryItemId', $this->inventoryItemId) + ->where('inventoryLocationId', $this->fromInventoryLocation->id) + ->value('quantity') ?? 0; + } + + public function isManualMovement(): bool + { + return $this->lineItemId === null && $this->transferId === null; + } + + public function toLocationAfterQuantity(): int + { + return DB::table(Table::INVENTORYTRANSACTIONS) + ->selectRaw('COALESCE(SUM(quantity), 0) + ? AS quantity', [$this->quantity]) + ->where('type', $this->toInventoryTransactionType->value) + ->where('inventoryItemId', $this->inventoryItemId) + ->where('inventoryLocationId', $this->toInventoryLocation->id) + ->value('quantity') ?? 0; + } +} diff --git a/src/Inventory/Models/InventoryMovement.php b/src/Inventory/Models/InventoryMovement.php new file mode 100644 index 0000000000..b0f473b2fc --- /dev/null +++ b/src/Inventory/Models/InventoryMovement.php @@ -0,0 +1,104 @@ +validate(); + } + + public function getInventoryMovementHash(): string + { + if ($this->_inventoryMovementHash === null) { + $this->_inventoryMovementHash = md5(uniqid((string)mt_rand(), true)); + } + + return $this->_inventoryMovementHash; + } + + #[\Override] + public function getToInventoryLocation(): InventoryLocation + { + return $this->toInventoryLocation; + } + + #[\Override] + public function getFromInventoryLocation(): InventoryLocation + { + return $this->fromInventoryLocation; + } + + #[\Override] + public function getToInventoryTransactionType(): InventoryTransactionType + { + return $this->toInventoryTransactionType; + } + + #[\Override] + public function getFromInventoryTransactionType(): InventoryTransactionType + { + return $this->fromInventoryTransactionType; + } + + #[\Override] + public function getQuantity(): int + { + return $this->quantity; + } + + #[\Override] + public function getTransferId(): ?int + { + return $this->transferId; + } + + #[\Override] + public function getLineItemId(): ?int + { + return $this->lineItemId; + } + + #[\Override] + public function getUserId(): ?int + { + return $this->userId; + } + + #[\Override] + public function getNote(): ?string + { + return $this->note; + } +} diff --git a/src/Inventory/Models/InventoryRestockMovement.php b/src/Inventory/Models/InventoryRestockMovement.php new file mode 100644 index 0000000000..434521f325 --- /dev/null +++ b/src/Inventory/Models/InventoryRestockMovement.php @@ -0,0 +1,28 @@ + [ + function(string $attribute, mixed $value, \Closure $fail) { + if ($this->fromInventoryTransactionType !== InventoryTransactionType::COMMITTED || $this->toInventoryTransactionType !== InventoryTransactionType::AVAILABLE) { + $fail('Invalid Restock transaction type'); + } + + if ($this->fromInventoryLocation->id !== $this->toInventoryLocation->id) { + $fail('The from and to inventory locations must be the same.'); + } + }, + ], + ]; + } +} diff --git a/src/Inventory/Models/InventoryTransaction.php b/src/Inventory/Models/InventoryTransaction.php new file mode 100644 index 0000000000..4b1963ba12 --- /dev/null +++ b/src/Inventory/Models/InventoryTransaction.php @@ -0,0 +1,84 @@ +getInventoryItemById($this->inventoryItemId); + } + + public function getInventoryLocation(): InventoryLocation + { + return app(InventoryLocations::class)->getInventoryLocationById($this->inventoryLocationId); + } + + public function getPurchasable(): PurchasableInterface + { + return $this->getInventoryItem()->getPurchasable(); + } + + public function getLineItem(): ?LineItem + { + if ($this->lineItemId === null) { + return null; + } + + return app(LineItems::class)->getLineItemById($this->lineItemId); + } + + public function getOrder(): ?Order + { + if (!$this->getLineItem()) { + return null; + } + + /** @var ?Order $order */ + $order = Order::find()->id($this->getLineItem()->orderId)->status(null)->one(); + + return $order; + } + + public function getUser(): ?User + { + if (!$this->userId) { + return null; + } + + return Users::getUserById($this->userId); + } +} diff --git a/src/Inventory/Models/InventoryTransferMovement.php b/src/Inventory/Models/InventoryTransferMovement.php new file mode 100644 index 0000000000..f2b2416fa6 --- /dev/null +++ b/src/Inventory/Models/InventoryTransferMovement.php @@ -0,0 +1,9 @@ + $t->value, InventoryTransactionType::allowedManualAdjustmentTypes()); + + return [ + 'updateAction' => ['required', Rule::in(InventoryUpdateQuantityType::values())], + 'quantity' => ['required', 'integer'], + 'inventoryLocationId' => ['required', 'integer'], + 'inventoryItemId' => ['required', 'integer'], + 'type' => ['required', Rule::in([...$allowedTypes, 'onHand'])], + 'note' => ['string'], + ]; + } +} diff --git a/src/Inventory/Models/UpdateInventoryLevelInTransfer.php b/src/Inventory/Models/UpdateInventoryLevelInTransfer.php new file mode 100644 index 0000000000..c15bd702b3 --- /dev/null +++ b/src/Inventory/Models/UpdateInventoryLevelInTransfer.php @@ -0,0 +1,22 @@ + $t->value, InventoryTransactionType::incoming()); + $rules['type'] = ['required', Rule::in([...$incomingTypes, 'onHand'])]; + + return $rules; + } +} diff --git a/src/Inventory/Records/InventoryItem.php b/src/Inventory/Records/InventoryItem.php new file mode 100644 index 0000000000..58d3a6d539 --- /dev/null +++ b/src/Inventory/Records/InventoryItem.php @@ -0,0 +1,27 @@ + 'integer', + ]; +} diff --git a/src/Inventory/Records/InventoryLocation.php b/src/Inventory/Records/InventoryLocation.php new file mode 100644 index 0000000000..91ddc8e6f5 --- /dev/null +++ b/src/Inventory/Records/InventoryLocation.php @@ -0,0 +1,31 @@ + 'integer', + ]; +} diff --git a/src/Order/Actions/CopyLoadCartUrl.php b/src/Order/Actions/CopyLoadCartUrl.php new file mode 100644 index 0000000000..2100ba6739 --- /dev/null +++ b/src/Order/Actions/CopyLoadCartUrl.php @@ -0,0 +1,54 @@ + { + new Craft.ElementActionTrigger({ + type: %s, + batch: false, + validateSelection: function($selectedItems) + { + return !!$selectedItems.find('.element').data('number'); + }, + activate: function($selectedItems) + { + var number = $selectedItems.find('.element').data('number'); + Craft.sendActionRequest('GET', %s, {params: {number: number}}).then(function(response) { + Craft.ui.createCopyTextPrompt({ + label: Craft.t('commerce', 'Copy the URL'), + instructions: Craft.t('commerce', "This URL will load the cart into the user's session, making it the active cart."), + value: response.data.url, + }); + }); + } + }); +})(); +JS; + + HtmlStack::js(sprintf($jsTemplate, $type, $actionUrl)); + + return null; + } +} diff --git a/src/Order/Actions/DownloadOrderPdfAction.php b/src/Order/Actions/DownloadOrderPdfAction.php new file mode 100644 index 0000000000..97c73af4a0 --- /dev/null +++ b/src/Order/Actions/DownloadOrderPdfAction.php @@ -0,0 +1,175 @@ +storeId === null) { + return ''; + } + + $allPdfs = app(Pdfs::class)->getAllEnabledPdfs($this->storeId); + + $pdfs = []; + foreach ($allPdfs as $pdf) { + $pdfs[] = ['label' => t($pdf->name, category: 'site'), 'value' => $pdf->id]; + } + $pdfOptions = Json::encode($pdfs); + + $typeOptions = Json::encode([ + ['label' => t('ZIP file', category: 'commerce'), 'value' => self::TYPE_ZIP_ARCHIVE], + ['label' => t('Collated PDF', category: 'commerce'), 'value' => self::TYPE_PDF_COLLATED], + ]); + + $action = Json::encode(static::class); + + if (count($allPdfs) > 0) { + $js = << { + new Craft.Commerce.DownloadOrderPdfAction($('#download-order-pdf'), $pdfOptions, $typeOptions, $action); +})(); +JS; + HtmlStack::js($js); + return template('commerce/_components/elementactions/DownloadOrderPdf/trigger', [], TemplateMode::Cp); + } + + return ''; + } + + public function performAction(ElementQueryInterface $query): bool + { + if ($this->storeId === null) { + throw new RuntimeException('Invalid store ID'); + } + + $pdfsService = app(Pdfs::class); + + $pdfId = $this->pdfId; + if ($pdfId === null) { + throw new RuntimeException('Invalid PDF ID'); + } + + $pdf = $pdfsService->getPdfById($pdfId, $this->storeId); + + if (!$pdf) { + throw new RuntimeException("Invalid PDF ID: '" . $pdfId . "'"); + } + + /** @var Order[] $orders */ + $orders = $query->all(); + + if (empty($orders)) { + return false; + } + + // Only one order, download single PDF + if (count($orders) === 1 && $this->downloadType === self::TYPE_PDF_COLLATED) { + $order = reset($orders); + $renderedPdf = $pdfsService->renderPdfForOrder($order, '', null, [], $pdf); + $filename = $this->pdfFileName($pdf, $order); + $this->setResponse($this->fileResponse($renderedPdf, $filename, 'application/pdf')); + return true; + } + + // Download collated in single PDF file + if ($this->downloadType === self::TYPE_PDF_COLLATED) { + $merger = new Merger(); + foreach ($orders as $order) { + $renderedPdf = $pdfsService->renderPdfForOrder($order, '', null, [], $pdf); + $merger->addRaw($renderedPdf); + } + $mergedPdf = $merger->merge(); + $this->setResponse($this->fileResponse($mergedPdf, 'Orders.pdf', 'application/pdf')); + return true; + } + + // If it is not collated, then it is a zip request + $zip = new ZipArchive(); + $zipPath = Path::temp() . '/' . (string)Str::uuid() . '.zip'; + + if ($zip->open($zipPath, ZipArchive::CREATE) !== true) { + throw new RuntimeException('Cannot create zip at ' . $zipPath); + } + + foreach ($orders as $order) { + $renderedPdf = $pdfsService->renderPdfForOrder($order, '', null, [], $pdf); + $filename = $this->pdfFileName($pdf, $order); + $zip->addFromString($filename, $renderedPdf); + } + + $zip->close(); + $this->setResponse($this->fileResponse((string)file_get_contents($zipPath), 'Orders.zip', 'application/zip')); + unlink($zipPath); + + return true; + } + + private function fileResponse(string $content, string $filename, string $contentType): Response + { + return new Response( + content: $content, + status: 200, + headers: [ + 'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename), + 'Content-Type' => $contentType, + ], + ); + } + + /** + * Returns a PDF's file name + */ + private function pdfFileName(Pdf $pdf, Order $order): string + { + $fileName = renderSandboxedObjectTemplate($pdf->fileNameFormat, $order); + if (!$fileName) { + $fileName = $pdf->handle . '-' . $order->number; + } + + return $fileName . '.pdf'; + } +} diff --git a/src/Order/Actions/UpdateOrderStatus.php b/src/Order/Actions/UpdateOrderStatus.php new file mode 100644 index 0000000000..18a6184e98 --- /dev/null +++ b/src/Order/Actions/UpdateOrderStatus.php @@ -0,0 +1,119 @@ +get() ?? Sites::getCurrentSite(); + $store = app(Stores::class)->getStoreBySiteId($site->id); + + // TODO: migrate to app(OrderStatuses::class)->getAllOrderStatuses() once the service migrated to src/ + $orderStatuses = app(\craft\commerce\services\OrderStatuses::class)->getAllOrderStatuses($store?->id) + ->map(function(OrderStatus $orderStatus) { + // Encode for output in JS + $orderStatus->name = htmlspecialchars($orderStatus->name ?? '', ENT_QUOTES); + $orderStatus->color = htmlspecialchars($orderStatus->color, ENT_QUOTES); + $orderStatus->description = htmlspecialchars($orderStatus->description ?? '', ENT_QUOTES); + + return $orderStatus; + }); + + $orderStatuses = Json::encode(array_values($orderStatuses->all())); + $type = Json::encode(static::class); + + $js = <<all(); + $orderCount = count($orders); + + $failureCount = 0; + foreach ($orders as $order) { + $order->orderStatusId = $this->orderStatusId; + $order->message = $this->message; + $order->suppressEmails = $this->suppressEmails; + if (!Elements::saveElement($order)) { + $failureCount++; + } + } + + if ($failureCount > 0) { + $message = t('Failed updating order status on {num, plural, =1{order} other{orders}}.', ['num' => $failureCount], category: 'commerce'); + if ($orderCount === $failureCount) { + $message = t('Failed to update {num, plural, =1{order status} other{order statuses}}.', ['num' => $failureCount], category: 'commerce'); + } + + $this->setMessage($message); + return false; + } + + $this->setMessage(t('{num, plural, =1{Order} other{Orders}} updated.', ['num' => $orderCount], category: 'commerce')); + + return true; + } +} diff --git a/src/Order/Adjuster/AdjusterTypes.php b/src/Order/Adjuster/AdjusterTypes.php new file mode 100644 index 0000000000..46eaaa2e9d --- /dev/null +++ b/src/Order/Adjuster/AdjusterTypes.php @@ -0,0 +1,31 @@ +register(MyAdjuster::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class AdjusterTypes extends TypeRegistry +{ + protected const ?string CONTRACT = AdjusterInterface::class; + + protected const array DEFAULT_TYPES = [ + Shipping::class, + ]; +} diff --git a/src/Order/Adjuster/Contracts/AdjusterInterface.php b/src/Order/Adjuster/Contracts/AdjusterInterface.php new file mode 100644 index 0000000000..d26e2988b9 --- /dev/null +++ b/src/Order/Adjuster/Contracts/AdjusterInterface.php @@ -0,0 +1,18 @@ +_order = $order; + $teller = $this->_getTeller(); + + $adjustments = []; + $availableDiscounts = []; + $discounts = app(Discounts::class)->getAllActiveDiscounts($order); + + foreach ($discounts as $discount) { + if (app(Discounts::class)->matchOrder($order, $discount)) { + $availableDiscounts[] = $discount; + } + } + + if (!$availableDiscounts) { + return []; + } + + foreach ($this->_order->getLineItems() as $lineItem) { + $lineItemHashId = spl_object_hash($lineItem); + $lineItemDiscountAmount = $lineItem->getDiscount(); + if ($lineItemDiscountAmount) { + $discountedUnitPrice = (float)$teller->add( + $lineItem->salePrice, + $teller->divide($lineItemDiscountAmount, $lineItem->qty) + ); + $this->_discountUnitPricesByLineItem[$lineItemHashId] = $discountedUnitPrice; + } + } + + foreach ($availableDiscounts as $discount) { + $newAdjustments = $this->_getAdjustments($discount); + if ($newAdjustments) { + array_push($adjustments, ...$newAdjustments); + + if ($discount->stopProcessing) { + break; + } + } + } + + if ($this->_spreadBaseOrderDiscountsToLineItems) { + $priceByLineItem = []; + foreach ($this->_order->getLineItems() as $lineItem) { + $lineItemHashId = spl_object_hash($lineItem); + $priceByLineItem[$lineItemHashId] = (float)$teller->add($lineItem->getSubtotal(), $lineItem->getDiscount()); + } + + $orderLevelAdjustments = []; + + // Remove other plugins previous order level discount adjustments + $allAdjustments = $this->_order->getAdjustments(); + foreach ($allAdjustments as $key => $previousAdjustment) { + if ($previousAdjustment->type == self::ADJUSTMENT_TYPE && !$previousAdjustment->getLineItem()) { + $orderLevelAdjustments[] = $previousAdjustment; + unset($allAdjustments[$key]); + } + } + $this->_order->setAdjustments($allAdjustments); + + // Our adjustments + foreach ($adjustments as $key => $adjustment) { + if ($adjustment->getLineItem()) { + $lineItemHashId = spl_object_hash($adjustment->getLineItem()); + // Reduce the price of the line item by the amount of discount from the adjuster + $priceByLineItem[$lineItemHashId] = (float)$teller->add($priceByLineItem[$lineItemHashId] ?? 0, $adjustment->amount); + } else { + // If it's an order level adjustment lets track it, but remove it from the standard adjustments. + $orderLevelAdjustments[] = $adjustment; + unset($adjustments[$key]); + } + } + + $lineItemsByPrice = $this->_order->getLineItems(); + usort($lineItemsByPrice, static function(LineItem $a, LineItem $b) use ($priceByLineItem) { + return $priceByLineItem[spl_object_hash($b)] <=> $priceByLineItem[spl_object_hash($a)]; + }); + + // Remove non-promotable line items + $lineItemsByPrice = Arr::where($lineItemsByPrice, fn(LineItem $lineItem) => $lineItem->getIsPromotable()); + + // Loop over each order level adjustment and add an adjustment to each line item until it runs out. + foreach ($orderLevelAdjustments as $orderLevelAdjustment) { + // Track the amount of discount (as a positive number), as we are going to deduct it as we use it up on line items. + $currentDiscountAmountRemaining = -$orderLevelAdjustment->amount; + + // Lets loop over the line items and apply some or all of the discount amount + foreach ($lineItemsByPrice as $lineItem) { + + // We need to know the hash ID of the line item since some line items do not have an ID yet + $lineItemHashId = spl_object_hash($lineItem); + + // Do we have any discount left to use, and can the line item still be discounted? + if ($currentDiscountAmountRemaining > 0 && $priceByLineItem[$lineItemHashId] > 0) { + + // The amount of the adjustment for this line item. + $amount = 0; + + // Is the amount of discount greater than the price of the item + if ($currentDiscountAmountRemaining >= $priceByLineItem[$lineItemHashId]) { + $amount = (float)$teller->multiply($priceByLineItem[$lineItemHashId], -1); // Take the full price of the item off + $priceByLineItem[$lineItemHashId] = 0; // Price is now free + $currentDiscountAmountRemaining = (float)$teller->add($currentDiscountAmountRemaining, $amount); // Reduce the price of the discount remaining so it can still be used + } else { + // Is the current amount of discount remaining less than the current price of the item? Take the whole discount remainder off the item. + if ($currentDiscountAmountRemaining < $priceByLineItem[$lineItemHashId]) { + $amount = (float)$teller->multiply($currentDiscountAmountRemaining, -1); // The adjustment amount is always a negative number + $currentDiscountAmountRemaining = 0; // Reduce the amount of discount to zero since there is none left + $priceByLineItem[$lineItemHashId] = (float)$teller->add($priceByLineItem[$lineItemHashId], $amount); // Reduce the price of the item that we are tracking + } + } + + if ($amount) { + /** @var OrderAdjustment $adjustment */ + $adjustment = clone $orderLevelAdjustment; + $adjustment->amount = $amount; + $adjustment->setLineItem($lineItem); + $adjustments[] = $adjustment; + } + } + } + } + } + + return $adjustments; + } + + private function _createOrderAdjustment(DiscountModel $discount): OrderAdjustment + { + //preparing model + $adjustment = new OrderAdjustment(); + $adjustment->type = self::ADJUSTMENT_TYPE; + $adjustment->name = $discount->name; + $adjustment->setOrder($this->_order); + $adjustment->description = $discount->description; + $snapshot = $discount->toArray(); + $snapshot['discountUseId'] = $discount->id ?? null; + $adjustment->sourceSnapshot = $snapshot; + + return $adjustment; + } + + /** + * @return OrderAdjustment[]|false + */ + private function _getAdjustments(DiscountModel $discount): array|false + { + $adjustments = []; + $teller = $this->_getTeller(); + + $matchingLineIds = []; + foreach ($this->_order->getLineItems() as $item) { + $lineItemHashId = spl_object_hash($item); + // Order is already a match to this discount, or we wouldn't get here. + if (app(Discounts::class)->matchLineItem($item, $discount, false)) { + $matchingLineIds[] = $lineItemHashId; + } + } + + foreach ($this->_order->getLineItems() as $item) { + $lineItemHashId = spl_object_hash($item); + if ($matchingLineIds && in_array($lineItemHashId, $matchingLineIds, false)) { + $adjustment = $this->_createOrderAdjustment($discount); + $adjustment->setLineItem($item); + $discountAmountPerItemPreDiscounts = 0; + $amountPerItem = Currency::round($discount->perItemDiscount); + + if ($discount->percentageOffSubject == DiscountRecord::TYPE_ORIGINAL_SALEPRICE) { + $discountAmountPerItemPreDiscounts = (float)$teller->multiply($item->salePrice, $discount->percentDiscount); + } + + $unitPrice = $this->_discountUnitPricesByLineItem[$lineItemHashId] ?? $item->salePrice; + + $lineItemSubtotal = (float)$teller->multiply($item->qty, $unitPrice); + + $unitPrice = max((float)$teller->add($unitPrice, $amountPerItem), 0); + + if ($unitPrice > 0) { + if ($discount->percentageOffSubject == DiscountRecord::TYPE_ORIGINAL_SALEPRICE) { + $discountedUnitPrice = (float)$teller->add($unitPrice, $discountAmountPerItemPreDiscounts); + } else { + $discountedUnitPrice = (float)$teller->add( + $unitPrice, + $teller->multiply($unitPrice, $discount->percentDiscount) + ); + } + + $discountedSubtotal = (float)$teller->multiply($discountedUnitPrice, $item->qty); + $amountOfPercentDiscount = (float)$teller->subtract($discountedSubtotal, $lineItemSubtotal); + $this->_discountUnitPricesByLineItem[$lineItemHashId] = $discountedUnitPrice; + $adjustment->amount = $amountOfPercentDiscount; //Adding already rounded + } else { + $adjustment->amount = -$lineItemSubtotal; + $this->_discountUnitPricesByLineItem[$lineItemHashId] = 0; + } + + if ($adjustment->amount != 0) { + $this->_discountTotal = (float)$teller->add($this->_discountTotal, $adjustment->amount); + $adjustments[] = $adjustment; + } + } + } + + if ($discount->baseDiscount != 0) { + $baseDiscountAdjustment = $this->_createOrderAdjustment($discount); + $baseDiscountAdjustment->amount = $discount->baseDiscount; + $adjustments[] = $baseDiscountAdjustment; + } + + // only display adjustment if an amount was calculated + if (!count($adjustments)) { + return false; + } + + // Raise the 'afterDiscountAdjustmentsCreated' event + $event = new DiscountAdjustmentsEvent( + order: $this->_order, + discount: $discount, + adjustments: $adjustments, + ); + + // TODO: migrate event firing to Laravel once event system is bridged + if (Event::hasHandlers(LegacyDiscount::class, self::EVENT_AFTER_DISCOUNT_ADJUSTMENTS_CREATED)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + Event::trigger(LegacyDiscount::class, self::EVENT_AFTER_DISCOUNT_ADJUSTMENTS_CREATED, $event); + } + + if (!$event->isValid) { + return false; + } + + return $event->adjustments; + } + + /** + * @throws \RuntimeException + */ + private function _getTeller(): Teller + { + return app(Currencies::class)->getTeller($this->_order->currency); + } +} diff --git a/src/Order/Adjuster/DiscountAdjusterTypes.php b/src/Order/Adjuster/DiscountAdjusterTypes.php new file mode 100644 index 0000000000..784ac0f60b --- /dev/null +++ b/src/Order/Adjuster/DiscountAdjusterTypes.php @@ -0,0 +1,32 @@ +register(MyDiscountAdjuster::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class DiscountAdjusterTypes extends TypeRegistry +{ + protected const ?string CONTRACT = AdjusterInterface::class; + + protected const array DEFAULT_TYPES = [ + Discount::class, + ]; +} diff --git a/src/Order/Adjuster/Shipping.php b/src/Order/Adjuster/Shipping.php new file mode 100644 index 0000000000..2308b083f9 --- /dev/null +++ b/src/Order/Adjuster/Shipping.php @@ -0,0 +1,217 @@ +_order = $order; + $this->_isEstimated = (!$order->shippingAddressId && $order->estimatedShippingAddressId); + + if (!$order->shippingMethodHandle) { + return []; + } + + $matchingMethods = app(ShippingMethods::class)->getMatchingShippingMethods($order); + $shippingMethod = $matchingMethods[$order->shippingMethodHandle] ?? null; + $lineItems = $order->getLineItems(); + + if ($shippingMethod === null) { + return []; + } + + $nonShippableItems = []; + + foreach ($lineItems as $item) { + if (!$item->getIsShippable()) { + $nonShippableItems[$item->id] = $item->id; + } + } + + // Are all line items non shippable items? No shipping cost. + if (count($lineItems) == count($nonShippableItems)) { + return []; + } + + $adjustments = []; + + $discounts = app(Discounts::class)->getAllActiveDiscounts($order); + + // Check to see if we have shipping related discounts + $hasOrderLevelShippingRelatedDiscounts = Arr::contains($discounts, 'hasFreeShippingForOrder', true, false); + $hasLineItemLevelShippingRelatedDiscounts = Arr::contains($discounts, 'hasFreeShippingForMatchingItems', true, false); + + /** @var ShippingRule|null $rule */ + $rule = $shippingMethod->getMatchingShippingRule($this->_order); + if ($rule) { + $itemTotalAmount = 0; + + // Check for order level discounts for shipping + $hasDiscountRemoveShippingCosts = false; + if ($hasOrderLevelShippingRelatedDiscounts) { + foreach ($discounts as $discount) { + $matchedOrder = app(Discounts::class)->matchOrder($this->_order, $discount); + + if ($discount->hasFreeShippingForOrder && $matchedOrder) { + $hasDiscountRemoveShippingCosts = true; + break; + } + + if ($matchedOrder && $discount->stopProcessing) { + break; + } + } + } + + if (!$hasDiscountRemoveShippingCosts) { + //checking items shipping categories + foreach ($order->getLineItems() as $item) { + // Lets match the discount now for free shipped items and not even make a shipping cost for the line item. + $hasFreeShippingFromDiscount = false; + if ($hasLineItemLevelShippingRelatedDiscounts) { + foreach ($discounts as $discount) { + $matchedLineItem = app(Discounts::class)->matchLineItem($item, $discount, true); + + if ($discount->hasFreeShippingForMatchingItems && $matchedLineItem) { + $hasFreeShippingFromDiscount = true; + break; + } + + if ($matchedLineItem && $discount->stopProcessing) { + break; + } + } + } + + $lineItemHasFreeShipping = $item->getHasFreeShipping(); + $shippable = $item->getIsShippable(); + + if (!$lineItemHasFreeShipping && !$hasFreeShippingFromDiscount && $shippable) { + $adjustment = $this->_createAdjustment($shippingMethod, $rule); + + $percentageRate = $rule->getPercentageRate($item->shippingCategoryId); + $perItemRate = $rule->getPerItemRate($item->shippingCategoryId); + $weightRate = $rule->getWeightRate($item->shippingCategoryId); + + $percentageAmount = $item->getSubtotal() * $percentageRate; + $perItemAmount = $item->qty * $perItemRate; + $weightAmount = ($item->weight * $item->qty) * $weightRate; + + $adjustment->amount = Currency::round($percentageAmount + $perItemAmount + $weightAmount); + $adjustment->setLineItem($item); + if ($adjustment->amount) { + $adjustments[] = $adjustment; + } + $itemTotalAmount += $adjustment->amount; + } + } + + $baseAmount = Currency::round($rule->getBaseRate()); + if ($baseAmount && $baseAmount != 0) { + $adjustment = $this->_createAdjustment($shippingMethod, $rule); + $adjustment->amount = $baseAmount; + $adjustments[] = $adjustment; + } + + $adjustmentToMinimumAmount = 0; + // Is there a minimum rate and is the total shipping cost currently below it? + if ($rule->getMinRate() != 0 && (($itemTotalAmount + $baseAmount) < Currency::round($rule->getMinRate()))) { + $adjustmentToMinimumAmount = Currency::round($rule->getMinRate()) - ($itemTotalAmount + $baseAmount); + $adjustment = $this->_createAdjustment($shippingMethod, $rule); + $adjustment->amount = $adjustmentToMinimumAmount; + $adjustment->description .= ' Adjusted to minimum rate'; + $adjustments[] = $adjustment; + } + + if ($rule->getMaxRate() != 0 && (($itemTotalAmount + $baseAmount + $adjustmentToMinimumAmount) > Currency::round($rule->getMaxRate()))) { + $adjustmentToMaxAmount = Currency::round($rule->getMaxRate()) - ($itemTotalAmount + $baseAmount + $adjustmentToMinimumAmount); + $adjustment = $this->_createAdjustment($shippingMethod, $rule); + $adjustment->amount = $adjustmentToMaxAmount; + $adjustment->description .= ' Adjusted to maximum rate'; + $adjustments[] = $adjustment; + } + } + } + + // Might be a shipping method that matches but does not have any shipping rules. + if ($rule === null) { + $adjustment = new OrderAdjustment(); + $adjustment->type = self::ADJUSTMENT_TYPE; + $adjustment->setOrder($this->_order); + $adjustment->name = $shippingMethod->getName(); + $adjustment->description = ''; + $adjustment->isEstimated = $this->_isEstimated; + $adjustment->sourceSnapshot = [ + 'shippingMethodHandle' => $shippingMethod->getHandle(), + 'shippingMethodId' => $shippingMethod->getId(), + 'shippingMethodName' => $shippingMethod->getName(), + 'shippingMethodType' => $shippingMethod->getType(), + ]; + $adjustment->amount = Currency::round($shippingMethod->getPriceForOrder($this->_order), $this->_order->getStore()->getCurrency()); + $adjustments[] = $adjustment; + } + + if ($this->_consolidateShippingToSingleAdjustment) { + $amount = 0; + foreach ($adjustments as $adjustment) { + $amount += $adjustment->amount; + } + + //preparing model + $adjustment = new OrderAdjustment(); + $adjustment->type = self::ADJUSTMENT_TYPE; + $adjustment->setOrder($this->_order); + $adjustment->name = $shippingMethod->getName(); + $adjustment->amount = $amount; + $adjustment->description = $rule->getDescription(); + $adjustment->isEstimated = $this->_isEstimated; + $adjustment->setSourceSnapshot([]); + + return [$adjustment]; + } + + return $adjustments; + } + + private function _createAdjustment(ShippingMethodInterface $shippingMethod, ShippingRule $rule): OrderAdjustment + { + //preparing model + $adjustment = new OrderAdjustment(); + $adjustment->type = self::ADJUSTMENT_TYPE; + $adjustment->setOrder($this->_order); + $adjustment->name = $shippingMethod->getName(); + $adjustment->description = $rule->getDescription(); + $adjustment->isEstimated = $this->_isEstimated; + $adjustment->sourceSnapshot = $rule->toArray(); + + return $adjustment; + } +} diff --git a/src/Order/Adjuster/Tax.php b/src/Order/Adjuster/Tax.php new file mode 100644 index 0000000000..5a4dbe7bb9 --- /dev/null +++ b/src/Order/Adjuster/Tax.php @@ -0,0 +1,479 @@ + + */ + private Collection $_taxRates; + + private bool $_isEstimated = false; + + /** + * Track the additional discounts created inside the tax adjuster per line item + */ + private array $_costRemovedByLineItem = []; + + /** + * Track the additional discounts created inside the tax adjuster for order shipping costs + */ + private float $_costRemovedForOrderShipping = 0; + + /** + * Track the additional discounts created inside the tax adjuster for total price + * + * @internal This should not be modified directly, use _addAmountRemovedForOrderShipping() instead + * @see _addAmountRemovedForOrderTotalPrice() + */ + private float $_costRemovedForOrderTotalPrice = 0; + + /** + * The way to internally interact with the _costRemovedForOrderShipping property + * + * @throws Exception + */ + private function _addAmountRemovedForOrderShipping(float $amount): void + { + if ($amount > 0) { + throw new Exception('Amount added to the total removed shipping must be a negative number'); + } + + $this->_costRemovedForOrderShipping = (float)$this->_getTeller()->add($this->_costRemovedForOrderShipping, $amount); + } + + /** + * The way to interact with the _costRemovedForOrderTotalPrice property + * + * @throws Exception + */ + private function _addAmountRemovedForOrderTotalPrice(float $amount): void + { + if ($amount > 0) { + throw new Exception('Amount added to the total removed price must be a negative number'); + } + + $this->_costRemovedForOrderTotalPrice = (float)$this->_getTeller()->add($this->_costRemovedForOrderTotalPrice, $amount); + } + + #[\Override] + public function adjust(Order $order): array + { + $this->_order = $order; + $this->_address = $this->_getTaxAddress(); + $this->_taxRates = $this->getTaxRates($order->storeId); + + return $this->_adjustInternal(); + } + + private function _adjustInternal(): array + { + $adjustments = []; + + foreach ($this->_taxRates as $rate) { + if (!$rate->enabled) { + continue; + } + $newAdjustments = $this->_getAdjustments($rate); + if ($newAdjustments) { + $adjustments[] = $newAdjustments; + } + } + + if ($adjustments) { + $adjustments = array_merge(...$adjustments); + } + + return $adjustments; + } + + /** + * @return OrderAdjustment[] + */ + private function _getAdjustments(TaxRate $taxRate): array + { + $adjustments = []; + $teller = $this->_getTeller(); + $hasValidTaxId = false; + + $zoneMatches = $taxRate->getIsEverywhere() || ($taxRate->getTaxZone() && $this->_matchAddress($taxRate->getTaxZone())); + + if ($zoneMatches && $taxRate->hasTaxIdValidators()) { + $hasValidTaxId = $this->organizationTaxIdIsValidTaxId($taxRate->getSelectedEnabledTaxIdValidators()); + } + + $removeIncluded = (!$zoneMatches && $taxRate->removeIncluded); + $removeDueToVatId = ($zoneMatches && $hasValidTaxId && $taxRate->removeVatIncluded); + if ($removeIncluded || $removeDueToVatId) { + + // Remove included tax for order level taxable. + if (in_array($taxRate->taxable, TaxRateRecord::ORDER_TAXABALES, false)) { + $orderTaxableAmount = 0; + + if ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_PRICE) { + $orderTaxableAmount = $this->_getOrderTotalTaxablePrice($this->_order); + } elseif ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_SHIPPING) { + $orderTaxableAmount = $this->_order->getTotalShippingCost(); + } + + $orderLevelAmountToBeRemovedByDiscount = -$this->_getTaxAmount($orderTaxableAmount, $taxRate->rate, $taxRate->include); + + if ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_PRICE) { + $this->_addAmountRemovedForOrderTotalPrice($orderLevelAmountToBeRemovedByDiscount); + } elseif ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_SHIPPING) { + $this->_addAmountRemovedForOrderShipping($orderLevelAmountToBeRemovedByDiscount); + } + + $adjustment = $this->_createAdjustment($taxRate); + // We need to display the adjustment that removed the included tax + $adjustment->name = t($taxRate->name, category: 'site') . ' ' . t('Removed', category: 'commerce'); + $adjustment->amount = $orderLevelAmountToBeRemovedByDiscount; + $adjustment->type = 'discount'; // @TODO Stop using a discount adjustment for removed included tax and instead modify the item price directly #COM-26 + $adjustment->included = false; + + $adjustments[] = $adjustment; + } + + // Not an order level taxable, add tax adjustments to the line items. + if (!in_array($taxRate->taxable, TaxRateRecord::ORDER_TAXABALES, false)) { + // Not an order level taxable, add tax adjustments to the line items. + foreach ($this->_order->getLineItems() as $item) { + if ($item->taxCategoryId == $taxRate->taxCategoryId) { + if ($taxRate->taxable == TaxRateRecord::TAXABLE_PURCHASABLE) { + // taxableAmount = salePrice - (discount / qty) + $taxableAmount = $teller->subtract( + $item->salePrice, + $teller->divide( + $item->getDiscount(), // float amount of discount + $item->qty + ) + ); + + // amount = taxableAmount - (taxableAmount / (1 + taxRate)) + $amount = $teller->subtract( + $taxableAmount, + $teller->divide( + $taxableAmount, + (1 + $taxRate->rate) + ) + ); + + $amount = -(float)$teller->multiply($amount, $item->qty); + } else { + $taxableAmount = $item->getTaxableSubtotal($taxRate->taxable); + // amount = taxableAmount - (taxableAmount / (1 + taxRate)) + $amount = $teller->subtract( + $taxableAmount, + $teller->divide( + $taxableAmount, + (1 + $taxRate->rate) + ) + ); + + $amount = -(float)$amount; + } + $adjustment = $this->_createAdjustment($taxRate); + // We need to display the adjustment that removed the included tax + $adjustment->name = t($taxRate->name, category: 'site') . ' ' . t('Removed', category: 'commerce'); + $adjustment->amount = $amount; + $adjustment->setLineItem($item); + $adjustment->type = 'discount'; + $adjustment->included = false; + + $objectId = spl_object_hash($item); // We use this ID since some line items are not saved in the DB yet and have no ID. + + if (isset($this->_costRemovedByLineItem[$objectId])) { + $this->_costRemovedByLineItem[$objectId] = (float)$this->_getTeller()->add($this->_costRemovedByLineItem[$objectId], $amount); + } else { + $this->_costRemovedByLineItem[$objectId] = $amount; + } + + $adjustments[] = $adjustment; + } + } + } + + // Return the removed included taxes as discounts. + return $adjustments; + } + + if (!$zoneMatches || ($taxRate->hasTaxIdValidators() && $hasValidTaxId)) { + return []; + } + + // We have taxes to add! + + // Is this an order level tax rate? + if (in_array($taxRate->taxable, TaxRateRecord::ORDER_TAXABALES, false)) { + $allItemsTaxFree = true; + foreach ($this->_order->getLineItems() as $item) { + if ($item->getIsTaxable()) { + $allItemsTaxFree = false; + } + } + + // Will not have any taxes, even for order level taxes. + if ($allItemsTaxFree) { + return []; + } + + $orderTaxableAmount = 0; + + if ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_PRICE) { + $orderTaxableAmount = $this->_getOrderTotalTaxablePrice($this->_order); + $orderTaxableAmount = (float)$this->_getTeller()->add($orderTaxableAmount, $this->_costRemovedForOrderTotalPrice); + } + + if ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_SHIPPING) { + $orderTaxableAmount = $this->_order->getTotalShippingCost(); + $orderTaxableAmount = (float)$this->_getTeller()->add($orderTaxableAmount, $this->_costRemovedForOrderShipping); + } + + $orderTax = $this->_getTaxAmount($orderTaxableAmount, $taxRate->rate, $taxRate->include); + + $adjustment = $this->_createAdjustment($taxRate); + // We need to display the adjustment that removed the included tax + $adjustment->amount = $orderTax; + + if ($taxRate->include) { + $adjustment->included = true; + } + + return [$adjustment]; + } + + // not an order level tax rate, create line item adjustments. + foreach ($this->_order->getLineItems() as $item) { + if ($item->taxCategoryId == $taxRate->taxCategoryId && $item->getIsTaxable()) { + // We use this ID since some line items are not saved in the DB yet and have no ID. + $objectId = spl_object_hash($item); + /** + * Any reduction in price to the line item we have added while inside this adjuster needs to be deducted, + * since the discount adjustments we just added won't be picked up in getTaxableSubtotal() + */ + if ($taxRate->taxable == TaxRateRecord::TAXABLE_PURCHASABLE) { + $purchasableAmount = $this->_getTeller()->subtract( + $item->salePrice, + $this->_getTeller()->divide( + $item->getDiscount(), + $item->qty + ) + ); + + $purchasableAmount = $this->_getTeller()->add( + $purchasableAmount, + $this->_getTeller()->divide( + ($this->_costRemovedByLineItem[$objectId] ?? 0), + $item->qty + ) + ); + $purchasableTax = $this->_getTaxAmount((float)$purchasableAmount, $taxRate->rate, $taxRate->include); + $itemTax = $this->_getTeller()->multiply($purchasableTax, $item->qty); //already rounded + } else { + $taxableAmount = $item->getTaxableSubtotal($taxRate->taxable); + $taxableAmount = (float)$this->_getTeller()->add( + $taxableAmount, + $this->_costRemovedByLineItem[$objectId] ?? 0 + ); + $itemTax = $this->_getTaxAmount($taxableAmount, $taxRate->rate, $taxRate->include); + } + + $adjustment = $this->_createAdjustment($taxRate); + // We need to display the adjustment that removed the included tax + $adjustment->amount = $itemTax; + $adjustment->setLineItem($item); + + if ($taxRate->include) { + $adjustment->included = true; + } + + $adjustments[] = $adjustment; + } + } + + return $adjustments; + } + + /** + * @throws StoreNotFoundException + * @throws \RuntimeException + */ + protected function getTaxRates(?int $storeId = null): Collection + { + return app(TaxRates::class)->getAllEnabledTaxRates($storeId); + } + + private function _getTaxAmount($taxableAmount, $rate, $included): float + { + $teller = $this->_getTeller(); + if (!$included) { + $incTax = $teller->multiply($taxableAmount, (1 + $rate)); + $tax = $teller->subtract($incTax, $taxableAmount); + } else { + $exTax = $teller->divide($taxableAmount, (1 + $rate)); + $tax = $teller->subtract($taxableAmount, $exTax); + } + + return (float)$tax; + } + + private function _matchAddress(TaxAddressZone $zone): bool + { + //when having no address check default tax zones only + if (!$this->_address) { + return $zone->default; + } + + return $zone->getCondition()->matchElement($this->_address); + } + + private function organizationTaxIdIsValidTaxId(array $validators): bool + { + if (!$this->_address) { + return false; + } + if (!$this->_address->organizationTaxId) { + return false; + } + + if (!$this->_address->getCountryCode()) { + return false; + } + + $validOrganizationTaxId = Cache::has('commerce:validVatId:' . $this->_address->organizationTaxId); + + // If we do not have a valid VAT ID in cache, see if we can get one from the API + if (!$validOrganizationTaxId) { + $validOrganizationTaxId = $this->validateTaxIdNumber($this->_address->organizationTaxId, $validators); + } + + if ($validOrganizationTaxId) { + Cache::forever('commerce:validVatId:' . $this->_address->organizationTaxId, '1'); + return true; + } + + Cache::forget('commerce:validVatId:' . $this->_address->organizationTaxId); + return false; + } + + #[\Deprecated(message: 'in 5.3.0. Use `validateTaxIdNumber()` instead, passing the validators you want to check the ID with.')] + protected function validateVatNumber(string $businessVatId): bool + { + $oldValidator = [new EuVatIdValidator()]; + return $this->validateTaxIdNumber($businessVatId, $oldValidator); + } + + /** + * @param TaxIdValidatorInterface[] $validators + */ + protected function validateTaxIdNumber(string $organizationTaxId, array $validators = []): bool + { + try { + foreach ($validators as $validator) { + if ($validator->validate($organizationTaxId)) { + return true; + } + } + } catch (Exception $e) { + Log::error('Communication with VAT API failed: ' . $e->getMessage()); + + return false; + } + + return false; + } + + private function _createAdjustment(TaxRate $rate): OrderAdjustment + { + $adjustment = new OrderAdjustment(); + $adjustment->type = self::ADJUSTMENT_TYPE; + $adjustment->name = t($rate->name, category: 'site'); + $adjustment->description = $rate->rate * 100 . '%'; + $adjustment->setOrder($this->_order); + $adjustment->isEstimated = $this->_isEstimated; + $adjustment->sourceSnapshot = $rate->toArray(); + + return $adjustment; + } + + /** + * Returns the total price of the order, minus any tax adjustments. + */ + private function _getOrderTotalTaxablePrice(Order $order): float + { + $itemTotal = $order->getItemSubtotal(); + + $allNonIncludedAdjustmentsTotal = $order->getAdjustmentsTotal(); + $taxAdjustments = $order->getTotalTax(); + $includedTaxAdjustments = $order->getTotalTaxIncluded(); + + $totals = (float)$this->_getTeller()->add($itemTotal, $allNonIncludedAdjustmentsTotal); + $adjustments = (float)$this->_getTeller()->add($taxAdjustments, $includedTaxAdjustments); + + return (float)$this->_getTeller()->subtract( + $totals, + $adjustments + ); + } + + private function _getTaxAddress(): ?Address + { + $this->_isEstimated = false; + if (!$this->_order->getStore()->getUseBillingAddressForTax()) { + $address = $this->_order->getShippingAddress(); + if (!$address) { + $address = $this->_order->getEstimatedShippingAddress(); + $this->_isEstimated = true; + } + } else { + $address = $this->_order->getBillingAddress(); + if (!$address) { + $address = $this->_order->getEstimatedBillingAddress(); + $this->_isEstimated = true; + } + } + + return $address; + } + + /** + * @throws \RuntimeException + */ + private function _getTeller(): Teller + { + return app(Currencies::class)->getTeller($this->_order->currency); + } +} diff --git a/src/Order/Carts.php b/src/Order/Carts.php new file mode 100644 index 0000000000..33b538267c --- /dev/null +++ b/src/Order/Carts.php @@ -0,0 +1,572 @@ +getCurrentStore(); + + // Complete the cart cookie config + if (!isset($this->cartCookie['name'])) { + $this->cartCookie['name'] = md5(sprintf('Craft.%s.%s.%s', self::class, \Craft::$app->id, $currentStore->handle)) . '_commerce_cart'; + } + + if (!app()->runningInConsole()) { + $this->cartCookie = \Craft::cookieConfig($this->cartCookie); + + // Also check pre Commerce 4.0 for a cart number in the session just in case. + if (session()->isStarted() && session()->has('commerce_cart')) { + $this->setSessionCartNumber(session()->get('commerce_cart')); + session()->forget('commerce_cart'); + } + } + } + + /** + * Get the current cart for this session. + * + * @param bool $forceSave Force the cart. + */ + public function getCart(bool $forceSave = false): Order + { + $this->loadCookie(); // @TODO Audit other public runtime entry points (e.g. forgetCart, restorePreviousCartForCurrentUser) to see if they also need loadCookie() called first + + $currentUser = currentUserElement(); + + // If there is no cart set for this request, and we can't get a cart from session, create one. + if (!isset($this->cart) && !$this->cart = $this->getCartFromSession()) { + $cartAttributes = [ + 'number' => $this->getSessionCartNumber(), + 'orderSiteId' => Sites::getCurrentSite()->id, + 'storeId' => app(Stores::class)->getCurrentStore()->id, + ]; + + if ($currentUser) { + $cartAttributes['customer'] = $currentUser; // Will ensure the email is also set + } + + $this->cart = \Craft::createObject([ + 'class' => Order::class, + 'attributes' => $cartAttributes, + ]); + } elseif ($this->cart->orderSiteId != Sites::getCurrentSite()->id) { + $this->cart->orderSiteId = Sites::getCurrentSite()->id; + $forceSave = true; + } + + // Just in case the cart go put into a non all recalculation mode + if ($this->cart->getRecalculationMode() !== Order::RECALCULATION_MODE_ALL) { + $this->cart->setRecalculationMode(Order::RECALCULATION_MODE_ALL); + $forceSave = true; + } + + $autoSetAddresses = false; + // We only want to call autoSetAddresses() if we have a authed cart customer + if ($currentUser && $currentUser->id == $this->cart->customerId) { + $autoSetAddresses = $this->cart->autoSetAddresses(); + } + $autoSetShippingMethod = $this->cart->autoSetShippingMethod(); + $autoSetPaymentSource = $this->cart->autoSetPaymentSource(); + if ($autoSetAddresses || $autoSetShippingMethod || $autoSetPaymentSource) { + $forceSave = true; + } + + // Ensure the session knows what the current cart is. + $this->setSessionCartNumber($this->cart->number); + + // Track the things that might change on this cart + $originalIp = $this->cart->lastIp; + $originalOrderLanguage = $this->cart->orderLanguage; + $originalSiteId = $this->cart->orderSiteId; + $originalPaymentCurrency = $this->cart->paymentCurrency; + $originalUserId = $this->cart->getCustomerId(); + + // These values should always be kept up to date when a cart is retrieved from session. + $this->cart->lastIp = request()->ip(); + $this->cart->orderLanguage = \Craft::$app->language; + $this->cart->orderSiteId = Sites::getHasCurrentSite() ? Sites::getCurrentSite()->id : Sites::getPrimarySite()->id; + $this->cart->paymentCurrency = $this->getCartPaymentCurrencyIso(); + $this->cart->origin = Order::ORIGIN_WEB; + + // Switch the cart customer if needed + if ($currentUser && ($this->cart->getCustomer() === null || ($currentUser->email && $currentUser->email !== $this->cart->getEmail()))) { + $this->cart->setCustomer($currentUser); + } + + $hasIpChanged = $originalIp != $this->cart->lastIp; + $hasOrderLanguageChanged = $originalOrderLanguage != $this->cart->orderLanguage; + $hasOrderSiteIdChanged = $originalSiteId != $this->cart->orderSiteId; + $hasPaymentCurrencyChanged = $originalPaymentCurrency != $this->cart->paymentCurrency; + $hasUserChanged = $originalUserId != $this->cart->getCustomerId(); + + $hasSomethingChangedOnCart = ($hasIpChanged || $hasOrderLanguageChanged || $hasUserChanged || $hasPaymentCurrencyChanged || $hasOrderSiteIdChanged); + + // If the cart has already been saved (has an ID), then only save if something else changed. + if (($this->cart->id && $hasSomethingChangedOnCart) || $forceSave) { + Elements::saveElement($this->cart, false); + } + + return $this->cart; + } + + /** + * Returns the existing cart for this session without creating one, setting cookies, or touching the session. + * Returns null if no cart cookie is present or no matching cart exists. + */ + public function peekCart(): ?Order + { + if (isset($this->cart)) { + return $this->cart; + } + + if ($this->cartNumber === false) { + return null; + } + + if (!$this->cartNumber) { + $cookieNumber = request()->cookie($this->cartCookie['name']); + if (!$cookieNumber) { + return null; + } + $this->cartNumber = $cookieNumber; + } + + /** @var Order|null $cart */ + $cart = Order::find() + ->number($this->cartNumber) + ->storeId(app(Stores::class)->getCurrentStore()->id) + ->isCompleted(false) + ->trashed(false) + ->one(); + + if (!$cart) { + return null; + } + + // Don't return a cart that belongs to a credentialed user who isn't currently logged in + // as that user, unless this session has been authorized to use it (e.g. loaded via a valid + // load-cart token). Mirrors the privacy check in getCartFromSession(), but without forgetting + // the cart (which would set a Set-Cookie header and defeat the purpose of this method). + $cartCustomer = $cart->getCustomer(); + if ($cartCustomer && $cartCustomer->getIsCredentialed()) { + $authorizedForCredentialedCart = session()->get('commerce:anonymousCartWithCredentialedCustomer:' . $cart->number, false); + if (!$authorizedForCredentialedCart) { + $currentUser = currentUserElement(); + if (!$currentUser || $currentUser->id != $cartCustomer->id) { + return null; + } + } + } + + $this->cart = $cart; + return $this->cart; + } + + /** + * Get the current cart for this session. + */ + private function getCartFromSession(): ?Order + { + $number = $this->getSessionCartNumber(); + /** @var Order|null $cart */ + $cart = Order::find() + ->withLineItems() + ->withAdjustments() + ->number($number) + ->storeId(app(Stores::class)->getCurrentStore()->id) + ->trashed(null) + ->status(null) + ->one(); + + // If the cart is already completed or trashed, forget the cart and start again. + if ($cart && ($cart->isCompleted || $cart->trashed)) { + $this->forgetCart(); + return null; + } + + $currentUser = currentUserElement(); + + $cartCustomer = $cart?->getCustomer(); + + // Is this session authorized to use a cart that belongs to a credentialed user? This is the + // case when an anonymous user submitted the credentialed user's email to the cart (see + // CartController::actionUpdate()), or when the cart was loaded via a valid load-cart token + // (see CartController::actionLoadCart()). + $authorizedForCredentialedCart = $cart && session()->get('commerce:anonymousCartWithCredentialedCustomer:' . $cart->number, false); + + if ($cart && $cartCustomer && $cartCustomer->getIsCredentialed() && + !$authorizedForCredentialedCart && + ( + // Forget cart if they are not logged-in. + !$currentUser + || + // Forget cart if the logged-in user is not the same as the cart customer. + $currentUser->id != $cartCustomer->id + ) + ) { + $this->forgetCart(); + return null; + } + + return $cart; + } + + /** + * Forgets the cart in the current session. + */ + public function forgetCart(): void + { + $this->cart = null; + // Force a new cart number to be generated when next requested. + $this->cartNumber = false; + if (!app()->runningInConsole()) { + Cookie::queue(Cookie::forget($this->cartCookie['name'])); + } + } + + /** + * Generates a new random cart number and returns it. + */ + public function generateCartNumber(): string + { + return bin2hex(random_bytes(16)); + } + + /** + * Calculates the date of the active cart duration edge. + */ + public function getActiveCartEdgeDuration(): string + { + $edge = new DateTime(); + $activeCartDuration = Config::durationInSeconds(Plugin::getInstance()->getSettings()->activeCartDuration); + $interval = new DateInterval("PT{$activeCartDuration}S"); + $edge->sub($interval); + return $edge->format(DateTime::ATOM); + } + + /** + * Returns whether there is a cart number in the session. + */ + public function getHasSessionCartNumber(): bool + { + if ($this->cartNumber === false) { + return false; + } + + if ($this->cartNumber === null) { + return request()->cookie($this->cartCookie['name']) !== null; + } + + return true; + } + + /** + * Get the session cart number or generates one if none exists. + */ + protected function getSessionCartNumber(): string + { + if (!app()->runningInConsole()) { + // Only try to retrieve the cart number from the cookie if `cartNumber` is `null`. + if ($this->cartNumber === null && $cookieNumber = request()->cookie($this->cartCookie['name'])) { + $this->cartNumber = $cookieNumber; + } + } + + // A `null` or `false` value means we need to generate a new cart number. + if ($this->cartNumber === null || $this->cartNumber === false) { + $this->cartNumber = $this->generateCartNumber(); + } + + // Just in case the current cart is not the one in session, clear the cached cart. + if ($this->cart && $this->cart->number !== $this->cartNumber) { + $this->cart = null; + } + + return $this->cartNumber; + } + + /** + * Set the session cart number. + */ + public function setSessionCartNumber(string $cartNumber): void + { + if (!app()->runningInConsole()) { + $this->cartNumber = $cartNumber; + Cookie::queue(Cookie::make( + $this->cartCookie['name'], + $cartNumber, + (int)ceil($this->cartCookieDuration / 60), + '/', + $this->cartCookie['domain'] ?? null, + $this->cartCookie['secure'] ?? null, + $this->cartCookie['httpOnly'] ?? true, + false, + $this->cartCookie['sameSite'] ?? null, + )); + } + } + + /** + * Returns a URL to load a cart with a secure token. + * + * @param Order $cart The cart to generate the load URL for + * @return string The URL with secure token + */ + public function getLoadCartUrl(Order $cart): string + { + $linkExpiry = Plugin::getInstance()->getSettings()->loadCartUrlExpiry; + $expiryDate = now('UTC')->add(new DateInterval("PT{$linkExpiry}S")); + + $token = app(RouteTokens::class)->createToken([ + 'commerce/cart/load-cart', + ['cartNumber' => $cart->number], + ], expiryDate: $expiryDate); + + $request = request(); + $isCpRequest = $request->isCpRequest(); + + if ($isCpRequest) { + $request->attributes->set('isCpRequest', false); + } + + try { + return Url::actionUrl('commerce/cart/load-cart', [ + 'number' => $cart->number, + 'code' => $token, + ]); + } finally { + if ($isCpRequest) { + $request->attributes->set('isCpRequest', $isCpRequest); + } + } + } + + /** + * Restores previous cart for the current user if their current cart is empty. + * Ideally this is only used when a user logs in. + */ + public function restorePreviousCartForCurrentUser(): void + { + $currentUser = currentUserElement(); + $currentStoreId = app(Stores::class)->getCurrentStore()->id; + + if (!$currentUser) { + return; + } + + // If the current cart is empty see if the logged-in user has a previous cart + // Get any cart that is not empty, is not trashed or complete, and belongings to the user + /** @var Order|null $previousCartsWithLineItems */ + $previousCartsWithLineItems = Order::find() + ->customer($currentUser) + ->isCompleted(false) + ->hasLineItems() + ->trashed(false) + ->storeId($currentStoreId) + ->one(); + + /** @var Order|null $anyPreviousCart */ + $anyPreviousCart = Order::find() + ->customer($currentUser) + ->isCompleted(false) + ->trashed(false) + ->storeId($currentStoreId) + ->one(); + + /** @var Order|null $currentCartInSession */ + $currentCartInSession = Order::find() + ->number($this->getSessionCartNumber()) + ->isCompleted(false) + ->hasLineItems() + ->trashed(false) + ->storeId($currentStoreId) + ->one(); + + /** + * Cart restoring preference order: + * 1. Give the cart in session to the current customer if they are logging in and there are items in the cart + * 2. Restore a previous cart belonging to the customer that has line items + * 3. Restore any other previous cart for the customer + */ + if ($currentCartInSession) { + // Give the cart to the current customer if they are logging in and there are items in the cart + // Call get cart as this will switch the user and save it if needed + $this->getCart(); + } elseif ($previousCartsWithLineItems) { + // Restore previous cart that has line items + $this->cart = $previousCartsWithLineItems; + $this->setSessionCartNumber($previousCartsWithLineItems->number); + } elseif ($anyPreviousCart) { + // Finally try to restore any other previous cart for the customer + $this->cart = $anyPreviousCart; + $this->setSessionCartNumber($anyPreviousCart->number); + } + } + + /** + * Removes all carts that are incomplete and older than the config setting. + * + * @return int The number of carts purged from the database + */ + public function purgeIncompleteCarts(): int + { + if (!Plugin::getInstance()->getSettings()->purgeInactiveCarts) { + return 0; + } + + $configInterval = Config::durationInSeconds(Plugin::getInstance()->getSettings()->purgeInactiveCartsDuration); + $edge = new DateTime(); + $interval = new DateInterval("PT{$configInterval}S"); + $edge->sub($interval); + + // This query is exposed via CartPurgeEvent::$inactiveCartsQuery as a legacy craft\db\Query, + // so it can't be swapped for the Laravel query builder without breaking that event's contract. + $cartIdsQuery = new Query() + ->select(['orders.id']) + ->where(['not', ['isCompleted' => true]]) + ->andWhere('[[orders.dateUpdated]] <= :edge', ['edge' => CraftDb::prepareDateForDb($edge)]) + ->from(['orders' => Table::ORDERS]); + + $event = new CartPurgeEvent(inactiveCartsQuery: $cartIdsQuery); + + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getCarts()->hasEventHandlers(self::EVENT_BEFORE_PURGE_INACTIVE_CARTS)) { + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getCarts()->trigger(self::EVENT_BEFORE_PURGE_INACTIVE_CARTS, $event); + } + + if (!$event->isValid) { + return 0; + } + + $cartIds = $event->inactiveCartsQuery->column(); + + if (empty($cartIds)) { + return 0; + } + + // The searchindex table is probably MyISAM, though + DB::table(CraftTable::SEARCHINDEX)->whereIn('elementId', $cartIds)->delete(); + + // Taken from craft\services\Elements::deleteElement(); Using the method directly + // takes too many resources since it retrieves the order before deleting it. + // Delete the elements table rows, which will cascade across all other InnoDB tables + DB::table(CraftTable::ELEMENTS)->whereIn('id', $cartIds)->delete(); + + return count($cartIds); + } + + protected function loadCookie(): void + { + $currentStore = app(Stores::class)->getCurrentStore(); + + // Complete the cart cookie config + if (!isset($this->cartCookie['name'])) { + $this->cartCookie['name'] = md5(sprintf('Craft.%s.%s.%s', self::class, \Craft::$app->id, $currentStore->handle)) . '_commerce_cart'; + } + + // Don't restore from cookie if the cart was explicitly forgotten this request. + if ($this->cartNumber === false) { + return; + } + + if (!app()->runningInConsole()) { + $this->cartCookie = \Craft::cookieConfig($this->cartCookie); + + // If we have a cart cookie, assign it to the cart number. + if (request()->hasCookie($this->cartCookie['name'])) { + $this->setSessionCartNumber(request()->cookie($this->cartCookie['name'])); + } + } + } + + /** + * Gets the current payment currency ISO code + * @todo in Commerce 6.0, replace the COMMERCE_PAYMENT_CURRENCY constant with a proper per-store config setting and surface validation errors instead of throwing InvalidConfigException + */ + private function getCartPaymentCurrencyIso(): string + { + if ($this->cart) { + // Is the payment currency locked to the constant + if (defined('COMMERCE_PAYMENT_CURRENCY')) { + $paymentCurrencies = app(PaymentCurrencies::class)->getAllPaymentCurrencies($this->cart->storeId); + // if not in array + if (!$paymentCurrencies->contains('iso', '==', COMMERCE_PAYMENT_CURRENCY)) { + throw new \RuntimeException('The COMMERCE_PAYMENT_CURRENCY constant is not set to a valid payment currency.'); + } + + $this->cart->paymentCurrency = COMMERCE_PAYMENT_CURRENCY; + } + + return $this->cart->paymentCurrency; + } + + return app(PaymentCurrencies::class)->getPrimaryPaymentCurrencyIso(); + } + + public function afterSaveUserHandler(ElementSaved $event): void + { + $segments = request()->actionSegments(); + $userSaveSegments = ['users', 'save-user']; + $isUserSaveAction = $segments == $userSaveSegments; + + // we have a cart number, currently anon, and the current action being executed is user save + if (!currentUserElement() && + !request()->isCpRequest() && + $isUserSaveAction + ) { + $currentCartNumber = $this->getSessionCartNumber(); + // Set the session flag to preserve the cart for this user + session()->put('commerce:anonymousCartWithCredentialedCustomer:' . $currentCartNumber, true); + } + } +} diff --git a/src/Order/Conditions/CompletedConditionRule.php b/src/Order/Conditions/CompletedConditionRule.php new file mode 100644 index 0000000000..b8d74ae040 --- /dev/null +++ b/src/Order/Conditions/CompletedConditionRule.php @@ -0,0 +1,39 @@ +isCompleted($this->value); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Order $element */ + return $element->isCompleted === $this->value; + } +} diff --git a/src/Order/Conditions/ContainsPurchasablesConditionRule.php b/src/Order/Conditions/ContainsPurchasablesConditionRule.php new file mode 100644 index 0000000000..b5e7283180 --- /dev/null +++ b/src/Order/Conditions/ContainsPurchasablesConditionRule.php @@ -0,0 +1,185 @@ +_match; + } + + public function setMatch(ContainsPurchasablesMatch|string $value): void + { + $this->_match = $value instanceof ContainsPurchasablesMatch ? $value : ContainsPurchasablesMatch::from($value); + } + + public function getLabel(): string + { + return t('Contains Purchasables', category: 'commerce'); + } + + public function getExclusiveQueryParams(): array + { + return ['hasPurchasable']; + } + + protected function elementType(): string + { + return $this->purchasableType; + } + + public function modifyQuery(ElementQueryInterface $query): void + { + $ids = $this->getElementIds(); + if (empty($ids)) { + return; + } + + /** @var OrderQuery $query */ + $query->containsPurchasables(['purchasables' => $ids, 'match' => $this->getMatch()]); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Order $element */ + return $element->hasPurchasables($this->getElementIds(), $this->getMatch()); + } + + #[Override] + protected function allowMultiple(): bool + { + return true; + } + + #[Override] + public function getConfig(): array + { + return array_merge(parent::getConfig(), [ + 'purchasableType' => $this->purchasableType, + 'match' => $this->getMatch()->value, + ]); + } + + #[Override] + public function getRules(): array + { + return array_merge(parent::getRules(), [ + 'purchasableType' => ['nullable', 'string'], + 'match' => ['nullable'], + ]); + } + + #[Override] + protected function inputHtml(): string + { + $matchId = 'match'; + $purchasableTypeOptions = $this->purchasableTypeOptions(); + + $purchasableTypeHtml = count($purchasableTypeOptions) === 1 + ? Html::hiddenInput('purchasableType', $purchasableTypeOptions[0]['value']) + : Cp::selectHtml([ + 'id' => 'purchasable-type', + 'name' => 'purchasableType', + 'options' => $purchasableTypeOptions, + 'value' => $this->purchasableType, + 'inputAttributes' => [ + 'hx' => [ + 'post' => Url::actionUrl('conditions/render'), + ], + ], + ]); + + return Html::hiddenLabel($this->getLabel(), $matchId) . + Html::tag('div', + Cp::selectHtml([ + 'id' => $matchId, + 'name' => 'match', + 'options' => $this->matchOptions(), + 'value' => $this->getMatch()->value, + 'inputAttributes' => [ + 'hx' => [ + 'post' => Url::actionUrl('conditions/render'), + ], + ], + ]) . + $purchasableTypeHtml . + parent::inputHtml(), + [ + 'class' => ['flex', 'flex-start'], + ] + ); + } + + #[Override] + protected function selectionCondition(): ?ElementConditionInterface + { + /** @var OrderCondition $condition */ + $condition = Conditions::createCondition(['class' => OrderCondition::class]); + + return $condition; + } + + /** @return list, label: string}> */ + private function purchasableTypeOptions(): array + { + $options = []; + + foreach (app(Purchasables::class)->getAllPurchasableElementTypes() as $elementType) { + $options[] = [ + 'value' => $elementType, + 'label' => $elementType::displayName(), + ]; + } + + return $options; + } + + /** @return list */ + private function matchOptions(): array + { + return array_map( + fn(ContainsPurchasablesMatch $m) => ['value' => $m->value, 'label' => $m->label()], + ContainsPurchasablesMatch::cases() + ); + } + + #[Override] + protected function elementSelectConfig(): array + { + return array_merge(parent::elementSelectConfig(), [ + 'showSiteMenu' => true, + ]); + } +} diff --git a/src/Order/Conditions/CouponCodeConditionRule.php b/src/Order/Conditions/CouponCodeConditionRule.php new file mode 100644 index 0000000000..88a49fc68b --- /dev/null +++ b/src/Order/Conditions/CouponCodeConditionRule.php @@ -0,0 +1,46 @@ +operator) { + case self::OPERATOR_EMPTY: + return !$value; + case self::OPERATOR_NOT_EMPTY: + return (bool)$value; + } + + if ($this->value === '') { + return true; + } + + return match ($this->operator) { + self::OPERATOR_EQ => strcasecmp((string)$value, $this->value) === 0, + self::OPERATOR_NE => strcasecmp((string)$value, $this->value) !== 0, + self::OPERATOR_BEGINS_WITH => is_string($value) && str_starts_with(mb_strtolower($value), mb_strtolower($this->value)), + self::OPERATOR_ENDS_WITH => is_string($value) && str_ends_with(mb_strtolower($value), mb_strtolower($this->value)), + self::OPERATOR_CONTAINS => is_string($value) && str_contains(mb_strtolower($value), mb_strtolower($this->value)), + default => throw new RuntimeException("Invalid operator: $this->operator"), + }; + } +} diff --git a/src/Order/Conditions/CustomerConditionRule.php b/src/Order/Conditions/CustomerConditionRule.php new file mode 100644 index 0000000000..dad0f8135b --- /dev/null +++ b/src/Order/Conditions/CustomerConditionRule.php @@ -0,0 +1,75 @@ +status(null)->limit(null)->id($this->values)->all(); + + return Cp::elementSelectHtml([ + 'name' => 'values', + 'elements' => $users, + 'elementType' => User::class, + 'sources' => null, + 'criteria' => null, + 'condition' => null, + 'single' => false, + ]); + } + + /** @return array */ + #[Override] + protected function options(): array + { + return []; + } + + public function getExclusiveQueryParams(): array + { + return ['customerId']; + } + + public function modifyQuery(ElementQueryInterface $query): void + { + /** @var OrderQuery $query */ + $paramValue = $this->paramValue(); + if ($this->operator === self::OPERATOR_NOT_IN) { + // Account for the fact that querying using a combination of `not` and `in` doesn't match `null` in the column + $query->whereParam(DB::raw('coalesce(commerce_orders.customerId, -1)'), $paramValue); + } else { + $query->customerId($paramValue); + } + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Order $element */ + return $this->matchValue((string)$element->getCustomerId()); + } +} diff --git a/src/Order/Conditions/DateOrderedConditionRule.php b/src/Order/Conditions/DateOrderedConditionRule.php new file mode 100644 index 0000000000..48469ba0c7 --- /dev/null +++ b/src/Order/Conditions/DateOrderedConditionRule.php @@ -0,0 +1,39 @@ +dateOrdered($this->queryParamValue()); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Order $element */ + return $this->matchValue($element->dateOrdered); + } +} diff --git a/src/Order/Conditions/DiscountOrderCondition.php b/src/Order/Conditions/DiscountOrderCondition.php new file mode 100644 index 0000000000..306458ac71 --- /dev/null +++ b/src/Order/Conditions/DiscountOrderCondition.php @@ -0,0 +1,46 @@ + ['nullable', 'integer'], + ]); + } + + #[Override] + protected function config(): array + { + return array_merge(parent::config(), ['storeId' => $this->storeId]); + } + + #[Override] + protected function selectableConditionRules(): array + { + $rules = parent::selectableConditionRules(); + + // We don't need the condition to have the coupon code rule + return Arr::where($rules, fn($rule) => $rule !== CouponCodeConditionRule::class); + } + + #[Override] + public function modifyQuery(ElementQueryInterface $query): void + { + throw new LogicException('Discount Order Condition does not support element queries.'); + } +} diff --git a/src/Order/Conditions/DiscountedItemSubtotalConditionRule.php b/src/Order/Conditions/DiscountedItemSubtotalConditionRule.php new file mode 100644 index 0000000000..5ed814c34a --- /dev/null +++ b/src/Order/Conditions/DiscountedItemSubtotalConditionRule.php @@ -0,0 +1,53 @@ +getDiscountAdjusters(); + foreach ($discountAdjusters as $discountAdjuster) { + $adjuster = new $discountAdjuster(); + $discountAdjustments = array_merge($discountAdjustments, $adjuster->adjust($element)); + } + + $discountAmount = 0; + foreach ($discountAdjustments as $adjustment) { + $discountAmount += $adjustment->amount; + } + + $itemTotal = $element->getItemSubtotal() + $discountAmount; + + return $this->matchValue($itemTotal); + } +} diff --git a/src/Order/Conditions/GatewayOrderCondition.php b/src/Order/Conditions/GatewayOrderCondition.php new file mode 100644 index 0000000000..c94753f0ef --- /dev/null +++ b/src/Order/Conditions/GatewayOrderCondition.php @@ -0,0 +1,21 @@ + parent::getBuilderHtml()); + } + + return parent::getBuilderHtml(); + } +} diff --git a/src/Order/Conditions/HasAdminNoticesConditionRule.php b/src/Order/Conditions/HasAdminNoticesConditionRule.php new file mode 100644 index 0000000000..e499d436c3 --- /dev/null +++ b/src/Order/Conditions/HasAdminNoticesConditionRule.php @@ -0,0 +1,39 @@ +hasAdminNotices($this->value); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Order $element */ + return $element->hasAdminNotices() === $this->value; + } +} diff --git a/src/Order/Conditions/HasPurchasableConditionRule.php b/src/Order/Conditions/HasPurchasableConditionRule.php new file mode 100644 index 0000000000..20b947bc4e --- /dev/null +++ b/src/Order/Conditions/HasPurchasableConditionRule.php @@ -0,0 +1,136 @@ +purchasableType; + } + + public function modifyQuery(ElementQueryInterface $query): void + { + if ($this->getElementId() === null) { + return; + } + + /** @var OrderQuery $query */ + $query->hasPurchasables([$this->getElementId()]); + } + + public function matchElement(ElementInterface $element): bool + { + return Order::find() + ->id($element->id) + ->hasPurchasables([$this->getElementId()]) + ->exists(); + } + + #[Override] + public function getConfig(): array + { + return array_merge(parent::getConfig(), [ + 'purchasableType' => $this->purchasableType, + ]); + } + + #[Override] + public function getRules(): array + { + return array_merge(parent::getRules(), [ + 'purchasableType' => ['nullable', 'string'], + ]); + } + + #[Override] + protected function inputHtml(): string + { + $id = 'purchasable-type'; + + return Html::hiddenLabel($this->getLabel(), $id) . + Html::tag('div', + Cp::selectHtml([ + 'id' => $id, + 'name' => 'purchasableType', + 'options' => $this->purchasableTypeOptions(), + 'value' => $this->purchasableType, + 'inputAttributes' => [ + 'hx' => [ + 'post' => Url::actionUrl('conditions/render'), + ], + ], + ]) . + parent::inputHtml(), + [ + 'class' => ['flex', 'flex-start'], + ] + ); + } + + #[Override] + protected function selectionCondition(): ?ElementConditionInterface + { + /** @var OrderCondition $condition */ + $condition = Conditions::createCondition(['class' => OrderCondition::class]); + + return $condition; + } + + /** @return list, label: string}> */ + private function purchasableTypeOptions(): array + { + $options = []; + + foreach (app(Purchasables::class)->getAllPurchasableElementTypes() as $elementType) { + $options[] = [ + 'value' => $elementType, + 'label' => $elementType::displayName(), + ]; + } + + return $options; + } + + #[Override] + protected function elementSelectConfig(): array + { + return array_merge(parent::elementSelectConfig(), [ + 'showSiteMenu' => true, + ]); + } +} diff --git a/src/Order/Conditions/ItemSubtotalConditionRule.php b/src/Order/Conditions/ItemSubtotalConditionRule.php new file mode 100644 index 0000000000..7050daccf6 --- /dev/null +++ b/src/Order/Conditions/ItemSubtotalConditionRule.php @@ -0,0 +1,21 @@ +currency = $condition->getStore()->getCurrency(); + } else { + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $this->currency = Sites::getCurrentSite()->getStore()->getCurrency(); + } + + if ($this->currency) { + $this->subUnit = app(Currencies::class)->getSubunitFor($this->currency); + } + } + + public function getExclusiveQueryParams(): array + { + return [$this->orderAttribute]; + } + + public function getLabel(): string + { + return 'Label not implemented'; + } + + public function matchElement(ElementInterface $element): bool + { + return $this->matchValue($element->{$this->orderAttribute}); + } + + public function modifyQuery(ElementQueryInterface $query): void + { + $query->{$this->orderAttribute}($this->paramValue()); + } + + #[Override] + protected function inputHtml(): string + { + // don't show the value input if the condition checks for empty/notempty + if ($this->operator === self::OPERATOR_EMPTY || $this->operator === self::OPERATOR_NOT_EMPTY) { + return ''; + } + + if ($this->operator === self::OPERATOR_BETWEEN) { + $maxValue = is_numeric($this->maxValue) ? MoneyHelper::toNumber(MoneyHelper::toMoney(['value' => $this->maxValue, 'currency' => $this->currencyCode()])) : $this->maxValue; + + return Html::tag('div', + Html::hiddenLabel(t('Min Value'), 'min') . + FormFields::moneyInputHtml($this->inputOptions()) . + Html::tag('span', t('and')) . + Html::hiddenLabel(t('Max Value'), 'max') . + FormFields::moneyInputHtml(array_merge( + $this->inputOptions(), + ['id' => 'maxValue', 'name' => 'maxValue', 'value' => $maxValue] + )) . + Html::tag('craft-info-icon', t('The values are matched inclusively.')), + ['class' => 'flex flex-center'] + ); + } + + return FormFields::moneyInputHtml($this->inputOptions()); + } + + /** @return array */ + #[Override] + protected function inputOptions(): array + { + $value = is_numeric($this->value) ? MoneyHelper::toNumber(MoneyHelper::toMoney(['value' => $this->value, 'currency' => $this->currencyCode()])) : $this->value; + + return [ + 'type' => 'text', + 'id' => 'value', + 'name' => 'value', + 'value' => $value, + 'autocomplete' => false, + 'currency' => $this->currencyCode(), + 'currencyLabel' => $this->currencyLabel(), + 'showCurrency' => true, + 'decimals' => $this->subUnit ?? 2, + 'showClear' => false, + ]; + } + + private function currencyCode(): string + { + return $this->currency?->getCode() ?? 'USD'; + } + + private function currencyLabel(): string + { + return t('({currencyCode}) {currencySymbol}', [ + 'currencyCode' => $this->currencyCode(), + 'currencySymbol' => I18N::getFormattingLocale()->getCurrencySymbol($this->currencyCode()), + ]); + } +} diff --git a/src/Order/Conditions/OrderSiteConditionRule.php b/src/Order/Conditions/OrderSiteConditionRule.php new file mode 100644 index 0000000000..7e60c30650 --- /dev/null +++ b/src/Order/Conditions/OrderSiteConditionRule.php @@ -0,0 +1,45 @@ +pluck('name', 'id')->all(); + } + + public function modifyQuery(ElementQueryInterface $query): void + { + /** @var OrderQuery $query */ + $query->orderSiteId($this->paramValue()); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Order $element */ + return $this->matchValue((string)$element->orderSiteId); + } +} diff --git a/src/Order/Conditions/OrderStatusConditionRule.php b/src/Order/Conditions/OrderStatusConditionRule.php new file mode 100644 index 0000000000..ab4f6644ad --- /dev/null +++ b/src/Order/Conditions/OrderStatusConditionRule.php @@ -0,0 +1,54 @@ +getAllOrderStatuses(); + + /** @var OrderQuery $query */ + $query->orderStatus($this->paramValue(fn(string $value) => Arr::first($orderStatuses, fn(OrderStatus $status) => $status->uid === $value)?->handle)); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Order $element */ + $orderStatusUid = $element->getOrderStatus()?->uid; + + return $this->matchValue($orderStatusUid); + } + + protected function options(): array + { + return app(OrderStatuses::class)->getAllOrderStatuses()->mapWithKeys(fn(OrderStatus $status) => [$status->uid => $status->name])->all(); + } +} diff --git a/src/Order/Conditions/OrderTextValuesAttributeConditionRule.php b/src/Order/Conditions/OrderTextValuesAttributeConditionRule.php new file mode 100644 index 0000000000..7825f200c7 --- /dev/null +++ b/src/Order/Conditions/OrderTextValuesAttributeConditionRule.php @@ -0,0 +1,38 @@ +orderAttribute]; + } + + public function getLabel(): string + { + return 'Label not implemented'; + } + + public function matchElement(ElementInterface $element): bool + { + return $this->matchValue($element->{$this->orderAttribute}); + } + + public function modifyQuery(ElementQueryInterface $query): void + { + $query->{$this->orderAttribute}($this->paramValue()); + } +} diff --git a/src/Order/Conditions/OrderValuesAttributeConditionRule.php b/src/Order/Conditions/OrderValuesAttributeConditionRule.php new file mode 100644 index 0000000000..cd5d549c69 --- /dev/null +++ b/src/Order/Conditions/OrderValuesAttributeConditionRule.php @@ -0,0 +1,38 @@ +orderAttribute]; + } + + public function getLabel(): string + { + return 'Label not implemented'; + } + + public function matchElement(ElementInterface $element): bool + { + return $this->matchValue($element->{$this->orderAttribute}); + } + + public function modifyQuery(ElementQueryInterface $query): void + { + $query->{$this->orderAttribute}($this->paramValue()); + } +} diff --git a/src/Order/Conditions/PaidConditionRule.php b/src/Order/Conditions/PaidConditionRule.php new file mode 100644 index 0000000000..438731ae30 --- /dev/null +++ b/src/Order/Conditions/PaidConditionRule.php @@ -0,0 +1,43 @@ +value) { + $query->isPaid(); + } else { + $query->isUnpaid(); + } + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Order $element */ + return $this->value ? $element->getIsPaid() : $element->getIsUnpaid(); + } +} diff --git a/src/Order/Conditions/PaymentGatewayConditionRule.php b/src/Order/Conditions/PaymentGatewayConditionRule.php new file mode 100644 index 0000000000..28ca605f13 --- /dev/null +++ b/src/Order/Conditions/PaymentGatewayConditionRule.php @@ -0,0 +1,118 @@ + $values */ + #[Override] + public function setAttributes($values): void + { + // For backwards compatibility: convert single 'value' to 'values' array + if (isset($values['value']) && !isset($values['values'])) { + $values['values'] = is_array($values['value']) ? $values['value'] : [$values['value']]; + unset($values['value']); + } + + parent::setAttributes($values); + } + + /** + * Returns the single value for backwards compatibility + */ + #[Deprecated(message: 'Use getValues() instead')] + public function getValue(): ?string + { + $values = $this->getValues(); + + return !empty($values) ? reset($values) : null; + } + + /** + * Sets a single value for backwards compatibility + */ + #[Deprecated(message: 'Use setValues() instead')] + public function setValue(?string $value): void + { + $this->setValues($value ? [$value] : []); + } + + public function getExclusiveQueryParams(): array + { + return ['gatewayId']; + } + + protected function options(): array + { + return app(Gateways::class)->getAllGateways()->mapWithKeys(fn(GatewayInterface $gateway) => [$gateway->uid => $gateway->name])->all(); + } + + public function modifyQuery(ElementQueryInterface $query): void + { + $gateways = app(Gateways::class)->getAllGateways(); + + /** @var OrderQuery $query */ + $query->gatewayId($this->paramValue(fn($uid) => $gateways->firstWhere('uid', $uid)?->id)); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Order $element */ + /** @phpstan-ignore-next-line property.notFound, nullsafe.neverNull ($uid is declared on legacy craft\commerce\base\Gateway via SavableComponent, which implements GatewayInterface via the class_alias chain, which PHPStan can't trace) */ + $gatewayUid = $element->getGateway()?->uid ?? ''; + + return $this->matchValue($gatewayUid); + } +} diff --git a/src/Order/Conditions/ReferenceConditionRule.php b/src/Order/Conditions/ReferenceConditionRule.php new file mode 100644 index 0000000000..5249a1ba91 --- /dev/null +++ b/src/Order/Conditions/ReferenceConditionRule.php @@ -0,0 +1,21 @@ +getCondition(); + + return app(ShippingZones::class)->getAllShippingZones($condition->storeId)->mapWithKeys(fn(ShippingAddressZone $zone) => [$zone->id => $zone->name])->all(); + } + + #[Override] + public function modifyQuery(ElementQueryInterface $query): void + { + throw new LogicException('Shipping Address Zone condition rule does not support queries'); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var ShippingRuleOrderCondition $condition */ + $condition = $this->getCondition(); + /** @var Order $element */ + $shippingAddress = $element->getShippingAddress() ?? $element->getEstimatedShippingAddress(); + + if (!$shippingAddress) { + return false; + } + + /** @var ShippingAddressZone[] $shippingZones */ + $shippingZones = app(ShippingZones::class)->getAllShippingZones($condition->storeId)->whereIn('id', $this->getValues())->all(); + + // Start on `true` or `false` depending on the operator + $match = $this->operator !== self::OPERATOR_IN; + foreach ($shippingZones as $shippingZone) { + if ($shippingZone->getCondition()->matchElement($shippingAddress)) { + $match = $this->operator === self::OPERATOR_IN; + break; + } + } + + return $match; + } +} diff --git a/src/Order/Conditions/ShippingMethodConditionRule.php b/src/Order/Conditions/ShippingMethodConditionRule.php new file mode 100644 index 0000000000..3ff90de4c4 --- /dev/null +++ b/src/Order/Conditions/ShippingMethodConditionRule.php @@ -0,0 +1,46 @@ +getAllShippingMethods()->mapWithKeys(fn(BaseShippingMethod $method) => [$method->handle => $method->name])->all(); + } + + public function modifyQuery(ElementQueryInterface $query): void + { + /** @var OrderQuery $query */ + $query->shippingMethodHandle($this->paramValue()); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Order $element */ + return $this->matchValue($element->shippingMethodHandle); + } +} diff --git a/src/Order/Conditions/ShippingMethodOrderCondition.php b/src/Order/Conditions/ShippingMethodOrderCondition.php new file mode 100644 index 0000000000..ed92451ac3 --- /dev/null +++ b/src/Order/Conditions/ShippingMethodOrderCondition.php @@ -0,0 +1,64 @@ + ['nullable', 'integer'], + ]); + } + + #[Override] + protected function config(): array + { + return array_merge(parent::config(), ['storeId' => $this->storeId]); + } + + #[Override] + public function modifyQuery(ElementQueryInterface $query): void + { + throw new LogicException('Shipping Method Order Condition does not support queries'); + } + + #[Override] + protected function selectableConditionRules(): array + { + $ruleTypes = parent::selectableConditionRules(); + + foreach ($ruleTypes as $key => $ruleType) { + if (in_array($ruleType, [ + CompletedConditionRule::class, + DateOrderedConditionRule::class, + PaidConditionRule::class, + OrderStatusConditionRule::class, + ShippingMethodConditionRule::class, + TotalPaidConditionRule::class, + ])) { + unset($ruleTypes[$key]); + } + } + + $ruleTypes[] = DiscountedItemSubtotalConditionRule::class; + $ruleTypes[] = ShippingAddressZoneConditionRule::class; + + return $ruleTypes; + } +} diff --git a/src/Order/Conditions/ShippingRuleOrderCondition.php b/src/Order/Conditions/ShippingRuleOrderCondition.php new file mode 100644 index 0000000000..d47c9defe9 --- /dev/null +++ b/src/Order/Conditions/ShippingRuleOrderCondition.php @@ -0,0 +1,64 @@ + ['nullable', 'integer'], + ]); + } + + #[Override] + protected function config(): array + { + return array_merge(parent::config(), ['storeId' => $this->storeId]); + } + + #[Override] + public function modifyQuery(ElementQueryInterface $query): void + { + throw new LogicException('Shipping Rule Order Condition does not support queries'); + } + + #[Override] + protected function selectableConditionRules(): array + { + $ruleTypes = parent::selectableConditionRules(); + + foreach ($ruleTypes as $key => $ruleType) { + if (in_array($ruleType, [ + CompletedConditionRule::class, + DateOrderedConditionRule::class, + PaidConditionRule::class, + OrderStatusConditionRule::class, + ShippingMethodConditionRule::class, + TotalPaidConditionRule::class, + ])) { + unset($ruleTypes[$key]); + } + } + + $ruleTypes[] = DiscountedItemSubtotalConditionRule::class; + $ruleTypes[] = ShippingAddressZoneConditionRule::class; + + return $ruleTypes; + } +} diff --git a/src/Order/Conditions/TotalConditionRule.php b/src/Order/Conditions/TotalConditionRule.php new file mode 100644 index 0000000000..498a743ecb --- /dev/null +++ b/src/Order/Conditions/TotalConditionRule.php @@ -0,0 +1,21 @@ + t('equals'), + self::OPERATOR_NE => t('does not equal'), + self::OPERATOR_GT => t('is less than'), + self::OPERATOR_GTE => t('is less than or equals'), + self::OPERATOR_LT => t('is greater than'), + self::OPERATOR_LTE => t('is greater than or equals'), + default => $operator, + }; + } + + #[Override] + protected function paramValue(): ?string + { + if ($this->value === '') { + return null; + } + + $value = $this->value; + if (is_numeric($value)) { + $value = (string)((float)$value * -1); + } + + $value = Query::escapeParam($value); + + return "$this->operator $value"; + } + + #[Override] + protected function matchValue(mixed $value): bool + { + if ($this->value === '') { + return true; + } + + $ruleValue = $this->value; + if (is_numeric($ruleValue)) { + $ruleValue = (float)$ruleValue * -1; + } + + return match ($this->operator) { + self::OPERATOR_EQ => $value == $ruleValue, + self::OPERATOR_NE => $value != $ruleValue, + self::OPERATOR_LT => $value < $ruleValue, + self::OPERATOR_LTE => $value <= $ruleValue, + self::OPERATOR_GT => $value > $ruleValue, + self::OPERATOR_GTE => $value >= $ruleValue, + default => throw new RuntimeException("Invalid operator: $this->operator"), + }; + } +} diff --git a/src/Order/Conditions/TotalPaidConditionRule.php b/src/Order/Conditions/TotalPaidConditionRule.php new file mode 100644 index 0000000000..f92965d23f --- /dev/null +++ b/src/Order/Conditions/TotalPaidConditionRule.php @@ -0,0 +1,21 @@ +orderLanguage === null) { + $this->orderLanguage = app()->getLocale(); + } + + if ($this->storeId === null) { + $this->storeId = app(Stores::class)->getCurrentStore()->id; + } + + if ($this->orderSiteId === null) { + $storeSites = $this->getStore()->getSites(); + $primarySite = Sites::getPrimarySite(); + // Prefer the Craft primary site if it belongs to this store, otherwise use the first available site + /** @phpstan-ignore-next-line nullsafe.neverNull (firstWhere() genuinely can return null if no site matches) */ + $this->orderSiteId = $storeSites->firstWhere('id', $primarySite->id)?->id ?? $storeSites->first()->id; + } + + if ($this->currency === null) { + $this->currency = $this->getStore()->getCurrency()?->getCode(); + } + + // Better default for carts if the base currency changes (usually only happens in development) + if (!$this->isCompleted && $this->paymentCurrency && !app(PaymentCurrencies::class)->getPaymentCurrencyByIso($this->paymentCurrency, $this->getStore()->id)) { + $this->paymentCurrency = app(PaymentCurrencies::class)->getPrimaryPaymentCurrencyIso($this->getStore()->id); + } + + if ($this->origin === null) { + $this->origin = static::ORIGIN_WEB; + } + + if ($this->_recalculationMode === null) { + if ($this->isCompleted) { + $this->setRecalculationMode(self::RECALCULATION_MODE_NONE); + } else { + $this->setRecalculationMode(self::RECALCULATION_MODE_ALL); + } + } + } + + public static function displayName(): string + { + return t('Order', category: 'commerce'); + } + + #[Override] + public static function lowerDisplayName(): string + { + return t('order', category: 'commerce'); + } + + #[Override] + public static function pluralDisplayName(): string + { + return t('Orders', category: 'commerce'); + } + + #[Override] + public static function pluralLowerDisplayName(): string + { + return t('orders', category: 'commerce'); + } + + #[Override] + public function __toString(): string + { + return $this->reference ?: $this->getShortNumber(); + } + + #[Override] + public function canSave(\CraftCms\Cms\User\Elements\User $user): bool + { + return parent::canSave($user) || $user->can('commerce-editOrders'); + } + + #[Override] + public function canView(\CraftCms\Cms\User\Elements\User $user): bool + { + return parent::canView($user) || $user->can('commerce-manageOrders'); + } + + #[Override] + public function canDuplicate(\CraftCms\Cms\User\Elements\User $user): bool + { + return false; + } + + #[Override] + public function canDelete(\CraftCms\Cms\User\Elements\User $user): bool + { + return parent::canDelete($user) || $user->can('commerce-deleteOrders'); + } + + /** + * The new validation system has no `beforeValidate(): bool` hook — the equivalent + * pre-validation mutation point is `prepareForValidation()` (Illuminate-style, runs before + * rules are applied, no return value). + */ + #[Override] + public function prepareForValidation(): void + { + // Set default gateway if none present and no payment source selected + if (!$this->gatewayId && !$this->paymentSourceId) { + $gateways = app(Gateways::class)->getAllCustomerEnabledGateways(); + if ($gateways->isNotEmpty()) { + $gateway = $gateways->filter(fn(GatewayInterface $g) => $g->availableForUseWithOrder($this))->first(); + + if ($gateway) { + $this->gatewayId = $gateway->id; + } + } + } + + // If the gateway ID doesn't exist, just drop it. + if ($this->gatewayId && !$this->getGateway()) { + $this->gatewayId = null; + } + } + + /** + * Runs the imperative, side-effecting validators that used to be wired up via `defineRules()`'s + * `[[attributes], 'validateX']` callback syntax. {@see OrderRules} keeps only the handful of + * plain declarative rules; everything else lives here because it mutates notices/errors on + * nested models (addresses, line items) using dotted attribute keys, which doesn't map onto + * Illuminate's rule closures. This is invoked automatically by + * {@see \CraftCms\Cms\Validation\Ruleset::after()}. + */ + #[Override] + public function afterValidate(?Validator $validator = null): void + { + $this->validateAddress('billingAddress'); + $this->validateAddress('shippingAddress'); + $this->validateAddressCountry('billingAddress'); + $this->validateAddressCountry('shippingAddress'); + + if (!$this->isCompleted) { + $this->validateAddressReuse('billingAddress'); + $this->validateAddressReuse('shippingAddress'); + } + + if ($this->getStore()->getValidateOrganizationTaxIdAsVatId() && !$this->getStore()->getUseBillingAddressForTax()) { + $this->validateOrganizationTaxIdAsVatId('shippingAddress'); + } + + if ($this->getStore()->getValidateOrganizationTaxIdAsVatId() && $this->getStore()->getUseBillingAddressForTax()) { + $this->validateOrganizationTaxIdAsVatId('billingAddress'); + } + + $this->validateLineItems(); + $this->validateCouponCode('couponCode'); + $this->validateGatewayId('gatewayId'); + $this->validatePaymentCurrency('paymentCurrency'); + $this->validatePaymentSourceId('paymentSourceId'); + } + + #[Override] + public function attributes(): array + { + $names = parent::attributes(); + $names[] = 'adjustmentSubtotal'; + $names[] = 'adjustmentsTotal'; + $names[] = 'customer'; + $names[] = 'customerId'; + $names[] = 'customerDeleted'; + $names[] = 'paymentCurrency'; + $names[] = 'paymentAmount'; + $names[] = 'isPaid'; + $names[] = 'itemSubtotal'; + $names[] = 'itemTotal'; + $names[] = 'lineItems'; + $names[] = 'orderAdjustments'; + $names[] = 'outstandingBalance'; + $names[] = 'paidStatus'; + $names[] = 'recalculationMode'; + $names[] = 'shortNumber'; + $names[] = 'totalPaid'; + $names[] = 'total'; + $names[] = 'totalPrice'; + $names[] = 'totalQty'; + $names[] = 'totalPromotionalAmount'; + $names[] = 'totalWeight'; + return $names; + } + + /** + * The attributes on the order that should be made available as formatted currency. + */ + public function currencyAttributes(): array + { + return [ + 'adjustmentSubtotal', + 'adjustmentsTotal', + 'itemSubtotal', + 'itemTotal', + 'outstandingBalance', + 'paymentAmount', + 'totalPaid', + 'total', + 'totalPrice', + 'totalPromotionalAmount', + 'totalTax', + 'totalTaxIncluded', + 'totalShippingCost', + 'totalDiscount', + 'storedTotal', + 'storedTotalPrice', + 'storedTotalPaid', + 'storedItemTotal', + 'storedItemSubtotal', + 'storedTotalShippingCost', + 'storedTotalDiscount', + 'storedTotalTax', + 'storedTotalTaxIncluded', + ]; + } + + public function getAdjustmentSubtotalAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('adjustmentSubtotal'); + } + + public function getAdjustmentsTotalAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('adjustmentsTotal'); + } + + public function getItemSubtotalAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('itemSubtotal'); + } + + public function getItemTotalAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('itemTotal'); + } + + public function getOutstandingBalanceAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('outstandingBalance'); + } + + public function getPaymentAmountAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('paymentAmount'); + } + + public function getTotalPaidAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('totalPaid'); + } + + public function getTotalAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('total'); + } + + public function getTotalPriceAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('totalPrice'); + } + + public function getTotalPromotionalAmountAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('totalPromotionalAmount'); + } + + /** + * @deprecated in 5.0.0. Use {@see Order::getTotalPromotionalAmountAsCurrency()} instead. + */ + #[\Deprecated(message: 'in 5.0.0. Use [[getTotalPromotionalAmountAsCurrency()]] instead.')] + public function getTotalSaleAmountAsCurrency(): string + { + return $this->getTotalPromotionalAmountAsCurrency(); + } + + public function getTotalTaxAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('totalTax'); + } + + public function getTotalTaxIncludedAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('totalTaxIncluded'); + } + + public function getTotalShippingCostAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('totalShippingCost'); + } + + public function getTotalDiscountAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('totalDiscount'); + } + + public function getStoredTotalAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('storedTotal'); + } + + public function getStoredTotalPriceAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('storedTotalPrice'); + } + + public function getStoredTotalPaidAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('storedTotalPaid'); + } + + public function getStoredItemTotalAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('storedItemTotal'); + } + + public function getStoredItemSubtotalAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('storedItemSubtotal'); + } + + public function getStoredTotalShippingCostAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('storedTotalShippingCost'); + } + + public function getStoredTotalDiscountAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('storedTotalDiscount'); + } + + public function getStoredTotalTaxAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('storedTotalTax'); + } + + public function getStoredTotalTaxIncludedAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('storedTotalTaxIncluded'); + } + + /** + * Mirrors {@see NewPurchasable::_currencyAttributeAsCurrency()}. `CurrencyAttributeBehavior::getDefaultCurrency()` + * (the legacy behaviour this replaces) unconditionally resolves to the owner's *current* store currency + * whenever the owner implements `HasStoreInterface` — not the order's own frozen `currency` attribute — so + * `$this->getStore()->getCurrency()` is the faithful source here, even though it means historic orders are + * formatted using the store's current currency rather than the currency they were placed in. + */ + private function _currencyAttributeAsCurrency(string $attribute): string + { + $amount = $this->$attribute ?? 0; + return Currency::formatAsCurrency($amount, $this->getStore()->getCurrency()); + } + + #[Override] + public function fields(): array + { + $fields = parent::fields(); + + $datetimeAttributes = ComponentHelper::datetimeAttributes($this); + + // @todo Commerce 6 - remove this and let the parent handle ISO-8601 serialization; update Vue components + // (OrderMeta.vue, DateOrderedInput.vue) to parse/format dates from ISO-8601 using the JS Intl API instead. + foreach ($datetimeAttributes as $attribute) { + $fields[$attribute] = static function($model, $attribute) { + if (!empty($model->$attribute)) { + $formatter = I18N::getFormatter(); + + return [ + 'date' => $formatter->asDate($model->$attribute, Locale::LENGTH_SHORT), + 'time' => $formatter->asTime($model->$attribute, Locale::LENGTH_SHORT), + ]; + } + + return $model->$attribute; + }; + } + + $fields['email'] = 'email'; + $fields['paidStatusHtml'] = 'paidStatusHtml'; + $fields['customerLinkHtml'] = 'customerLinkHtml'; + $fields['orderStatusHtml'] = 'orderStatusHtml'; + $fields['totalTax'] = 'totalTax'; + $fields['totalTaxIncluded'] = 'totalTaxIncluded'; + $fields['totalShippingCost'] = 'totalShippingCost'; + $fields['totalDiscount'] = 'totalDiscount'; + + // @TODO Remove these deprecated `totalSaleAmount` aliases in Commerce 6.0 + $fields['totalSaleAmount'] = 'totalPromotionalAmount'; + $fields['totalSaleAmountAsCurrency'] = 'totalPromotionalAmountAsCurrency'; + + return $fields; + } + + #[Override] + public function extraFields(): array + { + $names = parent::extraFields(); + $names[] = 'adjustments'; + $names[] = 'availableShippingMethodOptions'; + $names[] = 'billingAddress'; + $names[] = 'customer'; + $names[] = 'estimatedBillingAddress'; + $names[] = 'estimatedShippingAddress'; + $names[] = 'gateway'; + $names[] = 'histories'; + $names[] = 'loadCartUrl'; + $names[] = 'nestedTransactions'; + $names[] = 'adminNotices'; + $names[] = 'notices'; + $names[] = 'orderSite'; + $names[] = 'orderStatus'; + $names[] = 'pdfUrl'; + $names[] = 'shippingAddress'; + $names[] = 'shippingMethod'; + $names[] = 'store'; + $names[] = 'totalCommittedStock'; + $names[] = 'transactions'; + return $names; + } + + public function getTeller(): Teller + { + return app(Currencies::class)->getTeller($this->currency); + } + + /** + * Automatically set addresses on the order if it's a cart and `autoSetNewCartAddresses` is `true`. + * + * @return bool returns true if order is mutated + */ + public function autoSetAddresses(): bool + { + if ($this->isCompleted || !$this->getStore()->getAutoSetNewCartAddresses()) { + return false; + } + + $user = $this->getCustomer(); + if (!$user) { + return false; + } + + $autoSetOccurred = false; + + /** @phpstan-ignore-next-line method.notFound (getPrimaryShippingAddress() is added to User via a Macroable macro registered in Plugin::registerCustomerMacros(), not visible to static analysis) */ + if (!$this->_shippingAddress && !$this->shippingAddressId && $primaryShippingAddress = $user->getPrimaryShippingAddress()) { + /** @var AddressElement $primaryShippingAddress */ + $this->sourceShippingAddressId = $primaryShippingAddress->id; + /** @var AddressElement $shippingAddress */ + $shippingAddress = Elements::duplicateElement($primaryShippingAddress, [ + 'owner' => $this, + 'primaryOwner' => $this, + ]); + $this->setShippingAddress($shippingAddress); + $autoSetOccurred = true; + } + + /** @phpstan-ignore-next-line method.notFound (getPrimaryBillingAddress() is added to User via a Macroable macro registered in Plugin::registerCustomerMacros(), not visible to static analysis) */ + if (!$this->_billingAddress && !$this->billingAddressId && $primaryBillingAddress = $user->getPrimaryBillingAddress()) { + /** @var AddressElement $primaryBillingAddress */ + $this->sourceBillingAddressId = $primaryBillingAddress->id; + /** @var AddressElement $billingAddress */ + $billingAddress = Elements::duplicateElement($primaryBillingAddress, [ + 'owner' => $this, + 'primaryOwner' => $this, + ]); + $this->setBillingAddress($billingAddress); + $autoSetOccurred = true; + } + + return $autoSetOccurred; + } + + public function autoSetPaymentSource(): bool + { + if ($this->isCompleted || !$this->getStore()->getAutoSetPaymentSource() || $this->paymentSourceId || $this->gatewayId) { + return false; + } + + $customer = $this->getCustomer(); + + // Only set the payment source if there is a customer set and that is it the current user + if (!$customer || $customer->id !== currentUser()?->getCraftUserId()) { + return false; + } + + /** @phpstan-ignore-next-line method.notFound (getPrimaryPaymentSource() is added to User via a Macroable macro registered in Plugin::registerCustomerMacros(), not visible to static analysis) */ + $paymentSource = $customer->getPrimaryPaymentSource(); + if (!$paymentSource) { + return false; + } + + $this->setPaymentSource($paymentSource); + return true; + } + + /** + * Auto set shipping method based on config settings and available options. + * + * @return bool returns true if order is mutated + */ + public function autoSetShippingMethod(): bool + { + if ($this->shippingMethodHandle || $this->isCompleted || !$this->getStore()->getAutoSetCartShippingMethodOption()) { + return false; + } + + $availableMethodOptions = $this->getAvailableShippingMethodOptions(); + if (empty($availableMethodOptions)) { + return false; + } + + $this->shippingMethodHandle = array_key_first($availableMethodOptions); + + return true; + } + + /** + * Updates the paid status and paid date of the order, and marks as complete if the order is paid or authorized. + */ + public function updateOrderPaidInformation(): void + { + $this->_transactions = null; // clear order's transaction cache + + $paidInFull = !$this->hasOutstandingBalance(); + $authorizedInFull = $this->getTotalAuthorized() >= $this->getTotalPrice(); + + $justPaid = $paidInFull && $this->datePaid == null; + $justAuthorized = $authorizedInFull && $this->dateAuthorized == null; + + $completeTotal = $this->getTeller()->add($this->getTotalAuthorized(), $this->getTotalPaid()); + $canComplete = $this->getTeller()->greaterThan($completeTotal, 0); + + // If it is no longer paid in full, set datePaid to null + if (!$paidInFull) { + $this->datePaid = null; + } + + // If it is no longer authorized in full, set dateAuthorized to null + if (!$authorizedInFull) { + $this->dateAuthorized = null; + } + + // If it was just paid set the date paid to now. + if ($justPaid) { + $this->datePaid = new DateTime(); + } + + // If it was just paid and this is the first time, set the date first paid to now. + if ($justPaid && $this->dateFirstPaid === null) { + $this->dateFirstPaid = new DateTime(); + } + + // If it was just authorized set the date authorized to now. + if ($justAuthorized) { + $this->dateAuthorized = new DateTime(); + } + + // Lock for recalculation + $originalRecalculationMode = $this->getRecalculationMode(); + $this->setRecalculationMode(self::RECALCULATION_MODE_NONE); + + // Saving the order will update the datePaid as set above and also update the paidStatus. + Elements::saveElement($this, false); + + // If the order is now paid or authorized in full, lets mark it as complete if it has not already been. + if (!$this->isCompleted) { + $totalAuthorized = $this->getTotalAuthorized(); + if ($totalAuthorized >= $this->getTotalPrice() || $paidInFull || $canComplete) { + // We need to remove the payment source from the order now that it's paid + // This means the order needs new payment details for future payments: https://github.com/craftcms/commerce/issues/891 + // Payment information is still stored in the transactions. + $this->paymentSourceId = null; + + $this->markAsComplete(); + } + } + + if ($justPaid && $this->hasEventHandlers(self::EVENT_AFTER_ORDER_PAID)) { + $this->trigger(self::EVENT_AFTER_ORDER_PAID); + } + + if ($justAuthorized && $this->hasEventHandlers(self::EVENT_AFTER_ORDER_AUTHORIZED)) { + $this->trigger(self::EVENT_AFTER_ORDER_AUTHORIZED); + } + + // Restore the original recalculation mode, unless this call completed the order + // a completed order must stay locked at `RECALCULATION_MODE_NONE` rather than reverting to its cart mode. + if (!$this->isCompleted) { + $this->setRecalculationMode($originalRecalculationMode); + } + } + + /** + * Marks the order as complete and sets the default order status, then saves the order. + * + * @throws OrderStatusException + * @throws \Exception + * @throws Throwable + */ + public function markAsComplete(): bool + { + // Use a lock to make sure we check the order is not already complete due to a race condition. + $lockName = 'orderComplete:' . $this->id; + $lock = Cache::lock($lockName, 30); + try { + $lock->block(5); + } catch (LockTimeoutException) { + throw new \Exception('Unable to acquire a lock for completion of Order: ' . $this->id); + } + + // Now that we have a lock, make sure this order is not already completed. + if ($this->isCompleted) { + $lock->release(); + return true; + } + + // Try to catch where the order could be marked as completed twice at the same time, and thus cause a race condition. + $completedInDb = OrderRecord::query()->where('isCompleted', true)->where('id', $this->id)->exists(); + + if ($completedInDb) { + $lock->release(); + return true; + } + + $this->isCompleted = true; + $this->dateOrdered = new DateTime(); + + // Reset estimated address relations + $this->estimatedShippingAddressId = null; + $this->estimatedBillingAddressId = null; + $this->orderCompletedEmail = $this->getEmail(); + + $orderStatus = app(OrderStatuses::class)->getDefaultOrderStatusForOrder($this); + + // If the order status returned was overridden by a plugin, use the configured default order status if they give us a bogus one with no ID. + if ($orderStatus && $orderStatus->id) { + $this->orderStatusId = $orderStatus->id; + } else { + $lock->release(); + throw new OrderStatusException('Could not find a valid default order status.'); + } + + if ($this->reference == null) { + $referenceTemplate = $this->getStore()->getOrderReferenceFormat(); + + try { + // Replaces the legacy `renderSandboxedObjectTemplate()`; object-template rendering is sandboxed by default. + $baseReference = renderObjectTemplate($referenceTemplate, $this); + + // Check if this reference already exists and append suffix if needed + $suffix = 0; + $testReference = $baseReference; + + while (true) { + $existingReference = OrderRecord::query()->where('reference', $testReference)->exists(); + + if (!$existingReference) { + // Reference is unique, use it + $this->reference = $testReference; + break; + } + + // Reference exists, increment suffix and try again + $suffix++; + $testReference = $baseReference . '-' . $suffix; + } + } catch (Throwable $exception) { + $lock->release(); + Log::error('Unable to generate order completion reference for order ID: ' . $this->id . ', with format: ' . $referenceTemplate . ', error: ' . $exception->getMessage()); + throw $exception; + } + } + + // Raising the 'beforeCompleteOrder' event + if ($this->hasEventHandlers(self::EVENT_BEFORE_COMPLETE_ORDER)) { + $this->trigger(self::EVENT_BEFORE_COMPLETE_ORDER); + } + + // Completed orders should no longer recalculate anything by default + $this->setRecalculationMode(static::RECALCULATION_MODE_NONE); + + $this->clearNotices(); // Customer notices are assessed as being delivered once the customer decides to complete the order. + $success = Elements::saveElement($this, false); + + if (!$success) { + Log::error(t('Could not mark order {number} as complete. Order save failed during order completion with errors: {order}', [ + 'number' => $this->number, + 'order' => json_encode($this->errors()->getMessages()), + ], category: 'commerce')); + + $lock->release(); + return false; + } + + $lock->release(); + + $this->afterOrderComplete(); + + return true; + } + + /** + * Called after the order successfully completes. + */ + public function afterOrderComplete(): void + { + // Run order complete handlers directly. + app(Discounts::class)->orderCompleteHandler($this); + app(Customers::class)->orderCompleteHandler($this); + app(Inventory::class)->orderCompleteHandler($this); + + foreach ($this->getLineItems() as $lineItem) { + app(LineItems::class)->orderCompleteHandler($lineItem, $this); + } + + // Persist any admin notices added by the handlers above. + $this->_saveNotices(); + + // Raising the 'afterCompleteOrder' event + if ($this->hasEventHandlers(self::EVENT_AFTER_COMPLETE_ORDER)) { + $this->trigger(self::EVENT_AFTER_COMPLETE_ORDER); + } + } + + /** + * Removes a specific line item from the order. + */ + public function removeLineItem(LineItem $lineItem): void + { + $lineItems = $this->getLineItems(); + foreach ($lineItems as $key => $item) { + if (($item->id !== null && $lineItem->id == $item->id) || $lineItem === $item) { + unset($lineItems[$key]); + $this->setLineItems($lineItems); + } + } + + if ($this->hasEventHandlers(self::EVENT_AFTER_REMOVE_LINE_ITEM)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $this->trigger(self::EVENT_AFTER_REMOVE_LINE_ITEM, new LineItemEvent( + lineItem: $lineItem, + )); + } + } + + /** + * Adds a line item to the order. Updates the line item if the ID of that line item is already in the cart. + */ + public function addLineItem(LineItem $lineItem): void + { + $lineItems = $this->getLineItems(); + $isNew = ($lineItem->id === null); + + if ($isNew && $this->hasEventHandlers(self::EVENT_BEFORE_ADD_LINE_ITEM)) { + $lineItemEvent = new AddLineItemEvent(lineItem: $lineItem, isNew: $isNew); + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $this->trigger(self::EVENT_BEFORE_ADD_LINE_ITEM, $lineItemEvent); + + if (!$lineItemEvent->isValid) { + return; + } + } + + $replaced = false; + foreach ($lineItems as $key => $item) { + if ($lineItem->id && $item->id == $lineItem->id) { + $lineItems[$key] = $lineItem; + $replaced = true; + } + } + + if (!$replaced) { + array_unshift($lineItems, $lineItem); + } + + $this->setLineItems($lineItems); + + // Raising the 'afterAddLineItemToOrder' event + if ($this->hasEventHandlers(self::EVENT_AFTER_ADD_LINE_ITEM)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $this->trigger(self::EVENT_AFTER_ADD_LINE_ITEM, new LineItemEvent( + lineItem: $lineItem, + isNew: !$replaced, + )); + } + } + + /** + * Returns any line item with that purchasable. + */ + public function lineItemsByPurchasable(PurchasableInterface $purchasable): Collection + { + return collect($this->getLineItems()) + ->filter(fn(LineItem $lineItem) => $lineItem->purchasableId == $purchasable->getId()); + } + + /** + * Gets the recalculation mode of the order. + */ + public function getRecalculationMode(): string + { + return $this->_recalculationMode ?? self::RECALCULATION_MODE_ALL; + } + + /** + * Sets the recalculation mode of the order. + */ + public function setRecalculationMode(string $value): void + { + $this->_recalculationMode = $value; + } + + /** + * Regenerates all adjusters and updates line items, depending on the current recalculationMode. + */ + public function recalculate(): void + { + if (!$this->id) { + throw new \BadMethodCallException('Do not recalculate an order that has not been saved'); + } + + if ($this->errors()->isNotEmpty()) { + Log::info(t('Do not call recalculate on the order (Number: {orderNumber}) if errors are present.', ['orderNumber' => $this->number], category: 'commerce')); + return; + } + + if ($this->getRecalculationMode() == self::RECALCULATION_MODE_NONE) { + return; + } + + if ($this->getRecalculationMode() == self::RECALCULATION_MODE_ALL) { + // Make sure we set a default shipping method option + if (!$this->isCompleted && $this->getStore()->getAutoSetCartShippingMethodOption()) { + $availableMethodOptions = $this->getAvailableShippingMethodOptions(); + if (!$this->shippingMethodHandle || !isset($availableMethodOptions[$this->shippingMethodHandle])) { + $this->shippingMethodHandle = array_key_first($availableMethodOptions); + } + } + + if (!$this->shippingMethodHandle) { + $this->shippingMethodName = null; + } else { + $shippingMethod = Arr::first($this->getAvailableShippingMethodOptions(), fn($option) => $option->handle == $this->shippingMethodHandle); + if ($shippingMethod) { + $this->shippingMethodName = $shippingMethod->getName(); + } + } + + $recalculateOrder = false; + if ($this->hasEventHandlers(self::EVENT_BEFORE_LINE_ITEMS_REFRESHED)) { + $event = new OrderLineItemsRefreshEvent( + lineItems: $this->getLineItems(), + recalculate: $recalculateOrder, + ); + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $this->trigger(self::EVENT_BEFORE_LINE_ITEMS_REFRESHED, $event); + + $this->setLineItems($event->lineItems); + $recalculateOrder = $event->recalculate; + } + + foreach ($this->getLineItems() as $item) { + $originalSalePrice = $item->getSalePrice(); + $originalSalePriceAsCurrency = $item->salePriceAsCurrency; + + if ($item->refresh()) { + if ($originalSalePrice > $item->salePrice) { + $message = t('The price of {description} was reduced from {originalSalePriceAsCurrency} to {newSalePriceAsCurrency}', ['originalSalePriceAsCurrency' => $originalSalePriceAsCurrency, 'newSalePriceAsCurrency' => $item->salePriceAsCurrency, 'description' => $item->getDescription()], category: 'commerce'); + $notice = new OrderNotice([ + 'type' => 'lineItemSalePriceChanged', + 'attribute' => "lineItems.$item->id.salePrice", + 'message' => $message, + ]); + $this->addNotice($notice); + } + + if ($originalSalePrice < $item->salePrice) { + $message = t('The price of {description} increased from {originalSalePriceAsCurrency} to {newSalePriceAsCurrency}', ['originalSalePriceAsCurrency' => $originalSalePriceAsCurrency, 'newSalePriceAsCurrency' => $item->salePriceAsCurrency, 'description' => $item->getDescription()], category: 'commerce'); + $notice = new OrderNotice([ + 'type' => 'lineItemSalePriceChanged', + 'attribute' => "lineItems.$item->id.salePrice", + 'message' => $message, + ]); + $this->addNotice($notice); + } + } else { + $message = t('{description} is no longer available.', ['description' => $item->getDescription()], category: 'commerce'); + $notice = new OrderNotice([ + 'message' => $message, + 'type' => 'lineItemRemoved', + 'attribute' => 'lineItems', + ]); + $this->addNotice($notice); + $this->removeLineItem($item); + $recalculateOrder = true; + } + } + + // This is run in a validation, but need to run again incase the options + // data was changed on population of the line item by a plugin. + if (OrderHelper::mergeDuplicateLineItems($this)) { + $recalculateOrder = true; + } + + if ($this->hasEventHandlers(self::EVENT_AFTER_LINE_ITEMS_REFRESHED)) { + $event = new OrderLineItemsRefreshEvent( + lineItems: $this->getLineItems(), + recalculate: $recalculateOrder, + ); + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $this->trigger(self::EVENT_AFTER_LINE_ITEMS_REFRESHED, $event); + + $this->setLineItems($event->lineItems); + $recalculateOrder = $event->recalculate; + } + + if ($recalculateOrder) { + $this->recalculate(); + return; + } + } + + if ($this->getRecalculationMode() == self::RECALCULATION_MODE_ALL || $this->getRecalculationMode() == self::RECALCULATION_MODE_ADJUSTMENTS_ONLY) { + //clear adjustments + $this->setAdjustments([]); + + foreach (app(OrderAdjustments::class)->getAdjusters() as $adjuster) { + /** @var string|\CraftCms\Commerce\Order\Adjuster\Contracts\AdjusterInterface $adjuster */ + $adjuster = app($adjuster); + $adjustments = $adjuster->adjust($this); + $this->setAdjustments(array_merge($this->getAdjustments(), $adjustments)); + } + } + + if ($this->getRecalculationMode() == self::RECALCULATION_MODE_ALL) { + // Since shipping adjusters run on the original price, pre discount, let's recalculate + // if the currently selected shipping method is now not available after adjustments have run. + $availableMethodOptions = $this->getAvailableShippingMethodOptions(); + if ($this->shippingMethodHandle && !isset($availableMethodOptions[$this->shippingMethodHandle])) { + $this->shippingMethodHandle = array_key_first($availableMethodOptions); + $message = t('The previously-selected shipping method is no longer available.', category: 'commerce'); + $orderNotice = new OrderNotice([ + 'type' => 'shippingMethodChanged', + 'attribute' => 'shippingMethodHandle', + 'message' => $message, + ]); + + $this->addNotice($orderNotice); + $this->recalculate(); + } + } + } + + /** + * @return ShippingMethodOption[] + */ + public function getAvailableShippingMethodOptions(): array + { + // Matching will contain the core shipping methods and any plugin dynamically returned shipping methods. + $methods = app(ShippingMethods::class)->getMatchingShippingMethods($this); + $matchingMethodHandles = Arr::pluck($methods, fn(ShippingMethodInterface $sm) => $sm->getHandle()); + + // Get all regular methods and add them to the list, for use only when the order is complete. + if ($this->isCompleted) { + $allShippingMethods = app(ShippingMethods::class)->getAllShippingMethods() + ->keyBy(fn(ShippingMethodInterface $sm) => $sm->getHandle()) + ->filter(fn(ShippingMethodInterface $sm) => $sm->getIsEnabled()) + ->all(); + + $methods = Arr::merge($allShippingMethods, $methods); + } + + $availableShippingMethodOptions = []; + + foreach ($methods as $method) { + $option = new ShippingMethodOption(); + + $storeId = $this->storeId; + + if ($method instanceof ShippingMethod) { + // @TODO Remove this dateCreated/dateUpdated copy in Commerce 6.0 once ShippingMethodOption no longer exposes those attributes + foreach (['dateCreated', 'dateUpdated'] as $attribute) { + $option->$attribute = $method->$attribute; + } + + if ($method->storeId !== $storeId) { + continue; + } + } + + $matchesOrder = in_array($method->getHandle(), $matchingMethodHandles); + $option->setOrder($this); + $option->enabled = $method->getIsEnabled(); + $option->id = $method->getId(); + $option->name = $method->getName(); + $option->handle = $method->getHandle(); + $option->matchesOrder = $matchesOrder; + $option->price = $matchesOrder ? $method->getPriceForOrder($this) : 0; + $option->shippingMethod = $method; + $option->storeId = $storeId; + + // Add all methods if completed, and only the matching methods when it is not completed. + if ($this->isCompleted || $option->matchesOrder) { + $availableShippingMethodOptions[$option->handle] = $option; + } + } + + return $availableShippingMethodOptions; + } + + public function getAvailableGateways(): Collection + { + return app(Gateways::class)->getAllCustomerEnabledGatewaysAndAvailableForUseWithOrder($this); + } + + #[Override] + public function afterSave(bool $isNew): void + { + $lockKey = "order-after-save:$this->number"; + $lock = Cache::lock($lockKey, 30); + try { + $lock->block(15); + } catch (LockTimeoutException) { + throw new MutexException($lockKey, 'Could not acquire a lock to save the order.'); + } + + try { + // Make sure addresses are set before recalculation so that on the next page load + // the correct adjustments and totals are shown + if ($this->shippingSameAsBilling) { + $this->setShippingAddress($this->getBillingAddress()); + } + + if ($this->billingSameAsShipping) { + $this->setBillingAddress($this->getShippingAddress()); + } + + // @TODO Move recalculate() out of afterSave(); saving should not implicitly recalculate, and the always-recalc-on-save-when-incomplete behavior should be opt-in #COM-40 + $this->recalculate(); + + if (!$isNew) { + $orderRecord = OrderRecord::query()->find($this->id); + + if (!$orderRecord) { + throw new \Exception('Invalid order ID: ' . $this->id); + } + } else { + $orderRecord = new OrderRecord(); + $orderRecord->id = $this->id; + } + + $oldStatusId = $orderRecord->orderStatusId; + + $orderRecord->storeId = $this->storeId ?? app(Stores::class)->getCurrentStore()->id; + $orderRecord->number = $this->number; + $orderRecord->reference = $this->reference; + $orderRecord->itemTotal = $this->getItemTotal(); + $orderRecord->itemSubtotal = $this->getItemSubtotal(); + $orderRecord->email = $this->getEmail() ?: ''; + $orderRecord->orderCompletedEmail = $this->orderCompletedEmail; + $orderRecord->isCompleted = $this->isCompleted; + + $dateOrdered = $this->dateOrdered; + if (!$dateOrdered && $orderRecord->isCompleted) { + $dateOrdered = new DateTime(); + } + // Always convert to UTC before storing, whether `dateOrdered` was just defaulted + // above or was already set (e.g. by `markAsComplete()`, which sets it to a raw + // `new DateTime()` in the server's local timezone). + $orderRecord->dateOrdered = Query::prepareDateForDb($dateOrdered); + + $orderRecord->datePaid = $this->datePaid ? Carbon::instance($this->datePaid) : null; + $orderRecord->dateFirstPaid = $this->dateFirstPaid ? Carbon::instance($this->dateFirstPaid) : null; + $orderRecord->dateAuthorized = $this->dateAuthorized ? Carbon::instance($this->dateAuthorized) : null; + $orderRecord->shippingMethodHandle = $this->shippingMethodHandle ?? ''; + $orderRecord->shippingMethodName = $this->shippingMethodName ?? ''; + $orderRecord->paymentSourceId = $this->getPaymentSource() ? $this->getPaymentSource()->id : null; + $orderRecord->gatewayId = $this->gatewayId; + $orderRecord->orderStatusId = $this->orderStatusId; + $orderRecord->couponCode = $this->couponCode; + $orderRecord->total = $this->getTotal(); + $orderRecord->totalPrice = $this->getTotalPrice(); + $orderRecord->totalPaid = $this->getTotalPaid(); + $orderRecord->totalDiscount = $this->getTotalDiscount(); + $orderRecord->totalShippingCost = $this->getTotalShippingCost(); + $orderRecord->totalTax = $this->getTotalTax(); + $orderRecord->totalTaxIncluded = $this->getTotalTaxIncluded(); + $orderRecord->totalQty = $this->getTotalQty(); + $orderRecord->totalWeight = $this->getTotalWeight(); + $orderRecord->currency = $this->currency; + $orderRecord->lastIp = $this->lastIp; + $orderRecord->orderLanguage = $this->orderLanguage; + $orderRecord->orderSiteId = $this->orderSiteId; + $orderRecord->origin = $this->origin; + $orderRecord->paymentCurrency = $this->paymentCurrency; + $orderRecord->customerId = $this->getCustomerId(); + $orderRecord->customerDeleted = $this->getCustomerDeleted(); + $orderRecord->registerUserOnOrderComplete = $this->registerUserOnOrderComplete; + $orderRecord->saveBillingAddressOnOrderComplete = $this->saveBillingAddressOnOrderComplete; + $orderRecord->saveShippingAddressOnOrderComplete = $this->saveShippingAddressOnOrderComplete; + $orderRecord->returnUrl = $this->returnUrl; + $orderRecord->cancelUrl = $this->cancelUrl; + $orderRecord->message = $this->message; + $orderRecord->paidStatus = $this->getPaidStatus(); + $orderRecord->recalculationMode = $this->getRecalculationMode(); + $orderRecord->sourceShippingAddressId = $this->sourceShippingAddressId; + $orderRecord->sourceBillingAddressId = $this->sourceBillingAddressId; + $orderRecord->makePrimaryShippingAddress = $this->makePrimaryShippingAddress; + $orderRecord->makePrimaryBillingAddress = $this->makePrimaryBillingAddress; + + // We want to always have the same date as the element table, based on the logic for updating these in the element service i.e resaving + $orderRecord->dateUpdated = $this->dateUpdated ? Carbon::instance($this->dateUpdated) : Carbon::now(); + $orderRecord->dateCreated = $this->dateCreated ? Carbon::instance($this->dateCreated) : Carbon::now(); + + $currentUser = currentUser(); + $currentUserIsCustomer = ($currentUser && $this->getCustomer() && $currentUser->getCraftUserId() == $this->getCustomer()->id); + + if ($shippingAddress = $this->getShippingAddress()) { + // If we only set the owner ID an element query will be triggered. If this is a brand-new order we will encounter an error + // This is because the order record has not been saved. + // We can avoid this by simply fully setting the owner on the address element. This is also a performance optimisation to avoid an extra query. + $shippingAddress->setPrimaryOwner($this); // Always ensure the address is owned by the order + $shippingAddress->title = t('Shipping Address', category: 'commerce'); // Ensure the address is labelled correctly + Elements::saveElement($shippingAddress, false); + $orderRecord->shippingAddressId = $shippingAddress->id; + $this->setShippingAddress($shippingAddress); + // Set primary shipping if asked + if ($this->makePrimaryShippingAddress && $currentUserIsCustomer && $this->sourceShippingAddressId) { + app(Customers::class)->savePrimaryShippingAddressId($this->getCustomer(), $this->sourceShippingAddressId); + } + } else { + $orderRecord->shippingAddressId = null; + $this->setShippingAddress(null); + } + + if ($billingAddress = $this->getBillingAddress()) { + // If these were set to the same address element, we don't want the same address IDs + if ($shippingAddress && $billingAddress->id == $shippingAddress->id) { + /** @var AddressElement $billingAddress */ + $billingAddress = Elements::duplicateElement($billingAddress, + ['owner' => $this, 'title' => t('Billing Address', category: 'commerce')]); + } else { + // If we only set the owner ID an element query will be triggered. If this is a brand-new order we will encounter an error + // This is because the order record has not been saved. + // We can avoid this by simply fully setting the owner on the address element. This is also a performance optimisation to avoid an extra query. + $billingAddress->setOwner($this); // Always ensure the address is owned by the order + $billingAddress->title = t('Billing Address', category: 'commerce'); // Ensure the address is labelled correctly + Elements::saveElement($billingAddress, false); + } + + $orderRecord->billingAddressId = $billingAddress->id; + $this->setBillingAddress($billingAddress); + // Set primary billing if asked + if ($this->makePrimaryBillingAddress && $currentUserIsCustomer && $this->sourceBillingAddressId) { + app(Customers::class)->savePrimaryBillingAddressId($this->getCustomer(), $this->sourceBillingAddressId); + } + } else { + $orderRecord->billingAddressId = null; + $this->setBillingAddress(null); + } + + if ($estimatedShippingAddress = $this->getEstimatedShippingAddress()) { + // If we only set the owner ID an element query will be triggered. If this is a brand-new order we will encounter an error + // This is because the order record has not been saved. + // We can avoid this by simply fully setting the owner on the address element. This is also a performance optimisation to avoid an extra query. + $estimatedShippingAddress->setPrimaryOwner($this); // Always ensure the address is owned by the order + Elements::saveElement($estimatedShippingAddress, false); + $orderRecord->estimatedShippingAddressId = $estimatedShippingAddress->id; + $this->setEstimatedShippingAddress($estimatedShippingAddress); + + // If estimate billing same as shipping set it here + if ($this->estimatedBillingSameAsShipping) { + $orderRecord->estimatedBillingAddressId = $estimatedShippingAddress->id; + $this->setEstimatedBillingAddress($estimatedShippingAddress); + } + } + + if (!$this->estimatedBillingSameAsShipping && $estimatedBillingAddress = $this->getEstimatedBillingAddress()) { + // If we only set the owner ID an element query will be triggered. If this is a brand-new order we will encounter an error + // This is because the order record has not been saved. + // We can avoid this by simply fully setting the owner on the address element. This is also a performance optimisation to avoid an extra query. + $estimatedBillingAddress->setOwner($this); // Always ensure the address is owned by the order + Elements::saveElement($estimatedBillingAddress, false); + $orderRecord->estimatedBillingAddressId = $estimatedBillingAddress->id; + $this->setEstimatedBillingAddress($estimatedBillingAddress); + } + + $orderRecord->save(); + + $this->_saveAdjustments(); + $this->_saveLineItems(); + $this->_saveNotices(); + $this->_deleteOrphanedOrderAddresses(); + } catch (\Exception $exception) { + $lock->release(); + throw $exception; + } + + $lock->release(); + + // We can do this after the lock + $this->_saveOrderHistory($oldStatusId, $orderRecord->orderStatusId); + + parent::afterSave($isNew); + } + + public function getShortNumber(): string + { + return substr((string) $this->number, 0, 7); + } + + public function getLink(?string $title = null, array $options = []): ?HtmlString + { + if ($title) { + $options['title'] = $title; + } + + $title = $title ?: ($this->reference ?: $this->getShortNumber()); + $link = Html::a($title, $this->getCpEditUrl(), $options); + + return new HtmlString($link); + } + + #[Override] + public function getCpEditUrl(): ?string + { + return Url::cpUrl('commerce/orders/' . $this->id); + } + + /** + * Returns the URL to the order's PDF invoice. + * + * @param string|null $option The option that should be available to the PDF template (e.g. "receipt") + * @param string|null $pdfHandle The handle of the PDF to use. If none is passed the default PDF is used. + * @param bool $inline Whether the PDF should be displayed inline in the browser (default: false) + * @return string The URL to the order's PDF invoice with a secure token + */ + public function getPdfUrl(?string $option = null, ?string $pdfHandle = null, bool $inline = false): string + { + return app(Pdfs::class)->getPdfUrl($this, $option, $pdfHandle, $inline); + } + + /** + * Returns the URL to the cart's load action url with a secure token. + * + * @return string|null The URL to the order's load cart URL, or null if the cart is an order + */ + public function getLoadCartUrl(): ?string + { + if ($this->isCompleted) { + return null; + } + + return app(Carts::class)->getLoadCartUrl($this); + } + + public function getCustomerId(): ?int + { + return $this->_customerId; + } + + /** + * @param int|int[]|null $customerId + */ + public function setCustomerId(mixed $customerId): void + { + if (is_array($customerId)) { + $this->_customerId = reset($customerId) ?: null; + } else { + $this->_customerId = $customerId; + } + + $this->_customer = null; + } + + public function getCustomerDeleted(): bool + { + return $this->_customerDeleted && !$this->getCustomerId(); + } + + public function setCustomerDeleted(bool $customerDeleted): void + { + $this->_customerDeleted = $customerDeleted; + } + + /** + * Returns the order's customer. + */ + public function getCustomer(): ?User + { + if (!isset($this->_customer)) { + if (!$this->getCustomerId()) { + return null; + } + + if (($this->_customer = Users::getUserById($this->getCustomerId())) === null) { + $this->_customer = false; + } + } + + return $this->_customer ?: null; + } + + /** + * Sets the order's customer. + */ + public function setCustomer(?User $customer = null): void + { + $this->_customer = $customer; + if ($this->_customer) { + $this->_customerId = $this->_customer->id; + } else { + $this->_customerId = null; + } + } + + #[\Deprecated(message: 'in 4.0.0. Use [[getCustomer()]] instead.')] + public function getUser(): ?User + { + Deprecator::log('Order::getUser()', 'The `Order::getUser()` is deprecated, use `Order::getCustomer()` instead.'); + return $this->getCustomer(); + } + + /** + * Sets the orders user based on the email address provided. + */ + #[\Deprecated(message: 'in 4.3.0. Use [[setCustomer()]] instead.')] + public function setEmail(?string $email): void + { + Deprecator::log(__METHOD__, '`Order::setEmail()` has been deprecated use `Order::setCustomer()` instead.'); + if (!$email) { + $this->_customer = null; + $this->_customerId = null; + return; + } + + if ($this->_customer && $this->_customer->email === $email) { + return; + } + + $user = Users::ensureUserByEmail($email); + $this->setCustomer($user); + } + + /** + * Returns the email for this order. Will always be the customer's email if they exist. + */ + public function getEmail(): ?string + { + /** @phpstan-ignore-next-line nullsafe.neverNull (getCustomer() genuinely returns ?User) */ + return $this->getCustomer()?->email ?? $this->email ?? null; + } + + /** + * Returns a masked version of the email for this order. + */ + public function getMaskedEmail(): string + { + if ($email = $this->getEmail()) { + return $this->_maskEmail($email); + } + + return ''; + } + + private function _maskEmail($email, $minLength = 3, $maxLength = 10, $mask = "***") + { + $atPos = strrpos((string) $email, "@"); + $name = substr((string) $email, 0, $atPos); + $len = strlen($name); + $domain = substr((string) $email, $atPos); + + if (($len / 2) < $maxLength) { + $maxLength = ($len / 2); + } + + $shortenedEmail = (($len > $minLength) ? substr($name, 0, $maxLength) : ""); + return "{$shortenedEmail}{$mask}{$domain}"; + } + + public function getIsPaid(): bool + { + return !$this->hasOutstandingBalance() && $this->isCompleted; + } + + public function getIsUnpaid(): bool + { + return $this->hasOutstandingBalance(); + } + + /** + * Returns the paymentAmount for this order. + */ + public function getPaymentAmount(): float + { + $paymentAmount = $this->getOutstandingBalance(); + + // Only convert if we have differing currencies + if ($this->currency !== $this->getPaymentCurrency()) { + $teller = $this->getTeller(); + $tellerTo = app(Currencies::class)->getTeller($this->getPaymentCurrency()); + $outstandingBalanceAmount = $teller->convertToMoney($this->getOutstandingBalance()); + $outstandingBalanceInPaymentCurrency = app(PaymentCurrencies::class)->convertAmount($outstandingBalanceAmount, $this->getPaymentCurrency(), $this->getStore()->id); + + $paymentAmount = (float)$tellerTo->convertToString($outstandingBalanceInPaymentCurrency); + } + + if (isset($this->_paymentAmount) && $this->_paymentAmount >= 0 && $this->_paymentAmount <= $paymentAmount) { + return $this->_paymentAmount; + } + + return $paymentAmount; + } + + /** + * Sets the order's payment amount in the order's currency. This amount is not persisted. + * This will remain null if set to zero or a negative number. + */ + public function setPaymentAmount(float $amount): void + { + $paymentCurrency = app(PaymentCurrencies::class)->getPaymentCurrencyByIso($this->getPaymentCurrency()); + $amount = Currency::round($amount, $paymentCurrency); + + if ($amount > 0) { + $this->_paymentAmount = $amount; + } + } + + /** + * Returns whether the payment amount currently set is a partial amount of the order's outstanding balance. + */ + public function isPaymentAmountPartial(): bool + { + // NOTE: `PaymentCurrencies::convertCurrency()` was not carried over to the migrated + // `src/Services/PaymentCurrencies.php` (only `convert()`/`convertAmount()` were), so the + // legacy `Plugin::getInstance()->getPaymentCurrencies()` facade is used deliberately here — + // it still implements `convertCurrency()` in terms of the new service's primitives. + $paymentAmountInPrimaryCurrency = Plugin::getInstance()->getPaymentCurrencies()->convertCurrency($this->getPaymentAmount(), $this->getPaymentCurrency(), $this->currency, true); + + return $paymentAmountInPrimaryCurrency < $this->getOutstandingBalance(); + } + + /** + * What is the status of the orders payment. + */ + public function getPaidStatus(): string + { + if ($this->getIsPaid() && + $this->getTeller()->greaterThan($this->getTotalPrice(), 0) && + $this->getTeller()->greaterThan($this->getTotalPaid(), $this->getTotalPrice()) + ) { + return self::PAID_STATUS_OVERPAID; + } + + if ($this->getIsPaid()) { + return self::PAID_STATUS_PAID; + } + + if ($this->getTeller()->greaterThan($this->getTotalPaid(), 0)) { + return self::PAID_STATUS_PARTIAL; + } + + return self::PAID_STATUS_UNPAID; + } + + /** + * Customer User link represented as HTML. + */ + public function getCustomerLinkHtml(): string + { + $html = ''; + if ($user = $this->getCustomer()) { + $email = Html::encode($user->email); + $html = Html::tag('a', $email, ['href' => $user->getCpEditUrl()]); + } + + return $html; + } + + public function getOrderStatusHtml(): string + { + if ($status = $this->getOrderStatus()) { + return $status->getLabelHtml(); + } + + return ''; + } + + /** + * Paid status represented as HTML. + */ + public function getPaidStatusHtml(): string + { + return match ($this->getPaidStatus()) { + self::PAID_STATUS_OVERPAID => app(StatusHtml::class)->statusLabelHtml(['color' => 'blue', 'label' => t('Overpaid', category: 'commerce')]), + self::PAID_STATUS_PAID => app(StatusHtml::class)->statusLabelHtml(['color' => 'green', 'label' => t('Paid', category: 'commerce')]), + self::PAID_STATUS_PARTIAL => app(StatusHtml::class)->statusLabelHtml(['color' => 'orange', 'label' => t('Partial', category: 'commerce')]), + self::PAID_STATUS_UNPAID => app(StatusHtml::class)->statusLabelHtml(['color' => 'red', 'label' => t('Unpaid', category: 'commerce')]), + default => '', + }; + } + + /** + * Returns the raw total of the order, which is the total of all line items and adjustments. This + * number can be negative, so it is not the price of the order. + * + * @see Order::getTotalPrice() The actual total price of the order. + */ + public function getTotal(): float + { + $itemSubtotal = $this->getItemSubtotal(); + $adjustmentsTotal = $this->getAdjustmentsTotal(); + return (float)$this->getTeller()->add($itemSubtotal, $adjustmentsTotal); + } + + /** + * Get the total price of the order, whose minimum value is enforced by the configured + * {@see Store::getMinimumTotalPriceStrategy() strategy set for minimum total price}. + */ + public function getTotalPrice(): float + { + $total = (float)$this->getTeller()->add($this->getItemSubtotal(), $this->getAdjustmentsTotal()); + // Don't get the pre-rounded total. + $strategy = $this->getStore()->getMinimumTotalPriceStrategy(); + + if ($strategy === Store::MINIMUM_TOTAL_PRICE_STRATEGY_ZERO) { + return (float)$this->getTeller()->max(0, $total); + } + + if ($strategy === Store::MINIMUM_TOTAL_PRICE_STRATEGY_SHIPPING) { + return (float)$this->getTeller()->max($this->getTotalShippingCost(), $total); + } + + return $total; + } + + public function getItemTotal(): float + { + $total = 0; + $teller = $this->getTeller(); + foreach ($this->getLineItems() as $lineItem) { + $total = (float)$teller->add($total, $lineItem->getTotal()); + } + + return $total; + } + + public function hasShippableItems(): bool + { + return array_any($this->getLineItems(), fn($item) => $item->getIsShippable()); + } + + /** + * Returns the difference between the order amount and amount paid. + */ + public function getOutstandingBalance(): float + { + return (float)$this->getTeller()->subtract($this->getTotalPrice(), $this->getTotalPaid()); + } + + public function hasOutstandingBalance(): bool + { + return $this->getTeller()->greaterThan($this->getOutstandingBalance(), 0); + } + + /** + * Returns the total `purchase` and `captured` transactions belonging to this order. + */ + public function getTotalPaid(): float + { + if ($this->id === null) { + return 0; + } + + if ($this->_transactions === null) { + $this->_transactions = app(Transactions::class)->getAllTransactionsByOrderId($this->id); + } + + $transactions = collect($this->_transactions); + + $paid = $transactions->filter(fn($transaction) => $transaction->status == TransactionRecord::STATUS_SUCCESS + && in_array($transaction->type, [TransactionRecord::TYPE_PURCHASE, TransactionRecord::TYPE_CAPTURE]))->sum('amount'); + + $refunded = $transactions->filter(fn($transaction) => $transaction->status == TransactionRecord::STATUS_SUCCESS + && $transaction->type == TransactionRecord::TYPE_REFUND)->sum('amount'); + + return (float)$this->getTeller()->subtract($paid, $refunded); + } + + public function getTotalAuthorized(): float + { + if (!$this->id) { + return 0; + } + + $authorized = 0; + $captured = 0; + + if ($this->_transactions === null) { + $this->_transactions = app(Transactions::class)->getAllTransactionsByOrderId($this->id); + } + + foreach ($this->_transactions as $transaction) { + $isSuccess = ($transaction->status == TransactionRecord::STATUS_SUCCESS); + $isAuth = ($transaction->type == TransactionRecord::TYPE_AUTHORIZE); + $isCapture = ($transaction->type == TransactionRecord::TYPE_CAPTURE); + + if (!$isSuccess) { + continue; + } + + if ($isAuth) { + $authorized += $transaction->amount; + continue; + } + + if ($isCapture) { + $captured += $transaction->amount; + } + } + + return (float)$this->getTeller()->subtract($authorized, $captured); + } + + /** + * Returns whether this order is the user's current active cart. + */ + public function getIsActiveCart(): bool + { + $cart = app(Carts::class)->getCart(); + + return $cart->id == $this->id; + } + + /** + * Returns whether the order has any items in it. + */ + public function getIsEmpty(): bool + { + return $this->getTotalQty() == 0; + } + + public function hasLineItems(): bool + { + return (bool)$this->getLineItems(); + } + + /** + * Returns whether the order contains the given purchasable IDs. + * + * @param mixed $purchasableIds One or more purchasable IDs or purchasable models to check for. + * @param ContainsPurchasablesMatch $match The match mode. + */ + public function hasPurchasables(mixed $purchasableIds, ContainsPurchasablesMatch $match = ContainsPurchasablesMatch::Any): bool + { + if (!is_array($purchasableIds)) { + $purchasableIds = [$purchasableIds]; + } + + $orderPurchasableIds = collect($this->getLineItems()) + ->pluck('purchasableId') + ->filter(fn($id) => $id !== null); + + $requestedIds = collect($purchasableIds) + ->map(fn($id) => $id instanceof PurchasableInterface ? $id->getId() : $id) + ->filter(fn($id) => $id !== null); + + if ($match === ContainsPurchasablesMatch::Any) { + return $orderPurchasableIds->intersect($requestedIds)->isNotEmpty(); + } + + if ($match === ContainsPurchasablesMatch::Only) { + // If there are custom line items (null purchasableId), the order + // has purchasables beyond what was specified, so it can't be only. + $hasCustomLineItems = collect($this->getLineItems()) + ->pluck('purchasableId') + ->contains(null); + + if ($hasCustomLineItems) { + return false; + } + + return $orderPurchasableIds->diff($requestedIds)->isEmpty() + && $requestedIds->diff($orderPurchasableIds)->isEmpty(); + } + + // ContainsPurchasablesMatch::All — every requested purchasable must exist in the order + return $requestedIds->every(fn($id) => $orderPurchasableIds->contains($id)); + } + + public function getTotalCommittedStock(): int + { + return app(Inventory::class)->getInventoryFulfillmentLevels($this)->sum('committedQuantity') ?? 0; + } + + /** + * Returns total number of items. + */ + public function getTotalQty(): int + { + $qty = 0; + foreach ($this->getLineItems() as $item) { + $qty += $item->qty; + } + + return $qty; + } + + /** + * @return LineItem[] + */ + public function getLineItems(): array + { + if (!isset($this->_lineItems)) { + $lineItems = $this->id ? app(LineItems::class)->getAllLineItemsByOrderId($this->id) : []; + foreach ($lineItems as $lineItem) { + $lineItem->setOrder($this); + } + $this->_lineItems = $lineItems; + } + + return $this->_lineItems; + } + + /** + * @param LineItem[] $lineItems + */ + public function setLineItems(array $lineItems): void + { + $this->_lineItems = []; + + foreach ($lineItems as $lineItem) { + $lineItem->setOrder($this); + } + + $this->_lineItems = $lineItems; + } + + public function _getAdjustmentsTotalByType(array|string $types, bool $included = false): float|int + { + $amount = 0; + $teller = $this->getTeller(); + + if (is_string($types)) { + $types = preg_split('/\s*,\s*/', $types, -1, PREG_SPLIT_NO_EMPTY); + } + + foreach ($this->getAdjustments() as $adjustment) { + if ($adjustment->included == $included && in_array($adjustment->type, $types, false)) { + $amount = (float)$teller->add($amount, $adjustment->amount); + } + } + + return $amount; + } + + /** + * The total amount of tax adjustments that are additive taxes that affect total price. + */ + public function getTotalTax(): float + { + return $this->_getAdjustmentsTotalByType('tax'); + } + + /** + * The total amount of tax adjustments on the order that are included in the price, and do not affect total price. + */ + public function getTotalTaxIncluded(): float + { + return $this->_getAdjustmentsTotalByType('tax', true); + } + + /** + * The total amount of discount adjustments. + */ + public function getTotalDiscount(): float + { + return $this->_getAdjustmentsTotalByType('discount'); + } + + /** + * The total amount of shipping adjustments. + */ + public function getTotalShippingCost(): float + { + return $this->_getAdjustmentsTotalByType('shipping'); + } + + public function getTotalWeight(): float + { + $weight = 0; + foreach ($this->getLineItems() as $item) { + $weight += ($item->qty * $item->weight); + } + + return $weight; + } + + /** + * Returns the total promotional amount. + */ + public function getTotalPromotionalAmount(): float + { + $value = 0; + $teller = $this->getTeller(); + foreach ($this->getLineItems() as $item) { + $value = (float)$teller->add( + $value, + $teller->multiply($item->qty, $item->getPromotionalAmount()), + ); + } + + return $value; + } + + /** + * Returns the total sale amount. + * + * @deprecated in 5.0.0. Use {@see Order::getTotalPromotionalAmount()} instead. + */ + #[\Deprecated(message: 'in 5.0.0. Use [[getTotalPromotionalAmount()]] instead.')] + public function getTotalSaleAmount(): float + { + Deprecator::log(__METHOD__, '`getTotalSaleAmount()` method has been deprecated. Use `getTotalPromotionalAmount()` instead.'); + return $this->getTotalPromotionalAmount(); + } + + /** + * Returns the total of all line item's subtotals. + */ + public function getItemSubtotal(): float + { + $value = 0; + $teller = $this->getTeller(); + foreach ($this->getLineItems() as $item) { + $value = (float)$teller->add($value, $item->getSubtotal()); + } + + return $value; + } + + /** + * Returns the total of adjustments made to order. + */ + public function getAdjustmentSubtotal(): float + { + $value = 0; + $teller = $this->getTeller(); + foreach ($this->getAdjustments() as $adjustment) { + if (!$adjustment->included) { + $value = (float)$teller->add($value, $adjustment->amount); + } + } + + return (float)$value; + } + + /** + * @return OrderAdjustment[]|null + */ + public function getAdjustments(): ?array + { + if (isset($this->_orderAdjustments)) { + return $this->_orderAdjustments; + } + + if ($this->id) { + $this->setAdjustments(app(OrderAdjustments::class)->getAllOrderAdjustmentsByOrderId($this->id)); + } + + return $this->_orderAdjustments ?? []; + } + + public function getAdjustmentsByType(string $type): array + { + $adjustments = []; + + foreach ($this->getAdjustments() as $adjustment) { + if ($adjustment->type === $type) { + $adjustments[] = $adjustment; + } + } + + return $adjustments; + } + + public function getOrderAdjustments(): array + { + $adjustments = $this->getAdjustments(); + $orderAdjustments = []; + + foreach ($adjustments as $adjustment) { + if (!$adjustment->getLineItem() && $adjustment->orderId == $this->id) { + $orderAdjustments[] = $adjustment; + } + } + + return $orderAdjustments; + } + + /** + * @param OrderAdjustment[] $adjustments + */ + public function setAdjustments(array $adjustments): void + { + $this->_orderAdjustments = []; + + foreach ($adjustments as $adjustment) { + $adjustment->setOrder($this); + } + + $this->_orderAdjustments = $adjustments; + } + + public function getAdjustmentsTotal(): float + { + $amount = 0; + $teller = $this->getTeller(); + foreach ($this->getAdjustments() as $adjustment) { + if (!$adjustment->included) { + $amount = (float)$teller->add($amount, $adjustment->amount); + } + } + + return $amount; + } + + /** + * Get the shipping address on the order. + */ + public function getShippingAddress(): ?AddressElement + { + if (!isset($this->_shippingAddress) && $this->shippingAddressId) { + /** @var AddressElement|null $address */ + $address = AddressElement::find() + ->owner($this) + ->id($this->shippingAddressId) + ->one(); + + $this->_shippingAddress = $address; + } + + return $this->_shippingAddress; + } + + /** + * Set the shipping address on the order. + */ + public function setShippingAddress(AddressElement|array|null $address): void + { + if ($address === null) { + $this->shippingAddressId = null; + $this->_shippingAddress = null; + return; + } + + if (is_array($address)) { + unset($address['id']); + $addressElement = $this->_shippingAddress ?: new AddressElement(); + $addressElement->setAttributes($address); + $this->_populateAddressNameAttributes($addressElement, $address); + $addressElement->setPrimaryOwner($this); + $address = $addressElement; + } + + // Ensure that address can only belong to this order + if ($address->getPrimaryOwnerId() != $this->id) { + throw new \InvalidArgumentException('Can not set a shipping address on the order that is not owned by the order.'); + } + + $this->shippingAddressId = $address->id; + $address->title = t('Shipping Address', category: 'commerce'); + $this->_shippingAddress = $address; + } + + public function removeShippingAddress(): void + { + $this->shippingAddressId = null; + $this->_shippingAddress = null; + } + + public function getEstimatedShippingAddress(): ?AddressElement + { + if (!isset($this->_estimatedShippingAddress) && $this->estimatedShippingAddressId) { + /** @var AddressElement|null $address */ + $address = AddressElement::find()->owner($this)->id($this->estimatedShippingAddressId)->one(); + $this->_estimatedShippingAddress = $address; + } + + return $this->_estimatedShippingAddress; + } + + public function setEstimatedShippingAddress(AddressElement|array|null $address): void + { + if ($address === null) { + $this->estimatedShippingAddressId = null; + $this->_estimatedShippingAddress = null; + return; + } + + if (!$address instanceof AddressElement) { + $addressElement = new AddressElement(); + $addressElement->setAttributes($address); + $address = $addressElement; + } + + $this->estimatedShippingAddressId = $address->id; + $this->_estimatedShippingAddress = $address; + } + + /** + * Get the billing address on the order. + */ + public function getBillingAddress(): ?AddressElement + { + if (!isset($this->_billingAddress) && $this->billingAddressId) { + /** @var AddressElement|null $address */ + $address = AddressElement::find() + ->owner($this) + ->id($this->billingAddressId) + ->one(); + + $this->_billingAddress = $address; + } + + return $this->_billingAddress; + } + + /** + * Set the billing address on the order. + */ + public function setBillingAddress(AddressElement|array|null $address): void + { + if ($address === null) { + $this->billingAddressId = null; + $this->_billingAddress = null; + return; + } + + if (is_array($address)) { + unset($address['id']); // only ever allow setting of the address data + $addressElement = $this->_billingAddress ?: new AddressElement(); + $addressElement->setAttributes($address); + $this->_populateAddressNameAttributes($addressElement, $address); + $addressElement->setPrimaryOwner($this); + $address = $addressElement; + } + + // Ensure that address can only belong to this order + if ($address->getPrimaryOwnerId() !== $this->id) { + throw new \InvalidArgumentException('Can not set a billing address on the order that is not owned by the order.'); + } + + $address->ownerId = $this->id; + $this->billingAddressId = $address->id; + $address->title = t('Billing Address', category: 'commerce'); + $this->_billingAddress = $address; + } + + public function removeBillingAddress(): void + { + $this->billingAddressId = null; + $this->_billingAddress = null; + } + + /** + * Returns whether the billing and shipping addresses' data matches. + * + * @param string[]|null $attributes array of attributes names on which to match the addresses + */ + public function hasMatchingAddresses(?array $attributes = null): bool + { + $addressAttributes = new ReflectionClass(AddressInterface::class)->getMethods(); + $addressAttributes = array_map(static fn(ReflectionMethod $method) => // Remove `get` and lower case first character + lcfirst(substr($method->name, 3)), $addressAttributes); + + $relationCustomFieldHandles = []; + $customFieldHandles = array_map(static function(FieldInterface $field) use (&$relationCustomFieldHandles) { + if ($field instanceof BaseRelationField) { + $relationCustomFieldHandles[] = $field->handle; + } + + return $field->handle; + }, new AddressElement()->getFieldLayout()->getCustomFields()); + + $nameTraitProperties = array_map(static fn(ReflectionProperty $property) => $property->name, new ReflectionClass(HasNames::class)->getProperties()); + + $toArrayHandles = [...$nameTraitProperties, ...$addressAttributes, ...$customFieldHandles]; + + if (!empty($attributes)) { + $toArrayHandles = array_intersect($toArrayHandles, $attributes); + } + + // Figure out if we need to do any extra work for custom fields + $toArrayRelationFields = !empty($relationCustomFieldHandles) ? array_intersect($toArrayHandles, $relationCustomFieldHandles) : []; + + $matchingShippingAddress = []; + if ($this->getShippingAddress() instanceof AddressElement) { + $matchingShippingAddress = $this->getShippingAddress()->toArray(array_diff($toArrayHandles, $toArrayRelationFields)); + } + + $matchingBillingAddress = []; + if ($this->getBillingAddress() instanceof AddressElement) { + $matchingBillingAddress = $this->getBillingAddress()->toArray(array_diff($toArrayHandles, $toArrayRelationFields)); + } + + // Add any relational custom fields to the matching arrays + if (!empty($toArrayRelationFields)) { + foreach ($toArrayRelationFields as $handle) { + if ($this->getShippingAddress() instanceof AddressElement) { + $matchingShippingAddress[$handle] = $this->getShippingAddress()->getFieldValue($handle)?->ids(); + } + + if ($this->getBillingAddress() instanceof AddressElement) { + $matchingBillingAddress[$handle] = $this->getBillingAddress()->getFieldValue($handle)?->ids(); + } + } + } + + return $matchingBillingAddress == $matchingShippingAddress; + } + + public function getEstimatedBillingAddress(): ?AddressElement + { + if (!isset($this->_estimatedBillingAddress) && $this->estimatedBillingAddressId) { + /** @var AddressElement|null $address */ + $address = AddressElement::find()->owner($this)->id($this->estimatedBillingAddressId)->one(); + $this->_estimatedBillingAddress = $address; + } + + return $this->_estimatedBillingAddress; + } + + public function setEstimatedBillingAddress(AddressElement|array|null $address): void + { + if ($address === null) { + $this->estimatedBillingAddressId = null; + $this->_estimatedBillingAddress = null; + return; + } + + if (!$address instanceof AddressElement) { + $addressElement = new AddressElement(); + $addressElement->setAttributes($address); + $address = $addressElement; + } + + $this->estimatedBillingAddressId = $address->id; + $this->_estimatedBillingAddress = $address; + } + + /** + * @deprecated in 3.4.18. Use `$shippingMethodHandle` or `$shippingMethodName` instead. + */ + #[\Deprecated(message: 'in 3.4.18. Use `$shippingMethodHandle` or `$shippingMethodName` instead.')] + public function getShippingMethod(): ?ShippingMethod + { + return app(ShippingMethods::class)->getShippingMethodByHandle((string)$this->shippingMethodHandle); + } + + public function getGateway(): ?GatewayInterface + { + if ($this->gatewayId === null && $this->paymentSourceId === null) { + return null; + } + + $gateway = null; + + // sources before gateways + if ($this->paymentSourceId) { + if ($paymentSource = app(PaymentSources::class)->getPaymentSourceById($this->paymentSourceId)) { + $gateway = app(Gateways::class)->getGatewayById($paymentSource->gatewayId); + } + } else { + if ($this->gatewayId) { + $gateway = app(Gateways::class)->getGatewayById((int)$this->gatewayId); + } + } + + return $gateway; + } + + /** + * Returns the current payment currency, and defaults to the primary currency if not set. + */ + public function getPaymentCurrency(): string + { + if ($this->_paymentCurrency === null) { + $this->_paymentCurrency = $this->getStore()->getCurrency()?->getCode(); + } + + return $this->_paymentCurrency; + } + + public function setPaymentCurrency(string $value): void + { + $this->_paymentCurrency = $value; + } + + /** + * Returns the order's selected payment source if any. + * + * @throws \RuntimeException if the payment source is being set by a guest customer. + * @throws \InvalidArgumentException if the order is set to an invalid payment source. + */ + public function getPaymentSource(): ?PaymentSource + { + if ($this->paymentSourceId === null) { + return null; + } + + if (($user = $this->getCustomer()) === null) { + throw new \RuntimeException('Guest customers can not set a payment source.'); + } + + if (($paymentSource = app(PaymentSources::class)->getPaymentSourceByIdAndUserId($this->paymentSourceId, $user->id)) === null) { + throw new \InvalidArgumentException("Invalid payment source ID: $this->paymentSourceId"); + } + + return $paymentSource; + } + + public function setPaymentSource(?PaymentSource $paymentSource): void + { + // Setting the payment source to null clears it + if ($paymentSource === null) { + $this->paymentSourceId = null; + return; + } + + // We are now dealing with a PaymentSource + $customer = $this->getCustomer(); + if ($customer?->id && $paymentSource->getCustomer()?->id !== $customer->id) { + throw new \InvalidArgumentException('PaymentSource is not owned by the user of the order.'); + } + + $this->paymentSourceId = $paymentSource->id; + $this->gatewayId = null; + } + + public function setGatewayId(int $gatewayId): void + { + $this->gatewayId = $gatewayId; + $this->paymentSourceId = null; + } + + /** + * @return OrderHistory[] + */ + public function getHistories(): array + { + if ($this->id === null) { + return []; + } + + $histories = app(OrderHistories::class)->getAllOrderHistoriesByOrderId($this->id); + + foreach ($histories as $history) { + $history->setOrder($this); + } + + return $histories; + } + + /** + * Set transactions on the order. Set to null to clear cache and force next getTransactions() call to get the latest transactions. + * + * @param Transaction[]|null $transactions + */ + public function setTransactions(?array $transactions): void + { + $this->_transactions = $transactions; + } + + /** + * @return Transaction[] + */ + public function getTransactions(): array + { + if ($this->id === null) { + $this->_transactions = []; + } + + if ($this->_transactions === null) { + $transactions = app(Transactions::class)->getAllTransactionsByOrderId($this->id); + + foreach ($transactions as $transaction) { + $transaction->setOrder($this); + } + + $this->_transactions = $transactions; + } + + return $this->_transactions; + } + + public function getLastTransaction(): ?Transaction + { + $transactions = $this->getTransactions(); + return count($transactions) ? array_pop($transactions) : null; + } + + /** + * Returns an array of transactions for the order that have child transactions set on them. + * + * @return Transaction[] + */ + public function getNestedTransactions(): array + { + // Transactions come in sorted by `id ASC`. + // Given that transactions cannot be modified, it means that parents will always come first. + // So we can just store a reference to them and build our tree in one pass. + $transactions = $this->getTransactions(); + + /** @var Transaction[] $referenceStore */ + $referenceStore = []; + $nestedTransactions = []; + + foreach ($transactions as $transaction) { + // We'll be adding all of the children in this loop, anyway, so we set the children list to an empty array. + // This way no db queries are triggered when transactions are queried for children. + $transaction->setChildTransactions([]); + if ($transaction->parentId && isset($referenceStore[$transaction->parentId])) { + $referenceStore[$transaction->parentId]->addChildTransaction($transaction); + } else { + $nestedTransactions[] = $transaction; + } + + $referenceStore[$transaction->id] = $transaction; + } + + return $nestedTransactions; + } + + public function getOrderStatus(): ?OrderStatus + { + return $this->orderStatusId !== null ? app(OrderStatuses::class)->getOrderStatusById($this->orderStatusId, $this->storeId) : null; + } + + /** + * Get the site for the order. + */ + public function getOrderSite(): ?Site + { + if (!$this->orderSiteId) { + return null; + } + + return Sites::getSiteById($this->orderSiteId); + } + + #[Override] + public function getMetadata(): array + { + $metadata = []; + + if ($this->isCompleted) { + $metadata[t('Reference', category: 'commerce')] = Html::encode($this->reference); + $metadata[t('Date Ordered', category: 'commerce')] = I18N::getFormatter()->asDatetime($this->dateOrdered, 'short'); + } + + $metadata[t('Coupon Code', category: 'commerce')] = Html::encode($this->couponCode); + + $orderSite = $this->getOrderSite(); + $metadata[t('Order Site', category: 'commerce')] = Html::encode($orderSite?->getName() ?? ''); + + $metadata[t('Shipping Method', category: 'commerce')] = Html::encode($this->shippingMethodName ?? ''); + + $metadata[t('ID')] = $this->id; + $metadata[t('Short Number', category: 'commerce')] = $this->getShortNumber(); + $metadata[t('Paid Status', category: 'commerce')] = $this->getPaidStatusHtml(); + $metadata[t('Total Price', category: 'commerce')] = $this->totalPriceAsCurrency; + $metadata[t('Paid Amount', category: 'commerce')] = $this->totalPaidAsCurrency; + $metadata[t('Origin', category: 'commerce')] = Html::encode($this->origin); + + return array_merge($metadata, parent::getMetadata()); + } + + #[Override] + public function beforeDelete(): bool + { + if (!parent::beforeDelete()) { + return false; + } + + // Capture line items before the cascade delete fires so afterDelete() can refresh stock caches + if ($this->isCompleted) { + $this->_deletingLineItems = $this->getLineItems(); + } + + return true; + } + + #[Override] + public function afterDelete(): void + { + parent::afterDelete(); + + if ($this->isCompleted) { + foreach ($this->_deletingLineItems as $lineItem) { + $purchasable = $lineItem->getPurchasable(); + if ($purchasable instanceof NewPurchasable && $purchasable::hasInventory() && $purchasable->inventoryTracked) { + app(Purchasables::class)->updateStoreStockCache($purchasable, true); + } + } + } + } + + /** + * Returns non-admin notices. Admin notices are excluded by default. + * + * @param string|null $type type name. Use null to retrieve notices for all types. + * @param string|null $attribute attribute name. Use null to retrieve notices for all attributes. + * @return OrderNotice[] notices for all types or the specified type / attribute. Empty array is returned if no notice. + */ + public function getNotices(?string $type = null, ?string $attribute = null): array + { + $notices = array_values(array_filter($this->_notices, fn(OrderNotice $n) => $n->noticeType === OrderNoticeType::Customer)); + return $this->_filterNotices($notices, $type, $attribute); + } + + /** + * Returns admin-only notices, optionally filtered by type and/or attribute. + * + * @return OrderNotice[] + */ + public function getAdminNotices(?string $type = null, ?string $attribute = null): array + { + $notices = array_values(array_filter($this->_notices, fn(OrderNotice $n) => $n->noticeType === OrderNoticeType::Admin)); + return $this->_filterNotices($notices, $type, $attribute); + } + + /** + * Adds a new notice. + */ + public function addNotice(OrderNotice $notice): void + { + $notice->setOrder($this); + $this->_notices[] = $notice; + } + + /** + * Returns the first non-admin notice matching the specified type or attribute. + */ + public function getFirstNotice($type = null, $attribute = null): ?OrderNotice + { + return Arr::first($this->getNotices($type, $attribute)); + } + + /** + * Adds a list of notices. + * + * @param OrderNotice[] $notices an array of notices. + */ + public function addNotices(array $notices): void + { + foreach ($notices as $notice) { + $this->addNotice($notice); + } + } + + /** + * Removes notices matching the given criteria, scoped to the specified notice types. + * + * By default only customer notices are cleared, preserving admin notices for backwards compatibility. + * Pass one or more {@see OrderNoticeType} values to control which notice types are affected. + * + * @param string|null $type type name. Use null to remove notices for all types. + * @param string|null $attribute attribute name. Use null to remove notices for all attributes. + * @param OrderNoticeType|OrderNoticeType[]|null $noticeTypes Notice type(s) to clear. Defaults to customer notices only. + */ + public function clearNotices(?string $type = null, ?string $attribute = null, array|OrderNoticeType|null $noticeTypes = null): void + { + if ($noticeTypes === null) { + $noticeTypes = [OrderNoticeType::Customer]; + } elseif ($noticeTypes instanceof OrderNoticeType) { + $noticeTypes = [$noticeTypes]; + } + + $targetNotices = array_values(array_filter($this->_notices, fn(OrderNotice $n) => in_array($n->noticeType, $noticeTypes))); + $preservedNotices = array_values(array_filter($this->_notices, fn(OrderNotice $n) => !in_array($n->noticeType, $noticeTypes))); + + if ($type === null && $attribute === null) { + $remaining = []; + } elseif ($attribute === null) { + $remaining = array_values(array_filter($targetNotices, fn(OrderNotice $n) => $n->type !== $type)); + } elseif ($type === null) { + $remaining = array_values(array_filter($targetNotices, fn(OrderNotice $n) => $n->attribute !== $attribute)); + } else { + $remaining = array_values(array_filter($targetNotices, fn(OrderNotice $n) => !($n->type === $type && $n->attribute === $attribute))); + } + + $this->_notices = array_merge($preservedNotices, $remaining); + } + + /** + * Returns a value indicating whether there are any non-admin notices. + */ + public function hasNotices(?string $type = null, ?string $attribute = null): bool + { + return !empty($this->getNotices($type, $attribute)); + } + + /** + * Returns whether there are any admin notices. + */ + public function hasAdminNotices(): bool + { + return !empty($this->getAdminNotices()); + } + + /** + * Filters an array of notices by type and/or attribute. + * + * @param OrderNotice[] $notices + * @return OrderNotice[] + */ + private function _filterNotices(array $notices, ?string $type, ?string $attribute): array + { + if ($type === null && $attribute === null) { + return $notices; + } + + if ($attribute === null) { + return Arr::where($notices, fn(OrderNotice $n) => $n->type == $type); + } + + if ($type === null) { + return Arr::where($notices, fn(OrderNotice $n) => $n->attribute == $attribute); + } + + return Arr::where($notices, fn(OrderNotice $n) => $n->attribute === $attribute && $n->type === $type); + } + + public function validateGatewayId(string $attribute): void + { + if ($this->gatewayId && !$this->getGateway()) { + $this->errors()->add($attribute, t('Invalid gateway: {value}', category: 'commerce')); + } + } + + public function validatePaymentSourceId(string $attribute): void + { + try { + // this will confirm the payment source is valid and belongs to the orders customer + $this->getPaymentSource(); + } catch (\RuntimeException $e) { + Log::error($e->getMessage(), ['exception' => $e]); + $this->errors()->add($attribute, t('Invalid payment source ID: {value}', category: 'commerce')); + } + } + + public function validatePaymentCurrency(string $attribute): void + { + try { + // this will confirm the payment source is valid and belongs to the orders customer + $this->getPaymentCurrency(); + } catch (\RuntimeException) { + $this->errors()->add($attribute, t('Invalid payment source ID: {value}', category: 'commerce')); + } + } + + /** + * Validates addresses, and also adds prefixed validation errors to order. + * + * @param string $attribute the attribute being validated + */ + public function validateAddress(string $attribute): void + { + /** @var AddressElement|null $address */ + $address = $this->$attribute; + + // Set live scenario for addresses to match CP + $address?->ruleset->useScenario(ElementRules::SCENARIO_LIVE); + + if ($address && !$address->validate()) { + $this->addModelErrors($address, $attribute); + } + + $marketLocationCondition = $this->getStore()->getSettings()->getMarketAddressCondition(); + if ($address && count($marketLocationCondition->getConditionRules()) > 0 && !$marketLocationCondition->matchElement($address)) { + $this->errors()->add($attribute, t('The address provided is outside the store\'s market.', category: 'commerce')); + } + } + + /** + * Validates that address country is in the allowed list. + * + * @param string $attribute the attribute being validated + */ + public function validateAddressCountry(string $attribute): void + { + $address = $this->$attribute; + if ($address && $address->countryCode) { + $countriesList = array_keys($this->getStore()->getSettings()->getCountriesList()); + if (count($countriesList) && !in_array($address->countryCode, $countriesList, false)) { + $this->errors()->add($attribute, t('Country not allowed.', category: 'commerce')); + } + } + } + + /** + * Validates that shipping address isn't being set to be the same as billing address, when + * billing address is set to be shipping address. + * + * @param string $attribute the attribute being validated + */ + public function validateAddressReuse(string $attribute): void + { + if ($this->shippingSameAsBilling && $this->billingSameAsShipping) { + $this->errors()->add($attribute, t('shippingSameAsBilling and billingSameAsShipping can\'t both be set.', category: 'commerce')); + } + } + + /** + * Validates line items, and also adds prefixed validation errors to order. + */ + public function validateLineItems(): void + { + OrderHelper::normalizeLineItemPurchasableAvailability($this); + OrderHelper::mergeDuplicateLineItems($this); + + foreach ($this->getLineItems() as $key => $lineItem) { + if (!$lineItem->validate()) { + $this->addModelErrors($lineItem, "lineItems.$key"); + } + } + } + + public function validateCouponCode($attribute): void + { + $recalculateAll = $this->getRecalculationMode() == self::RECALCULATION_MODE_ALL; + $recalculateAll = $recalculateAll || $this->getRecalculationMode() == self::RECALCULATION_MODE_ADJUSTMENTS_ONLY; + if ($recalculateAll && $this->$attribute && !app(Discounts::class)->orderCouponAvailable($this, $explanation)) { + $notice = new OrderNotice([ + 'type' => 'invalidCouponRemoved', + 'attribute' => $attribute, + 'message' => t('Coupon removed: {explanation}', [ + 'explanation' => $explanation, + ], category: 'commerce'), + ]); + $this->addNotice($notice); + $this->$attribute = null; + } + } + + public function validateOrganizationTaxIdAsVatId($attribute): void + { + /** @var AddressElement $address */ + $address = $this->$attribute; + + // Skip on empty + if (!$address->organizationTaxId) { + return; + } + + if (app(Vat::class)->isValidVatId($address->organizationTaxId)) { + return; + } + + $address->errors()->add('organizationTaxId', t('Invalid VAT ID.', category: 'commerce')); + $this->addModelErrors($address, $attribute); + } + + /** + * @return OrderQuery The newly created OrderQuery instance. + */ + #[Override] + public static function find(): OrderQuery + { + return new OrderQuery(); + } + + /** + * Order has a single, shared field layout keyed by element type (not a per-instance + * `fieldLayoutId`), so this overrides the default `HasCustomFields::getFieldLayout()` rather + * than using the `HasFieldLayout` concern — that concern is for classes that own/configure a + * field layout for other elements (e.g. a section or product type), not for elements that + * simply consume one shared-by-type layout. + */ + #[Override] + public function getFieldLayout(): FieldLayout + { + return Fields::getLayoutByType(static::class); + } + + #[Override] + protected function htmlAttributes(string $context): array + { + $attributes = parent::htmlAttributes($context); + $attributes['data'] = ['number' => $this->number]; + return $attributes; + } + + #[Override] + protected function attributeHtml(string $attribute): string + { + switch ($attribute) { + case 'orderStatus': + { + return $this->getOrderStatus() ? $this->getOrderStatus()->getLabelHtml() : ''; + } + case 'customer': + { + return $this->getCustomerLinkHtml(); + } + case 'shippingFullName': + { + return $this->getShippingAddress() ? Html::encode($this->getShippingAddress()->fullName ?? '') : ''; + } + case 'shippingFirstName': + { + return $this->getShippingAddress() ? Html::encode($this->getShippingAddress()->firstName ?? '') : ''; + } + case 'shippingLastName': + { + return $this->getShippingAddress() ? Html::encode($this->getShippingAddress()->lastName ?? '') : ''; + } + case 'billingFullName': + { + return $this->getBillingAddress() ? Html::encode($this->getBillingAddress()->fullName ?? '') : ''; + } + case 'billingFirstName': + { + return $this->getBillingAddress() ? Html::encode($this->getBillingAddress()->firstName ?? '') : ''; + } + case 'billingLastName': + { + return $this->getBillingAddress() ? Html::encode($this->getBillingAddress()->lastName ?? '') : ''; + } + case 'shippingOrganizationName': + { + return $this->getShippingAddress() ? Html::encode($this->getShippingAddress()->organization ?? '') : ''; + } + case 'billingOrganizationName': + { + return $this->getBillingAddress() ? Html::encode($this->getBillingAddress()->organization ?? '') : ''; + } + case 'shippingMethodName': + { + return Html::encode($this->shippingMethodName ?? ''); + } + case 'gatewayName': + { + return Html::encode($this->getGateway()->name ?? ''); + } + case 'paidStatus': + { + return $this->getPaidStatusHtml(); + } + case 'totalPaid': + { + return $this->storedTotalPaidAsCurrency; + } + case 'itemTotal': + { + return $this->storedItemTotalAsCurrency; + } + case 'itemSubtotal': + { + return $this->storedItemSubtotalAsCurrency; + } + case 'totalQty': + { + return (string)$this->storedTotalQty; + } + case 'total': + { + return $this->totalAsCurrency; + } + case 'totalPrice': + { + return $this->storedTotalPriceAsCurrency; + } + case 'totalShippingCost': + { + return $this->storedTotalShippingCostAsCurrency; + } + case 'totalDiscount': + { + return $this->storedTotalDiscountAsCurrency; + } + case 'totalTax': + { + return $this->storedTotalTaxAsCurrency; + } + case 'totalIncludedTax': + { + return $this->storedTotalTaxIncludedAsCurrency; + } + case 'totals': + { + $miniTable = []; + + $miniTable[] = [ + 'label' => t('Qty', category: 'commerce'), + 'value' => $this->storedTotalQty, + ]; + + if ($this->itemSubtotal > 0) { + $miniTable[] = [ + 'label' => t('Items', category: 'commerce'), + 'value' => $this->itemSubtotalAsCurrency, + ]; + } + + if ($this->storedTotalDiscount < 0) { + $miniTable[] = [ + 'label' => t('Discounts', category: 'commerce'), + 'value' => $this->storedTotalDiscountAsCurrency, + ]; + } + + if ($this->storedTotalShippingCost > 0) { + $miniTable[] = [ + 'label' => t('Shipping', category: 'commerce'), + 'value' => $this->storedTotalShippingCostAsCurrency, + ]; + } + + if ($this->storedTotalTaxIncluded > 0) { + $miniTable[] = [ + 'label' => t('Tax (inc)', category: 'commerce'), + 'value' => $this->storedTotalTaxIncludedAsCurrency, + ]; + } + + if ($this->storedTotalTax > 0) { + $miniTable[] = [ + 'label' => t('Tax', category: 'commerce'), + 'value' => $this->storedTotalTaxAsCurrency, + ]; + } + + if ($this->storedTotalPrice > 0) { + $miniTable[] = [ + 'label' => t('Price', category: 'commerce'), + 'value' => $this->storedTotalPriceAsCurrency, + ]; + } + + return $this->_miniTable($miniTable); + } + case 'orderSite': + { + $site = Sites::getSiteById($this->orderSiteId); + return Html::encode($site->name ?? ''); + } + case 'hasAdminNotices': + { + if (!$this->hasAdminNotices()) { + return ''; + } + return app(StatusHtml::class)->statusLabelHtml(['color' => 'red', 'label' => t('Yes', category: 'commerce')]); + } + default: + { + return parent::attributeHtml($attribute); + } + } + } + + #[Override] + protected static function defineSearchableAttributes(): array + { + return [ + 'billingFirstName', + 'billingLastName', + 'billingFullName', + 'billingAddress', + 'email', + 'number', + 'shippingFirstName', + 'shippingLastName', + 'shippingFullName', + 'shippingAddress', + 'shortNumber', + 'transactionReference', + 'username', + 'reference', + 'skus', + 'lineItemDescriptions', + 'customerName', + ]; + } + + #[Override] + public function getSearchKeywords(string $attribute): string + { + switch ($attribute) { + case 'billingFirstName': + return $this->billingAddress->firstName ?? ''; + case 'billingLastName': + return $this->billingAddress->lastName ?? ''; + case 'billingFullName': + return $this->billingAddress->fullName ?? ''; + case 'billingAddress': + $address = $this->getBillingAddress(); + return $address ? Addresses::formatAddress($address) : ''; + case 'shippingFirstName': + return $this->shippingAddress->firstName ?? ''; + case 'shippingLastName': + return $this->shippingAddress->lastName ?? ''; + case 'shippingFullName': + return $this->shippingAddress->fullName ?? ''; + case 'shippingAddress': + $address = $this->getShippingAddress(); + return $address ? Addresses::formatAddress($address) : ''; + case 'transactionReference': + return implode(' ', Arr::pluck($this->getTransactions(), 'reference')); + case 'username': + return $this->getCustomer()->username ?? ''; + case 'skus': + return implode(' ', Arr::pluck($this->getLineItems(), 'sku')); + case 'lineItemDescriptions': + return implode(' ', Arr::pluck($this->getLineItems(), 'description')); + case 'customerName': + return $this->getCustomer()->fullName ?? ''; + default: + return parent::getSearchKeywords($attribute); + } + } + + #[Override] + protected static function defineSources(string $context): array + { + $siteHandle = request()->query('site'); + $site = $siteHandle ? Sites::getSiteByHandle($siteHandle) : Sites::getCurrentSite(); + $store = app(Stores::class)->getStoreBySiteId($site->id); + $orderCriteria = ['isCompleted' => true, 'storeId' => $store->id]; + + $sources = [ + '*' => [ + 'key' => '*', + 'label' => t('All Orders', category: 'commerce'), + 'criteria' => $orderCriteria, + 'defaultSort' => ['dateOrdered', 'desc'], + 'data' => [ + 'date-attr' => 'dateOrdered', + ], + ], + ]; + + $edge = app(Carts::class)->getActiveCartEdgeDuration(); + + $criteriaActive = ['dateUpdated' => ['>= ' . $edge], 'isCompleted' => false]; + $criteriaInactive = ['dateUpdated' => ['< ' . $edge], 'isCompleted' => false]; + $criteriaAttemptedPayment = ['hasTransactions' => true, 'isCompleted' => false]; + + $orderStatuses = app(OrderStatuses::class)->getAllOrderStatuses($store->id)->all(); + + $sources[] = ['heading' => $store->getName()]; + + foreach ($orderStatuses as $orderStatus) { + $key = 'orderStatus:' . $orderStatus->handle; + + $sources[$key] = [ + 'key' => $key, + 'status' => $orderStatus->color, + 'label' => t($orderStatus->name, category: 'site'), + 'badgeCount' => 0, + 'criteria' => Arr::merge($orderCriteria, ['orderStatusId' => $orderStatus->id]), + 'defaultSort' => ['dateOrdered', 'desc'], + 'data' => [ + 'handle' => $orderStatus->handle, + 'date-attr' => 'dateOrdered', + ], + ]; + } + + $sources[] = [ + 'key' => 'carts:active:' . $store->handle, + 'label' => t('Active Carts', category: 'commerce'), + 'criteria' => Arr::merge($criteriaActive, ['storeId' => $store->id]), + 'defaultSort' => ['commerce_orders.dateUpdated', 'asc'], + 'data' => [ + 'handle' => 'cartsActive', + 'date-attr' => 'dateUpdated', + ], + ]; + + $sources[] = [ + 'key' => 'carts:inactive:' . $store->handle, + 'label' => t('Inactive Carts', category: 'commerce'), + 'criteria' => Arr::merge($criteriaInactive, ['storeId' => $store->id]), + 'defaultSort' => ['commerce_orders.dateUpdated', 'desc'], + 'data' => [ + 'handle' => 'cartsInactive', + 'date-attr' => 'dateUpdated', + ], + ]; + + $sources[] = [ + 'key' => 'carts:attempted-payment:' . $store->handle, + 'label' => t('Attempted Payments', category: 'commerce'), + 'criteria' => Arr::merge($criteriaAttemptedPayment, ['storeId' => $store->id]), + 'defaultSort' => ['commerce_orders.dateUpdated', 'desc'], + 'data' => [ + 'handle' => 'cartsAttemptedPayment', + 'date-attr' => 'dateUpdated', + ], + ]; + + return $sources; + } + + #[Override] + protected static function defineActions(string $source): array + { + $actions = parent::defineActions($source); + + $user = currentUser(); + + if ($user?->can('commerce-manageOrders')) { + $site = app(RequestedSite::class)->get() ?? Sites::getCurrentSite(); + $store = app(Stores::class)->getStoreBySiteId($site->id); + // Remove nested "all" prefix if it exists at the start of the string + $source = str_starts_with($source, '*/') ? substr($source, 2) : $source; + + if ($store && app(Pdfs::class)->getHasEnabledPdf($store->id)) { + $actions[] = ElementActions::createAction([ + 'type' => DownloadOrderPdfAction::class, + 'storeId' => $store->id, + ], static::class); + } + + if ($user->can('commerce-deleteOrders')) { + $actions[] = ElementActions::createAction([ + 'type' => Delete::class, + 'confirmationMessage' => t('Are you sure you want to delete the selected orders?', category: 'commerce'), + 'successMessage' => t('Orders deleted.', category: 'commerce'), + ], static::class); + } + + if ($user->can('commerce-editOrders')) { + // Only allow mass updating order status when all selected are of the same status, and not carts. + $isStatus = strpos($source, 'orderStatus:'); + if ($isStatus === 0) { + $actions[] = ElementActions::createAction([ + 'type' => UpdateOrderStatus::class, + ], static::class); + } + + $isStatus = strpos($source, 'carts:'); + if ($isStatus === 0) { + $actions[] = ElementActions::createAction([ + 'type' => CopyLoadCartUrl::class, + ], static::class); + } + } + + if ($user->can('commerce-deleteOrders')) { + // Restore + $actions[] = ElementActions::createAction([ + 'type' => Restore::class, + 'successMessage' => t('Orders restored.', category: 'commerce'), + 'partialSuccessMessage' => t('Some orders restored.', category: 'commerce'), + 'failMessage' => t('Orders not restored.', category: 'commerce'), + ], static::class); + } + } + + return $actions; + } + + #[Override] + protected static function defineExporters(string $source): array + { + $default = parent::defineExporters($source); + // Remove the standard expanded exporter and use our own + $default = array_filter($default, fn($exporter) => $exporter !== CraftExpanded::class); + $default[] = Expanded::class; + $default[] = OrderExport::class; + $default[] = LineItemExport::class; + + return $default; + } + + #[Override] + protected static function defineTableAttributes(): array + { + return array_merge(parent::defineTableAttributes(), [ + 'reference' => ['label' => t('Reference', category: 'commerce')], + 'shortNumber' => ['label' => t('Short Number', category: 'commerce')], + 'number' => ['label' => t('Number', category: 'commerce')], + 'id' => ['label' => t('ID', category: 'commerce')], + 'orderStatus' => ['label' => t('Status', category: 'commerce')], + 'totals' => ['label' => t('All Totals', category: 'commerce')], + 'totalQty' => ['label' => t('Total Qty', category: 'commerce')], + 'total' => ['label' => t('Total', category: 'commerce')], + 'totalPrice' => ['label' => t('Total Price', category: 'commerce')], + 'totalPaid' => ['label' => t('Total Paid', category: 'commerce')], + 'totalDiscount' => ['label' => t('Total Discount', category: 'commerce')], + 'totalShippingCost' => ['label' => t('Total Shipping', category: 'commerce')], + 'totalTax' => ['label' => t('Total Tax', category: 'commerce')], + 'totalIncludedTax' => ['label' => t('Total Included Tax', category: 'commerce')], + 'dateOrdered' => ['label' => t('Date Ordered', category: 'commerce')], + 'datePaid' => ['label' => t('Date Paid', category: 'commerce')], + 'dateFirstPaid' => ['label' => t('Date First Paid', category: 'commerce')], + 'dateCreated' => ['label' => t('Date Created', category: 'commerce')], + 'dateUpdated' => ['label' => t('Date Updated', category: 'commerce')], + 'email' => ['label' => t('Email', category: 'commerce')], + 'customer' => ['label' => t('Customer', category: 'commerce')], + 'shippingFullName' => ['label' => t('Shipping Full Name', category: 'commerce')], + 'shippingFirstName' => ['label' => t('Shipping First Name', category: 'commerce')], + 'shippingLastName' => ['label' => t('Shipping Last Name', category: 'commerce')], + 'billingFullName' => ['label' => t('Billing Full Name', category: 'commerce')], + 'billingFirstName' => ['label' => t('Billing First Name', category: 'commerce')], + 'billingLastName' => ['label' => t('Billing Last Name', category: 'commerce')], + 'shippingOrganizationName' => ['label' => t('Shipping Business Name', category: 'commerce')], + 'billingOrganizationName' => ['label' => t('Billing Business Name', category: 'commerce')], + 'shippingMethodName' => ['label' => t('Shipping Method', category: 'commerce')], + 'gatewayName' => ['label' => t('Gateway', category: 'commerce')], + 'paidStatus' => ['label' => t('Paid Status', category: 'commerce')], + 'couponCode' => ['label' => t('Coupon Code', category: 'commerce')], + 'itemTotal' => ['label' => t('Item Total', category: 'commerce')], + 'itemSubtotal' => ['label' => t('Item Subtotal', category: 'commerce')], + 'orderSite' => ['label' => t('Order Site', category: 'commerce')], + 'hasAdminNotices' => ['label' => t('Admin Notices', category: 'commerce')], + ]); + } + + #[Override] + protected static function defineDefaultTableAttributes(string $source): array + { + $attributes = []; + $attributes[] = 'order'; + + if (!str_starts_with($source, 'carts:')) { + // For orders (including order status sources) + $attributes[] = 'reference'; + if (!str_starts_with($source, 'orderStatus:')) { + // Only show status column when not filtered by status + $attributes[] = 'orderStatus'; + } + $attributes[] = 'customer'; + $attributes[] = 'dateOrdered'; + $attributes[] = 'datePaid'; + $attributes[] = 'dateFirstPaid'; + $attributes[] = 'totalPaid'; + $attributes[] = 'paidStatus'; + $attributes[] = 'totals'; + } else { + // For carts + $attributes[] = 'shortNumber'; + $attributes[] = 'dateUpdated'; + $attributes[] = 'totalPrice'; + } + + return $attributes; + } + + #[Override] + public static function prepElementQueryForTableAttribute(ElementQueryInterface $elementQuery, string $attribute): void + { + /** @var OrderQuery $elementQuery */ + + match ($attribute) { + 'totals', 'total', 'totalPrice', 'totalDiscount', 'totalShippingCost', 'totalTax', 'totalIncludedTax' => $elementQuery->withAdjustments(), + 'totalPaid', 'paidStatus' => $elementQuery->withTransactions(), + 'shippingFullName', 'shippingFirstName', 'shippingLastName', 'billingFullName', 'billingFirstName', 'billingLastName', 'shippingOrganizationName', 'billingOrganizationName', 'shippingMethodName' => $elementQuery->withAddresses(), + 'email', 'customer' => $elementQuery->withCustomer(), + 'itemTotal', 'itemSubtotal' => $elementQuery->withLineItems(), + default => parent::prepElementQueryForTableAttribute($elementQuery, $attribute), + }; + } + + /** + * @return ElementConditionInterface + */ + #[Override] + public static function createCondition(): ElementConditionInterface + { + return new OrderCondition(static::class); + } + + #[Override] + protected static function defineSortOptions(): array + { + return [ + 'number' => t('Number', category: 'commerce'), + 'reference' => t('Reference', category: 'commerce'), + 'orderStatusId' => t('Order Status', category: 'commerce'), + 'totalPrice' => t('Total Price', category: 'commerce'), + 'totalPaid' => t('Total Paid', category: 'commerce'), + [ + 'label' => t('Shipping First Name', category: 'commerce'), + 'orderBy' => 'shipping_address.firstName', + 'attribute' => 'shippingFirstName', + ], + [ + 'label' => t('Shipping Last Name', category: 'commerce'), + 'orderBy' => 'shipping_address.lastName', + 'attribute' => 'shippingLastName', + ], + [ + 'label' => t('Shipping Full Name', category: 'commerce'), + 'orderBy' => 'shipping_address.fullName', + 'attribute' => 'shippingFullName', + ], + [ + 'label' => t('Billing First Name', category: 'commerce'), + 'orderBy' => 'billing_address.firstName', + 'attribute' => 'billingFirstName', + ], + [ + 'label' => t('Billing Last Name', category: 'commerce'), + 'orderBy' => 'billing_address.lastName', + 'attribute' => 'billingLastName', + ], + [ + 'label' => t('Billing Full Name', category: 'commerce'), + 'orderBy' => 'billing_address.fullName', + 'attribute' => 'billingFullName', + ], + [ + 'label' => t('Date Ordered', category: 'commerce'), + 'orderBy' => 'dateOrdered', + 'defaultDir' => 'desc', + ], + [ + 'label' => t('Date Updated', category: 'commerce'), + 'orderBy' => 'commerce_orders.dateUpdated', + 'attribute' => 'dateUpdated', + 'defaultDir' => 'desc', + ], + [ + 'label' => t('Date Paid', category: 'commerce'), + 'orderBy' => 'datePaid', + 'defaultDir' => 'desc', + ], + [ + 'label' => t('Date First Paid', category: 'commerce'), + 'orderBy' => 'dateFirstPaid', + 'defaultDir' => 'desc', + ], + 'couponCode' => t('Coupon Code', category: 'commerce'), + [ + 'label' => t('ID'), + 'orderBy' => 'elements.id', + 'attribute' => 'id', + ], + ]; + } + + /** + * @param array $miniTable Expects an array with rows of 'label', 'value' keys values. + */ + private function _miniTable(array $miniTable): string + { + $output = ''; + foreach ($miniTable as $row) { + $output .= ''; + $output .= ''; + $output .= ''; + $output .= ''; + } + $output .= '
' . $row['label'] . '' . $row['value'] . '
'; + + return $output; + } + + #[Override] + public static function modifyCustomSource(array $config): array + { + try { + $condition = Conditions::createCondition($config['condition']); + /** @phpstan-ignore-next-line catch.neverThrown (Conditions::createCondition() genuinely throws InvalidArgumentException for an invalid condition class - PHPStan can't trace exceptions through the facade's __callStatic dispatch) */ + } catch (\InvalidArgumentException) { + return $config; + } + + if (!$condition instanceof OrderCondition) { + return $config; + } + + $rules = $condition->getConditionRules(); + + // see if it's limited to one product type + /** @var OrderStatusConditionRule|null $orderStatusConditionRule */ + $orderStatusConditionRule = Arr::first($rules, fn($rule) => $rule instanceof OrderStatusConditionRule); + $orderStatusOptions = $orderStatusConditionRule?->getValues(); + + $currentSite = app(RequestedSite::class)->get() ?? Sites::getCurrentSite(); + $store = app(Stores::class)->getStoreBySiteId($currentSite->id); + + if ($orderStatusOptions && count($orderStatusOptions) === 1) { + $orderStatus = app(OrderStatuses::class)->getOrderStatusByUid(reset($orderStatusOptions)); + + if ($store->id != $orderStatus->storeId) { + $config['disabled'] = true; + } + + if ($orderStatus) { + $config['status'] = $orderStatus->color; + } + } + + return $config; + } + + #[Override] + protected static function defineCardAttributes(): array + { + $status = app(OrderStatuses::class)->getAllOrderStatuses()->first(); + $site = Sites::getCurrentSite(); + $number = app(Carts::class)->generateCartNumber(); + + return array_merge(parent::defineCardAttributes(), [ + 'shortNumber' => [ + 'label' => t('Short Number', category: 'commerce'), + 'placeholder' => substr($number, 0, 7), + ], + 'number' => [ + 'label' => t('Number', category: 'commerce'), + 'placeholder' => $number, + ], + 'id' => [ + 'label' => t('ID', category: 'commerce'), + 'placeholder' => '12345', + ], + 'orderStatus' => [ + 'label' => t('Status', category: 'commerce'), + 'placeholder' => $status?->getLabelHtml(), + ], + 'totalQty' => [ + 'label' => t('Total Qty', category: 'commerce'), + 'placeholder' => '10', + ], + 'total' => [ + 'label' => t('Total', category: 'commerce'), + 'placeholder' => '¤' . I18N::getFormatter()->asDecimal(123.99), + ], + 'totalPrice' => [ + 'label' => t('Total Price', category: 'commerce'), + 'placeholder' => '¤' . I18N::getFormatter()->asDecimal(123.99), + ], + 'totalPaid' => [ + 'label' => t('Total Paid', category: 'commerce'), + 'placeholder' => '¤' . I18N::getFormatter()->asDecimal(123.99), + ], + 'totalDiscount' => [ + 'label' => t('Total Discount', category: 'commerce'), + 'placeholder' => '¤' . I18N::getFormatter()->asDecimal(12.99), + ], + 'totalShippingCost' => [ + 'label' => t('Total Shipping', category: 'commerce'), + 'placeholder' => '¤' . I18N::getFormatter()->asDecimal(9.99), + ], + 'totalTax' => [ + 'label' => t('Total Tax', category: 'commerce'), + 'placeholder' => '¤' . I18N::getFormatter()->asDecimal(19.99), + ], + 'totalIncludedTax' => [ + 'label' => t('Total Included Tax', category: 'commerce'), + 'placeholder' => '¤' . I18N::getFormatter()->asDecimal(19.99), + ], + 'dateOrdered' => [ + 'label' => t('Date Ordered', category: 'commerce'), + 'placeholder' => I18N::getFormatter()->asDate(time(), 'short'), + ], + 'datePaid' => [ + 'label' => t('Date Paid', category: 'commerce'), + 'placeholder' => I18N::getFormatter()->asDate(time(), 'short'), + ], + 'dateFirstPaid' => [ + 'label' => t('Date First Paid', category: 'commerce'), + 'placeholder' => I18N::getFormatter()->asDate(time(), 'short'), + ], + 'dateUpdated' => [ + 'label' => t('Date Updated', category: 'commerce'), + 'placeholder' => I18N::getFormatter()->asDate(time(), 'short'), + ], + 'email' => [ + 'label' => t('Email', category: 'commerce'), + 'placeholder' => 'user@example.com', + ], + 'customer' => [ + 'label' => t('Customer', category: 'commerce'), + 'placeholder' => t('Customer', category: 'commerce'), + ], + 'shippingFullName' => [ + 'label' => t('Shipping Full Name', category: 'commerce'), + 'placeholder' => t('Shipping Full Name', category: 'commerce'), + ], + 'shippingFirstName' => [ + 'label' => t('Shipping First Name', category: 'commerce'), + 'placeholder' => t('Shipping First Name', category: 'commerce'), + ], + 'shippingLastName' => [ + 'label' => t('Shipping Last Name', category: 'commerce'), + 'placeholder' => t('Shipping Last Name', category: 'commerce'), + ], + 'billingFullName' => [ + 'label' => t('Billing Full Name', category: 'commerce'), + 'placeholder' => t('Billing Full Name', category: 'commerce'), + ], + 'billingFirstName' => [ + 'label' => t('Billing First Name', category: 'commerce'), + 'placeholder' => t('Billing First Name', category: 'commerce'), + ], + 'billingLastName' => [ + 'label' => t('Billing Last Name', category: 'commerce'), + 'placeholder' => t('Billing Last Name', category: 'commerce'), + ], + 'shippingOrganizationName' => [ + 'label' => t('Shipping Business Name', category: 'commerce'), + 'placeholder' => t('Shipping Business Name', category: 'commerce'), + ], + 'billingOrganizationName' => [ + 'label' => t('Billing Business Name', category: 'commerce'), + 'placeholder' => t('Billing Business Name', category: 'commerce'), + ], + 'shippingMethodName' => [ + 'label' => t('Shipping Method', category: 'commerce'), + 'placeholder' => t('Shipping Method', category: 'commerce'), + ], + 'gatewayName' => [ + 'label' => t('Gateway', category: 'commerce'), + 'placeholder' => t('Gateway', category: 'commerce'), + ], + 'paidStatus' => [ + 'label' => t('Paid Status', category: 'commerce'), + 'placeholder' => app(StatusHtml::class)->statusLabelHtml(['color' => 'green', 'label' => t('Paid', category: 'commerce')]), + ], + 'couponCode' => [ + 'label' => t('Coupon Code', category: 'commerce'), + 'placeholder' => 'SAVE10', + ], + 'itemTotal' => [ + 'label' => t('Item Total', category: 'commerce'), + 'placeholder' => '¤' . I18N::getFormatter()->asDecimal(99.99), + ], + 'itemSubtotal' => [ + 'label' => t('Item Subtotal', category: 'commerce'), + 'placeholder' => '¤' . I18N::getFormatter()->asDecimal(89.99), + ], + 'orderSite' => [ + 'label' => t('Order Site', category: 'commerce'), + 'placeholder' => $site->name, + ], + 'reference' => [ + 'label' => t('Reference', category: 'commerce'), + 'placeholder' => 'ORD-XXXXX', + ], + ]); + } + + #[Override] + protected static function defineDefaultCardAttributes(): array + { + return array_merge(parent::defineDefaultCardAttributes(), [ + 'reference', + 'orderStatus', + 'totalPrice', + ]); + } + + /** + * Updates the adjustments, including deleting the old ones. + */ + private function _saveAdjustments(): void + { + $newAdjustmentIds = []; + + foreach ($this->getAdjustments() as $adjustment) { + try { + // Don't run validation as validation of the adjustment should happen before saving the order + app(OrderAdjustments::class)->saveOrderAdjustment($adjustment, false); + } catch (OrderAdjustmentNotFoundException) { + // If the adjustment was not found, it means it may have previously existed but was already deleted (race condition). + // See: https://github.com/craftcms/commerce/issues/3283 + continue; + } + + $newAdjustmentIds[] = $adjustment->id; + $adjustment->orderId = $this->id; + } + + // Make sure all other adjustments have been cleaned up. + DB::table(Table::ORDERADJUSTMENTS) + ->where('orderId', $this->id) + ->whereNotIn('id', $newAdjustmentIds) + ->delete(); + } + + private function _saveNotices(): void + { + $previousNoticeIds = OrderNoticeRecord::where('orderId', $this->id)->pluck('id')->all(); + + $currentNoticeIds = []; + + // We are never updating a notice, just adding it or keeping it. + foreach (array_merge($this->getNotices(), $this->getAdminNotices()) as $notice) { + if ($notice->id === null) { + $orderNoticeEvent = new OrderNoticeEvent( + orderNotice: $notice, + ); + + // Raising the 'beforeAddNoticeToOrder' event + if ($this->hasEventHandlers(self::EVENT_BEFORE_APPLY_ADD_NOTICE)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $this->trigger(self::EVENT_BEFORE_APPLY_ADD_NOTICE, $orderNoticeEvent); + + if ($orderNoticeEvent->isValid === false) { + continue; + } + } + $noticeRecord = new OrderNoticeRecord(); + $noticeRecord->orderId = $notice->orderId; + $noticeRecord->type = $notice->type; + $noticeRecord->attribute = $notice->attribute; + $noticeRecord->message = $notice->message; + $noticeRecord->noticeType = $notice->noticeType->value; + if ($noticeRecord->save()) { + $notice->id = $noticeRecord->id; + } + } + + $currentNoticeIds[] = $notice->id; + } + + // Delete any notices that are no longer on the order + if ($deletableNoticeIds = array_diff($previousNoticeIds, $currentNoticeIds)) { + OrderNoticeRecord::whereIn('id', $deletableNoticeIds)->delete(); + } + } + + /** + * Updates the line items, including deleting the old ones. + */ + private function _saveLineItems(): void + { + // Line items that are currently in the DB + $previousLineItems = $this->id ? app(LineItems::class)->getAllLineItemsByOrderId($this->id) : []; + + $currentLineItemIds = []; + + // Determine the line items that will be saved + foreach ($this->getLineItems() as $lineItem) { + // If the ID is null that's ok, it's a new line item and will be saved anyway + $currentLineItemIds[] = $lineItem->id; + } + + // Delete any line items that no longer will be saved on this order. + foreach ($previousLineItems as $previousLineItem) { + if (!in_array($previousLineItem->id, $currentLineItemIds, false)) { + DB::table(Table::LINEITEMS)->where('id', $previousLineItem->id)->delete(); + + if ($this->hasEventHandlers(self::EVENT_AFTER_APPLY_REMOVE_LINE_ITEM)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $this->trigger(self::EVENT_AFTER_APPLY_REMOVE_LINE_ITEM, new LineItemEvent( + lineItem: $previousLineItem, + )); + } + } + } + + // Save the line items last, as we know that any possible duplicates are already removed. + // We also need to re-save any adjustments that didn't have a line item ID for a line item if it's new. + foreach ($this->getLineItems() as $lineItem) { + $originalId = $lineItem->id; + $lineItem->setOrder($this); // just in case. + + try { + // Don't run validation as validation of the line item should happen before saving the order + app(LineItems::class)->saveLineItem($lineItem, false); + } catch (LineItemNotFoundException) { + // If the line item was not found, it means it may have previously existed but was already deleted (race condition). + // See: https://github.com/craftcms/commerce/issues/3283 + continue; + } + + // Is this a new line item? + if ($originalId === null) { + // Raising the 'afterAddLineItemToOrder' event + if ($this->hasEventHandlers(self::EVENT_AFTER_APPLY_ADD_LINE_ITEM)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $this->trigger(self::EVENT_AFTER_APPLY_ADD_LINE_ITEM, new LineItemEvent( + lineItem: $lineItem, + isNew: true, + )); + } + } + + // Update any adjustments to this line item with the new line item ID. + foreach ($this->getAdjustments() as $adjustment) { + // Was the adjustment for this line item, but the line item ID didn't exist when the adjustment was made? + if ($adjustment->getLineItem() === $lineItem && !$adjustment->lineItemId) { + // Re-save the adjustment with the new line item ID, since it exists now. + $adjustment->lineItemId = $lineItem->id; + // Validation not needed as the adjustments are validated before the order is saved + try { + app(OrderAdjustments::class)->saveOrderAdjustment($adjustment, false); + } catch (OrderAdjustmentNotFoundException) { + // This can happen if the adjustment was removed during a race condition recalculation. + continue; + } + } + } + } + } + + /** + * Delete all addresses that are owned by the order but are not in use. + */ + private function _deleteOrphanedOrderAddresses(): void + { + if (!$this->id) { + return; + } + + $safeIds = array_filter([ + $this->getBillingAddress()?->id, + $this->getShippingAddress()?->id, + $this->getEstimatedBillingAddress()?->id, + $this->getEstimatedShippingAddress()?->id, + ]); + + $orphanedAddresses = AddressElement::find() + ->ownerId($this->id); + + if (!empty($safeIds)) { + array_unshift($safeIds, 'not'); + $orphanedAddresses->id($safeIds); + } + + ($orphanedAddresses->collect())->each(function(AddressElement $address) { + Elements::deleteElement($address, true); + }); + } + + private function _saveOrderHistory(?int $oldStatusId, ?int $currentOrderStatId): void + { + $hasNewStatus = ($oldStatusId !== $currentOrderStatId); + if ($this->isCompleted && $hasNewStatus) { + if (!app(OrderHistories::class)->createOrderHistoryFromOrder($this, $oldStatusId)) { + Log::error('Error saving order history after order save.'); + } + } + } + + /** + * Sets the first and last name attributes on the address model if no full name is set. + */ + private function _populateAddressNameAttributes(AddressElement $addressElement, array $address): void + { + if (!isset($address['fullName']) || !$address['fullName']) { + $firstName = $address['firstName'] ?? null; + $lastName = $address['lastName'] ?? null; + + if ($firstName !== null || $lastName !== null) { + $addressElement->fullName = null; + $addressElement->firstName = $firstName ?? $addressElement->firstName; + $addressElement->lastName = $lastName ?? $addressElement->lastName; + } + } + } +} diff --git a/src/Order/Enums/OrderNoticeType.php b/src/Order/Enums/OrderNoticeType.php new file mode 100644 index 0000000000..d5472155a1 --- /dev/null +++ b/src/Order/Enums/OrderNoticeType.php @@ -0,0 +1,16 @@ + $query */ + #[\Override] + public function export(ElementQueryInterface $query): mixed + { + // This export should be identical to the parent, except for the additional extra fields + $extraAttributes = ['adjustments', 'billingAddress', 'shippingAddress', 'transactions']; + + // Eager-load as much as we can + $eagerLoadableFields = []; + foreach (app(Fields::class)->getAllFields() as $field) { + if ($field instanceof EagerLoadingFieldInterface) { + $eagerLoadableFields[] = [ + 'path' => $field->handle, + 'criteria' => [ + 'status' => null, + ], + ]; + } + } + + $data = []; + + $query->with($eagerLoadableFields); + + $query->each(function(ElementInterface $element) use (&$data, $extraAttributes) { + // Get the basic array representation excluding custom fields + $attributes = array_flip($element->attributes()); + if (($fieldLayout = $element->getFieldLayout()) !== null) { + foreach ($fieldLayout->getCustomFields() as $field) { + unset($attributes[$field->handle]); + } + } + + $datetimeAttributes = ComponentHelper::datetimeAttributes($element); + $otherAttributes = array_diff(array_keys($attributes), $datetimeAttributes); + $elementArr = $element->toArray($otherAttributes, $extraAttributes); + + foreach ($datetimeAttributes as $attribute) { + $date = $element->$attribute; + $elementArr[$attribute] = $date ? DateTimeHelper::toIso8601($date) : $element->$attribute; + } + + if ($fieldLayout !== null) { + foreach ($fieldLayout->getCustomFields() as $field) { + $value = $element->getFieldValue($field->handle); + $elementArr[$field->handle] = $field->serializeValue($value, $element); + } + } + + $data[] = $elementArr; + }, 100); + + return $data; + } +} diff --git a/src/Order/Exporters/LineItemExport.php b/src/Order/Exporters/LineItemExport.php new file mode 100644 index 0000000000..76750596af --- /dev/null +++ b/src/Order/Exporters/LineItemExport.php @@ -0,0 +1,79 @@ +ids(); + + return DB::table(Table::LINEITEMS . ' as lineitems') + ->select([ + 'lineitems.id', + 'lineitems.orderId', + 'lineitems.purchasableId', + 'lineitems.description', + 'lineitems.sku', + 'lineitems.taxCategoryId', + 'lineitems.lineItemStatusId', + 'lineitems.shippingCategoryId', + 'lineitems.options', + 'lineitems.optionsSignature', + 'lineitems.price', + 'lineitems.promotionalAmount', + 'lineitems.salePrice', + 'lineitems.qty', + 'lineitems.subtotal', + 'lineitems.total', + 'lineitems.weight', + 'lineitems.height', + 'lineitems.length', + 'lineitems.width', + 'lineitems.note', + 'lineitems.privateNote', + 'lineitems.snapshot', + 'lineitems.dateCreated', + 'lineitems.dateUpdated', + 'lineitems.uid', + ]) + ->selectSub($this->lineItemAdjustmentTotal(Tax::ADJUSTMENT_TYPE)->where('included', 0), 'totalTax') + ->selectSub($this->lineItemAdjustmentTotal(Tax::ADJUSTMENT_TYPE)->where('included', 1), 'totalTaxIncluded') + ->selectSub($this->lineItemAdjustmentTotal(Shipping::ADJUSTMENT_TYPE), 'totalShipping') + ->selectSub($this->lineItemAdjustmentTotal(Discount::ADJUSTMENT_TYPE), 'totalDiscount') + ->leftJoin(Table::ORDERS . ' as orders', 'lineitems.orderId', '=', 'orders.id') + ->whereIn('lineitems.orderId', $orderIds) + ->get() + ->map(fn($row) => (array)$row) + ->all(); + } + + private function lineItemAdjustmentTotal(string $adjustmentType): Builder + { + return DB::table(Table::ORDERADJUSTMENTS . ' as adjustments') + ->selectRaw('SUM(amount)') + ->whereColumn('adjustments.orderId', 'lineitems.orderId') + ->whereColumn('adjustments.lineItemId', 'lineitems.id') + ->where('type', $adjustmentType); + } +} diff --git a/src/Order/Exporters/OrderExport.php b/src/Order/Exporters/OrderExport.php new file mode 100644 index 0000000000..f9d245ecee --- /dev/null +++ b/src/Order/Exporters/OrderExport.php @@ -0,0 +1,72 @@ +ids(); + + return DB::table(Table::ORDERS) + ->select([ + 'id', + 'number', + 'email', + 'gatewayId', + 'paymentSourceId', + 'customerId', + 'orderStatusId', + 'couponCode', + 'itemTotal', + 'totalPrice', + 'totalPaid', + 'paidStatus', + 'isCompleted', + 'dateOrdered', + 'datePaid', + 'currency', + 'paymentCurrency', + 'lastIp', + 'orderLanguage', + 'message', + 'shippingMethodHandle', + ]) + ->selectSub($this->orderAdjustmentTotal(Tax::ADJUSTMENT_TYPE)->where('included', 0), 'totalTax') + ->selectSub($this->orderAdjustmentTotal(Tax::ADJUSTMENT_TYPE)->where('included', 1), 'totalTaxIncluded') + ->selectSub($this->orderAdjustmentTotal(Shipping::ADJUSTMENT_TYPE), 'totalShipping') + ->selectSub($this->orderAdjustmentTotal(Discount::ADJUSTMENT_TYPE), 'totalDiscount') + ->whereIn('id', $orderIds) + ->get() + ->map(fn($row) => (array)$row) + ->all(); + } + + private function orderAdjustmentTotal(string $adjustmentType): Builder + { + return DB::table(Table::ORDERADJUSTMENTS) + ->selectRaw('SUM(amount)') + ->whereColumn('orderId', Table::ORDERS . '.id') + ->where('type', $adjustmentType); + } +} diff --git a/src/Order/LineItem/Data/LineItem.php b/src/Order/LineItem/Data/LineItem.php new file mode 100644 index 0000000000..f4b7eccea3 --- /dev/null +++ b/src/Order/LineItem/Data/LineItem.php @@ -0,0 +1,781 @@ +price`) routes + * through the matching `getPrice()`/`setPrice()` methods exactly like it did on the legacy Yii2 + * `Model` — this matters a great deal here, since a lot of still-legacy code (adjusters) and + * already-migrated code (`Order::recalculate()`) reads computed values like `$lineItem->salePrice` + * as a bare property. An Eloquent model would NOT route bare property access through a same-named + * `getSalePrice()` method (Eloquent's `__get()` only consults real attributes/casts/accessors), so + * unlike {@see Order} this is deliberately NOT a unified Eloquent class — persistence is handled by + * the separate, thin {@see \CraftCms\Commerce\Order\LineItem\Models\LineItem} Eloquent model instead, + * mirroring the `Entry\Data\EntryType` / `Entry\Models\EntryType` split in cms-6. + * + * @property float $price + * @property-read float $salePrice + * @property-read string $salePriceAsCurrency + */ +class LineItem extends Component implements HasStoreInterface +{ + public ?int $id = null; + + public LineItemType $type = LineItemType::Purchasable; + + public float $weight = 0; + + public float $length = 0; + + public float $height = 0; + + public float $width = 0; + + public int $qty = 1; + + public string $note = ''; + + public string $privateNote = ''; + + public ?int $purchasableId = null; + + public ?int $orderId = null; + + public ?int $lineItemStatusId = null; + + public ?int $taxCategoryId = null; + + public ?int $shippingCategoryId = null; + + public ?DateTime $dateCreated = null; + + public ?DateTime $dateUpdated = null; + + public ?string $uid = null; + + private ?string $_description = null; + + private float $_price = 0; + + private ?float $_promotionalPrice = null; + + private ?float $_salePrice = null; + + private ?array $_snapshot = null; + + private ?string $_sku = null; + + private array $_options = []; + + private ?PurchasableInterface $_purchasable = null; + + private ?Order $_order = null; + + private ?LineItemStatus $_lineItemStatus = null; + + private ?bool $_isPromotable = null; + + private ?bool $_hasFreeShipping = null; + + private ?bool $_isTaxable = null; + + private ?bool $_isShippable = null; + + public function __construct($config = []) + { + parent::__construct($config); + + $this->note = LitEmoji::shortcodeToUnicode($this->note); + $this->privateNote = LitEmoji::shortcodeToUnicode($this->privateNote); + } + + /** + * @throws StoreNotFoundException + */ + public function getStore(): Store + { + if (!$this->getOrder()) { + throw new StoreNotFoundException('Cannot determine line item store without an order assigned to the line item.'); + } + + return $this->getOrder()->getStore(); + } + + public function getOrder(): ?Order + { + if ($this->_order === null && $this->orderId) { + $this->_order = app(Orders::class)->getOrderById($this->orderId); + } + + return $this->_order; + } + + public function setOrder(Order $order): void + { + $this->orderId = $order->id; + $this->_order = $order; + } + + public function getLineItemStatus(): ?LineItemStatus + { + if ($this->_lineItemStatus === null && $this->lineItemStatusId) { + $this->_lineItemStatus = app(LineItemStatuses::class)->getLineItemStatusById($this->lineItemStatusId, $this->getOrder()?->getStore()->id); + } + + return $this->_lineItemStatus; + } + + public function setLineItemStatus(?LineItemStatus $status = null): void + { + if ($status !== null) { + $this->_lineItemStatus = $status; + $this->lineItemStatusId = (int)$status->id; + } else { + $this->lineItemStatusId = null; + $this->_lineItemStatus = null; + } + } + + public function getOptions(): array + { + return $this->_options; + } + + public function setOptions(array|string $options): void + { + $options = Json::decodeIfJson($options); + + if (!is_array($options)) { + $options = []; + } + + // @TODO Normalize emoji handling in options to a consistent shape across DB drivers (currently only stripped when MB4 is unsupported); breaking change targeted for Commerce 6.0 #COM-46 + $this->_options = $options; + } + + public function getSnapshot(): array + { + return $this->_snapshot ?? []; + } + + public function setSnapshot(array|string $snapshot): void + { + $snapshot = Json::decodeIfJson($snapshot); + + if (!is_array($snapshot)) { + $snapshot = []; + } + + $this->_snapshot = $snapshot; + } + + public function getDescription(): string + { + if ($this->_description === null || $this->_description === '') { + return (string)($this->getSnapshot()['description'] ?? ''); + } + + return $this->_description; + } + + public function setDescription(?string $description): void + { + $this->_description = (string)$description; + } + + public function getSku(): string + { + if ($this->_sku === null) { + return (string)($this->getSnapshot()['sku'] ?? ''); + } + + return $this->_sku; + } + + public function setSku(?string $sku): void + { + $this->_sku = (string)$sku; + } + + /** + * Returns a unique hash of the line item options. + */ + public function getOptionsSignature(): string + { + $lineItemId = $this->getOrder()?->isCompleted ? $this->id : null; + + return LineItemHelper::generateOptionsSignature($this->getOptions(), $lineItemId); + } + + public function getPrice(): float + { + return Currency::round($this->_price); + } + + public function setPrice(float|int $price): void + { + $this->_price = (float)$price; + // clear sale price cache + $this->_salePrice = null; + } + + public function getPromotionalPrice(): ?float + { + if ($this->_promotionalPrice === null) { + return null; + } + + return Currency::round($this->_promotionalPrice); + } + + public function setPromotionalPrice(float|int|null $price): void + { + $this->_promotionalPrice = $price !== null ? (float)$price : null; + // clear sale price cache + $this->_salePrice = null; + } + + public function getSalePrice(): float + { + if ($this->_salePrice === null) { + $this->_salePrice = $this->getOnPromotion() ? $this->getPromotionalPrice() : $this->getPrice(); + } + + return $this->_salePrice; + } + + public function getPromotionalAmount(): float + { + if ($this->getPromotionalPrice() === null) { + return 0; + } + + return Currency::round($this->getPrice() - $this->getPromotionalPrice()); + } + + /** + * Returns legacy-shaped validation rules for this line item, merging in any purchasable-supplied + * rules from {@see PurchasableInterface::getLineItemRules()}. + * + * @TODO Not yet wired up to a real validator. This is kept as a plain data method, in the same + * shape as the legacy `defineRules()`, pending the broader migration of line item validation onto + * the new Ruleset system. + */ + public function getValidationRules(): array + { + $rules = [ + [ + [ + 'optionsSignature', + 'price', + 'promotionalAmount', + 'weight', + 'length', + 'height', + 'width', + 'qty', + 'taxCategoryId', + 'type', + 'shippingCategoryId', + ], 'required', + ], + [['snapshot'], 'required', 'when' => fn() => $this->type === LineItemType::Purchasable], + [['qty'], 'integer', 'min' => 1], + [['shippingCategoryId', 'taxCategoryId'], 'integer'], + [['price'], 'number', 'min' => 0], + [['promotionalPrice'], 'number', 'min' => 0, 'skipOnEmpty' => true], + [['orderId', 'purchasableId', 'hasFreeShipping', 'isPromotable', 'isShippable', 'isTaxable', 'type'], 'safe'], + ]; + + if ($this->type === LineItemType::Purchasable && $this->purchasableId) { + $order = $this->getOrder(); + $purchasable = app(Purchasables::class)->getPurchasableById($this->purchasableId, $order?->orderSiteId, $order?->getCustomer()?->id); + if ($purchasable && !empty($purchasableRules = $purchasable->getLineItemRules($this))) { + foreach ($purchasableRules as $rule) { + $rules[] = $this->_normalizePurchasableRule($rule, $purchasable); + } + } + } + + // @TODO Add a validation rule preventing qty from being reduced below the total fulfilled quantity across inventory locations when the order is complete + + return $rules; + } + + public function getFulfilledTotalQuantity(): int + { + if ($order = $this->getOrder()) { + return (int)app(Inventory::class)->getInventoryFulfillmentLevels($order) + ->filter(fn($fulfillment) => $fulfillment->getLineItem()->id === $this->id) + ->sum('fulfilledQuantity'); + } + + return 0; + } + + /** + * Normalizes a purchasable's validation rule. + */ + private function _normalizePurchasableRule(mixed $rule, PurchasableInterface $purchasable): mixed + { + if (isset($rule[1]) && $rule[1] instanceof Closure) { + $method = $rule[1]; + $method = $method->bindTo($purchasable); + $rule[1] = static function($attribute, $params, $validator, $current) use ($method) { + $method($attribute, $params, $validator, $current); + }; + } + + return $rule; + } + + /** + * The attributes on the line item that should be made available as formatted currency. + */ + public function currencyAttributes(): array + { + return [ + 'price', + 'promotionalPrice', + 'promotionalAmount', + 'salePrice', + 'subtotal', + 'total', + 'discount', + 'shippingCost', + 'tax', + 'taxIncluded', + 'adjustmentsTotal', + ]; + } + + /** + * Mirrors {@see Order::_currencyAttributeAsCurrency()}. `CurrencyAttributeBehavior` (the legacy + * behaviour this replaces) resolved the default currency via the owner's `getStore()->getCurrency()` + * whenever the owner implemented `HasStoreInterface`, which `LineItem` does. + */ + private function _currencyAttributeAsCurrency(float $amount): string + { + return Currency::formatAsCurrency($amount, $this->getStore()->getCurrency()); + } + + public function getPriceAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency($this->getPrice()); + } + + public function getPromotionalPriceAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency($this->getPromotionalPrice() ?? 0); + } + + public function getPromotionalAmountAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency($this->getPromotionalAmount()); + } + + public function getSalePriceAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency($this->getSalePrice()); + } + + public function getSubtotalAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency($this->getSubtotal()); + } + + public function getTotalAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency($this->getTotal()); + } + + public function getDiscountAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency($this->getDiscount()); + } + + public function getShippingCostAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency($this->getShippingCost()); + } + + public function getTaxAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency($this->getTax()); + } + + public function getTaxIncludedAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency($this->getTaxIncluded()); + } + + public function getAdjustmentsTotalAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency($this->getAdjustmentsTotal()); + } + + public function getSubtotal(): float + { + return Currency::round($this->qty * $this->getSalePrice()); + } + + /** + * Returns the Purchasable's sale price multiplied by the quantity of the line item, plus any adjustment belonging to this lineitem. + */ + public function getTotal(): float + { + return (float)$this->getOrder()->getTeller()->add($this->getSubtotal(), $this->getAdjustmentsTotal()); + } + + public function getTaxableSubtotal(string $taxable): float + { + return match ($taxable) { + TaxRateRecord::TAXABLE_SHIPPING => $this->getShippingCost(), + TaxRateRecord::TAXABLE_PRICE_SHIPPING => (float)$this->getOrder()->getTeller()->sum($this->getSubtotal(), $this->getDiscount(), $this->getShippingCost()), + default => (float)$this->getOrder()->getTeller()->add($this->getSubtotal(), $this->getDiscount()), // TaxRateRecord::TAXABLE_PRICE is default + }; + } + + public function refresh(): bool + { + if ($this->type === LineItemType::Custom) { + return true; + } + + return $this->_refreshFromPurchasable(); + } + + /** + * @return bool False when no related purchasable exists + */ + private function _refreshFromPurchasable(): bool + { + if ($this->type === LineItemType::Custom) { + throw new \Exception('Cannot refresh a custom line item from a purchasable'); + } + + if ($this->qty <= 0 && $this->id) { + return false; + } + + $purchasable = $this->getPurchasable(); + if (!$purchasable || !app(Purchasables::class)->isPurchasableAvailable($purchasable, $this->getOrder())) { + return false; + } + + $this->_populateFromPurchasable($purchasable); + + return true; + } + + public function setHasFreeShipping(?bool $hasFreeShipping): void + { + $this->_hasFreeShipping = $hasFreeShipping; + } + + public function getHasFreeShipping(): bool + { + // For purchasable line item types try and get the live data + if ($this->type === LineItemType::Purchasable && $this->getPurchasable()) { + return $this->getPurchasable()->hasFreeShipping(); + } + + return $this->_hasFreeShipping ?? false; + } + + public function getPurchasable(): ?PurchasableInterface + { + if ($this->type === LineItemType::Custom) { + throw new RuntimeException('Cannot get a purchasable for a custom line item'); + } + + if (!isset($this->_purchasable) && isset($this->purchasableId)) { + $order = $this->getOrder(); + $purchasable = app(Purchasables::class)->getPurchasableById($this->purchasableId, $order?->orderSiteId, $order?->getCustomer()?->id); + + // If we are still using sales we need to make sure that the promotional price is set. + if (!app(CatalogPricingRules::class)->canUseCatalogPricingRules()) { + if ($purchasable instanceof Purchasable) { + $purchasable->loadSales($this->getOrder()); + } + } + + $this->_purchasable = $purchasable; + } + + return $this->_purchasable; + } + + public function setPurchasable(PurchasableInterface $purchasable): void + { + $this->purchasableId = $purchasable->getId(); + $this->_purchasable = $purchasable; + $this->type = LineItemType::Purchasable; + } + + public function populate(mixed $data = null): void + { + if ($this->type === LineItemType::Custom) { + return; + } + + if ($data) { + $this->_populateFromPurchasable($data); + } + } + + private function _populateFromPurchasable(PurchasableInterface $purchasable): void + { + if ($this->type === LineItemType::Custom) { + throw new \Exception('Cannot populate a custom line item from a purchasable'); + } + + // Set all things from the purchasable interface that are applicable to the line item. + $this->purchasableId = $purchasable->getId(); + $this->setPrice($purchasable->getPrice()); + $this->setPromotionalPrice($purchasable->getPromotionalPrice()); + $this->taxCategoryId = $purchasable->getTaxCategory()->id; + $this->shippingCategoryId = $purchasable->getShippingCategory()->id; + $this->setSku($purchasable->getSku()); + $this->setDescription($purchasable->getDescription()); + + // Check to see if there is a discount applied that ignores promotions for this line item + $ignorePromotions = false; + foreach (app(Discounts::class)->getAllActiveDiscounts($this->getOrder()) as $discount) { + if (app(Discounts::class)->matchLineItem($this, $discount, true)) { + // Break if matched discount is set to ignore promotions. + $ignorePromotions = $discount->ignorePromotions; + if ($ignorePromotions) { + break; + } + + // Break if matched discount is set to not apply any subsequent discounts. + if ($discount->stopProcessing) { + break; + } + } + } + + // One of the matching discounts has ignored promotions, so we want to remove any promotional price. + if ($ignorePromotions) { + $this->setPromotionalPrice(null); + } + + $snapshot = [ + // @TODO Move these common snapshot fields (price, sku, description, purchasableId, cpEditUrl, options) into the base purchasable's getSnapshot() in Commerce 6.0 + 'price' => $purchasable->getPrice(), + 'sku' => $purchasable->getSku(), + 'description' => $purchasable->getDescription(), + 'purchasableId' => $purchasable->getId(), + 'cpEditUrl' => '#', + 'options' => $this->getOptions(), + // Only add sales information to the snapshot if we are not ignoring promotions and they are still using the sales system. + 'sales' => $ignorePromotions || app(CatalogPricingRules::class)->canUseCatalogPricingRules() ? [] : app(Sales::class)->getSalesForPurchasable($purchasable, $this->getOrder()), + ]; + + // Add our purchasable data to the snapshot, save our sales. + $purchasableSnapshot = $purchasable->getSnapshot(); + $this->setSnapshot(array_merge($purchasableSnapshot, $snapshot)); + + $purchasable->populateLineItem($this); + + // TODO: migrate event firing to Laravel once event system is bridged + $lineItemsService = Plugin::getInstance()->getLineItems(); + + if ($lineItemsService->hasEventHandlers($lineItemsService::EVENT_POPULATE_LINE_ITEM)) { + $event = new LineItemEvent( + lineItem: $this, + isNew: !$this->id, + ); + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $lineItemsService->trigger($lineItemsService::EVENT_POPULATE_LINE_ITEM, $event); + } + } + + public function setIsPromotable(?bool $isPromotable): void + { + $this->_isPromotable = $isPromotable; + } + + public function getIsPromotable(): bool + { + // For purchasable line item types try and get the live data + if ($this->type === LineItemType::Purchasable && $this->getPurchasable()) { + return $this->getPurchasable()->getIsPromotable(); + } + + return $this->_isPromotable ?? false; + } + + public function getOnPromotion(): bool + { + return $this->getPromotionalAmount() > 0; + } + + public function getTaxCategory(): TaxCategory + { + // Category may have been archived + $categories = app(TaxCategories::class)->getAllTaxCategories(true); + return collect($categories)->firstWhere('id', $this->taxCategoryId); + } + + /** + * @throws StoreNotFoundException + */ + public function getShippingCategory(): ShippingCategory + { + if (!isset($this->shippingCategoryId)) { + throw new RuntimeException('Line Item is missing its shipping category ID'); + } + + // Category may have been archived + $categories = app(ShippingCategories::class)->getAllShippingCategories(withTrashed: true); + return $categories->firstWhere('id', $this->shippingCategoryId); + } + + /** + * @return \CraftCms\Commerce\Order\Models\OrderAdjustment[] + */ + public function getAdjustments(): array + { + $lineItemAdjustments = []; + + $adjustments = $this->getOrder()->getAdjustments(); + + foreach ($adjustments as $adjustment) { + // Since the line item may not yet be saved and won't have an ID, we need to check the adjuster references this as it's line item. + if (($adjustment->lineItemId && $adjustment->lineItemId == $this->id) || (!$adjustment->lineItemId && $adjustment->getLineItem() === $this)) { + $lineItemAdjustments[] = $adjustment; + } + } + + return $lineItemAdjustments; + } + + public function getAdjustmentsTotal(bool $included = false): float + { + $amount = 0.0; + $teller = $this->_getTeller(); + foreach ($this->getAdjustments() as $adjustment) { + if ($adjustment->included == $included) { + $amount = (float)$teller->add($amount, $adjustment->amount); + } + } + + return $amount; + } + + private function _getAdjustmentsTotalByType(string $type, bool $included = false): float + { + $amount = 0.0; + $teller = $this->_getTeller(); + foreach ($this->getAdjustments() as $adjustment) { + if ($adjustment->included == $included && $adjustment->type === $type) { + $amount = (float)$teller->add($amount, $adjustment->amount); + } + } + + return $amount; + } + + public function setIsTaxable(?bool $isTaxable): void + { + $this->_isTaxable = $isTaxable; + } + + public function getIsTaxable(): bool + { + if ($this->type === LineItemType::Custom) { + return $this->_isTaxable ?? false; + } + + if (!$this->getPurchasable()) { + return $this->_isTaxable ?? true; // we have a default tax category so assume so. + } + + return $this->getPurchasable()->getIsTaxable(); + } + + public function setIsShippable(?bool $isShippable): void + { + $this->_isShippable = $isShippable; + } + + public function getIsShippable(): bool + { + if ($this->type === LineItemType::Custom) { + return $this->_isShippable ?? false; + } + + if (!$this->getPurchasable()) { + return $this->_isShippable ?? true; // we have a default shipping category so assume so. + } + + return app(Purchasables::class)->isPurchasableShippable($this->getPurchasable(), $this->getOrder()); + } + + public function getTax(): float + { + return $this->_getAdjustmentsTotalByType('tax'); + } + + public function getTaxIncluded(): float + { + return $this->_getAdjustmentsTotalByType('tax', true); + } + + public function getShippingCost(): float + { + return $this->_getAdjustmentsTotalByType('shipping'); + } + + public function getDiscount(): float + { + return $this->_getAdjustmentsTotalByType('discount'); + } + + private function _getTeller(): Teller + { + if (!$order = $this->getOrder()) { + throw new RuntimeException('Line Item requires an order to calculate costs.'); + } + + return $order->getTeller(); + } +} diff --git a/src/Order/LineItem/Enums/LineItemType.php b/src/Order/LineItem/Enums/LineItemType.php new file mode 100644 index 0000000000..ac01cc14c2 --- /dev/null +++ b/src/Order/LineItem/Enums/LineItemType.php @@ -0,0 +1,30 @@ + t('Custom', category: 'commerce'), + self::Purchasable => t('Purchasable', category: 'commerce'), + }; + } +} diff --git a/src/Order/LineItem/LineItems.php b/src/Order/LineItem/LineItems.php new file mode 100644 index 0000000000..e0326920f2 --- /dev/null +++ b/src/Order/LineItem/LineItems.php @@ -0,0 +1,382 @@ +where('orderId', $orderId) + ->orderByDesc('dateCreated') + ->get() + ->map(fn(LineItemRecord $record) => $this->_toData($record)) + ->all(); + } + + /** + * Takes an order, a purchasable ID, options, and resolves it to a line item. + * + * If a line item is found for that order ID with those exact options, that line item is + * returned. Otherwise, a new line item is returned. + * + * @throws \Exception + */ + public function resolveLineItem(Order $order, int $purchasableId, array $options = [], array $params = []): LineItem + { + $signature = LineItemHelper::generateOptionsSignature($options); + + $record = $order->id + ? LineItemRecord::query() + ->where('orderId', $order->id) + ->where('purchasableId', $purchasableId) + ->where('optionsSignature', $signature) + ->first() + : null; + + if ($record) { + return $this->_toData($record); + } + + $params = array_merge([ + 'qty' => 1, + 'options' => $options, + 'note' => '', + 'purchasableId' => $purchasableId, + ], $params); + + return $this->create($order, $params); + } + + /** + * @throws \Exception + */ + public function resolveCustomLineItem(Order $order, string $sku, array $options = []): LineItem + { + $signature = LineItemHelper::generateOptionsSignature($options); + + $record = $order->id + ? LineItemRecord::query() + ->where('orderId', $order->id) + ->where('sku', $sku) + ->where('optionsSignature', $signature) + ->where('type', LineItemType::Custom->value) + ->first() + : null; + + if ($record) { + return $this->_toData($record); + } + + return $this->create($order, [ + 'sku' => $sku, + 'options' => $options, + ], LineItemType::Custom); + } + + /** + * Save a line item. + * + * @param LineItem $lineItem The line item to save. + * @param bool $runValidation Whether the Line Item should be validated. + * @TODO `$runValidation` is not yet wired up to a real validator; `LineItem::getValidationRules()` + * still returns legacy-shaped rule arrays pending the broader migration of line item validation + * onto the new Ruleset system. + */ + public function saveLineItem(LineItem $lineItem, bool $runValidation = true): bool + { + $isNewLineItem = !$lineItem->id; + + // TODO: migrate event firing to Laravel once event system is bridged + $legacyService = Plugin::getInstance()->getLineItems(); + + if ($legacyService->hasEventHandlers(self::EVENT_BEFORE_SAVE_LINE_ITEM)) { + $event = new LineItemEvent( + lineItem: $lineItem, + isNew: $isNewLineItem, + ); + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $legacyService->trigger(self::EVENT_BEFORE_SAVE_LINE_ITEM, $event); + } + + $record = $this->_toRecord($lineItem); + + // Save this information for all line item types, even though live lookups will happen for line items with purchasables + $record->hasFreeShipping = $lineItem->getHasFreeShipping(); + $record->isPromotable = $lineItem->getIsPromotable(); + $record->isShippable = $lineItem->getIsShippable(); + $record->isTaxable = $lineItem->getIsTaxable(); + + $record->sku = $lineItem->getSku(); + $record->description = $lineItem->getDescription(); + $record->optionsSignature = $lineItem->getOptionsSignature(); + + $record->promotionalAmount = $lineItem->getPromotionalAmount(); + $record->salePrice = $lineItem->getSalePrice(); + $record->total = $lineItem->getTotal(); + $record->subtotal = $lineItem->getSubtotal(); + + $success = DB::transaction(fn() => $record->save()); + + if ($success) { + $lineItem->id = $record->id; + $lineItem->uid = $record->uid; + $lineItem->dateCreated = $record->dateCreated; + $lineItem->dateUpdated = $record->dateUpdated; + } + + if ($success && $legacyService->hasEventHandlers(self::EVENT_AFTER_SAVE_LINE_ITEM)) { + $event = new LineItemEvent( + lineItem: $lineItem, + isNew: $isNewLineItem, + ); + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $legacyService->trigger(self::EVENT_AFTER_SAVE_LINE_ITEM, $event); + } + + return $success; + } + + /** + * Get a line item by its ID. + */ + public function getLineItemById(int $id): ?LineItem + { + $record = LineItemRecord::query()->find($id); + + return $record ? $this->_toData($record) : null; + } + + /** + * @throws \Exception + */ + public function create(Order $order, array $params = [], LineItemType $type = LineItemType::Purchasable): LineItem + { + $params = array_merge([ + 'qty' => 1, + 'options' => [], + 'note' => '', + ], $params); + + $params['type'] = $type; + + if ($type === LineItemType::Purchasable && empty($params['purchasableId']) && empty($params['purchasable'])) { + throw new \InvalidArgumentException('Purchasable ID or Purchasable must be set'); + } + + $explicitPurchasable = $params['purchasable'] ?? null; + unset($params['purchasable']); + + $lineItem = new LineItem($params); + $lineItem->setOrder($order); + + if ($explicitPurchasable instanceof PurchasableInterface) { + $lineItem->setPurchasable($explicitPurchasable); + } + + if ($lineItem->type === LineItemType::Purchasable) { + $purchasable = $lineItem->getPurchasable(); + + if ($purchasable) { + $lineItem->setPurchasable($purchasable); + $lineItem->populate($purchasable); + } else { + throw new \InvalidArgumentException('Invalid purchasable ID'); + } + } else { + $lineItem->populate(); + } + + // TODO: migrate event firing to Laravel once event system is bridged + $legacyService = Plugin::getInstance()->getLineItems(); + if ($legacyService->hasEventHandlers(self::EVENT_CREATE_LINE_ITEM)) { + $event = new LineItemEvent( + lineItem: $lineItem, + isNew: true, + ); + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $legacyService->trigger(self::EVENT_CREATE_LINE_ITEM, $event); + } + + $lineItem->refresh(); + + return $lineItem; + } + + /** + * Deletes all line items associated with an order, per the order's ID. + * + * @return bool whether any line items were deleted + */ + public function deleteAllLineItemsByOrderId(int $orderId): bool + { + return (bool)LineItemRecord::query()->where('orderId', $orderId)->delete(); + } + + /** + * @param Order[] $orders + * @return Order[] + */ + public function eagerLoadLineItemsForOrders(array $orders): array + { + $orderIds = collect($orders)->pluck('id')->filter()->all(); + + $lineItemsByOrderId = LineItemRecord::query() + ->whereIn('orderId', $orderIds) + ->orderByDesc('dateCreated') + ->get() + ->map(fn(LineItemRecord $record) => $this->_toData($record)) + ->groupBy('orderId'); + + foreach ($orders as $key => $order) { + if ($lineItemsByOrderId->has($order->id)) { + $order->setLineItems($lineItemsByOrderId->get($order->id)->all()); + $orders[$key] = $order; + } + } + + return $orders; + } + + /** + * @throws \Throwable + */ + public function orderCompleteHandler(LineItem $lineItem, Order $order): void + { + // Called the after order complete method for the purchasable if there is one + if ($lineItem->type === LineItemType::Purchasable && $lineItem->getPurchasable()) { + $lineItem->getPurchasable()->afterOrderComplete($order, $lineItem); + } + + // Retrieve the default status for the current line item. This is a chance for + // developers to hook into an event for finer control + $defaultStatus = app(LineItemStatuses::class)->getDefaultLineItemStatusForLineItem($lineItem); + if (!$defaultStatus) { + return; + } + + // Set the status ID and save the line item + $lineItem->setLineItemStatus($defaultStatus); + $this->saveLineItem($lineItem, false); + } + + /** + * Hydrates a rich {@see LineItem} data object from a persisted {@see LineItemRecord} row. + * + * Built up via explicit property/setter assignment rather than passing `$record->getAttributes()` + * into the constructor's config array — several persisted columns (`optionsSignature`, `salePrice`, + * `subtotal`, `total`, `promotionalAmount`) back pure computed, setter-less getters on the Data + * object, so passing them as config would throw "Setting read-only property". + */ + private function _toData(LineItemRecord $record): LineItem + { + $lineItem = new LineItem(); + $lineItem->id = $record->id; + $lineItem->type = $record->type; + $lineItem->orderId = $record->orderId; + $lineItem->purchasableId = $record->purchasableId; + $lineItem->lineItemStatusId = $record->lineItemStatusId; + $lineItem->taxCategoryId = $record->taxCategoryId; + $lineItem->shippingCategoryId = $record->shippingCategoryId; + $lineItem->qty = $record->qty; + $lineItem->note = $record->note ?? ''; + $lineItem->privateNote = $record->privateNote ?? ''; + $lineItem->weight = $record->weight ?? 0; + $lineItem->length = $record->length ?? 0; + $lineItem->height = $record->height ?? 0; + $lineItem->width = $record->width ?? 0; + $lineItem->uid = $record->uid; + $lineItem->dateCreated = $record->dateCreated; + $lineItem->dateUpdated = $record->dateUpdated; + $lineItem->setOptions($record->options ?? []); + $lineItem->setSnapshot($record->snapshot ?? []); + $lineItem->setPrice($record->price ?? 0); + $lineItem->setPromotionalPrice($record->promotionalPrice); + $lineItem->setSku($record->sku); + $lineItem->setDescription($record->description); + $lineItem->setHasFreeShipping($record->hasFreeShipping); + $lineItem->setIsPromotable($record->isPromotable); + $lineItem->setIsShippable($record->isShippable); + $lineItem->setIsTaxable($record->isTaxable); + + return $lineItem; + } + + /** + * Finds (or creates) the {@see LineItemRecord} backing a {@see LineItem} data object, and copies + * every plain persisted attribute across. + */ + private function _toRecord(LineItem $lineItem): LineItemRecord + { + $record = $lineItem->id + ? (LineItemRecord::query()->find($lineItem->id) ?? new LineItemRecord()) + : new LineItemRecord(); + + $record->type = $lineItem->type; + $record->orderId = $lineItem->orderId; + $record->purchasableId = $lineItem->purchasableId; + $record->lineItemStatusId = $lineItem->lineItemStatusId; + $record->taxCategoryId = $lineItem->taxCategoryId; + $record->shippingCategoryId = $lineItem->shippingCategoryId; + $record->qty = $lineItem->qty; + $record->note = $lineItem->note; + $record->privateNote = $lineItem->privateNote; + $record->weight = $lineItem->weight; + $record->length = $lineItem->length; + $record->height = $lineItem->height; + $record->width = $lineItem->width; + $record->price = $lineItem->getPrice(); + $record->promotionalPrice = $lineItem->getPromotionalPrice(); + $record->options = $lineItem->getOptions(); + $record->snapshot = $lineItem->getSnapshot(); + + return $record; + } +} diff --git a/src/Order/LineItem/Models/LineItem.php b/src/Order/LineItem/Models/LineItem.php new file mode 100644 index 0000000000..3dd0e70c66 --- /dev/null +++ b/src/Order/LineItem/Models/LineItem.php @@ -0,0 +1,50 @@ + LineItemType::class, + 'options' => 'array', + 'snapshot' => 'array', + 'hasFreeShipping' => 'boolean', + 'isPromotable' => 'boolean', + 'isShippable' => 'boolean', + 'isTaxable' => 'boolean', + 'qty' => 'integer', + 'orderId' => 'integer', + 'purchasableId' => 'integer', + 'lineItemStatusId' => 'integer', + 'taxCategoryId' => 'integer', + 'shippingCategoryId' => 'integer', + 'weight' => 'float', + 'length' => 'float', + 'height' => 'float', + 'width' => 'float', + 'price' => 'float', + 'promotionalPrice' => 'float', + 'promotionalAmount' => 'float', + 'salePrice' => 'float', + 'subtotal' => 'float', + 'total' => 'float', + ]; +} diff --git a/src/Order/LineItemStatuses.php b/src/Order/LineItemStatuses.php new file mode 100644 index 0000000000..6cd21d77d4 --- /dev/null +++ b/src/Order/LineItemStatuses.php @@ -0,0 +1,307 @@ +>|null + */ + private ?array $allLineItemStatuses = null; + + /** + * Get line item status by its handle. + */ + public function getLineItemStatusByHandle(string $handle, ?int $storeId = null): ?LineItemStatus + { + return $this->getAllLineItemStatuses($storeId)->firstWhere('handle', $handle); + } + + /** + * Get default lineItem status ID from the DB + */ + public function getDefaultLineItemStatusId(?int $storeId = null): ?int + { + return $this->getDefaultLineItemStatus($storeId)?->id; + } + + /** + * Get default lineItem status from the DB + */ + public function getDefaultLineItemStatus(?int $storeId = null): ?LineItemStatus + { + return $this->getAllLineItemStatuses($storeId)->firstWhere('default', true); + } + + /** + * Get the default lineItem status for a particular lineItem. Defaults to the default lineItem status as configured + * in the control panel. + */ + public function getDefaultLineItemStatusForLineItem(LineItem $lineItem): ?LineItemStatus + { + if (!$order = $lineItem->getOrder()) { + return null; + } + + $lineItemStatus = $this->getDefaultLineItemStatus($order->getStore()->id); + + $event = new DefaultLineItemStatusEvent( + lineItem: $lineItem, + lineItemStatus: $lineItemStatus, + ); + + // TODO: migrate event firing to Laravel once event system is bridged + $legacyService = Plugin::getInstance()->getLineItemStatuses(); + if ($legacyService->hasEventHandlers(self::EVENT_DEFAULT_LINE_ITEM_STATUS)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $legacyService->trigger(self::EVENT_DEFAULT_LINE_ITEM_STATUS, $event); + } + + return $event->lineItemStatus; + } + + /** + * Save the line item status. + */ + public function saveLineItemStatus(LineItemStatus $lineItemStatus, bool $runValidation = true): bool + { + $isNewStatus = !$lineItemStatus->id; + + if ($runValidation && !$lineItemStatus->validate()) { + Log::info('Line item status not saved due to validation error.'); + + return false; + } + + if ($isNewStatus) { + $statusUid = Str::uuid()->toString(); + } else { + $statusUid = CraftDb::uidById(Table::LINEITEMSTATUSES, $lineItemStatus->id); + } + + // Make sure no statuses that are not archived share the handle + $existingStatus = $this->getLineItemStatusByHandle($lineItemStatus->handle, $lineItemStatus->storeId); + + if ($existingStatus && (!$lineItemStatus->id || $lineItemStatus->id !== $existingStatus->id)) { + $lineItemStatus->addError('handle', t('That handle is already in use', category: 'commerce')); + return false; + } + + $configData = $lineItemStatus->isArchived ? null : $lineItemStatus->getConfig(); + + $configPath = self::CONFIG_STATUSES_KEY . '.' . $statusUid; + ProjectConfig::set($configPath, $configData); + + if ($isNewStatus) { + $lineItemStatus->id = CraftDb::idByUid(Table::LINEITEMSTATUSES, $statusUid); + } + + $this->clearCaches(); + + return true; + } + + /** + * Handle line item status change. + * + * @throws Throwable if reasons + */ + public function handleChangedLineItemStatus(ConfigEvent $event): void + { + ProjectConfigData::ensureAllStoresProcessed(); + + $statusUid = $event->tokenMatches[0]; + $data = $event->newValue; + + DB::beginTransaction(); + try { + $statusRecord = $this->getLineItemStatusRecord($statusUid); + $store = app(Stores::class)->getStoreByUid($data['store']); + + $statusRecord->storeId = $store->id; + $statusRecord->name = $data['name']; + $statusRecord->handle = $data['handle']; + $statusRecord->color = $data['color']; + $statusRecord->sortOrder = $data['sortOrder'] ?? 99; + $statusRecord->default = $data['default']; + $statusRecord->uid = $statusUid; + $statusRecord->isArchived = false; + $statusRecord->dateArchived = null; + + $statusRecord->save(); + + if ($statusRecord->default) { + LineItemStatusRecord::where('id', '!=', $statusRecord->id) + ->where('storeId', $statusRecord->storeId) + ->update(['default' => false]); + } + + DB::commit(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + } + + /** + * Archive an line item status by it's id. + * + * @throws Throwable + */ + public function archiveLineItemStatusById(int $id, ?int $storeId = null): bool + { + $status = $this->getLineItemStatusById($id, $storeId); + if ($status) { + $status->isArchived = true; + return $this->saveLineItemStatus($status); + } + return false; + } + + /** + * Handle line item status being archived + * + * @throws Throwable if reasons + */ + public function handleArchivedLineItemStatus(ConfigEvent $event): void + { + $lineItemStatusUid = $event->tokenMatches[0]; + + DB::beginTransaction(); + try { + $lineItemStatusRecord = $this->getLineItemStatusRecord($lineItemStatusUid); + + $lineItemStatusRecord->isArchived = true; + $lineItemStatusRecord->dateArchived = Carbon::now(); + + $lineItemStatusRecord->save(); + + DB::commit(); + + $this->clearCaches(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + } + + /** + * Returns all Order Statuses + * + * @return Collection + */ + public function getAllLineItemStatuses(?int $storeId = null): Collection + { + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + if ($this->allLineItemStatuses === null || !isset($this->allLineItemStatuses[$storeId])) { + $results = $this->query()->where('storeId', $storeId)->get(); + + // Start with a blank slate if it isn't memoized + $this->allLineItemStatuses ??= []; + + foreach ($results as $result) { + $lineItemStatus = new LineItemStatus((array)$result); + + $this->allLineItemStatuses[$lineItemStatus->storeId] ??= collect(); + $this->allLineItemStatuses[$lineItemStatus->storeId]->push($lineItemStatus); + } + } + + return $this->allLineItemStatuses[$storeId] ?? collect(); + } + + /** + * Get a line item status by ID + */ + public function getLineItemStatusById(int $id, ?int $storeId = null): ?LineItemStatus + { + return $this->getAllLineItemStatuses($storeId)->firstWhere('id', $id); + } + + /** + * Reorders the line item statuses. + * + * @param int[] $ids + */ + public function reorderLineItemStatuses(array $ids): bool + { + $uidsByIds = CraftDb::uidsByIds(Table::LINEITEMSTATUSES, $ids); + + foreach ($ids as $lineItemStatus => $statusId) { + if (!empty($uidsByIds[$statusId])) { + $statusUid = $uidsByIds[$statusId]; + ProjectConfig::set(self::CONFIG_STATUSES_KEY . '.' . $statusUid . '.sortOrder', $lineItemStatus + 1); + } + } + + $this->clearCaches(); + + return true; + } + + private function query(): Builder + { + return DB::table(Table::LINEITEMSTATUSES) + ->select([ + 'color', + 'default', + 'handle', + 'id', + 'name', + 'sortOrder', + 'storeId', + 'uid', + ]) + ->where('isArchived', false) + ->orderBy('sortOrder'); + } + + /** + * Gets an lineitem status' record by uid. + */ + private function getLineItemStatusRecord(string $uid): LineItemStatusRecord + { + if ($lineItemStatus = LineItemStatusRecord::where('uid', $uid)->first()) { + return $lineItemStatus; + } + + return new LineItemStatusRecord(); + } + + /** + * Clear all memoization + */ + public function clearCaches(): void + { + $this->allLineItemStatuses = null; + } +} diff --git a/src/Order/Models/LineItemStatus.php b/src/Order/Models/LineItemStatus.php new file mode 100644 index 0000000000..d997879ace --- /dev/null +++ b/src/Order/Models/LineItemStatus.php @@ -0,0 +1,115 @@ +getUiLabel(); + } + + #[\Override] + public function getUiLabel(): string + { + return t($this->name ?? '', category: 'site'); + } + + #[\Override] + public static function get(int|string $id): ?self + { + $site = app(RequestedSite::class)->get(); + $storeId = $site ? app(Stores::class)->getStoreBySiteId($site->id)?->id : null; + + return app(LineItemStatuses::class)->getLineItemStatusById($id, $storeId); + } + + #[\Override] + public function getId(): string|int|null + { + return $this->id; + } + + #[\Override] + public function getStore(): \CraftCms\Commerce\Store\Models\Store + { + if (!$store = app(Stores::class)->getStoreById($this->storeId)) { + throw new \InvalidArgumentException('Invalid store ID: ' . $this->storeId); + } + + return $store; + } + + public function getCpEditUrl(): string + { + return Url::cpUrl('commerce/settings/lineitemstatuses/' . $this->getStore()->handle . '/' . $this->id); + } + + public function getLabelHtml(): string + { + return app(StatusHtml::class)->statusLabelHtml([ + 'label' => e($this->getUiLabel()), + 'color' => e($this->color), + ]) ?? ''; + } + + public function getConfig(): array + { + return [ + 'store' => $this->getStore()->uid, + 'name' => $this->name, + 'handle' => $this->handle, + 'color' => $this->color, + 'sortOrder' => $this->sortOrder ?: 9999, + 'default' => $this->default, + ]; + } + + #[\Override] + public function getRules(): array + { + return [ + 'name' => ['required', 'string'], + 'handle' => ['required', 'string', 'regex:/^[a-zA-Z_][a-zA-Z0-9_]*$/'], + ]; + } + + #[\Override] + public function extraFields(): array + { + return array_merge(parent::extraFields(), ['labelHtml', 'uiLabel']); + } +} diff --git a/src/Order/Models/Order.php b/src/Order/Models/Order.php new file mode 100644 index 0000000000..4c028f49bb --- /dev/null +++ b/src/Order/Models/Order.php @@ -0,0 +1,31 @@ + 'datetime', + 'dateFirstPaid' => 'datetime', + 'dateAuthorized' => 'datetime', + 'dateCreated' => 'datetime', + 'dateUpdated' => 'datetime', + ]; +} diff --git a/src/Order/Models/OrderAdjustment.php b/src/Order/Models/OrderAdjustment.php new file mode 100644 index 0000000000..d1c8360489 --- /dev/null +++ b/src/Order/Models/OrderAdjustment.php @@ -0,0 +1,109 @@ +_sourceSnapshot; + } + + public function setSourceSnapshot(array|string $snapshot): void + { + if (is_string($snapshot)) { + $snapshot = Json::decode($snapshot); + } + + if (!is_array($snapshot)) { + throw new \InvalidArgumentException('Adjustment source snapshot must be an array.'); + } + + $this->_sourceSnapshot = $snapshot; + } + + public function getLineItem(): ?LineItem + { + if ($this->_lineItem === null && $this->lineItemId) { + $this->_lineItem = app(LineItems::class)->getLineItemById($this->lineItemId); + } + + return $this->_lineItem; + } + + public function setLineItem(LineItem $lineItem): void + { + $this->_lineItem = $lineItem; + } + + public function getOrder(): ?Order + { + if (!isset($this->_order) && $this->orderId) { + $this->_order = app(Orders::class)->getOrderById($this->orderId); + } + + return $this->_order; + } + + public function setOrder(Order $order): void + { + $this->_order = $order; + $this->orderId = $order->id; + } + + #[\Override] + public function getRules(): array + { + return [ + 'type' => ['required', 'string'], + 'amount' => ['required', 'numeric'], + 'sourceSnapshot' => ['required'], + 'orderId' => ['required', 'integer'], + 'lineItemId' => ['nullable', 'integer'], + ]; + } + + #[\Override] + public function validationData(): array + { + return array_merge(parent::validationData(), [ + 'sourceSnapshot' => $this->_sourceSnapshot, + ]); + } +} diff --git a/src/Order/Models/OrderHistory.php b/src/Order/Models/OrderHistory.php new file mode 100644 index 0000000000..f978aad153 --- /dev/null +++ b/src/Order/Models/OrderHistory.php @@ -0,0 +1,88 @@ +_order === null) { + $this->_order = app(Orders::class)->getOrderById($this->orderId); + } + + return $this->_order; + } + + public function setOrder(Order $order): void + { + $this->_order = $order; + $this->orderId = $order->id; + } + + public function getPrevStatus(): ?OrderStatus + { + if ($this->prevStatusId === null) { + return null; + } + + $orderStatuses = app(OrderStatuses::class)->getAllOrderStatuses($this->getOrder()?->storeId); + + return collect($orderStatuses)->first(fn($status) => $status->id === $this->prevStatusId); + } + + public function getNewStatus(): ?OrderStatus + { + if ($this->newStatusId === null) { + return null; + } + + $orderStatuses = app(OrderStatuses::class)->getAllOrderStatuses($this->getOrder()?->storeId); + + return collect($orderStatuses)->first(fn($status) => $status->id === $this->newStatusId); + } + + public function getUser(): ?User + { + if ($this->userId === null) { + return null; + } + + return Users::getUserById($this->userId); + } + + #[\Override] + public function getRules(): array + { + return [ + 'orderId' => ['required', 'integer'], + ]; + } +} diff --git a/src/Order/Models/OrderNotice.php b/src/Order/Models/OrderNotice.php new file mode 100644 index 0000000000..cebec38546 --- /dev/null +++ b/src/Order/Models/OrderNotice.php @@ -0,0 +1,74 @@ +message ?: ''; + } + + public function getNoticeType(): OrderNoticeType + { + return $this->_noticeType; + } + + public function setNoticeType(string|OrderNoticeType $noticeType): void + { + $this->_noticeType = $noticeType instanceof OrderNoticeType + ? $noticeType + : OrderNoticeType::from($noticeType); + } + + #[\Override] + public function getRules(): array + { + return [ + 'type' => ['required', 'string'], + 'message' => ['required', 'string'], + 'attribute' => ['required', 'string'], + 'orderId' => ['required', 'integer'], + 'noticeType' => ['string'], + ]; + } + + public function setOrder(Order $order): void + { + $this->_order = $order; + $this->orderId = $order->id; + } + + public function getOrder(): ?Order + { + if (!isset($this->_order) && $this->orderId) { + $this->_order = app(Orders::class)->getOrderById($this->orderId); + } + + return $this->_order; + } +} diff --git a/src/Order/Models/OrderStatus.php b/src/Order/Models/OrderStatus.php new file mode 100644 index 0000000000..e786c5e526 --- /dev/null +++ b/src/Order/Models/OrderStatus.php @@ -0,0 +1,150 @@ +getUiLabel(); + } + + #[\Override] + public function getUiLabel(): string + { + if ($this->dateDeleted !== null) { + return t('{name} (Trashed)', ['name' => t($this->name ?? '', category: 'site')], category: 'commerce'); + } + + return t($this->name ?? '', category: 'site'); + } + + #[\Deprecated(message: 'in 5.6. Use [[getUiLabel()]] instead.')] + public function getDisplayName(): string + { + return $this->getUiLabel(); + } + + #[\Override] + public function getRules(): array + { + return [ + 'name' => ['required', 'string'], + 'handle' => [ + 'required', + 'string', + 'regex:/^[a-zA-Z][a-zA-Z0-9_]*$/', + Rule::unique(Table::ORDERSTATUSES, 'handle')->where('storeId', $this->storeId)->ignore($this->id), + function($attribute, $value, $fail) { + $reserved = ['id', 'dateCreated', 'dateUpdated', 'uid', 'title', 'create']; + if (in_array($value, $reserved, true)) { + $fail(t('"{value}" is a reserved word.', ['value' => $value], category: 'commerce')); + } + }, + ], + ]; + } + + #[\Override] + public function extraFields(): array + { + return array_merge(parent::extraFields(), ['emails', 'emailIds', 'labelHtml', 'uiLabel']); + } + + public function getCpEditUrl(): string + { + return $this->getStore()->getStoreSettingsUrl('orderstatuses/' . $this->id); + } + + public function getEmailIds(): array + { + return array_column($this->getEmails(), 'id'); + } + + public function getEmails(): array + { + return $this->id ? app(Emails::class)->getAllEmailsByOrderStatusId($this->id) : []; + } + + public function getLabelHtml(): string + { + return app(StatusHtml::class)->statusLabelHtml([ + 'color' => htmlspecialchars($this->color, ENT_QUOTES | ENT_SUBSTITUTE), + 'label' => htmlspecialchars($this->getUiLabel(), ENT_QUOTES | ENT_SUBSTITUTE), + ]) ?? ''; + } + + public function canDelete(): bool + { + // TODO: migrate to app(Orders::class) query once element migrated to src/ + $orderQuery = \craft\commerce\elements\Order::find()->trashed(null); + return !$orderQuery->orderStatus($this)->one() && !$this->default; + } + + public function getConfig(?array $emailIds = null): array + { + if ($emailIds === null) { + $emailIds = $this->getEmailIds(); + } + + $emails = !empty($emailIds) ? DB::table(Table::EMAILS)->uidsByIds($emailIds) : []; + return [ + 'name' => $this->name, + 'handle' => $this->handle, + 'color' => $this->color, + 'description' => $this->description, + 'sortOrder' => $this->sortOrder ?? 99, + 'default' => $this->default, + 'emails' => !empty($emails) ? array_combine($emails, $emails) : [], + 'store' => $this->getStore()->uid, + ]; + } + + #[\Override] + public static function get(int|string $id): ?static + { + /** @phpstan-ignore-next-line */ + return app(OrderStatuses::class)->getOrderStatusById($id); + } + + #[\Override] + public function getId(): ?int + { + return $this->id; + } +} diff --git a/src/Order/OrderAdjustments.php b/src/Order/OrderAdjustments.php new file mode 100644 index 0000000000..cdf0b6ad7d --- /dev/null +++ b/src/Order/OrderAdjustments.php @@ -0,0 +1,195 @@ +[] + */ + public function getAdjusters(): array + { + $adjusters = app(AdjusterTypes::class)->types()->all(); + + foreach ($this->getDiscountAdjusters() as $discountAdjuster) { + $adjusters[] = $discountAdjuster; + } + + $taxEngine = app(Taxes::class)->getEngine(); + $adjusters[] = $taxEngine->taxAdjusterClass(); + + return array_values(array_unique($adjusters)); + } + + public function getOrderAdjustmentById(int $id): ?OrderAdjustment + { + $row = $this->query()->where('id', $id)->first(); + + if (!$row) { + return null; + } + + $row = (array)$row; + $row['sourceSnapshot'] = Json::decodeIfJson($row['sourceSnapshot']); + + return new OrderAdjustment($row); + } + + /** + * Get all order adjustments by order's ID. + * + * @return OrderAdjustment[] + */ + public function getAllOrderAdjustmentsByOrderId(int $orderId): array + { + return $this->query() + ->where('orderId', $orderId) + ->get() + ->map(function($row) { + $row = (array)$row; + $row['sourceSnapshot'] = Json::decodeIfJson($row['sourceSnapshot']); + + return new OrderAdjustment($row); + }) + ->all(); + } + + /** + * Save an order adjustment. + */ + public function saveOrderAdjustment(OrderAdjustment $orderAdjustment, bool $runValidation = true): bool + { + $newAdjustment = !$orderAdjustment->id; + + if ($newAdjustment) { + $record = new OrderAdjustmentRecord(); + } else { + $record = OrderAdjustmentRecord::find($orderAdjustment->id); + + if (!$record) { + throw new OrderAdjustmentNotFoundException('Order Adjustment with ID "' . $orderAdjustment->id . '" not found!'); + } + } + + if ($runValidation && !$orderAdjustment->validate()) { + Log::info('Order Adjustment not saved due to validation error(s).'); + return false; + } + + $record->name = $orderAdjustment->name; + $record->type = $orderAdjustment->type; + $record->description = $orderAdjustment->description; + $record->amount = $orderAdjustment->amount; + $record->included = $orderAdjustment->included; + $record->sourceSnapshot = $orderAdjustment->getSourceSnapshot(); + $record->lineItemId = $orderAdjustment->getLineItem()->id ?? null; + $record->orderId = $orderAdjustment->getOrder()->id ?? null; + $record->isEstimated = $orderAdjustment->isEstimated; + + $record->save(); + + // Update the model with the latest IDs + $orderAdjustment->id = $record->id; + $orderAdjustment->orderId = $record->orderId; + $orderAdjustment->lineItemId = $record->lineItemId; + + return true; + } + + /** + * Delete all adjustments belonging to an order by its ID. + */ + public function deleteAllOrderAdjustmentsByOrderId(int $orderId): bool + { + return (bool)OrderAdjustmentRecord::where('orderId', $orderId)->delete(); + } + + /** + * Delete an order adjustment by its ID. + */ + public function deleteOrderAdjustmentByAdjustmentId(int $adjustmentId): bool + { + $orderAdjustment = OrderAdjustmentRecord::find($adjustmentId); + + if (!$orderAdjustment) { + return false; + } + + return (bool)$orderAdjustment->delete(); + } + + /** + * @param Order[] $orders + * @return Order[] + */ + public function eagerLoadOrderAdjustmentsForOrders(array $orders): array + { + $orderIds = collect($orders)->pluck('id')->filter()->all(); + $orderAdjustmentResults = $this->query()->whereIn('orderId', $orderIds)->get(); + + $orderAdjustments = []; + + foreach ($orderAdjustmentResults as $result) { + $result = (array)$result; + $result['sourceSnapshot'] = Json::decodeIfJson($result['sourceSnapshot']); + $adjustment = new OrderAdjustment($result); + + $orderAdjustments[$adjustment->orderId] ??= []; + $orderAdjustments[$adjustment->orderId][] = $adjustment; + } + + foreach ($orders as $key => $order) { + if (isset($orderAdjustments[$order->id])) { + $order->setAdjustments($orderAdjustments[$order->id]); + $orders[$key] = $order; + } + } + + return $orders; + } + + /** + * @return class-string[] + */ + public function getDiscountAdjusters(): array + { + return app(DiscountAdjusterTypes::class)->types()->all(); + } + + private function query(): Builder + { + return DB::table(Table::ORDERADJUSTMENTS) + ->select([ + 'amount', + 'description', + 'id', + 'included', + 'isEstimated', + 'lineItemId', + 'name', + 'orderId', + 'sourceSnapshot', + 'type', + ]); + } +} diff --git a/src/Order/OrderHistories.php b/src/Order/OrderHistories.php new file mode 100644 index 0000000000..379c798067 --- /dev/null +++ b/src/Order/OrderHistories.php @@ -0,0 +1,170 @@ +query()->where('id', $id)->first(); + + return $result ? new OrderHistory((array)$result) : null; + } + + /** + * Get all order histories by an order ID. + * + * @return OrderHistory[] + */ + public function getAllOrderHistoriesByOrderId(int $id): array + { + return $this->query() + ->where('orderId', $id) + ->orderBy('dateCreated', 'desc') + ->orderBy('id', 'desc') + ->get() + ->map(fn($row) => new OrderHistory((array)$row)) + ->all(); + } + + /** + * Create an order history from an order. + */ + public function createOrderHistoryFromOrder(Order $order, ?int $oldStatusId): bool + { + $orderHistoryModel = new OrderHistory(); + $orderHistoryModel->orderId = $order->id; + $orderHistoryModel->prevStatusId = $oldStatusId; + $orderHistoryModel->newStatusId = $order->orderStatusId; + + // By default the user who changed the status is the same as the user who placed the order + $userId = $order->getCustomerId(); + + // If the user is logged in, use the current user + if (!app()->runningInConsole() + && session()->isStarted() + && $currentUser = currentUserElement() + ) { + $userId = $currentUser->id; + } + + if ($userId) { + $user = Users::getUserById($userId); + if ($user) { + $orderHistoryModel->userId = $userId; + $orderHistoryModel->userName = $user->fullName ?? $user->email; + } else { + $orderHistoryModel->userName = $order->getEmail(); + } + } + + $orderHistoryModel->message = $order->message; + + if (!$this->saveOrderHistory($orderHistoryModel)) { + return false; + } + + app(OrderStatuses::class)->statusChangeHandler($order, $orderHistoryModel); + + // Raising 'orderStatusChange' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getOrderHistories()->hasEventHandlers(self::EVENT_ORDER_STATUS_CHANGE)) { + $event = new OrderStatusEvent( + orderHistory: $orderHistoryModel, + order: $order, + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getOrderHistories()->trigger(self::EVENT_ORDER_STATUS_CHANGE, $event); + } + + return true; + } + + /** + * Save an order history. + */ + public function saveOrderHistory(OrderHistory $model, bool $runValidation = true): bool + { + if ($model->id) { + $record = OrderHistoryRecord::find($model->id); + + if (!$record) { + throw new \RuntimeException(t('No order history exists with the ID "{id}"', ['id' => $model->id], category: 'commerce')); + } + } else { + $record = new OrderHistoryRecord(); + } + + if ($runValidation && !$model->validate()) { + Log::info('Order history not saved due to validation error.'); + + return false; + } + + $record->message = $model->message; + $record->newStatusId = $model->newStatusId; + $record->prevStatusId = $model->prevStatusId; + $record->userId = $model->userId; + $record->userName = $model->userName; + $record->orderId = $model->orderId; + + $record->save(); + + // Now that we have a record ID, save it on the model + $model->id = $record->id; + /** @phpstan-ignore-next-line */ + $model->dateCreated = \CraftCms\Cms\Support\DateTimeHelper::toDateTime($record->dateCreated); + + return true; + } + + /** + * Delete an order history by its ID. + */ + public function deleteOrderHistoryById(int $id): bool + { + $orderHistory = OrderHistoryRecord::find($id); + + if ($orderHistory) { + return (bool)$orderHistory->delete(); + } + + return false; + } + + private function query(): Builder + { + return DB::table(Table::ORDERHISTORIES) + ->select([ + 'userId', + 'dateCreated', + 'id', + 'message', + 'newStatusId', + 'orderId', + 'prevStatusId', + ]); + } +} diff --git a/src/Order/OrderNotices.php b/src/Order/OrderNotices.php new file mode 100644 index 0000000000..a699d9890b --- /dev/null +++ b/src/Order/OrderNotices.php @@ -0,0 +1,47 @@ +pluck('id')->filter()->all(); + + $orderNoticeResults = DB::table(Table::ORDERNOTICES) + ->select(['attribute', 'noticeType', 'id', 'message', 'orderId', 'type']) + ->whereIn('orderId', $orderIds) + ->get(); + + $orderNotices = []; + + foreach ($orderNoticeResults as $result) { + $notice = new OrderNotice((array)$result); + + $orderNotices[$notice->orderId] ??= []; + $orderNotices[$notice->orderId][] = $notice; + } + + foreach ($orders as $key => $order) { + if (isset($orderNotices[$order->id])) { + $order->addNotices($orderNotices[$order->id]); + $orders[$key] = $order; + } + } + + return $orders; + } +} diff --git a/src/Order/OrderStatuses.php b/src/Order/OrderStatuses.php new file mode 100644 index 0000000000..115c1ea540 --- /dev/null +++ b/src/Order/OrderStatuses.php @@ -0,0 +1,454 @@ +>|null + */ + private ?array $allOrderStatuses = null; + + /** + * Returns all Order Statuses + * + * @return Collection + */ + public function getAllOrderStatuses(?int $storeId = null, bool $withTrashed = false): Collection + { + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + if ($this->allOrderStatuses === null || !isset($this->allOrderStatuses[$storeId])) { + $results = $this->query(true)->where('storeId', $storeId)->get(); + + $this->allOrderStatuses ??= []; + + foreach ($results as $result) { + $orderStatus = new OrderStatus((array)$result); + + $this->allOrderStatuses[$orderStatus->storeId] ??= collect(); + $this->allOrderStatuses[$orderStatus->storeId]->push($orderStatus); + } + } + + if (!isset($this->allOrderStatuses[$storeId])) { + return collect(); + } + + return $this->allOrderStatuses[$storeId]->filter(fn(OrderStatus $os) => (!$withTrashed && $os->dateDeleted === null) || $withTrashed); + } + + /** + * Get an order status by ID + */ + public function getOrderStatusById(int $id, ?int $storeId = null): ?OrderStatus + { + return $this->getAllOrderStatuses($storeId)->firstWhere('id', $id); + } + + /** + * Get an order status by ID + */ + public function getOrderStatusByUid(string $uid, ?int $storeId = null): ?OrderStatus + { + return $this->getAllOrderStatuses($storeId)->firstWhere('uid', $uid); + } + + /** + * Get order status by its handle. + */ + public function getOrderStatusByHandle(string $handle, ?int $storeId = null): ?OrderStatus + { + return $this->getAllOrderStatuses($storeId)->firstWhere('handle', $handle); + } + + /** + * Get default order status from the DB + */ + public function getDefaultOrderStatus(?int $storeId = null): ?OrderStatus + { + return $this->getAllOrderStatuses($storeId)->firstWhere('default', true); + } + + /** + * Get default order status ID from the DB + */ + public function getDefaultOrderStatusId(?int $storeId = null): ?int + { + return $this->getDefaultOrderStatus($storeId)?->id; + } + + /** + * Get the default order status for a particular order. Defaults to the control-panel-configured default order status. + */ + public function getDefaultOrderStatusForOrder(Order $order): ?OrderStatus + { + $orderStatus = $this->getDefaultOrderStatus($order->storeId); + + $event = new DefaultOrderStatusEvent( + orderStatus: $orderStatus, + order: $order, + ); + + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getOrderStatuses()->hasEventHandlers(self::EVENT_DEFAULT_ORDER_STATUS)) { + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getOrderStatuses()->trigger(self::EVENT_DEFAULT_ORDER_STATUS, $event); + } + + return $event->orderStatus; + } + + public function getOrderCountByStatus(?int $storeId = null): array + { + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + $countGroupedByStatusId = DB::table(Table::ORDERS . ' as o') + ->select(['o.orderStatusId', DB::raw('count(o.id) as orderCount')]) + ->join(CraftTable::ELEMENTS . ' as e', 'o.id', '=', 'e.id') + ->where('o.isCompleted', true) + ->whereNull('e.dateDeleted') + ->where('o.storeId', $storeId) + ->groupBy('o.orderStatusId') + ->get() + ->keyBy('orderStatusId') + ->map(fn($row) => (array)$row) + ->all(); + + // For those not in the groupBy + $allStatuses = $this->getAllOrderStatuses($storeId); + foreach ($allStatuses as $status) { + if (!isset($countGroupedByStatusId[$status->id])) { + $countGroupedByStatusId[$status->id] = [ + 'orderStatusId' => $status->id, + 'handle' => $status->handle, + 'orderCount' => 0, + ]; + } + + // Make sure all have their handle + $countGroupedByStatusId[$status->id]['handle'] = $status->handle; + } + + return $countGroupedByStatusId; + } + + /** + * Save the order status. + */ + public function saveOrderStatus(OrderStatus $orderStatus, array $emailIds = [], bool $runValidation = true, bool $force = false): bool + { + $isNewStatus = !(bool)$orderStatus->id; + + if ($runValidation && !$orderStatus->validate()) { + Log::info('Order status not saved due to validation error.'); + + return false; + } + + if ($isNewStatus) { + $statusUid = Str::uuid()->toString(); + } else { + $statusUid = CraftDb::uidById(Table::ORDERSTATUSES, $orderStatus->id); + } + + $otherStatuses = $this->getAllOrderStatuses($orderStatus->storeId)->where('uid', '!=', $statusUid)->all(); + + // if this is the only order status, set it as the default + $orderStatus->default = empty($otherStatuses) ? true : $orderStatus->default; + + $configData = $orderStatus->dateDeleted ? null : $orderStatus->getConfig($emailIds); + + $configPath = self::CONFIG_STATUSES_KEY . '.' . $statusUid; + ProjectConfig::set($configPath, $configData, force: $force); + + if ($isNewStatus) { + $orderStatus->id = CraftDb::idByUid(Table::ORDERSTATUSES, $statusUid); + $orderStatus->uid = $statusUid; + } + + $this->allOrderStatuses = null; + + // Make sure this is the only default + if ($orderStatus->default) { + foreach ($otherStatuses as $otherStatus) { + $otherStatus->default = false; + $this->saveOrderStatus($otherStatus, $otherStatus->getEmailIds(), false, true); + } + } + + return true; + } + + /** + * Handle order status change. + * + * @throws Throwable if reasons + */ + public function handleChangedOrderStatus(ConfigEvent $event): void + { + ProjectConfigData::ensureAllStoresProcessed(); + + $statusUid = $event->tokenMatches[0]; + $data = $event->newValue; + + DB::beginTransaction(); + try { + $statusRecord = $this->getOrderStatusRecord($statusUid); + + // Get store by uid and convert `$data['store']` to `storeId` + $store = app(Stores::class)->getStoreByUid($data['store']); + + $statusRecord->name = $data['name']; + $statusRecord->storeId = $store->id; + $statusRecord->handle = $data['handle']; + $statusRecord->color = $data['color']; + $statusRecord->description = $data['description'] ?? null; + $statusRecord->sortOrder = $data['sortOrder'] ?? 99; + $statusRecord->default = $data['default']; + $statusRecord->uid = $statusUid; + + // Save the status + if ($statusRecord->dateDeleted) { + $statusRecord->restore(); + } else { + $statusRecord->save(); + } + + // Drop them all and we will recreate the new ones. + DB::table(Table::ORDERSTATUS_EMAILS)->where('orderStatusId', $statusRecord->id)->delete(); + + if (!empty($data['emails'])) { + foreach ($data['emails'] as $emailUid) { + ProjectConfig::processConfigChanges(Emails::CONFIG_EMAILS_KEY . '.' . $emailUid); + } + + $emailIds = CraftDb::idsByUids(Table::EMAILS, $data['emails']); + $now = now()->toDateTimeString(); + + foreach ($emailIds as $emailId) { + DB::table(Table::ORDERSTATUS_EMAILS)->insert([ + 'orderStatusId' => $statusRecord->id, + 'emailId' => $emailId, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + } + } + + DB::commit(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + } + + /** + * Delete an order status by it's id. + * + * @throws Throwable + */ + public function deleteOrderStatusById(int $id, ?int $storeId = null): bool + { + $statuses = $this->getAllOrderStatuses($storeId); + $orderStatus = $this->getOrderStatusById($id, $storeId); + + // Can only delete if we have one that can remain as the default + if (count($statuses) < 2 || $orderStatus === null) { + return false; + } + + // Prevent deletion of order status if there are orders with this status + $orderCounts = $this->getOrderCountByStatus($storeId); + if (!isset($orderCounts[$id]) || $orderCounts[$id]['orderCount'] > 0) { + return false; + } + + ProjectConfig::remove(self::CONFIG_STATUSES_KEY . '.' . $orderStatus->uid); + return true; + } + + /** + * Handle order status being deleted + * + * @throws Throwable if reasons + */ + public function handleDeletedOrderStatus(ConfigEvent $event): void + { + $orderStatusUid = $event->tokenMatches[0]; + + DB::beginTransaction(); + try { + $orderStatusRecord = $this->getOrderStatusRecord($orderStatusUid); + + $orderStatusRecord->delete(); + + DB::commit(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + + // Clear caches + $this->allOrderStatuses = null; + } + + /** + * Prune a deleted email from order statuses. + */ + public function pruneDeletedEmail(EmailEvent $event): void + { + $emailUid = $event->email->uid; + + $statuses = ProjectConfig::get(self::CONFIG_STATUSES_KEY); + + // Loop through the volumes and prune the UID from field layouts. + if (is_array($statuses)) { + foreach ($statuses as $orderStatusUid => $orderStatus) { + ProjectConfig::remove(self::CONFIG_STATUSES_KEY . '.' . $orderStatusUid . '.emails.' . $emailUid); + } + } + } + + /** + * Handler for order status change event + */ + public function statusChangeHandler(Order $order, OrderHistory $orderHistory): void + { + $status = $this->getOrderStatusById($order->orderStatusId, $order->storeId); + + if ($status === null) { + return; + } + + // Raising 'beforeOrderStatusChange' event + $event = new OrderStatusEmailsEvent( + orderHistory: $orderHistory, + order: $order, + emails: $status->getEmails(), + ); + $event->isValid = !$order->suppressEmails; + + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getOrderStatuses()->hasEventHandlers(self::EVENT_ORDER_STATUS_CHANGE_EMAILS)) { + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getOrderStatuses()->trigger(self::EVENT_ORDER_STATUS_CHANGE_EMAILS, $event); + } + + if (!$event->isValid || empty($event->emails)) { + // Don't send emails + return; + } + + $originalLanguage = \Craft::$app->language; + $originalFormattingLocale = \Craft::$app->formattingLocale; + + foreach ($event->emails as $email) { + if (!$email->enabled) { + continue; + } + + // Set language by email's set locale + // We need to do this here since $order->toArray() uses the locale to format asCurrency attributes + $language = $email->getRenderLanguage($event->order); + Locale::switchAppLanguage($language); + + Queue::push(new SendEmail([ + 'orderId' => $event->order->id, + 'commerceEmailId' => $email->id, + 'orderHistoryId' => $event->orderHistory->id, + 'orderData' => $event->order->toArray(), + ]), 100); + } + + // Set previous language back + Locale::switchAppLanguage($originalLanguage, $originalFormattingLocale->id); + } + + /** + * Reorders the order statuses. + * + * @param int[] $ids + */ + public function reorderOrderStatuses(array $ids): bool + { + $uidsByIds = CraftDb::uidsByIds(Table::ORDERSTATUSES, $ids); + + foreach ($ids as $orderStatus => $statusId) { + if (!empty($uidsByIds[$statusId])) { + $statusUid = $uidsByIds[$statusId]; + ProjectConfig::set(self::CONFIG_STATUSES_KEY . '.' . $statusUid . '.sortOrder', $orderStatus + 1); + } + } + + return true; + } + + private function query(bool $withTrashed = false): Builder + { + $query = DB::table(Table::ORDERSTATUSES) + ->select([ + 'color', + 'dateDeleted', + 'default', + 'description', + 'handle', + 'id', + 'name', + 'sortOrder', + 'storeId', + 'uid', + ]) + ->orderBy('sortOrder'); + + if (!$withTrashed) { + $query->whereNull('dateDeleted'); + } + + return $query; + } + + /** + * Gets an order status' record by uid. + */ + private function getOrderStatusRecord(string $uid): OrderStatusRecord + { + return OrderStatusRecord::withTrashed()->where('uid', $uid)->first() ?? new OrderStatusRecord(); + } +} diff --git a/src/Order/Orders.php b/src/Order/Orders.php new file mode 100644 index 0000000000..b049798528 --- /dev/null +++ b/src/Order/Orders.php @@ -0,0 +1,289 @@ +newValue; + + ProjectConfigHelper::ensureAllFieldsProcessed(); + + if (empty($data) || empty(reset($data))) { + // Delete the field layout + Fields::deleteLayoutsByType(Order::class); + return; + } + + // Save the field layout + $layout = FieldLayout::createFromConfig(reset($data)); + $layout->id = Fields::getLayoutByType(Order::class)->id; + $layout->type = Order::class; + $layout->uid = key($data); + Fields::saveLayout($layout, false); + } + + /** + * Handle field layout being deleted. + */ + public function handleDeletedFieldLayout(): void + { + Fields::deleteLayoutsByType(Order::class); + } + + /** + * Get an order by its ID. + */ + public function getOrderById(int $id): ?Order + { + if (!$id) { + return null; + } + + return Order::find()->id($id)->status(null)->one(); + } + + /** + * Get an order by its number. + */ + public function getOrderByNumber(string $number): ?Order + { + return Order::find()->number($number)->one(); + } + + /** + * Get all orders by their customer. + * + * @return Order[]|null + */ + public function getOrdersByCustomer(User|int $customer): ?array + { + if (!$customer) { + return null; + } + + $query = Order::find(); + if ($customer instanceof User) { + $query->customer($customer); + } else { + $query->customerId($customer); + } + $query->isCompleted(); + $query->limit(null); + + return $query->all(); + } + + /** + * Get all orders by their email. + * + * @return Order[]|null + */ + public function getOrdersByEmail(string $email): ?array + { + return Order::find()->email($email)->isCompleted()->limit(null)->all(); + } + + /** + * @param Order[] $orders + * @return Order[] + */ + public function eagerLoadAddressesForOrders(array $orders): array + { + $shippingAddressIds = collect($orders)->pluck('shippingAddressId')->filter()->all(); + $billingAddressIds = collect($orders)->pluck('billingAddressId')->filter()->all(); + $ids = array_unique(array_merge($shippingAddressIds, $billingAddressIds)); + + // Query addresses as array to avoid instantiating elements immediately + $query = Address::find() + ->id($ids) + ->indexBy('id') + ->asArray(); + /** @var array $addresses */ + $addresses = $query->all(); + + foreach ($orders as $key => $order) { + if (isset($order['shippingAddressId'], $addresses[$order['shippingAddressId']])) { + $data = $addresses[$order['shippingAddressId']]; + $data['owner'] = $order; + /** @var Address $address */ + $address = $query->createElement($data); + + $order->setShippingAddress($address); + } + + if (isset($order['billingAddressId'], $addresses[$order['billingAddressId']])) { + $data = $addresses[$order['billingAddressId']]; + $data['owner'] = $order; + + /** @var Address $address */ + $address = $query->createElement($data); + + $order->setBillingAddress($address); + } + + $orders[$key] = $order; + } + + return $orders; + } + + /** + * Prevent deleting a user if they have any orders. + */ + public function beforeDeleteUserHandler(DefineDeletionBlockers $event): void + { + if ($event->elementType !== User::class) { + return; + } + + $event->blockers[] = new OrderCustomersDeletionBlocker($event->elements, $event->hardDelete); + } + + /** + * Reassigns orders to a new customer. + * + * @param int|int[] $oldUserId + * @return int The number of affected orders + */ + public function reassignOrders(int|array $oldUserId, int $newUserId): int + { + $newUserEmail = DB::table(CraftTable::USERS) + ->where('id', $newUserId) + ->value('email'); + + if (!$newUserEmail) { + throw new \InvalidArgumentException('Unable to reassign user id: ' . $newUserId); + } + + $count = DB::table(Table::ORDERS) + ->where('customerId', $oldUserId) + ->update([ + 'customerId' => $newUserId, + 'email' => $newUserEmail, + ]); + + // Invalidate all order caches + ElementCaches::invalidateForElementType(Order::class); + + return $count; + } + + /** + * @param int|int[] $orderIds + */ + public function removeCustomerData(int|array $orderIds, array $dataToRemove = ['customerId', 'email']): int + { + $allowedRemovalKeys = [ + 'customerId', + 'email', + 'billingAddressId', + 'shippingAddressId', + 'orderCompletedEmail', + ]; + + $data = []; + foreach ($dataToRemove as $key) { + if (!in_array($key, $allowedRemovalKeys)) { + continue; + } + + // Make sure we are setting the `customerDeleted` flag when removing the `customerId` + if ($key === 'customerId') { + $data['customerDeleted'] = true; + } + + $data[$key] = null; + } + + $count = DB::table(Table::ORDERS) + ->whereIn('id', (array)$orderIds) + ->update($data); + + ElementCaches::invalidateForElementType(Order::class); + + return $count; + } + + public function afterSaveAddressHandler(ElementSaved $event): void + { + if (!$event->element instanceof Address) { + return; + } + + $address = $event->element; + if ($address->getIsDraft()) { + return; + } + + // Find all orders using this address as a source + $ids = DB::table(Table::ORDERS) + ->select('id') + ->where('sourceBillingAddressId', $address->id) + ->orWhere('sourceShippingAddressId', $address->id) + ->pluck('id') + ->all(); + + /** @var Order[] $carts */ + $carts = Order::find() + ->where(['commerce_orders.id' => $ids]) + ->isCompleted(false) + ->all(); + + if (empty($carts)) { + return; + } + + foreach ($carts as $cart) { + // Update the billing address + if ($cart->sourceBillingAddressId === $address->id) { + $newBillingAddress = Elements::duplicateElement($address, [ + 'primaryOwner' => $cart, + 'owner' => $cart, + 'title' => t('Billing Address', category: 'commerce'), + ]); + $cart->billingAddressId = $newBillingAddress->id; + } + + // Update the shipping address + if ($cart->sourceShippingAddressId === $address->id) { + $newShippingAddress = Elements::duplicateElement($address, [ + 'primaryOwner' => $cart, + 'owner' => $cart, + 'title' => t('Shipping Address', category: 'commerce'), + ]); + $cart->shippingAddressId = $newShippingAddress->id; + } + + // Save the cart to trigger events and recalculations. + Elements::saveElement($cart, false); + } + } +} diff --git a/src/Order/Queries/OrderQuery.php b/src/Order/Queries/OrderQuery.php new file mode 100644 index 0000000000..328e6a07b7 --- /dev/null +++ b/src/Order/Queries/OrderQuery.php @@ -0,0 +1,742 @@ + + */ +class OrderQuery extends ElementQuery +{ + protected string $table = Table::ORDERS; + + /** @var array */ + protected array $defaultOrderBy = [ + 'commerce_orders.id' => SORT_ASC, + ]; + + public mixed $number = null; + + public mixed $shortNumber = null; + + public mixed $reference = null; + + public mixed $couponCode = null; + + public mixed $email = null; + + public ?bool $isCompleted = null; + + public mixed $dateOrdered = null; + + public mixed $expiryDate = null; + + public mixed $datePaid = null; + + public mixed $dateFirstPaid = null; + + public mixed $dateAuthorized = null; + + public mixed $orderStatusId = null; + + public mixed $orderLanguage = null; + + public mixed $orderSiteId = null; + + public mixed $origin = null; + + public mixed $customerId = null; + + public mixed $gatewayId = null; + + public ?int $storeId = null; + + public mixed $total = null; + + public mixed $totalPrice = null; + + public mixed $totalPaid = null; + + public mixed $totalQty = null; + + public mixed $totalWeight = null; + + public mixed $totalDiscount = null; + + public mixed $totalTax = null; + + public mixed $itemTotal = null; + + public mixed $itemSubtotal = null; + + public mixed $shippingMethodHandle = null; + + public ?bool $isPaid = null; + + public ?bool $isUnpaid = null; + + public mixed $hasPurchasables = null; + + /** @var array{purchasables: array, match: ContainsPurchasablesMatch}|null */ + public ?array $containsPurchasables = null; + + public ?bool $hasTransactions = null; + + public ?bool $hasLineItems = null; + + public ?bool $hasAdminNotices = null; + + public bool $withAll = false; + + public bool $withAddresses = false; + + public bool $withAdjustments = false; + + public bool $withCustomer = false; + + public bool $withLineItems = false; + + public bool $withTransactions = false; + + /** @param array $config */ + public function __construct(array $config = []) + { + parent::__construct(Order::class, $config); + + $this->query->addSelect([ + 'commerce_orders.storeId', + 'commerce_orders.number', + 'commerce_orders.reference', + 'commerce_orders.couponCode', + 'commerce_orders.orderStatusId', + 'commerce_orders.dateOrdered', + + // `commerce_orders.email` is deliberately not selected: the customer's email now lives + // on the customer relation, not this column. The `email()` scope below still filters by + // it via a join to the users table. + 'commerce_orders.isCompleted', + 'commerce_orders.datePaid', + 'commerce_orders.dateFirstPaid', + 'commerce_orders.currency', + 'commerce_orders.paymentCurrency', + 'commerce_orders.lastIp', + 'commerce_orders.orderLanguage', + 'commerce_orders.message', + 'commerce_orders.returnUrl', + 'commerce_orders.cancelUrl', + 'commerce_orders.billingAddressId', + 'commerce_orders.shippingAddressId', + 'commerce_orders.estimatedBillingAddressId', + 'commerce_orders.estimatedShippingAddressId', + 'commerce_orders.sourceBillingAddressId', + 'commerce_orders.sourceShippingAddressId', + 'commerce_orders.shippingMethodHandle', + 'commerce_orders.gatewayId', + 'commerce_orders.paymentSourceId', + 'commerce_orders.customerId', + 'commerce_orders.customerDeleted', + 'commerce_orders.dateUpdated', + 'commerce_orders.registerUserOnOrderComplete', + 'commerce_orders.saveBillingAddressOnOrderComplete', + 'commerce_orders.saveShippingAddressOnOrderComplete', + 'commerce_orders.makePrimaryShippingAddress', + 'commerce_orders.makePrimaryBillingAddress', + 'commerce_orders.recalculationMode', + 'commerce_orders.origin', + 'commerce_orders.dateAuthorized', + 'commerce_orders.totalPrice as storedTotalPrice', + 'commerce_orders.totalPaid as storedTotalPaid', + 'commerce_orders.itemTotal as storedItemTotal', + 'commerce_orders.totalDiscount as storedTotalDiscount', + 'commerce_orders.totalShippingCost as storedTotalShippingCost', + 'commerce_orders.totalTax as storedTotalTax', + 'commerce_orders.totalTaxIncluded as storedTotalTaxIncluded', + 'commerce_orders.itemSubtotal as storedItemSubtotal', + 'commerce_orders.totalQty as storedTotalQty', + 'commerce_orders.shippingMethodName', + 'commerce_orders.orderSiteId', + 'commerce_orders.orderCompletedEmail', + ]); + + // Addresses joined for sorting/filtering purposes. + $this->query->leftJoin(new Alias(CraftTable::ADDRESSES, 'billing_address'), 'billing_address.id', '=', 'commerce_orders.billingAddressId'); + $this->query->leftJoin(new Alias(CraftTable::ADDRESSES, 'shipping_address'), 'shipping_address.id', '=', 'commerce_orders.shippingAddressId'); + + $this->beforeQuery(function(self $query) { + if (isset($query->number)) { + // If it's set to anything besides a non-empty string, abort the query + if (!is_string($query->number) || $query->number === '') { + throw new QueryAbortedException(); + } + + $query->where('commerce_orders.number', $query->number); + } + + if (isset($query->shortNumber)) { + // If it's set to anything besides a non-empty string, abort the query + if (!is_string($query->shortNumber) || $query->shortNumber === '') { + throw new QueryAbortedException(); + } + + $query->whereRaw('LEFT(commerce_orders.number, 7) = ?', [$query->shortNumber]); + } + + if (isset($query->storeId) && $query->storeId) { + $query->whereParam('commerce_orders.storeId', $query->storeId); + } + + if (isset($query->origin) && $query->origin) { + $query->whereParam('commerce_orders.origin', $query->origin); + } + + if (isset($query->reference) && $query->reference) { + $query->whereParam('commerce_orders.reference', $query->reference); + } + + if (isset($query->couponCode)) { + // Coupon code criteria is case-insensitive like in the adjuster + $query->whereParam('commerce_orders.couponCode', $query->couponCode, caseInsensitive: true); + } + + if (isset($query->email) && $query->email) { + // Join and search the users table for email address + $query->leftJoin(new Alias(CraftTable::USERS, 'users'), 'users.id', '=', 'commerce_orders.customerId'); + $query->whereParam('users.email', $query->email, caseInsensitive: true); + } + + if (isset($query->isCompleted)) { + $query->whereBooleanParam('commerce_orders.isCompleted', $query->isCompleted, false); + } + + // NOTE: ported verbatim from the legacy Yii2 query, which filters `dateAuthorized` by the + // value of `datePaid` here (not `dateAuthorized`). This looks like a pre-existing bug, but + // is preserved for behavioral parity; worth revisiting separately. + if (isset($query->dateAuthorized)) { + $query->whereDateParam('commerce_orders.dateAuthorized', $query->datePaid); + } + + if (isset($query->dateOrdered)) { + $query->whereDateParam('commerce_orders.dateOrdered', $query->dateOrdered); + } + + if (isset($query->datePaid)) { + $query->whereDateParam('commerce_orders.datePaid', $query->datePaid); + } + + if (isset($query->dateFirstPaid)) { + $query->whereDateParam('commerce_orders.dateFirstPaid', $query->dateFirstPaid); + } + + if (isset($query->expiryDate)) { + $query->whereDateParam('commerce_orders.expiryDate', $query->expiryDate); + } + + if (isset($query->orderStatusId)) { + $query->whereParam('commerce_orders.orderStatusId', $query->orderStatusId); + } + + if (isset($query->shippingMethodHandle)) { + $query->whereParam('commerce_orders.shippingMethodHandle', $query->shippingMethodHandle); + } + + if (isset($query->orderLanguage)) { + $query->whereParam('commerce_orders.orderLanguage', $query->orderLanguage); + } + + if (isset($query->orderSiteId)) { + $query->whereParam('commerce_orders.orderSiteId', $query->orderSiteId); + } + + if (isset($query->customerId)) { + $query->whereParam('commerce_orders.customerId', $query->customerId); + } + + if (isset($query->gatewayId)) { + $query->whereParam('commerce_orders.gatewayId', $query->gatewayId); + } + + if (isset($query->total)) { + $query->whereParam('commerce_orders.total', $query->total); + } + + if (isset($query->totalPrice)) { + $query->whereParam('commerce_orders.totalPrice', $query->totalPrice); + } + + if (isset($query->totalPaid)) { + $query->whereParam('commerce_orders.totalPaid', $query->totalPaid); + } + + if (isset($query->itemTotal)) { + $query->whereParam('commerce_orders.itemTotal', $query->itemTotal); + } + + if (isset($query->itemSubtotal)) { + $query->whereParam('commerce_orders.itemSubtotal', $query->itemSubtotal); + } + + if (isset($query->totalQty)) { + $query->whereParam('commerce_orders.totalQty', $query->totalQty); + } + + if (isset($query->totalWeight)) { + $query->whereParam('commerce_orders.totalWeight', $query->totalWeight); + } + + if (isset($query->totalDiscount)) { + $query->whereParam('commerce_orders.totalDiscount', $query->totalDiscount); + } + + if (isset($query->totalTax)) { + $query->whereParam('commerce_orders.totalTax', $query->totalTax); + } + + // Allow true but not null + if (isset($query->isPaid) && $query->isPaid) { + $query->whereColumn('commerce_orders.totalPaid', '>=', 'commerce_orders.totalPrice'); + } + + // Allow true but not null + if (isset($query->isUnpaid) && $query->isUnpaid) { + $query->whereColumn('commerce_orders.totalPaid', '<', 'commerce_orders.totalPrice'); + } + + // Allow integer/PurchasableInterface object or array of integers/PurchasableInterface objects + if (isset($query->hasPurchasables)) { + $purchasables = is_array($query->hasPurchasables) ? $query->hasPurchasables : [$query->hasPurchasables]; + $purchasableIds = []; + + foreach ($purchasables as $purchasable) { + if ($purchasable instanceof PurchasableInterface) { + $purchasableIds[] = $purchasable->getId(); + } elseif (is_numeric($purchasable)) { + $purchasableIds[] = $purchasable; + } + } + + // Remove any blank purchasable IDs (if any) + $purchasableIds = array_filter($purchasableIds); + + $query->whereExists(function(Builder $sub) use ($purchasableIds) { + $sub->from(Table::LINEITEMS . ' as lineitems') + ->whereColumn('lineitems.orderId', 'elements.id') + ->whereIn('lineitems.purchasableId', $purchasableIds); + }); + } + + if (isset($query->containsPurchasables)) { + $purchasables = $query->containsPurchasables['purchasables']; + $match = $query->containsPurchasables['match']; + + $purchasableIds = []; + + foreach ($purchasables as $purchasable) { + if ($purchasable instanceof PurchasableInterface) { + $purchasableIds[] = $purchasable->getId(); + } elseif (is_numeric($purchasable)) { + $purchasableIds[] = $purchasable; + } + } + + $purchasableIds = array_values(array_filter($purchasableIds)); + + if ($match === ContainsPurchasablesMatch::All || $match === ContainsPurchasablesMatch::Only) { + // Every requested purchasable must have its own line item (AND logic) + foreach ($purchasableIds as $id) { + $query->whereExists(function(Builder $sub) use ($id) { + $sub->from(Table::LINEITEMS . ' as lineitems') + ->whereColumn('lineitems.orderId', 'elements.id') + ->where('lineitems.purchasableId', $id); + }); + } + + if ($match === ContainsPurchasablesMatch::Only) { + // No line items with a purchasable outside the set, and no custom line items + $query->whereNotExists(function(Builder $sub) use ($purchasableIds) { + $sub->from(Table::LINEITEMS . ' as lineitems') + ->whereColumn('lineitems.orderId', 'elements.id') + ->where(function(Builder $q) use ($purchasableIds) { + $q->whereNull('lineitems.purchasableId') + ->orWhereNotIn('lineitems.purchasableId', $purchasableIds); + }); + }); + } + } else { + // ContainsPurchasablesMatch::Any: at least one of the purchasables must be in the order + $query->whereExists(function(Builder $sub) use ($purchasableIds) { + $sub->from(Table::LINEITEMS . ' as lineitems') + ->whereColumn('lineitems.orderId', 'elements.id') + ->whereIn('lineitems.purchasableId', $purchasableIds); + }); + } + } + + // Allow true or false but not null + if (isset($query->hasTransactions)) { + $method = $query->hasTransactions ? 'whereExists' : 'whereNotExists'; + $query->$method(function(Builder $sub) { + $sub->from(Table::TRANSACTIONS . ' as transactions') + ->whereColumn('transactions.orderId', 'elements.id'); + }); + } + + // Allow true or false but not null + if (isset($query->hasLineItems)) { + $method = $query->hasLineItems ? 'whereExists' : 'whereNotExists'; + $query->$method(function(Builder $sub) { + $sub->from(Table::LINEITEMS . ' as lineitems') + ->whereColumn('lineitems.orderId', 'elements.id'); + }); + } + + if (isset($query->hasAdminNotices)) { + $method = $query->hasAdminNotices ? 'whereExists' : 'whereNotExists'; + $query->$method(function(Builder $sub) { + $sub->from(Table::ORDERNOTICES . ' as adminNotices') + ->whereColumn('adminNotices.orderId', 'elements.id') + ->where('adminNotices.noticeType', OrderNoticeType::Admin->value); + }); + } + }); + } + + public function number(mixed $value): static + { + $this->number = $value; + return $this; + } + + public function shortNumber(mixed $value): static + { + $this->shortNumber = $value; + return $this; + } + + public function reference(mixed $value): static + { + $this->reference = $value; + return $this; + } + + public function couponCode(mixed $value): static + { + $this->couponCode = $value; + return $this; + } + + public function email(mixed $value): static + { + $this->email = $value; + return $this; + } + + public function isCompleted(?bool $value = true): static + { + $this->isCompleted = $value; + return $this; + } + + public function dateOrdered(mixed $value): static + { + $this->dateOrdered = $value; + return $this; + } + + public function datePaid(mixed $value): static + { + $this->datePaid = $value; + return $this; + } + + public function dateFirstPaid(mixed $value): static + { + $this->dateFirstPaid = $value; + return $this; + } + + public function dateAuthorized(mixed $value): static + { + $this->dateAuthorized = $value; + return $this; + } + + public function expiryDate(mixed $value): static + { + $this->expiryDate = $value; + return $this; + } + + /** @param string|string[]|OrderStatus|null $value */ + public function orderStatus(mixed $value): static + { + if ($value instanceof OrderStatus) { + $this->orderStatusId = $value->id; + } elseif ($value !== null) { + $this->orderStatusId = DB::table(Table::ORDERSTATUSES) + ->whereParam('handle', $value) + ->pluck('id') + ->all(); + } else { + $this->orderStatusId = null; + } + + return $this; + } + + public function orderStatusId(mixed $value): static + { + $this->orderStatusId = $value; + return $this; + } + + public function shippingMethodHandle(mixed $value): static + { + $this->shippingMethodHandle = $value; + return $this; + } + + public function orderLanguage(mixed $value): static + { + $this->orderLanguage = $value; + return $this; + } + + public function orderSiteId(mixed $value): static + { + $this->orderSiteId = $value; + return $this; + } + + public function origin(mixed $value): static + { + $this->origin = $value; + return $this; + } + + public function gateway(?GatewayInterface $value): static + { + $this->gatewayId = $value?->id; + return $this; + } + + public function gatewayId(mixed $value): static + { + $this->gatewayId = $value; + return $this; + } + + public function customer(int|User|null $value): static + { + $this->customerId = $value instanceof User ? $value->id : $value; + return $this; + } + + public function customerId(mixed $value): static + { + $this->customerId = $value; + return $this; + } + + public function total(mixed $value): static + { + $this->total = $value; + return $this; + } + + public function totalPrice(mixed $value): static + { + $this->totalPrice = $value; + return $this; + } + + public function totalPaid(mixed $value): static + { + $this->totalPaid = $value; + return $this; + } + + public function totalQty(mixed $value): static + { + $this->totalQty = $value; + return $this; + } + + public function totalWeight(mixed $value): static + { + $this->totalWeight = $value; + return $this; + } + + public function totalDiscount(mixed $value): static + { + $this->totalDiscount = $value; + return $this; + } + + public function totalTax(mixed $value): static + { + $this->totalTax = $value; + return $this; + } + + public function itemTotal(mixed $value): static + { + $this->itemTotal = $value; + return $this; + } + + public function itemSubtotal(mixed $value): static + { + $this->itemSubtotal = $value; + return $this; + } + + public function isPaid(?bool $value = true): static + { + $this->isPaid = $value; + return $this; + } + + public function isUnpaid(?bool $value = true): static + { + $this->isUnpaid = $value; + return $this; + } + + public function hasLineItems(?bool $value = true): static + { + $this->hasLineItems = $value; + return $this; + } + + public function hasAdminNotices(?bool $value = true): static + { + $this->hasAdminNotices = $value; + return $this; + } + + public function hasTransactions(?bool $value = true): static + { + $this->hasTransactions = $value; + return $this; + } + + /** @param PurchasableInterface|array|null $value */ + public function hasPurchasables(mixed $value): static + { + $this->hasPurchasables = $value; + return $this; + } + + /** @param array{purchasables: array, match: ContainsPurchasablesMatch} $value */ + public function containsPurchasables(array $value): static + { + $this->containsPurchasables = $value; + return $this; + } + + public function storeId(?int $value): static + { + $this->storeId = $value; + return $this; + } + + public function withAll(bool $value = true): static + { + $this->withAll = $value; + return $this; + } + + public function withAddresses(bool $value = true): static + { + $this->withAddresses = $value; + return $this; + } + + public function withAdjustments(bool $value = true): static + { + $this->withAdjustments = $value; + return $this; + } + + public function withCustomer(bool $value = true): static + { + $this->withCustomer = $value; + return $this; + } + + public function withLineItems(bool $value = true): static + { + $this->withLineItems = $value; + return $this; + } + + public function withTransactions(bool $value = true): static + { + $this->withTransactions = $value; + return $this; + } + + /** @phpstan-ignore-next-line method.childParameterType, method.childReturnType (this query only ever hydrates Order elements; narrowing Collection to Collection is safe here even though the interface's Collection generics are invariant to PHPStan) */ + #[Override] + public function afterHydrate(Collection $elements): Collection + { + if ($elements->isEmpty()) { + return $elements; + } + + /** @var Order[] $orders */ + $orders = $elements->all(); + + if ($this->withLineItems || $this->withAll) { + // TODO: migrate to app(LineItems::class)->eagerLoadLineItemsForOrders() once the LineItems + // service and LineItem model are migrated to src/ (blocked on the Order/Purchasable-tied + // LineItem migration - see laravel-migration-private.md) + $orders = app(LineItems::class)->eagerLoadLineItemsForOrders($orders); + } + + if ($this->withTransactions || $this->withAll) { + $orders = app(Transactions::class)->eagerLoadTransactionsForOrders($orders); + } + + if ($this->withAdjustments || $this->withAll) { + $orders = app(OrderAdjustments::class)->eagerLoadOrderAdjustmentsForOrders($orders); + } + + if ($this->withCustomer || $this->withAll) { + $orders = app(Customers::class)->eagerLoadCustomerForOrders($orders); + } + + if ($this->withAddresses || $this->withAll) { + $orders = app(Orders::class)->eagerLoadAddressesForOrders($orders); + } + + $orders = app(OrderNotices::class)->eagerLoadOrderNoticesForOrders($orders); + + return new Collection($orders); + } +} diff --git a/src/Order/Records/LineItemStatus.php b/src/Order/Records/LineItemStatus.php new file mode 100644 index 0000000000..99514428ce --- /dev/null +++ b/src/Order/Records/LineItemStatus.php @@ -0,0 +1,31 @@ + 'integer', + 'default' => 'boolean', + 'isArchived' => 'boolean', + 'sortOrder' => 'integer', + 'dateArchived' => 'datetime', + ]; +} diff --git a/src/Order/Records/OrderAdjustment.php b/src/Order/Records/OrderAdjustment.php new file mode 100644 index 0000000000..26f45ec176 --- /dev/null +++ b/src/Order/Records/OrderAdjustment.php @@ -0,0 +1,32 @@ + 'integer', + 'lineItemId' => 'integer', + 'amount' => 'float', + 'included' => 'boolean', + 'isEstimated' => 'boolean', + 'sourceSnapshot' => 'array', + ]; +} diff --git a/src/Order/Records/OrderHistory.php b/src/Order/Records/OrderHistory.php new file mode 100644 index 0000000000..c7a8852cf8 --- /dev/null +++ b/src/Order/Records/OrderHistory.php @@ -0,0 +1,30 @@ + 'integer', + 'userId' => 'integer', + 'prevStatusId' => 'integer', + 'newStatusId' => 'integer', + ]; +} diff --git a/src/Order/Records/OrderNotice.php b/src/Order/Records/OrderNotice.php new file mode 100644 index 0000000000..aff60a7757 --- /dev/null +++ b/src/Order/Records/OrderNotice.php @@ -0,0 +1,27 @@ + 'integer', + ]; +} diff --git a/src/Order/Records/OrderStatus.php b/src/Order/Records/OrderStatus.php new file mode 100644 index 0000000000..c9452c2a12 --- /dev/null +++ b/src/Order/Records/OrderStatus.php @@ -0,0 +1,32 @@ + 'integer', + 'default' => 'boolean', + 'sortOrder' => 'integer', + ]; +} diff --git a/src/Order/Validation/OrderRules.php b/src/Order/Validation/OrderRules.php new file mode 100644 index 0000000000..d93a30b42a --- /dev/null +++ b/src/Order/Validation/OrderRules.php @@ -0,0 +1,35 @@ +afterValidate($validator)` when it exists. This ruleset is therefore + * intentionally minimal, covering only the handful of attributes that were plain type/format + * rules in the legacy `defineRules()`. + * + * @property Order $subject + */ +class OrderRules extends ElementRules +{ + public function rules(): array + { + $rules = parent::rules(); + + $rules['gatewayId'] = ['nullable', 'integer']; + $rules['shippingAddressId'] = ['nullable', 'integer']; + $rules['billingAddressId'] = ['nullable', 'integer']; + $rules['paymentSourceId'] = ['nullable', 'integer']; + + return $rules; + } +} diff --git a/src/Payment/Currencies.php b/src/Payment/Currencies.php new file mode 100644 index 0000000000..f7d9d37e48 --- /dev/null +++ b/src/Payment/Currencies.php @@ -0,0 +1,95 @@ + */ + private array $tellersByIso = []; + + public function __construct() + { + $this->isoCurrencies = new ISOCurrencies(); + } + + public function getTeller(Currency|string $currency): Teller + { + if (is_string($currency)) { + $currency = new Currency($currency); + } + + $iso = $currency->getCode(); + if (isset($this->tellersByIso[$iso])) { + return $this->tellersByIso[$iso]; + } + + $parser = new DecimalMoneyParser($this->isoCurrencies); + $formatter = new DecimalMoneyFormatter($this->isoCurrencies); + + return $this->tellersByIso[$iso] = new Teller( + $currency, + $parser, + $formatter, + Money::ROUND_HALF_UP, + ); + } + + public function getCurrencyByIso(string $iso): ?Currency + { + return $this->getAllCurrencies()->first(fn(Currency $currency) => $currency->getCode() === $iso); + } + + /** + * @return Collection + */ + public function getAllCurrencies(): Collection + { + /** @var Collection $currencies */ + $currencies = collect($this->isoCurrencies); + + return $currencies; + } + + /** + * @return list + */ + public function getAllCurrenciesList(): array + { + return $this->getAllCurrencies()->map(fn(Currency $currency) => [ + 'label' => $currency->getCode(), // TODO: get name somehow + 'value' => $currency->getCode(), + ])->all(); + } + + public function getSubunitFor(Currency|string $currency): int + { + if (is_string($currency)) { + $currency = $this->getCurrencyByIso($currency); + } + + return $this->isoCurrencies->subunitFor($currency); + } + + public function numericCodeFor(Currency|string $currency): int + { + if (is_string($currency)) { + $currency = $this->getCurrencyByIso($currency); + } + + return $this->isoCurrencies->numericCodeFor($currency); + } +} diff --git a/src/Payment/Events/PaymentCurrencyRateEvent.php b/src/Payment/Events/PaymentCurrencyRateEvent.php new file mode 100644 index 0000000000..99a668dd4d --- /dev/null +++ b/src/Payment/Events/PaymentCurrencyRateEvent.php @@ -0,0 +1,18 @@ +number = preg_replace('/\D/', '', $values['number'] ?? ''); + + if (isset($values['expiry'])) { + $expiry = explode('/', (string) $values['expiry']); + + $this->month = trim($expiry[0]); + + if (isset($expiry[1])) { + $this->year = trim($expiry[1]); + } + } + } + + #[\Override] + public function getRules(): array + { + return [ + 'firstName' => ['required', 'string'], + 'lastName' => ['required', 'string'], + 'month' => ['required', 'numeric', 'min:1', 'max:12'], + 'year' => ['required', 'numeric', 'min:' . date('Y'), 'max:' . ((int)date('Y') + 12)], + 'cvv' => ['required', 'digits_between:3,4'], + 'number' => ['required', 'digits_between:1,19', function(string $attribute, mixed $value, \Closure $fail) { + $str = ''; + foreach (array_reverse(str_split((string) $value)) as $i => $c) { + $str .= ($i % 2) ? (int)$c * 2 : $c; + } + if (array_sum(str_split($str)) % 10 !== 0) { + $fail(t('Not a valid credit card number.', category: 'commerce')); + } + }], + ]; + } +} diff --git a/src/Payment/Forms/DummyPaymentForm.php b/src/Payment/Forms/DummyPaymentForm.php new file mode 100644 index 0000000000..6c20c94e9e --- /dev/null +++ b/src/Payment/Forms/DummyPaymentForm.php @@ -0,0 +1,26 @@ +token = (string)$paymentSource->id; + } + + #[\Override] + public function getRules(): array + { + if ($this->token) { + return []; + } + + return parent::getRules(); + } +} diff --git a/src/Payment/Forms/OffsitePaymentForm.php b/src/Payment/Forms/OffsitePaymentForm.php new file mode 100644 index 0000000000..2dc1beaeba --- /dev/null +++ b/src/Payment/Forms/OffsitePaymentForm.php @@ -0,0 +1,9 @@ +name; + } + + public function setIsFrontendEnabled(bool|string|null $isFrontendEnabled): void + { + $this->_isFrontendEnabled = $isFrontendEnabled; + } + + public function getIsFrontendEnabled(bool $parse = true): bool|string|null + { + return $parse ? Env::parseBoolean($this->_isFrontendEnabled) : $this->_isFrontendEnabled; + } + + /** + * Shows the payment button on the payment form. + */ + public function showPaymentFormSubmitButton(): bool + { + return true; + } + + /** + * Returns the webhook url for this gateway. + * + * @param array $params Parameters for the url. + */ + public function getWebhookUrl(array $params = []): string + { + $params = array_merge(['gateway' => $this->id], $params); + + $url = Url::actionUrl('commerce/webhooks/process-webhook', $params); + + // Remove the cpTrigger from the url if it's there. + if ($cpTrigger = Cms::config()->cpTrigger) { + $url = str_replace($cpTrigger . '/', '', $url); + } + + return $url; + } + + /** + * Returns whether this gateway allows payments in control panel. + */ + public function cpPaymentsEnabled(): bool + { + return true; + } + + public function getCpEditUrl(): string + { + return Url::cpUrl('commerce/settings/gateways/' . $this->id); + } + + /** + * Returns the payment type options. + */ + public function getPaymentTypeOptions(): array + { + return [ + 'authorize' => t('Authorize Only (Manually Capture)', category: 'commerce'), + 'purchase' => t('Purchase (Authorize and Capture Immediately)', category: 'commerce'), + ]; + } + + #[Override] + public function getRules(): array + { + return [ + 'paymentType' => ['required'], + 'handle' => ['required'], + ]; + } + + /** + * Returns the html to use when paying with a stored payment source. + */ + public function getPaymentConfirmationFormHtml(array $params): string + { + return ''; + } + + public function availableForUseWithOrder(Order $order): bool + { + if ($this->hasOrderCondition() && !$this->getOrderCondition()->matchElement($order)) { + return false; + } + + if ($this->hasBillingAddressCondition() && $order->billingAddress && !$this->getBillingAddressCondition()->matchElement($order->billingAddress)) { + return false; + } + + if ($this->hasShippingAddressCondition() && $order->shippingAddress && !$this->getShippingAddressCondition()->matchElement($order->shippingAddress)) { + return false; + } + + return true; + } + + /** + * Returns true if gateway supports partial refund requests. + */ + public function supportsPartialPayment(): bool + { + return true; + } + + /** + * Returns true if this gateway has an order condition + */ + public function hasOrderCondition(): bool + { + return $this->getOrderCondition()->getConditionRules() !== []; + } + + /** + * Returns payment Form HTML + */ + abstract public function getPaymentFormHtml(array $params): ?string; + + public function getTransactionHashFromWebhook(): ?string + { + return null; + } + + public function transactionSupportsRefund(Transaction $transaction): bool + { + return true; + } + + /** + * Gets the order condition for this gateway + */ + public function getOrderCondition(): ElementConditionInterface + { + /** @var GatewayOrderCondition $condition */ + $condition = $this->_orderCondition ?? new GatewayOrderCondition(); + $condition->mainTag = 'div'; + $condition->name = 'orderCondition'; + + return $condition; + } + + /** + * Sets the order condition for this gateway + */ + public function setOrderCondition(ElementConditionInterface|string|array|null $condition): void + { + if (empty($condition)) { + $this->_orderCondition = null; + return; + } + + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof GatewayOrderCondition) { + $condition['class'] = GatewayOrderCondition::class; + $condition = Conditions::createCondition($condition); + } + $condition->forProjectConfig = true; + + /** @phpstan-ignore-next-line assign.propertyType */ + $this->_orderCondition = $condition; + } + + /** + * Returns true if this gateway has a billing address condition + */ + public function hasBillingAddressCondition(): bool + { + return $this->getBillingAddressCondition()->getConditionRules() !== []; + } + + /** + * Gets the billing address condition for this gateway + */ + public function getBillingAddressCondition(): ElementConditionInterface + { + /** @var GatewayAddressCondition $condition */ + $condition = $this->_billingAddressCondition ?? new GatewayAddressCondition(); + $condition->mainTag = 'div'; + $condition->name = 'billingAddressCondition'; + + return $condition; + } + + /** + * Sets the billing address condition for this gateway + */ + public function setBillingAddressCondition(ElementConditionInterface|string|array|null $condition): void + { + if (empty($condition)) { + $this->_billingAddressCondition = null; + return; + } + + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof GatewayAddressCondition) { + $condition['class'] = GatewayAddressCondition::class; + $condition = Conditions::createCondition($condition); + } + $condition->forProjectConfig = true; + + /** @phpstan-ignore-next-line assign.propertyType */ + $this->_billingAddressCondition = $condition; + } + + /** + * Returns true if this gateway has a shipping address condition + */ + public function hasShippingAddressCondition(): bool + { + return $this->getShippingAddressCondition()->getConditionRules() !== []; + } + + /** + * Gets the shipping address condition for this gateway + */ + public function getShippingAddressCondition(): ElementConditionInterface + { + /** @var GatewayAddressCondition $condition */ + $condition = $this->_shippingAddressCondition ?? new GatewayAddressCondition(); + $condition->mainTag = 'div'; + $condition->name = 'shippingAddressCondition'; + + return $condition; + } + + /** + * Sets the shipping address condition for this gateway + */ + public function setShippingAddressCondition(ElementConditionInterface|string|array|null $condition): void + { + if (empty($condition)) { + $this->_shippingAddressCondition = null; + return; + } + + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof GatewayAddressCondition) { + $condition['class'] = GatewayAddressCondition::class; + $condition = Conditions::createCondition($condition); + } + $condition->forProjectConfig = true; + + /** @phpstan-ignore-next-line assign.propertyType */ + $this->_shippingAddressCondition = $condition; + } + + public function getConfig(): array + { + return [ + 'name' => $this->name, + 'handle' => $this->handle, + 'type' => static::class, + 'settings' => $this->getSettings(), + 'sortOrder' => ($this->sortOrder ?? 99), + 'paymentType' => $this->paymentType, + 'isFrontendEnabled' => $this->getIsFrontendEnabled(false), + 'orderCondition' => $this->getOrderCondition()->getConfig(), + 'billingAddressCondition' => $this->getBillingAddressCondition()->getConfig(), + 'shippingAddressCondition' => $this->getShippingAddressCondition()->getConfig(), + ]; + } +} diff --git a/src/Payment/Gateway/GatewayTypes.php b/src/Payment/Gateway/GatewayTypes.php new file mode 100644 index 0000000000..0c9851c3cd --- /dev/null +++ b/src/Payment/Gateway/GatewayTypes.php @@ -0,0 +1,34 @@ +register(MyGateway::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class GatewayTypes extends TypeRegistry +{ + protected const ?string CONTRACT = GatewayInterface::class; + + protected const array DEFAULT_TYPES = [ + Dummy::class, + Manual::class, + ]; +} diff --git a/src/Payment/Gateway/Gateways.php b/src/Payment/Gateway/Gateways.php new file mode 100644 index 0000000000..635035e6a4 --- /dev/null +++ b/src/Payment/Gateway/Gateways.php @@ -0,0 +1,351 @@ +|null All gateways + */ + private ?Collection $allGateways = null; + + /** + * Returns all registered gateway types. + * + * @return string[] + */ + public function getAllGatewayTypes(): array + { + return app(GatewayTypes::class)->types()->all(); + } + + /** + * Returns all customer enabled gateways. + * + * @return Collection All gateways that are enabled for frontend + */ + public function getAllCustomerEnabledGateways(): Collection + { + return $this->getAllGateways()->filter(fn(Gateway $gateway) => $gateway->getIsFrontendEnabled()); + } + + /** + * Returns all customer enabled gateways and allowed for the order/cart. + * + * @return Collection All gateways that are enabled for frontend and allowed for the order/cart. + */ + public function getAllCustomerEnabledGatewaysAndAvailableForUseWithOrder(Order $order): Collection + { + return $this->getAllCustomerEnabledGateways()->filter(fn(Gateway $gateway) => $gateway->availableForUseWithOrder($order)); + } + + /** + * Returns all gateways. + * + * @return Collection All gateways + */ + public function getAllGateways(): Collection + { + return $this->_getAllGateways()->where('isArchived', false); + } + + /** + * @return Gateway[] + */ + public function getAllArchivedGateways(): array + { + return $this->_getAllGateways()->where('isArchived', true)->all(); + } + + /** + * Archives a gateway by its ID. + * + * @return bool Whether the archiving was successful or not + */ + public function archiveGatewayById(int $id): bool + { + /** @var Gateway $gateway */ + $gateway = $this->getGatewayById($id); + $gateway->isArchived = true; + + if (!$this->saveGateway($gateway)) { + return false; + } + + // remove all payment sources for this gateway + // this will also remove them as the payment source for a cart + DB::table(Table::PAYMENTSOURCES)->where('gatewayId', $id)->delete(); + + // Clear this as the selected gateway from all active carts and orders + DB::table(Table::ORDERS)->where('gatewayId', $id)->update([ + 'gatewayId' => null, + 'paymentSourceId' => null, + ]); + + return true; + } + + /** + * Returns a gateway by its ID. + */ + public function getGatewayById(int $id): ?Gateway + { + return $this->_getAllGateways()->firstWhere('id', $id); + } + + /** + * Returns a gateway by its handle. + */ + public function getGatewayByHandle(string $handle): ?Gateway + { + return $this->_getAllGateways()->firstWhere('handle', $handle); + } + + /** + * Saves a gateway. + */ + public function saveGateway(Gateway $gateway, bool $runValidation = true): bool + { + $isNewGateway = $gateway->getIsNew(); + + if ($runValidation && !$gateway->validate()) { + Log::info('Gateway not saved due to validation error.'); + + return false; + } + + $gatewayUid = $isNewGateway ? Str::uuid()->toString() : $gateway->uid; + + $existingGateway = $this->getGatewayByHandle($gateway->handle); + + if ($existingGateway && (!$gateway->id || $gateway->id != $existingGateway->id)) { + $gateway->addError('handle', t('That handle is already in use.', category: 'commerce')); + + return false; + } + + $configData = $gateway->isArchived ? null : $gateway->getConfig(); + + $configPath = self::CONFIG_GATEWAY_KEY . '.' . $gatewayUid; + ProjectConfig::set($configPath, $configData); + + if ($isNewGateway) { + $gateway->id = CraftDb::idByUid(Table::GATEWAYS, $gatewayUid); + } + + $this->allGateways = null; // reset cache + + return true; + } + + /** + * Handle gateway change. + * + * @throws Throwable if reasons + */ + public function handleChangedGateway(ConfigEvent $event): void + { + $gatewayUid = $event->tokenMatches[0]; + $data = $event->newValue; + + // Bail if the data is not a valid gateway config array + if (!is_array($data)) { + return; + } + + DB::beginTransaction(); + try { + $gatewayRecord = $this->_getGatewayRecord($gatewayUid); + + $gatewayRecord->name = $data['name']; + $gatewayRecord->handle = $data['handle']; + $gatewayRecord->type = $data['type']; + $gatewayRecord->settings = $data['settings'] ?? null; + $gatewayRecord->sortOrder = $data['sortOrder']; + $gatewayRecord->paymentType = $data['paymentType']; + if ($data['isFrontendEnabled'] === null || is_bool($data['isFrontendEnabled'])) { + $data['isFrontendEnabled'] = $data['isFrontendEnabled'] ? '1' : '0'; + } + + $gatewayRecord->isFrontendEnabled = $data['isFrontendEnabled']; + $gatewayRecord->orderCondition = $data['orderCondition'] ?? null; + $gatewayRecord->billingAddressCondition = $data['billingAddressCondition'] ?? null; + $gatewayRecord->shippingAddressCondition = $data['shippingAddressCondition'] ?? null; + $gatewayRecord->isArchived = false; + $gatewayRecord->dateArchived = null; + $gatewayRecord->uid = $gatewayUid; + + $gatewayRecord->save(); + + DB::commit(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + } + + /** + * Handle gateway being archived. + * + * @throws Throwable if reasons + */ + public function handleArchivedGateway(ConfigEvent $event): void + { + $gatewayUid = $event->tokenMatches[0]; + + DB::beginTransaction(); + try { + $gatewayRecord = $this->_getGatewayRecord($gatewayUid); + + $gatewayRecord->isArchived = true; + $gatewayRecord->dateArchived = Carbon::now(); + + $gatewayRecord->save(); + + DB::commit(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + } + + /** + * Reorders gateways by ids. + * + * @param int[] $ids Array of gateway IDs. + * @return bool Always true. + */ + public function reorderGateways(array $ids): bool + { + $uidsByIds = CraftDb::uidsByIds(Table::GATEWAYS, $ids); + + foreach ($ids as $gatewayOrder => $gatewayId) { + if (!empty($uidsByIds[$gatewayId])) { + $gatewayUid = $uidsByIds[$gatewayId]; + ProjectConfig::set(self::CONFIG_GATEWAY_KEY . '.' . $gatewayUid . '.sortOrder', $gatewayOrder + 1); + } + } + + $this->allGateways = null; // reset cache + + return true; + } + + /** + * Creates a gateway with a given config. + * + * @param string|array $config The gateway's class name, or its config, with a `type` value and optionally a `settings` value + */ + public function createGateway(string|array $config): Gateway + { + if (is_string($config)) { + $config = ['type' => $config]; + } + + try { + if ($config['type'] === MissingGateway::class) { + throw new MissingComponentException('Missing Gateway Class.'); + } + + /** @var Gateway $gateway */ + $gateway = ComponentHelper::createComponent($config, GatewayInterface::class); + } catch (MissingComponentException $e) { + $config['errorMessage'] = $e->getMessage(); + $config['expectedType'] = $config['type']; + unset($config['type']); + + $gateway = new MissingGateway($config); + } + + return $gateway; + } + + private function query(): Builder + { + $query = DB::table(Table::GATEWAYS) + ->select([ + 'dateArchived', + 'handle', + 'id', + 'isArchived', + 'isFrontendEnabled', + 'name', + 'paymentType', + 'settings', + 'sortOrder', + 'type', + 'uid', + ]) + ->orderBy('sortOrder'); + + // TODO: Remove these hasColumn checks in Commerce 6.0 once the schema guarantees orderCondition / billingAddressCondition / shippingAddressCondition columns on the gateways table + if (Schema::hasColumn(Table::GATEWAYS, 'orderCondition')) { + $query->addSelect('orderCondition'); + } + if (Schema::hasColumn(Table::GATEWAYS, 'billingAddressCondition')) { + $query->addSelect('billingAddressCondition'); + } + if (Schema::hasColumn(Table::GATEWAYS, 'shippingAddressCondition')) { + $query->addSelect('shippingAddressCondition'); + } + + return $query; + } + + /** + * Gets a gateway's record by uid. + */ + private function _getGatewayRecord(string $uid): GatewayRecord + { + if ($gateway = GatewayRecord::where('uid', $uid)->first()) { + return $gateway; + } + + return new GatewayRecord(); + } + + /** + * @return Collection + */ + private function _getAllGateways(): Collection + { + if ($this->allGateways === null) { + $results = $this->query()->get(); + + $gateways = []; + foreach ($results as $result) { + $gateways[] = $this->createGateway((array)$result); + } + + $this->allGateways = collect($gateways)->keyBy('id'); + } + + return $this->allGateways; + } +} diff --git a/src/Payment/Gateway/Records/Gateway.php b/src/Payment/Gateway/Records/Gateway.php new file mode 100644 index 0000000000..aae880a8da --- /dev/null +++ b/src/Payment/Gateway/Records/Gateway.php @@ -0,0 +1,33 @@ + 'boolean', + 'sortOrder' => 'integer', + 'settings' => 'array', + 'orderCondition' => 'array', + 'billingAddressCondition' => 'array', + 'shippingAddressCondition' => 'array', + 'dateArchived' => 'datetime', + ]; +} diff --git a/src/Payment/Gateway/Responses/Dummy.php b/src/Payment/Gateway/Responses/Dummy.php new file mode 100644 index 0000000000..739f574420 --- /dev/null +++ b/src/Payment/Gateway/Responses/Dummy.php @@ -0,0 +1,100 @@ +_success = false; + return; + } + + // Token populated? This is a "payment source" so no need to fail anything. + if ($form->token) { + return; + } + + $number = (string)$form->number; + $isValid = ((int)substr($number, -1) % 2 === 0); + + if (!$isValid) { + $this->_success = false; + } + } + + #[\Override] + public function isSuccessful(): bool + { + return $this->_success; + } + + #[\Override] + public function isRedirect(): bool + { + return false; + } + + #[\Override] + public function getRedirectMethod(): string + { + return ''; + } + + #[\Override] + public function getRedirectData(): array + { + return []; + } + + #[\Override] + public function getRedirectUrl(): string + { + return ''; + } + + #[\Override] + public function getTransactionReference(): string + { + return date('Y-m-d-H-i-s'); + } + + #[\Override] + public function getCode(): string + { + return $this->_success ? '' : 'payment.failed'; + } + + #[\Override] + public function getMessage(): string + { + return $this->_success ? '' : t('Dummy gateway payment failed.', category: 'commerce'); + } + + #[\Override] + public function redirect(): void + { + } + + #[\Override] + public function getData(): mixed + { + return ''; + } + + #[\Override] + public function isProcessing(): bool + { + return false; + } +} diff --git a/src/Payment/Gateway/Responses/Manual.php b/src/Payment/Gateway/Responses/Manual.php new file mode 100644 index 0000000000..786e0b0d91 --- /dev/null +++ b/src/Payment/Gateway/Responses/Manual.php @@ -0,0 +1,75 @@ +getPaymentFormModel(); + + if (Cms::config()->devMode) { + $paymentFormModel->firstName = 'Jenny'; + $paymentFormModel->lastName = 'Andrews'; + $paymentFormModel->number = '4242424242424242'; + $paymentFormModel->expiry = '01/' . date('Y', strtotime('+1 year')); + $paymentFormModel->cvv = '123'; + } + + $defaults = [ + 'paymentForm' => $paymentFormModel, + ]; + + $params = array_merge($defaults, $params); + + return template('commerce/_components/gateways/_creditCardFields', $params, TemplateMode::Cp); + } + + public function getPaymentFormModel(): DummyPaymentForm + { + return new DummyPaymentForm(); + } + + #[Override] + public function authorize(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface + { + if (!$form instanceof CreditCardPaymentForm) { + throw new InvalidArgumentException(sprintf('%s only accepts %s objects passed to $form.', __METHOD__, CreditCardPaymentForm::class)); + } + + return new DummyRequestResponse($form); + } + + #[Override] + public function capture(Transaction $transaction, string $reference): RequestResponseInterface + { + return new DummyRequestResponse(); + } + + #[Override] + public function completeAuthorize(Transaction $transaction): RequestResponseInterface + { + return new DummyRequestResponse(); + } + + #[Override] + public function completePurchase(Transaction $transaction): RequestResponseInterface + { + return new DummyRequestResponse(); + } + + #[Override] + public function createPaymentSource(BasePaymentForm $sourceData, int $customerId): PaymentSource + { + /** @var CreditCardPaymentForm $sourceData */ + $paymentSource = new PaymentSource(); + $paymentSource->customerId = $customerId; + $paymentSource->gatewayId = $this->id; + $paymentSource->token = Str::random(); + $paymentSource->response = ''; + $paymentSource->description = 'Card ending with ' . substr((string)$sourceData->number, -4); + + return $paymentSource; + } + + #[Override] + public function deletePaymentSource(string $token): bool + { + return true; + } + + #[Override] + public function purchase(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface + { + if (!$form instanceof CreditCardPaymentForm) { + throw new InvalidArgumentException(sprintf('%s only accepts %s objects passed to $form.', __METHOD__, CreditCardPaymentForm::class)); + } + + return new DummyRequestResponse($form); + } + + #[Override] + public function processWebHook(): Response + { + throw new NotImplementedException(self::class . ' does not support processWebhook()'); + } + + #[Override] + public function refund(Transaction $transaction): RequestResponseInterface + { + $form = new DummyPaymentForm(); + + if ($transaction->note != 'fail') { + $form->number = '4242424242424242'; + } else { + $form->number = '378282246310005'; + } + + return new DummyRequestResponse($form); + } + + #[Override] + public function supportsAuthorize(): bool + { + return true; + } + + #[Override] + public function supportsCapture(): bool + { + return true; + } + + #[Override] + public function supportsCompleteAuthorize(): bool + { + return true; + } + + #[Override] + public function supportsCompletePurchase(): bool + { + return true; + } + + #[Override] + public function supportsPaymentSources(): bool + { + return true; + } + + #[Override] + public function supportsPurchase(): bool + { + return true; + } + + #[Override] + public function supportsRefund(): bool + { + return true; + } + + #[Override] + public function supportsPartialRefund(): bool + { + return true; + } + + #[Override] + public function supportsWebhooks(): bool + { + return false; + } +} diff --git a/src/Payment/Gateway/Types/Manual.php b/src/Payment/Gateway/Types/Manual.php new file mode 100644 index 0000000000..9586336ec2 --- /dev/null +++ b/src/Payment/Gateway/Types/Manual.php @@ -0,0 +1,196 @@ +getOnlyAllowForZeroPriceOrders(false); + + return $settings; + } + + public function getPaymentFormHtml(array $params): ?string + { + return ''; + } + + #[Override] + public function getPaymentFormModel(): BasePaymentForm + { + return new OffsitePaymentForm(); + } + + #[Override] + public function settingsForm(FormContext $context = new FormContext()): ?Form + { + return Form::make() + ->add(Field::make(t('Only allow for orders with a zero balance', category: 'commerce')) + ->control(Lightswitch::make('onlyAllowForZeroPriceOrders')->value($this->getOnlyAllowForZeroPriceOrders(false)))); + } + + #[Override] + public function authorize(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface + { + return new ManualRequestResponse(); + } + + #[Override] + public function capture(Transaction $transaction, string $reference): RequestResponseInterface + { + return new ManualRequestResponse(); + } + + #[Override] + public function completeAuthorize(Transaction $transaction): RequestResponseInterface + { + throw new NotImplementedException(t('This gateway does not support that functionality.', category: 'commerce')); + } + + #[Override] + public function completePurchase(Transaction $transaction): RequestResponseInterface + { + throw new NotImplementedException(t('This gateway does not support that functionality.', category: 'commerce')); + } + + #[Override] + public function createPaymentSource(BasePaymentForm $sourceData, int $customerId): PaymentSource + { + throw new NotImplementedException(t('This gateway does not support that functionality.', category: 'commerce')); + } + + #[Override] + public function deletePaymentSource(string $token): bool + { + throw new NotImplementedException(t('This gateway does not support that functionality.', category: 'commerce')); + } + + #[Override] + public function getPaymentTypeOptions(): array + { + return [ + 'authorize' => t('Authorize Only (Manually Capture)', category: 'commerce'), + ]; + } + + #[Override] + public function purchase(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface + { + throw new NotImplementedException(t('This gateway does not support that functionality.', category: 'commerce')); + } + + #[Override] + public function processWebHook(): Response + { + throw new NotImplementedException(t('This gateway does not support that functionality.', category: 'commerce')); + } + + #[Override] + public function refund(Transaction $transaction): RequestResponseInterface + { + return new ManualRequestResponse(); + } + + #[Override] + public function supportsAuthorize(): bool + { + return true; + } + + #[Override] + public function supportsCapture(): bool + { + return true; + } + + #[Override] + public function supportsCompleteAuthorize(): bool + { + return false; + } + + #[Override] + public function supportsCompletePurchase(): bool + { + return false; + } + + #[Override] + public function supportsPaymentSources(): bool + { + return false; + } + + #[Override] + public function supportsPurchase(): bool + { + return false; + } + + #[Override] + public function supportsRefund(): bool + { + return true; + } + + #[Override] + public function supportsPartialRefund(): bool + { + return true; + } + + #[Override] + public function supportsWebhooks(): bool + { + return false; + } + + #[Override] + public function availableForUseWithOrder(Order $order): bool + { + if ($this->getOnlyAllowForZeroPriceOrders() && $order->getTotalPrice() != 0) { + return false; + } + + return parent::availableForUseWithOrder($order); + } + + public function getOnlyAllowForZeroPriceOrders(bool $parse = true): bool|string + { + return $parse ? (Env::parseBoolean($this->_onlyAllowForZeroPriceOrders) ?? false) : $this->_onlyAllowForZeroPriceOrders; + } + + public function setOnlyAllowForZeroPriceOrders(bool|string $onlyAllowForZeroPriceOrders): void + { + $this->_onlyAllowForZeroPriceOrders = $onlyAllowForZeroPriceOrders; + } +} diff --git a/src/Payment/Gateway/Types/MissingGateway.php b/src/Payment/Gateway/Types/MissingGateway.php new file mode 100644 index 0000000000..a157ad4f2f --- /dev/null +++ b/src/Payment/Gateway/Types/MissingGateway.php @@ -0,0 +1,147 @@ +iso; + } + + public function getCurrency(): Currency + { + return new Currency($this->iso); + } + + public function getCpEditUrl(): string + { + if ($this->storeId === null) { + return ''; + } + + $store = app(Stores::class)->getStoreById($this->storeId); + if ($store === null) { + throw new \InvalidArgumentException('Invalid store ID: ' . $this->storeId); + } + + return Url::cpUrl(sprintf('commerce/store-management/%s/payment-currencies/%s', $store->handle, $this->id)); + } + + public function getAlphabeticCode(): ?string + { + return $this->iso; + } + + public function getNumericCode(): ?int + { + return app(Currencies::class)->numericCodeFor($this->iso); + } + + public function getEntity(): ?string + { + return ''; + } + + #[\Deprecated(message: 'Use getSubUnit() instead.')] + public function getMinorUnit(): ?int + { + return $this->getSubUnit(); + } + + public function getSubUnit(): ?int + { + return app(Currencies::class)->getSubunitFor($this->iso); + } + + public function getName(): ?string + { + return $this->iso; + } + + public function getStore(): \CraftCms\Commerce\Store\Models\Store + { + $store = app(Stores::class)->getStoreById($this->storeId); + if ($store === null) { + throw new \InvalidArgumentException('Invalid store ID: ' . $this->storeId); + } + + return $store; + } + + public function getPrimary(): bool + { + return $this->getCode() === $this->getStore()->getCurrency()->getCode(); + } + + public function getCode(): ?string + { + return $this->iso; + } + + #[\Override] + public function getRules(): array + { + return [ + 'iso' => ['required', 'string'], + 'rate' => ['required', 'numeric'], + ]; + } +} diff --git a/src/Payment/Models/PaymentSource.php b/src/Payment/Models/PaymentSource.php new file mode 100644 index 0000000000..f0c476ddad --- /dev/null +++ b/src/Payment/Models/PaymentSource.php @@ -0,0 +1,80 @@ +token; + } + + public function getCustomer(): ?User + { + if (!isset($this->_customer)) { + $this->_customer = Users::getUserById($this->customerId); + } + + return $this->_customer; + } + + public function getIsPrimary(): bool + { + $customer = $this->getCustomer(); + return $customer && $customer->primaryPaymentSourceId === $this->id; + } + + #[\Deprecated(message: 'in 4.0.0. Use [[getCustomer()]] instead.')] + public function getUser(): ?User + { + Deprecator::log('PaymentSource::getUser()', 'The `PaymentSource::getUser()` is deprecated, use the `PaymentSource::getCustomer()` instead.'); + return $this->getCustomer(); + } + + public function getGateway(): ?GatewayInterface + { + if ($this->_gateway === null && $this->gatewayId) { + $this->_gateway = app(Gateways::class)->getGatewayById($this->gatewayId); + } + + return $this->_gateway; + } + + #[\Override] + public function getRules(): array + { + return [ + 'token' => ['required', 'string', Rule::unique(Table::PAYMENTSOURCES)->where(fn($q) => $q->where('gatewayId', $this->gatewayId))], + 'gatewayId' => ['required', 'integer'], + 'customerId' => ['required', 'integer'], + 'description' => ['required', 'string'], + ]; + } +} diff --git a/src/Payment/Models/Transaction.php b/src/Payment/Models/Transaction.php new file mode 100644 index 0000000000..a137857de8 --- /dev/null +++ b/src/Payment/Models/Transaction.php @@ -0,0 +1,165 @@ +hash = md5(uniqid((string)mt_rand(), true)); + + $primaryCurrency = app(PaymentCurrencies::class)->getPrimaryPaymentCurrencyIso(); + $this->currency ??= $primaryCurrency; + $this->paymentCurrency ??= $primaryCurrency; + + parent::__construct($config); + } + + #[\Override] + public function getRules(): array + { + return [ + 'type' => ['required', 'string'], + 'status' => ['required', 'string'], + 'orderId' => ['required', 'integer'], + ]; + } + + public function canCapture(): bool + { + return app(Transactions::class)->canCaptureTransaction($this); + } + + public function canRefund(): bool + { + return app(Transactions::class)->canRefundTransaction($this); + } + + public function getRefundableAmount(): float + { + return app(Transactions::class)->refundableAmountForTransaction($this); + } + + public function getParent(): ?Transaction + { + if ($this->_parentTransaction === null && $this->parentId) { + $this->_parentTransaction = app(Transactions::class)->getTransactionById($this->parentId); + } + + return $this->_parentTransaction; + } + + public function getOrder(): ?Order + { + if (!isset($this->_order) && $this->orderId) { + $this->_order = app(Orders::class)->getOrderById($this->orderId); + } + + return $this->_order; + } + + public function setOrder(Order $order): void + { + $this->_order = $order; + $this->orderId = $order->id; + } + + public function getGateway(): ?Gateway + { + if (!isset($this->_gateway) && $this->gatewayId) { + $this->_gateway = app(Gateways::class)->getGatewayById($this->gatewayId); + } + + return $this->_gateway; + } + + public function setGateway(Gateway $gateway): void + { + $this->_gateway = $gateway; + } + + public function getChildTransactions(): array + { + if (!isset($this->_children) && $this->id) { + $this->_children = app(Transactions::class)->getChildrenByTransactionId($this->id); + } + + return $this->_children ?? []; + } + + public function addChildTransaction(Transaction $transaction): void + { + if ($this->_children === null) { + $this->_children = []; + } + + $this->_children[] = $transaction; + } + + public function setChildTransactions(array $transactions): void + { + $this->_children = $transactions; + } +} diff --git a/src/Payment/PaymentCurrencies.php b/src/Payment/PaymentCurrencies.php new file mode 100644 index 0000000000..6a552b9a6c --- /dev/null +++ b/src/Payment/PaymentCurrencies.php @@ -0,0 +1,240 @@ +rate` to override the rate used for conversions and historical transaction snapshots. + * + * @since 5.7.0 + */ + public const EVENT_DEFINE_PAYMENT_CURRENCY_RATE = 'definePaymentCurrencyRate'; + + /** @var array>|null */ + private ?array $allPaymentCurrencies = null; + + /** + * Returns the rate for a payment currency, after giving event handlers a chance to override it. + * + * @since 5.7.0 + */ + public function getRateFor(PaymentCurrency $currency, ?Transaction $transaction = null): float + { + $event = new PaymentCurrencyRateEvent( + rate: $currency->rate, + paymentCurrency: $currency, + transaction: $transaction, + ); + + // TODO: migrate event firing to Laravel once the event system is bridged + if (Plugin::getInstance()->getPaymentCurrencies()->hasEventHandlers(self::EVENT_DEFINE_PAYMENT_CURRENCY_RATE)) { + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPaymentCurrencies()->trigger(self::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $event); + } + + return $event->rate; + } + + public function getPaymentCurrencyById(int $id, ?int $storeId = null): ?PaymentCurrency + { + $storeId ??= $this->currentStoreId(); + + return $this->getAllPaymentCurrencies($storeId)->firstWhere('id', $id); + } + + /** + * @return Collection + */ + public function getAllPaymentCurrencies(?int $storeId = null): Collection + { + $storeId ??= $this->currentStoreId(); + + if ($this->allPaymentCurrencies === null || !isset($this->allPaymentCurrencies[$storeId])) { + $rows = DB::table(Table::PAYMENTCURRENCIES) + ->select(['dateCreated', 'dateUpdated', 'id', 'iso', 'storeId', 'rate']) + ->orderBy('iso') + ->where('storeId', $storeId) + ->get() + ->all(); + + $this->allPaymentCurrencies ??= []; + + foreach ($rows as $row) { + $paymentCurrency = new PaymentCurrency((array) $row); + + $this->allPaymentCurrencies[$paymentCurrency->storeId] ??= collect(); + $this->allPaymentCurrencies[$paymentCurrency->storeId]->push($paymentCurrency); + } + } + + return $this->allPaymentCurrencies[$storeId] ?? collect(); + } + + public function getPaymentCurrencyByIso(string $iso, ?int $storeId = null): ?PaymentCurrency + { + $storeId ??= $this->currentStoreId(); + + return $this->getAllPaymentCurrencies($storeId)->firstWhere('iso', $iso); + } + + public function getPrimaryPaymentCurrencyIso(?int $storeId = null): string + { + return $this->getPrimaryPaymentCurrency($storeId)?->iso ?? 'USD'; /** @phpstan-ignore-line */ + } + + public function getPrimaryPaymentCurrency(?int $storeId = null): ?PaymentCurrency + { + $storeId ??= $this->currentStoreId(); + + $storeCurrency = app(Stores::class)->getStoreById($storeId)->getCurrency(); + + return $this->getAllPaymentCurrencies($storeId)->firstWhere( + fn(PaymentCurrency $currency) => $currency->getCode() === $storeCurrency->getCode(), + ); + } + + /** + * @return Collection + */ + public function getNonPrimaryPaymentCurrencies(?int $storeId = null): Collection + { + $storeCurrency = app(Stores::class)->getStoreById($storeId)->getCurrency(); + + return $this->getAllPaymentCurrencies($storeId)->where( + fn(PaymentCurrency $currency) => $currency->getCode() !== $storeCurrency->getCode(), + ); + } + + /** + * Convert an amount in the store's primary currency to another by ISO code. + * + * @throws \RuntimeException + */ + public function convert(float $amount, string $iso): float + { + $destination = $this->getPaymentCurrencyByIso($iso); + + if (!$destination) { + throw new \RuntimeException('No payment currency found with ISO code: ' . $iso); + } + + $primary = $this->getPrimaryPaymentCurrency(); + if (!$primary) { + return $amount * $this->getRateFor($destination); + } + + // Amount is already in the primary currency; convert to destination. + return $amount * $this->getRateFor($destination); + } + + public function savePaymentCurrency(PaymentCurrency $model, bool $runValidation = true): bool + { + if ($model->id) { + $record = PaymentCurrencyRecord::find($model->id); + if (!$record) { + throw new \RuntimeException(t('No currency exists with the ID "{id}"', ['id' => $model->id], category: 'commerce')); + } + } else { + $record = new PaymentCurrencyRecord(); + } + + if ($runValidation && !$model->validate()) { + return false; + } + + $record->iso = strtoupper((string) $model->iso); + $record->storeId = $model->storeId; + // If this rate is primary, the rate must be 1. + $record->rate = $model->getPrimary() ? 1 : $model->rate; + + $record->save(); + + $model->id = $record->id; + + return true; + } + + public function deletePaymentCurrencyById(int $id): bool + { + $paymentCurrency = PaymentCurrencyRecord::find($id); + + if (!$paymentCurrency) { + return false; + } + + $baseCurrency = $this->getPrimaryPaymentCurrency($paymentCurrency->storeId); + + DB::table(Table::ORDERS) + ->where('paymentCurrency', $paymentCurrency->iso) + ->where('storeId', $paymentCurrency->storeId) + ->update(['paymentCurrency' => $baseCurrency->iso]); + + return (bool) $paymentCurrency->delete(); + } + + /** + * @throws \RuntimeException + */ + public function convertAmount(Money $amount, Currency|string $currency, ?int $storeId = null): Money + { + if (is_string($currency)) { + $currency = new Currency($currency); + } + + $storeId ??= $this->currentStoreId(); + + $fromPaymentCurrency = $this->getPaymentCurrencyByIso($amount->getCurrency()->getCode(), $storeId); + $toPaymentCurrency = $this->getPaymentCurrencyByIso($currency->getCode(), $storeId); + + if (!$fromPaymentCurrency || !$toPaymentCurrency) { + throw new \RuntimeException('Currency not found in store: ' . $currency->getCode()); + } + + $converter = new Converter(new ISOCurrencies(), $this->buildExchange($storeId)); + return $converter->convert($amount, $toPaymentCurrency->getCurrency()); + } + + private function buildExchange(?int $storeId = null): FixedExchange + { + $storeId ??= $this->currentStoreId(); + + $storeCurrency = app(Stores::class)->getStoreById($storeId)->getCurrency(); + $nonPrimaryCurrencies = $this->getNonPrimaryPaymentCurrencies($storeId) + ->mapWithKeys(fn(PaymentCurrency $c) => [$c->iso => (string) $this->getRateFor($c)]); + + $exchange = [$storeCurrency->getCode() => $nonPrimaryCurrencies->all()]; + + foreach ($nonPrimaryCurrencies->all() as $iso => $rate) { + $exchange[$iso] = [$storeCurrency->getCode() => (string) (1 / (float) $rate)]; + } + + return new FixedExchange($exchange); + } + + private function currentStoreId(): int + { + return app(Stores::class)->getCurrentStore()->id; + } +} diff --git a/src/Payment/PaymentSources.php b/src/Payment/PaymentSources.php new file mode 100644 index 0000000000..dedf8fa44b --- /dev/null +++ b/src/Payment/PaymentSources.php @@ -0,0 +1,248 @@ + + */ + public function getAllPaymentSourcesByCustomerId(?int $customerId = null, ?int $gatewayId = null): Collection + { + if ($customerId === null) { + return collect(); + } + + $query = $this->query() + ->join(Table::GATEWAYS . ' as gateways', 'gateways.id', '=', 'ps.gatewayId') + ->where('ps.customerId', $customerId); + + if ($gatewayId) { + $query->where('ps.gatewayId', $gatewayId); + } + + return $query->get()->map(fn($row) => new PaymentSource((array)$row)); + } + + /** + * Returns all payment sources for a gateway. + * + * @return Collection + */ + public function getAllPaymentSourcesByGatewayId(?int $gatewayId = null): Collection + { + if ($gatewayId === null) { + return collect(); + } + + return $this->query() + ->where('ps.gatewayId', $gatewayId) + ->get() + ->map(fn($row) => new PaymentSource((array)$row)); + } + + /** + * Returns a customer's payment sources on a gateway, per the customer/user's ID. + * + * @return Collection + */ + public function getAllGatewayPaymentSourcesByCustomerId(?int $gatewayId = null, ?int $customerId = null): Collection + { + if ($gatewayId === null || $customerId === null) { + return collect(); + } + + return $this->query() + ->where('ps.customerId', $customerId) + ->where('ps.gatewayId', $gatewayId) + ->get() + ->map(fn($row) => new PaymentSource((array)$row)); + } + + /** + * Returns a payment source by its gateway's token. + */ + public function getPaymentSourceByTokenAndGatewayId(string $token, int $gatewayId): ?PaymentSource + { + $result = $this->query() + ->where('ps.token', $token) + ->where('ps.gatewayId', $gatewayId) + ->first(); + + return $result ? new PaymentSource((array)$result) : null; + } + + /** + * Returns a payment source by its ID. + */ + public function getPaymentSourceById(int $sourceId): ?PaymentSource + { + // Join on gateways to ensure it is still a valid gateway payment source + $result = $this->query() + ->join(Table::GATEWAYS . ' as gateways', 'gateways.id', '=', 'ps.gatewayId') + ->where('ps.id', $sourceId) + ->first(); + + return $result ? new PaymentSource((array)$result) : null; + } + + /** + * Returns a payment source by its ID and user ID. + */ + public function getPaymentSourceByIdAndUserId(int $sourceId, int $userId): ?PaymentSource + { + $result = $this->query() + ->where('ps.id', $sourceId) + ->where('ps.customerId', $userId) + ->first(); + + return $result ? new PaymentSource((array)$result) : null; + } + + /** + * Creates a payment source for a user in the gateway based on a payment form. + * + * @throws PaymentSourceException If unable to create the payment source + */ + public function createPaymentSource(int $customerId, GatewayInterface $gateway, BasePaymentForm $paymentForm, ?string $sourceDescription = null, bool $makePrimarySource = false): PaymentSource + { + $source = $gateway->createPaymentSource($paymentForm, $customerId); + + $source->customerId = $customerId; + + if (!empty($sourceDescription)) { + $source->description = $sourceDescription; + } + + if (!$this->savePaymentSource($source)) { + throw new PaymentSourceException(t('Could not create the payment source.', category: 'commerce')); + } + + if ($makePrimarySource) { + app(Customers::class)->savePrimaryPaymentSourceId($source->getCustomer(), $source->id); + } + + return $source; + } + + /** + * Saves a payment source. + * + * @throws \RuntimeException if the payment source couldn't be found + */ + public function savePaymentSource(PaymentSource $paymentSource, bool $runValidation = true): bool + { + if ($paymentSource->id) { + $record = PaymentSourceRecord::find($paymentSource->id); + + if (!$record) { + throw new \RuntimeException(t('No payment source exists with the ID "{id}"', ['id' => $paymentSource->id], category: 'commerce')); + } + } else { + $record = new PaymentSourceRecord(); + } + + // Raise 'beforeSavePaymentSource' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPaymentSources()->hasEventHandlers(self::EVENT_BEFORE_SAVE_PAYMENT_SOURCE)) { + $event = new PaymentSourceEvent(paymentSource: $paymentSource); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPaymentSources()->trigger(self::EVENT_BEFORE_SAVE_PAYMENT_SOURCE, $event); + } + + if ($runValidation && !$paymentSource->validate()) { + Log::info('Payment source not saved due to validation error.'); + + return false; + } + + $record->customerId = $paymentSource->customerId; + $record->gatewayId = $paymentSource->gatewayId; + $record->token = $paymentSource->token; + $record->description = $paymentSource->description; + $record->response = $paymentSource->response; + + $record->save(); + + $paymentSource->id = $record->id; + + // Raise 'afterSavePaymentSource' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPaymentSources()->hasEventHandlers(self::EVENT_AFTER_SAVE_PAYMENT_SOURCE)) { + $event = new PaymentSourceEvent(paymentSource: $paymentSource); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPaymentSources()->trigger(self::EVENT_AFTER_SAVE_PAYMENT_SOURCE, $event); + } + + return true; + } + + /** + * Delete a payment source by its ID. + */ + public function deletePaymentSourceById(int $id): bool + { + $record = PaymentSourceRecord::find($id); + + if ($record) { + $gateway = app(Gateways::class)->getGatewayById($record->gatewayId); + + $gateway?->deletePaymentSource($record->token); + + $paymentSource = $this->getPaymentSourceById($id); + + // Raise 'deletePaymentSource' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPaymentSources()->hasEventHandlers(self::EVENT_DELETE_PAYMENT_SOURCE)) { + $event = new PaymentSourceEvent(paymentSource: $paymentSource); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPaymentSources()->trigger(self::EVENT_DELETE_PAYMENT_SOURCE, $event); + } + + return (bool)$record->delete(); + } + + return false; + } + + private function query(): Builder + { + return DB::table(Table::PAYMENTSOURCES . ' as ps') + ->select([ + 'ps.description', + 'ps.gatewayId', + 'ps.id', + 'ps.response', + 'ps.token', + 'ps.customerId', + ]); + } +} diff --git a/src/Payment/Payments.php b/src/Payment/Payments.php new file mode 100644 index 0000000000..ac8e68cec3 --- /dev/null +++ b/src/Payment/Payments.php @@ -0,0 +1,453 @@ +getPayments(); + if ($legacyService->hasEventHandlers(self::EVENT_BEFORE_PROCESS_PAYMENT)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $legacyService->trigger(self::EVENT_BEFORE_PROCESS_PAYMENT, $event); + } + + if (!$event->isValid) { + // This error potentially is going to be displayed in the frontend, so we have to be vague about it. + // Long story short - a plugin said "no." + throw new PaymentException(t('Unable to make payment at this time.', category: 'commerce')); + } + + // Order could have zero totalPrice and already considered 'paid'. Free orders complete immediately. + $paymentStrategy = $order->getStore()->getFreeOrderPaymentStrategy(); + if (!$order->hasOutstandingBalance() && !$order->datePaid && $paymentStrategy === Store::FREE_ORDER_PAYMENT_STRATEGY_COMPLETE) { + $order->updateOrderPaidInformation(); + + if ($order->isCompleted) { + return; + } + } + + $gateway = $order->getGateway(); + if (!$gateway) { + throw new \RuntimeException(t('Missing Gateway', category: 'commerce')); + } + + //choosing default action + /** @phpstan-ignore-next-line property.notFound (paymentType is declared on legacy craft\commerce\base\Gateway, which implements GatewayInterface via the class_alias chain, which PHPStan can't trace) */ + $defaultAction = $gateway->paymentType; + $defaultAction = ($defaultAction === TransactionRecord::TYPE_PURCHASE) ? $defaultAction : TransactionRecord::TYPE_AUTHORIZE; + + if ($defaultAction === TransactionRecord::TYPE_AUTHORIZE) { + if (!$gateway->supportsAuthorize()) { + throw new PaymentException(t('Gateway doesn\'t support authorize', category: 'commerce')); + } + } elseif (!$gateway->supportsPurchase()) { + throw new PaymentException(t('Gateway doesn\'t support purchase', category: 'commerce')); + } + + //creating order, transaction and request + $transaction = app(Transactions::class)->createTransaction($order, null, $defaultAction); + + try { + $response = match ($defaultAction) { + TransactionRecord::TYPE_PURCHASE => $gateway->purchase($transaction, $form), + TransactionRecord::TYPE_AUTHORIZE => $gateway->authorize($transaction, $form), + }; + + $this->updateTransaction($transaction, $response); + + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPayments()->hasEventHandlers(self::EVENT_AFTER_PROCESS_PAYMENT)) { + $afterEvent = new ProcessPaymentEvent(order: $order, form: $form); + $afterEvent->transaction = $transaction; + $afterEvent->response = $response; + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPayments()->trigger(self::EVENT_AFTER_PROCESS_PAYMENT, $afterEvent); + } + + // For redirects or unsuccessful transactions, save the transaction before bailing + if ($response->isRedirect()) { + $this->handleRedirect($response, $redirect, $redirectData); + return; + } + + if (!in_array($transaction->status, [TransactionRecord::STATUS_SUCCESS, TransactionRecord::STATUS_PROCESSING])) { + throw new PaymentException($transaction->message); + } + + // Success! + $order->updateOrderPaidInformation(); + } catch (Exception $e) { + $transaction->status = TransactionRecord::STATUS_FAILED; + $transaction->message = $e->getMessage(); + + // If this transactions is already saved, don't even try. + if (!$transaction->id) { + $this->saveTransaction($transaction); + } + + Log::error($e->getMessage(), ['exception' => $e]); + throw new PaymentException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Capture a transaction. + * + * @throws TransactionException if something went wrong when saving the transaction + */ + public function captureTransaction(Transaction $transaction): Transaction + { + // Raise 'beforeCaptureTransaction' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPayments()->hasEventHandlers(self::EVENT_BEFORE_CAPTURE_TRANSACTION)) { + $beforeEvent = new TransactionEvent(transaction: $transaction); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPayments()->trigger(self::EVENT_BEFORE_CAPTURE_TRANSACTION, $beforeEvent); + } + + $transaction = $this->capture($transaction); + + // Raise 'afterCaptureTransaction' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPayments()->hasEventHandlers(self::EVENT_AFTER_CAPTURE_TRANSACTION)) { + $afterEvent = new TransactionEvent(transaction: $transaction); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPayments()->trigger(self::EVENT_AFTER_CAPTURE_TRANSACTION, $afterEvent); + } + + return $transaction; + } + + /** + * Refund a transaction. + * + * @param float|null $amount the amount to refund or null for full amount. + * @param string $note the administrators note on the refund + * @throws RefundException if something went wrong during the refund. + */ + public function refundTransaction(Transaction $transaction, ?float $amount = null, string $note = ''): Transaction + { + // Raise 'beforeRefundTransaction' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPayments()->hasEventHandlers(self::EVENT_BEFORE_REFUND_TRANSACTION)) { + $beforeEvent = new RefundTransactionEvent(transaction: $transaction, amount: $amount); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPayments()->trigger(self::EVENT_BEFORE_REFUND_TRANSACTION, $beforeEvent); + } + + $refundTransaction = $this->refund($transaction, $amount, $note); + + // Raise 'afterRefundTransaction' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPayments()->hasEventHandlers(self::EVENT_AFTER_REFUND_TRANSACTION)) { + $afterEvent = new RefundTransactionEvent(transaction: $transaction, amount: $amount); + $afterEvent->refundTransaction = $refundTransaction; + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPayments()->trigger(self::EVENT_AFTER_REFUND_TRANSACTION, $afterEvent); + } + + return $refundTransaction; + } + + /** + * Process return from off-site payment. + * + * @throws Throwable + * @throws \craft\commerce\errors\OrderStatusException + * @throws \CraftCms\Cms\Element\Queries\Exceptions\ElementNotFoundException + */ + public function completePayment(Transaction $transaction, ?string &$customError): bool + { + // Only transactions with the status of "redirect" can be completed + if (!in_array($transaction->status, [TransactionRecord::STATUS_REDIRECT, TransactionRecord::STATUS_SUCCESS], true)) { + $customError = $transaction->message; + + return false; + } + + $transactionLockName = 'commerceTransaction:' . $transaction->hash; + $lock = Cache::lock($transactionLockName, 60); + try { + $lock->block(15); + } catch (LockTimeoutException) { + throw new Exception('Unable to acquire a lock for transaction: ' . $transaction->hash); + } + + // Make sure we have the latest transaction data + $transaction = app(Transactions::class)->getTransactionByHash($transaction->hash); + + // If it's successful already, we're good. + if (app(Transactions::class)->isTransactionSuccessful($transaction)) { + $transaction->getOrder()->updateOrderPaidInformation(); + $lock->release(); + return true; + } + + // Load payment driver for the transaction we are trying to complete + $gateway = $transaction->getGateway(); + + switch ($transaction->type) { + case TransactionRecord::TYPE_PURCHASE: + $response = $gateway->completePurchase($transaction); + break; + case TransactionRecord::TYPE_AUTHORIZE: + $response = $gateway->completeAuthorize($transaction); + break; + default: + $lock->release(); + return false; + } + + $childTransaction = app(Transactions::class)->createTransaction(null, $transaction); + $this->updateTransaction($childTransaction, $response); + + // Success can mean 2 things in this context. + // 1) The transaction completed successfully with the gateway, and is now marked as complete. + // 2) The result of the gateway request was successful but also got a redirect response. We now need to redirect if $redirect is not null. + $success = $response->isSuccessful() || $response->isProcessing(); + $isParentTransactionRedirect = ($transaction->status === TransactionRecord::STATUS_REDIRECT); + + if ($success) { + if ($transaction->status === TransactionRecord::STATUS_SUCCESS || ($isParentTransactionRedirect && $childTransaction->status == TransactionRecord::STATUS_SUCCESS)) { + $transaction->getOrder()->updateOrderPaidInformation(); + } + + if ($isParentTransactionRedirect && $childTransaction->status == TransactionRecord::STATUS_PROCESSING) { + $transaction->getOrder()->markAsComplete(); + } + } + + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPayments()->hasEventHandlers(self::EVENT_AFTER_COMPLETE_PAYMENT)) { + $completeEvent = new TransactionEvent(transaction: $transaction); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPayments()->trigger(self::EVENT_AFTER_COMPLETE_PAYMENT, $completeEvent); + } + + $redirectData = []; + if ($response->isRedirect() && $transaction->status === TransactionRecord::STATUS_REDIRECT) { + $lock->release(); + $this->handleRedirect($response, $redirect, $redirectData); + \Craft::$app->getResponse()->redirect($redirect); + \Craft::$app->end(); + } + + if (!$success) { + $customError = $response->getMessage(); + } + + $lock->release(); + + return $success; + } + + /** + * Handles a redirect. + * + * @throws ExitException + * @throws LoaderError + * @throws RuntimeError + * @throws SyntaxError + * @throws \Exception + */ + private function handleRedirect(RequestResponseInterface $response, ?string &$redirect, ?array &$redirectData): void + { + // If the gateway tells is it is a GET redirect, let them + if ($response->getRedirectMethod() === 'GET') { + $redirect = $response->getRedirectUrl(); + $redirectData = $response->getRedirectData(); + } else { + $gatewayPostRedirectTemplate = Plugin::getInstance()->getSettings()->gatewayPostRedirectTemplate; + + if (!empty($gatewayPostRedirectTemplate)) { + $variables = []; + $hiddenFields = ''; + + // Gather all post hidden data inputs. + foreach ($response->getRedirectData() as $key => $value) { + $hiddenFields .= sprintf('', htmlentities($key, ENT_QUOTES, 'UTF-8', false), htmlentities($value, ENT_QUOTES, 'UTF-8', false)) . "\n"; + } + + $variables['inputs'] = $hiddenFields; + + // Set the action url to the responses redirect url + $variables['actionUrl'] = $response->getRedirectUrl(); + + $template = Template::renderPageTemplate($gatewayPostRedirectTemplate, $variables, TemplateMode::Site); + + // Send the template back to the user. + ob_start(); + echo $template; + \Craft::$app->end(); + } + + // Let the gateway's response redirect us + $response->redirect(); + } + } + + /** + * Process a capture or refund exception. + * + * @throws TransactionException if unable to save transaction + */ + private function capture(Transaction $parent): Transaction + { + $child = app(Transactions::class)->createTransaction(null, $parent, TransactionRecord::TYPE_CAPTURE); + + $gateway = $parent->getGateway(); + + try { + $response = $gateway->capture($child, (string)$parent->reference); + $this->updateTransaction($child, $response); + } catch (Exception $e) { + $child->status = TransactionRecord::STATUS_FAILED; + $child->message = $e->getMessage(); + $this->saveTransaction($child); + + Log::error($e->getMessage(), ['exception' => $e]); + } + + return $child; + } + + /** + * Process a capture or refund exception. + * + * @param string $note the administrators note on the refund + * @throws RefundException if anything goes wrong during a refund + */ + private function refund(Transaction $parent, ?float $amount = null, string $note = ''): Transaction + { + try { + $gateway = $parent->getGateway(); + + if (!$gateway->supportsRefund()) { + throw new RefundException(t('Gateway doesn\'t support refunds.', category: 'commerce')); + } + + if ($amount < $parent->paymentAmount && !$gateway->supportsPartialRefund()) { + throw new RefundException(t('Gateway doesn\'t support partial refunds.', category: 'commerce')); + } + + $child = app(Transactions::class)->createTransaction(null, $parent, TransactionRecord::TYPE_REFUND); + + // If amount is not supplied refund the full amount + $child->paymentAmount = Currency::round($amount, $child->currency) ?: $parent->getRefundableAmount(); + + // Calculate amount in the primary currency + $child->amount = Currency::round($child->paymentAmount / $parent->paymentRate, $child->currency); + $child->note = $note; + + $gateway = $parent->getGateway(); + + try { + $response = $gateway->refund($child); + $this->updateTransaction($child, $response); + } catch (Throwable $exception) { + Log::error(t('Error refunding transaction: {transactionHash}', ['transactionHash' => $parent->hash], category: 'commerce')); + $child->status = TransactionRecord::STATUS_FAILED; + $child->message = $exception->getMessage(); + $this->saveTransaction($child); + } + + return $child; + } catch (Throwable $exception) { + throw new RefundException($exception->getMessage()); + } + } + + /** + * Save a transaction. + * + * @throws TransactionException + */ + private function saveTransaction(Transaction $child): void + { + if (!app(Transactions::class)->saveTransaction($child)) { + throw new TransactionException('Error saving transaction: ' . implode(', ', $child->getFirstErrors())); + } + } + + /** + * Updates a transaction. + */ + private function updateTransaction(Transaction $transaction, RequestResponseInterface $response): void + { + if ($response->isSuccessful()) { + $transaction->status = TransactionRecord::STATUS_SUCCESS; + } elseif ($response->isProcessing()) { + $transaction->status = TransactionRecord::STATUS_PROCESSING; + } elseif ($response->isRedirect()) { + $transaction->status = TransactionRecord::STATUS_REDIRECT; + } else { + $transaction->status = TransactionRecord::STATUS_FAILED; + } + + $transaction->response = $response->getData(); + $transaction->code = $response->getCode(); + $transaction->reference = $response->getTransactionReference(); + $transaction->message = $response->getMessage(); + + $this->saveTransaction($transaction); + } +} diff --git a/src/Payment/Records/PaymentCurrency.php b/src/Payment/Records/PaymentCurrency.php new file mode 100644 index 0000000000..3e2467bf3a --- /dev/null +++ b/src/Payment/Records/PaymentCurrency.php @@ -0,0 +1,28 @@ + 'integer', + 'rate' => 'float', + ]; +} diff --git a/src/Payment/Records/PaymentSource.php b/src/Payment/Records/PaymentSource.php new file mode 100644 index 0000000000..460099a774 --- /dev/null +++ b/src/Payment/Records/PaymentSource.php @@ -0,0 +1,28 @@ + 'integer', + 'customerId' => 'integer', + ]; +} diff --git a/src/Payment/Records/Transaction.php b/src/Payment/Records/Transaction.php new file mode 100644 index 0000000000..2634021235 --- /dev/null +++ b/src/Payment/Records/Transaction.php @@ -0,0 +1,51 @@ + 'integer', + 'gatewayId' => 'integer', + 'userId' => 'integer', + 'parentId' => 'integer', + 'amount' => 'float', + 'paymentAmount' => 'float', + 'paymentRate' => 'float', + ]; +} diff --git a/src/Payment/Transactions.php b/src/Payment/Transactions.php new file mode 100644 index 0000000000..3c920fa106 --- /dev/null +++ b/src/Payment/Transactions.php @@ -0,0 +1,433 @@ +type !== TransactionRecord::TYPE_AUTHORIZE || $transaction->status !== TransactionRecord::STATUS_SUCCESS) { + return false; + } + + $gateway = $transaction->getGateway(); + + if (!$gateway) { + return false; + } + + if (!$gateway->supportsCapture()) { + return false; + } + + // And only if we don't have a successful refund transaction for this order already + return !$this->query() + ->where([ + 'type' => TransactionRecord::TYPE_CAPTURE, + 'status' => TransactionRecord::STATUS_SUCCESS, + 'orderId' => $transaction->orderId, + 'parentId' => $transaction->id, + ]) + ->exists(); + } + + /** + * Returns true if a specific transaction can be refunded. + */ + public function canRefundTransaction(Transaction $transaction): bool + { + // Can refund only successful purchase or capture transactions + if (!in_array($transaction->type, [TransactionRecord::TYPE_PURCHASE, TransactionRecord::TYPE_CAPTURE], true)) { + return false; + } + + if ($transaction->status !== TransactionRecord::STATUS_SUCCESS) { + return false; + } + + $gateway = $transaction->getGateway(); + + if (!$gateway) { + return false; + } + + if (!$gateway->supportsRefund()) { + return false; + } + + // Allow gateways to help determine if a transaction can be refunded + if (!$gateway->transactionSupportsRefund($transaction)) { + return false; + } + + return $this->refundableAmountForTransaction($transaction) > 0; + } + + /** + * Return the refundable amount for a transaction. + */ + public function refundableAmountForTransaction(Transaction $transaction): float + { + // We need to use the payment currency to calculate the refundable amount + $teller = app(Currencies::class)->getTeller($transaction->paymentCurrency); + + $amount = DB::table(Table::TRANSACTIONS) + ->where([ + 'type' => TransactionRecord::TYPE_REFUND, + 'status' => TransactionRecord::STATUS_SUCCESS, + 'orderId' => $transaction->orderId, + 'parentId' => $transaction->id, + ]) + ->sum('paymentAmount'); + + return (float)$teller->subtract($transaction->paymentAmount, $amount); + } + + /** + * Create a transaction either from an order or a parent transaction. At least one must be present. + * + * @param Order|null $order Order that the transaction is a part of. Ignored, if `$parentTransaction` is specified. + * @param Transaction|null $parentTransaction Parent transaction, if this transaction is a child. Required, if `$order` is not specified. + * @param string|null $typeOverride The type of transaction. If set, this overrides the type of the parent transaction, or sets the type when no parentTransaction is passed. + * @throws TransactionException if neither `$order` or `$parentTransaction` is specified. + */ + public function createTransaction(?Order $order = null, ?Transaction $parentTransaction = null, ?string $typeOverride = null): Transaction + { + if (!$order && !$parentTransaction) { + throw new TransactionException('Tried to create a transaction without order or parent transaction'); + } + + $transaction = new Transaction(); + $transaction->status = TransactionRecord::STATUS_PENDING; + + if ($parentTransaction) { + // Assume parent values instead of Order values. + $transaction->parentId = $parentTransaction->id; + $transaction->gatewayId = $parentTransaction->gatewayId; + $transaction->amount = $parentTransaction->amount; + $transaction->currency = $parentTransaction->currency; + $transaction->paymentAmount = $parentTransaction->paymentAmount; + $transaction->paymentCurrency = $parentTransaction->paymentCurrency; + $transaction->paymentRate = $parentTransaction->paymentRate; + $transaction->setOrder($parentTransaction->getOrder()); + $transaction->reference = $parentTransaction->reference; + $transaction->type = $parentTransaction->type; + } else { + $paymentCurrency = app(PaymentCurrencies::class)->getPaymentCurrencyByIso($order->paymentCurrency, $order->getStore()->id); + $currency = app(PaymentCurrencies::class)->getPaymentCurrencyByIso($order->currency, $order->getStore()->id); + + /** @var Gateway $gateway */ + $gateway = $order->getGateway(); + $transaction->gatewayId = $gateway->id; + + // Gets the outstanding balance, unless the order had a paymentAmount set in this request + $transaction->currency = $currency->iso; + $transaction->paymentCurrency = $paymentCurrency->iso; + + // Payment amount is the amount in the paymentCurrency + $transaction->paymentAmount = Currency::round($order->getPaymentAmount(), $paymentCurrency); + $amount = $transaction->paymentAmount; + + if ($currency->iso !== $paymentCurrency->iso) { + $tellerTo = app(Currencies::class)->getTeller($paymentCurrency->iso); + $paymentAmount = $tellerTo->convertToMoney($transaction->paymentAmount); + $amount = app(PaymentCurrencies::class)->convertAmount($paymentAmount, $currency->iso, $order->getStore()->id); + $amount = (float)$tellerTo->convertToString($amount); + } + + // Amount is always in the base currency + $transaction->amount = $amount; + + $transaction->setOrder($order); + + // Capture historical rate + $transaction->paymentRate = app(PaymentCurrencies::class)->getRateFor($paymentCurrency, $transaction); + } + + $user = currentUserElement(); + + if ($user) { + $transaction->userId = $user->id; + } + + if ($typeOverride) { + $transaction->type = $typeOverride; + } + + // Raise 'afterCreateTransaction' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getTransactions()->hasEventHandlers(self::EVENT_AFTER_CREATE_TRANSACTION)) { + $event = new TransactionEvent(transaction: $transaction); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getTransactions()->trigger(self::EVENT_AFTER_CREATE_TRANSACTION, $event); + } + + return $transaction; + } + + /** + * Delete a transaction by id. + */ + public function deleteTransactionById(int $id): bool + { + $record = TransactionRecord::find($id); + + if ($record) { + return (bool)$record->delete(); + } + + return false; + } + + /** + * @return Transaction[] + */ + public function getAllTopLevelTransactionsByOrderId(int $orderId): array + { + $transactions = $this->getAllTransactionsByOrderId($orderId); + + foreach ($transactions as $key => $transaction) { + // Remove transactions that have a parentId + if ($transaction->parentId) { + unset($transactions[$key]); + } + } + + return $transactions; + } + + /** + * Returns all transactions for an order, per the order's ID. + * + * @return Transaction[] + */ + public function getAllTransactionsByOrderId(int $orderId): array + { + return $this->query() + ->where('orderId', $orderId) + ->get() + ->map(fn($row) => new Transaction((array)$row)) + ->all(); + } + + /** + * Get all children transactions, per a parent transaction's ID. + * + * @return Transaction[] + */ + public function getChildrenByTransactionId(int $transactionId): array + { + return $this->query() + ->where('parentId', $transactionId) + ->get() + ->map(fn($row) => new Transaction((array)$row)) + ->all(); + } + + /** + * Get a transaction by its hash. + */ + public function getTransactionByHash(string $hash): ?Transaction + { + $result = $this->query()->where('hash', $hash)->first(); + + return $result ? new Transaction((array)$result) : null; + } + + /** + * Get a transaction by its reference and status. + */ + public function getTransactionByReferenceAndStatus(string $reference, string $status): ?Transaction + { + $result = $this->query()->where(compact('reference', 'status'))->first(); + + return $result ? new Transaction((array)$result) : null; + } + + /** + * Get a transaction by its reference. + */ + public function getTransactionByReference(string $reference): ?Transaction + { + $result = $this->query()->where(compact('reference'))->first(); + + return $result ? new Transaction((array)$result) : null; + } + + /** + * Get a transaction by its ID. + */ + public function getTransactionById(int $id): ?Transaction + { + $result = $this->query()->where('id', $id)->first(); + + return $result ? new Transaction((array)$result) : null; + } + + /** + * Returns true if a transaction or a direct child of the transaction is successful. + */ + public function isTransactionSuccessful(Transaction $transaction): bool + { + if ($transaction->status === TransactionRecord::STATUS_SUCCESS) { + return true; + } + + return $this->query() + ->where([ + 'parentId' => $transaction->id, + 'status' => TransactionRecord::STATUS_SUCCESS, + 'orderId' => $transaction->orderId, + ]) + ->exists(); + } + + /** + * Save a transaction. + * + * @throws TransactionException if an attempt is made to modify an existing transaction + */ + public function saveTransaction(Transaction $model, bool $runValidation = true): bool + { + if ($model->id) { + throw new TransactionException('Transactions cannot be modified.'); + } + + if ($runValidation && !$model->validate()) { + Log::info('Transaction not saved due to validation error.'); + + return false; + } + + $fields = [ + 'orderId', + 'hash', + 'gatewayId', + 'type', + 'status', + 'amount', + 'currency', + 'paymentAmount', + 'paymentCurrency', + 'paymentRate', + 'reference', + 'message', + 'note', + 'code', + 'response', + 'userId', + 'parentId', + ]; + + $record = new TransactionRecord(); + + foreach ($fields as $field) { + $record->$field = $model->$field; + } + + $record->save(); + $model->id = $record->id; + + if ($model->status === TransactionRecord::STATUS_SUCCESS) { + $model->getOrder()->updateOrderPaidInformation(); + } + + if ($model->status === TransactionRecord::STATUS_PROCESSING) { + $model->getOrder()->markAsComplete(); + } + + $model->getOrder()->setTransactions(null); // clear the local cache of transactions from the order. + + // Raise 'afterSaveTransaction' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getTransactions()->hasEventHandlers(self::EVENT_AFTER_SAVE_TRANSACTION)) { + $event = new TransactionEvent(transaction: $model); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getTransactions()->trigger(self::EVENT_AFTER_SAVE_TRANSACTION, $event); + } + + return true; + } + + /** + * @param Order[] $orders + * @return Order[] + */ + public function eagerLoadTransactionsForOrders(array $orders): array + { + $orderIds = collect($orders)->pluck('id')->filter()->all(); + $transactionResults = $this->query()->whereIn('orderId', $orderIds)->get(); + + $transactions = []; + + foreach ($transactionResults as $result) { + $transaction = new Transaction((array)$result); + $transactions[$transaction->orderId] ??= []; + $transactions[$transaction->orderId][] = $transaction; + } + + foreach ($orders as $key => $order) { + if (isset($transactions[$order->id])) { + $order->setTransactions($transactions[$order->id]); + $orders[$key] = $order; + } + } + + return $orders; + } + + private function query(): Builder + { + return DB::table(Table::TRANSACTIONS) + ->select([ + 'amount', + 'code', + 'currency', + 'dateCreated', + 'dateUpdated', + 'gatewayId', + 'hash', + 'id', + 'message', + 'note', + 'orderId', + 'parentId', + 'paymentAmount', + 'paymentCurrency', + 'paymentRate', + 'reference', + 'response', + 'status', + 'type', + 'userId', + ]) + ->orderBy('id'); + } +} diff --git a/src/Payment/Webhooks.php b/src/Payment/Webhooks.php new file mode 100644 index 0000000000..cbeecf0600 --- /dev/null +++ b/src/Payment/Webhooks.php @@ -0,0 +1,85 @@ +getWebhooks()->hasEventHandlers(self::EVENT_BEFORE_PROCESS_WEBHOOK)) { + $beforeEvent = new WebhookEvent(gateway: $gateway); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getWebhooks()->trigger(self::EVENT_BEFORE_PROCESS_WEBHOOK, $beforeEvent); + } + + $transactionHash = $gateway->getTransactionHashFromWebhook(); + $useMutex = (bool)$transactionHash; + $transactionLockName = 'commerceTransaction:' . $transactionHash; + $lock = null; + + if ($useMutex) { + $lock = Cache::lock($transactionLockName, 60); + try { + $lock->block(15); + } catch (LockTimeoutException) { + throw new \Exception('Unable to acquire a lock for transaction: ' . $transactionHash); + } + } + + try { + if ($gateway->supportsWebhooks()) { + $response = $gateway->processWebHook(); + } else { + throw new BadRequestHttpException('Gateway not found or does not support webhooks.'); + } + } catch (Throwable $exception) { + $message = 'Exception while processing webhook: ' . $exception->getMessage() . "\n"; + $message .= 'Exception thrown in ' . $exception->getFile() . ':' . $exception->getLine() . "\n"; + $message .= 'Stack trace:' . "\n" . $exception->getTraceAsString(); + + Log::error($message); + + $statusCode = $exception instanceof HttpException ? $exception->getStatusCode() : 500; + $response = new Response('', $statusCode); + } + + if ($useMutex) { + $lock->release(); + } + + // Fire a 'afterProcessWebhook' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getWebhooks()->hasEventHandlers(self::EVENT_AFTER_PROCESS_WEBHOOK)) { + $afterEvent = new WebhookEvent(gateway: $gateway); + $afterEvent->response = $response; + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getWebhooks()->trigger(self::EVENT_AFTER_PROCESS_WEBHOOK, $afterEvent); + } + + return $response; + } +} diff --git a/src/Pdf/Events/PdfEvent.php b/src/Pdf/Events/PdfEvent.php new file mode 100644 index 0000000000..54200d82c3 --- /dev/null +++ b/src/Pdf/Events/PdfEvent.php @@ -0,0 +1,16 @@ +getStore()->handle . '/' . $this->id); + } + + #[\Override] + public function getRules(): array + { + return [ + 'name' => ['required', 'string'], + 'handle' => ['required', 'string', Rule::unique(Table::PDFS, 'handle')->where('storeId', $this->storeId)], + 'templatePath' => ['required', 'string'], + 'language' => ['required', 'string'], + 'paperOrientation' => ['required', Rule::in([PdfRecord::PAPER_ORIENTATION_PORTRAIT, PdfRecord::PAPER_ORIENTATION_LANDSCAPE])], + 'paperSize' => ['required', Rule::in(array_keys(CPDF::$PAPER_SIZES))], + ]; + } + + #[\Override] + public function extraFields(): array + { + return array_merge(parent::extraFields(), ['config']); + } + + public function getRenderLanguage(?Order $order = null): string + { + $language = $this->language; + + if ($order === null && $language === PdfRecord::LOCALE_ORDER_LANGUAGE) { + throw new \InvalidArgumentException('Can not get language for this PDF without providing an order'); + } + + if ($order && $language === PdfRecord::LOCALE_ORDER_LANGUAGE) { + $language = $order->orderLanguage; + } + + return $language; + } + + public function getConfig(): array + { + return [ + 'description' => $this->description, + 'enabled' => $this->enabled, + 'fileNameFormat' => $this->fileNameFormat ?? '', + 'handle' => $this->handle, + 'isDefault' => $this->isDefault, + 'language' => $this->language, + 'name' => $this->name, + 'paperOrientation' => $this->paperOrientation, + 'paperSize' => $this->paperSize, + 'sortOrder' => $this->sortOrder ?: 9999, + 'store' => $this->getStore()->uid, + 'templatePath' => $this->templatePath, + 'linkExpiry' => $this->linkExpiry, + ]; + } + + public static function getPaperOrientationOptions(): array + { + return [ + PdfRecord::PAPER_ORIENTATION_PORTRAIT => t('Portrait', category: 'commerce'), + PdfRecord::PAPER_ORIENTATION_LANDSCAPE => t('Landscape', category: 'commerce'), + ]; + } + + public static function getPaperSizeOptions(): array + { + return collect(CPDF::$PAPER_SIZES)->mapWithKeys(fn($value, $key) => [$key => $key])->all(); + } +} diff --git a/src/Pdf/Pdfs.php b/src/Pdf/Pdfs.php new file mode 100644 index 0000000000..334abea9be --- /dev/null +++ b/src/Pdf/Pdfs.php @@ -0,0 +1,504 @@ +>|null + */ + private ?array $allPdfs = null; + + /** + * @return Collection + */ + public function getAllPdfs(?int $storeId = null): Collection + { + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + if ($this->allPdfs === null || !isset($this->allPdfs[$storeId])) { + $results = $this->query()->where('storeId', $storeId)->get(); + + $this->allPdfs ??= []; + + foreach ($results as $result) { + $pdf = new Pdf((array)$result); + + $this->allPdfs[$pdf->storeId] ??= collect(); + $this->allPdfs[$pdf->storeId]->push($pdf); + } + } + + return $this->allPdfs[$storeId] ?? collect(); + } + + public function getHasEnabledPdf(?int $storeId = null): bool + { + return $this->getAllPdfs($storeId)->contains('enabled', true); + } + + /** + * @return Collection + */ + public function getAllEnabledPdfs(?int $storeId = null): Collection + { + return $this->getAllPdfs($storeId)->where('enabled', true); + } + + public function getDefaultPdf(?int $storeId = null): ?Pdf + { + return $this->getAllPdfs($storeId)->firstWhere('isDefault', true); + } + + public function getPdfByHandle(string $handle, ?int $storeId = null): ?Pdf + { + return $this->getAllPdfs($storeId)->firstWhere('handle', $handle); + } + + /** + * Get an PDF by its ID. + */ + public function getPdfById(int $id, ?int $storeId = null): ?Pdf + { + return $this->getAllPdfs($storeId)->firstWhere('id', $id); + } + + /** + * Save an PDF. + */ + public function savePdf(Pdf $pdf, bool $runValidation = true): bool + { + $isNewPdf = !(bool)$pdf->id; + + // Raise 'beforeSavePdf' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPdfs()->hasEventHandlers(self::EVENT_BEFORE_SAVE_PDF)) { + $beforeEvent = new PdfEvent( + pdf: $pdf, + isNew: $isNewPdf, + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPdfs()->trigger(self::EVENT_BEFORE_SAVE_PDF, $beforeEvent); + } + + if ($runValidation && !$pdf->validate()) { + Log::info('Pdf not saved due to validation error(s).'); + return false; + } + + if ($isNewPdf) { + $pdf->uid = Str::uuid()->toString(); + } + + $configPath = self::CONFIG_PDFS_KEY . '.' . $pdf->uid; + $configData = $pdf->getConfig(); + ProjectConfig::set($configPath, $configData); + + if ($isNewPdf) { + $pdf->id = CraftDb::idByUid(Table::PDFS, $pdf->uid); + } + + return true; + } + + /** + * Handle PDF status change. + */ + public function handleChangedPdf(ConfigEvent $event): void + { + ProjectConfigData::ensureAllStoresProcessed(); + + $pdfUid = $event->tokenMatches[0]; + $data = $event->newValue; + + DB::beginTransaction(); + try { + $pdfRecord = $this->getPdfRecord($pdfUid); + $isNewPdf = !$pdfRecord->exists; + $store = app(Stores::class)->getStoreByUid($data['store']); + + $pdfRecord->storeId = $store->id; + $pdfRecord->name = $data['name']; + $pdfRecord->handle = $data['handle']; + $pdfRecord->description = $data['description']; + $pdfRecord->templatePath = $data['templatePath'] ?? ''; + $pdfRecord->fileNameFormat = $data['fileNameFormat'] ?? ''; + $pdfRecord->enabled = $data['enabled']; + $pdfRecord->sortOrder = $data['sortOrder']; + $pdfRecord->isDefault = $data['isDefault']; + $pdfRecord->language = $data['language'] ?? PdfRecord::LOCALE_ORDER_LANGUAGE; + $pdfRecord->paperOrientation = $data['paperOrientation'] ?? PdfRecord::PAPER_ORIENTATION_PORTRAIT; + $pdfRecord->paperSize = $data['paperSize'] ?? 'letter'; + $pdfRecord->linkExpiry = $data['linkExpiry'] ?? 86400; + + $pdfRecord->uid = $pdfUid; + + $pdfRecord->save(); + + if ($pdfRecord->isDefault) { + PdfRecord::where('id', '!=', $pdfRecord->id) + ->where('storeId', $pdfRecord->storeId) + ->update(['isDefault' => false]); + } + + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + + // Raise 'afterSavePdf' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPdfs()->hasEventHandlers(self::EVENT_AFTER_SAVE_PDF)) { + $afterEvent = new PdfEvent( + pdf: $this->getPdfById($pdfRecord->id, $pdfRecord->storeId), + isNew: $isNewPdf, + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPdfs()->trigger(self::EVENT_AFTER_SAVE_PDF, $afterEvent); + } + + $this->allPdfs = null; // clear cache + } + + /** + * Delete an PDF by its ID. + */ + public function deletePdfById(int $id): bool + { + $pdf = PdfRecord::find($id); + + if ($pdf) { + // Raise 'beforeDeletePdf' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPdfs()->hasEventHandlers(self::EVENT_BEFORE_DELETE_PDF)) { + $event = new PdfEvent( + pdf: $this->getPdfById($pdf->id, $pdf->storeId), + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPdfs()->trigger(self::EVENT_BEFORE_DELETE_PDF, $event); + } + ProjectConfig::remove(self::CONFIG_PDFS_KEY . '.' . $pdf->uid); + } + + return true; + } + + /** + * Handle email getting deleted. + * + * @throws Throwable + */ + public function handleDeletedPdf(ConfigEvent $event): void + { + $uid = $event->tokenMatches[0]; + $pdfRecord = $this->getPdfRecord($uid); + + if (!$pdfRecord->id) { + return; + } + + $pdfRecord->delete(); + } + + /** + * @param int[] $ids + */ + public function reorderPdfs(array $ids): bool + { + foreach ($ids as $index => $id) { + if ($pdf = $this->getPdfById($id)) { + $pdf->sortOrder = $index + 1; + $this->savePdf($pdf, false); + } + } + + $this->allPdfs = null; // clear cache + + return true; + } + + /** + * Returns a token-based URL for downloading an order's PDF. + * + * This URL is compatible with the DownloadsController::actionPdf() method + * and includes a secure token for anonymous access. + */ + public function getPdfUrl(Order $order, ?string $option = null, ?string $pdfHandle = null, bool $inline = false): string + { + // Load the PDF to get its link expiry setting + if ($pdfHandle) { + $pdf = $this->getPdfByHandle($pdfHandle); + } else { + $pdf = $this->getDefaultPdf(); + } + + if (!$pdf) { + throw new \InvalidArgumentException('Can not find a PDF to generate URL.'); + } + + $expiryDate = new \DateTime()->add(new \DateInterval('PT' . $pdf->linkExpiry . 'S')); + + $token = app(RouteTokens::class)->createToken( + ['commerce/downloads/pdf', ['orderNumber' => $order->number]], + null, + $expiryDate + ); + + // Build the URL parameters + $params = [ + 'number' => $order->number, + 'code' => $token, + ]; + + if ($pdfHandle !== null) { + $params['pdfHandle'] = $pdfHandle; + } + + if ($option) { + $params['option'] = $option; + } + + if ($inline) { + $params['inline'] = true; + } + + $request = request(); + $isCpRequest = $request->isCpRequest(); + + if ($isCpRequest) { + $request->attributes->set('isCpRequest', false); + } + + try { + return Url::actionUrl('commerce/downloads/pdf', $params); + } finally { + if ($isCpRequest) { + $request->attributes->set('isCpRequest', $isCpRequest); + } + } + } + + /** + * Returns a rendered PDF object for the order. + */ + public function renderPdfForOrder(Order $order, string $option = '', ?string $templatePath = null, array $variables = [], ?Pdf $pdf = null): string + { + if ($pdf instanceof Pdf) { + $templatePath = $pdf->templatePath; + } + + if (!$templatePath) { + $templatePath = app(Pdfs::class)->getDefaultPdf()->templatePath; + } + + // Raise 'beforeRenderPdf' event + $event = new PdfRenderEvent( + order: $order, + option: $option, + template: $templatePath, + variables: $variables, + sourcePdf: $pdf, + ); + + // TODO: migrate event firing to Laravel once event system is bridged + $legacyService = Plugin::getInstance()->getPdfs(); + if ($legacyService->hasEventHandlers(self::EVENT_BEFORE_RENDER_PDF)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $legacyService->trigger(self::EVENT_BEFORE_RENDER_PDF, $event); + } + + if ($event->pdf !== null) { + return $event->pdf; + } + + $variables = $event->variables; + $variables['order'] = $event->order; + $variables['option'] = $event->option; + + $originalLanguage = \Craft::$app->language; + $originalFormattingLanguage = \Craft::$app->formattingLocale; + $pdfLanguage = $pdf?->getRenderLanguage($order) ?? $originalLanguage; + + Locale::switchAppLanguage($pdfLanguage); + + if (!$event->template || !app(TemplateResolver::class)->exists($event->template, TemplateMode::Site)) { + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + + throw new \Exception('PDF template file does not exist.'); + } + + try { + $html = Template::renderTemplate($event->template, $variables, TemplateMode::Site); + } catch (\Exception $e) { + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + // Set the pdf html to the render error. + Log::error('Order PDF render error. Order number: ' . $order->getShortNumber() . '. ' . $e->getMessage(), ['exception' => $e]); + $html = t('An error occurred while generating this PDF.', category: 'commerce'); + } + + Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); + + // Set the config options + $dompdfTempDir = Path::temp() . DIRECTORY_SEPARATOR . 'commerce_dompdf'; + $dompdfFontCache = Path::cache() . DIRECTORY_SEPARATOR . 'commerce_dompdf'; + $dompdfLogFile = Path::logs() . DIRECTORY_SEPARATOR . 'commerce_dompdf.htm'; + + // Ensure directories are created + File::makeDirectory($dompdfTempDir); + File::makeDirectory($dompdfFontCache); + + if (!FileHelper::isWritable($dompdfLogFile)) { + throw new \ErrorException("Unable to write to file: $dompdfLogFile"); + } + + if (!FileHelper::isWritable($dompdfFontCache)) { + throw new \ErrorException("Unable to write to folder: $dompdfFontCache"); + } + + if (!FileHelper::isWritable($dompdfTempDir)) { + throw new \ErrorException("Unable to write to folder: $dompdfTempDir"); + } + + $isRemoteEnabled = Plugin::getInstance()->getSettings()->pdfAllowRemoteImages; + + $options = new Options(); + $options->setTempDir($dompdfTempDir); + $options->setFontCache($dompdfFontCache); + $options->setLogOutputFile($dompdfLogFile); + $options->setIsRemoteEnabled($isRemoteEnabled); + + if ($pdf instanceof Pdf) { + $options->setDefaultPaperOrientation($pdf->paperOrientation); + $options->setDefaultPaperSize($pdf->paperSize); + } + + $renderOptionsEvent = new PdfRenderOptionsEvent( + options: $options, + ); + + // Set additional render options + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPdfs()->hasEventHandlers(self::EVENT_MODIFY_RENDER_OPTIONS)) { + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPdfs()->trigger(self::EVENT_MODIFY_RENDER_OPTIONS, $renderOptionsEvent); + } + + // Create and render the PDF + $dompdf = new Dompdf($renderOptionsEvent->options); + $dompdf->loadHtml($html); + $dompdf->render(); + + // Raise 'afterRenderPdf' event + $afterEvent = new PdfRenderEvent( + order: $event->order, + option: $event->option, + template: $event->template, + variables: $variables, + pdf: $dompdf->output(), + sourcePdf: $pdf, + ); + + // TODO: migrate event firing to Laravel once event system is bridged + $legacyService = Plugin::getInstance()->getPdfs(); + if ($legacyService->hasEventHandlers(self::EVENT_AFTER_RENDER_PDF)) { + /** @phpstan-ignore-next-line argument.type (TODO: migrate event firing to Laravel once event system is bridged) */ + $legacyService->trigger(self::EVENT_AFTER_RENDER_PDF, $afterEvent); + } + + return $afterEvent->pdf; + } + + /** + * Gets an PDF record by uid. + */ + private function getPdfRecord(string $uid): PdfRecord + { + if ($pdf = PdfRecord::where('uid', $uid)->first()) { + return $pdf; + } + + return new PdfRecord(); + } + + private function query(): Builder + { + $query = DB::table(Table::PDFS) + ->select([ + 'description', + 'enabled', + 'fileNameFormat', + 'handle', + 'id', + 'isDefault', + 'language', + 'name', + 'paperOrientation', + 'paperSize', + 'sortOrder', + 'storeId', + 'templatePath', + 'uid', + ]) + ->orderBy('name') + ->orderBy('sortOrder'); + + // TODO: Remove this hasColumn check in Commerce 6.0 once the schema guarantees the linkExpiry column on the pdfs table + if (Schema::hasColumn(Table::PDFS, 'linkExpiry')) { + $query->addSelect('linkExpiry'); + } + + return $query; + } +} diff --git a/src/Pdf/Records/Pdf.php b/src/Pdf/Records/Pdf.php new file mode 100644 index 0000000000..d62e9d0432 --- /dev/null +++ b/src/Pdf/Records/Pdf.php @@ -0,0 +1,37 @@ + 'integer', + 'enabled' => 'boolean', + 'isDefault' => 'boolean', + 'linkExpiry' => 'integer', + 'sortOrder' => 'integer', + ]; +} diff --git a/src/Plugin.php b/src/Plugin.php old mode 100755 new mode 100644 index 9a6147bf80..a103ef3bb2 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -1,1414 +1,689 @@ - * @since 2.0 - */ -class Plugin extends BasePlugin -{ - public const EDITION_PRO = 'pro'; - public const EDITION_ENTERPRISE = 'enterprise'; - - public const EDITION_PRO_STORE_LIMIT = 5; - public static function config(): array - { - return [ - 'components' => [ - 'carts' => ['class' => Carts::class], - 'catalogPricing' => ['class' => CatalogPricing::class], - 'catalogPricingRules' => ['class' => CatalogPricingRules::class], - 'coupons' => ['class' => Coupons::class], - 'currencies' => ['class' => Currencies::class], - 'customers' => ['class' => Customers::class], - 'discounts' => ['class' => Discounts::class], - 'emails' => ['class' => Emails::class], - 'formulas' => ['class' => Formulas::class], - 'gateways' => ['class' => Gateways::class], - 'inventory' => ['class' => Inventory::class], - 'inventoryLocations' => ['class' => InventoryLocations::class], - 'lineItemStatuses' => ['class' => LineItemStatuses::class], - 'lineItems' => ['class' => LineItems::class], - 'orderAdjustments' => ['class' => OrderAdjustments::class], - 'orderHistories' => ['class' => OrderHistories::class], - 'orderNotices' => ['class' => OrderNotices::class], - 'orderStatuses' => ['class' => OrderStatuses::class], - 'orders' => ['class' => OrdersService::class], - 'paymentCurrencies' => ['class' => PaymentCurrencies::class], - 'paymentMethods' => ['class' => Gateways::class], - 'paymentSources' => ['class' => PaymentSources::class], - 'payments' => ['class' => Payments::class], - 'pdfs' => ['class' => Pdfs::class], - 'plans' => ['class' => Plans::class], - 'productTypes' => ['class' => ProductTypes::class], - 'products' => ['class' => Products::class], - 'purchasables' => ['class' => Purchasables::class], - 'sales' => ['class' => Sales::class], - 'shippingCategories' => ['class' => ShippingCategories::class], - 'shippingMethods' => ['class' => ShippingMethods::class], - 'shippingRuleCategories' => ['class' => ShippingRuleCategories::class], - 'shippingRules' => ['class' => ShippingRules::class], - 'shippingZones' => ['class' => ShippingZones::class], - 'store' => ['class' => Store::class], - 'storeSettings' => ['class' => StoreSettings::class], - 'stores' => ['class' => Stores::class], - 'subscriptions' => ['class' => Subscriptions::class], - 'taxCategories' => ['class' => TaxCategories::class], - 'taxRates' => ['class' => TaxRates::class], - 'taxZones' => ['class' => TaxZones::class], - 'taxes' => ['class' => Taxes::class], - 'transactions' => ['class' => Transactions::class], - 'transfers' => ['class' => Transfers::class], - 'variants' => ['class' => VariantsService::class], - 'vat' => ['class' => Vat::class], - 'webhooks' => ['class' => Webhooks::class], - ], - ]; - } +declare(strict_types=1); + +namespace CraftCms\Commerce; + +use Closure; +use craft\commerce\services\Gateways as LegacyGateways; +use craft\commerce\services\OrderAdjustments as LegacyOrderAdjustments; +use craft\commerce\services\Purchasables as LegacyPurchasables; +use craft\commerce\web\twig\Extension as CommerceTwigExtension; +use CraftCms\Cms\Address\Elements\Address; +use CraftCms\Cms\Auth\Events\ElementAuthorizing; +use CraftCms\Cms\Cms; +use CraftCms\Cms\Cp\Data\NavItem; +use CraftCms\Cms\Element\Events\DefineDeletionBlockers; +use CraftCms\Cms\Element\Events\ElementSaved; +use CraftCms\Cms\FieldLayout\FieldLayout; +use CraftCms\Cms\GarbageCollection\Actions\DeletePartialElements; +use CraftCms\Cms\GarbageCollection\Events\RunningGarbageCollection; +use CraftCms\Cms\Gql\Events\GqlEagerLoadableFieldsResolving; +use CraftCms\Cms\Gql\Events\GqlSchemaComponentsResolving; +use CraftCms\Cms\Gql\GqlArguments; +use CraftCms\Cms\Plugin\Plugin as BasePlugin; +use CraftCms\Cms\Route\Routes; +use CraftCms\Cms\Site\Data\Site; +use CraftCms\Cms\Site\Events\SiteDeleted; +use CraftCms\Cms\Site\Events\SiteSaved; +use CraftCms\Cms\Support\Facades\Twig; +use CraftCms\Cms\Support\File; +use CraftCms\Cms\Support\Path; +use CraftCms\Cms\Support\Typecast; +use CraftCms\Cms\SystemMessage\Models\SystemMessage; +use CraftCms\Cms\Twig\Variables\CraftVariable as NewCraftVariable; +use CraftCms\Cms\User\Elements\User; +use CraftCms\Cms\User\Events\EditUserScreensResolving; +use CraftCms\Cms\User\Events\UserAssignedToGroups; +use CraftCms\Commerce\Catalog\Elements\Product; +use CraftCms\Commerce\Catalog\Elements\Variant; +use CraftCms\Commerce\Catalog\FieldLayoutElements\ProductTitleField; +use CraftCms\Commerce\Catalog\FieldLayoutElements\VariantsField as VariantsLayoutElement; +use CraftCms\Commerce\Catalog\FieldLayoutElements\VariantTitleField; +use CraftCms\Commerce\Catalog\Fields\Products as ProductsField; +use CraftCms\Commerce\Catalog\Fields\Variants as VariantsField; +use CraftCms\Commerce\Catalog\LinkTypes\ProductLinkType; +use CraftCms\Commerce\Catalog\Products; +use CraftCms\Commerce\Catalog\ProductType\ProductTypes; +use CraftCms\Commerce\CatalogPricing\CatalogPricingRules; +use CraftCms\Commerce\Console\Commands\ExampleTemplates\ExampleTemplatesCommand; +use CraftCms\Commerce\Console\Commands\Gateways\GatewaysListCommand; +use CraftCms\Commerce\Console\Commands\Gateways\GatewaysWebhookUrlCommand; +use CraftCms\Commerce\Console\Commands\PricingCatalog\PricingCatalogGenerateCommand; +use CraftCms\Commerce\Console\Commands\Resave\ResaveCartsCommand; +use CraftCms\Commerce\Console\Commands\Resave\ResaveOrdersCommand; +use CraftCms\Commerce\Console\Commands\Resave\ResaveProductsCommand; +use CraftCms\Commerce\Console\Commands\Resave\ResaveVariantsCommand; +use CraftCms\Commerce\Console\Commands\ResetData\ResetDataCommand; +use CraftCms\Commerce\Console\Commands\TransferCustomerData\TransferCustomerDataCommand; +use CraftCms\Commerce\Customer\Customers; +use CraftCms\Commerce\Customer\FieldLayoutElements\UserAddressSettings; +use CraftCms\Commerce\Customer\Records\Customer as CustomerRecord; +use CraftCms\Commerce\Dashboard\Widgets\AverageOrderTotal; +use CraftCms\Commerce\Dashboard\Widgets\NewCustomers; +use CraftCms\Commerce\Dashboard\Widgets\Orders as OrdersWidget; +use CraftCms\Commerce\Dashboard\Widgets\RepeatCustomers; +use CraftCms\Commerce\Dashboard\Widgets\TopCustomers; +use CraftCms\Commerce\Dashboard\Widgets\TopProducts; +use CraftCms\Commerce\Dashboard\Widgets\TopProductTypes; +use CraftCms\Commerce\Dashboard\Widgets\TopPurchasables; +use CraftCms\Commerce\Dashboard\Widgets\TotalOrders; +use CraftCms\Commerce\Dashboard\Widgets\TotalOrdersByCountry; +use CraftCms\Commerce\Dashboard\Widgets\TotalRevenue; +use CraftCms\Commerce\Database\Table; +use CraftCms\Commerce\Gql\Handlers\HasProduct; +use CraftCms\Commerce\Gql\Handlers\HasVariant; +use CraftCms\Commerce\Gql\Handlers\RelatedProducts; +use CraftCms\Commerce\Gql\Handlers\RelatedVariants; +use CraftCms\Commerce\Gql\Interfaces\Elements\Product as ProductInterface; +use CraftCms\Commerce\Gql\Interfaces\Elements\Variant as VariantInterface; +use CraftCms\Commerce\Gql\Queries\Product as ProductQuery; +use CraftCms\Commerce\Gql\Queries\Variant as VariantQuery; +use CraftCms\Commerce\Http\Controllers\Users\UsersController; +use CraftCms\Commerce\Http\RateLimiters\CartChallengeRateLimiter; +use CraftCms\Commerce\Http\RateLimiters\CartRateLimiter; +use CraftCms\Commerce\Http\RateLimiters\PdfChallengeRateLimiter; +use CraftCms\Commerce\Inventory\InventoryLocations; +use CraftCms\Commerce\Order\Carts; +use CraftCms\Commerce\Order\Elements\Order; +use CraftCms\Commerce\Order\Orders; +use CraftCms\Commerce\Payment\Models\PaymentSource; +use CraftCms\Commerce\Payment\PaymentSources; +use CraftCms\Commerce\Plugin\Concerns\HasPermissions; +use CraftCms\Commerce\Plugin\Concerns\HasServices; +use CraftCms\Commerce\Purchasable\Elements\Donation; +use CraftCms\Commerce\Purchasable\FieldLayoutElements\PurchasableAllowedQtyField; +use CraftCms\Commerce\Purchasable\FieldLayoutElements\PurchasableAvailableForPurchaseField; +use CraftCms\Commerce\Purchasable\FieldLayoutElements\PurchasableDimensionsField; +use CraftCms\Commerce\Purchasable\FieldLayoutElements\PurchasableFreeShippingField; +use CraftCms\Commerce\Purchasable\FieldLayoutElements\PurchasablePriceField; +use CraftCms\Commerce\Purchasable\FieldLayoutElements\PurchasablePromotableField; +use CraftCms\Commerce\Purchasable\FieldLayoutElements\PurchasableSkuField; +use CraftCms\Commerce\Purchasable\FieldLayoutElements\PurchasableStockField; +use CraftCms\Commerce\Purchasable\FieldLayoutElements\PurchasableWeightField; +use CraftCms\Commerce\Store\Models\Store; +use CraftCms\Commerce\Store\Stores; +use CraftCms\Commerce\Store\StoreSettings; +use CraftCms\Commerce\Support\ObjectState; +use CraftCms\Commerce\Transfer\Elements\Transfer; +use CraftCms\Commerce\Transfer\FieldLayoutElements\TransferManagementField; +use Illuminate\Auth\Events\Login; +use Illuminate\Auth\Events\Logout; +use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\RateLimiter; + +use function CraftCms\Cms\currentUser; +use function CraftCms\Cms\t; - /** - * Returns the editions for Craft Commerce - * - * @inheritDoc - */ - public static function editions(): array - { - return [ - self::EDITION_PRO, - self::EDITION_ENTERPRISE, - ]; - } - - /** - * @inheritDoc - */ - public string $schemaVersion = '5.7.0.0'; - - /** - * @inheritdoc - */ - public bool $hasCpSettings = true; +class Plugin extends BasePlugin +{ + use HasPermissions; + use HasServices; + + protected array $elementTypes = [ + Product::class, + Variant::class, + Order::class, + Donation::class, + Transfer::class, + ]; + + protected array $fieldTypes = [ + ProductsField::class, + VariantsField::class, + ]; + + protected array $widgets = [ + AverageOrderTotal::class, + NewCustomers::class, + OrdersWidget::class, + RepeatCustomers::class, + TotalOrders::class, + TotalOrdersByCountry::class, + TopCustomers::class, + TopProducts::class, + TopProductTypes::class, + TopPurchasables::class, + TotalRevenue::class, + ]; + + protected array $linkTypes = [ + ProductLinkType::class, + ]; + + protected array $gqlTypes = [ + ProductInterface::class, + VariantInterface::class, + ]; + + protected array $gqlQueries = [ + ProductQuery::class, + VariantQuery::class, + ]; + + protected array $commands = [ + ResaveProductsCommand::class, + ResaveVariantsCommand::class, + ResaveOrdersCommand::class, + ResaveCartsCommand::class, + ExampleTemplatesCommand::class, + GatewaysListCommand::class, + GatewaysWebhookUrlCommand::class, + PricingCatalogGenerateCommand::class, + ResetDataCommand::class, + TransferCustomerDataCommand::class, + ]; - /** - * @inheritdoc - */ public bool $hasCpSection = true; - /** - * @inheritdoc - */ - public string $minVersionRequired = '3.4.11'; - - /** - * @inheritdoc - */ - public CmsEdition $minCmsEdition = CmsEdition::Pro; - - /** - * @inheritdoc - */ - public bool $hasReadOnlyCpSettings = true; - - use CommerceServices; - use Variables; - use Routes; - - /** - * @inheritdoc - */ - public function init(): void + public function boot(): void { - parent::init(); - $request = Craft::$app->getRequest(); - - $this->_addTwigExtensions(); - $this->_registerFieldTypes(); - $this->_registerPermissions(); - $this->_registerCraftEventListeners(); - $this->_registerProjectConfigEventListeners(); - $this->_registerVariables(); - $this->_registerForeignKeysRestore(); - $this->_registerPoweredByHeader(); - $this->_registerElementTypes(); - $this->_registerGqlInterfaces(); - $this->_registerGqlQueries(); - $this->_registerGqlComponents(); - $this->_registerGqlEagerLoadableFields(); - $this->_registerGqlArgumentHandlers(); - $this->_registerLinkTypes(); - $this->_registerCacheTypes(); - $this->_registerGarbageCollection(); - - if ($request->getIsConsoleRequest()) { - $this->_defineResaveCommand(); - } elseif ($request->getIsCpRequest()) { - $this->_registerCpRoutes(); - $this->_registerWidgets(); - $this->_registerElementExports(); - $this->_defineFieldLayoutElements(); - $this->_registerRedactorLinkOptions(); - $this->_registerCKEditorLinkOptions(); - } else { - $this->_registerSiteRoutes(); - } + // Reconcile our type registries against any legacy `Event::on(...)` listeners for the + // deprecated EVENT_REGISTER_* constants, once every plugin has finished registering its listeners. + $this->app->booted(function() { + if (!Cms::isInstalled(strict: true)) { + return; + } - Craft::$app->onInit(function() { - $this->_registerDebugPanels(); + LegacyOrderAdjustments::finalizeRegistrationEvents(); + LegacyGateways::finalizeRegistrationEvents(); + LegacyPurchasables::finalizeRegistrationEvents(); }); - Craft::setAlias('@commerceLib', Craft::getAlias('@craft/commerce/../lib')); - } + $arguments = app(GqlArguments::class); + $arguments->register('hasProduct', HasProduct::class); + $arguments->register('hasVariant', HasVariant::class); + $arguments->register('relatedToProducts', RelatedProducts::class); + $arguments->register('relatedToVariants', RelatedVariants::class); - /** - * @inheritdoc - */ - public function beforeInstall(): void - { - // Check version before installing - if (version_compare(Craft::$app->getInfo()->version, '5.1.0', '<')) { - throw new Exception('Craft Commerce 5 requires Craft CMS 5.1+ in order to run.'); - } + $this->registerBehaviorMacros(); + $this->registerVariableMacros(); - if (!defined('PHP_VERSION_ID') || PHP_VERSION_ID < 80200) { - Craft::error('Craft Commerce requires PHP 8.2.0+ in order to run.'); - } - } + Twig::registerExtension(new CommerceTwigExtension()); - /** - * @inheritdoc - */ - public function getSettingsResponse(): mixed - { - return Craft::$app->getResponse()->redirect(UrlHelper::cpUrl('commerce/settings/general')); - } - - /** - * @inheritdoc - */ - public function getReadOnlySettingsResponse(): mixed - { - return Craft::$app->getResponse()->redirect(UrlHelper::cpUrl('commerce/settings/general')); + if ($this->isInstalled) { + $this->registerCraftEventListeners(); + } } /** - * @inheritdoc + * Registers `craft.commerce`/`craft.orders`/`craft.products`/`craft.variants` Twig variable + * macros, replacing the legacy `NewCraftVariable::macro(...)` calls in `src-yii2/Plugin.php` + * (which already used this same mechanism — this is a straight move, not a rewrite). */ - public function getCpNavItem(): ?array + private function registerVariableMacros(): void { - $ret = parent::getCpNavItem(); - $userService = Craft::$app->getUser(); + $plugin = $this; + NewCraftVariable::macro('commerce', fn() => $plugin); - if ($userService->checkPermission('accessPlugin-commerce')) { - $ret['label'] = Craft::t('commerce', 'Commerce'); - } + NewCraftVariable::macro('orders', function(array $criteria = []) { + $query = Order::find(); + Typecast::configure($query, $criteria); - if ($userService->checkPermission('commerce-manageOrders')) { - $ret['subnav']['orders'] = [ - 'label' => Craft::t('commerce', 'Orders'), - 'url' => 'commerce/orders', - ]; - } - - $hasViewableProductTypes = Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(true); - if ($hasViewableProductTypes) { - $ret['subnav']['products'] = [ - 'label' => Craft::t('commerce', 'Products'), - 'url' => 'commerce/products', - ]; - } - - if (Craft::$app->getUser()->checkPermission('commerce-manageInventoryStockLevels')) { - $ret['subnav']['inventory'] = [ - 'label' => Craft::t('commerce', 'Inventory'), - 'url' => 'commerce/inventory', - ]; - } - - if (Craft::$app->getUser()->checkPermission('commerce-manageInventoryLocations')) { - $ret['subnav']['inventory-locations'] = [ - 'label' => Craft::t('commerce', 'Inventory Locations'), - 'url' => 'commerce/inventory-locations', - ]; - } - - $multipleLocations = Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocations()->count() > 1; - if ($multipleLocations && Craft::$app->getUser()->checkPermission('commerce-manageInventoryTransfers')) { - $ret['subnav']['inventory-transfers'] = [ - 'label' => Craft::t('commerce', 'Inventory Transfers'), - 'url' => 'commerce/inventory/transfers', - ]; - } - - if (Craft::$app->getUser()->checkPermission('commerce-manageSubscriptions') && Plugin::getInstance()->getPlans()->getAllPlans()) { - $ret['subnav']['subscriptions'] = [ - 'label' => Craft::t('commerce', 'Subscriptions'), - 'url' => 'commerce/subscriptions', - ]; - } - - if (Craft::$app->getUser()->checkPermission('commerce-manageSubscriptionPlans')) { - $ret['subnav']['subscription-plans'] = [ - 'label' => Craft::t('commerce', 'Subscription Plans'), - 'url' => 'commerce/subscription-plans', - ]; - } + return $query; + }); - if (Craft::$app->getUser()->checkPermission('commerce-manageDonationSettings')) { - $ret['subnav']['donations'] = [ - 'label' => Craft::t('commerce', 'Donations'), - 'url' => 'commerce/donations', - ]; - } + NewCraftVariable::macro('products', function(array $criteria = []) { + $query = Product::find(); + Typecast::configure($query, $criteria); - if (Craft::$app->getUser()->checkPermission('commerce-manageStoreSettings')) { - $ret['subnav']['store-management'] = [ - 'label' => Craft::t('commerce', 'Store Management'), - 'url' => 'commerce/store-management', - ]; - } + return $query; + }); - if (Craft::$app->getUser()->getIsAdmin()) { - $ret['subnav']['settings'] = [ - 'ariaLabel' => Craft::t('commerce', 'Commerce Settings'), - 'label' => Craft::t('app', 'Settings'), - 'url' => 'commerce/settings/general', - ]; - } + NewCraftVariable::macro('variants', function(array $criteria = []) { + $query = Variant::find(); + Typecast::configure($query, $criteria); - return $ret; + return $query; + }); } - /** - * @inheritdoc + * Replaces the legacy Yii2 `StoreBehavior`/`CustomerBehavior`/`CustomerAddressBehavior` classes, + * which no longer attach to anything — `Site`/`User`/`Address` extend the new + * `CraftCms\Cms\Component\Component`, not `yii\base\Component`, so `attachBehavior()` doesn't exist + * on them at all. `Macroable` (already `use`d by `Component`) is the replacement mechanism; its + * `MacroableMagicMethods` concern makes registered macros transparently reachable via method-call + * syntax (`$site->getStore()`), PHP magic-property syntax (`$site->store`), and Twig dot-notation + * (`{{ site.store }}`) alike — verified empirically via `php artisan tinker` this session. */ - protected function createSettingsModel(): ?Model + private function registerBehaviorMacros(): void { - return new Settings(); - } - + Site::macro('getStore', function(): ?Store { + /** @var Site $this */ + return app(Stores::class)->getStoreBySiteId($this->id); + }); - /** - * Register Commerce’s twig extensions - */ - private function _addTwigExtensions(): void - { - Craft::$app->view->registerTwigExtension(new Extension()); + $this->registerCustomerMacros(); + $this->registerCustomerAddressMacros(); } /** - * Register Link types + * Replaces `craft\commerce\behaviors\CustomerBehavior`, attached to `User` in Commerce 5. */ - private function _registerLinkTypes(): void + private function registerCustomerMacros(): void { - if (!class_exists(Link::class)) { - return; - } + User::macro('getPrimaryBillingAddressId', function(): ?int { + /** @var User $this */ + if (!ObjectState::has($this, 'primaryBillingAddressId')) { + $customer = CustomerRecord::where('customerId', $this->id)->first(); + ObjectState::set($this, 'primaryBillingAddressId', $customer?->primaryBillingAddressId); + } - Event::on(Link::class, Link::EVENT_REGISTER_LINK_TYPES, function(RegisterComponentTypesEvent $event) { - $event->types[] = ProductLinkType::class; + return ObjectState::get($this, 'primaryBillingAddressId'); }); - } - /** - * Register links to product in the redactor rich text field - */ - private function _registerRedactorLinkOptions(): void - { - if (!class_exists(RedactorField::class)) { - return; - } - - Event::on(RedactorField::class, RedactorField::EVENT_REGISTER_LINK_OPTIONS, function(RegisterLinkOptionsEvent $event) { - // Include a Product link option if there are any product types that have URLs - $productSources = []; + User::macro('setPrimaryBillingAddressId', function(?int $primaryBillingAddressId): void { + ObjectState::set($this, 'primaryBillingAddressId', $primaryBillingAddressId); + }); - $sites = Craft::$app->getSites()->getAllSites(); + User::macro('getPrimaryBillingAddress', function(): ?Address { + /** @var User $this */ + /** @phpstan-ignore-next-line method.notFound (getPrimaryBillingAddressId() is another macro registered above, not visible to static analysis) */ + return $this->getAddresses()->firstWhere('id', $this->getPrimaryBillingAddressId()); + }); - foreach ($this->getProductTypes()->getAllProductTypes() as $productType) { - foreach ($sites as $site) { - $productTypeSettings = $productType->getSiteSettings(); - if (isset($productTypeSettings[$site->id]) && $productTypeSettings[$site->id]->hasUrls) { - $productSources[] = 'productType:' . $productType->uid; - } - } + User::macro('getPrimaryShippingAddressId', function(): ?int { + /** @var User $this */ + if (!ObjectState::has($this, 'primaryShippingAddressId')) { + $customer = CustomerRecord::where('customerId', $this->id)->first(); + ObjectState::set($this, 'primaryShippingAddressId', $customer?->primaryShippingAddressId); } - $productSources = array_unique($productSources); + return ObjectState::get($this, 'primaryShippingAddressId'); + }); - if ($productSources) { - $event->linkOptions[] = [ - 'optionTitle' => Craft::t('commerce', 'Link to a product'), - 'elementType' => Product::class, - 'refHandle' => Product::refHandle(), - 'sources' => $productSources, - ]; + User::macro('setPrimaryShippingAddressId', function(?int $primaryShippingAddressId): void { + ObjectState::set($this, 'primaryShippingAddressId', $primaryShippingAddressId); + }); - $event->linkOptions[] = [ - 'optionTitle' => Craft::t('commerce', 'Link to a variant'), - 'elementType' => Variant::class, - 'refHandle' => Variant::refHandle(), - 'sources' => $productSources, - ]; - } + User::macro('getPrimaryShippingAddress', function(): ?Address { + /** @var User $this */ + /** @phpstan-ignore-next-line method.notFound (getPrimaryShippingAddressId() is another macro registered above, not visible to static analysis) */ + return $this->getAddresses()->firstWhere('id', $this->getPrimaryShippingAddressId()); }); - } - /** - * Register links to product in the ckeditor rich text field - */ - private function _registerCKEditorLinkOptions(): void - { - $ckEditorPlugin = Craft::$app->getPlugins()->getPlugin('ckeditor'); - if (!class_exists(CKEditorField::class) || !$ckEditorPlugin || version_compare($ckEditorPlugin->getVersion(), '3.0', '<')) { - return; - } + User::macro('setPrimaryPaymentSourceId', function(?int $paymentSourceId): void { + ObjectState::set($this, 'primaryPaymentSourceId', $paymentSourceId); + }); - Event::on(CKEditorField::class, CKEditorField::EVENT_DEFINE_LINK_OPTIONS, function(DefineLinkOptionsEvent $event) { - // Include a Product link option if there are any product types that have URLs - $productSources = []; + User::macro('getPrimaryPaymentSourceId', function(): ?int { + /** @var User $this */ + if (!ObjectState::has($this, 'primaryPaymentSourceId')) { + $customer = CustomerRecord::where('customerId', $this->id)->first(); - $sites = Craft::$app->getSites()->getAllSites(); + if (!$customer) { + return null; + } - foreach ($this->getProductTypes()->getAllProductTypes() as $productType) { - foreach ($sites as $site) { - $productTypeSettings = $productType->getSiteSettings(); - if (isset($productTypeSettings[$site->id]) && $productTypeSettings[$site->id]->hasUrls) { - $productSources[] = 'productType:' . $productType->uid; - } + if ($customer->primaryPaymentSourceId) { + ObjectState::set($this, 'primaryPaymentSourceId', $customer->primaryPaymentSourceId); + } else { + /** @phpstan-ignore-next-line method.notFound (getPrimaryPaymentSource() is another macro registered below, not visible to static analysis) */ + $paymentSource = $this->getPrimaryPaymentSource(); + ObjectState::set($this, 'primaryPaymentSourceId', $paymentSource?->id); } } - $productSources = array_unique($productSources); + return ObjectState::get($this, 'primaryPaymentSourceId'); + }); - if ($productSources) { - $event->linkOptions[] = [ - 'label' => Craft::t('commerce', 'Link to a product'), - 'elementType' => Product::class, - 'refHandle' => Product::refHandle(), - 'sources' => $productSources, - ]; + User::macro('getPrimaryPaymentSource', function(): ?PaymentSource { + /** @var User $this */ + $paymentSources = app(PaymentSources::class)->getAllPaymentSourcesByCustomerId(customerId: $this->id); - $event->linkOptions[] = [ - 'label' => Craft::t('commerce', 'Link to a variant'), - 'elementType' => Variant::class, - 'refHandle' => Variant::refHandle(), - 'sources' => $productSources, - ]; + if ($paymentSources->isEmpty()) { + return null; } - }); - } - /** - * Register Commerce’s permissions - */ - private function _registerPermissions(): void - { - Event::on(UserPermissions::class, UserPermissions::EVENT_REGISTER_PERMISSIONS, function(RegisterUserPermissionsEvent $event) { - $this->_productPermissions($event->permissions); - $this->_orderPermissions($event->permissions); - $this->_subscriptionPermissions($event->permissions); - $this->_inventoryPermissions($event->permissions); - $this->_adminPermissions($event->permissions); - }); - } + $primaryId = ObjectState::get($this, 'primaryPaymentSourceId'); - private function _productPermissions(array &$permissions): void - { - $productTypes = self::getInstance()->getProductTypes()->getAllProductTypes(); + if (!$primaryId) { + return $paymentSources->first(); + } - if (empty($productTypes)) { - return; - } + return $paymentSources->firstWhere('id', $primaryId); + }); - $pluralType = Product::pluralLowerDisplayName(); - - foreach ($productTypes as $productType) { - $permissions[] = [ - 'heading' => Craft::t('commerce', 'Craft Commerce - Product Type - {name}', [ - 'name' => Craft::t('site', $productType->name), - ]), - 'permissions' => [ - "commerce-viewProductType:$productType->uid" => [ - 'label' => Craft::t('app', 'View {type}', ['type' => $pluralType]), - 'info' => Craft::t('app', 'Allows viewing existing {type} and creating drafts for them.', [ - 'type' => $pluralType, - ]), - 'nested' => [ - "commerce-createProductType:$productType->uid" => [ - 'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Create {type}', ['type' => $pluralType])), - 'info' => Craft::t('app', 'Allows creating drafts of new {type}.', ['type' => $pluralType]), - ], - "commerce-saveProductType:$productType->uid" => [ - 'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Save {type}', ['type' => $pluralType])), - 'info' => Craft::t('app', 'Allows fully saving canonical {type} (directly or by applying drafts).', [ - 'type' => $pluralType, - ]), - ], - "commerce-deleteProductType:$productType->uid" => [ - 'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Delete {type}', ['type' => $pluralType])), - 'info' => Craft::t('app', 'Allows deleting {type} for all sites.', [ - 'type' => $pluralType, - ]), - ], - ], - ], - ], - ]; - } - } + User::macro('getActiveCarts', function(): array { + /** @var User $this */ + $edge = app(Carts::class)->getActiveCartEdgeDuration(); - private function _orderPermissions(array &$permissions): void - { - $permissions[] = [ - 'heading' => Craft::t('commerce', 'Craft Commerce - Orders'), - 'permissions' => [ - 'commerce-manageOrders' => [ - 'label' => Craft::t('commerce', 'Manage orders'), 'nested' => [ - 'commerce-editOrders' => [ - 'label' => Craft::t('commerce', 'Edit orders'), - ], - 'commerce-deleteOrders' => [ - 'label' => Craft::t('commerce', 'Delete orders'), - ], - 'commerce-capturePayment' => [ - 'label' => Craft::t('commerce', 'Capture payment'), - ], - 'commerce-refundPayment' => [ - 'label' => Craft::t('commerce', 'Refund payment'), - ], - - ], - ], - ], - ]; - } + return Order::find() + ->customer($this) + ->isCompleted(false) + ->where('elements.dateUpdated', '>=', $edge) + /** @phpstan-ignore-next-line arguments.count (ElementQuery's @method static orderBy($column) docblock tag conflicts with its own real 2-param method signature) */ + ->orderBy('elements.dateUpdated', 'desc') + ->all(); + }); - private function _subscriptionPermissions(array &$permissions): void - { - $permissions[] = [ - 'heading' => Craft::t('commerce', 'Craft Commerce - Subscriptions'), - 'permissions' => [ - 'commerce-manageSubscriptions' => ['label' => Craft::t('commerce', 'Manage subscriptions')], - 'commerce-manageSubscriptionPlans' => ['label' => Craft::t('commerce', 'Manage subscription plans')], - ], - ]; - } + User::macro('getInactiveCarts', function(): array { + /** @var User $this */ + $edge = app(Carts::class)->getActiveCartEdgeDuration(); - private function _inventoryPermissions(array &$permissions): void - { - $permissions[] = [ - 'heading' => Craft::t('commerce', 'Craft Commerce - Inventory'), - 'permissions' => [ - 'commerce-manageInventoryStockLevels' => ['label' => Craft::t('commerce', 'Manage inventory stock levels')], - 'commerce-manageInventoryLocations' => ['label' => Craft::t('commerce', 'Manage inventory locations')], - 'commerce-manageInventoryTransfers' => ['label' => Craft::t('commerce', 'Manage inventory transfers')], - ], - ]; - } + return Order::find() + ->customer($this) + ->isCompleted(false) + ->where('elements.dateUpdated', '<', $edge) + /** @phpstan-ignore-next-line arguments.count (ElementQuery's @method static orderBy($column) docblock tag conflicts with its own real 2-param method signature) */ + ->orderBy('elements.dateUpdated', 'asc') + ->all(); + }); - private function _adminPermissions(array &$permissions): void - { - $permissions[] = [ - 'heading' => Craft::t('commerce', 'Craft Commerce - Administration'), - 'permissions' => [ - 'commerce-manageStoreSettings' => ['label' => Craft::t('commerce', 'Manage store settings'), - 'nested' => [ - 'commerce-manageGeneralStoreSettings' => ['label' => Craft::t('commerce', 'Manage general store settings')], - 'commerce-managePaymentCurrencies' => ['label' => Craft::t('commerce', 'Manage payment currencies')], - 'commerce-manageShipping' => ['label' => Craft::t('commerce', 'Manage shipping')], - 'commerce-manageTaxes' => ['label' => Craft::t('commerce', 'Manage taxes')], - 'commerce-managePromotions' => [ - 'label' => Craft::t('commerce', 'Manage promotions'), - 'nested' => [ - 'commerce-editSales' => ['label' => Craft::t('commerce', 'Edit sales')], - 'commerce-createSales' => ['label' => Craft::t('commerce', 'Create sales')], - 'commerce-deleteSales' => ['label' => Craft::t('commerce', 'Delete sales')], - 'commerce-editCatalogPricingRules' => ['label' => Craft::t('commerce', 'Edit catalog pricing rules')], - 'commerce-createCatalogPricingRules' => ['label' => Craft::t('commerce', 'Create catalog pricing rules')], - 'commerce-deleteCatalogPricingRules' => ['label' => Craft::t('commerce', 'Delete catalog pricing rules')], - 'commerce-editDiscounts' => ['label' => Craft::t('commerce', 'Edit discounts')], - 'commerce-createDiscounts' => ['label' => Craft::t('commerce', 'Create discounts')], - 'commerce-deleteDiscounts' => ['label' => Craft::t('commerce', 'Delete discounts')], - ], - ], - ], - ], - 'commerce-manageDonationSettings' => ['label' => Craft::t('commerce', 'Manage donation settings')], - ], - ]; + User::macro('getOrders', function(): array { + /** @var User $this */ + return Order::find() + ->customer($this) + ->isCompleted() + ->withAll() + /** @phpstan-ignore-next-line arguments.count (ElementQuery's @method static orderBy($column) docblock tag conflicts with its own real 2-param method signature) */ + ->orderBy('dateOrdered', 'desc') + ->all(); + }); } /** - * Register Commerce’s project config event listeners + * Replaces `craft\commerce\behaviors\CustomerAddressBehavior`, attached to `Address` in Commerce 5. */ - private function _registerProjectConfigEventListeners(): void + private function registerCustomerAddressMacros(): void { - $projectConfigService = Craft::$app->getProjectConfig(); - - $gatewayService = $this->getGateways(); - $projectConfigService->onAdd(Gateways::CONFIG_GATEWAY_KEY . '.{uid}', [$gatewayService, 'handleChangedGateway']) - ->onUpdate(Gateways::CONFIG_GATEWAY_KEY . '.{uid}', [$gatewayService, 'handleChangedGateway']) - ->onRemove(Gateways::CONFIG_GATEWAY_KEY . '.{uid}', [$gatewayService, 'handleArchivedGateway']); - - $productTypeService = $this->getProductTypes(); - $projectConfigService->onAdd(ProductTypes::CONFIG_PRODUCTTYPES_KEY . '.{uid}', [$productTypeService, 'handleChangedProductType']) - ->onUpdate(ProductTypes::CONFIG_PRODUCTTYPES_KEY . '.{uid}', [$productTypeService, 'handleChangedProductType']) - ->onRemove(ProductTypes::CONFIG_PRODUCTTYPES_KEY . '.{uid}', [$productTypeService, 'handleDeletedProductType']); - - Event::on(Sites::class, Sites::EVENT_AFTER_DELETE_SITE, function(DeleteSiteEvent $event) use ($productTypeService) { - if (!Craft::$app->getProjectConfig()->getIsApplyingExternalChanges()) { - $productTypeService->pruneDeletedSite($event); + Address::macro('getIsPrimaryBilling', function(): bool { + /** @var Address $this */ + if (!ObjectState::has($this, 'isPrimaryBilling')) { + $owner = $this->getPrimaryOwner(); + /** @phpstan-ignore-next-line method.notFound (getPrimaryBillingAddressId() is a macro registered in registerCustomerMacros(), not visible to static analysis) */ + $value = $this->id && $owner instanceof User && $this->id === $owner->getPrimaryBillingAddressId(); + ObjectState::set($this, 'isPrimaryBilling', $value); } - }); - $ordersService = $this->getOrders(); - $projectConfigService->onAdd(OrdersService::CONFIG_FIELDLAYOUT_KEY, [$ordersService, 'handleChangedFieldLayout']) - ->onUpdate(OrdersService::CONFIG_FIELDLAYOUT_KEY, [$ordersService, 'handleChangedFieldLayout']) - ->onRemove(OrdersService::CONFIG_FIELDLAYOUT_KEY, [$ordersService, 'handleDeletedFieldLayout']); - - $transfersService = $this->getTransfers(); - $projectConfigService->onAdd(TransfersService::CONFIG_FIELDLAYOUT_KEY, [$transfersService, 'handleChangedFieldLayout']) - ->onUpdate(TransfersService::CONFIG_FIELDLAYOUT_KEY, [$transfersService, 'handleChangedFieldLayout']) - ->onRemove(TransfersService::CONFIG_FIELDLAYOUT_KEY, [$transfersService, 'handleDeletedFieldLayout']); - - $subscriptionsService = $this->getSubscriptions(); - $projectConfigService->onAdd(Subscriptions::CONFIG_FIELDLAYOUT_KEY, [$subscriptionsService, 'handleChangedFieldLayout']) - ->onUpdate(Subscriptions::CONFIG_FIELDLAYOUT_KEY, [$subscriptionsService, 'handleChangedFieldLayout']) - ->onRemove(Subscriptions::CONFIG_FIELDLAYOUT_KEY, [$subscriptionsService, 'handleDeletedFieldLayout']); - - $orderStatusService = $this->getOrderStatuses(); - $projectConfigService->onAdd(OrderStatuses::CONFIG_STATUSES_KEY . '.{uid}', [$orderStatusService, 'handleChangedOrderStatus']) - ->onUpdate(OrderStatuses::CONFIG_STATUSES_KEY . '.{uid}', [$orderStatusService, 'handleChangedOrderStatus']) - ->onRemove(OrderStatuses::CONFIG_STATUSES_KEY . '.{uid}', [$orderStatusService, 'handleDeletedOrderStatus']); - - Event::on(Emails::class, Emails::EVENT_AFTER_DELETE_EMAIL, function(EmailEvent $event) use ($orderStatusService) { - if (!Craft::$app->getProjectConfig()->getIsApplyingExternalChanges()) { - $orderStatusService->pruneDeletedEmail($event); - } + return ObjectState::get($this, 'isPrimaryBilling'); }); - $lineItemStatusService = $this->getLineItemStatuses(); - $projectConfigService->onAdd(LineItemStatuses::CONFIG_STATUSES_KEY . '.{uid}', [$lineItemStatusService, 'handleChangedLineItemStatus']) - ->onUpdate(LineItemStatuses::CONFIG_STATUSES_KEY . '.{uid}', [$lineItemStatusService, 'handleChangedLineItemStatus']) - ->onRemove(LineItemStatuses::CONFIG_STATUSES_KEY . '.{uid}', [$lineItemStatusService, 'handleArchivedLineItemStatus']); + Address::macro('setIsPrimaryBilling', function(bool|string $value): void { + ObjectState::set($this, 'isPrimaryBilling', (bool) $value); + }); - $emailService = $this->getEmails(); - $projectConfigService->onAdd(Emails::CONFIG_EMAILS_KEY . '.{uid}', [$emailService, 'handleChangedEmail']) - ->onUpdate(Emails::CONFIG_EMAILS_KEY . '.{uid}', [$emailService, 'handleChangedEmail']) - ->onRemove(Emails::CONFIG_EMAILS_KEY . '.{uid}', [$emailService, 'handleDeletedEmail']); + Address::macro('hasIsPrimaryBillingBeenSet', function(): bool { + /** @var Address $this */ + return ObjectState::has($this, 'isPrimaryBilling'); + }); - $storesService = $this->getStores(); - $projectConfigService->onAdd(Stores::CONFIG_STORES_KEY . '.{uid}', [$storesService, 'handleChangedStore']) - ->onUpdate(Stores::CONFIG_STORES_KEY . '.{uid}', [$storesService, 'handleChangedStore']) - ->onRemove(Stores::CONFIG_STORES_KEY . '.{uid}', [$storesService, 'handleDeletedStore']); + Address::macro('getIsPrimaryShipping', function(): bool { + /** @var Address $this */ + if (!ObjectState::has($this, 'isPrimaryShipping')) { + $owner = $this->getPrimaryOwner(); + /** @phpstan-ignore-next-line method.notFound (getPrimaryShippingAddressId() is a macro registered in registerCustomerMacros(), not visible to static analysis) */ + $value = $this->id && $owner instanceof User && $this->id === $owner->getPrimaryShippingAddressId(); + ObjectState::set($this, 'isPrimaryShipping', $value); + } - $projectConfigService->onAdd(Stores::CONFIG_SITESTORES_KEY . '.{uid}', [$storesService, 'handleChangedSiteStore']) - ->onUpdate(Stores::CONFIG_SITESTORES_KEY . '.{uid}', [$storesService, 'handleChangedSiteStore']) - ->onRemove(Stores::CONFIG_SITESTORES_KEY . '.{uid}', [$storesService, 'handleDeletedSiteStore']); + return ObjectState::get($this, 'isPrimaryShipping'); + }); - $pdfService = $this->getPdfs(); - $projectConfigService->onAdd(Pdfs::CONFIG_PDFS_KEY . '.{uid}', [$pdfService, 'handleChangedPdf']) - ->onUpdate(Pdfs::CONFIG_PDFS_KEY . '.{uid}', [$pdfService, 'handleChangedPdf']) - ->onRemove(Pdfs::CONFIG_PDFS_KEY . '.{uid}', [$pdfService, 'handleDeletedPdf']); + Address::macro('setIsPrimaryShipping', function(bool|string $value): void { + ObjectState::set($this, 'isPrimaryShipping', (bool) $value); + }); - Event::on(ProjectConfig::class, ProjectConfig::EVENT_REBUILD, static function(RebuildConfigEvent $event) { - $event->config['commerce'] = ProjectConfigData::rebuildProjectConfig(); + Address::macro('hasIsPrimaryShippingBeenSet', function(): bool { + /** @var Address $this */ + return ObjectState::has($this, 'isPrimaryShipping'); }); } - /** - * Register general event listeners - */ - private function _registerCraftEventListeners(): void + private function registerCraftEventListeners(): void { - // Guard against the case where the Plugin class is loaded during Craft installation due to a project config existing but commerce is not installed. - // Also fixed in core but this is an extra guard: https://github.com/craftcms/cms/commit/369807d9b8da0ff0968e292591eee5f8924b57cc - if (!$this->isInstalled) { - return; - } - - if (!Craft::$app->getRequest()->isConsoleRequest) { - Event::on(User::class, User::EVENT_AFTER_LOGIN, [$this->getCustomers(), 'loginHandler']); - Event::on(User::class, User::EVENT_AFTER_LOGOUT, [$this->getCarts(), 'forgetCart']); - - Event::on(UserElement::class, UserElement::EVENT_AFTER_SAVE, [$this->getCarts(), 'afterSaveUserHandler']); - } - - Event::on(Sites::class, Sites::EVENT_AFTER_SAVE_SITE, [$this->getProductTypes(), 'afterSaveSiteHandler']); - Event::on(Sites::class, Sites::EVENT_AFTER_SAVE_SITE, [$this->getProducts(), 'afterSaveSiteHandler']); - Event::on(Sites::class, Sites::EVENT_AFTER_SAVE_SITE, [$this->getStores(), 'afterSaveCraftSiteHandler']); - Event::on(Sites::class, Sites::EVENT_AFTER_DELETE_SITE, [$this->getStores(), 'afterDeleteCraftSiteHandler']); + Event::listen(Login::class, static fn() => app(Customers::class)->loginHandler()); + Event::listen(Logout::class, static fn() => app(Carts::class)->forgetCart()); - Event::on(UserElement::class, UserElement::EVENT_DEFINE_DELETION_BLOCKERS, [$this->getOrders(), 'beforeDeleteUserHandler']); - Event::on(UserElement::class, UserElement::EVENT_DEFINE_DELETION_BLOCKERS, [$this->getSubscriptions(), 'beforeDeleteUserHandler']); - Event::on(Address::class, Address::EVENT_AFTER_SAVE, [$this->getOrders(), 'afterSaveAddressHandler']); - - Event::on( - UserElement::class, - UserElement::EVENT_DEFINE_BEHAVIORS, - function(DefineBehaviorsEvent $event) { - $event->behaviors['commerce:customer'] = CustomerBehavior::class; + Event::listen(ElementSaved::class, static function(ElementSaved $event) { + if ($event->element instanceof User) { + app(Carts::class)->afterSaveUserHandler($event); + app(CatalogPricingRules::class)->afterSaveUserHandler($event); + app(Customers::class)->afterSaveUserHandler($event); } - ); - - Event::on(UserQuery::class, UserQuery::EVENT_AFTER_POPULATE_ELEMENTS, function(PopulateElementsEvent $event) { - $users = $event->elements; - $customerIds = ArrayHelper::getColumn($users, 'id'); - if (empty($customerIds)) { - return; + if ($event->element instanceof Address) { + app(Orders::class)->afterSaveAddressHandler($event); + app(Customers::class)->afterSaveAddressHandler($event); } + }); - $customers = (new Query()) - ->select(['customerId', 'primaryBillingAddressId', 'primaryShippingAddressId']) - ->from([Table::CUSTOMERS]) - ->where(['customerId' => $customerIds]) - ->all(); + Event::listen(UserAssignedToGroups::class, static fn(UserAssignedToGroups $event) => app(CatalogPricingRules::class)->afterSaveUserHandler($event)); - if (empty($customers)) { - return; - } + Event::listen(SiteSaved::class, static function(SiteSaved $event) { + app(ProductTypes::class)->afterSaveSiteHandler($event); + app(Products::class)->afterSaveSiteHandler($event); + app(Stores::class)->afterSaveCraftSiteHandler($event); + }); - foreach ($customers as $customer) { - /** @var User|CustomerBehavior|null $user */ - $user = ArrayHelper::firstWhere($users, 'id', $customer['customerId']); - if (!$user) { - continue; - } + Event::listen(SiteDeleted::class, static fn(SiteDeleted $event) => app(Stores::class)->afterDeleteCraftSiteHandler($event)); - $user->setPrimaryBillingAddressId($customer['primaryBillingAddressId']); - $user->setPrimaryShippingAddressId($customer['primaryShippingAddressId']); + Event::listen(DefineDeletionBlockers::class, static function(DefineDeletionBlockers $event) { + if ($event->elementType === User::class) { + app(Orders::class)->beforeDeleteUserHandler($event); } }); - // Add Commerce info to user edit screen - Event::on(UsersController::class, UsersController::EVENT_DEFINE_EDIT_SCREENS, function(DefineEditUserScreensEvent $event) { - // Add Commerce screen to user edit screen if the user has permission to access Commerce - if (Craft::$app->getUser()->checkPermission('accessPlugin-commerce')) { - $event->screens[CommerceUsersController::SCREEN_COMMERCE] = ['label' => Craft::t('commerce', 'Commerce')]; - } + Event::listen(ElementAuthorizing::class, static function(ElementAuthorizing $event) { + match ($event->ability) { + 'view' => app(StoreSettings::class)->authorizeStoreLocationView($event), + 'save', 'createDrafts' => app(StoreSettings::class)->authorizeStoreLocationEdit($event), + default => null, + }; + + match ($event->ability) { + 'view' => app(InventoryLocations::class)->authorizeInventoryLocationAddressView($event), + 'save', 'createDrafts' => app(InventoryLocations::class)->authorizeInventoryLocationAddressEdit($event), + default => null, + }; }); + } - // Don't attach behavior if Craft is in the middle of an update - if (!Craft::$app->getUpdates()->getIsCraftUpdatePending()) { - // Site models are instantiated early meaning we have to manually attach the behavior alongside using the event - $sites = Craft::$app->getSites()->getAllSites(true); - foreach ($sites as $site) { - $site->attachBehavior('commerce:store', StoreBehavior::class); - } - Event::on(Site::class, Site::EVENT_DEFINE_BEHAVIORS, function(DefineBehaviorsEvent $event) { - $event->behaviors['commerce:store'] = StoreBehavior::class; - }); - } - - Event::on(UserElement::class, UserElement::EVENT_AFTER_SAVE, [$this->getCatalogPricingRules(), 'afterSaveUserHandler']); - Event::on(Users::class, Users::EVENT_AFTER_ASSIGN_USER_TO_GROUPS, [$this->getCatalogPricingRules(), 'afterSaveUserHandler']); + public function register(): void + { + // Gateway webhooks and off-site payment returns can't provide a CSRF token + $routes = app(Routes::class); + PreventRequestForgery::except([ + 'commerce/webhooks/process-webhook/gateway/*', + $routes->actionTriggerUriPrefix() . '/commerce/webhooks/process-webhook', + $routes->cpActionTriggerUriPrefix() . '/commerce/webhooks/process-webhook', + $routes->actionTriggerUriPrefix() . '/commerce/payments/complete-payment', + $routes->cpActionTriggerUriPrefix() . '/commerce/payments/complete-payment', + ]); - Event::on(Address::class, Address::EVENT_DEFINE_BEHAVIORS, function(DefineBehaviorsEvent $event) { - /** @var Address $address */ - $address = $event->sender; + RateLimiter::for(CartRateLimiter::NAME, fn(Request $request) => app(CartRateLimiter::class)->limit($request)); + RateLimiter::for(CartChallengeRateLimiter::NAME, fn(Request $request) => app(CartChallengeRateLimiter::class)->limit($request)); + RateLimiter::for(PdfChallengeRateLimiter::NAME, fn(Request $request) => app(PdfChallengeRateLimiter::class)->limit($request)); - if ($address->ownerId) { - $owner = $address->getOwner(); - if ($owner instanceof UserElement) { - $event->behaviors['commerce:address'] = CustomerAddressBehavior::class; - } + // Add a "Commerce" screen to the Edit User screen for users who can access Commerce + Event::listen(EditUserScreensResolving::class, function(EditUserScreensResolving $event) { + if (currentUser()?->can('accessPlugin-commerce')) { + $event->screens[UsersController::SCREEN_COMMERCE] = ['label' => t('Commerce', category: 'commerce')]; } }); - Event::on(Purchasable::class, Elements::EVENT_BEFORE_RESTORE_ELEMENT, [$this->getPurchasables(), 'beforeRestorePurchasableHandler']); - - // Register system message for PDF download emails - Event::on( - SystemMessages::class, - SystemMessages::EVENT_REGISTER_MESSAGES, - function(RegisterEmailMessagesEvent $event) { - $event->messages = array_merge($event->messages, [ - [ - 'key' => 'commerce_pdf_download', - 'heading' => Craft::t('commerce', 'Order PDF Download Link'), - 'subject' => Craft::t('commerce', 'Your Order PDF Download Link'), - 'body' => $this->_getDefaultPdfDownloadMessage(), - ], - [ - 'key' => 'commerce_cart_recovery', - 'heading' => Craft::t('commerce', 'Cart Recovery Link'), - 'subject' => Craft::t('commerce', 'Your Cart Recovery Link'), - 'body' => $this->_getDefaultCartRecoveryMessage(), - ], - ]); - } - ); - - Event::on(Elements::class, Elements::EVENT_AUTHORIZE_VIEW, [$this->getStoreSettings(), 'authorizeStoreLocationView']); - Event::on(Elements::class, Elements::EVENT_AUTHORIZE_SAVE, [$this->getStoreSettings(), 'authorizeStoreLocationEdit']); - Event::on(Elements::class, Elements::EVENT_AUTHORIZE_CREATE_DRAFTS, [$this->getStoreSettings(), 'authorizeStoreLocationEdit']); + Event::listen(GqlSchemaComponentsResolving::class, function(GqlSchemaComponentsResolving $event) { + $productTypes = app(ProductTypes::class)->getAllProductTypes(); - Event::on(Elements::class, Elements::EVENT_AUTHORIZE_VIEW, [$this->getInventoryLocations(), 'authorizeInventoryLocationAddressView']); - Event::on(Elements::class, Elements::EVENT_AUTHORIZE_SAVE, [$this->getInventoryLocations(), 'authorizeInventoryLocationAddressEdit']); - Event::on(Elements::class, Elements::EVENT_AUTHORIZE_CREATE_DRAFTS, [$this->getInventoryLocations(), 'authorizeInventoryLocationAddressEdit']); - } + if (empty($productTypes)) { + return; + } - /** - * Register Commerce’s fields - */ - private function _registerFieldTypes(): void - { - Event::on(Fields::class, Fields::EVENT_REGISTER_FIELD_TYPES, static function(RegisterComponentTypesEvent $event) { - $event->types[] = ProductsField::class; - $event->types[] = VariantsField::class; - }); - } + $label = t('Products', category: 'commerce'); + $productPermissions = []; - /** - * Register Commerce’s widgets. - */ - private function _registerWidgets(): void - { - Event::on(Dashboard::class, Dashboard::EVENT_REGISTER_WIDGET_TYPES, static function(RegisterComponentTypesEvent $event) { - $event->types[] = AverageOrderTotal::class; - $event->types[] = NewCustomers::class; - $event->types[] = Orders::class; - $event->types[] = RepeatCustomers::class; - $event->types[] = TotalOrders::class; - $event->types[] = TotalOrdersByCountry::class; - $event->types[] = TopCustomers::class; - $event->types[] = TopProducts::class; - $event->types[] = TopProductTypes::class; - $event->types[] = TopPurchasables::class; - $event->types[] = TotalRevenue::class; - }); - } + foreach ($productTypes as $productType) { + $suffix = 'productTypes.' . $productType->uid; + $productPermissions[$suffix . ':read'] = [ + 'label' => t('View product type - {productType}', ['productType' => t($productType->name, category: 'site')], category: 'commerce'), + ]; + } - /** - * Register Commerce’s template variable. - */ - private function _registerVariables(): void - { - Event::on(CraftVariable::class, CraftVariable::EVENT_INIT, static function(Event $event) { - /** @var CraftVariable $variable */ - $variable = $event->sender; - $variable->attachBehavior('commerce', CraftVariableBehavior::class); + $event->queries[$label] = $productPermissions; }); - } - /** - * Register for FK restore plugin - */ - private function _registerForeignKeysRestore(): void - { - if (!class_exists(RestoreController::class)) { - return; - } - - Event::on(RestoreController::class, RestoreController::EVENT_AFTER_RESTORE_FKS, static function() { - // Add default FKs - (new Install())->addForeignKeys(); + Event::listen(GqlEagerLoadableFieldsResolving::class, function(GqlEagerLoadableFieldsResolving $event) { + $event->fieldList['variants'] = [ProductsField::class]; + $event->fieldList['product'] = [VariantsField::class]; }); - } - /** - * Register the powered-by header - */ - private function _registerPoweredByHeader(): void - { - if (!Craft::$app->request->isConsoleRequest) { - $headers = Craft::$app->getResponse()->getHeaders(); - // Send the X-Powered-By header? - if (Craft::$app->getConfig()->getGeneral()->sendPoweredByHeader) { - $original = $headers->get('X-Powered-By'); - $headers->set('X-Powered-By', $original . ($original ? ',' : '') . 'Craft Commerce'); - } else { - // In case PHP is already setting one - header_remove('X-Powered-By'); + Event::listen(RunningGarbageCollection::class, function(RunningGarbageCollection $event) { + app(Carts::class)->purgeIncompleteCarts(); + + DB::table(Table::VARIANTS)->whereNull('primaryOwnerId')->delete(); + + foreach ([ + [Donation::class, Table::DONATIONS], + [Order::class, Table::ORDERS], + [Product::class, Table::PRODUCTS], + [Variant::class, Table::VARIANTS], + [Transfer::class, Table::TRANSFERS], + ] as [$elementType, $table]) { + app(DeletePartialElements::class, [ + 'garbageCollection' => $event->garbageCollection, + 'elementType' => $elementType, + 'table' => $table, + ])(); } - } - } - - /** - * Register the element types supplied by Craft Commerce - */ - private function _registerElementTypes(): void - { - Event::on(Elements::class, Elements::EVENT_REGISTER_ELEMENT_TYPES, static function(RegisterComponentTypesEvent $e) { - $e->types[] = Variant::class; - $e->types[] = Product::class; - $e->types[] = Order::class; - $e->types[] = Subscription::class; - $e->types[] = Donation::class; - $e->types[] = Transfer::class; - }); - } - - /** - * Register the Gql interfaces - */ - private function _registerGqlInterfaces(): void - { - Event::on(Gql::class, Gql::EVENT_REGISTER_GQL_TYPES, static function(RegisterGqlTypesEvent $event) { - // Add my GraphQL types - $types = $event->types; - $types[] = GqlProductInterface::class; - $types[] = GqlVariantInterface::class; - $event->types = $types; }); } /** - * Register the Gql queries + * The CP nav's `craft-icon` component resolves `icon` to a published icon + * *name* (e.g. `/vendor/craft/icons/solid/cart-shopping.svg`), not a + * filesystem path, so the base `cpNavIconPath()` (which points at + * `resources/icon-mask.svg`) never renders here. Use a published system + * icon until plugin-contributed custom icons are supported. */ - private function _registerGqlQueries(): void + #[\Override] + protected function cpNavIconPath(): ?string { - Event::on(Gql::class, Gql::EVENT_REGISTER_GQL_QUERIES, static function(RegisterGqlQueriesEvent $event) { - // Add my GraphQL queries - $event->queries = array_merge( - $event->queries, - GqlProductQueries::getQueries(), - GqlVariantQueries::getQueries() - ); - }); + return 'cart-shopping'; } - /** - * Register the Gql permissions - */ - private function _registerGqlComponents(): void + #[\Override] + public function getCpNavItem(): NavItem|array|null { - Event::on(Gql::class, Gql::EVENT_REGISTER_GQL_SCHEMA_COMPONENTS, static function(RegisterGqlSchemaComponentsEvent $event) { - $queryComponents = []; - - $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); + $item = parent::getCpNavItem(); - if (!empty($productTypes)) { - $label = Craft::t('commerce', 'Products'); - $productPermissions = []; - - foreach ($productTypes as $productType) { - $suffix = 'productTypes.' . $productType->uid; - $productPermissions[$suffix . ':read'] = ['label' => Craft::t('commerce', 'View product type - {productType}', ['productType' => Craft::t('site', $productType->name)])]; - } - - $queryComponents[$label] = $productPermissions; - } + if (!$item instanceof NavItem) { + return $item; + } - $event->queries = array_merge($event->queries, $queryComponents); - }); - } + $item->label(t('Commerce', category: 'commerce')); - private function _registerGqlEagerLoadableFields(): void - { - Event::on(ElementQueryConditionBuilder::class, ElementQueryConditionBuilder::EVENT_REGISTER_GQL_EAGERLOADABLE_FIELDS, function(RegisterGqlEagerLoadableFields $event) { - $event->fieldList['variants'] = [ProductsField::class]; - $event->fieldList['product'] = [VariantsField::class]; - }); - } - - /** - * Register the Gql argument handlers - * - * @since 5.6.0 - */ - private function _registerGqlArgumentHandlers(): void - { - Event::on(ArgumentManager::class, ArgumentManager::EVENT_DEFINE_GQL_ARGUMENT_HANDLERS, static function(RegisterGqlArgumentHandlersEvent $event) { - $event->handlers['hasProduct'] = HasProduct::class; - $event->handlers['hasVariant'] = HasVariant::class; - $event->handlers['relatedToProducts'] = RelatedProducts::class; - $event->handlers['relatedToVariants'] = RelatedVariants::class; - }); + if (currentUser()?->can('commerce-manageOrders')) { + $item->add(new NavItem()->label(t('Orders', category: 'commerce'))->url('commerce/orders')); + } - // Add relatedToProducts and relatedToVariants arguments to element queries - Event::on(Gql::class, Gql::EVENT_REGISTER_GQL_QUERIES, static function(RegisterGqlQueriesEvent $event) { - $relatedToProductsArg = [ - 'name' => 'relatedToProducts', - 'type' => \GraphQL\Type\Definition\Type::listOf(ProductRelation::getType()), - 'description' => 'Narrows the query results to elements that relate to a product list defined with this argument.', - ]; - $relatedToVariantsArg = [ - 'name' => 'relatedToVariants', - 'type' => \GraphQL\Type\Definition\Type::listOf(VariantRelation::getType()), - 'description' => 'Narrows the query results to elements that relate to a variant list defined with this argument.', - ]; - - // Add the arguments to all relevant queries - foreach ($event->queries as $queryName => &$queryConfig) { - if (isset($queryConfig['args']) && is_array($queryConfig['args'])) { - $queryConfig['args']['relatedToProducts'] = $relatedToProductsArg; - $queryConfig['args']['relatedToVariants'] = $relatedToVariantsArg; - } - } - }); - } + if (app(ProductTypes::class)->getViewableProductTypeIds(true)) { + $item->add(new NavItem()->label(t('Products', category: 'commerce'))->url('commerce/products')); + } - /** - * Register the cache types - */ - private function _registerCacheTypes(): void - { - // create the directory if it doesn't exist + if (currentUser()?->can('commerce-manageInventoryStockLevels')) { + $item->add(new NavItem()->label(t('Inventory', category: 'commerce'))->url('commerce/inventory')); + } - $path = Craft::$app->getPath()->getRuntimePath() . DIRECTORY_SEPARATOR . 'commerce-order-exports'; + if (currentUser()?->can('commerce-manageInventoryLocations')) { + $item->add(new NavItem()->label(t('Inventory Locations', category: 'commerce'))->url('commerce/inventory-locations')); + } - try { - FileHelper::createDirectory($path); - } catch (\Exception $e) { - Craft::$app->getErrorHandler()->logException($e); + $multipleLocations = app(InventoryLocations::class)->getAllInventoryLocations()->count() > 1; + if ($multipleLocations && currentUser()?->can('commerce-manageInventoryTransfers')) { + $item->add(new NavItem()->label(t('Inventory Transfers', category: 'commerce'))->url('commerce/inventory/transfers')); } - Event::on(ClearCaches::class, ClearCaches::EVENT_REGISTER_CACHE_OPTIONS, static function(RegisterCacheOptionsEvent $e) use ($path) { - try { - FileHelper::createDirectory($path); - } catch (\Exception $e) { - Craft::$app->getErrorHandler()->logException($e); - } + if (currentUser()?->can('commerce-manageDonationSettings')) { + $item->add(new NavItem()->label(t('Donations', category: 'commerce'))->url('commerce/donations')); + } - $e->options[] = [ - 'key' => 'commerce-order-exports', - 'label' => Craft::t('commerce', 'Commerce order exports'), - 'action' => static function() use ($path) { - if (file_exists($path)) { - FileHelper::clearDirectory($path); - } - }, - ]; - }); - } + if (currentUser()?->can('commerce-manageStoreSettings')) { + $item->add(new NavItem()->label(t('Store Management', category: 'commerce'))->url('commerce/store-management')); + } - /** - * Register the things that need to be garbage collected - * - * @since 2.2 - */ - private function _registerGarbageCollection(): void - { - Event::on(Gc::class, Gc::EVENT_RUN, function(Event $event) { - // Deletes carts that meet the purge settings - if (Craft::$app instanceof ConsoleApplication) { - Console::stdout(' > purging inactive carts ... '); - } - Plugin::getInstance()->getCarts()->purgeIncompleteCarts(); - if (Craft::$app instanceof ConsoleApplication) { - Console::stdout("done\n", Console::FG_GREEN); - } + if (currentUser()?->isAdmin()) { + $item->add(new NavItem() + ->label(t('Settings', category: 'app')) + ->ariaLabel(t('Commerce Settings', category: 'commerce')) + ->url('commerce/settings/general')); + } - // Delete orphaned variants - Db::delete(Table::VARIANTS, ['primaryOwnerId' => null]); - - // Delete partial elements - /** @var Gc $gc */ - $gc = $event->sender; - $gc->deletePartialElements(Donation::class, Table::DONATIONS, 'id'); - $gc->deletePartialElements(Order::class, Table::ORDERS, 'id'); - $gc->deletePartialElements(Product::class, Table::PRODUCTS, 'id'); - $gc->deletePartialElements(Subscription::class, Table::SUBSCRIPTIONS, 'id'); - $gc->deletePartialElements(Variant::class, Table::VARIANTS, 'id'); - $gc->deletePartialElements(Transfer::class, Table::TRANSFERS, 'id'); - }); + return $item; } - /** - * Register the element exportables - * - * @since 2.2 - */ - private function _registerElementExports(): void + #[\Override] + protected function getSystemMessages(): array { - Event::on(Order::class, Order::EVENT_REGISTER_EXPORTERS, static function(RegisterElementExportersEvent $e) { - $e->exporters[] = OrderExport::class; - $e->exporters[] = LineItemExport::class; - }); + return [ + 'commerce_pdf_download' => fn() => new SystemMessage([ + 'key' => 'commerce_pdf_download', + 'heading' => t('Order PDF Download Link', category: 'commerce'), + 'subject' => t('Your Order PDF Download Link', category: 'commerce'), + 'body' => $this->defaultPdfDownloadMessage(), + ]), + 'commerce_cart_recovery' => fn() => new SystemMessage([ + 'key' => 'commerce_cart_recovery', + 'heading' => t('Cart Recovery Link', category: 'commerce'), + 'subject' => t('Your Cart Recovery Link', category: 'commerce'), + 'body' => $this->defaultCartRecoveryMessage(), + ]), + ]; } - /** - * Register Commerce related debug panels. - * - * @since 4.0 - */ - private function _registerDebugPanels(): void + #[\Override] + protected function getCacheOptions(): array { - Event::on(Application::class, Application::EVENT_BEFORE_REQUEST, static function() { - /** @var Module|null $module */ - $module = Craft::$app->getModule('debug'); - $user = Craft::$app->getUser()->getIdentity(); - - if (!$module || !$user || !Craft::$app->getConfig()->getGeneral()->devMode) { - return; - } - - $pref = Craft::$app->getRequest()->getIsCpRequest() ? 'enableDebugToolbarForCp' : 'enableDebugToolbarForSite'; - if (!$user->getPreference($pref)) { - return; - } - - $module->panels['commerce'] = new CommercePanel([ - 'id' => 'commerce', - 'module' => $module, - ]); - }); + return [ + 'commerce-order-exports' => [ + 'label' => t('Commerce order exports', category: 'commerce'), + 'action' => static function() { + File::cleanDirectory(app(Path::class)->runtime('commerce-order-exports')); + }, + ], + ]; } - /** - * Registers additional standard fields for the product and variant field layout designers. - * - * @since 3.2.0 - */ - private function _defineFieldLayoutElements(): void + #[\Override] + protected function getNativeFields(): ?Closure { - Event::on(FieldLayout::class, FieldLayout::EVENT_DEFINE_NATIVE_FIELDS, static function(DefineFieldLayoutFieldsEvent $e) { - /** @var FieldLayout $fieldLayout */ - $fieldLayout = $e->sender; - + return function(FieldLayout $fieldLayout, array $fields): array { switch ($fieldLayout->type) { case Address::class: - $e->fields[] = UserAddressSettings::class; + $fields[] = UserAddressSettings::class; break; case Product::class: - $e->fields[] = ProductTitleField::class; - $e->fields[] = VariantsLayoutElement::class; + $fields[] = ProductTitleField::class; + $fields[] = VariantsLayoutElement::class; break; case Transfer::class: - $e->fields[] = TransferManagementField::class; + $fields[] = TransferManagementField::class; break; case Variant::class: - $e->fields[] = VariantTitleField::class; - $e->fields[] = PurchasableSkuField::class; - $e->fields[] = PurchasablePriceField::class; - $e->fields[] = PurchasableStockField::class; - $e->fields[] = PurchasableAvailableForPurchaseField::class; - $e->fields[] = PurchasableAllowedQtyField::class; - $e->fields[] = PurchasableFreeShippingField::class; - $e->fields[] = PurchasablePromotableField::class; - $e->fields[] = PurchasableDimensionsField::class; - $e->fields[] = PurchasableWeightField::class; + $fields[] = VariantTitleField::class; + $fields[] = PurchasableSkuField::class; + $fields[] = PurchasablePriceField::class; + $fields[] = PurchasableStockField::class; + $fields[] = PurchasableAvailableForPurchaseField::class; + $fields[] = PurchasableAllowedQtyField::class; + $fields[] = PurchasableFreeShippingField::class; + $fields[] = PurchasablePromotableField::class; + $fields[] = PurchasableDimensionsField::class; + $fields[] = PurchasableWeightField::class; + break; } - }); - } - /** - * Defines the `resave/products`, `resave/variants`, `resave/carts` and `resave/orders` commands. - */ - private function _defineResaveCommand(): void - { - Event::on(ResaveController::class, ConsoleController::EVENT_DEFINE_ACTIONS, static function(DefineConsoleActionsEvent $e) { - $e->actions['products'] = [ - 'action' => function(): int { - /** @var ResaveController $controller */ - $controller = Craft::$app->controller; - $criteria = []; - - if ($controller->type !== null) { - $criteria['type'] = explode(',', $controller->type); - } - - if (!empty($controller->withFields)) { - $handles = Collection::make(self::getInstance()->getProductTypes()->getAllProductTypes()) - ->filter(fn(ProductType $productType) => $controller->hasTheFields($productType->getProductFieldLayout())) - ->map(fn(ProductType $productType) => $productType->handle) - ->all(); - if (isset($criteria['type'])) { - $criteria['type'] = array_intersect($criteria['type'], $handles); - } else { - $criteria['type'] = $handles; - } - - if (empty($criteria['type'])) { - $controller->output($controller->markdownToAnsi('No product types satisfy `--with-fields`.')); - return ExitCode::UNSPECIFIED_ERROR; - } - } - - return $controller->resaveElements(Product::class, $criteria); - }, - 'options' => array_filter(['type', (property_exists(ResaveController::class, 'withFields') ? 'withFields' : null)]), - 'helpSummary' => 'Re-saves Commerce products.', - 'optionsHelp' => [ - 'type' => 'The product type handle(s) of the products to resave.', - ], - ]; - - $e->actions['variants'] = [ - 'action' => function(): int { - /** @var ResaveController $controller */ - $controller = Craft::$app->controller; - $criteria = []; - - if ($controller->type !== null) { - $criteria['type'] = explode(',', $controller->type); - } - if (!empty($controller->withFields)) { - $handles = Collection::make(self::getInstance()->getProductTypes()->getAllProductTypes()) - ->filter(fn(ProductType $productType) => $controller->hasTheFields($productType->getVariantFieldLayout())) - ->map(fn(ProductType $productType) => $productType->handle) - ->all(); - if (isset($criteria['type'])) { - $criteria['type'] = array_intersect($criteria['type'], $handles); - } else { - $criteria['type'] = $handles; - } - - if (empty($criteria['type'])) { - $controller->output($controller->markdownToAnsi('No variant types satisfy `--with-fields`.')); - return ExitCode::UNSPECIFIED_ERROR; - } - } - - // Convert type handles to type IDs for the variant query - if (!empty($criteria['type'])) { - $criteria['typeId'] = (new Query()) - ->select('id') - ->from(Table::PRODUCTTYPES) - ->where(['handle' => $criteria['type']]) - ->column(); - - unset($criteria['type']); - } - - return $controller->resaveElements(Variant::class, $criteria); - }, - 'options' => array_filter(['type', (property_exists(ResaveController::class, 'withFields') ? 'withFields' : null)]), - 'helpSummary' => 'Re-saves Commerce variants.', - 'optionsHelp' => [ - 'type' => 'The product type handle(s) of the variants to resave.', - ], - ]; - - $e->actions['orders'] = [ - 'action' => function(): int { - /** @var ResaveController $controller */ - $controller = Craft::$app->controller; - // @TODO Remove this version_compare and property_exists guard once Commerce composer.json requires Craft 5.5+ (where ResaveController::$withFields is always available) - if (version_compare(Craft::$app->getInfo()->version, '5.5.0', '>=') && !empty($controller->withFields)) { - $fieldLayout = Craft::$app->getFields()->getLayoutByType(Order::class); - if (!$controller->hasTheFields($fieldLayout)) { - $controller->output($controller->markdownToAnsi('The order field layout doesn’t satisfy `--with-fields`.')); - return ExitCode::UNSPECIFIED_ERROR; - } - } - - return $controller->resaveElements(Order::class, [ - 'isCompleted' => true, - ]); - }, - 'options' => array_filter([(property_exists(ResaveController::class, 'withFields') ? 'withFields' : null)]), - 'helpSummary' => 'Re-saves completed Commerce orders.', - ]; - - $e->actions['carts'] = [ - 'action' => function(): int { - /** @var ResaveController $controller */ - $controller = Craft::$app->controller; - // @TODO Remove this version_compare and property_exists guard once Commerce composer.json requires Craft 5.5+ (where ResaveController::$withFields is always available) - if (version_compare(Craft::$app->getInfo()->version, '5.5.0', '>=') && !empty($controller->withFields)) { - $fieldLayout = Craft::$app->getFields()->getLayoutByType(Order::class); - if (!$controller->hasTheFields($fieldLayout)) { - $controller->output($controller->markdownToAnsi('The order field layout doesn’t satisfy `--with-fields`.')); - return ExitCode::UNSPECIFIED_ERROR; - } - } - - return $controller->resaveElements(Order::class, [ - 'isCompleted' => false, - ]); - }, - 'options' => array_filter([(property_exists(ResaveController::class, 'withFields') ? 'withFields' : null)]), - 'helpSummary' => 'Re-saves Commerce carts.', - ]; - }); + return $fields; + }; } /** * Returns the default message body for the PDF download email. - * - * @return string */ - private function _getDefaultPdfDownloadMessage(): string + private function defaultPdfDownloadMessage(): string { return "Hello,\n\n" . "You requested a PDF download for your order. Click the link below to download your PDF:\n\n" . @@ -1419,10 +694,8 @@ private function _getDefaultPdfDownloadMessage(): string /** * Returns the default message body for the cart recovery email. - * - * @return string */ - private function _getDefaultCartRecoveryMessage(): string + private function defaultCartRecoveryMessage(): string { return "Hello,\n\n" . "You requested a link to recover your shopping cart. Click the link below to continue shopping:\n\n" . diff --git a/src/Plugin/Concerns/HasPermissions.php b/src/Plugin/Concerns/HasPermissions.php new file mode 100644 index 0000000000..bd23661de2 --- /dev/null +++ b/src/Plugin/Concerns/HasPermissions.php @@ -0,0 +1,112 @@ +productTypePermissions(), + new Permission( + key: 'commerce-manageOrders', + label: t('Manage orders', category: 'commerce'), + nested: new Collection([ + new Permission(key: 'commerce-editOrders', label: t('Edit orders', category: 'commerce')), + new Permission(key: 'commerce-deleteOrders', label: t('Delete orders', category: 'commerce')), + new Permission(key: 'commerce-capturePayment', label: t('Capture payment', category: 'commerce')), + new Permission(key: 'commerce-refundPayment', label: t('Refund payment', category: 'commerce')), + ]), + ), + new Permission( + key: 'commerce-manageInventoryStockLevels', + label: t('Manage inventory stock levels', category: 'commerce'), + ), + new Permission( + key: 'commerce-manageInventoryLocations', + label: t('Manage inventory locations', category: 'commerce'), + ), + new Permission( + key: 'commerce-manageInventoryTransfers', + label: t('Manage inventory transfers', category: 'commerce'), + ), + new Permission( + key: 'commerce-manageStoreSettings', + label: t('Manage store settings', category: 'commerce'), + nested: new Collection([ + new Permission(key: 'commerce-manageGeneralStoreSettings', label: t('Manage general store settings', category: 'commerce')), + new Permission(key: 'commerce-managePaymentCurrencies', label: t('Manage payment currencies', category: 'commerce')), + new Permission(key: 'commerce-manageShipping', label: t('Manage shipping', category: 'commerce')), + new Permission(key: 'commerce-manageTaxes', label: t('Manage taxes', category: 'commerce')), + new Permission( + key: 'commerce-managePromotions', + label: t('Manage promotions', category: 'commerce'), + nested: new Collection([ + new Permission(key: 'commerce-editSales', label: t('Edit sales', category: 'commerce')), + new Permission(key: 'commerce-createSales', label: t('Create sales', category: 'commerce')), + new Permission(key: 'commerce-deleteSales', label: t('Delete sales', category: 'commerce')), + new Permission(key: 'commerce-editCatalogPricingRules', label: t('Edit catalog pricing rules', category: 'commerce')), + new Permission(key: 'commerce-createCatalogPricingRules', label: t('Create catalog pricing rules', category: 'commerce')), + new Permission(key: 'commerce-deleteCatalogPricingRules', label: t('Delete catalog pricing rules', category: 'commerce')), + new Permission(key: 'commerce-editDiscounts', label: t('Edit discounts', category: 'commerce')), + new Permission(key: 'commerce-createDiscounts', label: t('Create discounts', category: 'commerce')), + new Permission(key: 'commerce-deleteDiscounts', label: t('Delete discounts', category: 'commerce')), + ]), + ), + ]), + ), + new Permission( + key: 'commerce-manageDonationSettings', + label: t('Manage donation settings', category: 'commerce'), + ), + ]; + } + + /** + * @return Permission[] + */ + private function productTypePermissions(): array + { + $productTypes = app(ProductTypes::class)->getAllProductTypes(); + + if (empty($productTypes)) { + return []; + } + + $pluralType = Product::pluralLowerDisplayName(); + + return array_map(fn($productType) => new Permission( + key: "commerce-viewProductType:$productType->uid", + label: t('View {type}', ['type' => $pluralType], category: 'app'), + info: t('Allows viewing existing {type} and creating drafts for them.', ['type' => $pluralType], category: 'app'), + nested: new Collection([ + new Permission( + key: "commerce-createProductType:$productType->uid", + label: ucfirst(t('Create {type}', ['type' => $pluralType], category: 'app')), + info: t('Allows creating drafts of new {type}.', ['type' => $pluralType], category: 'app'), + ), + new Permission( + key: "commerce-saveProductType:$productType->uid", + label: ucfirst(t('Save {type}', ['type' => $pluralType], category: 'app')), + info: t('Allows fully saving canonical {type} (directly or by applying drafts).', ['type' => $pluralType], category: 'app'), + ), + new Permission( + key: "commerce-deleteProductType:$productType->uid", + label: ucfirst(t('Delete {type}', ['type' => $pluralType], category: 'app')), + info: t('Allows deleting {type} for all sites.', ['type' => $pluralType], category: 'app'), + ), + ]), + ), $productTypes); + } +} diff --git a/src/Plugin/Concerns/HasServices.php b/src/Plugin/Concerns/HasServices.php new file mode 100644 index 0000000000..60391c3e7f --- /dev/null +++ b/src/Plugin/Concerns/HasServices.php @@ -0,0 +1,342 @@ +getFoo()` + * relied on when Commerce extended `craft\base\Plugin` (a `yii\base\Module`). Every one of these + * getters is still called throughout src-yii2/ exactly as before; only the underlying lazy-instantiate- + * and-cache mechanism changed. + */ +trait HasServices +{ + /** @var array */ + private array $serviceInstances = []; + + /** @var array */ + private static array $serviceMap = [ + 'carts' => Carts::class, + 'catalogPricing' => CatalogPricing::class, + 'catalogPricingRules' => CatalogPricingRules::class, + 'coupons' => Coupons::class, + 'currencies' => Currencies::class, + 'customers' => Customers::class, + 'discounts' => Discounts::class, + 'emails' => Emails::class, + 'formulas' => Formulas::class, + 'gateways' => Gateways::class, + 'inventory' => Inventory::class, + 'inventoryLocations' => InventoryLocations::class, + 'lineItems' => LineItems::class, + 'lineItemStatuses' => LineItemStatuses::class, + 'orderAdjustments' => OrderAdjustments::class, + 'orderHistories' => OrderHistories::class, + 'orderNotices' => OrderNotices::class, + 'orders' => Orders::class, + 'orderStatuses' => OrderStatuses::class, + 'paymentCurrencies' => PaymentCurrencies::class, + // Legacy alias kept for any third-party code still doing get('paymentMethods') directly. + 'paymentMethods' => Gateways::class, + 'payments' => Payments::class, + 'paymentSources' => PaymentSources::class, + 'pdfs' => Pdfs::class, + 'products' => Products::class, + 'productTypes' => ProductTypes::class, + 'purchasables' => Purchasables::class, + 'sales' => Sales::class, + 'shippingCategories' => ShippingCategories::class, + 'shippingMethods' => ShippingMethods::class, + 'shippingRuleCategories' => ShippingRuleCategories::class, + 'shippingRules' => ShippingRules::class, + 'shippingZones' => ShippingZones::class, + 'storeSettings' => StoreSettings::class, + 'stores' => Stores::class, + 'taxCategories' => TaxCategories::class, + 'taxes' => Taxes::class, + 'taxRates' => TaxRates::class, + 'taxZones' => TaxZones::class, + 'transactions' => Transactions::class, + 'transfers' => Transfers::class, + 'variants' => Variants::class, + 'vat' => Vat::class, + 'webhooks' => Webhooks::class, + ]; + + /** + * Returns a legacy service component by ID, matching the `yii\di\ServiceLocator::get()` API + * that third-party code may still call directly. + * + * @throws \RuntimeException if no service is registered under that ID + */ + public function get(string $id): object + { + if (!isset(self::$serviceMap[$id])) { + throw new \RuntimeException("Unknown component ID: $id"); + } + + $class = self::$serviceMap[$id]; + + return $this->serviceInstances[$id] ??= new $class(); + } + + public function getCarts(): Carts + { + return $this->get('carts'); + } + + public function getCatalogPricing(): CatalogPricing + { + return $this->get('catalogPricing'); + } + + public function getCatalogPricingRules(): CatalogPricingRules + { + return $this->get('catalogPricingRules'); + } + + public function getCoupons(): Coupons + { + return $this->get('coupons'); + } + + public function getCurrencies(): Currencies + { + return $this->get('currencies'); + } + + public function getCustomers(): Customers + { + return $this->get('customers'); + } + + public function getDiscounts(): Discounts + { + return $this->get('discounts'); + } + + public function getEmails(): Emails + { + return $this->get('emails'); + } + + public function getFormulas(): Formulas + { + return $this->get('formulas'); + } + + public function getGateways(): Gateways + { + return $this->get('gateways'); + } + + public function getInventory(): Inventory + { + return $this->get('inventory'); + } + + public function getInventoryLocations(): InventoryLocations + { + return $this->get('inventoryLocations'); + } + + public function getLineItems(): LineItems + { + return $this->get('lineItems'); + } + + public function getLineItemStatuses(): LineItemStatuses + { + return $this->get('lineItemStatuses'); + } + + public function getOrderAdjustments(): OrderAdjustments + { + return $this->get('orderAdjustments'); + } + + public function getOrderHistories(): OrderHistories + { + return $this->get('orderHistories'); + } + + public function getOrderNotices(): OrderNotices + { + return $this->get('orderNotices'); + } + + public function getOrders(): Orders + { + return $this->get('orders'); + } + + public function getOrderStatuses(): OrderStatuses + { + return $this->get('orderStatuses'); + } + + public function getPaymentCurrencies(): PaymentCurrencies + { + return $this->get('paymentCurrencies'); + } + + public function getPayments(): Payments + { + return $this->get('payments'); + } + + public function getPaymentSources(): PaymentSources + { + return $this->get('paymentSources'); + } + + public function getPdfs(): Pdfs + { + return $this->get('pdfs'); + } + + public function getProducts(): Products + { + return $this->get('products'); + } + + public function getProductTypes(): ProductTypes + { + return $this->get('productTypes'); + } + + public function getPurchasables(): Purchasables + { + return $this->get('purchasables'); + } + + public function getSales(): Sales + { + return $this->get('sales'); + } + + public function getShippingCategories(): ShippingCategories + { + return $this->get('shippingCategories'); + } + + public function getShippingMethods(): ShippingMethods + { + return $this->get('shippingMethods'); + } + + public function getShippingRuleCategories(): ShippingRuleCategories + { + return $this->get('shippingRuleCategories'); + } + + public function getShippingRules(): ShippingRules + { + return $this->get('shippingRules'); + } + + public function getShippingZones(): ShippingZones + { + return $this->get('shippingZones'); + } + + public function getStoreSettings(): StoreSettings + { + return $this->get('storeSettings'); + } + + public function getStores(): Stores + { + return $this->get('stores'); + } + + public function getTaxCategories(): TaxCategories + { + return $this->get('taxCategories'); + } + + public function getTaxes(): Taxes + { + return $this->get('taxes'); + } + + public function getTaxRates(): TaxRates + { + return $this->get('taxRates'); + } + + public function getTaxZones(): TaxZones + { + return $this->get('taxZones'); + } + + public function getTransactions(): Transactions + { + return $this->get('transactions'); + } + + public function getTransfers(): Transfers + { + return $this->get('transfers'); + } + + public function getVariants(): Variants + { + return $this->get('variants'); + } + + public function getVat(): Vat + { + return $this->get('vat'); + } + + public function getWebhooks(): Webhooks + { + return $this->get('webhooks'); + } +} diff --git a/src/Promotion/Actions/CreateDiscount.php b/src/Promotion/Actions/CreateDiscount.php new file mode 100644 index 0000000000..e64c7ae27e --- /dev/null +++ b/src/Promotion/Actions/CreateDiscount.php @@ -0,0 +1,44 @@ +getCurrentStore(); + $type = Json::encode(static::class); + $url = Json::encode('commerce/store-management/' . $currentStore->handle . '/discounts/new'); + $js = <<getCurrentStore(); + $type = Json::encode(static::class); + $url = Json::encode('commerce/store-management/' . $currentStore->handle . '/sales/new'); + $js = <<allCodes !== null) { + return $this->allCodes; + } + + $this->allCodes = $this->query() + ->select(['id', 'code']) + ->get() + ->keyBy('id') + ->map(fn($row) => $row->code) + ->all(); + + return $this->allCodes; + } + + public function getCouponByCode(string $code): ?Coupon + { + $row = $this->query()->where('code', $code)->first(); + + return $row ? new Coupon((array) $row) : null; + } + + /** + * @return Coupon[] + */ + public function getCouponsByDiscountId(int $discountId): array + { + return $this->query() + ->where('discountId', $discountId) + ->get() + ->map(fn($row) => new Coupon((array) $row)) + ->all(); + } + + /** + * @param string[] $existingCodes + * @return string[] + * @throws \Exception + */ + public function generateCouponCodes(int $count = 1, string $format = self::DEFAULT_COUPON_FORMAT, array $existingCodes = []): array + { + $numReplacementChars = strlen($format) - strlen(str_replace(self::COUPON_FORMAT_REPLACEMENT_CHAR, '', $format)); + $numPossibleCodes = strlen(self::CHARS_UPPER) ** $numReplacementChars; + + if ($numPossibleCodes < $count) { + throw new \Exception('The format is too restrictive to generate enough unique codes.'); + } + + $existingCodes = array_unique([...$existingCodes, ...$this->getAllCodes()]); + $coupons = []; + + for ($i = 1; $i <= $count; $i++) { + $code = preg_replace_callback('/([' . self::COUPON_FORMAT_REPLACEMENT_CHAR . ']+)/', static function($matches) { + return self::randomStringWithChars(self::CHARS_UPPER, strlen($matches[0])); + }, $format); + + if (!empty($existingCodes) && in_array($code, $existingCodes, true)) { + $i--; + continue; + } + $coupons[] = $code; + $existingCodes[] = $code; + } + + return $coupons; + } + + public function deleteCouponById(int $id): bool + { + $record = CouponRecord::find($id); + + if (!$record) { + return false; + } + + return (bool) $record->delete(); + } + + public function saveDiscountCoupons(Discount $discount): bool + { + if (!$discount->id) { + throw new \RuntimeException('Discount must be saved before it can have coupons'); + } + + $existingCouponIds = $this->query() + ->where('discountId', $discount->id) + ->pluck('id') + ->all(); + + $couponIds = []; + foreach ($discount->getCoupons() as $key => $coupon) { + $coupon->discountId = $discount->id; + + if (!$this->saveCoupon($coupon)) { + $discount->addModelErrors($coupon, 'coupon.' . $key); + } + + if ($coupon->id) { + $couponIds[] = $coupon->id; + } + } + + $return = !$discount->hasErrors(); + + if (empty($existingCouponIds) || $existingCouponIds === $couponIds) { + return $return; + } + + foreach (array_diff($existingCouponIds, $couponIds) as $deleteId) { + $this->deleteCouponById($deleteId); + } + + return $return; + } + + public function saveCoupon(Coupon $coupon, bool $runValidation = true): bool + { + if ($coupon->id) { + $record = CouponRecord::find($coupon->id); + + if (!$record) { + throw new \RuntimeException("Invalid coupon ID: {$coupon->id}"); + } + } else { + $record = new CouponRecord(); + } + + if ($runValidation && !$coupon->validate()) { + Log::info('Coupon not saved due to validation error.'); + return false; + } + + $record->code = $coupon->code; + $record->discountId = $coupon->discountId; + $record->uses = $coupon->uses; + $record->maxUses = $coupon->maxUses; + + $record->save(); + + $coupon->id = $record->id; + + $this->clearCaches(); + + return true; + } + + protected function clearCaches(): void + { + $this->allCodes = null; + } + + private function query(): \Illuminate\Database\Query\Builder + { + return DB::table(Table::COUPONS) + ->select([ + 'id', + 'code', + 'uses', + 'maxUses', + 'discountId', + ]); + } + + private static function randomStringWithChars(string $chars, int $length): string + { + $result = ''; + $max = strlen($chars) - 1; + for ($i = 0; $i < $length; $i++) { + $result .= $chars[random_int(0, $max)]; + } + return $result; + } +} diff --git a/src/Promotion/Discounts.php b/src/Promotion/Discounts.php new file mode 100644 index 0000000000..816b3255eb --- /dev/null +++ b/src/Promotion/Discounts.php @@ -0,0 +1,1098 @@ +>|null */ + private ?array $allDiscounts = null; + + /** @var array|null */ + private ?array $activeDiscountsByKey = null; + + /** @var array|null */ + private ?array $matchingLineItemCategoryCondition = null; + + public function getDiscountById(int $id, ?int $storeId = null): ?Discount + { + // TODO: migrate to app(Stores::class)->getCurrentStore()->id once Stores service migrated + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + $rows = $this->query() + ->where('discounts.id', $id) + ->where('storeId', $storeId) + ->get() + ->all(); + + if (!$rows) { + return null; + } + + $populated = $this->populateDiscounts($rows); + return reset($populated) ?: null; + } + + /** + * @return Collection + */ + public function getAllDiscounts(?int $storeId = null): Collection + { + // TODO: migrate to app(Stores::class)->getCurrentStore()->id once Stores service migrated + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + if ($this->allDiscounts === null || !isset($this->allDiscounts[$storeId])) { + $rows = $this->query()->where('storeId', $storeId)->get()->all(); + + $this->allDiscounts ??= []; + $this->allDiscounts[$storeId] = collect($rows ? $this->populateDiscounts($rows) : []); + } + + return $this->allDiscounts[$storeId]; + } + + /** + * Get all currently active discounts, pre-filtered for the given order. + * TODO: update Order type hint when Order element is migrated to src/ + * + * @return Discount[] + */ + public function getAllActiveDiscounts(?Order $order = null): array + { + $purchasableIds = []; + if ($order) { + // TODO: update when LineItem is migrated + $purchasableIds = collect($order->getLineItems())->pluck('purchasableId')->unique()->all(); + } + + if ($order && $order->dateOrdered) { + $date = $order->dateOrdered; + } else { + $date = new DateTime(); + $date->setTime((int) $date->format('H'), (int) (round($date->format('i') / 1) * 1)); + } + + // TODO: migrate to app(Stores::class)->getCurrentStore() once Stores service migrated + $store = $order ? $order->getStore() : app(Stores::class)->getCurrentStore(); + + $couponKey = ($order && $order->couponCode) ? $order->couponCode : '*'; + $dateKey = $date->format('c'); + $storeKey = $order ? $order->getStore()->id : '*'; + $purchasablesKey = !empty($purchasableIds) ? md5(serialize($purchasableIds)) : '*'; + $itemSubtotalKey = $order ? $order->getItemSubtotal() : '*'; + $orderTotalQtyKey = $order ? $order->getTotalQty() : '*'; + $orderEmailKey = ($order && $order->getEmail()) ? $order->getEmail() : '*'; + + $cacheKeyMd5 = md5(implode(':', [$couponKey, $dateKey, $storeKey, $purchasablesKey, $itemSubtotalKey, $orderTotalQtyKey, $orderEmailKey])); + + if (isset($this->activeDiscountsByKey[$cacheKeyMd5])) { + return $this->activeDiscountsByKey[$cacheKeyMd5]; + } + + $isPgsql = DB::connection()->getDriverName() === 'pgsql'; + + $discountQuery = $this->query() + ->where('enabled', true) + ->where('storeId', $store->id) + ->where(function($q) use ($date) { + $q->whereNull('dateFrom')->orWhere('dateFrom', '<=', $date->format('Y-m-d H:i:s')); + }) + ->where(function($q) use ($date) { + $q->whereNull('dateTo')->orWhere('dateTo', '>=', $date->format('Y-m-d H:i:s')); + }) + ->where(function($q) { + $q->where('totalDiscountUseLimit', 0) + ->orWhereColumn('totalDiscountUses', '<', 'totalDiscountUseLimit'); + }); + + if ($order) { + if ($order->getEmail()) { + $emailUsesSubQuery = DB::table(Table::EMAIL_DISCOUNTUSES . ' as edu') + ->selectRaw('COALESCE(SUM(edu.uses), 0)') + ->whereColumn('edu.discountId', 'discounts.id') + ->where('edu.email', $order->getEmail()); + + $discountQuery->where(function($q) use ($emailUsesSubQuery) { + $q->where('perEmailLimit', 0) + ->orWhere(function($q2) use ($emailUsesSubQuery) { + $q2->where('perEmailLimit', '>', 0) + ->where('perEmailLimit', '>', $emailUsesSubQuery); + }); + }); + } else { + $discountQuery->where('perEmailLimit', 0); + } + + $discountQuery->where(function($q) use ($order) { + $q->where('purchaseTotal', 0) + ->orWhere(function($q2) use ($order) { + $q2->where('allPurchasables', true)->where('allCategories', true)->where('purchaseTotal', '<=', $order->getItemSubtotal()); + }) + ->orWhere('allPurchasables', false) + ->orWhere('allCategories', false); + }); + + $discountQuery->where(function($q) use ($order) { + $q->where(function($q2) { + $q2->where('purchaseQty', 0)->where('maxPurchaseQty', 0); + }) + ->orWhere(function($q2) use ($order) { + $q2->where('allPurchasables', true)->where('allCategories', true) + ->where('purchaseQty', '>', 0)->where('maxPurchaseQty', 0)->where('purchaseQty', '<=', $order->getTotalQty()); + }) + ->orWhere(function($q2) use ($order) { + $q2->where('allPurchasables', true)->where('allCategories', true) + ->where('maxPurchaseQty', '>', 0)->where('purchaseQty', 0)->where('maxPurchaseQty', '>=', $order->getTotalQty()); + }) + ->orWhere(function($q2) use ($order) { + $q2->where('allPurchasables', true)->where('allCategories', true) + ->where('maxPurchaseQty', '>', 0)->where('purchaseQty', '>', 0) + ->where('purchaseQty', '<=', $order->getTotalQty())->where('maxPurchaseQty', '>=', $order->getTotalQty()); + }) + ->orWhere('allPurchasables', false) + ->orWhere('allCategories', false); + }); + } + + if ($order && $order->couponCode) { + $code = $order->couponCode; + $discountQuery->where(function($q) use ($code, $isPgsql) { + $q->whereExists(function($sub) use ($code, $isPgsql) { + $sub->from(Table::COUPONS) + ->whereColumn('discountId', 'discounts.id') + ->where('requireCouponCode', true) + ->when($isPgsql, + fn($q) => $q->whereRaw('LOWER(code) = LOWER(?)', [$code]), + fn($q) => $q->where('code', $code) + ) + ->where(function($q2) { + $q2->whereNull('maxUses')->orWhereColumn('uses', '<', 'maxUses'); + }); + })->orWhere('requireCouponCode', false); + }); + } elseif ($order) { + $discountQuery->where('requireCouponCode', false); + } + + if ($order && !empty($purchasableIds)) { + $discountQuery->where(function($q) use ($purchasableIds) { + $q->where('allPurchasables', true) + ->orWhereExists(function($sub) use ($purchasableIds) { + $sub->from(Table::DISCOUNT_PURCHASABLES . ' as subdp') + ->whereColumn('subdp.discountId', 'discounts.id') + ->whereIn('subdp.purchasableId', $purchasableIds); + }); + }); + } + + $rows = $discountQuery->get()->all(); + $discounts = $this->populateDiscounts($rows); + $this->activeDiscountsByKey[$cacheKeyMd5] = $discounts; + + return $discounts; + } + + /** + * TODO: update Order type hint when Order element migrated to src/ + */ + public function orderCouponAvailable(Order $order, ?string &$explanation = null): bool + { + $discount = $this->getDiscountByCode($order->couponCode, $order->storeId); + + if (!$discount) { + $explanation = t('Coupon not valid.', category: 'commerce'); + return false; + } + + if (!$discount->requireCouponCode) { + $explanation = t('Coupon not valid.', category: 'commerce'); + return false; + } + + if (!$this->isDiscountCouponCodeValid($order, $discount)) { + $explanation = t('Coupon not valid.', category: 'commerce'); + return false; + } + + if ($discount->hasOrderCondition() && !$discount->getOrderCondition()->matchElement($order)) { + $explanation = t('Coupon can not apply discount to this order.', category: 'commerce'); + return false; + } + + if ($discount->hasCustomerCondition() && (!$order->getCustomer() || !$discount->getCustomerCondition()->matchElement($order->getCustomer()))) { + $explanation = t('Coupon can not apply discount to this order due to customer mismatch.', category: 'commerce'); + return false; + } + + if ($discount->hasShippingAddressCondition() && (!$order->getShippingAddress() || !$discount->getShippingAddressCondition()->matchElement($order->getShippingAddress()))) { + $explanation = t('Coupon can not apply discount to this order due to address mismatch.', category: 'commerce'); + return false; + } + + if ($discount->hasBillingAddressCondition() && (!$order->getBillingAddress() || !$discount->getBillingAddressCondition()->matchElement($order->getBillingAddress()))) { + $explanation = t('Coupon can not apply discount to this order due to address mismatch.', category: 'commerce'); + return false; + } + + if (!$this->isDiscountConditionFormulaValid($order, $discount)) { + $explanation = t('Discount is not allowed for the order', category: 'commerce'); + return false; + } + + if (!$this->isDiscountDateValid($order, $discount)) { + $explanation = t('Discount is out of date.', category: 'commerce'); + return false; + } + + if (!$this->isDiscountTotalUseLimitValid($discount)) { + $explanation = t('Discount use has reached its limit.', category: 'commerce'); + return false; + } + + if (!$this->isDiscountPerUserUsageValid($discount, $order->getCustomer())) { + $explanation = t('This coupon is for registered users and limited to {limit} uses.', ['limit' => $discount->perUserLimit], category: 'commerce'); + return false; + } + + if (!$this->isDiscountEmailRequirementValid($discount, $order)) { + $explanation = t('This coupon requires an email address.', category: 'commerce'); + return false; + } + + if (!$this->isDiscountPerEmailLimitValid($discount, $order)) { + $explanation = t('This coupon is limited to {limit} uses.', ['limit' => $discount->perEmailLimit], category: 'commerce'); + return false; + } + + return true; + } + + public function getDiscountByCode(?string $code, ?int $storeId = null): ?Discount + { + if ($code === null || $code === '') { + return null; + } + + // TODO: migrate to app(Stores::class)->getCurrentStore()->id once Stores service migrated + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + $isPgsql = DB::connection()->getDriverName() === 'pgsql'; + + $query = $this->query() + ->where('storeId', $storeId) + ->join(Table::COUPONS . ' as coupons', 'coupons.discountId', '=', 'discounts.id'); + + if ($isPgsql) { + $query->whereRaw('LOWER(coupons.code) = LOWER(?)', [$code]); + } else { + $query->where('coupons.code', $code); + } + + $rows = $query->get()->all(); + + if (!$rows) { + return null; + } + + $discounts = $this->populateDiscounts($rows); + + foreach ($discounts as $discount) { + if (!$discount->enabled) { + continue; + } + foreach ($discount->getCoupons() as $coupon) { + if (strcasecmp((string) $coupon->code, $code) === 0) { + return $discount; + } + } + } + + return null; + } + + /** + * TODO: update PurchasableInterface type hint when migrated to src/ + * + * @return Discount[] + */ + public function getDiscountsRelatedToPurchasable(PurchasableInterface $purchasable): array + { + $discounts = []; + + if ($purchasable->getId()) { + foreach ($this->getAllDiscounts($purchasable->getStoreId()) as $discount) { + $purchasableIds = $discount->getPurchasableIds(); + $id = $purchasable->getId(); + + // TODO: update Category/Entry element calls when migrated + $relatedTo = [$discount->categoryRelationshipType => $purchasable->getPromotionRelationSource()]; + $categoryIds = $discount->getCategoryIds(); + $relatedCategories = Category::find()->id($categoryIds)->relatedTo($relatedTo)->ids(); + $relatedEntries = Entry::find()->id($categoryIds)->relatedTo($relatedTo)->ids(); + $relatedCategoriesOrEntries = array_merge($relatedCategories, $relatedEntries); + + if (in_array($id, $purchasableIds, false) || !empty($relatedCategoriesOrEntries)) { + $discounts[$discount->id] = $discount; + } + } + } + + return $discounts; + } + + /** + * TODO: update Order/LineItem type hints when elements migrated to src/ + */ + public function matchLineItem(mixed $lineItem, Discount $discount, bool $matchOrder = false): bool + { + if ($matchOrder && !$this->matchOrder($lineItem->order, $discount)) { + return false; + } + + $siteId = $lineItem->order->orderSiteId ?? Sites::getCurrentSite()->id; + + if ($lineItem->getOnPromotion() && $discount->excludeOnPromotion) { + return false; + } + + if (!$lineItem->getIsPromotable()) { + return false; + } + + // TODO: update LineItemType enum reference once migrated + /** @phpstan-ignore-next-line */ + if ($lineItem->type === \craft\commerce\enums\LineItemType::Purchasable) { + $purchasable = $lineItem->getPurchasable(); + + if (!$discount->allPurchasables && !in_array($purchasable->id, $discount->getPurchasableIds(), false)) { + return false; + } + + if (!$discount->allCategories) { + $key = 'relationshipType:' . $discount->categoryRelationshipType . ':purchasableId:' . $purchasable->getId() . ':categoryIds:' . implode('|', $discount->getCategoryIds()); + + if (!isset($this->matchingLineItemCategoryCondition[$key])) { + $relatedTo = [$discount->categoryRelationshipType => $purchasable->getPromotionRelationSource()]; + + // TODO: update Category/Entry element calls when migrated + $relatedEntries = Entry::find()->siteId($siteId)->relatedTo($relatedTo)->ids(); + $relatedCategories = Category::find()->siteId($siteId)->relatedTo($relatedTo)->ids(); + + $relatedCategoriesOrEntries = array_merge($relatedEntries, $relatedCategories); + $purchasableIsRelated = (bool) array_intersect($relatedCategoriesOrEntries, $discount->getCategoryIds()); + + $this->matchingLineItemCategoryCondition[$key] = $purchasableIsRelated; + if (!$purchasableIsRelated) { + return false; + } + } elseif ($this->matchingLineItemCategoryCondition[$key] === false) { + return false; + } + } + } + + $event = new MatchLineItemEvent(lineItem: $lineItem, discount: $discount); + event($event); + + return $event->isValid; + } + + /** + * TODO: update Order type hint when Order element migrated to src/ + */ + public function matchOrder(Order $order, Discount $discount): bool + { + if (!$discount->enabled) { + return false; + } + + if ($discount->hasOrderCondition() && !$discount->getOrderCondition()->matchElement($order)) { + return false; + } + + if ($discount->hasCustomerCondition() && (!$order->getCustomer() || !$discount->getCustomerCondition()->matchElement($order->getCustomer()))) { + return false; + } + + if ($discount->hasShippingAddressCondition() && (!$order->getShippingAddress() || !$discount->getShippingAddressCondition()->matchElement($order->getShippingAddress()))) { + return false; + } + + if ($discount->hasBillingAddressCondition() && (!$order->getBillingAddress() || !$discount->getBillingAddressCondition()->matchElement($order->getBillingAddress()))) { + return false; + } + + if (!$this->isDiscountCouponCodeValid($order, $discount)) { + return false; + } + + if (!$this->isDiscountDateValid($order, $discount)) { + return false; + } + + if (!$this->isDiscountTotalUseLimitValid($discount)) { + return false; + } + + if (!$this->isDiscountPerUserUsageValid($discount, $order->getCustomer())) { + return false; + } + + if (!$this->isDiscountEmailRequirementValid($discount, $order)) { + return false; + } + + if (!$this->isDiscountPerEmailLimitValid($discount, $order)) { + return false; + } + + if (!$this->isDiscountConditionFormulaValid($order, $discount)) { + return false; + } + + $allItemsMatch = ($discount->allPurchasables && $discount->allCategories); + + if ($allItemsMatch && $discount->purchaseTotal > 0 && $order->getItemSubtotal() < $discount->purchaseTotal) { + return false; + } + + if ($allItemsMatch && $discount->purchaseQty > 0 && $order->getTotalQty() < $discount->purchaseQty) { + return false; + } + + if ($allItemsMatch && $discount->maxPurchaseQty > 0 && $order->getTotalQty() > $discount->maxPurchaseQty) { + return false; + } + + if (!$discount->allPurchasables || !$discount->allCategories) { + $matchingItems = collect($order->getLineItems()) + ->filter(fn($item) => $this->matchLineItem($item, $discount)); + + if ($matchingItems->isEmpty()) { + return false; + } + + $matchingQty = $matchingItems->sum('qty'); + $matchingTotal = $matchingItems->sum('subtotal'); + + if ($discount->purchaseTotal > 0 && $matchingTotal < $discount->purchaseTotal) { + return false; + } + if ($discount->purchaseQty > 0 && $matchingQty < $discount->purchaseQty) { + return false; + } + if ($discount->maxPurchaseQty > 0 && $matchingQty > $discount->maxPurchaseQty) { + return false; + } + } + + $event = new MatchOrderEvent(order: $order, discount: $discount); + event($event); + + return $event->isValid; + } + + public function saveDiscount(Discount $model, bool $runValidation = true): bool + { + $isNew = !$model->id; + + if ($model->id) { + $record = DiscountRecord::find($model->id); + + if (!$record) { + throw new \RuntimeException(t('No discount exists with the ID "{id}"', ['id' => $model->id], category: 'commerce')); + } + } else { + $record = new DiscountRecord(); + } + + if (!$isNew) { + // TODO: update to new date helper once migrated + /** @phpstan-ignore-next-line */ + $model->dateCreated = \CraftCms\Cms\Support\DateTimeHelper::toDateTime($record->dateCreated); + /** @phpstan-ignore-next-line */ + $model->dateUpdated = \CraftCms\Cms\Support\DateTimeHelper::toDateTime($record->dateUpdated); + } + + $ev = new DiscountEvent(discount: $model, isNew: $isNew); + event($ev); + + if ($runValidation && !$model->validate()) { + Log::info('Discount not saved due to validation error.'); + return false; + } + + $record->storeId = $model->storeId; + $record->name = $model->name; + $record->description = $model->description; + $record->dateFrom = $model->dateFrom ? Carbon::instance($model->dateFrom) : null; + $record->dateTo = $model->dateTo ? Carbon::instance($model->dateTo) : null; + $record->enabled = $model->enabled; + $record->stopProcessing = $model->stopProcessing; + $record->orderCondition = $model->hasOrderCondition() ? $model->getOrderCondition()->getConfig() : null; + $record->customerCondition = $model->hasCustomerCondition() ? $model->getCustomerCondition()->getConfig() : null; + $record->shippingAddressCondition = $model->hasShippingAddressCondition() ? $model->getShippingAddressCondition()->getConfig() : null; + $record->billingAddressCondition = $model->hasBillingAddressCondition() ? $model->getBillingAddressCondition()->getConfig() : null; + $record->requireCouponCode = $model->requireCouponCode; + $record->orderConditionFormula = $model->orderConditionFormula; + $record->purchaseQty = $model->purchaseQty; + $record->maxPurchaseQty = $model->maxPurchaseQty; + $record->baseDiscount = $model->baseDiscount; + $record->purchaseTotal = $model->purchaseTotal; + $record->perItemDiscount = $model->perItemDiscount; + $record->percentDiscount = $model->percentDiscount; + $record->percentageOffSubject = $model->percentageOffSubject; + $record->hasFreeShippingForMatchingItems = $model->hasFreeShippingForMatchingItems; + $record->hasFreeShippingForOrder = $model->hasFreeShippingForOrder; + $record->excludeOnPromotion = $model->excludeOnPromotion; + $record->perUserLimit = $model->perUserLimit; + $record->perEmailLimit = $model->perEmailLimit; + $record->totalDiscountUseLimit = $model->totalDiscountUseLimit; + $record->ignorePromotions = $model->ignorePromotions; + $record->appliedTo = $model->appliedTo; + $record->purchasableIds = $model->getPurchasableIds(); + $record->categoryIds = $model->getCategoryIds(); + $record->sortOrder = $record->sortOrder ?: 0; + $record->couponFormat = $model->couponFormat; + $record->categoryRelationshipType = $model->categoryRelationshipType; + + if ($record->allCategories = $model->allCategories) { + $model->setCategoryIds([]); + $record->categoryIds = null; + } + if ($record->allPurchasables = $model->allPurchasables) { + $model->setPurchasableIds([]); + $record->purchasableIds = null; + } + + DB::beginTransaction(); + + try { + $record->save(); + $model->id = $record->id; + + // TODO: update to new date helper once migrated + /** @phpstan-ignore-next-line */ + $model->dateCreated = \CraftCms\Cms\Support\DateTimeHelper::toDateTime($record->dateCreated); + /** @phpstan-ignore-next-line */ + $model->dateUpdated = \CraftCms\Cms\Support\DateTimeHelper::toDateTime($record->dateUpdated); + + DiscountPurchasableRecord::where('discountId', $model->id)->delete(); + DiscountCategoryRecord::where('discountId', $model->id)->delete(); + + // TODO: update getStore()->getSites() when Store/Sites migrated + $siteIds = $model->getStore()->getSites()->pluck('id')->all(); + + foreach ($model->getCategoryIds() as $categoryId) { + $relation = new DiscountCategoryRecord(); + $relation->categoryId = $categoryId; + $relation->discountId = $model->id; + $relation->save(); + } + + foreach ($model->getPurchasableIds() as $purchasableId) { + $relation = new DiscountPurchasableRecord(); + $element = Elements::getElementById($purchasableId, siteId: $siteIds); + $relation->purchasableType = $element::class; + $relation->purchasableId = $purchasableId; + $relation->discountId = $model->id; + $relation->save(); + } + + app(\CraftCms\Commerce\Promotion\Coupons::class)->saveDiscountCoupons($model); + + DB::commit(); + + $this->ensureSortOrder($model->storeId); + + $afterEv = new DiscountEvent(discount: $model, isNew: $isNew); + event($afterEv); + + $this->clearCaches(); + + return true; + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + } + + public function deleteDiscountById(int $id): bool + { + $discountRecord = DiscountRecord::find($id); + + if (!$discountRecord) { + return false; + } + + $discount = $this->getDiscountById($id, $discountRecord->storeId); + $storeId = $discount->storeId; + + $result = (bool) $discountRecord->delete(); + + if ($result) { + $this->ensureSortOrder($storeId); + + $ev = new DiscountEvent(discount: $discount, isNew: false); + event($ev); + } + + $this->clearCaches(); + + return $result; + } + + public function ensureSortOrder(?int $storeId = null): void + { + // TODO: migrate to app(Stores::class)->getCurrentStore()->id once Stores service migrated + $storeId ??= app(Stores::class)->getCurrentStore()->id; + + $table = Table::DISCOUNTS; + $isPgsql = DB::connection()->getDriverName() === 'pgsql'; + + if ($isPgsql) { + DB::statement(" + UPDATE {$table} a + SET sortOrder = b.rownumber + FROM ( + SELECT id, sortOrder, ROW_NUMBER() OVER (ORDER BY sortOrder ASC, id ASC) as rownumber + FROM {$table} + WHERE storeId = {$storeId} + ORDER BY sortOrder ASC, id ASC + ) b + WHERE a.id = b.id + "); + } else { + DB::statement(" + UPDATE {$table} a + JOIN ( + SELECT id, sortOrder, (@ROW_NUMBER := @ROW_NUMBER + 1) as rownumber + FROM {$table}, + (SELECT @ROW_NUMBER := 0) AS X + WHERE storeId = {$storeId} + ORDER BY sortOrder ASC, id ASC + ) b ON a.id = b.id + SET a.sortOrder = b.rownumber + "); + } + + $this->clearCaches(); + } + + public function clearCustomerUsageHistoryById(int $id): void + { + DB::table(Table::CUSTOMER_DISCOUNTUSES)->where('discountId', $id)->delete(); + $this->clearCaches(); + } + + public function clearEmailUsageHistoryById(int $id): void + { + DB::table(Table::EMAIL_DISCOUNTUSES)->where('discountId', $id)->delete(); + $this->clearCaches(); + } + + public function clearDiscountUsesById(int $id): void + { + DB::table(Table::DISCOUNTS)->where('id', $id)->update(['totalDiscountUses' => 0]); + $this->clearCaches(); + } + + public function reorderDiscounts(array $ids): bool + { + foreach ($ids as $sortOrder => $id) { + DB::table(Table::DISCOUNTS)->where('id', $id)->update(['sortOrder' => $sortOrder + 1]); + } + + $this->clearCaches(); + + return true; + } + + public function appendCouponCode(int $discountId, string|Coupon $coupon, ?int $maxUses = null): bool + { + $discount = $this->getDiscountById($discountId); + + if (!$discount) { + throw new \RuntimeException('No discount exists with the ID "' . $discountId . '"'); + } + + if (!$discount->requireCouponCode) { + throw new \RuntimeException('The discount with ID "' . $discountId . '" does not require a coupon code'); + } + + if (is_string($coupon)) { + $couponModel = new Coupon(); + $couponModel->discountId = $discountId; + $couponModel->code = $coupon; + $couponModel->maxUses = $maxUses; + $couponModel->uses = 0; + } else { + $couponModel = $coupon; + $couponModel->discountId = $discountId; + } + + $result = app(\CraftCms\Commerce\Promotion\Coupons::class)->saveCoupon($couponModel); + + if ($result) { + $this->clearCaches(); + } + + return $result; + } + + public function getEmailUsageStatsById(int $id): array + { + return (array) DB::table(Table::EMAIL_DISCOUNTUSES) + ->selectRaw('COALESCE(SUM(uses), 0) as uses, COUNT(email) as emails') + ->where('discountId', $id) + ->first(); + } + + public function getCustomerUsageStatsById(int $id): array + { + return (array) DB::table(Table::CUSTOMER_DISCOUNTUSES) + ->selectRaw('COALESCE(SUM(uses), 0) as uses, COUNT(customerId) as users') + ->where('discountId', $id) + ->first(); + } + + /** + * TODO: update Order type hint when Order element migrated to src/ + * TODO: update LineItem/OrderAdjustment references when migrated + */ + public function orderCompleteHandler(Order $order): void + { + $discountAdjustments = $order->getAdjustmentsByType(DiscountAdjuster::ADJUSTMENT_TYPE); + + if (empty($discountAdjustments)) { + return; + } + + $discounts = []; + foreach ($discountAdjustments as $discountAdjustment) { + $snapshot = $discountAdjustment->sourceSnapshot ?? null; + if (!$snapshot || !isset($snapshot['discountUseId']) || isset($discounts[$snapshot['discountUseId']])) { + continue; + } + $discounts[$snapshot['discountUseId']] = $snapshot; + } + + if (empty($discounts)) { + return; + } + + // TODO: update User/customer references when elements migrated + $user = $order->getCustomer(); + + foreach ($discounts as $discount) { + if ($user && $user->getIsCredentialed()) { + $userDiscountUseRecord = CustomerDiscountUse::where('customerId', $user->id) + ->where('discountId', $discount['discountUseId']) + ->first(); + + if (!$userDiscountUseRecord) { + $userDiscountUseRecord = new CustomerDiscountUse(); + $userDiscountUseRecord->customerId = $user->id; + $userDiscountUseRecord->discountId = $discount['discountUseId']; + $userDiscountUseRecord->uses = 1; + $userDiscountUseRecord->save(); + } else { + DB::table(Table::CUSTOMER_DISCOUNTUSES) + ->where('customerId', $order->getCustomerId()) + ->where('discountId', $discount['discountUseId']) + ->increment('uses'); + } + } + + $emailRecord = EmailDiscountUseRecord::where('email', $order->getEmail()) + ->where('discountId', $discount['discountUseId']) + ->first(); + + if (!$emailRecord) { + $emailRecord = new EmailDiscountUseRecord(); + $emailRecord->email = $order->getEmail(); + $emailRecord->discountId = $discount['discountUseId']; + $emailRecord->uses = 1; + $emailRecord->save(); + } else { + DB::table(Table::EMAIL_DISCOUNTUSES) + ->where('email', $order->getEmail()) + ->where('discountId', $discount['discountUseId']) + ->increment('uses'); + } + + DB::table(Table::DISCOUNTS)->where('id', $discount['discountUseId'])->increment('totalDiscountUses'); + + // Check if the total use limit has been exceeded (race condition / oversell scenario) + if (($discount['totalDiscountUseLimit'] ?? 0) > 0) { + $updatedUses = DB::table(Table::DISCOUNTS) + ->where('id', $discount['discountUseId']) + ->value('totalDiscountUses'); + if ($updatedUses > $discount['totalDiscountUseLimit']) { + $notice = new OrderNotice([ + 'type' => 'discountUsageExceeded', + 'attribute' => 'couponCode', + 'message' => t('The discount "{name}" has exceeded its total usage limit of {limit}.', [ + 'name' => $discount['name'] ?? $discount['discountUseId'], + 'limit' => $discount['totalDiscountUseLimit'], + ], category: 'commerce'), + 'noticeType' => OrderNoticeType::Admin, + ]); + $order->addNotice($notice); + } + } + + if ($order->couponCode) { + $coupon = CouponRecord::where('code', $order->couponCode) + ->where('discountId', $discount['discountUseId']) + ->first(); + if ($coupon) { + DB::table(Table::COUPONS)->where('id', $coupon->id)->increment('uses'); + + // Check if the coupon's max uses has been exceeded + if ($coupon->maxUses !== null && ($coupon->uses + 1) > $coupon->maxUses) { + $notice = new OrderNotice([ + 'type' => 'couponUsageExceeded', + 'attribute' => 'couponCode', + 'message' => t('The coupon "{code}" has exceeded its usage limit of {limit}.', [ + 'code' => $order->couponCode, + 'limit' => $coupon->maxUses, + ], category: 'commerce'), + 'noticeType' => OrderNoticeType::Admin, + ]); + $order->addNotice($notice); + } + } + } + + $this->clearCaches(); + } + } + + private function isDiscountCouponCodeValid(Order $order, Discount $discount): bool + { + if (!$discount->requireCouponCode) { + return true; + } + + $coupons = $discount->getCoupons(); + if (empty($coupons)) { + return false; + } + + foreach ($coupons as $coupon) { + if (strcasecmp((string) $coupon->code, (string) $order->couponCode) === 0 && ($coupon->maxUses === null || $coupon->maxUses > $coupon->uses)) { + return true; + } + } + + return false; + } + + private function isDiscountDateValid(Order $order, Discount $discount): bool + { + $now = new DateTime(); + + if ($order->isCompleted && $order->dateOrdered) { + $now = $order->dateOrdered; + } + + $from = $discount->dateFrom; + $to = $discount->dateTo; + + return !(($from && $from > $now) || ($to && $to < $now)); + } + + private function isDiscountConditionFormulaValid(Order $order, Discount $discount): bool + { + if ($discount->orderConditionFormula) { + $fieldsAsArray = $order->getSerializedFieldValues(); + $orderAsArray = $order->toArray([], ['lineItems.snapshot', 'shippingAddress', 'billingAddress']); + $orderConditionParams = ['order' => array_merge($orderAsArray, $fieldsAsArray)]; + + // TODO: migrate to app(Formulas::class)->evaluateCondition() once Formulas service migrated + return app(Formulas::class)->evaluateCondition($discount->orderConditionFormula, $orderConditionParams, 'Evaluate Order Discount Condition Formula'); + } + + return true; + } + + private function isDiscountTotalUseLimitValid(Discount $discount): bool + { + if ($discount->totalDiscountUseLimit > 0) { + if ($discount->totalDiscountUses >= $discount->totalDiscountUseLimit) { + return false; + } + } + + return true; + } + + private function isDiscountPerUserUsageValid(Discount $discount, ?User $user): bool + { + if ($discount->perUserLimit > 0) { + if (!$user) { + return false; + } + + if (request()->isSiteRequest()) { + $currentUser = currentUserElement(); + $isCustomerCurrentUser = ($currentUser && $currentUser->id == $user->id); + + if (!$isCustomerCurrentUser) { + return false; + } + } + + $usage = DB::table(Table::CUSTOMER_DISCOUNTUSES) + ->where('customerId', $user->id) + ->where('discountId', $discount->id) + ->value('uses'); + + if ($usage && $usage >= $discount->perUserLimit) { + return false; + } + } + + return true; + } + + private function isDiscountEmailRequirementValid(Discount $discount, Order $order): bool + { + if ($discount->perEmailLimit > 0 && !$order->getEmail()) { + return false; + } + + return true; + } + + private function isDiscountPerEmailLimitValid(Discount $discount, Order $order): bool + { + if ($discount->perEmailLimit > 0 && $order->getEmail()) { + $usage = DB::table(Table::EMAIL_DISCOUNTUSES) + ->where('email', $order->getEmail()) + ->where('discountId', $discount->id) + ->value('uses'); + + if ($usage && $usage >= $discount->perEmailLimit) { + return false; + } + } + + return true; + } + + private function clearCaches(): void + { + $this->allDiscounts = null; + $this->activeDiscountsByKey = null; + $this->matchingLineItemCategoryCondition = null; + } + + /** + * @param object[] $rows + * @return Discount[] + */ + private function populateDiscounts(array $rows): array + { + $discounts = []; + foreach ($rows as $row) { + $data = (array) $row; + $data['purchasableIds'] = !empty($data['purchasableIds']) ? json_decode($data['purchasableIds'], true) : []; + $data['categoryIds'] = !empty($data['categoryIds']) ? json_decode($data['categoryIds'], true) : []; + $data['orderCondition'] ??= ''; + $data['customerCondition'] ??= ''; + $data['billingAddressCondition'] ??= ''; + $data['shippingAddressCondition'] ??= ''; + + $discounts[] = new Discount($data); + } + + return $discounts; + } + + private function query(): \Illuminate\Database\Query\Builder + { + return DB::table(Table::DISCOUNTS . ' as discounts') + ->select([ + 'discounts.allCategories', + 'discounts.allPurchasables', + 'discounts.appliedTo', + 'discounts.baseDiscount', + 'discounts.categoryRelationshipType', + 'discounts.couponFormat', + 'discounts.dateCreated', + 'discounts.dateFrom', + 'discounts.dateTo', + 'discounts.dateUpdated', + 'discounts.description', + 'discounts.enabled', + 'discounts.excludeOnPromotion', + 'discounts.hasFreeShippingForMatchingItems', + 'discounts.hasFreeShippingForOrder', + 'discounts.id', + 'discounts.ignorePromotions', + 'discounts.maxPurchaseQty', + 'discounts.name', + 'discounts.orderCondition', + 'discounts.orderConditionFormula', + 'discounts.percentageOffSubject', + 'discounts.percentDiscount', + 'discounts.perEmailLimit', + 'discounts.perItemDiscount', + 'discounts.perUserLimit', + 'discounts.purchaseTotal', + 'discounts.purchaseQty', + 'discounts.requireCouponCode', + 'discounts.sortOrder', + 'discounts.stopProcessing', + 'discounts.storeId', + 'discounts.totalDiscountUseLimit', + 'discounts.totalDiscountUses', + 'discounts.customerCondition', + 'discounts.shippingAddressCondition', + 'discounts.billingAddressCondition', + 'discounts.purchasableIds', + 'discounts.categoryIds', + ]) + ->leftJoin(Table::DISCOUNT_PURCHASABLES . ' as dp', 'dp.discountId', '=', 'discounts.id') + ->leftJoin(Table::DISCOUNT_CATEGORIES . ' as dpt', 'dpt.discountId', '=', 'discounts.id') + ->groupBy('discounts.id') + ->orderBy('discounts.sortOrder'); + } +} diff --git a/src/Promotion/Events/DiscountAdjustmentsEvent.php b/src/Promotion/Events/DiscountAdjustmentsEvent.php new file mode 100644 index 0000000000..bb1d7d77e8 --- /dev/null +++ b/src/Promotion/Events/DiscountAdjustmentsEvent.php @@ -0,0 +1,21 @@ + ['required', 'string'], + ]; + } +} diff --git a/src/Promotion/Models/Discount.php b/src/Promotion/Models/Discount.php new file mode 100644 index 0000000000..f21513de1c --- /dev/null +++ b/src/Promotion/Models/Discount.php @@ -0,0 +1,418 @@ +getStore()->getStoreSettingsUrl('discounts/' . $this->id); + } + + public function getOrderCondition(): ElementConditionInterface + { + /** @var DiscountOrderCondition $condition */ + $condition = $this->_orderCondition ?? new DiscountOrderCondition(); + $condition->mainTag = 'div'; + $condition->name = 'orderCondition'; + $condition->storeId = $this->storeId; + + return $condition; + } + + public function hasOrderCondition(): bool + { + if ($this->_orderCondition === null) { + return false; + } + + return !empty($this->getOrderCondition()->getConditionRules()); + } + + public function setOrderCondition(ElementConditionInterface|string|array|null $condition): void + { + if (empty($condition)) { + $this->_orderCondition = null; + return; + } + + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof ElementConditionInterface) { + $condition['class'] = DiscountOrderCondition::class; + /** @var DiscountOrderCondition $condition */ + $condition = Conditions::createCondition($condition); + } + $condition->forProjectConfig = false; + + $this->_orderCondition = $condition; + } + + public function getCustomerCondition(): ElementConditionInterface + { + $condition = $this->_customerCondition ?? new DiscountCustomerCondition(); + $condition->mainTag = 'div'; + $condition->name = 'customerCondition'; + + return $condition; + } + + public function hasCustomerCondition(): bool + { + if ($this->_customerCondition === null) { + return false; + } + + return !empty($this->getCustomerCondition()->getConditionRules()); + } + + public function setCustomerCondition(ElementConditionInterface|string|array|null $condition): void + { + if (empty($condition)) { + $this->_customerCondition = null; + return; + } + + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof ElementConditionInterface) { + $condition['class'] = DiscountCustomerCondition::class; + /** @var DiscountCustomerCondition $condition */ + $condition = Conditions::createCondition($condition); + } + $condition->forProjectConfig = false; + + $this->_customerCondition = $condition; + } + + public function getShippingAddressCondition(): ElementConditionInterface + { + $condition = $this->_shippingAddressCondition ?? new DiscountAddressCondition(); + $condition->mainTag = 'div'; + $condition->id = 'shippingAddressCondition'; + $condition->name = 'shippingAddressCondition'; + + return $condition; + } + + public function hasShippingAddressCondition(): bool + { + if ($this->_shippingAddressCondition === null) { + return false; + } + + return !empty($this->getShippingAddressCondition()->getConditionRules()); + } + + public function setShippingAddressCondition(ElementConditionInterface|string|array|null $condition): void + { + if (empty($condition)) { + $this->_shippingAddressCondition = null; + return; + } + + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof ElementConditionInterface) { + $condition['class'] = DiscountAddressCondition::class; + /** @var DiscountAddressCondition $condition */ + $condition = Conditions::createCondition($condition); + } + $condition->forProjectConfig = false; + + $this->_shippingAddressCondition = $condition; + } + + public function getBillingAddressCondition(): ElementConditionInterface + { + $condition = $this->_billingAddressCondition ?? new DiscountAddressCondition(); + $condition->mainTag = 'div'; + $condition->id = 'billingAddressCondition'; + $condition->name = 'billingAddressCondition'; + + return $condition; + } + + public function hasBillingAddressCondition(): bool + { + if ($this->_billingAddressCondition === null) { + return false; + } + + return !empty($this->getBillingAddressCondition()->getConditionRules()); + } + + public function setBillingAddressCondition(ElementConditionInterface|string|array|null $condition): void + { + if (empty($condition)) { + $this->_billingAddressCondition = null; + return; + } + + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof ElementConditionInterface) { + $condition['class'] = DiscountAddressCondition::class; + /** @var DiscountAddressCondition $condition */ + $condition = Conditions::createCondition($condition); + } + $condition->forProjectConfig = false; + + $this->_billingAddressCondition = $condition; + } + + /** + * @return int[] + */ + public function getCategoryIds(): array + { + if (!isset($this->_categoryIds)) { + $this->_loadCategoryRelations(); + } + + return $this->_categoryIds; + } + + /** + * @return int[] + */ + public function getPurchasableIds(): array + { + if (!isset($this->_purchasableIds)) { + $this->_loadPurchasableRelations(); + } + + return $this->_purchasableIds; + } + + /** + * @param int[] $categoryIds + */ + public function setCategoryIds(array $categoryIds): void + { + $this->_categoryIds = array_unique($categoryIds); + } + + /** + * @param int[] $purchasableIds + */ + public function setPurchasableIds(array $purchasableIds): void + { + $this->_purchasableIds = array_unique($purchasableIds); + } + + public function setHasFreeShippingForMatchingItems(bool $value): void + { + $this->hasFreeShippingForMatchingItems = $value; + } + + public function getHasFreeShippingForMatchingItems(): bool + { + return $this->hasFreeShippingForMatchingItems; + } + + /** + * @return Coupon[] + */ + public function getCoupons(): array + { + if ($this->_coupons === null && $this->id) { + $this->_coupons = app(Coupons::class)->getCouponsByDiscountId($this->id); + } + + return $this->_coupons ?? []; + } + + /** + * @param Coupon[] $coupons + */ + public function setCoupons(array $coupons): void + { + $this->_coupons = $coupons; + } + + public function getPercentDiscountAsPercent(): string + { + return I18N::getFormatter()->asPercent(-$this->percentDiscount); + } + + #[\Override] + public function getRules(): array + { + return [ + 'name' => ['required', 'string'], + 'couponFormat' => ['required', 'string', 'min:1', 'max:20'], + 'perUserLimit' => ['numeric'], + 'perEmailLimit' => ['numeric'], + 'totalDiscountUseLimit' => ['numeric'], + 'totalDiscountUses' => ['numeric'], + 'purchaseQty' => ['numeric'], + 'maxPurchaseQty' => ['numeric'], + 'baseDiscount' => ['numeric'], + 'perItemDiscount' => ['numeric'], + 'percentDiscount' => ['numeric'], + 'categoryRelationshipType' => [Rule::in([ + DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_SOURCE, + DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_TARGET, + DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH, + ])], + 'appliedTo' => [Rule::in([ + DiscountRecord::APPLIED_TO_MATCHING_LINE_ITEMS, + DiscountRecord::APPLIED_TO_ALL_LINE_ITEMS, + ])], + 'hasFreeShippingForOrder' => [ + function($attribute, $value, \Closure $fail) { + if ($this->hasFreeShippingForMatchingItems && $this->hasFreeShippingForOrder) { + $fail(t('Free shipping can only be for whole order or matching items, not both.', category: 'commerce')); + } + }, + ], + 'orderConditionFormula' => [ + 'nullable', + 'string', + 'max:65000', + function($attribute, $value, \Closure $fail) { + if (!$value) { + return; + } + /** @var Order $order */ + $order = Order::find()->one() ?? new Order(); + + $fieldsAsArray = $order->getSerializedFieldValues(); + $orderAsArray = $order->toArray([], ['lineItems.snapshot', 'shippingAddress', 'billingAddress']); + $orderConditionParams = [ + 'order' => array_merge($orderAsArray, $fieldsAsArray), + ]; + + if (!app(Formulas::class)->validateConditionSyntax($value, $orderConditionParams)) { + $fail(t('Invalid order condition syntax.', category: 'commerce')); + } + }, + ], + ]; + } + + private function _loadPurchasableRelations(): void + { + $purchasableIds = DB::table(Table::DISCOUNTS . ' as discounts') + ->leftJoin(Table::DISCOUNT_PURCHASABLES . ' as dp', 'dp.discountId', '=', 'discounts.id') + ->where('discounts.id', $this->id) + ->pluck('dp.purchasableId') + ->all(); + + $this->setPurchasableIds($purchasableIds); + } + + private function _loadCategoryRelations(): void + { + $categoryIds = DB::table(Table::DISCOUNTS . ' as discounts') + ->leftJoin(Table::DISCOUNT_CATEGORIES . ' as dpt', 'dpt.discountId', '=', 'discounts.id') + ->where('discounts.id', $this->id) + ->pluck('dpt.categoryId') + ->all(); + + $this->setCategoryIds($categoryIds); + } +} diff --git a/src/Promotion/Models/Sale.php b/src/Promotion/Models/Sale.php new file mode 100644 index 0000000000..39411d1ef8 --- /dev/null +++ b/src/Promotion/Models/Sale.php @@ -0,0 +1,162 @@ + ['required', 'in:toPercent,toFlat,byPercent,byFlat'], + 'categoryRelationshipType' => ['required', 'in:' . implode(',', [ + SaleRecord::CATEGORY_RELATIONSHIP_TYPE_SOURCE, + SaleRecord::CATEGORY_RELATIONSHIP_TYPE_TARGET, + SaleRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH, + ])], + 'enabled' => ['boolean'], + 'name' => ['required', 'string'], + 'allGroups' => ['required', 'boolean'], + 'allPurchasables' => ['required', 'boolean'], + 'allCategories' => ['required', 'boolean'], + ]; + } + + public function getCpEditUrl(): string + { + $store = app(Stores::class)->getPrimaryStore(); + return $store->getStoreSettingsUrl('sales/' . $this->id); + } + + public function getApplyAmountAsPercent(): string + { + return I18N::getFormatter()->asPercent(-($this->applyAmount ?? 0.0)); + } + + public function getApplyAmountAsFlat(): string + { + return $this->applyAmount !== null ? (string)($this->applyAmount * -1) : '0'; + } + + public function getCategoryIds(): array + { + if (!isset($this->_categoryIds)) { + $categoryIds = []; + if ($this->id) { + $categoryIds = array_filter( + DB::table(Table::SALES . ' sales') + ->leftJoin(Table::SALE_CATEGORIES . ' spt', 'spt.saleId', '=', 'sales.id') + ->where('sales.id', $this->id) + ->pluck('spt.categoryId') + ->all() + ); + } + $this->_categoryIds = $categoryIds; + } + + return $this->_categoryIds; + } + + public function getPurchasableIds(): array + { + if (!isset($this->_purchasableIds)) { + $purchasableIds = []; + if ($this->id) { + $purchasableIds = array_filter( + DB::table(Table::SALES . ' sales') + ->leftJoin(Table::SALE_PURCHASABLES . ' sp', 'sp.saleId', '=', 'sales.id') + ->where('sales.id', $this->id) + ->pluck('sp.purchasableId') + ->all() + ); + } + $this->_purchasableIds = $purchasableIds; + } + + return $this->_purchasableIds; + } + + public function getUserGroupIds(): array + { + if (!isset($this->_userGroupIds)) { + $userGroupIds = []; + if ($this->id) { + $userGroupIds = array_filter( + DB::table(Table::SALES . ' sales') + ->leftJoin(Table::SALE_USERGROUPS . ' sug', 'sug.saleId', '=', 'sales.id') + ->where('sales.id', $this->id) + ->pluck('sug.userGroupId') + ->all() + ); + } + $this->_userGroupIds = $userGroupIds; + } + + return $this->_userGroupIds; + } + + public function setCategoryIds(array $ids): void + { + $this->_categoryIds = array_unique($ids); + } + + public function setPurchasableIds(array $purchasableIds): void + { + $this->_purchasableIds = array_unique($purchasableIds); + } + + public function setUserGroupIds(array $userGroupIds): void + { + $this->_userGroupIds = array_unique($userGroupIds); + } +} diff --git a/src/Promotion/Records/Coupon.php b/src/Promotion/Records/Coupon.php new file mode 100644 index 0000000000..4ef0a07f14 --- /dev/null +++ b/src/Promotion/Records/Coupon.php @@ -0,0 +1,29 @@ + 'integer', + 'uses' => 'integer', + 'maxUses' => 'integer', + ]; +} diff --git a/src/Promotion/Records/CustomerDiscountUse.php b/src/Promotion/Records/CustomerDiscountUse.php new file mode 100644 index 0000000000..186034e226 --- /dev/null +++ b/src/Promotion/Records/CustomerDiscountUse.php @@ -0,0 +1,27 @@ + 'integer', + 'discountId' => 'integer', + 'uses' => 'integer', + ]; +} diff --git a/src/Promotion/Records/Discount.php b/src/Promotion/Records/Discount.php new file mode 100644 index 0000000000..3d383ed3ad --- /dev/null +++ b/src/Promotion/Records/Discount.php @@ -0,0 +1,69 @@ + 'integer', + 'allCategories' => 'boolean', + 'allPurchasables' => 'boolean', + 'categoryIds' => 'array', + 'purchasableIds' => 'array', + 'baseDiscount' => 'float', + 'purchaseTotal' => 'float', + 'dateFrom' => 'datetime', + 'dateTo' => 'datetime', + 'enabled' => 'boolean', + 'excludeOnPromotion' => 'boolean', + 'hasFreeShippingForMatchingItems' => 'boolean', + 'hasFreeShippingForOrder' => 'boolean', + 'maxPurchaseQty' => 'integer', + 'percentDiscount' => 'float', + 'perEmailLimit' => 'integer', + 'perItemDiscount' => 'float', + 'perUserLimit' => 'integer', + 'purchaseQty' => 'integer', + 'orderCondition' => 'array', + 'customerCondition' => 'array', + 'shippingAddressCondition' => 'array', + 'billingAddressCondition' => 'array', + 'requireCouponCode' => 'boolean', + 'sortOrder' => 'integer', + 'stopProcessing' => 'boolean', + 'ignorePromotions' => 'boolean', + 'totalDiscountUseLimit' => 'integer', + 'totalDiscountUses' => 'integer', + ]; +} diff --git a/src/Promotion/Records/DiscountCategory.php b/src/Promotion/Records/DiscountCategory.php new file mode 100644 index 0000000000..ad156b8a96 --- /dev/null +++ b/src/Promotion/Records/DiscountCategory.php @@ -0,0 +1,27 @@ + 'integer', + 'categoryId' => 'integer', + ]; +} diff --git a/src/Promotion/Records/DiscountPurchasable.php b/src/Promotion/Records/DiscountPurchasable.php new file mode 100644 index 0000000000..bb0a15ed5a --- /dev/null +++ b/src/Promotion/Records/DiscountPurchasable.php @@ -0,0 +1,27 @@ + 'integer', + 'purchasableId' => 'integer', + ]; +} diff --git a/src/Promotion/Records/EmailDiscountUse.php b/src/Promotion/Records/EmailDiscountUse.php new file mode 100644 index 0000000000..d611845adc --- /dev/null +++ b/src/Promotion/Records/EmailDiscountUse.php @@ -0,0 +1,26 @@ + 'integer', + 'uses' => 'integer', + ]; +} diff --git a/src/Promotion/Records/Sale.php b/src/Promotion/Records/Sale.php new file mode 100644 index 0000000000..96d6c6a528 --- /dev/null +++ b/src/Promotion/Records/Sale.php @@ -0,0 +1,50 @@ + 'boolean', + 'allGroups' => 'boolean', + 'allPurchasables' => 'boolean', + 'dateFrom' => 'datetime', + 'dateTo' => 'datetime', + 'applyAmount' => 'float', + 'ignorePrevious' => 'boolean', + 'stopProcessing' => 'boolean', + 'enabled' => 'boolean', + 'sortOrder' => 'integer', + ]; +} diff --git a/src/Promotion/Records/SaleCategory.php b/src/Promotion/Records/SaleCategory.php new file mode 100644 index 0000000000..759f9b5164 --- /dev/null +++ b/src/Promotion/Records/SaleCategory.php @@ -0,0 +1,26 @@ + 'integer', + 'categoryId' => 'integer', + ]; +} diff --git a/src/Promotion/Records/SalePurchasable.php b/src/Promotion/Records/SalePurchasable.php new file mode 100644 index 0000000000..42e6d33c6d --- /dev/null +++ b/src/Promotion/Records/SalePurchasable.php @@ -0,0 +1,26 @@ + 'integer', + 'purchasableId' => 'integer', + ]; +} diff --git a/src/Promotion/Records/SaleUserGroup.php b/src/Promotion/Records/SaleUserGroup.php new file mode 100644 index 0000000000..e7545f0d70 --- /dev/null +++ b/src/Promotion/Records/SaleUserGroup.php @@ -0,0 +1,26 @@ + 'integer', + 'userGroupId' => 'integer', + ]; +} diff --git a/src/Promotion/Sales.php b/src/Promotion/Sales.php new file mode 100644 index 0000000000..2f0994a319 --- /dev/null +++ b/src/Promotion/Sales.php @@ -0,0 +1,499 @@ +> */ + private array $purchasableSaleMatch = []; + + public function canUseSales(): bool + { + // TODO: migrate to app(Stores::class)->getAllStores() once Stores service migrated + $singleStore = app(Stores::class)->getAllStores()->count() === 1; + $noCatalogPricingRules = app(\CraftCms\Commerce\CatalogPricing\CatalogPricingRules::class)->getAllCatalogPricingRules()->isEmpty(); + + return $singleStore && $noCatalogPricingRules; + } + + public function getSaleById(int $id): ?Sale + { + foreach ($this->getAllSales() as $sale) { + if ($sale->id == $id) { + return $sale; + } + } + + return null; + } + + /** + * @return Sale[] + */ + public function getAllSales(): array + { + if ($this->allSales !== null) { + return $this->allSales; + } + + $rows = DB::table(Table::SALES . ' as sales') + ->select([ + 'sales.id', + 'sales.name', + 'sales.description', + 'sales.dateFrom', + 'sales.dateTo', + 'sales.apply', + 'sales.applyAmount', + 'sales.stopProcessing', + 'sales.ignorePrevious', + 'sales.allGroups', + 'sales.allPurchasables', + 'sales.allCategories', + 'sales.sortOrder', + 'sales.categoryRelationshipType', + 'sales.enabled', + 'sales.dateCreated', + 'sales.dateUpdated', + 'sp.purchasableId', + 'spt.categoryId', + 'sug.userGroupId', + ]) + ->leftJoin(Table::SALE_PURCHASABLES . ' as sp', 'sp.saleId', '=', 'sales.id') + ->leftJoin(Table::SALE_CATEGORIES . ' as spt', 'spt.saleId', '=', 'sales.id') + ->leftJoin(Table::SALE_USERGROUPS . ' as sug', 'sug.saleId', '=', 'sales.id') + ->orderBy('sales.sortOrder') + ->get() + ->all(); + + $allSalesById = []; + $purchasables = []; + $categories = []; + $groups = []; + + foreach ($rows as $row) { + $row = (array) $row; + $id = $row['id']; + + if ($row['purchasableId']) { + $purchasables[$id][] = $row['purchasableId']; + } + if ($row['categoryId']) { + $categories[$id][] = $row['categoryId']; + } + if ($row['userGroupId']) { + $groups[$id][] = $row['userGroupId']; + } + + unset($row['purchasableId'], $row['userGroupId'], $row['categoryId']); + + if (!isset($allSalesById[$id])) { + $allSalesById[$id] = new Sale($row); + } + } + + foreach ($allSalesById as $id => $sale) { + $sale->setPurchasableIds($purchasables[$id] ?? []); + $sale->setCategoryIds($categories[$id] ?? []); + $sale->setUserGroupIds($groups[$id] ?? []); + } + + $this->allSales = $allSalesById; + + return $this->allSales; + } + + /** + * Returns sales that match the purchasable. + * + * TODO: update Order type hint when Order element migrated to src/ + * + * @return Sale[] + */ + public function getSalesForPurchasable(PurchasableInterface $purchasable, mixed $order = null): array + { + $matchedSales = []; + + foreach ($this->getAllEnabledSales() as $sale) { + if ($this->matchPurchasableAndSale($purchasable, $sale, $order)) { + $matchedSales[] = $sale; + + if ($sale->stopProcessing) { + break; + } + } + } + + return $matchedSales; + } + + /** + * TODO: update PurchasableInterface type hint when fully migrated + * + * @return Sale[] + */ + public function getSalesRelatedToPurchasable(PurchasableInterface $purchasable): array + { + $sales = []; + $id = $purchasable->getId(); + + if ($id) { + foreach ($this->getAllSales() as $sale) { + $purchasableIds = $sale->getPurchasableIds(); + + $relatedTo = [$sale->categoryRelationshipType => $purchasable->getPromotionRelationSource()]; + $saleCategories = $sale->getCategoryIds(); + + // TODO: update Category/Entry element calls when migrated + $relatedCategories = Category::find()->id($saleCategories)->relatedTo($relatedTo)->siteId($purchasable->siteId)->ids(); + $relatedEntries = Entry::find()->id($saleCategories)->relatedTo($relatedTo)->siteId($purchasable->siteId)->ids(); + $relatedCategoriesOrEntries = array_merge($relatedCategories, $relatedEntries); + + if (in_array($id, $purchasableIds, false) || !empty($relatedCategoriesOrEntries)) { + $sales[] = $sale; + } + } + } + + return $sales; + } + + /** + * Returns the sale price of the purchasable based on all matched sales. + * + * TODO: update Order type hint when Order element migrated to src/ + */ + public function getSalePriceForPurchasable(PurchasableInterface $purchasable, mixed $order = null): float + { + $sales = $this->getSalesForPurchasable($purchasable, $order); + $originalPrice = $purchasable->getPrice(); + + $takeOffAmount = 0; + $newPrice = null; + + foreach ($sales as $sale) { + switch ($sale->apply) { + case SaleRecord::APPLY_BY_PERCENT: + $takeOffAmount += ($sale->applyAmount * $originalPrice); + if ($sale->ignorePrevious) { + $newPrice = $originalPrice + ($sale->applyAmount * $originalPrice); + } + break; + case SaleRecord::APPLY_TO_PERCENT: + $newPrice = (-$sale->applyAmount * $originalPrice); + break; + case SaleRecord::APPLY_BY_FLAT: + $takeOffAmount += $sale->applyAmount; + if ($sale->ignorePrevious) { + $newPrice = $originalPrice + $sale->applyAmount; + } + break; + case SaleRecord::APPLY_TO_FLAT: + $newPrice = -$sale->applyAmount; + break; + } + + if ($sale->stopProcessing) { + break; + } + } + + $salePrice = $originalPrice + $takeOffAmount; + + if ($newPrice !== null) { + $salePrice = $newPrice; + } + + if ($salePrice < 0) { + $salePrice = 0; + } + + // TODO: migrate to app(Currency::class)->round() once Currency service migrated + return Currency::round($salePrice); + } + + /** + * Match a purchasable and sale and return the result. + * + * TODO: update Order type hint when Order element migrated to src/ + */ + public function matchPurchasableAndSale(PurchasableInterface $purchasable, Sale $sale, mixed $order = null): bool + { + $purchasableId = $purchasable->getId(); + $saleId = $sale->id; + + $this->purchasableSaleMatch[$purchasableId] ??= []; + $this->purchasableSaleMatch[$purchasableId][$saleId] ??= null; + + if (!$order && $this->purchasableSaleMatch[$purchasableId][$saleId] !== null) { + return $this->purchasableSaleMatch[$purchasableId][$saleId]; + } + + $this->purchasableSaleMatch[$purchasableId][$saleId] = false; + + if (!$purchasable->getIsPromotable()) { + return false; + } + + if (!$sale->allPurchasables && !in_array($purchasable->getId(), $sale->getPurchasableIds(), false)) { + return false; + } + + $date = new DateTime(); + + if ($order) { + // TODO: update isCompleted/dateOrdered when Order is migrated + $date = $order->isCompleted ? $order->dateOrdered : $date; + } + + if ($sale->dateFrom && $sale->dateFrom >= $date) { + return false; + } + + if ($sale->dateTo && $sale->dateTo <= $date) { + return false; + } + + if ($order) { + // TODO: update getCustomer() when Order/User is migrated + $user = $order->getCustomer(); + + if (!$sale->allGroups) { + if (null === $user) { + return false; + } + // TODO: update getGroups() when User element is migrated + $userGroups = array_column($user->getGroups(), 'id'); + if (!$userGroups || !array_intersect($userGroups, $sale->getUserGroupIds())) { + return false; + } + } + } + + if (!$order && !$sale->allGroups) { + $userGroups = null; + if ($currentUser = currentUserElement()) { + $userGroups = array_column($currentUser->getGroups(), 'id'); + } + + if (!$userGroups || !array_intersect($userGroups, $sale->getUserGroupIds())) { + return false; + } + } + + if (!$sale->allCategories) { + $relatedTo = [$sale->categoryRelationshipType => $purchasable->getPromotionRelationSource()]; + $saleCategories = $sale->getCategoryIds(); + + // TODO: update Category/Entry element calls when migrated + $relatedCategories = Category::find()->id($saleCategories)->relatedTo($relatedTo)->siteId($purchasable->siteId)->ids(); + $relatedEntries = Entry::find()->id($saleCategories)->relatedTo($relatedTo)->siteId($purchasable->siteId)->ids(); + $relatedCategoriesOrEntries = array_merge($relatedCategories, $relatedEntries); + + if (empty($relatedCategoriesOrEntries)) { + return false; + } + } + + $event = new SaleMatchEvent(sale: $sale, purchasable: $purchasable, isNew: false); + event($event); + + if ($order) { + unset($this->purchasableSaleMatch[$purchasableId][$saleId]); + return $event->isValid; + } + + $this->purchasableSaleMatch[$purchasableId][$saleId] = $event->isValid; + + return $event->isValid; + } + + public function saveSale(Sale $model, bool $runValidation = true): bool + { + $isNew = !$model->id; + + if ($isNew) { + $record = new SaleRecord(); + } else { + $record = SaleRecord::find($model->id); + + if (!$record) { + throw new \RuntimeException(t('No sale exists with the ID "{id}"', ['id' => $model->id], category: 'commerce')); + } + } + + if ($runValidation && !$model->validate()) { + Log::info('Sale not saved due to validation error.'); + return false; + } + + $beforeEv = new SaleEvent(sale: $model, isNew: $isNew); + event($beforeEv); + + $record->name = $model->name; + $record->description = $model->description; + $record->dateFrom = $model->dateFrom ? Carbon::instance($model->dateFrom) : null; + $record->dateTo = $model->dateTo ? Carbon::instance($model->dateTo) : null; + $record->apply = $model->apply; + $record->applyAmount = $model->applyAmount; + $record->stopProcessing = $model->stopProcessing; + $record->ignorePrevious = $model->ignorePrevious; + $record->categoryRelationshipType = $model->categoryRelationshipType; + $record->enabled = $model->enabled; + + if ($record->allGroups = $model->allGroups) { + $model->setUserGroupIds([]); + } + if ($record->allCategories = $model->allCategories) { + $model->setCategoryIds([]); + } + if ($record->allPurchasables = $model->allPurchasables) { + $model->setPurchasableIds([]); + } + + if (!$isNew) { + // TODO: update to new date helper once migrated + /** @phpstan-ignore-next-line */ + $model->dateCreated = \CraftCms\Cms\Support\DateTimeHelper::toDateTime($record->dateCreated); + /** @phpstan-ignore-next-line */ + $model->dateUpdated = \CraftCms\Cms\Support\DateTimeHelper::toDateTime($record->dateUpdated); + } + + DB::beginTransaction(); + + try { + $record->save(); + $model->id = $record->id; + + // TODO: update to new date helper once migrated + /** @phpstan-ignore-next-line */ + $model->dateCreated = \CraftCms\Cms\Support\DateTimeHelper::toDateTime($record->dateCreated); + /** @phpstan-ignore-next-line */ + $model->dateUpdated = \CraftCms\Cms\Support\DateTimeHelper::toDateTime($record->dateUpdated); + + SaleUserGroupRecord::where('saleId', $model->id)->delete(); + SalePurchasableRecord::where('saleId', $model->id)->delete(); + SaleCategoryRecord::where('saleId', $model->id)->delete(); + + foreach ($model->getUserGroupIds() as $groupId) { + $relation = new SaleUserGroupRecord(); + $relation->userGroupId = $groupId; + $relation->saleId = $model->id; + $relation->save(); + } + + foreach ($model->getCategoryIds() as $categoryId) { + $relation = new SaleCategoryRecord(); + $relation->categoryId = $categoryId; + $relation->saleId = $model->id; + $relation->save(); + } + + foreach ($model->getPurchasableIds() as $purchasableId) { + $relation = new SalePurchasableRecord(); + $relation->purchasableId = $purchasableId; + $purchasable = Elements::getElementById($purchasableId, null, null, ['trashed' => null]); + $relation->purchasableType = $purchasable::class; + $relation->saleId = $model->id; + $relation->save(); + + ElementCaches::invalidateForElement($purchasable); + } + + DB::commit(); + + $this->clearCaches(); + + $afterEv = new SaleEvent(sale: $model, isNew: $isNew); + event($afterEv); + + return true; + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + } + + public function reorderSales(array $ids): bool + { + foreach ($ids as $sortOrder => $id) { + DB::table(Table::SALES)->where('id', $id)->update(['sortOrder' => $sortOrder + 1]); + } + + $this->clearCaches(); + + return true; + } + + public function deleteSaleById(int $id): bool + { + $record = SaleRecord::find($id); + + if (!$record) { + return false; + } + + $sale = $this->getSaleById($id); + + $this->clearCaches(); + + $result = (bool) $record->delete(); + + if ($result) { + $ev = new SaleEvent(sale: $sale, isNew: false); + event($ev); + } + + return $result; + } + + private function getAllEnabledSales(): array + { + if ($this->allActiveSales !== null) { + return $this->allActiveSales; + } + + $this->allActiveSales = array_filter($this->getAllSales(), fn(Sale $s) => $s->enabled); + + return $this->allActiveSales; + } + + private function clearCaches(): void + { + $this->allSales = null; + $this->allActiveSales = null; + $this->purchasableSaleMatch = []; + } +} diff --git a/src/Purchasable/Conditions/CatalogPricingRulePurchasableCategoryConditionRule.php b/src/Purchasable/Conditions/CatalogPricingRulePurchasableCategoryConditionRule.php new file mode 100644 index 0000000000..c2db0ca2e1 --- /dev/null +++ b/src/Purchasable/Conditions/CatalogPricingRulePurchasableCategoryConditionRule.php @@ -0,0 +1,134 @@ +|null */ + public ?array $elementIds = null; + + public function getLabel(): string + { + return t('Purchasable Categories', category: 'commerce'); + } + + public function getConfig(): array + { + return array_merge(parent::getConfig(), [ + 'elementIds' => $this->elementIds, + 'categoryRelationshipType' => $this->categoryRelationshipType, + ]); + } + + public function getRules(): array + { + return array_merge(parent::getRules(), [ + 'elementIds' => ['nullable'], + 'categoryRelationshipType' => ['nullable', 'string'], + ]); + } + + protected function inputHtml(): string + { + $id = 'cpr-purchasable-category'; + + $elements = !empty($this->elementIds) ? Category::find()->id($this->elementIds)->all() : []; + + return Html::hiddenLabel($this->getLabel(), $id) . + Html::tag('div', + Html::tag('div', + Cp::elementSelectHtml([ + 'name' => 'elementIds', + 'elements' => $elements, + 'elementType' => Category::class, + 'sources' => null, + 'criteria' => null, + 'single' => false, + ]) + ), + [ + 'class' => ['flex', 'flex-start'], + ] + ) . + Html::tag('div', + Html::a(t('Advanced'), null, [ + 'class' => array_filter(['fieldtoggle', $this->categoryRelationshipType !== self::CATEGORY_RELATIONSHIP_TYPE_BOTH ? 'expanded' : '']), + 'data-target' => 'category-relationship-type-advanced', + ]) . + Html::tag('div', + Cp::selectHtml([ + 'id' => 'categoryRelationshipType', + 'name' => 'categoryRelationshipType', + 'label' => t('Categories Relationship Type', category: 'commerce'), + 'instructions' => t('How the Purchasables and Categories are related, which determines the matching items. See [Relations Terminology]({link}).', ['link' => 'https://craftcms.com/docs/4.x/relations.html#terminology'], category: 'commerce'), + 'options' => [ + self::CATEGORY_RELATIONSHIP_TYPE_SOURCE => t('Source - The purchasable relationship field is on the category', category: 'commerce'), + self::CATEGORY_RELATIONSHIP_TYPE_TARGET => t('Target - The category relationship field is on the purchasable', category: 'commerce'), + self::CATEGORY_RELATIONSHIP_TYPE_BOTH => t('Either (Default) - The relationship field is on the purchasable or the category', category: 'commerce'), + ], + 'value' => $this->categoryRelationshipType, + ]), + [ + 'class' => $this->categoryRelationshipType === self::CATEGORY_RELATIONSHIP_TYPE_BOTH ? 'hidden' : '', + 'id' => 'category-relationship-type-advanced', + ] + ), + ['style' => ['width' => '100%']] + ); + } + + public function getExclusiveQueryParams(): array + { + return []; + } + + public function modifyQuery(ElementQueryInterface $query): void + { + if ($this->elementIds === null) { + return; + } + + $query->andRelatedTo([$this->categoryRelationshipType => $this->elementIds]); + } + + public function matchElement(ElementInterface $element): bool + { + if ($this->elementIds === null) { + return true; + } + + return Purchasable::find() + ->id($element->id ?: false) + ->site('*') + ->drafts($element->getIsDraft()) + ->provisionalDrafts($element->isProvisionalDraft) + ->revisions($element->getIsRevision()) + ->status(null) + ->relatedTo([$this->categoryRelationshipType => $this->elementIds]) + ->exists(); + } +} diff --git a/src/Purchasable/Conditions/CatalogPricingRulePurchasableCondition.php b/src/Purchasable/Conditions/CatalogPricingRulePurchasableCondition.php new file mode 100644 index 0000000000..7f058863cf --- /dev/null +++ b/src/Purchasable/Conditions/CatalogPricingRulePurchasableCondition.php @@ -0,0 +1,27 @@ + !in_array($type, [ + SiteConditionRule::class, + ], true)); + + $types[] = PurchasableConditionRule::class; + $types[] = SkuConditionRule::class; + $types[] = PurchasableTypeConditionRule::class; + $types[] = CatalogPricingRulePurchasableCategoryConditionRule::class; + + return $types; + } +} diff --git a/src/Purchasable/Conditions/PurchasableConditionRule.php b/src/Purchasable/Conditions/PurchasableConditionRule.php new file mode 100644 index 0000000000..914ed2452e --- /dev/null +++ b/src/Purchasable/Conditions/PurchasableConditionRule.php @@ -0,0 +1,140 @@ +>|null + * + * @see getElementIds() + * @see setElementIds() + */ + private ?array $_elementIds = null; + + public function getLabel(): string + { + return t('Purchasable', category: 'commerce'); + } + + /** @param array>|null $value */ + public function setElementIds(?array $value): void + { + $this->_elementIds = $value; + } + + /** @return array|null */ + public function getElementIds(): ?array + { + if ($this->_elementIds === null) { + return null; + } + + $elementIds = []; + foreach ($this->_elementIds as $ids) { + if (empty($ids)) { + continue; + } + + $elementIds = array_merge($elementIds, $ids); + } + + return $elementIds; + } + + public function getConfig(): array + { + return array_merge(parent::getConfig(), [ + 'elementIds' => $this->_elementIds, + ]); + } + + public function getRules(): array + { + return array_merge(parent::getRules(), [ + 'elementIds' => ['nullable'], + ]); + } + + protected function inputHtml(): string + { + $id = 'purchasable'; + + $html = ''; + foreach (app(Purchasables::class)->getAllPurchasableElementTypes() as $purchasableType) { + /** @var class-string $purchasableType */ + $elements = null; + if (!empty($this->_elementIds[$purchasableType])) { + $elements = $purchasableType::find() + ->id($this->_elementIds[$purchasableType]) + ->site('*') + ->preferSites(array_filter([Cp::requestedSite()?->id])) + ->status(null) + ->unique() + ->all(); + } + + $html .= Html::tag('div', + Html::beginTag('div') . + Html::tag('strong', $purchasableType::displayName()) . + Html::endTag('div') . + FormFields::elementSelectHtml([ + 'name' => Html::namespaceInputName($purchasableType, 'elementIds'), + 'elements' => $elements, + 'elementType' => $purchasableType, + 'sources' => null, + 'criteria' => null, + 'single' => false, + 'showSiteMenu' => true, + ]) + ); + } + + return Html::hiddenLabel($this->getLabel(), $id) . + Html::tag('div', + $html, + [ + 'class' => ['flex', 'flex-start'], + ] + ); + } + + public function getExclusiveQueryParams(): array + { + return ['id']; + } + + public function modifyQuery(ElementQueryInterface $query): void + { + $ids = $this->getElementIds(); + if ($ids === null) { + return; + } + + $query->id($ids); + } + + public function matchElement(ElementInterface $element): bool + { + $ids = $this->getElementIds(); + if ($ids === null) { + return true; + } + + return in_array($element->id, $ids); + } +} diff --git a/src/Purchasable/Conditions/PurchasableTypeConditionRule.php b/src/Purchasable/Conditions/PurchasableTypeConditionRule.php new file mode 100644 index 0000000000..9ef85f80c7 --- /dev/null +++ b/src/Purchasable/Conditions/PurchasableTypeConditionRule.php @@ -0,0 +1,49 @@ +whereParam('elements.type', $this->paramValue()); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Purchasable $element */ + return $this->matchValue($element::class); + } + + protected function options(): array + { + $types = []; + + foreach (app(Purchasables::class)->getAllPurchasableElementTypes() as $elementType) { + $types[$elementType] = $elementType::displayName(); + } + + return $types; + } +} diff --git a/src/Purchasable/Conditions/SkuConditionRule.php b/src/Purchasable/Conditions/SkuConditionRule.php new file mode 100644 index 0000000000..74e6d82d24 --- /dev/null +++ b/src/Purchasable/Conditions/SkuConditionRule.php @@ -0,0 +1,39 @@ +sku($this->paramValue()); + } + + public function matchElement(ElementInterface $element): bool + { + /** @var Purchasable $element */ + return $this->matchValue($element->getSku()); + } +} diff --git a/src/Purchasable/Contracts/PurchasableInterface.php b/src/Purchasable/Contracts/PurchasableInterface.php new file mode 100644 index 0000000000..07d7f46bfe --- /dev/null +++ b/src/Purchasable/Contracts/PurchasableInterface.php @@ -0,0 +1,53 @@ +getStore()->handle)); + } + + #[Override] + public function getUrl(): ?string + { + return ''; + } + + #[Override] + public function hasFreeShipping(): bool + { + return true; + } + + #[Override] + public function getIsShippable(): bool + { + return false; + } + + #[Override] + public function getIsTaxable(): bool + { + return false; + } + + #[Override] + public function populateLineItem(LineItem $lineItem): void + { + $options = $lineItem->getOptions(); + if (isset($options['donationAmount'])) { + $lineItem->price = $options['donationAmount']; + } + } + + #[Override] + public function getLineItemRules(LineItem $lineItem): array + { + return [ + [ + 'purchasableId', + function($attribute, $params, Validator $validator) use ($lineItem) { + $options = $lineItem->getOptions(); + if (!isset($options['donationAmount'])) { + $lineItem->errors()->add($attribute, t('No donation amount supplied.', category: 'commerce')); + } + if (isset($options['donationAmount']) && !is_numeric($options['donationAmount'])) { + $lineItem->errors()->add($attribute, t('Donation needs to be an amount.', category: 'commerce')); + } + if (isset($options['donationAmount']) && $options['donationAmount'] == 0) { + $lineItem->errors()->add($attribute, t('Donation can not be zero.', category: 'commerce')); + } + }, + ], + ]; + } + + #[Override] + public function getIsPromotable(): bool + { + return false; + } + + #[Override] + public function afterSave(bool $isNew): void + { + if (!$isNew) { + $record = DonationRecord::query()->findOrFail($this->id); + } else { + $record = new DonationRecord(); + $record->id = $this->id; + } + + $record->sku = $this->sku; + + // We want to always have the same date as the element table, based on the logic for updating these in the element service i.e resaving + $record->dateUpdated = $this->dateUpdated ? Carbon::instance($this->dateUpdated) : Carbon::now(); + $record->dateCreated = $this->dateCreated ? Carbon::instance($this->dateCreated) : Carbon::now(); + + $record->save(); + + parent::afterSave($isNew); + + // Loop through other stores to save the donation to all stores + $stores = app(Stores::class)->getAllStores(); + $stores + ->filter(fn(Store $s) => $s->id !== $this->getStore()->id) + ->each(function(Store $store) use ($isNew) { + $purchasableStoreRecord = PurchasableStoreRecord::where('purchasableId', $this->id) + ->where('storeId', $store->id) + ->first(); + if ($isNew || !$purchasableStoreRecord) { + $purchasableStoreRecord = new PurchasableStoreRecord(); + $purchasableStoreRecord->purchasableId = $this->id; + $purchasableStoreRecord->storeId = $store->id; + } + + $purchasableStoreRecord->basePrice = 0; + $purchasableStoreRecord->basePromotionalPrice = null; + $purchasableStoreRecord->stock = null; + $purchasableStoreRecord->inventoryTracked = false; + $purchasableStoreRecord->allowOutOfStockPurchases = false; + $purchasableStoreRecord->minQty = null; + $purchasableStoreRecord->maxQty = null; + $purchasableStoreRecord->promotable = false; + $purchasableStoreRecord->availableForPurchase = $this->availableForPurchase; + $purchasableStoreRecord->freeShipping = true; + $purchasableStoreRecord->shippingCategoryId = app(ShippingCategories::class)->getDefaultShippingCategory($store->id)->id; + + $purchasableStoreRecord->save(); + }); + } +} diff --git a/src/Purchasable/Elements/Purchasable.php b/src/Purchasable/Elements/Purchasable.php new file mode 100644 index 0000000000..75b0a770da --- /dev/null +++ b/src/Purchasable/Elements/Purchasable.php @@ -0,0 +1,1280 @@ +currencyAttributes() as $attribute) { + $fields[$attribute . 'AsCurrency'] = $attribute . 'AsCurrency'; + } + + return $fields; + } + + public function extraFields(): array + { + $names = parent::extraFields(); + + $names[] = 'description'; + $names[] = 'sales'; + $names[] = 'snapshot'; + return $names; + } + + public function currencyAttributes(): array + { + return [ + 'basePrice', + 'basePromotionalPrice', + 'price', + 'promotionalPrice', + 'salePrice', + ]; + } + + public function getBasePriceAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('basePrice'); + } + + public function getBasePromotionalPriceAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('basePromotionalPrice'); + } + + public function getPriceAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('price'); + } + + public function getPromotionalPriceAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('promotionalPrice'); + } + + public function getSalePriceAsCurrency(): string + { + return $this->_currencyAttributeAsCurrency('salePrice'); + } + + private function _currencyAttributeAsCurrency(string $attribute): string + { + $amount = $this->$attribute ?? 0; + return Currency::formatAsCurrency($amount, $this->getStore()->getCurrency()); + } + + public function setAttributesFromRequest(array $values): void + { + $length = $values['length'] ?? null; + unset($values['length']); + if ($length !== null) { + $this->length = $length ? (float)Localization::normalizeNumber($length) : null; + } + + $width = $values['width'] ?? null; + unset($values['width']); + if ($width !== null) { + $this->width = $width ? (float)Localization::normalizeNumber($width) : null; + } + + $height = $values['height'] ?? null; + unset($values['height']); + if ($height !== null) { + $this->height = $height ? (float)Localization::normalizeNumber($height) : null; + } + + $weight = $values['weight'] ?? null; + unset($values['weight']); + if ($weight !== null) { + $this->weight = $weight ? (float)Localization::normalizeNumber($weight) : null; + } + + $this->setAttributes($values); + } + + protected function inlineAttributeInputHtml(string $attribute): string + { + $localizePrice = function(string $attribute) { + $price = $this->{$attribute}; + if (empty($this->getErrors($attribute))) { + if ($price === null && $attribute === 'basePromotionalPrice') { + return null; + } elseif ($price === null) { + $price = 0; + } + + $price = I18N::getFormatter()->asDecimal($price); + } + + return $price; + }; + + return match ($attribute) { + 'availableForPurchase' => PurchasableHelper::availableForPurchaseInputHtml($this->availableForPurchase), + 'price' => Currency::moneyInputHtml($localizePrice('basePrice'), [ + 'id' => 'base-price', + 'name' => 'basePrice', + 'currency' => $this->getStore()->getCurrency()->getCode(), + 'currencyLabel' => $this->getStore()->getCurrency()->getCode(), + ]), + 'promotionalPrice' => Currency::moneyInputHtml($localizePrice('basePromotionalPrice'), [ + 'id' => 'base-promotional-price', + 'name' => 'basePromotionalPrice', + 'currency' => $this->getStore()->getCurrency()->getCode(), + 'currencyLabel' => $this->getStore()->getCurrency()->getCode(), + ]), + 'sku' => PurchasableHelper::skuInputHtml($this->getSkuAsText()), + default => parent::inlineAttributeInputHtml($attribute), + }; + } + + public static function displayName(): string + { + $classNameParts = explode('\\', static::class); + + return array_pop($classNameParts); + } + + private function getTeller(): Teller + { + return app(Currencies::class)->getTeller($this->getStore()->getCurrency()); + } + + public function __unset(string $name): void + { + // Allow clearing of specific memoized properties + if (in_array($name, ['stock', 'shippingCategory', 'taxCategory'])) { + $this->{'_' . $name} = null; + return; + } + + parent::__unset($name); + } + + public function getStore(): Store + { + if ($this->_store === null || !in_array($this->siteId, $this->_store->getSites()->pluck('id')->all())) { + if ($this->siteId === null) { + throw new \RuntimeException('Purchasable::siteId cannot be null'); + } + + $this->_store = app(Stores::class)->getStoreBySiteId($this->siteId); + if ($this->_store === null) { + throw new \RuntimeException('Unable to retrieve store.'); + } + } + + return $this->_store; + } + + public function getStoreId(): int + { + return $this->getStore()->id; + } + + public function getIsAvailable(): bool + { + // Is the element available for purchase? + if (!$this->availableForPurchase) { + return false; + } + + // is the element enabled? + if ($this->getStatus() !== self::STATUS_ENABLED) { + return false; + } + + // Temporary SKU can not be added to the cart + if (PurchasableHelper::isTempSku($this->getSku())) { + return false; + } + + if (static::hasInventory() && $this->inventoryTracked && $this->getStock() < 1) { + if (!app(Purchasables::class)->isPurchasableOutOfStockPurchasingAllowed($this)) { + return false; + } + } + + return true; + } + + public function setBasePrice(Money|array|float|int|null $basePrice): void + { + if (is_array($basePrice)) { + if (isset($basePrice['value']) && $basePrice['value'] === '') { + $this->_basePrice = null; + return; + } + + if (!isset($basePrice['currency'])) { + $basePrice['currency'] = $this->getStore()->getCurrency(); + } + + $basePrice = MoneyHelper::toMoney($basePrice); + // nullify if conversion fails + $basePrice = $basePrice ?: null; + } + + if ($basePrice instanceof Money) { + $basePrice = MoneyHelper::toDecimal($basePrice); + } elseif ($basePrice !== null) { + $basePrice = (float)$basePrice; + } + + $this->_basePrice = $basePrice; + } + + public function getBasePrice(): ?float + { + return $this->_basePrice; + } + + public function setBasePromotionalPrice(Money|array|float|int|null $basePromotionalPrice): void + { + if (is_array($basePromotionalPrice)) { + if (isset($basePromotionalPrice['value']) && $basePromotionalPrice['value'] === '') { + $this->_basePromotionalPrice = null; + return; + } + + if (!isset($basePromotionalPrice['currency'])) { + $basePromotionalPrice['currency'] = $this->getStore()->getCurrency(); + } + + $basePromotionalPrice = MoneyHelper::toMoney($basePromotionalPrice); + // nullify if conversion fails + $basePromotionalPrice = $basePromotionalPrice ?: null; + } + + if ($basePromotionalPrice instanceof Money) { + $basePromotionalPrice = MoneyHelper::toDecimal($basePromotionalPrice); + } elseif ($basePromotionalPrice !== null) { + $basePromotionalPrice = (float)$basePromotionalPrice; + } + + $this->_basePromotionalPrice = $basePromotionalPrice; + } + + public function getBasePromotionalPrice(): ?float + { + return $this->_basePromotionalPrice; + } + + public function setPrice(?float $price): void + { + $this->_price = $price; + } + + public function getPrice(): ?float + { + if (!app(CatalogPricingRules::class)->canUseCatalogPricingRules()) { + return $this->basePrice; + } + + $price = $this->_price ?? $this->basePrice; + + return (float)$this->getTeller()->convertToString($price); + } + + public function getPromotionalPrice(): ?float + { + $price = $this->getPrice(); + if (!app(CatalogPricingRules::class)->canUseCatalogPricingRules()) { + // Use the sales system to figure out the price + if (!isset($this->_sales)) { + $this->loadSales(); + } + $promotionalPrice = $this->_salesPrice ?? $this->basePromotionalPrice; + } else { + $promotionalPrice = $this->_promotionalPrice ?? $this->basePromotionalPrice; + } + + if ($promotionalPrice === null) { + return null; + } + + $promotionalPrice = (float)$this->getTeller()->convertToString($promotionalPrice); + return $this->getTeller()->lessThan($promotionalPrice, $price) ? $promotionalPrice : null; + } + + public function setPromotionalPrice(?float $price): void + { + $this->_promotionalPrice = $price; + } + + public function getCatalogPricingRule(): ?CatalogPricingRule + { + if ($this->_catalogPricingRule === null && $this->catalogPricingRuleId !== null) { + $this->_catalogPricingRule = app(CatalogPricingRules::class)->getCatalogPricingRuleById($this->catalogPricingRuleId, $this->storeId); + } + + return $this->_catalogPricingRule; + } + + public function getSalePrice(): ?float + { + if ($this->_salePrice === null) { + $this->_salePrice = $this->getPromotionalPrice() ?? $this->getPrice(); + } + + return $this->_salePrice ?? null; + } + + public function getSku(): string + { + return $this->_sku; + } + + /** + * Returns the SKU as text but returns a blank string if it's a temp SKU. + */ + public function getSkuAsText(): string + { + $sku = $this->getSku(); + + if (PurchasableHelper::isTempSku($sku)) { + $sku = ''; + } + + return $sku; + } + + public function setSku(?string $sku = null): void + { + $this->_sku = $sku; + } + + /** + * Returns whether this variant has stock. + */ + public function hasStock(): bool + { + return !$this->inventoryTracked || $this->getStock() > 0; + } + + public function setTaxCategoryId(?int $taxCategoryId = null): void + { + $this->_taxCategoryId = $taxCategoryId; + } + + public function getTaxCategoryId(): int + { + if ($this->_taxCategoryId === null) { + $this->_taxCategoryId = app(TaxCategories::class)->getDefaultTaxCategory()->id; + } + + return $this->_taxCategoryId; + } + + public function getTaxCategory(): TaxCategory + { + if ($this->_taxCategory === null || $this->_taxCategory->id != $this->getTaxCategoryId()) { + $this->_taxCategory = app(TaxCategories::class)->getTaxCategoryById($this->getTaxCategoryId()); + } + + return $this->_taxCategory; + } + + public function getSnapshot(): array + { + return [ + 'catalogPricingRuleId' => $this->catalogPricingRuleId, + ]; + } + + public function setShippingCategoryId(?int $shippingCategoryId = null): void + { + $this->_shippingCategoryId = $shippingCategoryId; + } + + public function getShippingCategoryId(): int + { + if ($this->_shippingCategoryId === null) { + $this->_shippingCategoryId = app(ShippingCategories::class)->getDefaultShippingCategory($this->getStoreId())->id; + } + + return $this->_shippingCategoryId; + } + + public function getShippingCategory(): ShippingCategory + { + if ($this->_shippingCategory === null || $this->_shippingCategory->id !== $this->getShippingCategoryId()) { + $this->_shippingCategory = app(ShippingCategories::class)->getShippingCategoryById($this->getShippingCategoryId(), $this->getStoreId()); + } + + return $this->_shippingCategory; + } + + public function getDescription(): string + { + return (string)$this; + } + + public function populateLineItem(LineItem $lineItem): void + { + // Since we do not have a proper stock reservation system, we need deduct stock if they have more in the cart than is available, and to do this quietly. + // If this occurs in the payment request, the user will be notified the order has changed. + if (($order = $lineItem->getOrder()) && !$order->isCompleted) { + if ($this::hasInventory() && + !$this->getIsOutOfStockPurchasingAllowed() && + $this->inventoryTracked && + ($lineItem->qty > $this->getStock()) && + $this->getStock() > 0 + ) { + $message = t('{description} only has {stock} in stock.', ['description' => $lineItem->getDescription(), 'stock' => $this->getStock()], category: 'commerce'); + $notice = new OrderNotice([ + 'type' => 'lineItemMaxStockReached', + 'attribute' => "lineItems.$lineItem->id.qty", + 'message' => $message, + ]); + $order->addNotice($notice); + $lineItem->qty = $this->getStock(); + } + } + + $lineItem->weight = (float)$this->weight; //converting nulls + $lineItem->height = (float)$this->height; //converting nulls + $lineItem->length = (float)$this->length; //converting nulls + $lineItem->width = (float)$this->width; //converting nulls + } + + public function getLineItemRules(LineItem $lineItem): array + { + $order = $lineItem->getOrder(); + + // After the order is complete shouldn't check things like stock being available or the purchasable being around since they are irrelevant. + if ($order && $order->isCompleted) { + return []; + } + + $lineItemQuantitiesByPurchasableId = []; + foreach ($order->getLineItems() as $item) { + if ($item->purchasableId) { + $lineItemQuantitiesByPurchasableId[$item->purchasableId] = isset($lineItemQuantitiesByPurchasableId[$item->purchasableId]) ? $lineItemQuantitiesByPurchasableId[$item->purchasableId] + $item->qty : $item->qty; + } + } + + return [ + // an inline validator defined as an anonymous function + [ + 'purchasableId', + function($attribute, $params, Validator $validator) use ($lineItem) { + $purchasable = $lineItem->getPurchasable(); + if ($purchasable === null) { + $lineItem->errors()->add($attribute, t('No purchasable available.', category: 'commerce')); + } + + if (!app(Purchasables::class)->isPurchasableAvailable($lineItem->getPurchasable(), $lineItem->getOrder())) { + $lineItem->errors()->add($attribute, t('The item is not enabled for sale.', category: 'commerce')); + } + }, + ], + [ + 'qty', + function($attribute, $params, Validator $validator) use ($lineItem, $lineItemQuantitiesByPurchasableId) { + if ($lineItem->type == LineItemType::Custom) { + return; + } + + $lineItemPurchasable = $lineItem->getPurchasable(); + if (!$lineItemPurchasable instanceof Purchasable) { + return; + } + + if (!$this->hasStock()) { + if (!app(Purchasables::class)->isPurchasableOutOfStockPurchasingAllowed($lineItemPurchasable, $lineItem->getOrder())) { + $error = t('"{description}" is currently out of stock.', ['description' => $lineItemPurchasable->getDescription()], category: 'commerce'); + $lineItem->errors()->add($attribute, $error); + } + } + + $lineItemQty = $lineItem->purchasableId ? $lineItemQuantitiesByPurchasableId[$lineItem->purchasableId] : $lineItem->qty; + + if ($this->hasStock() && $this->inventoryTracked && $lineItemQty > $this->getStock()) { + if (!app(Purchasables::class)->isPurchasableOutOfStockPurchasingAllowed($lineItemPurchasable, $lineItem->getOrder())) { + $error = t('There are only {num} "{description}" items left in stock.', ['num' => $this->getStock(), 'description' => $lineItemPurchasable->getDescription()], category: 'commerce'); + $lineItem->errors()->add($attribute, $error); + } + } + + if ($this->minQty > 1 && $lineItemQty < $this->minQty) { + $error = t('Minimum order quantity for this item is {num}.', ['num' => $this->minQty], category: 'commerce'); + $lineItem->errors()->add($attribute, $error); + } + + if ($this->maxQty != 0 && $lineItemQty > $this->maxQty) { + $error = t('Maximum order quantity for this item is {num}.', ['num' => $this->maxQty], category: 'commerce'); + $lineItem->errors()->add($attribute, $error); + } + }, + ], + ]; + } + + public function setAttributes($values): void + { + // Normalize category IDs - handle arrays from componentSelect and empty strings + if (isset($values['taxCategoryId'])) { + if (is_array($values['taxCategoryId'])) { + $values['taxCategoryId'] = reset($values['taxCategoryId']) ?: null; + } + if ($values['taxCategoryId'] === '') { + $values['taxCategoryId'] = null; + } + } + if (isset($values['shippingCategoryId'])) { + if (is_array($values['shippingCategoryId'])) { + $values['shippingCategoryId'] = reset($values['shippingCategoryId']) ?: null; + } + if ($values['shippingCategoryId'] === '') { + $values['shippingCategoryId'] = null; + } + } + + parent::setAttributes($values); + } + + public function afterOrderComplete(Order $order, LineItem $lineItem): void + { + } + + public function hasFreeShipping(): bool + { + return $this->freeShipping; + } + + public function getIsShippable(): bool + { + return true; + } + + public function getIsTaxable(): bool + { + return true; + } + + public function getIsPromotable(): bool + { + return $this->promotable; + } + + public function getPromotionRelationSource(): mixed + { + return $this->id; + } + + public function getInventoryItem(): InventoryItem + { + return app(Inventory::class)->getInventoryItemByPurchasable($this); + } + + #[\Deprecated(message: 'in 5.0.0 use [[Purchasable::$inventoryTracked]] instead.')] + public function getHasUnlimitedStock(): bool + { + return !$this::hasInventory() || !$this->inventoryTracked; + } + + #[\Deprecated(message: 'in 5.0.0 use [[Purchasable::$inventoryTracked]] instead.')] + public function setHasUnlimitedStock($value): bool + { + return $this->inventoryTracked = !$value; + } + + private function getStockFromLevels(): int + { + if (!$this->inventoryTracked) { + return 0; + } + + $saleableAmount = 0; + foreach ($this->getInventoryLevels() as $inventoryLevel) { + if ($inventoryLevel->availableTotal > 0) { + $saleableAmount += $inventoryLevel->availableTotal; + } + } + + return $saleableAmount; + } + + public function getIsOutOfStockPurchasingAllowed(): bool + { + return app(Purchasables::class)->isPurchasableOutOfStockPurchasingAllowed($this); + } + + /** + * Returns the cached total available stock across all inventory locations for this store. + */ + public function getStock(): int + { + if ($this->_stock === null) { + $this->_stock = $this->getStockFromLevels(); + } + + return $this->_stock; + } + + /** + * Returns the total stock across all locations this purchasable is tracked in. + * + * @return Collection + */ + public function getInventoryLevels(): Collection + { + if (!$this->inventoryTracked) { + return collect(); + } + + return app(Inventory::class)->getInventoryLevelsForPurchasable($this); + } + + /** + * Update purchasable table. + */ + public function afterSave(bool $isNew): void + { + $canonicalPurchasableId = $this->getCanonicalId(); + $purchasableId = $this->id; + + if (!$this->propagating) { + $isOwnerDraftApplying = false; + $isOwnerRevisionApplying = false; + + // If this is a nested element, check if the owner is a draft and is being applied + if ($this instanceof NestedElementInterface) { + $owner = $this->getOwner(); + // A draft is only being "applied" if the owner is the canonical of the draft. + // Without this id check, "Save as a new product" from a draft would trip this branch + // and steal the original variant's inventory item via the transfer logic below. + $isOwnerDraftApplying = $owner + && $owner->getIsCanonical() + && $owner->duplicateOf !== null + && $owner->duplicateOf->getIsDraft() + && $owner->duplicateOf->getCanonicalId() === $owner->id; + + $isOwnerRevisionApplying = $owner + && $owner->duplicateOf !== null + && $owner->duplicateOf->getIsRevision() + && $owner->duplicateOf->getCanonicalId() === $owner->id; + } + + if (!$this->getIsRevision()) { + // Reset the purchasable's SKU when it is explicitly being duplicating + if ($this->duplicateOf !== null && !$isOwnerDraftApplying && !$isOwnerRevisionApplying) { + $this->sku = PurchasableHelper::tempSku(); + // Nullify inventory item so a new one is created + $this->inventoryItemId = null; + } + } + + $purchasable = PurchasableRecord::find($purchasableId); + + if (!$purchasable) { + $purchasable = new PurchasableRecord(); + } + $purchasable->sku = $this->getSku(); + $purchasable->id = $purchasableId; + $purchasable->width = $this->width; + $purchasable->height = $this->height; + $purchasable->length = $this->length; + $purchasable->weight = $this->weight; + $purchasable->taxCategoryId = $this->taxCategoryId; + + // Only update the description for the primary site until we have a concept + // of an order having a site ID + if ($this->siteId == Sites::getPrimarySite()->id) { + $purchasable->description = $this->getDescription(); + } + + $purchasable->save(); + + // Always create the inventory item even if it's a temporary draft (in the slide) since we want to allow stock to be + // added to inventory before it is saved as a permanent variant. + if (static::hasInventory() && $canonicalPurchasableId) { + /** @var InventoryItemRecord|null $inventoryItem */ + $inventoryItem = null; + + // When applying a draft to its canonical, hand the source's inventory + // item over so any stock movements made on the draft persist. + if ($isOwnerDraftApplying && $this->duplicateOf !== null) { + $inventoryItem = InventoryItemRecord::where('purchasableId', $this->duplicateOf->id)->first(); + if ($inventoryItem && $inventoryItem->purchasableId != $canonicalPurchasableId) { + $inventoryItem->purchasableId = $canonicalPurchasableId; + if (!$inventoryItem->save()) { + // Could not transfer (e.g. canonical already has its own row); fall through to the find-or-create below. + $inventoryItem = null; + } + } + } + + if (!$inventoryItem) { + $inventoryItem = app(Inventory::class)->ensureInventoryItemRecord($this); + } + + if ($inventoryItem) { + $this->inventoryItemId = $inventoryItem->id; + } + } + } + + if ($purchasableId) { + // Set Purchasables stores data + $purchasableStoreRecord = PurchasableStore::where('purchasableId', $purchasableId) + ->where('storeId', $this->getStoreId()) + ->first(); + if (!$purchasableStoreRecord) { + $purchasableStoreRecord = new PurchasableStore(); + $purchasableStoreRecord->storeId = $this->getStore()->id; + + if ($this->propagating) { + $purchasableStoreRecord->basePrice = 0; + $purchasableStoreRecord->basePromotionalPrice = null; + $purchasableStoreRecord->stock = app(Inventory::class)->getInventoryLevelsForPurchasable($this)->sum('availableTotal'); + $purchasableStoreRecord->inventoryTracked = false; + $purchasableStoreRecord->allowOutOfStockPurchases = false; + $purchasableStoreRecord->minQty = null; + $purchasableStoreRecord->maxQty = null; + $purchasableStoreRecord->promotable = false; + $purchasableStoreRecord->availableForPurchase = false; + $purchasableStoreRecord->freeShipping = false; + $purchasableStoreRecord->purchasableId = $purchasableId; + $purchasableStoreRecord->shippingCategoryId = app(ShippingCategories::class)->getDefaultShippingCategory($this->getStore()->id)->id; + + if ($this->duplicateOf !== null) { + // If this is a duplicate, copy the values from the original purchasable stores record + $purchasableStoreRecordDuplicate = PurchasableStore::where('purchasableId', $this->duplicateOf->id) + ->where('storeId', $this->getStoreId()) + ->first(); + + if ($purchasableStoreRecordDuplicate) { + $purchasableStoreRecord->basePrice = $purchasableStoreRecordDuplicate->basePrice; + $purchasableStoreRecord->basePromotionalPrice = $purchasableStoreRecordDuplicate->basePromotionalPrice; + $purchasableStoreRecord->stock = app(Inventory::class)->getInventoryLevelsForPurchasable($this)->sum('availableTotal'); + $purchasableStoreRecord->inventoryTracked = $purchasableStoreRecordDuplicate->inventoryTracked; + $purchasableStoreRecord->allowOutOfStockPurchases = $purchasableStoreRecordDuplicate->allowOutOfStockPurchases; + $purchasableStoreRecord->minQty = $purchasableStoreRecordDuplicate->minQty; + $purchasableStoreRecord->maxQty = $purchasableStoreRecordDuplicate->maxQty; + $purchasableStoreRecord->promotable = $purchasableStoreRecordDuplicate->promotable; + $purchasableStoreRecord->availableForPurchase = $purchasableStoreRecordDuplicate->availableForPurchase; + $purchasableStoreRecord->freeShipping = $purchasableStoreRecordDuplicate->freeShipping; + $purchasableStoreRecord->shippingCategoryId = $purchasableStoreRecordDuplicate->shippingCategoryId; + } + } + } + } + + if (!$this->propagating) { + $purchasableStoreRecord->basePrice = $this->basePrice; + $purchasableStoreRecord->basePromotionalPrice = $this->basePromotionalPrice; + $purchasableStoreRecord->stock = app(Inventory::class)->getInventoryLevelsForPurchasable($this)->sum('availableTotal'); + $purchasableStoreRecord->inventoryTracked = $this::hasInventory() ? $this->inventoryTracked : false; + $purchasableStoreRecord->allowOutOfStockPurchases = $this->allowOutOfStockPurchases; + $purchasableStoreRecord->minQty = $this->minQty; + $purchasableStoreRecord->maxQty = $this->maxQty; + $purchasableStoreRecord->promotable = $this->promotable; + $purchasableStoreRecord->availableForPurchase = $this->availableForPurchase; + $purchasableStoreRecord->freeShipping = $this->freeShipping; + $purchasableStoreRecord->purchasableId = $purchasableId; + $purchasableStoreRecord->shippingCategoryId = $this->getShippingCategoryId(); + } + + $purchasableStoreRecord->save(); + } + + parent::afterSave($isNew); + } + + public function afterPropagate(bool $isNew): void + { + parent::afterPropagate($isNew); + + if (!$this->getIsDraft() && !$this->getIsRevision()) { + app(CatalogPricing::class)->createCatalogPricingJob([ + 'purchasableIds' => [$this->getCanonicalId()], + 'storeId' => $this->getStoreId(), + ]); + } + } + + /** + * Clean up purchasable table. + */ + public function afterDelete(): void + { + $purchasable = PurchasableRecord::find($this->id); + + $purchasable?->delete(); + + parent::afterDelete(); + } + + /** + * @return Sale[] + */ + public function getSales(): array + { + if (!isset($this->_sales)) { + $this->loadSales(); + } + + return $this->_sales; + } + + /** + * @return Sale[] The sales that relate directly to this purchasable + */ + public function relatedSales(): array + { + return app(Sales::class)->getSalesRelatedToPurchasable($this); + } + + public function getOnPromotion(): bool + { + return $this->getPromotionalPrice() !== null; + } + + #[\Deprecated(message: 'Use [[getOnPromotion()]] instead.')] + public function getOnSale(): bool + { + \CraftCms\Cms\Support\Facades\Deprecator::log(__METHOD__, 'Purchasable `getOnSale()` method has been deprecated. Use `getOnPromotion()` instead.'); + return $this->getOnPromotion(); + } + + public function attributeLabels(): array + { + $labels = parent::attributeLabels(); + + return array_merge($labels, ['sku' => 'SKU']); + } + + protected function metaFieldsHtml(bool $static): string + { + $html = parent::metaFieldsHtml($static); + + $html .= $this->taxCategoryFieldHtml($static); + + $html .= $this->shippingCategoryFieldHtml($static); + + return $html; + } + + /** + * @return ShippingCategory[] + */ + protected function availableShippingCategories(): array + { + return app(ShippingCategories::class)->getAllShippingCategories($this->storeId)->all(); + } + + protected function shippingCategoryFieldHtml(bool $static): string + { + $availableShippingCategories = $this->availableShippingCategories(); + $shippingCategory = collect($availableShippingCategories)->firstWhere('id', $this->shippingCategoryId) + ?? collect($availableShippingCategories)->first(); + + return CommerceCp::shippingCategoryFieldHtml([ + 'label' => t('Shipping Category', category: 'commerce'), + 'id' => 'shippingCategoryId', + 'name' => 'shippingCategoryId', + 'value' => $shippingCategory, + 'options' => $availableShippingCategories, + 'limit' => 1, + 'min' => 1, + 'disabled' => $static, + 'create' => false, + 'storeId' => $this->storeId, + ]); + } + + /** + * @return TaxCategory[] + */ + protected function availableTaxCategories(): array + { + return app(TaxCategories::class)->getAllTaxCategories(); + } + + protected function taxCategoryFieldHtml(bool $static): string + { + $availableTaxCategories = $this->availableTaxCategories(); + $taxCategory = collect($availableTaxCategories)->firstWhere('id', $this->taxCategoryId) + ?? collect($availableTaxCategories)->first(); + + return CommerceCp::taxCategoryFieldHtml([ + 'label' => t('Tax Category', category: 'commerce'), + 'id' => 'taxCategoryId', + 'name' => 'taxCategoryId', + 'value' => $taxCategory, + 'options' => $availableTaxCategories, + 'limit' => 1, + 'min' => 1, + 'disabled' => $static, + 'create' => false, + ]); + } + + protected function attributeHtml(string $attribute): string + { + $stock = ''; + if ($attribute == 'stock') { + if (!$this->inventoryTracked) { + $stock = '∞'; + } else { + $stock = $this->getStock(); + } + } + + $dimensions = []; + if ($attribute === 'dimensions') { + $dimensions = array_filter([ + $this->length, + $this->width, + $this->height, + ]); + } + + if ($attribute === 'priceView') { + $price = $this->basePriceAsCurrency; + if ($this->getBasePromotionalPrice() && $this->getBasePromotionalPrice() < $this->getBasePrice()) { + $price = Html::tag('del', $price, ['style' => 'opacity: .5']) . ' ' . $this->basePromotionalPriceAsCurrency; + } + + return $price; + } + + if ($attribute === 'availableForPurchase') { + if ($this->availableForPurchase) { + $icon = Html::tag('span', '', [ + 'class' => 'checkbox-icon', + 'role' => 'img', + 'title' => t('Enabled'), + 'aria' => [ + 'label' => t('Enabled'), + ], + ]); + return $icon . Html::tag('span', ' ' . t('Available for purchase', category: 'commerce'), [ + 'class' => 'card-only-label', + 'style' => 'display:none;', + ]) . Html::tag('style', '.card-content .card-only-label { display: inline !important; }'); + } + } + + return match ($attribute) { + 'sku' => (string)Html::encode($this->getSkuAsText()), + 'price' => $this->basePriceAsCurrency, + 'promotionalPrice' => $this->basePromotionalPrice !== null ? $this->basePromotionalPriceAsCurrency : '', + 'weight' => $this->weight !== null ? I18N::getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->weightUnits : '', + 'length' => $this->length !== null ? I18N::getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits : '', + 'width' => $this->width !== null ? I18N::getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits : '', + 'height' => $this->height !== null ? I18N::getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits : '', + 'minQty' => (string)$this->minQty, + 'maxQty' => (string)$this->maxQty, + 'stock' => $this::hasInventory() ? $stock : '', + 'dimensions' => !empty($dimensions) ? implode(' x ', $dimensions) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits : '', + default => parent::attributeHtml($attribute), + }; + } + + protected static function defineTableAttributes(): array + { + return array_merge(parent::defineTableAttributes(), [ + 'title' => ['label' => t('Title', category: 'commerce')], + 'sku' => ['label' => t('SKU', category: 'commerce')], + 'price' => ['label' => t('Price', category: 'commerce')], + 'promotionalPrice' => ['label' => t('Promotional Price', category: 'commerce')], + 'width' => ['label' => t('Width ({unit})', ['unit' => Plugin::getInstance()->getSettings()->dimensionUnits], category: 'commerce')], + 'height' => ['label' => t('Height ({unit})', ['unit' => Plugin::getInstance()->getSettings()->dimensionUnits], category: 'commerce')], + 'length' => ['label' => t('Length ({unit})', ['unit' => Plugin::getInstance()->getSettings()->dimensionUnits], category: 'commerce')], + 'weight' => ['label' => t('Weight ({unit})', ['unit' => Plugin::getInstance()->getSettings()->weightUnits], category: 'commerce')], + 'stock' => ['label' => t('Stock', category: 'commerce')], + 'minQty' => ['label' => t('Min Qty', category: 'commerce')], + 'maxQty' => ['label' => t('Max Qty', category: 'commerce')], + 'availableForPurchase' => ['label' => t('Available for purchase', category: 'commerce')], + 'inventoryTracked' => ['label' => t('Inventory Tracked', category: 'commerce')], + ]); + } + + protected static function defineDefaultTableAttributes(string $source): array + { + return [ + 'sku', + 'price', + ]; + } + + public static function hasInventory(): bool + { + return true; + } + + public static function attributePreviewHtml(array $attribute): mixed + { + return match ($attribute['value']) { + 'sku', 'priceView', 'dimensions', 'weight' => $attribute['placeholder'], + 'availableForPurchase', 'promotable' => Html::tag('span', '', [ + 'class' => 'checkbox-icon', + 'role' => 'img', + 'title' => $attribute['label'], + 'aria' => [ + 'label' => $attribute['label'], + ], + ]) . + Html::tag('span', $attribute['label'], [ + 'class' => 'checkbox-preview-label', + ]), + default => parent::attributePreviewHtml($attribute) + }; + } + + protected static function defineDefaultCardAttributes(): array + { + return array_merge(parent::defineDefaultCardAttributes(), [ + 'sku', + 'priceView', + ]); + } + + protected static function defineCardAttributes(): array + { + return array_merge(parent::defineCardAttributes(), [ + 'availableForPurchase' => [ + 'label' => t('Available for purchase', category: 'commerce'), + ], + 'basePrice' => [ + 'label' => t('Base Price', category: 'commerce'), + 'placeholder' => '¤' . I18N::getFormatter()->asDecimal(123.99), + ], + 'basePromotionalPrice' => [ + 'label' => t('Base Promotional Price', category: 'commerce'), + 'placeholder' => '¤' . I18N::getFormatter()->asDecimal(123.99), + ], + 'dimensions' => [ + 'label' => t('Dimensions', category: 'commerce'), + 'placeholder' => '1 x 2 x 3 ' . Plugin::getInstance()->getSettings()->dimensionUnits, + ], + 'priceView' => [ + 'label' => t('Price', category: 'commerce'), + 'placeholder' => Html::tag('del', '¤' . I18N::getFormatter()->asDecimal(199.99), ['style' => 'opacity: .5']) . ' ¤' . I18N::getFormatter()->asDecimal(123.99), + ], + 'promotable' => [ + 'label' => t('Promotable', category: 'commerce'), + ], + 'sku' => [ + 'label' => t('SKU', category: 'commerce'), + 'placeholder' => Html::tag('code', 'SKU123'), + ], + 'stock' => [ + 'label' => t('Stock', category: 'commerce'), + 'placeholder' => 10, + ], + 'weight' => [ + 'label' => t('Weight', category: 'commerce'), + 'placeholder' => 123 . Plugin::getInstance()->getSettings()->weightUnits, + ], + ]); + } + + protected static function defineSortOptions(): array + { + return [ + 'title' => t('Title', category: 'commerce'), + 'sku' => t('SKU', category: 'commerce'), + ]; + } + + protected static function defineSearchableAttributes(): array + { + return [...parent::defineSearchableAttributes(), ...[ + 'description', + 'sku', + 'price', + 'width', + 'height', + 'length', + 'weight', + 'minQty', + 'maxQty', + ]]; + } + + /** + * Reloads any sales applicable to the purchasable. + * + * @internal + */ + public function loadSales(?Order $order = null): void + { + // Default the sales and salePrice to the original price without any sales + $this->_sales = []; + + if ($this->getId()) { + $this->_sales = app(Sales::class)->getSalesForPurchasable($this, $order); + $this->_salesPrice = app(Sales::class)->getSalePriceForPurchasable($this, $order); + } + } +} diff --git a/src/Purchasable/Events/PurchasableAvailableEvent.php b/src/Purchasable/Events/PurchasableAvailableEvent.php new file mode 100644 index 0000000000..e6126fed6e --- /dev/null +++ b/src/Purchasable/Events/PurchasableAvailableEvent.php @@ -0,0 +1,20 @@ + $config */ + public function __construct(array $config = []) + { + unset($config['required']); + parent::__construct($config); + } + + protected function inputHtml(?ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Purchasable) { + throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); + } + + return Html::beginTag('div', ['class' => 'flex']) . + Html::beginTag('div', ['class' => 'textwrapper']) . + FormFields::textHtml([ + 'id' => 'minQty', + 'name' => 'minQty', + 'value' => $element->minQty, + 'placeholder' => t('Any', category: 'commerce'), + 'title' => t('Minimum allowed quantity', category: 'commerce'), + 'disabled' => $static, + ]) . + Html::endTag('div') . + Html::tag('div', t('to', category: 'commerce'), ['class' => 'label light']) . + Html::beginTag('div', ['class' => 'textwrapper']) . + FormFields::textHtml([ + 'id' => 'maxQty', + 'name' => 'maxQty', + 'value' => $element->maxQty, + 'placeholder' => t('Any', category: 'commerce'), + 'title' => t('Maximum allowed quantity', category: 'commerce'), + 'disabled' => $static, + ]) . + Html::endTag('div') . + Html::endTag('div'); + } + + protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string + { + return t('Allowed Qty', category: 'commerce'); + } +} diff --git a/src/Purchasable/FieldLayoutElements/PurchasableAvailableForPurchaseField.php b/src/Purchasable/FieldLayoutElements/PurchasableAvailableForPurchaseField.php new file mode 100644 index 0000000000..d96f7dfad9 --- /dev/null +++ b/src/Purchasable/FieldLayoutElements/PurchasableAvailableForPurchaseField.php @@ -0,0 +1,63 @@ + $config */ + public function __construct(array $config = []) + { + unset($config['required']); + parent::__construct($config); + } + + protected function inputHtml(?ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Purchasable) { + throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); + } + + return PurchasableHelper::availableForPurchaseInputHtml($element->getIsFresh() ? $this->defaultAvailableForPurchase : $element->availableForPurchase, [ + 'disabled' => $static, + ]); + } + + #[Override] + protected function settingsHtml(): ?string + { + return parent::settingsHtml() . FormFields::lightswitchFromConfig([ + 'id' => 'defaultAvailableForPurchase', + 'name' => 'defaultAvailableForPurchase', + 'label' => t('Default Value'), + 'on' => $this->defaultAvailableForPurchase, + ])->toHtml(); + } + + protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string + { + return t('Available for purchase', category: 'commerce'); + } +} diff --git a/src/Purchasable/FieldLayoutElements/PurchasableDimensionsField.php b/src/Purchasable/FieldLayoutElements/PurchasableDimensionsField.php new file mode 100644 index 0000000000..b060e1bdcb --- /dev/null +++ b/src/Purchasable/FieldLayoutElements/PurchasableDimensionsField.php @@ -0,0 +1,88 @@ +getOwner()->getType()->hasDimensions) { + return false; + } + + return parent::showInForm($element); + } + + protected function inputHtml(?ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Purchasable) { + throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); + } + + // TODO: migrate to app(Plugin::class)->getSettings()->dimensionUnits once Settings service migrated to src/ + $dimensionUnits = Plugin::getInstance()->getSettings()->dimensionUnits; + + return Html::beginTag('div', ['class' => 'flex']) . + FormFields::fieldHtml(FormFields::textHtml([ + 'id' => 'length', + 'name' => 'length', + 'value' => $element->length !== null ? I18N::getFormatter()->asDecimal($element->length) : '', + 'class' => 'text', + 'size' => 10, + 'unit' => $dimensionUnits, + 'disabled' => $static, + ]), ['id' => 'length', 'label' => t('Length', category: 'commerce')]) . + FormFields::fieldHtml(FormFields::textHtml([ + 'id' => 'width', + 'name' => 'width', + 'value' => $element->width !== null ? I18N::getFormatter()->asDecimal($element->width) : '', + 'class' => 'text', + 'size' => 10, + 'unit' => $dimensionUnits, + 'disabled' => $static, + ]), ['id' => 'width', 'label' => t('Width', category: 'commerce')]) . + FormFields::fieldHtml(FormFields::textHtml([ + 'id' => 'height', + 'name' => 'height', + 'value' => $element->height !== null ? I18N::getFormatter()->asDecimal($element->height) : '', + 'class' => 'text', + 'size' => 10, + 'unit' => $dimensionUnits, + 'disabled' => $static, + ]), ['id' => 'height', 'label' => t('Height', category: 'commerce')]) . + Html::endTag('div'); + } + + protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string + { + return t('Dimensions', category: 'commerce'); + } +} diff --git a/src/Purchasable/FieldLayoutElements/PurchasableFreeShippingField.php b/src/Purchasable/FieldLayoutElements/PurchasableFreeShippingField.php new file mode 100644 index 0000000000..b5e62103a1 --- /dev/null +++ b/src/Purchasable/FieldLayoutElements/PurchasableFreeShippingField.php @@ -0,0 +1,50 @@ + $config */ + public function __construct(array $config = []) + { + unset($config['required']); + parent::__construct($config); + } + + protected function inputHtml(?ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Purchasable) { + throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); + } + + return FormFields::lightswitchFromConfig([ + 'id' => 'free-shipping', + 'name' => 'freeShipping', + 'small' => true, + 'on' => $element->freeShipping, + 'disabled' => $static, + ])->toHtml(); + } + + protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string + { + return t('Free Shipping', category: 'commerce'); + } +} diff --git a/src/Purchasable/FieldLayoutElements/PurchasablePriceField.php b/src/Purchasable/FieldLayoutElements/PurchasablePriceField.php new file mode 100644 index 0000000000..d05941edda --- /dev/null +++ b/src/Purchasable/FieldLayoutElements/PurchasablePriceField.php @@ -0,0 +1,232 @@ +getView()->registerAssetBundle(HtmxAsset::class); + + if (!$element instanceof Purchasable) { + throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); + } + + $basePrice = $element->basePrice; + if (empty($element->errors()->get('basePrice'))) { + if ($basePrice === null) { + $basePrice = 0; + } + + $basePrice = I18N::getFormatter()->asDecimal($basePrice); + } + + $basePromotionalPrice = $element->basePromotionalPrice; + if (empty($element->errors()->get('basePromotionalPrice')) && $basePromotionalPrice !== null) { + $basePromotionalPrice = I18N::getFormatter()->asDecimal($basePromotionalPrice); + } + + $id = InputNamespace::namespaceId('commerce-purchasable-price-field'); + $priceNamespace = InputNamespace::namespaceInputName('basePrice'); + $promotionalPriceNamespace = InputNamespace::namespaceInputName('basePromotionalPrice'); + + /** @var CatalogPricingCondition $catalogPricingCondition */ + $catalogPricingCondition = Conditions::createCondition([ + 'class' => CatalogPricingCondition::class, + 'allPrices' => true, + ]); + + $purchasableConditionRule = Conditions::createConditionRule([ + 'class' => CatalogPricingPurchasableConditionRule::class, + 'elementIds' => [$element::class => [$element->id]], + ]); + $catalogPricingCondition->addConditionRule($purchasableConditionRule); + $conditionBuilderConfig = Json::encode($catalogPricingCondition->getConfig()); + + \Craft::$app->getView()->registerAssetBundle(PurchasablePriceFieldAsset::class); + + $js = << { + new Craft.Commerce.PurchasablePriceField('$id', { + siteId: $element->siteId, + conditionBuilderConfig: $conditionBuilderConfig, + fieldNames: { + price: '$priceNamespace', + promotionalPrice: '$promotionalPriceNamespace', + } + }); +})(); +JS; + HtmlStack::js($js, Position::BodyEnd); + + $canUseCatalogPricingRules = app(CatalogPricingRules::class)->canUseCatalogPricingRules(); + $toggleTitle = t('Show related sales', category: 'commerce'); + $toggleAttributes = ['class' => 'js-purchasable-toggle-container', 'style' => ['position' => 'relative']]; + $toggleContent = null; + + if ($canUseCatalogPricingRules) { + $toggleTitle = t('Show all prices', category: 'commerce'); + $toggleAttributes['data-init-prices'] = 'true'; + $toggleContent = PurchasableHelper::catalogPricingRulesTableByPurchasableId($element->id, $element->storeId) . + Html::beginTag('div', ['class' => 'flex']) . + // New catalog price button + Html::button(t('Add catalog price', category: 'commerce'), [ + 'class' => 'btn icon add js-cpr-slideout', + 'data-icon' => 'plus', + 'data-store-id' => $element->storeId, + 'data-store-handle' => $element->getStore()->handle, + 'data-purchasable-id' => $element->id, + ]) . + template('commerce/prices/_status', [ + 'areCatalogPricingJobsRunning' => app(CatalogPricing::class)->areCatalogPricingJobsRunning(), + ], TemplateMode::Cp) . + Html::endTag('div'); + } else { + /** @var Sale[] $relatedSales */ + $relatedSales = app(Sales::class)->getSalesRelatedToPurchasable($element); + + if (!empty($relatedSales)) { + $salesTags = []; + foreach ($relatedSales as $sale) { + $salesTags[] = Html::a($sale->name, $sale->getCpEditUrl()); + } + + $toggleContent = Html::tag('div', implode(', ', $salesTags)); + } + } + + $toggleContent = $static ? null : $toggleContent; + + $currency = $element->getStore()->getCurrency(); + + return Html::beginTag('div', [ + 'id' => 'commerce-purchasable-price-field', + 'class' => 'js-purchasable-price-field', + ]) . + Html::beginTag('div', ['class' => 'flex']) . + FormFields::fieldHtml(Currency::moneyInputHtml($basePrice, [ + 'id' => 'base-price', + 'name' => 'basePrice', + 'currency' => $currency->getCode(), + 'currencyLabel' => $currency->getCode(), + 'required' => true, + 'errors' => $element->errors()->get('basePrice'), + 'disabled' => $static, + 'size' => 12, + ]), [ + 'id' => 'base-price', + 'required' => true, + 'label' => t('Price', category: 'commerce'), + ]) . + + // Don't show base promotional price field if the system is still using sales + ($canUseCatalogPricingRules ? + FormFields::fieldHtml(Currency::moneyInputHtml($basePromotionalPrice, [ + 'id' => 'base-promotional-price', + 'name' => 'basePromotionalPrice', + 'currency' => $currency->getCode(), + 'currencyLabel' => $currency->getCode(), + 'errors' => $element->errors()->get('basePromotionalPrice'), + 'disabled' => $static, + 'size' => 12, + ]), [ + 'id' => 'promotional-price', + 'label' => t('Promotional Price', category: 'commerce'), + ]) : '') . + + Html::endTag('div') . + + // Hide the prices table if the element is a draft + ($toggleContent ? Html::beginTag('div', ['class' => $element->getIsDraft() ? 'hidden' : '']) . + Html::tag('div', + Html::tag('a', $toggleTitle, ['class' => 'fieldtoggle', 'data-target' => 'purchasable-toggle']) . + Html::beginTag('div', $toggleAttributes) . + Html::tag( + 'div', + // Prices table + $toggleContent, + [ + 'id' => 'purchasable-toggle', + 'class' => 'hidden', + ] + ) . + Html::tag('div', '', [ + 'class' => 'js-purchasable-toggle-loading hidden', + 'style' => [ + 'position' => 'absolute', + 'top' => 0, + 'left' => 0, + 'width' => '100%', + 'height' => '100%', + 'background-color' => 'rgba(255, 255, 255, 0.5)', + ], + ]) . + Html::tag('div', Html::tag('span', '', ['class' => 'spinner']), [ + 'class' => 'js-purchasable-toggle-loading flex hidden', + 'style' => [ + 'position' => 'absolute', + 'top' => 0, + 'left' => 0, + 'width' => '100%', + 'height' => '100%', + 'align-items' => 'center', + 'justify-content' => 'center', + ], + ]) . + Html::endTag('div') + ) . + Html::endTag('div') : '') . + Html::endTag('div'); + } +} diff --git a/src/Purchasable/FieldLayoutElements/PurchasablePromotableField.php b/src/Purchasable/FieldLayoutElements/PurchasablePromotableField.php new file mode 100644 index 0000000000..19abf5ea55 --- /dev/null +++ b/src/Purchasable/FieldLayoutElements/PurchasablePromotableField.php @@ -0,0 +1,66 @@ + $config */ + public function __construct(array $config = []) + { + unset($config['required']); + parent::__construct($config); + } + + protected function inputHtml(?ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Purchasable) { + throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); + } + + return FormFields::lightswitchFromConfig([ + 'id' => 'promotable', + 'name' => 'promotable', + 'small' => true, + 'on' => $element->getIsFresh() ? $this->defaultPromotable : $element->promotable, + 'disabled' => $static, + ])->toHtml(); + } + + #[Override] + protected function settingsHtml(): ?string + { + return parent::settingsHtml() . FormFields::lightswitchFromConfig([ + 'id' => 'defaultPromotable', + 'name' => 'defaultPromotable', + 'label' => t('Default Value'), + 'on' => $this->defaultPromotable, + ])->toHtml(); + } + + protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string + { + return t('Promotable', category: 'commerce'); + } +} diff --git a/src/Purchasable/FieldLayoutElements/PurchasableSkuField.php b/src/Purchasable/FieldLayoutElements/PurchasableSkuField.php new file mode 100644 index 0000000000..d44c6a083a --- /dev/null +++ b/src/Purchasable/FieldLayoutElements/PurchasableSkuField.php @@ -0,0 +1,52 @@ +getScenario() === Element::SCENARIO_DEFAULT`), which has no new-system + // equivalent; the draft check alone covers the behavior that matters at runtime. + $variantWithSkuFormula = $element instanceof Variant && $element->getOwner()->getType()->skuFormat !== null; + if ($variantWithSkuFormula && $element->getIsDraft()) { + return null; + } + + return PurchasableHelper::skuInputHtml($element->getSkuAsText(), [ + 'disabled' => $static, + ]); + } + + protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string + { + return t('SKU', category: 'commerce'); + } +} diff --git a/src/Purchasable/FieldLayoutElements/PurchasableStockField.php b/src/Purchasable/FieldLayoutElements/PurchasableStockField.php new file mode 100644 index 0000000000..f8941b6b97 --- /dev/null +++ b/src/Purchasable/FieldLayoutElements/PurchasableStockField.php @@ -0,0 +1,243 @@ + $config */ + public function __construct(array $config = []) + { + unset($config['required']); + parent::__construct($config); + } + + protected function inputHtml(?ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Purchasable) { + throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); + } + + // If this is a revision get the canonical element to show the stock for. + // @TODO Re-evaluate swapping in the canonical element once revisions support tracking inventory independently + if ($element->getIsRevision()) { + /** @var Purchasable $element */ + $element = $element->getCanonical(); + } + + // TODO: this still registers the legacy `craft\commerce\web\assets\inventory\InventoryAsset` + // yii2 AssetBundle via the yii2-adapter bridge, since Commerce's own webpack-built CP assets + // haven't been ported to a native `HtmlStack`-based registration mechanism yet. + \Craft::$app->getView()->registerAssetBundle(InventoryAsset::class); + + $totalStock = $element->getStock(); + $inventoryLevels = app(Inventory::class)->getInventoryLevelsForPurchasable($element); + + $availableStockLabel = t('{total} saleable across {locationCount} location(s)', [ + 'total' => $totalStock, + 'locationCount' => $inventoryLevels->count(), + ], category: 'commerce'); + + $editInventoryItemId = sprintf('action-edit-inventory-item-%s', mt_rand()); + HtmlStack::jsWithVars(fn($id, $settings) => << { + e.preventDefault(); + const slideout = new Craft.CpScreenSlideout('commerce/inventory/item-edit', $settings); +}); +JS, [ + InputNamespace::namespaceId($editInventoryItemId), + ['params' => ['inventoryItemId' => $element->getInventoryItem()->id]], + ]); + + $inventoryLevelTableRows = ''; + /** @var InventoryLevel $inventoryLevel */ + foreach ($inventoryLevels as $inventoryLevel) { + // Update the quantity button + $editUpdateQuantityInventoryItemId = sprintf('action-update-qty-%s', mt_rand()); + $updatedValueId = sprintf('updated-value-%s', mt_rand()); + $settings = [ + 'params' => [ + 'inventoryLocationId' => $inventoryLevel->getInventoryLocation()->id, + 'ids[]' => [$element->inventoryItemId], + 'type' => 'available', + ], + ]; + + HtmlStack::jsWithVars(fn($id, $updatedValueId, $settings) => << { + e.preventDefault(); + const slideout = new Craft.Commerce.UpdateInventoryLevelModal($settings); + slideout.on('submit', (e) => { + if(e.response.data.updatedItems.length > 0 && e.response.data.updatedItems[0].availableTotal !== undefined) { + $('#' + $updatedValueId).html(e.response.data.updatedItems[0].availableTotal); + } + }); +}); +JS, [ + InputNamespace::namespaceId($editUpdateQuantityInventoryItemId), + InputNamespace::namespaceId($updatedValueId), + $settings, + ]); + + $inventoryLevelTableRows .= Html::beginTag('tr') . + Html::beginTag('td') . + htmlspecialchars($inventoryLevel->getInventoryLocation()->getUiLabel(), ENT_QUOTES) . + Html::endTag('td') . + Html::beginTag('td') . + Html::beginTag('div', ['class' => 'flex']) . + Html::tag('div', (string)$inventoryLevel->availableTotal, [ + 'id' => $updatedValueId, + ]) . + (!$static ? Html::tag('div', Html::button('', + [ + 'class' => 'btn menubtn action-btn', + 'id' => $editUpdateQuantityInventoryItemId, + ])) : '') . + Html::endTag('div') . + Html::endTag('td') . + (!$static ? Html::beginTag('td') . + (currentUser()?->can('commerce-manageInventoryStockLevels') ? + Html::a( + t('Manage', category: 'commerce'), + Url::cpUrl('commerce/inventory/levels/' . $inventoryLevel->getInventoryLocation()->handle, [ + 'inventoryItemId' => $inventoryLevel->getInventoryItem()->id, + ]), + [ + 'target' => '_blank', + 'class' => 'btn small', + 'id' => $editUpdateQuantityInventoryItemId, + 'aria-label' => t('Open in a new tab'), + 'data-icon' => 'external', + ] + ) : '') : '') . + Html::endTag('td') . + Html::endTag('tr'); + } + + $inventoryLevelsTable = Html::beginTag('table', ['class' => 'data fullwidth', 'style' => 'margin-top:5px;']) . + Html::beginTag('thead') . + Html::beginTag('tr') . + Html::beginTag('th') . + t('Location', category: 'commerce') . + Html::endTag('th') . + Html::beginTag('th') . + t('Available', category: 'commerce') . + Html::endTag('th') . + + (!$static ? Html::beginTag('th') . + t('Manage', category: 'commerce') . + Html::endTag('th') : '') . + + Html::endTag('tr') . + Html::endTag('thead') . + + Html::beginTag('tbody') . + $inventoryLevelTableRows . + Html::beginTag('tr') . + Html::beginTag('td', ['colspan' => '2']) . + $availableStockLabel . + Html::endTag('td') . + + (!$static ? Html::beginTag('td') . + Html::a( + t('Edit', category: 'commerce'), + '#', + [ + 'class' => 'btn small', + 'id' => $editInventoryItemId, + 'aria-label' => t('Edit Inventory Item'), + 'data-icon' => 'edit', + ] + ) . + Html::endTag('td') : '') . + + Html::endTag('tr') . + Html::endTag('tbody') . + Html::endTag('table'); + + $inventoryItemTrackedId = sprintf('store-inventory-item-tracked-%s', mt_rand()); + $storeInventoryTrackedLightswitchConfig = [ + 'id' => 'store-inventory-item-tracked', + 'name' => 'inventoryTracked', + 'small' => true, + 'on' => $element->getIsFresh() ? $this->defaultInventoryTracked : $element->inventoryTracked, + 'toggle' => $inventoryItemTrackedId, + 'disabled' => $static, + ]; + + $storeAllowOutOfStockPurchasesLightswitchConfig = [ + 'label' => t('Allow out of stock purchases', category: 'commerce'), + 'id' => 'store-backorder-allowed', + 'name' => 'allowOutOfStockPurchases', + 'small' => true, + 'on' => $element->getIsFresh() ? $this->defaultAllowOutOfStockPurchases : $element->getIsOutOfStockPurchasingAllowed(), + 'disabled' => $static, + ]; + + return Html::beginTag('div') . + FormFields::lightswitchFromConfig($storeInventoryTrackedLightswitchConfig)->toHtml() . + Html::beginTag('div', ['id' => $inventoryItemTrackedId, 'class' => 'hidden']) . + $inventoryLevelsTable . + FormFields::lightswitchFieldHtml($storeAllowOutOfStockPurchasesLightswitchConfig) . + Html::endTag('div') . + Html::endTag('div'); + } + + #[Override] + protected function settingsHtml(): ?string + { + $lightSwitches = FormFields::lightswitchFromConfig([ + 'id' => 'defaultInventoryTracked', + 'name' => 'defaultInventoryTracked', + 'label' => t('Track Inventory', category: 'commerce'), + 'on' => $this->defaultInventoryTracked, + ])->toHtml() . + FormFields::lightswitchFromConfig([ + 'id' => 'defaultAllowOutOfStockPurchases', + 'name' => 'defaultAllowOutOfStockPurchases', + 'label' => t('Allow out of stock purchases', category: 'commerce'), + 'on' => $this->defaultAllowOutOfStockPurchases, + ])->toHtml(); + + return parent::settingsHtml() . FormFields::fieldHtml($lightSwitches, ['label' => t('Default Value')]); + } + + protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string + { + return t('Track Inventory', category: 'commerce'); + } +} diff --git a/src/Purchasable/FieldLayoutElements/PurchasableWeightField.php b/src/Purchasable/FieldLayoutElements/PurchasableWeightField.php new file mode 100644 index 0000000000..19e9d57da6 --- /dev/null +++ b/src/Purchasable/FieldLayoutElements/PurchasableWeightField.php @@ -0,0 +1,66 @@ +getOwner()->getType()->hasDimensions) { + return false; + } + + return parent::showInForm($element); + } + + protected function inputHtml(?ElementInterface $element = null, bool $static = false): ?string + { + if (!$element instanceof Purchasable) { + throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); + } + + return FormFields::textHtml([ + 'id' => 'weight', + 'name' => 'weight', + 'value' => $element->weight !== null ? I18N::getFormatter()->asDecimal($element->weight) : '', + 'class' => 'text', + 'size' => 10, + // TODO: migrate to app(Plugin::class)->getSettings()->weightUnits once Settings service migrated to src/ + 'unit' => Plugin::getInstance()->getSettings()->weightUnits, + 'placeholder' => t('Weight', category: 'commerce'), + 'disabled' => $static, + ]); + } + + protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string + { + return t('Weight', category: 'commerce'); + } +} diff --git a/src/Purchasable/Models/Donation.php b/src/Purchasable/Models/Donation.php new file mode 100644 index 0000000000..cebd95e8e8 --- /dev/null +++ b/src/Purchasable/Models/Donation.php @@ -0,0 +1,28 @@ + 'datetime', + 'dateUpdated' => 'datetime', + ]; +} diff --git a/src/Purchasable/Models/PurchasableStore.php b/src/Purchasable/Models/PurchasableStore.php new file mode 100644 index 0000000000..3c17f32cec --- /dev/null +++ b/src/Purchasable/Models/PurchasableStore.php @@ -0,0 +1,57 @@ + ['required', 'integer'], + 'storeId' => ['required', 'integer'], + 'stock' => ['nullable', 'integer'], + 'minQty' => ['nullable', 'integer'], + 'maxQty' => ['nullable', 'integer'], + 'basePrice' => ['nullable', 'numeric'], + 'basePromotionalPrice' => ['nullable', 'numeric'], + 'hasUnlimitedStock' => ['boolean'], + 'promotable' => ['boolean'], + 'availableForPurchase' => ['boolean'], + 'freeShipping' => ['boolean'], + 'allowOutOfStockPurchases' => ['boolean'], + ]; + } +} diff --git a/src/Purchasable/PurchasableTypes.php b/src/Purchasable/PurchasableTypes.php new file mode 100644 index 0000000000..91ef021697 --- /dev/null +++ b/src/Purchasable/PurchasableTypes.php @@ -0,0 +1,32 @@ +register(MyPurchasable::class); + * } + * ``` + * + * @extends TypeRegistry + */ +#[Singleton] +class PurchasableTypes extends TypeRegistry +{ + protected const ?string CONTRACT = PurchasableInterface::class; + + protected const array DEFAULT_TYPES = [ + Variant::class, + ]; +} diff --git a/src/Purchasable/Purchasables.php b/src/Purchasable/Purchasables.php new file mode 100644 index 0000000000..a05062c5e2 --- /dev/null +++ b/src/Purchasable/Purchasables.php @@ -0,0 +1,198 @@ +allowOutOfStockPurchases, + ); + + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPurchasables()->hasEventHandlers(self::EVENT_PURCHASABLE_OUT_OF_STOCK_PURCHASES_ALLOWED)) { + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPurchasables()->trigger(self::EVENT_PURCHASABLE_OUT_OF_STOCK_PURCHASES_ALLOWED, $event); + } + + return $event->outOfStockPurchasesAllowed; + } + + public function isPurchasableAvailable(PurchasableInterface $purchasable, ?Order $order = null, ?User $currentUser = null): bool + { + $currentUser ??= currentUserElement(); + + $event = new PurchasableAvailableEvent( + purchasable: $purchasable, + isAvailable: $purchasable->getIsAvailable(), + order: $order, + currentUser: $currentUser, + ); + + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPurchasables()->hasEventHandlers(self::EVENT_PURCHASABLE_AVAILABLE)) { + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPurchasables()->trigger(self::EVENT_PURCHASABLE_AVAILABLE, $event); + } + + return $event->isAvailable; + } + + public function isPurchasableShippable(PurchasableInterface $purchasable, ?Order $order = null, ?User $currentUser = null): bool + { + $currentUser ??= currentUserElement(); + + $event = new PurchasableShippableEvent( + purchasable: $purchasable, + isShippable: $purchasable->getIsShippable(), + order: $order, + currentUser: $currentUser, + ); + + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getPurchasables()->hasEventHandlers(self::EVENT_PURCHASABLE_SHIPPABLE)) { + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getPurchasables()->trigger(self::EVENT_PURCHASABLE_SHIPPABLE, $event); + } + + return $event->isShippable; + } + + /** + * Updated the cached stock value for the purchasable in a store. + */ + public function updateStoreStockCache(PurchasableInterface $purchasable, bool $allSites = false): void + { + if ($allSites) { + $purchasables = $purchasable::find() + ->siteId('*') + ->id($purchasable->id) + ->status(null)->all(); + } else { + $purchasables = [$purchasable]; + } + + /** @var PurchasableInterface $purchasable */ + foreach ($purchasables as $purchasable) { + if (!$purchasable instanceof Purchasable) { + continue; + } + + $stock = app(Inventory::class)->getInventoryLevelsForPurchasable($purchasable)->sum('availableTotal'); + + DB::table(Table::PURCHASABLES_STORES) + ->where('purchasableId', $purchasable->id) + ->where('storeId', $purchasable->getStore()->id) + ->update(['stock' => $stock]); + + // Since we are updating the stock directly in the database, clear the cache + ElementCaches::invalidateForElement($purchasable); + } + } + + /** + * Delete a purchasable by its ID. + * + * @throws Throwable + */ + public function deletePurchasableById(int $purchasableId): bool + { + $this->purchasableById?->pull($purchasableId); + + return Elements::deleteElementById($purchasableId); + } + + /** + * Get a purchasable by its ID. + */ + public function getPurchasableById(int $purchasableId, ?int $siteId = null, int|false|null $forCustomer = null): ?PurchasableInterface + { + // @TODO Verify that returning the memoized purchasable regardless of the requested $siteId / $forCustomer is safe, or scope the cache key by those args + if ($this->purchasableById !== null && $this->purchasableById->has($purchasableId)) { + return $this->purchasableById->get($purchasableId); + } + + $siteId ??= Sites::getCurrentSite()->id; + $elementType = Elements::getElementTypeById($purchasableId); + + if ($elementType === null || !class_exists($elementType)) { + return null; + } + + $query = Elements::createElementQuery($elementType) + ->id($purchasableId) + ->siteId($siteId) + ->status(null) + ->drafts(null) + ->provisionalDrafts(null) + ->revisions(null); + + if ($query instanceof PurchasableQuery) { + $query->forCustomer($forCustomer); + } + + $purchasable = $query->one(); + if ($purchasable && !$purchasable instanceof PurchasableInterface) { + throw new \InvalidArgumentException(sprintf('Element %s does not implement %s', $purchasableId, PurchasableInterface::class)); + } + + $this->purchasableById ??= collect(); + $this->purchasableById->put($purchasableId, $purchasable); + + return $purchasable; + } + + /** + * Returns all available purchasable element classes. + * + * @return string[] The available purchasable element classes. + */ + public function getAllPurchasableElementTypes(): array + { + return app(PurchasableTypes::class)->types()->all(); + } +} diff --git a/src/Purchasable/Queries/DonationQuery.php b/src/Purchasable/Queries/DonationQuery.php new file mode 100644 index 0000000000..098339e8eb --- /dev/null +++ b/src/Purchasable/Queries/DonationQuery.php @@ -0,0 +1,29 @@ + + */ +class DonationQuery extends PurchasableQuery +{ + /** @param array $config */ + public function __construct(array $config = []) + { + parent::__construct(Donation::class, $config); + + $this->query->join(new Alias(Table::DONATIONS, 'commerce_donations'), 'commerce_donations.id', '=', 'elements.id'); + + $this->beforeQuery(function(self $query) { + if ($query->sku) { + $query->where('commerce_donations.sku', $query->sku); + } + }); + } +} diff --git a/src/Purchasable/Queries/PurchasableQuery.php b/src/Purchasable/Queries/PurchasableQuery.php new file mode 100644 index 0000000000..71bc28d061 --- /dev/null +++ b/src/Purchasable/Queries/PurchasableQuery.php @@ -0,0 +1,392 @@ + + */ +abstract class PurchasableQuery extends ElementQuery +{ + protected string $table = Table::PURCHASABLES; + + /** @var array */ + protected array $defaultOrderBy = [ + 'commerce_purchasables.sku' => SORT_ASC, + ]; + + public ?bool $availableForPurchase = null; + + public mixed $sku = null; + + public mixed $price = null; + + public mixed $promotionalPrice = null; + + public ?bool $onPromotion = null; + + public mixed $salePrice = null; + + public mixed $width = false; + + public mixed $height = false; + + public mixed $length = false; + + public mixed $weight = false; + + public mixed $stock = null; + + public ?bool $hasStock = null; + + public mixed $shippingCategoryId = null; + + public mixed $taxCategoryId = null; + + public int|false|null $forCustomer = null; + + public ?bool $inventoryTracked = null; + + /** @param array $config */ + public function __construct(string $elementType, array $config = []) + { + parent::__construct($elementType, $config); + + $this->query->addSelect([ + 'commerce_purchasables.sku', + 'commerce_purchasables.width', + 'commerce_purchasables.height', + 'commerce_purchasables.length', + 'commerce_purchasables.weight', + 'commerce_purchasables.taxCategoryId', + 'purchasables_stores.availableForPurchase', + 'purchasables_stores.basePrice', + 'purchasables_stores.basePromotionalPrice', + 'purchasables_stores.freeShipping', + 'purchasables_stores.maxQty', + 'purchasables_stores.minQty', + 'purchasables_stores.inventoryTracked', + 'purchasables_stores.allowOutOfStockPurchases', + 'purchasables_stores.promotable', + 'purchasables_stores.shippingCategoryId', + 'inventoryitems.id as inventoryItemId', + ]); + + $this->query->leftJoin(new Alias(Table::SITESTORES, 'sitestores'), 'elements_sites.siteId', '=', 'sitestores.siteId'); + $this->query->leftJoin(new Alias(Table::PURCHASABLES_STORES, 'purchasables_stores'), function($join) { + $join->on('purchasables_stores.storeId', '=', 'sitestores.storeId') + ->on('purchasables_stores.purchasableId', '=', 'commerce_purchasables.id'); + }); + $this->query->leftJoin(new Alias(Table::INVENTORYITEMS, 'inventoryitems'), 'inventoryitems.purchasableId', '=', 'commerce_purchasables.id'); + + if (app(CatalogPricingRules::class)->hasCatalogPricingRules()) { + $customerId = $this->forCustomer; + if ($customerId === null) { + $customerId = currentUser()?->getCraftUserId(); + } elseif ($customerId === false) { + $customerId = null; + } + + $catalogPricesQuery = app(CatalogPricing::class) + ->createCatalogPricesQuery(userId: $customerId) + ->addSelect(['cp.purchasableId', 'cp.storeId']); + + $this->query->leftJoinSub($catalogPricesQuery, 'catalogprices', function($join) { + $join->on('catalogprices.purchasableId', '=', 'commerce_purchasables.id') + ->on('catalogprices.storeId', '=', 'sitestores.storeId'); + }); + + // `salePrice` is deliberately not selected: it's a getter-only virtual attribute + // (Purchasable::getSalePrice()) with no setter, so populating it from the row would + // throw "Setting read-only property". It's still usable below as a where-filter column, + // since that only references it, it doesn't try to write it back to the element. + $this->query->addSelect([ + 'catalogprices.price', + 'catalogprices.promotionalPrice', + ]); + + // Joined here (rather than the previous correlated selectSub()), because + // ElementQuery::applySelectParams() unwraps any Expression back into a plain + // "column [as alias]" string and re-wraps it as an identifier, which mangles anything + // more complex than a bare column reference (e.g. a subquery-as-column expression). + $this->query->leftJoinSub( + DB::table(Table::CATALOG_PRICING . ' as cpr') + ->select(['purchasableId', 'storeId', DB::raw('MIN(catalogPricingRuleId) as catalogPricingRuleId')]) + ->whereNotNull('catalogPricingRuleId') + ->groupBy(['purchasableId', 'storeId']), + 'catalogpricingruleids', + function($join) { + $join->on('catalogpricingruleids.purchasableId', '=', 'commerce_purchasables.id') + ->on('catalogpricingruleids.storeId', '=', 'sitestores.storeId'); + }, + ); + + $this->query->addSelect(['catalogpricingruleids.catalogPricingRuleId']); + + if (isset($this->price)) { + $this->query->whereParam('catalogprices.price', $this->price); + } + + if (isset($this->promotionalPrice)) { + $this->query->whereParam('catalogprices.promotionalPrice', $this->promotionalPrice); + } + + if (isset($this->onPromotion)) { + if ($this->onPromotion) { + $this->query->whereColumn('catalogprices.promotionalPrice', '<', 'catalogprices.price'); + } else { + $this->query->whereColumn('catalogprices.price', '=', 'catalogprices.promotionalPrice'); + } + } + + if (isset($this->salePrice)) { + $this->query->whereParam('catalogprices.salePrice', $this->salePrice); + } + } else { + // `salePrice` and `catalogPricingRuleId` are deliberately not selected here: `salePrice` is a + // getter-only virtual attribute (Purchasable::getSalePrice()) with no setter, so populating it + // from the row would throw "Setting read-only property"; `catalogPricingRuleId` has no meaningful + // value without catalog pricing rules and already defaults to null on the element. Both are still + // usable below as where-filter expressions/columns, since that only reads them. + $this->query->addSelect([ + 'purchasables_stores.basePrice as price', + 'purchasables_stores.basePromotionalPrice as promotionalPrice', + ]); + + if (isset($this->price)) { + $this->query->whereParam('purchasables_stores.basePrice', $this->price); + } + + if (isset($this->promotionalPrice)) { + $this->query->whereParam('purchasables_stores.basePromotionalPrice', $this->promotionalPrice); + } + + if (isset($this->onPromotion)) { + if ($this->onPromotion) { + $this->query->whereColumn('purchasables_stores.basePromotionalPrice', '<', 'purchasables_stores.basePrice'); + } else { + $this->query->whereColumn('purchasables_stores.basePrice', '<', 'purchasables_stores.basePromotionalPrice'); + } + } + + if (isset($this->salePrice)) { + $this->query->whereParam(DB::raw('CASE WHEN purchasables_stores.basePromotionalPrice < purchasables_stores.basePrice THEN purchasables_stores.basePromotionalPrice ELSE purchasables_stores.basePrice END'), $this->salePrice); + } + } + + $this->beforeQuery(function(self $query) { + if (isset($query->sku)) { + $query->whereParam('commerce_purchasables.sku', $query->sku); + } + + // We don't join the inventory levels table, and rely on the cached store available total. + if (isset($query->stock)) { + $query->whereParam('purchasables_stores.stock', $query->stock); + } + + if (isset($query->inventoryTracked)) { + $query->whereParam('purchasables_stores.inventoryTracked', $query->inventoryTracked); + } + + if (isset($query->availableForPurchase)) { + $query->where('purchasables_stores.availableForPurchase', $query->availableForPurchase); + } + + if (isset($query->shippingCategoryId)) { + $query->whereParam('purchasables_stores.shippingCategoryId', $query->shippingCategoryId); + } + + if (isset($query->taxCategoryId)) { + $query->whereParam('commerce_purchasables.taxCategoryId', $query->taxCategoryId); + } + + if ($query->width !== false) { + if ($query->width === null) { + $query->whereNull('commerce_purchasables.width'); + } else { + $query->whereParam('commerce_purchasables.width', $query->width); + } + } + + if ($query->height !== false) { + if ($query->height === null) { + $query->whereNull('commerce_purchasables.height'); + } else { + $query->whereParam('commerce_purchasables.height', $query->height); + } + } + + if ($query->length !== false) { + if ($query->length === null) { + $query->whereNull('commerce_purchasables.length'); + } else { + $query->whereParam('commerce_purchasables.length', $query->length); + } + } + + if ($query->weight !== false) { + if ($query->weight === null) { + $query->whereNull('commerce_purchasables.weight'); + } else { + $query->whereParam('commerce_purchasables.weight', $query->weight); + } + } + + if (isset($query->hasStock)) { + if ($query->hasStock) { + $query->where(function($q) { + $q->where('purchasables_stores.inventoryTracked', false) + ->orWhere(function($q2) { + $q2->where('purchasables_stores.inventoryTracked', true) + ->where('purchasables_stores.stock', '>', 0); + }); + }); + } else { + $query->where('purchasables_stores.inventoryTracked', true) + ->where('purchasables_stores.stock', '<', 1); + } + } + }); + } + + public function availableForPurchase(?bool $value = true): static + { + $this->availableForPurchase = $value; + return $this; + } + + public function sku(mixed $value): static + { + $this->sku = $value; + return $this; + } + + public function stock(mixed $value): static + { + $this->stock = $value; + return $this; + } + + public function hasStock(?bool $value = true): static + { + $this->hasStock = $value; + return $this; + } + + public function forCustomer(int|false|null $value = null): static + { + $this->forCustomer = $value; + return $this; + } + + public function width(mixed $value): static + { + $this->width = $value; + return $this; + } + + public function height(mixed $value): static + { + $this->height = $value; + return $this; + } + + public function length(mixed $value): static + { + $this->length = $value; + return $this; + } + + public function weight(mixed $value): static + { + $this->weight = $value; + return $this; + } + + public function price(mixed $value): static + { + $this->price = $value; + return $this; + } + + public function inventoryTracked(?bool $value = true): static + { + $this->inventoryTracked = $value; + return $this; + } + + public function promotionalPrice(mixed $value): static + { + $this->promotionalPrice = $value; + return $this; + } + + public function salePrice(mixed $value): static + { + $this->salePrice = $value; + return $this; + } + + public function shippingCategoryId(mixed $value): static + { + $this->shippingCategoryId = $value; + return $this; + } + + public function shippingCategory(mixed $value): static + { + if ($value instanceof ShippingCategory) { + $this->shippingCategoryId = [$value->id]; + } elseif ($value !== null) { + $this->shippingCategoryId = DB::table(Table::SHIPPINGCATEGORIES . ' as shippingcategories') + ->whereColumn('shippingcategories.id', 'purchasables_stores.shippingCategoryId') + ->whereParam('handle', $value) + ->select('shippingcategories.id'); + } else { + $this->shippingCategoryId = null; + } + + return $this; + } + + public function taxCategoryId(mixed $value): static + { + $this->taxCategoryId = $value; + return $this; + } + + public function taxCategory(mixed $value): static + { + if ($value instanceof TaxCategory) { + $this->taxCategoryId = [$value->id]; + } elseif ($value !== null) { + $this->taxCategoryId = DB::table(Table::TAXCATEGORIES . ' as taxcategories') + ->whereColumn('taxcategories.id', 'commerce_purchasables.taxCategoryId') + ->whereParam('handle', $value) + ->select('taxcategories.id'); + } else { + $this->taxCategoryId = null; + } + + return $this; + } + + public function onPromotion(?bool $value = true): static + { + $this->onPromotion = $value; + return $this; + } +} diff --git a/src/Purchasable/Records/Purchasable.php b/src/Purchasable/Records/Purchasable.php new file mode 100644 index 0000000000..cb86ba1bfa --- /dev/null +++ b/src/Purchasable/Records/Purchasable.php @@ -0,0 +1,37 @@ + 'float', + 'height' => 'float', + 'length' => 'float', + 'weight' => 'float', + 'taxCategoryId' => 'integer', + ]; +} diff --git a/src/Purchasable/Records/PurchasableStore.php b/src/Purchasable/Records/PurchasableStore.php new file mode 100644 index 0000000000..e12fe76b9e --- /dev/null +++ b/src/Purchasable/Records/PurchasableStore.php @@ -0,0 +1,44 @@ + 'integer', + 'storeId' => 'integer', + 'basePrice' => 'float', + 'basePromotionalPrice' => 'float', + 'stock' => 'integer', + 'inventoryTracked' => 'boolean', + 'allowOutOfStockPurchases' => 'boolean', + 'minQty' => 'integer', + 'maxQty' => 'integer', + 'promotable' => 'boolean', + 'availableForPurchase' => 'boolean', + 'freeShipping' => 'boolean', + 'shippingCategoryId' => 'integer', + ]; +} diff --git a/src/Purchasable/Validation/DonationRules.php b/src/Purchasable/Validation/DonationRules.php new file mode 100644 index 0000000000..90515a9625 --- /dev/null +++ b/src/Purchasable/Validation/DonationRules.php @@ -0,0 +1,33 @@ +subject->sku)) { + $this->subject->sku = trim($this->subject->sku); + } + } + + public function rules(): array + { + $rules = parent::rules(); + + $rules['sku'][] = Rule::when( + fn() => $this->subject->availableForPurchase && $this->subject->enabled, + ['required'], + ); + + return $rules; + } +} diff --git a/src/Purchasable/Validation/PurchasableRules.php b/src/Purchasable/Validation/PurchasableRules.php new file mode 100644 index 0000000000..444d6e42e9 --- /dev/null +++ b/src/Purchasable/Validation/PurchasableRules.php @@ -0,0 +1,50 @@ +inScenarios(self::SCENARIO_LIVE), [ + 'required', + new UniqueCaseInsensitiveRule(Table::PURCHASABLES, 'sku') + ->where(fn($query) => $query->whereIn('id', DB::table(CraftTable::ELEMENTS)->whereNull('revisionId')->whereNull('draftId')->select('id'))) + ->ignore($this->subject->id), + ]), + ]; + $rules['price'] = ['nullable', 'numeric', Rule::when($this->inScenarios(self::SCENARIO_LIVE), ['required'])]; + $rules['promotionalPrice'] = ['nullable', 'numeric']; + $rules['weight'] = ['nullable', 'numeric']; + $rules['width'] = ['nullable', 'numeric']; + $rules['length'] = ['nullable', 'numeric']; + $rules['height'] = ['nullable', 'numeric']; + $rules['basePrice'] = ['nullable', 'numeric']; + $rules['basePromotionalPrice'] = ['nullable', 'numeric']; + $rules['minQty'] = ['nullable', 'numeric']; + $rules['maxQty'] = ['nullable', 'numeric']; + $rules['freeShipping'] = ['boolean']; + $rules['inventoryTracked'] = ['boolean']; + $rules['allowOutOfStockPurchases'] = ['boolean']; + $rules['promotable'] = ['boolean']; + $rules['availableForPurchase'] = ['boolean']; + $rules['taxCategoryId'] = ['required', 'integer']; + $rules['shippingCategoryId'] = ['required', 'integer']; + + return $rules; + } +} diff --git a/src/Report/Events/ReportEvent.php b/src/Report/Events/ReportEvent.php new file mode 100644 index 0000000000..a3d66a29c3 --- /dev/null +++ b/src/Report/Events/ReportEvent.php @@ -0,0 +1,19 @@ + t('Grams (g)', category: 'commerce'), + 'kg' => t('Kilograms (kg)', category: 'commerce'), + 'lb' => t('Pounds (lb)', category: 'commerce'), + ]; + } + + public function getDimensionUnits(): array + { + return [ + 'mm' => t('Millimeters (mm)', category: 'commerce'), + 'cm' => t('Centimeters (cm)', category: 'commerce'), + 'm' => t('Meters (m)', category: 'commerce'), + 'ft' => t('Feet (ft)', category: 'commerce'), + 'in' => t('Inches (in)', category: 'commerce'), + ]; + } + + /** + * @throws SiteNotFoundException + * @throws \InvalidArgumentException + */ + public function getPaymentCurrency(?string $siteHandle = null): ?string + { + $site = $siteHandle ? Sites::getSiteByHandle($siteHandle) : Sites::getPrimarySite(); + if (!$site) { + throw new \InvalidArgumentException("Invalid site: $siteHandle"); + } + + $paymentCurrency = Config::localizedValue($this->paymentCurrency, $siteHandle); + $store = app(Stores::class)->getStoreBySiteId($site->id); + $allPaymentCurrencies = app(PaymentCurrencies::class)->getAllPaymentCurrencies($store?->id); + + if ($paymentCurrency && !$allPaymentCurrencies->contains('iso', '==', $paymentCurrency)) { + throw new \InvalidArgumentException("Invalid payment currency: $paymentCurrency"); + } + + return $paymentCurrency; + } + + public function getDefaultViewOptions(): array + { + return [ + self::VIEW_URI_ORDERS => t('Orders', category: 'commerce'), + self::VIEW_URI_PRODUCTS => t('Products', category: 'commerce'), + self::VIEW_URI_INVENTORY => t('Inventory', category: 'commerce'), + self::VIEW_URI_STORE_MANAGEMENT => t('Store Management', category: 'commerce'), + ]; + } + + #[\Override] + protected function defineRules(): array + { + return [ + [['weightUnits', 'dimensionUnits'], 'required'], + [['weightUnits', 'dimensionUnits'], 'string'], + ]; + } +} diff --git a/src/Shipping/Contracts/ShippingMethodInterface.php b/src/Shipping/Contracts/ShippingMethodInterface.php new file mode 100644 index 0000000000..cc4b93121d --- /dev/null +++ b/src/Shipping/Contracts/ShippingMethodInterface.php @@ -0,0 +1,33 @@ + */ + public function getShippingRules(): Collection; + + public function getIsEnabled(): bool; + + public function getPriceForOrder(Order $order): float; + + public function getMatchingShippingRule(Order $order): ?ShippingRuleInterface; + + public function matchOrder(Order $order): bool; +} diff --git a/src/Shipping/Contracts/ShippingRuleInterface.php b/src/Shipping/Contracts/ShippingRuleInterface.php new file mode 100644 index 0000000000..4f0faf0a2e --- /dev/null +++ b/src/Shipping/Contracts/ShippingRuleInterface.php @@ -0,0 +1,30 @@ +_shippingMethods = $shippingMethods; + } + + public function getShippingMethods(): Collection + { + if ($this->_shippingMethods === null) { + $this->_shippingMethods = collect(); + } + + return $this->_shippingMethods; + } +} diff --git a/src/Shipping/Exceptions/ShippingMethodException.php b/src/Shipping/Exceptions/ShippingMethodException.php new file mode 100644 index 0000000000..ef309b9ac8 --- /dev/null +++ b/src/Shipping/Exceptions/ShippingMethodException.php @@ -0,0 +1,9 @@ + + */ + private array $_matchingRuleByOrderNumber = []; + + public function getType(): string + { + throw new \BadMethodCallException('Not implemented.'); + } + + public function getId(): ?int + { + throw new \BadMethodCallException('Not implemented.'); + } + + public function getName(): string + { + throw new \BadMethodCallException('Not implemented.'); + } + + public function getHandle(): string + { + throw new \BadMethodCallException('Not implemented.'); + } + + public function getCpEditUrl(): string + { + throw new \BadMethodCallException('Not implemented.'); + } + + public function getShippingRules(): Collection + { + return collect(); + } + + public function getIsEnabled(): bool + { + throw new \BadMethodCallException('Not implemented.'); + } + + public function setOrderCondition(ShippingMethodOrderCondition|string|array|null $condition): void + { + if (empty($condition)) { + $this->_orderCondition = null; + return; + } + + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof ShippingMethodOrderCondition) { + $condition['class'] = ShippingMethodOrderCondition::class; + // Inject storeId so condition rules can call getCondition()->getStore() during init. + if ($this->storeId !== null && !isset($condition['storeId'])) { + $condition['storeId'] = $this->storeId; + } + $condition = Conditions::createCondition($condition); + } + $condition->forProjectConfig = false; + + /** @var ShippingMethodOrderCondition $condition */ + $this->_orderCondition = $condition; + } + + public function getOrderCondition(): ShippingMethodOrderCondition + { + $condition = $this->_orderCondition ?? new ShippingMethodOrderCondition(); + $condition->mainTag = 'div'; + $condition->name = 'orderCondition'; + $condition->storeId = $this->storeId; + + return $condition; + } + + public function setCustomerCondition(ShippingMethodCustomerCondition|string|array|null $condition): void + { + if (empty($condition)) { + $this->_customerCondition = null; + return; + } + + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof ShippingMethodCustomerCondition) { + $condition['class'] = ShippingMethodCustomerCondition::class; + $condition = Conditions::createCondition($condition); + } + $condition->forProjectConfig = false; + + /** @var ShippingMethodCustomerCondition $condition */ + $this->_customerCondition = $condition; + } + + public function getCustomerCondition(): ShippingMethodCustomerCondition + { + $condition = $this->_customerCondition ?? new ShippingMethodCustomerCondition(); + $condition->mainTag = 'div'; + $condition->name = 'customerCondition'; + + return $condition; + } + + public function matchOrder(Order $order): bool + { + if (!$this->getOrderCondition()->matchElement($order)) { + return false; + } + + $customer = $order->getCustomer(); + if (!$customer && !empty($this->getCustomerCondition()->getConditionRules())) { + return false; + } + + if ($customer && !$this->getCustomerCondition()->matchElement($customer)) { + return false; + } + + if ($this->getMatchingShippingRule($order)) { + return true; + } + + return false; + } + + public function getMatchingShippingRule(Order $order): ?ShippingRuleInterface + { + if (array_key_exists($order->number, $this->_matchingRuleByOrderNumber)) { + return $this->_matchingRuleByOrderNumber[$order->number]; + } + + foreach ($this->getShippingRules() as $rule) { + /** @var ShippingRuleInterface $rule */ + if ($rule->matchOrder($order)) { + return $this->_matchingRuleByOrderNumber[$order->number] = $rule; + } + } + + return $this->_matchingRuleByOrderNumber[$order->number] = null; + } + + /** + * @return void + */ + public function clearMatchingShippingRuleCache(): void + { + $this->_matchingRuleByOrderNumber = []; + } + + public function getPriceForOrder(Order $order): float + { + $shippingRule = $this->getMatchingShippingRule($order); + $lineItems = $order->getLineItems(); + + if (!$shippingRule) { + return 0; + } + + $nonShippableItems = []; + + foreach ($lineItems as $item) { + if ($item->getIsShippable()) { + continue; + } + + $nonShippableItems[$item->id] = $item->id; + } + + if (count($lineItems) == count($nonShippableItems)) { + return 0; + } + + $amount = $shippingRule->getBaseRate(); + + foreach ($order->getLineItems() as $item) { + if ($item->getHasFreeShipping()) { + continue; + } + + if (!$item->getIsShippable()) { + continue; + } + + $percentageRate = $shippingRule->getPercentageRate($item->shippingCategoryId); + $perItemRate = $shippingRule->getPerItemRate($item->shippingCategoryId); + $weightRate = $shippingRule->getWeightRate($item->shippingCategoryId); + + $percentageAmount = $item->getSubtotal() * $percentageRate; + $perItemAmount = $item->qty * $perItemRate; + $weightAmount = ($item->weight * $item->qty) * $weightRate; + + $amount += ($percentageAmount + $perItemAmount + $weightAmount); + } + + $amount = max($amount, $shippingRule->getMinRate()); + + if ($shippingRule->getMaxRate()) { + $amount = min($amount, $shippingRule->getMaxRate()); + } + + return $amount; + } +} diff --git a/src/Shipping/Models/ShippingAddressZone.php b/src/Shipping/Models/ShippingAddressZone.php new file mode 100644 index 0000000000..3308bcdf34 --- /dev/null +++ b/src/Shipping/Models/ShippingAddressZone.php @@ -0,0 +1,58 @@ +where('storeId', $this->storeId); + + return $rules; + } + + #[\Override] + public function getCpEditUrl(): string + { + return Url::cpUrl('commerce/store-management/' . $this->getStore()->handle . '/shippingzones/' . $this->id); + } + + #[\Override] + public static function get(int|string $id): ?static + { + // TODO: migrate to app(ShippingZones::class)->getShippingZoneById() once service migrated to src/ + foreach (app(Stores::class)->getAllStores() as $store) { + $zone = app(ShippingZones::class)->getShippingZoneById((int)$id, $store->id); + if ($zone !== null) { + /** @phpstan-ignore-next-line */ + return $zone; + } + } + return null; + } + + #[\Override] + public function getUiLabel(): string + { + return t($this->name ?? '', category: 'site'); + } + + #[\Override] + public function getId(): ?int + { + return $this->id; + } +} diff --git a/src/Shipping/Models/ShippingCategory.php b/src/Shipping/Models/ShippingCategory.php new file mode 100644 index 0000000000..2d8977dee6 --- /dev/null +++ b/src/Shipping/Models/ShippingCategory.php @@ -0,0 +1,135 @@ +name; + } + + #[\Override] + public static function get(int|string $id): ?static + { + $site = app(RequestedSite::class)->get(); + $storeId = $site ? app(Stores::class)->getStoreBySiteId($site->id)?->id : null; + + // TODO: migrate to app(ShippingCategories::class)->getShippingCategoryById() once service migrated to src/ + /** @phpstan-ignore-next-line */ + return app(ShippingCategories::class)->getShippingCategoryById($id, $storeId); + } + + #[\Override] + public function getId(): ?int + { + return $this->id; + } + + #[\Override] + public function getUiLabel(): string + { + return t($this->name ?? '', category: 'site'); + } + + #[\Override] + public function getIcon(): ?string + { + return $this->icon; + } + + #[\Override] + public function getColor(): ?Color + { + return $this->color ? Color::tryFrom($this->color) : null; + } + + #[\Override] + public function getStore(): \CraftCms\Commerce\Store\Models\Store + { + if (!$store = app(Stores::class)->getStoreById($this->storeId)) { + throw new \InvalidArgumentException('Invalid store ID: ' . $this->storeId); + } + + return $store; + } + + public function getCpEditUrl(): string + { + return $this->getStore()->getStoreSettingsUrl('shippingcategories/' . $this->id); + } + + public function setProductTypes(array $productTypes): void + { + $this->_productTypes = $productTypes; + } + + public function getProductTypes(): array + { + if (!isset($this->_productTypes) && $this->id) { + $this->_productTypes = app(ProductTypes::class)->getProductTypesByShippingCategoryId($this->id); + } + + return $this->_productTypes ?? []; + } + + public function getProductTypeIds(): array + { + return array_column($this->getProductTypes(), 'id'); + } + + #[\Override] + public function getRules(): array + { + return [ + 'name' => ['required', 'string'], + 'handle' => ['required', 'string', 'regex:/^[a-zA-Z_][a-zA-Z0-9_]*$/'], + ]; + } + + #[\Override] + public function extraFields(): array + { + return array_merge(parent::extraFields(), ['productTypes', 'productTypeIds', 'uiLabel']); + } +} diff --git a/src/Shipping/Models/ShippingMethod.php b/src/Shipping/Models/ShippingMethod.php new file mode 100644 index 0000000000..ae06b3a1ff --- /dev/null +++ b/src/Shipping/Models/ShippingMethod.php @@ -0,0 +1,115 @@ +id; + } + + #[\Override] + public function getName(): string + { + return (string)$this->name; + } + + #[\Override] + public function getHandle(): string + { + return (string)$this->handle; + } + + #[\Override] + public function getShippingRules(): Collection + { + if ($this->id === null) { + return collect(); + } + + return app(\CraftCms\Commerce\Shipping\ShippingRules::class)->getAllShippingRulesByShippingMethodId($this->id); + } + + #[\Override] + public function getIsEnabled(): bool + { + return $this->enabled; + } + + #[\Override] + public function getCpEditUrl(): string + { + return $this->getStore()->getStoreSettingsUrl('shippingmethods/' . $this->id); + } + + public static function get(int|string $id): ?static + { + /** @phpstan-ignore-next-line */ + return app(ShippingMethods::class)->getShippingMethodById($id); + } + + public function getUiLabel(): string + { + return t($this->name ?? '', category: 'site'); + } + + #[\Override] + public function getRules(): array + { + return [ + 'name' => [ + 'required', + 'string', + Rule::unique(Table::SHIPPINGMETHODS, 'name')->where('storeId', $this->storeId)->ignore($this->id), + ], + 'handle' => [ + 'required', + 'string', + Rule::unique(Table::SHIPPINGMETHODS, 'handle')->where('storeId', $this->storeId)->ignore($this->id), + ], + ]; + } + + public function getIcon(): ?string + { + return $this->icon; + } + + public function getColor(): ?Color + { + return $this->color ? Color::tryFrom($this->color) : null; + } + + public static function statuses(): array + { + return [ + 'enabled' => ['label' => t('Enabled', category: 'commerce'), 'color' => 'green'], + 'disabled' => ['label' => t('Disabled', category: 'commerce'), 'color' => 'red'], + ]; + } + + public function getStatus(): ?string + { + return $this->enabled ? 'enabled' : 'disabled'; + } +} diff --git a/src/Shipping/Models/ShippingMethodOption.php b/src/Shipping/Models/ShippingMethodOption.php new file mode 100644 index 0000000000..9473b19a58 --- /dev/null +++ b/src/Shipping/Models/ShippingMethodOption.php @@ -0,0 +1,30 @@ +price; + } + + public function setOrder(Order $order): void + { + $this->_order = $order; + } +} diff --git a/src/Shipping/Models/ShippingRule.php b/src/Shipping/Models/ShippingRule.php new file mode 100644 index 0000000000..84d792da02 --- /dev/null +++ b/src/Shipping/Models/ShippingRule.php @@ -0,0 +1,351 @@ + ['required', 'string'], + 'methodId' => ['required', 'integer'], + 'priority' => ['required', 'integer'], + 'enabled' => ['required', 'boolean'], + 'baseRate' => ['required', 'numeric'], + 'perItemRate' => ['required', 'numeric'], + 'weightRate' => ['required', 'numeric'], + 'percentageRate' => ['required', 'numeric'], + 'minRate' => ['required', 'numeric'], + 'maxRate' => ['required', 'numeric'], + 'orderConditionFormula' => [ + 'nullable', + 'string', + 'min:1', + 'max:65000', + function(string $attribute, mixed $value, \Closure $fail) { + if ($value) { + $order = Order::find()->one() ?? new Order(); + $orderAsArray = app(\CraftCms\Commerce\Shipping\ShippingMethods::class)->getSerializedOrderForMatchingRules($order); + if (!app(Formulas::class)->validateConditionSyntax($value, ['order' => $orderAsArray])) { + $fail(t('Invalid order condition syntax.', category: 'commerce')); + } + } + }, + ], + 'shippingRuleCategories' => [ + 'sometimes', + function(string $attribute, mixed $value, \Closure $fail) { + if (!empty($value)) { + foreach ($value as $key => $ruleCategory) { + if (!$ruleCategory->validate()) { + $this->addModelErrors($ruleCategory, $attribute . '.' . $key); + } + } + } + }, + ], + ]; + } + + public function getIsEnabled(): bool + { + return $this->enabled; + } + + public function setOrderCondition(ShippingRuleOrderCondition|string|array|null $condition): void + { + if (empty($condition)) { + $this->_orderCondition = null; + return; + } + + $this->_orderCondition = $condition; + } + + public function getOrderCondition(): ShippingRuleOrderCondition + { + if ($this->_orderCondition instanceof ShippingRuleOrderCondition) { + return $this->_orderCondition; + } + + $condition = $this->_orderCondition ?? []; + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + $condition['class'] = ShippingRuleOrderCondition::class; + // Inject storeId so condition rules can call getCondition()->getStore() during init. + if ($this->storeId !== null && !isset($condition['storeId'])) { + $condition['storeId'] = $this->storeId; + } + $condition = Conditions::createCondition($condition); + /** @var ShippingRuleOrderCondition $condition */ + $condition->forProjectConfig = false; + $condition->mainTag = 'div'; + $condition->name = 'orderCondition'; + $condition->storeId = $this->storeId; + + $this->_orderCondition = $condition; + + return $this->_orderCondition; + } + + public function setCustomerCondition(ShippingRuleCustomerCondition|string|array|null $condition): void + { + if (empty($condition)) { + $this->_customerCondition = null; + return; + } + + $this->_customerCondition = $condition; + } + + public function getCustomerCondition(): ShippingRuleCustomerCondition + { + if ($this->_customerCondition instanceof ShippingRuleCustomerCondition) { + return $this->_customerCondition; + } + + $condition = $this->_customerCondition ?? []; + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + $condition['class'] = ShippingRuleCustomerCondition::class; + $condition = Conditions::createCondition($condition); + /** @var ShippingRuleCustomerCondition $condition */ + $condition->forProjectConfig = false; + $condition->mainTag = 'div'; + $condition->name = 'customerCondition'; + + $this->_customerCondition = $condition; + + return $this->_customerCondition; + } + + public function matchOrder(Order $order): bool + { + if (!$this->enabled) { + return false; + } + + $lineItems = $order->getLineItems(); + + $nonShippableItems = []; + foreach ($lineItems as $item) { + if ($item->getIsShippable()) { + continue; + } + + $nonShippableItems[$item->id] = $item->id; + } + + if (count($nonShippableItems) > 0 && count($lineItems) == count($nonShippableItems)) { + return false; + } + + $shippingRuleCategories = $this->getShippingRuleCategories(); + $orderShippingCategories = $this->_getUniqueCategoryIdsInOrder($order); + [$disallowedCategories, $requiredCategories] = $this->_getRequiredAndDisallowedCategoriesFromRule($shippingRuleCategories); + + if (!empty(array_intersect($orderShippingCategories, $disallowedCategories))) { + return false; + } + + if (!empty(array_diff($requiredCategories, $orderShippingCategories))) { + return false; + } + + if (!$this->getOrderCondition()->matchElement($order)) { + return false; + } + + $customer = $order->getCustomer(); + if (!$customer && !empty($this->getCustomerCondition()->getConditionRules())) { + return false; + } + + if ($customer && !$this->getCustomerCondition()->matchElement($customer)) { + return false; + } + + if ($this->orderConditionFormula) { + $orderAsArray = app(\CraftCms\Commerce\Shipping\ShippingMethods::class)->getSerializedOrderForMatchingRules($order); + if (!app(Formulas::class)->evaluateCondition($this->orderConditionFormula, ['order' => $orderAsArray], 'Evaluate Shipping Rule Order Condition Formula')) { + return false; + } + } + + return true; + } + + public function getShippingRuleCategories(): array + { + if ($this->_shippingRuleCategories === null && $this->id) { + $this->_shippingRuleCategories = app(\CraftCms\Commerce\Shipping\ShippingRuleCategories::class)->getShippingRuleCategoriesByRuleId($this->id); + } + + return $this->_shippingRuleCategories ?? []; + } + + public function setShippingRuleCategories(array $models): void + { + $this->_shippingRuleCategories = $models; + } + + public function getOptions(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'methodId' => $this->methodId, + 'priority' => $this->priority, + 'enabled' => $this->enabled, + 'orderConditionFormula' => $this->orderConditionFormula, + 'baseRate' => $this->baseRate, + 'perItemRate' => $this->perItemRate, + 'percentageRate' => $this->percentageRate, + 'weightRate' => $this->weightRate, + 'minRate' => $this->minRate, + 'maxRate' => $this->maxRate, + 'storeId' => $this->storeId, + ]; + } + + public function getPercentageRate(?int $shippingCategoryId = null): float + { + return $this->_getRate('percentageRate', $shippingCategoryId); + } + + public function getPerItemRate(?int $shippingCategoryId = null): float + { + return $this->_getRate('perItemRate', $shippingCategoryId); + } + + public function getWeightRate(?int $shippingCategoryId = null): float + { + return $this->_getRate('weightRate', $shippingCategoryId); + } + + public function getBaseRate(): float + { + return (float)$this->baseRate; + } + + public function getMaxRate(): float + { + return (float)$this->maxRate; + } + + public function getMinRate(): float + { + return (float)$this->minRate; + } + + public function getDescription(): string + { + return $this->description ?? ''; + } + + private function _getUniqueCategoryIdsInOrder(Order $order): array + { + $orderShippingCategories = []; + foreach ($order->getLineItems() as $lineItem) { + if (!$lineItem->getIsShippable()) { + continue; + } + + $orderShippingCategories[] = $lineItem->shippingCategoryId; + } + + return array_unique($orderShippingCategories); + } + + private function _getRequiredAndDisallowedCategoriesFromRule(array $shippingRuleCategories): array + { + $disallowedCategories = []; + $requiredCategories = []; + foreach ($shippingRuleCategories as $ruleCategory) { + if ($ruleCategory->condition === ShippingRuleCategoryRecord::CONDITION_DISALLOW) { + $disallowedCategories[] = $ruleCategory->shippingCategoryId; + } + + if ($ruleCategory->condition === ShippingRuleCategoryRecord::CONDITION_REQUIRE) { + $requiredCategories[] = $ruleCategory->shippingCategoryId; + } + } + + return [$disallowedCategories, $requiredCategories]; + } + + private function _getRate(string $attribute, ?int $shippingCategoryId = null): float + { + if (!$shippingCategoryId) { + return (float)$this->$attribute; + } + + foreach ($this->getShippingRuleCategories() as $ruleCategory) { + if ($shippingCategoryId === $ruleCategory->shippingCategoryId && $ruleCategory->$attribute !== null) { + return (float)$ruleCategory->$attribute; + } + } + + return (float)$this->$attribute; + } +} diff --git a/src/Shipping/Models/ShippingRuleCategory.php b/src/Shipping/Models/ShippingRuleCategory.php new file mode 100644 index 0000000000..847b0c86cf --- /dev/null +++ b/src/Shipping/Models/ShippingRuleCategory.php @@ -0,0 +1,47 @@ + ['required', 'in:allow,disallow,require'], + 'perItemRate' => ['nullable', 'numeric'], + 'weightRate' => ['nullable', 'numeric'], + 'percentageRate' => ['nullable', 'numeric'], + ]; + } + + public function getRule(): ShippingRule + { + return app(ShippingRules::class)->getShippingRuleById($this->shippingRuleId); + } + + public function getCategory(): ShippingCategory + { + return app(ShippingCategories::class)->getShippingCategoryById($this->shippingCategoryId); + } +} diff --git a/src/Shipping/Records/ShippingCategory.php b/src/Shipping/Records/ShippingCategory.php new file mode 100644 index 0000000000..34c7c05961 --- /dev/null +++ b/src/Shipping/Records/ShippingCategory.php @@ -0,0 +1,32 @@ + 'integer', + 'default' => 'boolean', + ]; +} diff --git a/src/Shipping/Records/ShippingMethod.php b/src/Shipping/Records/ShippingMethod.php new file mode 100644 index 0000000000..75656e02a3 --- /dev/null +++ b/src/Shipping/Records/ShippingMethod.php @@ -0,0 +1,31 @@ + 'integer', + 'enabled' => 'boolean', + 'orderCondition' => 'array', + 'customerCondition' => 'array', + ]; +} diff --git a/src/Shipping/Records/ShippingRule.php b/src/Shipping/Records/ShippingRule.php new file mode 100644 index 0000000000..fd039f28d6 --- /dev/null +++ b/src/Shipping/Records/ShippingRule.php @@ -0,0 +1,37 @@ + 'integer', + 'enabled' => 'boolean', + 'priority' => 'integer', + 'orderCondition' => 'array', + 'customerCondition' => 'array', + 'baseRate' => 'float', + 'perItemRate' => 'float', + 'weightRate' => 'float', + 'percentageRate' => 'float', + 'minRate' => 'float', + 'maxRate' => 'float', + ]; +} diff --git a/src/Shipping/Records/ShippingRuleCategory.php b/src/Shipping/Records/ShippingRuleCategory.php new file mode 100644 index 0000000000..17e6212831 --- /dev/null +++ b/src/Shipping/Records/ShippingRuleCategory.php @@ -0,0 +1,38 @@ + 'integer', + 'shippingCategoryId' => 'integer', + 'perItemRate' => 'float', + 'weightRate' => 'float', + 'percentageRate' => 'float', + ]; +} diff --git a/src/Shipping/Records/ShippingZone.php b/src/Shipping/Records/ShippingZone.php new file mode 100644 index 0000000000..e0b9047a2e --- /dev/null +++ b/src/Shipping/Records/ShippingZone.php @@ -0,0 +1,28 @@ + 'integer', + 'condition' => 'array', + ]; +} diff --git a/src/Shipping/ShippingCategories.php b/src/Shipping/ShippingCategories.php new file mode 100644 index 0000000000..c31f3cd3b6 --- /dev/null +++ b/src/Shipping/ShippingCategories.php @@ -0,0 +1,276 @@ +>|null */ + private ?array $allShippingCategories = null; + + /** + * @return Collection + */ + public function getAllShippingCategories(?int $storeId = null, bool $withTrashed = false): Collection + { + $storeId ??= $this->currentStoreId(); + + if ($this->allShippingCategories === null || !isset($this->allShippingCategories[$storeId])) { + $rows = $this->query(true)->where('storeId', $storeId)->get()->all(); + + $this->allShippingCategories ??= []; + + foreach ($rows as $row) { + $shippingCategory = new ShippingCategory((array) $row); + $this->allShippingCategories[$shippingCategory->storeId] ??= collect(); + $this->allShippingCategories[$shippingCategory->storeId]->push($shippingCategory); + } + } + + if (!isset($this->allShippingCategories[$storeId])) { + return collect(); + } + + return $this->allShippingCategories[$storeId]->filter( + fn(ShippingCategory $sc) => $withTrashed || $sc->dateDeleted === null, + ); + } + + /** + * @return array + */ + public function getAllShippingCategoriesAsList(?int $storeId = null): array + { + return $this->getAllShippingCategories($storeId) + ->mapWithKeys(fn(ShippingCategory $category) => [$category->id => $category->getUiLabel()]) + ->all(); + } + + public function getShippingCategoryById(int $shippingCategoryId, ?int $storeId = null): ?ShippingCategory + { + return $this->getAllShippingCategories($storeId)->firstWhere('id', $shippingCategoryId); + } + + public function getShippingCategoryByHandle(string $shippingCategoryHandle, ?int $storeId = null): ?ShippingCategory + { + return $this->getAllShippingCategories($storeId)->firstWhere('handle', $shippingCategoryHandle); + } + + public function getDefaultShippingCategory(int $storeId): ShippingCategory + { + $categories = $this->getAllShippingCategories($storeId); + $default = $categories->firstWhere('default', true) ?? $categories->first(); + + if (!$default) { + throw new \RuntimeException('Commerce must have at least one (default) shipping category set up.'); + } + + return $default; + } + + public function saveShippingCategory(ShippingCategory $shippingCategory, bool $runValidation = true): bool + { + if ($shippingCategory->id) { + $record = ShippingCategoryRecord::find($shippingCategory->id); + if (!$record) { + throw new \RuntimeException(t('No shipping category exists with the ID “{id}”', ['id' => $shippingCategory->id], category: 'commerce')); + } + } else { + $record = new ShippingCategoryRecord(); + } + + if ($runValidation && !$shippingCategory->validate()) { + return false; + } + + $record->name = $shippingCategory->name; + $record->storeId = $shippingCategory->storeId; + $record->handle = $shippingCategory->handle; + $record->description = $shippingCategory->description; + $record->icon = $shippingCategory->icon; + $record->color = $shippingCategory->color; + $record->default = $shippingCategory->default; + + $record->save(); + + $shippingCategory->id = $record->id; + + // If this was the default, clear default on all others in the same store. + if ($shippingCategory->default) { + ShippingCategoryRecord::withTrashed() + ->where('storeId', $record->storeId) + ->where('id', '!=', $record->id) + ->update(['default' => false]); + } + + $currentProductTypeIds = DB::table(Table::PRODUCTTYPES_SHIPPINGCATEGORIES) + ->where('shippingCategoryId', $shippingCategory->id) + ->pluck('productTypeId') + ->all(); + + $newProductTypeIds = collect($shippingCategory->getProductTypes())->pluck('id')->all(); + + $removedProductTypeIds = array_diff($currentProductTypeIds, $newProductTypeIds); + + // Update purchasables to the default shipping category when product types are removed. + if (!empty($removedProductTypeIds)) { + $defaultShippingCategory = $this->getDefaultShippingCategory($shippingCategory->storeId); + + $purchasableIds = DB::table(Table::PURCHASABLES_STORES . ' as ps') + ->join(Table::VARIANTS . ' as v', 'ps.purchasableId', '=', 'v.id') + ->join(Table::PRODUCTS . ' as p', 'v.primaryOwnerId', '=', 'p.id') + ->where('ps.shippingCategoryId', $shippingCategory->id) + ->where('ps.storeId', $shippingCategory->storeId) + ->whereIn('p.typeId', $removedProductTypeIds) + ->pluck('ps.purchasableId') + ->all(); + + if (!empty($purchasableIds)) { + DB::table(Table::PURCHASABLES_STORES) + ->whereIn('purchasableId', $purchasableIds) + ->where('storeId', $shippingCategory->storeId) + ->where('shippingCategoryId', $shippingCategory->id) + ->update(['shippingCategoryId' => $defaultShippingCategory->id]); + } + } + + foreach (array_diff($currentProductTypeIds, $newProductTypeIds) as $oldProductTypeId) { + $this->resaveVariantsByProductTypeId((int) $oldProductTypeId); + } + foreach (array_diff($newProductTypeIds, $currentProductTypeIds) as $newProductTypeId) { + $this->resaveVariantsByProductTypeId((int) $newProductTypeId); + } + + DB::table(Table::PRODUCTTYPES_SHIPPINGCATEGORIES) + ->where('shippingCategoryId', $shippingCategory->id) + ->delete(); + + $now = now()->toDateTimeString(); + foreach ($shippingCategory->getProductTypes() as $productType) { + DB::table(Table::PRODUCTTYPES_SHIPPINGCATEGORIES)->insert([ + 'productTypeId' => (int) $productType->id, + 'shippingCategoryId' => (int) $shippingCategory->id, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + } + + $this->allShippingCategories = null; + + return true; + } + + public function deleteShippingCategoryById(int $id): bool + { + $shippingCategory = ShippingCategoryRecord::find($id); + + if ($shippingCategory === null || $shippingCategory->default) { + return false; + } + + if ($shippingCategory->delete()) { + $this->allShippingCategories = null; + return true; + } + + return false; + } + + /** + * @return array + */ + public function getShippingCategoriesByProductTypeId(int $productTypeId): array + { + $rows = $this->query() + ->join(Table::PRODUCTTYPES_SHIPPINGCATEGORIES . ' as productTypeShippingCategories', 'shippingCategories.id', '=', 'productTypeShippingCategories.shippingCategoryId') + ->where('productTypeShippingCategories.productTypeId', $productTypeId) + ->get() + ->all(); + + if (empty($rows)) { + try { + $shippingCategory = $this->getAllShippingCategories()->firstWhere('default', true); + } catch (\RuntimeException) { + return []; + } + if (!$shippingCategory) { + return []; + } + + return [$shippingCategory->id => $shippingCategory]; + } + + $shippingCategories = []; + foreach ($rows as $row) { + $shippingCategory = new ShippingCategory((array) $row); + $shippingCategories[$shippingCategory->id] = $shippingCategory; + } + + return $shippingCategories; + } + + public function clearCaches(): void + { + $this->allShippingCategories = null; + } + + private function resaveVariantsByProductTypeId(int $productTypeId): void + { + dispatch(new ResaveElements( + elementType: Variant::class, + criteria: [ + 'typeId' => $productTypeId, + 'siteId' => '*', + 'unique' => true, + 'status' => null, + ], + updateSearchIndex: false, + )); + } + + private function query(bool $withTrashed = false): \Illuminate\Database\Query\Builder + { + $query = DB::table(Table::SHIPPINGCATEGORIES . ' as shippingCategories') + ->select([ + 'shippingCategories.dateCreated', + 'shippingCategories.dateDeleted', + 'shippingCategories.dateUpdated', + 'shippingCategories.default', + 'shippingCategories.description', + 'shippingCategories.handle', + 'shippingCategories.id', + 'shippingCategories.name', + 'shippingCategories.storeId', + ]); + + // Only add icon and color if the columns exist (for pre-migration compatibility). + if (Schema::hasColumn(Table::SHIPPINGCATEGORIES, 'icon')) { + $query->addSelect(['shippingCategories.icon', 'shippingCategories.color']); + } + + if (!$withTrashed) { + $query->whereNull('dateDeleted'); + } + + return $query; + } + + private function currentStoreId(): int + { + return app(Stores::class)->getCurrentStore()->id; + } +} diff --git a/src/Shipping/ShippingMethods.php b/src/Shipping/ShippingMethods.php new file mode 100644 index 0000000000..2b19f8681a --- /dev/null +++ b/src/Shipping/ShippingMethods.php @@ -0,0 +1,224 @@ +>|null */ + private ?array $allShippingMethods = null; + + /** @var array */ + private array $serializedOrdersByNumber = []; + + /** + * @return Collection + */ + public function getAllShippingMethods(?int $storeId = null): Collection + { + $storeId ??= $this->currentStoreId(); + + if ($this->allShippingMethods === null || !isset($this->allShippingMethods[$storeId])) { + $rows = $this->query()->where('storeId', $storeId)->get()->all(); + + $this->allShippingMethods ??= []; + + foreach ($rows as $row) { + $method = new ShippingMethod((array) $row); + $this->allShippingMethods[$method->storeId] ??= collect(); + $this->allShippingMethods[$method->storeId]->push($method); + } + } + + return $this->allShippingMethods[$storeId] ?? collect(); + } + + public function getShippingMethodByHandle(string $handle, ?int $storeId = null): ?ShippingMethod + { + return $this->getAllShippingMethods($storeId)->firstWhere('handle', $handle); + } + + public function getShippingMethodById(int $id, ?int $storeId = null): ?ShippingMethod + { + return $this->getAllShippingMethods($storeId)->firstWhere('id', $id); + } + + /** + * Returns all shipping methods that match the given order, sorted by price. + * + * @return array + */ + public function getMatchingShippingMethods(Order $order): array + { + $methods = $this->getAllShippingMethods($order->storeId); + + $event = new RegisterAvailableShippingMethodsEvent( + order: $order, + ); + $event->setShippingMethods($methods); + + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getShippingMethods()->hasEventHandlers(self::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS)) { + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getShippingMethods()->trigger(self::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS, $event); + } + + $matchingMethods = []; + foreach ($event->getShippingMethods() as $method) { + if ($method->getIsEnabled() && $method->matchOrder($order)) { + // Now we know the method matches, let's get the price + $totalPrice = $method->getPriceForOrder($order); + + $matchingMethods[$method->getHandle()] = [ + 'method' => $method, + 'price' => $totalPrice, + ]; + } + } + + uasort($matchingMethods, static fn($a, $b) => $a['price'] <=> $b['price']); + + $shippingMethods = []; + foreach ($matchingMethods as $item) { + $method = $item['method']; + $shippingMethods[$method->getHandle()] = $method; + + // Clear the matching cache in case things change in the future + if ($method instanceof BaseShippingMethod) { + $method->clearMatchingShippingRuleCache(); + } + } + + $this->serializedOrdersByNumber = []; + + return $shippingMethods; + } + + public function getSerializedOrderForMatchingRules(Order $order): array + { + if (isset($this->serializedOrdersByNumber[$order->number])) { + return $this->serializedOrdersByNumber[$order->number]; + } + + $fieldsAsArray = $order->getSerializedFieldValues(); + $orderAsArray = $order->toArray([], ['lineItems.snapshot', 'shippingAddress', 'billingAddress']); + $this->serializedOrdersByNumber[$order->number] = array_merge($orderAsArray, $fieldsAsArray); + + return $this->serializedOrdersByNumber[$order->number]; + } + + public function getMatchingShippingRule(Order $order, ShippingMethodInterface $method): ?ShippingRuleInterface + { + return $method->getMatchingShippingRule($order); + } + + public function saveShippingMethod(ShippingMethod $model, bool $runValidation = true): bool + { + if ($model->id) { + $record = ShippingMethodRecord::find($model->id); + if (!$record) { + throw new \RuntimeException(t('No shipping method exists with the ID "{id}"', ['id' => $model->id], category: 'commerce')); + } + } else { + $record = new ShippingMethodRecord(); + } + + if ($runValidation && !$model->validate()) { + return false; + } + + $record->storeId = $model->storeId; + $record->name = $model->name; + $record->handle = $model->handle; + $record->icon = $model->icon; + $record->color = $model->color; + $record->orderCondition = $model->getOrderCondition()->getConfig(); + $record->customerCondition = $model->getCustomerCondition()->getConfig(); + $record->enabled = $model->enabled; + + $record->save(); + $model->id = $record->id; + + $this->clearCache(); + + return true; + } + + public function deleteShippingMethodById(int $id): bool + { + DB::beginTransaction(); + + try { + $rules = app(ShippingRules::class)->getAllShippingRulesByShippingMethodId($id); + + foreach ($rules as $rule) { + app(ShippingRules::class)->deleteShippingRuleById($rule->id); + } + + $record = ShippingMethodRecord::find($id); + $record->delete(); + + DB::commit(); + $this->clearCache(); + + return true; + } catch (\Exception) { + DB::rollBack(); + + return false; + } + } + + public function clearCache(): void + { + $this->allShippingMethods = null; + } + + private function query(): \Illuminate\Database\Query\Builder + { + $query = DB::table(Table::SHIPPINGMETHODS) + ->select([ + 'storeId', + 'id', + 'name', + 'handle', + 'enabled', + 'orderCondition', + 'customerCondition', + 'dateCreated', + 'dateUpdated', + ]); + + if (Schema::hasColumn(Table::SHIPPINGMETHODS, 'icon')) { + $query->addSelect(['icon', 'color']); + } + + return $query; + } + + private function currentStoreId(): int + { + // TODO: migrate to app(Stores::class)->getCurrentStore()->id once Stores service migrated + return app(Stores::class)->getCurrentStore()->id; + } +} diff --git a/src/Shipping/ShippingRuleCategories.php b/src/Shipping/ShippingRuleCategories.php new file mode 100644 index 0000000000..4377d74870 --- /dev/null +++ b/src/Shipping/ShippingRuleCategories.php @@ -0,0 +1,120 @@ +>|null + */ + private ?array $allShippingRuleCategories = null; + + /** + * Returns all shipping rule categories, keyed by rule ID then category ID, memoized for the + * lifetime of the request to avoid N+1 queries when categories are fetched one rule at a time. + */ + public function getAllShippingRuleCategoriesData(): array + { + if ($this->allShippingRuleCategories === null) { + $rows = $this->query()->get(); + $categoriesByRuleId = []; + + foreach ($rows as $row) { + $row = (array) $row; + $categoriesByRuleId[$row['shippingRuleId']][$row['shippingCategoryId']] = new ShippingRuleCategory($row); + } + + $this->allShippingRuleCategories = $categoriesByRuleId; + } + + return $this->allShippingRuleCategories; + } + + /** + * @return array + */ + public function getShippingRuleCategoriesByRuleId(int $ruleId): array + { + return $this->getAllShippingRuleCategoriesData()[$ruleId] ?? []; + } + + /** + * @param int[] $ruleIds + * @return array> + */ + public function getShippingRuleCategoriesByRuleIds(array $ruleIds): array + { + if (empty($ruleIds)) { + return []; + } + + $rows = $this->query()->whereIn('shippingRuleId', $ruleIds)->get()->all(); + $categoriesByRuleId = []; + + foreach ($rows as $row) { + $row = (array) $row; + $categoriesByRuleId[$row['shippingRuleId']][$row['shippingCategoryId']] = new ShippingRuleCategory($row); + } + + return $categoriesByRuleId; + } + + public function createShippingRuleCategory(ShippingRuleCategory $model, bool $runValidation = true): bool + { + if ($runValidation && !$model->validate()) { + return false; + } + + $record = new ShippingRuleCategoryRecord(); + + $record->shippingRuleId = $model->shippingRuleId; + $record->shippingCategoryId = $model->shippingCategoryId; + $record->condition = $model->condition; + $record->perItemRate = $model->perItemRate; + $record->weightRate = $model->weightRate; + $record->percentageRate = $model->percentageRate; + + $record->save(); + $model->id = $record->id; + + $this->allShippingRuleCategories = null; + + return true; + } + + public function deleteShippingRuleCategoryById(int $id): bool + { + $record = ShippingRuleCategoryRecord::find($id); + + if ($record) { + $this->allShippingRuleCategories = null; + + return (bool) $record->delete(); + } + + return false; + } + + private function query(): \Illuminate\Database\Query\Builder + { + return DB::table(Table::SHIPPINGRULE_CATEGORIES) + ->select([ + 'condition', + 'id', + 'percentageRate', + 'perItemRate', + 'shippingCategoryId', + 'shippingRuleId', + 'weightRate', + ]); + } +} diff --git a/src/Shipping/ShippingRules.php b/src/Shipping/ShippingRules.php new file mode 100644 index 0000000000..4eceea0708 --- /dev/null +++ b/src/Shipping/ShippingRules.php @@ -0,0 +1,207 @@ + + */ + public function getAllShippingRules(): Collection + { + if ($this->allShippingRules !== null) { + return $this->allShippingRules; + } + + $rows = $this->query()->get()->all(); + $rules = []; + + foreach ($rows as $row) { + $row = (array) $row; + $row['orderCondition'] ??= ''; + $rules[] = new ShippingRule($row); + } + + $this->allShippingRules = collect($rules); + + $this->eagerLoadShippingRuleCategories($this->allShippingRules); + + return $this->allShippingRules; + } + + /** + * @return Collection + */ + public function getAllShippingRulesByShippingMethodId(int $methodId): Collection + { + return $this->getAllShippingRules()->where('methodId', $methodId); + } + + public function getShippingRuleById(int $id): ?ShippingRule + { + return $this->getAllShippingRules()->firstWhere('id', $id); + } + + public function saveShippingRule(ShippingRule $model, bool $runValidation = true): bool + { + if ($model->id) { + $record = ShippingRuleRecord::find($model->id); + if (!$record) { + throw new \RuntimeException(t('No shipping rule exists with the ID "{id}"', ['id' => $model->id], category: 'commerce')); + } + } else { + $record = new ShippingRuleRecord(); + } + + if ($runValidation && !$model->validate()) { + return false; + } + + $fields = [ + 'name', + 'description', + 'methodId', + 'enabled', + 'orderConditionFormula', + 'baseRate', + 'perItemRate', + 'weightRate', + 'percentageRate', + 'minRate', + 'maxRate', + ]; + + foreach ($fields as $field) { + $record->$field = $model->$field; + } + + $record->orderCondition = $model->getOrderCondition()->getConfig(); + $record->customerCondition = $model->getCustomerCondition()->getConfig(); + + if (empty($record->priority) && empty($model->priority)) { + $count = ShippingRuleRecord::where('methodId', $model->methodId)->count(); + $record->priority = $model->priority = $count + 1; + } elseif ($model->priority) { + $record->priority = $model->priority; + } else { + $model->priority = $record->priority; + } + + $record->save(); + $model->id = $record->id; + + ShippingRuleCategoryRecord::where('shippingRuleId', $model->id)->delete(); + + foreach (app(ShippingCategories::class)->getAllShippingCategories($model->storeId) as $shippingCategory) { + $ruleCategory = $model->getShippingRuleCategories()[$shippingCategory->id] ?? null; + if ($ruleCategory) { + $ruleCategory = new ShippingRuleCategory([ + 'shippingRuleId' => $model->id, + 'shippingCategoryId' => $shippingCategory->id, + 'condition' => $ruleCategory->condition, + 'perItemRate' => $ruleCategory->perItemRate, + 'weightRate' => $ruleCategory->weightRate, + 'percentageRate' => $ruleCategory->percentageRate, + ]); + } else { + $ruleCategory = new ShippingRuleCategory([ + 'shippingRuleId' => $model->id, + 'shippingCategoryId' => $shippingCategory->id, + 'condition' => ShippingRuleCategoryRecord::CONDITION_ALLOW, + ]); + } + + app(ShippingRuleCategories::class)->createShippingRuleCategory($ruleCategory, $runValidation); + } + + $this->allShippingRules = null; + + return true; + } + + public function reorderShippingRules(array $ids): bool + { + foreach ($ids as $sortOrder => $id) { + DB::table(Table::SHIPPINGRULES)->where('id', $id)->update(['priority' => $sortOrder + 1]); + } + + $this->allShippingRules = null; + + return true; + } + + public function deleteShippingRuleById(int $id): bool + { + $record = ShippingRuleRecord::find($id); + + if ($record) { + $result = (bool) $record->delete(); + $this->allShippingRules = null; + + return $result; + } + + return false; + } + + private function query(): \Illuminate\Database\Query\Builder + { + return DB::table(Table::SHIPPINGRULES . ' as shippingrules') + ->select([ + 'methods.storeId', + 'shippingrules.id', + 'shippingrules.methodId', + 'shippingrules.name', + 'shippingrules.description', + 'shippingrules.enabled', + 'shippingrules.priority', + 'shippingrules.orderConditionFormula', + 'shippingrules.orderCondition', + 'shippingrules.customerCondition', + 'shippingrules.baseRate', + 'shippingrules.perItemRate', + 'shippingrules.weightRate', + 'shippingrules.percentageRate', + 'shippingrules.minRate', + 'shippingrules.maxRate', + ]) + ->join(Table::SHIPPINGMETHODS . ' as methods', 'methods.id', '=', 'shippingrules.methodId') + ->orderBy('shippingrules.methodId') + ->orderBy('shippingrules.priority'); + } + + /** + * @param Collection $shippingRules + */ + private function eagerLoadShippingRuleCategories(Collection $shippingRules): void + { + $ruleIds = $shippingRules->pluck('id')->filter()->all(); + + if (empty($ruleIds)) { + return; + } + + $categoriesByRuleId = app(ShippingRuleCategories::class)->getShippingRuleCategoriesByRuleIds($ruleIds); + + foreach ($shippingRules as $rule) { + if ($rule->id !== null) { + $rule->setShippingRuleCategories($categoriesByRuleId[$rule->id] ?? []); + } + } + } +} diff --git a/src/Shipping/ShippingZones.php b/src/Shipping/ShippingZones.php new file mode 100644 index 0000000000..88aa31ec58 --- /dev/null +++ b/src/Shipping/ShippingZones.php @@ -0,0 +1,116 @@ +>|null */ + private ?array $allZones = null; + + /** + * @return Collection + */ + public function getAllShippingZones(?int $storeId = null): Collection + { + $storeId ??= $this->currentStoreId(); + + if ($this->allZones === null || !isset($this->allZones[$storeId])) { + $rows = $this->query()->where('storeId', $storeId)->get()->all(); + + $this->allZones ??= []; + + foreach ($rows as $row) { + $zone = new ShippingAddressZone((array) $row); + $this->allZones[$zone->storeId] ??= collect(); + $this->allZones[$zone->storeId]->push($zone); + } + } + + return $this->allZones[$storeId] ?? collect(); + } + + public function getShippingZoneById(int $id, ?int $storeId = null): ?ShippingAddressZone + { + return $this->getAllShippingZones($storeId)->firstWhere('id', $id); + } + + public function saveShippingZone(ShippingAddressZone $model, bool $runValidation = true): bool + { + if ($model->id) { + $record = ShippingZoneRecord::find($model->id); + if (!$record) { + throw new \RuntimeException(t('No shipping zone exists with the ID “{id}”', ['id' => $model->id], category: 'commerce')); + } + } else { + $record = new ShippingZoneRecord(); + } + + if ($runValidation && !$model->validate()) { + return false; + } + + $record->name = $model->name; + $record->storeId = $model->storeId; + $record->description = $model->description; + $record->condition = $model->getCondition()->getConfig(); + $this->clearCaches(); + + $record->save(); + $model->id = $record->id; + + return true; + } + + public function deleteShippingZoneById(int $id): bool + { + $record = ShippingZoneRecord::find($id); + + if (!$record) { + return false; + } + + $result = (bool) $record->delete(); + if ($result) { + $this->clearCaches(); + } + + return $result; + } + + private function clearCaches(): void + { + $this->allZones = []; + } + + private function query(): \Illuminate\Database\Query\Builder + { + return DB::table(Table::SHIPPINGZONES) + ->select([ + 'condition', + 'dateCreated', + 'dateUpdated', + 'description', + 'id', + 'name', + 'storeId', + ]) + ->orderBy('name'); + } + + private function currentStoreId(): int + { + return app(Stores::class)->getCurrentStore()->id; + } +} diff --git a/src/Stats/AverageOrderTotal.php b/src/Stats/AverageOrderTotal.php new file mode 100644 index 0000000000..2253a25e7e --- /dev/null +++ b/src/Stats/AverageOrderTotal.php @@ -0,0 +1,27 @@ +createStatQuery() + ->select(new Alias(new Round(new Divide(new Sum('total'), new Count('orders.id')), 4), 'averageOrderTotal')) + ->value('averageOrderTotal'); + } +} diff --git a/src/Stats/Contracts/StatInterface.php b/src/Stats/Contracts/StatInterface.php new file mode 100644 index 0000000000..6d107cafd2 --- /dev/null +++ b/src/Stats/Contracts/StatInterface.php @@ -0,0 +1,82 @@ + 'Sunday', + 1 => 'Monday', + 2 => 'Tuesday', + 3 => 'Wednesday', + 4 => 'Thursday', + 5 => 'Friday', + 6 => 'Saturday', + ]; + + public const START_DAY_INT_TO_END_DAY = [ + 0 => 'Saturday', + 1 => 'Sunday', + 2 => 'Monday', + 3 => 'Tuesday', + 4 => 'Wednesday', + 5 => 'Thursday', + 6 => 'Friday', + ]; + + public const DATE_RANGE_INTERVAL = [ + self::DATE_RANGE_TODAY => 'day', + self::DATE_RANGE_THISWEEK => 'day', + self::DATE_RANGE_THISMONTH => 'day', + self::DATE_RANGE_THISYEAR => 'month', + self::DATE_RANGE_PAST7DAYS => 'day', + self::DATE_RANGE_PAST30DAYS => 'day', + self::DATE_RANGE_PAST90DAYS => 'day', + self::DATE_RANGE_PASTYEAR => 'month', + self::DATE_RANGE_ALL => 'month', + ]; + + public function getHandle(): string; + + public function get(): mixed; + + public function getData(): mixed; + + public function getStartDate(): mixed; + + public function getEndDate(): mixed; + + public function setStartDate(?DateTime $date): void; + + public function setEndDate(?DateTime $date): void; + + public function prepareData(mixed $data): mixed; + + public function getDateRangeWording(): string; + + /** @return array|null */ + public function getOrderStatuses(): ?array; + + /** + * Set order statuses to limit stat query. Accepts OrderStatus models, handle strings, or uid strings. + * + * @param OrderStatus[]|string[]|null $orderStatuses + */ + public function setOrderStatuses(?array $orderStatuses): void; +} diff --git a/src/Stats/NewCustomers.php b/src/Stats/NewCustomers.php new file mode 100644 index 0000000000..36e9a1cdd6 --- /dev/null +++ b/src/Stats/NewCustomers.php @@ -0,0 +1,36 @@ +createStatQuery(); + + // Subquery to find customers who have orders before the start date + $existingCustomersQuery = DB::table(Table::ORDERS) + ->select('customerId') + ->where('isCompleted', true) + ->whereNotNull('customerId') + ->where('dateOrdered', '<', $this->formatDateForDb($this->getStartDate())); + + return $query->select(new Alias(new Count('customerId', distinct: true), 'newCustomers')) + ->whereNotNull('customerId') + ->whereNotIn('customerId', $existingCustomersQuery) + ->value('newCustomers'); + } +} diff --git a/src/Stats/RepeatCustomers.php b/src/Stats/RepeatCustomers.php new file mode 100644 index 0000000000..8866288cf2 --- /dev/null +++ b/src/Stats/RepeatCustomers.php @@ -0,0 +1,33 @@ +createStatQuery() + ->select('customerId') + ->groupBy('customerId') + ->getCountForPagination(); + + $repeatRows = $this->createStatQuery() + ->selectRaw('COUNT(orders.id) as cnt') + ->groupBy('customerId') + ->pluck('cnt'); + + $repeat = $repeatRows->filter(fn($row) => $row > 1)->count(); + + $percentage = round($total ? ($repeat / $total) * 100 : 0); + + return compact('total', 'repeat', 'percentage'); + } +} diff --git a/src/Stats/Stat.php b/src/Stats/Stat.php new file mode 100644 index 0000000000..2447186e8a --- /dev/null +++ b/src/Stats/Stat.php @@ -0,0 +1,472 @@ +weekStartDay = $user->getPreference('weekStartDay') ?? $this->weekStartDay; + } + + $this->dateRange = $dateRange ?? $this->dateRange; + if ($this->dateRange && $this->dateRange != self::DATE_RANGE_CUSTOM) { + $this->setDates(); + } else { + $this->setStartDate($startDate); + $this->setEndDate($endDate); + } + + $this->storeId = $storeId ?? $this->storeId; + } + + #[\Override] + public function getHandle(): string + { + return $this->_handle; + } + + #[\Override] + public function get(): mixed + { + $this->setDates(); + + if (!$this->cache) { + $data = $this->getData(); + return $this->prepareData($data); + } + + $this->_cacheKey = $this->getCacheKey(); + + if (!$this->_cacheKey) { + throw new \Exception('Unable to create cache key.'); + } + + if (!Cache::has($this->_cacheKey)) { + $data = $this->getData(); + Cache::put($this->_cacheKey, $data, $this->cacheDuration); + } else { + $data = Cache::get($this->_cacheKey); + } + + return $this->prepareData($data); + } + + #[\Override] + public function prepareData($data): mixed + { + return $data; + } + + #[\Override] + public function setStartDate(?DateTime $date): void + { + if (!$date) { + $this->_startDate = $this->getFirstCompletedOrderDate(); + } else { + $this->_startDate = $date; + } + } + + #[\Override] + public function setEndDate(?DateTime $date): void + { + if (!$date) { + $this->_endDate = new DateTime(); + } else { + $this->_endDate = $date; + } + } + + #[\Override] + public function getStartDate(): mixed + { + return $this->_startDate; + } + + #[\Override] + public function getEndDate(): mixed + { + return $this->_endDate; + } + + #[\Override] + public function getDateRangeWording(): string + { + switch ($this->dateRange) { + case self::DATE_RANGE_ALL: + return t('All', category: 'commerce'); + case self::DATE_RANGE_TODAY: + return t('Today', category: 'commerce'); + case self::DATE_RANGE_THISWEEK: + return t('This week', category: 'commerce'); + case self::DATE_RANGE_THISMONTH: + return t('This month', category: 'commerce'); + case self::DATE_RANGE_THISYEAR: + return t('This year', category: 'commerce'); + case self::DATE_RANGE_PAST7DAYS: + return t('Past {num} days', ['num' => 7], category: 'commerce'); + case self::DATE_RANGE_PAST30DAYS: + return t('Past {num} days', ['num' => 30], category: 'commerce'); + case self::DATE_RANGE_PAST90DAYS: + return t('Past {num} days', ['num' => 90], category: 'commerce'); + case self::DATE_RANGE_PASTYEAR: + return t('Past year', category: 'commerce'); + case self::DATE_RANGE_CUSTOM: + if (!$this->_startDate || !$this->_endDate) { + return ''; + } + + $startDate = I18N::getFormatter()->asDate($this->_startDate, Locale::LENGTH_SHORT); + $endDate = I18N::getFormatter()->asDate($this->_endDate, Locale::LENGTH_SHORT); + + if (I18N::getLocale()->getOrientation() == 'rtl') { + return $endDate . ' - ' . $startDate; + } + + return $startDate . ' - ' . $endDate; + default: + return ''; + } + } + + /** + * @throws \Exception + */ + private function setDates(): void + { + if (!$this->dateRange) { + throw new \Exception('A date range string must be specified to set stat dates.'); + } + + if ($this->_startDate && $this->_endDate) { + return; + } + + if ($this->dateRange != self::DATE_RANGE_CUSTOM) { + $this->setStartDate($this->getStartDateForRange($this->dateRange)); + $this->setEndDate($this->getEndDateForRange($this->dateRange)); + } + } + + /** + * Based on the date range return the start date. + */ + private function getStartDateForRange(string $dateRange): bool|DateTime + { + if ($dateRange == self::DATE_RANGE_CUSTOM) { + return false; + } + + $date = new DateTime(); + switch ($dateRange) { + case self::DATE_RANGE_ALL: + $date = $this->getFirstCompletedOrderDate(); + break; + case self::DATE_RANGE_THISMONTH: + $date = DateTimeHelper::toDateTime(strtotime('first day of this month')); + break; + case self::DATE_RANGE_THISWEEK: + if (date('l') != self::START_DAY_INT_TO_DAY[$this->weekStartDay]) { + $date = DateTimeHelper::toDateTime(strtotime('last ' . self::START_DAY_INT_TO_DAY[$this->weekStartDay])); + } + break; + case self::DATE_RANGE_THISYEAR: + $date->setDate((int)$date->format('Y'), 1, 1); + break; + case self::DATE_RANGE_PAST7DAYS: + case self::DATE_RANGE_PAST30DAYS: + case self::DATE_RANGE_PAST90DAYS: + $number = (int)str_replace(['past', 'Days'], '', $dateRange); + // Minus one so we include today as a "past day" + $number--; + $date = $this->getEndDateForRange($dateRange); + $interval = new DateInterval('P' . $number . 'D'); + $date->sub($interval); + break; + case self::DATE_RANGE_PASTYEAR: + $date = $this->getEndDateForRange($dateRange); + $interval = new DateInterval('P1Y'); + $date->sub($interval); + $date->modify('first day of next month'); + break; + } + + /** @phpstan-ignore-next-line method.nonObject (DateTimeHelper::toDateTime() can only return false for an unparseable input, and every input here is either a hardcoded relative-date string or a value already parsed above) */ + $date->setTime(0, 0); + return $date; + } + + private function getFirstCompletedOrderDate(): DateTime + { + $firstCompletedOrder = DB::table(Table::ORDERS) + ->where('isCompleted', true) + ->orderBy('dateOrdered') + ->value('dateOrdered'); + + /** @phpstan-ignore-next-line return.type (DateTimeHelper::toDateTime() can only return false for an unparseable input; dateOrdered is always a valid DB datetime string here) */ + return $firstCompletedOrder ? DateTimeHelper::toDateTime($firstCompletedOrder) : new DateTime(); + } + + /** + * Based on the date range return the end date. + */ + private function getEndDateForRange(string $dateRange): bool|DateTime + { + if ($dateRange == self::DATE_RANGE_CUSTOM) { + return false; + } + + $date = new DateTime(); + switch ($dateRange) { + case self::DATE_RANGE_THISMONTH: + $date = DateTimeHelper::toDateTime(strtotime('last day of this month')); + break; + case self::DATE_RANGE_THISWEEK: + $endDayOfWeek = self::START_DAY_INT_TO_END_DAY[$this->weekStartDay]; + if (date('l') != $endDayOfWeek) { + $date = DateTimeHelper::toDateTime(strtotime('next ' . $endDayOfWeek)); + } + break; + } + + /** @phpstan-ignore-next-line method.nonObject (DateTimeHelper::toDateTime() can only return false for an unparseable input, and every input here is a hardcoded relative-date string) */ + $date->setTime(23, 59, 59); + return $date; + } + + /** + * Generate cache key. + */ + private function getCacheKey(): string + { + $orderLastUpdatedString = 'never'; + + $orderLastUpdated = $this->createStatQuery() + ->orderByDesc('orders.dateUpdated') + ->value('orders.dateUpdated'); + + if ($orderLastUpdated) { + $orderLastUpdated = DateTimeHelper::toDateTime($orderLastUpdated); + $orderLastUpdatedString = $orderLastUpdated->format('Y-m-d-H-i-s'); + } + + return implode('-', [$this->getHandle(), $this->dateRange, $this->_startDate->format('U'), $this->_endDate->format('U'), $orderLastUpdatedString]); + } + + /** + * @return array{interval: string, dateKeyFormat: string, dateKey: Expression, groupBy: Expression, orderBy: Expression}|null + */ + public function getChartQueryOptionsByInterval(string $interval): ?array + { + $localTimestamp = new LocalTimestamp('dateOrdered', date_default_timezone_get()); + + return match ($interval) { + 'month' => [ + 'interval' => 'P1M', + 'dateKeyFormat' => 'Y-n', + 'dateKey' => new Alias(new MonthKey($localTimestamp), 'datekey'), + 'groupBy' => new MonthKey($localTimestamp), + 'orderBy' => new MonthKey($localTimestamp), + ], + 'day' => [ + 'interval' => 'P1D', + 'dateKeyFormat' => 'Y-m-d', + 'dateKey' => new Alias(new DateOnly($localTimestamp), 'datekey'), + 'groupBy' => new DateOnly($localTimestamp), + 'orderBy' => new DateOnly($localTimestamp), + ], + default => null, + }; + } + + public function getDateRangeInterval(): string + { + if ($this->dateRange == self::DATE_RANGE_CUSTOM) { + $interval = date_diff($this->_startDate, $this->_endDate); + return ($interval->days > 90) ? 'month' : 'day'; + } + + return self::DATE_RANGE_INTERVAL[$this->dateRange] ?? 'day'; + } + + #[\Override] + public function getOrderStatuses(): ?array + { + if (empty($this->_orderStatuses)) { + return $this->_orderStatuses; + } + + $allOrderStatuses = app(OrderStatuses::class)->getAllOrderStatuses(); + foreach ($this->_orderStatuses as $key => $orderStatus) { + if ($orderStatus instanceof OrderStatus) { + continue; + } + + if (!is_string($orderStatus)) { + unset($this->_orderStatuses[$key]); + continue; + } + + $orderStatus = Arr::first($allOrderStatuses, fn(OrderStatus $os) => $orderStatus === $os->handle || $orderStatus === $os->uid); + if (!$orderStatus) { + unset($this->_orderStatuses[$key]); + continue; + } + + $this->_orderStatuses[$key] = $orderStatus; + } + + return $this->_orderStatuses; + } + + #[\Override] + public function setOrderStatuses(?array $orderStatuses): void + { + $this->_orderStatuses = $orderStatuses; + } + + /** + * Generate base stat query + */ + protected function createStatQuery(): Builder + { + // Make sure the end time is always the last point on that day. + if ($this->_endDate instanceof DateTime) { + $this->_endDate->setTime(23, 59, 59); + } + + if ($this->storeId === null) { + throw new InvalidArgumentException('The store ID has not been set.'); + } + + $query = DB::table(Table::ORDERS . ' as orders') + ->join('elements', 'elements.id', '=', 'orders.id') + ->where('orders.storeId', $this->storeId) + ->where('dateOrdered', '>=', $this->formatDateForDb($this->_startDate)) + ->where('dateOrdered', '<=', $this->formatDateForDb($this->_endDate)) + ->where('isCompleted', true) + ->whereNull('elements.dateDeleted'); + + $orderStatuses = $this->getOrderStatuses(); + if (!empty($orderStatuses)) { + $query->join(Table::ORDERSTATUSES . ' as os', 'orders.orderStatusId', '=', 'os.id') + ->whereIn('os.id', Arr::pluck($orderStatuses, 'id')); + } + + return $query; + } + + /** + * @param array $select + * @param array $resultsDefaults + * @param Builder|null $query + */ + protected function createChartQuery(array $select = [], array $resultsDefaults = [], ?Builder $query = null): ?array + { + // Allow the passing in of a custom query in case we need to add extra logic + $query = $query ?: $this->createStatQuery(); + + $defaults = []; + $dateRangeInterval = $this->getDateRangeInterval(); + $options = $this->getChartQueryOptionsByInterval($dateRangeInterval); + + if (!$options) { + return null; + } + + // DateTimeHelper::toDateTime() can only return false for an unparseable input; getStartDate()->format('Y-m-d') is always valid + $dateKeyDate = DateTimeHelper::toDateTime($this->getStartDate()->format('Y-m-d'), true); + $endDate = $this->getEndDate(); + while ($dateKeyDate <= $endDate) { + // If we are looking monthly make sure we get every month by using the 1st day + if ($dateRangeInterval == 'month') { + $dateKeyDate->setDate((int)$dateKeyDate->format('Y'), (int)$dateKeyDate->format('n'), 1); /** @phpstan-ignore-line */ + } + + $key = $dateKeyDate->format($options['dateKeyFormat']); + + // Setup default results values + $tmp = $resultsDefaults; + $tmp['datekey'] = $key; + + $defaults[$key] = $tmp; + $dateKeyDate->add(new DateInterval($options['interval'])); /** @phpstan-ignore-line */ + } + + // Add defaults to select + $select[] = $options['dateKey']; + $results = $query + ->select($select) + ->groupBy($options['groupBy']) + ->orderBy($options['orderBy']) + ->get() + ->keyBy('datekey') + ->map(fn($row) => (array)$row) + ->all(); + + $return = array_replace($defaults, $results); + ksort($return, SORT_NATURAL); + + return $return; + } + + protected function formatDateForDb(DateTime $date): string + { + $date = clone $date; + $date->setTimezone(new DateTimeZone('UTC')); + return $date->format('Y-m-d H:i:s'); + } +} diff --git a/src/Stats/TopCustomers.php b/src/Stats/TopCustomers.php new file mode 100644 index 0000000000..d0bdbb44ae --- /dev/null +++ b/src/Stats/TopCustomers.php @@ -0,0 +1,82 @@ +type = $type; + } + + parent::__construct($dateRange, $startDate, $endDate, $storeId); + } + + #[\Override] + public function getData(): array + { + $averageExpression = new Round(new Divide(new Sum('total'), new Count('orders.id')), 4); + + $topCustomers = $this->createStatQuery() + ->select([ + new Alias($averageExpression, 'average'), + new Alias(new Count('orders.id'), 'count'), + 'customerId', + new Alias(new Sum('total'), 'total'), + 'users.email', + ]) + ->join(CmsTable::USERS . ' as users', 'orders.customerId', '=', 'users.id') + ->groupBy(['orders.customerId', 'users.email']) + ->limit($this->limit); + + if ($this->type == 'average') { + $topCustomers->orderBy($averageExpression, 'desc'); + } else { + $topCustomers->orderBy(new Sum('total'), 'desc'); + } + + return $topCustomers->get()->map(fn($row) => (array)$row)->all(); + } + + #[\Override] + public function getHandle(): string + { + return $this->_handle . $this->type; + } + + #[\Override] + public function prepareData($data): mixed + { + foreach ($data as &$topCustomer) { + $topCustomer['customer'] = Users::getUserById($topCustomer['customerId']); + } + + return $data; + } +} diff --git a/src/Stats/TopProductTypes.php b/src/Stats/TopProductTypes.php new file mode 100644 index 0000000000..895bb23fe2 --- /dev/null +++ b/src/Stats/TopProductTypes.php @@ -0,0 +1,83 @@ +type = $type ?? $this->type; + + parent::__construct($dateRange, $startDate, $endDate, $storeId); + } + + #[\Override] + public function getData(): array + { + $primarySite = Sites::getPrimarySite(); + $viewableProductTypeIds = app(ProductTypes::class)->getViewableProductTypeIds(); + + $results = $this->createStatQuery() + ->select(['pt.id as id', 'pt.name']) + ->addSelect(new Alias(new Sum('li.qty'), 'qty')) + ->addSelect(new Alias(new Sum('li.total'), 'revenue')) + ->leftJoin(Table::LINEITEMS . ' as li', 'li.orderId', '=', 'orders.id') + ->leftJoin(Table::PURCHASABLES . ' as p', 'p.id', '=', 'li.purchasableId') + ->leftJoin(Table::VARIANTS . ' as v', 'v.id', '=', 'p.id') + ->leftJoin(Table::PRODUCTS . ' as pr', 'pr.id', '=', 'v.primaryOwnerId') + ->leftJoin(Table::PRODUCTTYPES . ' as pt', 'pt.id', '=', 'pr.typeId') + ->leftJoin(CmsTable::ELEMENTS_SITES . ' as es', function($join) use ($primarySite) { + $join->on('es.elementId', '=', 'v.primaryOwnerId') + ->where('es.siteId', $primarySite->id); + }) + ->whereNotNull('pt.name') + ->whereIn('pt.id', $viewableProductTypeIds) + ->groupBy('pt.id') + ->orderBy($this->type == 'revenue' ? new Sum('li.total') : new Sum('li.qty'), 'desc') + ->limit($this->limit); + + return $results->get()->map(fn($row) => (array)$row)->all(); + } + + #[\Override] + public function getHandle(): string + { + return $this->_handle . $this->type; + } + + #[\Override] + public function prepareData($data): mixed + { + if (!empty($data)) { + foreach ($data as &$row) { + $row['productType'] = $row['id'] ? app(ProductTypes::class)->getProductTypeById((int)$row['id']) : null; + } + } + + return $data; + } +} diff --git a/src/Stats/TopProducts.php b/src/Stats/TopProducts.php new file mode 100644 index 0000000000..55199b3882 --- /dev/null +++ b/src/Stats/TopProducts.php @@ -0,0 +1,263 @@ +type = $type; + } + + // Set defaults + $this->revenueOptions = $this->_defaultRevenueOptions; + if (is_array($revenueOptions)) { + $this->revenueOptions = $revenueOptions; + } + + parent::__construct($dateRange, $startDate, $endDate, $storeId); + } + + #[\Override] + public function getData(): array + { + $primarySite = Sites::getPrimarySite(); + + $topProducts = $this->createStatQuery() + ->select(['v.primaryOwnerId as id', 'es.title']) + ->addSelect(new Alias(new Sum('li.qty'), 'qty')) + ->addSelect(new Alias(new Sum('li.total'), 'revenue')) + ->addSelect(new Alias(new Sum('li.subtotal'), 'revenue_subtotal')) + ->addSelect($this->getAdjustmentsSelect()) + ->leftJoin(Table::LINEITEMS . ' as li', 'li.orderId', '=', 'orders.id') + ->leftJoin(Table::PURCHASABLES . ' as p', 'p.id', '=', 'li.purchasableId') + ->leftJoin(Table::VARIANTS . ' as v', 'v.id', '=', 'p.id') + ->leftJoin(Table::PRODUCTS . ' as pr', 'pr.id', '=', 'v.primaryOwnerId') + ->leftJoin(Table::PRODUCTTYPES . ' as pt', 'pt.id', '=', 'pr.typeId') + ->leftJoin(CmsTable::ELEMENTS_SITES . ' as es', function($join) use ($primarySite) { + $join->on('es.elementId', '=', 'v.primaryOwnerId') + ->where('es.siteId', $primarySite->id); + }) + ->leftJoinSub($this->createAdjustmentsSubQuery(), 'adjustments', 'v.primaryOwnerId', '=', 'adjustments.primaryOwnerId') + ->groupBy($this->getGroupBy()) + ->orderBy($this->getOrderBy(), 'desc') + ->whereNotNull('v.primaryOwnerId') + ->limit($this->limit); + + return $topProducts->get()->map(fn($row) => (array)$row)->all(); + } + + #[\Override] + public function getHandle(): string + { + $handle = $this->_handle . $this->type; + + foreach ($this->revenueOptions as $revenueOption) { + $handle .= '-' . $revenueOption; + } + + return $handle; + } + + #[\Override] + public function prepareData($data): mixed + { + if (!empty($data)) { + foreach ($data as &$row) { + if ($row['id']) { + $row['product'] = app(Products::class)->getProductById($row['id']); + } + } + } + + return $data; + } + + /** + * Create select statement for a stat type `custom` based on the options chosen. + */ + protected function getAdjustmentsSelect(): Expression + { + $expression = new Sum('li.subtotal'); + + if (in_array(self::REVENUE_OPTION_DISCOUNT, $this->revenueOptions, true)) { + $expression = new Add($expression, 'adjustments.discount'); + } + + if (!in_array(self::REVENUE_OPTION_TAX_INCLUDED, $this->revenueOptions, true)) { + $expression = new Subtract($expression, 'adjustments.tax_included'); + } + + if (!in_array(self::REVENUE_OPTION_TAX, $this->revenueOptions, true)) { + $expression = new Subtract($expression, 'adjustments.tax'); + } + + if (!in_array(self::REVENUE_OPTION_SHIPPING, $this->revenueOptions, true)) { + $expression = new Subtract($expression, 'adjustments.shipping'); + } + + return new Alias(new Coalesce([$expression, new Sum('li.subtotal')]), 'revenue_custom'); + } + + /** + * Create the adjustments sub query for use with revenue calculation. + */ + protected function createAdjustmentsSubQuery(): Builder + { + $types = []; + foreach ($this->revenueOptions as $revenueOption) { + $types[] = str_starts_with($revenueOption, 'tax') ? 'tax' : $revenueOption; + } + $types = array_unique($types); + + $discountAmount = new CaseGroup([ + new CaseRule('amount', new Equal('oa.type', new Value('discount'))), + ]); + $shippingAmount = new CaseGroup([ + new CaseRule('amount', new Equal('oa.type', new Value('shipping'))), + ]); + $taxAmount = new CaseGroup([ + new CaseRule('amount', new CondAnd( + new Equal('oa.type', new Value('tax')), + new Equal('included', new Value(false)), + )), + ]); + $taxIncludedAmount = new CaseGroup([ + new CaseRule('amount', new CondAnd( + new Equal('oa.type', new Value('tax')), + new Equal('included', new Value(true)), + )), + ]); + + return DB::table(Table::ORDERADJUSTMENTS . ' as oa') + ->select('v.primaryOwnerId') + ->addSelect(new Alias(new Coalesce([new Sum($discountAmount), new Value(0)]), 'discount')) + ->addSelect(new Alias(new Coalesce([new Sum($shippingAmount), new Value(0)]), 'shipping')) + ->addSelect(new Alias(new Coalesce([new Sum($taxAmount), new Value(0)]), 'tax')) + ->addSelect(new Alias(new Coalesce([new Sum($taxIncludedAmount), new Value(0)]), 'tax_included')) + ->leftJoin(Table::LINEITEMS . ' as li', 'li.id', '=', 'oa.lineItemId') + ->leftJoin(Table::VARIANTS . ' as v', 'v.id', '=', 'li.purchasableId') + ->whereNotNull('oa.lineItemId') + ->whereNotNull('v.primaryOwnerId') + ->whereIn('oa.type', $types) + ->groupBy('v.primaryOwnerId'); + } + + /** + * Return the order by expression for the data query. + */ + protected function getOrderBy(): string|Expression + { + if ($this->type === self::TYPE_QTY) { + return new Sum('li.qty'); + } + + // Order by custom revenue options if not all options are selected. + if ($this->type === self::TYPE_REVENUE && count(array_intersect($this->_defaultRevenueOptions, $this->revenueOptions)) !== count($this->_defaultRevenueOptions)) { + return 'revenue_custom'; + } + + return new Sum('li.total'); + } + + /** + * Return group by columns based on state type. + * + * @return string[] + */ + protected function getGroupBy(): array + { + $groupBy = ['v.primaryOwnerId', 'es.title']; + + if (in_array(self::REVENUE_OPTION_DISCOUNT, $this->revenueOptions, true)) { + $groupBy[] = 'adjustments.discount'; + } + + if (!in_array(self::REVENUE_OPTION_TAX_INCLUDED, $this->revenueOptions, true)) { + $groupBy[] = 'adjustments.tax_included'; + } + + if (!in_array(self::REVENUE_OPTION_TAX, $this->revenueOptions, true)) { + $groupBy[] = 'adjustments.tax'; + } + + if (!in_array(self::REVENUE_OPTION_SHIPPING, $this->revenueOptions, true)) { + $groupBy[] = 'adjustments.shipping'; + } + + return $groupBy; + } +} diff --git a/src/Stats/TopPurchasables.php b/src/Stats/TopPurchasables.php new file mode 100644 index 0000000000..e34c4a8c5e --- /dev/null +++ b/src/Stats/TopPurchasables.php @@ -0,0 +1,64 @@ +type = $type ?? $this->type; + + parent::__construct($dateRange, $startDate, $endDate, $storeId); + } + + #[\Override] + public function getData(): array + { + $viewableProductTypeIds = app(ProductTypes::class)->getViewableProductTypeIds(); + + $topPurchasables = $this->createStatQuery() + ->select(['li.purchasableId', 'p.description', 'p.sku']) + ->addSelect(new Alias(new Sum('li.qty'), 'qty')) + ->addSelect(new Alias(new Sum('li.total'), 'revenue')) + ->leftJoin(Table::LINEITEMS . ' as li', 'li.orderId', '=', 'orders.id') + ->leftJoin(Table::PURCHASABLES . ' as p', 'p.id', '=', 'li.purchasableId') + ->leftJoin(Table::VARIANTS . ' as v', 'v.id', '=', 'p.id') + ->leftJoin(Table::PRODUCTS . ' as pr', 'pr.id', '=', 'v.primaryOwnerId') + ->leftJoin(Table::PRODUCTTYPES . ' as pt', 'pt.id', '=', 'pr.typeId') + ->whereIn('pt.id', $viewableProductTypeIds) + ->groupBy(['li.purchasableId', 'p.sku', 'p.description']) + ->orderBy($this->type == 'revenue' ? new Sum('li.total') : new Sum('li.qty'), 'desc') + ->orderBy('p.sku') + ->limit($this->limit); + + return $topPurchasables->get()->map(fn($row) => (array)$row)->all(); + } + + #[\Override] + public function getHandle(): string + { + return $this->_handle . $this->type; + } +} diff --git a/src/Stats/TotalOrders.php b/src/Stats/TotalOrders.php new file mode 100644 index 0000000000..4495543ad0 --- /dev/null +++ b/src/Stats/TotalOrders.php @@ -0,0 +1,33 @@ +createStatQuery()->count(); + + $chartData = $this->createChartQuery([ + new Alias(new Count('orders.id'), 'total'), + ], [ + 'total' => 0, + ]); + + return [ + 'total' => $total, + 'chart' => $chartData, + ]; + } +} diff --git a/src/Stats/TotalOrdersByCountry.php b/src/Stats/TotalOrdersByCountry.php new file mode 100644 index 0000000000..98ca896f65 --- /dev/null +++ b/src/Stats/TotalOrdersByCountry.php @@ -0,0 +1,99 @@ +type = $type ?? $this->type; + + parent::__construct($dateRange, $startDate, $endDate, $storeId); + } + + #[\Override] + public function getData(): array + { + $countryColumn = $this->type == 'billing' ? 'b.countryCode' : 's.countryCode'; + + $query = $this->createStatQuery() + ->select(["$countryColumn as countryCode"]) + ->addSelect(new Alias(new Count('orders.id'), 'total')) + ->leftJoin(CmsTable::ADDRESSES . ' as s', 's.id', '=', 'orders.shippingAddressId') + ->leftJoin(CmsTable::ADDRESSES . ' as b', 'b.id', '=', 'orders.billingAddressId') + ->whereNotNull($countryColumn) + ->groupBy($countryColumn) + ->orderBy(new Count('orders.id'), 'desc') + ->limit($this->limit); + + $rows = $query->get()->map(fn($row) => (array)$row)->all(); + + if (count($rows) < $this->limit) { + return $rows; + } + + $countryCodes = array_column($rows, 'countryCode'); + + $otherCountries = $this->createStatQuery() + ->select(new Alias(new Count('orders.id'), 'total')) + ->addSelect(new Alias(new Value(null), 'countryCode')) + ->leftJoin(CmsTable::ADDRESSES . ' as s', 's.id', '=', 'orders.shippingAddressId') + ->leftJoin(CmsTable::ADDRESSES . ' as b', 'b.id', '=', 'orders.billingAddressId') + ->whereNotIn($countryColumn, $countryCodes) + ->first(); + + $otherCountries = $otherCountries ? (array)$otherCountries : []; + + if (empty($otherCountries)) { + return $rows; + } + + $otherCountries['name'] = t('Other countries', category: 'commerce'); + $rows[] = $otherCountries; + + return $rows; + } + + #[\Override] + public function getHandle(): string + { + return $this->_handle . $this->type; + } + + #[\Override] + public function prepareData($data): mixed + { + if (!empty($data)) { + foreach ($data as &$row) { + if (!$row['countryCode']) { + continue; + } + $row['name'] = Addresses::getCountryRepository()->get($row['countryCode'])->getName(); + } + } + + return $data; + } +} diff --git a/src/Stats/TotalRevenue.php b/src/Stats/TotalRevenue.php new file mode 100644 index 0000000000..d225176fcc --- /dev/null +++ b/src/Stats/TotalRevenue.php @@ -0,0 +1,43 @@ +type, $allowedTypes, true)) { + $this->type = self::TYPE_TOTAL; + } + + return $this->createChartQuery( + [ + new Alias(new Sum($this->type), 'revenue'), + new Alias(new Count('orders.id'), 'count'), + ], + [ + 'revenue' => 0, + 'count' => 0, + ], + ); + } +} diff --git a/src/Store/Concerns/StoreTrait.php b/src/Store/Concerns/StoreTrait.php new file mode 100644 index 0000000000..c5c5a2a5e5 --- /dev/null +++ b/src/Store/Concerns/StoreTrait.php @@ -0,0 +1,21 @@ +getStoreById($this->storeId)) { + throw new \InvalidArgumentException('Invalid store ID: ' . $this->storeId); + } + + return $store; + } +} diff --git a/src/Store/Contracts/HasStoreInterface.php b/src/Store/Contracts/HasStoreInterface.php new file mode 100644 index 0000000000..6d83fd6d19 --- /dev/null +++ b/src/Store/Contracts/HasStoreInterface.php @@ -0,0 +1,12 @@ +siteId); + } + + public function getStoreUid(): ?string + { + if (!$this->storeId) { + return null; + } + + return DB::table(Table::STORES)->uidById($this->storeId) ?: null; + } + + public function getConfig(): array + { + return [ + 'store' => $this->getStoreUid(), + ]; + } + + #[\Override] + public function getRules(): array + { + return [ + 'storeId' => ['required', 'integer'], + 'siteId' => ['required', 'integer'], + ]; + } +} diff --git a/src/Store/Models/Store.php b/src/Store/Models/Store.php new file mode 100644 index 0000000000..195cfec738 --- /dev/null +++ b/src/Store/Models/Store.php @@ -0,0 +1,357 @@ + $this->getName(false), + 'currency' => $this->_currency, + ]); + } + + #[\Override] + public function getRules(): array + { + return [ + 'name' => ['required', 'string'], + 'handle' => ['required', 'string', Rule::unique(Table::STORES, 'handle')->ignore($this->id)], + 'currency' => [ + function($attribute, $value, \Closure $fail) { + if (!$this->id) { + return; + } + $isCurrencyChanging = \CraftCms\Commerce\Store\Records\Store::where('id', $this->id) + ->where('currency', $value) + ->doesntExist(); + if (!$isCurrencyChanging) { + return; + } + $hasOrders = Order::find() + ->trashed(null) + ->storeId($this->id) + ->exists(); + if ($hasOrders) { + $fail(t('The primary currency cannot be changed after orders are placed.', category: 'commerce')); + } + }, + ], + ]; + } + + public function getName(bool $parse = true): string + { + return ($parse ? Env::parse($this->_name) : $this->_name) ?? ''; + } + + public function setName(string $name): void + { + $this->_name = $name; + } + + public function getStoreSettingsUrl(?string $path = null): string + { + $path = $path ? '/' . $path : ''; + return Url::cpUrl('commerce/store-management/' . $this->handle . $path); + } + + public function getSettings(): StoreSettings + { + return app(\CraftCms\Commerce\Store\StoreSettings::class)->getStoreSettingsById($this->id); + } + + public function getSites(): Collection + { + return app(Stores::class)->getAllSitesForStore($this); + } + + /** + * @return Collection + */ + public function getSiteNames(): Collection + { + return collect($this->getSites())->map(fn(Site $site) => $site->getName()); + } + + #[\Override] + public function attributeLabels(): array + { + return [ + 'name' => t('Name', category: 'commerce'), + 'commerce' => t('Handle', category: 'commerce'), + 'primary' => t('Primary', category: 'commerce'), + ]; + } + + public function getConfig(): array + { + return [ + 'allowCheckoutWithoutPayment' => $this->getAllowCheckoutWithoutPayment(false), + 'allowEmptyCartOnCheckout' => $this->getAllowEmptyCartOnCheckout(false), + 'allowPartialPaymentOnCheckout' => $this->getAllowPartialPaymentOnCheckout(false), + 'autoSetCartShippingMethodOption' => $this->getAutoSetCartShippingMethodOption(false), + 'autoSetNewCartAddresses' => $this->getAutoSetNewCartAddresses(false), + 'autoSetPaymentSource' => $this->getAutoSetPaymentSource(false), + 'freeOrderPaymentStrategy' => $this->getFreeOrderPaymentStrategy(false), + 'handle' => $this->handle, + 'minimumTotalPriceStrategy' => $this->getMinimumTotalPriceStrategy(false), + 'name' => $this->_name, + 'orderReferenceFormat' => $this->getOrderReferenceFormat(false), + 'primary' => $this->primary, + 'requireBillingAddressAtCheckout' => $this->getRequireBillingAddressAtCheckout(false), + 'requireShippingAddressAtCheckout' => $this->getRequireShippingAddressAtCheckout(false), + 'requireShippingMethodSelectionAtCheckout' => $this->getRequireShippingMethodSelectionAtCheckout(false), + 'sortOrder' => $this->sortOrder, + 'useBillingAddressForTax' => $this->getUseBillingAddressForTax(false), + 'validateOrganizationTaxIdAsVatId' => $this->getValidateOrganizationTaxIdAsVatId(false), + 'currency' => $this->getCurrency()->getCode(), + ]; + } + + public function getFreeOrderPaymentStrategyOptions(): array + { + return [ + self::FREE_ORDER_PAYMENT_STRATEGY_COMPLETE => t('Free orders complete immediately', category: 'commerce'), + self::FREE_ORDER_PAYMENT_STRATEGY_PROCESS => t('Free orders are processed by the payment gateway', category: 'commerce'), + ]; + } + + public function getMinimumTotalPriceStrategyOptions(): array + { + return [ + self::MINIMUM_TOTAL_PRICE_STRATEGY_DEFAULT => t('Default - Allow the price to be negative if discounts are greater than the order value.', category: 'commerce'), + self::MINIMUM_TOTAL_PRICE_STRATEGY_ZERO => t('Zero - Minimum price is zero if discounts are greater than the order value.', category: 'commerce'), + self::MINIMUM_TOTAL_PRICE_STRATEGY_SHIPPING => t('Shipping - Minimum cost is the shipping cost, if the order price is less than the shipping cost.', category: 'commerce'), + ]; + } + + public function setAutoSetNewCartAddresses(bool|string $autoSetNewCartAddresses): void + { + $this->_autoSetNewCartAddresses = $autoSetNewCartAddresses; + } + + public function getAutoSetNewCartAddresses(bool $parse = true): bool|string + { + return $parse ? (Env::parseBoolean($this->_autoSetNewCartAddresses) ?? false) : $this->_autoSetNewCartAddresses; + } + + public function setAutoSetCartShippingMethodOption(bool|string $autoSetCartShippingMethodOption): void + { + $this->_autoSetCartShippingMethodOption = $autoSetCartShippingMethodOption; + } + + public function getAutoSetCartShippingMethodOption(bool $parse = true): bool|string + { + return $parse ? (Env::parseBoolean($this->_autoSetCartShippingMethodOption) ?? false) : $this->_autoSetCartShippingMethodOption; + } + + public function setAutoSetPaymentSource(bool|string $autoSetPaymentSource): void + { + $this->_autoSetPaymentSource = $autoSetPaymentSource; + } + + public function getAutoSetPaymentSource(bool $parse = true): bool|string + { + return $parse ? (Env::parseBoolean($this->_autoSetPaymentSource) ?? false) : $this->_autoSetPaymentSource; + } + + public function setAllowEmptyCartOnCheckout(bool|string $allowEmptyCartOnCheckout): void + { + $this->_allowEmptyCartOnCheckout = $allowEmptyCartOnCheckout; + } + + public function getAllowEmptyCartOnCheckout(bool $parse = true): bool|string + { + return $parse ? (Env::parseBoolean($this->_allowEmptyCartOnCheckout) ?? false) : $this->_allowEmptyCartOnCheckout; + } + + public function setAllowCheckoutWithoutPayment(bool|string $allowCheckoutWithoutPayment): void + { + $this->_allowCheckoutWithoutPayment = $allowCheckoutWithoutPayment; + } + + public function getAllowCheckoutWithoutPayment(bool $parse = true): bool|string + { + return $parse ? (Env::parseBoolean($this->_allowCheckoutWithoutPayment) ?? false) : $this->_allowCheckoutWithoutPayment; + } + + public function setAllowPartialPaymentOnCheckout(bool|string $allowPartialPaymentOnCheckout): void + { + $this->_allowPartialPaymentOnCheckout = $allowPartialPaymentOnCheckout; + } + + public function getAllowPartialPaymentOnCheckout(bool $parse = true): bool|string + { + return $parse ? (Env::parseBoolean($this->_allowPartialPaymentOnCheckout) ?? false) : $this->_allowPartialPaymentOnCheckout; + } + + public function setRequireShippingAddressAtCheckout(bool|string $requireShippingAddressAtCheckout): void + { + $this->_requireShippingAddressAtCheckout = $requireShippingAddressAtCheckout; + } + + public function getRequireShippingAddressAtCheckout(bool $parse = true): bool|string + { + return $parse ? (Env::parseBoolean($this->_requireShippingAddressAtCheckout) ?? false) : $this->_requireShippingAddressAtCheckout; + } + + public function setRequireBillingAddressAtCheckout(bool|string $requireBillingAddressAtCheckout): void + { + $this->_requireBillingAddressAtCheckout = $requireBillingAddressAtCheckout; + } + + public function getRequireBillingAddressAtCheckout(bool $parse = true): bool|string + { + return $parse ? (Env::parseBoolean($this->_requireBillingAddressAtCheckout) ?? false) : $this->_requireBillingAddressAtCheckout; + } + + public function setRequireShippingMethodSelectionAtCheckout(bool|string $requireShippingMethodSelectionAtCheckout): void + { + $this->_requireShippingMethodSelectionAtCheckout = $requireShippingMethodSelectionAtCheckout; + } + + public function getRequireShippingMethodSelectionAtCheckout(bool $parse = true): bool|string + { + return $parse ? (Env::parseBoolean($this->_requireShippingMethodSelectionAtCheckout) ?? false) : $this->_requireShippingMethodSelectionAtCheckout; + } + + public function setUseBillingAddressForTax(bool|string $useBillingAddressForTax): void + { + $this->_useBillingAddressForTax = $useBillingAddressForTax; + } + + public function getUseBillingAddressForTax(bool $parse = true): bool|string + { + return $parse ? (Env::parseBoolean($this->_useBillingAddressForTax) ?? false) : $this->_useBillingAddressForTax; + } + + public function setValidateOrganizationTaxIdAsVatId(bool|string $validateOrganizationTaxIdAsVatId): void + { + $this->_validateOrganizationTaxIdAsVatId = $validateOrganizationTaxIdAsVatId; + } + + public function getValidateOrganizationTaxIdAsVatId(bool $parse = true): bool|string + { + return $parse ? (Env::parseBoolean($this->_validateOrganizationTaxIdAsVatId) ?? false) : $this->_validateOrganizationTaxIdAsVatId; + } + + public function setOrderReferenceFormat(?string $orderReferenceFormat): void + { + if (!$orderReferenceFormat) { + return; + } + + $this->_orderReferenceFormat = $orderReferenceFormat; + } + + public function getOrderReferenceFormat(bool $parse = true): string + { + return $parse ? (Env::parse($this->_orderReferenceFormat) ?? '') : $this->_orderReferenceFormat; + } + + public function setFreeOrderPaymentStrategy(string $freeOrderPaymentStrategy): void + { + $this->_freeOrderPaymentStrategy = $freeOrderPaymentStrategy; + } + + public function getFreeOrderPaymentStrategy(bool $parse = true): string + { + return $parse ? (Env::parse($this->_freeOrderPaymentStrategy) ?? '') : $this->_freeOrderPaymentStrategy; + } + + public function setMinimumTotalPriceStrategy(string $minimumTotalPriceStrategy): void + { + $this->_minimumTotalPriceStrategy = $minimumTotalPriceStrategy; + } + + public function getMinimumTotalPriceStrategy(bool $parse = true): string + { + return $parse ? (Env::parse($this->_minimumTotalPriceStrategy) ?? '') : $this->_minimumTotalPriceStrategy; + } + + public function getCurrency(): ?MoneyCurrency + { + return $this->_currency ? (new MoneyCurrency($this->_currency)) : null; + } + + public function setCurrency(string|MoneyCurrency $currency): void + { + if ($currency instanceof MoneyCurrency) { + $currency = $currency->getCode(); + } + + $this->_currency = $currency; + } + + public function getInventoryLocations(): Collection + { + return app(InventoryLocations::class)->getInventoryLocations($this->id); + } + + public function getInventoryLocationsOptions(): array + { + return app(InventoryLocations::class)->getInventoryLocations($this->id)->map(fn($location) => ['value' => $location->id, 'label' => $location->getUiLabel()])->toArray(); + } +} diff --git a/src/Store/Models/StoreSettings.php b/src/Store/Models/StoreSettings.php new file mode 100644 index 0000000000..0e50f1f9ee --- /dev/null +++ b/src/Store/Models/StoreSettings.php @@ -0,0 +1,148 @@ +_locationAddressId = $this->getLocationAddress()?->id; + return; + } + + if (is_array($locationAddressId)) { + $this->_locationAddressId = Arr::first($locationAddressId) ?: null; + } else { + $this->_locationAddressId = $locationAddressId; + } + } + + public function getLocationAddressId(): ?int + { + return $this->_locationAddressId; + } + + public function getLocationAddress(): ?Address + { + if (!isset($this->_locationAddress)) { + if ($this->_locationAddressId) { + /** @var Address|null $location */ + $location = Elements::getElementById($this->_locationAddressId, Address::class); + if ($location) { + $this->_locationAddress = $location; + return $this->_locationAddress; + } + } + + $storeLocationAddress = new Address(); + $storeLocationAddress->title = 'Store'; + $storeLocationAddress->countryCode = 'US'; + if (Elements::saveElement($storeLocationAddress, false)) { + $this->setLocationAddress($storeLocationAddress); + StoreSettingsRecord::where('id', $this->id)->update(['locationAddressId' => $this->_locationAddressId]); + } else { + throw new \Exception('Could not save store location address'); + } + } + + return $this->_locationAddress; + } + + public function setLocationAddress(?Address $locationAddress = null): void + { + $this->_locationAddress = $locationAddress; + $this->setLocationAddressId($locationAddress?->id); + } + + public function getCountries(): array + { + return $this->_countries; + } + + public function setCountries(mixed $countries): void + { + $countries ??= []; + $countries = Json::decodeIfJson($countries) ?? []; + + if (!is_array($countries)) { + throw new \InvalidArgumentException('Countries must be an array.'); + } + + $this->_countries = $countries; + } + + public function getCountriesList(): array + { + $all = Addresses::getCountryRepository()->getList(app()->getLocale()); + return array_filter($all, fn($fieldHandle) => in_array($fieldHandle, $this->getCountries(), true), ARRAY_FILTER_USE_KEY); + } + + public function getAdministrativeAreasListByCountryCode(): array + { + if (empty($this->_countries)) { + return []; + } + + $administrativeAreas = []; + foreach ($this->_countries as $countryCode) { + $administrativeAreas[$countryCode] = Addresses::getSubdivisionRepository()->getList([$countryCode]); + } + + return $administrativeAreas; + } + + public function getMarketAddressCondition(): ZoneAddressCondition + { + if ($this->_marketAddressCondition !== null) { + return $this->_marketAddressCondition; + } + + /** @var ZoneAddressCondition $condition */ + $condition = Conditions::createCondition(ZoneAddressCondition::class); + return $condition; + } + + public function setMarketAddressCondition(ZoneAddressCondition|string|array|null $condition): void + { + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + $condition = Conditions::createCondition($condition); + } + + if (is_array($condition)) { + $condition = Conditions::createCondition($condition); + } + + if ($condition === null) { + $condition = Conditions::createCondition(ZoneAddressCondition::class); + } + + $condition->forProjectConfig = false; + + /** @var ZoneAddressCondition $condition */ + $this->_marketAddressCondition = $condition; + } +} diff --git a/src/Store/Records/SiteStore.php b/src/Store/Records/SiteStore.php new file mode 100644 index 0000000000..fea25d54f8 --- /dev/null +++ b/src/Store/Records/SiteStore.php @@ -0,0 +1,36 @@ + 'integer', + 'storeId' => 'integer', + ]; +} diff --git a/src/Store/Records/Store.php b/src/Store/Records/Store.php new file mode 100644 index 0000000000..33aed7271b --- /dev/null +++ b/src/Store/Records/Store.php @@ -0,0 +1,39 @@ + 'boolean', + 'autoSetNewCartAddresses' => 'boolean', + 'autoSetCartShippingMethodOption' => 'boolean', + 'autoSetPaymentSource' => 'boolean', + 'allowEmptyCartOnCheckout' => 'boolean', + 'allowCheckoutWithoutPayment' => 'boolean', + 'allowPartialPaymentOnCheckout' => 'boolean', + 'requireShippingAddressAtCheckout' => 'boolean', + 'requireBillingAddressAtCheckout' => 'boolean', + 'requireShippingMethodSelectionAtCheckout' => 'boolean', + 'useBillingAddressForTax' => 'boolean', + 'validateOrganizationTaxIdAsVatId' => 'boolean', + 'sortOrder' => 'integer', + ]; +} diff --git a/src/Store/Records/StoreSettings.php b/src/Store/Records/StoreSettings.php new file mode 100644 index 0000000000..8b4d1aa2c0 --- /dev/null +++ b/src/Store/Records/StoreSettings.php @@ -0,0 +1,35 @@ + 'integer', + 'countries' => 'array', + 'marketAddressCondition' => 'array', + ]; +} diff --git a/src/Store/StoreSettings.php b/src/Store/StoreSettings.php new file mode 100644 index 0000000000..804758a945 --- /dev/null +++ b/src/Store/StoreSettings.php @@ -0,0 +1,139 @@ +|null + */ + private ?Collection $allStoreSettings = null; + + /** + * Returns the store record. + */ + public function getStoreSettingsById(int $id): StoreSettingsModel + { + $store = app(Stores::class)->getStoreById($id); + + if (!$store) { + throw new \RuntimeException('Store not found'); + } + + $storeSettings = $this->getAllStoreSettings()->firstWhere('id', $id); + + if (!$storeSettings) { + $storeSettingsRecord = new StoreSettingsRecord(); + $storeSettingsRecord->id = $id; + + $storeSettings = new StoreSettingsModel(['id' => $storeSettingsRecord->id]); + + // Create a new blank store location + $locationAddress = $storeSettings->getLocationAddress(); + $storeSettingsRecord->locationAddressId = $locationAddress->id; + + $storeSettingsRecord->save(); + + $this->getAllStoreSettings()->put($storeSettings->id, $storeSettings); + } + + return $storeSettings; + } + + /** + * @return Collection + */ + public function getAllStoreSettings(): Collection + { + if ($this->allStoreSettings === null) { + $this->allStoreSettings = collect(); + $storeSettings = $this->query()->get(); + + foreach ($storeSettings as $storeSetting) { + $storeSetting = (array)$storeSetting; + $this->allStoreSettings->put($storeSetting['id'], new StoreSettingsModel($storeSetting)); + } + } + + return $this->allStoreSettings; + } + + /** + * Saves the store. + */ + public function saveStoreSettings(StoreSettingsModel $storeSettings): bool + { + $storeSettingsRecord = StoreSettingsRecord::find($storeSettings->id); + + if (!$storeSettingsRecord) { + throw new \RuntimeException('Invalid store ID'); + } + + $storeSettingsRecord->countries = $storeSettings->getCountries(); + $storeSettingsRecord->marketAddressCondition = $storeSettings->getMarketAddressCondition()->getConfig(); + + if (!$storeSettingsRecord->save()) { + return false; + } + + $this->getAllStoreSettings()->put($storeSettings->id, $storeSettings); + return true; + } + + public function authorizeStoreLocationView(ElementAuthorizing $event): void + { + if (!$this->checkStoreLocationAuthorization($event)) { + return; + } + + // @TODO Authorize the current user against the store from $storeSettingsRecord (e.g. "commerce-manageStore:" permission) rather than always granting view access + $event->authorized = true; + } + + public function authorizeStoreLocationEdit(ElementAuthorizing $event): void + { + if (!$this->checkStoreLocationAuthorization($event)) { + return; + } + + // @TODO Authorize the current user against the store from $storeSettingsRecord (e.g. "commerce-manageStore:" permission) rather than always granting edit access + $event->authorized = true; + } + + private function checkStoreLocationAuthorization(ElementAuthorizing $event): StoreSettingsRecord|false + { + if (!$event->element instanceof Address) { + return false; + } + + $storeSettings = StoreSettingsRecord::where('locationAddressId', $event->element->getCanonicalId())->first(); + if (!$storeSettings) { + return false; + } + + return $storeSettings; + } + + private function query(): \Illuminate\Database\Query\Builder + { + return DB::table(Table::STORESETTINGS) + ->select([ + 'id', + 'marketAddressCondition', + 'locationAddressId', + 'countries', + ]); + } +} diff --git a/src/Store/Stores.php b/src/Store/Stores.php new file mode 100644 index 0000000000..b8da46f3c7 --- /dev/null +++ b/src/Store/Stores.php @@ -0,0 +1,753 @@ +|null + */ + private ?Collection $allStores = null; + + /** + * @var Collection|null + */ + private ?Collection $allStoresBySiteId = null; + + /** + * @var Collection|null + */ + private ?Collection $allSiteStores = null; + + private function loadAllStores(): void + { + if (isset($this->allStores)) { + return; + } + + $results = $this->query()->get(); + $siteStores = $this->siteStoresQuery()->select(['storeId', 'siteId'])->get(); + + $allStores = []; + $allStoresBySiteId = []; + + foreach ($results as $row) { + $store = new Store((array)$row); + + $allStores[] = $store; + + foreach ($siteStores->where('storeId', $store->id) as $siteStore) { + $allStoresBySiteId[$siteStore->siteId] = $store; + } + } + + $this->allStores = collect($allStores); + $this->allStoresBySiteId = collect($allStoresBySiteId); + } + + /** + * Returns the current store. + */ + public function getCurrentStore(): Store + { + return $this->getStoreBySiteId(Sites::getCurrentSite()->id) ?? $this->getPrimaryStore(); + } + + /** + * @return Collection + */ + public function getAllStores(): Collection + { + if ($this->allStores === null) { + $this->loadAllStores(); + } + + return $this->allStores ?? collect(); + } + + public function getStoreById(int $id): ?Store + { + return $this->getAllStores()->firstWhere('id', $id); + } + + public function getStoreByUid(string $uid): ?Store + { + return $this->getAllStores()->firstWhere('uid', $uid); + } + + public function getStoreBySiteId(int $siteId): ?Store + { + if ($this->allStoresBySiteId === null) { + // Population of `allStoresBySiteId` is done in `loadAllStores()` + $this->loadAllStores(); + } + + return $this->allStoresBySiteId?->get($siteId); + } + + public function getStoreByHandle(string $handle): ?Store + { + return $this->getAllStores()->firstWhere('handle', $handle); + } + + /** + * Returns a collections of stores that are available to a user. + * + * @return Collection + */ + public function getStoresByUserId(int $userId): Collection + { + $user = Users::getUserById($userId); + + if (!$user) { + throw new \RuntimeException('Invalid user ID: ' . $userId); + } + + $allStores = $this->getAllStores(); + if (!Sites::isMultiSite()) { + return $allStores; + } + + return $allStores->filter(function(Store $store) use ($user) { + $siteUids = $store->getSites()->map(fn(Site $site) => $site->uid); + + foreach ($siteUids as $siteUid) { + if ($user->can('editSite:' . $siteUid)) { + return true; + } + } + + return false; + }); + } + + /** + * Saves a store. + */ + public function saveStore(Store $store, bool $runValidation = true): bool + { + $isNewStore = !$store->id; + + // Raise 'beforeSaveStore' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getStores()->hasEventHandlers(self::EVENT_BEFORE_SAVE_STORE)) { + $beforeEvent = new StoreEvent( + store: $store, + isNew: $isNewStore, + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getStores()->trigger(self::EVENT_BEFORE_SAVE_STORE, $beforeEvent); + } + + if ($runValidation && !$store->validate()) { + Log::info('Store not saved due to validation error.'); + return false; + } + + if ($isNewStore) { + $store->uid = Str::uuid()->toString(); + } elseif (!$store->uid) { + $store->uid = CraftDb::uidById(Table::STORES, $store->id); + } + + $configPath = self::CONFIG_STORES_KEY . '.' . $store->uid; + ProjectConfig::set( + $configPath, + $store->getConfig(), + "Save the \"{$store->handle}\" store" + ); + + // Now that we have a store ID, save it on the model + if ($isNewStore) { + $store->id = CraftDb::idByUid(Table::STORES, $store->uid); + + // Create any default data we need for the store + $orderStatus = new OrderStatus([ + 'name' => 'New', + 'handle' => 'new', + 'color' => 'green', + 'default' => true, + 'storeId' => $store->id, + ]); + app(OrderStatuses::class)->saveOrderStatus($orderStatus); + } + + // Update the other primary store. + if ($store->primary) { + foreach (ProjectConfig::get(self::CONFIG_STORES_KEY) as $uid => $config) { + if ($uid !== $store->uid && isset($config['primary']) && $config['primary'] === true) { + $configPath = self::CONFIG_STORES_KEY . '.' . $uid; + $config['primary'] = false; // Set the other to false + ProjectConfig::set( + $configPath, + $config, + "Set the \"{$config['name']}\" store to not be primary" + ); + } + } + } + + $this->refreshStores(); + + return true; + } + + /** + * @throws Exception + */ + public function deleteStoreById(int $storeId): bool + { + $store = $this->getStoreById($storeId); + + if (!$store) { + return false; + } + + return $this->deleteStore($store); + } + + /** + * @throws Exception + */ + public function deleteStore(Store $store): bool + { + // Make sure this isn't the primary site + if ($store->id === $this->getPrimaryStore()?->id) { + throw new Exception('You cannot delete the primary store.'); + } + + // Raise 'beforeDeleteStore' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getStores()->hasEventHandlers(self::EVENT_BEFORE_DELETE_STORE)) { + $event = new DeleteStoreEvent( + store: $store, + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getStores()->trigger(self::EVENT_BEFORE_DELETE_STORE, $event); + } + + $path = self::CONFIG_STORES_KEY . '.' . $store->uid; + ProjectConfig::remove($path, "Delete the \"{$store->handle}\" store"); + + return true; + } + + /** + * Handle store status change. + * + * @throws Throwable + */ + public function handleChangedStore(ConfigEvent $event): void + { + $storeUid = $event->tokenMatches[0]; + $data = $event->newValue; + + DB::beginTransaction(); + try { + $storeRecord = $this->getStoreRecord($storeUid); + $isNewStore = !$storeRecord->exists; + + $storeRecord->uid = $storeUid; + $storeRecord->name = $data['name']; + $storeRecord->handle = $data['handle']; + $storeRecord->primary = $data['primary']; + + $storeRecord->autoSetNewCartAddresses = ($data['autoSetNewCartAddresses'] ?? false); + $storeRecord->autoSetCartShippingMethodOption = ($data['autoSetCartShippingMethodOption'] ?? false); + $storeRecord->autoSetPaymentSource = ($data['autoSetPaymentSource'] ?? false); + $storeRecord->allowEmptyCartOnCheckout = ($data['allowEmptyCartOnCheckout'] ?? false); + $storeRecord->allowCheckoutWithoutPayment = ($data['allowCheckoutWithoutPayment'] ?? false); + $storeRecord->allowPartialPaymentOnCheckout = ($data['allowPartialPaymentOnCheckout'] ?? false); + $storeRecord->requireShippingAddressAtCheckout = ($data['requireShippingAddressAtCheckout'] ?? false); + $storeRecord->requireBillingAddressAtCheckout = ($data['requireBillingAddressAtCheckout'] ?? false); + $storeRecord->requireShippingMethodSelectionAtCheckout = ($data['requireShippingMethodSelectionAtCheckout'] ?? false); + $storeRecord->useBillingAddressForTax = ($data['useBillingAddressForTax'] ?? false); + $storeRecord->validateOrganizationTaxIdAsVatId = ($data['validateOrganizationTaxIdAsVatId'] ?? false); + $storeRecord->freeOrderPaymentStrategy = ($data['freeOrderPaymentStrategy'] ?? 'complete'); + $storeRecord->minimumTotalPriceStrategy = ($data['minimumTotalPriceStrategy'] ?? 'default'); + $storeRecord->orderReferenceFormat = ($data['orderReferenceFormat'] ?? '{{number[:7]}}'); + $storeRecord->currency = ($data['currency'] ?? null); + $storeRecord->sortOrder = ($data['sortOrder'] ?? 99); + + $storeRecord->save(); + + DB::commit(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + + // Did the primary site just change? + if ($data['primary']) { + DB::table(Table::STORES)->where('id', '!=', $storeRecord->id)->update(['primary' => false]); + DB::table(Table::STORES)->where('id', $storeRecord->id)->update(['primary' => true]); + } + + $paymentCurrency = app(PaymentCurrencies::class)->getPaymentCurrencyByIso($data['currency'] ?? '', $storeRecord->id); + if (!$paymentCurrency) { + $now = now()->toDateTimeString(); + DB::table(Table::PAYMENTCURRENCIES)->insert([ + 'iso' => $data['currency'] ?? 'USD', + 'storeId' => $storeRecord->id, + 'rate' => 1, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + } + + if (app(ShippingCategories::class)->getAllShippingCategories($storeRecord->id)->isEmpty()) { + $now = now()->toDateTimeString(); + DB::table(Table::SHIPPINGCATEGORIES)->insert([ + 'name' => 'General', + 'handle' => 'general', + 'default' => true, + 'storeId' => $storeRecord->id, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + } + + $this->refreshStores(); + + // Raise 'afterSaveStore' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getStores()->hasEventHandlers(self::EVENT_AFTER_SAVE_STORE)) { + $afterEvent = new StoreEvent( + store: $this->getStoreById($storeRecord->id), + isNew: $isNewStore, + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getStores()->trigger(self::EVENT_AFTER_SAVE_STORE, $afterEvent); + } + } + + /** + * Handle a deleted Store. + * + * @throws Throwable + */ + public function handleDeletedStore(ConfigEvent $event): void + { + $storeUid = $event->tokenMatches[0]; + $storeRecord = $this->getStoreRecord($storeUid); + + if (!$storeRecord->id) { + return; + } + + /** @var Store $store */ + $store = $this->getStoreById($storeRecord->id); + + // Raise 'beforeApplyStoreDelete' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getStores()->hasEventHandlers(self::EVENT_BEFORE_APPLY_STORE_DELETE)) { + $blockerEvent = new DeleteStoreEvent( + store: $store, + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getStores()->trigger(self::EVENT_BEFORE_APPLY_STORE_DELETE, $blockerEvent); + } + + DB::beginTransaction(); + + try { + $locationAddressId = $store->getSettings()->getLocationAddressId(); + + DB::table(Table::STORES)->where('id', $storeRecord->id)->delete(); + + // Delete store address + if ($locationAddressId) { + Elements::deleteElementById($locationAddressId, Address::class, hardDelete: true); + } + + DB::commit(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + + // Refresh stores + $this->refreshStores(); + + // Make sure any site store for this store is reassigned to the primary store + $siteStores = collect($this->getAllSiteStores())->where('storeId', $store->id)->all(); + foreach ($siteStores as $siteStore) { + $siteStore->storeId = $this->getPrimaryStore()->id; + $this->saveSiteStore($siteStore); + } + + // Raise 'afterDeleteStore' event + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getStores()->hasEventHandlers(self::EVENT_AFTER_DELETE_STORE)) { + $afterEvent = new DeleteStoreEvent( + store: $store, + ); + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getStores()->trigger(self::EVENT_AFTER_DELETE_STORE, $afterEvent); + } + } + + /** + * Refresh the status of all stores based on the DB data. + */ + public function refreshStores(): void + { + $this->allStores = null; + $this->allStoresBySiteId = null; + $this->loadAllStores(); + } + + /** + * Returns the primary store. + */ + public function getPrimaryStore(): ?Store + { + return $this->getAllStores()->firstWhere('primary', true); + } + + /** + * @param int[] $ids + */ + public function reorderStores(array $ids): bool + { + $uidsByIds = CraftDb::uidsByIds(Table::STORES, $ids); + + foreach ($ids as $sortOrder => $id) { + if (!empty($uidsByIds[$id])) { + $uid = $uidsByIds[$id]; + ProjectConfig::set(self::CONFIG_STORES_KEY . '.' . $uid . '.sortOrder', $sortOrder + 1); + } + } + + $this->refreshStores(); + + return true; + } + + /** + * Gets a store record by uid. + */ + private function getStoreRecord(string $uid): StoreRecord + { + if ($store = StoreRecord::where('uid', $uid)->first()) { + return $store; + } + + return new StoreRecord(); + } + + private function query(): Builder + { + $selectColumns = [ + 'handle', + 'id', + 'name', + 'primary', + 'uid', + ]; + + // TODO: Remove this schemaVersion guard in Commerce 6.0 once all installs are past schema 5.0.72 and the store settings columns are guaranteed to exist + // Note: right after a fresh install (same request), Plugins::installPlugin() only caches + // ['id', 'enabled'] — no 'schemaVersion' yet — so a missing key means "freshly installed", + // which always has the settings columns, not "pre-5.0.72". + $commerce = Plugins::getStoredPluginInfo('commerce'); + $hasSettingsColumns = !isset($commerce['schemaVersion']) || version_compare((string)$commerce['schemaVersion'], '5.0.72', '>='); + + if ($hasSettingsColumns) { + $selectColumns = array_merge($selectColumns, [ + 'allowCheckoutWithoutPayment', + 'allowEmptyCartOnCheckout', + 'allowPartialPaymentOnCheckout', + 'autoSetCartShippingMethodOption', + 'autoSetNewCartAddresses', + 'autoSetPaymentSource', + 'currency', + 'freeOrderPaymentStrategy', + 'minimumTotalPriceStrategy', + 'orderReferenceFormat', + 'requireBillingAddressAtCheckout', + 'requireShippingAddressAtCheckout', + 'requireShippingMethodSelectionAtCheckout', + 'sortOrder', + 'useBillingAddressForTax', + 'validateOrganizationTaxIdAsVatId', + ]); + } + + $query = DB::table(Table::STORES)->select($selectColumns); + + if ($hasSettingsColumns) { + $query->orderBy('sortOrder'); + } + + return $query; + } + + /** + * @return Collection + */ + public function getAllSitesForStore(Store $store): Collection + { + $sites = Sites::getAllSites(); + + return $this->getAllSiteStores() + ->filter(fn(SiteStore $siteStore) => $siteStore->storeId == $store->id) + ->map(fn(SiteStore $siteStore) => collect($sites)->firstWhere('id', $siteStore->siteId)); + } + + /** + * @return Collection + */ + public function getAllSiteStores(): Collection + { + if ($this->allSiteStores !== null) { + return $this->allSiteStores; + } + + $siteStores = []; + foreach ($this->siteStoresQuery()->get() as $store) { + $siteStores[] = new SiteStore((array)$store); + } + + return !empty($siteStores) ? $this->allSiteStores = collect($siteStores) : collect(); + } + + /** + * Returns sites that are assigned to more than one store assigned, so that other new stores can use them. + */ + public function getSiteIdsAvailableForAssignmentToNewStores(): array + { + // Sites that are assigned to more than one store + $storeIds = DB::table(Table::SITESTORES) + ->select('storeId') + ->groupBy('storeId') + ->havingRaw('COUNT(storeId) > 1') + ->pluck('storeId'); + + return DB::table(Table::SITESTORES) + ->select('siteId') + ->whereIn('storeId', $storeIds) + ->groupBy('siteId') + ->pluck('siteId') + ->all(); + } + + /** + * @throws Throwable + */ + public function saveSiteStore(SiteStore $siteStore, bool $runValidation = true): bool + { + if ($runValidation && !$siteStore->validate()) { + Log::info('Site store mapping not saved due to validation error.'); + return false; + } + + // We use the same UID as the site since we only have one record per site. + // This also makes it easier to see what site a store is mapped to in the project config. + $craftSite = Sites::getSiteById($siteStore->siteId); + if (!$craftSite) { + throw new \RuntimeException('Invalid site ID: ' . $siteStore->siteId); + } + + if (!$siteStore->uid) { + $siteStore->uid = CraftDb::uidById(CraftTable::SITES, $siteStore->siteId); + } + + $configPath = self::CONFIG_SITESTORES_KEY . '.' . $siteStore->uid; + ProjectConfig::set( + $configPath, + $siteStore->getConfig(), + "Save the \"{$craftSite->handle}\" commerce site store mapping" + ); + + $this->refreshStores(); + + return true; + } + + /** + * Handle site store mapping change. + * + * @throws Throwable + */ + public function handleChangedSiteStore(ConfigEvent $event): void + { + ProjectConfigHelper::ensureAllSitesProcessed(); + ProjectConfigData::ensureAllStoresProcessed(); + + $siteStoreUid = $event->tokenMatches[0]; + $data = $event->newValue; + + DB::beginTransaction(); + try { + $siteStoreRecord = SiteStoreRecord::where('uid', $siteStoreUid)->first(); + + if (!$siteStoreRecord) { + $siteStoreRecord = new SiteStoreRecord(); + } + + $siteStoreRecord->siteId = CraftDb::idByUid(CraftTable::SITES, $siteStoreUid); + $siteStoreRecord->storeId = CraftDb::idByUid(Table::STORES, $data['store']); + $siteStoreRecord->uid = $siteStoreUid; + + $siteStoreRecord->save(); + + DB::commit(); + + $this->refreshStores(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + } + + /** + * Handle a deleted Store. + * + * @throws Throwable + */ + public function handleDeletedSiteStore(ConfigEvent $event): void + { + $storeStoreUid = $event->tokenMatches[0]; + $siteStoreRecord = SiteStoreRecord::where('uid', $storeStoreUid)->first(); // site_stores uses the site UID + + if (!$siteStoreRecord) { + return; + } + + DB::beginTransaction(); + + try { + DB::table(Table::SITESTORES)->where('siteId', $siteStoreRecord->siteId)->delete(); + + DB::commit(); + + $this->refreshStores(); + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + } + + /** + * @throws Throwable + */ + public function afterSaveCraftSiteHandler(SiteSaved $event): void + { + $siteStore = SiteStoreRecord::find($event->site->id); + + // Only create it if it doesn't exist. + // The saving of the store does not currently change the store relation, but if it did, + // we would need to mutate the existing record. + if (!$siteStore) { + $siteStore = new SiteStore(); + $siteStore->siteId = $event->site->id; + $siteStore->storeId = $this->getPrimaryStore()->id; + $siteStore->uid = $event->site->uid; + $this->saveSiteStore($siteStore); + } + } + + /** + * @throws Throwable + */ + public function afterDeleteCraftSiteHandler(SiteDeleted $event): void + { + $siteStores = $this->getAllSiteStores(); + $siteStore = $siteStores->firstWhere('siteId', $event->site->id); + + if (!$siteStore) { + return; + } + + $store = $this->getStoreById($siteStore->storeId); + + $isStoreOrphaned = true; + foreach ($siteStores as $ss) { + if ($ss->siteId !== $siteStore->siteId && $ss->storeId === $siteStore->storeId) { + $isStoreOrphaned = false; + break; + } + } + + // If this was the primary store, make another the primary (if one exists) + if ($store->primary && $isStoreOrphaned) { + $newPrimaryStore = $this->getAllStores()->firstWhere('primary', false); + if ($newPrimaryStore) { + $newPrimaryStore->primary = true; + $this->saveStore($newPrimaryStore); + } + } + + // Delete the old siteStore record + ProjectConfig::remove(self::CONFIG_SITESTORES_KEY . '.' . $siteStore->uid); + } + + private function siteStoresQuery(): Builder + { + return DB::table(Table::SITESTORES) + ->select([ + 'siteId', + 'storeId', + 'uid', + ]); + } +} diff --git a/src/Support/Expressions/DateOnly.php b/src/Support/Expressions/DateOnly.php new file mode 100644 index 0000000000..a5861e8f10 --- /dev/null +++ b/src/Support/Expressions/DateOnly.php @@ -0,0 +1,28 @@ +stringize($grammar, $this->column) . ')'; + } +} diff --git a/src/Support/Expressions/LocalTimestamp.php b/src/Support/Expressions/LocalTimestamp.php new file mode 100644 index 0000000000..adb9ab27ea --- /dev/null +++ b/src/Support/Expressions/LocalTimestamp.php @@ -0,0 +1,76 @@ +stringize($grammar, $this->column); + $timezone = $this->stringize($grammar, new Value($this->timezone)); + + return match ($this->identify($grammar)) { + 'mariadb', 'mysql' => $this->mysqlLocal($column, $timezone), + 'pgsql' => "(({$column}) at time zone 'UTC' at time zone {$timezone})", + 'sqlite' => $this->sqliteLocal($column), + // MSSQL isn't a supported driver for Commerce. Operate on the value directly. + 'sqlsrv' => (string)$column, + }; + } + + private function mysqlLocal(string $column, string $timezone): string + { + // The fallback if timezone conversion can't happen in SQL is simply to extract the + // information from the UTC date stored in the column. + if (!Db::supportsTimeZones()) { + Log::warning('For accurate Commerce statistics it is recommend to make sure you have the timezones table populated. https://craftcms.com/knowledge-base/populating-mysql-mariadb-timezone-tables', ['category' => 'commerce']); + + return $column; + } + + return "convert_tz({$column}, 'UTC', {$timezone})"; + } + + /** + * SQLite has no named-timezone support, but its `datetime()` function accepts fixed + * `'+N minutes'`/`'-N minutes'` modifiers, so the offset is resolved in PHP (against the + * current instant, to account for DST) and applied that way instead. + */ + private function sqliteLocal(string $column): string + { + $offsetMinutes = intdiv(new DateTimeZone($this->timezone)->getOffset(new DateTime('now', new DateTimeZone('UTC'))), 60); + + if ($offsetMinutes === 0) { + return $column; + } + + $sign = $offsetMinutes > 0 ? '+' : '-'; + return "datetime({$column}, '{$sign}" . abs($offsetMinutes) . " minutes')"; + } +} diff --git a/src/Support/Expressions/MonthKey.php b/src/Support/Expressions/MonthKey.php new file mode 100644 index 0000000000..1a2f2ecf4c --- /dev/null +++ b/src/Support/Expressions/MonthKey.php @@ -0,0 +1,43 @@ +stringize($grammar, $this->column); + + return match ($this->identify($grammar)) { + 'mariadb', 'mysql', 'sqlsrv' => "concat(year({$column}), '-', month({$column}))", + 'pgsql' => "concat(extract(year from {$column}), '-', extract(month from {$column}))", + // strftime('%m', ...) is zero-padded; cast to int so the SQL-generated key matches + // PHP's `Y-n` dateKeyFormat (unpadded month) used to merge results. + 'sqlite' => "(strftime('%Y', {$column}) || '-' || cast(strftime('%m', {$column}) as integer))", + }; + } +} diff --git a/src/Support/Expressions/Round.php b/src/Support/Expressions/Round.php new file mode 100644 index 0000000000..60ca9053a2 --- /dev/null +++ b/src/Support/Expressions/Round.php @@ -0,0 +1,29 @@ +stringize($grammar, $this->expression) . ", {$this->decimals})"; + } +} diff --git a/src/Support/ObjectState.php b/src/Support/ObjectState.php new file mode 100644 index 0000000000..856b2f2c21 --- /dev/null +++ b/src/Support/ObjectState.php @@ -0,0 +1,43 @@ + + */ + private static function all(object $object): array + { + return (self::$state ??= new WeakMap())[$object] ?? []; + } +} diff --git a/src/Tax/Contracts/TaxEngineInterface.php b/src/Tax/Contracts/TaxEngineInterface.php new file mode 100644 index 0000000000..37ebf8f177 --- /dev/null +++ b/src/Tax/Contracts/TaxEngineInterface.php @@ -0,0 +1,44 @@ + + * @link http://ec.europa.eu/taxation_customs/vies/faq.html?locale=lt#item_11 + */ + private array $patterns = [ + 'AT' => 'U[A-Z\d]{8}', + 'BE' => '(0|1)\d{9}', + 'BG' => '\d{9,10}', + 'CY' => '\d{8}[A-Z]', + 'CZ' => '\d{8,10}', + 'DE' => '\d{9}', + 'DK' => '(\d{2} ?){3}\d{2}', + 'EE' => '\d{9}', + 'EL' => '\d{9}', + 'ES' => '([A-Z]\d{7}[A-Z]|\d{8}[A-Z]|[A-Z]\d{8})', + 'EU' => '\d{9}', + 'FI' => '\d{8}', + 'FR' => '[A-Z\d]{2}\d{9}', + 'GB' => '(\d{9}|\d{12}|(GD|HA)\d{3})', + 'HR' => '\d{11}', + 'HU' => '\d{8}', + 'IE' => '((\d{7}[A-Z]{1,2})|(\d[A-Z]\d{5}[A-Z]))', + 'IT' => '\d{11}', + 'LT' => '(\d{9}|\d{12})', + 'LU' => '\d{8}', + 'LV' => '\d{11}', + 'MT' => '\d{8}', + 'NL' => '\d{9}B\d{2}', + 'PL' => '\d{10}', + 'PT' => '\d{9}', + 'RO' => '\d{2,10}', + 'SE' => '\d{12}', + 'SI' => '\d{8}', + 'SK' => '\d{10}', + 'SM' => '\d{5}', + ]; + + #[\Override] + public static function displayName(): string + { + return t('EU VAT ID', category: 'commerce'); + } + + /** @return array{0: string, 1: string} */ + private function splitNumber(string $idNumber): array + { + $vatNumber = strtoupper($idNumber); + $country = substr($vatNumber, 0, 2); + $number = substr($vatNumber, 2); + + return [$country, $number]; + } + + #[\Override] + public function validateFormat(string $idNumber): bool + { + [$country, $number] = $this->splitNumber($idNumber); + + if (!isset($this->patterns[$country])) { + return false; + } + + return preg_match('/^' . $this->patterns[$country] . '$/', $number) > 0; + } + + #[\Override] + public function validateExistence(string $idNumber): bool + { + [$country, $number] = $this->splitNumber($idNumber); + + try { + $response = Http::asJson()->post(self::API_URL, [ + 'countryCode' => $country, + 'vatNumber' => $number, + ]); + + if (!$response->successful()) { + return false; + } + + return $response->json('valid') === true; + } catch (Exception $e) { + Log::error($e->getMessage()); + } + + return false; + } + + #[\Override] + public static function isEnabled(): bool + { + return true; + } + + #[\Override] + public function validate(string $idNumber): bool + { + try { + return $this->validateFormat($idNumber) && $this->validateExistence($idNumber); + } catch (Exception $e) { + Log::error('Error validating EU VAT ID: ' . $e->getMessage()); + return false; + } + } +} diff --git a/src/Tax/Models/TaxAddressZone.php b/src/Tax/Models/TaxAddressZone.php new file mode 100644 index 0000000000..9e47247100 --- /dev/null +++ b/src/Tax/Models/TaxAddressZone.php @@ -0,0 +1,57 @@ +getAllStores() as $store) { + $zone = app(\CraftCms\Commerce\Tax\TaxZones::class)->getTaxZoneById((int)$id, $store->id); + if ($zone !== null) { + /** @phpstan-ignore-next-line */ + return $zone; + } + } + return null; + } + + #[\Override] + public function getCpEditUrl(): string + { + return $this->getStore()->getStoreSettingsUrl('taxzones/' . $this->id); + } + + #[\Override] + public function getUiLabel(): string + { + return t($this->name ?? '', category: 'site'); + } + + #[\Override] + public function getId(): ?int + { + return $this->id; + } + + #[\Override] + public function getRules(): array + { + $rules = parent::getRules(); + $rules['name'][] = Rule::unique(Table::TAXZONES, 'name')->where('storeId', $this->storeId); + + return $rules; + } +} diff --git a/src/Tax/Models/TaxCategory.php b/src/Tax/Models/TaxCategory.php new file mode 100644 index 0000000000..b6d16b6cf1 --- /dev/null +++ b/src/Tax/Models/TaxCategory.php @@ -0,0 +1,125 @@ +name; + } + + #[\Override] + public static function get(int|string $id): ?static + { + /** @phpstan-ignore-next-line */ + return app(\CraftCms\Commerce\Tax\TaxCategories::class)->getTaxCategoryById($id); + } + + #[\Override] + public function getId(): ?int + { + return $this->id; + } + + #[\Override] + public function getUiLabel(): string + { + return t($this->name ?? '', category: 'site'); + } + + #[\Override] + public function getIcon(): ?string + { + return $this->icon; + } + + #[\Override] + public function getColor(): ?Color + { + return $this->color ? Color::tryFrom($this->color) : null; + } + + public function getTaxRates(?int $storeId = null): Collection + { + return app(\CraftCms\Commerce\Tax\TaxRates::class)->getAllTaxRates($storeId)->where('taxCategoryId', $this->id); + } + + public function getCpEditUrl(?int $storeId = null): string + { + if ($storeId === null || !$store = app(Stores::class)->getStoreById($storeId)) { + $store = app(Stores::class)->getPrimaryStore(); + } + + return $store->getStoreSettingsUrl('taxcategories/' . $this->id); + } + + public function setProductTypes(array $productTypes): void + { + $this->_productTypes = $productTypes; + } + + public function getProductTypes(): array + { + if ($this->_productTypes === null && $this->id) { + $this->_productTypes = app(ProductTypes::class)->getProductTypesByTaxCategoryId($this->id); + } + + return $this->_productTypes ?? []; + } + + public function getProductTypeIds(): array + { + return array_column($this->getProductTypes(), 'id'); + } + + #[\Override] + public function getRules(): array + { + return [ + 'handle' => ['required', 'string', 'regex:/^[a-zA-Z_][a-zA-Z0-9_]*$/'], + ]; + } + + #[\Override] + public function extraFields(): array + { + return array_merge(parent::extraFields(), ['productTypes', 'productTypeIds', 'taxRates']); + } +} diff --git a/src/Tax/Models/TaxRate.php b/src/Tax/Models/TaxRate.php new file mode 100644 index 0000000000..4bf5d2faf2 --- /dev/null +++ b/src/Tax/Models/TaxRate.php @@ -0,0 +1,147 @@ +getTaxRateById($id); + } + + #[\Override] + public function getUiLabel(): string + { + return t($this->name ?? '', category: 'site'); + } + + #[\Override] + public function getId(): ?int + { + return $this->id; + } + + public function getCpEditUrl(): string + { + return $this->getStore()->getStoreSettingsUrl('taxrates/' . $this->id); + } + + public function getRateAsPercent(): string + { + return I18N::getFormatter()->asPercent($this->rate); + } + + public function getTaxZone(): ?TaxAddressZone + { + if ($this->_taxZone === null && $this->taxZoneId) { + $this->_taxZone = app(\CraftCms\Commerce\Tax\TaxZones::class)->getTaxZoneById($this->taxZoneId, $this->storeId); + } + + return $this->_taxZone; + } + + public function getTaxCategory(): ?TaxCategory + { + if (!isset($this->_taxCategory) && $this->taxCategoryId) { + $this->_taxCategory = app(\CraftCms\Commerce\Tax\TaxCategories::class)->getTaxCategoryById($this->taxCategoryId); + } + + return $this->_taxCategory; + } + + public function getIsEverywhere(): bool + { + return !$this->getTaxZone(); + } + + public function hasTaxIdValidators(): bool + { + return count($this->taxIdValidators) > 0; + } + + public function hasTaxIdValidator(string $className): bool + { + return in_array($className, $this->taxIdValidators, true); + } + + public function getSelectedEnabledTaxIdValidators(): array + { + $selectedValidators = $this->taxIdValidators; + $validators = app(\CraftCms\Commerce\Tax\Taxes::class)->getEnabledTaxIdValidators(); + $activeValidators = []; + foreach ($validators as $validator) { + if (in_array($validator::class, $selectedValidators)) { + $activeValidators[] = $validator; + } + } + + return $activeValidators; + } + + #[\Override] + public function extraFields(): array + { + return array_merge(parent::extraFields(), ['taxCategory', 'taxZone', 'rateAsPercent', 'isEverywhere']); + } + + #[\Override] + public function getRules(): array + { + $rules = [ + 'name' => ['required', 'string'], + ]; + + if (!in_array($this->taxable, TaxRateRecord::ORDER_TAXABALES, true)) { + $rules['taxCategoryId'] = ['required', 'integer']; + } + + return $rules; + } +} diff --git a/src/Tax/Records/TaxCategory.php b/src/Tax/Records/TaxCategory.php new file mode 100644 index 0000000000..a941f6a165 --- /dev/null +++ b/src/Tax/Records/TaxCategory.php @@ -0,0 +1,30 @@ + 'boolean', + ]; +} diff --git a/src/Tax/Records/TaxRate.php b/src/Tax/Records/TaxRate.php new file mode 100644 index 0000000000..3592237b00 --- /dev/null +++ b/src/Tax/Records/TaxRate.php @@ -0,0 +1,75 @@ + 'integer', + 'taxCategoryId' => 'integer', + 'taxZoneId' => 'integer', + 'rate' => 'float', + 'include' => 'boolean', + 'isVat' => 'boolean', + 'removeIncluded' => 'boolean', + 'removeVatIncluded' => 'boolean', + 'isEverywhere' => 'boolean', + 'enabled' => 'boolean', + 'taxIdValidators' => 'array', + ]; +} diff --git a/src/Tax/Records/TaxZone.php b/src/Tax/Records/TaxZone.php new file mode 100644 index 0000000000..48f40ebd16 --- /dev/null +++ b/src/Tax/Records/TaxZone.php @@ -0,0 +1,29 @@ + 'integer', + 'default' => 'boolean', + 'condition' => 'array', + ]; +} diff --git a/src/Tax/TaxCategories.php b/src/Tax/TaxCategories.php new file mode 100644 index 0000000000..f49ffc0568 --- /dev/null +++ b/src/Tax/TaxCategories.php @@ -0,0 +1,238 @@ +allTaxCategories === null || $this->allTaxCategoriesWithTrashed === null) { + $rows = $this->query(true)->get()->all(); + + $this->allTaxCategories = []; + $this->allTaxCategoriesWithTrashed = []; + foreach ($rows as $row) { + $taxCategory = new TaxCategory((array) $row); + + if (!$taxCategory->dateDeleted) { + $this->allTaxCategories[] = $taxCategory; + } + $this->allTaxCategoriesWithTrashed[] = $taxCategory; + } + } + + return $withTrashed ? $this->allTaxCategoriesWithTrashed : $this->allTaxCategories; + } + + public function getTaxCategoryById(int $taxCategoryId): ?TaxCategory + { + return collect($this->getAllTaxCategories())->firstWhere('id', $taxCategoryId); + } + + public function getTaxCategoryByHandle(string $taxCategoryHandle): ?TaxCategory + { + return collect($this->getAllTaxCategories())->firstWhere('handle', $taxCategoryHandle); + } + + /** + * @return array + */ + public function getAllTaxCategoriesAsList(): array + { + return collect($this->getAllTaxCategories()) + ->mapWithKeys(fn(TaxCategory $c) => [$c->id => $c->getUiLabel()]) + ->all(); + } + + /** + * @throws \RuntimeException + */ + public function getDefaultTaxCategory(): TaxCategory + { + $categories = $this->getAllTaxCategories(); + + $default = collect($categories)->firstWhere('default', true) + ?? collect($categories)->first(); + + if (!$default) { + throw new \RuntimeException('Commerce must have at least one (default) tax category set up.'); + } + + return $default; + } + + public function saveTaxCategory(TaxCategory $taxCategory, bool $runValidation = true): bool + { + if ($taxCategory->id) { + $record = TaxCategoryRecord::find($taxCategory->id); + + if (!$record) { + throw new \RuntimeException(t('No tax category exists with the ID “{id}”', ['id' => $taxCategory->id], category: 'commerce')); + } + } else { + $record = new TaxCategoryRecord(); + } + + if ($runValidation && !$taxCategory->validate()) { + return false; + } + + $record->name = $taxCategory->name; + $record->handle = $taxCategory->handle; + $record->description = $taxCategory->description; + $record->icon = $taxCategory->icon; + $record->color = $taxCategory->color; + $record->default = $taxCategory->default; + + $record->save(); + + $taxCategory->id = $record->id; + + // If this was the default, clear default on all others. + if ($taxCategory->default) { + TaxCategoryRecord::withTrashed()->where('id', '!=', $record->id)->update(['default' => false]); + } + + $currentProductTypeIds = DB::table(Table::PRODUCTTYPES_TAXCATEGORIES) + ->where('taxCategoryId', $taxCategory->id) + ->pluck('productTypeId') + ->all(); + + $newProductTypeIds = collect($taxCategory->getProductTypes())->pluck('id')->all(); + + foreach (array_diff($currentProductTypeIds, $newProductTypeIds) as $oldProductTypeId) { + $this->resaveProductsByProductTypeId((int) $oldProductTypeId); + } + foreach (array_diff($newProductTypeIds, $currentProductTypeIds) as $newProductTypeId) { + $this->resaveProductsByProductTypeId((int) $newProductTypeId); + } + + DB::table(Table::PRODUCTTYPES_TAXCATEGORIES) + ->where('taxCategoryId', $record->id) + ->delete(); + + $now = now()->toDateTimeString(); + foreach ($taxCategory->getProductTypes() as $productType) { + DB::table(Table::PRODUCTTYPES_TAXCATEGORIES)->insert([ + 'productTypeId' => (int) $productType->id, + 'taxCategoryId' => $taxCategory->id, + 'dateCreated' => $now, + 'dateUpdated' => $now, + ]); + } + + $this->allTaxCategories = null; + + return true; + } + + public function deleteTaxCategoryById(int $id): bool + { + $taxCategory = TaxCategoryRecord::find($id); + + if ($taxCategory === null || $taxCategory->default) { + return false; + } + + if ($taxCategory->delete()) { + $this->allTaxCategories = null; + return true; + } + + return false; + } + + /** + * @return array + */ + public function getTaxCategoriesByProductTypeId(int $productTypeId): array + { + $rows = $this->query() + ->join(Table::PRODUCTTYPES_TAXCATEGORIES . ' as productTypeTaxCategories', 'taxCategories.id', '=', 'productTypeTaxCategories.taxCategoryId') + ->where('productTypeTaxCategories.productTypeId', $productTypeId) + ->get() + ->all(); + + if (empty($rows)) { + try { + $taxCategory = $this->getDefaultTaxCategory(); + } catch (\RuntimeException) { + return []; + } + + return [$taxCategory->id => $taxCategory]; + } + + $taxCategories = []; + foreach ($rows as $row) { + $taxCategory = new TaxCategory((array) $row); + $taxCategories[$taxCategory->id] = $taxCategory; + } + + return $taxCategories; + } + + private function resaveProductsByProductTypeId(int $productTypeId): void + { + dispatch(new ResaveElements( + elementType: Product::class, + criteria: [ + 'typeId' => $productTypeId, + 'siteId' => '*', + 'unique' => true, + 'status' => null, + ], + )); + } + + /** + * @return \Illuminate\Database\Query\Builder + */ + private function query(bool $withTrashed = false): \Illuminate\Database\Query\Builder + { + $query = DB::table(Table::TAXCATEGORIES . ' as taxCategories') + ->select([ + 'taxCategories.dateCreated', + 'taxCategories.dateDeleted', + 'taxCategories.dateUpdated', + 'taxCategories.default', + 'taxCategories.description', + 'taxCategories.handle', + 'taxCategories.id', + 'taxCategories.name', + ]); + + // Only add icon and color if the columns exist (for pre-migration compatibility). + if (Schema::hasColumn(Table::TAXCATEGORIES, 'icon')) { + $query->addSelect(['taxCategories.icon', 'taxCategories.color']); + } + + if (!$withTrashed) { + $query->whereNull('dateDeleted'); + } + + return $query; + } +} diff --git a/src/Tax/TaxRates.php b/src/Tax/TaxRates.php new file mode 100644 index 0000000000..e58db42f22 --- /dev/null +++ b/src/Tax/TaxRates.php @@ -0,0 +1,169 @@ +>|null */ + private ?array $allTaxRates = null; + + /** + * @return Collection + */ + public function getAllTaxRates(?int $storeId = null): Collection + { + $storeId ??= $this->currentStoreId(); + + if ($this->allTaxRates === null || !isset($this->allTaxRates[$storeId])) { + $rows = $this->query()->where('storeId', $storeId)->get()->all(); + + $this->allTaxRates ??= []; + + foreach ($rows as $row) { + $taxRate = new TaxRate((array) $row); + $this->allTaxRates[$taxRate->storeId] ??= collect(); + $this->allTaxRates[$taxRate->storeId]->push($taxRate); + } + } + + return $this->allTaxRates[$storeId] ?? collect(); + } + + /** + * @return Collection + */ + public function getAllEnabledTaxRates(?int $storeId = null): Collection + { + return $this->getAllTaxRates($storeId)->where('enabled', true); + } + + /** + * @return Collection + */ + public function getTaxRatesByTaxZoneId(int $taxZoneId, ?int $storeId = null): Collection + { + return $this->getAllTaxRates($storeId)->where('taxZoneId', $taxZoneId); + } + + public function getTaxRateById(int $id, ?int $storeId = null): ?TaxRate + { + return $this->getAllTaxRates($storeId)->firstWhere('id', $id); + } + + public function saveTaxRate(TaxRate $model, bool $runValidation = true): bool + { + if ($model->id) { + $record = TaxRateRecord::find($model->id); + if (!$record) { + throw new RuntimeException(t('No tax rate exists with the ID “{id}”', ['id' => $model->id], category: 'commerce')); + } + } else { + $record = new TaxRateRecord(); + } + + if ($runValidation && !$model->validate()) { + return false; + } + + $record->name = $model->name; + $record->code = $model->code; + $record->rate = $model->rate; + $record->storeId = $model->storeId; + + // if not an included tax, then can not be removed. + $record->include = $model->include; + $record->isVat = $model->hasTaxIdValidators(); + $record->removeIncluded = !$record->include ? false : $model->removeIncluded; + $record->removeVatIncluded = (!$record->include || !$record->isVat) ? false : $model->removeVatIncluded; + $record->taxable = $model->taxable; + $record->taxCategoryId = $model->taxCategoryId; + $record->taxZoneId = $model->taxZoneId ?: null; + $record->isEverywhere = $model->getIsEverywhere(); + $record->enabled = $model->enabled; + $record->taxIdValidators = $model->taxIdValidators; + + if (!$record->isEverywhere && $record->taxZoneId) { + $taxZone = app(TaxZones::class)->getTaxZoneById($record->taxZoneId, $record->storeId); + + if (!$taxZone) { + throw new RuntimeException(t('No tax zone exists with the ID “{id}”', ['id' => $record->taxZoneId], category: 'commerce')); + } + + if ($record->removeIncluded && !$taxZone->default) { + $model->addError('removeIncluded', t('Removable included tax rates are only allowed for the default tax zone.', category: 'commerce')); + + return false; + } + } + + $record->save(); + + $model->id = $record->id; + $this->clearCache(); + + return true; + } + + public function deleteTaxRateById(int $id): bool + { + $record = TaxRateRecord::find($id); + + if (!$record) { + return false; + } + + $result = (bool) $record->delete(); + if ($result) { + $this->clearCache(); + } + + return $result; + } + + private function clearCache(): void + { + $this->allTaxRates = null; + } + + private function query(): \Illuminate\Database\Query\Builder + { + return DB::table(Table::TAXRATES) + ->select([ + 'code', + 'dateCreated', + 'dateUpdated', + 'enabled', + 'id', + 'include', + 'name', + 'rate', + 'removeIncluded', + 'removeVatIncluded', + 'storeId', + 'taxable', + 'taxCategoryId', + 'taxIdValidators', + 'taxZoneId', + ]) + ->orderByDesc('include') + ->orderByDesc('isVat'); + } + + private function currentStoreId(): int + { + return app(Stores::class)->getCurrentStore()->id; + } +} diff --git a/src/Tax/TaxZones.php b/src/Tax/TaxZones.php new file mode 100644 index 0000000000..8b432cc58b --- /dev/null +++ b/src/Tax/TaxZones.php @@ -0,0 +1,127 @@ +>|null */ + private ?array $allZones = null; + + /** + * @return Collection + */ + public function getAllTaxZones(?int $storeId = null): Collection + { + $storeId ??= $this->currentStoreId(); + + if ($this->allZones === null || !isset($this->allZones[$storeId])) { + $rows = $this->query()->where('storeId', $storeId)->get()->all(); + + $this->allZones ??= []; + + foreach ($rows as $row) { + $zone = new TaxAddressZone((array) $row); + $this->allZones[$zone->storeId] ??= collect(); + $this->allZones[$zone->storeId]->push($zone); + } + } + + return $this->allZones[$storeId] ?? collect(); + } + + public function getTaxZoneById(int $id, ?int $storeId = null): ?TaxAddressZone + { + return $this->getAllTaxZones($storeId)->firstWhere('id', $id); + } + + public function saveTaxZone(TaxAddressZone $model, bool $runValidation = true): bool + { + if ($model->id) { + $record = TaxZoneRecord::find($model->id); + if (!$record) { + throw new \RuntimeException(t('No tax zone exists with the ID “{id}”', ['id' => $model->id], category: 'commerce')); + } + } else { + $record = new TaxZoneRecord(); + } + + if ($runValidation && !$model->validate()) { + return false; + } + + $record->storeId = $model->storeId; + $record->name = $model->name; + $record->description = $model->description; + $record->default = $model->default; + $record->condition = $model->getCondition()->getConfig(); + + $record->save(); + + $model->id = $record->id; + + // If this was the default, clear default on all others in the same store. + if ($model->default) { + TaxZoneRecord::where('id', '!=', $model->id) + ->where('storeId', $model->storeId) + ->update(['default' => false]); + } + + $this->clearCaches(); + + return true; + } + + public function deleteTaxZoneById(int $id): bool + { + $record = TaxZoneRecord::find($id); + + if (!$record) { + return false; + } + + $result = (bool) $record->delete(); + if ($result) { + $this->clearCaches(); + } + + return $result; + } + + private function clearCaches(): void + { + $this->allZones = []; + } + + private function query(): \Illuminate\Database\Query\Builder + { + return DB::table(Table::TAXZONES) + ->select([ + 'condition', + 'dateCreated', + 'dateUpdated', + 'default', + 'description', + 'id', + 'name', + 'storeId', + ]) + ->orderBy('name'); + } + + private function currentStoreId(): int + { + return app(Stores::class)->getCurrentStore()->id; + } +} diff --git a/src/Tax/Taxes.php b/src/Tax/Taxes.php new file mode 100644 index 0000000000..c82961b91e --- /dev/null +++ b/src/Tax/Taxes.php @@ -0,0 +1,193 @@ + + */ + public function getTaxIdValidators(): Collection + { + $validators = [new EuVatIdValidator()]; + + $event = new TaxIdValidatorsEvent( + validators: $validators, + ); + + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getTaxes()->hasEventHandlers(self::EVENT_REGISTER_TAX_ID_VALIDATORS)) { + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getTaxes()->trigger(self::EVENT_REGISTER_TAX_ID_VALIDATORS, $event); + } + + foreach ($event->validators as $validator) { + if (!$validator instanceof TaxIdValidatorInterface) { + throw new RuntimeException('Tax ID validator must implement TaxIdValidatorInterface'); + } + } + + return collect($event->validators); + } + + /** + * @return Collection + */ + public function getEnabledTaxIdValidators(): Collection + { + return $this->getTaxIdValidators()->filter(fn(TaxIdValidatorInterface $validator) => $validator::isEnabled()); + } + + public function getEngine(): TaxEngineInterface + { + if ($this->taxEngine !== null) { + return $this->taxEngine; + } + + $event = new TaxEngineEvent(engine: new Tax()); + + // TODO: migrate event firing to Laravel once event system is bridged + if (Plugin::getInstance()->getTaxes()->hasEventHandlers(self::EVENT_REGISTER_TAX_ENGINE)) { + /** @phpstan-ignore-next-line */ + Plugin::getInstance()->getTaxes()->trigger(self::EVENT_REGISTER_TAX_ENGINE, $event); + } + + $this->taxEngine = $event->engine; + + return $this->taxEngine; + } + + #[\Override] + public static function displayName(): string + { + return 'Commerce Taxes Service'; + } + + #[\Override] + public static function isSelectable(): bool + { + return true; + } + + #[\Override] + public function taxAdjusterClass(): string + { + return $this->getEngine()->taxAdjusterClass(); + } + + #[\Override] + public function viewTaxCategories(): bool + { + return $this->getEngine()->viewTaxCategories(); + } + + #[\Override] + public function createTaxCategories(): bool + { + return $this->getEngine()->createTaxCategories(); + } + + #[\Override] + public function editTaxCategories(): bool + { + return $this->getEngine()->editTaxCategories(); + } + + #[\Override] + public function deleteTaxCategories(): bool + { + return $this->getEngine()->deleteTaxCategories(); + } + + #[\Override] + public function taxCategoryActionHtml(): string + { + return $this->getEngine()->taxCategoryActionHtml(); + } + + #[\Override] + public function viewTaxZones(): bool + { + return $this->getEngine()->viewTaxZones(); + } + + #[\Override] + public function editTaxZones(): bool + { + return $this->getEngine()->editTaxZones(); + } + + #[\Override] + public function viewTaxRates(): bool + { + return $this->getEngine()->viewTaxRates(); + } + + #[\Override] + public function editTaxRates(): bool + { + return $this->getEngine()->editTaxRates(); + } + + #[\Override] + public function cpTaxNavSubItems(): array + { + return $this->getEngine()->cpTaxNavSubItems(); + } + + #[\Override] + public function createTaxZones(): bool + { + return $this->getEngine()->createTaxZones(); + } + + #[\Override] + public function deleteTaxZones(): bool + { + return $this->getEngine()->deleteTaxZones(); + } + + #[\Override] + public function taxZoneActionHtml(): string + { + return $this->getEngine()->taxZoneActionHtml(); + } + + #[\Override] + public function createTaxRates(): bool + { + return $this->getEngine()->createTaxRates(); + } + + #[\Override] + public function deleteTaxRates(): bool + { + return $this->getEngine()->deleteTaxRates(); + } + + #[\Override] + public function taxRateActionHtml(): string + { + return $this->getEngine()->taxRateActionHtml(); + } +} diff --git a/src/Tax/Vat.php b/src/Tax/Vat.php new file mode 100644 index 0000000000..ec9872e2c3 --- /dev/null +++ b/src/Tax/Vat.php @@ -0,0 +1,45 @@ +getEnabledTaxIdValidators(); + foreach ($validators as $validator) { + if ($validator->validateFormat($vatId) && $validator->validate($vatId)) { + $validOrganizationTaxId = true; + break; + } + } + } catch (Throwable $e) { + Log::error('Communication with VAT API failed: ' . $e->getMessage()); + + $validOrganizationTaxId = false; + } + } + + if (!$validOrganizationTaxId) { + Cache::forget(self::CACHE_KEY_PREFIX . $vatId); + return false; + } + + Cache::forever(self::CACHE_KEY_PREFIX . $vatId, '1'); + return true; + } +} diff --git a/src/Transfer/Conditions/TransferCondition.php b/src/Transfer/Conditions/TransferCondition.php new file mode 100644 index 0000000000..51c4387560 --- /dev/null +++ b/src/Transfer/Conditions/TransferCondition.php @@ -0,0 +1,15 @@ +getOriginLocation() === null && $this->getDestinationLocation() === null) { + return t('Transfer', category: 'commerce'); + } + + return t('{from} to {to}', [ + 'from' => $this->getOriginLocation()->getUiLabel(), + 'to' => $this->getDestinationLocation()->getUiLabel(), + ], category: 'commerce'); + } + + #[Override] + public static function displayName(): string + { + return t('Transfer', category: 'commerce'); + } + + #[Override] + public static function lowerDisplayName(): string + { + return t('transfer', category: 'commerce'); + } + + #[Override] + public static function pluralDisplayName(): string + { + return t('Transfers', category: 'commerce'); + } + + #[Override] + public static function pluralLowerDisplayName(): string + { + return t('transfers', category: 'commerce'); + } + + #[Override] + public static function refHandle(): ?string + { + return 'transfer'; + } + + #[Override] + public static function find(): TransferQuery + { + return new TransferQuery(); + } + + #[Override] + public static function createCondition(): ElementConditionInterface + { + return new TransferCondition(static::class); + } + + #[Override] + protected static function defineSources(string $context): array + { + $transferStatusSources = []; + foreach (TransferStatusType::cases() as $status) { + $transferStatusSources[] = [ + 'key' => $status->value, + 'status' => $status->color(), + 'label' => t($status->label(), category: 'commerce'), + 'badgeCount' => static::find()->transferStatus($status->value)->count(), + 'criteria' => [ + 'transferStatus' => $status->value, + ], + ]; + } + + return [ + [ + 'key' => '*', + 'label' => t('All Transfers', category: 'commerce'), + 'criteria' => [], + ], + [ + 'heading' => t('Transfer Status', category: 'commerce'), + ], + ...$transferStatusSources, + ]; + } + + #[Override] + protected static function defineTableAttributes(): array + { + return [ + 'id' => ['label' => t('ID', category: 'app')], + 'uid' => ['label' => t('UID', category: 'app')], + 'originLocation' => ['label' => t('Origin', category: 'commerce')], + 'destinationLocation' => ['label' => t('Destination', category: 'commerce')], + 'dateCreated' => ['label' => t('Date Created', category: 'app')], + 'dateUpdated' => ['label' => t('Date Updated', category: 'app')], + 'received' => ['label' => t('Received', category: 'commerce')], + ]; + } + + #[Override] + protected static function defineDefaultTableAttributes(string $source): array + { + return [ + 'id', + 'dateCreated', + 'received', + ]; + } + + #[Override] + protected function attributeHtml(string $attribute): string + { + return match ($attribute) { + 'originLocation' => $this->getOriginLocation()?->getUiLabel() ?? '', + 'destinationLocation' => $this->getDestinationLocation()?->getUiLabel() ?? '', + 'received' => $this->getTotalReceived() . '/' . $this->getTotalQuantity(), + default => parent::attributeHtml($attribute), + }; + } + + #[Override] + public function canView(User $user): bool + { + if (parent::canView($user)) { + return true; + } + + return $user->can('commerce-manageTransfers'); + } + + #[Override] + public function canSave(User $user): bool + { + if (parent::canSave($user)) { + return true; + } + + return $user->can('commerce-manageTransfers'); + } + + #[Override] + public function canDuplicate(User $user): bool + { + return false; + } + + #[Override] + public function canDelete(User $user): bool + { + $canDelete = false; + + if (parent::canSave($user)) { + $canDelete = true; + } + + if ($this->getTransferStatus() === TransferStatusType::DRAFT) { + $canDelete = true; + } + + return $canDelete && $user->can('commerce-manageTransfers'); + } + + #[Override] + public function canCreateDrafts(User $user): bool + { + return false; + } + + #[Override] + protected function cpEditUrl(): ?string + { + return Url::cpUrl("commerce/inventory/transfers/{$this->getCanonicalId()}"); + } + + #[Override] + public function getPostEditUrl(): ?string + { + return Url::cpUrl('commerce/inventory/transfers'); + } + + #[Override] + public function getMetadata(): array + { + $metadata = parent::getMetadata(); + + $statusHtml = app(StatusHtml::class)->statusIndicatorHtml($this->getTransferStatus()->label(), [ + 'color' => $this->getTransferStatus()->color(), + ]) . ' ' . Html::tag('span', $this->getTransferStatus()->label()); + + return array_merge([t('Transfer Status', category: 'commerce') => $statusHtml], $metadata); + } + + #[Override] + protected function safeActionMenuItems(): array + { + $safeActions = parent::safeActionMenuItems(); + + if ($this->isTransferDraft() && count($this->getDetails()) > 0) { + $safeActions['mark-as-pending'] = [ + 'action' => 'commerce/transfers/mark-as-pending', + 'label' => t('Mark as Pending', category: 'commerce'), + 'confirm' => t('Are you sure you want to mark this transfer as pending? This will show as incoming at the destination.', category: 'commerce'), + 'params' => [ + 'transferId' => $this->id, + ], + 'redirect' => 'commerce/inventory/transfers/' . $this->id, + ]; + } + + return $safeActions; + } + + #[Override] + public function getFieldLayout(): ?FieldLayout + { + return app(Transfers::class)->getFieldLayout(); + } + + #[Override] + public function afterValidate(?Validator $validator = null): void + { + if ($this->ruleset->inScenarios(TransferRules::SCENARIO_LIVE)) { + $this->validateLocations(); + $this->validateDetails(); + } + } + + public function validateLocations(): void + { + if ($this->originLocationId === $this->destinationLocationId) { + $this->errors()->add('originLocationId', t('Origin and destination cannot be the same.', category: 'commerce')); + } + } + + public function validateDetails(): void + { + if ($this->sumDetailsQuanity() < 1) { + $this->errors()->add('details', t('Transfer must have at least one item.', category: 'commerce')); + } + + foreach ($this->getDetails() as $detail) { + if (!$detail->validate()) { + $this->addModelErrors($detail, 'details'); + } + } + } + + #[Override] + public function prepareEditScreen(Response|CpScreenResponse $response, string $containerId): void + { + // TODO: this still registers the legacy `craft\commerce\web\assets\transfers\TransfersAsset` + // yii2 AssetBundle via the yii2-adapter bridge, since Commerce's own webpack-built CP assets + // haven't been ported to a native `HtmlStack`-based registration mechanism yet. + \Craft::$app->getView()->registerAssetBundle(TransfersAsset::class); + + HtmlStack::jsWithVars(fn($containerId, $settingsJs) => << << { + e.preventDefault(); + const modal = new Craft.Commerce.ReceiveTransferScreen($settings); + modal.on('close', (e) => { + console.log('closed'); + }); +}); +JS, [ + $receiveInventoryButtonId, + ['params' => ['transferId' => $this->id]], + ]); + + if (!$this->isTransferDraft()) { + $response->additionalButtonsHtml(Html::a( + t('Receive Inventory', category: 'commerce'), + '#', + [ + 'id' => $receiveInventoryButtonId, + 'class' => 'btn', + ] + )); + } + + $response->crumbs([ + [ + 'label' => t('Commerce', category: 'commerce'), + 'url' => Url::cpUrl('commerce'), + ], + [ + 'label' => self::pluralDisplayName(), + 'url' => Url::cpUrl('commerce/inventory/transfers'), + ], + ]); + + $response->selectedSubnavItem('inventory-transfers'); + } + + /** + * @return TransferDetail[] + */ + public function getDetails(): array + { + if ($this->_details === null) { + $this->_details = app(Transfers::class)->getTransferDetailsByTransferId($this->id); + } + + return $this->_details; + } + + /** + * @param TransferDetail[]|array> $value + */ + public function setDetails(array $value): void + { + foreach ($value as $key => $detail) { + if (!$detail instanceof TransferDetail) { + $value[$key] = new TransferDetail($detail); + } + + $value[$key]->setTransfer($this); + + if (!$value[$key]->inventoryItemId) { + unset($value[$key]); + } + } + + $this->_details = $value; + } + + public function sumDetailsQuanity(): int + { + $sum = 0; + foreach ($this->getDetails() as $detail) { + $sum += $detail->quantity; + } + return $sum; + } + + public function addDetail(TransferDetail $detail): void + { + if (!$this->_details) { + $this->_details = []; + } + + foreach ($this->_details as $existingDetail) { + if ($existingDetail->inventoryItemId == $detail->inventoryItemId) { + $existingDetail->quantity += $detail->quantity; + return; + } + } + + $this->_details[] = $detail; + } + + public function getOriginLocation(): ?InventoryLocation + { + if (!$this->originLocationId) { + return null; + } + + return app(InventoryLocations::class)->getInventoryLocationById($this->originLocationId); + } + + public function getDestinationLocation(): ?InventoryLocation + { + if (!$this->destinationLocationId) { + return null; + } + + return app(InventoryLocations::class)->getInventoryLocationById($this->destinationLocationId); + } + + public function getTransferStatus(): TransferStatusType + { + return $this->transferStatus; + } + + public function setTransferStatus(TransferStatusType|string $status): void + { + if (is_string($status)) { + $status = TransferStatusType::from($status); + } + + $this->transferStatus = $status; + } + + /** + * Updates the status to partial or received if all items have been received. + */ + public function updateTransferStatus(): void + { + // only pending can become partial or received. + if ($this->isTransferDraft()) { + return; + } + + $this->setTransferStatus(TransferStatusType::PENDING); + + if ($this->isAllReceived()) { + $this->setTransferStatus(TransferStatusType::RECEIVED); + } + + if ($this->getTotalReceived() > 0 && $this->getTotalReceived() < $this->getTotalQuantity()) { + $this->setTransferStatus(TransferStatusType::PARTIAL); + } + } + + public function isTransferDraft(): bool + { + return $this->getTransferStatus() === TransferStatusType::DRAFT; + } + + public function isTransferPending(): bool + { + return $this->getTransferStatus() === TransferStatusType::PENDING; + } + + public function isTransferPartial(): bool + { + return $this->getTransferStatus() === TransferStatusType::PARTIAL; + } + + public function isTransferReceived(): bool + { + return $this->getTransferStatus() === TransferStatusType::RECEIVED; + } + + public function getTotalRejected(): int + { + $totalRejected = 0; + foreach ($this->getDetails() as $detail) { + $totalRejected += $detail->quantityRejected; + } + return $totalRejected; + } + + public function getTotalAccepted(): int + { + $totalAccepted = 0; + foreach ($this->getDetails() as $detail) { + $totalAccepted += $detail->quantityAccepted; + } + return $totalAccepted; + } + + public function getTotalReceived(): int + { + return $this->getTotalAccepted() + $this->getTotalRejected(); + } + + public function isAllReceived(): bool + { + return array_all($this->getDetails(), fn($detail) => !($detail->getReceived() < $detail->quantity)); + } + + public function getTotalQuantity(): int + { + $totalQuantity = 0; + foreach ($this->getDetails() as $detail) { + $totalQuantity += $detail->quantity; + } + return $totalQuantity; + } + + #[Override] + public function afterSave(bool $isNew): void + { + if (!$this->propagating) { + $transferId = $this->getCanonicalId(); + $transferRecord = TransferRecord::find($transferId); + + if (!$transferRecord) { + $transferRecord = new TransferRecord(); + } + + $originalTransferStatus = $transferRecord->transferStatus; + + $transferRecord->id = $this->id; + $transferRecord->originLocationId = $this->originLocationId; + $transferRecord->destinationLocationId = $this->destinationLocationId; + $transferRecord->transferStatus = $this->getTransferStatus()->value; + + $transferRecord->save(); + + if ($this->getTransferStatus() === TransferStatusType::PENDING && $originalTransferStatus === TransferStatusType::DRAFT->value) { + $inventoryUpdateCollection = new UpdateInventoryLevelCollection(); + foreach ($this->getDetails() as $detail) { + $inventoryUpdate1 = new UpdateInventoryLevelInTransfer(); + $inventoryUpdate1->type = InventoryTransactionType::INCOMING->value; + $inventoryUpdate1->updateAction = InventoryUpdateQuantityType::ADJUST; + $inventoryUpdate1->inventoryItemId = $detail->inventoryItemId; + $inventoryUpdate1->transferId = $this->id; + $inventoryUpdate1->inventoryLocationId = $this->destinationLocationId; + $inventoryUpdate1->quantity = $detail->quantity; + $inventoryUpdate1->note = t('Incoming transfer from Transfer ID: ', category: 'commerce') . $this->id; + + $inventoryUpdateCollection->push($inventoryUpdate1); + + $inventoryUpdate2 = new UpdateInventoryLevelInTransfer(); + $inventoryUpdate2->type = 'onHand'; + $inventoryUpdate2->updateAction = InventoryUpdateQuantityType::ADJUST; + $inventoryUpdate2->inventoryItemId = $detail->inventoryItemId; + $inventoryUpdate2->transferId = $this->id; + $inventoryUpdate2->inventoryLocationId = $this->originLocationId; + $inventoryUpdate2->quantity = $detail->quantity * -1; + $inventoryUpdate2->note = t('Outgoing transfer from Transfer ID: ', category: 'commerce') . $this->id; + + $inventoryUpdateCollection->push($inventoryUpdate2); + } + + app(Inventory::class)->executeUpdateInventoryLevels($inventoryUpdateCollection); + } + + $existingDetailIds = TransferDetailRecord::where('transferId', $this->id)->pluck('id')->all(); + + $currentDetailIds = []; + + foreach ($this->getDetails() as $detail) { + if ($detail->id) { + $detailRecord = TransferDetailRecord::find($detail->id); + } else { + $detailRecord = new TransferDetailRecord(); + } + $detailRecord->transferId = $this->id; + $detailRecord->inventoryItemId = $detail->inventoryItemId; + $inventoryItem = $detail->inventoryItemId ? app(Inventory::class)->getInventoryItemById($detail->inventoryItemId) : null; + $detailRecord->inventoryItemDescription = $inventoryItem?->getSku() ?? ''; + $detailRecord->quantity = $detail->quantity; + $detailRecord->quantityAccepted = $detail->quantityAccepted; + $detailRecord->quantityRejected = $detail->quantityRejected; + + $detailRecord->save(); + $detail->id = $detailRecord->id; + + $currentDetailIds[] = $detailRecord->id; + } + + $deletedDetailIds = array_diff($existingDetailIds, $currentDetailIds); + if (!empty($deletedDetailIds)) { + TransferDetailRecord::whereIn('id', $deletedDetailIds)->delete(); + } + + $this->updateTransferStatus(); + $transferRecord->transferStatus = $this->getTransferStatus()->value; + + $transferRecord->save(); + } + + parent::afterSave($isNew); + } +} diff --git a/src/Transfer/Enums/TransferStatusType.php b/src/Transfer/Enums/TransferStatusType.php new file mode 100644 index 0000000000..0ab6887f48 --- /dev/null +++ b/src/Transfer/Enums/TransferStatusType.php @@ -0,0 +1,39 @@ + t('Draft', category: 'commerce'), + self::PENDING => t('Pending', category: 'commerce'), + self::PARTIAL => t('Partial', category: 'commerce'), + self::RECEIVED => t('Received', category: 'commerce'), + }; + } + + public function color(): string + { + return match ($this) { + self::DRAFT => 'blue', + self::PENDING => 'yellow', + self::PARTIAL => 'orange', + self::RECEIVED => 'green', + }; + } +} diff --git a/src/Transfer/FieldLayoutElements/TransferManagementField.php b/src/Transfer/FieldLayoutElements/TransferManagementField.php new file mode 100644 index 0000000000..9077c172a0 --- /dev/null +++ b/src/Transfer/FieldLayoutElements/TransferManagementField.php @@ -0,0 +1,294 @@ +getInventoryLocationById($element->originLocationId); + $destination = app(InventoryLocations::class)->getInventoryLocationById($element->destinationLocationId); + + $html .= Html::tag('div', + Html::tag('div', + app(ElementHtml::class)->elementCardHtml($origin->getAddress()), ['class' => 'flex-grow']) . + Html::tag('div', + app(ElementHtml::class)->elementCardHtml($destination->getAddress()), ['class' => 'flex-grow']) + , ['class' => 'flex']); + + $tableRows = ''; + + foreach ($element->getDetails() as $detail) { + $purchasable = $detail->getInventoryItem()?->getPurchasable(Sites::getCurrentSite()->id); + $tableRows .= Html::tag('tr', + Html::tag('td', ($purchasable ? app(ElementHtml::class)->elementChipHtml($purchasable, ['showActionMenu' => !$purchasable->getIsDraft() && $purchasable->canSave($currentUser)]) : Html::tag('span', $detail->inventoryItemDescription))) . + Html::tag('td', (string)$detail->quantityRejected, ['class' => 'rightalign']) . + Html::tag('td', (string)$detail->quantityAccepted, ['class' => 'rightalign']) . + Html::tag('td', $detail->getReceived() . '/' . $detail->quantity, ['class' => 'rightalign']) + ); + } + + $totalRow = Html::tag('tr', + Html::tag('td') . + Html::tag('td', '') . + Html::tag('td', '') . + Html::tag('td', t('Total ', category: 'commerce') . ' ' . $element->getTotalReceived() . '/' . $element->getTotalQuantity(), ['class' => 'rightalign']) + ); + + $table = Html::tag('table', + Html::tag('thead', + Html::tag('tr', + Html::tag('th', t('Inventory Item', category: 'commerce')) . + Html::tag('th', t('Rejected', category: 'commerce'), ['class' => 'rightalign', 'style' => 'width: 20%;']) . + Html::tag('th', t('Accepted', category: 'commerce'), ['class' => 'rightalign', 'style' => 'width: 20%;']) . + Html::tag('th', t('Total', category: 'commerce'), ['class' => 'rightalign', 'style' => 'width: 20%;']) + ) + ) . + Html::tag('tbody', $tableRows . $totalRow) + , ['class' => 'data fullwidth'] + ); + + $html .= Html::tag('hr') . $table; + + return $html; + } + + public static function renderFieldHtml(Transfer $element): string + { + // Only draft is editable + if (!$element->isTransferDraft()) { + return self::renderStaticFieldHtml($element); + } + + $currentUser = currentUserElement(); + $inventoryLocationOptions = app(InventoryLocations::class)->getAllInventoryLocationsAsList(false); + $isHtmxRequest = request()->hasHeader('HX-Request'); + + $allLocations = app(InventoryLocations::class)->getAllInventoryLocations(); + $defaultFirstLocation = $allLocations->first(); + $defaultSecondLocation = $allLocations->skip(1)->first(); + + // TODO: this still registers the legacy `craft\commerce\web\assets\transfers\TransfersAsset` + // yii2 AssetBundle via the yii2-adapter bridge, since Commerce's own webpack-built CP assets + // haven't been ported to a native `HtmlStack`-based registration mechanism yet. + \Craft::$app->getView()->registerAssetBundle(TransfersAsset::class); + + $namespacedId = InputNamespace::namespaceId('transfer-management'); + + $html = Html::beginTag('div', [ + 'id' => $namespacedId, + 'hx' => [ + 'ext' => 'craft-cp', + 'target' => '#' . $namespacedId, + 'include' => '#' . $namespacedId, + 'vals' => [ + 'action' => 'commerce/transfers/render-management', + 'transferId' => $element->id, + ], + ], + ]); + + $originLocationSelectFieldConfig = [ + 'label' => t('Origin', category: 'commerce'), + 'name' => 'originLocationId', + 'options' => $inventoryLocationOptions, + 'errors' => $element->errors()->get('originLocationId'), + 'value' => $element->originLocationId ?? $defaultFirstLocation->id, + 'inputAttributes' => [ + 'hx' => [ + 'post' => '', + 'trigger' => 'change', + ], + ], + ]; + + $destinationLocationSelectFieldConfig = [ + 'label' => t('Destination', category: 'commerce'), + 'name' => 'destinationLocationId', + 'errors' => $element->errors()->get('destinationLocationId'), + 'options' => $inventoryLocationOptions, + 'value' => $element->destinationLocationId ?? $defaultSecondLocation->id, + 'inputAttributes' => [ + 'hx' => [ + 'post' => '', + 'trigger' => 'change', + ], + ], + ]; + + $destinationLocationSelectField = Html::tag('div', FormFields::selectFieldHtml($destinationLocationSelectFieldConfig), ['class' => 'flex-grow']); + $originLocationSelectField = Html::tag('div', FormFields::selectFieldHtml($originLocationSelectFieldConfig), ['class' => 'flex-grow']); + + $html .= Html::tag('div', $originLocationSelectField . $destinationLocationSelectField, ['class' => 'flex']); + + $tableRows = ''; + + foreach ($element->getDetails() as $detail) { + $key = $detail->uid ?? (string)Str::uuid(); + $purchasable = $detail->getInventoryItem()?->getPurchasable(Sites::getCurrentSite()->id); + $tableRows .= Html::tag('tr', + Html::hiddenInput('details[' . $key . '][id]', (string)$detail->id) . + Html::hiddenInput('details[' . $key . '][uid]', $detail->uid) . + Html::hiddenInput('details[' . $key . '][inventoryItemId]', (string)$detail->inventoryItemId) . + Html::tag('td', ($purchasable ? app(ElementHtml::class)->elementChipHtml($purchasable, ['showActionMenu' => !$purchasable->getIsDraft() && $purchasable->canSave($currentUser)]) : Html::tag('span', $detail->inventoryItemDescription))) . + Html::tag('td', FormFields::textHtml([ + 'type' => 'number', + 'name' => 'details[' . $key . '][quantity]', + 'value' => (string)$detail->quantity, + 'class' => 'text fullwidth', + 'errors' => $element->errors()->get('details.' . $key . '.quantity'), + 'inputAttributes' => [ + 'hx' => [ + 'post' => '', + ], + ], + ])) . + Html::tag('td', Html::a('', '#', [ + 'hx' => [ + 'post' => '', + 'trigger' => 'click', + 'vals' => [ + 'removeInventoryItemUid' => $key, + ], + ], + 'class' => 'delete icon', + 'title' => t('Delete'), + 'aria-label' => t('Delete'), + 'role' => 'button', + ]), ['class' => 'thin']) + ); + } + + // sum row + $tableRows .= Html::tag('tr', + Html::tag('td') . + Html::tag('td', $element->sumDetailsQuanity() . ' ' . t('Total', category: 'commerce')) . + Html::tag('td') + ); + + $table = Html::tag('table', + Html::tag('thead', + Html::tag('tr', + Html::tag('th', t('Inventory Item', category: 'commerce')) . + Html::tag('th', t('Quantity', category: 'commerce'), ['style' => 'width: 20%;']) . + Html::tag('th', '') + ) + ) . + Html::tag('tbody', $tableRows) + , ['class' => 'data fullwidth'] + ); + + $html .= FormFields::fieldHtml($table, [ + 'label' => t('Transfer Items', category: 'commerce'), + ]); + + if ($element->originLocationId) { + $sourceLocation = app(InventoryLocations::class)->getInventoryLocationById($element->originLocationId); + } else { + $sourceLocation = $defaultFirstLocation; + } + + $inventoryLevels = app(Inventory::class)->getInventoryLocationLevels($sourceLocation)->sortByDesc([ + fn(InventoryLevel $level) => $level->onHandTotal, + ]); + $inventoryItemOptions = []; + + /** @var InventoryLevel $level */ + foreach ($inventoryLevels as $level) { + $inventoryItemOptions[] = [ + 'label' => $level->getInventoryItem()->getSku() . ' (' . ($level->onHandTotal ? $level->onHandTotal . ' ' . t('on hand', category: 'commerce') : t('None on hand', category: 'commerce')) . ')', + 'value' => $level->getInventoryItem()->id, + 'disabled' => !($level->onHandTotal > 0), + ]; + } + + HtmlStack::startJsBuffer(); + + $addToItems = Html::tag('div', + FormFields::selectizeHtml([ + 'name' => 'newInventoryItemId', + 'options' => $inventoryItemOptions, + 'value' => '', + 'placeholder' => t('Select an item', category: 'commerce'), + ]) . + Html::tag('button', t('Add an item', category: 'commerce'), [ + 'type' => 'button', + 'class' => 'btn secondary', + 'hx' => [ + 'post' => '', + 'target' => '#' . $namespacedId, + 'trigger' => 'click', + 'vals' => [ + 'addItem' => true, + ], + ], + ]) + , ['class' => 'flex']); + + $html .= $addToItems; + $fieldJs = (string)HtmlStack::clearJsBuffer(false); + + if ($fieldJs) { + if ($isHtmxRequest) { + $html .= Html::tag('script', $fieldJs, ['type' => 'text/javascript']); + } else { + HtmlStack::js($fieldJs); + } + } + + return $html . Html::endTag('div'); + } +} diff --git a/src/Transfer/Models/TransferDetail.php b/src/Transfer/Models/TransferDetail.php new file mode 100644 index 0000000000..8a22f8c5f1 --- /dev/null +++ b/src/Transfer/Models/TransferDetail.php @@ -0,0 +1,77 @@ +quantityAccepted + $this->quantityRejected; + } + + public function getInventoryItem(): ?InventoryItem + { + if ($this->inventoryItemId === null) { + return null; + } + + return app(Inventory::class)->getInventoryItemById($this->inventoryItemId); + } + + public function getTransfer(): ?Transfer + { + if ($this->_transfer !== null) { + return $this->_transfer; + } + + if ($this->transferId) { + $this->_transfer = Transfer::find()->id($this->transferId)->one(); + } + + return $this->_transfer; + } + + public function setTransfer(Transfer $transfer): void + { + $this->transferId = $transfer->id; + $this->_transfer = $transfer; + } + + #[\Override] + public function getRules(): array + { + $transfer = $this->_transfer; + + if ($transfer && $transfer->transferStatus === TransferStatusType::DRAFT) { + return ['quantity' => ['integer', 'min:1', 'max:99999']]; + } + + return []; + } +} diff --git a/src/Transfer/Queries/TransferQuery.php b/src/Transfer/Queries/TransferQuery.php new file mode 100644 index 0000000000..fe9f8dba84 --- /dev/null +++ b/src/Transfer/Queries/TransferQuery.php @@ -0,0 +1,111 @@ + + */ +class TransferQuery extends ElementQuery +{ + #[Override] + protected string $table = Table::TRANSFERS; + + /** @var array */ + #[Override] + protected array $defaultOrderBy = [ + 'elements.dateCreated' => SORT_DESC, + 'elements.id' => SORT_DESC, + ]; + + public mixed $transferStatus = null; + + public mixed $originLocation = null; + + public mixed $destinationLocation = null; + + /** @param array $config */ + public function __construct(array $config = []) + { + parent::__construct(Transfer::class, $config); + + $this->query->addSelect([ + 'commerce_transfers.transferStatus', + 'commerce_transfers.originLocationId', + 'commerce_transfers.destinationLocationId', + ]); + + $this->beforeQuery(function(self $query) { + if ($query->transferStatus) { + $query->whereParam('commerce_transfers.transferStatus', $query->transferStatus); + } + + if ($query->originLocation) { + $query->whereParam('commerce_transfers.originLocationId', $query->originLocation); + } + + if ($query->destinationLocation) { + $query->whereParam('commerce_transfers.destinationLocationId', $query->destinationLocation); + } + }); + } + + /** + * Narrows the query results based on the transfers' statuses. + */ + public function transferStatus(mixed $value): static + { + if ($value instanceof TransferStatusType) { + $value = $value->value; + } + + $this->transferStatus = $value; + return $this; + } + + /** + * Narrows the query results based on the transfers' origin inventory location. + */ + public function originLocation(mixed $value): static + { + if ($value instanceof InventoryLocation) { + $value = $value->id; + } + + $this->originLocation = $value; + return $this; + } + + /** + * Narrows the query results based on the transfers' destination inventory location. + */ + public function destinationLocation(mixed $value): static + { + if ($value instanceof InventoryLocation) { + $value = $value->id; + } + + $this->destinationLocation = $value; + return $this; + } + + /** @param array $row */ + #[Override] + public function createElement(array $row): ElementInterface + { + if (isset($row['transferStatus']) && is_string($row['transferStatus'])) { + $row['transferStatus'] = TransferStatusType::from($row['transferStatus']); + } + + return parent::createElement($row); + } +} diff --git a/src/Transfer/Records/Transfer.php b/src/Transfer/Records/Transfer.php new file mode 100644 index 0000000000..6cc9767828 --- /dev/null +++ b/src/Transfer/Records/Transfer.php @@ -0,0 +1,34 @@ + 'integer', + 'destinationLocationId' => 'integer', + ]; +} diff --git a/src/Transfer/Records/TransferDetail.php b/src/Transfer/Records/TransferDetail.php new file mode 100644 index 0000000000..273c1d2396 --- /dev/null +++ b/src/Transfer/Records/TransferDetail.php @@ -0,0 +1,33 @@ + 'integer', + 'inventoryItemId' => 'integer', + 'quantity' => 'integer', + 'quantityAccepted' => 'integer', + 'quantityRejected' => 'integer', + ]; +} diff --git a/src/Transfer/Transfers.php b/src/Transfer/Transfers.php new file mode 100644 index 0000000000..ffe3260c36 --- /dev/null +++ b/src/Transfer/Transfers.php @@ -0,0 +1,115 @@ +newValue; + + ProjectConfigHelper::ensureAllFieldsProcessed(); + + if (empty($data) || empty(reset($data))) { + // Delete the field layout + Fields::deleteLayoutsByType(Transfer::class); + return; + } + + // Save the field layout + $layout = FieldLayout::createFromConfig(reset($data)); + $layout->id = Fields::getLayoutByType(Transfer::class)->id; + $layout->type = Transfer::class; + $layout->uid = key($data); + Fields::saveLayout($layout, false); + } + + /** + * Handle field layout being deleted + */ + public function handleDeletedFieldLayout(): void + { + Fields::deleteLayoutsByType(Transfer::class); + } + + public function getFieldLayout(): FieldLayout + { + $fieldLayout = Fields::getLayoutByType(Transfer::class); + + if (!$fieldLayout->isFieldIncluded('transfer-management')) { + $layoutTabs = $fieldLayout->getTabs(); + $transfersTabName = t('Manage', category: 'commerce'); + if (Arr::contains($layoutTabs, 'name', $transfersTabName)) { + $transfersTabName .= ' ' . Str::random(10); + } + + $contentTab = new FieldLayoutTab(); + $contentTab->setLayout($fieldLayout); + $contentTab->name = $transfersTabName; + $contentTab->setElements([ + ['type' => TransferManagementField::class], + ]); + + $layoutTabs[] = $contentTab; + $fieldLayout->setTabs($layoutTabs); + } + + return $fieldLayout; + } + + /** + * @return TransferDetail[] + */ + public function getTransferDetailsByTransferId(int $transferId): array + { + $results = DB::table(Table::TRANSFERDETAILS) + ->select([ + 'id', + 'transferId', + 'inventoryItemId', + 'inventoryItemDescription', + 'quantity', + 'quantityAccepted', + 'quantityRejected', + 'uid', + ]) + ->where('transferId', $transferId) + ->get(); + + $transferDetails = []; + + foreach ($results as $result) { + $transferDetails[] = new TransferDetail((array)$result); + } + + return $transferDetails; + } +} diff --git a/src/Transfer/Validation/TransferRules.php b/src/Transfer/Validation/TransferRules.php new file mode 100644 index 0000000000..3d7f83ae3b --- /dev/null +++ b/src/Transfer/Validation/TransferRules.php @@ -0,0 +1,35 @@ +inScenarios(self::SCENARIO_LIVE), + ['required', 'integer'], + ); + + $rules['destinationLocationId'] = Rule::when( + $this->inScenarios(self::SCENARIO_LIVE), + ['required', 'integer'], + ); + + return $rules; + } +} diff --git a/src/adjusters/Discount.php b/src/adjusters/Discount.php deleted file mode 100644 index 420057ddf3..0000000000 --- a/src/adjusters/Discount.php +++ /dev/null @@ -1,338 +0,0 @@ - - * @since 2.0 - */ -class Discount extends Component implements AdjusterInterface -{ - /** - * The discount adjustment type. - */ - public const ADJUSTMENT_TYPE = 'discount'; - - /** - * @event DiscountAdjustmentsEvent The event that is triggered after a discount has matched the order and before it returns its adjustments. - * - * ```php - * use craft\commerce\adjusters\Discount; - * use craft\commerce\elements\Order; - * use craft\commerce\models\Discount as DiscountModel; - * use craft\commerce\models\OrderAdjustment; - * use craft\commerce\events\DiscountAdjustmentsEvent; - * use yii\base\Event; - * - * Event::on( - * Discount::class, - * Discount::EVENT_AFTER_DISCOUNT_ADJUSTMENTS_CREATED, - * function(DiscountAdjustmentsEvent $event) { - * // @var Order $order - * $order = $event->order; - * // @var DiscountModel $discount - * $discount = $event->discount; - * // @var OrderAdjustment[] $adjustments - * $adjustments = $event->adjustments; - * - * // Use a third party to check order data and modify the adjustments - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_DISCOUNT_ADJUSTMENTS_CREATED = 'afterDiscountAdjustmentsCreated'; - - - /** - * @var Order - */ - private Order $_order; - - /** - * @var float - */ - private float $_discountTotal = 0; - - /** - * Temporary feature flag for testing - * - * @var bool - */ - private bool $_spreadBaseOrderDiscountsToLineItems = true; - - /** - * @var array - */ - private array $_discountUnitPricesByLineItem = []; - - /** - * @inheritdoc - */ - public function adjust(Order $order): array - { - $this->_order = $order; - $teller = $this->_getTeller(); - - $adjustments = []; - $availableDiscounts = []; - $discounts = Plugin::getInstance()->getDiscounts()->getAllActiveDiscounts($order); - - foreach ($discounts as $discount) { - if (Plugin::getInstance()->getDiscounts()->matchOrder($order, $discount)) { - $availableDiscounts[] = $discount; - } - } - - if (!$availableDiscounts) { - return []; - } - - foreach ($this->_order->getLineItems() as $lineItem) { - $lineItemHashId = spl_object_hash($lineItem); - $lineItemDiscountAmount = $lineItem->getDiscount(); - if ($lineItemDiscountAmount) { - $discountedUnitPrice = (float)$teller->add( - $lineItem->salePrice, - $teller->divide($lineItemDiscountAmount, $lineItem->qty) - ); - $this->_discountUnitPricesByLineItem[$lineItemHashId] = $discountedUnitPrice; - } - } - - foreach ($availableDiscounts as $discount) { - $newAdjustments = $this->_getAdjustments($discount); - if ($newAdjustments) { - array_push($adjustments, ...$newAdjustments); - - if ($discount->stopProcessing) { - break; - } - } - } - - if ($this->_spreadBaseOrderDiscountsToLineItems) { - $priceByLineItem = []; - foreach ($this->_order->getLineItems() as $lineItem) { - $lineItemHashId = spl_object_hash($lineItem); - $priceByLineItem[$lineItemHashId] = (float)$teller->add($lineItem->getSubtotal(), $lineItem->getDiscount()); - } - - $orderLevelAdjustments = []; - - // Remove other plugins previous order level discount adjustments - $allAdjustments = $this->_order->getAdjustments(); - foreach ($allAdjustments as $key => $previousAdjustment) { - if ($previousAdjustment->type == self::ADJUSTMENT_TYPE && !$previousAdjustment->getLineItem()) { - $orderLevelAdjustments[] = $previousAdjustment; - unset($allAdjustments[$key]); - } - } - $this->_order->setAdjustments($allAdjustments); - - // Our adjustments - foreach ($adjustments as $key => $adjustment) { - if ($adjustment->getLineItem()) { - $lineItemHashId = spl_object_hash($adjustment->getLineItem()); - // Reduce the price of the line item by the amount of discount from the adjuster - $priceByLineItem[$lineItemHashId] = (float)$teller->add($priceByLineItem[$lineItemHashId] ?? 0, $adjustment->amount); - } else { - // If it's an order level adjustment lets track it, but remove it from the standard adjustments. - $orderLevelAdjustments[] = $adjustment; - unset($adjustments[$key]); - } - } - - $lineItemsByPrice = $this->_order->getLineItems(); - ArrayHelper::multisort($lineItemsByPrice, static function($item) use ($priceByLineItem) { - // sort by age if it exists or by name otherwise - /** @var LineItem $item */ - $lineItemHashId = spl_object_hash($item); - return $priceByLineItem[$lineItemHashId]; - }, SORT_DESC); - - // Remove non-promotable line items - $lineItemsByPrice = ArrayHelper::where($lineItemsByPrice, fn(LineItem $lineItem) => $lineItem->getIsPromotable(), true, true); - - // Loop over each order level adjustment and add an adjustment to each line item until it runs out. - foreach ($orderLevelAdjustments as $orderLevelAdjustment) { - // Track the amount of discount (as a positive number), as we are going to deduct it as we use it up on line items. - $currentDiscountAmountRemaining = -$orderLevelAdjustment->amount; - - // Lets loop over the line items and apply some or all of the discount amount - foreach ($lineItemsByPrice as $lineItem) { - - // We need to know the hash ID of the line item since some line items do not have an ID yet - $lineItemHashId = spl_object_hash($lineItem); - - // Do we have any discount left to use, and can the line item still be discounted? - if ($currentDiscountAmountRemaining > 0 && $priceByLineItem[$lineItemHashId] > 0) { - - // The amount of the adjustment for this line item. - $amount = 0; - - // Is the amount of discount greater than the price of the item - if ($currentDiscountAmountRemaining >= $priceByLineItem[$lineItemHashId]) { - $amount = (float)$teller->multiply($priceByLineItem[$lineItemHashId], -1); // Take the full price of the item off - $priceByLineItem[$lineItemHashId] = 0; // Price is now free - $currentDiscountAmountRemaining = (float)$teller->add($currentDiscountAmountRemaining, $amount); // Reduce the price of the discount remaining so it can still be used - } else { - // Is the current amount of discount remaining less than the current price of the item? Take the whole discount remainder off the item. - if ($currentDiscountAmountRemaining < $priceByLineItem[$lineItemHashId]) { - $amount = (float)$teller->multiply($currentDiscountAmountRemaining, -1); // The adjustment amount is always a negative number - $currentDiscountAmountRemaining = 0; // Reduce the amount of discount to zero since there is none left - $priceByLineItem[$lineItemHashId] = (float)$teller->add($priceByLineItem[$lineItemHashId], $amount); // Reduce the price of the item that we are tracking - } - } - - if ($amount) { - /** @var OrderAdjustment $adjustment */ - $adjustment = clone $orderLevelAdjustment; - $adjustment->amount = $amount; - $adjustment->setLineItem($lineItem); - $adjustments[] = $adjustment; - } - } - } - } - } - - - return $adjustments; - } - - - private function _createOrderAdjustment(DiscountModel $discount): OrderAdjustment - { - //preparing model - $adjustment = new OrderAdjustment(); - $adjustment->type = self::ADJUSTMENT_TYPE; - $adjustment->name = $discount->name; - $adjustment->setOrder($this->_order); - $adjustment->description = $discount->description; - $snapshot = $discount->toArray(); - $snapshot['discountUseId'] = $discount->id ?? null; - $adjustment->sourceSnapshot = $snapshot; - - return $adjustment; - } - - /** - * @return OrderAdjustment[]|false - */ - private function _getAdjustments(DiscountModel $discount): array|false - { - $adjustments = []; - $teller = $this->_getTeller(); - - $matchingLineIds = []; - foreach ($this->_order->getLineItems() as $item) { - $lineItemHashId = spl_object_hash($item); - // Order is already a match to this discount, or we wouldn't get here. - if (Plugin::getInstance()->getDiscounts()->matchLineItem($item, $discount, false)) { - $matchingLineIds[] = $lineItemHashId; - } - } - - foreach ($this->_order->getLineItems() as $item) { - $lineItemHashId = spl_object_hash($item); - if ($matchingLineIds && in_array($lineItemHashId, $matchingLineIds, false)) { - $adjustment = $this->_createOrderAdjustment($discount); - $adjustment->setLineItem($item); - $discountAmountPerItemPreDiscounts = 0; - $amountPerItem = Currency::round($discount->perItemDiscount); - - if ($discount->percentageOffSubject == DiscountRecord::TYPE_ORIGINAL_SALEPRICE) { - $discountAmountPerItemPreDiscounts = (float)$teller->multiply($item->salePrice, $discount->percentDiscount); - } - - $unitPrice = $this->_discountUnitPricesByLineItem[$lineItemHashId] ?? $item->salePrice; - - $lineItemSubtotal = (float)$teller->multiply($item->qty, $unitPrice); - - - $unitPrice = max((float)$teller->add($unitPrice, $amountPerItem), 0); - - if ($unitPrice > 0) { - if ($discount->percentageOffSubject == DiscountRecord::TYPE_ORIGINAL_SALEPRICE) { - $discountedUnitPrice = (float)$teller->add($unitPrice, $discountAmountPerItemPreDiscounts); - } else { - $discountedUnitPrice = (float)$teller->add( - $unitPrice, - $teller->multiply($unitPrice, $discount->percentDiscount) - ); - } - - $discountedSubtotal = (float)$teller->multiply($discountedUnitPrice, $item->qty); - $amountOfPercentDiscount = (float)$teller->subtract($discountedSubtotal, $lineItemSubtotal); - $this->_discountUnitPricesByLineItem[$lineItemHashId] = $discountedUnitPrice; - $adjustment->amount = $amountOfPercentDiscount; //Adding already rounded - } else { - $adjustment->amount = -$lineItemSubtotal; - $this->_discountUnitPricesByLineItem[$lineItemHashId] = 0; - } - - if ($adjustment->amount != 0) { - $this->_discountTotal = (float)$teller->add($this->_discountTotal, $adjustment->amount); - $adjustments[] = $adjustment; - } - } - } - - if ($discount->baseDiscount !== null && $discount->baseDiscount != 0) { - $baseDiscountAdjustment = $this->_createOrderAdjustment($discount); - $baseDiscountAdjustment->amount = $discount->baseDiscount; - $adjustments[] = $baseDiscountAdjustment; - } - - // only display adjustment if an amount was calculated - if (!count($adjustments)) { - return false; - } - - // Raise the 'afterDiscountAdjustmentsCreated' event - $event = new DiscountAdjustmentsEvent([ - 'order' => $this->_order, - 'discount' => $discount, - 'adjustments' => $adjustments, - ]); - - $this->trigger(self::EVENT_AFTER_DISCOUNT_ADJUSTMENTS_CREATED, $event); - - if (!$event->isValid) { - return false; - } - - return $event->adjustments; - } - - /** - * @return Teller - * @throws InvalidConfigException - */ - private function _getTeller(): Teller - { - return Plugin::getInstance()->getCurrencies()->getTeller($this->_order->currency); - } -} diff --git a/src/adjusters/Shipping.php b/src/adjusters/Shipping.php deleted file mode 100644 index 868c204f7e..0000000000 --- a/src/adjusters/Shipping.php +++ /dev/null @@ -1,234 +0,0 @@ - - * @since 2.0 - */ -class Shipping extends Component implements AdjusterInterface -{ - public const ADJUSTMENT_TYPE = 'shipping'; - - /** - * @var Order - */ - private Order $_order; - - /** - * @var bool - */ - private bool $_isEstimated = false; - - /** - * Temporary feature flag for testing - * - * @var bool - */ - private bool $_consolidateShippingToSingleAdjustment = false; - - /** - * @inheritdoc - */ - public function adjust(Order $order): array - { - $this->_order = $order; - $this->_isEstimated = (!$order->shippingAddressId && $order->estimatedShippingAddressId); - - if (!$order->shippingMethodHandle) { - return []; - } - - $matchingMethods = Plugin::getInstance()->getShippingMethods()->getMatchingShippingMethods($order); - $shippingMethod = $matchingMethods[$order->shippingMethodHandle] ?? null; - $lineItems = $order->getLineItems(); - - if ($shippingMethod === null) { - return []; - } - - $nonShippableItems = []; - - foreach ($lineItems as $item) { - if (!$item->getIsShippable()) { - $nonShippableItems[$item->id] = $item->id; - } - } - - // Are all line items non shippable items? No shipping cost. - if (count($lineItems) == count($nonShippableItems)) { - return []; - } - - $adjustments = []; - - $discounts = Plugin::getInstance()->getDiscounts()->getAllActiveDiscounts($order); - - // Check to see if we have shipping related discounts - $hasOrderLevelShippingRelatedDiscounts = (bool)ArrayHelper::firstWhere($discounts, 'hasFreeShippingForOrder', true, false); - $hasLineItemLevelShippingRelatedDiscounts = (bool)ArrayHelper::firstWhere($discounts, 'hasFreeShippingForMatchingItems', true, false); - - /** @var ShippingRule|null $rule */ - $rule = $shippingMethod->getMatchingShippingRule($this->_order); - if ($rule) { - $itemTotalAmount = 0; - - // Check for order level discounts for shipping - $hasDiscountRemoveShippingCosts = false; - if ($hasOrderLevelShippingRelatedDiscounts) { - foreach ($discounts as $discount) { - $matchedOrder = Plugin::getInstance()->getDiscounts()->matchOrder($this->_order, $discount); - - if ($discount->hasFreeShippingForOrder && $matchedOrder) { - $hasDiscountRemoveShippingCosts = true; - break; - } - - if ($matchedOrder && $discount->stopProcessing) { - break; - } - } - } - - if (!$hasDiscountRemoveShippingCosts) { - //checking items shipping categories - foreach ($order->getLineItems() as $item) { - // Lets match the discount now for free shipped items and not even make a shipping cost for the line item. - $hasFreeShippingFromDiscount = false; - if ($hasLineItemLevelShippingRelatedDiscounts) { - foreach ($discounts as $discount) { - $matchedLineItem = Plugin::getInstance()->getDiscounts()->matchLineItem($item, $discount, true); - - if ($discount->hasFreeShippingForMatchingItems && $matchedLineItem) { - $hasFreeShippingFromDiscount = true; - break; - } - - if ($matchedLineItem && $discount->stopProcessing) { - break; - } - } - } - - $lineItemHasFreeShipping = $item->getHasFreeShipping(); - $shippable = $item->getIsShippable(); - - if (!$lineItemHasFreeShipping && !$hasFreeShippingFromDiscount && $shippable) { - $adjustment = $this->_createAdjustment($shippingMethod, $rule); - - $percentageRate = $rule->getPercentageRate($item->shippingCategoryId); - $perItemRate = $rule->getPerItemRate($item->shippingCategoryId); - $weightRate = $rule->getWeightRate($item->shippingCategoryId); - - $percentageAmount = $item->getSubtotal() * $percentageRate; - $perItemAmount = $item->qty * $perItemRate; - $weightAmount = ($item->weight * $item->qty) * $weightRate; - - $adjustment->amount = Currency::round($percentageAmount + $perItemAmount + $weightAmount); - $adjustment->setLineItem($item); - if ($adjustment->amount) { - $adjustments[] = $adjustment; - } - $itemTotalAmount += $adjustment->amount; - } - } - - $baseAmount = Currency::round($rule->getBaseRate()); - if ($baseAmount && $baseAmount != 0) { - $adjustment = $this->_createAdjustment($shippingMethod, $rule); - $adjustment->amount = $baseAmount; - $adjustments[] = $adjustment; - } - - $adjustmentToMinimumAmount = 0; - // Is there a minimum rate and is the total shipping cost currently below it? - if ($rule->getMinRate() != 0 && (($itemTotalAmount + $baseAmount) < Currency::round($rule->getMinRate()))) { - $adjustmentToMinimumAmount = Currency::round($rule->getMinRate()) - ($itemTotalAmount + $baseAmount); - $adjustment = $this->_createAdjustment($shippingMethod, $rule); - $adjustment->amount = $adjustmentToMinimumAmount; - $adjustment->description .= ' Adjusted to minimum rate'; - $adjustments[] = $adjustment; - } - - if ($rule->getMaxRate() != 0 && (($itemTotalAmount + $baseAmount + $adjustmentToMinimumAmount) > Currency::round($rule->getMaxRate()))) { - $adjustmentToMaxAmount = Currency::round($rule->getMaxRate()) - ($itemTotalAmount + $baseAmount + $adjustmentToMinimumAmount); - $adjustment = $this->_createAdjustment($shippingMethod, $rule); - $adjustment->amount = $adjustmentToMaxAmount; - $adjustment->description .= ' Adjusted to maximum rate'; - $adjustments[] = $adjustment; - } - } - } - - // Might be a shipping method that matches but does not have any shipping rules. - if ($rule === null) { - $adjustment = new OrderAdjustment(); - $adjustment->type = self::ADJUSTMENT_TYPE; - $adjustment->setOrder($this->_order); - $adjustment->name = $shippingMethod->getName(); - $adjustment->description = ''; - $adjustment->isEstimated = $this->_isEstimated; - $adjustment->sourceSnapshot = [ - 'shippingMethodHandle' => $shippingMethod->getHandle(), - 'shippingMethodId' => $shippingMethod->getId(), - 'shippingMethodName' => $shippingMethod->getName(), - 'shippingMethodType' => $shippingMethod->getType(), - ]; - $adjustment->amount = Currency::round($shippingMethod->getPriceForOrder($this->_order), $this->_order->getStore()->getCurrency()); - $adjustments[] = $adjustment; - } - - if ($this->_consolidateShippingToSingleAdjustment) { - $amount = 0; - foreach ($adjustments as $adjustment) { - $amount += $adjustment->amount; - } - - //preparing model - $adjustment = new OrderAdjustment(); - $adjustment->type = self::ADJUSTMENT_TYPE; - $adjustment->setOrder($this->_order); - $adjustment->name = $shippingMethod->getName(); - $adjustment->amount = $amount; - $adjustment->description = $rule->getDescription(); - $adjustment->isEstimated = $this->_isEstimated; - $adjustment->setSourceSnapshot([]); - - return [$adjustment]; - } - - return $adjustments; - } - - - private function _createAdjustment(ShippingMethodInterface $shippingMethod, ShippingRule $rule): OrderAdjustment - { - //preparing model - $adjustment = new OrderAdjustment(); - $adjustment->type = self::ADJUSTMENT_TYPE; - $adjustment->setOrder($this->_order); - $adjustment->name = $shippingMethod->getName(); - $adjustment->description = $rule->getDescription(); - $adjustment->isEstimated = $this->_isEstimated; - $adjustment->sourceSnapshot = $rule->toArray(); - - return $adjustment; - } -} diff --git a/src/adjusters/Tax.php b/src/adjusters/Tax.php deleted file mode 100644 index 1ebd7dec37..0000000000 --- a/src/adjusters/Tax.php +++ /dev/null @@ -1,537 +0,0 @@ - - * @since 2.0 - * - * @property-read TaxRate[] $taxRates - * @property Validator $vatValidator - */ -class Tax extends Component implements AdjusterInterface -{ - public const ADJUSTMENT_TYPE = 'tax'; - - /** - * @var Order - */ - private Order $_order; - - /** - * @var Address|null - */ - private ?Address $_address = null; - - /** - * @var Collection - */ - private Collection $_taxRates; - - /** - * @var bool - */ - private bool $_isEstimated = false; - - /** - * Track the additional discounts created inside the tax adjuster per line item - * - * @var array - */ - private array $_costRemovedByLineItem = []; - - /** - * Track the additional discounts created inside the tax adjuster for order shipping costs - * - * @var float - */ - private float $_costRemovedForOrderShipping = 0; - - /** - * Track the additional discounts created inside the tax adjuster for total price - * - * @internal This should not be modified directly, use _addAmountRemovedForOrderShipping() instead - * @var float - * @see _addAmountRemovedForOrderTotalPrice() - */ - private float $_costRemovedForOrderTotalPrice = 0; - - /** - * The way to internally interact with the _costRemovedForOrderShipping property - * - * @param float $amount - * @return void - * @throws Exception - */ - private function _addAmountRemovedForOrderShipping(float $amount): void - { - if ($amount > 0) { - throw new Exception('Amount added to the total removed shipping must be a negative number'); - } - - $this->_costRemovedForOrderShipping = (float)$this->_getTeller()->add($this->_costRemovedForOrderShipping, $amount); - } - - - /** - * The way to interact with the _costRemovedForOrderTotalPrice property - * - * @param float $amount - * @return void - * @throws Exception - */ - private function _addAmountRemovedForOrderTotalPrice(float $amount): void - { - if ($amount > 0) { - throw new Exception('Amount added to the total removed price must be a negative number'); - } - - $this->_costRemovedForOrderTotalPrice = (float)$this->_getTeller()->add($this->_costRemovedForOrderTotalPrice, $amount); - } - - /** - * @inheritdoc - */ - public function adjust(Order $order): array - { - $this->_order = $order; - $this->_address = $this->_getTaxAddress(); - $this->_taxRates = $this->getTaxRates($order->storeId); - - return $this->_adjustInternal(); - } - - private function _adjustInternal(): array - { - $adjustments = []; - - foreach ($this->_taxRates as $rate) { - if (!$rate->enabled) { - continue; - } - $newAdjustments = $this->_getAdjustments($rate); - if ($newAdjustments) { - $adjustments[] = $newAdjustments; - } - } - - if ($adjustments) { - $adjustments = array_merge(...$adjustments); - } - - return $adjustments; - } - - - /** - * @return OrderAdjustment[] - */ - private function _getAdjustments(TaxRate $taxRate): array - { - $adjustments = []; - $teller = $this->_getTeller(); - $hasValidTaxId = false; - - $zoneMatches = $taxRate->getIsEverywhere() || ($taxRate->getTaxZone() && $this->_matchAddress($taxRate->getTaxZone())); - - if ($zoneMatches && $taxRate->hasTaxIdValidators()) { - $hasValidTaxId = $this->organizationTaxIdIsValidTaxId($taxRate->getSelectedEnabledTaxIdValidators()); - } - - $removeIncluded = (!$zoneMatches && $taxRate->removeIncluded); - $removeDueToVatId = ($zoneMatches && $hasValidTaxId && $taxRate->removeVatIncluded); - if ($removeIncluded || $removeDueToVatId) { - - // Remove included tax for order level taxable. - if (in_array($taxRate->taxable, TaxRateRecord::ORDER_TAXABALES, false)) { - $orderTaxableAmount = 0; - - if ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_PRICE) { - $orderTaxableAmount = $this->_getOrderTotalTaxablePrice($this->_order); - } elseif ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_SHIPPING) { - $orderTaxableAmount = $this->_order->getTotalShippingCost(); - } - - $orderLevelAmountToBeRemovedByDiscount = -$this->_getTaxAmount($orderTaxableAmount, $taxRate->rate, $taxRate->include); - - if ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_PRICE) { - $this->_addAmountRemovedForOrderTotalPrice($orderLevelAmountToBeRemovedByDiscount); - } elseif ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_SHIPPING) { - $this->_addAmountRemovedForOrderShipping($orderLevelAmountToBeRemovedByDiscount); - } - - $adjustment = $this->_createAdjustment($taxRate); - // We need to display the adjustment that removed the included tax - $adjustment->name = Craft::t('site', $taxRate->name) . ' ' . Craft::t('commerce', 'Removed'); - $adjustment->amount = $orderLevelAmountToBeRemovedByDiscount; - $adjustment->type = 'discount'; // @TODO Stop using a discount adjustment for removed included tax and instead modify the item price directly #COM-26 - $adjustment->included = false; - - $adjustments[] = $adjustment; - } - - // Not an order level taxable, add tax adjustments to the line items. - if (!in_array($taxRate->taxable, TaxRateRecord::ORDER_TAXABALES, false)) { - // Not an order level taxable, add tax adjustments to the line items. - foreach ($this->_order->getLineItems() as $item) { - if ($item->taxCategoryId == $taxRate->taxCategoryId) { - if ($taxRate->taxable == TaxRateRecord::TAXABLE_PURCHASABLE) { - // taxableAmount = salePrice - (discount / qty) - $taxableAmount = $teller->subtract( - $item->salePrice, - $teller->divide( - $item->getDiscount(), // float amount of discount - $item->qty - ) - ); - - // amount = taxableAmount - (taxableAmount / (1 + taxRate)) - $amount = $teller->subtract( - $taxableAmount, - $teller->divide( - $taxableAmount, - (1 + $taxRate->rate) - ) - ); - - $amount = -(float)$teller->multiply($amount, $item->qty); - } else { - $taxableAmount = $item->getTaxableSubtotal($taxRate->taxable); - // amount = taxableAmount - (taxableAmount / (1 + taxRate)) - $amount = $teller->subtract( - $taxableAmount, - $teller->divide( - $taxableAmount, - (1 + $taxRate->rate) - ) - ); - - $amount = -(float)$amount; - } - $adjustment = $this->_createAdjustment($taxRate); - // We need to display the adjustment that removed the included tax - $adjustment->name = Craft::t('site', $taxRate->name) . ' ' . Craft::t('commerce', 'Removed'); - $adjustment->amount = $amount; - $adjustment->setLineItem($item); - $adjustment->type = 'discount'; - $adjustment->included = false; - - $objectId = spl_object_hash($item); // We use this ID since some line items are not saved in the DB yet and have no ID. - - if (isset($this->_costRemovedByLineItem[$objectId])) { - $this->_costRemovedByLineItem[$objectId] = (float)$this->_getTeller()->add($this->_costRemovedByLineItem[$objectId], $amount); - } else { - $this->_costRemovedByLineItem[$objectId] = $amount; - } - - $adjustments[] = $adjustment; - } - } - } - - // Return the removed included taxes as discounts. - return $adjustments; - } - - if (!$zoneMatches || ($taxRate->hasTaxIdValidators() && $hasValidTaxId)) { - return []; - } - - // We have taxes to add! - - // Is this an order level tax rate? - if (in_array($taxRate->taxable, TaxRateRecord::ORDER_TAXABALES, false)) { - $allItemsTaxFree = true; - foreach ($this->_order->getLineItems() as $item) { - if ($item->getIsTaxable()) { - $allItemsTaxFree = false; - } - } - - // Will not have any taxes, even for order level taxes. - if ($allItemsTaxFree) { - return []; - } - - $orderTaxableAmount = 0; - - if ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_PRICE) { - $orderTaxableAmount = $this->_getOrderTotalTaxablePrice($this->_order); - $orderTaxableAmount = (float)$this->_getTeller()->add($orderTaxableAmount, $this->_costRemovedForOrderTotalPrice); - } - - if ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_SHIPPING) { - $orderTaxableAmount = $this->_order->getTotalShippingCost(); - $orderTaxableAmount = (float)$this->_getTeller()->add($orderTaxableAmount, $this->_costRemovedForOrderShipping); - } - - $orderTax = $this->_getTaxAmount($orderTaxableAmount, $taxRate->rate, $taxRate->include); - - $adjustment = $this->_createAdjustment($taxRate); - // We need to display the adjustment that removed the included tax - $adjustment->amount = $orderTax; - - if ($taxRate->include) { - $adjustment->included = true; - } - - return [$adjustment]; - } - - // not an order level tax rate, create line item adjustments. - foreach ($this->_order->getLineItems() as $item) { - if ($item->taxCategoryId == $taxRate->taxCategoryId && $item->getIsTaxable()) { - // We use this ID since some line items are not saved in the DB yet and have no ID. - $objectId = spl_object_hash($item); - /** - * Any reduction in price to the line item we have added while inside this adjuster needs to be deducted, - * since the discount adjustments we just added won't be picked up in getTaxableSubtotal() - */ - if ($taxRate->taxable == TaxRateRecord::TAXABLE_PURCHASABLE) { -// $item->salePrice - Currency::round($item->getDiscount() / $item->qty); - $purchasableAmount = $this->_getTeller()->subtract( - $item->salePrice, - $this->_getTeller()->divide( - $item->getDiscount(), - $item->qty - ) - ); - - $purchasableAmount = $this->_getTeller()->add( - $purchasableAmount, - $this->_getTeller()->divide( - ($this->_costRemovedByLineItem[$objectId] ?? 0), - $item->qty - ) - ); - $purchasableTax = $this->_getTaxAmount((float)$purchasableAmount, $taxRate->rate, $taxRate->include); - $itemTax = $this->_getTeller()->multiply($purchasableTax, $item->qty); //already rounded - } else { - $taxableAmount = $item->getTaxableSubtotal($taxRate->taxable); - $taxableAmount = (float)$this->_getTeller()->add( - $taxableAmount, - $this->_costRemovedByLineItem[$objectId] ?? 0 - ); - $itemTax = $this->_getTaxAmount($taxableAmount, $taxRate->rate, $taxRate->include); - } - - $adjustment = $this->_createAdjustment($taxRate); - // We need to display the adjustment that removed the included tax - $adjustment->amount = $itemTax; - $adjustment->setLineItem($item); - - if ($taxRate->include) { - $adjustment->included = true; - } - - $adjustments[] = $adjustment; - } - } - - return $adjustments; - } - - /** - * @return Collection - * @throws StoreNotFoundException - * @throws InvalidConfigException - */ - protected function getTaxRates(?int $storeId = null): Collection - { - return Plugin::getInstance()->getTaxRates()->getAllEnabledTaxRates($storeId); - } - - /** - * @param $taxableAmount - * @param $rate - * @param $included - * @return float - * @since 3.1 - */ - private function _getTaxAmount($taxableAmount, $rate, $included): float - { - $teller = $this->_getTeller(); - if (!$included) { - $incTax = $teller->multiply($taxableAmount, (1 + $rate)); - $tax = $teller->subtract($incTax, $taxableAmount); - } else { - $exTax = $teller->divide($taxableAmount, (1 + $rate)); - $tax = $teller->subtract($taxableAmount, $exTax); - } - - return (float)$tax; - } - - /** - * @param TaxAddressZone $zone - * @return bool - */ - private function _matchAddress(TaxAddressZone $zone): bool - { - //when having no address check default tax zones only - if (!$this->_address) { - return $zone->default; - } - - return $zone->getCondition()->matchElement($this->_address); - } - - /** - * @return bool - */ - private function organizationTaxIdIsValidTaxId(array $validators): bool - { - if (!$this->_address) { - return false; - } - if (!$this->_address->organizationTaxId) { - return false; - } - - if (!$this->_address->getCountryCode()) { - return false; - } - - $validOrganizationTaxId = Craft::$app->getCache()->exists('commerce:validVatId:' . $this->_address->organizationTaxId); - - // If we do not have a valid VAT ID in cache, see if we can get one from the API - if (!$validOrganizationTaxId) { - $validOrganizationTaxId = $this->validateTaxIdNumber($this->_address->organizationTaxId, $validators); - } - - if ($validOrganizationTaxId) { - Craft::$app->getCache()->set('commerce:validVatId:' . $this->_address->organizationTaxId, '1'); - return true; - } - - Craft::$app->getCache()->delete('commerce:validVatId:' . $this->_address->organizationTaxId); - return false; - } - - /** - * @param string $businessVatId - * @return bool - * @deprecated in 5.3.0. Use `validateTaxIdNumber()` instead, passing the validators you want to check the ID with. - */ - protected function validateVatNumber(string $businessVatId): bool - { - $oldValidator = [new EuVatIdValidator()]; - return $this->validateTaxIdNumber($businessVatId, $oldValidator); - } - - /** - * @param string $organizationTaxId - * @param TaxIdValidatorInterface[] $validators - * @return bool - */ - protected function validateTaxIdNumber(string $organizationTaxId, array $validators = []): bool - { - try { - foreach ($validators as $validator) { - if ($validator->validate($organizationTaxId)) { - return true; - } - } - } catch (Exception $e) { - Craft::error('Communication with VAT API failed: ' . $e->getMessage(), __METHOD__); - - return false; - } - - return false; - } - - private function _createAdjustment(TaxRate $rate): OrderAdjustment - { - $adjustment = new OrderAdjustment(); - $adjustment->type = self::ADJUSTMENT_TYPE; - $adjustment->name = Craft::t('site', $rate->name); - $adjustment->description = $rate->rate * 100 . '%'; - $adjustment->setOrder($this->_order); - $adjustment->isEstimated = $this->_isEstimated; - $adjustment->sourceSnapshot = $rate->toArray(); - - return $adjustment; - } - - /** - * Returns the total price of the order, minus any tax adjustments. - */ - private function _getOrderTotalTaxablePrice(Order $order): float - { - $itemTotal = $order->getItemSubtotal(); - - $allNonIncludedAdjustmentsTotal = $order->getAdjustmentsTotal(); - $taxAdjustments = $order->getTotalTax(); - $includedTaxAdjustments = $order->getTotalTaxIncluded(); - - $totals = (float)$this->_getTeller()->add($itemTotal, $allNonIncludedAdjustmentsTotal); - $adjustments = (float)$this->_getTeller()->add($taxAdjustments, $includedTaxAdjustments); - - return (float)$this->_getTeller()->subtract( - $totals, - $adjustments - ); - } - - /** - * @return Address|null - */ - private function _getTaxAddress(): ?Address - { - $this->_isEstimated = false; - if (!$this->_order->getStore()->getUseBillingAddressForTax()) { - $address = $this->_order->getShippingAddress(); - if (!$address) { - $address = $this->_order->getEstimatedShippingAddress(); - $this->_isEstimated = true; - } - } else { - $address = $this->_order->getBillingAddress(); - if (!$address) { - $address = $this->_order->getEstimatedBillingAddress(); - $this->_isEstimated = true; - } - } - - return $address; - } - - /** - * @return Teller - * @throws InvalidConfigException - * @since 5.3.0 - */ - private function _getTeller(): Teller - { - return Plugin::getInstance()->getCurrencies()->getTeller($this->_order->currency); - } -} diff --git a/src/base/AdjusterInterface.php b/src/base/AdjusterInterface.php deleted file mode 100644 index 2956d567f3..0000000000 --- a/src/base/AdjusterInterface.php +++ /dev/null @@ -1,27 +0,0 @@ - - * @since 2.0 - */ -interface AdjusterInterface -{ - /** - * Returns adjustments to add to the order - * - * @return OrderAdjustment[] - */ - public function adjust(Order $order): array; -} diff --git a/src/base/CatalogPricingConditionRuleInterface.php b/src/base/CatalogPricingConditionRuleInterface.php deleted file mode 100644 index f6e4961977..0000000000 --- a/src/base/CatalogPricingConditionRuleInterface.php +++ /dev/null @@ -1,35 +0,0 @@ - - * @since 5.0.0 - */ -interface CatalogPricingConditionRuleInterface extends ConditionRuleInterface -{ - /** - * Returns the query param names that this rule should have exclusive control over. - * - * @return string[] - */ - public function getExclusiveQueryParams(): array; - - /** - * Modifies the given query with the condition rule. - * - * @param Query $query - */ - public function modifyQuery(Query $query): void; -} diff --git a/src/base/EnumHelpersTrait.php b/src/base/EnumHelpersTrait.php deleted file mode 100644 index 8aaef7997e..0000000000 --- a/src/base/EnumHelpersTrait.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 2.0 - */ -abstract class Gateway extends SavableComponent implements GatewayInterface -{ - use GatewayTrait; - - /** - * @var ElementConditionInterface|null - * @since 5.4.0 - */ - private ?ElementConditionInterface $_orderCondition = null; - - /** - * @var ElementConditionInterface|null - * @since 5.5 - */ - private ?ElementConditionInterface $_billingAddressCondition = null; - - /** - * @var ElementConditionInterface|null - * @since 5.5 - */ - private ?ElementConditionInterface $_shippingAddressCondition = null; - - /** - * Returns the name of this payment method. - * - * @return string - */ - public function __toString() - { - return (string)$this->name; - } - - /** - * Shows the payment button on the payment form. - * - * @return bool - */ - public function showPaymentFormSubmitButton(): bool - { - return true; - } - - /** - * Returns the webhook url for this gateway. - * - * @param array $params Parameters for the url. - */ - public function getWebhookUrl(array $params = []): string - { - $params = array_merge(['gateway' => $this->id], $params); - - $url = UrlHelper::actionUrl('commerce/webhooks/process-webhook', $params); - - // Remove the cpTrigger from the url if it's there. - if (Craft::$app->getConfig()->getGeneral()->cpTrigger) { - $url = StringHelper::replace($url, Craft::$app->getConfig()->getGeneral()->cpTrigger . '/', ''); - } - - return $url; - } - - /** - * Returns whether this gateway allows payments in control panel. - */ - public function cpPaymentsEnabled(): bool - { - return true; - } - - public function getCpEditUrl(): string - { - return UrlHelper::cpUrl('commerce/settings/gateways/' . $this->id); - } - - /** - * Returns the payment type options. - */ - public function getPaymentTypeOptions(): array - { - return [ - 'authorize' => Craft::t('commerce', 'Authorize Only (Manually Capture)'), - 'purchase' => Craft::t('commerce', 'Purchase (Authorize and Capture Immediately)'), - ]; - } - - /** - * @inheritdoc - */ - public function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['paymentType', 'handle'], 'required']; - - $rules[] = [['name', 'handle', 'paymentType', 'isFrontendEnabled', 'orderCondition', 'billingAddressCondition', 'shippingAddressCondition', 'sortOrder'], 'safe']; - - return $rules; - } - - /** - * Returns the html to use when paying with a stored payment source. - * - * @param array $params - * @return string - */ - public function getPaymentConfirmationFormHtml(array $params): string - { - return ''; - } - - /** - * @inheritdoc - */ - public function availableForUseWithOrder(Order $order): bool - { - if ($this->hasOrderCondition() && !$this->getOrderCondition()->matchElement($order)) { - return false; - } - - if ($this->hasBillingAddressCondition() && $order->billingAddress && !$this->getBillingAddressCondition()->matchElement($order->billingAddress)) { - return false; - } - - if ($this->hasShippingAddressCondition() && $order->shippingAddress && !$this->getShippingAddressCondition()->matchElement($order->shippingAddress)) { - return false; - } - - return true; - } - - /** - * Returns true if gateway supports partial refund requests. - */ - public function supportsPartialPayment(): bool - { - return true; - } - - /** - * Returns true if this gateway has an order condition - * - * @since 5.4.0 - */ - public function hasOrderCondition(): bool - { - return $this->getOrderCondition()->getConditionRules() !== []; - } - - /** - * Returns payment Form HTML - */ - abstract public function getPaymentFormHtml(array $params): ?string; - - /** - * @inheritdoc - */ - public function getTransactionHashFromWebhook(): ?string - { - return null; - } - - /** - * @param Transaction $transaction - * @return bool - * @since 4.8.1 - */ - public function transactionSupportsRefund(Transaction $transaction): bool - { - return true; - } - - - /** - * Gets the order condition for this gateway - * - * @since 5.4.0 - */ - public function getOrderCondition(): ElementConditionInterface - { - /** @var DiscountOrderCondition $condition */ - $condition = $this->_orderCondition ?? new GatewayOrderCondition(); - $condition->mainTag = 'div'; - $condition->name = 'orderCondition'; - - return $condition; - } - - /** - * Sets the order condition for this gateway - * - * @since 5.4.0 - */ - public function setOrderCondition(ElementConditionInterface|string|array|null $condition): void - { - if (empty($condition)) { - $this->_orderCondition = null; - return; - } - - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof GatewayOrderCondition) { - $condition['class'] = GatewayOrderCondition::class; - $condition = \Craft::$app->getConditions()->createCondition($condition); - /** @var GatewayOrderCondition $condition */ - } - $condition->forProjectConfig = true; - - $this->_orderCondition = $condition; - } - - /** - * Returns true if this gateway has a billing address condition - * - * @since 5.5 - */ - public function hasBillingAddressCondition(): bool - { - return $this->getBillingAddressCondition()->getConditionRules() !== []; - } - - /** - * Gets the billing address condition for this gateway - * - * @since 5.5 - */ - public function getBillingAddressCondition(): ElementConditionInterface - { - /** @var GatewayAddressCondition $condition */ - $condition = $this->_billingAddressCondition ?? new GatewayAddressCondition(); - $condition->mainTag = 'div'; - $condition->name = 'billingAddressCondition'; - - return $condition; - } - - /** - * Sets the billing address condition for this gateway - * - * @since 5.5 - */ - public function setBillingAddressCondition(ElementConditionInterface|string|array $condition): void - { - if (empty($condition)) { - $this->_billingAddressCondition = null; - return; - } - - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof GatewayAddressCondition) { - $condition['class'] = GatewayAddressCondition::class; - $condition = \Craft::$app->getConditions()->createCondition($condition); - /** @var GatewayAddressCondition $condition */ - } - $condition->forProjectConfig = true; - - $this->_billingAddressCondition = $condition; - } - - /** - * Returns true if this gateway has a shipping address condition - * - * @since 5.5 - */ - public function hasShippingAddressCondition(): bool - { - return $this->getShippingAddressCondition()->getConditionRules() !== []; - } - - /** - * Gets the shipping address condition for this gateway - * - * @since 5.5 - */ - public function getShippingAddressCondition(): ElementConditionInterface - { - /** @var GatewayAddressCondition $condition */ - $condition = $this->_shippingAddressCondition ?? new GatewayAddressCondition(); - $condition->mainTag = 'div'; - $condition->name = 'shippingAddressCondition'; - - return $condition; - } - - /** - * Sets the shipping address condition for this gateway - * - * @since 5.5 - */ - public function setShippingAddressCondition(ElementConditionInterface|string|array $condition): void - { - if (empty($condition)) { - $this->_shippingAddressCondition = null; - return; - } - - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof GatewayAddressCondition) { - $condition['class'] = GatewayAddressCondition::class; - $condition = \Craft::$app->getConditions()->createCondition($condition); - /** @var GatewayAddressCondition $condition */ - } - $condition->forProjectConfig = true; - - $this->_shippingAddressCondition = $condition; - } - - /** - * @return array - * @since 5.4.0 - */ - public function getConfig(): array - { - $configData = [ - 'name' => $this->name, - 'handle' => $this->handle, - 'type' => static::class, - 'settings' => $this->getSettings(), - 'sortOrder' => ($this->sortOrder ?? 99), - 'paymentType' => $this->paymentType, - 'isFrontendEnabled' => $this->getIsFrontendEnabled(false), - 'orderCondition' => $this->getOrderCondition()->getConfig(), - 'billingAddressCondition' => $this->getBillingAddressCondition()->getConfig(), - 'shippingAddressCondition' => $this->getShippingAddressCondition()->getConfig(), - ]; - - return $configData; - } -} diff --git a/src/base/GatewayInterface.php b/src/base/GatewayInterface.php deleted file mode 100644 index 49d78394ac..0000000000 --- a/src/base/GatewayInterface.php +++ /dev/null @@ -1,190 +0,0 @@ - - * @since 2.0 - * @phpstan-require-extends Gateway - * @todo remove ignore: https://github.com/phpstan/phpstan/issues/6778 - * @phpstan-ignore-next-line - * @mixin GatewayTrait - */ -interface GatewayInterface extends SavableComponentInterface -{ - /** - * Makes an authorize request. - * - * @param Transaction $transaction The authorize transaction - * @param BasePaymentForm $form A form filled with payment info - */ - public function authorize(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface; - - /** - * Makes a capture request. - * - * @param Transaction $transaction The capture transaction - * @param string $reference Reference for the transaction being captured. - */ - public function capture(Transaction $transaction, string $reference): RequestResponseInterface; - - /** - * Complete the authorization for offsite payments. - * - * @param Transaction $transaction The transaction - */ - public function completeAuthorize(Transaction $transaction): RequestResponseInterface; - - /** - * Complete the purchase for offsite payments. - * - * @param Transaction $transaction The transaction - */ - public function completePurchase(Transaction $transaction): RequestResponseInterface; - - /** - * Creates a payment source from source data and customer id. - */ - public function createPaymentSource(BasePaymentForm $sourceData, int $customerId): PaymentSource; - - /** - * Deletes a payment source on the gateway by its token. - * - * @param string $token - */ - public function deletePaymentSource(string $token): bool; - - /** - * Returns payment form model to use in payment forms. - */ - public function getPaymentFormModel(): BasePaymentForm; - - /** - * Makes a purchase request. - * - * @param Transaction $transaction The purchase transaction - * @param BasePaymentForm $form A form filled with payment info - */ - public function purchase(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface; - - /** - * Makes an refund request. - * - * @param Transaction $transaction The refund transaction - */ - public function refund(Transaction $transaction): RequestResponseInterface; - - /** - * Processes a webhook and return a response - * - * @throws Throwable if something goes wrong - */ - public function processWebHook(): WebResponse; - - /** - * Returns true if gateway supports authorize requests. - */ - public function supportsAuthorize(): bool; - - /** - * Returns true if gateway supports capture requests. - */ - public function supportsCapture(): bool; - - /** - * Returns true if gateway supports completing authorize requests - */ - public function supportsCompleteAuthorize(): bool; - - /** - * Returns true if gateway supports completing purchase requests - */ - public function supportsCompletePurchase(): bool; - - /** - * Returns true if gateway supports storing payment sources - */ - public function supportsPaymentSources(): bool; - - /** - * Returns true if gateway supports purchase requests. - */ - public function supportsPurchase(): bool; - - /** - * Returns true if gateway supports refund requests. - */ - public function supportsRefund(): bool; - - /** - * Returns true if gateway supports partial refund requests. - */ - public function supportsPartialRefund(): bool; - - /** - * Returns true if gateway supports partial payment requests. - */ - public function supportsPartialPayment(): bool; - - /** - * Returns true if gateway supports webhooks. - * - * If `true` is returned, this show the webhook url - * to the person setting up your gateway (after the gateway is saved). - * This also affects whether the webhook controller should route webhook requests to your - * `processWebHook()` method in this class. - */ - public function supportsWebhooks(): bool; - - /** - * Returns `true` if gateway supports payments for the supplied order. - * - * This method is called before a payment is made for the supplied order. It can be - * used by developers building a checkout and deciding if this gateway should be shown as - * and option to the customer. - * - * It also can prevent a gateway from being used with a particular order. - * - * An example of this can be found in the manual payment gateway: It has a setting that can limit its use - * to only be used with orders that are of a zero value amount. See below for an example of how it uses this - * method to reject the gateway's use on orders that are not $0.00 if the setting is turned on - * - * ```php - * public function availableForUseWithOrder($order): bool - * if ($this->onlyAllowForZeroPriceOrders && $order->getTotalPrice() != 0) { - * return false; - * } - * return true; - * } - * ``` - * - * @param $order Order The order this gateway can or can not be available for payment with. - */ - public function availableForUseWithOrder(Order $order): bool; - - /** - * Retrieves the transaction hash from the webhook data. This could be a query string - * param or part of the response data. - * - * @return string|null - * @since 3.1.9 - */ - public function getTransactionHashFromWebhook(): ?string; -} diff --git a/src/base/HasStoreInterface.php b/src/base/HasStoreInterface.php deleted file mode 100644 index 2f919a2032..0000000000 --- a/src/base/HasStoreInterface.php +++ /dev/null @@ -1,24 +0,0 @@ - - * @since 5.0.0 - */ -interface HasStoreInterface -{ - /** - * @return Store - */ - public function getStore(): Store; -} diff --git a/src/base/InventoryItemTrait.php b/src/base/InventoryItemTrait.php deleted file mode 100644 index 1bfc485d20..0000000000 --- a/src/base/InventoryItemTrait.php +++ /dev/null @@ -1,61 +0,0 @@ - - * @since 5.3.0 - */ -trait InventoryItemTrait -{ - /** - * @var int|null The inventory item ID - */ - public ?int $inventoryItemId = null; - - /** - * @var InventoryItem|null The inventory item - * @see getInventoryItem() - * @see setInventoryItem() - */ - private ?InventoryItem $_inventoryItem = null; - - /** - * @param InventoryItem|null $inventoryItem - * @return void - */ - public function setInventoryItem(?InventoryItem $inventoryItem): void - { - $this->_inventoryItem = $inventoryItem; - $this->inventoryItemId = $inventoryItem?->id ?? null; - } - - /** - * @return InventoryItem|null - * @throws \yii\base\InvalidConfigException - */ - public function getInventoryItem(): ?InventoryItem - { - if (isset($this->_inventoryItem)) { - return $this->_inventoryItem; - } - - if ($this->inventoryItemId) { - $this->_inventoryItem = Plugin::getInstance()->getInventory()->getInventoryItemById($this->inventoryItemId); - - return $this->_inventoryItem; - } - - return null; - } -} diff --git a/src/base/InventoryLocationTrait.php b/src/base/InventoryLocationTrait.php deleted file mode 100644 index 02888bc927..0000000000 --- a/src/base/InventoryLocationTrait.php +++ /dev/null @@ -1,61 +0,0 @@ - - * @since 5.3.0 - */ -trait InventoryLocationTrait -{ - /** - * @var int|null The inventory item ID - */ - public ?int $inventoryLocationId = null; - - /** - * @var InventoryLocation|null The inventory item - * @see getInventoryLocation() - * @see setInventoryLocation() - */ - private ?InventoryLocation $_inventoryLocation = null; - - /** - * @param InventoryLocation|null $inventoryLocation - * @return void - */ - public function setInventoryLocation(?InventoryLocation $inventoryLocation): void - { - $this->_inventoryLocation = $inventoryLocation; - $this->inventoryLocationId = $inventoryLocation?->id ?? null; - } - - /** - * @return InventoryLocation|null - * @throws \yii\base\InvalidConfigException - */ - public function getInventoryLocation(): ?InventoryLocation - { - if (isset($this->_inventoryLocation)) { - return $this->_inventoryLocation; - } - - if ($this->inventoryLocationId) { - $this->_inventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($this->inventoryLocationId); - - return $this->_inventoryLocation; - } - - return null; - } -} diff --git a/src/base/InventoryMovement.php b/src/base/InventoryMovement.php deleted file mode 100644 index 6d8ea1b6d3..0000000000 --- a/src/base/InventoryMovement.php +++ /dev/null @@ -1,187 +0,0 @@ -_inventoryMovementHash = md5(uniqid((string)mt_rand(), true)); - - parent::init(); - } - - /** - * @return array - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - - $rules[] = [['inventoryItemId'], 'safe']; - - return $rules; - } - - /** - * @inheritDoc - */ - public function isValid(): bool - { - return $this->validate(); - } - - /** - * @inheritDoc - */ - public function getInventoryMovementHash(): string - { - return $this->_inventoryMovementHash; - } - - /** - * @inheritDoc - */ - public function getToInventoryLocation(): InventoryLocation - { - return $this->toInventoryLocation; - } - - /** - * @inheritDoc - */ - public function getFromInventoryLocation(): InventoryLocation - { - return $this->fromInventoryLocation; - } - - /** - * @inheritDoc - */ - public function getToInventoryTransactionType(): InventoryTransactionType - { - return $this->toInventoryTransactionType; - } - - /** - * @inheritDoc - */ - public function getFromInventoryTransactionType(): InventoryTransactionType - { - return $this->fromInventoryTransactionType; - } - - /** - * @inheritDoc - */ - public function getQuantity(): int - { - return $this->quantity; - } - - /** - * @inheritDoc - */ - public function getTransferId(): ?int - { - return $this->transferId; - } - - /** - * @inheritDoc - */ - public function getLineItemId(): ?int - { - return $this->lineItemId; - } - - /** - * @inheritDoc - */ - public function getUserId(): ?int - { - return $this->userId; - } - - /** - * @inheritDoc - */ - public function getNote(): ?string - { - return $this->note; - } -} diff --git a/src/base/InventoryMovementInterface.php b/src/base/InventoryMovementInterface.php deleted file mode 100644 index 7e29090bad..0000000000 --- a/src/base/InventoryMovementInterface.php +++ /dev/null @@ -1,80 +0,0 @@ - - * @since 2.0 - */ -class Model extends BaseModel -{ -} diff --git a/src/base/Plan.php b/src/base/Plan.php deleted file mode 100644 index 6d81a4d72e..0000000000 --- a/src/base/Plan.php +++ /dev/null @@ -1,187 +0,0 @@ - - * @since 2.0 - */ -abstract class Plan extends Model implements PlanInterface, CpEditable -{ - use PlanTrait; - - /** - * @var SubscriptionGatewayInterface|null the gateway - */ - private ?SubscriptionGatewayInterface $_gateway = null; - - /** - * @var mixed the plan data. - */ - private mixed $_data = null; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateCreated = null; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateUpdated = null; - - /** - * Returns the billing plan friendly name - * - * @return string - */ - public function __toString() - { - return (string)$this->name; - } - - /** - * Returns the gateway for this subscription plan. - * - * @throws InvalidConfigException if gateway does not support subscriptions - */ - public function getGateway(): ?SubscriptionGatewayInterface - { - if (!isset($this->_gateway)) { - /** @var Gateway|SubscriptionGatewayInterface|null $gateway */ - $gateway = Commerce::getInstance()->getGateways()->getGatewayById($this->gatewayId); - $this->_gateway = $gateway; - } - - if ($this->_gateway && !$this->_gateway instanceof SubscriptionGatewayInterface) { - throw new InvalidConfigException('This gateway does not support subscriptions'); - } - - return $this->_gateway; - } - - /** - * Returns the stored plan data. - * - * @return mixed - */ - public function getPlanData(): mixed - { - if ($this->_data === null) { - $this->_data = Json::decodeIfJson($this->planData); - } - - return $this->_data; - } - - /** - * Returns the plan's related Entry element, if any. - */ - public function getInformation(): ?Entry - { - if ($this->planInformationId) { - /** @var Entry|null $planInformation */ - $planInformation = Entry::find()->id($this->planInformationId)->one(); - return $planInformation; - } - - return null; - } - - /** - * Returns the subscription count for this plan. - */ - public function getSubscriptionCount(): int - { - return Commerce::getInstance()->getSubscriptions()->getSubscriptionCountByPlanId($this->id); - } - - /** - * Returns whether there exists an active subscription for this plan for this user. - */ - public function hasActiveSubscription(int $userId): bool - { - return (bool)count($this->getActiveUserSubscriptions($userId)); - } - - /** - * Returns active subscriptions for this plan by user id. - * - * @param int $userId the user id - * @return ElementInterface[] - */ - public function getActiveUserSubscriptions(int $userId): array - { - return Subscription::find() - ->userId($userId) - ->planId($this->id) - ->status(Subscription::STATUS_ACTIVE) - ->all(); - } - - /** - * Returns all subscriptions for this plan by user id, including expired subscriptions. - * - * @param int $userId the user id - * @return ElementInterface[] - */ - public function getAllUserSubscriptions(int $userId): array - { - return Subscription::find() - ->userId($userId) - ->planId($this->id) - ->status(null) - ->all(); - } - - /** - * @inheritdoc - */ - public function rules(): array - { - return [ - [ - ['handle'], - UniqueValidator::class, - 'targetClass' => PlanRecord::class, - 'targetAttribute' => ['handle'], - ], - [['gatewayId', 'reference', 'name', 'handle', 'planData'], 'required'], - ]; - } - - /** - * @inheritdoc - */ - public function getCpEditUrl(): ?string - { - return $this->id ? UrlHelper::cpUrl('commerce/subscription-plans/' . $this->id) : null; - } -} diff --git a/src/base/PlanInterface.php b/src/base/PlanInterface.php deleted file mode 100644 index 6dd3487b4f..0000000000 --- a/src/base/PlanInterface.php +++ /dev/null @@ -1,26 +0,0 @@ - - * @since 2.0 - * @todo remove ignore: https://github.com/phpstan/phpstan/issues/6778 - * @phpstan-ignore-next-line - * @mixin PlanTrait - */ -interface PlanInterface -{ - /** - * Returns whether it's possible to switch to this plan from a different plan. - * @todo rename the `$currentPlant` parameter to `$currentPlan` in Commerce 6.0 - */ - public function canSwitchFrom(PlanInterface $currentPlant): bool; -} diff --git a/src/base/PlanTrait.php b/src/base/PlanTrait.php deleted file mode 100644 index f4843bcb23..0000000000 --- a/src/base/PlanTrait.php +++ /dev/null @@ -1,79 +0,0 @@ - - * @since 2.0 - */ -trait PlanTrait -{ - /** - * @var int|null Plan ID - */ - public ?int $id = null; - - /** - * @var int|null The gateway ID. - */ - public ?int $gatewayId = null; - - /** - * @var string|null plan name - */ - public ?string $name = null; - - /** - * @var string|null plan handle - */ - public ?string $handle = null; - - /** - * @var int|null ID of the entry containing plan information - */ - public ?int $planInformationId = null; - - /** - * @var string|null plan reference on the gateway - */ - public ?string $reference = null; - - /** - * @var bool whether the plan is enabled on site - */ - public bool $enabled = false; - - /** - * @var bool whether the plan is archived - */ - public bool $isArchived = false; - - /** - * @var DateTime|null when the plan was archived - */ - public ?DateTime $dateArchived = null; - - /** - * @var string|null gateway response - */ - public ?string $planData = null; - - /** - * @var string|null plan uid - */ - public ?string $uid = null; - - /** - * @var int|null sort order - */ - public ?int $sortOrder = null; -} diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php deleted file mode 100644 index 0cc9ae527d..0000000000 --- a/src/base/Purchasable.php +++ /dev/null @@ -1,1642 +0,0 @@ - - * @since 2.0 - */ -abstract class Purchasable extends Element implements PurchasableInterface, HasStoreInterface -{ - /** - * @var float|null - */ - private ?float $_salePrice = null; - - /** - * @var float|null - * @see getPrice() - * @see setPrice() - */ - private ?float $_price = null; - - /** - * @var Sale[]|null - */ - private ?array $_sales = null; - - /** - * Promotional price generated by the sales system. - * - * @var float|null - */ - private ?float $_salesPrice = null; - - /** - * The store based on the `siteId` of the instance of the purchasable. - * - * @var Store|null - */ - private ?Store $_store = null; - - /** - * @var float|null - * @see getPromotionalPrice() - * @see setPromotionalPrice() - */ - private ?float $_promotionalPrice = null; - - /** - * @var string SKU - * @see getSku() - * @see setSku() - */ - private string $_sku = ''; - - /** - * @var int|null Tax category ID - * @since 5.0.0 - */ - private ?int $_taxCategoryId = null; - - /** - * @var TaxCategory|null Tax Category - * @since 5.0.0 - */ - private ?TaxCategory $_taxCategory = null; - - /** - * @var int|null Shipping category ID - * @since 5.0.0 - */ - private ?int $_shippingCategoryId = null; - - /** - * @var ShippingCategory|null Shipping Category - * @since 5.0.0 - */ - private ?ShippingCategory $_shippingCategory = null; - - /** - * @var float|null $width - * @since 5.0.0 - */ - public ?float $width = null; - - /** - * @var float|null $height - * @since 5.0.0 - */ - public ?float $height = null; - - /** - * @var float|null $length - * @since 5.0.0 - */ - public ?float $length = null; - - /** - * @var float|null $weight - * @since 5.0.0 - */ - public ?float $weight = null; - - /** - * @var float|null - * @see getBasePrice() - * @see setBasePrice() - * @since 5.0.0 - */ - private ?float $_basePrice = null; - - /** - * @var float|null - * @see getBasePromotionalPrice() - * @see setBasePromotionalPrice() - * @since 5.0.0 - */ - private ?float $_basePromotionalPrice = null; - - /** - * The ID of the catalog pricing rule that is affecting the sale price of this purchasable. - * - * @var int|null - * @since 5.4.0 - */ - public ?int $catalogPricingRuleId = null; - - /** - * @var CatalogPricingRule|null - * @since 5.4.0 - * @see getCatalogPricingRule() - */ - private ?CatalogPricingRule $_catalogPricingRule = null; - - /** - * @var bool - * @since 5.0.0 - */ - public bool $freeShipping = false; - - /** - * @var bool - * @since 5.0.0 - */ - public bool $promotable = false; - - /** - * @var bool - * @since 5.0.0 - */ - public bool $availableForPurchase = true; - - /** - * @var int|null - * @since 5.0.0 - */ - public ?int $minQty = null; - - /** - * @var int|null - * @since 5.0.0 - */ - public ?int $maxQty = null; - - /** - * @var int - * @since 5.0.0 - */ - public ?int $inventoryItemId = null; - - /** - * This is if the store cares about tracking the stock. - * - * @var bool - * @since 5.0.0 - */ - public bool $inventoryTracked = false; - - /** - * Should this purchases of this purchasable be allowed if it is out of stock. - * - * @var bool - * @since 5.3.0 - */ - public bool $allowOutOfStockPurchases = false; - - /** - * This is the cached total available stock across all inventory locations. - * - * @var int - * @since 5.0.0 - */ - private ?int $_stock = null; - - /** - * @inheritdoc - */ - public function attributes(): array - { - $names = parent::attributes(); - - $names[] = 'isAvailable'; - $names[] = 'isPromotable'; - $names[] = 'price'; - $names[] = 'promotionalPrice'; - $names[] = 'basePrice'; - $names[] = 'basePromotionalPrice'; - $names[] = 'onPromotion'; - $names[] = 'salePrice'; - $names[] = 'sku'; - $names[] = 'stock'; - $names[] = 'inventoryTracked'; - $names[] = 'allowOutOfStockPurchases'; - $names[] = 'shippingCategoryId'; - $names[] = 'taxCategoryId'; - - return $names; - } - - /** - * @inheritdoc - * @since 3.2.9 - */ - public function fields(): array - { - $fields = parent::fields(); - - $fields['salePrice'] = 'salePrice'; - return $fields; - } - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $names = parent::extraFields(); - - $names[] = 'description'; - $names[] = 'sales'; - $names[] = 'snapshot'; - return $names; - } - - /** - * @return array - */ - public function currencyAttributes(): array - { - return [ - 'basePrice', - 'basePromotionalPrice', - 'price', - 'promotionalPrice', - 'salePrice', - ]; - } - - /** - * @inheritdoc - */ - public function setAttributesFromRequest(array $values): void - { - $length = ArrayHelper::remove($values, 'length'); - if ($length !== null) { - $this->length = $length ? (float)Localization::normalizeNumber($length) : null; - } - - $width = ArrayHelper::remove($values, 'width'); - if ($width !== null) { - $this->width = $width ? (float)Localization::normalizeNumber($width) : null; - } - - $height = ArrayHelper::remove($values, 'height'); - if ($height !== null) { - $this->height = $height ? (float)Localization::normalizeNumber($height) : null; - } - - $weight = ArrayHelper::remove($values, 'weight'); - if ($weight !== null) { - $this->weight = $weight ? (float)Localization::normalizeNumber($weight) : null; - } - - $this->setAttributes($values); - } - - /** - * @inheritdoc - */ - protected function inlineAttributeInputHtml(string $attribute): string - { - $localizePrice = function(string $attribute) { - $price = $this->{$attribute}; - if (empty($this->getErrors($attribute))) { - if ($price === null && $attribute === 'basePromotionalPrice') { - return null; - } elseif ($price === null) { - $price = 0; - } - - $price = Craft::$app->getFormatter()->asDecimal($price); - } - - return $price; - }; - - return match ($attribute) { - 'availableForPurchase' => PurchasableHelper::availableForPurchaseInputHtml($this->availableForPurchase), - 'price' => Currency::moneyInputHtml($localizePrice('basePrice'), [ - 'id' => 'base-price', - 'name' => 'basePrice', - 'currency' => $this->getStore()->getCurrency()->getCode(), - 'currencyLabel' => $this->getStore()->getCurrency()->getCode(), - ]), - 'promotionalPrice' => Currency::moneyInputHtml($localizePrice('basePromotionalPrice'), [ - 'id' => 'base-promotional-price', - 'name' => 'basePromotionalPrice', - 'currency' => $this->getStore()->getCurrency()->getCode(), - 'currencyLabel' => $this->getStore()->getCurrency()->getCode(), - ]), - 'sku' => PurchasableHelper::skuInputHtml($this->getSkuAsText()), - default => parent::inlineAttributeInputHtml($attribute), - }; - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - $classNameParts = explode('\\', static::class); - - return array_pop($classNameParts); - } - - /** - * @return Teller - * @throws InvalidConfigException - * @since 5.0.0 - */ - private function _getTeller(): Teller - { - return Plugin::getInstance()->getCurrencies()->getTeller($this->getStore()->getCurrency()); - } - - /** - * @inheritdoc - */ - public function __unset($name) - { - // Allow clearing of specific memoized properties - if (in_array($name, ['stock', 'shippingCategory', 'taxCategory'])) { - $this->{'_' . $name} = null; - return; - } - - parent::__unset($name); - } - - /** - * @inheritdoc - */ - public function getStore(): Store - { - if ($this->_store === null || !in_array($this->siteId, $this->_store->getSites()->pluck('id')->all())) { - if ($this->siteId === null) { - throw new InvalidConfigException('Purchasable::siteId cannot be null'); - } - - $this->_store = Plugin::getInstance()->getStores()->getStoreBySiteId($this->siteId); - if ($this->_store === null) { - throw new InvalidConfigException('Unable to retrieve store.'); - } - } - - return $this->_store; - } - - /** - * @return int - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function getStoreId(): int - { - return $this->getStore()->id; - } - - /** - * @inheritdoc - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function getIsAvailable(): bool - { - // Is the element available for purchase? - if (!$this->availableForPurchase) { - return false; - } - - // is the element enabled? - if ($this->getStatus() !== Element::STATUS_ENABLED) { - return false; - } - - // Temporary SKU can not be added to the cart - if (PurchasableHelper::isTempSku($this->getSku())) { - return false; - } - - if (static::hasInventory() && $this->inventoryTracked && $this->getStock() < 1) { - if (!Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($this)) { - return false; - } - } - - return true; - } - - /** - * @param Money|array|float|int|null $basePrice - * @return void - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function setBasePrice(Money|array|float|int|null $basePrice): void - { - if (is_array($basePrice)) { - if (isset($basePrice['value']) && $basePrice['value'] === '') { - $this->_basePrice = null; - return; - } - - if (!isset($basePrice['currency'])) { - $basePrice['currency'] = $this->getStore()->getCurrency(); - } - - $basePrice = MoneyHelper::toMoney($basePrice); - // nullify if conversion fails - $basePrice = $basePrice ?: null; - } - - if ($basePrice instanceof Money) { - $basePrice = MoneyHelper::toDecimal($basePrice); - } elseif ($basePrice !== null) { - $basePrice = (float)$basePrice; - } - - $this->_basePrice = $basePrice; - } - - /** - * @return float|null - * @since 5.0.0 - */ - public function getBasePrice(): ?float - { - return $this->_basePrice; - } - - /** - * @param Money|array|float|int|null $basePromotionalPrice - * @return void - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function setBasePromotionalPrice(Money|array|float|int|null $basePromotionalPrice): void - { - if (is_array($basePromotionalPrice)) { - if (isset($basePromotionalPrice['value']) && $basePromotionalPrice['value'] === '') { - $this->_basePromotionalPrice = null; - return; - } - - if (!isset($basePromotionalPrice['currency'])) { - $basePromotionalPrice['currency'] = $this->getStore()->getCurrency(); - } - - $basePromotionalPrice = MoneyHelper::toMoney($basePromotionalPrice); - // nullify if conversion fails - $basePromotionalPrice = $basePromotionalPrice ?: null; - } - - if ($basePromotionalPrice instanceof Money) { - $basePromotionalPrice = MoneyHelper::toDecimal($basePromotionalPrice); - } elseif ($basePromotionalPrice !== null) { - $basePromotionalPrice = (float)$basePromotionalPrice; - } - - $this->_basePromotionalPrice = $basePromotionalPrice; - } - - /** - * @return float|null - * @since 5.0.0 - */ - public function getBasePromotionalPrice(): ?float - { - return $this->_basePromotionalPrice; - } - - /** - * @param float|null $price - * @return void - * @since 5.0.0 - */ - public function setPrice(?float $price): void - { - $this->_price = $price; - } - - /** - * @return float|null - * @throws InvalidConfigException - * @throws \Throwable - * @since 5.0.0 - */ - public function getPrice(): ?float - { - if (!Plugin::getInstance()->getCatalogPricingRules()->canUseCatalogPricingRules()) { - return $this->basePrice; - } - - $price = $this->_price ?? $this->basePrice; - - return (float)$this->_getTeller()->convertToString($price); - } - - /** - * @return float|null - * @throws InvalidConfigException - * @throws \Throwable - * @since 5.0.0 - */ - public function getPromotionalPrice(): ?float - { - $price = $this->getPrice(); - if (!Plugin::getInstance()->getCatalogPricingRules()->canUseCatalogPricingRules()) { - // Use the sales system to figure out the price - if (!isset($this->_sales)) { - $this->loadSales(); - } - $promotionalPrice = $this->_salesPrice ?? $this->basePromotionalPrice; - } else { - $promotionalPrice = $this->_promotionalPrice ?? $this->basePromotionalPrice; - } - - if ($promotionalPrice === null) { - return null; - } - - $promotionalPrice = (float)$this->_getTeller()->convertToString($promotionalPrice); - return $this->_getTeller()->lessThan($promotionalPrice, $price) ? $promotionalPrice : null; - } - - /** - * @param float|null $price - * @return void - * @since 5.0.0 - */ - public function setPromotionalPrice(?float $price): void - { - $this->_promotionalPrice = $price; - } - - /** - * @return CatalogPricingRule|null - * @throws InvalidConfigException - * @throws SiteNotFoundException - * @since 5.4.0 - */ - public function getCatalogPricingRule(): ?CatalogPricingRule - { - if ($this->_catalogPricingRule === null && $this->catalogPricingRuleId !== null) { - $this->_catalogPricingRule = Plugin::getInstance()->getCatalogPricingRules()->getCatalogPricingRuleById($this->catalogPricingRuleId, $this->storeId); - } - - return $this->_catalogPricingRule; - } - - /** - * @inheritdoc - */ - public function getSalePrice(): ?float - { - if ($this->_salePrice === null) { - $this->_salePrice = $this->getPromotionalPrice() ?? $this->getPrice(); - } - - return $this->_salePrice ?? null; - } - - /** - * @inheritdoc - */ - public function getSku(): string - { - return $this->_sku ?? ''; - } - - /** - * Returns the SKU as text but returns a blank string if it’s a temp SKU. - */ - public function getSkuAsText(): string - { - $sku = $this->getSku(); - - if (PurchasableHelper::isTempSku($sku)) { - $sku = ''; - } - - return $sku; - } - - /** - * @param string|null $sku - */ - public function setSku(string $sku = null): void - { - $this->_sku = $sku; - } - - /** - * Returns whether this variant has stock. - */ - public function hasStock(): bool - { - return !$this->inventoryTracked || $this->getStock() > 0; - } - - /** - * @param int|null $taxCategoryId - * @return void - * @since 5.0.0 - */ - public function setTaxCategoryId(?int $taxCategoryId = null): void - { - $this->_taxCategoryId = $taxCategoryId; - } - - /** - * @return int - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function getTaxCategoryId(): int - { - if ($this->_taxCategoryId === null) { - $this->_taxCategoryId = Plugin::getInstance()->getTaxCategories()->getDefaultTaxCategory()->id; - } - - return $this->_taxCategoryId; - } - - /** - * @inheritdoc - */ - public function getTaxCategory(): TaxCategory - { - if ($this->_taxCategory === null || $this->_taxCategory->id != $this->getTaxCategoryId()) { - $this->_taxCategory = Plugin::getInstance()->getTaxCategories()->getTaxCategoryById($this->getTaxCategoryId()); - } - - return $this->_taxCategory; - } - - /** - * @inheritdoc - */ - public function getSnapshot(): array - { - return [ - 'catalogPricingRuleId' => $this->catalogPricingRuleId, - ]; - } - - /** - * @param int|null $shippingCategoryId - * @return void - * @since 5.0.0 - */ - public function setShippingCategoryId(?int $shippingCategoryId = null): void - { - $this->_shippingCategoryId = $shippingCategoryId; - } - - /** - * @return int - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function getShippingCategoryId(): int - { - if ($this->_shippingCategoryId === null) { - $this->_shippingCategoryId = Plugin::getInstance()->getShippingCategories()->getDefaultShippingCategory($this->getStoreId())->id; - } - - return $this->_shippingCategoryId; - } - - /** - * @inheritdoc - */ - public function getShippingCategory(): ShippingCategory - { - if ($this->_shippingCategory === null || $this->_shippingCategory->id !== $this->getShippingCategoryId()) { - $this->_shippingCategory = Plugin::getInstance()->getShippingCategories()->getShippingCategoryById($this->getShippingCategoryId(), $this->getStoreId()); - } - - return $this->_shippingCategory; - } - - /** - * @inheritdoc - */ - public function getDescription(): string - { - return (string)$this; - } - - /** - * @inheritdoc - */ - public function populateLineItem(LineItem $lineItem): void - { - // Since we do not have a proper stock reservation system, we need deduct stock if they have more in the cart than is available, and to do this quietly. - // If this occurs in the payment request, the user will be notified the order has changed. - if (($order = $lineItem->getOrder()) && !$order->isCompleted) { - if ($this::hasInventory() && - !$this->getIsOutOfStockPurchasingAllowed() && - $this->inventoryTracked && - ($lineItem->qty > $this->getStock()) && - $this->getStock() > 0 - ) { - $message = Craft::t('commerce', '{description} only has {stock} in stock.', ['description' => $lineItem->getDescription(), 'stock' => $this->getStock()]); - /** @var OrderNotice $notice */ - $notice = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => [ - 'type' => 'lineItemMaxStockReached', - 'attribute' => "lineItems.$lineItem->id.qty", - 'message' => $message, - ], - ]); - $order->addNotice($notice); - $lineItem->qty = $this->getStock(); - } - } - - $lineItem->weight = (float)$this->weight; //converting nulls - $lineItem->height = (float)$this->height; //converting nulls - $lineItem->length = (float)$this->length; //converting nulls - $lineItem->width = (float)$this->width; //converting nulls - } - - /** - * @inheritdoc - */ - public function getLineItemRules(LineItem $lineItem): array - { - $order = $lineItem->getOrder(); - - // After the order is complete shouldn't check things like stock being available or the purchasable being around since they are irrelevant. - if ($order && $order->isCompleted) { - return []; - } - - $lineItemQuantitiesByPurchasableId = []; - foreach ($order->getLineItems() as $item) { - if ($item->purchasableId) { - $lineItemQuantitiesByPurchasableId[$item->purchasableId] = isset($lineItemQuantitiesByPurchasableId[$item->purchasableId]) ? $lineItemQuantitiesByPurchasableId[$item->purchasableId] + $item->qty : $item->qty; - } - } - - return [ - // an inline validator defined as an anonymous function - [ - 'purchasableId', - function($attribute, $params, Validator $validator) use ($lineItem) { - $purchasable = $lineItem->getPurchasable(); - if ($purchasable === null) { - $validator->addError($lineItem, $attribute, Craft::t('commerce', 'No purchasable available.')); - } - - if (!Plugin::getInstance()->getPurchasables()->isPurchasableAvailable($lineItem->getPurchasable(), $lineItem->getOrder())) { - $validator->addError($lineItem, $attribute, Craft::t('commerce', 'The item is not enabled for sale.')); - } - }, - ], - [ - 'qty', - function($attribute, $params, Validator $validator) use ($lineItem, $lineItemQuantitiesByPurchasableId) { - if ($lineItem->type == LineItemType::Custom) { - return; - } - - $lineItemPurchasable = $lineItem->getPurchasable(); - if (!$lineItemPurchasable instanceof Purchasable) { - return; - } - - if (!$this->hasStock()) { - if (!Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($lineItemPurchasable, $lineItem->getOrder())) { - $error = Craft::t('commerce', '“{description}” is currently out of stock.', ['description' => $lineItemPurchasable->getDescription()]); - $validator->addError($lineItem, $attribute, $error); - } - } - - $lineItemQty = $lineItem->purchasableId ? $lineItemQuantitiesByPurchasableId[$lineItem->purchasableId] : $lineItem->qty; - - if ($this->hasStock() && $this->inventoryTracked && $lineItemQty > $this->getStock()) { - if (!Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($lineItemPurchasable, $lineItem->getOrder())) { - $error = Craft::t('commerce', 'There are only {num} “{description}” items left in stock.', ['num' => $this->getStock(), 'description' => $lineItemPurchasable->getDescription()]); - $validator->addError($lineItem, $attribute, $error); - } - } - - if ($this->minQty > 1 && $lineItemQty < $this->minQty) { - $error = Craft::t('commerce', 'Minimum order quantity for this item is {num}.', ['num' => $this->minQty]); - $validator->addError($lineItem, $attribute, $error); - } - - if ($this->maxQty != 0 && $lineItemQty > $this->maxQty) { - $error = Craft::t('commerce', 'Maximum order quantity for this item is {num}.', ['num' => $this->maxQty]); - $validator->addError($lineItem, $attribute, $error); - } - }, - ], - ]; - } - - /** - * @inheritdoc - */ - public function setAttributes($values, $safeOnly = true): void - { - // Normalize category IDs - handle arrays from componentSelect and empty strings - if (isset($values['taxCategoryId'])) { - if (is_array($values['taxCategoryId'])) { - $values['taxCategoryId'] = reset($values['taxCategoryId']) ?: null; - } - if ($values['taxCategoryId'] === '') { - $values['taxCategoryId'] = null; - } - } - if (isset($values['shippingCategoryId'])) { - if (is_array($values['shippingCategoryId'])) { - $values['shippingCategoryId'] = reset($values['shippingCategoryId']) ?: null; - } - if ($values['shippingCategoryId'] === '') { - $values['shippingCategoryId'] = null; - } - } - - parent::setAttributes($values, $safeOnly); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return array_merge(parent::defineRules(), [ - [['sku'], 'string', 'max' => 255], - [['sku', 'price'], 'required', 'on' => self::SCENARIO_LIVE], - [['price', 'promotionalPrice', 'weight', 'width', 'length', 'height'], 'number'], - [ - ['sku'], - UniqueValidator::class, - 'targetClass' => PurchasableRecord::class, - 'caseInsensitive' => true, - 'filter' => function(ActiveQuery $query) { - /** @var class-string<\yii\db\ActiveRecord> $modelClass */ - $modelClass = $query->modelClass; - $targetRecordClassTableName = $modelClass::tableName(); - $elementsTable = CraftTable::ELEMENTS; - $query->leftJoin(['elements' => $elementsTable], "[[elements.id]] = {$targetRecordClassTableName}.id"); - $query->andWhere(['elements.revisionId' => null, 'elements.draftId' => null]); - }, - 'on' => self::SCENARIO_LIVE, - ], - [['basePrice'], 'number'], - [['basePromotionalPrice', 'minQty', 'maxQty'], 'number', 'skipOnEmpty' => true], - [['freeShipping', 'inventoryTracked', 'allowOutOfStockPurchases', 'promotable', 'availableForPurchase'], 'boolean'], - [['taxCategoryId', 'shippingCategoryId', 'price', 'promotionalPrice', 'productSlug', 'productTypeHandle'], 'safe'], - [['taxCategoryId', 'shippingCategoryId', ], 'required'], - ]); - } - - /** - * @inheritdoc - */ - public function afterOrderComplete(Order $order, LineItem $lineItem): void - { - } - - /** - * @inheritdoc - */ - public function hasFreeShipping(): bool - { - return $this->freeShipping; - } - - public function getIsShippable(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function getIsTaxable(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function getIsPromotable(): bool - { - return $this->promotable; - } - - /** - * @inheritdoc - */ - public function getPromotionRelationSource(): mixed - { - return $this->id; - } - - /** - * @return InventoryItem - * @throws InvalidConfigException - */ - public function getInventoryItem(): InventoryItem - { - return Plugin::getInstance()->getInventory()->getInventoryItemByPurchasable($this); - } - - /** - * @deprecated in 5.0.0 use [[Purchasable::$inventoryTracked]] instead. - */ - public function getHasUnlimitedStock(): bool - { - return !$this::hasInventory() || !$this->inventoryTracked; - } - - /** - * @deprecated in 5.0.0 use [[Purchasable::$inventoryTracked]] instead. - */ - public function setHasUnlimitedStock($value): bool - { - return $this->inventoryTracked = !$value; - } - - /** - * @return int - */ - private function _getStock(): int - { - if (!$this->inventoryTracked) { - return 0; - } - - $saleableAmount = 0; - foreach ($this->getInventoryLevels() as $inventoryLevel) { - if ($inventoryLevel->availableTotal > 0) { - $saleableAmount += $inventoryLevel->availableTotal; - } - } - - return $saleableAmount; - } - - /** - * @return bool - * @since 5.3.0 - */ - public function getIsOutOfStockPurchasingAllowed(): bool - { - return Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($this); - } - - /** - * Returns the cached total available stock across all inventory locations for this store. - * - * @return int - * @since 5.0.0 - */ - public function getStock(): int - { - if ($this->_stock === null) { - $this->_stock = $this->_getStock(); - } - - return $this->_stock; - } - - /** - * Returns the total stock across all locations this purchasable is tracked in. - * @return Collection - * @since 5.0.0 - */ - public function getInventoryLevels(): Collection - { - if (!$this->inventoryTracked) { - return collect(); - } - - return Plugin::getInstance()->getInventory()->getInventoryLevelsForPurchasable($this); - } - - /** - * Update purchasable table - * - * @throws SiteNotFoundException - * @throws InvalidConfigException - * @throws InvalidConfigException - * @throws InvalidConfigException - */ - public function afterSave(bool $isNew): void - { - $canonicalPurchasableId = $this->getCanonicalId(); - $purchasableId = $this->id; - - if (!$this->propagating) { - $isOwnerDraftApplying = false; - $isOwnerRevisionApplying = false; - - // If this is a nested element, check if the owner is a draft and is being applied - if ($this instanceof NestedElementInterface) { - $owner = $this->getOwner(); - // A draft is only being "applied" if the owner is the canonical of the draft. - // Without this id check, "Save as a new product" from a draft would trip this branch - // and steal the original variant's inventory item via the transfer logic below. - $isOwnerDraftApplying = $owner - && $owner->getIsCanonical() - && $owner->duplicateOf !== null - && $owner->duplicateOf->getIsDraft() - && $owner->duplicateOf->getCanonicalId() === $owner->id; - - $isOwnerRevisionApplying = $owner - && $owner->duplicateOf !== null - && $owner->duplicateOf->getIsRevision() - && $owner->duplicateOf->getCanonicalId() === $owner->id; - } - - if (!$this->getIsRevision()) { - // Reset the purchasable's SKU when it is explicitly being duplicating - if ($this->duplicateOf !== null && !$isOwnerDraftApplying && !$isOwnerRevisionApplying) { - $this->sku = PurchasableHelper::tempSku(); - // Nullify inventory item so a new one is created - $this->inventoryItemId = null; - } - } - - $purchasable = PurchasableRecord::findOne($purchasableId); - - if (!$purchasable) { - $purchasable = new PurchasableRecord(); - } - $purchasable->sku = $this->getSku(); - $purchasable->id = $purchasableId; - $purchasable->width = $this->width; - $purchasable->height = $this->height; - $purchasable->length = $this->length; - $purchasable->weight = $this->weight; - $purchasable->taxCategoryId = $this->taxCategoryId; - - // Only update the description for the primary site until we have a concept - // of an order having a site ID - if ($this->siteId == Craft::$app->getSites()->getPrimarySite()->id) { - $purchasable->description = $this->getDescription(); - } - - $purchasable->save(false); - - // Always create the inventory item even if it's a temporary draft (in the slide) since we want to allow stock to be - // added to inventory before it is saved as a permanent variant. - if (static::hasInventory() && $canonicalPurchasableId) { - /** @var InventoryItemRecord|null $inventoryItem */ - $inventoryItem = null; - - // When applying a draft to its canonical, hand the source's inventory - // item over so any stock movements made on the draft persist. - if ($isOwnerDraftApplying && $this->duplicateOf !== null) { - /** @var InventoryItemRecord|null $inventoryItem */ - $inventoryItem = InventoryItemRecord::find()->where(['purchasableId' => $this->duplicateOf->id])->one(); - if ($inventoryItem && $inventoryItem->purchasableId != $canonicalPurchasableId) { - $inventoryItem->purchasableId = $canonicalPurchasableId; - if (!$inventoryItem->save()) { - // Could not transfer (e.g. canonical already has its own row); fall through to the find-or-create below. - $inventoryItem = null; - } - } - } - - if (!$inventoryItem) { - $inventoryItem = Plugin::getInstance()->getInventory()->ensureInventoryItemRecord($this); - } - - if ($inventoryItem) { - $this->inventoryItemId = $inventoryItem->id; - } - } - } - - if ($purchasableId) { - // Set Purchasables stores data - $purchasableStoreRecord = PurchasableStore::findOne([ - 'purchasableId' => $purchasableId, - 'storeId' => $this->getStoreId(), - ]); - if (!$purchasableStoreRecord) { - $purchasableStoreRecord = Craft::createObject(PurchasableStore::class); - $purchasableStoreRecord->storeId = $this->getStore()->id; - - if ($this->propagating) { - $purchasableStoreRecord->basePrice = 0; - $purchasableStoreRecord->basePromotionalPrice = null; - $purchasableStoreRecord->stock = Plugin::getInstance()->getInventory()->getInventoryLevelsForPurchasable($this)->sum('availableTotal'); - $purchasableStoreRecord->inventoryTracked = false; - $purchasableStoreRecord->allowOutOfStockPurchases = false; - $purchasableStoreRecord->minQty = null; - $purchasableStoreRecord->maxQty = null; - $purchasableStoreRecord->promotable = false; - $purchasableStoreRecord->availableForPurchase = false; - $purchasableStoreRecord->freeShipping = false; - $purchasableStoreRecord->purchasableId = $purchasableId; - $purchasableStoreRecord->shippingCategoryId = Plugin::getInstance()->getShippingCategories()->getDefaultShippingCategory($this->getStore()->id)->id; - - if ($this->duplicateOf !== null) { - // If this is a duplicate, copy the values from the original purchasable stores record - $purchasableStoreRecordDuplicate = PurchasableStore::findOne([ - 'purchasableId' => $this->duplicateOf->id, - 'storeId' => $this->getStoreId(), - ]); - - if ($purchasableStoreRecordDuplicate) { - $purchasableStoreRecord->basePrice = $purchasableStoreRecordDuplicate->basePrice; - $purchasableStoreRecord->basePromotionalPrice = $purchasableStoreRecordDuplicate->basePromotionalPrice; - $purchasableStoreRecord->stock = Plugin::getInstance()->getInventory()->getInventoryLevelsForPurchasable($this)->sum('availableTotal'); - $purchasableStoreRecord->inventoryTracked = $purchasableStoreRecordDuplicate->inventoryTracked; - $purchasableStoreRecord->allowOutOfStockPurchases = $purchasableStoreRecordDuplicate->allowOutOfStockPurchases; - $purchasableStoreRecord->minQty = $purchasableStoreRecordDuplicate->minQty; - $purchasableStoreRecord->maxQty = $purchasableStoreRecordDuplicate->maxQty; - $purchasableStoreRecord->promotable = $purchasableStoreRecordDuplicate->promotable; - $purchasableStoreRecord->availableForPurchase = $purchasableStoreRecordDuplicate->availableForPurchase; - $purchasableStoreRecord->freeShipping = $purchasableStoreRecordDuplicate->freeShipping; - $purchasableStoreRecord->shippingCategoryId = $purchasableStoreRecordDuplicate->shippingCategoryId; - } - } - } - } - - if (!$this->propagating) { - $purchasableStoreRecord->basePrice = $this->basePrice; - $purchasableStoreRecord->basePromotionalPrice = $this->basePromotionalPrice; - $purchasableStoreRecord->stock = Plugin::getInstance()->getInventory()->getInventoryLevelsForPurchasable($this)->sum('availableTotal'); - $purchasableStoreRecord->inventoryTracked = $this::hasInventory() ? $this->inventoryTracked : false; - $purchasableStoreRecord->allowOutOfStockPurchases = $this->allowOutOfStockPurchases; - $purchasableStoreRecord->minQty = $this->minQty; - $purchasableStoreRecord->maxQty = $this->maxQty; - $purchasableStoreRecord->promotable = $this->promotable; - $purchasableStoreRecord->availableForPurchase = $this->availableForPurchase; - $purchasableStoreRecord->freeShipping = $this->freeShipping; - $purchasableStoreRecord->purchasableId = $purchasableId; - $purchasableStoreRecord->shippingCategoryId = $this->getShippingCategoryId(); - } - - $purchasableStoreRecord->save(false); - } - - parent::afterSave($isNew); - } - - /** - * @inheritdoc - */ - public function afterPropagate(bool $isNew): void - { - parent::afterPropagate($isNew); - - if (!$this->getIsDraft() && !$this->getIsRevision()) { - Plugin::getInstance()->getCatalogPricing()->createCatalogPricingJob([ - 'purchasableIds' => [$this->getCanonicalId()], - 'storeId' => $this->getStoreId(), - ]); - } - } - - /** - * Clean up purchasable table - */ - public function afterDelete(): void - { - $purchasable = PurchasableRecord::findOne($this->id); - - $purchasable?->delete(); - - parent::afterDelete(); - } - - /** - * @return array|Sale[] - * @throws InvalidConfigException - */ - public function getSales(): array - { - if (!isset($this->_sales)) { - $this->loadSales(); - } - - return $this->_sales; - } - - /** - * @return Sale[] The sales that relate directly to this purchasable - * @throws InvalidConfigException - */ - public function relatedSales(): array - { - return Plugin::getInstance()->getSales()->getSalesRelatedToPurchasable($this); - } - - /** - * @inheritdoc - */ - public function getOnPromotion(): bool - { - return $this->getPromotionalPrice() !== null; - } - - /** - * @return bool - * @throws DeprecationException - */ - public function getOnSale(): bool - { - Craft::$app->getDeprecator()->log(__METHOD__, 'Purchasable `' . __METHOD__ . '()` method has been deprecated. Use `getOnPromotion()` instead.'); - return $this->getOnPromotion(); - } - - /** - * @inheritdoc - */ - public function attributeLabels(): array - { - $labels = parent::attributeLabels(); - - return array_merge($labels, ['sku' => 'SKU']); - } - - /** - * @inheritdoc - */ - protected function metaFieldsHtml(bool $static): string - { - $html = parent::metaFieldsHtml($static); - - $html .= $this->taxCategoryFieldHtml($static); - - $html .= $this->shippingCategoryFieldHtml($static); - - return $html; - } - - /** - * @return ShippingCategory[] - * @throws InvalidConfigException - * @throws StoreNotFoundException - * @since 5.0.12 - */ - protected function availableShippingCategories(): array - { - return Plugin::getInstance()->getShippingCategories()->getAllShippingCategories($this->storeId)->all(); - } - - /** - * @param bool $static - * @return string - * @throws InvalidConfigException - * @since 5.0.12 - */ - protected function shippingCategoryFieldHtml(bool $static): string - { - $availableShippingCategories = $this->availableShippingCategories(); - $shippingCategory = collect($availableShippingCategories)->firstWhere('id', $this->shippingCategoryId) - ?? collect($availableShippingCategories)->first(); - - return CommerceCp::shippingCategoryFieldHtml([ - 'label' => Craft::t('commerce', 'Shipping Category'), - 'id' => 'shippingCategoryId', - 'name' => 'shippingCategoryId', - 'value' => $shippingCategory, - 'options' => $availableShippingCategories, - 'limit' => 1, - 'min' => 1, - 'disabled' => $static, - 'create' => false, - 'storeId' => $this->storeId, - ]); - } - - /** - * @return TaxCategory[] - * @throws InvalidConfigException - * @since 5.0.12 - */ - protected function availableTaxCategories(): array - { - return Plugin::getInstance()->getTaxCategories()->getAllTaxCategories(); - } - - /** - * @param bool $static - * @return string - * @throws InvalidConfigException - * @since 5.0.12 - */ - protected function taxCategoryFieldHtml(bool $static): string - { - $availableTaxCategories = $this->availableTaxCategories(); - $taxCategory = collect($availableTaxCategories)->firstWhere('id', $this->taxCategoryId) - ?? collect($availableTaxCategories)->first(); - - return CommerceCp::taxCategoryFieldHtml([ - 'label' => Craft::t('commerce', 'Tax Category'), - 'id' => 'taxCategoryId', - 'name' => 'taxCategoryId', - 'value' => $taxCategory, - 'options' => $availableTaxCategories, - 'limit' => 1, - 'min' => 1, - 'disabled' => $static, - 'create' => false, - ]); - } - - /** - * @inheritdoc - */ - protected function attributeHtml(string $attribute): string - { - $stock = ''; - if ($attribute == 'stock') { - if (!$this->inventoryTracked) { - $stock = '∞'; - } else { - $stock = $this->getStock(); - } - } - - $dimensions = []; - if ($attribute === 'dimensions') { - $dimensions = array_filter([ - $this->length, - $this->width, - $this->height, - ]); - } - - if ($attribute === 'priceView') { - $price = $this->basePriceAsCurrency; - if ($this->getBasePromotionalPrice() && $this->getBasePromotionalPrice() < $this->getBasePrice()) { - $price = Html::tag('del', $price, ['style' => 'opacity: .5']) . ' ' . $this->basePromotionalPriceAsCurrency; - } - - return $price; - } - - if ($attribute === 'availableForPurchase') { - if ($this->availableForPurchase) { - $icon = Html::tag('span', '', [ - 'class' => 'checkbox-icon', - 'role' => 'img', - 'title' => Craft::t('app', 'Enabled'), - 'aria' => [ - 'label' => Craft::t('app', 'Enabled'), - ], - ]); - return $icon . Html::tag('span', ' ' . Craft::t('commerce', 'Available for purchase'), [ - 'class' => 'card-only-label', - 'style' => 'display:none;', - ]) . Html::tag('style', '.card-content .card-only-label { display: inline !important; }'); - } - } - - return match ($attribute) { - 'sku' => (string)Html::encode($this->getSkuAsText()), - 'price' => $this->basePriceAsCurrency, - 'promotionalPrice' => $this->basePromotionalPrice !== null ? $this->basePromotionalPriceAsCurrency : '', - 'weight' => $this->weight !== null ? Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->weightUnits : '', - 'length' => $this->length !== null ? Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits : '', - 'width' => $this->width !== null ? Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits : '', - 'height' => $this->height !== null ? Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits : '', - 'minQty' => (string)$this->minQty, - 'maxQty' => (string)$this->maxQty, - 'stock' => $this::hasInventory() ? $stock : '', - 'dimensions' => !empty($dimensions) ? implode(' x ', $dimensions) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits : '', - default => parent::attributeHtml($attribute), - }; - } - - /** - * @inheritdoc - */ - protected static function defineTableAttributes(): array - { - return array_merge(parent::defineTableAttributes(), [ - 'title' => Craft::t('commerce', 'Title'), - 'sku' => Craft::t('commerce', 'SKU'), - 'price' => Craft::t('commerce', 'Price'), - 'promotionalPrice' => Craft::t('commerce', 'Promotional Price'), - 'width' => Craft::t('commerce', 'Width ({unit})', ['unit' => Plugin::getInstance()->getSettings()->dimensionUnits]), - 'height' => Craft::t('commerce', 'Height ({unit})', ['unit' => Plugin::getInstance()->getSettings()->dimensionUnits]), - 'length' => Craft::t('commerce', 'Length ({unit})', ['unit' => Plugin::getInstance()->getSettings()->dimensionUnits]), - 'weight' => Craft::t('commerce', 'Weight ({unit})', ['unit' => Plugin::getInstance()->getSettings()->weightUnits]), - 'stock' => Craft::t('commerce', 'Stock'), - 'minQty' => Craft::t('commerce', 'Min Qty'), - 'maxQty' => Craft::t('commerce', 'Max Qty'), - 'availableForPurchase' => Craft::t('commerce', 'Available for purchase'), - 'inventoryTracked' => Craft::t('commerce', 'Inventory Tracked'), - ]); - } - - /** - * @inheritdoc - */ - protected static function defineDefaultTableAttributes(string $source): array - { - return [ - 'sku', - 'price', - ]; - } - - /** - * @return bool - * @since 5.3.0 - */ - public static function hasInventory(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public static function attributePreviewHtml(array $attribute): mixed - { - return match ($attribute['value']) { - 'sku', 'priceView', 'dimensions', 'weight' => $attribute['placeholder'], - 'availableForPurchase', 'promotable' => Html::tag('span', '', [ - 'class' => 'checkbox-icon', - 'role' => 'img', - 'title' => $attribute['label'], - 'aria' => [ - 'label' => $attribute['label'], - ], - ]) . - Html::tag('span', $attribute['label'], [ - 'class' => 'checkbox-preview-label', - ]), - default => parent::attributePreviewHtml($attribute) - }; - } - - /** - * @inheritdoc - */ - protected static function defineDefaultCardAttributes(): array - { - return array_merge(parent::defineDefaultCardAttributes(), [ - 'sku', - 'priceView', - ]); - } - - /** - * @inheritdoc - */ - protected static function defineCardAttributes(): array - { - return array_merge(Element::defineCardAttributes(), [ - 'availableForPurchase' => [ - 'label' => Craft::t('commerce', 'Available for purchase'), - ], - 'basePrice' => [ - 'label' => Craft::t('commerce', 'Base Price'), - 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(123.99), - ], - 'basePromotionalPrice' => [ - 'label' => Craft::t('commerce', 'Base Promotional Price'), - 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(123.99), - ], - 'dimensions' => [ - 'label' => Craft::t('commerce', 'Dimensions'), - 'placeholder' => '1 x 2 x 3 ' . Plugin::getInstance()->getSettings()->dimensionUnits, - ], - 'priceView' => [ - 'label' => Craft::t('commerce', 'Price'), - 'placeholder' => Html::tag('del', '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(199.99), ['style' => 'opacity: .5']) . ' ¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(123.99), - ], - 'promotable' => [ - 'label' => Craft::t('commerce', 'Promotable'), - ], - 'sku' => [ - 'label' => Craft::t('commerce', 'SKU'), - 'placeholder' => Html::tag('code', 'SKU123'), - ], - 'stock' => [ - 'label' => Craft::t('commerce', 'Stock'), - 'placeholder' => 10, - ], - 'weight' => [ - 'label' => Craft::t('commerce', 'Weight'), - 'placeholder' => 123 . Plugin::getInstance()->getSettings()->weightUnits, - ], - ]); - } - - /** - * @inheritdoc - */ - protected static function defineSortOptions(): array - { - return [ - 'title' => Craft::t('commerce', 'Title'), - 'sku' => Craft::t('commerce', 'SKU'), - ]; - } - - /** - * @inheritdoc - */ - protected static function defineSearchableAttributes(): array - { - return [...parent::defineSearchableAttributes(), ...[ - 'description', - 'sku', - 'price', - 'width', - 'height', - 'length', - 'weight', - 'minQty', - 'maxQty', - ]]; - } - - /** - * Reloads any sales applicable to the purchasable. - * - * @param Order|null $order - * @return void - * @throws InvalidConfigException - * @since 5.3.0 - * @internal - */ - public function loadSales(?Order $order = null): void - { - // Default the sales and salePrice to the original price without any sales - $this->_sales = []; - - if ($this->getId()) { - $this->_sales = Plugin::getInstance()->getSales()->getSalesForPurchasable($this, $order); - $this->_salesPrice = Plugin::getInstance()->getSales()->getSalePriceForPurchasable($this, $order); - } - } -} diff --git a/src/base/PurchasableInterface.php b/src/base/PurchasableInterface.php deleted file mode 100644 index 6ab13e78f4..0000000000 --- a/src/base/PurchasableInterface.php +++ /dev/null @@ -1,146 +0,0 @@ - - * @since 2.0 - */ -interface PurchasableInterface extends ElementInterface -{ - /** - * Returns the store for the current instance of the purchasable. - * - * @return Store - */ - public function getStore(): Store; - - /** - * Returns the store ID for the current instance of the purchasable. - * - * @return int - */ - public function getStoreId(): int; - - - /** - * Returns the live price including catalog rule pricing. - * - * @return float|null decimal(14,4) - */ - public function getPrice(): ?float; - - /** - * Returns the live promotional price including the catalog rule pricing. - * - * @return float|null decimal(14,4) - * @since 5.0.0 - */ - public function getPromotionalPrice(): ?float; - - /** - * Returns the actual price the purchasable will be sold for. - * - * @return float|null decimal(14,4) - */ - public function getSalePrice(): ?float; - - /** - * Returns a unique code. Unique as per the commerce_purchasables table. - */ - public function getSku(): string; - - /** - * Returns your element's title or any additional descriptive information. - */ - public function getDescription(): string; - - /** - * Returns the purchasable's tax category. - */ - public function getTaxCategory(): TaxCategory; - - /** - * Returns the purchasable's shipping category. - */ - public function getShippingCategory(): ShippingCategory; - - /** - * Returns whether the purchasable is currently available for purchase. - */ - public function getIsAvailable(): bool; - - /** - * Populates the line item when this purchasable is found on it. Called when - * Purchasable is added to the cart and when the cart recalculates. - * This is your chance to modify the weight, height, width, length, price - * and saleAmount. This is called before any LineItems::EVENT_POPULATE_LINE_ITEM event listeners. - */ - public function populateLineItem(LineItem $lineItem): void; - - /** - * Returns an array of data that is serializable to json for storing a line - * item at time of adding to the cart or order. - */ - public function getSnapshot(): array; - - /** - * Returns any validation rules this purchasable required the line item to have. - * - * @param LineItem $lineItem - * @return array - */ - public function getLineItemRules(LineItem $lineItem): array; - - /** - * Runs any logic needed for this purchasable after it was on an order that was just completed (not when an order was paid, although paying an order will complete it). - * - * This is called for each line item the purchasable was contained within. - * - * @param Order $order - * @param LineItem $lineItem - */ - public function afterOrderComplete(Order $order, LineItem $lineItem): void; - - /** - * Returns whether this purchasable has free shipping. - */ - public function hasFreeShipping(): bool; - - /** - * Returns whether this purchasable can be shipped and whether it is counted in shipping calculations. - */ - public function getIsShippable(): bool; - - /** - * Returns whether this purchasable is exempt from taxes. - */ - public function getIsTaxable(): bool; - - /** - * Returns whether this purchasable can be subject to discounts or sales. - */ - public function getIsPromotable(): bool; - - /** - * Returns the source param used for knowing if a promotion category is related to this purchasable. - * - * @return mixed - */ - public function getPromotionRelationSource(): mixed; -} diff --git a/src/base/RequestResponseInterface.php b/src/base/RequestResponseInterface.php deleted file mode 100644 index 4ace9e58cf..0000000000 --- a/src/base/RequestResponseInterface.php +++ /dev/null @@ -1,74 +0,0 @@ - - * @since 2.0 - */ -interface RequestResponseInterface -{ - /** - * Returns whether the payment was successful. - */ - public function isSuccessful(): bool; - - /** - * Returns whether the payment is being processed by gateway. - */ - public function isProcessing(): bool; - - /** - * Returns whether the user needs to be redirected. - */ - public function isRedirect(): bool; - - /** - * Returns the redirect method to use, if any. - */ - public function getRedirectMethod(): string; - - /** - * Returns the redirect data provided. - */ - public function getRedirectData(): array; - - /** - * Returns the redirect URL to use, if any. - */ - public function getRedirectUrl(): string; - - /** - * Returns the transaction reference. - */ - public function getTransactionReference(): string; - - /** - * Returns the response code. - */ - public function getCode(): string; - - /** - * Returns the data. - * - * @return mixed - */ - public function getData(): mixed; - - /** - * Returns the gateway message. - */ - public function getMessage(): string; - - /** - * Perform the redirect. - */ - public function redirect(): void; -} diff --git a/src/base/ShippingMethod.php b/src/base/ShippingMethod.php deleted file mode 100644 index 319146eb5e..0000000000 --- a/src/base/ShippingMethod.php +++ /dev/null @@ -1,370 +0,0 @@ - - * @since 2.0 - * - * @property-read string $cpEditUrl - * @property-read array $shippingRules - * @property-read bool $isEnabled - * @property-read string $type - * @property ShippingMethodOrderCondition $orderCondition - * @property ShippingMethodCustomerCondition $customerCondition - */ -abstract class ShippingMethod extends BaseModel implements ShippingMethodInterface, HasStoreInterface -{ - use StoreTrait; - - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string|null Name - */ - public ?string $name = null; - - /** - * @var string|null Handle - */ - public ?string $handle = null; - - /** - * @var string|null Icon - */ - public ?string $icon = null; - - /** - * @var string|null Color - */ - public ?string $color = null; - - /** - * @var bool Enabled - */ - public bool $enabled = true; - - /** - * @var ShippingMethodOrderCondition|null - * @see getOrderCondition() - * @see setOrderCondition() - * @since 5.0.0 - */ - private ?ShippingMethodOrderCondition $_orderCondition = null; - - /** - * @var ShippingMethodCustomerCondition|null - * @see getCustomerCondition() - * @see setCustomerCondition() - * @since 5.4.0 - */ - private ?ShippingMethodCustomerCondition $_customerCondition = null; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateCreated = null; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateUpdated = null; - - /** - * @var array - */ - private array $_matchingRuleByOrderNumber = []; - - /** - * @inheritdoc - */ - public function getType(): string - { - throw new NotImplementedException(); - } - - /** - * @inheritdoc - */ - public function getId(): ?int - { - throw new NotImplementedException(); - } - - /** - * @inheritdoc - */ - public function getName(): string - { - throw new NotImplementedException(); - } - - /** - * @inheritdoc - */ - public function getHandle(): string - { - throw new NotImplementedException(); - } - - /** - * @inheritdoc - */ - public function getCpEditUrl(): string - { - throw new NotImplementedException(); - } - - /** - * @inheritdoc - */ - public function getShippingRules(): Collection - { - return collect(); - } - - /** - * @inheritdoc - */ - public function getIsEnabled(): bool - { - throw new NotImplementedException(); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [[ - 'id', - 'name', - 'handle', - 'icon', - 'color', - 'storeId', - 'orderCondition', - 'customerCondition', - 'enabled', - 'dateCreated', - 'dateUpdated', - ], 'safe']; - - return $rules; - } - - /** - * @param ShippingMethodOrderCondition|string|array|null $condition - * @return void - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function setOrderCondition(ShippingMethodOrderCondition|string|array|null $condition): void - { - if (empty($condition)) { - $this->_orderCondition = null; - return; - } - - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof ShippingMethodOrderCondition) { - $condition['class'] = ShippingMethodOrderCondition::class; - $condition = Craft::$app->getConditions()->createCondition($condition); - /** @var ShippingMethodOrderCondition $condition */ - } - $condition->forProjectConfig = false; - - $this->_orderCondition = $condition; - } - - /** - * @return ShippingMethodOrderCondition - * @since 5.0.0 - */ - public function getOrderCondition(): ShippingMethodOrderCondition - { - $condition = $this->_orderCondition ?? new ShippingMethodOrderCondition(); - $condition->mainTag = 'div'; - $condition->name = 'orderCondition'; - $condition->storeId = $this->storeId; - - return $condition; - } - - /** - * @param ShippingMethodCustomerCondition|string|array|null $condition - * @return void - * @throws InvalidConfigException - * @since 5.4.0 - */ - public function setCustomerCondition(ShippingMethodCustomerCondition|string|array|null $condition): void - { - if (empty($condition)) { - $this->_customerCondition = null; - return; - } - - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof ShippingMethodCustomerCondition) { - $condition['class'] = ShippingMethodCustomerCondition::class; - $condition = Craft::$app->getConditions()->createCondition($condition); - /** @var ShippingMethodCustomerCondition $condition */ - } - $condition->forProjectConfig = false; - - $this->_customerCondition = $condition; - } - - /** - * @return ShippingMethodCustomerCondition - * @since 5.0.0 - */ - public function getCustomerCondition(): ShippingMethodCustomerCondition - { - $condition = $this->_customerCondition ?? new ShippingMethodCustomerCondition(); - $condition->mainTag = 'div'; - $condition->name = 'customerCondition'; - - return $condition; - } - - /** - * @inheritdoc - */ - public function matchOrder(Order $order): bool - { - // Match the method's order condition first to see if we need to even check the rules. - if (!$this->getOrderCondition()->matchElement($order)) { - return false; - } - - $customer = $order->getCustomer(); - // If there is no customer on the order and there are customer conditions, we can't match. - if (!$customer && !empty($this->getCustomerCondition()->getConditionRules())) { - return false; - } - - // Match the method's customer condition. - if ($customer && !$this->getCustomerCondition()->matchElement($customer)) { - return false; - } - - if ($this->getMatchingShippingRule($order)) { - return true; - } - - return false; - } - - /** - * @inheritdoc - */ - public function getMatchingShippingRule(Order $order): ?ShippingRuleInterface - { - if (array_key_exists($order->number, $this->_matchingRuleByOrderNumber)) { - return $this->_matchingRuleByOrderNumber[$order->number]; - } - - foreach ($this->getShippingRules() as $rule) { - /** @var ShippingRuleInterface $rule */ - if ($rule->matchOrder($order)) { - return $this->_matchingRuleByOrderNumber[$order->number] = $rule; - } - } - - return $this->_matchingRuleByOrderNumber[$order->number] = null; - } - - /** - * @return void - */ - public function clearMatchingShippingRuleCache(): void - { - $this->_matchingRuleByOrderNumber = []; - } - - public function getPriceForOrder(Order $order): float - { - $shippingRule = $this->getMatchingShippingRule($order); - $lineItems = $order->getLineItems(); - - if (!$shippingRule) { - return 0; - } - - $nonShippableItems = []; - - foreach ($lineItems as $item) { - if ($item->getIsShippable()) { - continue; - } - - $nonShippableItems[$item->id] = $item->id; - } - - // Are all line items non shippable items? No shipping cost. - if (count($lineItems) == count($nonShippableItems)) { - return 0; - } - - $amount = $shippingRule->getBaseRate(); - - foreach ($order->getLineItems() as $item) { - if ($item->getHasFreeShipping()) { - continue; - } - - if (!$item->getIsShippable()) { - continue; - } - - $percentageRate = $shippingRule->getPercentageRate($item->shippingCategoryId); - $perItemRate = $shippingRule->getPerItemRate($item->shippingCategoryId); - $weightRate = $shippingRule->getWeightRate($item->shippingCategoryId); - - $percentageAmount = $item->getSubtotal() * $percentageRate; - $perItemAmount = $item->qty * $perItemRate; - $weightAmount = ($item->weight * $item->qty) * $weightRate; - - $amount += ($percentageAmount + $perItemAmount + $weightAmount); - } - - $amount = max($amount, $shippingRule->getMinRate()); - - if ($shippingRule->getMaxRate()) { - $amount = min($amount, $shippingRule->getMaxRate()); - } - - return $amount; - } -} diff --git a/src/base/ShippingMethodInterface.php b/src/base/ShippingMethodInterface.php deleted file mode 100644 index ceec49d11d..0000000000 --- a/src/base/ShippingMethodInterface.php +++ /dev/null @@ -1,73 +0,0 @@ - - * @since 2.0 - */ -interface ShippingMethodInterface -{ - /** - * Returns the type of Shipping Method. This might be the name of the plugin or provider. - * The core shipping methods have type: `Custom`. This is shown in the control panel only. - */ - public function getType(): string; - - /** - * Returns the ID of this Shipping Method, if it is managed by Craft Commerce. - * - * @return int|null The shipping method ID, or null if it is not managed by Craft Commerce - */ - public function getId(): ?int; - - /** - * Returns the name of this Shipping Method as displayed to the customer and in the control panel. - */ - public function getName(): string; - - /** - * Returns the unique handle of this Shipping Method. - */ - public function getHandle(): string; - - /** - * Returns the control panel URL to manage this method and its rules. - * An empty string will result in no link. - */ - public function getCpEditUrl(): string; - - /** - * Returns an array of rules that meet the `ShippingRules` interface. - * - * @return Collection The array of ShippingRules - */ - public function getShippingRules(): Collection; - - /** - * Returns whether this shipping method is enabled for listing and selection by customers. - */ - public function getIsEnabled(): bool; - - public function getPriceForOrder(Order $order): float; - - /** - * The first matching shipping rule for this shipping method - */ - public function getMatchingShippingRule(Order $order): ?ShippingRuleInterface; - - /** - * Is this shipping method available to the order? - */ - public function matchOrder(Order $order): bool; -} diff --git a/src/base/ShippingRuleInterface.php b/src/base/ShippingRuleInterface.php deleted file mode 100644 index 94c6dcbcc5..0000000000 --- a/src/base/ShippingRuleInterface.php +++ /dev/null @@ -1,86 +0,0 @@ - - * @since 2.0 - */ -interface ShippingRuleInterface -{ - /** - * Returns whether this rule a match on the order. If false is returned, the shipping engine tries the next rule. - */ - public function matchOrder(Order $order): bool; - - /** - * Returns whether this shipping rule is enabled for listing and selection. - */ - public function getIsEnabled(): bool; - - /** - * Returns this data as json on the order's shipping adjustment. - * - * @return mixed - */ - public function getOptions(): mixed; - - /** - * Returns the percentage rate that is multiplied per line item subtotal. - * Zero will not make any changes. - * - * @param int|null $shippingCategoryId the shipping category ID for the rate requested. A null category ID should use the default shipping category set up in Craft Commerce. - */ - public function getPercentageRate(?int $shippingCategoryId): float; - - /** - * Returns the flat rate that is multiplied per qty. - * Zero will not make any changes. - * - * @param int|null $shippingCategoryId the shipping category ID for the rate requested. A null category ID should use the default shipping category set up in Craft Commerce. - */ - public function getPerItemRate(?int $shippingCategoryId): float; - - /** - * Returns the rate that is multiplied by the line item's weight. - * Zero will not make any changes. - * - * @param int|null $shippingCategoryId the shipping category ID for the rate requested. A null category ID should use the default shipping category set up in Craft Commerce. - */ - public function getWeightRate(?int $shippingCategoryId): float; - - /** - * Returns a base shipping cost. This is added at the order level. - * Zero will not make any changes. - */ - public function getBaseRate(): float; - - /** - * Returns a max cost this rule should ever apply. - * If the total of your rates as applied to the order are greater than this, an order level adjustment is made to reduce the shipping amount on the order. - */ - public function getMaxRate(): float; - - /** - * Returns a min cost this rule should have applied. - * If the total of your rates as applied to the order are less than this, the baseShippingCost - * on the order is modified to meet this min rate. - * Zero will not make any changes. - */ - public function getMinRate(): float; - - /** - * Returns a description of the rates applied by this rule; - * Zero will not make any changes. - */ - public function getDescription(): string; -} diff --git a/src/base/Stat.php b/src/base/Stat.php deleted file mode 100644 index 6bc71ea77a..0000000000 --- a/src/base/Stat.php +++ /dev/null @@ -1,539 +0,0 @@ - - * @since 3.0 - */ -abstract class Stat implements StatInterface, HasStoreInterface -{ - use StatTrait; - use StoreTrait; - - /** - * Stat constructor. - * - * @param string|null $dateRange - * @param DateTime|bool|null $startDate - * @param DateTime|bool|null $endDate - * @throws \Exception - */ - public function __construct(string $dateRange = null, mixed $startDate = null, mixed $endDate = null, ?int $storeId = null) - { - $user = Craft::$app->getUser()->getIdentity(); - if ($user) { - $this->weekStartDay = $user->getPreference('weekStartDay') ?? $this->weekStartDay; - } - - $this->dateRange = $dateRange ?? $this->dateRange; - if ($this->dateRange && $this->dateRange != self::DATE_RANGE_CUSTOM) { - $this->_setDates(); - } else { - $this->setStartDate($startDate); - $this->setEndDate($endDate); - } - - $this->storeId = $storeId ?? $this->storeId; - } - - /** - * @inheritdoc - */ - public function getHandle(): string - { - return $this->_handle; - } - - /** - * @return mixed - * @throws Exception - */ - public function get(): mixed - { - $this->_setDates(); - - if (!$this->cache) { - $data = $this->getData(); - return $this->prepareData($data); - } - - $this->_cacheKey = $this->_getCacheKey(); - - if (!$this->_cacheKey) { - throw new Exception('Unable to create cache key.'); - } - - $data = Craft::$app->getCache()->get($this->_cacheKey); - - if (!$data) { - $data = $this->getData(); - Craft::$app->getCache()->set($this->_cacheKey, $data, $this->cacheDuration); - } - - return $this->prepareData($data); - } - - /** - * @inheritdoc - */ - public function prepareData($data): mixed - { - return $data; - } - - /** - * @inheritdoc - */ - public function setStartDate(?DateTime $date): void - { - if (!$date) { - $this->_startDate = $this->_getFirstCompletedOrderDate(); - } else { - $this->_startDate = $date; - } - } - - /** - * @inheritdoc - */ - public function setEndDate(?DateTime $date): void - { - if (!$date) { - $this->_endDate = new DateTime(); - } else { - $this->_endDate = $date; - } - } - - /** - * @inheritdoc - */ - public function getStartDate(): mixed - { - return $this->_startDate; - } - - /** - * @inheritdoc - */ - public function getEndDate(): mixed - { - return $this->_endDate; - } - - /** - * @inheritdoc - */ - public function getDateRangeWording(): string - { - switch ($this->dateRange) { - case self::DATE_RANGE_ALL: - { - return Craft::t('commerce', 'All'); - } - case self::DATE_RANGE_TODAY: - { - return Craft::t('commerce', 'Today'); - } - case self::DATE_RANGE_THISWEEK: - { - return Craft::t('commerce', 'This week'); - } - case self::DATE_RANGE_THISMONTH: - { - return Craft::t('commerce', 'This month'); - } - case self::DATE_RANGE_THISYEAR: - { - return Craft::t('commerce', 'This year'); - } - case self::DATE_RANGE_PAST7DAYS: - { - return Craft::t('commerce', 'Past {num} days', ['num' => 7]); - } - case self::DATE_RANGE_PAST30DAYS: - { - return Craft::t('commerce', 'Past {num} days', ['num' => 30]); - } - case self::DATE_RANGE_PAST90DAYS: - { - return Craft::t('commerce', 'Past {num} days', ['num' => 90]); - } - case self::DATE_RANGE_PASTYEAR: - { - return Craft::t('commerce', 'Past year'); - } - case self::DATE_RANGE_CUSTOM: - { - if (!$this->_startDate || !$this->_endDate) { - return ''; - } - - $startDate = Craft::$app->getFormatter()->asDate($this->_startDate, Locale::LENGTH_SHORT); - $endDate = Craft::$app->getFormatter()->asDate($this->_endDate, Locale::LENGTH_SHORT); - - if (Craft::$app->getLocale()->getOrientation() == 'rtl') { - return $endDate . ' - ' . $startDate; - } - - return $startDate . ' - ' . $endDate; - } - default: - { - return ''; - } - } - } - - /** - * @throws Exception - */ - private function _setDates(): void - { - if (!$this->dateRange) { - throw new Exception('A date range string must be specified to set stat dates.'); - } - - if ($this->_startDate && $this->_endDate) { - return; - } - - if ($this->dateRange != self::DATE_RANGE_CUSTOM) { - $this->setStartDate($this->_getStartDate($this->dateRange)); - $this->setEndDate($this->_getEndDate($this->dateRange)); - } - } - - /** - * Based on the date range return the start date. - * - * @throws \Exception - */ - private function _getStartDate(string $dateRange): bool|DateTime - { - if ($dateRange == self::DATE_RANGE_CUSTOM) { - return false; - } - - $date = new DateTime(); - switch ($dateRange) { - case self::DATE_RANGE_ALL: - { - $date = $this->_getFirstCompletedOrderDate(); - break; - } - case self::DATE_RANGE_THISMONTH: - { - $date = DateTimeHelper::toDateTime(strtotime('first day of this month')); - break; - } - case self::DATE_RANGE_THISWEEK: - { - if (date('l') != self::START_DAY_INT_TO_DAY[$this->weekStartDay]) { - $date = DateTimeHelper::toDateTime(strtotime('last ' . self::START_DAY_INT_TO_DAY[$this->weekStartDay])); - } - break; - } - case self::DATE_RANGE_THISYEAR: - { - $date->setDate((int)$date->format('Y'), 1, 1); - break; - } - case self::DATE_RANGE_PAST7DAYS: - case self::DATE_RANGE_PAST30DAYS: - case self::DATE_RANGE_PAST90DAYS: - { - $number = str_replace(['past', 'Days'], '', $dateRange); - // Minus one so we include today as a "past day" - $number--; - $date = $this->_getEndDate($dateRange); - $interval = new DateInterval('P' . $number . 'D'); - $date->sub($interval); - break; - } - case self::DATE_RANGE_PASTYEAR: - { - $date = $this->_getEndDate($dateRange); - $interval = new DateInterval('P1Y'); - $date->sub($interval); - $date->modify('first day of next month'); - break; - } - } - - $date->setTime(0, 0); - return $date; - } - - /** - * @throws \Exception - */ - private function _getFirstCompletedOrderDate(): DateTime|false - { - $firstCompletedOrder = (new Query()) - ->select(['dateOrdered']) - ->from(Table::ORDERS) - ->where(['isCompleted' => true]) - ->orderBy('dateOrdered ASC') - ->scalar(); - - return $firstCompletedOrder ? DateTimeHelper::toDateTime($firstCompletedOrder) : new DateTime(); - } - - /** - * Based on the date range return the end date. - * - * @throws \Exception - */ - private function _getEndDate(string $dateRange): bool|DateTime - { - if ($dateRange == self::DATE_RANGE_CUSTOM) { - return false; - } - - $date = new DateTime(); - switch ($dateRange) { - case self::DATE_RANGE_THISMONTH: - { - $date = DateTimeHelper::toDateTime(strtotime('last day of this month')); - break; - } - case self::DATE_RANGE_THISWEEK: - { - $endDayOfWeek = self::START_DAY_INT_TO_END_DAY[$this->weekStartDay]; - if (date('l') != $endDayOfWeek) { - $date = DateTimeHelper::toDateTime(strtotime('next ' . $endDayOfWeek)); - } - break; - } - } - - $date->setTime(23, 59, 59); - return $date; - } - - /** - * Generate cache key. - * - * @throws \Exception - */ - private function _getCacheKey(): string - { - $orderLastUpdatedString = 'never'; - - $orderLastUpdated = $this->_createStatQuery() - ->select(['dateUpdated']) - ->orderBy('dateUpdated DESC') - ->scalar(); - - if ($orderLastUpdated) { - $orderLastUpdated = DateTimeHelper::toDateTime($orderLastUpdated); - $orderLastUpdatedString = $orderLastUpdated->format('Y-m-d-H-i-s'); - } - - return implode('-', [$this->getHandle(), $this->dateRange, $this->_startDate->format('U'), $this->_endDate->format('U'), $orderLastUpdatedString]); - } - - public function getChartQueryOptionsByInterval(string $interval): ?array - { - if (Craft::$app->getDb()->getIsMysql()) { - // The fallback if timezone can't happen in sql is simply just extract the information from the UTC date stored in `dateOrdered`. - $timezoneConversionSql = "[[dateOrdered]]"; - - if (Db::supportsTimeZones()) { - $timezoneConversionSql = "CONVERT_TZ([[dateOrdered]], 'UTC', '" . Craft::$app->getTimeZone() . "')"; - } else { - Craft::getLogger()->log('For accurate Commerce statistics it is recommend to make sure you have the timezones table populated. https://craftcms.com/knowledge-base/populating-mysql-mariadb-timezone-tables', Craft::getLogger()::LEVEL_WARNING, 'commerce'); - } - } else { - $timezoneConversionSql = "(([[dateOrdered]] AT TIME ZONE 'UTC') AT TIME ZONE '" . Craft::$app->getTimeZone() . "')"; - } - - switch ($interval) { - case 'month': - { - $sqlExpression = "CONCAT(EXTRACT(YEAR FROM " . $timezoneConversionSql . "), '-', EXTRACT(MONTH FROM " . $timezoneConversionSql . "))"; - return [ - 'interval' => 'P1M', - 'dateKeyFormat' => 'Y-n', - 'dateKey' => $sqlExpression, - 'groupBy' => $sqlExpression, - 'orderBy' => $sqlExpression . ' ASC', - ]; - } - case 'day': - { - $sqlExpression = "DATE(" . $timezoneConversionSql . ")"; - return [ - 'interval' => 'P1D', - 'dateKeyFormat' => 'Y-m-d', - 'dateKey' => $sqlExpression, - 'groupBy' => $sqlExpression, - 'orderBy' => $sqlExpression, - ]; - } - } - - return null; - } - - public function getDateRangeInterval(): string - { - if ($this->dateRange == self::DATE_RANGE_CUSTOM) { - $interval = date_diff($this->_startDate, $this->_endDate); - return ($interval->days > 90) ? 'month' : 'day'; - } - - return self::DATE_RANGE_INTERVAL[$this->dateRange] ?? 'day'; - } - - /** - * @inheritdoc - * @throws InvalidConfigException - */ - public function getOrderStatuses(): ?array - { - if (empty($this->_orderStatuses)) { - return $this->_orderStatuses; - } - - $allOrderStatuses = Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses(); - foreach ($this->_orderStatuses as $key => $orderStatus) { - if ($orderStatus instanceof OrderStatus) { - continue; - } - - if (!is_string($orderStatus)) { - unset($this->_orderStatuses[$key]); - continue; - } - - $orderStatus = ArrayHelper::firstWhere($allOrderStatuses, fn(OrderStatus $os) => $orderStatus === $os->handle || $orderStatus === $os->uid); - if (!$orderStatus) { - unset($this->_orderStatuses[$key]); - continue; - } - - $this->_orderStatuses[$key] = $orderStatus; - } - - return $this->_orderStatuses; - } - - /** - * @inheritdoc - */ - public function setOrderStatuses(?array $orderStatuses): void - { - $this->_orderStatuses = $orderStatuses; - } - - /** - * Generate base stat query - */ - protected function _createStatQuery(): \yii\db\Query - { - // Make sure the end time is always the last point on that day. - if ($this->_endDate instanceof DateTime) { - $this->_endDate->setTime(23, 59, 59); - } - - if ($this->storeId === null) { - throw new InvalidConfigException('The store ID has not been set.'); - } - - $query = (new Query()) - ->from(Table::ORDERS . ' orders') - ->innerJoin('{{%elements}} elements', '[[elements.id]] = [[orders.id]]') - ->where(['orders.storeId' => $this->storeId]) - ->andWhere(['>=', 'dateOrdered', Db::prepareDateForDb($this->_startDate)]) - ->andWhere(['<=', 'dateOrdered', Db::prepareDateForDb($this->_endDate)]) - ->andWhere(['isCompleted' => true]) - ->andWhere(['elements.dateDeleted' => null]); - - $orderStatuses = $this->getOrderStatuses(); - if (!empty($orderStatuses)) { - $query->innerJoin(Table::ORDERSTATUSES . ' os', '[[orders.orderStatusId]] = [[os.id]]'); - $orderStatusIds = ArrayHelper::getColumn($orderStatuses, 'id'); - $query->andWhere(['os.id' => $orderStatusIds]); - } - - return $query; - } - - /** - * @param array $select - * @param array $resultsDefaults - * @param null|Query $query - * @return array|null - * @throws \Exception - */ - protected function _createChartQuery(array $select = [], array $resultsDefaults = [], ?Query $query = null): ?array - { - // Allow the passing in of a custom query in case we need to add extra logic - $query = $query ?: $this->_createStatQuery(); - - $defaults = []; - $dateRangeInterval = $this->getDateRangeInterval(); - $options = $this->getChartQueryOptionsByInterval($dateRangeInterval); - - if (!$options) { - return null; - } - - $dateKeyDate = DateTimeHelper::toDateTime($this->getStartDate()->format('Y-m-d'), true); - $endDate = $this->getEndDate(); - while ($dateKeyDate <= $endDate) { - // If we are looking monthly make sure we get every month by using the 1st day - if ($dateRangeInterval == 'month') { - $dateKeyDate->setDate((int)$dateKeyDate->format('Y'), (int)$dateKeyDate->format('n'), 1); - } - - $key = $dateKeyDate->format($options['dateKeyFormat']); - - // Setup default results values - $tmp = $resultsDefaults; - $tmp['datekey'] = $key; - - $defaults[$key] = $tmp; - $dateKeyDate->add(new DateInterval($options['interval'])); - } - - // Add defaults to select - $select[] = new Expression($options['dateKey'] . ' as datekey'); - $results = $query - ->select($select) - ->groupBy(new Expression($options['groupBy'])) - ->orderBy(new Expression($options['orderBy'])) - ->indexBy('datekey') - ->all(); - - $return = array_replace($defaults, $results); - ksort($return, SORT_NATURAL); - - return $return; - } -} diff --git a/src/base/StatInterface.php b/src/base/StatInterface.php deleted file mode 100644 index 3bdc21211f..0000000000 --- a/src/base/StatInterface.php +++ /dev/null @@ -1,115 +0,0 @@ - - * @since 3.0 - * @todo remove ignore: https://github.com/phpstan/phpstan/issues/6778 - * @phpstan-ignore-next-line - * @mixin StatTrait - */ -interface StatInterface -{ - public const DATE_RANGE_ALL = 'all'; - public const DATE_RANGE_TODAY = 'today'; - public const DATE_RANGE_THISWEEK = 'thisWeek'; - public const DATE_RANGE_THISMONTH = 'thisMonth'; - public const DATE_RANGE_THISYEAR = 'thisYear'; - public const DATE_RANGE_PAST7DAYS = 'past7Days'; - public const DATE_RANGE_PAST30DAYS = 'past30Days'; - public const DATE_RANGE_PAST90DAYS = 'past90Days'; - public const DATE_RANGE_PASTYEAR = 'pastYear'; - public const DATE_RANGE_CUSTOM = 'custom'; - - public const START_DAY_INT_TO_DAY = [ - 0 => 'Sunday', - 1 => 'Monday', - 2 => 'Tuesday', - 3 => 'Wednesday', - 4 => 'Thursday', - 5 => 'Friday', - 6 => 'Saturday', - ]; - - public const START_DAY_INT_TO_END_DAY = [ - 0 => 'Saturday', - 1 => 'Sunday', - 2 => 'Monday', - 3 => 'Tuesday', - 4 => 'Wednesday', - 5 => 'Thursday', - 6 => 'Friday', - ]; - - public const DATE_RANGE_INTERVAL = [ - self::DATE_RANGE_TODAY => 'day', - self::DATE_RANGE_THISWEEK => 'day', - self::DATE_RANGE_THISMONTH => 'day', - self::DATE_RANGE_THISYEAR => 'month', - self::DATE_RANGE_PAST7DAYS => 'day', - self::DATE_RANGE_PAST30DAYS => 'day', - self::DATE_RANGE_PAST90DAYS => 'day', - self::DATE_RANGE_PASTYEAR => 'month', - self::DATE_RANGE_ALL => 'month', - ]; - - public function getHandle(): string; - - /** - * @return mixed - */ - public function get(): mixed; - - /** - * @return mixed - */ - public function getData(): mixed; - - /** - * @return mixed - */ - public function getStartDate(): mixed; - - /** - * @return mixed - */ - public function getEndDate(): mixed; - - public function setStartDate(?DateTime $date): void; - - public function setEndDate(?DateTime $date): void; - - /** - * @param $data - * @return mixed - */ - public function prepareData($data): mixed; - - public function getDateRangeWording(): string; - - /** - * @return array|null - * @since 4.2.0 - */ - public function getOrderStatuses(): ?array; - - /** - * Set order statuses to limit stat query. Accepts array of `OrderStatus` models, handle strings or uid strings. - * - * @param OrderStatus[]|string[]|null $orderStatuses - * @return void - * @since 4.2.0 - */ - public function setOrderStatuses(?array $orderStatuses): void; -} diff --git a/src/base/StatWidgetTrait.php b/src/base/StatWidgetTrait.php deleted file mode 100644 index 3e7b5db5b7..0000000000 --- a/src/base/StatWidgetTrait.php +++ /dev/null @@ -1,60 +0,0 @@ - - * @since 4.2.0 - */ -trait StatWidgetTrait -{ - use StoreTrait; - - /** - * @var int|DateTime|null - */ - public mixed $startDate = null; - - /** - * @var int|DateTime|null - */ - public mixed $endDate = null; - - /** - * @var string|null - */ - public ?string $dateRange = null; - - /** - * @var array|null - */ - public ?array $orderStatuses = null; - - /** - * @return array - */ - public function getOrderStatusOptions(): array - { - $orderStatuses = []; - - foreach (Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses($this->storeId) as $orderStatus) { - $orderStatuses[$orderStatus->uid] = [ - 'label' => $orderStatus->name, - 'value' => $orderStatus->uid, - 'data' => ['data' => ['status' => $orderStatus->color]], - ]; - } - - return $orderStatuses; - } -} diff --git a/src/base/StoreRecordTrait.php b/src/base/StoreRecordTrait.php deleted file mode 100644 index ec754cc052..0000000000 --- a/src/base/StoreRecordTrait.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 5.0.0 - */ -trait StoreRecordTrait -{ - /** - * @return ActiveQueryInterface - */ - public function getStore(): ActiveQueryInterface - { - /** @var ActiveRecord $this */ - return $this->hasOne(Store::class, ['id' => 'storeId']); - } -} diff --git a/src/base/StoreTrait.php b/src/base/StoreTrait.php deleted file mode 100644 index cc92f91216..0000000000 --- a/src/base/StoreTrait.php +++ /dev/null @@ -1,53 +0,0 @@ - - * @since 5.0.0 - */ -trait StoreTrait -{ - /** - * @var int|null Store ID - */ - public ?int $storeId = null; - - /** - * @return Store - * @throws InvalidConfigException - */ - public function getStore(): Store - { - // If the store ID is not set check to see if the class has a `siteId` property and use that. - if ($this->storeId === null && !$this instanceof Element) { - throw new InvalidConfigException('Store ID is required'); - } - - if ($this->storeId === null && $this instanceof Element) { - $store = Plugin::getInstance()->getStores()->getStoreBySiteId($this->siteId); - if (!$store) { - throw new InvalidConfigException('Unable to locate store for site ID: ' . $this->siteId); - } - $this->storeId = $store->id; - } - - if (!$store = Plugin::getInstance()->getStores()->getStoreById($this->storeId)) { - throw new InvalidConfigException('Invalid store ID: ' . $this->storeId); - } - - return $store; - } -} diff --git a/src/base/SubscriptionGateway.php b/src/base/SubscriptionGateway.php deleted file mode 100644 index 775eb3b38a..0000000000 --- a/src/base/SubscriptionGateway.php +++ /dev/null @@ -1,86 +0,0 @@ - - * @since 2.0 - * - * @property-read Plan $planModel - * @property-read CancelSubscriptionForm $cancelSubscriptionFormModel - * @property-read SwitchPlansForm $switchPlansFormModel - * @property-read SubscriptionForm $subscriptionFormModel - */ -abstract class SubscriptionGateway extends Gateway implements SubscriptionGatewayInterface -{ - /** - * Returns the cancel subscription form HTML - * - * @param Subscription $subscription the subscription to cancel - */ - abstract public function getCancelSubscriptionFormHtml(Subscription $subscription): string; - - /** - * Returns the cancel subscription form model - */ - abstract public function getCancelSubscriptionFormModel(): CancelSubscriptionForm; - - /** - * Returns the subscription plan settings HTML - * - * @param array $params - * @return string|null - */ - abstract public function getPlanSettingsHtml(array $params = []): ?string; - - /** - * Returns the subscription plan model. - */ - abstract public function getPlanModel(): Plan; - - /** - * Returns the subscription form model - */ - abstract public function getSubscriptionFormModel(): SubscriptionForm; - - /** - * Returns the html form to use when switching between two plans - */ - public function getSwitchPlansFormHtml(PlanInterface $originalPlan, PlanInterface $targetPlan): string - { - return ''; - } - - /** - * Returns the form model used for switching plans. - */ - abstract public function getSwitchPlansFormModel(): SwitchPlansForm; - - /** - * @inheritdoc - */ - public function reactivateSubscription(Subscription $subscription): SubscriptionResponseInterface - { - throw new NotImplementedException('This gateway has not implemented subscription reactivation'); - } - - /** - * @inheritdoc - */ - public function refreshPaymentHistory(Subscription $subscription): void - { - } -} diff --git a/src/base/SubscriptionGatewayInterface.php b/src/base/SubscriptionGatewayInterface.php deleted file mode 100644 index 8a56852098..0000000000 --- a/src/base/SubscriptionGatewayInterface.php +++ /dev/null @@ -1,116 +0,0 @@ - - * @since 2.0 - */ -interface SubscriptionGatewayInterface extends GatewayInterface -{ - /** - * Cancels a subscription. - * - * @param Subscription $subscription the subscription to cancel - * @param CancelSubscriptionForm $parameters additional parameters to use - * @throws SubscriptionException for all subscription-related errors. - */ - public function cancelSubscription(Subscription $subscription, CancelSubscriptionForm $parameters): SubscriptionResponseInterface; - - /** - * Returns the next payment amount for a subscription, taking into account all discounts. - * - * @return string next payment amount with currency code - */ - public function getNextPaymentAmount(Subscription $subscription): string; - - /** - * Returns a list of subscription payments for a given subscription. - * - * @return SubscriptionPayment[] - */ - public function getSubscriptionPayments(Subscription $subscription): array; - - /** - * Refresh the subscription payment history for a given subscription. - */ - public function refreshPaymentHistory(Subscription $subscription); - - /** - * Returns a subscription plan by its reference - */ - public function getSubscriptionPlanByReference(string $reference): string; - - /** - * Returns all subscription plans as array containing hashes with `reference` and `name` as keys. - */ - public function getSubscriptionPlans(): array; - - /** - * Reactivates a subscription. - * - * @param Subscription $subscription the canceled subscription to reactivate - * @throws NotImplementedException - */ - public function reactivateSubscription(Subscription $subscription): SubscriptionResponseInterface; - - /** - * Subscribe user to a plan. - * - * @param User $user the Craft user to subscribe - * @param Plan $plan the plan to subscribe to - * @param SubscriptionForm $parameters additional parameters to use - * @throws SubscriptionException for all subscription-related errors. - */ - public function subscribe(User $user, Plan $plan, SubscriptionForm $parameters): SubscriptionResponseInterface; - - /** - * Switch a subscription to a different subscription plan. - * - * @param Subscription $subscription the subscription to modify - * @param Plan $plan the plan to change the subscription to - * @param SwitchPlansForm $parameters additional parameters to use - */ - public function switchSubscriptionPlan(Subscription $subscription, Plan $plan, SwitchPlansForm $parameters): SubscriptionResponseInterface; - - /** - * Returns whether this gateway supports reactivating subscriptions. - */ - public function supportsReactivation(): bool; - - /** - * Returns whether this gateway supports switching plans. - */ - public function supportsPlanSwitch(): bool; - - /** - * Returns whether this subscription has billing issues. - */ - public function getHasBillingIssues(Subscription $subscription): bool; - - /** - * Return a description of the billing issue (if any) with this subscription. - */ - public function getBillingIssueDescription(Subscription $subscription): string; - - /** - * Return the form HTML for resolving the billing issue (if any) with this subscription. - */ - public function getBillingIssueResolveFormHtml(Subscription $subscription): string; -} diff --git a/src/base/SubscriptionResponseInterface.php b/src/base/SubscriptionResponseInterface.php deleted file mode 100644 index 82a6b348c1..0000000000 --- a/src/base/SubscriptionResponseInterface.php +++ /dev/null @@ -1,56 +0,0 @@ - - * @since 2.0 - */ -interface SubscriptionResponseInterface -{ - /** - * Returns the response data. - * - * @return mixed - */ - public function getData(): mixed; - - /** - * Returns the subscription reference. - */ - public function getReference(): string; - - /** - * Returns the number of trial days on the subscription. - */ - public function getTrialDays(): int; - - /** - * Returns the time of next payment. - */ - public function getNextPaymentDate(): DateTime; - - /** - * Returns whether the subscription is canceled. - */ - public function isCanceled(): bool; - - /** - * Returns whether the subscription is scheduled to be canceled. - */ - public function isScheduledForCancellation(): bool; - - /** - * Whether the subscription is unpaid. - */ - public function isInactive(): bool; -} diff --git a/src/base/TaxEngineInterface.php b/src/base/TaxEngineInterface.php deleted file mode 100644 index 2525b47607..0000000000 --- a/src/base/TaxEngineInterface.php +++ /dev/null @@ -1,112 +0,0 @@ - - * @since 3.1 - */ -interface TaxEngineInterface extends ComponentInterface -{ - /** - * Return class name for the Adjuster to be used for tax - */ - public function taxAdjusterClass(): string; - - /** - * Whether Craft Commerce should show the tax categories interface - * and allow tax categories to be edited. - */ - public function viewTaxCategories(): bool; - - /** - * Whether Craft Commerce should allow tax categories to be created by users. - * will not be called if viewTaxCategories is returned as false; - */ - public function createTaxCategories(): bool; - - /** - * Whether Craft Commerce should allow tax categories to be edited. - * will not be called if viewTaxCategories is returned as false; - */ - public function editTaxCategories(): bool; - - /** - * Whether Craft Commerce should allow tax categories to be deleted. - * will not be called if viewTaxCategories is returned as false; - */ - public function deleteTaxCategories(): bool; - - /** - * Any action html to be added to the tax categories index header - */ - public function taxCategoryActionHtml(): string; - - /** - * Whether Craft Commerce should show the tax zones interface - * and allow tax zones to be edited. - */ - public function viewTaxZones(): bool; - - /** - * Whether Craft Commerce should allow tax zones to be created by users. - * will not be called if viewTaxZones is returned as false; - */ - public function createTaxZones(): bool; - - /** - * Whether Craft Commerce should allow tax zones to be edited. - * will not be called if viewTaxZones is returned as false; - */ - public function editTaxZones(): bool; - - /** - * Whether Craft Commerce should allow tax zones to be deleted. - * will not be called if viewTaxZones is returned as false; - */ - public function deleteTaxZones(): bool; - - /** - * Any action html to be added to the tax zones index header - */ - public function taxZoneActionHtml(): string; - - - /** - * Whether Craft Commerce should show the tax rates interface - * and allow tax rates to be edited. - */ - public function viewTaxRates(): bool; - - /** - * Whether Craft Commerce should allow tax rates to be created by users. - * will not be called if viewTaxRates is returned as false; - */ - public function createTaxRates(): bool; - - /** - * Whether Craft Commerce should allow tax rates to be edited. - * will not be called if viewTaxRates is returned as false; - */ - public function editTaxRates(): bool; - - /** - * Whether Craft Commerce should allow tax rates to be deleted. - * will not be called if viewTaxRates is returned as false; - */ - public function deleteTaxRates(): bool; - - /** - * Any action html to be added to the tax rates index header - */ - public function taxRateActionHtml(): string; - - /** - * The tax subNav items - */ - public function cpTaxNavSubItems(): array; -} diff --git a/src/base/TaxIdValidatorInterface.php b/src/base/TaxIdValidatorInterface.php deleted file mode 100644 index f51b732e2e..0000000000 --- a/src/base/TaxIdValidatorInterface.php +++ /dev/null @@ -1,61 +0,0 @@ - - * @since 5.3.0 - */ -interface TaxIdValidatorInterface -{ - /** - * The display name of this tax ID type. - * - * @return string - * @since 5.3.0 - */ - public static function displayName(): string; - - /** - * Tests if the ID looks generally correct. This would usually be something like a regex check. - * - * @param string $idNumber - * @return bool - * @since 5.3.0 - */ - public function validateFormat(string $idNumber): bool; - - /** - * Tests if the ID exists as valid in the country's tax system. This would usually be an API call. - * - * @param string $idNumber - * @return bool - * @since 5.3.0 - */ - public function validateExistence(string $idNumber): bool; - - /** - * This would usually just call validateFormat() and then validateExistence() and return the result. - * - * @param string $idNumber - * @return bool - * @since 5.3.0 - */ - public function validate(string $idNumber): bool; - - /** - * Tests if the validator is available for use by tax rates. - * This would usually be a check against the existence or settings or API keys so that the validator can be used. - * - * @return bool - * @since 5.3.0 - */ - public static function isEnabled(): bool; -} diff --git a/src/base/Zone.php b/src/base/Zone.php deleted file mode 100644 index a2be42ce3c..0000000000 --- a/src/base/Zone.php +++ /dev/null @@ -1,103 +0,0 @@ -_condition ?? new ZoneAddressCondition(Address::class); - } - - /** - * @param ZoneAddressCondition|string|array|null $condition - * @return void - * @throws InvalidConfigException - */ - public function setCondition(ZoneAddressCondition|string|array|null $condition): void - { - if ($condition === null) { - $condition = new ZoneAddressCondition(Address::class); - } - - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof ZoneAddressCondition) { - $condition['class'] = ZoneAddressCondition::class; - - // @TODO Remove this 3.x -> 4.x migration fallback that forces the elementType to Address in Commerce 6.0 - $condition['elementType'] = Address::class; - - /** @var ZoneAddressCondition $condition */ - $condition = Craft::$app->getConditions()->createCondition($condition); - } - $condition->forProjectConfig = false; - - $this->_condition = $condition; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['name', 'condition', 'storeId'], 'required'], - [['storeId', 'id', 'description', 'dateCreated', 'dateUpdated'], 'safe'], - ]; - } -} diff --git a/src/base/ZoneInterface.php b/src/base/ZoneInterface.php deleted file mode 100644 index b91b39b68c..0000000000 --- a/src/base/ZoneInterface.php +++ /dev/null @@ -1,36 +0,0 @@ - - * @method static self make($items = []) - * - * @author Pixel & Tonic, Inc. - * @since 5.0.0 - */ -class InventoryMovementCollection extends Collection -{ - /** - * @return array - * @since 5.3.2 - */ - public function getPurchasables(): array - { - return $this->map(fn(InventoryMovementInterface $updateInventoryLevel) => $updateInventoryLevel->getInventoryItem()->getPurchasable())->all(); - } -} diff --git a/src/collections/UpdateInventoryLevelCollection.php b/src/collections/UpdateInventoryLevelCollection.php deleted file mode 100644 index f689316803..0000000000 --- a/src/collections/UpdateInventoryLevelCollection.php +++ /dev/null @@ -1,52 +0,0 @@ - - * @since 5.0.0 - */ -class UpdateInventoryLevelCollection extends Collection -{ - /** - * Creates a UpdateInventoryLevelCollection from an array of UpdateInventoryLevel attributes. - * - * @param array $items - * @return static - */ - public static function make($items = []) - { - foreach ($items as &$item) { - if ($item instanceof UpdateInventoryLevel) { - continue; - } - - $item = \Craft::createObject(UpdateInventoryLevel::class, [ - 'config' => ['attributes' => $item], - ]); - } - - /** @var static $collection */ - $collection = parent::make($items); - return $collection; - } - - /** - * @return array - */ - public function getPurchasables(): array - { - return $this->map(fn(UpdateInventoryLevel|UpdateInventoryLevelInTransfer $updateInventoryLevel) => $updateInventoryLevel->getInventoryItem()->getPurchasable())->filter()->all(); - } -} diff --git a/src/console/Controller.php b/src/console/Controller.php deleted file mode 100644 index a03dab7f57..0000000000 --- a/src/console/Controller.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 3.3 - */ -class Controller extends CraftController -{ -} diff --git a/src/console/controllers/ExampleTemplatesController.php b/src/console/controllers/ExampleTemplatesController.php deleted file mode 100644 index 1c09dd3f55..0000000000 --- a/src/console/controllers/ExampleTemplatesController.php +++ /dev/null @@ -1,286 +0,0 @@ - - * @since 3.3 - */ -class ExampleTemplatesController extends Controller -{ - /** - * @inheritdoc - */ - public $defaultAction = 'generate'; - - /** - * @var string|null Name of the target folder the templates will be copied to. - * @since 3.3 - */ - public ?string $folderName = null; - - /** - * @var bool Whether to overwrite an existing folder. Must be passed if a folder with that name already exists. - * @since 3.3 - */ - public bool $overwrite = false; - - /** - * @var bool Whether to generate and copy to the example-templates build folder (used by Craft Commerce developers) - * @since 3.3 - */ - public bool $devBuild = false; - - /** - * @var string|null The base color for the generated example templates. - */ - public ?string $baseColor = 'blue'; - - /** - * @var array - */ - private array $_replacementData = []; - - /** - * @inheritdoc - */ - public function options($actionID): array - { - $options = parent::options($actionID); - $options[] = 'folderName'; - $options[] = 'overwrite'; - $options[] = 'baseColor'; - $options[] = 'devBuild'; - return $options; - } - - /** - * Generates and copies the example templates. - * - * @throws ErrorException - * @throws Exception - */ - public function actionGenerate(): int - { - if ($this->devBuild) { - $this->overwrite = true; - $this->folderName = 'shop'; - } - - $slash = DIRECTORY_SEPARATOR; - $pathService = Craft::$app->getPath(); - $templatesPath = $this->_getTemplatesPath(); - - $exampleTemplatesSource = FileHelper::normalizePath( - $pathService->getVendorPath() . '/craftcms/commerce/example-templates/src/shop' - ); - - if (isset($this->folderName)) { - $folderName = $this->folderName; - } else { - $this->stdout('A folder will be copied to your templates directory.' . PHP_EOL); - $folderName = $this->prompt('Choose folder name:', ['required' => true, 'default' => 'shop']); - } - - // Folder name is required - if (!$folderName) { - $errors[] = 'No destination folder name provided.'; - return $this->_returnErrors($errors); - } - - // Add the string replacement data to be swapped out in templates - $this->_replacementData = ArrayHelper::merge($this->_replacementData, [ - '[[folderName]]' => $folderName, - ]); - $this->_addCssClassesToReplacementData(); - $this->_addResourceAssetsToReplacementData(); - - // Create a temporary directory to hold the copy of the templates before we replace variables - $tempDestination = $pathService->getTempPath() . $slash . 'commerce_example_templates_' . md5(uniqid((string)mt_rand(), true)); - - try { - // Copy the templates to the temporary directory - FileHelper::copyDirectory( - $exampleTemplatesSource, - $tempDestination, - ['recursive' => true, 'copyEmptyDirectories' => true] - ); - - // Find all text files in which we want to replace [[ ]] notation - $files = FileHelper::findFiles($tempDestination, [ - 'only' => ['*.twig', '*.html', '*.svg', '*.css'], - ]); - - // Set the [[ ]] notation variables and write the files - foreach ($files as $file) { - $fileContents = file_get_contents($file); - $fileContents = str_replace( - array_keys($this->_replacementData), - array_values($this->_replacementData), - $fileContents - ); - file_put_contents($file, $fileContents); - } - } catch (\Exception $e) { - $errors[] = 'Could not generate templates. Exception raised:'; - $errors[] = $e->getCode() . ' ' . $e->getMessage(); - } - - if (!is_dir($tempDestination)) { - $errors[] = 'Could not generate templates.'; - } - - if (!empty($errors)) { - return $this->_returnErrors($errors); - } - - // New source is our temp directory ready for copying to site templates - $source = $tempDestination; - - if ($this->devBuild) { - // If this is a dev build, copy them to the build folder - $destination = FileHelper::normalizePath( - Craft::getAlias('@vendor') . '/craftcms/commerce/example-templates/dist/' . $this->folderName - ); - } else { - // If this is not a dev build, copy them to the templates folder - if (!$templatesPath) { - $errors[] = 'Can not determine the site template path.'; - } elseif (!FileHelper::isWritable($templatesPath)) { - $errors[] = 'Site template path is not writable.'; - } - - if (!empty($errors)) { - return $this->_returnErrors($errors); - } - - $destination = $templatesPath . $slash . $folderName; - } - - $destinationExists = is_dir($destination); - - if ($destinationExists && $this->overwrite) { - // We’re allowed to overwrite templates, and we’ve got valid source and destination folders - $this->stdout('Overwriting ...' . PHP_EOL, Console::FG_YELLOW); - FileHelper::removeDirectory($destination); - } elseif ($destinationExists && !$this->overwrite) { - // A target folder’s been specified that already exists, but we’re not supposed to overwrite it - $errors[] = 'The “' . $folderName . '” directory already exists. Set the `overwrite` param to `true` to replace it.'; - return $this->_returnErrors($errors); - } - - // Now let’s try and copy that template directory - try { - $this->stdout('Copying ...' . PHP_EOL, Console::FG_YELLOW); - FileHelper::copyDirectory($source, $destination, ['recursive' => true, 'copyEmptyDirectories' => true]); - } catch (\Exception $e) { - $errors[] = $e->getMessage(); - } - - // delete the temp directory - FileHelper::removeDirectory($tempDestination); - - if (!empty($errors)) { - return $this->_returnErrors($errors); - } - - $this->stdout('Done!' . PHP_EOL, Console::FG_GREEN); - - return ExitCode::OK; - } - - /** - * Adds CSS key-value replacements to the array, where the key is our special `[[ ]]` template notation and - * the value is what it’ll be replaced with. - */ - private function _addCssClassesToReplacementData(): void - { - $mainColor = $this->baseColor; - $dangerColor = ($mainColor === 'red') ? 'purple' : 'red'; - $this->_replacementData = ArrayHelper::merge($this->_replacementData, [ - '[[color]]' => $mainColor, - '[[dangerColor]]' => $dangerColor, - '[[classes.text.color]]' => "text-$mainColor-500", - '[[classes.text.dangerColor]]' => "text-$dangerColor-500", - '[[classes.a]]' => "text-$mainColor-500 hover:text-$mainColor-600", - '[[classes.docs]]' => "text-gray-400 hover:text-gray-600 hover:underline", - '[[classes.input]]' => "border border-gray-300 hover:border-gray-500 px-4 py-2 leading-tight rounded", - '[[classes.box.base]]' => "bg-gray-100 border-$mainColor-300 border-b-2 p-6", - '[[classes.box.selection]]' => "border-$mainColor-300 border-b-2 px-6 py-4 rounded-md shadow-md hover:shadow-lg", - '[[classes.box.error]]' => "bg-$dangerColor-100 border-$dangerColor-500 border-b-2 p-6", - '[[classes.btn.base]]' => "cursor-pointer rounded px-4 py-2 inline-block", - '[[classes.btn.small]]' => "cursor-pointer rounded px-2 py-1 text-sm inline-block", - '[[classes.btn.mainColor]]' => "bg-$mainColor-500 hover:bg-$mainColor-600 text-white hover:text-white", - '[[classes.btn.grayColor]]' => "bg-gray-500 hover:bg-gray-600 text-white hover:text-white", - '[[classes.btn.grayLightColor]]' => "bg-gray-300 hover:bg-gray-400 text-gray-600 hover:text-white", - ]); - } - - /** - * Adds external resource key-value replacements to the array, where the key is our special `[[ ]]` template - * notation and the value is what it’ll be replaced with. - */ - private function _addResourceAssetsToReplacementData(): void - { - $resourceTags = [ - Html::cssFile('https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css'), - ]; - - $this->_replacementData = ArrayHelper::merge($this->_replacementData, [ - '[[resourceTags]]' => implode("\n", $resourceTags), - ]); - } - - /** - * Formats and outputs errors and exits. - * - * @param string[] $errors Error strings to be shown to the user - */ - private function _returnErrors(array $errors): int - { - if (count($errors) === 1) { - $this->stderr('Error: ' . array_shift($errors) . PHP_EOL, Console::FG_RED); - } else { - $this->stderr( - 'Errors:' . PHP_EOL . ' - ' . implode(PHP_EOL . ' - ', $errors) . PHP_EOL, - Console::FG_RED - ); - } - - return ExitCode::USAGE; - } - - /** - * Returns the relevant site base template path. - * - * @return string The sites’s base template path - * @throws Exception - */ - private function _getTemplatesPath(): string - { - $view = Craft::$app->getView(); - $originalMode = $view->getTemplateMode(); - $view->setTemplateMode(View::TEMPLATE_MODE_SITE); - $templatesPath = $view->getTemplatesPath(); - $view->setTemplateMode($originalMode); - return $templatesPath; - } -} diff --git a/src/console/controllers/GatewaysController.php b/src/console/controllers/GatewaysController.php deleted file mode 100644 index 69b75b32ce..0000000000 --- a/src/console/controllers/GatewaysController.php +++ /dev/null @@ -1,83 +0,0 @@ - - * @since 4.3 - */ -class GatewaysController extends Controller -{ - public $defaultAction = 'index'; - - /** - * Default action. See `commerce/gateways/list`. - */ - public function actionIndex() - { - return $this->runAction('list'); - } - - /** - * Lists the currently-configured, non-archived gateways. - */ - public function actionList() - { - $gateways = Commerce::getInstance()->getGateways()->getAllGateways(); - $rows = collect($gateways) - ->map(function($gateway) { - /** @var \craft\commerce\base\Gateway $gateway */ - return [ - $gateway->id, - $gateway->name, - $gateway->handle, - $gateway->getIsFrontendEnabled() ? 'Yes' : 'No', - $gateway::class, - $gateway->uid, - ]; - }) - ->all(); - - Console::table([ - 'ID', - 'Name', - 'Handle', - 'Enabled', - 'Type', - 'UUID', - ], $rows); - } - - /** - * Gets a Webhook URL for the provided gateway - * - * @param string $handle - */ - public function actionWebhookUrl(string $handle) - { - $gateway = Commerce::getInstance()->getGateways()->getGatewayByHandle($handle); - - if (!$gateway) { - $this->stderr("A gateway with handle `$handle` does not exist." . PHP_EOL, Console::FG_YELLOW); - - return ExitCode::UNSPECIFIED_ERROR; - } - - $this->stdout("Webhook URL for the {$gateway->name} gateway:" . PHP_EOL); - $this->stdout($gateway->getWebhookUrl() . PHP_EOL, Console::FG_BLUE); - - return ExitCode::OK; - } -} diff --git a/src/console/controllers/PricingCatalogController.php b/src/console/controllers/PricingCatalogController.php deleted file mode 100644 index 7572ec04d4..0000000000 --- a/src/console/controllers/PricingCatalogController.php +++ /dev/null @@ -1,40 +0,0 @@ - - * @since 5.0.0 - */ -class PricingCatalogController extends Controller -{ - /** - * Generates catalog pricing. - */ - public function actionGenerate(): int - { - $this->stdout('Generating catalog pricing... '); - - Plugin::getInstance()->getCatalogPricing()->generateCatalogPrices(showConsoleOutput: true); - - $this->_done(); - return ExitCode::OK; - } - - private function _done(): void - { - $this->stdout('Done!' . PHP_EOL, Console::FG_GREEN); - } -} diff --git a/src/console/controllers/ResetDataController.php b/src/console/controllers/ResetDataController.php deleted file mode 100644 index 324ac7010d..0000000000 --- a/src/console/controllers/ResetDataController.php +++ /dev/null @@ -1,138 +0,0 @@ - - * @since 3.2.8 - */ -class ResetDataController extends Controller -{ - /** - * @var bool Whether to force the reset without confirmation - */ - public bool $force = false; - - /** - * @inheritdoc - */ - public function options($actionID): array - { - $options = parent::options($actionID); - $options[] = 'force'; - return $options; - } - - /** - * Reset Commerce data. - */ - public function actionIndex(): int - { - if ($this->force) { - $reset = 'yes'; - } else { - $reset = $this->prompt('Resetting Commerce data will permanently delete all orders, subscriptions, payment sources, customers, addresses and reset discount usages ... do you wish to continue?', [ - 'required' => true, - 'default' => 'no', - 'validator' => function($input) { - if (!in_array($input, ['yes', 'no'])) { - $this->stderr('You must answer either "yes" or "no".' . PHP_EOL, Console::FG_RED); - return false; - } - - return true; - }, - ]); - } - - if ($reset == 'yes') { - $transaction = Craft::$app->getDb()->beginTransaction(); - - try { - $this->stdout('Resetting Commerce data ...' . PHP_EOL . PHP_EOL, Console::FG_GREEN); - - // Orders - $this->stdout('Deleting orders ...' . PHP_EOL, Console::FG_GREEN); - $ids = (new Query()) - ->select(['orders.id']) - ->from(['orders' => Table::ORDERS]) - ->column(); - - $count = Craft::$app->getDb()->createCommand() - ->delete(CraftTable::ELEMENTS, ['id' => $ids]) - ->execute(); - - $this->stdout($count . ' orders deleted.' . PHP_EOL . PHP_EOL, Console::FG_GREEN); - - // Subscriptions - $this->stdout('Deleting subscriptions ...' . PHP_EOL, Console::FG_GREEN); - $subscriptionIds = (new Query()) - ->select(['subscriptions.id']) - ->from(['subscriptions' => Table::SUBSCRIPTIONS]) - ->column(); - - $count = Craft::$app->getDb()->createCommand() - ->delete(CraftTable::ELEMENTS, ['id' => $subscriptionIds]) - ->execute(); - - $this->stdout($count . ' subscriptions deleted.' . PHP_EOL . PHP_EOL, Console::FG_GREEN); - - // These should really be deleted with a cascade - Craft::$app->getDb()->createCommand() - ->delete(Table::SUBSCRIPTIONS) - ->execute(); - - // Payment Sources - $this->stdout('Deleting payment sources ...' . PHP_EOL, Console::FG_GREEN); - $count = Craft::$app->getDb()->createCommand() - ->delete(Table::PAYMENTSOURCES) - ->execute(); - - $this->stdout($count . ' payment sources deleted.' . PHP_EOL . PHP_EOL, Console::FG_GREEN); - - // Discount usage - $this->stdout('Resetting discount usage data ...' . PHP_EOL, Console::FG_GREEN); - Craft::$app->getDb()->createCommand() - ->delete(Table::CUSTOMER_DISCOUNTUSES) - ->execute(); - Craft::$app->getDb()->createCommand() - ->delete(Table::EMAIL_DISCOUNTUSES) - ->execute(); - Craft::$app->getDb()->createCommand() - ->update(Table::DISCOUNTS, ['totalDiscountUses' => 0], '', [], false) - ->execute(); - - $this->stdout(' - per customer discount counter cleared.' . PHP_EOL, Console::FG_GREEN); - $this->stdout(' - per email discount counter cleared.' . PHP_EOL, Console::FG_GREEN); - $this->stdout(' - total discount uses counter cleared.' . PHP_EOL . PHP_EOL, Console::FG_GREEN); - - $this->stdout('Finished.' . PHP_EOL . PHP_EOL, Console::FG_GREEN); - - $transaction->commit(); - } catch (Exception $e) { - $this->stdout($e->getmessage() . PHP_EOL, Console::FG_RED); - $transaction->rollBack(); - } - } else { - $this->stdout('Skipping data reset.' . PHP_EOL . PHP_EOL, Console::FG_GREEN); - } - - return ExitCode::OK; - } -} diff --git a/src/console/controllers/TransferCustomerDataController.php b/src/console/controllers/TransferCustomerDataController.php deleted file mode 100644 index 125623b309..0000000000 --- a/src/console/controllers/TransferCustomerDataController.php +++ /dev/null @@ -1,107 +0,0 @@ - - * @since 4.1.0 - */ -class TransferCustomerDataController extends Controller -{ - /** - * @var string|null The User email or username of the user that is having their commerce content moved. - */ - public ?string $fromUser = null; - - /** - * @var string|null The User email or username of the user that is having the commerce content moved to. - */ - public ?string $toUser = null; - - /** - * @inheritdoc - */ - public function options($actionID): array - { - $options = parent::options($actionID); - $options[] = 'fromUser'; - $options[] = 'toUser'; - return $options; - } - - /** - * Move Commerce data. - */ - public function actionIndex(): int - { - $this->stdout('This command will transfer all commerce data from one user to another.' . PHP_EOL); - - $this->fromUser = $this->prompt('Move Commerce data from user (email or username):', [ - 'required' => true, - 'default' => $this->fromUser ?? '', - ]); - - $this->toUser = $this->prompt('To user (email or username):', [ - 'required' => true, - 'default' => $this->toUser ?? '', - ]); - - if ($this->fromUser === '' || $this->toUser === '') { - $this->stderr('You must specify both a “to” and “from” user.' . PHP_EOL, Console::FG_RED); - return ExitCode::UNSPECIFIED_ERROR; - } - - $fromUser = Craft::$app->getUsers()->getUserByUsernameOrEmail($this->fromUser); - $toUser = Craft::$app->getUsers()->getUserByUsernameOrEmail($this->toUser); - - if ($fromUser === null) { - $this->stderr("No user found with a username or email of `{$this->fromUser}`" . PHP_EOL, Console::FG_RED); - return ExitCode::UNSPECIFIED_ERROR; - } - - if ($toUser === null) { - $this->stderr("No user found with a username or email of `{$this->toUser}`" . PHP_EOL, Console::FG_RED); - return ExitCode::UNSPECIFIED_ERROR; - } - - // Make sure they're not the same! - if ($fromUser->id === $toUser->id) { - $this->stderr('The transfer must happen between different users.' . PHP_EOL, Console::FG_RED); - return ExitCode::UNSPECIFIED_ERROR; - } - - $confirm = $this->confirm('Are you sure you want to move all Commerce data from user: ' . $this->fromUser . ' to user: ' . $this->toUser . '?'); - if (!$confirm) { - $this->stdout('No data will be moved.', Console::FG_YELLOW); - return ExitCode::OK; - } - - $this->stdout('Moving data... '); - - try { - Plugin::getInstance()->getCustomers()->transferCustomerData($fromUser, $toUser); - } catch (Exception $e) { - $this->stderr('failed!' . PHP_EOL, Console::FG_RED); - $this->stderr($e->getMessage() . PHP_EOL, Console::FG_RED); - return ExitCode::UNSPECIFIED_ERROR; - } - - $this->stdout('done!' . PHP_EOL, Console::FG_GREEN); - - return ExitCode::OK; - } -} diff --git a/src/controllers/BaseAdminController.php b/src/controllers/BaseAdminController.php deleted file mode 100644 index 849f979bde..0000000000 --- a/src/controllers/BaseAdminController.php +++ /dev/null @@ -1,35 +0,0 @@ - - * @since 2.0 - */ -class BaseAdminController extends BaseCpController -{ - /** - * @inheritdoc - * @throws ForbiddenHttpException - */ - public function init(): void - { - parent::init(); - $this->requireAdmin(false); - } - - protected function isReadOnlyScreen(): bool - { - return !Craft::$app->getConfig()->getGeneral()->allowAdminChanges; - } -} diff --git a/src/controllers/BaseController.php b/src/controllers/BaseController.php deleted file mode 100644 index 037f194cc1..0000000000 --- a/src/controllers/BaseController.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -abstract class BaseController extends Controller -{ -} diff --git a/src/controllers/BaseCpController.php b/src/controllers/BaseCpController.php deleted file mode 100644 index 983d851608..0000000000 --- a/src/controllers/BaseCpController.php +++ /dev/null @@ -1,34 +0,0 @@ - - * @since 2.0 - */ -class BaseCpController extends BaseController -{ - /** - * @inheritdoc - * @throws ForbiddenHttpException - */ - public function init(): void - { - parent::init(); - - // All system setting actions require access to commerce - $this->requirePermission('accessPlugin-commerce'); - - $this->getView()->registerAssetBundle(CommerceCpAsset::class); - } -} diff --git a/src/controllers/BaseFrontEndController.php b/src/controllers/BaseFrontEndController.php deleted file mode 100644 index 71d7973abd..0000000000 --- a/src/controllers/BaseFrontEndController.php +++ /dev/null @@ -1,71 +0,0 @@ - - * @since 2.0 - */ -class BaseFrontEndController extends BaseController -{ - /** - * @event Event The event that’s triggered when a cart is returned as an array for Ajax cart update requests. - * - * --- - * ```php - * use craft\commerce\controllers\BaseFrontEndController; - * use craft\commerce\events\ModifyCartInfoEvent; - * use yii\base\Event; - * - * Event::on( - * BaseFrontEndController::class, - * BaseFrontEndController::EVENT_MODIFY_CART_INFO, - * function(ModifyCartInfoEvent $e) { - * $cartArray = $e->cartInfo; - * $cartArray['anotherOne'] = 'Howdy'; - * $e->cartInfo = $cartArray; - * } - * ); - * ``` - */ - public const EVENT_MODIFY_CART_INFO = 'modifyCartInfo'; - - - /** - * @inheritdoc - */ - protected array|bool|int $allowAnonymous = true; - - protected function cartArray(Order $cart): array - { - $extraFields = [ - 'availableShippingMethodOptions', - 'billingAddress', - 'lineItems.snapshot', - 'notices', - 'shippingAddress', - ]; - - $cartInfo = $cart->toArray([], $extraFields); - - // Fire a 'modifyCartContent' event - $event = new ModifyCartInfoEvent([ - 'cartInfo' => $cartInfo, - 'cart' => $cart, - ]); - - $this->trigger(self::EVENT_MODIFY_CART_INFO, $event); - - return $event->cartInfo; - } -} diff --git a/src/controllers/BaseShippingSettingsController.php b/src/controllers/BaseShippingSettingsController.php deleted file mode 100644 index de34e4992a..0000000000 --- a/src/controllers/BaseShippingSettingsController.php +++ /dev/null @@ -1,28 +0,0 @@ - - * @since 2.0 - */ -class BaseShippingSettingsController extends BaseStoreManagementController -{ - /** - * @inheritdoc - */ - public function init(): void - { - parent::init(); - - // All system setting actions require access to commerce - $this->requirePermission('commerce-manageShipping'); - } -} diff --git a/src/controllers/BaseStoreManagementController.php b/src/controllers/BaseStoreManagementController.php deleted file mode 100644 index 42dba734e1..0000000000 --- a/src/controllers/BaseStoreManagementController.php +++ /dev/null @@ -1,261 +0,0 @@ - - * @since 2.0 - */ -class BaseStoreManagementController extends BaseCpController -{ - public array $storeSettingsNav = []; - - /** - * @return void - * @throws \yii\base\InvalidConfigException - * @throws \yii\web\ForbiddenHttpException - */ - public function init(): void - { - parent::init(); - - $this->requirePermission('commerce-manageStoreSettings'); - } - - /** - * @param string|null $storeHandle - * @param bool $isIndex - * @param bool $hasStoreSwitcher - * @return Response - * @throws InvalidConfigException - * @since 5.5.0 - */ - public function asStoreManagementCpScreen(?string $storeHandle = null, bool $isIndex = true, bool $hasStoreSwitcher = true): Response - { - $screen = $this->asCpScreen(); - - $requestStoreHandle = Craft::$app->getRequest()->getSegment(Craft::$app->getConfig()->getGeneral()->cpTrigger ? 3 : 2); - $requestSelectedItem = Craft::$app->getRequest()->getSegment(Craft::$app->getConfig()->getGeneral()->cpTrigger ? 4 : 3); - - $storeHandle ??= $requestStoreHandle ?? Plugin::getInstance()->getStores()->getPrimaryStore()->handle; - $store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle); - $selectedItem = $requestSelectedItem ?? 'general'; - - $screen->crumbs(array_filter([ - [ - 'label' => Craft::t('commerce', 'Commerce'), - 'url' => 'commerce', - ], - $hasStoreSwitcher ? $this->getStoreSwitcher($storeHandle) : null, - ])); - - if ($isIndex) { - // Most index pages need the admin table asset bundle - $this->getView()->registerAssetBundle(AdminTableAsset::class); - - // Render the sidebar - $screen->pageSidebarTemplate('commerce/_includes/_storeManagementNav', [ - 'storeSettingsNav' => $this->getStoreSettingsNav(), - 'store' => $store, - 'selectedItem' => $selectedItem, - ]); - } - - $screen->title(Craft::t('commerce', 'Store Management')); - $screen->selectedSubnavItem('store-management'); - - return $screen; - } - - /** - * @inheritDoc - */ - public function renderTemplate(string $template, array $variables = [], ?string $templateMode = null): YiiResponse - { - $variables['storeSettingsNav'] = $this->getStoreSettingsNav(); - - if (!isset($variables['storeHandle'])) { - /** @var UrlManager $urlManager */ - $urlManager = Craft::$app->getUrlManager(); - $routeParams = $urlManager->getRouteParams(); - - // Make sure store handle is always passed to the template - if (isset($routeParams['storeHandle'])) { - $variables['storeHandle'] = $routeParams['storeHandle']; - } - } - - if (!isset($variables['storeSwitcher'])) { - $variables['storeSwitcher'] = $this->getStoreSwitcher($variables['storeHandle']); - } - - return parent::renderTemplate($template, $variables, $templateMode); - } - - /** - * @param string|null $storeHandle - * @return array - * @throws InvalidConfigException - * @since 5.3.0 - */ - protected function getStoreSwitcher(?string $storeHandle = null): array - { - $stores = Plugin::getInstance()->getStores()->getAllStores(); - - $store = $storeHandle ? Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle) : null; - - $storeItems = $stores->filter(function(Store $s) { - // Check that the user has permission to access a site that this store is related to - foreach ($s->getSites() as $site) { - if (Craft::$app->getUser()->checkPermission('editSite:' . $site->uid)) { - return true; - } - } - - return false; - })->map(function(Store $s) use ($storeHandle) { - $segments = Craft::$app->getRequest()->getSegments(); - $storeSubSection = count($segments) >= 4 ? $segments[3] : null; - - return [ - 'status' => null, - 'label' => Craft::t('site', $s->getName()), - 'url' => 'commerce/store-management/' . $s->handle . ($storeSubSection ? '/' . $storeSubSection : ''), - 'selected' => $storeHandle === $s->handle, - 'attributes' => [ - 'data' => [ - 'store-handle' => $s->handle, - ], - ], - ]; - })->all(); - - return [ - 'id' => 'site-crumb', - 'iconAltText' => Craft::t('commerce', 'Store'), - 'icon' => 'store', - 'label' => $store?->getName() ?? Craft::t('commerce', 'Store Management'), - 'menu' => [ - 'label' => Craft::t('app', 'Select site'), - 'items' => $storeItems, - ], - ]; - } - - /** - * @return array - * @throws InvalidConfigException - */ - protected function getStoreSettingsNav(): array - { - $userService = Craft::$app->getUser(); - - $this->storeSettingsNav['general'] = [ - 'label' => Craft::t('commerce', "General"), - 'path' => '', - 'disabled' => !$userService->checkPermission('commerce-manageGeneralStoreSettings'), - ]; - - $this->storeSettingsNav['payment-currencies'] = [ - 'label' => Craft::t('commerce', 'Payment Currencies'), - 'path' => 'payment-currencies', - 'disabled' => !$userService->checkPermission('commerce-managePaymentCurrencies'), - ]; - - $managePromotions = $userService->checkPermission('commerce-managePromotions'); - $this->storeSettingsNav['pricing-heading'] = [ - 'heading' => Craft::t('commerce', 'Pricing'), - ]; - - $this->storeSettingsNav['discounts'] = [ - 'label' => Craft::t('commerce', 'Discounts'), - 'path' => 'discounts', - 'disabled' => !$managePromotions, - ]; - - if (Plugin::getInstance()->getCatalogPricingRules()->canUseCatalogPricingRules()) { - $this->storeSettingsNav['pricing-rules'] = [ - 'label' => Craft::t('commerce', 'Pricing Rules'), - 'path' => 'pricing-rules', - 'disabled' => !$managePromotions, - ]; - } else { - $this->storeSettingsNav['sales'] = [ - 'label' => Craft::t('commerce', 'Sales'), - 'path' => 'sales', - 'disabled' => !$managePromotions, - ]; - } - - - $this->storeSettingsNav['shipping-header'] = [ - 'heading' => Craft::t('commerce', 'Shipping'), - ]; - - $manageShipping = $userService->checkPermission('commerce-manageShipping'); - $this->storeSettingsNav['shippingmethods'] = [ - 'label' => Craft::t('commerce', 'Shipping Methods'), - 'path' => 'shippingmethods', - 'disabled' => !$manageShipping, - ]; - - $this->storeSettingsNav['shippingzones'] = [ - 'label' => Craft::t('commerce', 'Shipping Zones'), - 'path' => 'shippingzones', - 'disabled' => !$manageShipping, - ]; - - $this->storeSettingsNav['shippingcategories'] = [ - 'label' => Craft::t('commerce', 'Shipping Categories'), - 'path' => 'shippingcategories', - 'disabled' => !$manageShipping, - ]; - - $this->storeSettingsNav['tax'] = [ - 'heading' => Craft::t('commerce', 'Tax'), - ]; - - $manageTaxes = $userService->checkPermission('commerce-manageTaxes'); - if (Plugin::getInstance()->getTaxes()->viewTaxRates()) { - $this->storeSettingsNav['taxrates'] = [ - 'label' => Craft::t('commerce', 'Tax Rates'), - 'path' => 'taxrates', - 'disabled' => !$manageTaxes, - ]; - } - - if (Plugin::getInstance()->getTaxes()->viewTaxZones()) { - $this->storeSettingsNav['taxzones'] = [ - 'label' => Craft::t('commerce', 'Tax Zones'), - 'path' => 'taxzones', - 'disabled' => !$manageTaxes, - ]; - } - - if (Plugin::getInstance()->getTaxes()->viewTaxCategories()) { - $this->storeSettingsNav['taxcategories'] = [ - 'label' => Craft::t('commerce', 'Tax Categories'), - 'path' => 'taxcategories', - 'disabled' => !$manageTaxes, - ]; - } - - return $this->storeSettingsNav; - } -} diff --git a/src/controllers/BaseTaxSettingsController.php b/src/controllers/BaseTaxSettingsController.php deleted file mode 100644 index 2c5033501d..0000000000 --- a/src/controllers/BaseTaxSettingsController.php +++ /dev/null @@ -1,28 +0,0 @@ - - * @since 2.0 - */ -class BaseTaxSettingsController extends BaseStoreManagementController -{ - /** - * @inheritdoc - */ - public function init(): void - { - parent::init(); - - // All system setting actions require access to commerce - $this->requirePermission('commerce-manageTaxes'); - } -} diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php deleted file mode 100644 index b529d43835..0000000000 --- a/src/controllers/CartController.php +++ /dev/null @@ -1,1022 +0,0 @@ - - * @since 2.0 - */ -class CartController extends BaseFrontEndController -{ - /** - * Params that trigger IP-based rate limiting on cart actions. - */ - public const RATE_LIMITED_PARAMS = ['number', 'couponCode']; - - /** - * @var Order The cart element - */ - protected Order $_cart; - - /** - * @var string the name of the cart variable - */ - protected string $_cartVariable; - - /** - * @var User|null - */ - protected ?User $_currentUser = null; - - /** - * @var Mutex|null - */ - private ?Mutex $_mutex = null; - - /** - * @var string|null - */ - private ?string $_mutexLockName = null; - - /** - * @throws InvalidConfigException - */ - public function init(): void - { - $this->_cartVariable = Plugin::getInstance()->getSettings()->cartVariable; - $this->_currentUser = Craft::$app->getUser()->getIdentity(); - - parent::init(); - } - - /** - * @inerhitdoc - */ - public function behaviors(): array - { - return array_merge(parent::behaviors(), [ - 'rateLimiter' => [ - 'class' => RateLimiter::class, - 'only' => ['get-cart', 'update-cart', 'load-cart', 'complete'], - 'enableRateLimitHeaders' => false, - 'user' => function() { - // Only apply rate limiting when a cart number or coupon code is explicitly passed - $request = Craft::$app->getRequest(); - $isActive = collect(self::RATE_LIMITED_PARAMS) - ->contains(fn($param) => $request->getBodyParam($param) || $request->getQueryParam($param)); - - return $isActive ? new IpRateLimitIdentity([ - 'limit' => 1, - 'window' => 1, - 'keyPrefix' => 'cart-rate-limit', - 'ip' => $request->getUserIP() ?? 'unknown', - ]) : null; - }, - ], - 'cartChallengeRateLimiter' => [ - 'class' => RateLimiter::class, - 'only' => ['cart-challenge'], - 'enableRateLimitHeaders' => false, - 'user' => function() { - $request = Craft::$app->getRequest(); - return new IpRateLimitIdentity([ - 'limit' => 1, - 'window' => 30, - 'keyPrefix' => 'cart-challenge-rate-limit', - 'ip' => $request->getUserIP() ?? 'unknown', - ]); - }, - ], - ]); - } - - /** - * Returns the cart as JSON - * - * @throws BadRequestHttpException - */ - public function actionGetCart(): Response - { - $this->requireAcceptsJson(); - - if ($this->request->getBodyParam('peek')) { - $cart = Plugin::getInstance()->getCarts()->peekCart(); - return $this->asSuccess(data: [ - $this->_cartVariable => $cart ? $this->cartArray($cart) : null, - ]); - } - - $this->_cart = $this->_getCart(); - - return $this->asSuccess(data: [ - $this->_cartVariable => $this->cartArray($this->_cart), - ]); - } - - /** - * Updates the cart by adding purchasables to the cart, updating line items, or updating various cart attributes. - * - * @throws BadRequestHttpException - * @throws ElementNotFoundException - * @throws Exception - * @throws NotFoundHttpException - * @throws Throwable - */ - public function actionUpdateCart(): ?Response - { - $this->requirePostRequest(); - $isSiteRequest = $this->request->getIsSiteRequest(); - $isConsoleRequest = $this->request->getIsConsoleRequest(); - $currentUser = Craft::$app->getUser()->getIdentity(); - /** @var Plugin $plugin */ - $plugin = Plugin::getInstance(); - - $useMutex = (!$isConsoleRequest && Craft::$app->getRequest()->getBodyParam('number')) || (!$isConsoleRequest && $plugin->getCarts()->getHasSessionCartNumber()); - - if ($useMutex) { - $lockOrderNumber = null; - if ($bodyNumber = Craft::$app->getRequest()->getBodyParam('number')) { - $lockOrderNumber = $bodyNumber; - } elseif (!$isConsoleRequest) { - $request = Craft::$app->getRequest(); - $requestCookies = $request->getCookies(); - $cookieNumber = $requestCookies->getValue($plugin->getCarts()->cartCookie['name']); - - if ($cookieNumber) { - $lockOrderNumber = $cookieNumber; - } - } - - if ($lockOrderNumber) { - $this->_mutexLockName = "order:$lockOrderNumber"; - $this->_mutex = Craft::$app->getMutex(); - if (!$this->_mutex->acquire($this->_mutexLockName, 5)) { - throw new Exception('Unable to acquire a lock for saving of Order: ' . $lockOrderNumber); - } - } - } - - // Get the cart from the request or from the session. - $this->_cart = $this->_getCart(); - - // When we are about to update the cart, we consider it a real cart at this point, and want to actually create it in the DB. - if ($this->_cart->id === null) { - // Make sure we have a fully saved cart before attempting any mutations. - $this->_cart = $this->_getCart(true); - } - - // Can clear line items when updating the cart - $clearLineItems = $this->request->getParam('clearLineItems'); - if ($clearLineItems) { - $this->_cart->setLineItems([]); - } - - // Can clear notices when updating the cart - if ($this->request->getParam('clearNotices') !== null) { - $this->_cart->clearNotices(); - } - - // Set the custom fields submitted - $this->_cart->setFieldValuesFromRequest('fields'); - - // Backwards compatible way of adding to the cart - if ($purchasableId = $this->request->getParam('purchasableId')) { - $note = $this->request->getParam('note', ''); - $options = $this->request->getParam('options', []); // @TODO Restrict `options` to key/value pairs only in Commerce 6.0 #COM-55 - $qty = (int)$this->request->getParam('qty', 1); - - $params = compact('qty', 'note', 'purchasableId', 'options'); - - if ($qty > 0) { - // We only want a new line item if they cleared the cart - if ($clearLineItems) { - $lineItem = Plugin::getInstance()->getLineItems()->create($this->_cart, params: $params); - } else { - // we are passing everything into params but need to pass purchasableId and options for now until we refactor - $lineItem = Plugin::getInstance()->getLineItems()->resolveLineItem($this->_cart, $params['purchasableId'], $params['options'], params: $params); - } - - // New line items already have a qty of one. - if ($lineItem->id) { - $lineItem->qty += $qty; - } else { - $lineItem->qty = $qty; - } - - $lineItem->note = $note; - - $this->_cart->addLineItem($lineItem); - } - } - - // Add multiple items to the cart - if ($purchasables = $this->request->getParam('purchasables')) { - // Initially combine same purchasables - $purchasablesByKey = []; - foreach ($purchasables as $key => $purchasable) { - $purchasableId = $this->request->getParam("purchasables.$key.id"); - $note = $this->request->getParam("purchasables.$key.note", ''); - $options = $this->request->getParam("purchasables.$key.options", []); - $qty = (int)$this->request->getParam("purchasables.$key.qty", 1); - - $purchasable = []; - $purchasable['id'] = $purchasableId; - $purchasable['options'] = is_array($options) ? $options : []; - $purchasable['note'] = $note; - $purchasable['qty'] = $qty; - - $key = $purchasableId . '-' . LineItemHelper::generateOptionsSignature($purchasable['options']); - if (isset($purchasablesByKey[$key])) { - $purchasablesByKey[$key]['qty'] += $purchasable['qty']; - } else { - $purchasablesByKey[$key] = $purchasable; - } - } - - foreach ($purchasablesByKey as $purchasable) { - if ($purchasable['id'] == null) { - continue; - } - - // Ignore zero value qty for multi-add forms https://github.com/craftcms/commerce/issues/330#issuecomment-384533139 - if ($purchasable['qty'] > 0) { - $params = [ - 'purchasableId' => $purchasable['id'], - 'options' => $purchasable['options'], - 'note' => $purchasable['note'], - 'qty' => $purchasable['qty'], - ]; - - // We only want a new line item if they cleared the cart - if ($clearLineItems) { - $lineItem = Plugin::getInstance()->getLineItems()->create($this->_cart, params: $params); - } else { - $lineItem = Plugin::getInstance()->getLineItems()->resolveLineItem($this->_cart, $params['purchasableId'], $params['options'], $params); - } - - // New line items already have a qty of one. - if ($lineItem->id) { - $lineItem->qty += $purchasable['qty']; - } else { - $lineItem->qty = $purchasable['qty']; - } - - $lineItem->note = $purchasable['note']; - $this->_cart->addLineItem($lineItem); - } - } - } - - // Update multiple line items in the cart - if ($lineItems = $this->request->getParam('lineItems')) { - foreach ($lineItems as $key => $lineItem) { - $lineItem = $this->_getCartLineItemById($key); - if ($lineItem) { - $lineItem->qty = (int)$this->request->getParam("lineItems.$key.qty", $lineItem->qty); - $lineItem->note = $this->request->getParam("lineItems.$key.note", $lineItem->note); - $lineItem->setOptions($this->request->getParam("lineItems.$key.options", $lineItem->getOptions())); - - $removeLine = $this->request->getParam("lineItems.$key.remove", false); - if (($lineItem->qty !== null && $lineItem->qty == 0) || $removeLine) { - $this->_cart->removeLineItem($lineItem); - } else { - $this->_cart->addLineItem($lineItem); - } - } - } - } - - $this->_setAddresses(); - - // Setting email only allowed for guest customers - if (!$currentUser) { - // Set guest email address onto guest customers order. - $email = $this->request->getParam('email'); - if ($email && ($this->_cart->getEmail() === null || $this->_cart->getEmail() != $email)) { - try { - $user = Craft::$app->getUsers()->ensureUserByEmail($email); - $this->_cart->setCustomer($user); - if ($user->getIsCredentialed()) { - Craft::$app->getSession()->set('commerce:anonymousCartWithCredentialedCustomer:' . $this->_cart->number, true); - } - } catch (\Exception $e) { - $this->_cart->addError('email', $e->getMessage()); - } - } - } else { - Craft::$app->getSession()->remove('commerce:anonymousCartWithCredentialedCustomer:' . $this->_cart->number); - } - - // Set if the customer should be registered on order completion - $registerUserOnOrderComplete = $this->request->getBodyParam('registerUserOnOrderComplete'); - if ($registerUserOnOrderComplete !== null) { - $this->_cart->registerUserOnOrderComplete = (bool)$registerUserOnOrderComplete; - } - - $saveBillingAddressOnOrderComplete = $this->request->getBodyParam('saveBillingAddressOnOrderComplete'); - if ($saveBillingAddressOnOrderComplete !== null) { - $this->_cart->saveBillingAddressOnOrderComplete = (bool)$saveBillingAddressOnOrderComplete; - } - - $saveShippingAddressOnOrderComplete = $this->request->getBodyParam('saveShippingAddressOnOrderComplete'); - if ($saveShippingAddressOnOrderComplete !== null) { - $this->_cart->saveShippingAddressOnOrderComplete = (bool)$saveShippingAddressOnOrderComplete; - } - - $saveAddressesOnOrderComplete = $this->request->getBodyParam('saveAddressesOnOrderComplete'); - if ($saveAddressesOnOrderComplete !== null) { - $this->_cart->saveBillingAddressOnOrderComplete = (bool)$saveAddressesOnOrderComplete; - $this->_cart->saveShippingAddressOnOrderComplete = (bool)$saveAddressesOnOrderComplete; - } - - // Set payment currency on cart - if ($currency = $this->request->getParam('paymentCurrency')) { - $this->_cart->paymentCurrency = $currency; - } - - // Set Coupon on Cart. Allow blank string to remove coupon - if (($couponCode = $this->request->getParam('couponCode')) !== null) { - $this->_cart->couponCode = trim($couponCode) ?: null; - } - - // Set Payment Gateway on cart - if ($gatewayId = $this->request->getParam('gatewayId')) { - if ($plugin->getGateways()->getGatewayById($gatewayId)) { - $this->_cart->setGatewayId($gatewayId); - } - } - - // Submit payment source on cart - if (($paymentSourceId = $this->request->getParam('paymentSourceId')) !== null) { - if ($paymentSourceId && $paymentSource = $plugin->getPaymentSources()->getPaymentSourceById($paymentSourceId)) { - // The payment source can only be used by the same user as the cart's user. - $cartCustomerId = $this->_cart->getCustomer() ? $this->_cart->getCustomer()->id : null; - $paymentSourceCustomerId = $paymentSource->getCustomer()?->id; - $allowedToUsePaymentSource = ($cartCustomerId && $paymentSourceCustomerId && $this->_currentUser && $isSiteRequest && ($paymentSourceCustomerId == $cartCustomerId)); - if ($allowedToUsePaymentSource) { - $this->_cart->setPaymentSource($paymentSource); - } - } else { - $this->_cart->setPaymentSource(null); - } - } - - // Set Shipping method on cart. - if ($shippingMethodHandle = $this->request->getParam('shippingMethodHandle')) { - $this->_cart->shippingMethodHandle = $shippingMethodHandle; - } - - return $this->_returnCart(); - } - - /** - * @return Response|null - * @throws BadRequestHttpException - * @throws InvalidConfigException - * @since 4.3 - */ - public function actionForgetCart(): ?Response - { - $this->requirePostRequest(); - Plugin::getInstance()->getCarts()->forgetCart(); - $this->setSuccessFlash(Craft::t('commerce', 'Cart forgotten.')); - return $this->redirectToPostedUrl(); - } - - /** - * @throws BadRequestHttpException - * @throws Exception - * @throws MissingComponentException - * @since 3.1 - */ - public function actionLoadCart(): ?Response - { - $carts = Plugin::getInstance()->getCarts(); - $number = $this->request->getParam('number'); - $token = $this->request->getParam('code'); - $loadCartRedirectUrl = Plugin::getInstance()->getSettings()->loadCartRedirectUrl ?? ''; - $redirect = UrlHelper::siteUrl($loadCartRedirectUrl); - - if (!$number) { - $error = Craft::t('commerce', 'A cart number must be specified.'); - if ($this->request->getAcceptsJson()) { - return $this->asFailure($error); - } - $this->setFailFlash($error); - return $this->request->getIsGet() ? $this->redirect($redirect) : null; - } - - $cart = Order::find()->number($number)->isCompleted(false)->one(); - - if (!$cart) { - $error = Craft::t('commerce', 'Unable to retrieve cart.'); - if ($this->request->getAcceptsJson()) { - return $this->asFailure($error); - } - $this->setFailFlash($error); - return $this->request->getIsGet() ? $this->redirect($redirect) : null; - } - - // Carts without email or addresses don't need token validation - $hasEmail = (bool)$cart->getEmail(); - $hasAddresses = $cart->billingAddressId || $cart->shippingAddressId; - - if ($hasEmail || $hasAddresses) { - $currentUser = Craft::$app->getUser()->getIdentity(); - $hasValidToken = false; - - // Check token if provided - if ($token) { - $tokenData = Craft::$app->getTokens()->getTokenRoute($token); - - if (!$tokenData || !isset($tokenData[1]['cartNumber']) || $tokenData[1]['cartNumber'] !== $number) { - $error = Craft::t('commerce', 'The cart recovery link is invalid. Please request a new one.'); - $challengeUrl = UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number]); - if ($this->request->getAcceptsJson()) { - return $this->asFailure($error, ['challengeUrl' => $challengeUrl]); - } - $this->setFailFlash($error); - return $this->redirect($challengeUrl); - } - - $hasValidToken = true; - } - - // Check permissions if no valid token - if (!$hasValidToken) { - $challengeUrl = UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number]); - if ($currentUser) { - $isCartCustomer = $cart->getCustomer() && $cart->getCustomer()->id === $currentUser->id; - if (!$isCartCustomer) { - if ($this->request->getAcceptsJson()) { - return $this->asFailure( - Craft::t('commerce', 'You do not have permission to load this cart.'), - ['challengeUrl' => $challengeUrl] - ); - } - return $this->redirect($challengeUrl); - } - } else { - if ($this->request->getAcceptsJson()) { - return $this->asFailure( - Craft::t('commerce', 'You must be logged in or provide a valid token to load this cart.'), - ['challengeUrl' => $challengeUrl] - ); - } - return $this->redirect($challengeUrl); - } - } - } - - // Set the token to null on the request so it will not be added to the redirect URL that is generated - $this->request->setToken(null); - $redirect = UrlHelper::siteUrl(path: $loadCartRedirectUrl, siteId: $cart->orderSiteId); - $carts->forgetCart(); - $carts->setSessionCartNumber($number); - - // Reaching this point means the cart was loaded via a valid token or by an authorized user. - // Authorize this session to use the cart even if it belongs to a credentialed user who isn't - // (yet) logged in. If the loader is logged in, Carts::getCart() will acquire the cart to their - // account on the next retrieval. - Craft::$app->getSession()->set('commerce:anonymousCartWithCredentialedCustomer:' . $number, true); - - if ($this->request->getAcceptsJson()) { - return $this->asSuccess(); - } - - return $this->request->getIsGet() ? $this->redirect($redirect) : $this->redirectToPostedUrl(); - } - - /** - * @return Response|null - * @throws BadRequestHttpException - * @throws ElementNotFoundException - * @throws Exception - * @throws HttpException - * @throws NotFoundHttpException - * @throws Throwable - * @since 3.3 - */ - public function actionComplete(): ?Response - { - /** @var Plugin $plugin */ - $plugin = Plugin::getInstance(); - $this->requirePostRequest(); - - $this->_cart = $this->_getCart(); - $errors = []; - - if (!$this->_cart->getStore()->getAllowCheckoutWithoutPayment()) { - throw new HttpException(401, Craft::t('commerce', 'You must make a payment to complete the order.')); - } - - $lockName = 'completeOrder'; - $mutex = Craft::$app->getMutex(); - if (!$mutex->acquire($lockName, 10)) { - $this->_cart->addError('isComplete', Craft::t('commerce', 'Unable to complete order: another request is already in progress.')); - return $this->_returnCart(); - } - - // Check email address exists on order. - if (empty($this->_cart->email)) { - $errors['email'] = Craft::t('commerce', 'No customer email address exists on this cart.'); - } - - if ($this->_cart->getStore()->getAllowEmptyCartOnCheckout() && $this->_cart->getIsEmpty()) { - $errors['lineItems'] = Craft::t('commerce', 'Order can not be empty.'); - } - - if ($this->_cart->getStore()->getRequireShippingMethodSelectionAtCheckout() && !$this->_cart->shippingMethodHandle) { - $errors['shippingMethodHandle'] = Craft::t('commerce', 'There is no shipping method selected for this order.'); - } - - if ($this->_cart->getStore()->getRequireBillingAddressAtCheckout() && !$this->_cart->billingAddressId) { - $errors['billingAddressId'] = Craft::t('commerce', 'Billing address required.'); - } - - if ($this->_cart->getStore()->getRequireShippingAddressAtCheckout() && !$this->_cart->shippingAddressId) { - $errors['shippingAddressId'] = Craft::t('commerce', 'Shipping address required.'); - } - - // Set if the customer should be registered on order completion - if ($this->request->getBodyParam('registerUserOnOrderComplete')) { - $this->_cart->registerUserOnOrderComplete = true; - } - - if ($this->request->getBodyParam('registerUserOnOrderComplete') === 'false') { - $this->_cart->registerUserOnOrderComplete = false; - } - - if (!empty($errors)) { - $this->_cart->addErrors($errors); - } - - - if (empty($errors)) { - try { - $completedSuccess = $this->_cart->markAsComplete(); - } catch (\Exception) { - $completedSuccess = false; - } - - if (!$completedSuccess) { - $this->_cart->addError('isComplete', Craft::t('commerce', 'Completing order failed.')); - } - } - - $mutex->release($lockName); - return $this->_returnCart(); - } - - /** - * @param $lineItemId |null - */ - private function _getCartLineItemById(?int $lineItemId): ?LineItem - { - $lineItem = null; - - foreach ($this->_cart->getLineItems() as $item) { - if ($item->id && $item->id == $lineItemId) { - $lineItem = $item; - } - } - - return $lineItem; - } - - /** - * @return Response|null - * @throws BadRequestHttpException - * @throws ElementNotFoundException - * @throws Exception - * @throws Throwable - */ - private function _returnCart(): ?Response - { - // Allow validation of custom fields when passing this param - $validateCustomFields = Plugin::getInstance()->getSettings()->validateCartCustomFieldsOnSubmission; - - // Do we want to validate fields submitted - $customFieldAttributes = []; - - if ($validateCustomFields) { - // $fields will be null so - if ($submittedFields = $this->request->getBodyParam('fields')) { - $this->_cart->setScenario(Element::SCENARIO_LIVE); - - $vp = new VersionParser(); - $currentCraftVersion = $vp->normalize(Craft::$app->getVersion()); - $v44 = $vp->normalize('4.4.0'); - - // since Craft 4.4.0, custom fields passed to Element::validate() need to be prepended with 'field:' - // @TODO Remove the pre-Craft 4.4 branch in Commerce 6.0 once Craft >= 4.4 is the minimum requirement - if (Comparator::greaterThanOrEqualTo($currentCraftVersion, $v44)) { - $customFieldAttributes = array_map( - fn($value) => 'field:' . $value, - array_keys($submittedFields) - ); - } else { - $customFieldAttributes = array_keys($submittedFields); - } - } - } - - $attributes = array_merge($this->_cart->activeAttributes(), $customFieldAttributes); - - $updateCartSearchIndexes = Plugin::getInstance()->getSettings()->updateCartSearchIndexes; - - // Do not clear errors, as errors could be added to the cart before _returnCart is called. - if (!$this->_cart->validate($attributes, false) || !Craft::$app->getElements()->saveElement($this->_cart, false, false, $updateCartSearchIndexes)) { - $error = Craft::t('commerce', 'Unable to update cart.'); - $message = $this->request->getValidatedBodyParam('failMessage') ?? $error; - - if ($this->_mutex && $this->_mutexLockName) { - $this->_mutex->release($this->_mutexLockName); - } - - $data = [ - $this->_cartVariable => $this->cartArray($this->_cart), - ]; - - $originalCart = Order::find()->id($this->_cart->id)->isCompleted(null)->one(); - - if ($originalCart && $this->_cart->number == $originalCart->number) { - $data['original' . StringHelper::toTitleCase($this->_cartVariable)] = $this->cartArray($originalCart); - } - - return $this->asModelFailure( - $this->_cart, - $message, - 'cart', - $data, - [ - $this->_cartVariable => $this->_cart, - ] - ); - } - - $cartUpdatedMessage = Craft::t('commerce', 'Cart updated.'); - $message = $this->request->getValidatedBodyParam('successMessage') ?? $cartUpdatedMessage; - - Craft::$app->getUrlManager()->setRouteParams([ - $this->_cartVariable => $this->_cart, - ]); - - if ($this->_mutex && $this->_mutexLockName) { - $this->_mutex->release($this->_mutexLockName); - } - - return $this->asModelSuccess( - $this->_cart, - $message, - 'cart', - [ - $this->_cartVariable => $this->cartArray($this->_cart), - ] - ); - } - - /** - * @param bool $forceSave Force the cart to save to the DB - * - * @throws ElementNotFoundException - * @throws Exception - * @throws NotFoundHttpException - * @throws Throwable - */ - private function _getCart(bool $forceSave = false): Order - { - $orderNumber = $this->request->getBodyParam('number'); - - if ($orderNumber) { - // Get the cart from the order number - $cart = Order::find()->number($orderNumber)->isCompleted(false)->one(); - - if ($cart === null) { - throw new NotFoundHttpException('Cart not found'); - } - - return $cart; - } - - $requestForceSave = (bool)$this->request->getBodyParam('forceSave'); - $doForceSave = ($requestForceSave || $forceSave); - - $this->_cart = Plugin::getInstance()->getCarts()->getCart($doForceSave); - - return $this->_cart; - } - - /** - * Set addresses on the cart. - */ - private function _setAddresses(): void - { - $currentUser = Craft::$app->getUser()->getIdentity(); - - $setShippingAddress = true; - if ($this->request->getParam('clearShippingAddress') !== null) { - $this->_cart->setShippingAddress(null); - $this->_cart->sourceShippingAddressId = null; - $setShippingAddress = false; - } - - $setBillingAddress = true; - if ($this->request->getParam('clearBillingAddress') !== null) { - $this->_cart->setBillingAddress(null); - $this->_cart->sourceBillingAddressId = null; - $setBillingAddress = false; - } - - if ($this->request->getParam('clearAddresses') !== null) { - $this->_cart->setShippingAddress(null); - $this->_cart->sourceShippingAddressId = null; - $this->_cart->setBillingAddress(null); - $this->_cart->sourceBillingAddressId = null; - $setBillingAddress = false; - $setShippingAddress = false; - } - - // Copy address options - $shippingIsBilling = $this->request->getParam('shippingAddressSameAsBilling'); - $billingIsShipping = $this->request->getParam('billingAddressSameAsShipping'); - $estimatedBillingIsShipping = $this->request->getParam('estimatedBillingAddressSameAsShipping'); - - $shippingAddress = $this->request->getParam('shippingAddress'); - $estimatedShippingAddress = $this->request->getParam('estimatedShippingAddress'); - $billingAddress = $this->request->getParam('billingAddress'); - $estimatedBillingAddress = $this->request->getParam('estimatedBillingAddress'); - - // Use an address ID from the customer address book to populate the address - $shippingAddressId = $this->request->getParam('shippingAddressId'); - $billingAddressId = $this->request->getParam('billingAddressId'); - - if ($setShippingAddress) { - // Shipping address - if ($shippingAddressId && !$shippingIsBilling) { - /** @var Address|null $userShippingAddress */ - $userShippingAddress = Collection::make($currentUser->getAddresses())->firstWhere('id', $shippingAddressId); - - // If a user's address ID has been submitted duplicate the address to the order - if ($userShippingAddress) { - $this->_cart->sourceShippingAddressId = $shippingAddressId; - $validShippingAddress = $userShippingAddress->validate(); - - if (!$validShippingAddress) { - $this->_cart->addModelErrors($userShippingAddress, 'shippingAddress'); - } else { - /** @var Address $cartShippingAddress */ - $cartShippingAddress = Craft::$app->getElements()->duplicateElement($userShippingAddress, - [ - 'primaryOwner' => $this->_cart, - 'owner' => $this->_cart, - ]); - $this->_cart->setShippingAddress($cartShippingAddress); - } - - if ($billingIsShipping) { - $this->_cart->sourceBillingAddressId = $userShippingAddress->id; - - if ($validShippingAddress) { - $this->_cart->setBillingAddress($cartShippingAddress); - } - } - } - } elseif ($shippingAddress && !$shippingIsBilling) { - $this->_cart->sourceShippingAddressId = null; - $this->_cart->setShippingAddress($shippingAddress); - - if (!empty($shippingAddress['fields']) && $this->_cart->getShippingAddress()) { - $this->_cart->getShippingAddress()->setFieldValues($shippingAddress['fields']); - } - - if ($billingIsShipping) { - $this->_cart->sourceBillingAddressId = null; - $this->_cart->setBillingAddress($this->_cart->getShippingAddress()); - } - } - } - - // Billing address - if ($setBillingAddress) { - if ($billingAddressId && !$billingIsShipping) { - /** @var Address|null $userBillingAddress */ - $userBillingAddress = Collection::make($currentUser->getAddresses())->firstWhere('id', $billingAddressId); - - // If a user's address ID has been submitted duplicate the address to the order - if ($userBillingAddress) { - $this->_cart->sourceBillingAddressId = $billingAddressId; - $validBillingAddress = $userBillingAddress->validate(); - - if (!$validBillingAddress) { - $this->_cart->addModelErrors($userBillingAddress, 'billingAddress'); - } else { - /** @var Address $cartBillingAddress */ - $cartBillingAddress = Craft::$app->getElements()->duplicateElement($userBillingAddress, [ - 'primaryOwner' => $this->_cart, - 'owner' => $this->_cart, - ]); - $this->_cart->setBillingAddress($cartBillingAddress); - } - - if ($shippingIsBilling) { - $this->_cart->sourceShippingAddressId = $userBillingAddress->id; - - if ($validBillingAddress) { - $this->_cart->setShippingAddress($cartBillingAddress); - } - } - } - } elseif ($billingAddress && !$billingIsShipping) { - $this->_cart->sourceBillingAddressId = null; - $this->_cart->setBillingAddress($billingAddress); - - if (!empty($billingAddress['fields']) && $this->_cart->getBillingAddress()) { - $this->_cart->getBillingAddress()->setFieldValues($billingAddress['fields']); - } - - if ($shippingIsBilling) { - $this->_cart->sourceShippingAddressId = null; - $this->_cart->setShippingAddress($this->_cart->getBillingAddress()); - } - } - } - - // Estimated Shipping Address - if ($estimatedShippingAddress) { - if ($this->_cart->estimatedShippingAddressId) { - if ($address = Address::findOne($this->_cart->estimatedShippingAddressId)) { - $address->setAttributes($estimatedShippingAddress); - $estimatedShippingAddress = $address; - } - } - - $this->_cart->setEstimatedShippingAddress($estimatedShippingAddress); - } - - // Estimated Billing Address - if ($estimatedBillingAddress) { - if ($this->_cart->estimatedBillingAddressId) { - if ($address = Address::findOne($this->_cart->estimatedBillingAddressId)) { - $address->setAttributes($estimatedBillingAddress); - $estimatedBillingAddress = $address; - } - } - - $this->_cart->setEstimatedBillingAddress($estimatedBillingAddress); - } - - - $this->_cart->billingSameAsShipping = (bool)$billingIsShipping; - $this->_cart->shippingSameAsBilling = (bool)$shippingIsBilling; - $this->_cart->estimatedBillingSameAsShipping = (bool)$estimatedBillingIsShipping; - - // Set primary addresses - if ($setShippingAddress) { - $makePrimaryShippingAddress = $this->request->getBodyParam('makePrimaryShippingAddress'); - if ($makePrimaryShippingAddress !== null) { - $this->_cart->makePrimaryShippingAddress = (bool)$makePrimaryShippingAddress; - } - } - if ($setBillingAddress) { - $makePrimaryBillingAddress = $this->request->getBodyParam('makePrimaryBillingAddress'); - if ($makePrimaryBillingAddress !== null) { - $this->_cart->makePrimaryBillingAddress = (bool)$makePrimaryBillingAddress; - } - } - } - - /** - * Renders the cart email challenge template. - */ - private function renderCartEmailChallenge(Order $cart, string $cartNumber): Response - { - return $this->renderTemplate('commerce/_cart/email-challenge', [ - 'cart' => $cart, - 'cartNumber' => $cartNumber, - ], View::TEMPLATE_MODE_CP); - } - - /** - * Displays the email challenge form for cart recovery. - * @since 5.7.0 - */ - public function actionEmailChallenge(): Response - { - $number = $this->request->getQueryParam('number'); - - if (!$number) { - throw new BadRequestHttpException('Cart number required'); - } - - $cart = Order::find()->number($number)->isCompleted(false)->one(); - - if (!$cart || !$cart->getEmail()) { - throw new HttpException(404, 'Cart not found'); - } - - return $this->renderCartEmailChallenge($cart, $number); - } - - /** - * Handles the email challenge form submission for cart recovery. - * @since 5.7.0 - */ - public function actionCartChallenge(): Response - { - $this->requirePostRequest(); - - $cartNumberHash = $this->request->getBodyParam('cartNumberHash'); - - if (!$cartNumberHash) { - throw new BadRequestHttpException('Cart number hash is required'); - } - - $cartNumber = Craft::$app->getSecurity()->validateData($cartNumberHash); - - if ($cartNumber === false) { - throw new BadRequestHttpException('Invalid cart number hash'); - } - - $cart = Order::find()->number($cartNumber)->isCompleted(false)->one(); - - if (!$cart) { - throw new HttpException(404, 'Cart not found'); - } - - $loadCartUrl = Plugin::getInstance()->getCarts()->getLoadCartUrl($cart); - - if (!Craft::$app->getMailer()->composeFromKey('commerce_cart_recovery', [ - 'link' => $loadCartUrl, - 'cart' => $cart, - ])->setTo($cart->email)->send()) { - Craft::$app->getSession()->setError(Craft::t('commerce', 'Failed to send email. Please try again.')); - return $this->renderCartEmailChallenge($cart, $cartNumber); - } - - return $this->redirect(UrlHelper::actionUrl('commerce/cart/cart-sent', ['hash' => $cartNumberHash])); - } - - /** - * Displays success page after cart recovery email is sent. - * @since 5.7.0 - */ - public function actionCartSent(): Response - { - $cartNumberHash = $this->request->getQueryParam('hash'); - - if (!$cartNumberHash) { - throw new BadRequestHttpException('Hash parameter required'); - } - - $cartNumber = Craft::$app->getSecurity()->validateData($cartNumberHash); - - if ($cartNumber === false) { - throw new HttpException(400, 'Invalid hash parameter'); - } - - $cart = Order::find()->number($cartNumber)->isCompleted(false)->one(); - - if (!$cart) { - throw new HttpException(404, 'Cart not found'); - } - - return $this->renderTemplate('commerce/_cart/email-sent', [ - 'email' => $cart->getMaskedEmail(), - ], View::TEMPLATE_MODE_CP); - } -} diff --git a/src/controllers/CatalogPricingController.php b/src/controllers/CatalogPricingController.php deleted file mode 100755 index 356651eb3c..0000000000 --- a/src/controllers/CatalogPricingController.php +++ /dev/null @@ -1,230 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricingController extends BaseStoreManagementController -{ - public function beforeAction($action): bool - { - if (!parent::beforeAction($action)) { - return false; - } - - $this->requirePermission('commerce-managePromotions'); - - if (!Plugin::getInstance()->getCatalogPricingRules()->canUseCatalogPricingRules()) { - throw new ForbiddenHttpException('Unable to use catalog pricing rules while sales exist.'); - } - - return true; - } - - /** - * @return Response - * @throws InvalidConfigException - * @throws NotFoundHttpException - * @throws SiteNotFoundException - */ - public function actionIndex(): Response - { - $siteHandle = Craft::$app->getRequest()->getQueryParam('site'); - $site = $siteHandle === null ? Craft::$app->getSites()->getPrimarySite() : Craft::$app->getSites()->getSiteByHandle($siteHandle); - if ($site === null) { - throw new NotFoundHttpException('Site not found'); - } - - /** @var Site|StoreBehavior $site */ - $store = $site->getStore(); - - $purchasableId = Craft::$app->getRequest()->getQueryParam('purchasableId'); - $conditionBuilder = Craft::$app->getConditions()->createCondition([ - 'class' => CatalogPricingCondition::class, - 'allPrices' => true, - ]); - - if ($purchasableId && $purchasableElementType = Craft::$app->getElements()->getElementTypeById($purchasableId)) { - $purchasableConditionRule = Craft::$app->getConditions()->createConditionRule([ - 'class' => CatalogPricingPurchasableConditionRule::class, - 'elementIds' => [$purchasableElementType => [$purchasableId]], - ]); - - $conditionBuilder->addConditionRule($purchasableConditionRule); - } - - $catalogPrices = Plugin::getInstance()->getCatalogPricing()->getCatalogPrices($store->id, $conditionBuilder, limit: 100, offset: 0); - $pageInfo = Plugin::getInstance()->getCatalogPricing()->getCatalogPricesPageInfo($store->id, $conditionBuilder); - - Craft::$app->getView()->registerAssetBundle(HtmxAsset::class); - Craft::$app->getView()->registerAssetBundle(CatalogPricingAsset::class); - - return $this->renderTemplate('commerce/prices/_index', [ - 'catalogPrices' => $catalogPrices->all(), - 'pageInfo' => $pageInfo, - 'condition' => $conditionBuilder, - 'areCatalogPricingJobsRunning' => Plugin::getInstance()->getCatalogPricing()->areCatalogPricingJobsRunning(), - ]); - } - - /** - * @return Response - * @throws InvalidConfigException - */ - public function actionFilter(): Response - { - $condition = $this->request->getBodyParam('condition') ?? ['class' => CatalogPricingCondition::class]; - $conditionBuilder = Craft::$app->getConditions()->createCondition($condition); - $conditionBuilderHtml = $conditionBuilder->getBuilderHtml(); - - $view = Craft::$app->getView(); - - return $this->asJson([ - 'condition' => $conditionBuilder->getConfig(), - 'hudHtml' => $conditionBuilderHtml, - 'headHtml' => $view->getHeadHtml(), - 'bodyHtml' => $view->getBodyHtml(), - ]); - } - - /** - * @return Response - * @throws BadRequestHttpException - */ - public function actionPrices(): Response - { - $siteId = $this->request->getRequiredBodyParam('siteId'); - $condition = $this->request->getBodyParam('condition'); - $searchText = $this->request->getBodyParam('searchText'); - $limit = $this->request->getBodyParam('limit'); - $offset = $this->request->getBodyParam('offset', 0); - $includeBasePrices = $this->request->getBodyParam('includeBasePrices', true); - $forPurchasable = $this->request->getBodyParam('forPurchasable', false); - $isPriceRecalculation = array_key_exists('basePrice', $this->request->getBodyParams()) || array_key_exists('basePromotionalPrice', $this->request->getBodyParams()); - - $conditionBuilder = null; - if ($condition && isset($condition['condition'])) { - /** @var CatalogPricingCondition $conditionBuilder */ - $conditionBuilder = Craft::$app->getConditions()->createCondition($condition['condition']); - } - - /** @var Site|null|StoreBehavior $site */ - $site = Craft::$app->getSites()->getSiteById($siteId); - if (!$site) { - throw new InvalidArgumentException('Invalid site ID: ' . $siteId); - } - - $catalogPrices = Plugin::getInstance()->getCatalogPricing()->getCatalogPrices($site->getStore()->id, $conditionBuilder, $includeBasePrices, $searchText, $limit, $offset); - $catalogPricesPageInfo = null; - if ($limit !== null && $offset !== null) { - $catalogPricesPageInfo = Plugin::getInstance()->getCatalogPricing()->getCatalogPricesPageInfo($site->getStore()->id, $conditionBuilder, $includeBasePrices, $searchText, $limit, $offset); - } - - $view = Craft::$app->getView(); - - $tableHtml = $view->renderTemplate('commerce/prices/_table', [ - 'catalogPrices' => $catalogPrices->all(), - 'showPurchasable' => !$forPurchasable, - 'removeMargin' => $forPurchasable, - ]); - - return $this->asJson([ - 'headHtml' => $view->getHeadHtml(), - 'bodyHtml' => $view->getBodyHtml(), - 'tableHtml' => $tableHtml, - 'pageInfo' => $catalogPricesPageInfo, - ]); - } - - /** - * @return Response - * @throws InvalidConfigException - */ - public function actionQueueStatus(): Response - { - /** @var Site|StoreBehavior|null $site */ - $site = Cp::requestedSite(); - $storeHandle = $site?->getStore()->handle ?? null; - - return $this->renderTemplate('commerce/prices/_polling', [ - 'areCatalogPricingJobsRunning' => Plugin::getInstance()->getCatalogPricing()->areCatalogPricingJobsRunning(), - 'storeHandle' => $storeHandle, - ]); - } - - /** - * @return string|null - * @throws SiteNotFoundException - * @throws InvalidConfigException - */ - public function actionGetCatalogPrices(): ?string - { - // @TODO Remove this action once the catalog pricing UI refactor lands and no longer needs this endpoint - $purchasableId = $this->request->getBodyParam('purchasableId'); - $storeId = $this->request->getBodyParam('storeId'); - - if ($purchasableId === null) { - return Html::tag('div', Craft::t('commerce', 'Purchasable ID is required.'), ['class' => 'error']); - } - - if ($storeId === null) { - return Html::tag('div', Craft::t('commerce', 'Purchasable ID is required.'), ['class' => 'error']); - } - - $isPriceRecalculation = array_key_exists('basePrice', $this->request->getBodyParams()) || array_key_exists('basePromotionalPrice', $this->request->getBodyParams()); - - if (!$isPriceRecalculation) { - // No need to generate prices if we are just getting the standard price list - return Purchasable::catalogPricingRulesTableByPurchasableId($purchasableId, $storeId); - } - - $basePrice = $this->request->getBodyParam('basePrice'); - $basePromotionalPrice = $this->request->getBodyParam('basePromotionalPrice'); - - $basePrice = $basePrice ? (float)$basePrice : null; - $basePromotionalPrice = $basePromotionalPrice ? (float)$basePromotionalPrice : null; - - $allPurchasableRules = Plugin::getInstance()->getCatalogPricingRules()->getAllCatalogPricingRulesByPurchasableId($purchasableId, $storeId); - $catalogPricing = Plugin::getInstance()->getCatalogPricing()->getCatalogPricesByPurchasableId($purchasableId); - - $catalogPricing->each(function(CatalogPricing $cp) use ($basePrice, $basePromotionalPrice, $allPurchasableRules) { - $rule = $allPurchasableRules->firstWhere('id', $cp->catalogPricingRuleId); - if (!$rule) { - return; - } - - $cp->price = Plugin::getInstance()->getCatalogPricingRules()->generateRulePriceFromPrice($basePrice, $basePromotionalPrice, $rule); - }); - - return Purchasable::catalogPricingRulesTableByPurchasableId($purchasableId, $storeId, $catalogPricing); - } -} diff --git a/src/controllers/CatalogPricingRulesController.php b/src/controllers/CatalogPricingRulesController.php deleted file mode 100755 index 6b1b9c33fa..0000000000 --- a/src/controllers/CatalogPricingRulesController.php +++ /dev/null @@ -1,508 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricingRulesController extends BaseStoreManagementController -{ - public function beforeAction($action): bool - { - if (!parent::beforeAction($action)) { - return false; - } - - $this->requirePermission('commerce-managePromotions'); - - return true; - } - - /** - * @throws InvalidConfigException - */ - public function actionIndex(?string $storeHandle = null): Response - { - if ($storeHandle !== null) { - $store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle); - } else { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $catalogPricingRules = Plugin::getInstance()->getcatalogPricingRules()->getAllcatalogPricingRules($store->id); - - $actionButtonHtml = Craft::$app->getUser()->getIdentity()->can('commerce-createCatalogPricingRules') ? - Html::a(Craft::t('commerce', 'New catalog pricing rule'), - $store->getStoreSettingsUrl('pricing-rules/new'), - ['class' => 'btn submit add icon']) - : ''; - - $this->getView()->registerTranslations('commerce', [ - 'Delete', - 'Disabled', - 'Duration', - 'Effect', - 'Enabled', - 'Is Promotional Price?', - 'Name', - 'No catalog pricing rules exist yet.', - 'No', - 'Set status', - 'Yes', - ]); - - $tableData = []; - $catalogPricingRules->each(function(CatalogPricingRule $pcr) use (&$tableData, $store) { - $effect = $pcr->apply === CatalogPricingRuleRecord::APPLY_BY_PERCENT || $pcr->apply === CatalogPricingRuleRecord::APPLY_TO_PERCENT - ? $pcr->applyAmountAsPercent . ' ' . ($pcr->apply === CatalogPricingRuleRecord::APPLY_BY_PERCENT - ? Craft::t('commerce', '(off original price)') - : Craft::t('commerce', '(of original price)')) - : Currency::formatAsCurrency($pcr->applyAmountAsFlat, Plugin::getInstance()->getPaymentCurrencies()->getPrimaryPaymentCurrency($store->id)->iso, true) . ' ' . ($pcr->apply === CatalogPricingRuleRecord::APPLY_BY_FLAT - ? Craft::t('commerce', '(off original price)') - : Craft::t('commerce', '(new price)')); - - $dateRange = ($pcr->dateFrom ? Craft::$app->getFormatter()->asDatetime($pcr->dateFrom, 'short') : '∞') . ' - ' . ($pcr->dateTo ? Craft::$app->getFormatter()->asDatetime($pcr->dateTo, 'short') : '∞'); - $dateRange = !$pcr->dateFrom && !$pcr->dateTo ? '∞' : $dateRange; - - $tableData[] = [ - 'id' => $pcr->id, - 'title' => Craft::t('site', $pcr->name), - 'url' => $pcr->getCpEditUrl(), - 'status' => $pcr->enabled ? true : false, - 'duration' => $dateRange, - 'effect' => $effect, - 'isPromotionalPrice' => $pcr->isPromotionalPrice, - ]; - }); - - $tableData = Json::encode($tableData); - - $actions = []; - if (Craft::$app->getUser()->getIdentity()->can('commerce-editCatalogPricingRules')) { - $actions[] = [ - 'label' => Craft::t('commerce', 'Set status'), - 'actions' => [ - [ - 'label' => Craft::t('commerce', 'Enabled'), - 'action' => 'commerce/catalog-pricing-rules/update-status', - 'param' => 'status', - 'value' => 'enabled', - 'status' => 'enabled', - ], - [ - 'label' => Craft::t('commerce', 'Disabled'), - 'action' => 'commerce/catalog-pricing-rules/update-status', - 'param' => 'status', - 'value' => 'disabled', - 'status' => 'disabled', - ], - ], - ]; - } - - $deleteAction = null; - if (Craft::$app->getUser()->getIdentity()->can('commerce-deleteCatalogPricingRules')) { - $actions[] = [ - 'label' => Craft::t('commerce', 'Delete'), - 'action' => 'commerce/catalog-pricing-rules/delete', - 'error' => true, - ]; - $deleteAction = '"commerce/catalog-pricing-rules/delete"'; - } - - - $actions = Json::encode($actions); - - $js = <<'; - } - } - }, -]; - -new Craft.VueAdminTable({ - actions: actions, - checkboxes: true, - columns: columns, - fullPane: false, - container: '#pcr-vue-admin-table', - deleteAction: {$deleteAction}, - emptyMessage: Craft.t('commerce', 'No catalog pricing rules exist yet.'), - padded: true, - tableData: {$tableData} -}); -JS; - - $this->getView()->registerJs($js, View::POS_END); - - return $this->asStoreManagementCpScreen($storeHandle) - ->additionalButtonsHtml($actionButtonHtml) - ->contentTemplate('commerce/store-management/pricing-rules/index'); - } - - /** - * @param int|null $id - * @param CatalogPricingRule|null $catalogPricingRule - * @throws HttpException - * @throws InvalidConfigException - */ - public function actionEdit(?string $storeHandle = null, int $id = null, CatalogPricingRule $catalogPricingRule = null): Response - { - if ($id === null) { - $this->requirePermission('commerce-createCatalogPricingRules'); - } else { - $this->requirePermission('commerce-editCatalogPricingRules'); - } - - $store = null; - if ($storeHandle !== null) { - $store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle); - } - - $store ??= Plugin::getInstance()->getStores()->getPrimaryStore(); - - $variables = compact('id', 'catalogPricingRule', 'storeHandle'); - - if (!$variables['catalogPricingRule']) { - if ($variables['id']) { - $variables['catalogPricingRule'] = Plugin::getInstance()->getcatalogPricingRules()->getcatalogPricingRuleById($variables['id'], $store->id); - - if (!$variables['catalogPricingRule'] || $variables['catalogPricingRule']->storeId !== $store->id) { - throw new HttpException(404); - } - } else { - /** @var CatalogPricingRule $catalogPricingRule */ - $catalogPricingRule = Craft::createObject([ - 'class' => CatalogPricingRule::class, - 'storeId' => $store->id, - ]); - - $purchasableId = Craft::$app->getRequest()->getParam('purchasableId'); - if ($purchasableId && $purchasableType = Craft::$app->getElements()->getElementTypeById($purchasableId)) { - $purchasable = Craft::$app->getElements()->getElementById($purchasableId, $purchasableType, Cp::requestedSite()->id); - - // Create a "first pass" name for the rule - if ($purchasable && $purchasable->title) { - $catalogPricingRule->name = Craft::t('commerce', '{name} catalog price', ['name' => $purchasable->title]); - } - - $rule = Craft::$app->getConditions()->createConditionRule([ - 'class' => PurchasableConditionRule::class, - 'elementIds' => [$purchasableType => [$purchasableId]], - ]); - - /** @var CatalogPricingRulePurchasableCondition $purchasableCondition */ - $purchasableCondition = Craft::$app->getConditions()->createCondition(CatalogPricingRulePurchasableCondition::class); - $purchasableCondition->addConditionRule($rule); - $catalogPricingRule->setPurchasableCondition($purchasableCondition); - } - - $variables['catalogPricingRule'] = $catalogPricingRule; - } - } - - DebugPanel::prependOrAppendModelTab(model: $variables['catalogPricingRule'], prepend: true); - - $variables = $this->_populateVariables($variables); - - return $this->asStoreManagementCpScreen($storeHandle, false) - ->title(Craft::t('commerce', 'Catalog Pricing Rule')) - ->addCrumb(Craft::t('commerce', 'Pricing Rules'), $store->getStoreSettingsUrl('pricing-rules')) - ->action('commerce/catalog-pricing-rules/save') - ->redirectUrl('commerce/store-management/' . $store->handle . '/pricing-rules') - ->metaSidebarTemplate('commerce/store-management/pricing-rules/_sidebar', $variables) - ->tabs([ - [ - 'label' => Craft::t('commerce', 'Rule'), - 'url' => '#rule', - 'class' => array_filter([$variables['catalogPricingRule']->getErrors() ? 'error' : null]), - ], - [ - 'label' => Craft::t('commerce', 'Conditions'), - 'url' => '#conditions', - ], - [ - 'label' => Craft::t('commerce', 'Actions'), - 'url' => '#actions', - 'class' => array_filter([($variables['catalogPricingRule']->getErrors('applyAmount') or $variables['catalogPricingRule']->getErrors('apply')) ? 'error' : null]), - ], - ]) - ->contentTemplate('commerce/store-management/pricing-rules/_edit', $variables); - } - - /** - * @throws Exception - * @throws \yii\base\Exception - * @throws BadRequestHttpException - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - $id = $this->request->getBodyParam('id'); - $storeId = $this->request->getBodyParam('storeId'); - - if ($id) { - $catalogPricingRule = Plugin::getInstance()->getcatalogPricingRules()->getcatalogPricingRuleById($id, $storeId); - if (!$catalogPricingRule) { - throw new NotFoundHttpException('Catalog Pricing Rule not found'); - } - } else { - $catalogPricingRule = Craft::createObject(CatalogPricingRule::class); - } - - if ($catalogPricingRule->id === null) { - $this->requirePermission('commerce-createCatalogPricingRules'); - } else { - $this->requirePermission('commerce-editCatalogPricingRules'); - } - - $catalogPricingRule->storeId = $storeId; - $catalogPricingRule->name = $this->request->getBodyParam('name'); - $catalogPricingRule->description = $this->request->getBodyParam('description'); - $catalogPricingRule->apply = $this->request->getBodyParam('apply'); - $catalogPricingRule->enabled = (bool)$this->request->getBodyParam('enabled'); - $catalogPricingRule->isPromotionalPrice = (bool)$this->request->getBodyParam('isPromotionalPrice'); - $catalogPricingRule->applyPriceType = $this->request->getBodyParam('applyPriceType'); - - $catalogPricingRule->dateFrom = - ($date = $this->request->getBodyParam('dateFrom')) !== false - ? (DateTimeHelper::toDateTime($date) ?: null) - : $catalogPricingRule->dateFrom; - - $catalogPricingRule->dateTo = - ($date = $this->request->getBodyParam('dateTo')) !== false - ? (DateTimeHelper::toDateTime($date) ?: null) - : $catalogPricingRule->dateTo; - - $applyAmount = $this->request->getBodyParam('applyAmount'); - - if ($catalogPricingRule->apply == CatalogPricingRuleRecord::APPLY_BY_PERCENT || $catalogPricingRule->apply == CatalogPricingRuleRecord::APPLY_TO_PERCENT) { - $applyAmount = Localization::normalizeNumber($applyAmount); - $catalogPricingRule->applyAmount = (float)$applyAmount / -100; - } else { - if (is_array($applyAmount)) { - $applyAmount += [ - 'currency' => $catalogPricingRule->getStore()->getCurrency(), - ]; - $applyAmount = MoneyHelper::toDecimal(MoneyHelper::toMoney($applyAmount)); - } - $catalogPricingRule->applyAmount = (float)$applyAmount * -1; - } - - // Set product conditions - $productCondition = $this->request->getBodyParam('productCondition'); - if ($productCondition === null) { - $productCondition = Craft::$app->getConditions()->createCondition([ - 'class' => CatalogPricingRuleProductCondition::class, - ]); - } - - $catalogPricingRule->setProductCondition($productCondition); - - // Set product conditions - $variantCondition = $this->request->getBodyParam('variantCondition'); - if ($variantCondition === null) { - $variantCondition = Craft::$app->getConditions()->createCondition([ - 'class' => CatalogPricingRuleVariantCondition::class, - ]); - } - - $catalogPricingRule->setVariantCondition($variantCondition); - - // Set purchasable conditions - $purchasableCondition = $this->request->getBodyParam('purchasableCondition'); - if ($purchasableCondition === null) { - $purchasableCondition = Craft::$app->getConditions()->createCondition([ - 'class' => CatalogPricingRulePurchasableCondition::class, - ]); - } - - $catalogPricingRule->setPurchasableCondition($purchasableCondition); - - // Set user conditions - $catalogPricingRule->setCustomerCondition($this->request->getBodyParam('customerCondition')); - - // Save it - if (Plugin::getInstance()->getcatalogPricingRules()->saveCatalogPricingRule($catalogPricingRule)) { - return $this->asSuccess(Craft::t('commerce', 'Catalog pricing rule saved.')); - } - - $variables = compact('catalogPricingRule'); - $this->_populateVariables($variables); - - return $this->asFailure(Craft::t('commerce', 'Couldn’t save catalog pricing rule.'), [], $variables); - } - - /** - * @throws Exception - * @throws Throwable - * @throws StaleObjectException - * @throws BadRequestHttpException - */ - public function actionDelete(): Response - { - $this->requirePermission('commerce-deleteCatalogPricingRules'); - $this->requirePostRequest(); - - $id = $this->request->getBodyParam('id'); - $ids = $this->request->getBodyParam('ids'); - - if ((!$id && empty($ids)) || ($id && !empty($ids))) { - throw new BadRequestHttpException('id or ids must be specified.'); - } - - if ($id) { - $this->requireAcceptsJson(); - $ids = [$id]; - } - - foreach ($ids as $id) { - Plugin::getInstance()->getcatalogPricingRules()->deletecatalogPricingRuleById($id); - } - - if ($this->request->getAcceptsJson()) { - return $this->asSuccess(); - } - - $this->setSuccessFlash(Craft::t('commerce', 'Catalog pricing rules deleted.')); - - return $this->redirect($this->request->getReferrer()); - } - - /** - * @throws BadRequestHttpException - * @throws \yii\db\Exception - * @throws ForbiddenHttpException - */ - public function actionUpdateStatus(): void - { - $this->requirePostRequest(); - $this->requirePermission('commerce-editCatalogPricingRules'); - - $ids = $this->request->getRequiredBodyParam('ids'); - $status = $this->request->getRequiredBodyParam('status'); - - - if (empty($ids)) { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t update catalog pricing rule statuses.')); - } - - $transaction = Craft::$app->getDb()->beginTransaction(); - $rules = CatalogPricingRuleRecord::find() - ->where(['id' => $ids]) - ->all(); - $storeId = null; - - /** @var CatalogPricingRuleRecord $rule */ - foreach ($rules as $rule) { - $storeId ??= $rule->storeId; - $rule->enabled = ($status == 'enabled'); - $rule->save(); - } - $transaction->commit(); - - Plugin::getInstance()->getCatalogPricing()->createCatalogPricingJob([ - 'catalogPricingRuleIds' => $ids, - 'storeId' => $storeId, - ]); - - $this->setSuccessFlash(Craft::t('commerce', 'Catalog pricing rules updated.')); - } - - /** - * @param $variables - * @return array - * @throws InvalidConfigException - */ - private function _populateVariables($variables): array - { - /** @var CatalogPricingRule $catalogPricingRule */ - $catalogPricingRule = $variables['catalogPricingRule']; - - if ($catalogPricingRule->id) { - $variables['title'] = $catalogPricingRule->name; - } else { - $variables['title'] = Craft::t('commerce', 'Create a new catalog pricing rule'); - } - - //getting user groups map - $groups = Craft::$app->getUserGroups()->getAllGroups(); - $variables['groups'] = ArrayHelper::map($groups, 'id', 'name'); - - $variables['percentSymbol'] = Craft::$app->getFormattingLocale()->getNumberSymbol(Locale::SYMBOL_PERCENT); - $primaryCurrencyIso = Plugin::getInstance()->getPaymentCurrencies()->getPrimaryPaymentCurrencyIso(); - $variables['currencySymbol'] = Craft::$app->getLocale()->getCurrencySymbol($primaryCurrencyIso); - - $variables['applyAmount'] = ''; - if (isset($variables['catalogPricingRule']->applyAmount) && $variables['catalogPricingRule']->applyAmount !== null) { - if ($catalogPricingRule->apply == CatalogPricingRuleRecord::APPLY_BY_PERCENT || $catalogPricingRule->apply == CatalogPricingRuleRecord::APPLY_TO_PERCENT) { - $amount = -(float)$variables['catalogPricingRule']->applyAmount * 100; - $variables['applyAmount'] = Craft::$app->getFormatter()->asDecimal($amount); - } else { - $variables['applyAmount'] = Craft::$app->getFormatter()->asDecimal(-(float)$variables['catalogPricingRule']->applyAmount); - } - } - - $variables['applyOptions'] = [ - ['optgroup' => Craft::t('commerce', 'Reduce price')], - ['label' => Craft::t('commerce', 'Reduce the price by a percentage of the original price'), 'value' => CatalogPricingRuleRecord::APPLY_BY_PERCENT], - ['label' => Craft::t('commerce', 'Reduce the price by a fixed amount'), 'value' => CatalogPricingRuleRecord::APPLY_BY_FLAT], - ['optgroup' => Craft::t('commerce', 'Set price')], - ['label' => Craft::t('commerce', 'Set the price to a percentage of the original price'), 'value' => CatalogPricingRuleRecord::APPLY_TO_PERCENT], - ['label' => Craft::t('commerce', 'Set the price to a flat amount'), 'value' => CatalogPricingRuleRecord::APPLY_TO_FLAT], - ]; - - $variables['applyPriceTypeOptions'] = [ - ['label' => Craft::t('commerce', 'Original price'), 'value' => 'price' ], - ['label' => Craft::t('commerce', 'Original promotional price'), 'value' => 'promotionalPrice' ], - ]; - - return $variables; - } -} diff --git a/src/controllers/DiscountsController.php b/src/controllers/DiscountsController.php deleted file mode 100644 index ee41b6c0ad..0000000000 --- a/src/controllers/DiscountsController.php +++ /dev/null @@ -1,887 +0,0 @@ - - * @since 2.0 - */ -class DiscountsController extends BaseStoreManagementController -{ - public const DISCOUNT_COUNTER_TYPE_TOTAL = 'total'; - public const DISCOUNT_COUNTER_TYPE_EMAIL = 'email'; - public const DISCOUNT_COUNTER_TYPE_CUSTOMER = 'customer'; - - /** - * @inheritdoc - */ - public function init(): void - { - parent::init(); - - $this->requirePermission('commerce-managePromotions'); - } - - /** - * @throws HttpException - */ - public function actionIndex(string $storeHandle = null): Response - { - if ($storeHandle) { - $store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle); - if ($store === null) { - throw new InvalidConfigException('Invalid store.'); - } - } else { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $this->getView()->registerTranslations('commerce', [ - 'Couldn’t reorder discounts.', - 'Delete', - 'Disabled', - 'Discounts reordered.', - 'Duration', - 'Enabled', - 'Require Coupon Code', - 'Ignore Promotions?', - 'Name', - 'No discounts exist yet.', - 'No', - 'Set status', - 'Stops Processing?', - 'Times Used', - 'Yes', - ]); - - $actionButtonHtml = Craft::$app->getUser()->getIdentity()->can('commerce-createDiscounts') - ? Html::a(Craft::t('commerce', 'New discount'), $store->getStoreSettingsUrl('discounts/new'), ['class' => 'btn submit add icon']) - : ''; - - $actions = []; - if (Craft::$app->getUser()->getIdentity()->can('commerce-editDiscounts')) { - $actions[] = [ - 'label' => Craft::t('commerce', 'Set status'), - 'actions' => [ - [ - 'label' => Craft::t('commerce', 'Enabled'), - 'action' => 'commerce/discounts/update-status', - 'param' => 'status', - 'value' => 'enabled', - 'status' => 'enabled', - ], - [ - 'label' => Craft::t('commerce', 'Disabled'), - 'action' => 'commerce/discounts/update-status', - 'param' => 'status', - 'value' => 'disabled', - 'status' => 'disabled', - ], - ], - ]; - } - - $deleteAction = null; - if (Craft::$app->getUser()->getIdentity()->can('commerce-deleteDiscounts')) { - $actions[] = [ - 'label' => Craft::t('commerce', 'Delete'), - 'action' => 'commerce/discounts/delete', - 'error' => true, - ]; - $deleteAction = '"commerce/discounts/delete"'; - } - - $actions = Json::encode($actions); - - $tableDataEndpoint = UrlHelper::actionUrl('commerce/discounts/table-data', ['storeId' => $store->id]); - - $js = <<'; - } - - return ''; - } - }, - { name: 'duration', title: Craft.t('commerce', 'Duration') }, - { name: 'timesUsed', title: Craft.t('commerce', 'Times Used') }, - { name: 'stop', title: Craft.t('commerce', 'Stops Processing?'), - callback: function(value) { - if (value) { - return ''; - } - - return ''; - } - }, - { name: 'ignore', title: Craft.t('commerce', 'Ignore Promotions?'), - callback: function(value) { - if (value) { - return ''; - } - - return ''; - } - }, - ]; - - new Craft.VueAdminTable({ - actions: actions, - checkboxes: true, - columns: columns, - fullPane: false, - container: '#discounts-vue-admin-table', - allowMultipleDeletions: true, - deleteAction: {$deleteAction}, - emptyMessage: Craft.t('commerce', 'No discounts exist yet.'), - padded: true, - paginatedReorderAction: 'commerce/discounts/reorder', - moveToPageAction: 'commerce/discounts/move-to-page', - reorderSuccessMessage: Craft.t('commerce', 'Discounts reordered.') , - reorderFailMessage: Craft.t('commerce', 'Couldn’t reorder discounts.'), - tableDataEndpoint: '{$tableDataEndpoint}', - search: true, - perPage: 100, - }); -JS; - - $this->getView()->registerJs($js, View::POS_END); - - return $this->asStoreManagementCpScreen($storeHandle) - ->additionalButtonsHtml($actionButtonHtml) - ->contentTemplate('commerce/store-management/discounts/index'); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @since 4.3.3 - */ - public function actionTableData(): Response - { - $this->requireAcceptsJson(); - - $storeId = $this->request->getRequiredParam('storeId'); - - if (!$store = Plugin::getInstance()->getStores()->getStoreById($storeId)) { - throw new InvalidConfigException('Invalid store.'); - } - - - $page = $this->request->getParam('page', 1); - $limit = $this->request->getParam('per_page', 100); - $search = $this->request->getParam('search'); - $offset = ($page - 1) * $limit; - - $sqlQuery = (new Query()) - ->from(['discounts' => Table::DISCOUNTS]) - ->select([ - 'discounts.id', - 'discounts.name', - 'discounts.enabled', - 'discounts.dateFrom', - 'discounts.dateTo', - 'discounts.totalDiscountUses', - 'discounts.ignorePromotions', - 'discounts.requireCouponCode', - 'discounts.stopProcessing', - 'discounts.sortOrder', - ]) - ->where(['discounts.storeId' => $storeId]) - ->orderBy(['sortOrder' => SORT_ASC]); - - - if ($search) { - $likeOperator = Craft::$app->getDb()->getIsPgsql() ? 'ILIKE' : 'LIKE'; - $sqlQuery - ->andWhere([ - 'or', - // Search discount name - [$likeOperator, 'discounts.name', '%' . str_replace(' ', '%', $search) . '%', false], - // Search discount description - [$likeOperator, 'discounts.description', '%' . str_replace(' ', '%', $search) . '%', false], - // Search coupon code - ['discounts.id' => (new Query()) - ->from(Table::COUPONS) - ->select('discountId') - ->where([$likeOperator, 'code', '%' . str_replace(' ', '%', $search) . '%', false]), - ], - ]); - } - - $total = $sqlQuery->count(); - - $sqlQuery->limit($limit); - $sqlQuery->offset($offset); - - $result = $sqlQuery->all(); - - $tableData = []; - $dateFormat = Craft::$app->getFormattingLocale()->getDateTimeFormat('short', Locale::FORMAT_PHP); - foreach ($result as $item) { - $dateFrom = $item['dateFrom'] ? DateTimeHelper::toDateTime($item['dateFrom']) : null; - $dateTo = $item['dateTo'] ? DateTimeHelper::toDateTime($item['dateTo']) : null; - $dateRange = ($dateFrom ? $dateFrom->format($dateFormat) : '∞') . ' - ' . ($dateTo ? $dateTo->format($dateFormat) : '∞'); - - $dateRange = !$dateFrom && !$dateTo ? '∞' : $dateRange; - - $tableData[] = [ - 'id' => $item['id'], - 'title' => Craft::t('site', $item['name']), - 'url' => UrlHelper::cpUrl('commerce/store-management/' . $store->handle . '/discounts/' . $item['id']), - 'status' => (bool)$item['enabled'], - 'duration' => $dateRange, - 'timesUsed' => $item['totalDiscountUses'], - 'requireCouponCode' => (bool)$item['requireCouponCode'], - 'ignore' => (bool)$item['ignorePromotions'], - 'stop' => (bool)$item['stopProcessing'], - ]; - } - - return $this->asSuccess(data: [ - 'pagination' => AdminTable::paginationLinks($page, $total, $limit), - 'data' => $tableData, - ]); - } - - /** - * @param int|null $id - * @param Discount|null $discount - * @throws HttpException - */ - public function actionEdit(int $id = null, Discount $discount = null, string $storeHandle = null): Response - { - if ($id === null) { - $this->requirePermission('commerce-createDiscounts'); - } else { - $this->requirePermission('commerce-editDiscounts'); - } - - $variables = compact('id', 'discount'); - $variables['isNewDiscount'] = false; - - if ($storeHandle) { - $store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle); - if ($store === null) { - throw new InvalidConfigException('Invalid store.'); - } - } else { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $variables['siteIds'] = $store->getSites()->pluck('id')->all(); - $variables['storeHandle'] = $store->handle; - $variables['currency'] = $store->getCurrency(); - $variables['decimals'] = Plugin::getInstance()->getCurrencies()->getSubunitFor($store->getCurrency()); - - if (!$variables['discount']) { - if ($variables['id']) { - $variables['discount'] = Plugin::getInstance()->getDiscounts()->getDiscountById($variables['id'], $store->id); - - if (!$variables['discount']) { - throw new HttpException(404); - } - } else { - $variables['discount'] = Craft::createObject([ - 'class' => Discount::class, - 'attributes' => [ - 'allCategories' => true, - 'allPurchasables' => true, - 'storeId' => $store->id, - ], - ]); - $variables['isNewDiscount'] = true; - } - } - - DebugPanel::prependOrAppendModelTab(model: $variables['discount'], prepend: true); - - $this->_populateVariables($variables); - $variables['percentSymbol'] = Craft::$app->getFormattingLocale()->getNumberSymbol(Locale::SYMBOL_PERCENT); - $this->getView()->registerAssetBundle(CouponsAsset::class); - - $variables['coupons'] = collect($variables['discount']->getCoupons()) - ->map(fn(Coupon $coupon) => $coupon->toArray()) - ->all(); - - $tabs = [ - 'discount' => [ - 'label' => Craft::t('commerce', 'Discount'), - 'url' => '#discount', - 'class' => $variables['discount']->getErrors('name') ? 'error' : '', - ], - 'coupons' => [ - 'label' => Craft::t('commerce', 'Coupons'), - 'url' => '#coupons', - 'class' => $variables['discount']->getErrors('code') ? 'error' : '', - ], - 'matchingItems' => [ - 'label' => Craft::t('commerce', 'Matching Items'), - 'url' => '#matching-items', - ], - 'conditions' => [ - 'label' => Craft::t('commerce', 'Conditions'), - 'url' => '#conditions', - 'class' => $variables['discount']->getErrors('startDate') || $variables['discount']->getErrors('endDate') ? 'error' : '', - ], - 'actions' => [ - 'label' => Craft::t('commerce', 'Actions'), - 'url' => '#actions', - 'class' => $variables['discount']->getErrors('startDate') || $variables['discount']->getErrors('endDate') ? 'error' : '', - ], - ]; - - return $this->asStoreManagementCpScreen($storeHandle, false) - ->title($variables['title']) - ->tabs($tabs) - ->addCrumb(Craft::t('commerce', 'Discounts'), $store->getStoreSettingsUrl('discounts')) - ->metaSidebarTemplate('commerce/store-management/discounts/_sidebar', $variables) - ->action('commerce/discounts/save') - ->redirectUrl($store->getStoreSettingsUrl('discounts')) - ->contentTemplate('commerce/store-management/discounts/_edit', $variables); - } - - /** - * @throws HttpException - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - - $discount = new Discount(); - - $discount->id = $this->request->getBodyParam('id'); - - if ($discount->id === null) { - $this->requirePermission('commerce-createDiscounts'); - } else { - $this->requirePermission('commerce-editDiscounts'); - } - - $discount->storeId = $this->request->getBodyParam('storeId'); - $discount->name = $this->request->getBodyParam('name'); - $discount->description = $this->request->getBodyParam('description'); - $discount->enabled = (bool)$this->request->getBodyParam('enabled'); - $discount->setOrderCondition($this->request->getBodyParam('orderCondition')); - $discount->setCustomerCondition($this->request->getBodyParam('customerCondition')); - $discount->setShippingAddressCondition($this->request->getBodyParam('shippingAddressCondition')); - $discount->setBillingAddressCondition($this->request->getBodyParam('billingAddressCondition')); - $discount->requireCouponCode = (bool)$this->request->getBodyParam('requireCouponCode'); - $discount->stopProcessing = (bool)$this->request->getBodyParam('stopProcessing'); - $discount->purchaseQty = $this->request->getBodyParam('purchaseQty'); - $discount->maxPurchaseQty = $this->request->getBodyParam('maxPurchaseQty'); - $discount->percentDiscount = (float)$this->request->getBodyParam('percentDiscount'); - $discount->percentageOffSubject = $this->request->getBodyParam('percentageOffSubject'); - $discount->hasFreeShippingForMatchingItems = (bool)$this->request->getBodyParam('hasFreeShippingForMatchingItems'); - $discount->hasFreeShippingForOrder = (bool)$this->request->getBodyParam('hasFreeShippingForOrder'); - $discount->excludeOnPromotion = (bool)$this->request->getBodyParam('excludeOnPromotion'); - $discount->couponFormat = $this->request->getBodyParam('couponFormat', Coupons::DEFAULT_COUPON_FORMAT); - $discount->perUserLimit = (int)$this->request->getBodyParam('perUserLimit'); - $discount->perEmailLimit = (int)$this->request->getBodyParam('perEmailLimit'); - $discount->totalDiscountUseLimit = (int)$this->request->getBodyParam('totalDiscountUseLimit'); - $discount->ignorePromotions = (bool)$this->request->getBodyParam('ignorePromotions'); - $discount->categoryRelationshipType = $this->request->getBodyParam('categoryRelationshipType', $discount->categoryRelationshipType); - $discount->appliedTo = $this->request->getBodyParam('appliedTo') ?: DiscountRecord::APPLIED_TO_MATCHING_LINE_ITEMS; - $discount->orderConditionFormula = $this->request->getBodyParam('orderConditionFormula'); - - $moneyInputAttributes = [ - 'baseDiscount', - 'perItemDiscount', - 'purchaseTotal', - ]; - foreach ($moneyInputAttributes as $attr) { - $attrValue = $this->request->getBodyParam($attr) ?: ['value' => '0']; - $attrValue['value'] = preg_replace('/[^0-9\.\-\,]/', '', $attrValue['value']); - $attrValue += [ - 'currency' => $discount->getStore()->getCurrency(), - ]; - $attrValue = MoneyHelper::toDecimal(MoneyHelper::toMoney($attrValue)); - - // Invert non-purchaseTotal values - if ($attr !== 'purchaseTotal') { - // Sanitize the input from the user - we store negative values, expecting the user to enter positive values - $attrValue = (float)$attrValue; - if ($attrValue > 0) { - $attrValue = $attrValue * -1; - } - } - - $discount->{$attr} = (float)$attrValue; - } - - $date = $this->request->getBodyParam('dateFrom'); - if ($date) { - $dateTime = DateTimeHelper::toDateTime($date) ?: null; - $discount->dateFrom = $dateTime; - } - - $date = $this->request->getBodyParam('dateTo'); - if ($date) { - $dateTime = DateTimeHelper::toDateTime($date) ?: null; - $discount->dateTo = $dateTime; - } - - $percentDiscount = $this->request->getBodyParam('percentDiscount', 0); - $percentDiscount = preg_replace('/[^0-9\.\-\,]/', '', $percentDiscount); - $discount->percentDiscount = -Localization::normalizePercentage($percentDiscount); - - // Set purchasable conditions - $allPurchasables = !$this->request->getBodyParam('allPurchasables', false); - if ($discount->allPurchasables = $allPurchasables) { - $discount->setPurchasableIds([]); - } else { - $purchasables = []; - $purchasableGroups = $this->request->getBodyParam('purchasables') ?: []; - foreach ($purchasableGroups as $group) { - if (is_array($group)) { - array_push($purchasables, ...$group); - } - } - $purchasables = array_unique($purchasables); - $discount->setPurchasableIds($purchasables); - } - - // False in the allCategories param is true in the DB - $allCategories = !$this->request->getBodyParam('allCategories', false); - // Set category conditions - if ($discount->allCategories = $allCategories) { - $discount->setCategoryIds([]); - } else { - $relatedElements = []; - $relatedElementByType = $this->request->getBodyParam('relatedElements') ?: []; - foreach ($relatedElementByType as $type) { - if (is_array($type)) { - array_push($relatedElements, ...$type); - } - } - $relatedElements = array_unique($relatedElements); - $discount->setCategoryIds($relatedElements); - } - - $coupons = $this->request->getBodyParam('coupons') ?: []; - $this->_setCouponsOnDiscount(coupons: $coupons, discount: $discount); - - // Save it - if (Plugin::getInstance()->getDiscounts()->saveDiscount($discount)) { - $this->setSuccessFlash(Craft::t('commerce', 'Discount saved.')); - return $this->redirectToPostedUrl($discount); - } else { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save discount.')); - } - - // Send the model back to the template - $variables = [ - 'discount' => $discount, - ]; - $this->_populateVariables($variables); - - Craft::$app->getUrlManager()->setRouteParams($variables); - - return null; - } - - /** - * @param array $coupons - * @param Discount $discount - * @return void - * @throws InvalidConfigException - * @since 4.0 - */ - private function _setCouponsOnDiscount(array $coupons, Discount $discount): void - { - if (empty($coupons)) { - $discount->setCoupons([]); - return; - } - - $discountCoupons = []; - - foreach ($coupons as $c) { - $discountCoupons[] = Craft::createObject(Coupon::class, [ - 'config' => [ - 'attributes' => [ - 'id' => $c['id'] ?: null, - 'discountId' => null, - 'code' => $c['code'], - 'uses' => $c['uses'] ?: 0, - 'maxUses' => is_numeric($c['maxUses']) ? (int)$c['maxUses'] : null, - ], - ], - ]); - } - - $discount->setCoupons($discountCoupons); - } - - /** - * @throws BadRequestHttpException - */ - public function actionReorder(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $ids = Json::decode($this->request->getRequiredBodyParam('ids')); - $key = $this->request->getBodyParam('startPosition'); - - $idsOrdered = []; - foreach ($ids as $id) { - // Temporary -1 because the `reorderDiscounts()` method will increment the key before saving. - // @TODO Remove the `$key - 1` offset once `reorderDiscounts()` can be changed to not pre-increment the key (Commerce 6.0) - $idsOrdered[$key - 1] = $id; - $key++; - } - - if (!Plugin::getInstance()->getDiscounts()->reorderDiscounts($idsOrdered)) { - return $this->asFailure(Craft::t('commerce', 'Couldn’t reorder discounts.')); - } - - return $this->asSuccess(); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @since 4.4.0 - */ - public function actionMoveToPage(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $id = $this->request->getRequiredBodyParam('id'); - $page = $this->request->getRequiredBodyParam('page'); - $perPage = $this->request->getRequiredBodyParam('perPage'); - - // @TODO Scope discount move-to-page operations by `storeId` so reordering only affects the active store - - if (AdminTable::moveToPage(Table::DISCOUNTS, $id, $page, $perPage)) { - return $this->asSuccess(Craft::t('commerce', 'Discounts reordered.')); - } - - return $this->asFailure(Craft::t('commerce', 'Couldn’t reorder discounts.')); - } - - /** - * @throws HttpException - */ - public function actionDelete(): Response - { - $this->requirePermission('commerce-deleteDiscounts'); - $this->requirePostRequest(); - - $id = $this->request->getBodyParam('id'); - $ids = $this->request->getBodyParam('ids'); - - if ((!$id && empty($ids)) || ($id && !empty($ids))) { - throw new BadRequestHttpException('id or ids must be specified.'); - } - - if ($id) { - $this->requireAcceptsJson(); - $ids = [$id]; - } - - foreach ($ids as $id) { - Plugin::getInstance()->getDiscounts()->deleteDiscountById($id); - } - - if ($this->request->getAcceptsJson()) { - return $this->asSuccess(); - } - - $this->setSuccessFlash(Craft::t('commerce', 'Discounts deleted.')); - - return $this->redirect($this->request->getReferrer()); - } - - /** - * @throws Exception - * @throws BadRequestHttpException - * @since 3.0 - */ - public function actionClearDiscountUses(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $id = $this->request->getRequiredBodyParam('id'); - $type = $this->request->getBodyParam('type', 'total'); - $types = [self::DISCOUNT_COUNTER_TYPE_TOTAL, self::DISCOUNT_COUNTER_TYPE_CUSTOMER, self::DISCOUNT_COUNTER_TYPE_EMAIL]; - - if (!in_array($type, $types, true)) { - return $this->asFailure(Craft::t('commerce', 'Type not in allowed options.')); - } - - match ($type) { - self::DISCOUNT_COUNTER_TYPE_EMAIL => Plugin::getInstance()->getDiscounts()->clearEmailUsageHistoryById($id), - self::DISCOUNT_COUNTER_TYPE_CUSTOMER => Plugin::getInstance()->getDiscounts()->clearCustomerUsageHistoryById($id), - self::DISCOUNT_COUNTER_TYPE_TOTAL => Plugin::getInstance()->getDiscounts()->clearDiscountUsesById($id), - }; - - return $this->asSuccess(); - } - - /** - * @throws MissingComponentException - * @throws Exception - * @throws BadRequestHttpException - * @throws ForbiddenHttpException - * @since 3.0 - */ - public function actionUpdateStatus(): void - { - $this->requirePostRequest(); - $this->requirePermission('commerce-editDiscounts'); - - $ids = $this->request->getRequiredBodyParam('ids'); - $status = $this->request->getRequiredBodyParam('status'); - - if (empty($ids)) { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t update status.')); - } - - $transaction = Craft::$app->getDb()->beginTransaction(); - $discounts = DiscountRecord::find() - ->where(['id' => $ids]) - ->all(); - - /** @var DiscountRecord $discount */ - foreach ($discounts as $discount) { - $discount->enabled = ($status == 'enabled'); - $discount->save(); - } - $transaction->commit(); - - $this->setSuccessFlash(Craft::t('commerce', 'Discounts updated.')); - } - - /** - * @throws BadRequestHttpException - */ - public function actionGetDiscountsByPurchasableId(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - $id = $this->request->getParam('id'); - - if (!$id) { - return $this->asFailure(Craft::t('commerce', 'Purchasable ID is required.')); - } - - $purchasable = Plugin::getInstance()->getPurchasables()->getPurchasableById($id); - - if (!$purchasable) { - return $this->asFailure(Craft::t('commerce', 'No purchasable available.')); - } - - $discounts = []; - $purchasableDiscounts = Plugin::getInstance()->getDiscounts()->getDiscountsRelatedToPurchasable($purchasable); - foreach ($purchasableDiscounts as $discount) { - if (!ArrayHelper::firstWhere($discounts, 'id', $discount->id)) { - /** @var Sale $discount */ - $discountArray = $discount->toArray(); - $discountArray['cpEditUrl'] = $discount->getCpEditUrl(); - $discounts[] = $discountArray; - } - } - - return $this->asSuccess(data: [ - 'discounts' => $discounts, - ]); - } - - private function _populateVariables(array &$variables): void - { - if ($variables['discount']->id) { - $variables['title'] = $variables['discount']->name; - } else { - $variables['title'] = Craft::t('commerce', 'Create a Discount'); - } - - // getting user groups map - if (Craft::$app->getEdition() == Craft::Pro) { - $groups = Craft::$app->getUserGroups()->getAllGroups(); - $variables['groups'] = ArrayHelper::map($groups, 'id', 'name'); - } else { - $variables['groups'] = []; - } - - $flipNegativeNumberAttributes = ['baseDiscount', 'perItemDiscount']; - foreach ($flipNegativeNumberAttributes as $attr) { - if (!isset($variables['discount']->{$attr})) { - continue; - } - - if ($variables['discount']->{$attr} < 0) { - // Flip negative numbers for display to the user - $variables['discount']->{$attr} *= -1; - } elseif ($variables['discount']->{$attr} == 0) { - $variables['discount']->{$attr} = 0; - } - } - - $variables['counterTypeTotal'] = self::DISCOUNT_COUNTER_TYPE_TOTAL; - $variables['counterTypeEmail'] = self::DISCOUNT_COUNTER_TYPE_EMAIL; - $variables['counterTypeUser'] = self::DISCOUNT_COUNTER_TYPE_CUSTOMER; - - if ($variables['discount']->id) { - $variables['emailUsage'] = Plugin::getInstance()->getDiscounts()->getEmailUsageStatsById($variables['discount']->id); - $variables['customerUsage'] = Plugin::getInstance()->getDiscounts()->getCustomerUsageStatsById($variables['discount']->id); - } else { - $variables['emailUsage'] = 0; - $variables['customerUsage'] = 0; - } - - $variables['categoryElementType'] = Category::class; - $variables['entryElementType'] = Entry::class; - $variables['categories'] = null; - $variables['entries'] = null; - - $categories = []; - $entries = []; - - if (empty($variables['id']) && $this->request->getParam('categoryIds')) { - $categoryIds = explode('|', $this->request->getParam('categoryIds')); - } else { - $categoryIds = $variables['discount']->getCategoryIds(); - } - - foreach ($categoryIds as $categoryId) { - $id = (int)$categoryId; - $element = Craft::$app->getElements()->getElementById($id, siteId: '*'); - - if ($element instanceof Category) { - $categories[] = $element; - } elseif ($element instanceof Entry) { - $entries[] = $element; - } - } - - $variables['categories'] = $categories; - $variables['entries'] = $entries; - - $variables['elementRelationshipTypeOptions'] = [ - DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_SOURCE => Craft::t('commerce', 'The purchasable defines the relationship'), - DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_TARGET => Craft::t('commerce', 'The purchasable is related by another element'), - DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH => Craft::t('commerce', 'Either way'), - ]; - - $variables['appliedTo'] = [ - DiscountRecord::APPLIED_TO_MATCHING_LINE_ITEMS => Craft::t('commerce', 'Discount the matching items only'), - DiscountRecord::APPLIED_TO_ALL_LINE_ITEMS => Craft::t('commerce', 'Discount all line items'), - ]; - - $variables['purchasables'] = null; - - if (empty($variables['id']) && $this->request->getParam('purchasableIds')) { - $purchasableIdsFromUrl = explode('|', $this->request->getParam('purchasableIds')); - foreach ($purchasableIdsFromUrl as $purchasableId) { - $purchasable = Craft::$app->getElements()->getElementById((int)$purchasableId, siteId: $variables['siteIds']); - if ($purchasable instanceof Product) { - $purchasableIds[] = $purchasable->defaultVariantId; // this would only be null if we are duplicating a variant, otherwise should never be null - } else { - $purchasableIds[] = $purchasableId; - } - } - $variables['discount']->allPurchasables = false; - } else { - $purchasableIds = $variables['discount']->getPurchasableIds(); - } - - $purchasableIds = array_filter($purchasableIds); - - $purchasables = []; - foreach ($purchasableIds as $purchasableId) { - $purchasable = Craft::$app->getElements()->getElementById((int)$purchasableId, siteId: $variables['siteIds']); - if ($purchasable instanceof PurchasableInterface) { - $class = $purchasable::class; - $purchasables[$class] ??= []; - $purchasables[$class][] = $purchasable; - } - } - $variables['purchasables'] = $purchasables; - - $variables['purchasableTypes'] = []; - $purchasableTypes = Plugin::getInstance()->getPurchasables()->getAllPurchasableElementTypes(); - - /** @var Purchasable $purchasableType */ - foreach ($purchasableTypes as $purchasableType) { - $variables['purchasableTypes'][] = [ - 'name' => $purchasableType::displayName(), - 'elementType' => $purchasableType, - ]; - } - } - - /** - * @return Response - * @throws BadRequestHttpException - * @since 4.0 - */ - public function actionGenerateCoupons(): Response - { - $this->requireAcceptsJson(); - $this->requirePostRequest(); - - $count = (int)$this->request->getBodyParam('count', 0); - $format = $this->request->getBodyParam('format', Coupons::DEFAULT_COUPON_FORMAT); - $existingCodes = $this->request->getBodyParam('existingCodes', []); - - try { - $coupons = Plugin::getInstance()->getCoupons()->generateCouponCodes(count: $count, format: $format, existingCodes: $existingCodes); - } catch (\Exception $e) { - return $this->asFailure(message: Craft::t('commerce', 'Unable to generate coupon codes: {message}', ['message' => $e->getMessage()])); - } - - return $this->asSuccess(data: ['coupons' => $coupons]); - } -} diff --git a/src/controllers/DonationsController.php b/src/controllers/DonationsController.php deleted file mode 100644 index 8171a3f4d9..0000000000 --- a/src/controllers/DonationsController.php +++ /dev/null @@ -1,98 +0,0 @@ - - * @since 2.0 - */ -class DonationsController extends BaseCpController -{ - /** - * @inheritdoc - */ - public function init(): void - { - parent::init(); - - $this->requirePermission('commerce-manageDonationSettings'); - } - - - public function actionEdit(): Response - { - $donation = Donation::find()->status(null)->one(); - - if ($donation === null) { - $primaryStore = Plugin::getInstance()->getStores()->getPrimaryStore(); - $primarySite = Craft::$app->getSites()->getPrimarySite(); - $donation = new Donation(); - $donation->siteId = $primarySite->id; - $donation->sku = 'DONATION-CC5'; - $donation->availableForPurchase = false; - $donation->taxCategoryId = Plugin::getInstance()->getTaxCategories()->getDefaultTaxCategory()->id; - $donation->shippingCategoryId = Plugin::getInstance()->getShippingCategories()->getDefaultShippingCategory($primaryStore->id)->id; - Craft::$app->getElements()->saveElement($donation); - } - - return $this->asCpScreen() - ->title('Donation Settings') - ->crumbs([ - ['label' => Craft::t('commerce', 'Commerce'), 'url' => 'commerce'], - ]) - ->selectedSubnavItem('donations') - ->action('commerce/donations/save') - ->submitButtonLabel(Craft::t('app', 'Save')) - ->redirectUrl('commerce/donations') - ->contentTemplate('commerce/donation/_edit.twig', compact('donation')); - } - - /** - * @throws Throwable - * @throws ElementNotFoundException - * @throws MissingComponentException - * @throws Exception - * @throws BadRequestHttpException - */ - public function actionSave(): Response - { - $this->requirePostRequest(); - - // Not using a service to save a donation yet. Always editing the only donation. - /** @var Donation|null $donation */ - $donation = Donation::find()->status(null)->one(); - - if ($donation === null) { - $donation = new Donation(); - $donation->siteId = Craft::$app->getSites()->getPrimarySite()->id; - } - - $donation->sku = $this->request->getBodyParam('sku'); - $donation->availableForPurchase = (bool)$this->request->getBodyParam('availableForPurchase'); - $donation->enabled = (bool)$this->request->getBodyParam('enabled'); - - if (!Craft::$app->getElements()->saveElement($donation)) { - return $this->renderTemplate('commerce/donation/_edit', compact('donation')); - } - - $this->setSuccessFlash(Craft::t('commerce', 'Donation settings saved.')); - return $this->redirectToPostedUrl(); - } -} diff --git a/src/controllers/DownloadsController.php b/src/controllers/DownloadsController.php deleted file mode 100644 index 0b5e2d99f3..0000000000 --- a/src/controllers/DownloadsController.php +++ /dev/null @@ -1,330 +0,0 @@ - - * @since 2.0 - */ -class DownloadsController extends BaseFrontEndController -{ - /** - * @inheritdoc - */ - public function behaviors(): array - { - return array_merge(parent::behaviors(), [ - 'pdfChallengeRateLimiter' => [ - 'class' => RateLimiter::class, - 'only' => ['pdf-challenge'], - 'enableRateLimitHeaders' => false, - 'user' => function() { - $request = Craft::$app->getRequest(); - return new IpRateLimitIdentity([ - 'limit' => 1, - 'window' => 30, - 'keyPrefix' => 'pdf-challenge-rate-limit', - 'ip' => $request->getUserIP() ?? 'unknown', - ]); - }, - ], - ]); - } - - /** - * Renders the email challenge template with the provided parameters. - * - * @param Order $order The order to display - * @param string $orderNumber The order number - * @param string|null $pdfHandle The PDF handle - * @param string $option The PDF option - * @param bool $inline Whether to display inline - * @param array $errors Optional errors to display - * @param string|null $email Optional email value to pre-fill - * @return Response - * @since 4.9.5 - */ - private function renderEmailChallenge( - Order $order, - string $orderNumber, - ?string $pdfHandle, - string $option, - bool $inline, - array $errors = [], - ?string $email = null, - ): Response { - $params = [ - 'order' => $order, - 'orderNumber' => $orderNumber, - 'pdfHandle' => $pdfHandle, - 'option' => $option, - 'inline' => $inline, - ]; - - if (!empty($errors)) { - $params['errors'] = $errors; - } - - if ($email !== null) { - $params['email'] = $email; - } - - return $this->renderTemplate('commerce/_downloads/email-challenge', $params, View::TEMPLATE_MODE_CP); - } - - /** - * @throws HttpException - * @throws Throwable - * @throws Exception - * @throws RangeNotSatisfiableHttpException - */ - public function actionPdf(): Response - { - $number = $this->request->getQueryParam('number'); - $pdfHandle = $this->request->getQueryParam('pdfHandle'); - $option = $this->request->getQueryParam('option', ''); - $inline = (bool)$this->request->getQueryParam('inline', false); - $token = $this->request->getQueryParam('code'); - - // Maybe they are coming in on an "old" link with "token" instead of "code" as the query param, so check for that too. - if (!$token) { - $token = $this->request->getQueryParam('token'); - } - - if (!$number) { - throw new BadRequestHttpException('Order number required'); - } - - $order = Plugin::getInstance()->getOrders()->getOrderByNumber($number); - - if (!$order) { - throw new HttpException(404, 'Order not found'); - } - - // Don't allow PDF downloads for carts without an email - if (!$order->getEmail()) { - throw new HttpException(404, 'Order not found'); - } - - $currentUser = Craft::$app->getUser()->getIdentity(); - $hasValidToken = false; - - // Check if token is provided and valid (works for anyone, logged in or not) - if ($token) { - $tokenData = Craft::$app->getTokens()->getTokenRoute($token); - - // Validate token structure and order number - if (!$tokenData || !isset($tokenData[1]['orderNumber']) || $tokenData[1]['orderNumber'] !== $number) { - Craft::$app->getSession()->setError(Craft::t('commerce', 'The download link has expired. Please request a new one.')); - return $this->redirect(UrlHelper::actionUrl('commerce/downloads/email-challenge', [ - 'number' => $number, - 'pdfHandle' => $pdfHandle, - 'option' => $option, - 'inline' => $inline, - ])); - } - - $hasValidToken = true; - } - - // Check user permissions if no valid token - if (!$hasValidToken) { - if ($currentUser) { - // Check if user is the order customer, admin, or has permission to manage orders - $isOrderCustomer = $order->getCustomer() && $order->getCustomer()->id === $currentUser->id; - $hasPermission = $currentUser->admin || $order->canView($currentUser); - - if (!($isOrderCustomer || $hasPermission)) { - // Logged-in user without permission - redirect to email challenge form - return $this->redirect(UrlHelper::actionUrl('commerce/downloads/email-challenge', [ - 'number' => $number, - 'pdfHandle' => $pdfHandle, - 'option' => $option, - 'inline' => $inline, - ])); - } - } else { - // Anonymous user without valid token - redirect to email challenge form - return $this->redirect(UrlHelper::actionUrl('commerce/downloads/email-challenge', [ - 'number' => $number, - 'pdfHandle' => $pdfHandle, - 'option' => $option, - 'inline' => $inline, - ])); - } - } - - if ($pdfHandle) { - $pdf = Plugin::getInstance()->getPdfs()->getPdfByHandle($pdfHandle, $order->storeId); - - if (!$pdf) { - throw new InvalidCallException("Can not find the PDF to render based on the handle supplied."); - } - } else { - $pdf = Plugin::getInstance()->getPdfs()->getDefaultPdf($order->storeId); - } - - if (!$pdf) { - throw new InvalidCallException("Can not find a PDF to render."); - } - - $originalLanguage = Craft::$app->language; - $originalFormattingLocale = Craft::$app->formattingLocale; - - $language = $pdf->getRenderLanguage($order); - Locale::switchAppLanguage($language); - - $renderedPdf = Plugin::getInstance()->getPdfs()->renderPdfForOrder($order, $option, null, [], $pdf); - - // Set previous language back - Locale::switchAppLanguage($originalLanguage, $originalFormattingLocale->id); - - $fileName = $this->getView()->renderSandboxedObjectTemplate((string)$pdf->fileNameFormat, $order); - if (!$fileName) { - $fileName = $pdf->handle . '-' . $order->number; - } - - return $this->response->sendContentAsFile($renderedPdf, $fileName . '.pdf', [ - 'mimeType' => 'application/pdf', - 'inline' => $inline, - ]); - } - - /** - * Displays the email challenge form for anonymous users trying to download an order PDF - * - * @return Response - * @throws HttpException - */ - public function actionEmailChallenge(): Response - { - $number = $this->request->getQueryParam('number'); - $pdfHandle = $this->request->getQueryParam('pdfHandle'); - $option = $this->request->getQueryParam('option', ''); - $inline = (bool)$this->request->getQueryParam('inline', false); - - if (!$number) { - throw new BadRequestHttpException('Order number required'); - } - - $order = Plugin::getInstance()->getOrders()->getOrderByNumber($number); - - if (!$order) { - throw new HttpException(404, 'Order not found'); - } - - // Don't allow PDF downloads for carts without an email - if (!$order->getEmail()) { - throw new HttpException(404, 'Order not found'); - } - - return $this->renderEmailChallenge($order, $number, $pdfHandle, $option, $inline); - } - - /** - * Handles the email challenge form submission for anonymous users trying to download an order PDF - * - * @throws HttpException - * @throws Exception - */ - public function actionPdfChallenge(): Response - { - $this->requirePostRequest(); - - $orderNumberHash = $this->request->getBodyParam('orderNumberHash'); - $pdfHandle = $this->request->getBodyParam('pdfHandle'); - $option = $this->request->getBodyParam('option', ''); - $inline = (bool)$this->request->getBodyParam('inline', false); - - if (!$orderNumberHash) { - throw new BadRequestHttpException('Order number hash is required'); - } - - // Validate the order number hash - $orderNumber = Craft::$app->getSecurity()->validateData($orderNumberHash); - - if ($orderNumber === false) { - throw new BadRequestHttpException('Invalid order number hash'); - } - - $order = Plugin::getInstance()->getOrders()->getOrderByNumber($orderNumber); - - if (!$order) { - throw new HttpException(404, 'Order not found'); - } - - // Build the download URL with the token using the Pdfs service - $downloadUrl = Plugin::getInstance()->getPdfs()->getPdfUrl($order, $option, $pdfHandle, $inline); - - // Send email using system message - $systemMessage = Craft::$app->getSystemMessages()->getMessage('commerce_pdf_download', $order->getOrderSite()->language); - - if (!Craft::$app->getMailer()->composeFromKey('commerce_pdf_download', [ - 'link' => $downloadUrl, - 'order' => $order, - ])->setTo($order->email)->send()) { - Craft::$app->getSession()->setError(Craft::t('commerce', 'Failed to send email. Please try again.')); - return $this->renderEmailChallenge($order, $orderNumber, $pdfHandle, $option, $inline); - } - - Craft::$app->getSession()->setNotice(Craft::t('commerce', 'A new download link has been sent to {email}', ['email' => $order->getMaskedEmail()])); - - // Redirect to success page to prevent duplicate submissions on refresh - return $this->redirect(UrlHelper::actionUrl('commerce/downloads/pdf-sent', ['hash' => $orderNumberHash])); - } - - /** - * Displays the success page after email challenge is completed - * - * @return Response - * @throws HttpException - */ - public function actionPdfSent(): Response - { - $orderNumberHash = $this->request->getQueryParam('hash'); - - if (!$orderNumberHash) { - throw new BadRequestHttpException('Hash parameter required'); - } - - // Validate and extract the order number from the hash - $orderNumber = Craft::$app->getSecurity()->validateData($orderNumberHash); - - if ($orderNumber === false) { - throw new HttpException(400, 'Invalid hash parameter'); - } - - $order = Plugin::getInstance()->getOrders()->getOrderByNumber($orderNumber); - - if (!$order) { - throw new HttpException(404, 'Order not found'); - } - - return $this->renderTemplate('commerce/_downloads/email-sent', [ - 'email' => $order->getMaskedEmail(), - ], View::TEMPLATE_MODE_CP); - } -} diff --git a/src/controllers/EmailPreviewController.php b/src/controllers/EmailPreviewController.php deleted file mode 100644 index ab612ff925..0000000000 --- a/src/controllers/EmailPreviewController.php +++ /dev/null @@ -1,84 +0,0 @@ - - * @since 2.0 - */ -class EmailPreviewController extends Controller -{ - /** - * @throws Exception - * @throws ForbiddenHttpException - */ - public function actionRender(): Response - { - $this->requireAdmin(false); - - $email = $this->request->getParam('email'); - $emailId = (int)StringHelper::split($email, ':')[0]; - $storeId = (int)StringHelper::split($email, ':')[1]; - $email = Plugin::getInstance()->getEmails()->getEmailById($emailId, $storeId); - - $orderNumber = $this->request->getParam('number'); - - if ($orderNumber) { - $order = Order::find()->shortNumber(substr($orderNumber, 0, 7))->one(); - } else { - $orderQuery = Order::find()->isCompleted(true); - - if (Craft::$app->getDb()->getIsPgsql()) { - $orderQuery->orderBy('RANDOM()'); - } else { - $orderQuery->orderBy('RAND()'); - } - - $order = $orderQuery->one(); - } - - if (!$order) { - $order = new Order(); - } - - if ($email && $template = $email->templatePath) { - $emailLanguage = $email->getRenderLanguage($order); - - Locale::switchAppLanguage($emailLanguage); - - $orderHistory = ArrayHelper::firstValue($order->getHistories()) ?: new OrderHistory(); - $orderData = $order->toArray(); - $option = 'email'; - $result = $this->renderTemplate($template, compact('order', 'orderHistory', 'option', 'orderData'), View::TEMPLATE_MODE_SITE); - - return $result; - } - - $errors = []; - if (!$email) { - $errors[] = Craft::t('commerce', 'Could not find the email or template.'); - } - - return $this->renderTemplate('commerce/settings/emails/_previewError', compact('errors')); - } -} diff --git a/src/controllers/EmailsController.php b/src/controllers/EmailsController.php deleted file mode 100644 index dd3d8f8018..0000000000 --- a/src/controllers/EmailsController.php +++ /dev/null @@ -1,207 +0,0 @@ - - * @since 2.0 - */ -class EmailsController extends BaseAdminController -{ - /** - * @throws InvalidConfigException - */ - public function actionIndex(): Response - { - $emails = []; - $stores = Plugin::getInstance()->getStores()->getAllStores(); - - $stores->each(function(Store $store) use (&$emails) { - $emails[$store->handle] = Plugin::getInstance()->getEmails()->getAllEmails($store->id); - }); - $stores = $stores->all(); - - return $this->renderTemplate('commerce/settings/emails/index', [ - 'stores' => $stores, - 'emails' => $emails, - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * @param int|null $id - * @param Email|null $email - * @throws HttpException - */ - public function actionEdit(?string $storeHandle = null, int $id = null, Email $email = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - if (!$email) { - if ($id) { - $email = Plugin::getInstance()->getEmails()->getEmailById($id, $store->id); - - if (!$email) { - throw new HttpException(404); - } - } else { - $email = Craft::createObject([ - 'class' => Email::class, - 'attributes' => ['storeId' => $store->id], - ]); - } - } - - $title = $email->id ? $email->name : Craft::t('commerce', 'Create a new email'); - - DebugPanel::prependOrAppendModelTab(model: $email, prepend: true); - - $pdfs = Plugin::getInstance()->getPdfs()->getAllPdfs($email->storeId); - $pdfList = [null => Craft::t('commerce', 'Do not attach a PDF to this email')]; - $pdfList = ArrayHelper::merge($pdfList, $pdfs->mapWithKeys(fn(Pdf $pdf) => [$pdf->id => $pdf->name])->all()); - $senderAddressPlaceholder = App::mailSettings()->fromEmail; - $senderNamePlaceholder = App::mailSettings()->fromName; - - $emailLanguageOptions = [ - EmailRecord::LOCALE_ORDER_LANGUAGE => Craft::t('commerce', 'The language the order was made in.'), - ]; - - $emailLanguageOptions = array_merge($emailLanguageOptions, LocaleHelper::getSiteAndOtherLanguages()); - - $emailRenderSiteOptions = [ - null => Craft::t('commerce', 'The site the order was made in.'), - ['optgroup' => Craft::t('commerce', 'Sites')], - ] + collect(Craft::$app->getSites()->getAllSites())->mapWithKeys(fn(Site $site) => [$site->id => $site->name])->all(); - - return $this->asCpScreen() - ->title($title) - ->crumbs([ - ['label' => Craft::t('commerce', 'Commerce'), 'url' => 'commerce'], - ['label' => Craft::t('app', 'Settings'), 'url' => 'commerce/settings', 'ariaLabel' => Craft::t('commerce', 'Commerce Settings')], - ['label' => Craft::t('commerce', 'Emails'), 'url' => 'commerce/settings/emails'], - ]) - ->selectedSubnavItem('settings') - ->action('commerce/emails/save') - ->redirectUrl('commerce/settings/emails') - ->contentTemplate('commerce/settings/emails/_edit', [ - 'email' => $email, - 'pdfList' => $pdfList, - 'senderAddressPlaceholder' => $senderAddressPlaceholder, - 'senderNamePlaceholder' => $senderNamePlaceholder, - 'emailLanguageOptions' => $emailLanguageOptions, - 'emailRenderSiteOptions' => $emailRenderSiteOptions, - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * @throws BadRequestHttpException - * @throws ErrorException - * @throws Exception - * @throws NotSupportedException - * @throws ServerErrorHttpException - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - - $emailsService = Plugin::getInstance()->getEmails(); - $emailId = $this->request->getBodyParam('emailId'); - $storeId = $this->request->getBodyParam('storeId'); - - if (!$storeId) { - throw new BadRequestHttpException("Invalid store ID: $storeId"); - } - - if ($emailId) { - $email = $emailsService->getEmailById($emailId, $storeId); - if (!$email) { - throw new BadRequestHttpException("Invalid email ID: $emailId"); - } - } else { - $email = new Email(); - } - - $renderSiteId = $this->request->getBodyParam('renderSiteId'); - - // Shared attributes - $email->storeId = $storeId; - $email->name = $this->request->getBodyParam('name'); - $email->subject = $this->request->getBodyParam('subject'); - $email->recipientType = $this->request->getBodyParam('recipientType'); - $email->to = $this->request->getBodyParam('to'); - $email->bcc = $this->request->getBodyParam('bcc'); - $email->cc = $this->request->getBodyParam('cc'); - $email->replyTo = $this->request->getBodyParam('replyTo'); - $email->enabled = (bool)$this->request->getBodyParam('enabled'); - $email->templatePath = $this->request->getBodyParam('templatePath'); - $email->plainTextTemplatePath = $this->request->getBodyParam('plainTextTemplatePath'); - $pdfId = $this->request->getBodyParam('pdfId'); - $email->pdfId = $pdfId ?: null; - $email->language = $this->request->getBodyParam('language'); - $email->renderSiteId = $renderSiteId ? (int)$renderSiteId : null; - $email->setSenderAddress($this->request->getBodyParam('senderAddress')); - $email->setSenderName($this->request->getBodyParam('senderName')); - - // Save it - if ($emailsService->saveEmail($email)) { - $this->setSuccessFlash(Craft::t('commerce', 'Email saved.')); - return $this->redirectToPostedUrl($email); - } - - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save email.')); - // Send the model back to the template - Craft::$app->getUrlManager()->setRouteParams(['email' => $email]); - - return null; - } - - /** - * @throws HttpException - */ - public function actionDelete(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $id = $this->request->getRequiredBodyParam('id'); - if (!$id) { - return $this->asFailure(Craft::t('commerce', 'Couldn’t delete email.')); - } - - if (!Plugin::getInstance()->getEmails()->deleteEmailById($id)) { - return $this->asFailure(Craft::t('commerce', 'Couldn’t delete email.')); - } - - return $this->asSuccess(); - } -} diff --git a/src/controllers/FormulasController.php b/src/controllers/FormulasController.php deleted file mode 100644 index e22149d6bd..0000000000 --- a/src/controllers/FormulasController.php +++ /dev/null @@ -1,66 +0,0 @@ - - * @since 2.2 - */ -class FormulasController extends BaseCpController -{ - /** - * @throws BadRequestHttpException - */ - public function actionValidateCondition(): Response - { - $this->requireAcceptsJson(); - $this->requirePostRequest(); - - $condition = $this->request->getBodyParam('condition'); - $params = $this->request->getBodyParam('params'); - - if ($condition == '') { - return $this->asSuccess(); - } - - if (!Plugin::getInstance()->getFormulas()->validateConditionSyntax($condition, $params)) { - return $this->asFailure(Craft::t('commerce', 'Invalid condition syntax')); - } - - return $this->asSuccess(); - } - - /** - * @throws BadRequestHttpException - */ - public function actionValidateFormula(): Response - { - $this->requireAcceptsJson(); - $this->requirePostRequest(); - - $formula = $this->request->getBodyParam('formula'); - $params = $this->request->getBodyParam('params'); - - if ($formula == '') { - return $this->asSuccess(); - } - - if (!Plugin::getInstance()->getFormulas()->validateFormulaSyntax($formula, $params)) { - return $this->asFailure(Craft::t('commerce', 'Invalid formula syntax')); - } - - return $this->asSuccess(); - } -} diff --git a/src/controllers/GatewaysController.php b/src/controllers/GatewaysController.php deleted file mode 100644 index 95a232c914..0000000000 --- a/src/controllers/GatewaysController.php +++ /dev/null @@ -1,247 +0,0 @@ - - * @since 2.0 - */ -class GatewaysController extends BaseAdminController -{ - public function actionIndex(): Response - { - $gateways = Plugin::getInstance()->getGateways()->getAllGateways(); - $archivedGateways = Plugin::getInstance()->getGateways()->getAllArchivedGateways(); - - if (!empty($archivedGateways)) { - $gatewayIdsWithTransactions = (new Query()) - ->select(['gatewayId']) - ->from(Table::TRANSACTIONS) - ->groupBy(['gatewayId']) - ->column(); - - foreach ($archivedGateways as &$gateway) { - $missing = $gateway instanceof MissingGateway; - $gateway = [ - 'id' => $gateway->id, - 'title' => Html::encode(Craft::t('site', $gateway->name)), - 'handle' => Html::encode($gateway->handle), - 'type' => [ - 'missing' => $missing, - 'name' => Html::encode($missing ? $gateway->expectedType : $gateway->displayName()), - ], - 'hasTransactions' => in_array($gateway->id, $gatewayIdsWithTransactions), - ]; - } - } - - return $this->renderTemplate('commerce/settings/gateways/index', [ - 'gateways' => $gateways, - 'archivedGateways' => array_values($archivedGateways), - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * @param int|null $id - * @param GatewayInterface|null $gateway - * @return Response - * @throws HttpException - * @throws DeprecationException - * @throws InvalidConfigException - */ - public function actionEdit(?string $storeHandle = null, int $id = null, ?GatewayInterface $gateway = null): Response - { - /** @var Gateway|null $gateway */ - $variables = compact('id', 'gateway'); - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $gatewayService = Plugin::getInstance()->getGateways(); - - if (!$variables['gateway']) { - if ($variables['id']) { - $variables['gateway'] = $gatewayService->getGatewayById($variables['id']); - - if (!$variables['gateway']) { - throw new HttpException(404); - } - } else { - $variables['gateway'] = $gatewayService->createGateway([ - 'type' => Dummy::class, - ]); - } - } - - /** @var string[]|GatewayInterface[] $allGatewayTypes */ - $allGatewayTypes = $gatewayService->getAllGatewayTypes(); - - // Make sure the selected gateway class is in there - if ($gateway && !in_array($gateway::class, $allGatewayTypes, true)) { - $allGatewayTypes[] = $gateway::class; - } - - $gatewayInstances = []; - $gatewayOptions = []; - - foreach ($allGatewayTypes as $class) { - if (($gateway && $class === $gateway::class) || $class::isSelectable()) { - $gatewayInstances[$class] = $gatewayService->createGateway($class); - - $gatewayOptions[] = [ - 'value' => $class, - 'label' => $class::displayName(), - ]; - } - } - - $variables['gatewayTypes'] = $allGatewayTypes; - $variables['gatewayInstances'] = $gatewayInstances; - $variables['gatewayOptions'] = $gatewayOptions; - - if ($variables['gateway']->id) { - $variables['title'] = $variables['gateway']->name; - } else { - $variables['title'] = Craft::t('commerce', 'Create a new gateway'); - } - - DebugPanel::prependOrAppendModelTab(model: $variables['gateway'], prepend: true); - - $variables['readOnly'] = $this->isReadOnlyScreen(); - - return $this->renderTemplate('commerce/settings/gateways/_edit', $variables); - } - - /** - * @throws Exception - * @throws BadRequestHttpException - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - - $gatewayService = Plugin::getInstance()->getGateways(); - - $type = $this->request->getRequiredParam('type'); - $gatewayId = $this->request->getBodyParam('id'); - - $config = [ - 'id' => $gatewayId, - 'type' => $type, - 'name' => $this->request->getBodyParam('name'), - 'handle' => $this->request->getBodyParam('handle'), - 'paymentType' => $this->request->getBodyParam('paymentTypes.' . $type . '.paymentType'), - 'isFrontendEnabled' => $this->request->getParam('isFrontendEnabled'), - 'settings' => $this->request->getBodyParam('types.' . $type), - ]; - - // Handle order condition if it's in the request - $orderCondition = $this->request->getBodyParam('orderCondition'); - if ($orderCondition !== null) { - $config['orderCondition'] = $orderCondition; - } - - // Handle billing address condition if it's in the request - $billingAddressCondition = $this->request->getBodyParam('billingAddressCondition'); - if ($billingAddressCondition !== null) { - $config['billingAddressCondition'] = $billingAddressCondition; - } - - // Handle shipping address condition if it's in the request - $shippingAddressCondition = $this->request->getBodyParam('shippingAddressCondition'); - if ($shippingAddressCondition !== null) { - $config['shippingAddressCondition'] = $shippingAddressCondition; - } - - // For new gateway avoid NULL value. - if (!$this->request->getBodyParam('id')) { - $config['isArchived'] = false; - } - - // If this is an existing gateway, populate with properties unchangeable by this action. - if ($gatewayId) { - /** @var Gateway $savedGateway */ - $savedGateway = $gatewayService->getGatewayById($gatewayId); - $config['uid'] = $savedGateway->uid; - $config['sortOrder'] = $savedGateway->sortOrder; - } - - /** @var Gateway $gateway */ - $gateway = $gatewayService->createGateway($config); - - // Save it - if (!Plugin::getInstance()->getGateways()->saveGateway($gateway)) { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save gateway.')); - - // Send the volume back to the template - Craft::$app->getUrlManager()->setRouteParams([ - 'gateway' => $gateway, - ]); - - return null; - } - - $this->setSuccessFlash(Craft::t('commerce', 'Gateway saved.')); - return $this->redirectToPostedUrl($gateway); - } - - /** - * @throws HttpException - */ - public function actionArchive(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $id = $this->request->getRequiredBodyParam('id'); - - if (!$id || !Plugin::getInstance()->getGateways()->archiveGatewayById((int)$id)) { - return $this->asFailure(Craft::t('commerce', 'Could not archive gateway.')); - } - - return $this->asSuccess(); - } - - /** - * @throws HttpException - */ - public function actionReorder(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $ids = Json::decode($this->request->getRequiredBodyParam('ids')); - - if (!Plugin::getInstance()->getGateways()->reorderGateways($ids)) { - return $this->asFailure(Craft::t('commerce', 'Couldn’t reorder gateways.')); - } - - return $this->asSuccess(); - } -} diff --git a/src/controllers/InventoryController.php b/src/controllers/InventoryController.php deleted file mode 100644 index 702637400b..0000000000 --- a/src/controllers/InventoryController.php +++ /dev/null @@ -1,769 +0,0 @@ - - * @since 5.0.0 - */ -class InventoryController extends BaseCpController -{ - public $defaultAction = 'index'; - - /** - * @param int|null $inventoryItemId - * @param InventoryItem|null $inventoryItem - * @return Response - * @throws NotFoundHttpException - * @throws InvalidConfigException - */ - public function actionItemEdit(?int $inventoryItemId = null, ?InventoryItem $inventoryItem = null): Response - { - $this->requirePermission('commerce-manageInventoryStockLevels'); - - $view = Craft::$app->getView(); - $view->registerAssetBundle(HtmxAsset::class); - - if ($inventoryItemId !== null) { - if ($inventoryItem === null) { - $inventoryItem = Plugin::getInstance()->getInventory()->getInventoryItemById($inventoryItemId); - } - } else { - if ($inventoryItem === null) { - throw new NotFoundHttpException('Inventory Item not found'); - } - } - - $params = [ - 'inventoryItem' => $inventoryItem, - ]; - - return $this->asCpScreen() - ->title('Inventory Item') - ->action('commerce/inventory/item-save') - ->submitButtonLabel(Craft::t('app', 'Save')) - ->redirectUrl('commerce/inventory') - ->contentTemplate('commerce/inventory/item/_edit.twig', $params) - ->addCrumb(Craft::t('commerce', 'Inventory'), 'commerce/inventory') - ->tabs( - [ - 'details' => [ - 'label' => Craft::t('commerce', 'Details'), - 'url' => '#details', - ], - 'history' => [ - 'label' => Craft::t('commerce', 'History'), - 'url' => '#history', - ], - ]) - ->prepareScreen( - function(Response $response, string $containerId) { - /** @var CpScreenResponseBehavior $response */ - $this->getView()->registerJs('htmx.process(document.getElementById("' . $containerId . '"));'); - } - ); - } - - /** - * @return Response - * @throws HttpException - * @throws InvalidConfigException - * @throws BadRequestHttpException - */ - public function actionItemSave(): Response - { - $this->requirePermission('commerce-manageInventoryStockLevels'); - - $inventoryItemId = Craft::$app->getRequest()->getRequiredParam('inventoryItemId'); - - if ($inventoryItemId) { - $inventoryItem = Plugin::getInstance()->getInventory()->getInventoryItemById($inventoryItemId); - } else { - throw new HttpException(404); - } - - $inventoryItem->countryCodeOfOrigin = Craft::$app->getRequest()->getParam('countryCodeOfOrigin', $inventoryItem->countryCodeOfOrigin); - $inventoryItem->administrativeAreaCodeOfOrigin = Craft::$app->getRequest()->getParam('administrativeAreaCodeOfOrigin', $inventoryItem->administrativeAreaCodeOfOrigin); - $inventoryItem->harmonizedSystemCode = Craft::$app->getRequest()->getParam('harmonizedSystemCode', $inventoryItem->harmonizedSystemCode); - - $success = Plugin::getInstance()->getInventory()->saveInventoryItem($inventoryItem); - - if (!$success) { - return $this->asModelFailure($inventoryItem, Craft::t('app', 'Couldn’t save inventory item.'), 'inventoryItem'); - } - - return $this->asModelSuccess($inventoryItem, Craft::t('app', 'inventory Item saved.'), 'inventoryItem'); - } - - /** - * commerce/inventory action - * - * @param string|null $inventoryLocationHandle - * @return Response - * @throws InvalidConfigException - * @throws DeprecationException - */ - public function actionEditLocationLevels(?string $inventoryLocationHandle = null): Response - { - $this->requirePermission('commerce-manageInventoryStockLevels'); - $view = Craft::$app->getView(); - $view->registerAssetBundle(InventoryAsset::class); - - $inventoryItemId = $this->request->getQueryParam('inventoryItemId'); // Used for quick link to manage stock - $inventoryLocations = Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocations(); - - if (!$inventoryLocationHandle) { - $inventoryLocationHandle = Craft::$app->getRequest()->getParam('inventoryLocationHandle'); - - if (!$inventoryLocationHandle) { - return $this->redirect('commerce/inventory/levels/' . $inventoryLocations[0]->handle); - } - } - - $search = Craft::$app->getRequest()->getQueryParam('search'); - - $currentLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationByHandle($inventoryLocationHandle); - $selectedItem = 'manage-' . $currentLocation->handle; - $title = $currentLocation->getUiLabel() . ' ' . Craft::t('commerce', 'Inventory'); - - $locationMenuItems = []; - - /** @var InventoryLocation $location */ - foreach ($inventoryLocations as $location) { - $locationMenuItems[] = [ - 'label' => $location->getUiLabel(), - 'url' => $location->getCpManageInventoryUrl(), - 'selected' => $location->handle === $inventoryLocationHandle, - ]; - } - $crumbs = [ - [ - 'label' => Craft::t('commerce', 'Inventory'), - 'url' => 'commerce/inventory', - ], - ]; - - if (count($locationMenuItems) > 1) { - $crumbs[] = [ - 'icon' => 'warehouse', - 'menu' => [ - 'label' => Craft::t('app', 'Select section'), - 'items' => $locationMenuItems, - ], - ]; - } else { - $crumbs[] = [ - 'label' => $currentLocation->getUiLabel(), - 'url' => $currentLocation->getCpManageInventoryUrl(), - ]; - } - - return $this->asCpScreen() - ->title($title) - ->site(Cp::requestedSite()) - ->selectableSites(Craft::$app->getSites()->getEditableSites()) - ->action(null) - ->crumbs($crumbs) - ->contentTemplate('commerce/inventory/levels/_index', compact( - 'inventoryLocations', - 'currentLocation', - 'inventoryItemId', - 'selectedItem', - 'search', - )) - ->selectedSubnavItem('inventory'); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @throws InvalidConfigException - * @throws \Throwable - */ - public function actionInventoryLevelsTableData(): Response - { - $this->requirePermission('commerce-manageInventoryStockLevels'); - - $currentUser = Craft::$app->getUser()->getIdentity(); - $inventoryLevelsManagerContainerId = $this->request->getRequiredParam('containerId'); - $inventoryItemId = $this->request->getParam('inventoryItemId'); // Used for quick link to manage stock - $page = $this->request->getParam('page', 1); - $limit = $this->request->getParam('per_page', 15); - $offset = ($page - 1) * $limit; - $inventoryLocationId = (int)Craft::$app->getRequest()->getParam('inventoryLocationId'); - $search = $this->request->getParam('search'); - - $inventoryQuery = Plugin::getInstance()->getInventory()->getInventoryLevelQuery(limit: $limit, offset: $offset, inventoryLocationId: $inventoryLocationId) - ->andWhere(['inventoryLocationId' => $inventoryLocationId]); - - if ($inventoryItemId) { - $inventoryQuery->andWhere(['inventoryItemId' => $inventoryItemId]); - } - - $inventoryQuery->addSelect(['[[purchasables.description]]', '[[purchasables.sku]]']); - $inventoryQuery->leftJoin(['purchasables' => Table::PURCHASABLES], '[[ii.purchasableId]] = [[purchasables.id]]'); - $inventoryQuery->addGroupBy(['[[purchasables.description]]', '[[purchasables.sku]]']); - - $inventoryQuery->andWhere(['not', ['elements.id' => null]]); - - if ($search) { - $likeOperator = Craft::$app->getDb()->getIsPgsql() ? 'ilike' : 'like'; - $inventoryQuery->andWhere(['or', [$likeOperator, 'purchasables.description', $search], [$likeOperator, 'purchasables.sku', $search]]); - } - - $sort = $this->request->getParam('sort'); - if ($sort) { - $field = $sort[0]['sortField']; - $direction = $sort[0]['direction']; - - // Validate the sorting inputs - if (!in_array($direction, ['asc', 'desc']) || - !in_array($field, [ - 'item', - 'sku', - 'reservedTotal', - 'damagedTotal', - 'safetyTotal', - 'qualityControlTotal', - 'committedTotal', - 'availableTotal', - 'onHandTotal', - 'incomingTotal', - ])) { - $field = null; - $direction = null; - } - - if ($field && $direction) { - if ($field == 'sku') { - $field = 'purchasables.sku'; - } - - if ($field == 'item') { - $field = 'purchasables.description'; - } - $inventoryQuery->addOrderBy($field . ' ' . $direction); - } - } - - $inventoryTableData = $inventoryQuery->all(); - - $total = $inventoryQuery - ->limit(null) - ->offset(null) - ->count(); - - // Batch-load all purchasables for this page in one query per element type, - // rather than one getElementById call per row. - $requestedSite = Cp::requestedSite(); - $purchasableIds = array_unique(array_filter(array_column($inventoryTableData, 'purchasableId'))); - $purchasablesMap = []; - if ($purchasableIds) { - $elementTypes = (new Query()) - ->select(['id', 'type']) - ->from(CraftTable::ELEMENTS) - ->where(['id' => $purchasableIds]) - ->pairs(); - $byType = []; - foreach ($elementTypes as $id => $type) { - /** @var class-string<\craft\base\Element> $type */ - $byType[$type][] = $id; - } - foreach ($byType as $type => $ids) { - foreach ($type::find()->id($ids)->siteId($requestedSite->id)->all() as $element) { - $purchasablesMap[$element->id] = $element; - } - } - } - - $view = Craft::$app->getView(); - $time = microtime(true); - foreach ($inventoryTableData as $key => &$inventoryLevel) { - $id = $inventoryLevel['inventoryItemId']; - /** @var ?Purchasable $purchasable */ - $purchasable = $purchasablesMap[$inventoryLevel['purchasableId']] ?? null; - $inventoryItemDomId = sprintf("edit-$id-link-%s", mt_rand()); - if ($purchasable) { - // When providing the `labelHtml` option we need to encode it ourselves - $inventoryLevel['purchasable'] = Cp::chipHtml($purchasable, ['labelHtml' => Html::encode($purchasable->getDescription()), 'showActionMenu' => !$purchasable->getIsDraft() && $purchasable->canSave($currentUser)]); - } else { - $inventoryLevel['purchasable'] = Html::encode($inventoryLevel['description']); - } - if (PurchasableHelper::isTempSku($inventoryLevel['sku'])) { - $inventoryLevel['sku'] = ''; - } - - // Ensure encoded SKU - $inventoryLevel['sku'] = Html::tag('span', Html::a(Html::encode($inventoryLevel['sku']), "#", ['id' => "$inventoryItemDomId", 'class' => 'code'])); - $inventoryLevel['id'] = $id; - - $view->registerJsWithVars(fn($id, $params, $inventoryLevelsManagerContainerId) => << { - e.preventDefault(); - const slideout = new Craft.CpScreenSlideout('commerce/inventory/item-edit', $params); - slideout.on('close', (e) => { - $($inventoryLevelsManagerContainerId).data('inventoryLevelsManager').adminTable.reload(); - }); -}); -JS, [ - $inventoryItemDomId, - ['params' => ['inventoryItemId' => $id]], - $inventoryLevelsManagerContainerId, - ]); - - // @TODO Reduce the number of per-row modal click listeners registered here for inventory level columns - $columnTypes = [...InventoryTransactionType::values(), 'onHand']; - ArrayHelper::removeValue($columnTypes, 'fulfilled'); - foreach ($columnTypes as $type) { - $items = []; - $id = $inventoryLevel['id']; - - $showOrderLinks = ( - $type == InventoryTransactionType::COMMITTED->value && - $inventoryLevel['committedTotal'] > 0 - ); - - if ($showOrderLinks) { - $showOrderLinksId = sprintf("$type-show-$id-order-links-%s", mt_rand()); - $items['orderLinks'] = [ - 'type' => MenuItemType::Button, - 'id' => $showOrderLinksId, - 'label' => Craft::t('commerce', 'See Orders'), - 'icon' => 'cart-shopping', - ]; - - $view->registerJsWithVars(fn($id, $params, $inventoryLevelsManagerContainerId) => << { - e.preventDefault(); - let modal = new Craft.CpModal('commerce/inventory/unfulfilled-orders', { - containerElement: 'div', - showSubmitButton: false, - params: $params - }) - modal.on('close', (e) => { - $($inventoryLevelsManagerContainerId).data('inventoryLevelsManager').adminTable.reload(); - }); -}); -JS, [ - $showOrderLinksId, - [ - 'inventoryItemId' => $inventoryLevel['inventoryItemId'], - 'inventoryLocationId' => $inventoryLevel['inventoryLocationId'], - ], - $inventoryLevelsManagerContainerId, - ]); - } - - $showSet = ( - $type == 'onHand' || - in_array(InventoryTransactionType::from($type), InventoryTransactionType::allowedManualAdjustmentTypes()) - ); - - if ($showSet) { - $setId = sprintf("$type-update-level-$id-set-%s", mt_rand()); - $items['set'] = [ - 'type' => MenuItemType::Button, - 'id' => $setId, - 'label' => Craft::t('commerce', 'Set Quantity'), - 'icon' => 'bullseye', - ]; - - $view->registerJsWithVars(fn($id, $params, $inventoryLevelsManagerContainerId) => << { - e.preventDefault(); - let modal = new Craft.Commerce.UpdateInventoryLevelModal({ - params: $params, - showHeader: true - }) - modal.on('submit', (e) => { - $($inventoryLevelsManagerContainerId).data('inventoryLevelsManager').adminTable.reload(); - }); -}); -JS, [ - $setId, - [ - 'ids' => [$inventoryLevel['inventoryItemId']], - 'inventoryLocationId' => $inventoryLevel['inventoryLocationId'], - 'updateAction' => InventoryUpdateQuantityType::SET->value, - 'type' => $type, - ], - $inventoryLevelsManagerContainerId, - ]); - } - - // Leave as it until we add more conditions for showing an adjustment - $showAdjust = $showSet; - - if ($showAdjust) { - $adjustId = sprintf("$type-update-level-$id-adjust-%s", mt_rand()); - $items['adjust'] = [ - 'type' => MenuItemType::Button, - 'id' => $adjustId, - 'icon' => 'arrow-trend-up', - 'label' => Craft::t('commerce', 'Adjust Quantity'), - ]; - - $view->registerJsWithVars(fn($id, $params, $inventoryLevelsManagerContainerId) => << { - e.preventDefault(); - let modal = new Craft.Commerce.UpdateInventoryLevelModal({ - params: $params, - showHeader: true - }) - modal.on('submit', (e) => { - $($inventoryLevelsManagerContainerId).data('inventoryLevelsManager').adminTable.reload(); - }); -}); -JS, [ - $adjustId, - [ - 'ids' => [$inventoryLevel['inventoryItemId']], - 'inventoryLocationId' => $inventoryLevel['inventoryLocationId'], - 'updateAction' => InventoryUpdateQuantityType::ADJUST->value, - 'type' => $type, - ], - $inventoryLevelsManagerContainerId, - ]); - } - - $showMovement = ( - $type !== 'onHand' && - in_array(InventoryTransactionType::from($type), InventoryTransactionType::allowedManualMoveTransactionTypes()) && - $inventoryLevel[$type . 'Total'] > 0); - - if ($showMovement) { - $movementId = sprintf("$type-inventory-movement-$id-%s", mt_rand()); - $items['movement'] = [ - 'type' => MenuItemType::Button, - 'id' => $movementId, - 'icon' => 'arrow-right', - 'label' => Craft::t('commerce', 'Move Inventory'), - ]; - - $view->registerJsWithVars(fn($id, $params, $inventoryLevelsManagerContainerId) => << { - e.preventDefault(); - let modal = new Craft.Commerce.InventoryMovementModal({ - params: $params, - showHeader: true - }) - modal.on('submit', (e) => { - console.log(e); - $($inventoryLevelsManagerContainerId).data('inventoryLevelsManager').adminTable.reload(); - }); -}); -JS, [ - $movementId, - [ - 'inventoryMovement' => [ - 'note' => '', - 'fromInventoryTransactionType' => $type, - 'quantity' => '0', - 'inventoryItemId' => $inventoryLevel['inventoryItemId'], - 'fromInventoryLocationId' => $inventoryLevel['inventoryLocationId'], - ], - ], - $inventoryLevelsManagerContainerId, - ]); - } - - - $config = [ - 'class' => '', - 'hiddenLabel' => Craft::t('app', 'Actions'), - 'buttonAttributes' => [ - 'class' => ['action-btn'], - 'data' => [ - 'icon' => 'ellipsis', - 'inventoryItemId' => $inventoryLevel['inventoryItemId'], - 'inventoryLocationId' => $inventoryLocationId, - 'type' => $type, - ], - ], - ]; - $valueDiv = $inventoryLevel[$type . 'Total']; - $actionButton = Cp::disclosureMenu($items, $config); - $inventoryLevel[$type] = $valueDiv . (count($items) ? $actionButton : ''); - } - } - - $totalTime = sprintf(' (time: %.3fs)', microtime(true) - $time); - return $this->asJson([ - 'pagination' => AdminTable::paginationLinks($page, $total, $limit), - 'data' => $inventoryTableData, - 'headHtml' => $view->getHeadHtml(), - 'bodyHtml' => $view->getBodyHtml(), - ]); - } - - /** - * @return Response - * @throws InvalidConfigException - * @throws BadRequestHttpException - */ - public function actionUpdateLevels(): Response - { - $this->requirePermission('commerce-manageInventoryStockLevels'); - - $updateAction = InventoryUpdateQuantityType::from(Craft::$app->getRequest()->getRequiredParam('updateAction')); - $quantity = (int)Craft::$app->getRequest()->getRequiredParam('quantity'); - $note = Craft::$app->getRequest()->getRequiredParam('note'); - $inventoryLocationId = (int)Craft::$app->getRequest()->getRequiredParam('inventoryLocationId'); - $inventoryItemIds = Craft::$app->getRequest()->getRequiredParam('ids'); - $type = Craft::$app->getRequest()->getRequiredParam('type'); - - // We don't add zero amounts as transactions movements - if ($updateAction === InventoryUpdateQuantityType::ADJUST && $quantity == 0) { - return $this->asFailure(Craft::t('commerce', 'No inventory changes made.')); - } - - $errors = []; - $updateInventoryLevels = UpdateInventoryLevelCollection::make(); - foreach ($inventoryItemIds as $inventoryItemId) { - // Verbosely set property to show usages - $updateInventoryLevel = new UpdateInventoryLevel(); - $updateInventoryLevel->type = $type; - $updateInventoryLevel->updateAction = $updateAction; - $updateInventoryLevel->inventoryItemId = $inventoryItemId; - $updateInventoryLevel->inventoryLocationId = $inventoryLocationId; - $updateInventoryLevel->quantity = $quantity; - $updateInventoryLevel->note = $note; - - $updateInventoryLevels->push($updateInventoryLevel); - } - - - if (!Plugin::getInstance()->getInventory()->executeUpdateInventoryLevels($updateInventoryLevels)) { - $errors['updateQuantities'] = [Craft::t('commerce', 'Inventory could not be set.')]; - } - - if (count($errors) > 0) { - return $this->asFailure(Craft::t('commerce', 'Inventory was not updated.',), - ['errors' => $errors] - ); - } - - $resultingInventoryLevels = []; - foreach ($updateInventoryLevels as $updateInventoryLevel) { - /** @var UpdateInventoryLevel $updateInventoryLevel */ - $resultingInventoryLevels[] = Plugin::getInstance()->getInventory()->getInventoryLevel($updateInventoryLevel->inventoryItemId, $updateInventoryLevel->inventoryLocationId); - } - - return $this->asSuccess(Craft::t('commerce', 'Inventory updated.'), [ - 'updatedItems' => collect($resultingInventoryLevels)->toArray(), - ]); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @throws DeprecationException - * @throws InvalidConfigException - */ - public function actionEditUpdateLevelsModal(): Response - { - $this->requirePermission('commerce-manageInventoryStockLevels'); - - $inventoryLocationId = (int)$this->request->getParam('inventoryLocationId'); - $note = $this->request->getParam('note', ''); - $inventoryItemIds = (array)$this->request->getParam('ids', []); // param needs to be 'ids' to be compatible with admin table - $updateAction = $this->request->getParam('updateAction', 'adjust'); - $quantity = (int)$this->request->getParam('quantity', 0); - $type = $this->request->getRequiredParam('type'); - - $inventoryLevels = []; - foreach ($inventoryItemIds as $inventoryItemId) { - $inventoryLevels[] = Plugin::getInstance()->getInventory()->getInventoryLevel((int)$inventoryItemId, $inventoryLocationId); - } - - $params = [ - 'inventoryLocationId' => $inventoryLocationId, - 'inventoryItemIds' => $inventoryItemIds, - 'inventoryLevels' => $inventoryLevels, - 'updateAction' => $updateAction, - 'inventoryLocationOptions' => Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocations()->mapWithKeys(fn($location) => [$location->id => $location->getUiLabel()])->all(), - 'type' => $type, - 'quantity' => $quantity, - 'note' => $note, - ]; - - // Live preview refresh only swaps the preview region, leaving the form inputs untouched. - if ($this->request->getParam('preview')) { - return $this->asJson([ - 'previewHtml' => Craft::$app->getView()->renderTemplate('commerce/inventory/levels/_updateInventoryLevelPreview', $params), - ]); - } - - return $this->asCpModal() - ->action('commerce/inventory/update-levels') - ->submitButtonLabel(Craft::t('commerce', 'Update')) - ->contentTemplate('commerce/inventory/levels/_updateInventoryLevelModal', $params); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @throws InvalidConfigException - * @throws Exception - */ - public function actionSaveInventoryMovement(): Response - { - $this->requirePermission('commerce-manageInventoryStockLevels'); - - $fromInventoryLocationId = (int)Craft::$app->getRequest()->getRequiredParam('inventoryMovement.fromInventoryLocationId'); - $toInventoryLocationId = (int)Craft::$app->getRequest()->getRequiredParam('inventoryMovement.toInventoryLocationId'); - $note = Craft::$app->getRequest()->getRequiredParam('inventoryMovement.note'); - $fromInventoryTransactionType = Craft::$app->getRequest()->getRequiredParam('inventoryMovement.fromInventoryTransactionType'); - $toInventoryTransactionType = Craft::$app->getRequest()->getRequiredParam('inventoryMovement.toInventoryTransactionType'); - $inventoryItemId = Craft::$app->getRequest()->getRequiredParam('inventoryMovement.inventoryItemId'); - $quantity = (int)Craft::$app->getRequest()->getRequiredParam('inventoryMovement.quantity'); - - if ($quantity == 0) { - return $this->asSuccess(Craft::t('commerce', 'No inventory movements made.')); - } - - $inventoryMovement = new InventoryManualMovement(); - $inventoryMovement->inventoryItemId = $inventoryItemId; - $inventoryMovement->fromInventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($fromInventoryLocationId); - $inventoryMovement->toInventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($toInventoryLocationId); - $inventoryMovement->fromInventoryTransactionType = InventoryTransactionType::from($fromInventoryTransactionType); - $inventoryMovement->toInventoryTransactionType = InventoryTransactionType::from($toInventoryTransactionType); - $inventoryMovement->quantity = $quantity; - $inventoryMovement->note = $note; - - if ($inventoryMovement->validate()) { - /** @var InventoryMovementCollection $inventoryMovementCollection */ - $inventoryMovementCollection = InventoryMovementCollection::make()->push($inventoryMovement); - if (!Plugin::getInstance()->getInventory()->executeInventoryMovements($inventoryMovementCollection)) { - return $this->asFailure(Craft::t('commerce', 'Inventory movement could not be saved.')); - } - } - - return $this->asSuccess(Craft::t('commerce', 'Inventory movement saved.')); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @throws InvalidConfigException - */ - public function actionEditMovementModal(): Response - { - $this->requirePermission('commerce-manageInventoryStockLevels'); - - $fromInventoryLocationId = (int)Craft::$app->getRequest()->getRequiredParam('inventoryMovement.fromInventoryLocationId'); - $toInventoryLocationId = (int)Craft::$app->getRequest()->getParam('inventoryMovement.toInventoryLocationId', $fromInventoryLocationId); - $note = Craft::$app->getRequest()->getParam('inventoryMovement.note', ''); - $fromInventoryTransactionType = Craft::$app->getRequest()->getParam('inventoryMovement.fromInventoryTransactionType'); - $toInventoryTransactionType = Craft::$app->getRequest()->getParam('inventoryMovement.toInventoryTransactionType'); - $inventoryItemId = Craft::$app->getRequest()->getParam('inventoryMovement.inventoryItemId'); - $quantity = (int)Craft::$app->getRequest()->getParam('inventoryMovement.quantity', 0); - - $movableTo = collect(InventoryTransactionType::allowedManualMoveTransactionTypes()) - ->filter(fn($type) => $type->value !== $fromInventoryTransactionType) - ->mapWithKeys(fn($type) => [$type->value => $type->typeAsLabel()]); - - $toInventoryTransactionType = InventoryTransactionType::tryFrom($toInventoryTransactionType); - if (!$toInventoryTransactionType) { - $toInventoryTransactionType = $movableTo->keys()->first(); - } else { - $toInventoryTransactionType = $toInventoryTransactionType->value; - } - - $inventoryMovement = new InventoryManualMovement(); - $inventoryMovement->inventoryItemId = $inventoryItemId; - $inventoryMovement->fromInventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($fromInventoryLocationId); - $inventoryMovement->toInventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($toInventoryLocationId); - $inventoryMovement->fromInventoryTransactionType = InventoryTransactionType::from($fromInventoryTransactionType); - $inventoryMovement->toInventoryTransactionType = InventoryTransactionType::from($toInventoryTransactionType); - $inventoryMovement->quantity = $quantity; - $inventoryMovement->note = $note; - - $fromLevel = Plugin::getInstance()->getInventory()->getInventoryLevel($inventoryMovement->inventoryItemId, $inventoryMovement->fromInventoryLocation); - $fromTotal = $fromLevel->{$fromInventoryTransactionType . 'Total'}; - - $movableTo = $movableTo->toArray(); - $params = [ - 'inventoryMovement' => $inventoryMovement, - 'toInventoryTransactionTypes' => $movableTo, - 'maxFromQuantity' => $fromTotal, - ]; - - // Live preview refresh only swaps the preview region, leaving the form inputs untouched. - if ($this->request->getParam('preview')) { - return $this->asJson([ - 'previewHtml' => Craft::$app->getView()->renderTemplate('commerce/inventory/levels/_inventoryMovementPreview', $params), - ]); - } - - return $this->asCpModal() - ->action('commerce/inventory/save-inventory-movement') - ->submitButtonLabel(Craft::t('commerce', 'Move')) - ->contentTemplate('commerce/inventory/levels/_inventoryMovementModal', $params); - } - - /** - * @return Response - * @throws InvalidConfigException - */ - public function actionUnfulfilledOrders(): Response - { - $this->requirePermission('commerce-manageInventoryStockLevels'); - - $view = Craft::$app->getView(); - $view->registerAssetBundle(InventoryAsset::class); - - $inventoryLocationId = Craft::$app->getRequest()->getParam('inventoryLocationId'); - $inventoryItemId = Craft::$app->getRequest()->getParam('inventoryItemId'); - - $orders = Plugin::getInstance()->getInventory()->getUnfulfilledOrders($inventoryItemId, $inventoryLocationId); - - $title = Craft::t('commerce', '{count} Unfulfilled Orders', [ - 'count' => count($orders), - ]); - - return $this->asCpModal() - ->contentTemplate('commerce/inventory/levels/_unfulfilledOrdersModal', compact( - 'title', - 'orders' - )); - } -} diff --git a/src/controllers/InventoryLocationsController.php b/src/controllers/InventoryLocationsController.php deleted file mode 100644 index 56a1a4be0d..0000000000 --- a/src/controllers/InventoryLocationsController.php +++ /dev/null @@ -1,421 +0,0 @@ - - * @since 5.0.0 - */ -class InventoryLocationsController extends BaseCpController -{ - /** - * @inheritdoc - */ - public function init(): void - { - parent::init(); - - $this->requirePermission('commerce-manageInventoryLocations'); - } - - - /** - * Inventory Locations index - * - * @return Response - * @throws DeprecationException - * @throws InvalidConfigException - * @throws Throwable - */ - public function actionIndex(): Response - { - $inventoryLocations = Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocations(); - $currentUser = Craft::$app->getUser()->getIdentity(); - $variables = []; - - $screen = $this->asCpScreen() - ->title(Craft::t('commerce', 'Inventory Locations')) - ->addCrumb(Craft::t('commerce', 'Commerce'), 'commerce') - ->selectedSubnavItem('inventory-locations') - ->contentTemplate('commerce/inventory-locations/_index', $variables); - - $locationCount = count($inventoryLocations); - $showNewButton = false; - $userCanCreate = ($currentUser && $currentUser->can('commerce-createLocations')); - - if ($locationCount < Plugin::EDITION_PRO_STORE_LIMIT) { - $showNewButton = true; - } - - if ($userCanCreate && $showNewButton) { - $button = Html::a( - Craft::t('commerce', 'New location'), - 'commerce/inventory-locations/new', - [ - 'class' => 'btn submit add icon', - ]); - $screen->additionalButtonsHtml($button); - } - - return $screen; - } - - /** - * @param int|null $inventoryLocationId - * @param InventoryLocation|null $inventoryLocation - * @return Response - * @throws InvalidConfigException - * @throws NotFoundHttpException - */ - public function actionEdit(?int $inventoryLocationId = null, ?InventoryLocation $inventoryLocation = null): Response - { - if ($inventoryLocationId !== null) { - if ($inventoryLocation === null) { - $inventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($inventoryLocationId); - - if (!$inventoryLocation) { - throw new NotFoundHttpException('Inventory location not found'); - } - } - - $title = trim($inventoryLocation->getUiLabel()) ?: Craft::t('app', 'Edit Inventory Location'); - } else { - if ($inventoryLocation === null) { - $inventoryLocation = new InventoryLocation(); - - $title = Craft::t('app', 'Create a new inventory location'); - } else { - $title = Craft::t('app', 'Create a new inventory location'); - } - } - - Craft::$app->getView()->setNamespace('inventoryLocationAddress'); - - $address = $inventoryLocation->getAddress(); - $fieldLayout = $address->getFieldLayout(); - - $form = $fieldLayout->createForm($address); - $form->tabIdPrefix = 'inventoryLocationAddress'; - $tabs = $form->getTabMenu(); - // Reset the `tabIdPrefix` so that the namespaces are correct for the inventory location fields - $form->tabIdPrefix = null; - - // Remove the title/label field from the address field layout - foreach ($form->tabs as &$tab) { - $values = array_values(array_filter($tab->elements, function($element) { - if (is_array($element) && $element[0] instanceof LabelField && $element[0]->attribute === 'title') { - return false; - } - - return true; - })); - $tab->elements = $values; - } - - ArrayHelper::prependOrAppend($form->tabs[0]->elements, [ - null, - false, - Html::tag('hr'), - false, - ], true); - ArrayHelper::prependOrAppend($form->tabs[0]->elements, [ - null, - false, - Html::hiddenInput('id', (string)$address->id), - false, - ], true); - ArrayHelper::prependOrAppend($form->tabs[0]->elements, [ - null, - false, - Cp::textFieldHtml([ - 'name' => 'handle', - 'id' => 'handle', - 'value' => $inventoryLocation->handle, - 'required' => true, - 'label' => Craft::t('commerce', 'Handle'), - 'errors' => $inventoryLocation->getErrors('handle'), - ]), - false, - ], true); - ArrayHelper::prependOrAppend($form->tabs[0]->elements, [ - null, - false, - Cp::textFieldHtml([ - 'name' => 'name', - 'id' => 'name', - 'value' => $inventoryLocation->name, - 'required' => true, - 'label' => Craft::t('commerce', 'Name'), - 'errors' => $inventoryLocation->getErrors('name'), - ]), - false, - ], true); - ArrayHelper::prependOrAppend($form->tabs[0]->elements, [ - null, - false, - Html::hiddenInput('inventoryLocationId', (string)$inventoryLocationId), - false, - ], true); - - $variables = [ - 'inventoryLocationId' => $inventoryLocationId, - 'inventoryLocation' => $inventoryLocation, - 'typeName' => Craft::t('commerce', 'Inventory Location'), - 'lowerTypeName' => Craft::t('commerce', 'inventory location'), - 'locationFieldHtml' => '', - 'addressField' => new AddressField(), - 'form' => $form, - 'countries' => Craft::$app->getAddresses()->getCountryRepository()->getList(Craft::$app->language), - ]; - - return $this->asCpScreen() - ->title($title) - ->tabs($tabs) - ->addCrumb(Craft::t('commerce', 'Commerce'), 'commerce') - ->addCrumb(Craft::t('commerce', 'Inventory Locations'), 'commerce/inventory-locations') - ->action('commerce/inventory-locations/save') - ->redirectUrl('commerce/inventory-locations') - ->selectedSubnavItem('inventory-locations') - ->contentTemplate('commerce/inventory-locations/_edit', $variables); - } - - /** - * @return Response|null - * @throws InvalidConfigException - * @throws MethodNotAllowedHttpException - * @throws Throwable - * @throws ElementNotFoundException - * @throws \yii\base\Exception - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - - // find the inventory location or make a new one - $inventoryLocationId = Craft::$app->getRequest()->getBodyParam('inventoryLocationAddress[inventoryLocationId]'); - $inventoryLocation = null; - - if ($inventoryLocationId) { - $inventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($inventoryLocationId); - } - - if (!$inventoryLocation) { - $inventoryLocation = new InventoryLocation(); - } - - $inventoryLocation->name = Craft::$app->getRequest()->getBodyParam('inventoryLocationAddress[name]'); - $inventoryLocation->handle = Craft::$app->getRequest()->getBodyParam('inventoryLocationAddress[handle]'); - - // Pre-validate the inventory location so that we don't save the address if the rest isn't valid - // This is to avoid orphaned addresses - $isValid = $inventoryLocation->validate(); - - if ($inventoryLocationAddress = Craft::$app->getRequest()->getBodyParam('inventoryLocationAddress')) { - // Remove the non-address fields from the post data - unset($inventoryLocationAddress['name'], $inventoryLocationAddress['handle'], $inventoryLocationAddress['inventoryLocationId']); - - $inventoryLocationAddress['title'] = $inventoryLocation->name; - if ($isValid) { - $addressId = $inventoryLocationAddress['id'] ?: null; - $address = $addressId ? Craft::$app->getElements()->getElementById($addressId, Address::class) : new Address(); - - $address->id = $addressId; - } else { - $address = new Address(); - } - - $address->setAttributes($inventoryLocationAddress, false); - - if (isset($inventoryLocationAddress['fields'])) { - $address->setFieldValues($inventoryLocationAddress['fields']); - } - - // Only try and save if the inventory location is valid - $hasAddressErrors = false; - if ($isValid && !Craft::$app->getElements()->saveElement($address)) { - $hasAddressErrors = $address->hasErrors(); - } else { - // If we aren't saving the address let's validate it to show any potential errors - if (!$address->validate()) { - $hasAddressErrors = $address->hasErrors(); - } - } - - if ($hasAddressErrors) { - $inventoryLocation->addModelErrors($address, 'address'); - } - - $inventoryLocation->setAddress($address); - } - - $inventoryLocation->addressId = $inventoryLocation->getAddress()->id; - - if ($inventoryLocation->hasErrors() || !Plugin::getInstance()->getInventoryLocations()->saveInventoryLocation($inventoryLocation)) { - return $this->asModelFailure( - model: $inventoryLocation, - message: Craft::t('commerce', 'Couldn’t save inventory location.'), - modelName: 'inventoryLocation' - ); - } - - return $this->asModelSuccess( - model: $inventoryLocation, - message: Craft::t('commerce', 'Inventory location saved.'), - modelName: 'inventoryLocation' - ); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @throws DeprecationException - * @throws InvalidConfigException - */ - public function actionInventoryLocationsTableData(): Response - { - $this->requireAcceptsJson(); - $view = $this->getView(); - $inventoryLocations = Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocations(); - - $data = []; - foreach ($inventoryLocations as $inventoryLocation) { - $id = $inventoryLocation->id; - $deleteButtonId = sprintf("deleteButton-$id-%s", mt_rand()); - - $deleteButton = Html::a('', '#', [ - 'role' => 'button', - 'title' => Craft::t('commerce', 'Delete'), - 'class' => 'delete icon', - 'id' => $deleteButtonId, - ]); - - $view->registerJsWithVars(fn($id, $settings) => << { - e.preventDefault(); - const slideout = new Craft.CpModal('commerce/inventory-locations/prepare-delete-modal', $settings); - slideout.on('close', (e) => { - window.InventoryLocationsAdminTable.reload(); - }); -}); -JS, [ - $deleteButtonId, - ['params' => ['inventoryLocationId' => $id]], - ]); - - /** @var InventoryLocation $inventoryLocation */ - $data[] = [ - 'id' => $inventoryLocation->id, - 'title' => $inventoryLocation->getUiLabel(), - 'handle' => $inventoryLocation->handle, - 'address' => Html::encode($inventoryLocation->getAddressLine()), - 'url' => $inventoryLocation->getCpEditUrl(), - 'delete' => $inventoryLocations->count() > 1 ? $deleteButton : '', - ]; - } - - return $this->asJson([ - 'data' => $data, - 'headHtml' => $view->getHeadHtml(), - 'bodyHtml' => $view->getBodyHtml(), - ]); - } - - /** - * @return \craft\web\Response - * @throws DeprecationException - * @throws InvalidConfigException - * @throws BadRequestHttpException - */ - public function actionPrepareDeleteModal(): Response - { - $this->requireAcceptsJson(); - $inventoryLocationId = Craft::$app->getRequest()->getRequiredParam('inventoryLocationId'); - $inventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($inventoryLocationId); - $allInventoryLocations = Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocations(); - - $destinationInventoryLocations = $allInventoryLocations - ->filter(fn($location) => $location->id != $inventoryLocation->id); - - $destinationInventoryLocationsOptions = $destinationInventoryLocations - ->map(fn($location) => ['value' => $location->id, 'label' => $location->getUiLabel()])->all(); - - if (empty($destinationInventoryLocationsOptions)) { - // throw exception not allowed to delete - throw new \Exception('Can not delete last inventory location.'); - } - - $deactivateInventoryLocation = new DeactivateInventoryLocation([ - 'inventoryLocation' => $inventoryLocation, - 'destinationInventoryLocation' => $destinationInventoryLocations->first(), - ]); - - return $this->asCpModal() - ->action('commerce/inventory-locations/deactivate') - ->submitButtonLabel(Craft::t('commerce', 'Delete')) - ->errorSummary('Can not delete inventory location.') - ->contentTemplate('commerce/inventory-locations/_deleteModal', [ - 'deactivateInventoryLocation' => $deactivateInventoryLocation, - 'inventoryLocationOptions' => $destinationInventoryLocationsOptions, - ]); - } - - /** - * @return Response - * @throws Throwable - * @throws InvalidConfigException - * @throws Exception - * @throws BadRequestHttpException - * @throws MethodNotAllowedHttpException - */ - public function actionDeactivate(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $inventoryLocationId = Craft::$app->getRequest()->getRequiredBodyParam('inventoryLocation'); - $destinationInventoryLocationId = Craft::$app->getRequest()->getRequiredBodyParam('destinationInventoryLocation'); - - $inventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($inventoryLocationId); - $destinationInventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($destinationInventoryLocationId); - - $deactivateInventoryLocation = new DeactivateInventoryLocation([ - 'inventoryLocation' => $inventoryLocation, - 'destinationInventoryLocation' => $destinationInventoryLocation, - ]); - - if (!Plugin::getInstance()->getInventoryLocations()->executeDeactivateInventoryLocation($deactivateInventoryLocation)) { - return $this->asFailure(Craft::t('commerce', 'Inventory was not updated.',), - ['errors' => $deactivateInventoryLocation->getErrors()] - ); - } - - return $this->asJson(['success' => true]); - } -} diff --git a/src/controllers/LineItemStatusesController.php b/src/controllers/LineItemStatusesController.php deleted file mode 100644 index 94aed215fe..0000000000 --- a/src/controllers/LineItemStatusesController.php +++ /dev/null @@ -1,190 +0,0 @@ - - * @since 2.0 - */ -class LineItemStatusesController extends BaseAdminController -{ - public function actionIndex(): Response - { - $lineItemStatuses = []; - $stores = Plugin::getInstance()->getStores()->getAllStores(); - - $stores->each(function(Store $store) use (&$lineItemStatuses) { - $lineItemStatuses[$store->handle] = Plugin::getInstance()->getLineItemStatuses()->getAllLineItemStatuses($store->id); - }); - $stores = $stores->all(); - - return $this->renderTemplate('commerce/settings/lineitemstatuses/index', [ - 'lineItemStatuses' => $lineItemStatuses, - 'stores' => $stores, - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * @param int|null $id - * @param LineItemStatus|null $lineItemStatus - * @throws HttpException - */ - public function actionEdit(?string $storeHandle = null, int $id = null, LineItemStatus $lineItemStatus = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - if (!$lineItemStatus) { - if ($id) { - $lineItemStatus = Plugin::getInstance()->getLineItemStatuses()->getLineItemStatusById($id, $store->id); - - if (!$lineItemStatus) { - throw new HttpException(404); - } - } else { - $lineItemStatus = Craft::createObject([ - 'class' => LineItemStatus::class, - 'storeId' => $store->id, - ]); - } - } - - $statusColors = ['green', 'orange', 'red', 'blue', 'yellow', 'pink', 'purple', 'turquoise', 'light', 'grey', 'black']; - $nextAvailableColor = null; - - DebugPanel::prependOrAppendModelTab(model: $lineItemStatus, prepend: true); - - if ($lineItemStatus->id) { - $title = $lineItemStatus->name; - } else { - $title = Craft::t('commerce', 'Create a new line item status'); - - $availableColors = $statusColors; - Plugin::getInstance()->getLineItemStatuses()->getAllLineItemStatuses($store->id)->each(function(LineItemStatus $status) use (&$availableColors) { - $key = array_search($status->color, $availableColors, true); - if ($key !== false) { - unset($availableColors[$key]); - } - }); - - $nextAvailableColor = !empty($availableColors) ? array_shift($availableColors) : 'green'; - } - - return $this->asCpScreen() - ->title($title) - ->crumbs([ - ['label' => Craft::t('commerce', 'Commerce'), 'url' => 'commerce'], - ['label' => Craft::t('app', 'Settings'), 'url' => 'commerce/settings', 'ariaLabel' => Craft::t('commerce', 'Commerce Settings')], - ['label' => Craft::t('commerce', 'Line Item Statuses'), 'url' => 'commerce/settings/lineitemstatuses'], - ]) - ->selectedSubnavItem('settings') - ->action('commerce/line-item-statuses/save') - ->redirectUrl('commerce/settings/lineitemstatuses') - ->contentTemplate('commerce/settings/lineitemstatuses/_edit', [ - 'lineItemStatus' => $lineItemStatus, - 'statusColors' => $statusColors, - 'nextAvailableColor' => $nextAvailableColor, - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * @throws BadRequestHttpException - * @throws ErrorException - * @throws Exception - * @throws MissingComponentException - */ - public function actionSave(): void - { - $this->requirePostRequest(); - - $id = $this->request->getBodyParam('id'); - $lineItemStatus = $id ? Plugin::getInstance()->getLineItemStatuses()->getLineItemStatusById($id, $this->request->getBodyParam('storeId')) : false; - - if (!$lineItemStatus) { - $lineItemStatus = new LineItemStatus(); - } - - $lineItemStatus->storeId = $this->request->getBodyParam('storeId'); - $lineItemStatus->name = $this->request->getBodyParam('name'); - $lineItemStatus->handle = $this->request->getBodyParam('handle'); - $lineItemStatus->color = $this->request->getBodyParam('color'); - $lineItemStatus->default = (bool)$this->request->getBodyParam('default'); - - // Save it - if (Plugin::getInstance()->getLineItemStatuses()->saveLineItemStatus($lineItemStatus)) { - $this->setSuccessFlash(Craft::t('commerce', 'Order status saved.')); - $this->redirectToPostedUrl($lineItemStatus); - } else { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save line item status.')); - } - - Craft::$app->getUrlManager()->setRouteParams(compact('lineItemStatus')); - } - - /** - * @throws BadRequestHttpException - * @throws ErrorException - * @throws Exception - * @throws NotSupportedException - * @throws ServerErrorHttpException - */ - public function actionReorder(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $ids = Json::decode($this->request->getRequiredBodyParam('ids')); - if (!Plugin::getInstance()->getLineItemStatuses()->reorderLineItemStatuses($ids)) { - return $this->asFailure(Craft::t('commerce', 'Couldn’t reorder Line Item Statuses.')); - } - - return $this->asSuccess(); - } - - /** - * @throws BadRequestHttpException - * @throws Throwable - */ - public function actionArchive(): ?Response - { - $this->requireAcceptsJson(); - - $lineItemStatusId = $this->request->getRequiredParam('id'); - - $storeId = (new Query())->from(Table::LINEITEMSTATUSES)->select(['storeId'])->where(['id' => $lineItemStatusId])->scalar(); - - if (!$storeId || !Plugin::getInstance()->getLineItemStatuses()->archiveLineItemStatusById((int)$lineItemStatusId, $storeId)) { - return $this->asFailure(Craft::t('commerce', 'Couldn’t archive Line Item Status.')); - } - - return $this->asSuccess(); - } -} diff --git a/src/controllers/OrderSettingsController.php b/src/controllers/OrderSettingsController.php deleted file mode 100644 index d92ff0a98f..0000000000 --- a/src/controllers/OrderSettingsController.php +++ /dev/null @@ -1,91 +0,0 @@ - - * @since 2.0 - */ -class OrderSettingsController extends BaseAdminController -{ - public function actionEdit(array $variables = []): Response - { - $fieldLayout = Craft::$app->getFields()->getLayoutByType(Order::class); - - $variables['fieldLayout'] = $fieldLayout; - $variables['title'] = Craft::t('commerce', 'Order Settings'); - $variables['readOnly'] = $this->isReadOnlyScreen(); - - return $this->renderTemplate('commerce/settings/ordersettings/_edit', $variables); - } - - /** - * @throws BadRequestHttpException - * @throws ErrorException - * @throws Exception - * @throws InvalidConfigException - * @throws NotSupportedException - * @throws ServerErrorHttpException - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - - $fieldLayout = Craft::$app->getFields()->assembleLayoutFromPost(); - - $fieldLayout->reservedFieldHandles = [ - 'billingAddress', - 'customer', - 'estimatedBillingAddress', - 'estimatedShippingAddress', - 'paymentAmount', - 'paymentCurrency', - 'paymentSource', - 'recalculationMode', - 'shippingAddress', - ]; - - if (!$fieldLayout->validate()) { - Craft::info('Field layout not saved due to validation error.', __METHOD__); - - Craft::$app->getUrlManager()->setRouteParams([ - 'variables' => [ - 'fieldLayout' => $fieldLayout, - ], - ]); - - return $this->asFailure(Craft::t('commerce', 'Couldn’t save order fields.')); - } - - if ($currentOrderFieldLayout = Craft::$app->getProjectConfig()->get(Orders::CONFIG_FIELDLAYOUT_KEY)) { - $uid = ArrayHelper::firstKey($currentOrderFieldLayout); - } else { - $uid = StringHelper::UUID(); - } - - $configData = [$uid => $fieldLayout->getConfig()]; - Craft::$app->getProjectConfig()->set(Orders::CONFIG_FIELDLAYOUT_KEY, $configData); - - return $this->asSuccess(Craft::t('commerce', 'Order fields saved.')); - } -} diff --git a/src/controllers/OrderStatusesController.php b/src/controllers/OrderStatusesController.php deleted file mode 100644 index ed90fa0ef3..0000000000 --- a/src/controllers/OrderStatusesController.php +++ /dev/null @@ -1,233 +0,0 @@ - - * @since 2.0 - */ -class OrderStatusesController extends BaseAdminController -{ - public function actionIndex(): Response - { - $orderStatuses = []; - $stores = Plugin::getInstance()->getStores()->getAllStores(); - - $stores->each(function(Store $store) use (&$orderStatuses) { - $orderStatuses[$store->handle] = Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses($store->id); - }); - $stores = $stores->all(); - - return $this->renderTemplate('commerce/settings/orderstatuses/index', [ - 'orderStatuses' => $orderStatuses, - 'stores' => $stores, - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * @param int|null $id - * @param OrderStatus|null $orderStatus - * @throws HttpException - */ - public function actionEdit(?string $storeHandle = null, int $id = null, OrderStatus $orderStatus = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - if (!$orderStatus) { - if ($id) { - $orderStatus = Plugin::getInstance()->getOrderStatuses()->getOrderStatusById($id, $store->id); - - if (!$orderStatus) { - throw new HttpException(404); - } - } else { - $orderStatus = Craft::createObject([ - 'class' => OrderStatus::class, - 'attributes' => ['storeId' => $store->id], - ]); - } - } - - $statusColors = ['green', 'orange', 'red', 'blue', 'yellow', 'pink', 'purple', 'turquoise', 'light', 'grey', 'black']; - $nextAvailableColor = null; - - if ($orderStatus->id) { - $title = $orderStatus->name; - } else { - $title = Craft::t('commerce', 'Create a new order status'); - - $availableColors = $statusColors; - Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses($store->id)->each(function(OrderStatus $status) use (&$availableColors) { - $key = array_search($status->color, $availableColors, true); - if ($key !== false) { - unset($availableColors[$key]); - } - }); - - $nextAvailableColor = !empty($availableColors) ? array_shift($availableColors) : 'green'; - } - - DebugPanel::prependOrAppendModelTab(model: $orderStatus, prepend: true); - - $emails = Plugin::getInstance()->getEmails()->getAllEmails($store->id)->mapWithKeys(fn(Email $email) => [$email->id => $email->name])->all(); - - return $this->asCpScreen() - ->title($title) - ->crumbs([ - ['label' => Craft::t('commerce', 'Commerce'), 'url' => 'commerce'], - ['label' => Craft::t('app', 'Settings'), 'url' => 'commerce/settings', 'ariaLabel' => Craft::t('commerce', 'Commerce Settings')], - ['label' => Craft::t('commerce', 'Order Statuses'), 'url' => 'commerce/settings/orderstatuses'], - ]) - ->selectedSubnavItem('settings') - ->action('commerce/order-statuses/save') - ->redirectUrl('commerce/settings/orderstatuses') - ->contentTemplate('commerce/settings/orderstatuses/_edit', [ - 'orderStatus' => $orderStatus, - 'statusColors' => $statusColors, - 'nextAvailableColor' => $nextAvailableColor, - 'emails' => $emails, - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * @throws Exception - * @throws BadRequestHttpException - */ - public function actionSave(): void - { - $this->requirePostRequest(); - - $id = $this->request->getBodyParam('id'); - $storeId = $this->request->getBodyParam('storeId'); - $orderStatus = $id ? Plugin::getInstance()->getOrderStatuses()->getOrderStatusById($id, $storeId) : false; - - if (!$orderStatus) { - $orderStatus = new OrderStatus(); - } - - $orderStatus->storeId = $storeId; - $orderStatus->name = $this->request->getBodyParam('name'); - $orderStatus->handle = $this->request->getBodyParam('handle'); - $orderStatus->color = $this->request->getBodyParam('color'); - $orderStatus->description = $this->request->getBodyParam('description'); - $orderStatus->default = (bool)$this->request->getBodyParam('default'); - $emailIds = $this->request->getBodyParam('emails', []); - - if (!$emailIds) { - $emailIds = []; - } - - if (!$id) { - $orderStatus->sortOrder = (new Query()) - ->from(Table::ORDERSTATUSES) - ->where(['storeId' => $storeId]) - ->max("[[sortOrder]]") + 1; - } - - // Save it - if (Plugin::getInstance()->getOrderStatuses()->saveOrderStatus($orderStatus, $emailIds)) { - $this->setSuccessFlash(Craft::t('commerce', 'Order status saved.')); - $this->redirectToPostedUrl($orderStatus); - } else { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save order status.')); - } - - Craft::$app->getUrlManager()->setRouteParams(compact('orderStatus', 'emailIds')); - } - - /** - * Returns the order statuses for a store based on the current user. - * - * @return Response - * @throws BadRequestHttpException - * @since 5.0.0 - */ - public function actionGetOrderStatuses(): Response - { - $this->requireAcceptsJson(); - - $storeId = $this->request->getRequiredParam('storeId'); - $store = Plugin::getInstance()->getStores()->getStoreById($storeId); - $allowableStoreIds = Plugin::getInstance()->getStores()->getStoresByUserId(Craft::$app->getUser()->id)->map(fn(Store $s) => $s->id)->all(); - - if (!$store || !in_array($store->id, $allowableStoreIds)) { - return $this->asFailure(Craft::t('commerce', 'Invalid store.')); - } - - $orderStatuses = Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses($storeId)->all(); - - return $this->asSuccess(data: compact('orderStatuses')); - } - - /** - * @throws BadRequestHttpException - * @throws Exception - * @throws ErrorException - * @throws NotSupportedException - * @throws ServerErrorHttpException - */ - public function actionReorder(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - $ids = Json::decode($this->request->getRequiredBodyParam('ids')); - - if (!Plugin::getInstance()->getOrderStatuses()->reorderOrderStatuses($ids)) { - return $this->asFailure(Craft::t('commerce', 'Couldn’t reorder Order Statuses.')); - } - - return $this->asSuccess(); - } - - /** - * @throws Throwable - * @throws BadRequestHttpException - * @since 2.2 - */ - public function actionDelete(): ?Response - { - $this->requireAcceptsJson(); - $orderStatusId = $this->request->getRequiredParam('id'); - - if (!$orderStatusId) { - return $this->asFailure(Craft::t('commerce', 'Couldn’t archive Order Status.')); - } - - $storeId = (new Query())->from(Table::ORDERSTATUSES)->select(['storeId'])->where(['id' => $orderStatusId])->scalar(); - - if (!$storeId || !Plugin::getInstance()->getOrderStatuses()->deleteOrderStatusById((int)$orderStatusId, $storeId)) { - return $this->asFailure(Craft::t('commerce', 'Couldn’t archive Order Status.')); - } - - return $this->asSuccess(); - } -} diff --git a/src/controllers/OrdersController.php b/src/controllers/OrdersController.php deleted file mode 100644 index e0d9c704f1..0000000000 --- a/src/controllers/OrdersController.php +++ /dev/null @@ -1,2228 +0,0 @@ - - * @since 2.2 - */ -class OrdersController extends Controller -{ - /** - * @event Event The event that’s triggered when retrieving the purchasables for the add line item table on the order edit page. - * @since 4.3.0 - * - * --- - * ```php - * use craft\commerce\controllers\OrdersController; - * use craft\commerce\events\ModifyPurchasablesQueryEvent; - * use yii\base\Event; - * - * Event::on( - * OrdersController::class, - * OrdersController::EVENT_MODIFY_PURCHASABLES_TABLE_QUERY, - * function(ModifyCartInfoEvent $e) { - * $e->query->andWhere(['sku' => 'foo']); - * } - * ); - * ``` - */ - public const EVENT_MODIFY_PURCHASABLES_TABLE_QUERY = 'modifyPurchasablesTableQuery'; - - /** - * @throws HttpException - * @throws InvalidConfigException - */ - public function init(): void - { - parent::init(); - - $this->requirePermission('commerce-manageOrders'); - } - - /** - * Index of orders - * - * @throws Throwable - */ - public function actionOrderIndex(string $orderStatusHandle = ''): Response - { - Craft::$app->getView()->registerAssetBundle(CommerceCpAsset::class); - - $site = Cp::requestedSite(); - /** @var StoreBehavior $site */ - $store = $site->getStore(); - - Craft::$app->getView()->registerJs('window.orderEdit = {};', View::POS_BEGIN); - $permissions = [ - 'commerce-manageOrders' => Craft::$app->getUser()->getIdentity()->can('commerce-manageOrders'), - 'commerce-editOrders' => Craft::$app->getUser()->getIdentity()->can('commerce-editOrders'), - 'commerce-deleteOrders' => Craft::$app->getUser()->getIdentity()->can('commerce-deleteOrders'), - ]; - - Craft::$app->getView()->registerJs('window.orderEdit.currentUserPermissions = ' . Json::encode($permissions) . ';', View::POS_BEGIN); - - return $this->renderTemplate('commerce/orders/_index', compact('orderStatusHandle', 'store')); - } - - /** - * Create an order - * - * @throws ElementNotFoundException - * @throws Exception - * @throws ForbiddenHttpException - * @throws Throwable - */ - public function actionCreate(string $storeHandle): Response - { - $this->requirePermission('commerce-manageOrders'); - - $store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle); - - if (!$store) { - throw new InvalidArgumentException('Invalid store handle: ' . $storeHandle); - } - - $userId = $this->request->getParam('customerId'); - $user = $userId ? Craft::$app->getUsers()->getUserById($userId) : null; - - if ($userId && !$user) { - throw new BadRequestHttpException("Invalid user ID: $userId"); - } - - $attributes = [ - 'number' => Plugin::getInstance()->getCarts()->generateCartNumber(), - 'origin' => Order::ORIGIN_CP, - 'storeId' => $store->id, - ]; - if ($user) { - $attributes['customer'] = $user; - } - - $order = Craft::createObject([ - 'class' => Order::class, - 'attributes' => $attributes, - ]); - - if ($user) { - // Try to set defaults - $order->autoSetAddresses(); - $order->autoSetShippingMethod(); - } - - if (!Craft::$app->getElements()->saveElement($order, false)) { - throw new Exception(Craft::t('commerce', 'Can not create a new order')); - } - - return $this->redirect('commerce/orders/' . $order->id); - } - - /** - * @param Order|null $order - * @param null $paymentForm - * @throws CurrencyException - * @throws Exception - * @throws ForbiddenHttpException - * @throws HttpException - * @throws InvalidConfigException - * @throws LoaderError - * @throws RuntimeError - * @throws SyntaxError - */ - public function actionEditOrder(int $orderId, Order $order = null, $paymentForm = null): Response - { - $plugin = Plugin::getInstance(); - $variables = []; - - if ($order === null && $orderId) { - $order = $plugin->getOrders()->getOrderById($orderId); - - if (!$order) { - throw new HttpException(404, Craft::t('commerce', 'Can not find order.')); - } - } - - $this->enforceManageOrderPermissions($order); - - $variables['order'] = $order; - - DebugPanel::prependOrAppendModelTab(model: $order, prepend: true); - - $variables['paymentForm'] = $paymentForm; - $variables['orderId'] = $order->id; - - $transactions = $order->getTransactions(); - - $variables['orderTransactions'] = $this->_getTransactionsWithLevelsTableArray($transactions); - - $this->_updateTemplateVariables($variables); - $this->_registerJavascript($variables); - - return $this->renderTemplate('commerce/orders/_edit', $variables); - } - - /** - * @return Response - * @throws InvalidConfigException - * @throws \yii\db\Exception - * @throws \yii\web\MethodNotAllowedHttpException - * @since 5.0.0 - */ - public function actionFulfill(): Response - { - $this->requirePostRequest(); - - $fulfillments = $this->request->getBodyParam('fulfillment'); - $movements = []; - foreach ($fulfillments as $fulfillment) { - $qty = (int)$fulfillment['quantity']; - if ($qty != 0) { - $inventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($fulfillment['inventoryLocationId']); - - $movement = new InventoryFulfillMovement(); - $movement->fromInventoryLocation = $inventoryLocation; - $movement->inventoryItemId = $fulfillment['inventoryItemId']; - $movement->toInventoryLocation = $inventoryLocation; - $movement->fromInventoryTransactionType = InventoryTransactionType::COMMITTED; - $movement->toInventoryTransactionType = InventoryTransactionType::FULFILLED; - $movement->lineItemId = $fulfillment['lineItemId']; - $movement->quantity = $qty; - $movement->userId = Craft::$app->getUser()->getId(); - $movements[] = $movement; - } - } - - foreach ($movements as $movement) { - if (!$movement->isValid()) { - return $this->asFailure(Craft::t('commerce', 'Invalid inventory movements.'), - [ - 'errors' => ['fulfillment' => $movement->getErrors() ], - ]); - } - } - - /** @var InventoryMovementCollection $movements */ - $movements = InventoryMovementCollection::make($movements); - - if (!Plugin::getInstance()->getInventory()->executeInventoryMovements($movements)) { - return $this->asFailure(Craft::t('commerce', 'Invalid inventory movements.')); - } - - return $this->asSuccess(Craft::t('commerce', 'Updated committed stock successfully.')); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @throws InvalidConfigException - * @throws \craft\errors\DeprecationException - * @since 5.0.0 - */ - public function actionFulfillmentModal(): Response - { - $this->requireAcceptsJson(); - - $orderId = $this->request->getRequiredParam('orderId'); - $order = Plugin::getInstance()->getOrders()->getOrderById($orderId); - $inventoryFulfillmentLevels = Plugin::getInstance()->getInventory()->getInventoryFulfillmentLevels($order)->groupBy('inventoryLocationId'); - - /** @phpstan-ignore-next-line */ - $response = $this->asCpModal() - ->action('commerce/orders/fulfill') - ->submitButtonLabel(Craft::t('commerce', 'Update')) - ->contentTemplate('commerce/orders/modals/_fulfillmentModal', [ - 'inventoryFulfillmentLevels' => $inventoryFulfillmentLevels, - 'order' => $order, - ])->prepareModal(function() { - $view = Craft::$app->getView(); - $view->registerJsWithVars(fn() => <<{ - const el = e.target || e - if(el.type == "number" && el.max && el.min ){ - let value = parseInt(el.value) - el.value = value // for 000 like input cleanup to 0 - let max = parseInt(el.max) - let min = parseInt(el.min) - if ( value > max ) el.value = el.max - if ( value < min ) el.value = el.min - } -}); -JS, []); - }); - - return $response; - } - - /** - * @throws BadRequestHttpException - * @throws ElementNotFoundException - * @throws Exception - * @throws ForbiddenHttpException - * @throws HttpException - * @throws InvalidConfigException - * @throws OrderStatusException - * @throws Throwable - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - - $data = $this->request->getBodyParam('orderData'); - - $orderRequestData = Json::decodeIfJson($data); - - $order = Plugin::getInstance()->getOrders()->getOrderById($orderRequestData['order']['id']); - - if (!$order) { - throw new HttpException(400, Craft::t('commerce', 'Invalid Order ID')); - } - - $this->enforceManageOrderPermissions($order); - - // Set custom field values - $order->setFieldValuesFromRequest('fields'); - - $alreadyCompleted = $order->isCompleted; - // Set data from request to the order - $this->_updateOrder($order, $orderRequestData, false); - $markAsComplete = !$alreadyCompleted && $order->isCompleted; - - // We don't want to save it as completed yet since we will markAsComplete() after saving the cart - if ($markAsComplete) { - $order->isCompleted = false; - $order->dateOrdered = null; - $order->orderStatusId = null; - } - - $order->setScenario(Element::SCENARIO_LIVE); - $valid = $order->validate(null, false); - - if (!$valid || !Craft::$app->getElements()->saveElement($order, false)) { - // Recalculation mode should always return to none, unless it is still a cart - $order->setRecalculationMode(Order::RECALCULATION_MODE_NONE); - if (!$order->isCompleted) { - $order->setRecalculationMode(Order::RECALCULATION_MODE_ALL); - } - - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save order.')); - - Craft::$app->getUrlManager()->setRouteParams([ - 'order' => $order, - ]); - - return null; - } - - // This request is marking the order as complete - if ($markAsComplete) { - $order->markAsComplete(); - } - - return $this->redirectToPostedUrl(); - } - - /** - * Deletes an order. - * - * @throws Exception if you try to edit a non-existent ID. - * @throws Throwable - */ - public function actionDeleteOrder(): ?Response - { - $this->requirePostRequest(); - - $orderId = (int)$this->request->getRequiredBodyParam('orderId'); - $order = Plugin::getInstance()->getOrders()->getOrderById($orderId); - - if (!$order) { - throw new HttpException(404, Craft::t('commerce', 'Can not find order.')); - } - - if (!Craft::$app->getElements()->canDelete($order)) { - throw new ForbiddenHttpException('User not authorized to view this address.'); - } - - if (!Craft::$app->getElements()->deleteElementById($order->id)) { - return $this->asFailure(); - } - - return $this->asSuccess(Craft::t('commerce', 'Order deleted.')); - } - - /** - * The refresh action accepts a json representation of an order, recalculates it depending on the mode submitted, - * and returns the order as json with any validation errors. - * - * @throws Exception - */ - public function actionRefresh(): Response - { - $data = $this->request->getRawBody(); - $orderRequestData = Json::decodeIfJson($data); - - $order = Plugin::getInstance()->getOrders()->getOrderById($orderRequestData['order']['id']); - - if (!$order) { - return $this->asFailure(Craft::t('commerce', 'Invalid Order ID')); - } - - $this->enforceManageOrderPermissions($order); - - $this->_updateOrder($order, $orderRequestData); - - if ($order->validate(null, false) && $order->getRecalculationMode() == Order::RECALCULATION_MODE_ALL) { - $order->recalculate(); // dont save, just recalculate - } - - // Recalculation mode should always return to none, unless it is still a cart - $order->setRecalculationMode(Order::RECALCULATION_MODE_NONE); - if (!$order->isCompleted) { - $order->setRecalculationMode(Order::RECALCULATION_MODE_ALL); - } - - if ($order->hasErrors()) { - return $this->asModelFailure( - $order, - Craft::t('commerce', 'The order is not valid.'), - 'order', - [ - 'order' => $this->_orderToArray($order), - ] - ); - } - - return $this->asSuccess(data: [ - 'order' => $this->_orderToArray($order), - ]); - } - - /** - * Returns the available shipping method options for the order in its current draft state. - * - * @throws Exception - * @since 5.7.0 - */ - public function actionGetShippingMethodOptions(): Response - { - $this->requireAcceptsJson(); - $this->requirePostRequest(); - - $data = $this->request->getRawBody(); - $orderRequestData = Json::decodeIfJson($data); - - $order = Plugin::getInstance()->getOrders()->getOrderById($orderRequestData['order']['id']); - - if (!$order) { - return $this->asFailure(Craft::t('commerce', 'Invalid Order ID')); - } - - $this->enforceManageOrderPermissions($order); - - $this->_updateOrder($order, $orderRequestData); - - if ($order->validate(null, false) && $order->getRecalculationMode() == Order::RECALCULATION_MODE_ALL) { - $order->recalculate(); - } - - return $this->asSuccess(data: [ - 'shippingMethodOptions' => $order->toArray([], ['availableShippingMethodOptions'])['availableShippingMethodOptions'], - ]); - } - - /** - * @throws BadRequestHttpException - * @throws ForbiddenHttpException - */ - public function actionUserOrdersTable(): Response - { - $this->requirePermission('commerce-manageOrders'); - $this->requireAcceptsJson(); - - $page = $this->request->getParam('page', 1); - $sort = $this->request->getParam('sort'); - $limit = $this->request->getParam('per_page', 10); - $search = $this->request->getParam('search'); - $offset = ($page - 1) * $limit; - - $customerId = $this->request->getQueryParam('customerId'); - - if (!$customerId) { - return $this->asFailure(Craft::t('commerce', 'Customer ID is required.')); - } - - $customer = Craft::$app->getUsers()->getUserById($customerId); - - if (!$customer) { - return $this->asFailure(Craft::t('commerce', 'Unable to retrieve customer.')); - } - - $orderQuery = Order::find() - ->customer($customer) - ->withAll() // eager-load all related data - ->isCompleted(); - - if ($search) { - $orderQuery->search($search); - } - - $orderQuery->orderBy('dateOrdered DESC'); - if ($sort) { - if (is_array($sort)) { - $field = $sort[0]['sortField']; - $direction = $sort[0]['direction']; - } else { - [$field, $direction] = explode('|', $sort); - } - - // Validate sorting - if (!in_array($direction, ['asc', 'desc']) || - !in_array($field, [ - 'reference', - 'dateOrdered', - 'totalPrice', - ]) - ) { - $field = null; - $direction = null; - } - - if ($field && $direction) { - $orderQuery->orderBy($field . ' ' . $direction); - } - } - - $total = $orderQuery->count(); - - $orderQuery->offset($offset); - $orderQuery->limit($limit); - $orders = $orderQuery->all(); - - $rows = []; - foreach ($orders as $order) { - $rows[] = [ - 'id' => $order->id, - 'title' => $order->reference, - 'url' => $order->getCpEditUrl(), - 'date' => $order->dateOrdered->format('D jS M Y'), - 'total' => $order->totalAsCurrency, - 'orderStatus' => $order->getOrderStatusHtml(), - ]; - } - - return $this->asSuccess(data: [ - 'pagination' => AdminTable::paginationLinks($page, $total, $limit), - 'data' => $rows, - ]); - } - - - /** - * @param Order $order - * @return array - */ - private function _orderToArray(Order $order): array - { - // Remove custom fields - $orderFields = array_keys($order->fields()); - - sort($orderFields); - - // Remove unneeded fields - $removeProps = [ - 'hasDescendants', - 'makePrimaryShippingAddress', - 'shippingSameAsBilling', - 'billingSameAsShipping', - 'tempId', - 'resaving', - 'duplicateOf', - 'totalDescendants', - 'fieldLayoutId', - 'contentId', - 'trashed', - 'structureId', - 'url', - 'ref', - 'title', - 'slug', - ]; - foreach ($removeProps as $removeProp) { - ArrayHelper::removeValue($orderFields, $removeProp); - } - - if (($fieldLayout = $order->getFieldLayout()) !== null) { - foreach ($fieldLayout->getCustomFields() as $field) { - /** @var Field $field */ - ArrayHelper::removeValue($orderFields, $field->handle); - } - } - - $extraFields = [ - 'lineItems.snapshot', - 'billingAddress', - 'shippingAddress', - 'orderSite', - 'notices', - 'adminNotices', - 'loadCartUrl', - 'store', - 'totalCommittedStock', - 'lineItems.fulfilledTotalQuantity', - ]; - - $lineItems = $order->getLineItems(); - $purchasableCpEditUrlByPurchasableId = []; - foreach ($lineItems as $lineItem) { - if ($lineItem->type === LineItemType::Custom) { - continue; - } - - /** @var Purchasable|PurchasableElement|null $purchasable */ - $purchasable = $lineItem->getPurchasable(); - if (!$purchasable || isset($purchasableCpEditUrlByPurchasableId[$purchasable->id])) { - continue; - } - - if ($purchasable instanceof Variant) { - $product = $purchasable->getOwner(); - $purchasableCpEditUrlByPurchasableId[$purchasable->id] = $product?->getCpEditUrl() ?? null; - } else { - $purchasableCpEditUrlByPurchasableId[$purchasable->id] = $purchasable->getCpEditUrl(); - } - } - - $purchasableCpEditUrlByPurchasableId = array_filter($purchasableCpEditUrlByPurchasableId); - - $billingAddress = $order->getBillingAddress(); - $shippingAddress = $order->getShippingAddress(); - - $subUnit = Plugin::getInstance()->getCurrencies()->getSubunitFor($order->currency); - - $orderArray = $order->toArray($orderFields, $extraFields); - - if ($orderArray['customer'] && $orderArray['customer']['id'] && $customer = Craft::$app->getUsers()->getUserById($orderArray['customer']['id'])) { - $orderArray['customer'] = $this->_customerToArray($customer); - } - - if ($billingAddress) { - $orderArray['billingAddressHtml'] = Cp::elementCardHtml($billingAddress, [ - 'showEditButton' => false, - ]); - } - - if ($shippingAddress) { - $orderArray['shippingAddressHtml'] = Cp::elementCardHtml($shippingAddress, [ - 'showEditButton' => false, - ]); - } - - if (!empty($orderArray['lineItems'])) { - foreach ($orderArray['lineItems'] as &$lineItem) { - $lineItem['price'] = $lineItem['price'] !== null ? Craft::$app->getFormatter()->asDecimal($lineItem['price'], $subUnit) : null; - $lineItem['promotionalPrice'] = $lineItem['promotionalPrice'] !== null ? Craft::$app->getFormatter()->asDecimal($lineItem['promotionalPrice'], $subUnit) : null; - - $lineItem['showForm'] = ArrayHelper::isAssociative($lineItem['options']) || (is_array($lineItem['options']) && empty($lineItem['options'])); - $lineItem['purchasableCpEditUrl'] = $purchasableCpEditUrlByPurchasableId[$lineItem['purchasableId']] ?? null; - } - unset($lineItem); - } - - return $orderArray; - } - - /** - * @throws BadRequestHttpException - * @throws ForbiddenHttpException - * @throws InvalidConfigException - */ - public function actionPurchasablesTable(): Response - { - $this->requirePermission('commerce-manageOrders'); - $this->requireAcceptsJson(); - - $page = $this->request->getParam('page', 1); - $sort = $this->request->getParam('sort'); - $limit = $this->request->getParam('per_page', 10); - $search = $this->request->getParam('search'); - $siteId = $this->request->getQueryParam('siteId'); - $customerId = $this->request->getQueryParam('customerId', false); - - if (!$siteId) { - throw new InvalidArgumentException('siteId is required'); - } - - $store = Plugin::getInstance()->getStores()->getStoreBySiteId($siteId); - if (!$store) { - throw new InvalidArgumentException('Store not found'); - } - - $offset = ($page - 1) * $limit; - - // Prepare purchasables query - $likeOperator = Craft::$app->getDb()->getIsPgsql() ? 'ILIKE' : 'LIKE'; - $sqlQuery = (new Query()) - ->select(['purchasables.id', 'pstores.basePrice', 'purchasables.description', 'purchasables.sku', 'elements.type']) - ->leftJoin(['elements' => CraftTable::ELEMENTS], [ - 'and', - '[[elements.id]] = [[purchasables.id]]', - ]) - // Make sure this purchasable is enabled for the site - ->innerJoin(['es' => CraftTable::ELEMENTS_SITES], [ - 'and', - '[[es.elementId]] = [[purchasables.id]]', - '[[es.siteId]] = :siteId', - ], [ - ':siteId' => $siteId, - ]) - ->innerJoin(Table::PURCHASABLES_STORES . ' pstores', '[[purchasables.id]] = [[pstores.purchasableId]]') - ->where(['elements.enabled' => true]) - ->andWhere(['pstores.storeId' => $store->id]) - ->andWhere(['elements.revisionId' => null]) - ->andWhere(['elements.draftId' => null]) - ->from(['purchasables' => Table::PURCHASABLES]); - - // Are they searching for a SKU or purchasable description? - if ($search) { - $sqlQuery->andwhere([ - 'or', - [$likeOperator, 'purchasables.description', '%' . str_replace(' ', '%', $search) . '%', false], - [$likeOperator, 'purchasables.sku', $search], - ]); - } - - // Do not return any purchasables with temp SKUs - $sqlQuery->andWhere(new Expression("LEFT([[purchasables.sku]], " . strlen(Purchasable::TEMPORARY_SKU_PREFIX) . ") != '" . Purchasable::TEMPORARY_SKU_PREFIX . "'")); - - // Do not return soft deleted purchasables - $sqlQuery->andWhere(['elements.dateDeleted' => null]); - - // Apply sorting if required - if ($sort && strpos($sort, '|')) { - [$column, $direction] = explode('|', $sort); - - if (!in_array($column, [ - 'description', - 'sku', - 'price', - ])) { - $column = null; - } - - if ($column && in_array($direction, ['asc', 'desc'], true)) { - $sqlQuery->orderBy([$column => $direction == 'asc' ? SORT_ASC : SORT_DESC]); - } - } else { - $sqlQuery->orderBy(['id' => 'asc']); - } - - // Trigger event before working out the total and limiting the results for pagination - if ($this->hasEventHandlers(self::EVENT_MODIFY_PURCHASABLES_TABLE_QUERY)) { - $event = new ModifyPurchasablesTableQueryEvent([ - 'query' => $sqlQuery, - 'search' => $search, - ]); - $this->trigger(self::EVENT_MODIFY_PURCHASABLES_TABLE_QUERY, $event); - $sqlQuery = $event->query; - } - - $total = $sqlQuery->count(); - - $sqlQuery->limit($limit); - $sqlQuery->offset($offset); - - $result = $sqlQuery->all(); - - return $this->asSuccess(data: [ - 'pagination' => AdminTable::paginationLinks($page, $total, $limit), - 'data' => $this->_addLivePurchasableInfo($result, $siteId, $customerId), - ]); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @since 4.0 - */ - public function actionCustomerSearch(): Response - { - $this->requireAcceptsJson(); - - $query = $this->request->getQueryParam('query'); - - $limit = 30; - $customers = []; - - if ($query === null) { - return $this->asJson($customers); - } - - $userQuery = User::find()->status(null)->limit($limit); - - if ($query) { - $userQuery->search(urldecode($query)); - } - - $customers = $userQuery->collect()->map(fn(User $user) => $this->_customerToArray($user)); - - return $this->asSuccess(data: compact('customers')); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @since 4.0 - */ - public function actionGetCustomerAddresses(): Response - { - $this->requireAcceptsJson(); - - $id = $this->request->getRequiredParam('id'); - $page = $this->request->getParam('page', 1); - $limit = $this->request->getParam('per_page', 10); - $offset = ($page - 1) * $limit; - - $user = Craft::$app->getUsers()->getUserById($id); - - if (!$user) { - return $this->asFailure(message: Craft::t('commerce', 'User not found.')); - } - - $addressElements = Address::find() - ->ownerId($user->id) - ->limit($limit) - ->offset($offset) - ->collect(); - - $total = $addressElements->count(); - - $addresses = $addressElements->map(fn(Address $address) => $address->toArray() + [ - 'html' => Cp::elementCardHtml($address), - ]); - - return $this->asSuccess(data: compact('addresses', 'total')); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @since 4.0 - */ - public function actionGetOrderAddress(): Response - { - $this->requireAcceptsJson(); - - $orderId = $this->request->getRequiredParam('orderId'); - $addressId = $this->request->getRequiredParam('addressId'); - - $order = Plugin::getInstance()->getOrders()->getOrderById($orderId); - - if (!$order) { - return $this->asFailure(message: Craft::t('commerce', 'Order not found.')); - } - - /** @var Address|null $address */ - $address = Address::find() - ->ownerId($order->id) - ->id($addressId) - ->one(); - - if (!$address) { - return $this->asFailure(message: Craft::t('commerce', 'Address not found.')); - } - - return $this->asSuccess(data: [ - 'address' => $address->toArray() + [ - 'html' => Cp::elementCardHtml($address), - ], - ]); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @throws InvalidConfigException - * @since 4.0 - */ - public function actionValidateAddress(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $attributes = $this->request->getRequiredParam('address'); - - $attributes += ['class' => Address::class]; - - $address = Craft::createObject($attributes); - - if (!$address->validate()) { - return $this->asModelFailure(model: $address, message: Craft::t('commerce', 'Unable to validate address.'), modelName: 'address'); - } - - return $this->asSuccess(); - } - - /** - * @return Response - * @throws BadRequestHttpException - */ - public function actionCreateCustomer(): Response - { - $this->requireAcceptsJson(); - $this->requirePostRequest(); - - $email = $this->request->getRequiredParam('email'); - - try { - $user = Craft::$app->getUsers()->ensureUserByEmail($email); - $user = $this->_customerToArray($user); - } catch (\Exception $e) { - return $this->asFailure(message: $e->getMessage()); - } - - return $this->asSuccess(data: compact('user')); - } - - /** - * Returns a secure load-cart URL (with token) for the given cart number. - * Intended for CP use via the "Share cart" element action. - * - * @throws BadRequestHttpException - * @throws NotFoundHttpException - * @since 5.7.0 - */ - public function actionGetLoadCartUrl(): Response - { - $this->requireAcceptsJson(); - $this->requirePermission('commerce-manageOrders'); - - $number = $this->request->getRequiredParam('number'); - $cart = Order::find()->number($number)->isCompleted(false)->one(); - - if (!$cart) { - throw new NotFoundHttpException('Cart not found.'); - } - - return $this->asSuccess(data: [ - 'url' => Plugin::getInstance()->getCarts()->getLoadCartUrl($cart), - ]); - } - - /** - * @throws BadRequestHttpException - * @throws InvalidConfigException - * @throws Throwable - */ - public function actionSendEmail(): Response - { - $this->requireAcceptsJson(); - - $id = $this->request->getParam('id'); - $orderId = $this->request->getParam('orderId'); - - if ($id === null || $orderId === null) { - return $this->asFailure(Craft::t('commerce', 'Bad Request')); - } - - $order = Order::find()->id($orderId)->one(); - if ($order === null) { - return $this->asFailure(Craft::t('commerce', 'Can not find order')); - } - - $email = Plugin::getInstance()->getEmails()->getEmailById($id, $order->storeId); - if ($email === null || !$email->enabled) { - return $this->asFailure(Craft::t('commerce', 'Can not find enabled email.')); - } - - $originalLanguage = Craft::$app->language; - $originalFormattingLocale = Craft::$app->formattingLocale; - - // Set language by email's set locale - $language = $email->getRenderLanguage($order); - Locale::switchAppLanguage($language); - - $orderData = $order->toArray(); - - $success = true; - $error = ''; - try { - if (!Plugin::getInstance()->getEmails()->sendEmail($email, $order, null, $orderData, $error)) { - $success = false; - } - } catch (\Exception) { - $success = false; - } - - // Set previous language back - Locale::switchAppLanguage($originalLanguage, $originalFormattingLocale->id); - - if (!$success) { - $error = $error ?: Craft::t('commerce', 'Could not send email'); - return $this->asFailure($error); - } - - return $this->asSuccess(); - } - - /** - * Updates an order address - * - * @throws Exception - * @throws Throwable - * @throws ElementNotFoundException - * @throws BadRequestHttpException - */ - public function actionUpdateOrderAddress(): Response - { - $this->requireAcceptsJson(); - - $orderId = $this->request->getParam('orderId'); - $addressId = $this->request->getParam('addressId'); - $type = $this->request->getParam('addressType'); - - // Validate Address Type - if (!in_array($type, ['shippingAddress', 'billingAddress'], true)) { - $this->asFailure(Craft::t('commerce', 'Not a valid address type')); - } - - $order = Plugin::getInstance()->getOrders()->getOrderById($orderId); - if (!$order) { - $this->asFailure(Craft::t('commerce', 'Bad order ID.')); - } - - // Return early if the address is already set. - if ($order->{$type . 'Id'} == $addressId) { - return $this->asSuccess(); - } - - // Validate Address Id - $address = $addressId ? Address::find()->id($addressId)->one() : null; - if (!$address) { - return $this->asFailure(Craft::t('commerce', 'Bad address ID.')); - } - - $order->{$type . 'Id'} = $address->id; - - if (!Craft::$app->getElements()->saveElement($order)) { - return $this->asFailure(Craft::t('commerce', 'Could not update orders address.')); - } - - return $this->asSuccess(); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @throws MethodNotAllowedHttpException - * @throws Throwable - * @since 5.4.0 - */ - public function actionCopyAddressToUser(): Response - { - $this->requirePermission('editUsers'); - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $addressId = $this->request->getRequiredBodyParam('addressId'); - $userId = $this->request->getRequiredBodyParam('userId'); - - $address = Address::find()->id($addressId)->one(); - - if (!$address) { - return $this->asFailure(Craft::t('commerce', 'Address not found.')); - } - - $user = Craft::$app->getUsers()->getUserById($userId); - - if (!$user || !$user->getIsCredentialed()) { - return $this->asFailure(Craft::t('commerce', 'Invalid user.')); - } - - try { - // Clone the address - $newAddress = Craft::$app->getElements()->duplicateElement($address, [ - 'owner' => $user, - 'primaryOwner' => $user, - ]); - } catch (\Exception $exception) { - return $this->asFailure($exception->getMessage()); - } - - return $this->asSuccess(data: [ - 'address' => $newAddress->toArray(), - ]); - } - - /** - * @throws BadRequestHttpException - * @throws InvalidConfigException - * @since 3.0.11 - */ - public function actionGetIndexSourcesBadgeCounts(): Response - { - $this->requireAcceptsJson(); - - /** @var Site|StoreBehavior|null $site */ - $site = Cp::requestedSite(); - $storeId = $site?->getStore()->id ?? null; - - $counts = Plugin::getInstance()->getOrderStatuses()->getOrderCountByStatus($storeId); - - $total = array_reduce($counts, static fn($sum, $thing) => $sum + (int)$thing['orderCount'], 0); - - return $this->asSuccess(data: compact('counts', 'total')); - } - - /** - * Returns Payment Modal - * - * @throws BadRequestHttpException - * @throws Exception - * @throws LoaderError - * @throws RuntimeError - * @throws SyntaxError - */ - public function actionGetPaymentModal(): Response - { - $this->requireAcceptsJson(); - $view = $this->getView(); - - $orderId = $this->request->getParam('orderId'); - $paymentFormData = $this->request->getParam('paymentForm'); - - $plugin = Plugin::getInstance(); - $order = $plugin->getOrders()->getOrderById($orderId); - $gateways = $plugin->getGateways()->getAllGateways(); - - if ($paymentAmount = $this->request->getParam('paymentAmount')) { - $order->setPaymentAmount($paymentAmount); - } - if ($paymentCurrency = $this->request->getParam('paymentCurrency')) { - $order->setPaymentCurrency($paymentCurrency); - } - - $formHtml = ''; - /** @var Gateway $gateway */ - foreach ($gateways as $key => $gateway) { - // If gateway adapter does no support backend cp payments. - if ($gateway->availableForUseWithOrder($order) === false || !$gateway->cpPaymentsEnabled() || $gateway instanceof MissingGateway) { - unset($gateways[$key]); - continue; - } - - // Add the errors and data back to the current form model. - if ($gateway->id == $order->gatewayId) { - $paymentFormModel = $gateway->getPaymentFormModel(); - - if ($paymentFormData) { - // Re-add submitted data to payment form model - if (isset($paymentFormData['attributes'])) { - $paymentFormModel->attributes = $paymentFormData['attributes']; - } - - // Re-add errors to payment form model - if (isset($paymentFormData['errors'])) { - $paymentFormModel->addErrors($paymentFormData['errors']); - } - } - } else { - $paymentFormModel = $gateway->getPaymentFormModel(); - } - - // For backend stripe payments we cant use the 3D secure form. - /** @todo Remove the legacy PaymentIntents `getOldPaymentFormHtml()` branch in Commerce 6.0 */ - /** @phpstan-ignore-next-line */ - if ($gateway instanceof PaymentIntents) { - /** @phpstan-ignore-next-line */ - $paymentFormHtml = $gateway->getOldPaymentFormHtml([ - 'paymentForm' => $paymentFormModel, - 'order' => $order, - ]); - } else { - $paymentFormHtml = $gateway->getPaymentFormHtml([ - 'paymentForm' => $paymentFormModel, - 'order' => $order, - ]); - } - - $paymentFormHtml = Html::namespaceInputs($paymentFormHtml, PaymentForm::getPaymentFormNamespace($gateway->handle)); - - $paymentFormHtml = $view->renderTemplate('commerce/_components/gateways/_modalWrapper', [ - 'formHtml' => $paymentFormHtml, - 'gateway' => $gateway, - 'paymentForm' => $paymentFormModel, - 'order' => $order, - ]); - - $formHtml .= $paymentFormHtml; - } - - $view->registerAssetBundle(InputmaskAsset::class); - - $modalHtml = $view->renderTemplate('commerce/orders/_paymentmodal', [ - 'gateways' => $gateways, - 'order' => $order, - 'paymentForms' => $formHtml, - ]); - - return $this->asSuccess(data: [ - 'modalHtml' => $modalHtml, - 'headHtml' => $view->getHeadHtml(), - 'footHtml' => $view->getBodyHtml(), - ]); - } - - /** - * Captures Transaction - * - * @throws BadRequestHttpException - * @throws ForbiddenHttpException - * @throws TransactionException - */ - public function actionTransactionCapture(): Response - { - $this->requirePermission('commerce-capturePayment'); - $this->requirePostRequest(); - $id = $this->request->getRequiredBodyParam('id'); - $transaction = Plugin::getInstance()->getTransactions()->getTransactionById($id); - - if ($transaction->canCapture()) { - // capture transaction and display result - $child = Plugin::getInstance()->getPayments()->captureTransaction($transaction); - - $message = $child->message ? ' (' . $child->message . ')' : ''; - - if ($child->status == TransactionRecord::STATUS_SUCCESS) { - $child->order->updateOrderPaidInformation(); - $this->setSuccessFlash(Craft::t('commerce', 'Transaction captured successfully: {message}', [ - 'message' => $message, - ])); - } else { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t capture transaction: {message}', [ - 'message' => $message, - ])); - } - } else { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t capture transaction.', ['id' => $id])); - } - - return $this->redirectToPostedUrl(); - } - - /** - * Refunds transaction. - * - * @throws BadRequestHttpException - * @throws ForbiddenHttpException - */ - public function actionTransactionRefund(): Response - { - $this->requirePermission('commerce-refundPayment'); - $this->requirePostRequest(); - $id = $this->request->getRequiredBodyParam('id'); - - $transaction = Plugin::getInstance()->getTransactions()->getTransactionById($id); - - $amount = $this->request->getParam('amount'); - $amount = MoneyHelper::toMoney(array_merge($amount,['currency' => $transaction->paymentCurrency])); - $amount = MoneyHelper::toDecimal($amount); - - $note = $this->request->getRequiredBodyParam('note'); - - if (!$transaction) { - $error = Craft::t('commerce', 'Can not find the transaction to refund'); - if ($this->request->getAcceptsJson()) { - return $this->asFailure($error); - } else { - $this->setFailFlash($error); - return $this->redirectToPostedUrl(); - } - } - - if (!$amount || $amount <= 0) { - $amount = $transaction->getRefundableAmount(); - } - - if ($amount <= 0 || $amount > $transaction->getRefundableAmount()) { - $error = Craft::t('commerce', 'Can not refund amount greater than the remaining amount'); - if ($this->request->getAcceptsJson()) { - return $this->asFailure($error); - } else { - $this->setFailFlash($error); - return $this->redirectToPostedUrl(); - } - } - - if ($transaction->canRefund()) { - try { - // refund transaction and display result - $child = Plugin::getInstance()->getPayments()->refundTransaction($transaction, $amount, $note); - - $message = $child->message ? ' (' . $child->message . ')' : ''; - - if ($child->status == TransactionRecord::STATUS_SUCCESS || $child->status == TransactionRecord::STATUS_PROCESSING) { - $child->order->updateOrderPaidInformation(); - $this->setSuccessFlash(Craft::t('commerce', 'Transaction refunded successfully: {message}', [ - 'message' => $message, - ])); - } else { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t refund transaction: {message}', [ - 'message' => $message, - ])); - } - } catch (RefundException $exception) { - $this->setFailFlash($exception->getMessage()); - } - } else { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t refund transaction.')); - } - - return $this->redirectToPostedUrl(); - } - - /** - * @throws BadRequestHttpException - * @throws CurrencyException - */ - public function actionPaymentAmountData(): Response - { - $this->requireAcceptsJson(); - $this->requirePostRequest(); - $paymentCurrencies = Plugin::getInstance()->getPaymentCurrencies(); - $paymentCurrency = $this->request->getRequiredParam('paymentCurrency'); - $paymentAmount = $this->request->getRequiredParam('paymentAmount'); - $locale = $this->request->getRequiredParam('locale'); - $orderId = $this->request->getRequiredParam('orderId'); - /** @var Order $order */ - $order = Order::find()->id($orderId)->one(); - $baseCurrency = $order->currency; - - $paymentAmount = MoneyHelper::toMoney(['value' => $paymentAmount, 'currency' => $baseCurrency, 'locale' => $locale]); - $paymentAmount = MoneyHelper::toDecimal($paymentAmount); - - $baseCurrencyPaymentAmount = $paymentCurrencies->convertCurrency((float)$paymentAmount, $paymentCurrency, $baseCurrency); - $baseCurrencyPaymentAmountAsCurrency = Craft::t('commerce', 'Pay {amount} of {currency} on the order.', ['amount' => Currency::formatAsCurrency($baseCurrencyPaymentAmount, $baseCurrency), 'currency' => $baseCurrency]); - - $outstandingBalance = $order->outstandingBalance; - $outstandingBalanceAsCurrency = $order->outstandingBalanceAsCurrency; - - $message = ''; - if (Currency::round($baseCurrencyPaymentAmount) > Currency::round($outstandingBalance)) { - $baseCurrencyPaymentAmount = $outstandingBalance; - $baseCurrencyPaymentAmountAsCurrency = Craft::t('commerce', 'Pay {amount} of {currency} on the order.', ['amount' => $outstandingBalanceAsCurrency, 'currency' => $baseCurrency]); - $message = Craft::t('commerce', 'Order payment balance is {outstandingBalanceAsCurrency}. This is the maximum value that will be charged.', ['outstandingBalanceAsCurrency' => $outstandingBalanceAsCurrency]); - } - - return $this->asSuccess($message, data: [ - 'paymentCurrency' => $paymentCurrency, - 'paymentAmount' => $paymentAmount, - 'outstandingBalance' => $outstandingBalance, - 'outstandingBalanceAsCurrency' => $outstandingBalanceAsCurrency, - 'baseCurrencyPaymentAmountAsCurrency' => $baseCurrencyPaymentAmountAsCurrency, - 'baseCurrencyPaymentAmount' => $baseCurrencyPaymentAmount, - ]); - } - - /** - * @since 5.7.0 - */ - public function actionReassignModal(): Response - { - $this->requireCpRequest(); - $this->requireAcceptsJson(); - $this->requirePermission('deleteUsers'); - - $oldUserIds = $this->request->getRequiredParam('oldUserIds'); - - return $this->asCpModal() - ->action('commerce/orders/reassign') - ->contentHtml(fn() => - Cp::elementSelectFieldHtml([ - 'label' => Craft::t('commerce', 'Choose a new customer'), - 'name' => 'newUserId', - 'elementType' => User::class, - 'criteria' => [ - 'id' => array_map(fn($id) => "not $id", $oldUserIds), - ], - 'single' => true, - ]) . - implode('', array_map(fn($id) => Html::hiddenInput('oldUserIds[]', $id), $oldUserIds)) - ) - ->submitButtonLabel(Craft::t('app', 'Reassign')); - } - - /** - * @since 5.7.0 - */ - public function actionReassign(): Response - { - $this->requireCpRequest(); - $this->requireAcceptsJson(); - $this->requirePermission('deleteUsers'); - - $oldUserIds = array_map(fn($id) => (int)$id, $this->request->getRequiredParam('oldUserIds')); - $newUserId = (int)$this->request->getRequiredBodyParam('newUserId'); - - if (!$newUserId) { - return $this->asFailure(Craft::t('commerce', 'No new customer selected.')); - } - - try { - $count = Plugin::getInstance()->getOrders()->reassignOrders($oldUserIds, $newUserId); - } catch (\Exception) { - return $this->asFailure(Craft::t('commerce', 'Unable to reassign orders.')); - } - - return $this->asSuccess(Craft::t('app', '{type} reassigned.', [ - 'type' => $count === 1 ? Order::displayName() : Order::pluralDisplayName(), - ])); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @throws ForbiddenHttpException - * @since 5.7.0 - */ - public function actionRemoveCustomerDataModal(): Response - { - $this->requireCpRequest(); - $this->requireAcceptsJson(); - $this->requirePermission('deleteUsers'); - - $orderIds = array_map(fn($id) => (int)$id, $this->request->getRequiredParam('orderIds')); - - return $this->asCpModal() - ->action('commerce/orders/remove-customer-data') - ->contentHtml(fn() => - Html::tag('p', Craft::t('commerce', 'Remove customer association and email from the {numOrders, plural, =1{order} other{orders}}. Optionally select additional customer data to remove below', [ - 'numOrders' => count($orderIds), - ])) . - Html::beginTag('div') . - Cp::checkboxSelectFieldHtml([ - 'label' => Craft::t('commerce', 'Customer data'), - 'name' => 'customerData', - 'options' => [ - 'billingAddressId' => Craft::t('commerce', 'Billing Address'), - 'shippingAddressId' => Craft::t('commerce', 'Shipping Address'), - 'orderCompletedEmail' => Craft::t('commerce', 'Completed Email'), - ], - 'values' => null, - 'showAllOption' => true, - ]) . - Html::endTag('div') . - implode('', array_map(fn($id) => Html::hiddenInput('orderIds[]', (string)$id), $orderIds)) - ) - ->submitButtonLabel(Craft::t('commerce', 'Remove customer data')); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @throws ForbiddenHttpException - * @since 5.7.0 - */ - public function actionRemoveCustomerData(): Response - { - $this->requireCpRequest(); - $this->requireAcceptsJson(); - $this->requirePermission('deleteUsers'); - - $orderIds = array_map(fn($id) => (int)$id, $this->request->getRequiredParam('orderIds')); - $customerData = $this->request->getBodyParam('customerData', []); - $customerData = $customerData === '' ? [] : $customerData; - - $customerData = $customerData === '*' ? ['billingAddressId', 'shippingAddressId', 'orderCompletedEmail'] : $customerData; - - $dataToRemove = array_merge(['customerId', 'email'], $customerData); - - try { - Plugin::getInstance()->getOrders()->removeCustomerData($orderIds, $dataToRemove); - } catch (\Exception) { - return $this->asFailure(Craft::t('commerce', 'Unable to remove order data.')); - } - - return $this->asSuccess(Craft::t('commerce', 'Order customer data removed.')); - } - - /** - * Modifies the variables of the request. - */ - private function _updateTemplateVariables(array &$variables): void - { - /** @var Order $order */ - $order = $variables['order']; - - $variables['ordersBodyClass'] = ''; - - if (version_compare(Craft::$app->getVersion(), '5.7.0', '>=')) { - $variables['ordersBodyClass'] .= ' commerceorders-post-57'; - } - - $variables['title'] = Craft::t('commerce', 'Order') . ' ' . $order->reference; - - if (!$order->isCompleted && $order->origin == Order::ORIGIN_CP) { - $variables['title'] = Craft::t('commerce', 'New Order'); - } - - if (!$order->isCompleted && $order->origin == Order::ORIGIN_WEB) { - $variables['title'] = Craft::t('commerce', 'Cart {number}', ['number' => $order->getShortNumber()]); - } - - $fieldLayout = Craft::$app->getFields()->getLayoutByType(Order::class); - $staticForm = $fieldLayout->createForm($order, true, [ - 'namespace' => 'static_fields', - 'tabIdPrefix' => 'static-fields', - ]); - $dynamicForm = $fieldLayout->createForm($order, false, [ - 'tabIdPrefix' => 'fields', - ]); - - $variables['staticFieldsHtml'] = $staticForm->render(false); - $variables['dynamicFieldsHtml'] = $dynamicForm->render(false); - - $variables['tabs'] = []; - - $variables['tabs']['order-details'] = [ - 'label' => Craft::t('commerce', 'Order Details'), - 'url' => '#orderDetailsTab', - 'class' => null, - ]; - - foreach ($staticForm->getTabMenu() as $tabId => $tab) { - $tab['class'] .= ' custom-tab static'; - $variables['tabs'][$tabId] = $tab; - } - - foreach ($dynamicForm->getTabMenu() as $tabId => $tab) { - $tab['class'] .= ' custom-tab'; - $variables['tabs'][$tabId] = $tab; - } - - $variables['tabs']['order-transactions'] = [ - 'label' => Craft::t('commerce', 'Transactions'), - 'url' => '#transactionsTab', - 'class' => null, - ]; - - $variables['tabs']['order-history'] = [ - 'label' => Craft::t('commerce', 'Status History'), - 'url' => '#orderHistoryTab', - 'class' => null, - ]; - - $variables['fullPageForm'] = true; - - - $variables['paymentMethodsAvailable'] = false; - - if (empty($variables['paymentForm'])) { - $gateway = $order->getGateway(); - - if ($gateway && !$gateway instanceof MissingGateway) { - $variables['paymentForm'] = $gateway->getPaymentFormModel(); - } else { - $gateway = Plugin::getInstance()->getGateways()->getAllGateways()->first(); - - if ($gateway && !$gateway instanceof MissingGateway) { - $variables['paymentForm'] = $gateway->getPaymentFormModel(); - } - } - - if ($gateway instanceof MissingGateway) { - $variables['paymentMethodsAvailable'] = false; - } - } - } - - /** - * @throws Exception - * @throws InvalidConfigException - */ - private function _registerJavascript(array $variables): void - { - /** @var Order $order */ - $order = $variables['order']; - Craft::$app->getView()->registerAssetBundle(CommerceOrderAsset::class); - // Include the input mask asset for use in pricing fields - Craft::$app->getView()->registerAssetBundle(MoneyAsset::class); - - Craft::$app->getView()->registerJs('window.orderEdit = {};', View::POS_BEGIN); - - Craft::$app->getView()->registerJs('window.orderEdit.autoSetNewCartAddresses = ' . Json::encode($order->getStore()->getAutoSetNewCartAddresses()) . ';', View::POS_BEGIN); - - Craft::$app->getView()->registerJs('window.orderEdit.orderId = ' . $order->id . ';', View::POS_BEGIN); - - $orderStatuses = Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses($order->storeId) - ->map(fn(OrderStatus $orderStatus) => $orderStatus->toArray(expand: ['uiLabel'])) - ->all(); - Craft::$app->getView()->registerJs('window.orderEdit.orderStatuses = ' . Json::encode($orderStatuses) . ';', View::POS_BEGIN); - - $orderSites = $order->getStore()->getSites()->all(); - Craft::$app->getView()->registerJs('window.orderEdit.orderSites = ' . Json::encode(array_values($orderSites)) . ';', View::POS_BEGIN); - - $lineItemStatuses = Plugin::getInstance()->getLineItemStatuses()->getAllLineItemStatuses($order->storeId) - ->map(fn(LineItemStatus $lineItemStatus) => $lineItemStatus->toArray(expand: ['uiLabel'])) - ->all(); - - Craft::$app->getView()->registerJs('window.orderEdit.lineItemStatuses = ' . Json::encode($lineItemStatuses) . ';', View::POS_BEGIN); - - $lineItemTypes = LineItemType::types(); - - Craft::$app->getView()->registerJs('window.orderEdit.lineItemTypes = ' . Json::encode($lineItemTypes) . ';', View::POS_BEGIN); - - $taxCategories = Plugin::getInstance()->getTaxCategories()->getAllTaxCategoriesAsList(); - Craft::$app->getView()->registerJs('window.orderEdit.taxCategories = ' . Json::encode(ArrayHelper::toArray($taxCategories)) . ';', View::POS_BEGIN); - - $defaultTaxCategoryId = Plugin::getInstance()->getTaxCategories()->getDefaultTaxCategory()->id; - Craft::$app->getView()->registerJs('window.orderEdit.defaultTaxCategoryId = ' . Json::encode($defaultTaxCategoryId) . ';', View::POS_BEGIN); - - $shippingCategories = Plugin::getInstance()->getShippingCategories()->getAllShippingCategoriesAsList($order->storeId); - Craft::$app->getView()->registerJs('window.orderEdit.shippingCategories = ' . Json::encode(ArrayHelper::toArray($shippingCategories)) . ';', View::POS_BEGIN); - - $defaultShippingCategoryId = Plugin::getInstance()->getShippingCategories()->getDefaultShippingCategory($order->storeId)->id; - Craft::$app->getView()->registerJs('window.orderEdit.defaultShippingCategoryId = ' . Json::encode($defaultShippingCategoryId) . ';', View::POS_BEGIN); - - $currentUser = Craft::$app->getUser()->getIdentity(); - - $permissions = ArrayHelper::map([ - 'editUsers', - 'commerce-manageOrders', - 'commerce-editOrders', - 'commerce-deleteOrders', - ], fn($permission) => $permission, fn($permission) => Craft::$app->getUser()->getIdentity()->can($permission)); - - Craft::$app->getView()->registerJs('window.orderEdit.currentUserPermissions = ' . Json::encode($permissions) . ';', View::POS_BEGIN); - Craft::$app->getView()->registerJs('window.orderEdit.currentUserId = ' . Json::encode($currentUser->id) . ';', View::POS_BEGIN); - - Craft::$app->getView()->registerJs('window.orderEdit.ordersIndexUrl = "' . UrlHelper::cpUrl('commerce/orders') . '"', View::POS_BEGIN); - Craft::$app->getView()->registerJs('window.orderEdit.ordersIndexUrlHashed = "' . Craft::$app->getSecurity()->hashData('commerce/orders') . '"', View::POS_BEGIN); - Craft::$app->getView()->registerJs('window.orderEdit.continueEditingUrl = "' . $order->cpEditUrl . '"', View::POS_BEGIN); - Craft::$app->getView()->registerJs('window.orderEdit.userPhotoFallback = "' . Craft::$app->getAssetManager()->getPublishedUrl('@app/web/assets/cp/dist', true, 'images/user.svg') . '"', View::POS_BEGIN); - - // Pad the decimal mask with `#` to match the number of decimal places in the currency - $subUnit = Plugin::getInstance()->getCurrencies()->getSubunitFor($order->currency); - $formattingLocale = Craft::$app->getFormattingLocale(); - - $currencyConfig = [ - 'currency' => $order->currency, - 'decimals' => $subUnit, - 'decimalSeparator' => $formattingLocale->getNumberSymbol($formattingLocale::SYMBOL_DECIMAL_SEPARATOR), - 'groupSeparator' => $formattingLocale->getNumberSymbol($formattingLocale::SYMBOL_GROUPING_SEPARATOR), - ]; - - Craft::$app->getView()->registerJs('window.orderEdit.currencyConfig = ' . Json::encode($currencyConfig) , View::POS_BEGIN); - - $customer = $order->customerId ? $order->getCustomer() : null; - if ($customer) { - $customer = $this->_customerToArray($customer); - } - - Craft::$app->getView()->registerJs('window.orderEdit.originalCustomer = ' . Json::encode($customer, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT), View::POS_BEGIN); - - $pdfUrls = Plugin::getInstance()->getPdfs()->getAllEnabledPdfs($order->storeId)->map(fn(Pdf $pdf) => [ - 'name' => $pdf->name, - 'url' => $order->getPdfUrl(null, $pdf->handle), - ])->all(); - - Craft::$app->getView()->registerJs('window.orderEdit.pdfUrls = ' . Json::encode($pdfUrls) . ';', View::POS_BEGIN); - - $emails = Plugin::getInstance()->getEmails()->getAllEnabledEmails($order->storeId); - // Reset keys in case any have been removed, so the JS doesn't think it is an object - $emails = array_values($emails->all()); - Craft::$app->getView()->registerJs('window.orderEdit.emailTemplates = ' . Json::encode(ArrayHelper::toArray($emails)) . ';', View::POS_BEGIN); - - $response = []; - $response['order'] = $this->_orderToArray($order); - - if ($order->hasErrors()) { - $response['order']['errors'] = $order->getErrors(); - $response['errors'] = $order->getErrors(); - $response['error'] = Craft::t('commerce', 'The order is not valid.'); - } - - Craft::$app->getView()->registerJs('window.orderEdit.data = ' . Json::encode($response, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT) . ';', View::POS_BEGIN); - - $forceEdit = ($order->hasErrors() || !$order->isCompleted); - - Craft::$app->getView()->registerJs('window.orderEdit.forceEdit = ' . Json::encode($forceEdit) . ';', View::POS_BEGIN); - - $store = $order->getStore(); - Craft::$app->getView()->registerJs('window.orderEdit.store = ' . Json::encode($store->toArray([], ['settings.locationAddress'])) . ';', View::POS_BEGIN); - } - - /** - * @param Order $order - * @param $orderRequestData - * @throws InvalidConfigException - * @throws Throwable - * @throws InvalidElementException - * @throws UnsupportedSiteException - */ - private function _updateOrder(Order $order, $orderRequestData, bool $tryAutoSet = true): void - { - $order->setRecalculationMode($orderRequestData['order']['recalculationMode']); - $order->reference = $orderRequestData['order']['reference']; - - $hasSetCustomer = false; - $customerId = $orderRequestData['order']['customerId'] ?? null; - if ($customerId && $customer = Craft::$app->getUsers()->getUserById($customerId)) { - $hasSetCustomer = true; - $order->setCustomer($customer); - } else { - $order->setCustomer(); - } - $order->couponCode = $orderRequestData['order']['couponCode']; - $order->isCompleted = $orderRequestData['order']['isCompleted']; - $order->orderStatusId = $orderRequestData['order']['orderStatusId']; - $order->orderSiteId = $orderRequestData['order']['orderSiteId']; - - // Set the order language based on the `orderSiteId` - if ($site = Craft::$app->getSites()->getSiteById($order->orderSiteId)) { - $order->orderLanguage = $site->language; - } - - $order->message = $orderRequestData['order']['message']; - $order->shippingMethodHandle = $orderRequestData['order']['shippingMethodHandle']; - $order->suppressEmails = $orderRequestData['order']['suppressEmails'] ?? false; - - $submittedBillingAddress = $orderRequestData['order']['billingAddress'] ?? null; - $submittedShippingAddress = $orderRequestData['order']['shippingAddress'] ?? null; - - if ($tryAutoSet && $hasSetCustomer && $submittedShippingAddress === null && $submittedBillingAddress === null) { - // Try and auto set addresses if the customer has changed and no address data is submitted - // Remove any lingering addresses from previous saves - if (!$order->isCompleted) { - $order->setBillingAddress(null); - $order->setShippingAddress(null); - } - - $order->autoSetAddresses(); - } else { - $getAddress = static function($address, Order $order, $title) { - if ($address && ($address['id'] && ($address['ownerId'] != $order->id || isset($address['_copy'])))) { - if (isset($address['_copy'])) { - unset($address['_copy']); - } - $address = Craft::$app->getElements()->getElementById($address['id'], Address::class); - $address = Craft::$app->getElements()->duplicateElement($address, [ - 'owner' => $order, - 'primaryOwner' => $order, - 'title' => $title, - ]); - } elseif ($address && ($address['id'] && $address['ownerId'] == $order->id)) { - /** @var Address|null $address */ - $address = Address::find()->ownerId($address['ownerId'])->id($address['id'])->one(); - } - - return $address; - }; - $billingAddress = $getAddress($submittedBillingAddress, $order, Craft::t('commerce', 'Billing Address')); - $order->setBillingAddress($billingAddress); - - $shippingAddress = $getAddress($submittedShippingAddress, $order, Craft::t('commerce', 'Shipping Address')); - $order->setShippingAddress($shippingAddress); - - if (array_key_exists('sourceBillingAddressId',$orderRequestData['order'])) { - $order->sourceBillingAddressId = $orderRequestData['order']['sourceBillingAddressId']; - } - - if (array_key_exists('sourceShippingAddressId',$orderRequestData['order'])) { - $order->sourceShippingAddressId = $orderRequestData['order']['sourceShippingAddressId']; - } - } - - if (!$order->shippingMethodHandle) { - // If no shipping method or it is being removed nullify the name - $order->shippingMethodName = null; - } elseif (!empty($orderRequestData['order']['shippingMethodName'])) { - // If the shipping method name is being submitted, use it. - // This is particularly useful for custom shipping methods as they can't be retrieved from the DB via their handle - $order->shippingMethodName = $orderRequestData['order']['shippingMethodName']; - } else { - // Fallback to attempting to retrieve the shipping method - $shippingMethod = Plugin::getInstance()->getShippingMethods()->getShippingMethodByHandle($order->shippingMethodHandle); - if ($shippingMethod) { - $order->shippingMethodName = $shippingMethod->name ?? null; - } - } - - // CP save has full control over all notices including admin ones - $order->clearNotices(noticeTypes: [OrderNoticeType::Customer, OrderNoticeType::Admin]); - - // Create Notices on Order - $notices = []; - foreach ($orderRequestData['order']['notices'] ?? [] as $notice) { - $notices[] = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => array_merge($notice, ['noticeType' => OrderNoticeType::Customer]), - ]); - } - foreach ($orderRequestData['order']['adminNotices'] ?? [] as $notice) { - $notices[] = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => array_merge($notice, ['noticeType' => OrderNoticeType::Admin]), - ]); - } - $order->addNotices($notices); - - $dateOrdered = $orderRequestData['order']['dateOrdered']; - if ($dateOrdered !== null) { - if ($orderRequestData['order']['dateOrdered']['time'] == '') { - $dateTime = (new DateTime('now', new DateTimeZone($dateOrdered['timezone']))); - $dateOrdered['time'] = $dateTime->format('H:i'); - } - - if ($orderRequestData['order']['dateOrdered']['date'] == '' && $orderRequestData['order']['dateOrdered']['time'] == '') { - $order->dateOrdered = null; - } else { - $order->dateOrdered = DateTimeHelper::toDateTime($dateOrdered) ?: null; - } - } - - if ($dateOrdered === null && $order->isCompleted) { - $order->dateOrdered = null; - } - - // If the customer was changed, the payment source or gateway may not be valid on the order for the new customer and we should unset it. - try { - $order->getPaymentSource(); - $order->getGateway(); - } catch (\Exception) { - $order->paymentSourceId = null; - $order->gatewayId = null; - } - - $lineItems = []; - $adjustments = []; - - foreach ($orderRequestData['order']['lineItems'] as $lineItemData) { - // Normalize data - $type = $lineItemData['type'] ?? LineItemType::Purchasable; - if (is_string($type)) { - $type = LineItemType::from($type); - } elseif (is_array($type) && isset($type['value'])) { - $type = LineItemType::from($type['value']); - } - - $description = $lineItemData['description'] ?? null; - $sku = $lineItemData['sku'] ?? null; - $lineItemId = $lineItemData['id'] ?? null; - $note = $lineItemData['note'] ?? ''; - $privateNote = $lineItemData['privateNote'] ?? ''; - $purchasableId = $lineItemData['purchasableId']; - $lineItemStatusId = $lineItemData['lineItemStatusId']; - $options = $lineItemData['options'] ?? []; - $qty = $lineItemData['qty'] ?? 1; - $shippingCategoryId = $lineItemData['shippingCategoryId'] ?? null; - $taxCategoryId = $lineItemData['taxCategoryId'] ?? null; - $hasFreeShipping = $lineItemData['hasFreeShipping'] ?? null; - $isPromotable = $lineItemData['isPromotable'] ?? null; - $isShippable = $lineItemData['isShippable'] ?? null; - $isTaxable = $lineItemData['isTaxable'] ?? null; - $uid = $lineItemData['uid'] ?? StringHelper::UUID(); - - if ($lineItemId) { - $lineItem = Plugin::getInstance()->getLineItems()->getLineItemById($lineItemId); - } else { - try { - $params = compact('options', 'qty', 'note', 'uid'); - if ($type === LineItemType::Purchasable) { - $params['purchasableId'] = $purchasableId; - } - - $lineItem = Plugin::getInstance()->getLineItems()->create($order, $params, $type); - } catch (\Exception $exception) { - $order->addError('lineItems', $exception->getMessage()); - continue; - } - } - - $lineItem->type = $type; - - $lineItem->purchasableId = $purchasableId; - $lineItem->qty = $qty; - $lineItem->note = $note; - $lineItem->privateNote = $privateNote; - $lineItem->lineItemStatusId = $lineItemStatusId; - $lineItem->setOptions($options); - $lineItem->uid = $uid; - - $lineItem->setOrder($order); - - if ($lineItem->type === LineItemType::Custom) { - if ($description) { - $lineItem->setDescription($description); - } - - if ($sku) { - $lineItem->setSku($sku); - } - - if ($shippingCategoryId) { - $lineItem->shippingCategoryId = $shippingCategoryId; - } - - if ($taxCategoryId) { - $lineItem->taxCategoryId = $taxCategoryId; - } - - if ($hasFreeShipping !== null) { - $lineItem->setHasFreeShipping($hasFreeShipping); - } - - if ($isPromotable !== null) { - $lineItem->setIsPromotable($isPromotable); - } - - if ($isShippable !== null) { - $lineItem->setIsShippable($isShippable); - } - - if ($isTaxable !== null) { - $lineItem->setIsTaxable($isTaxable); - } - } - - // Deleted a purchasable while we had a purchasable ID in memory on the order edit page, unset it. - if ($lineItem->type === LineItemType::Purchasable && $purchasableId && !Plugin::getInstance()->getPurchasables()->getPurchasableById($purchasableId, $orderRequestData['order']['orderSiteId'], $orderRequestData['order']['customerId'] ?? false)) { - $lineItem->purchasableId = null; - } - - if ($order->getRecalculationMode() == Order::RECALCULATION_MODE_NONE || $lineItem->type === LineItemType::Custom) { - $promotionalPrice = $lineItemData['promotionalPrice'] ? Localization::normalizeNumber($lineItemData['promotionalPrice']) : null; - $price = $lineItemData['price'] ? Localization::normalizeNumber($lineItemData['price']) : 0; - - $lineItem->setPromotionalPrice($promotionalPrice); - $lineItem->setPrice($price); - } - - if ($qty !== null && $qty > 0) { - $lineItems[] = $lineItem; - } - - if ($order->getRecalculationMode() == Order::RECALCULATION_MODE_NONE) { - foreach ($lineItemData['adjustments'] as $adjustmentData) { - $id = $adjustmentData['id']; - - $adjustment = null; - if ($id) { - $adjustment = Plugin::getInstance()->getOrderAdjustments()->getOrderAdjustmentById($id); - } - if ($adjustment === null) { - $adjustment = new OrderAdjustment(); - } - - $adjustment->setOrder($order); - $adjustment->setLineItem($lineItem); - $adjustment->amount = $adjustmentData['amount']; - $adjustment->type = $adjustmentData['type']; - $adjustment->name = $adjustmentData['name']; - $adjustment->description = $adjustmentData['description']; - $adjustment->included = $adjustmentData['included']; - $adjustment->setSourceSnapshot($adjustmentData['sourceSnapshot']); - - $adjustments[] = $adjustment; - } - } - } - - $order->setLineItems($lineItems); - - // Only update the adjustments if the recalculation mode is none (manually updating adjustments) - if ($order->getRecalculationMode() == Order::RECALCULATION_MODE_NONE) { - foreach ($orderRequestData['order']['orderAdjustments'] as $adjustmentData) { - $id = $adjustmentData['id']; - - $adjustment = null; - if ($id) { - $adjustment = Plugin::getInstance()->getOrderAdjustments()->getOrderAdjustmentById($id); - } - if ($adjustment === null) { - $adjustment = new OrderAdjustment(); - } - - $adjustment->setOrder($order); - $adjustment->amount = $adjustmentData['amount']; - $adjustment->type = $adjustmentData['type']; - $adjustment->name = $adjustmentData['name']; - $adjustment->description = $adjustmentData['description']; - $adjustment->included = $adjustmentData['included']; - $adjustment->setSourceSnapshot($adjustmentData['sourceSnapshot']); - - $adjustments[] = $adjustment; - } - - // add all the updated adjustments to the order - $order->setAdjustments($adjustments); - } - } - - /** - * @param Transaction[] $transactions - * @throws Exception - * @throws LoaderError - * @throws RuntimeError - * @throws SyntaxError - * @throws CurrencyException - * @since 3.0 - */ - private function _getTransactionsWithLevelsTableArray(array $transactions, int $level = 0): array - { - $return = []; - $user = Craft::$app->getUser()->getIdentity(); - foreach ($transactions as $transaction) { - if (!ArrayHelper::firstWhere($return, 'id', $transaction->id)) { - $refundCapture = ''; - if ($user->can('commerce-capturePayment') && $transaction->canCapture()) { - $refundCapture = Craft::$app->getView()->renderTemplate( - 'commerce/orders/includes/_capture', - [ - 'currentUser' => $user, - 'transaction' => $transaction, - ] - ); - } elseif ($user->can('commerce-refundPayment') && $transaction->canRefund()) { - $refundCapture = Craft::$app->getView()->renderTemplate( - 'commerce/orders/includes/_refund', - [ - 'currentUser' => $user, - 'transaction' => $transaction, - ] - ); - } - - $transactionResponse = Json::decodeIfJson($transaction->response); - if (is_array($transactionResponse)) { - $transactionResponse = Json::htmlEncode($transactionResponse); - } - - $transactionMessage = Json::decodeIfJson($transaction->message); - $transactionMessage = Json::htmlEncode($transactionMessage); - - $return[] = [ - 'id' => $transaction->id, - 'level' => $level, - 'type' => [ - 'label' => Html::encode(Craft::t('commerce', StringHelper::toTitleCase($transaction->type))), - 'level' => $level, - ], - 'status' => [ - 'key' => $transaction->status, - 'label' => Html::encode(Craft::t('commerce', StringHelper::toTitleCase($transaction->status))), - ], - 'paymentAmount' => $transaction->paymentAmountAsCurrency, - 'amount' => $transaction->amountAsCurrency, - 'gateway' => Html::encode($transaction->gateway->name ?? Craft::t('commerce', 'Missing Gateway')), - 'date' => $transaction->dateUpdated ? $transaction->dateUpdated->format('H:i:s (jS M Y)') : '', - 'info' => [ - ['label' => Html::encode(Craft::t('commerce', 'Transaction ID')), 'type' => 'code', 'value' => $transaction->id], - ['label' => Html::encode(Craft::t('commerce', 'Transaction Hash')), 'type' => 'code', 'value' => $transaction->hash], - ['label' => Html::encode(Craft::t('commerce', 'Gateway Reference')), 'type' => 'code', 'value' => $transaction->reference], - ['label' => Html::encode(Craft::t('commerce', 'Gateway Message')), 'type' => 'text', 'value' => $transactionMessage], - ['label' => Html::encode(Craft::t('commerce', 'Note')), 'type' => 'text', 'value' => Html::encode($transaction->note)], - ['label' => Html::encode(Craft::t('commerce', 'Gateway Code')), 'type' => 'code', 'value' => $transaction->code], - ['label' => Html::encode(Craft::t('commerce', 'Converted Price')), 'type' => 'text', 'value' => $transaction->paymentAmountAsCurrency . ' (1 ' . $transaction->currency . ' = ' . $transaction->paymentRate . ' ' . $transaction->paymentCurrency . ')'], - ['label' => Html::encode(Craft::t('commerce', 'Gateway Response')), 'type' => 'response', 'value' => $transactionResponse], - ], - 'actions' => $refundCapture, - ]; - - if (!empty($transaction->childTransactions)) { - $childTransactions = $this->_getTransactionsWithLevelsTableArray($transaction->childTransactions, $level + 1); - - foreach ($childTransactions as $childTransaction) { - $return[] = $childTransaction; - } - } - } - } - - return $return; - } - - /** - * @throws InvalidConfigException - */ - private function _addLivePurchasableInfo(array $results, int $siteId, int|false|null $customerId = null): array - { - $purchasables = []; - $store = Plugin::getInstance()->getStores()->getStoreBySiteId($siteId); - $baseCurrency = $store->getCurrency(); - - $elementIdsByType = []; - foreach ($results as $r) { - if (!array_key_exists($r['type'], $elementIdsByType)) { - $elementIdsByType[$r['type']] = []; - } - $elementIdsByType[$r['type']][] = $r['id']; - } - - $purchasablesById = []; - foreach ($elementIdsByType as $type => $ids) { - /** @var ElementInterface $type */ - - if (!class_exists($type)) { - continue; - } - - /** @var ElementQuery $query */ - $query = $type::find(); - - if ($type::isLocalized()) { - $query->siteId($siteId); - } - - $query->status(null); - - if ($query instanceof PurchasableQuery) { - $query->forCustomer($customerId); - } - - $purchasablesById = [...$purchasablesById, ...$query->id($ids)->all()]; - } - - foreach ($results as $row) { - /** @var PurchasableInterface|null $purchasable */ - $purchasable = ArrayHelper::firstWhere($purchasablesById, 'id', $row['id']); - if ($purchasable) { - // @TODO Revisit purchasable price lookup once per-store currency handling is finalized - $row['price'] = $purchasable->getSalePrice(); - $row['promotionalPrice'] = $purchasable->getPromotionalPrice(); - $row['priceAsCurrency'] = MoneyHelper::toString(MoneyHelper::toMoney(['value' => $purchasable->getSalePrice(), 'currency' => $baseCurrency])); - $row['isAvailable'] = Plugin::getInstance()->getPurchasables()->isPurchasableAvailable($purchasable); - $row['detail'] = [ - 'title' => Craft::t('commerce', 'Information'), - 'content' => $purchasable->getSnapshot(), - 'showAsList' => true, - ]; - $row['newLineItemUid'] = StringHelper::UUID(); - $row['newLineItemOptionsSignature'] = LineItem::generateOptionsSignature([]); - $row['description'] = Html::encode($row['description']); - $row['sku'] = Html::encode($row['sku']); - $row['qty'] = ''; - $purchasables[] = $row; - } - } - - return $purchasables; - } - - - /** - * @param User $customer - * @return array - * @since 4.0 - */ - private function _customerToArray(User $customer): array - { - $totalAddresses = Address::find()->ownerId($customer->id)->count(); - - return $customer->toArray(expand: ['photo']) + [ - 'cpEditUrl' => $customer->getCpEditUrl(), - 'totalAddresses' => $totalAddresses, - 'photoThumbHtml' => $customer->getThumbHtml(100), - - // @TODO Remove `photoThumbUrl` once the order edit Vue UI is updated to use `photoThumbHtml` instead - 'photoThumbUrl' => '', - ]; - } - - /** - * @param Order $order - * @throws ForbiddenHttpException - */ - protected function enforceManageOrderPermissions(Order $order): void - { - if (!Craft::$app->getElements()->canView($order)) { - throw new ForbiddenHttpException('User not authorized to view this order.'); - } - } -} diff --git a/src/controllers/PaymentCurrenciesController.php b/src/controllers/PaymentCurrenciesController.php deleted file mode 100644 index 8368737de1..0000000000 --- a/src/controllers/PaymentCurrenciesController.php +++ /dev/null @@ -1,176 +0,0 @@ - - * @since 2.0 - */ -class PaymentCurrenciesController extends BaseStoreManagementController -{ - /** - * @throws CurrencyException - */ - public function actionIndex(?string $storeHandle = null): Response - { - $this->getView()->registerTranslations('commerce', [ - 'Base', - 'Code', - 'Conversion Rate', - 'Currency', - 'No additional payment currencies exist yet.', - 'Warning, deleting this currency will stop all payments and refunds in this currency, are you sure you want to delete “{name}”?', - ]); - - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $currencies = Plugin::getInstance()->getPaymentCurrencies()->getAllPaymentCurrencies($store->id); - - return $this->asStoreManagementCpScreen($storeHandle) - ->additionalButtonsHtml(Html::a( - Craft::t('commerce', 'New currency'), "commerce/store-management/$storeHandle/payment-currencies/new", - ['class' => 'btn submit add icon'] - )) - ->contentTemplate('commerce/store-management/paymentcurrencies/index', compact('currencies', 'store')); - } - - /** - * @param int|null $id - * @param PaymentCurrency|null $currency - * @throws HttpException - * @throws InvalidConfigException - */ - public function actionEdit(int $id = null, PaymentCurrency $currency = null, string $storeHandle = null): Response - { - if ($storeHandle) { - $store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle); - if ($store === null) { - throw new InvalidConfigException('Invalid store.'); - } - } else { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - if (!$currency) { - if ($id) { - $currency = Plugin::getInstance()->getPaymentCurrencies()->getPaymentCurrencyById($id, $store->id); - - if (!$currency || $currency->storeId !== $store->id) { - throw new HttpException(404); - } - } else { - $currency = Craft::createObject([ - 'class' => PaymentCurrency::class, - 'storeId' => $store->id, - ]); - } - } - - if ($currency->id) { - $title = $currency->iso; // @TODO Use the full currency name instead of the ISO code for the page title - } else { - $title = Craft::t('commerce', 'Create a new currency'); - } - - DebugPanel::prependOrAppendModelTab(model: $currency, prepend: true); - - $storeCurrency = Plugin::getInstance()->getPaymentCurrencies()->getPrimaryPaymentCurrencyIso(); - $currencyOptions = Plugin::getInstance()->getCurrencies()->getAllCurrenciesList(); - $hasCompletedOrders = Order::find()->isCompleted(true)->exists(); - - $formatter = Craft::$app->getFormatter(); - - $metaSidebarHtml = $currency->id ? Cp::metadataHtml([ - Craft::t('app', 'Created at') => $formatter->asDateTime($currency->dateCreated, Formatter::FORMAT_WIDTH_SHORT), - Craft::t('app', 'Updated at') => $formatter->asDateTime($currency->dateUpdated, Formatter::FORMAT_WIDTH_SHORT), - ]) : ''; - - return $this->asStoreManagementCpScreen($storeHandle, false) - ->addCrumb(Craft::t('commerce','Payment Currencies'), "commerce/store-management/$storeHandle/payment-currencies") - ->metaSidebarHtml($metaSidebarHtml) - ->action('commerce/payment-currencies/save') - ->redirectUrl("commerce/store-management/$storeHandle/payment-currencies") - ->submitButtonLabel(Craft::t('app', 'Save')) - ->contentTemplate('commerce/store-management/paymentcurrencies/_edit', [ - 'id' => $id, - 'currency' => $currency, - 'title' => $title, - 'storeCurrency' => $storeCurrency, - 'currencyOptions' => $currencyOptions, - 'store' => $store, - 'hasCompletedOrders' => $hasCompletedOrders, - ]); - } - - /** - * @throws Exception - * @throws DbException - * @throws BadRequestHttpException - */ - public function actionSave(): void - { - $this->requirePostRequest(); - - $currency = new PaymentCurrency(); - - // Shared attributes - $currency->id = $this->request->getBodyParam('currencyId'); - $currency->storeId = $this->request->getBodyParam('storeId'); - $currency->iso = $this->request->getBodyParam('iso'); - $currency->rate = $this->request->getBodyParam('rate', 1); - - // Save it - if (Plugin::getInstance()->getPaymentCurrencies()->savePaymentCurrency($currency)) { - $this->setSuccessFlash(Craft::t('commerce', 'Currency saved.')); - $this->redirectToPostedUrl($currency); - } else { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save currency.')); - } - - // Send the model back to the template - Craft::$app->getUrlManager()->setRouteParams(['currency' => $currency]); - } - - /** - * @throws BadRequestHttpException - * @throws InvalidConfigException - */ - public function actionDelete(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $id = $this->request->getRequiredBodyParam('id'); - - if (!Plugin::getInstance()->getPaymentCurrencies()->deletePaymentCurrencyById($id)) { - return $this->asFailure(); - } - - return $this->asSuccess(); - } -} diff --git a/src/controllers/PaymentSourcesController.php b/src/controllers/PaymentSourcesController.php deleted file mode 100644 index 684d0ae707..0000000000 --- a/src/controllers/PaymentSourcesController.php +++ /dev/null @@ -1,164 +0,0 @@ - - * @since 2.0 - */ -class PaymentSourcesController extends BaseFrontEndController -{ - /** - * Adds a payment source. - * - * @throws BadRequestHttpException - * @throws HttpException - * @throws InvalidConfigException - */ - public function actionAdd(): ?Response - { - $this->requirePostRequest(); - - $plugin = Plugin::getInstance(); - - // Are we paying anonymously? - $customer = Craft::$app->getUser()->getIdentity(); - - if (!$customer) { - throw new HttpException(401, Craft::t('commerce', 'You must be signed in to create a payment source.')); - } - - // Allow setting the payment method at time of submitting payment. - $gatewayId = $this->request->getRequiredBodyParam('gatewayId'); - - $isPrimaryPaymentSource = $this->request->getBodyParam('isPrimaryPaymentSource', false); - - $gateway = $plugin->getGateways()->getGatewayById($gatewayId); - - if (!$gateway || !$gateway->supportsPaymentSources()) { - return $this->asFailure(Craft::t('commerce', 'There is no gateway selected that supports payment sources.')); - } - - // Get the payment method' gateway adapter's expected form model - $paymentForm = $gateway->getPaymentFormModel(); - $paymentFormParams = $this->request->getBodyParam(PaymentForm::getPaymentFormParamName($gateway->handle), []); - $paymentForm->setAttributes($paymentFormParams, false); - $description = (string)$this->request->getBodyParam('description'); - - try { - $paymentSource = $plugin->getPaymentSources()->createPaymentSource($customer->id, $gateway, $paymentForm, $description, $isPrimaryPaymentSource); - } catch (Throwable $exception) { - Craft::$app->getErrorHandler()->logException($exception); - return $this->asModelFailure( - $paymentForm, - Craft::t('commerce', 'Could not create the payment source.'), - 'paymentForm', - ['paymentFormErrors' => $paymentForm->getErrors()] - ); - } - - if ($isPrimaryPaymentSource) { - $plugin->getCustomers()->savePrimaryPaymentSourceId($customer, $paymentSource->id); - } - - return $this->asModelSuccess( - $paymentSource, - Craft::t('commerce', 'Payment source created.'), - 'paymentSource' - ); - } - - /** - * @return Response|null - * @throws BadRequestHttpException - * @throws HttpException - * @throws InvalidConfigException - * @since 4.2 - */ - public function actionSetPrimaryPaymentSource(): ?Response - { - $this->requirePostRequest(); - - $user = Craft::$app->getUser()->getIdentity(); - if (!$user) { - throw new HttpException(401, Craft::t('commerce', 'You must be signed in to set a primary payment source.')); - } - - $paymentSourceId = $this->request->getRequiredBodyParam('id'); - - // Check payment source exists and belongs to the user - $paymentSource = Plugin::getInstance()->getPaymentSources()->getPaymentSourceByIdAndUserId($paymentSourceId, $user->id); - if (!$paymentSource) { - return $this->asFailure( - Craft::t('commerce', 'Unable to retrieve payment source.'), - ['paymentSourceId' => $paymentSourceId], - ['paymentSourceId' => $paymentSourceId] - ); - } - - if (!Plugin::getInstance()->getCustomers()->savePrimaryPaymentSourceId($user, $paymentSource->id)) { - return $this->asFailure( - Craft::t('commerce', 'Unable to set primary payment source.'), - ['paymentSourceId' => $paymentSourceId], - ['paymentSourceId' => $paymentSourceId] - ); - } - - return $this->asSuccess( - Craft::t('commerce', 'Primary payment source updated.') - ); - } - - /** - * Deletes a payment source. - * - * @throws Throwable if failed to delete the payment source on the gateway - * @throws BadRequestHttpException if user not logged in - */ - public function actionDelete(): ?Response - { - $this->requirePostRequest(); - $this->requireLogin(); - - $id = $this->request->getRequiredBodyParam('id'); - - $paymentSources = Commerce::getInstance()->getPaymentSources(); - $paymentSource = $paymentSources->getPaymentSourceById($id); - - if (!$paymentSource) { - return null; - } - - $currentUser = Craft::$app->getUser()->getIdentity(); - - if ($paymentSource->getCustomer()?->id != $currentUser->getId() && !$currentUser->can('commerce-manageOrders')) { - return null; - } - - $result = $paymentSources->deletePaymentSourceById($id); - - if ($result) { - return $this->asModelSuccess($paymentSource, Craft::t('commerce', 'Payment source deleted.')); - } - - return $this->asModelFailure($paymentSource, Craft::t('commerce', 'Couldn’t delete the payment source.')); - } -} diff --git a/src/controllers/PaymentsController.php b/src/controllers/PaymentsController.php deleted file mode 100644 index 966d98bc07..0000000000 --- a/src/controllers/PaymentsController.php +++ /dev/null @@ -1,601 +0,0 @@ - - * @since 2.0 - */ -class PaymentsController extends BaseFrontEndController -{ - /** - * @var string - */ - private string $_cartVariableName; - - public function init(): void - { - parent::init(); - $this->_cartVariableName = Plugin::getInstance()->getSettings()->cartVariable; - } - - /** - * @inheritDoc - */ - public function beforeAction($action): bool - { - // Don't enable CSRF validation for complete-payment requests since they can come from offsite, and the transaction hash is validated anyway. - if ($action->id === 'complete-payment') { - $this->enableCsrfValidation = false; - } - - return parent::beforeAction($action); - } - - /** - * @throws CurrencyException - * @throws Exception - * @throws NotSupportedException - * @throws Throwable - * @throws ElementNotFoundException - * @throws InvalidConfigException - * @throws BadRequestHttpException - */ - public function actionPay(): ?Response - { - $this->requirePostRequest(); - - $error = ''; - - /** @var Plugin $plugin */ - $plugin = Plugin::getInstance(); - $currentUser = Craft::$app->getUser()->getIdentity(); - $isSiteRequest = $this->request->getIsSiteRequest(); - $isCpRequest = $this->request->getIsCpRequest(); - $userSession = Craft::$app->getUser(); - - $number = $this->request->getParam('number'); - - $useMutex = $number || (!$isCpRequest && $plugin->getCarts()->getHasSessionCartNumber()); - - if ($useMutex) { - $lockOrderNumber = null; - if ($number) { - $lockOrderNumber = $number; - } elseif (!$isCpRequest) { - $request = Craft::$app->getRequest(); - $requestCookies = $request->getCookies(); - $cookieNumber = $requestCookies->getValue($plugin->getCarts()->cartCookie['name']); - - if ($cookieNumber) { - $lockOrderNumber = $cookieNumber; - } - } - - if ($lockOrderNumber) { - $lockName = "order:$lockOrderNumber"; - $mutex = Craft::$app->getMutex(); - if (!$mutex->acquire($lockName, 5)) { - throw new Exception('Unable to acquire a lock for saving of Order: ' . $lockOrderNumber); - } - } - } - - - if ($number !== null) { - $order = $plugin->getOrders()->getOrderByNumber($number); - - if (!$order) { - $error = Craft::t('commerce', 'Can not find an order to pay.'); - - if ($this->request->getAcceptsJson()) { - return $this->asFailure($error, data: [ - $this->_cartVariableName => null, - ]); - } - - $this->setFailFlash($error); - - return null; - } - - // @TODO Fix the response variable name in Commerce 6.0: use `order` when completed and `cartVariableName` when not completed #COM-36 - $this->_cartVariableName = 'order'; // can not override the name of the order cart in json responses for orders - } else { - $order = $plugin->getCarts()->getCart(); - } - - /** - * Payments on completed orders can only be made if the order number and email - * address are passed to the payments controller. If this is via the control panel, - * it requires the user have the correct permission. - */ - $isSiteRequestAndAllowed = $isSiteRequest && $order->getEmail() == $this->request->getParam('email'); - $isCpAndAllowed = $isCpRequest && $currentUser && $currentUser->can('commerce-manageOrders'); - $checkPaymentCanBeMade = $number && ($isSiteRequestAndAllowed || $isCpAndAllowed); - - if (!$order->getIsActiveCart() && !$checkPaymentCanBeMade) { - $error = Craft::t('commerce', 'Email required to make payments on a completed order.'); - return $this->asFailure($error); - } - - // Paying by order number + email is an anonymous flow: it doesn't prove the requester is - // logged in as the order's own customer. If the order already has a payment source on file - // (e.g. attached earlier by the credentialed customer), clear it unless the current user - // actually is that customer, so it can't be charged anonymously. - if ($number !== null && $isSiteRequestAndAllowed && $order->paymentSourceId) { - $orderCustomer = $order->getCustomer(); - $isLoggedInAsOrderCustomer = $currentUser && $orderCustomer && $currentUser->id == $orderCustomer->id; - if (!$isLoggedInAsOrderCustomer) { - $order->setPaymentSource(null); - } - } - - if ($order->getStore()->getRequireShippingAddressAtCheckout() && !$order->shippingAddressId) { - $error = Craft::t('commerce', 'Shipping address required.'); - return $this->asFailure($error, data: [ - $this->_cartVariableName => $this->cartArray($order), - ]); - } - - if ($order->getStore()->getRequireBillingAddressAtCheckout() && !$order->billingAddressId) { - $error = Craft::t('commerce', 'Billing address required.'); - return $this->asFailure($error, data: [ - $this->_cartVariableName => $this->cartArray($order), - ]); - } - - if (!$order->getStore()->getAllowEmptyCartOnCheckout() && $order->getIsEmpty()) { - $error = Craft::t('commerce', 'Order can not be empty.'); - return $this->asFailure($error, data: [ - $this->_cartVariableName => $this->cartArray($order), - ]); - } - - // Set if the customer should be registered on order completion - $registerUserOnOrderComplete = $this->request->getBodyParam('registerUserOnOrderComplete'); - if ($registerUserOnOrderComplete !== null) { - $order->registerUserOnOrderComplete = (bool)$registerUserOnOrderComplete; - } - - $saveBillingAddressOnOrderComplete = $this->request->getBodyParam('saveBillingAddressOnOrderComplete'); - if ($saveBillingAddressOnOrderComplete !== null) { - $order->saveBillingAddressOnOrderComplete = (bool)$saveBillingAddressOnOrderComplete; - } - - $saveShippingAddressOnOrderComplete = $this->request->getBodyParam('saveShippingAddressOnOrderComplete'); - if ($saveShippingAddressOnOrderComplete !== null) { - $order->saveShippingAddressOnOrderComplete = (bool)$saveShippingAddressOnOrderComplete; - } - - $saveAddressesOnOrderComplete = $this->request->getBodyParam('saveAddressesOnOrderComplete'); - if ($saveAddressesOnOrderComplete !== null) { - $order->saveBillingAddressOnOrderComplete = (bool)$saveAddressesOnOrderComplete; - $order->saveShippingAddressOnOrderComplete = (bool)$saveAddressesOnOrderComplete; - } - - // These are used to compare if the order changed during its final - // recalculation before payment. - $originalTotalPrice = $order->getOutstandingBalance(); - $originalTotalQty = $order->getTotalQty(); - $originalTotalAdjustments = count($order->getAdjustments()); - - // Set guest email address onto guest customer and order. - if ($paymentCurrency = $this->request->getParam('paymentCurrency')) { - try { - $order->setPaymentCurrency($paymentCurrency); - } catch (CurrencyException $exception) { - Craft::$app->getErrorHandler()->logException($exception); - - $error = $exception->getMessage(); - $order->addError('paymentCurrency', $exception->getMessage()); - - return $this->asFailure($error, data: [ - $this->_cartVariableName => $this->cartArray($order), - ]); - } - } - - // Set Payment Gateway on cart - // Same as CartController::updateCart() - if ($gatewayId = $this->request->getParam('gatewayId')) { - if ($plugin->getGateways()->getGatewayById($gatewayId)) { - $order->setGatewayId($gatewayId); - } - } - - // Submit payment source on cart - // See CartController::updateCart() - if ($paymentSourceId = $this->request->getParam('paymentSourceId')) { - if ($paymentSource = $plugin->getPaymentSources()->getPaymentSourceById($paymentSourceId)) { - // The payment source can only be used by the same user as the cart's user. - $cartUserId = $order->getCustomer()?->id; - $paymentSourceUserId = $paymentSource->getCustomer()?->id; - $allowedToUsePaymentSource = ($cartUserId && $paymentSourceUserId && $currentUser && $isSiteRequest && ($paymentSourceUserId == $cartUserId)); - if ($allowedToUsePaymentSource) { - $order->setPaymentSource($paymentSource); - } - } - } - - // This will return the gateway to be used. The orders gateway ID could be null, but it will know the gateway from the paymentSource ID - $gateway = $order->getGateway(); - - if (!$gateway || !$gateway->availableForUseWithOrder($order) || (!$gateway->getIsFrontendEnabled() && !$isCpRequest)) { - $error = Craft::t('commerce', 'There is no gateway or payment source available for use with this order.'); - - if ($order->gatewayId) { - $order->addError('gatewayId', $error); - } - - if ($order->paymentSourceId) { - $order->addError('paymentSourceId', $error); - } - - return $this->asFailure($error, data: [ - $this->_cartVariableName => $this->cartArray($order), - ]); - } - - // We need the payment form whether we are populating it from the request or from the payment source. - $paymentForm = $gateway->getPaymentFormModel(); - - /** - * - * Are we paying with: - * - * 1) The current order paymentSourceId - * OR - * 2) The current order gatewayId and a payment form populated from the request - * - */ - - // 1) Paying with the current order paymentSourceId - if ($order->paymentSourceId) { - /** @var PaymentSource $paymentSource */ - $paymentSource = $order->getPaymentSource(); - if ($gateway->supportsPaymentSources()) { - $paymentForm->populateFromPaymentSource($paymentSource); - } - } - - // 2) Paying with the current order gatewayId and a payment form populated from the request - if ($order->gatewayId && !$order->paymentSourceId) { - - // Populate the payment form from the params - $paymentFormParams = $this->request->getBodyParam(PaymentForm::getPaymentFormParamName($gateway->handle)); - - if ($paymentFormParams) { - $paymentForm->setAttributes($paymentFormParams, false); - } - - // Does the user want to save this card as a payment source? - if ($currentUser && $this->request->getBodyParam('savePaymentSource') && $gateway->supportsPaymentSources()) { - $sourceCreated = false; - try { - $paymentSource = $plugin->getPaymentSources()->createPaymentSource($currentUser->id, $gateway, $paymentForm); - - // Last line of try block we have a successful payment source creation - $sourceCreated = true; - } catch (PaymentSourceCreatedLaterException) { - if (property_exists($paymentForm, 'paymentSource')) { - $paymentForm->savePaymentSource = true; - } - } catch (PaymentSourceException $exception) { - Craft::$app->getErrorHandler()->logException($exception); - - $error = $exception->getMessage(); - - return $this->asModelFailure( - $paymentForm, - $error, - 'paymentForm', - [ - $this->_cartVariableName => $this->cartArray($order), - 'paymentFormErrors' => $paymentForm->getErrors(), - ], - [ - $this->_cartVariableName => $order, - ] - ); - } - - if ($sourceCreated) { - /** @phpstan-ignore-next-line */ - $order->setPaymentSource($paymentSource); - /** @phpstan-ignore-next-line */ - $paymentForm->populateFromPaymentSource($paymentSource); - } - } - } - - // Allowed to update order's custom fields? - if ($order->getIsActiveCart() || $userSession->checkPermission('commerce-manageOrders')) { - $order->setFieldValuesFromRequest('fields'); - } - - // Check email address exists on order. - if (!$order->email) { - $error = Craft::t('commerce', 'No customer email address exists on this cart.'); - - return $this->asModelFailure( - $paymentForm, - $error, - 'paymentForm', - [ - $this->_cartVariableName => $this->cartArray($order), - 'paymentFormErrors' => $paymentForm->getErrors(), - ], - [ - $this->_cartVariableName => $order, - ] - ); - } - - // Does the order require shipping - if ($order->hasShippableItems() && $order->getStore()->getRequireShippingMethodSelectionAtCheckout() && !$order->shippingMethodHandle) { - $error = Craft::t('commerce', 'There is no shipping method selected for this order.'); - - return $this->asModelFailure( - $paymentForm, - $error, - 'paymentForm', - [ - $this->_cartVariableName => $this->cartArray($order), - ] - ); - } - - // Save the return and cancel URLs to the order - $returnUrl = $this->request->getValidatedBodyParam('redirect'); - if ($returnUrl !== null) { - $order->returnUrl = $this->getView()->renderSandboxedObjectTemplate($returnUrl, $order); - } - - $cancelUrl = $this->request->getValidatedBodyParam('cancelUrl'); - if ($cancelUrl !== null) { - $order->cancelUrl = $this->getView()->renderSandboxedObjectTemplate($cancelUrl, $order); - } - - // Do one final save to confirm the price does not change out from under the customer. Also removes any out of stock items etc. - // This also confirms the products are available and discounts are current. - $order->recalculate(); - // Save the orders new values. - - $totalPriceChanged = $originalTotalPrice != $order->getOutstandingBalance(); - $totalQtyChanged = $originalTotalQty != $order->getTotalQty(); - $totalAdjustmentsChanged = $originalTotalAdjustments != count($order->getAdjustments()); - - $updateCartSearchIndexes = Plugin::getInstance()->getSettings()->updateCartSearchIndexes; - $updateSearchIndex = ($order->isCompleted || $updateCartSearchIndexes); - - if (Craft::$app->getElements()->saveElement($order, true, false, $updateSearchIndex)) { - // Has the order changed in a significant way? - if ($totalPriceChanged || $totalQtyChanged || $totalAdjustmentsChanged) { - if ($totalPriceChanged) { - $order->addError('totalPrice', Craft::t('commerce', 'The total price of the order changed.')); - } - - if ($totalQtyChanged) { - $order->addError('totalQty', Craft::t('commerce', 'The total quantity of items within the order changed.')); - } - - if ($totalAdjustmentsChanged) { - $order->addError('totalAdjustments', Craft::t('commerce', 'The total number of order adjustments changed.')); - } - - $error = Craft::t('commerce', 'Something changed with the order before payment, please review your order and submit payment again.'); - - if ($useMutex && isset($mutex, $lockName)) { - $mutex->release($lockName); - } - - return $this->asModelFailure( - $paymentForm, - $error, - 'paymentForm', - [ - $this->_cartVariableName => $this->cartArray($order), - 'paymentFormErrors' => $paymentForm->getErrors(), - ], - [ - $this->_cartVariableName => $order, - ] - ); - } - } - - if ($useMutex && isset($mutex, $lockName)) { - $mutex->release($lockName); - } - - $redirect = ''; - $redirectData = []; - $transaction = null; - $paymentForm->validate(); - - // Make sure during this payment request the order does not recalculate. - // We don't want to save the order in this mode in case the payment fails. The customer should still be able to edit and recalculate the cart. - // When the order is marked as complete from a payment later, the order will be set to 'recalculate none' mode permanently. - $order->setRecalculationMode(Order::RECALCULATION_MODE_NONE); - - // set a partial payment amount on the order in the orders currency (not payment currency) - $partialAllowed = (($this->request->isSiteRequest && $order->getStore()->getAllowPartialPaymentOnCheckout()) || $this->request->isCpRequest); - - if ($partialAllowed) { - if ($isCpAndAllowed) { - // Payment amount in the CP accepts number based in the user's formatting locale - $cpPaymentAmount = $this->request->getBodyParam('paymentAmount'); - - if (is_array($cpPaymentAmount)) { - $cpPaymentAmount = $cpPaymentAmount['value']; - } - $cpPaymentAmount = Localization::normalizeNumber($cpPaymentAmount); - - $order->setPaymentAmount($cpPaymentAmount); - } elseif ($this->request->getBodyParam('paymentAmount')) { - $paymentAmount = (float)$this->request->getValidatedBodyParam('paymentAmount'); - $order->setPaymentAmount($paymentAmount); - } - } - - if ((!$partialAllowed || !$gateway->supportsPartialPayment()) && $order->isPaymentAmountPartial()) { - $error = Craft::t('commerce', 'Partial payment not allowed.'); - - if (!$order->isCompleted) { - $order->setRecalculationMode(Order::RECALCULATION_MODE_ALL); - } - - return $this->asModelFailure( - $paymentForm, - $error, - 'paymentForm', - [ - $this->_cartVariableName => $this->cartArray($order), - 'paymentFormErrors' => $paymentForm->getErrors(), - ], - [ - $this->_cartVariableName => $order, - ] - ); - } - - if (!$paymentForm->hasErrors() && !$order->hasErrors()) { - try { - $plugin->getPayments()->processPayment($order, $paymentForm, $redirect, $transaction, $redirectData); - $success = true; - } catch (PaymentException $exception) { - $error = $exception->getMessage(); - $success = false; - } - } else { - $error = Craft::t('commerce', 'Invalid payment or order. Please review.'); - $success = false; - } - - if (!$success) { - // Reset so the cart can still be edited and recalculated after a failed payment. - if (!$order->isCompleted) { - $order->setRecalculationMode(Order::RECALCULATION_MODE_ALL); - } - - // Keep old paymentFormErrors as is. - $originalPaymentFormErrors = $paymentForm->getErrors(); - - // Adds the order errors to the payment form errors. - $paymentForm->addModelErrors($order, $this->_cartVariableName); - - return $this->asModelFailure( - $paymentForm, - $error, - 'paymentForm', - [ - $this->_cartVariableName => $this->cartArray($order), - // @TODO Remove the legacy `paymentFormErrors` key in Commerce 6.0 - 'paymentFormErrors' => $originalPaymentFormErrors, - ], - [ - $this->_cartVariableName => $order, - ] - ); - } - - // If the gateway did not give us a redirect URL, use the order's return URL. - if (!$redirect) { - // Can be set from the redirect body param - $redirect = $order->returnUrl; - } - - if ($this->request->getAcceptsJson()) { - return $this->asModelSuccess( - $paymentForm, - $error, - 'paymentForm', - [ - $this->_cartVariableName => $this->cartArray($order), - 'redirect' => $redirect, - 'redirectData' => $redirectData, - 'transactionId' => $transaction->reference ?? null, - 'transactionHash' => $transaction->hash ?? null, - ], - $redirect - ); - } - - return $this->redirect($redirect); - } - - /** - * Processes return from off-site payment - * - * @throws Exception - * @throws HttpException - */ - public function actionCompletePayment(): Response - { - /** @var Plugin $plugin */ - $plugin = Plugin::getInstance(); - - $hash = $this->request->getParam('commerceTransactionHash'); - - $transaction = $plugin->getTransactions()->getTransactionByHash($hash); - - if (!$transaction) { - throw new HttpException(400, Craft::t('commerce', 'Can not complete payment for missing transaction.')); - } - - $error = ''; - $success = $plugin->getPayments()->completePayment($transaction, $error); - - if (!$success) { - $errorMessage = Craft::t('commerce', 'Payment error: {message}', ['message' => $error]); - $this->setFailFlash($errorMessage); - - if ($this->request->getAcceptsJson()) { - $data = [ - 'url' => $transaction->order->cancelUrl, - ]; - - return $this->asFailure($errorMessage, data: $data); - } - - return $this->redirect($transaction->order->cancelUrl); - } - - if ($this->request->getAcceptsJson()) { - $data = [ - 'url' => $transaction->order->returnUrl, - ]; - - return $this->asSuccess(data: $data); - } - - return $this->redirect($transaction->order->returnUrl); - } -} diff --git a/src/controllers/PdfsController.php b/src/controllers/PdfsController.php deleted file mode 100644 index 3ac8e4b95c..0000000000 --- a/src/controllers/PdfsController.php +++ /dev/null @@ -1,205 +0,0 @@ - - * @since 3.2 - */ -class PdfsController extends BaseAdminController -{ - /** - * @since 3.2 - */ - public function actionIndex(): Response - { - $pdfs = []; - $stores = Plugin::getInstance()->getStores()->getAllStores(); - - $stores->each(function(Store $store) use (&$pdfs) { - $pdfs[$store->handle] = Plugin::getInstance()->getPdfs()->getAllPdfs($store->id); - }); - $stores = $stores->all(); - - return $this->renderTemplate('commerce/settings/pdfs/index', [ - 'pdfs' => $pdfs, - 'stores' => $stores, - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * @param string|null $storeHandle - * @param int|null $id - * @param Pdf|null $pdf - * @return Response - * @throws Exception - * @throws HttpException - * @throws InvalidConfigException - * @since 3.2 - */ - public function actionEdit(?string $storeHandle = null, int $id = null, Pdf $pdf = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $pdfLanguageOptions = [ - PdfRecord::LOCALE_ORDER_LANGUAGE => Craft::t('commerce', 'The language the order was made in.'), - ]; - - $pdfLanguageOptions = array_merge($pdfLanguageOptions, LocaleHelper::getSiteAndOtherLanguages()); - - if (!$pdf) { - if ($id) { - $pdf = Plugin::getInstance()->getPdfs()->getPdfById($id, $store->id); - - if (!$pdf) { - throw new HttpException(404); - } - } else { - $pdf = Craft::createObject([ - 'class' => Pdf::class, - 'attributes' => ['storeId' => $store->id], - ]); - } - } - - $title = $pdf->id ? $pdf->name : Craft::t('commerce', 'Create a new PDF'); - - $isDefault = Plugin::getInstance()->getPdfs()->getAllPdfs($pdf->storeId)->count() === 0 || $pdf->isDefault; - $paperOrientationOptions = Pdf::getPaperOrientationOptions(); - $paperSizeOptions = Pdf::getPaperSizeOptions(); - - DebugPanel::prependOrAppendModelTab(model: $pdf, prepend: true); - - return $this->asCpScreen() - ->title($title) - ->crumbs([ - ['label' => Craft::t('commerce', 'Commerce'), 'url' => 'commerce'], - ['label' => Craft::t('app', 'Settings'), 'url' => 'commerce/settings', 'ariaLabel' => Craft::t('commerce', 'Commerce Settings')], - ['label' => Craft::t('commerce', 'PDFs'), 'url' => 'commerce/settings/pdfs'], - ]) - ->selectedSubnavItem('settings') - ->action('commerce/pdfs/save') - ->redirectUrl('commerce/settings/pdfs') - ->contentTemplate('commerce/settings/pdfs/_edit', [ - 'pdf' => $pdf, - 'pdfLanguageOptions' => $pdfLanguageOptions, - 'isDefault' => $isDefault, - 'paperOrientationOptions' => $paperOrientationOptions, - 'paperSizeOptions' => $paperSizeOptions, - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * @throws BadRequestHttpException - * @throws ErrorException - * @throws Exception - * @throws NotSupportedException - * @throws ServerErrorHttpException - * @since 3.2 - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - - $pdfsService = Plugin::getInstance()->getPdfs(); - $pdfId = $this->request->getBodyParam('id'); - $storeId = $this->request->getBodyParam('storeId'); - - if ($pdfId) { - $pdf = $pdfsService->getPdfById($pdfId, $storeId); - if (!$pdf) { - throw new BadRequestHttpException("Invalid PDF ID: $pdfId"); - } - } else { - $pdf = new Pdf(); - } - - // Shared attributes - $pdf->storeId = $storeId; - $pdf->name = $this->request->getBodyParam('name'); - $pdf->handle = $this->request->getBodyParam('handle'); - $pdf->description = $this->request->getBodyParam('description'); - $pdf->templatePath = $this->request->getBodyParam('templatePath'); - $pdf->fileNameFormat = $this->request->getBodyParam('fileNameFormat'); - $pdf->enabled = $this->request->getBodyParam('enabled'); - $pdf->isDefault = $this->request->getBodyParam('isDefault'); - $pdf->language = $this->request->getBodyParam('language'); - $pdf->linkExpiry = (int)$this->request->getBodyParam('linkExpiry'); - $pdf->paperSize = $this->request->getBodyParam('paperSize'); - $pdf->paperOrientation = $this->request->getBodyParam('paperOrientation'); - - // Save it - if ($pdfsService->savePdf($pdf)) { - $this->setSuccessFlash(Craft::t('commerce', 'PDF saved.')); - return $this->redirectToPostedUrl($pdf); - } else { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save PDF.')); - } - - // Send the model back to the template - Craft::$app->getUrlManager()->setRouteParams(['pdf' => $pdf]); - - return null; - } - - /** - * @throws HttpException - * @since 3.2 - */ - public function actionDelete(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $id = $this->request->getRequiredBodyParam('id'); - - Plugin::getInstance()->getPdfs()->deletePdfById($id); - return $this->asSuccess(); - } - - /** - * @throws \yii\db\Exception - * @throws BadRequestHttpException - * @since 3.2 - */ - public function actionReorder(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - $ids = Json::decode($this->request->getRequiredBodyParam('ids')); - - if (!Plugin::getInstance()->getPdfs()->reorderPdfs($ids)) { - return $this->asFailure(Craft::t('commerce', 'Couldn’t reorder PDFs.')); - } - - return $this->asSuccess(); - } -} diff --git a/src/controllers/PlansController.php b/src/controllers/PlansController.php deleted file mode 100644 index a3a6398257..0000000000 --- a/src/controllers/PlansController.php +++ /dev/null @@ -1,252 +0,0 @@ - - * @since 2.0 - */ -class PlansController extends BaseCpController -{ - /** - * @return Response - * @throws InvalidConfigException - */ - public function actionPlanIndex(): Response - { - $plans = Plugin::getInstance()->getPlans()->getAllPlans(); - - return $this->asCpScreen() - ->title(Craft::t('commerce', 'Subscription plans')) - ->crumbs([ - ['label' => Craft::t('commerce', 'Commerce'), 'url' => 'commerce'], - ]) - ->redirectUrl('commerce/subscription-plans') - ->selectedSubnavItem('subscription-plans') - ->additionalButtonsHtml(Html::a( - Craft::t('commerce', 'New subscription plan'), - 'commerce/subscription-plans/new', - ['class' => 'submit btn add icon'] - )) - ->contentTemplate('commerce/subscriptions/plans/index.twig', compact('plans')); - } - - /** - * @param int|null $planId - * @param Plan|null $plan - * @return Response - * @throws HttpException - * @throws InvalidConfigException - * @throws DeprecationException - * @throws ForbiddenHttpException - */ - public function actionEditPlan(int $planId = null, Plan $plan = null): Response - { - $this->requirePermission('commerce-manageSubscriptions'); - - $variables = compact('planId', 'plan'); - - $variables['brandNewPlan'] = false; - - if (empty($variables['plan'])) { - if (!empty($variables['planId'])) { - $planId = $variables['planId']; - try { - $variables['plan'] = Plugin::getInstance()->getPlans()->getPlanById($planId); - } catch (InvalidConfigException) { - throw new HttpException(404); - } - - if (!$variables['plan']) { - throw new HttpException(404); - } - } else { - $variables['brandNewPlan'] = true; - } - } - - if (!empty($variables['planId'])) { - $variables['title'] = $variables['plan']->name; - DebugPanel::prependOrAppendModelTab(model: $variables['plan'], prepend: true); - } else { - $variables['title'] = Craft::t('commerce', 'Create a Subscription Plan'); - } - - - $variables['entryElementType'] = Entry::class; - - $gateways = Plugin::getInstance()->getGateways()->getAllSubscriptionGateways(); - $variables['supportedGateways'] = $gateways; - $variables['gatewayOptions'] = [['value' => '', 'label' => '-']]; - - foreach ($gateways as $gateway) { - $variables['gatewayOptions'][] = ['value' => $gateway->id, 'label' => $gateway->name]; - } - - $sidebar = Html::beginTag('div', ['class' => 'meta']) . - Cp::lightswitchFieldHtml([ - 'label' => Craft::t('commerce', 'Enabled for customers to select?'), - 'name' => 'enabled', - 'on' => $variables['plan']?->enabled ?? false, - 'errors' => $variables['plan']?->getErrors('enabled') ?? null, - ]) . - Html::endTag('div'); - - if ($variables['plan']?->id) { - $dateCreated = $variables['plan']->dateCreated; - $dateUpdated = $variables['plan']->dateUpdated; - $sidebar .= Html::beginTag('div', ['class' => 'meta read-only']); - if ($dateCreated) { - $sidebar .= - Html::beginTag('div', ['class' => 'data', 'attribute' => 'dateCreated']) . - Html::tag('h5', Craft::t('app', 'Created at'), ['class' => 'heading']) . - Html::tag('div', Craft::$app->getFormatter()->asDate($dateCreated, Locale::LENGTH_SHORT), ['class' => 'value', 'id' => 'date-created-value']) . - Html::endTag('div'); - } - - if ($dateUpdated) { - $sidebar .= - Html::beginTag('div', ['class' => 'data', 'attribute' => 'dateUpdated']) . - Html::tag('h5', Craft::t('app', 'Updated at'), ['class' => 'heading']) . - Html::tag('div', Craft::$app->getFormatter()->asDate($dateUpdated, Locale::LENGTH_SHORT), ['class' => 'value', 'id' => 'date-updated-value']) . - Html::endTag('div'); - } - $sidebar .= Html::endTag('div'); - } - - return $this->asCpScreen() - ->title($variables['title']) - ->selectedSubnavItem('subscription-plans') - ->addCrumb(Craft::t('commerce', 'Commerce'), 'commerce') - ->addCrumb(Craft::t('commerce', 'Subscription Plans'), 'commerce/subscription-plans') - ->contentTemplate('commerce/subscriptions/plans/_edit.twig', $variables) - ->action('commerce/plans/save-plan') - ->redirectUrl('commerce/subscription-plans') - ->metaSidebarHtml($sidebar); - } - - /** - * @throws Exception - * @throws HttpException if request does not match requirements - * @throws InvalidConfigException if gateway does not support subscriptions - * @throws BadRequestHttpException - */ - public function actionSavePlan(): void - { - $this->requirePermission('commerce-manageSubscriptions'); - - $this->requirePostRequest(); - - $gatewayId = $this->request->getBodyParam('gatewayId'); - $reference = $this->request->getBodyParam("gateway.$gatewayId.reference", ''); - - $gateway = Plugin::getInstance()->getGateways()->getGatewayById($gatewayId); - - if ($gateway instanceof SubscriptionGateway) { - $planData = $gateway->getSubscriptionPlanByReference($reference); - } else { - throw new InvalidConfigException('This gateway does not support subscription plans.'); - } - - $planInformationIds = $this->request->getBodyParam('planInformation'); - - $planService = Plugin::getInstance()->getPlans(); - $planId = $this->request->getParam('planId'); - - $plan = null; - if ($planId) { - $plan = $planService->getPlanById($planId); - } - - if ($plan === null) { - $plan = $gateway->getPlanModel(); - } - - // Shared attributes - $plan->id = $planId; - $plan->gatewayId = $gatewayId; - $plan->name = $this->request->getParam('name'); - $plan->handle = $this->request->getParam('handle'); - $plan->planInformationId = is_array($planInformationIds) ? reset($planInformationIds) : null; - $plan->reference = $reference; - $plan->enabled = (bool)$this->request->getParam('enabled'); - $plan->planData = $planData; - $plan->isArchived = false; - - // Save $plan - if ($planService->savePlan($plan)) { - $this->setSuccessFlash(Craft::t('commerce', 'Subscription plan saved.')); - $this->redirectToPostedUrl($plan); - } else { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save subscription plan.')); - } - - // Send the productType back to the template - Craft::$app->getUrlManager()->setRouteParams([ - 'plan' => $plan, - ]); - } - - /** - * @throws HttpException if request does not match requirements - */ - public function actionArchivePlan(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $this->requirePermission('commerce-manageSubscriptions'); - - $planId = $this->request->getRequiredBodyParam('id'); - - try { - Plugin::getInstance()->getPlans()->archivePlanById($planId); - } catch (Exception $exception) { - return $this->asFailure($exception->getMessage()); - } - - return $this->asSuccess(); - } - - /** - * @throws HttpException - */ - public function actionReorder(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - $ids = Json::decode($this->request->getRequiredBodyParam('ids')); - - $success = Plugin::getInstance()->getPlans()->reorderPlans($ids); - - return $success ? - $this->asSuccess() : - $this->asFailure(Craft::t('commerce', 'Couldn’t reorder plans.')); - } -} diff --git a/src/controllers/ProductTypesController.php b/src/controllers/ProductTypesController.php deleted file mode 100644 index 6f8700f4f9..0000000000 --- a/src/controllers/ProductTypesController.php +++ /dev/null @@ -1,239 +0,0 @@ - - * @since 2.0 - */ -class ProductTypesController extends BaseAdminController -{ - public function actionProductTypeIndex(): Response - { - $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); - return $this->renderTemplate('commerce/settings/producttypes/index',[ - 'productTypes' => $productTypes, - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * @param int|null $productTypeId - * @param ProductType|null $productType - * @throws HttpException - */ - public function actionEditProductType(int $productTypeId = null, ProductType $productType = null): Response - { - $variables = compact('productTypeId', 'productType'); - - $variables['brandNewProductType'] = false; - - if (empty($variables['productType'])) { - if (!empty($variables['productTypeId'])) { - $productTypeId = $variables['productTypeId']; - $variables['productType'] = Plugin::getInstance()->getProductTypes()->getProductTypeById($productTypeId); - - if (!$variables['productType']) { - throw new HttpException(404); - } - } else { - $variables['productType'] = new ProductType(); - $variables['brandNewProductType'] = true; - } - } - - if (!empty($variables['productTypeId'])) { - $variables['title'] = $variables['productType']->name; - } else { - $variables['title'] = Craft::t('commerce', 'Create a new product type'); - } - - DebugPanel::prependOrAppendModelTab(model: $variables['productType'], prepend: true); - - $variables['selectedTab'] = 'productTypeSettings'; - - $this->getView()->registerAssetBundle(EditSectionAsset::class); - - $variables['readOnly'] = $this->isReadOnlyScreen(); - - return $this->asCpScreen() - ->title($variables['title']) - ->crumbs([ - ['label' => Craft::t('commerce', 'Commerce'), 'url' => 'commerce'], - ['label' => Craft::t('app', 'Settings'), 'url' => 'commerce/settings', 'ariaLabel' => Craft::t('commerce', 'Commerce Settings')], - ['label' => Craft::t('commerce', 'Product Types'), 'url' => 'commerce/settings/producttypes'], - ]) - ->tabs([ - 'productTypeSettings' => [ - 'label' => Craft::t('commerce', 'Settings'), - 'url' => '#product-type-settings', - ], - 'taxAndShipping' => [ - 'label' => Craft::t('commerce', 'Tax & Shipping'), - 'url' => '#tax-and-shipping', - ], - 'productFields' => [ - 'label' => Craft::t('commerce', 'Product Fields'), - 'url' => '#product-fields', - ], - 'variantFields' => [ - 'label' => Craft::t('commerce', 'Variant Fields'), - 'url' => '#variant-fields', - ], - ]) - ->selectedSubnavItem('settings') - ->action('commerce/product-types/save-product-type') - ->submitButtonLabel(Craft::t('app', 'Save')) - ->redirectUrl('commerce/settings/producttypes') - ->contentTemplate('commerce/settings/producttypes/_edit', $variables); - } - - /** - * @throws HttpException - * @throws Throwable - * @throws BadRequestHttpException - */ - public function actionSaveProductType(): ?Response - { - $currentUser = Craft::$app->getUser()->getIdentity(); - - if (!$currentUser->can('manageCommerce')) { - throw new HttpException(403, Craft::t('commerce', 'This action is not allowed for the current user.')); - } - - $this->requirePostRequest(); - $productTypeId = $this->request->getBodyParam('productTypeId'); - - if ($productTypeId) { - $productType = Plugin::getInstance()->getProductTypes()->getProductTypeById($productTypeId); - - if (!$productType) { - throw new BadRequestHttpException("Invalid section ID: $productTypeId"); - } - } else { - $productType = new ProductType(); - } - - // Shared attributes - $productType->id = $this->request->getBodyParam('productTypeId'); - $productType->name = $this->request->getBodyParam('name'); - $productType->handle = $this->request->getBodyParam('handle'); - $productType->enableVersioning = $this->request->getBodyParam('enableVersioning') ?? $productType->enableVersioning; - $productType->hasDimensions = (bool)$this->request->getBodyParam('hasDimensions'); - $productType->hasProductTitleField = (bool)$this->request->getBodyParam('hasProductTitleField'); - $productType->productTitleFormat = $this->request->getBodyParam('productTitleFormat'); - $productType->productUiLabelFormat = $this->request->getBodyParam('productUiLabelFormat'); - $productType->productTitleTranslationMethod = $this->request->getBodyParam('productTitleTranslationMethod', $productType->productTitleTranslationMethod); - $productType->productTitleTranslationKeyFormat = $this->request->getBodyParam('productTitleTranslationKeyFormat', $productType->productTitleTranslationKeyFormat); - $productType->showSlugField = (bool)$this->request->getBodyParam('showSlugField', $productType->showSlugField); - $productType->slugTranslationMethod = $this->request->getBodyParam('slugTranslationMethod', $productType->slugTranslationMethod); - $productType->slugTranslationKeyFormat = $this->request->getBodyParam('slugTranslationKeyFormat', $productType->slugTranslationKeyFormat); - $productType->maxVariants = $this->request->getBodyParam('maxVariants') ?: null; - $productType->hasVariantTitleField = $this->request->getBodyParam('hasVariantTitleField', false); - $productType->variantTitleFormat = $this->request->getBodyParam('variantTitleFormat'); - $productType->variantUiLabelFormat = $this->request->getBodyParam('variantUiLabelFormat'); - $productType->variantTitleTranslationMethod = $this->request->getBodyParam('variantTitleTranslationMethod', $productType->variantTitleTranslationMethod); - $productType->variantTitleTranslationKeyFormat = $this->request->getBodyParam('variantTitleTranslationKeyFormat', $productType->variantTitleTranslationKeyFormat); - $productType->skuFormat = $this->request->getBodyParam('skuFormat'); - $productType->descriptionFormat = $this->request->getBodyParam('descriptionFormat'); - $productType->propagationMethod = PropagationMethod::tryFrom($this->request->getBodyParam('propagationMethod') ?? '') ?? PropagationMethod::All; - $productType->isStructure = $this->request->getBodyParam('isStructure'); - $maxLevels = (int)$this->request->getBodyParam('maxLevels'); - $productType->maxLevels = $maxLevels ?: null; // zero should be null - $productType->defaultPlacement = $this->request->getBodyParam('defaultPlacement'); - $productType->previewTargets = $this->request->getBodyParam('previewTargets') ?: []; - - // Site-specific settings - $allSiteSettings = []; - - foreach (Craft::$app->getSites()->getAllSites() as $site) { - $postedSettings = $this->request->getBodyParam('sites.' . $site->handle); - - // Skip disabled sites if this is a multi-site install - if (Craft::$app->getIsMultiSite() && empty($postedSettings['enabled'])) { - continue; - } - - $siteSettings = new ProductTypeSite(); - $siteSettings->siteId = $site->id; - $siteSettings->hasUrls = !empty($postedSettings['uriFormat']); - - $siteSettings->enabledByDefault = (bool)$postedSettings['enabledByDefault']; - - if ($siteSettings->hasUrls) { - $siteSettings->uriFormat = $postedSettings['uriFormat']; - $siteSettings->template = $postedSettings['template']; - } else { - $siteSettings->uriFormat = null; - $siteSettings->template = null; - } - - $allSiteSettings[$site->id] = $siteSettings; - } - - $productType->setSiteSettings($allSiteSettings); - - // Set the product type field layout - $fieldLayout = Craft::$app->getFields()->assembleLayoutFromPost(); - $fieldLayout->type = Product::class; - /** @var FieldLayoutBehavior $behavior */ - $behavior = $productType->getBehavior('productFieldLayout'); - $behavior->setFieldLayout($fieldLayout); - - // Set the variant field layout - $variantFieldLayout = Craft::$app->getFields()->assembleLayoutFromPost('variant-layout'); - $variantFieldLayout->type = Variant::class; - /** @var FieldLayoutBehavior $behavior */ - $behavior = $productType->getBehavior('variantFieldLayout'); - $behavior->setFieldLayout($variantFieldLayout); - - // Save it - if (Plugin::getInstance()->getProductTypes()->saveProductType($productType)) { - return $this->asSuccess(Craft::t('commerce', 'Product type saved.'), redirect: $this->getPostedRedirectUrl($productType)); - } - - // Send the productType back to the template - Craft::$app->getUrlManager()->setRouteParams([ - 'productType' => $productType, - ]); - - return $this->asModelFailure($productType, Craft::t('commerce', 'Couldn’t save product type.'), 'productType'); - } - - /** - * @throws Throwable - * @throws BadRequestHttpException - */ - public function actionDeleteProductType(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $productTypeId = $this->request->getRequiredBodyParam('id'); - - Plugin::getInstance()->getProductTypes()->deleteProductTypeById($productTypeId); - return $this->asSuccess(); - } -} diff --git a/src/controllers/ProductsController.php b/src/controllers/ProductsController.php deleted file mode 100644 index d682cae598..0000000000 --- a/src/controllers/ProductsController.php +++ /dev/null @@ -1,187 +0,0 @@ - - * @since 2.0 - */ -class ProductsController extends BaseCpController -{ - /** - * @inheritdoc - * @throws ForbiddenHttpException - */ - public function init(): void - { - parent::init(); - - if (empty(Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(true))) { - throw new ForbiddenHttpException('User is not permitted to view any product types.'); - } - } - - /** - * @throws InvalidConfigException - */ - public function actionProductIndex(?string $productTypeHandle = null): Response - { - $this->getView()->registerAssetBundle(ProductIndexAsset::class); - return $this->renderTemplate('commerce/products/_index', [ - 'productTypeHandle' => $productTypeHandle, - ]); - } - - public function actionCreate(?string $productType = null) - { - if ($productType) { - $productTypeHandle = $productType; - } else { - $productTypeHandle = $this->request->getRequiredBodyParam('productType'); - } - - $productType = Plugin::getInstance()->getProductTypes()->getProductTypeByHandle($productTypeHandle); - if (!$productType) { - throw new BadRequestHttpException("Invalid product type handle: $productTypeHandle"); - } - - $sitesService = Craft::$app->getSites(); - $siteId = $this->request->getBodyParam('siteId'); - - if ($siteId) { - $site = $sitesService->getSiteById($siteId); - if (!$site) { - throw new BadRequestHttpException("Invalid site ID: $siteId"); - } - } else { - $site = Cp::requestedSite(); - if (!$site) { - throw new ForbiddenHttpException('User not authorized to edit content in any sites.'); - } - } - - $editableSiteIds = $sitesService->getEditableSiteIds(); - if (!in_array($site->id, $editableSiteIds)) { - // Go with the first one - $site = $sitesService->getSiteById($editableSiteIds[0]); - } - - $user = static::currentUser(); - - // Create & populate the draft - $product = Craft::createObject(Product::class); - $product->siteId = $site->id; - $product->typeId = $productType->id; - $product->enabled = true; - - // Structure parent - if ( - $productType->isStructure && - (int)$productType->maxLevels !== 1 - ) { - // Set the initially selected parent - $product->setParentId($this->request->getParam('parentId')); - } - - // Make sure the user is allowed to create this entry - if (!Craft::$app->getElements()->canSave($product, $user)) { - throw new ForbiddenHttpException('User not authorized to create this product.'); - } - - // Title & slug - $product->title = $this->request->getParam('title'); - $product->slug = $this->request->getParam('slug'); - if ($product->title && !$product->slug) { - $product->slug = ElementHelper::generateSlug($product->title, null, $site->language); - } - if (!$product->slug) { - $product->slug = ElementHelper::tempSlug(); - } - - // Pause time so postDate will definitely be equal to dateCreated, if not explicitly defined - DateTimeHelper::pause(); - - // Post & expiry dates - if (($postDate = $this->request->getParam('postDate')) !== null) { - $product->postDate = DateTimeHelper::toDateTime($postDate); - } else { - $product->postDate = DateTimeHelper::now(); - } - - if (($expiryDate = $this->request->getParam('expiryDate')) !== null) { - $product->expiryDate = DateTimeHelper::toDateTime($expiryDate); - } - - // Custom fields - foreach ($product->getFieldLayout()->getCustomFields() as $field) { - if (($value = $this->request->getParam($field->handle)) !== null) { - $product->setFieldValue($field->handle, $value); - } - } - - // Save it - $product->setScenario(Element::SCENARIO_ESSENTIALS); - $success = Craft::$app->getDrafts()->saveElementAsDraft($product, $user->id, markAsSaved: false); - - // Resume time - DateTimeHelper::resume(); - - if (!$success) { - return $this->asModelFailure($product, Craft::t('app', 'Couldn’t create {type}.', [ - 'type' => Product::lowerDisplayName(), - ]), 'product'); - } - - // Set its position in the structure if a before/after param was passed - if ($productType->isStructure) { - if ($nextId = $this->request->getParam('before')) { - $nextEntry = Plugin::getInstance()->getProducts()->getProductById($nextId, $site->id, [ - 'structureId' => $productType->structureId, - ]); - Craft::$app->getStructures()->moveBefore($productType->structureId, $product, $nextEntry); - } elseif ($prevId = $this->request->getParam('after')) { - $prevEntry = Plugin::getInstance()->getProducts()->getProductById($prevId, $site->id, [ - 'structureId' => $productType->structureId, - ]); - Craft::$app->getStructures()->moveAfter($productType->structureId, $product, $prevEntry); - } - } - - $editUrl = $product->getCpEditUrl(); - - $response = $this->asModelSuccess($product, Craft::t('app', '{type} created.', [ - 'type' => Product::displayName(), - ]), 'product', array_filter([ - 'cpEditUrl' => $this->request->getIsCpRequest() ? $editUrl : null, - ])); - - if (!$this->request->getAcceptsJson()) { - $response->redirect(UrlHelper::urlWithParams($editUrl, [ - 'fresh' => 1, - ])); - } - - return $response; - } -} diff --git a/src/controllers/PromotionsController.php b/src/controllers/PromotionsController.php deleted file mode 100644 index e78e1ed458..0000000000 --- a/src/controllers/PromotionsController.php +++ /dev/null @@ -1,22 +0,0 @@ - - * @since 2.0 - */ -class PromotionsController extends BaseCpController -{ - public function actionIndex(): void - { - $this->redirect('commerce/promotions/sales'); - } -} diff --git a/src/controllers/SalesController.php b/src/controllers/SalesController.php deleted file mode 100644 index 49df5538f2..0000000000 --- a/src/controllers/SalesController.php +++ /dev/null @@ -1,564 +0,0 @@ - - * @since 2.0 - */ -class SalesController extends BaseStoreManagementController -{ - public function beforeAction($action): bool - { - if (!parent::beforeAction($action)) { - return false; - } - - $this->requirePermission('commerce-managePromotions'); - - if (!Plugin::getInstance()->getSales()->canUseSales()) { - throw new ForbiddenHttpException('Unable to use sales while using multi store or pricing rules.'); - } - - return true; - } - - /** - * @throws InvalidConfigException - */ - public function actionIndex(?string $storeHandle = null): Response - { - $sales = Plugin::getInstance()->getSales()->getAllSales(); - if (empty($sales)) { - return $this->redirect('commerce/store-management/' . $storeHandle . '/pricing-rules'); - } - - return $this->renderTemplate('commerce/promotions/sales/index', compact('sales')); - } - - /** - * @param int|null $id - * @param Sale|null $sale - * @throws HttpException - * @throws InvalidConfigException - */ - public function actionEdit(int $id = null, Sale $sale = null, ?string $storeHandle = null): Response - { - if ($id === null) { - $this->requirePermission('commerce-createSales'); - } else { - $this->requirePermission('commerce-editSales'); - } - - $variables = compact('id', 'sale'); - - if ($storeHandle) { - $store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle); - if ($store === null) { - throw new InvalidConfigException('Invalid store.'); - } - } else { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - $variables['storeHandle'] = $store->handle; - - $variables['isNewSale'] = false; - - if (!$variables['sale']) { - if ($variables['id']) { - $variables['sale'] = Plugin::getInstance()->getSales()->getSaleById($variables['id']); - - if (!$variables['sale']) { - throw new HttpException(404); - } - } else { - $variables['sale'] = new Sale(); - $variables['isNewSale'] = true; - $variables['sale']->allCategories = true; - $variables['sale']->allPurchasables = true; - $variables['sale']->allGroups = true; - } - } - - DebugPanel::prependOrAppendModelTab(model: $variables['sale'], prepend: true); - - $this->_populateVariables($variables); - - return $this->renderTemplate('commerce/promotions/sales/_edit', $variables); - } - - /** - * @throws Exception - * @throws \yii\base\Exception - * @throws BadRequestHttpException - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - - $sale = new Sale(); - - // Shared attributes - if ($sale->id === null) { - $this->requirePermission('commerce-createSales'); - } else { - $this->requirePermission('commerce-editSales'); - } - - $sale->id = $this->request->getBodyParam('id'); - $sale->name = $this->request->getBodyParam('name'); - $sale->description = $this->request->getBodyParam('description'); - $sale->apply = $this->request->getBodyParam('apply'); - $sale->enabled = (bool)$this->request->getBodyParam('enabled'); - - $dateFields = [ - 'dateFrom', - 'dateTo', - ]; - foreach ($dateFields as $field) { - if (($date = $this->request->getBodyParam($field)) !== false) { - $sale->$field = DateTimeHelper::toDateTime($date) ?: null; - } else { - $sale->$field = $sale->$date; - } - } - - $applyAmount = $this->request->getBodyParam('applyAmount'); - $sale->sortOrder = (int)$this->request->getBodyParam('sortOrder'); - $sale->ignorePrevious = (bool)$this->request->getBodyParam('ignorePrevious'); - $sale->stopProcessing = (bool)$this->request->getBodyParam('stopProcessing'); - $sale->categoryRelationshipType = $this->request->getBodyParam('categoryRelationshipType', $sale->categoryRelationshipType); - - $applyAmount = Localization::normalizeNumber($applyAmount); - if ($sale->apply == SaleRecord::APPLY_BY_PERCENT || $sale->apply == SaleRecord::APPLY_TO_PERCENT) { - if ((float)$applyAmount >= 1) { - $sale->applyAmount = (float)$applyAmount / -100; - } else { - $sale->applyAmount = -(float)$applyAmount; - } - } else { - $sale->applyAmount = (float)$applyAmount * -1; - } - - // Set purchasable conditions - $allPurchasables = !$this->request->getBodyParam('allPurchasables', false); - if ($sale->allPurchasables = $allPurchasables) { - $sale->setPurchasableIds([]); - } else { - $purchasables = []; - $purchasableGroups = $this->request->getBodyParam('purchasables') ?: []; - foreach ($purchasableGroups as $group) { - if (is_array($group)) { - array_push($purchasables, ...$group); - } - } - $sale->setPurchasableIds($purchasables); - } - - // False in the allCategories param is true in the DB - $allCategories = !$this->request->getBodyParam('allCategories', false); - // Set category conditions - if ($sale->allCategories = $allCategories) { - $sale->setCategoryIds([]); - } else { - $relatedElements = []; - $relatedElementByType = $this->request->getBodyParam('relatedElements') ?: []; - foreach ($relatedElementByType as $type) { - if (is_array($type)) { - array_push($relatedElements, ...$type); - } - } - $relatedElements = array_unique($relatedElements); - $sale->setCategoryIds($relatedElements); - } - - // Set user group conditions - // Default value is `true` to catch projects that do not have user groups and therefore do not have this field - if ($sale->allGroups = (bool)$this->request->getBodyParam('allGroups', true)) { - $sale->setUserGroupIds([]); - } else { - $groups = $this->request->getBodyParam('groups', []); - if (!$groups) { - $groups = []; - } - $sale->setUserGroupIds($groups); - } - - // Save it - if (Plugin::getInstance()->getSales()->saveSale($sale)) { - $this->setSuccessFlash(Craft::t('commerce', 'Sale saved.')); - return $this->redirectToPostedUrl($sale); - } - - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save sale.')); - - $variables = [ - 'sale' => $sale, - ]; - $this->_populateVariables($variables); - - Craft::$app->getUrlManager()->setRouteParams($variables); - - return null; - } - - /** - * @throws BadRequestHttpException - */ - public function actionReorder(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $ids = Json::decode($this->request->getRequiredBodyParam('ids')); - if (!Plugin::getInstance()->getSales()->reorderSales($ids)) { - return $this->asFailure(Craft::t('commerce', 'Couldn’t reorder sales.')); - } - - return $this->asSuccess(); - } - - /** - * @throws Exception - * @throws Throwable - * @throws StaleObjectException - * @throws BadRequestHttpException - */ - public function actionDelete(): Response - { - $this->requirePermission('commerce-deleteSales'); - $this->requirePostRequest(); - - $id = $this->request->getBodyParam('id'); - $ids = $this->request->getBodyParam('ids'); - - if ((!$id && empty($ids)) || ($id && !empty($ids))) { - throw new BadRequestHttpException('id or ids must be specified.'); - } - - if ($id) { - $this->requireAcceptsJson(); - $ids = [$id]; - } - - foreach ($ids as $id) { - Plugin::getInstance()->getSales()->deleteSaleById($id); - } - - if ($this->request->getAcceptsJson()) { - return $this->asSuccess(); - } - - $this->setSuccessFlash(Craft::t('commerce', 'Sales deleted.')); - - return $this->redirect($this->request->getReferrer()); - } - - /** - * @throws BadRequestHttpException - */ - public function actionGetAllSales(): Response - { - $this->requireAcceptsJson(); - $sales = Plugin::getInstance()->getSales()->getAllSales(); - - return $this->asJson(array_values($sales)); - } - - /** - * @throws BadRequestHttpException - * @throws InvalidConfigException - */ - public function actionGetSalesByProductId(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - $id = $this->request->getParam('id'); - - if (!$id) { - return $this->asFailure(Craft::t('commerce', 'Product ID is required.')); - } - - $product = Plugin::getInstance()->getProducts()->getProductById($id); - - if (!$product) { - return $this->asFailure(Craft::t('commerce', 'No product available.')); - } - - $sales = []; - foreach ($product->getVariants(true) as $variant) { - $variantSales = Plugin::getInstance()->getSales()->getSalesRelatedToPurchasable($variant); - foreach ($variantSales as $sale) { - if (!ArrayHelper::firstWhere($sales, 'id', $sale->id)) { - /** @var Sale $sale */ - $saleArray = $sale->toArray(); - $saleArray['cpEditUrl'] = $sale->getCpEditUrl(); - $sales[] = $saleArray; - } - } - } - - return $this->asSuccess(data: [ - 'sales' => $sales, - ]); - } - - /** - * @throws BadRequestHttpException - * @throws InvalidConfigException - */ - public function actionGetSalesByPurchasableId(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - $id = $this->request->getParam('id'); - - if (!$id) { - return $this->asFailure(Craft::t('commerce', 'Purchasable ID is required.')); - } - - $purchasable = Plugin::getInstance()->getPurchasables()->getPurchasableById($id); - - if (!$purchasable) { - return $this->asFailure(Craft::t('commerce', 'No purchasable available.')); - } - - $sales = []; - $purchasableSales = Plugin::getInstance()->getSales()->getSalesRelatedToPurchasable($purchasable); - foreach ($purchasableSales as $sale) { - if (!ArrayHelper::firstWhere($sales, 'id', $sale->id)) { - /** @var Sale $sale */ - $saleArray = $sale->toArray(); - $saleArray['cpEditUrl'] = $sale->getCpEditUrl(); - $sales[] = $saleArray; - } - } - - return $this->asSuccess(data: [ - 'sales' => $sales, - ]); - } - - /** - * @throws BadRequestHttpException - * @throws InvalidConfigException - * @throws \yii\base\Exception - */ - public function actionAddPurchasableToSale(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - $ids = $this->request->getParam('ids', []); - $saleId = $this->request->getParam('saleId'); - - if (empty($ids) || !$saleId) { - return $this->asFailure(Craft::t('commerce', 'Purchasable ID and Sale ID are required.')); - } - - $purchasables = []; - foreach ($ids as $id) { - $purchasables[] = Plugin::getInstance()->getPurchasables()->getPurchasableById($id); - } - - $sale = Plugin::getInstance()->getSales()->getSaleById($saleId); - - if (empty($purchasables) || count($purchasables) != count($ids) || !$sale) { - return $this->asFailure(Craft::t('commerce', 'Unable to retrieve Sale and Purchasable.')); - } - - $salePurchasableIds = $sale->getPurchasableIds(); - - array_push($salePurchasableIds, ...$ids); - if (!empty($salePurchasableIds)) { - $sale->allPurchasables = false; - } - $sale->setPurchasableIds(array_unique($salePurchasableIds)); - - if (!Plugin::getInstance()->getSales()->saveSale($sale)) { - return $this->asFailure(Craft::t('commerce', 'Couldn’t save sale.')); - } - - return $this->asSuccess(); - } - - /** - * @throws BadRequestHttpException - * @throws \yii\db\Exception - * @throws ForbiddenHttpException - * @since 3.0 - */ - public function actionUpdateStatus(): void - { - $this->requirePostRequest(); - $this->requirePermission('commerce-editSales'); - - $ids = $this->request->getRequiredBodyParam('ids'); - $status = $this->request->getRequiredBodyParam('status'); - - - if (empty($ids)) { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t updated sales status.')); - } - - $transaction = Craft::$app->getDb()->beginTransaction(); - $sales = SaleRecord::find() - ->where(['id' => $ids]) - ->all(); - - /** @var SaleRecord $sale */ - foreach ($sales as $sale) { - $sale->enabled = ($status == 'enabled'); - $sale->save(); - } - $transaction->commit(); - - $this->setSuccessFlash(Craft::t('commerce', 'Sales updated.')); - } - - - /** - * @param $variables - * @throws InvalidConfigException - */ - private function _populateVariables(&$variables): void - { - /** @var Sale $sale */ - $sale = $variables['sale']; - - if ($sale->id) { - $variables['title'] = $sale->name; - } else { - $variables['title'] = Craft::t('commerce', 'Create a new sale'); - } - - //getting user groups map - if (Craft::$app->getEdition() == Craft::Pro) { - $groups = Craft::$app->getUserGroups()->getAllGroups(); - $variables['groups'] = ArrayHelper::map($groups, 'id', 'name'); - } else { - $variables['groups'] = []; - } - - $variables['percentSymbol'] = Craft::$app->getFormattingLocale()->getNumberSymbol(Locale::SYMBOL_PERCENT); - $primaryCurrencyIso = Plugin::getInstance()->getPaymentCurrencies()->getPrimaryPaymentCurrencyIso(); - $variables['currencySymbol'] = Craft::$app->getLocale()->getCurrencySymbol($primaryCurrencyIso); - - $variables['saleApplyAmount'] = ''; - if (isset($variables['sale']->applyAmount) && $variables['sale']->applyAmount !== null) { - if ($sale->apply == SaleRecord::APPLY_BY_PERCENT || $sale->apply == SaleRecord::APPLY_TO_PERCENT) { - $amount = -(float)$variables['sale']->applyAmount * 100; - $variables['saleApplyAmount'] = Craft::$app->getFormatter()->asDecimal($amount); - } else { - $variables['saleApplyAmount'] = Craft::$app->getFormatter()->asDecimal(-(float)$variables['sale']->applyAmount); - } - } - - $variables['categoryElementType'] = Category::class; - $variables['entryElementType'] = Entry::class; - $variables['categories'] = null; - $variables['entries'] = null; - - $categories = []; - $entries = []; - - if (empty($variables['id']) && $this->request->getParam('categoryIds')) { - $categoryIds = explode('|', $this->request->getParam('categoryIds')); - } else { - $categoryIds = $sale->getCategoryIds(); - } - - foreach ($categoryIds as $categoryId) { - $id = (int)$categoryId; - $element = Craft::$app->getElements()->getElementById($id); - - if ($element instanceof Category) { - $categories[] = $element; - } elseif ($element instanceof Entry) { - $entries[] = $element; - } - } - - $variables['categories'] = $categories; - $variables['entries'] = $entries; - - $variables['elementRelationshipTypeOptions'] = [ - SaleRecord::CATEGORY_RELATIONSHIP_TYPE_SOURCE => Craft::t('commerce', 'The purchasable defines the relationship'), - SaleRecord::CATEGORY_RELATIONSHIP_TYPE_TARGET => Craft::t('commerce', 'The purchasable is related by another element'), - SaleRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH => Craft::t('commerce', 'Either way'), - ]; - - $variables['purchasables'] = null; - $purchasables = []; - - if (empty($variables['id']) && $this->request->getParam('purchasableIds')) { - $purchasableIdsFromUrl = explode('|', $this->request->getParam('purchasableIds')); - $purchasableIds = []; - foreach ($purchasableIdsFromUrl as $purchasableId) { - $purchasable = Craft::$app->getElements()->getElementById((int)$purchasableId); - if ($purchasable instanceof Product) { - foreach ($purchasable->getVariants(true) as $variant) { - $purchasableIds[] = $variant->getId(); - } - } else { - $purchasableIds[] = $purchasableId; - } - } - $variables['sale']->allPurchasables = false; - } else { - $purchasableIds = $sale->getPurchasableIds(); - } - - foreach ($purchasableIds as $purchasableId) { - $purchasable = Craft::$app->getElements()->getElementById((int)$purchasableId); - if ($purchasable instanceof PurchasableInterface) { - $class = $purchasable::class; - $purchasables[$class] ??= []; - $purchasables[$class][] = $purchasable; - } - } - $variables['purchasables'] = $purchasables; - - $variables['purchasableTypes'] = []; - $purchasableTypes = Plugin::getInstance()->getPurchasables()->getAllPurchasableElementTypes(); - - /** @var Purchasable $purchasableType */ - foreach ($purchasableTypes as $purchasableType) { - $variables['purchasableTypes'][] = [ - 'name' => $purchasableType::displayName(), - 'elementType' => $purchasableType, - ]; - } - } -} diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php deleted file mode 100644 index 39ca0accbc..0000000000 --- a/src/controllers/SettingsController.php +++ /dev/null @@ -1,200 +0,0 @@ - - * @since 2.0 - */ -class SettingsController extends BaseAdminController -{ - /** - * Commerce Settings Form - */ - public function actionEdit(): Response - { - $readOnly = $this->isReadOnlyScreen(); - return $this->renderTemplate('commerce/settings/general', [ - 'settings' => Plugin::getInstance()->getSettings(), - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * @throws InvalidConfigException - * @throws BadRequestHttpException - */ - public function actionSaveSettings(): ?Response - { - $this->requirePostRequest(); - $plugin = Plugin::getInstance(); - $settings = $this->request->getBodyParam('settings'); - $pluginSettingsSaved = Craft::$app->getPlugins()->savePluginSettings($plugin, $settings); - - if (!$pluginSettingsSaved) { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save settings.')); - return $this->renderTemplate('commerce/settings/general/index', ['settings' => $plugin->getSettings()]); - } - - $this->setSuccessFlash(Craft::t('commerce', 'Settings saved.')); - - return $this->redirectToPostedUrl(); - } - - /** - * @return Response - * @throws InvalidConfigException - */ - public function actionSites(): Response - { - $sites = Craft::$app->getSites()->getAllSites(); - - return $this->renderTemplate('commerce/settings/sites/_edit', [ - 'sites' => $sites, - 'primaryStoreId' => Plugin::getInstance()->getStores()->getPrimaryStore()->id, - 'stores' => Plugin::getInstance()->getStores()->getAllStores(), - 'storesList' => Plugin::getInstance()->getStores()->getAllStores()->map(fn($store) => [ - 'label' => $store->name . ($store->primary ? ' (' . Craft::t('commerce', 'Primary') . ')' : ''), - 'value' => $store->id, - ]), - ]); - } - - /** - * @return Response - */ - public function actionSaveTransferSettings(): Response - { - $this->requirePostRequest(); - - $fieldLayout = Craft::$app->getFields()->assembleLayoutFromPost(); - - $fieldLayout->reservedFieldHandles = [ - 'originLocationId', - 'originLocation', - 'destinationLocationId', - 'destinationLocation', - ]; - - $fieldLayout->type = Transfer::class; - - if (!$fieldLayout->validate()) { - Craft::info('Field layout not saved due to validation error.', __METHOD__); - - Craft::$app->getUrlManager()->setRouteParams([ - 'variables' => [ - 'fieldLayout' => $fieldLayout, - ], - ]); - - return $this->asFailure(Craft::t('commerce', 'Couldn’t save transfer fields.')); - } - - if ($currentTransfersFieldLayout = Craft::$app->getProjectConfig()->get(Transfers::CONFIG_FIELDLAYOUT_KEY)) { - $uid = array_key_first($currentTransfersFieldLayout); - } else { - $uid = StringHelper::UUID(); - } - - $configData = [$uid => $fieldLayout->getConfig()]; - $result = Craft::$app->getProjectConfig()->set(Transfers::CONFIG_FIELDLAYOUT_KEY, $configData, force: true); - - if (!$result) { - return $this->asFailure(Craft::t('app', 'Couldn’t save transfer fields.')); - } - - return $this->asSuccess(Craft::t('commerce', 'Transfer fields saved.')); - } - - /** - * @return Response - */ - public function actionSaveSubscriptionSettings(): Response - { - $this->requirePostRequest(); - - $fieldLayout = Craft::$app->getFields()->assembleLayoutFromPost(); - - $fieldLayout->reservedFieldHandles = [ - ]; - - $fieldLayout->type = Subscription::class; - - if (!$fieldLayout->validate()) { - Craft::info('Field layout not saved due to validation error.', __METHOD__); - - Craft::$app->getUrlManager()->setRouteParams([ - 'variables' => [ - 'fieldLayout' => $fieldLayout, - ], - ]); - - return $this->asFailure(Craft::t('commerce', 'Couldn’t save subscription fields.')); - } - - if ($currentSubscriptionsFieldLayout = Craft::$app->getProjectConfig()->get(Subscriptions::CONFIG_FIELDLAYOUT_KEY)) { - $uid = array_key_first($currentSubscriptionsFieldLayout); - } else { - $uid = StringHelper::UUID(); - } - - $configData = [$uid => $fieldLayout->getConfig()]; - $result = Craft::$app->getProjectConfig()->set(Subscriptions::CONFIG_FIELDLAYOUT_KEY, $configData, force: true); - - if (!$result) { - return $this->asFailure(Craft::t('app', 'Couldn’t save subscription fields.')); - } - - return $this->asSuccess(Craft::t('commerce', 'Subscription fields saved.')); - } - - - /** - * @param array $variables - * @return Response - */ - public function actionEditTransferSettings(array $variables = []): Response - { - $fieldLayout = Plugin::getInstance()->getTransfers()->getFieldLayout(); - - $variables['fieldLayout'] = $fieldLayout; - $variables['title'] = Craft::t('commerce', 'Transfer Settings'); - $variables['readOnly'] = $this->isReadOnlyScreen(); - - return $this->renderTemplate('commerce/settings/transfers/_edit', $variables); - } - - /** - * @param array $variables - * @return Response - */ - public function actionEditSubscriptionSettings(array $variables = []): Response - { - $fieldLayout = Craft::$app->getFields()->getLayoutByType(Subscription::class); - - $variables['fieldLayout'] = $fieldLayout; - $variables['title'] = Craft::t('commerce', 'Subscription Settings'); - $variables['readOnly'] = $this->isReadOnlyScreen(); - - return $this->renderTemplate('commerce/settings/subscriptions/_edit', $variables); - } -} diff --git a/src/controllers/ShippingCategoriesController.php b/src/controllers/ShippingCategoriesController.php deleted file mode 100644 index ecec2787f0..0000000000 --- a/src/controllers/ShippingCategoriesController.php +++ /dev/null @@ -1,325 +0,0 @@ - - * @since 2.0 - */ -class ShippingCategoriesController extends BaseShippingSettingsController -{ - /** - * @param string|null $storeHandle - * @return Response - * @throws InvalidConfigException - */ - public function actionIndex(?string $storeHandle = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $shippingCategories = Plugin::getInstance()->getShippingCategories()->getAllShippingCategories($store->id); - - // Generate table data with chips - $tableData = []; - foreach ($shippingCategories as $shippingCategory) { - $label = Html::encode(Craft::t('site', $shippingCategory->name)); - $tableData[] = [ - 'id' => $shippingCategory->id, - 'title' => $label, - 'chip' => Cp::chipHtml($shippingCategory, [ - 'labelHtml' => Html::a($label, $shippingCategory->getCpEditUrl(), [ - 'class' => ['chip-label', 'cell-bold'], - ]), - ]), - 'url' => $shippingCategory->getCpEditUrl(), - 'handle' => $shippingCategory->handle, - 'description' => Html::encode(Craft::t('site', $shippingCategory->description)), - 'default' => $shippingCategory->default, - '_showDelete' => (count($shippingCategories) > 1 && !$shippingCategory->default), - ]; - } - - $this->getView()->registerTranslations('commerce', [ - 'Default', - 'Description', - 'Handle', - 'Name', - 'Yes', - ]); - - $tableData = Json::encode($tableData); - - $js = <<'; - } - } - }, - ]; - - new Craft.VueAdminTable({ - actions: [ - { - label: '', - icon: 'settings', - actions: [ - { - label: Craft.t('commerce', 'Set Default Category'), - action: 'commerce/shipping-categories/set-default-category', - param: 'storeHandle', - value: '{$storeHandle}', - allowMultiple: false - } - ] - } - ], - checkboxes: true, - columns: columns, - container: '#shipping-vue-admin-table', - deleteAction: 'commerce/shipping-categories/delete', - padded: true, - tableData: {$tableData}, - }); - -JS; - - - $this->getView()->registerJs($js, View::POS_END); - - return $this->asStoreManagementCpScreen($storeHandle) - ->additionalButtonsHtml(Html::a( - Craft::t('commerce', 'New shipping category'), - $store->getStoreSettingsUrl('shippingcategories/new'), - ['class' => 'btn submit add icon'] - )) - ->contentHtml(Html::tag('div', '', ['id' => 'shipping-vue-admin-table'])); - } - - /** - * @param int|null $id - * @param ShippingCategory|null $shippingCategory - * @throws HttpException - */ - public function actionEdit(?string $storeHandle = null, int $id = null, ShippingCategory $shippingCategory = null): Response - { - $variables = [ - 'id' => $id, - 'shippingCategory' => $shippingCategory, - 'productTypes' => Plugin::getInstance()->getProductTypes()->getAllProductTypes(), - 'storeHandle' => $storeHandle, - ]; - - $store = null; - if ($storeHandle !== null) { - $store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle); - } - - $store ??= Plugin::getInstance()->getStores()->getPrimaryStore(); - - if (!$variables['shippingCategory']) { - if ($variables['id']) { - $variables['shippingCategory'] = Plugin::getInstance() - ->getShippingCategories() - ->getShippingCategoryById($variables['id'], $store->id); - - if (!$variables['shippingCategory']) { - throw new HttpException(404); - } - } else { - $variables['shippingCategory'] = Craft::createObject([ - 'class' => ShippingCategory::class, - 'attributes' => ['storeId' => $store->id], - ]); - } - } - - if ($variables['shippingCategory']->id) { - $variables['title'] = $variables['shippingCategory']->name; - } else { - $variables['title'] = Craft::t('commerce', 'Create a new shipping category'); - } - - DebugPanel::prependOrAppendModelTab(model: $variables['shippingCategory'], prepend: true); - - $variables['productTypesOptions'] = []; - if (!empty($variables['productTypes'])) { - $variables['productTypesOptions'] = ArrayHelper::map($variables['productTypes'], 'id', fn($row) => ['label' => $row->name, 'value' => $row->id]); - } - - $allShippingCategories = Plugin::getInstance()->getShippingCategories()->getAllShippingCategories($store->id); - $variables['isDefaultAndOnlyCategory'] = $variables['id'] && $allShippingCategories->count() === 1 && $allShippingCategories->firstWhere('id', $variables['id']); - - $metaSidebar = ''; - if ($variables['shippingCategory']->id) { - $metaSidebar = Cp::metadataHtml([ - Craft::t('app', 'Created at') => Craft::$app->getFormatter()->asDatetime($variables['shippingCategory']->dateCreated, 'short'), - Craft::t('app', 'Updated at') => Craft::$app->getFormatter()->asDatetime($variables['shippingCategory']->dateUpdated, 'short'), - ]); - } - - return $this->asStoreManagementCpScreen($storeHandle, false) - ->title($variables['title']) - ->addCrumb(Craft::t('commerce', 'Shipping Categories'),$store->getStoreSettingsUrl('shippingcategories')) - ->action('commerce/shipping-categories/save') - ->redirectUrl($store->getStoreSettingsUrl('shippingcategories')) - ->metaSidebarHtml($metaSidebar) - ->contentTemplate('commerce/store-management/shipping/shippingcategories/_edit', $variables); - } - - /** - * @throws BadRequestHttpException - * @throws Exception - * @noinspection Duplicates - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - - $shippingCategory = new ShippingCategory(); - - // Shared attributes - $shippingCategory->id = $this->request->getBodyParam('shippingCategoryId'); - $shippingCategory->storeId = $this->request->getBodyParam('storeId'); - $shippingCategory->name = $this->request->getBodyParam('name'); - $shippingCategory->handle = $this->request->getBodyParam('handle'); - $shippingCategory->icon = $this->request->getBodyParam('icon'); - $shippingCategory->color = $this->request->getBodyParam('color'); - $shippingCategory->description = $this->request->getBodyParam('description'); - $shippingCategory->default = (bool)$this->request->getBodyParam('default'); - - // Set the new product types - // If this is the default category, it should be available to all product types - if ($shippingCategory->default) { - $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); - } else { - $postedProductTypes = $this->request->getBodyParam('productTypes', []) ?: []; - $productTypes = []; - foreach ($postedProductTypes as $productTypeId) { - if ($productTypeId && $productType = Plugin::getInstance()->getProductTypes()->getProductTypeById($productTypeId)) { - $productTypes[] = $productType; - } - } - } - $shippingCategory->setProductTypes($productTypes); - - - // Save it - if (!Plugin::getInstance()->getShippingCategories()->saveShippingCategory($shippingCategory)) { - return $this->asModelFailure( - $shippingCategory, - Craft::t('commerce', 'Couldn’t save shipping category.'), - 'shippingCategory' - ); - } - - return $this->asModelSuccess( - $shippingCategory, - Craft::t('commerce', 'Shipping category saved.'), - 'shippingCategory', - data: [ - 'id' => $shippingCategory->id, - 'name' => $shippingCategory->name, - ] - ); - } - - /** - * @throws HttpException - */ - public function actionDelete(): ?Response - { - $this->requirePostRequest(); - - $id = $this->request->getBodyParam('id'); - $ids = $this->request->getBodyParam('ids'); - - if ((!$id && empty($ids)) || ($id && !empty($ids))) { - throw new BadRequestHttpException('id or ids must be specified.'); - } - - if ($id) { - // If it is just the one id we know it has come from an ajax request on the table - $this->requireAcceptsJson(); - $ids = [$id]; - } - - $failedIds = []; - foreach ($ids as $id) { - if (!Plugin::getInstance()->getShippingCategories()->deleteShippingCategoryById($id)) { - $failedIds[] = $id; - } - } - - if (!empty($failedIds)) { - return $this->asFailure(Craft::t('commerce', 'Could not delete {count, number} shipping {count, plural, one{category} other{categories}}.', [ - 'count' => count($failedIds), - ])); - } - - return $this->asSuccess(Craft::t('commerce', 'Shipping categories deleted.')); - } - - /** - * @throws BadRequestHttpException - * @throws Exception - * @since 3.2.9 - */ - public function actionSetDefaultCategory(): ?Response - { - $this->requirePostRequest(); - - $ids = $this->request->getRequiredBodyParam('ids'); - $storeHandle = $this->request->getRequiredBodyParam('storeHandle'); - if (!$storeHandle || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - throw new InvalidConfigException('Invalid store.'); - } - - if (!empty($ids)) { - $id = ArrayHelper::firstValue($ids); - - $shippingCategory = Plugin::getInstance()->getShippingCategories()->getShippingCategoryById($id, $store->id); - if ($shippingCategory) { - $shippingCategory->default = true; - if (Plugin::getInstance()->getShippingCategories()->saveShippingCategory($shippingCategory)) { - $this->setSuccessFlash(Craft::t('commerce', 'Shipping category updated.')); - return null; - } - } - } - - $this->setFailFlash(Craft::t('commerce', 'Unable to set default shipping category.')); - return null; - } -} diff --git a/src/controllers/ShippingMethodsController.php b/src/controllers/ShippingMethodsController.php deleted file mode 100644 index 565c65c04c..0000000000 --- a/src/controllers/ShippingMethodsController.php +++ /dev/null @@ -1,297 +0,0 @@ - - * @since 2.0 - */ -class ShippingMethodsController extends BaseShippingSettingsController -{ - /** - * @throws InvalidConfigException - */ - public function actionIndex(?string $storeHandle = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $shippingMethods = Plugin::getInstance()->getShippingMethods()->getAllShippingMethods($store->id); - - // Generate table data with chips - $tableData = []; - foreach ($shippingMethods as $shippingMethod) { - $label = Html::encode(Craft::t('site', $shippingMethod->name)); - $tableData[] = [ - 'id' => $shippingMethod->id, - 'title' => $label, - 'chip' => Cp::chipHtml($shippingMethod, [ - 'showStatus' => true, - 'showThumb' => true, - 'labelHtml' => Html::a($label, $shippingMethod->getCpEditUrl(), [ - 'class' => ['chip-label', 'cell-bold'], - ]), - ]), - 'url' => $shippingMethod->getCpEditUrl(), - 'handle' => $shippingMethod->handle, - 'type' => $shippingMethod->getType(), - 'status' => $shippingMethod->enabled, - ]; - } - - $this->getView()->registerTranslations('commerce', [ - 'Disabled', - 'Enabled', - 'Handle', - 'Name', - 'Set status', - 'Type', - ]); - - $tableData = Json::encode($tableData); - - $js = <<getView()->registerJs($js, View::POS_END); - - return $this->asStoreManagementCpScreen($storeHandle) - ->additionalButtonsHtml(Html::a(Craft::t('commerce', 'New shipping method'), $store->getStoreSettingsUrl('shippingmethods/new'), ['class' => 'btn submit add icon'])) - ->contentHtml(Html::tag('div', '', ['id' => 'shipping-vue-admin-table'])); - } - - /** - * @param int|null $id - * @param ShippingMethod|null $shippingMethod - * @throws HttpException - * @throws InvalidConfigException - */ - public function actionEdit(?string $storeHandle = null, int $id = null, ShippingMethod $shippingMethod = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - if (!$shippingMethod) { - if ($id) { - $shippingMethod = Plugin::getInstance()->getShippingMethods()->getShippingMethodById($id, $store->id); - - if (!$shippingMethod) { - throw new HttpException(404); - } - } else { - $shippingMethod = Craft::createObject([ - 'class' => ShippingMethod::class, - 'attributes' => ['storeId' => $store->id], - ]); - } - } - - if ($shippingMethod->id) { - $title = $shippingMethod->name; - } else { - $title = Craft::t('commerce', 'Create a new shipping method'); - } - - $storeHandle = $store->handle; - - DebugPanel::prependOrAppendModelTab(model: $shippingMethod, prepend: true); - - $shippingRules = $shippingMethod->id !== null - ? Plugin::getInstance()->getShippingRules()->getAllShippingRulesByShippingMethodId($shippingMethod->id) - : []; - - $this->getView()->registerTranslations('commerce', [ - 'Couldn’t reorder rules.', - 'Description', - 'No shipping rules exist yet.', - 'Rules reordered.', - 'Shipping Rule', - ]); - - $metaDataHtml = Html::beginTag('div', ['class' => 'meta']) . - Cp::lightswitchFieldHtml([ - 'label' => Craft::t('commerce', 'Enable this shipping method on the front end'), - 'id' => 'enabled', - 'name' => 'enabled', - 'on' => $shippingMethod->enabled, - 'errors' => $shippingMethod->getErrors('enabled'), - ]) . - Html::endTag('div'); - - if ($shippingMethod->id) { - $metaDataHtml .= Cp::metadataHtml([ - Craft::t('app', 'Created at') => Craft::$app->getFormatter()->asDatetime($shippingMethod->dateCreated, 'short'), - Craft::t('app', 'Updated at') => Craft::$app->getFormatter()->asDatetime($shippingMethod->dateUpdated, 'short'), - ]); - } - - return $this->asStoreManagementCpScreen($storeHandle, false) - ->title($title) - ->action('commerce/shipping-methods/save') - ->redirectUrl($store->getStoreSettingsUrl('shippingmethods/{id}#rules')) - ->addCrumb(Craft::t('commerce', 'Shipping Methods'), $store->getStoreSettingsUrl('shippingmethods')) - ->metaSidebarHtml($metaDataHtml) - ->submitButtonLabel($shippingMethod->id ? Craft::t('commerce', 'Save and set rules') : Craft::t('app', 'Save')) - ->contentTemplate('commerce/store-management/shipping/shippingmethods/_edit', [ - 'shippingMethod' => $shippingMethod, - 'shippingRules' => $shippingRules, - 'store' => $store, - 'storeHandle' => $storeHandle, - ]); - } - - /** - * @throws BadRequestHttpException - * @throws \yii\base\Exception - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - $shippingMethod = new ShippingMethod(); - - // Shared attributes - $shippingMethod->id = $this->request->getBodyParam('shippingMethodId'); - $shippingMethod->name = $this->request->getBodyParam('name'); - $shippingMethod->handle = $this->request->getBodyParam('handle'); - $shippingMethod->icon = $this->request->getBodyParam('icon'); - $shippingMethod->color = $this->request->getBodyParam('color'); - $shippingMethod->storeId = $this->request->getBodyParam('storeId'); - $shippingMethod->setOrderCondition($this->request->getBodyParam('orderCondition')); - $shippingMethod->setCustomerCondition($this->request->getBodyParam('customerCondition')); - $shippingMethod->enabled = (bool)$this->request->getBodyParam('enabled'); - - // Save it - if (!Plugin::getInstance()->getShippingMethods()->saveShippingMethod($shippingMethod)) { - return $this->asModelFailure($shippingMethod, Craft::t('commerce', 'Couldn’t save shipping method.'), 'shippingMethod'); - } - - return $this->asModelSuccess($shippingMethod, Craft::t('commerce', 'Shipping method saved.'), 'shippingMethod'); - } - - /** - * @throws HttpException - */ - public function actionDelete(): ?Response - { - $this->requirePostRequest(); - - $id = $this->request->getBodyParam('id'); - $ids = $this->request->getBodyParam('ids'); - - if ((!$id && empty($ids)) || ($id && !empty($ids))) { - throw new BadRequestHttpException('id or ids must be specified.'); - } - - if ($id) { - // If it is just the one id we know it has come from an ajax request on the table - $this->requireAcceptsJson(); - $ids = [$id]; - } - - $failedIds = []; - foreach ($ids as $id) { - if (!Plugin::getInstance()->getShippingMethods()->deleteShippingMethodById($id)) { - $failedIds[] = $id; - } - } - - if (!empty($failedIds)) { - return $this->asFailure(Craft::t('commerce', 'Could not delete {count, number} shipping {count, plural, one{method} other{methods}} and rules.', [ - 'count' => count($failedIds), - ])); - } - - return $this->asSuccess(Craft::t('commerce', 'Shipping methods and rules deleted.')); - } - - /** - * @throws BadRequestHttpException - * @throws Exception - * @since 3.2.9 - */ - public function actionUpdateStatus(): void - { - $this->requirePostRequest(); - $ids = $this->request->getRequiredBodyParam('ids'); - $status = $this->request->getRequiredBodyParam('status'); - - if (empty($ids)) { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t update status.')); - } - - $transaction = Craft::$app->getDb()->beginTransaction(); - $shippingMethods = ShippingMethodRecord::find() - ->where(['id' => $ids]) - ->all(); - - /** @var ShippingMethodRecord $shippingMethod */ - foreach ($shippingMethods as $shippingMethod) { - $shippingMethod->enabled = ($status == 'enabled'); - $shippingMethod->save(); - } - $transaction->commit(); - - $this->setSuccessFlash(Craft::t('commerce', 'Shipping methods updated.')); - } -} diff --git a/src/controllers/ShippingRulesController.php b/src/controllers/ShippingRulesController.php deleted file mode 100644 index 942e82f3a2..0000000000 --- a/src/controllers/ShippingRulesController.php +++ /dev/null @@ -1,261 +0,0 @@ - - * @since 2.0 - */ -class ShippingRulesController extends BaseShippingSettingsController -{ - /** - * @param int|null $methodId - * @param int|null $ruleId - * @param ShippingRule|null $shippingRule - * @throws HttpException - * @throws LoaderError - * @throws RuntimeError - * @throws SyntaxError - * @throws Exception - */ - public function actionEdit(?string $storeHandle = null, int $methodId = null, int $ruleId = null, ShippingRule $shippingRule = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $variables = compact('methodId', 'ruleId', 'shippingRule'); - - $plugin = Plugin::getInstance(); - $variables['shippingMethod'] = $plugin->getShippingMethods()->getShippingMethodById($variables['methodId'], $store->id); - - if (!$variables['shippingMethod']) { - throw new HttpException(404); - } - - if (!$variables['shippingRule']) { - if ($variables['ruleId']) { - $variables['shippingRule'] = $plugin->getShippingRules()->getShippingRuleById($variables['ruleId']); - - if (!$variables['shippingRule']) { - throw new HttpException(404); - } - } else { - $variables['shippingRule'] = new ShippingRule(); - $variables['shippingRule']->methodId = $variables['shippingMethod']->id; - $variables['shippingRule']->storeId = $variables['shippingMethod']->storeId; - } - } - - $this->getView()->setNamespace('new'); - - $this->getView()->startJsBuffer(); - - $newZone = new ShippingAddressZone(); - $condition = $newZone->getCondition(); - $condition->mainTag = 'div'; - $condition->name = 'condition'; - $condition->id = 'condition'; - - $variables['newShippingZoneFields'] = $this->getView()->namespaceInputs( - $this->getView()->renderTemplate('commerce/store-management/shipping/shippingzones/_fields', ['condition' => $condition]) - ); - $variables['newShippingZoneJs'] = $this->getView()->clearJsBuffer(false); - $this->getView()->setNamespace(null); - - if (!empty($variables['ruleId'])) { - $variables['title'] = $variables['shippingRule']->name; - } else { - $variables['title'] = Craft::t('commerce', 'Create a new shipping rule'); - } - - DebugPanel::prependOrAppendModelTab(model: $variables['shippingMethod'], prepend: true); - DebugPanel::prependOrAppendModelTab(model: $variables['shippingRule'], prepend: true); - - $shippingZones = $plugin->getShippingZones()->getAllShippingZones($store->id)->all(); - $variables['shippingZones'] = []; - $variables['shippingZones'][] = Craft::t('commerce', 'Anywhere'); - foreach ($shippingZones as $model) { - $variables['shippingZones'][$model->id] = $model->name; - } - - $variables['categoryShippingOptions'] = []; - $variables['categoryShippingOptions'][] = ['label' => Craft::t('commerce', 'Allow'), 'value' => ShippingRuleCategoryRecord::CONDITION_ALLOW]; - $variables['categoryShippingOptions'][] = ['label' => Craft::t('commerce', 'Disallow'), 'value' => ShippingRuleCategoryRecord::CONDITION_DISALLOW]; - $variables['categoryShippingOptions'][] = ['label' => Craft::t('commerce', 'Require'), 'value' => ShippingRuleCategoryRecord::CONDITION_REQUIRE]; - - $variables['storeId'] = $store->id; - $variables['storeHandle'] = $store->handle; - - return $this->renderTemplate('commerce/store-management/shipping/shippingrules/_edit', $variables); - } - - /** - * Duplicates a shipping rule. - * - * @throws InvalidRouteException - * @since 3.2 - */ - public function actionDuplicate(): ?Response - { - return $this->runAction('save', ['duplicate' => true]); - } - - /** - * @throws BadRequestHttpException - * @throws Exception - */ - public function actionSave(bool $duplicate = false): void - { - $this->requirePostRequest(); - - $shippingRule = new ShippingRule(); - - if (!$duplicate) { - $shippingRule->id = $this->request->getBodyParam('id'); - } - $shippingRule->storeId = $this->request->getBodyParam('storeId'); - - $moneyInputs = [ - 'baseRate', - 'maxRate', - 'minRate', - 'perItemRate', - 'weightRate', - ]; - - foreach ($moneyInputs as $moneyInput) { - $input = $this->request->getBodyParam($moneyInput); - $input += [ - 'currency' => $shippingRule->getStore()->getCurrency(), - ]; - $shippingRule->$moneyInput = (float)MoneyHelper::toDecimal(MoneyHelper::toMoney($input)); - } - - $shippingRule->name = $this->request->getBodyParam('name'); - $shippingRule->description = $this->request->getBodyParam('description'); - $shippingRule->methodId = $this->request->getBodyParam('methodId'); - $shippingRule->enabled = (bool)$this->request->getBodyParam('enabled'); - $shippingRule->orderConditionFormula = trim($this->request->getBodyParam('orderConditionFormula', '')); - $shippingRule->percentageRate = Localization::normalizeNumber($this->request->getBodyParam('percentageRate')); - $shippingRule->setOrderCondition($this->request->getBodyParam('orderCondition')); - $shippingRule->setCustomerCondition($this->request->getBodyParam('customerCondition')); - - $ruleCategories = []; - $allRulesCategories = $this->request->getBodyParam('ruleCategories'); - foreach ($allRulesCategories as $key => $ruleCategory) { - $perItemRate = $ruleCategory['perItemRate']; - $weightRate = $ruleCategory['weightRate']; - $percentageRate = $ruleCategory['percentageRate']; - $ruleCategory['perItemRate'] = (!isset($perItemRate) || trim($perItemRate['value']) === '') - ? null - : MoneyHelper::toDecimal(MoneyHelper::toMoney(array_merge([ - 'currency' => $shippingRule->getStore()->getCurrency(), - ], $perItemRate))); - $ruleCategory['weightRate'] = (!isset($weightRate) || trim($weightRate['value']) === '') - ? null - : MoneyHelper::toDecimal(MoneyHelper::toMoney(array_merge([ - 'currency' => $shippingRule->getStore()->getCurrency(), - ], $weightRate))); - $ruleCategory['percentageRate'] = (!isset($percentageRate) || trim($percentageRate) === '') ? null : Localization::normalizeNumber($percentageRate); - - $ruleCategories[$key] = new ShippingRuleCategory($ruleCategory); - $ruleCategories[$key]->shippingCategoryId = $key; - } - - $shippingRule->setShippingRuleCategories($ruleCategories); - - // Save it - if (Plugin::getInstance()->getShippingRules()->saveShippingRule($shippingRule)) { - $this->setSuccessFlash(Craft::t('commerce', 'Shipping rule saved.')); - $this->redirectToPostedUrl($shippingRule); - } else { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save shipping rule.')); - } - - // Send the model back to the template - Craft::$app->getUrlManager()->setRouteParams(['shippingRule' => $shippingRule]); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @throws InvalidConfigException - * @throws \yii\db\Exception - */ - public function actionReorder(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $ids = Json::decode($this->request->getRequiredBodyParam('ids')); - Plugin::getInstance()->getShippingRules()->reorderShippingRules($ids); - - return $this->asSuccess(); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @throws Exception - * @throws InvalidConfigException - * @throws Throwable - * @throws StaleObjectException - */ - public function actionDelete(): Response - { - $this->requirePostRequest(); - - if (Craft::$app->getRequest()->getIsAjax()) { - $this->requireAcceptsJson(); - } - - if (!$id = $this->request->getRequiredBodyParam('id')) { - throw new BadRequestHttpException('Shipping rule ID not submitted'); - } - - $rule = Plugin::getInstance()->getShippingRules()->getShippingRuleById($id); - if (!$rule) { - throw new Exception('Cannot find shipping rule to delete'); - } - - if (!Plugin::getInstance()->getShippingRules()->deleteShippingRuleById($id)) { - return $this->asFailure(Craft::t('commerce', 'Could not delete shipping rule')); - } - - if (Craft::$app->getRequest()->getIsAjax()) { - return $this->asSuccess(); - } - - return $this->redirectToPostedUrl($rule); - } -} diff --git a/src/controllers/ShippingZonesController.php b/src/controllers/ShippingZonesController.php deleted file mode 100644 index 8abb2c7c6a..0000000000 --- a/src/controllers/ShippingZonesController.php +++ /dev/null @@ -1,218 +0,0 @@ - - * @since 2.0 - */ -class ShippingZonesController extends BaseShippingSettingsController -{ - public function actionIndex(?string $storeHandle = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $shippingZones = Plugin::getInstance()->getShippingZones()->getAllShippingZones($store->id); - - // Generate table data - $tableData = []; - foreach ($shippingZones as $shippingZone) { - $label = Html::encode(Craft::t('site', $shippingZone->name)); - $tableData[] = [ - 'id' => $shippingZone->id, - 'title' => Html::a($label, $shippingZone->getCpEditUrl()), - 'url' => $shippingZone->getCpEditUrl(), - 'description' => Html::encode(Craft::t('site', $shippingZone->description)), - ]; - } - - $tableData = Json::encode($tableData); - - $js = <<getView()->registerJs($js, View::POS_END); - - $this->getView()->registerTranslations('commerce', [ - 'Name', - 'Description', - ]); - - return $this->asStoreManagementCpScreen($storeHandle) - ->additionalButtonsHtml(Html::a(Craft::t('commerce', 'New shipping zone'), $store->getStoreSettingsUrl('shippingzones/new'), ['class' => 'btn submit add icon'])) - ->contentHtml(Html::tag('div', '', ['id' => 'shipping-vue-admin-table'])); - } - - /** - * @param int|null $id - * @param ShippingAddressZone|null $shippingZone - * @throws HttpException - */ - public function actionEdit(?string $storeHandle = null, int $id = null, ShippingAddressZone $shippingZone = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - if (!$shippingZone) { - if ($id) { - $shippingZone = Plugin::getInstance()->getShippingZones()->getShippingZoneById($id, $store->id); - - if (!$shippingZone) { - throw new HttpException(404); - } - } else { - $shippingZone = Craft::createObject([ - 'class' => ShippingAddressZone::class, - 'attributes' => ['storeId' => $store->id], - ]); - } - } - - if ($shippingZone->id) { - $title = $shippingZone->name; - } else { - $title = Craft::t('commerce', 'Create a shipping zone'); - } - - $storeHandle = $store->handle; - - $condition = $shippingZone->getCondition(); - $condition->mainTag = 'div'; - $condition->name = 'condition'; - $condition->id = 'condition'; - - DebugPanel::prependOrAppendModelTab(model: $shippingZone, prepend: true); - - $metadata = []; - if ($shippingZone->id) { - $metadata = [ - Craft::t('app', 'Created at') => Craft::$app->getFormatter()->asDatetime($shippingZone->dateCreated, 'short'), - Craft::t('app', 'Updated at') => Craft::$app->getFormatter()->asDatetime($shippingZone->dateUpdated, 'short'), - ]; - } - - return $this->asStoreManagementCpScreen($storeHandle, false) - ->title($title) - ->addCrumb(Craft::t('commerce', 'Shipping Zones'), $store->getStoreSettingsUrl('shippingzones')) - ->action('commerce/shipping-zones/save') - ->redirectUrl($store->getStoreSettingsUrl('shippingzones')) - ->metaSidebarHtml(Cp::metadataHtml($metadata)) - ->contentTemplate('commerce/store-management/shipping/shippingzones/_edit', [ - 'shippingZone' => $shippingZone, - 'condition' => $condition, - 'store' => $store, - ]); - } - - /** - * @throws Exception - * @throws BadRequestHttpException - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - - $shippingZone = new ShippingAddressZone(); - - // Shared attributes - $shippingZone->id = $this->request->getBodyParam('shippingZoneId'); - $shippingZone->storeId = $this->request->getBodyParam('storeId'); - $shippingZone->name = $this->request->getBodyParam('name'); - $shippingZone->description = $this->request->getBodyParam('description'); - $shippingZone->setCondition($this->request->getBodyParam('condition')); - - if ($shippingZone->validate() && Plugin::getInstance()->getShippingZones()->saveShippingZone($shippingZone)) { - return $this->asModelSuccess( - $shippingZone, - Craft::t('commerce', 'Shipping zone saved.'), - 'shippingZone', - data: [ - 'id' => $shippingZone->id, - 'name' => $shippingZone->name, - ] - ); - } - - return $this->asModelFailure( - $shippingZone, - Craft::t('commerce', 'Couldn’t save shipping zone.'), - 'shippingZone' - ); - } - - /** - * @throws HttpException - */ - public function actionDelete(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $id = $this->request->getRequiredBodyParam('id'); - - if (!Plugin::getInstance()->getShippingZones()->deleteShippingZoneById($id)) { - return $this->asFailure(Craft::t('commerce', 'Could not delete shipping zone')); - } - - return $this->asSuccess(); - } - - /** - * @throws BadRequestHttpException - * @throws LoaderError - * @throws SyntaxError - * @since 2.2 - */ - public function actionTestZip(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $zipCodeFormula = (string)$this->request->getRequiredBodyParam('zipCodeConditionFormula'); - $testZipCode = (string)$this->request->getRequiredBodyParam('testZipCode'); - - $params = ['zipCode' => $testZipCode]; - - if (!Plugin::getInstance()->getFormulas()->evaluateCondition($zipCodeFormula, $params)) { - return $this->asFailure('failed'); - } - - return $this->asSuccess(); - } -} diff --git a/src/controllers/StoreManagementController.php b/src/controllers/StoreManagementController.php deleted file mode 100644 index 210dcc7f57..0000000000 --- a/src/controllers/StoreManagementController.php +++ /dev/null @@ -1,243 +0,0 @@ - - * @since 4.0 - */ -class StoreManagementController extends BaseStoreManagementController -{ - public function actionIndex(): Response - { - $user = Craft::$app->getUser(); - /** @var Site|HasStoreInterface $site */ - $site = Cp::requestedSite(); - - if ($user->checkPermission('commerce-manageGeneralStoreSettings')) { - return $this->redirect($site->getStore()->getStoreSettingsUrl()); - } - - if ($user->checkPermission('commerce-managePaymentCurrencies')) { - return $this->redirect($site->getStore()->getStoreSettingsUrl('payment-currencies')); - } - - if ($user->checkPermission('commerce-managePromotions')) { - return $this->redirect($site->getStore()->getStoreSettingsUrl('discounts')); - } - - if ($user->checkPermission('commerce-manageShipping')) { - return $this->redirect($site->getStore()->getStoreSettingsUrl('shipping')); - } - - if ($user->checkPermission('commerce-manageTaxes')) { - return $this->redirect($site->getStore()->getStoreSettingsUrl('taxrates')); - } - - return $this->asStoreManagementCpScreen($site->getStore()->handle) - ->contentHtml(Html::tag( - 'p', - Craft::t('commerce', 'No access given to any specific store management features.') - )); - } - - /** - * @return YiiResponse - * @throws TemplateLoaderException - * @throws InvalidConfigException - */ - public function actionEdit(StoreSettings $storeSettings = null, ?string $storeHandle = null): Response - { - $this->requirePermission('commerce-manageGeneralStoreSettings'); - - if (!$storeSettings) { - if ($storeHandle) { - // Store has the same ID as Store Settings - $store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle); - - if (!$store) { - throw new HttpException(404); - } - - $storeSettings = $store->getSettings(); - } else { - // Attempt to redirect the user to the correct store settings for the site they were working on - /** @var Site|StoreBehavior $site */ - $site = Cp::requestedSite(); - return $this->redirect($site->getStore()->getStoreSettingsUrl()); - } - } else { - $store = Plugin::getInstance()->getStores()->getStoreById($storeSettings->id); - } - - $addressesService = Craft::$app->getAddresses(); - $allCountries = $addressesService->getCountryRepository()->getList(Craft::$app->language); - - $locationFieldHtml = Cp::elementCardHtml($storeSettings->getLocationAddress(), [ - 'context' => 'field', - 'inputName' => 'locationAddressId', - 'showActionMenu' => true, - ]); - - // Countries market condition field HTML - $condition = $storeSettings->getMarketAddressCondition(); - $condition->mainTag = 'div'; - $condition->name = 'marketAddressCondition'; - $condition->id = 'marketAddressCondition'; - $marketAddressConditionFieldHtml = Cp::fieldHtml($condition->getBuilderHtml(), [ - 'label' => Craft::t('app', 'Order Address Condition'), - 'instructions' => Craft::t('app', 'Only allow orders with addresses that match the following rules:'), - ]); - - // Countries allowed field HTML - $countriesField = Cp::selectizeFieldHtml([ - 'label' => Craft::t('commerce', 'Country List'), - 'instructions' => Craft::t('commerce', 'The countries that orders are allowed to be placed from.'), - 'id' => 'countries', - 'name' => 'countries', - 'multi' => true, - 'values' => $storeSettings->getCountries(), - 'options' => $allCountries, - 'errors' => $storeSettings->getErrors('countries'), - 'allowEmptyOption' => true, - ]); - - // Inventory locations field HTML - $inventoryLocations = Plugin::getInstance()->getInventoryLocations()->getInventoryLocations($store->id); - $allInventoryLocations = Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocations(); - $currentUser = Craft::$app->getUser()->getIdentity(); - - $locationsCount = count($allInventoryLocations); - $userCanCreate = $currentUser->can('commerce-manageInventoryLocations'); - $inventoryLocationsField = ''; - - if ($userCanCreate) { - $canCreate = false; - - $limit = Plugin::EDITION_PRO_STORE_LIMIT; - if ($locationsCount < $limit) { - $canCreate = true; - } - - if (Plugin::getInstance()->is(Plugin::EDITION_ENTERPRISE, '=')) { - $limit = null; - $canCreate = true; - } - - $config = [ - 'label' => Craft::t('commerce', 'Inventory Locations'), - 'instructions' => Craft::t('commerce', 'The inventory locations this store uses.'), - 'id' => 'inventoryLocations', - 'name' => 'inventoryLocations[]', - 'values' => $inventoryLocations, - 'create' => $canCreate, - ]; - - if ($limit !== null) { - $config['limit'] = $limit; - } - - $inventoryLocationsField = CommerceCp::inventoryLocationFieldHtml($config); - } - - return $this->asStoreManagementCpScreen($storeHandle) - ->action('commerce/store-management/save') - ->redirectUrl($store->getStoreSettingsUrl()) - ->submitButtonLabel(Craft::t('app', 'Save')) - ->contentTemplate('commerce/store-management/general/_edit', [ - 'store' => $store, - 'storeHandle' => $storeHandle, - 'storeSettings' => $storeSettings, - 'marketAddressConditionField' => $marketAddressConditionFieldHtml, - 'countriesField' => $countriesField, - 'locationField' => $locationFieldHtml, - 'inventoryLocationsField' => $inventoryLocationsField, - ]); - } - - /** - * @return YiiResponse|null - * @throws InvalidConfigException - * @throws Throwable - * @throws Exception - */ - public function actionSave(): ?YiiResponse - { - $this->requirePermission('commerce-manageGeneralStoreSettings'); - - $storeId = Craft::$app->getRequest()->getBodyParam('id'); - $store = Plugin::getInstance()->getStores()->getStoreById($storeId); - $storeSettings = Plugin::getInstance()->getStoreSettings()->getStoreSettingsById($storeId); - $currentUser = Craft::$app->getUser()->getIdentity(); - - if ($locationAddressId = $this->request->getBodyParam('locationAddressId')) { - /** @var Address|null $locationAddress */ - $locationAddress = Address::find()->id($locationAddressId)->one(); - if ($locationAddress) { - $storeSettings->setLocationAddress($locationAddress); - } - } - $marketAddressCondition = $this->request->getBodyParam('marketAddressCondition') ?? new ZoneAddressCondition(); - $storeSettings->setMarketAddressCondition($marketAddressCondition); - $countries = $this->request->getBodyParam('countries') ?: []; - $storeSettings->setCountries($countries); - - // Save inventory locations - if ($currentUser->can('commerce-manageInventoryLocations')) { - $inventoryLocations = Craft::$app->getRequest()->getParam('inventoryLocations'); - - if (!$inventoryLocations) { - return $this->asFailure( - Craft::t('commerce', 'Missing a default inventory location.'), - - ); - } - - if (!Plugin::getInstance()->getInventoryLocations()->saveStoreInventoryLocations($store, $inventoryLocations)) { - return $this->asFailure( - Craft::t('commerce', 'Inventory locations not saved.') - ); - } - } - - if (!$storeSettings->validate() || !Plugin::getInstance()->getStoreSettings()->saveStoreSettings($storeSettings)) { - return $this->asModelFailure( - model: $storeSettings, - message: Craft::t('commerce', 'Couldn’t save store.'), - modelName: 'storeSettings', - ); - } - - return $this->asModelSuccess( - model: $storeSettings, - message: Craft::t('commerce', 'Store saved.'), - modelName: 'storeSettings', - ); - } -} diff --git a/src/controllers/StoresController.php b/src/controllers/StoresController.php deleted file mode 100644 index 6c48478a01..0000000000 --- a/src/controllers/StoresController.php +++ /dev/null @@ -1,400 +0,0 @@ - - * @since 5.0 - */ -class StoresController extends BaseAdminController -{ - /** - * Edit a store. - * - * @param int|null $storeId The Store’s ID, if editing an existing Store - * @param Store|null $storeModel The Store being edited, if there were any validation errors - */ - public function actionEditStore(?int $storeId = null, ?Store $storeModel = null): Response - { - $storesService = Plugin::getInstance()->getStores(); - - $brandNewStore = false; - $allowCurrencyChange = false; - - if ($storeId !== null) { - if ($storeModel === null) { - $storeModel = $storesService->getStoreById($storeId); - - if (!$storeModel) { - throw new NotFoundHttpException('Store not found'); - } - } - - $title = trim($storeModel->getName()) ?: Craft::t('app', 'Edit Store'); - } else { - if ($storeModel === null) { - $storeModel = new Store(); - $brandNewStore = true; - $allowCurrencyChange = true; - } - - $title = Craft::t('app', 'Create a new Store'); - } - - // Breadcrumbs - $crumbs = [ - [ - 'label' => Craft::t('commerce', 'Commerce'), - 'url' => UrlHelper::url('commerce'), - ], - [ - 'label' => Craft::t('commerce', 'Settings'), - 'url' => UrlHelper::url('commerce/settings'), - ], - [ - 'label' => Craft::t('app', 'Stores'), - 'url' => UrlHelper::url('commerce/settings/stores'), - ], - ]; - - $hasOrders = $storeModel->id && Order::find() - ->trashed(null) - ->storeId($storeModel->id) - ->exists(); - - if (!$hasOrders) { - $allowCurrencyChange = true; - } - - // map sites into select box options array - $availableSiteOptions = collect(Craft::$app->getSites()->getAllSites())->map(function($site) { - $availableForAssignmentToNewStores = Plugin::getInstance()->getStores()->getSiteIdsAvailableForAssignmentToNewStores(); - return [ - 'label' => $site->name, - 'value' => $site->id, - 'disabled' => collect($availableForAssignmentToNewStores)->contains($site->id) === false, - ]; - })->all(); - - $currencyOptions = Plugin::getInstance()->getCurrencies()->getAllCurrenciesList(); - - return $this->renderTemplate('commerce/settings/stores/_edit', [ - 'brandNewStore' => $brandNewStore, - 'allowCurrencyChange' => $allowCurrencyChange, - 'title' => $title, - 'crumbs' => $crumbs, - 'store' => $storeModel, - 'currencyOptions' => $currencyOptions, - 'availableSiteOptions' => $availableSiteOptions, - 'freeOrderPaymentStrategyOptions' => $storeModel->getFreeOrderPaymentStrategyOptions(), - 'minimumTotalPriceStrategyOptions' => $storeModel->getMinimumTotalPriceStrategyOptions(), - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * Saves a store. - * - * @return Response|null - * @throws BadRequestHttpException - * @throws BusyResourceException - * @throws StaleResourceException - * @throws ErrorException - * @throws Exception - * @throws InvalidConfigException - * @throws NotSupportedException - * @throws ServerErrorHttpException - */ - public function actionSaveStore(): ?Response - { - $this->requirePostRequest(); - - $storesService = Plugin::getInstance()->getStores(); - $storeId = $this->request->getBodyParam('storeId'); - - if ($storeId) { - $store = $storesService->getStoreById($storeId); - if (!$store) { - throw new BadRequestHttpException("Invalid store ID: $storeId"); - } - } else { - $store = new Store(); - } - - $store->setName($this->request->getBodyParam('name')); - $store->handle = $this->request->getBodyParam('handle'); - $store->setAutoSetNewCartAddresses($this->request->getBodyParam('autoSetNewCartAddresses')); - $store->setAutoSetCartShippingMethodOption($this->request->getBodyParam('autoSetCartShippingMethodOption')); - $store->setAutoSetPaymentSource($this->request->getBodyParam('autoSetPaymentSource')); - $store->setAllowEmptyCartOnCheckout($this->request->getBodyParam('allowEmptyCartOnCheckout')); - $store->setAllowCheckoutWithoutPayment($this->request->getBodyParam('allowCheckoutWithoutPayment')); - $store->setAllowPartialPaymentOnCheckout($this->request->getBodyParam('allowPartialPaymentOnCheckout')); - $store->setRequireShippingAddressAtCheckout($this->request->getBodyParam('requireShippingAddressAtCheckout')); - $store->setRequireBillingAddressAtCheckout($this->request->getBodyParam('requireBillingAddressAtCheckout')); - $store->setRequireShippingMethodSelectionAtCheckout($this->request->getBodyParam('requireShippingMethodSelectionAtCheckout')); - $store->setUseBillingAddressForTax($this->request->getBodyParam('useBillingAddressForTax')); - $store->setValidateOrganizationTaxIdAsVatId($this->request->getBodyParam('validateOrganizationTaxIdAsVatId')); - $store->setOrderReferenceFormat($this->request->getBodyParam('orderReferenceFormat')); - $store->setFreeOrderPaymentStrategy($this->request->getBodyParam('freeOrderPaymentStrategy')); - $store->setMinimumTotalPriceStrategy($this->request->getBodyParam('minimumTotalPriceStrategy')); - $store->primary = (bool)$this->request->getBodyParam('primary', $store->primary); - - if ($currency = $this->request->getBodyParam('currency')) { - $store->setCurrency($currency); - } - - if ($storeId && $savedStore = $storesService->getStoreById($storeId)) { - $store->uid = $savedStore->uid; - $store->sortOrder = $savedStore->sortOrder; - } elseif (!$storeId) { - $store->sortOrder = (new Query())->from(Table::STORES)->max('[[sortOrder]]') + 1; - } - - // Save it - if (!$store->validate() || !$storesService->saveStore($store)) { - $this->setFailFlash(Craft::t('app', 'Couldn’t save the store.')); - - // Send the store back to the template - Craft::$app->getUrlManager()->setRouteParams([ - 'storeModel' => $store, - ]); - - return null; - } - - // Create the site store relationship for this new order - if ($siteId = $this->request->getBodyParam('siteId')) { - $siteStore = collect($storesService->getAllSiteStores())->where('siteId', $siteId)->first(); - $siteStore->storeId = $store->id; - $storesService->saveSiteStore($siteStore); - } - - - $this->setSuccessFlash(Craft::t('app', 'Store saved.')); - return $this->redirectToPostedUrl($store); - } - - - /** - * @return Response - * @throws \yii\base\InvalidConfigException - */ - public function actionStoresIndex(): Response - { - $stores = Plugin::getInstance()->getStores()->getAllStores(); - - // Breadcrumbs - $crumbs = [ - [ - 'label' => Craft::t('commerce', 'Commerce'), - 'url' => UrlHelper::url('commerce'), - ], - ]; - - $menuItems = []; - $stores->each(function(Store $s) use (&$menuItems) { - $m = []; - $m[] = [ - 'label' => Craft::t('commerce', 'Payment Currencies'), - 'url' => UrlHelper::cpUrl('commerce/store-management/' . $s->handle . '/payment-currencies'), - ]; - - $m[] = [ - 'label' => Craft::t('commerce', 'Discounts'), - 'url' => UrlHelper::cpUrl('commerce/store-management/' . $s->handle . '/discounts'), - ]; - - if (Plugin::getInstance()->getCatalogPricingRules()->canUseCatalogPricingRules()) { - $m[] = [ - 'label' => Craft::t('commerce', 'Pricing Rules'), - 'url' => UrlHelper::cpUrl('commerce/store-management/' . $s->handle . '/pricing-rules'), - ]; - } else { - $m[] = [ - 'label' => Craft::t('commerce', 'Sales'), - 'url' => UrlHelper::cpUrl('commerce/store-management/' . $s->handle . '/sales'), - ]; - } - - $m[] = [ - 'label' => Craft::t('commerce', 'Shipping Methods'), - 'url' => UrlHelper::cpUrl('commerce/store-management/' . $s->handle . '/shippingmethods'), - ]; - - $m[] = [ - 'label' => Craft::t('commerce', 'Shipping Zones'), - 'url' => UrlHelper::cpUrl('commerce/store-management/' . $s->handle . '/shippingzones'), - ]; - - $m[] = [ - 'label' => Craft::t('commerce', 'Shipping Categories'), - 'url' => UrlHelper::cpUrl('commerce/store-management/' . $s->handle . '/shippingcategories'), - ]; - - $m[] = [ - 'label' => Craft::t('commerce', 'Tax Rates'), - 'url' => UrlHelper::cpUrl('commerce/store-management/' . $s->handle . '/taxrates'), - ]; - - $m[] = [ - 'label' => Craft::t('commerce', 'Tax Zones'), - 'url' => UrlHelper::cpUrl('commerce/store-management/' . $s->handle . '/taxzones'), - ]; - - $m[] = [ - 'label' => Craft::t('commerce', 'Tax Categories'), - 'url' => UrlHelper::cpUrl('commerce/store-management/' . $s->handle . '/taxcategories'), - ]; - - $menuItems[$s->handle] = $m; - }); - - - return $this->renderTemplate('commerce/settings/stores/index', [ - 'stores' => $stores, - 'crumbs' => $crumbs, - 'sitesStores' => Plugin::getInstance()->getStores()->getAllSiteStores(), - 'primaryStoreId' => Plugin::getInstance()->getStores()->getPrimaryStore()->id, - 'menuItems' => $menuItems, - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * Deletes a store. - * - * @return Response - */ - public function actionDeleteStore(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $siteId = $this->request->getRequiredBodyParam('id'); - - Plugin::getInstance()->getStores()->deleteStoreById($siteId); - - return $this->asSuccess(); - } - - /** - * @return Response - * @throws BadRequestHttpException - * @throws ErrorException - * @throws Exception - * @throws InvalidConfigException - * @throws NotSupportedException - * @throws ServerErrorHttpException - */ - public function actionReorderStores(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $ids = Json::decode($this->request->getRequiredBodyParam('ids')); - - if (!Plugin::getInstance()->getStores()->reorderStores($ids)) { - return $this->asFailure(Craft::t('commerce', 'Couldn’t reorder stores.')); - } - - return $this->asSuccess(); - } - - /** - * @param Collection|null $sitesStores - * @return Response - * @throws InvalidConfigException - */ - public function actionEditSiteStores(Collection $sitesStores = null): Response - { - // Breadcrumbs - $crumbs = [ - [ - 'label' => Craft::t('commerce', 'Commerce'), - 'url' => UrlHelper::url('commerce'), - ], - ]; - - return $this->renderTemplate('commerce/settings/stores/_siteStore', [ - 'crumbs' => $crumbs, - 'stores' => Plugin::getInstance()->getStores()->getAllStores(), - 'sites' => Craft::$app->getSites()->getAllSites(), - 'sitesStores' => $sitesStores ?? Plugin::getInstance()->getStores()->getAllSiteStores(), - 'primaryStoreId' => Plugin::getInstance()->getStores()->getPrimaryStore()->id, - 'readOnly' => $this->isReadOnlyScreen(), - ]); - } - - /** - * Saves the site settings records - * - * @return ?Response - */ - public function actionSaveSiteStores(): ?Response - { - $siteStoresData = $this->request->getBodyParam('siteStores', []); - $sitesStores = Plugin::getInstance()->getStores()->getAllSiteStores(); - $stores = Plugin::getInstance()->getStores()->getAllStores(); - - foreach ($sitesStores as $siteStore) { - if (isset($siteStoresData[$siteStore->siteId])) { - $siteStore->storeId = $siteStoresData[$siteStore->siteId]['storeId']; - } - } - - $unassignedStores = []; - foreach ($stores as $store) { - $storeAssigned = false; - foreach ($sitesStores as $siteStore) { - if ($siteStore->storeId == $store->id) { - $storeAssigned = true; - } - } - if (!$storeAssigned) { - $unassignedStores[] = $store->getName(); - } - } - if ($unassignedStores) { - return $this->asFailure( - Craft::t('commerce', '{storeNames} {num, plural, =1{has} other{have}} not been assigned to a site.', [ - 'storeNames' => implode(', ', $unassignedStores), - 'num' => count($unassignedStores), - ]), - routeParams: ['sitesStores' => collect($sitesStores)] - ); - } - - foreach ($sitesStores as $siteStore) { - Plugin::getInstance()->getStores()->saveSiteStore($siteStore); - } - - return $this->asSuccess(Craft::t('commerce', 'Site store mapping saved.')); - } -} diff --git a/src/controllers/SubscriptionsController.php b/src/controllers/SubscriptionsController.php deleted file mode 100644 index d7c7c785d7..0000000000 --- a/src/controllers/SubscriptionsController.php +++ /dev/null @@ -1,652 +0,0 @@ - - * @since 2.0 - */ -class SubscriptionsController extends BaseController -{ - /** - * @throws ForbiddenHttpException - */ - public function actionIndex(): Response - { - $this->requirePermission('commerce-manageSubscriptions'); - return $this->renderTemplate('commerce/subscriptions/_index'); - } - - /** - * @param int|null $subscriptionId - * @param Subscription|null $subscription - * @throws HttpException - * @throws InvalidConfigException - */ - public function actionEdit(int $subscriptionId = null, Subscription $subscription = null): Response - { - $variables = []; - - $this->getView()->registerAssetBundle(CommerceCpAsset::class); - - if ($subscription === null && $subscriptionId) { - /** @var Subscription|null $subscription */ - $subscription = Subscription::find()->status(null)->id($subscriptionId)->one(); - } - - if (!$subscription) { - throw new NotFoundHttpException('Subscription not found'); - } - - $this->enforceManageSubscriptionPermissions($subscription); - - $fieldLayout = Craft::$app->getFields()->getLayoutByType(Subscription::class); - - $form = $fieldLayout->createForm($subscription); - $tabMenu = $form->getTabMenu(); - $tabMenu['tab--subscriptionManageTab'] = [ - 'label' => Craft::t('commerce', 'Manage'), - 'url' => '#tab--subscriptionManageTab', - 'class' => null, - ]; - $variables['tabs'] = $tabMenu; - $variables['fieldsHtml'] = $form->render(); - - $variables['continueEditingUrl'] = $subscription->getCpEditUrl(); - $variables['subscriptionId'] = $subscriptionId; - $variables['subscription'] = $subscription; - $variables['fieldLayout'] = $fieldLayout; - - return $this->renderTemplate('commerce/subscriptions/_edit', $variables); - } - - /** - * Save a subscription's custom fields. - * - * @throws NotFoundHttpException if subscription not found - * @throws ForbiddenHttpException if permissions are lacking - * @throws HttpException if invalid data posted - * @throws Throwable if reasons - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - - $subscriptionId = $this->request->getRequiredBodyParam('subscriptionId'); - /** @var Subscription|null $subscription */ - $subscription = Subscription::find()->status(null)->id($subscriptionId)->one(); - - if (!$subscription) { - throw new NotFoundHttpException('Subscription not found'); - } - - if (!$this->_canUpdateSubscription($subscription) === true) { - $this->enforceManageSubscriptionPermissions($subscription); - } - - $subscription->setFieldValuesFromRequest('fields'); - - $subscription->setScenario(Element::SCENARIO_LIVE); - - if (!Craft::$app->getElements()->saveElement($subscription)) { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save subscription.')); - Craft::$app->getUrlManager()->setRouteParams([ - 'subscription' => $subscription, - ]); - return null; - } - - return $this->redirectToPostedUrl($subscription); - } - - /** - * Refreshes all subscription payments - * - * @throws BadRequestHttpException If not POST request - * @throws ForbiddenHttpException If permissions are lacking - * @throws NotFoundHttpException If subscription not found - * @throws InvalidConfigException - */ - public function actionRefreshPayments(): Response - { - $this->requirePostRequest(); - $this->requirePermission('commerce-manageSubscriptions'); - - $subscriptionId = $this->request->getRequiredBodyParam('subscriptionId'); - - if (!$subscription = Subscription::find()->status(null)->id($subscriptionId)->one()) { - throw new NotFoundHttpException('Subscription not found'); - } - - /** @var Subscription $subscription */ - $gateway = $subscription->getGateway(); - $gateway->refreshPaymentHistory($subscription); - - // Save - return $this->redirectToPostedUrl($subscription); - } - - /** - * @throws Exception - * @throws HttpException if request does not match requirements - * @throws InvalidConfigException if gateway does not support subscriptions - * @throws BadRequestHttpException - */ - public function actionSubscribe(): ?Response - { - $this->requireLogin(); - $this->requirePostRequest(); - $user = Craft::$app->getUser()->getIdentity(); - - $returnUrl = $this->request->getValidatedBodyParam('redirect'); - - $plugin = Commerce::getInstance(); - - $planUid = $this->request->getValidatedBodyParam('planUid'); - - if (!$planUid || !$plan = $plugin->getPlans()->getPlanByUid($planUid)) { - throw new InvalidConfigException('Subscription plan not found with that id.'); - } - - $error = null; - $subscription = null; - - try { - /** @var SubscriptionGateway $gateway */ - $gateway = $plan->getGateway(); - $parameters = $gateway->getSubscriptionFormModel(); - - foreach ($parameters->attributes() as $attributeName) { - $value = $this->request->getValidatedBodyParam($attributeName); - - if (is_string($value) && StringHelper::countSubstrings($value, ':') > 0) { - [$hashedPlanUid, $parameterValue] = explode(':', $value); - - if ($plan->uid == $hashedPlanUid) { - $parameters->{$attributeName} = $parameterValue; - } - } - } - - try { - $paymentFormData = $this->request->getBodyParam(PaymentForm::getPaymentFormParamName($gateway->handle)) ?? []; - - if (!empty($paymentFormData)) { - Craft::$app->getDeprecator()->log('SubscriptionController::create-newPaymentMethod', 'The subscription create action now requires that a customer’s default payment source is set up before subscribing, or pass the payment source information to the subscribe form.'); - - $createPaymentSource = function($gateway, $paymentFormData) use ($plugin) { - $paymentForm = $gateway->getPaymentFormModel(); - $paymentForm->setAttributes($paymentFormData, false); - - if ($paymentForm->validate()) { - $plugin->getPaymentSources()->createPaymentSource(Craft::$app->getUser()->getId(), $gateway, $paymentForm); - } - }; - - $exists = class_exists(PaymentIntents::class); - /** @phpstan-ignore-next-line */ - if ($exists && $plan->getGateway() instanceof PaymentIntents) { - if (isset($paymentFormData['paymentMethodId'])) { - $createPaymentSource($gateway, $paymentFormData); - } - } else { - $createPaymentSource($gateway, $paymentFormData); - } - } - - $fieldsLocation = $this->request->getParam('fieldsLocation', 'fields'); - $fieldValues = $this->request->getBodyParam($fieldsLocation, []); - - $subscription = $plugin->getSubscriptions()->createSubscription($user, $plan, $parameters, $fieldValues); - } catch (\Exception $exception) { - Craft::$app->getErrorHandler()->logException($exception); - - throw new SubscriptionException(Craft::t('commerce', 'Unable to start the subscription. ' . $exception->getMessage())); - } - } catch (SubscriptionException $exception) { - $error = $exception->getMessage(); - } - - if ($subscription && $returnUrl) { - $returnUrl = $this->getView()->renderSandboxedObjectTemplate($returnUrl, $subscription); - $subscriptionRecord = SubscriptionRecord::findOne($subscription->id); - $subscriptionRecord->returnUrl = $returnUrl; - $subscriptionRecord->save(); - $subscription->returnUrl = $returnUrl; - } - - if (!$error && $subscription && $subscription->isSuspended && !$subscription->hasStarted) { - $url = Plugin::getInstance()->getSettings()->updateBillingDetailsUrl; - - if (empty($url)) { - $error = Craft::t('commerce', 'Unable to start the subscription. Please check your payment details.'); - } else { - return $this->redirect(UrlHelper::url(App::parseEnv($url), ['subscription' => $subscription->uid])); - } - } - - if ($error) { - return $this->asFailure($error); - } - - return $this->asSuccess( - Craft::t('commerce', 'Subscription started.'), - data: [ - 'subscription' => $subscription ?? null, - ], - redirect: $returnUrl - ); - } - - /** - * @throws BadRequestHttpException - * @throws Throwable - */ - public function actionReactivate(): ?Response - { - $this->requireLogin(); - $this->requirePostRequest(); - - $plugin = Commerce::getInstance(); - - $error = false; - $subscription = null; - - try { - $subscriptionUid = $this->request->getValidatedBodyParam('subscriptionUid'); - /** @var Subscription|null $subscription */ - $subscription = Subscription::find()->status(null)->uid($subscriptionUid)->one(); - - $validData = $subscriptionUid && $subscription; - $validAction = $subscription->canReactivate(); - $canModifySubscription = Craft::$app->getElements()->canSave($subscription); - - if (($validData && $validAction && $canModifySubscription) || $this->_canUpdateSubscription($subscription)) { - if (!$plugin->getSubscriptions()->reactivateSubscription($subscription)) { - $error = Craft::t('commerce', 'Unable to reactivate subscription at this time.'); - } - } else { - $error = Craft::t('commerce', 'Unable to reactivate subscription at this time.'); - } - } catch (Exception $exception) { - $error = $exception->getMessage(); - } - - if ($error) { - return $this->asFailure($error); - } - - return $this->asSuccess( - Craft::t('commerce', 'Subscription reactivated.'), - data: [ - 'subscription' => $subscription, - ] - ); - } - - /** - * @throws InvalidConfigException - * @throws BadRequestHttpException - */ - public function actionSwitch(): ?Response - { - $this->requireLogin(); - $this->requirePostRequest(); - - $plugin = Commerce::getInstance(); - - $subscriptionUid = $this->request->getValidatedBodyParam('subscriptionUid'); - $planUid = $this->request->getValidatedBodyParam('planUid'); - - $error = false; - - try { - /** @var Subscription|null $subscription */ - $subscription = Subscription::find()->status(null)->uid($subscriptionUid)->one(); - $plan = Commerce::getInstance()->getPlans()->getPlanByUid($planUid); - - $validData = $planUid && $plan && $subscriptionUid && $subscription; - $validAction = $plan->canSwitchFrom($subscription->getPlan()); - $canModifySubscription = Craft::$app->getElements()->canSave($subscription); - - if (($validData && $validAction && $canModifySubscription) || $this->_canUpdateSubscription($subscription)) { - /** @var SubscriptionGateway $gateway */ - $gateway = $subscription->getGateway(); - $parameters = $gateway->getSwitchPlansFormModel(); - - foreach ($parameters->attributes() as $attributeName) { - $value = $this->request->getValidatedBodyParam($attributeName); - - if (is_string($value) && StringHelper::countSubstrings($value, ':') > 0) { - [$hashedPlanUid, $parameterValue] = explode(':', $value); - - if ($hashedPlanUid == $planUid) { - $parameters->{$attributeName} = $parameterValue; - } - } - } - - if (!$plugin->getSubscriptions()->switchSubscriptionPlan($subscription, $plan, $parameters)) { - $error = Craft::t('commerce', 'Unable to modify subscription at this time.'); - } - } else { - $error = Craft::t('commerce', 'Unable to modify subscription at this time.'); - } - } catch (SubscriptionException $exception) { - return $this->asFailure($exception->getMessage()); - } - - if ($error) { - return $this->asFailure($error); - } - - return $this->asSuccess( - Craft::t('commerce', 'Subscription switched.'), - data: [ - 'subscription' => $subscription, - ] - ); - } - - /** - * @throws InvalidConfigException - * @throws BadRequestHttpException - */ - public function actionCancel(): ?Response - { - $this->requireLogin(); - $this->requirePostRequest(); - - $plugin = Commerce::getInstance(); - - $error = false; - $subscription = null; - - try { - $subscriptionUid = $this->request->getValidatedBodyParam('subscriptionUid'); - /** @var Subscription|null $subscription */ - $subscription = Subscription::find()->status(null)->uid($subscriptionUid)->one(); - $validData = $subscriptionUid && $subscription; - - $canModifySubscription = Craft::$app->getElements()->canSave($subscription); - - if (($validData === true && $canModifySubscription === true) || $this->_canUpdateSubscription($subscription)) { - /** @var SubscriptionGateway $gateway */ - $gateway = $subscription->getGateway(); - $parameters = $gateway->getCancelSubscriptionFormModel(); - - foreach ($parameters->attributes() as $attributeName) { - $value = $this->request->getValidatedBodyParam($attributeName); - - if (is_string($value) && StringHelper::countSubstrings($value, ':') > 0) { - [$hashedSubscriptionUid, $parameterValue] = explode(':', $value); - - if ($hashedSubscriptionUid == $subscriptionUid) { - $parameters->{$attributeName} = $parameterValue; - } - } - } - - if (!$plugin->getSubscriptions()->cancelSubscription($subscription, $parameters)) { - $error = Craft::t('commerce', 'Unable to cancel subscription at this time.'); - } - } else { - $error = Craft::t('commerce', 'Unable to cancel subscription at this time.'); - } - } catch (SubscriptionException $exception) { - $error = $exception->getMessage(); - } - - if ($error) { - return $this->asFailure($error); - } - - return $this->asSuccess( - Craft::t('commerce', 'Subscription cancelled.'), - data: [ - 'subscription' => $subscription, - ] - ); - } - - public function actionCompleteSubscription(): ?Response - { - $subscriptionUid = $this->request->getRequiredQueryParam('subscription'); - $subscription = Subscription::find()->status(null)->uid($subscriptionUid)->one(); - - if (!$subscription) { - throw new NotFoundHttpException('Subscription not found'); - } - - $gateway = $subscription->getGateway(); - $transactionHash = $gateway->getTransactionHashFromWebhook(); - $useMutex = (bool)$transactionHash; - $transactionLockName = 'commerceTransaction:' . $transactionHash; - $mutex = Craft::$app->getMutex(); - - if ($useMutex && !$mutex->acquire($transactionLockName, 15)) { - throw new Exception('Unable to acquire a lock for transaction: ' . $transactionHash); - } - - $gateway->refreshPaymentHistory($subscription); - - if ($useMutex) { - $mutex->release($transactionLockName); - } - - return $this->asSuccess(redirect: $subscription->returnUrl); - } - - - /** - * @since 5.7.0 - */ - public function actionDeleteSubscriptionsModal(): Response - { - $this->requireCpRequest(); - $this->requireAcceptsJson(); - $this->requirePermission('deleteUsers'); - - $numSubscriptions = count($this->request->getRequiredParam('subscriptionIds')); - - return $this->_renderGatewayCancelModal('commerce/subscriptions/delete-subscriptions') - ->submitButtonLabel(Craft::t('app', 'Delete {type}', [ - 'type' => $numSubscriptions === 1 ? Subscription::lowerDisplayName() : Subscription::pluralLowerDisplayName(), - ])); - } - - /** - * @since 5.7.0 - */ - public function actionDeleteSubscriptions(): Response - { - $this->requireCpRequest(); - $this->requireAcceptsJson(); - $this->requirePermission('deleteUsers'); - - $subscriptions = $this->_subscriptionsFromRequest(); - $this->_cancelSubscriptionsAtGateway($subscriptions); - - foreach ($subscriptions as $subscription) { - if (!Craft::$app->getElements()->deleteElement($subscription)) { - Craft::warning('Failed to delete subscription ' . $subscription->id . ' (' . $subscription->reference . ')', __METHOD__); - } - } - - $numSubscriptions = count($subscriptions); - - return $this->asSuccess(Craft::t('app', '{type} deleted.', [ - 'type' => $numSubscriptions === 1 ? Subscription::displayName() : Subscription::pluralDisplayName(), - ])); - } - - /** - * Returns the gateway cancel modal response, with an action URL for the submit endpoint. - */ - private function _renderGatewayCancelModal(string $actionUrl): \craft\web\Response - { - $subscriptionIds = collect($this->request->getRequiredParam('subscriptionIds'))->filter()->map(fn($id) => (int)$id)->all(); - $gatewayId = (int)$this->request->getRequiredParam('gatewayId'); - - $gateway = Plugin::getInstance()->getGateways()->getGatewayById($gatewayId); - $subscription = Subscription::find()->id($subscriptionIds)->status(null)->one(); - - $cancelFormHtml = ''; - if ($gateway instanceof SubscriptionGateway && $subscription) { - $cancelFormHtml = $gateway->getCancelSubscriptionFormHtml($subscription); - } - - return $this->asCpModal() - ->action($actionUrl) - ->contentHtml(function() use ($cancelFormHtml, $subscriptionIds, $gatewayId) { - $view = Craft::$app->getView(); - - if ($cancelFormHtml) { - $view->registerJsWithVars( - fn($formId, $inputName) => <<namespaceInputId('cancel-form'), - $view->namespaceInputName('cancelWithGateway'), - ] - ); - } - - return Cp::fieldHtml('template:_includes/forms/radioGroup.twig', [ - 'label' => Craft::t('commerce', 'Gateway'), - 'name' => 'cancelWithGateway', - 'value' => '1', - 'options' => [ - ['label' => Craft::t('commerce', 'Cancel with gateway now'), 'value' => '1'], - ['label' => Craft::t('commerce', 'Leave gateway subscription as-is'), 'value' => '0'], - ], - ]) . - ($cancelFormHtml ? Html::tag('div', $cancelFormHtml, ['id' => 'cancel-form']) : '') . - implode('', array_map(fn($id) => Html::hiddenInput('subscriptionIds[]', (string)$id), $subscriptionIds)) . - Html::hiddenInput('gatewayId', (string)$gatewayId); - }); - } - - /** - * @return Subscription[] - */ - private function _subscriptionsFromRequest(): array - { - $subscriptionIds = collect($this->request->getRequiredParam('subscriptionIds'))->filter()->map(fn($id) => (int)$id)->all(); - - return Subscription::find() - ->id($subscriptionIds) - ->status(null) - ->all(); - } - - /** - * Cancels the given subscriptions at the gateway if the request opted in. Returns whether anything was cancelled. - * - * @param Subscription[] $subscriptions - */ - private function _cancelSubscriptionsAtGateway(array $subscriptions): bool - { - $cancelWithGateway = (bool)$this->request->getBodyParam('cancelWithGateway', false); - if (!$cancelWithGateway) { - return false; - } - - $gatewayId = (int)$this->request->getRequiredParam('gatewayId'); - $gateway = Plugin::getInstance()->getGateways()->getGatewayById($gatewayId); - if (!$gateway instanceof SubscriptionGateway) { - return false; - } - - $parameters = $gateway->getCancelSubscriptionFormModel(); - foreach ($parameters->attributes() as $attribute) { - $value = $this->request->getBodyParam($attribute); - if ($value !== null) { - $parameters->$attribute = $value; - } - } - - $subscriptionsService = Plugin::getInstance()->getSubscriptions(); - $cancelled = false; - - foreach ($subscriptions as $subscription) { - if (!$subscription->isExpired) { - try { - $subscriptionsService->cancelSubscription($subscription, $parameters); - $cancelled = true; - } catch (Throwable $e) { - Craft::warning('Failed to cancel subscription ' . $subscription->reference . ' with gateway: ' . $e->getMessage(), __METHOD__); - } - } - } - - return $cancelled; - } - - /** - * @param Subscription $subscription - * @throws ForbiddenHttpException - */ - protected function enforceManageSubscriptionPermissions(Subscription $subscription) - { - if (!Craft::$app->getElements()->canView($subscription)) { - throw new ForbiddenHttpException('User not authorized to view this subscription.'); - } - } - - /** - * @param Subscription $subscription - * @return bool - * @throws Throwable - */ - private function _canUpdateSubscription(Subscription $subscription): bool - { - $currentUser = Craft::$app->getUser()->getIdentity(); - - $isOwner = $subscription->userId === $currentUser->id; - $isFrontEnd = !Craft::$app->getRequest()->getIsCpRequest(); - - return ($isOwner === true && $isFrontEnd === true); - } -} diff --git a/src/controllers/TaxCategoriesController.php b/src/controllers/TaxCategoriesController.php deleted file mode 100644 index a2553e37a3..0000000000 --- a/src/controllers/TaxCategoriesController.php +++ /dev/null @@ -1,316 +0,0 @@ - - * @since 2.0 - */ -class TaxCategoriesController extends BaseTaxSettingsController -{ - /** - * @param string|null $storeHandle - * @return Response - * @throws InvalidConfigException - */ - public function actionIndex(?string $storeHandle = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $taxCategories = Plugin::getInstance()->getTaxCategories()->getAllTaxCategories(); - - // Generate table data with chips - $tableData = []; - foreach ($taxCategories as $taxCategory) { - $label = Html::encode(Craft::t('site', $taxCategory->name)); - $taxRates = $taxCategory->getTaxRates($store->id); - $tableData[] = [ - 'id' => $taxCategory->id, - 'title' => $label, - 'chip' => Cp::chipHtml($taxCategory, [ - 'labelHtml' => Html::a($label, $taxCategory->getCpEditUrl($store->id), [ - 'class' => ['chip-label', 'cell-bold'], - ]), - ]), - 'url' => $taxCategory->getCpEditUrl($store->id), - 'handle' => $taxCategory->handle, - 'description' => Html::encode(Craft::t('site', $taxCategory->description)), - 'default' => $taxCategory->default, - '_showDelete' => $taxRates->isEmpty() && (count($taxCategories) > 1 && !$taxCategory->default), - ]; - } - - $this->getView()->registerTranslations('commerce', [ - 'Default?', - 'Description', - 'Handle', - 'Name', - 'Set default category', - 'Used By Tax Rates', - 'Used by Tax Rates', - ]); - - $buttons = Plugin::getInstance()->getTaxes()->taxCategoryActionHtml(); - if (Plugin::getInstance()->getTaxes()->createTaxCategories()) { - $buttons .= Html::a(Craft::t('commerce', 'New tax category'), $store->getStoreSettingsUrl('taxcategories/new'), [ - 'class' => ['btn', 'submit', 'add', 'icon'], - ]); - } - - $tableData = Json::encode($tableData); - $deleteAction = Plugin::getInstance()->getTaxes()->deleteTaxCategories() ? "'commerce/tax-categories/delete'" : 'null'; - - $js = <<'; - } - } - }, - ]; - - var actions = [ - { - label: '', - icon: 'settings', - actions: [ - { - label: Craft.t('commerce', 'Set default category'), - action: 'commerce/tax-categories/set-default-category', - param: 'default', - value: 1, - allowMultiple: false - } - ] - } - ]; - - new Craft.VueAdminTable({ - columns: columns, - checkboxes: true, - actions: actions, - padded: true, - container: '#tax-vue-admin-table', - deleteAction: {$deleteAction}, - tableData: {$tableData}, - }); -JS; - - $this->getView()->registerJs($js, View::POS_END); - - return $this->asStoreManagementCpScreen($storeHandle, hasStoreSwitcher: false) - ->additionalButtonsHtml($buttons) - ->contentHtml(Html::tag('div', '', ['id' => 'tax-vue-admin-table'])); - } - - /** - * @param int|null $id - * @param TaxCategory|null $taxCategory - * @throws HttpException - */ - public function actionEdit(?string $storeHandle = null, int $id = null, TaxCategory $taxCategory = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $storeHandle = $store->handle; - - $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); - - if (!$taxCategory) { - if ($id) { - $taxCategory = Plugin::getInstance()->getTaxCategories()->getTaxCategoryById($id); - - if (!$taxCategory) { - throw new HttpException(404); - } - } else { - $taxCategory = new TaxCategory(); - } - } - - $title = $taxCategory->id ? $taxCategory->name : Craft::t('commerce', 'Create a new tax category'); - - DebugPanel::prependOrAppendModelTab(model: $taxCategory, prepend: true); - - $productTypesOptions = []; - if (!empty($productTypes)) { - $productTypesOptions = ArrayHelper::map($productTypes, 'id', fn($row) => ['label' => $row->name, 'value' => $row->id]); - } - - $allTaxCategoryIds = array_keys(Plugin::getInstance()->getTaxCategories()->getAllTaxCategories()); - $isDefaultAndOnlyCategory = $id && count($allTaxCategoryIds) === 1 && in_array($id, $allTaxCategoryIds); - - // Get all tax rates for all stores - $taxRates = collect(); - Plugin::getInstance()->getStores()->getAllStores()->each(fn(Store $s) => $taxRates->push(...Plugin::getInstance()->getTaxRates()->getAllTaxRates($s->id)->all())); - - $metaSidebar = ''; - if ($taxCategory->id) { - $metaSidebar = Cp::metadataHtml([ - Craft::t('app', 'Created at') => Craft::$app->getFormatter()->asDatetime($taxCategory->dateCreated, 'short'), - Craft::t('app', 'Updated at') => Craft::$app->getFormatter()->asDatetime($taxCategory->dateUpdated, 'short'), - ]); - } - - return $this->asStoreManagementCpScreen($storeHandle, false, false) - ->title($title) - ->addCrumb(Craft::t('commerce', 'Tax Categories'), $store->getStoreSettingsUrl('taxcategories')) - ->action('commerce/tax-categories/save') - ->redirectUrl($store->getStoreSettingsUrl('taxcategories')) - ->metaSidebarHtml($metaSidebar) - ->contentTemplate('commerce/store-management/tax/taxcategories/_edit', [ - 'taxCategory' => $taxCategory, - 'productTypes' => $productTypes, - 'productTypesOptions' => $productTypesOptions, - 'isDefaultAndOnlyCategory' => $isDefaultAndOnlyCategory, - 'taxRates' => $taxRates, - 'store' => $store, - ]); - } - - /** - * @throws BadRequestHttpException - * @throws Exception - * @noinspection Duplicates - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - - $taxCategory = new TaxCategory(); - - // Shared attributes - $taxCategory->id = $this->request->getBodyParam('taxCategoryId'); - $taxCategory->name = $this->request->getBodyParam('name'); - $taxCategory->handle = $this->request->getBodyParam('handle'); - $taxCategory->icon = $this->request->getBodyParam('icon'); - $taxCategory->color = $this->request->getBodyParam('color'); - $taxCategory->description = $this->request->getBodyParam('description'); - $taxCategory->default = (bool)$this->request->getBodyParam('default'); - - // Set the new product types - $postedProductTypes = $this->request->getBodyParam('productTypes', []) ?: []; - $productTypes = []; - foreach ($postedProductTypes as $productTypeId) { - if ($productTypeId && $productType = Plugin::getInstance()->getProductTypes()->getProductTypeById($productTypeId)) { - $productTypes[] = $productType; - } - } - $taxCategory->setProductTypes($productTypes); - - // Save it - if (!Plugin::getInstance()->getTaxCategories()->saveTaxCategory($taxCategory)) { - return $this->asModelFailure( - $taxCategory, - Craft::t('commerce', 'Couldn’t save tax category.'), - 'taxCategory' - ); - } - - return $this->asModelSuccess( - $taxCategory, - Craft::t('commerce', 'Tax category saved.'), - 'taxCategory' - ); - } - - /** - * @throws HttpException - */ - public function actionDelete(): ?Response - { - $this->requirePostRequest(); - - $id = $this->request->getBodyParam('id'); - $ids = $this->request->getBodyParam('ids'); - - if ((!$id && empty($ids)) || ($id && !empty($ids))) { - throw new BadRequestHttpException('id or ids must be specified.'); - } - - if ($id) { - // If it is just the one id we know it has come from an ajax request on the table - $this->requireAcceptsJson(); - $ids = [$id]; - } - - $failedIds = []; - foreach ($ids as $id) { - if (!Plugin::getInstance()->getTaxCategories()->deleteTaxCategoryById($id)) { - $failedIds[] = $id; - } - } - - if (!empty($failedIds)) { - return $this->asFailure(Craft::t('commerce', 'Could not delete {count, number} tax {count, plural, one{category} other{categories}}.', [ - 'count' => count($failedIds), - ])); - } - - return $this->asSuccess(Craft::t('commerce', 'Tax categories deleted.')); - } - - /** - * @throws MissingComponentException - * @throws Exception - * @throws BadRequestHttpException - * @since 3.2.9 - */ - public function actionSetDefaultCategory(): ?Response - { - $this->requirePostRequest(); - - $ids = $this->request->getRequiredBodyParam('ids'); - - if (!empty($ids)) { - $id = ArrayHelper::firstValue($ids); - - $taxCategory = Plugin::getInstance()->getTaxCategories()->getTaxCategoryById($id); - if ($taxCategory) { - $taxCategory->default = true; - if (Plugin::getInstance()->getTaxCategories()->saveTaxCategory($taxCategory)) { - $this->setSuccessFlash(Craft::t('commerce', 'Tax category updated.')); - return null; - } - } - } - - $this->setFailFlash(Craft::t('commerce', 'Unable to set default tax category.')); - return null; - } -} diff --git a/src/controllers/TaxRatesController.php b/src/controllers/TaxRatesController.php deleted file mode 100644 index 7e1da5e30a..0000000000 --- a/src/controllers/TaxRatesController.php +++ /dev/null @@ -1,363 +0,0 @@ - - * @since 2.0 - */ -class TaxRatesController extends BaseTaxSettingsController -{ - /** - * @param string|null $storeHandle - * @return Response - * @throws StoreNotFoundException - * @throws InvalidConfigException - */ - public function actionIndex(?string $storeHandle = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $plugin = Plugin::getInstance(); - $taxRates = $plugin->getTaxRates()->getAllTaxRates($store->id); - - // Preload all zone and category data for listing. - $plugin->getTaxZones()->getAllTaxZones($store->id); - $plugin->getTaxCategories()->getAllTaxCategories(); - - // Generate table data - $tableData = []; - foreach ($taxRates as $taxRate) { - $label = Html::encode(Craft::t('site', $taxRate->name)); - $tableData[] = [ - 'id' => $taxRate->id, - 'status' => $taxRate->enabled, - 'title' => Html::a($label, $taxRate->getCpEditUrl()), - 'url' => $taxRate->getCpEditUrl(), - 'rate' => $taxRate->getRateAsPercent(), - 'included' => $taxRate->include, - 'removeIncluded' => $taxRate->removeIncluded, - 'vat' => $taxRate->isVat, - 'zone' => $taxRate->isEverywhere ? Craft::t('commerce', 'Everywhere') : ($taxRate->taxZone ? Html::encode($taxRate->taxZone->name) : ''), - 'category' => $taxRate->taxCategory ? Cp::chipHtml($taxRate->taxCategory) : '', - ]; - } - - $this->getView()->registerTranslations('commerce', [ - 'Include in price?', - 'Remove from price?', - 'Name', - 'Rate', - 'Tax Category', - 'Tax Zone', - 'Yes', - ]); - - $buttonsHtml = Plugin::getInstance()->getTaxes()->taxRateActionHtml(); - - if (Plugin::getInstance()->getTaxes()->createTaxRates()) { - $buttonsHtml .= Html::a(Craft::t('commerce', 'New tax rate'), "commerce/store-management/$storeHandle/taxrates/new", [ - 'class' => 'btn submit add icon', - ]); - } - - $tableData = Json::encode($tableData, JSON_UNESCAPED_UNICODE); - $deleteAction = Plugin::getInstance()->getTaxes()->deleteTaxRates() ? 'commerce/tax-rates/delete' : null; - - $js = <<'; - } - } }, - { name: 'removeIncluded', title: Craft.t('commerce', 'Remove from price?'), callback: function(value) { - if (value) { - return ''; - } - } }, - { name: 'zone', title: Craft.t('commerce', 'Tax Zone') }, - { name: 'category', title: Craft.t('commerce', 'Tax Category') } -]; - -var actions = [ - { - label: Craft.t('commerce', 'Set status'), - actions: [ - { - label: Craft.t('commerce', 'Enabled'), - action: 'commerce/tax-rates/update-status', - param: 'status', - value: 'enabled', - status: 'enabled' - }, - { - label: Craft.t('commerce', 'Disabled'), - action: 'commerce/tax-rates/update-status', - param: 'status', - value: 'disabled', - status: 'disabled' - } - ] - } -]; - -new Craft.VueAdminTable({ - columns: columns, - actions: actions, - checkboxes: true, - container: '#taxrate-vue-admin-table', - deleteAction: '{$deleteAction}', - tableData: {$tableData}, -}); -JS; - - $this->getView()->registerJs($js, View::POS_END); - - return $this->asStoreManagementCpScreen($storeHandle) - ->additionalButtonsHtml($buttonsHtml) - ->contentHtml(Html::tag('div', '', ['id' => 'taxrate-vue-admin-table'])); - } - - /** - * @param int|null $id - * @param TaxRate|null $taxRate - * @throws ForbiddenHttpException - * @throws HttpException - * @throws \Twig\Error\LoaderError - * @throws \Twig\Error\RuntimeError - * @throws \Twig\Error\SyntaxError - * @throws Exception - */ - public function actionEdit(?string $storeHandle = null, int $id = null, TaxRate $taxRate = null): Response - { - if (!Plugin::getInstance()->getTaxes()->viewTaxRates()) { - throw new ForbiddenHttpException('Tax engine does not permit you to perform this action'); - } - - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $storeHandle = $store->handle; - $percentSymbol = Craft::$app->getFormattingLocale()->getNumberSymbol(Locale::SYMBOL_PERCENT); - - $plugin = Plugin::getInstance(); - - if (!$taxRate) { - if ($id) { - $taxRate = $plugin->getTaxRates()->getTaxRateById($id, $store->id); - - if (!$taxRate) { - throw new HttpException(404); - } - } else { - $taxRate = Craft::createObject([ - 'class' => TaxRate::class, - 'storeId' => $store->id, - ]); - } - } - - $title = $taxRate->id ? $taxRate->name : Craft::t('commerce', 'Create a new tax rate'); - - DebugPanel::prependOrAppendModelTab(model: $taxRate, prepend: true); - - $variables = compact('taxRate', 'store', 'storeHandle', 'percentSymbol'); - - // Get the actual tax zone object if there's an ID - $taxZone = null; - if ($taxRate->taxZoneId) { - $taxZone = $plugin->getTaxZones()->getTaxZoneById($taxRate->taxZoneId, $store->id); - } - - // Get the actual tax category object if there's an ID - $taxCategory = null; - if ($taxRate->taxCategoryId) { - $taxCategory = $plugin->getTaxCategories()->getTaxCategoryById($taxRate->taxCategoryId); - } - - // Tax zone field with slideout - $variables['taxZoneField'] = CommerceCp::taxZoneFieldHtml([ - 'label' => Craft::t('commerce', 'Tax Zone'), - 'instructions' => Craft::t('commerce', 'Select a tax zone. If empty, this rate will match anywhere.'), - 'id' => 'taxZoneId', - 'name' => 'taxZoneId', - 'value' => $taxZone, - 'errors' => $taxRate->getErrors('taxZoneId'), - 'required' => false, - 'limit' => 1, - 'storeId' => $store->id, - 'storeHandle' => $storeHandle, - ]); - - // Tax category field with slideout - $variables['taxCategoryField'] = CommerceCp::taxCategoryFieldHtml([ - 'label' => Craft::t('commerce', 'Tax Category'), - 'instructions' => Craft::t('commerce', 'Select a tax category.'), - 'id' => 'taxCategoryId', - 'name' => 'taxCategoryId', - 'value' => $taxCategory, - 'errors' => $taxRate->getErrors('taxCategoryId'), - 'required' => true, - 'limit' => 1, - 'storeHandle' => $storeHandle, - ]); - - $taxable = []; - $taxable[TaxRateRecord::TAXABLE_PURCHASABLE] = Craft::t('commerce', 'Unit price (minus discounts)'); - $taxable[TaxRateRecord::TAXABLE_PRICE] = Craft::t('commerce', 'Line item price (minus discounts)'); - $taxable[TaxRateRecord::TAXABLE_SHIPPING] = Craft::t('commerce', 'Line item shipping cost'); - $taxable[TaxRateRecord::TAXABLE_PRICE_SHIPPING] = Craft::t('commerce', 'Both (Line item price + Line item shipping costs)'); - $taxable[TaxRateRecord::TAXABLE_ORDER_TOTAL_SHIPPING] = Craft::t('commerce', 'Order total shipping cost'); - $taxable[TaxRateRecord::TAXABLE_ORDER_TOTAL_PRICE] = Craft::t('commerce', 'Order total taxable price (Line item subtotal + Total discounts + Total shipping)'); - $variables['taxables'] = $taxable; - $variables['taxablesNoTaxCategory'] = TaxRateRecord::ORDER_TAXABALES; - - $variables['hideTaxCategory'] = false; - if ($variables['taxRate']->id && in_array($variables['taxRate']->taxable, $variables['taxablesNoTaxCategory'], false)) { - $variables['hideTaxCategory'] = true; - } - - $taxIdValidators = Plugin::getInstance()->getTaxes()->getEnabledTaxIdValidators(); - foreach ($taxIdValidators as $validator) { - $variables['taxIdValidators'][] = $validator; - } - - return $this->asStoreManagementCpScreen($storeHandle, false) - ->title($title) - ->addCrumb(Craft::t('commerce', 'Tax Rates'), $store->getStoreSettingsUrl('taxrates')) - ->selectedSubnavItem('store-management') - ->action('commerce/tax-rates/save') - ->redirectUrl($store->getStoreSettingsUrl('taxrates')) - ->metaSidebarTemplate('commerce/store-management/tax/taxrates/_sidebar', $variables) - ->contentTemplate('commerce/store-management/tax/taxrates/_edit', $variables); - } - - /** - * @throws Exception - * @throws ForbiddenHttpException - * @throws BadRequestHttpException - */ - public function actionSave(): void - { - if (!Plugin::getInstance()->getTaxes()->editTaxRates()) { - throw new ForbiddenHttpException('Tax engine does not permit you to perform this action'); - } - - $this->requirePostRequest(); - - $taxRate = new TaxRate(); - - // Shared attributes - $taxRate->id = $this->request->getBodyParam('taxRateId'); - $taxRate->storeId = $this->request->getBodyParam('storeId'); - $taxRate->name = $this->request->getBodyParam('name'); - $taxRate->code = $this->request->getBodyParam('code'); - $taxRate->include = (bool)$this->request->getBodyParam('include'); - $taxRate->removeIncluded = (bool)$this->request->getBodyParam('removeIncluded'); - $taxRate->removeVatIncluded = (bool)$this->request->getBodyParam('removeVatIncluded'); - $taxRate->taxable = $this->request->getBodyParam('taxable'); - $taxRate->taxCategoryId = (int)$this->request->getBodyParam('taxCategoryId') ?: null; - $taxRate->taxZoneId = (int)$this->request->getBodyParam('taxZoneId') ?: null; - $taxRate->rate = Localization::normalizePercentage($this->request->getBodyParam('rate')); - $taxRate->enabled = (bool)($this->request->getBodyParam('enabled')); - - // data comes in as className => bool, we want just the class names that are true - $validators = collect($this->request->getBodyParam('taxIdValidators'))->filter(fn($enabled) => (bool)$enabled)->keys(); - $taxRate->taxIdValidators = $validators->toArray(); - - // Save it - if (Plugin::getInstance()->getTaxRates()->saveTaxRate($taxRate)) { - $this->setSuccessFlash(Craft::t('commerce', 'Tax rate saved.')); - $this->redirectToPostedUrl($taxRate); - } else { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t save tax rate.')); - } - - // Send the model back to the template - Craft::$app->getUrlManager()->setRouteParams([ - 'taxRate' => $taxRate, - ]); - } - - /** - * @throws BadRequestHttpException - * @throws ForbiddenHttpException - */ - public function actionDelete(): Response - { - if (!Plugin::getInstance()->getTaxes()->deleteTaxRates()) { - throw new ForbiddenHttpException('Tax engine does not permit you to perform this action'); - } - - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $id = $this->request->getRequiredBodyParam('id'); - - Plugin::getInstance()->getTaxRates()->deleteTaxRateById($id); - return $this->asSuccess(); - } - - /** - * @throws BadRequestHttpException - * @throws Exception - * @since 5.x - */ - public function actionUpdateStatus(): void - { - $this->requirePostRequest(); - $ids = $this->request->getRequiredBodyParam('ids'); - $status = $this->request->getRequiredBodyParam('status'); - - if (empty($ids)) { - $this->setFailFlash(Craft::t('commerce', 'Couldn’t update status.')); - } - - $transaction = Craft::$app->getDb()->beginTransaction(); - $taxRates = TaxRateRecord::find() - ->where(['id' => $ids]) - ->all(); - - /** @var TaxRateRecord $taxRate */ - foreach ($taxRates as $taxRate) { - $taxRate->enabled = ($status == 'enabled'); - $taxRate->save(); - } - $transaction->commit(); - - $this->setSuccessFlash(Craft::t('commerce', 'Tax rates updated.')); - } -} diff --git a/src/controllers/TaxZonesController.php b/src/controllers/TaxZonesController.php deleted file mode 100644 index 28fe4bbb32..0000000000 --- a/src/controllers/TaxZonesController.php +++ /dev/null @@ -1,233 +0,0 @@ - - * @since 2.0 - */ -class TaxZonesController extends BaseTaxSettingsController -{ - /** - * @param string|null $storeHandle - * @return Response - * @throws StoreNotFoundException - * @throws InvalidConfigException - */ - public function actionIndex(?string $storeHandle = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $taxZones = Plugin::getInstance()->getTaxZones()->getAllTaxZones($store->id); - - // Generate table data - $tableData = []; - foreach ($taxZones as $taxZone) { - $label = Html::encode(Craft::t('site', $taxZone->name)); - $tableData[] = [ - 'id' => $taxZone->id, - 'title' => Html::a($label, $taxZone->getCpEditUrl()), - 'url' => $taxZone->getCpEditUrl(), - 'description' => Html::encode(Craft::t('site', $taxZone->description)), - 'default' => $taxZone->default, - ]; - } - - $this->getView()->registerTranslations('commerce', [ - 'Name', - 'Description', - 'Default Zone', - ]); - - $tableData = Json::encode($tableData); - - $js = <<'; - } - } - }, -]; - -new Craft.VueAdminTable({ - columns: columns, - container: '#tax-vue-admin-table', - deleteAction: 'commerce/tax-zones/delete', - tableData: {$tableData}, - }); -JS; - $this->getView()->registerJs($js, View::POS_END); - - return $this->asStoreManagementCpScreen($storeHandle) - ->additionalButtonsHtml(Html::a(Craft::t('commerce', 'New tax zone'), $store->getStoreSettingsUrl('taxzones/new'), ['class' => 'btn submit add icon'])) - ->contentHtml(Html::tag( - 'div', - '', - ['id' => 'tax-vue-admin-table'] - )); - } - - /** - * @param int|null $id - * @param TaxAddressZone|null $taxZone - * @throws HttpException - */ - public function actionEdit(?string $storeHandle = null, int $id = null, TaxAddressZone $taxZone = null): Response - { - if ($storeHandle === null || !$store = Plugin::getInstance()->getStores()->getStoreByHandle($storeHandle)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - $storeHandle = $store->handle; - - if (!$taxZone) { - if ($id) { - $taxZone = Plugin::getInstance()->getTaxZones()->getTaxZoneById($id, $store->id); - - if (!$taxZone) { - throw new HttpException(404); - } - } else { - $taxZone = Craft::createObject([ - 'class' => TaxAddressZone::class, - 'storeId' => $store->id, - ]); - } - } - - $title = $taxZone->id ? $taxZone->name : Craft::t('commerce', 'Create a tax zone'); - - $condition = $taxZone->getCondition(); - $condition->mainTag = 'div'; - $condition->name = 'condition'; - $condition->id = 'condition'; - - DebugPanel::prependOrAppendModelTab(model: $taxZone, prepend: true); - - $metaSidebar = ''; - if ($taxZone->id) { - $metaSidebar = Cp::metadataHtml([ - Craft::t('app', 'Created at') => Craft::$app->getFormatter()->asDatetime($taxZone->dateCreated, 'short'), - Craft::t('app', 'Updated at') => Craft::$app->getFormatter()->asDatetime($taxZone->dateUpdated, 'short'), - ]); - } - - return $this->asStoreManagementCpScreen($storeHandle, false) - ->title($title) - ->addCrumb(Craft::t('commerce', 'Tax Zones'), $store->getStoreSettingsUrl('taxzones')) - ->selectedSubnavItem('store-management') - ->action('commerce/tax-zones/save') - ->redirectUrl($store->getStoreSettingsUrl('taxzones')) - ->metaSidebarHtml($metaSidebar) - ->contentTemplate('commerce/store-management/tax/taxzones/_edit', [ - 'taxZone' => $taxZone, - 'store' => $store, - 'condition' => $condition, - ]); - } - - /** - * @throws Exception - * @throws BadRequestHttpException - */ - public function actionSave(): ?Response - { - $this->requirePostRequest(); - - $taxZone = new TaxAddressZone(); - - $taxZone->id = $this->request->getBodyParam('taxZoneId'); - $taxZone->storeId = $this->request->getBodyParam('storeId'); - $taxZone->name = $this->request->getBodyParam('name'); - $taxZone->description = $this->request->getBodyParam('description'); - $taxZone->default = (bool)$this->request->getBodyParam('default'); - $taxZone->setCondition($this->request->getBodyParam('condition')); - - if ($taxZone->validate() && Plugin::getInstance()->getTaxZones()->saveTaxZone($taxZone)) { - return $this->asModelSuccess( - $taxZone, - Craft::t('commerce', 'Tax zone saved.'), - 'taxZone', - data: [ - 'id' => $taxZone->id, - 'name' => $taxZone->name, - ] - ); - } - - return $this->asModelFailure( - $taxZone, - Craft::t('commerce', 'Couldn’t save tax zone.'), - 'taxZone' - ); - } - - /** - * @throws HttpException - */ - public function actionDelete(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $id = $this->request->getRequiredBodyParam('id'); - - Plugin::getInstance()->getTaxZones()->deleteTaxZoneById($id); - return $this->asSuccess(); - } - - /** - * @throws BadRequestHttpException - * @throws LoaderError - * @throws SyntaxError - * @since 2.2 - */ - public function actionTestZip(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $zipCodeFormula = (string)$this->request->getRequiredBodyParam('zipCodeConditionFormula'); - $testZipCode = (string)$this->request->getRequiredBodyParam('testZipCode'); - - $params = ['zipCode' => $testZipCode]; - if (!Plugin::getInstance()->getFormulas()->evaluateCondition($zipCodeFormula, $params)) { - return $this->asFailure('failed'); - } - - return $this->asSuccess(); - } -} diff --git a/src/controllers/TransfersController.php b/src/controllers/TransfersController.php deleted file mode 100644 index 7be7113cf2..0000000000 --- a/src/controllers/TransfersController.php +++ /dev/null @@ -1,346 +0,0 @@ - - * @since 5.1.0 - */ -class TransfersController extends BaseCpController -{ - /** - * @return void - * @throws \yii\base\InvalidConfigException - * @throws \yii\web\ForbiddenHttpException - */ - public function init(): void - { - parent::init(); - - $this->requirePermission('commerce-manageInventoryTransfers'); - } - - /** - * @return Response - */ - public function actionCreate(): Response - { - $user = static::currentUser(); - $transfer = Craft::createObject(Transfer::class); - - if (!Craft::$app->getElements()->canSave($transfer, $user)) { - throw new ForbiddenHttpException('User not authorized to save this transfer.'); - } - - $transfer->setScenario(Element::SCENARIO_ESSENTIALS); - $success = Craft::$app->getDrafts()->saveElementAsDraft($transfer, Craft::$app->getUser()->getId(), null, null, false); - - if (!$success) { - return $this->asModelFailure($transfer, Craft::t('app', 'Couldn’t create {type}.', [ - 'type' => Transfer::lowerDisplayName(), - ]), 'transfer'); - } - - $editUrl = $transfer->getCpEditUrl(); - - $response = $this->asModelSuccess($transfer, Craft::t('app', '{type} created.', [ - 'type' => Transfer::displayName(), - ]), 'transfer', array_filter([ - 'cpEditUrl' => $this->request->isCpRequest ? $editUrl : null, - ])); - - if (!$this->request->getAcceptsJson()) { - $response->redirect(UrlHelper::urlWithParams($editUrl, [ - 'fresh' => 1, - ])); - } - - return $response; - } - - /** - * @return Response - */ - public function actionIndex(): Response - { - return $this->renderTemplate('commerce/inventory/transfers/_index'); - } - - /** - * @return Response - * @throws \yii\base\InvalidConfigException - * @throws \yii\web\BadRequestHttpException - * @throws \yii\web\MethodNotAllowedHttpException - */ - public function actionMarkAsPending(): Response - { - $this->requirePostRequest(); - - $transferId = $this->request->getRequiredBodyParam('transferId'); - $transfer = Transfer::findOne($transferId); - $transfer->transferStatus = TransferStatusType::PENDING; - - if (!Craft::$app->getElements()->saveElement($transfer)) { - return $this->asFailure(Craft::t('app', 'Couldn’t mark transfer as pending.')); - } - - return $this->asSuccess(Craft::t('app', 'Transfer marked as pending.')); - } - - /** - * @return Response - */ - public function actionSaveSettings(): Response - { - $this->requirePostRequest(); - - $fieldLayout = Craft::$app->getFields()->assembleLayoutFromPost(); - - $fieldLayout->reservedFieldHandles = [ - 'originLocationId', - 'originLocation', - 'destinationLocationId', - 'destinationLocation', - ]; - - $fieldLayout->type = Transfer::class; - - if (!$fieldLayout->validate()) { - Craft::info('Field layout not saved due to validation error.', __METHOD__); - - Craft::$app->getUrlManager()->setRouteParams([ - 'variables' => [ - 'fieldLayout' => $fieldLayout, - ], - ]); - - return $this->asFailure(Craft::t('commerce', 'Couldn’t save transfer fields.')); - } - - if ($currentTransfersFieldLayout = Craft::$app->getProjectConfig()->get(Transfers::CONFIG_FIELDLAYOUT_KEY)) { - $uid = array_key_first($currentTransfersFieldLayout); - } else { - $uid = StringHelper::UUID(); - } - - $configData = [$uid => $fieldLayout->getConfig()]; - $result = Craft::$app->getProjectConfig()->set(Transfers::CONFIG_FIELDLAYOUT_KEY, $configData, force: true); - - if (!$result) { - return $this->asFailure(Craft::t('app', 'Couldn’t save transfer fields.')); - } - - return $this->asSuccess(Craft::t('commerce', 'Transfer fields saved.')); - } - - /** - * @return Response - */ - public function actionReceiveTransfer(): Response - { - $details = $this->request->getParam('details', []); - $transferId = $this->request->getRequiredParam('transferId'); - /** @var Transfer $transfer */ - $transfer = Transfer::find()->id($transferId)->one(); - - $inventoryMovementCollection = new InventoryMovementCollection(); - $inventoryUpdateCollection = new UpdateInventoryLevelCollection(); - - $transferDetails = $transfer->getDetails(); - - foreach ($transferDetails as $detail) { - if ($acceptedAmount = $details[$detail->uid]['accept'] ?? null) { - // Update the total accepted - $detail->quantityAccepted += $acceptedAmount; - - $inventoryAcceptedMovement = new InventoryTransferMovement(); - $inventoryAcceptedMovement->quantity = $acceptedAmount; - $inventoryAcceptedMovement->transferId = $transfer->id; - $inventoryAcceptedMovement->setInventoryItem($detail->getInventoryItem()); - $inventoryAcceptedMovement->toInventoryLocation = $transfer->getDestinationLocation(); - $inventoryAcceptedMovement->fromInventoryLocation = $transfer->getDestinationLocation(); // we are moving from incoming to available - $inventoryAcceptedMovement->toInventoryTransactionType = InventoryTransactionType::AVAILABLE; - $inventoryAcceptedMovement->fromInventoryTransactionType = InventoryTransactionType::INCOMING; - - $inventoryMovementCollection->push($inventoryAcceptedMovement); - } - - if ($rejectedAmount = $details[$detail->uid]['reject'] ?? null) { - // Update the total rejected - $detail->quantityRejected += $rejectedAmount; - - $inventoryRejectedMovement = new UpdateInventoryLevel(); - $inventoryRejectedMovement->quantity = $rejectedAmount * -1; - $inventoryRejectedMovement->updateAction = InventoryUpdateQuantityType::ADJUST; - $inventoryRejectedMovement->inventoryItemId = $detail->inventoryItemId; - $inventoryRejectedMovement->transferId = $transfer->id; - $inventoryRejectedMovement->setInventoryLocation($transfer->getDestinationLocation()); - $inventoryRejectedMovement->type = InventoryTransactionType::INCOMING->value; - - $inventoryUpdateCollection->push($inventoryRejectedMovement); - } - } - - $transfer->setDetails($transferDetails); - - try { - // Accepted movement - Plugin::getInstance()->getInventory()->executeInventoryMovements($inventoryMovementCollection); - // Rejected updates - Plugin::getInstance()->getInventory()->executeUpdateInventoryLevels($inventoryUpdateCollection); - Craft::$app->getElements()->saveElement($transfer, false); - } catch (\Throwable $e) { - Craft::error('Failed to save transfer details: ' . $e->getMessage(), __METHOD__); - return $this->asFailure(Craft::t('commerce', 'Failed to receive transfer: {error}', ['error' => $e->getMessage()])); - } - - return $this->asSuccess(Craft::t('commerce', 'Updated')); - } - - /** - * @return Response - */ - public function actionReceiveTransferScreen(): Response - { - $transferId = $this->request->getRequiredParam('transferId'); - /** @var ?Transfer $transfer */ - $transfer = Transfer::find()->id($transferId)->one(); - - if (!$transfer) { - return $this->asCpScreen() - ->contentHtml('Cant find transfer'); - } - - $html = Html::beginTag('div', [ - 'hx' => [ - 'action' => 'commerce/transfers/receive-transfer-modal-content', - ], - ]); - - $html .= Html::tag('h2', Craft::t('commerce', 'Receive Transfer')); - - $html .= Html::hiddenInput('transferId', $transferId); - - // @TODO Add shortcut links to accept-all and reject-all unreceived items in the receive-transfer modal - // $html .= Html::a(Craft::t('commerce', 'Accept All Unreceived'), '#'); - // $html .= Html::a(Craft::t('commerce', 'Reject All Unreceived'), '#'); - - $tableRows = ''; - foreach ($transfer->getDetails() as $detail) { - $deleted = $detail->inventoryItemId == null; - $key = $detail->uid; - $purchasable = $detail->getInventoryItem()?->getPurchasable(CraftCp::requestedSite()->id); - $label = $purchasable ? CraftCp::elementChipHtml($purchasable) : $detail->inventoryItemDescription; - $tableRows .= Html::beginTag('tr'); - $tableRows .= Html::tag('td', $label); - $tableRows .= Html::tag('td', (string)$detail->quantityAccepted, ['class' => 'rightalign']); - $tableRows .= Html::tag('td', - Html::input('number', 'details[' . $key . '][accept]', '', [ - 'class' => 'text fullwidth', - 'disabled' => $deleted, - 'placeholder' => $deleted ? Craft::t('app', '“{name}” deleted.', ['name' => $detail->inventoryItemDescription]) : '', - ]) - ); - $tableRows .= Html::tag('td', (string)$detail->quantityRejected, ['class' => 'rightalign']); - $tableRows .= Html::tag('td', - Html::input('number', 'details[' . $key . '][reject]', '', [ - 'class' => 'text fullwidth', - 'disabled' => $deleted, - 'placeholder' => $deleted ? Craft::t('app', '“{name}” deleted.', ['name' => $detail->inventoryItemDescription]) : '', - ]) - ); - } - - $html .= Html::tag('table', - Html::tag('thead', - Html::tag('tr', - Html::tag('th', Craft::t('commerce', 'Item')) . - Html::tag('th', Craft::t('commerce', 'Accepted'), ['class' => 'rightalign']) . - Html::tag('th', Craft::t('commerce', 'Accept')) . - Html::tag('th', Craft::t('commerce', 'Rejected'), ['class' => 'rightalign']) . - Html::tag('th', Craft::t('commerce', 'Reject')) - ) - ) . - $tableRows, - ['class' => 'data fullwidth']); - - $html .= Html::endTag('div'); - - return $this->asCpScreen() - ->action('commerce/transfers/receive-transfer') - ->submitButtonLabel(Craft::t('commerce', 'Receive')) -// ->additionalButtonsHtml($acceptAllUnreceivedButton) - ->contentHtml($html); - } - - public function actionRenderManagement(): string - { - $transferId = $this->request->getRequiredParam('transferId'); - - /** @var ?Transfer $transfer */ - $transfer = Transfer::find()->id($transferId)->drafts(null)->one(); - - // We will only change the transfer if it is a draft. - if ($transfer && $transfer->isTransferDraft()) { - $allLocations = Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocations(); - $defaultFirstLocationId = $allLocations->first()->id; - $defaultSecondLocationId = $allLocations->skip(1)->first()->id; - - $originLocationId = (int)$this->request->getParam('originLocationId', $defaultFirstLocationId); - $destinationLocationId = (int)$this->request->getParam('destinationLocationId', $defaultSecondLocationId); - - $transfer->originLocationId = $originLocationId; - $transfer->destinationLocationId = $destinationLocationId; - - $details = $this->request->getParam('details', []); - $transfer->setDetails($details); - - $details = $this->request->getParam('details', []); - - if ($this->request->getParam('removeInventoryItemUid')) { - $details = array_filter($details, fn($detail) => $detail['uid'] !== $this->request->getParam('removeInventoryItemUid')); - } - $transfer->setDetails($details); - - $addItem = $this->request->getParam('addItem', false); - $addInventoryItemId = $this->request->getParam('newInventoryItemId', null); - if ($addItem && $addInventoryItemId) { - $transfer->addDetail(new TransferDetail([ - 'uid' => StringHelper::UUID(), - 'inventoryItemId' => $addInventoryItemId, - 'quantity' => 1, - ])); - } - } - - return TransferManagementField::renderFieldHtml($transfer); - } -} diff --git a/src/controllers/UserOrdersController.php b/src/controllers/UserOrdersController.php deleted file mode 100644 index 2a6680c6a1..0000000000 --- a/src/controllers/UserOrdersController.php +++ /dev/null @@ -1,46 +0,0 @@ - - * @since 4.0 - */ -class UserOrdersController extends BaseFrontEndController -{ - /** - * Get customer's orders - * - * @throws BadRequestHttpException - */ - public function actionGetOrders(): Response - { - $this->requireAcceptsJson(); - - /** @var User|CustomerBehavior|null $user */ - $user = Craft::$app->getUser()->getIdentity(); - - if (!$user) { - return $this->asFailure(Craft::t('commerce', 'No user authenticated.')); - } - - $orders = $user->getOrders(); - - return $this->asSuccess(data: [ - 'orders' => $orders, - ]); - } -} diff --git a/src/controllers/UsersController.php b/src/controllers/UsersController.php deleted file mode 100644 index a000d548cb..0000000000 --- a/src/controllers/UsersController.php +++ /dev/null @@ -1,141 +0,0 @@ - - * @since 5.0.0 - */ -class UsersController extends BaseFrontEndController -{ - use EditUserTrait; - - public const SCREEN_COMMERCE = 'commerce'; - - /** - * @param int|null $userId - * @return Response - * @throws BadRequestHttpException - * @throws ForbiddenHttpException - * @throws \Throwable - * @throws InvalidConfigException - */ - public function actionIndex(?int $userId = null): Response - { - $user = $this->editedUser($userId); - - /** @var Response|CpScreenResponseBehavior $response */ - $response = $this->asEditUserScreen($user, 'commerce'); - - $view = Craft::$app->getView(); - $view->registerAssetBundle(CommerceCpAsset::class); - - $config = [ - 'context' => 'embedded-index', - 'sources' => false, - 'showSiteMenu' => true, - 'jsSettings' => [ - 'criteria' => ['customerId' => $user->id], - ], - ]; - - $edge = Plugin::getInstance()->getCarts()->getActiveCartEdgeDuration(); - - $content = ''; - $key = 'Commerce-Users-element-indexes-%s'; - - if (Craft::$app->getUser()->getIdentity()->can('commerce-manageOrders')) { - $completedOrdersKey = sprintf($key, 'completed-orders'); - $activeCartsKey = sprintf($key, 'active-carts'); - $inactiveCartsKey = sprintf($key, 'inactive-carts'); - - $content .= Html::tag('h2', Craft::t('commerce', 'Orders')) . - Html::beginTag('div', ['class' => 'commerce-user-orders']) . - Cp::elementIndexHtml(Order::class, ArrayHelper::merge($config, [ - 'id' => $completedOrdersKey, - 'jsSettings' => [ - 'criteria' => ['isCompleted' => true], - 'storageKey' => $completedOrdersKey, - ], - ])) . - Html::endTag('div') . - - Html::tag('hr') . - - Html::tag('h2', Craft::t('commerce', 'Active Carts')) . - Html::beginTag('div', ['class' => 'commerce-user-active-carts']) . - Cp::elementIndexHtml(Order::class, ArrayHelper::merge($config, [ - 'id' => $activeCartsKey, - 'jsSettings' => [ - 'criteria' => [ - 'isCompleted' => false, - 'dateUpdated' => '>= ' . $edge, - ], - 'storageKey' => $activeCartsKey, - ], - ])) . - Html::endTag('div') . - - Html::tag('hr') . - - Html::tag('h2', Craft::t('commerce', 'Inactive Carts')) . - Html::beginTag('div', ['class' => 'commerce-user-active-carts']) . - Cp::elementIndexHtml(Order::class, ArrayHelper::merge($config, [ - 'id' => $inactiveCartsKey, - 'jsSettings' => [ - 'criteria' => [ - 'isCompleted' => false, - 'dateUpdated' => '< ' . $edge, - ], - 'storageKey' => $inactiveCartsKey, - ], - ])) . - Html::endTag('div'); - } - - - if (Craft::$app->getUser()->getIdentity()->can('commerce-manageSubscriptions') and !empty(Plugin::getInstance()->getPlans()->getAllPlans())) { - $subscriptionsKey = sprintf($key, 'subscriptions'); - $content .= Html::tag('hr') . - Html::tag('h2', Craft::t('commerce', 'Subscriptions')) . - Html::beginTag('div', ['class' => 'commerce-user-subscriptions']) . - Cp::elementIndexHtml(Subscription::class, [ - 'id' => $subscriptionsKey, - 'context' => 'embedded-index', - 'sources' => false, - 'jsSettings' => [ - 'criteria' => [ - 'userId' => $user->id, - 'status' => null, - ], - 'storageKey' => $subscriptionsKey, - ], - ]) . - Html::endTag('div'); - } - - return $response->contentHtml($content); - } -} diff --git a/src/controllers/VariantsController.php b/src/controllers/VariantsController.php deleted file mode 100755 index b5945919ff..0000000000 --- a/src/controllers/VariantsController.php +++ /dev/null @@ -1,42 +0,0 @@ - - * @since 5.0.0 - */ -class VariantsController extends BaseCpController -{ - /** - * @inheritdoc - * @throws ForbiddenHttpException - */ - public function init(): void - { - parent::init(); - - if (empty(Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(true))) { - throw new ForbiddenHttpException('User is not permitted to view any product types.'); - } - } - - /** - * @return Response - */ - public function actionIndex(): Response - { - return $this->renderTemplate('commerce/variants/_index'); - } -} diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php deleted file mode 100644 index e7aff419c6..0000000000 --- a/src/controllers/WebhooksController.php +++ /dev/null @@ -1,57 +0,0 @@ - - * @since 2.0 - */ -class WebhooksController extends BaseController -{ - /** - * @inheritdoc - */ - protected array|bool|int $allowAnonymous = ['process-webhook']; - - /** - * @inheritdoc - */ - public $enableCsrfValidation = false; - - /** - * @param int|null $gatewayId - * @return Response - * @throws BadRequestHttpException - * @throws NotFoundHttpException - * @throws InvalidConfigException - */ - public function actionProcessWebhook(?int $gatewayId = null): Response - { - if ($gatewayId === null) { - $gatewayId = $this->request->getRequiredParam('gateway'); - } - - if (!$gatewayId) { - throw new BadRequestHttpException('Invalid gateway ID: ' . $gatewayId); - } - - if (!$gateway = Plugin::getInstance()->getGateways()->getGatewayById($gatewayId)) { - throw new NotFoundHttpException('Gateway not found'); - } - - return Plugin::getInstance()->getWebhooks()->processWebhook($gateway); - } -} diff --git a/src/db/Table.php b/src/db/Table.php deleted file mode 100644 index 64434cd844..0000000000 --- a/src/db/Table.php +++ /dev/null @@ -1,80 +0,0 @@ - - * @since 2.2 - */ -abstract class Table -{ - public const COUPONS = '{{%commerce_coupons}}'; - public const CHARGES = '{{%commerce_charges}}'; - public const CUSTOMER_DISCOUNTUSES = '{{%commerce_customer_discountuses}}'; - public const CUSTOMERS = '{{%commerce_customers}}'; - public const DISCOUNT_CATEGORIES = '{{%commerce_discount_categories}}'; - public const DISCOUNT_PURCHASABLES = '{{%commerce_discount_purchasables}}'; - public const DISCOUNTS = '{{%commerce_discounts}}'; - public const DONATIONS = '{{%commerce_donations}}'; - public const EMAIL_DISCOUNTUSES = '{{%commerce_email_discountuses}}'; - public const EMAILS = '{{%commerce_emails}}'; - public const GATEWAYS = '{{%commerce_gateways}}'; - public const LINEITEMS = '{{%commerce_lineitems}}'; - public const LINEITEMSTATUSES = '{{%commerce_lineitemstatuses}}'; - public const ORDERADJUSTMENTS = '{{%commerce_orderadjustments}}'; - public const ORDERHISTORIES = '{{%commerce_orderhistories}}'; - public const ORDERS = '{{%commerce_orders}}'; - public const ORDERNOTICES = '{{%commerce_ordernotices}}'; - public const ORDERSTATUS_EMAILS = '{{%commerce_orderstatus_emails}}'; - public const ORDERSTATUSES = '{{%commerce_orderstatuses}}'; - public const PAYMENTCURRENCIES = '{{%commerce_paymentcurrencies}}'; - public const PAYMENTSOURCES = '{{%commerce_paymentsources}}'; - public const PDFS = '{{%commerce_pdfs}}'; - public const PLANS = '{{%commerce_plans}}'; - public const PRODUCTS = '{{%commerce_products}}'; - public const PRODUCTTYPES = '{{%commerce_producttypes}}'; - public const PRODUCTTYPES_SHIPPINGCATEGORIES = '{{%commerce_producttypes_shippingcategories}}'; - public const PRODUCTTYPES_SITES = '{{%commerce_producttypes_sites}}'; - public const PRODUCTTYPES_TAXCATEGORIES = '{{%commerce_producttypes_taxcategories}}'; - public const PURCHASABLES = '{{%commerce_purchasables}}'; - public const SALE_CATEGORIES = '{{%commerce_sale_categories}}'; - public const SALE_PURCHASABLES = '{{%commerce_sale_purchasables}}'; - public const SALE_USERGROUPS = '{{%commerce_sale_usergroups}}'; - public const SALES = '{{%commerce_sales}}'; - public const SHIPPINGCATEGORIES = '{{%commerce_shippingcategories}}'; - public const SHIPPINGMETHODS = '{{%commerce_shippingmethods}}'; - public const SHIPPINGRULE_CATEGORIES = '{{%commerce_shippingrule_categories}}'; - public const SHIPPINGRULES = '{{%commerce_shippingrules}}'; - public const SHIPPINGZONES = '{{%commerce_shippingzones}}'; - public const SUBSCRIPTIONS = '{{%commerce_subscriptions}}'; - public const TAXCATEGORIES = '{{%commerce_taxcategories}}'; - public const TAXRATES = '{{%commerce_taxrates}}'; - public const TAXZONES = '{{%commerce_taxzones}}'; - public const TRANSACTIONS = '{{%commerce_transactions}}'; - public const VARIANTS = '{{%commerce_variants}}'; - - /** @since 5.0.0 */ - public const CATALOG_PRICING = '{{%commerce_catalogpricing}}'; - public const CATALOG_PRICING_RULES = '{{%commerce_catalogpricingrules}}'; - public const CATALOG_PRICING_RULES_USERS = '{{%commerce_catalogpricingrules_users}}'; - public const PURCHASABLES_STORES = '{{%commerce_purchasables_stores}}'; - public const SITESTORES = '{{%commerce_site_stores}}'; - public const STORES = '{{%commerce_stores}}'; - public const STORESETTINGS = '{{%commerce_storesettings}}'; // Previously stores table - public const TRANSFERS = '{{%commerce_transfers}}'; - public const TRANSFERDETAILS = '{{%commerce_transferdetails}}'; - public const INVENTORYITEMS = '{{%commerce_inventoryitems}}'; - public const INVENTORYLOCATIONS = '{{%commerce_inventorylocations}}'; - public const INVENTORYLOCATIONS_STORES = '{{%commerce_inventorylocations_stores}}'; - public const INVENTORYTRANSACTIONS = '{{%commerce_inventorytransactions}}'; - - /** @since 5.7.0 */ - public const CATALOG_PRICING_QUEUE = '{{%commerce_catalogpricing_queue}}'; -} diff --git a/src/debug/CommercePanel.php b/src/debug/CommercePanel.php deleted file mode 100644 index b5ba2f41f4..0000000000 --- a/src/debug/CommercePanel.php +++ /dev/null @@ -1,103 +0,0 @@ - - * @since 3.0.0 - */ -class CommercePanel extends Panel -{ - /** - * @event \yii\base\Event The event that is triggered after the data for the panel is prepared. - * - * ```php - * use craft\commerce\debug\CommercePanel; - * use craft\commerce\events\CommerceDebugPanelDataEvent; - * use yii\base\Event; - * - * Event::on( - * CommercePanel::class, - * CommercePanel::EVENT_AFTER_DATA_PREPARE, - * function(CommerceDebugPanelDataEvent $event) { - * $event->nav[] = 'Foo'; - * $event->content[] = 'Bar'; - * } - * ); - * ``` - */ - public const EVENT_AFTER_DATA_PREPARE = 'afterDataPrepare'; - - /** - * @var Order|null - */ - public ?Order $cart = null; - - /** - * @inheritdoc - */ - public function getName(): string - { - return 'Commerce'; - } - - /** - * @inheritdoc - */ - public function getSummary(): string - { - return Craft::$app->getView()->render('@craft/commerce/views/debug/commerce/summary', [ - 'panel' => $this, - ]); - } - - /** - * @inheritdoc - */ - public function getDetail(): string - { - return Craft::$app->getView()->render('@craft/commerce/views/debug/commerce/detail', [ - 'panel' => $this, - ]); - } - - /** - * @inheritdoc - */ - public function save() - { - $nav = []; - $content = []; - - if (!Craft::$app->getRequest()->getIsCpRequest()) { - $this->cart = Plugin::getInstance()->getCarts()->getCart(); - } - - if ($this->cart) { - $nav[] = 'Cart'; - - $content[] = Craft::$app->getView()->render('@craft/commerce/views/debug/commerce/model', [ - 'model' => $this->cart, - ]); - } - - // Trigger event allowing extra tabs to be added. - $event = new CommerceDebugPanelDataEvent(['nav' => $nav, 'content' => $content]); - $this->trigger(self::EVENT_AFTER_DATA_PREPARE, $event); - - return ['nav' => $event->nav, 'content' => $event->content]; - } -} diff --git a/src/elements/Donation.php b/src/elements/Donation.php deleted file mode 100644 index e7f8568a5a..0000000000 --- a/src/elements/Donation.php +++ /dev/null @@ -1,300 +0,0 @@ - - * @since 2.0 - */ -class Donation extends Purchasable -{ - /** - * By default the donation is not available for purchase. - * - * @inerhitdoc - */ - public bool $availableForPurchase = false; - - - /** - * @inheritdoc - */ - public static function hasInventory(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function behaviors(): array - { - $behaviors = parent::behaviors(); - - $behaviors['currencyAttributes'] = [ - 'class' => CurrencyAttributeBehavior::class, - 'currencyAttributes' => $this->currencyAttributes(), - ]; - - return $behaviors; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - - $rules[] = [['sku'], 'trim']; - $rules[] = [ - ['sku'], 'required', 'when' => fn($model) => - /** @var self $model */ - $model->availableForPurchase && $model->enabled, - ]; - - return $rules; - } - - /** - * @inerhitdoc - */ - public static function hasStatuses(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function getPrice(?Store $store = null): ?float - { - return 0; - } - - /** - * @inheritdoc - */ - public function __toString(): string - { - return Craft::t('commerce', 'Donation'); - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Donation'); - } - - /** - * @inheritdoc - */ - public static function lowerDisplayName(): string - { - return Craft::t('commerce', 'donation'); - } - - /** - * @inheritdoc - */ - public static function pluralDisplayName(): string - { - return Craft::t('commerce', 'Donations'); - } - - /** - * @inheritdoc - */ - public static function pluralLowerDisplayName(): string - { - return Craft::t('commerce', 'donations'); - } - - /** - * @inheritdoc - */ - public static function refHandle(): ?string - { - return 'donation'; - } - - /** - * @inheritdoc - * @return DonationQuery The newly created [[DonationQuery]] instance. - */ - public static function find(): ElementQueryInterface - { - return new DonationQuery(static::class); - } - - /** - * Returns the product title and variants title together for variable products. - */ - public function getDescription(): string - { - return Craft::t('commerce', 'Donation'); - } - - /** - * @inheritdoc - */ - public function getCpEditUrl(): ?string - { - return UrlHelper::cpUrl(sprintf('commerce/store-management/%s/donation', $this->getStore()->handle)); - } - - /** - * @inheritdoc - */ - public function getUrl(): ?string - { - return ''; - } - - /** - * @inheritdoc - */ - public function hasFreeShipping(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function getIsShippable(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function getIsTaxable(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function populateLineItem(LineItem $lineItem): void - { - $options = $lineItem->getOptions(); - if (isset($options['donationAmount'])) { - $lineItem->price = $options['donationAmount']; - } - } - - /** - * @inheritdoc - */ - public function getLineItemRules(LineItem $lineItem): array - { - return [ - [ - 'purchasableId', - function($attribute, $params, Validator $validator) use ($lineItem) { - $options = $lineItem->getOptions(); - if (!isset($options['donationAmount'])) { - $validator->addError($lineItem, $attribute, Craft::t('commerce', 'No donation amount supplied.')); - } - if (isset($options['donationAmount']) && !is_numeric($options['donationAmount'])) { - $validator->addError($lineItem, $attribute, Craft::t('commerce', 'Donation needs to be an amount.')); - } - if (isset($options['donationAmount']) && $options['donationAmount'] == 0) { - $validator->addError($lineItem, $attribute, Craft::t('commerce', 'Donation can not be zero.')); - } - }, - ], - ]; - } - - /** - * @inheritdoc - */ - public function getIsPromotable(?Store $store = null): bool - { - return false; - } - - /** - * @throws Exception - */ - public function afterSave(bool $isNew): void - { - if (!$isNew) { - $record = DonationRecord::findOne($this->id); - - if (!$record) { - throw new Exception('Invalid donation ID: ' . $this->id); - } - } else { - $record = new DonationRecord(); - $record->id = $this->id; - } - - $record->sku = $this->sku; - - // We want to always have the same date as the element table, based on the logic for updating these in the element service i.e resaving - $record->dateUpdated = $this->dateUpdated; - $record->dateCreated = $this->dateCreated; - - $record->save(false); - - parent::afterSave($isNew); - - // Loop through other stores to save the donation to all stores - $stores = Plugin::getInstance()->getStores()->getAllStores(); - $stores - ->filter(fn(Store $s) => $s->id !== $this->getStore()->id) - ->each(function(Store $store) use ($isNew) { - $purchasableStoreRecord = PurchasableStore::findOne(['purchasableId' => $this->id, 'storeId' => $store->id]); - if ($isNew || !$purchasableStoreRecord) { - $purchasableStoreRecord = new PurchasableStore(); - $purchasableStoreRecord->purchasableId = $this->id; - $purchasableStoreRecord->storeId = $store->id; - }; - - $purchasableStoreRecord->basePrice = 0; - $purchasableStoreRecord->basePromotionalPrice = null; - $purchasableStoreRecord->stock = null; - $purchasableStoreRecord->inventoryTracked = false; - $purchasableStoreRecord->allowOutOfStockPurchases = false; - $purchasableStoreRecord->minQty = null; - $purchasableStoreRecord->maxQty = null; - $purchasableStoreRecord->promotable = false; - $purchasableStoreRecord->availableForPurchase = $this->availableForPurchase; - $purchasableStoreRecord->freeShipping = true; - $purchasableStoreRecord->shippingCategoryId = Plugin::getInstance()->getShippingCategories()->getDefaultShippingCategory($store->id)->id; - - $purchasableStoreRecord->save(false); - }); - } -} diff --git a/src/elements/Order.php b/src/elements/Order.php deleted file mode 100644 index 21ec63db6e..0000000000 --- a/src/elements/Order.php +++ /dev/null @@ -1,4041 +0,0 @@ - - * @since 2.0 - */ -class Order extends Element implements HasStoreInterface -{ - use OrderValidatorsTrait; - use OrderElementTrait; - use OrderNoticesTrait; - use StoreTrait; - - /** - * Payments exceed order total. - */ - public const PAID_STATUS_OVERPAID = 'overPaid'; - - /** - * Payments equal order total. - */ - public const PAID_STATUS_PAID = 'paid'; - - /** - * Payments less than order total. - */ - public const PAID_STATUS_PARTIAL = 'partial'; - - /** - * Payments total zero on non-free order. - */ - public const PAID_STATUS_UNPAID = 'unpaid'; - - /** - * Recalculates line items, populates from purchasables, and regenerates adjustments. - */ - public const RECALCULATION_MODE_ALL = 'all'; - - /** - * Recalculates adjustments only; does not recalculate line items or populate from purchasables. - */ - public const RECALCULATION_MODE_ADJUSTMENTS_ONLY = 'adjustmentsOnly'; - - /** - * Does not recalculate anything on the order. - */ - public const RECALCULATION_MODE_NONE = 'none'; - - /** - * Order created from the front end. - */ - public const ORIGIN_WEB = 'web'; - - /** - * Order created from the control panel. - */ - public const ORIGIN_CP = 'cp'; - - /** - * Order created by a remote source. - */ - public const ORIGIN_REMOTE = 'remote'; - - /** - * @event \yii\base\Event The event that is triggered before a new line item has been added to the order. - * - * ```php - * use craft\commerce\elements\Order; - * use craft\commerce\models\LineItem; - * use craft\commerce\events\AddLineItemEvent; - * use yii\base\Event; - * - * Event::on( - * Order::class, - * Order::EVENT_BEFORE_ADD_LINE_ITEM, - * function(AddLineItemEvent $event) { - * // @var LineItem $lineItem - * $lineItem = $event->lineItem; - * // @var bool $isNew - * $isNew = $event->isNew; - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_ADD_LINE_ITEM = 'beforeAddLineItemToOrder'; - - /** - * @event \yii\base\Event The event that is triggered after a line item has been added to an order. - * - * ```php - * use craft\commerce\elements\Order; - * use craft\commerce\events\LineItemEvent; - * use craft\commerce\models\LineItem; - * use yii\base\Event; - * - * Event::on( - * Order::class, - * Order::EVENT_AFTER_APPLY_ADD_LINE_ITEM, - * function(LineItemEvent $event) { - * // @var LineItem $lineItem - * $lineItem = $event->lineItem; - * // @var bool $isNew - * $isNew = $event->isNew; - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_APPLY_ADD_LINE_ITEM = 'afterApplyAddLineItemToOrder'; - - /** - * @event \yii\base\Event The event that is triggered after a line item has been added to an order. - * - * ```php - * use craft\commerce\elements\Order; - * use craft\commerce\events\LineItemEvent; - * use craft\commerce\models\LineItem; - * use yii\base\Event; - * - * Event::on( - * Order::class, - * Order::EVENT_AFTER_ADD_LINE_ITEM, - * function(LineItemEvent $event) { - * // @var LineItem $lineItem - * $lineItem = $event->lineItem; - * // @var bool $isNew - * $isNew = $event->isNew; - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_ADD_LINE_ITEM = 'afterAddLineItemToOrder'; - - /** - * @event \yii\base\Event The event that is triggered after a line item has been removed from an order. - * - * ```php - * use craft\commerce\elements\Order; - * use craft\commerce\events\LineItemEvent; - * use craft\commerce\models\LineItem; - * use yii\base\Event; - * - * Event::on( - * Order::class, - * Order::EVENT_AFTER_REMOVE_LINE_ITEM, - * function(LineItemEvent $event) { - * // @var LineItem $lineItem - * $lineItem = $event->lineItem; - * // @var bool $isNew - * $isNew = $event->isNew; - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_REMOVE_LINE_ITEM = 'afterRemoveLineItemFromOrder'; - - /** - * @event \yii\base\Event The event that is triggered after a line item has been removed from an order. - * - * ```php - * use craft\commerce\elements\Order; - * use craft\commerce\events\LineItemEvent; - * use craft\commerce\models\LineItem; - * use yii\base\Event; - * - * Event::on( - * Order::class, - * Order::EVENT_AFTER_APPLY_REMOVE_LINE_ITEM, - * function(LineItemEvent $event) { - * // @var LineItem $lineItem - * $lineItem = $event->lineItem; - * // @var bool $isNew - * $isNew = $event->isNew; - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_APPLY_REMOVE_LINE_ITEM = 'afterApplyRemoveLineItemFromOrder'; - - /** - * @event \yii\base\Event The event that is triggered before an order is completed. - * - * ```php - * use craft\commerce\elements\Order; - * use yii\base\Event; - * - * Event::on( - * Order::class, - * Order::EVENT_BEFORE_COMPLETE_ORDER, - * function(Event $event) { - * // @var Order $order - * $order = $event->sender; - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_COMPLETE_ORDER = 'beforeCompleteOrder'; - - /** - * @event \yii\base\Event The event that is triggered after an order is completed. - * - * ```php - * use craft\commerce\elements\Order; - * use yii\base\Event; - * - * Event::on( - * Order::class, - * Order::EVENT_AFTER_COMPLETE_ORDER, - * function(Event $event) { - * // @var Order $order - * $order = $event->sender; - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_COMPLETE_ORDER = 'afterCompleteOrder'; - - /** - * @event \yii\base\Event The event that is triggered after an order is paid and completed. - * - * ```php - * use craft\commerce\elements\Order; - * use yii\base\Event; - * - * Event::on( - * Order::class, - * Order::EVENT_AFTER_ORDER_PAID, - * function(Event $event) { - * // @var Order $order - * $order = $event->sender; - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_ORDER_PAID = 'afterOrderPaid'; - - /** - * @event \yii\base\Event This event is raised after an order is authorized in full and completed - * - * Plugins can get notified after an order is authorized in full and completed - * - * ```php - * use craft\commerce\elements\Order; - * use yii\base\Event; - * - * Event::on(Order::class, Order::EVENT_AFTER_ORDER_AUTHORIZED, function(Event $e) { - * // @var Order $order - * $order = $e->sender; - * // ... - * }); - * ``` - */ - public const EVENT_AFTER_ORDER_AUTHORIZED = 'afterOrderAuthorized'; - - /** - * @event \yii\base\Event The event that is triggered before a notice has been added to the order. - * - * ```php - * use craft\commerce\elements\Order; - * use craft\commerce\models\OrderNotice; - * use craft\commerce\events\OrderNoticeEvent; - * use yii\base\Event; - * - * Event::on( - * Order::class, - * Order::EVENT_BEFORE_APPLY_ADD_NOTICE, - * function(OrderNoticeEvent $event) { - * // @var OrderNotice $orderNotice - * $orderNotice = $event->orderNotice; - * // ... - * } - * ); - * ``` - * - * @since 4.1.0 - */ - public const EVENT_BEFORE_APPLY_ADD_NOTICE = 'beforeApplyAddNoticeToOrder'; - - /** - * @event \yii\base\Event The event that is triggered before line items are refreshed during recalculation of an order. - * - * ```php - * use craft\commerce\elements\Order; - * use craft\commerce\events\OrderLineItemsRefreshEvent; - * use yii\base\Event; - * - * Event::on( - * Order::class, - * Order::EVENT_BEFORE_LINE_ITEMS_REFRESHED, - * function(OrderLineItemsRefreshEvent $event) { - * $event->lineItems = []; - * $event->recalculate = true; - * // ... - * } - * ); - * ``` - * - * @since 5.1.0 - */ - public const EVENT_BEFORE_LINE_ITEMS_REFRESHED = 'beforeLineItemsRefreshed'; - - /** - * @event \yii\base\Event The event that is triggered after line items are refreshed during recalculation of an order. - * - * ```php - * use craft\commerce\elements\Order; - * use craft\commerce\events\OrderLineItemsRefreshEvent; - * use yii\base\Event; - * - * Event::on( - * Order::class, - * Order::EVENT_AFTER_LINE_ITEMS_REFRESHED, - * function(OrderLineItemsRefreshEvent $event) { - * $event->lineItems = []; - * $event->recalculate = true; - * // ... - * } - * ); - * ``` - * - * @since 5.1.0 - */ - public const EVENT_AFTER_LINE_ITEMS_REFRESHED = 'afterLineItemsRefreshed'; - - /** - * This is the unique number (hash) generated for the order when it was first created. - * - * @var string|null Number - * --- - * ```php - * echo $order->number; - * ``` - * ```twig - * {{ order.number }} - * ``` - */ - public ?string $number = null; - - /** - * This is the reference number generated once the order was completed. - * While the order is a cart, this is null. - * - * @var string|null Reference - * --- - * ```php - * echo $order->reference; - * ``` - * ```twig - * {{ order.reference }} - * ``` - */ - public ?string $reference = null; - - /** - * This is the currently applied coupon code. - * - * @var string|null Coupon Code - * --- - * ```php - * echo $order->couponCode; - * ``` - * ```twig - * {{ order.couponCode }} - * ``` - */ - public ?string $couponCode = null; - - /** - * Is this order completed (no longer a cart). - * - * @var bool Is completed - * --- - * ```php - * echo $order->isCompleted; - * ``` - * ```twig - * {{ order.isCompleted }} - * ``` - */ - public bool $isCompleted = false; - - /** - * The date and time this order was completed - * - * @var DateTime|null Date ordered - * --- - * ```php - * echo $order->dateOrdered; - * ``` - * ```twig - * {{ order.dateOrdered }} - * ``` - */ - public ?DateTime $dateOrdered = null; - - /** - * The date and time this order was paid in full. - * - * @var DateTime|null Date paid - * --- - * ```php - * echo $order->datePaid; - * ``` - * ```twig - * {{ order.datePaid }} - * ``` - */ - public ?DateTime $datePaid = null; - - /** - * The date and time this order was first paid in full. - * - * @var DateTime|null Date first paid - * --- - * ```php - * echo $order->dateFirstPaid; - * ``` - * ```twig - * {{ order.dateFirstPaid }} - * ``` - */ - public ?DateTime $dateFirstPaid = null; - - /** - * The date and time this order was authorized in full. - * This may the same date as datePaid if the order was paid immediately. - * - * @var DateTime|null Date authorized - * --- - * ```php - * echo $order->dateAuthorized; - * ``` - * ```twig - * {{ order.dateAuthorized }} - * ``` - */ - public ?DateTime $dateAuthorized = null; - - /** - * The currency of the order (ISO code) - * - * @var string|null Currency - * --- - * ```php - * echo $order->currency; - * ``` - * ```twig - * {{ order.currency }} - * ``` - */ - public ?string $currency = null; - - /** - * The current gateway ID to identify the gateway the order should use when accepting payments. - * If the `paymentSourceId` is set on this order, this `gatewayId` will be that belonging to the - * payment source. - * - * @var int|null Gateway ID - * --- - * ```php - * echo $order->gatewayId; - * ``` - * ```twig - * {{ order.gatewayId }} - * ``` - */ - public ?int $gatewayId = null; - - /** - * The last IP address of the user building the order before it was marked as complete. - * - * @var string|null Last IP address - * --- - * ```php - * echo $order->lastIp; - * ``` - * ```twig - * {{ order.lastIp }} - * ``` - */ - public ?string $lastIp = null; - - /** - * The current message set on the order when having it’s order status being changed. - * - * @var string|null message - * --- - * ```php - * echo $order->message; - * ``` - * ```twig - * {{ order.message }} - * ``` - */ - public ?string $message = null; - - /** - * The current URL the order should return to after successful payment. - * This is stored on the order as we may be redirected off-site for payments. - * - * @var string|null Return URL - * --- - * ```php - * echo $order->returnUrl; - * ``` - * ```twig - * {{ order.returnUrl }} - * ``` - */ - public ?string $returnUrl = null; - - /** - * The current URL the order should return to if the customer cancels payment off-site. - * This is stored on the order as we may be redirected off-site for payments. - * - * @var string|null Cancel URL - * --- - * ```php - * echo $order->cancelUrl; - * ``` - * ```twig - * {{ order.cancelUrl }} - * ``` - */ - public ?string $cancelUrl = null; - - /** - * The current order status ID. This will be null if the order is not complete - * and is still a cart. - * - * @var int|null Order status ID - * --- - * ```php - * echo $order->orderStatusId; - * ``` - * ```twig - * {{ order.orderStatusId }} - * ``` - */ - public ?int $orderStatusId = null; - - /** - * The language the cart was created in. - * - * @var string|null The language the order was made in. - * --- - * ```php - * echo $order->orderLanguage; - * ``` - * ```twig - * {{ order.orderLanguage }} - * ``` - */ - public ?string $orderLanguage = null; - - /** - * The store the order was created in. - * - * @var int|null Order store ID - * --- - * ```php - * echo $order->storeId; - * ``` - * ```twig - * {{ order.storeId }} - * ``` - */ - public ?int $storeId = null; - - /** - * The site the order was created in. - * - * @var int|null Order site ID - * --- - * ```php - * echo $order->orderSiteId; - * ``` - * ```twig - * {{ order.orderSiteId }} - * ``` - */ - public ?int $orderSiteId = null; - - - /** - * The origin of the order when it was first created. - * Values can be 'web', 'cp', or 'api' - * - * @var string|null Order origin - * --- - * ```php - * echo $order->origin; - * ``` - * ```twig - * {{ order.origin }} - * ``` - */ - public ?string $origin = null; - - /** - * The email address that was on the cart when the order was completed. - * This is only stored for historic data. - * - * @var string|null The email address when the order was completed - * @since 4.2.12 - * --- - * ```php - * echo $order->orderCompletedEmail; - * ``` - * ```twig - * {{ order.orderCompletedEmail }} - * ``` - */ - public ?string $orderCompletedEmail = null; - - /** - * The current billing address ID - * - * @var int|null Billing address ID - * --- - * ```php - * echo $order->billingAddressId; - * ``` - * ```twig - * {{ order.billingAddressId }} - * ``` - */ - public ?int $billingAddressId = null; - - /** - * The current shipping address ID - * - * @var int|null Shipping address ID - * --- - * ```php - * echo $order->shippingAddressId; - * ``` - * ```twig - * {{ order.shippingAddressId }} - * ``` - */ - public ?int $shippingAddressId = null; - - - /** - * Whether the shipping address should be made the primary address of the - * order‘s customer. This is persisted while the order is a cart, and is only used during the - * update cart request or on order completion and new addresses are being saved. - * - * @var bool Make this the customer’s primary shipping address - * @see \craft\commerce\services\Customers::_saveAddressesFromOrder() - * --- - * ```php - * echo $order->makePrimaryShippingAddress; - * ``` - * ```twig - * {{ order.makePrimaryShippingAddress }} - * ``` - */ - public bool $makePrimaryShippingAddress = false; - - /** - * Whether the billing address should be made the primary address of the - * order‘s customer. This is persisted while the order is a cart, and is only used during the - * update cart request or on order completion and new addresses are being saved. - * - * @var bool Make this the customer‘s primary billing address - * @see \craft\commerce\services\Customers::_saveAddressesFromOrder() - * --- - * ```php - * echo $order->makePrimaryBillingAddress; - * ``` - * ```twig - * {{ order.makePrimaryBillingAddress }} - * ``` - */ - public bool $makePrimaryBillingAddress = false; - - /** - * Whether the shipping address should be the same address as the order’s - * billing address. This is not persisted on the order, and is only used during the - * update order request. Can not be set to `true` at the same time as setting - * `billingSameAsShipping` to true, or an error will be raised. - * - * @var bool Make this the shipping address the same as the billing address - * --- - * ```php - * echo $order->shippingSameAsBilling; - * ``` - * ```twig - * {{ order.shippingSameAsBilling }} - * ``` - */ - public bool $shippingSameAsBilling = false; - - /** - * Whether the billing address should be the same address as the order’s - * shipping address. This is not persisted on the order, and is only used during the - * update order request. Can not be set to `true` at the same time as setting - * `shippingSameAsBilling` to true, or an error will be raised. - * - * @var bool Make this the shipping address the same as the billing address - * --- - * ```php - * echo $order->billingSameAsShipping; - * ``` - * ```twig - * {{ order.billingSameAsShipping }} - * ``` - */ - public bool $billingSameAsShipping = false; - - /** - * @var int|null Estimated Billing address ID - * @since 2.2 - */ - public ?int $estimatedBillingAddressId = null; - - /** - * @var int|null Estimated Shipping address ID - * @since 2.2 - */ - public ?int $estimatedShippingAddressId = null; - - /** - * @var int|null The billing address ID that was selected from the customer’s address book, - * which populated the billing address on the order. - * @since 4.0 - */ - public ?int $sourceBillingAddressId = null; - - /** - * @var int|null The shipping address ID that was selected from the customer’s address book, - * which populated the shipping address on the order. - * @since 4.0 - */ - public ?int $sourceShippingAddressId = null; - - /** - * @var bool Whether estimated billing address should be set to the same address as estimated shipping - * @since 2.2 - */ - public bool $estimatedBillingSameAsShipping = false; - - /** - * @var string|null Shipping Method Handle - * @todo Change type to just `string` in Commerce 6.0 - */ - public ?string $shippingMethodHandle = ''; - - /** - * @var string|null Shipping Method Name - * @since 3.2.0 - */ - public ?string $shippingMethodName = null; - - /** - * @var int|null Customer’s ID - */ - private ?int $_customerId = null; - - /** - * @var bool Whether the customer has been deleted - */ - private bool $_customerDeleted = false; - - /** - * Whether the email address on the order should be used to register - * as a user account when the order is complete. - * - * @var bool Register user on order complete - * --- - * ```php - * echo $order->registerUserOnOrderComplete; - * ``` - * ```twig - * {{ order.registerUserOnOrderComplete }} - * ``` - */ - public bool $registerUserOnOrderComplete = false; - - /** - * Whether the billing address on the order should be saved to the customer's - * address book when the order is complete. - * - * @var bool Save the order's billing address to the customer's address book - * --- - * ```php - * echo $order->saveBillingAddressOnOrderComplete; - * ``` - * ```twig - * {{ order.saveBillingAddressOnOrderComplete }} - * ``` - */ - public bool $saveBillingAddressOnOrderComplete = false; - - /** - * Whether the shipping address on the order should be saved to the customer's - * address book when the order is complete. - * - * @var bool Save the order's shipping address to the customer's address book - * --- - * ```php - * echo $order->saveShippingAddressOnOrderComplete; - * ``` - * ```twig - * {{ order.saveShippingAddressOnOrderComplete }} - * ``` - */ - public bool $saveShippingAddressOnOrderComplete = false; - - /** - * The current payment source that should be used to make payments on the - * order. If this is set, the `gatewayId` will also be set to the related - * gateway. - * - * @var int|null Payment source ID - * --- - * ```php - * echo $order->paymentSourceId; - * ``` - * ```twig - * {{ order.paymentSourceId }} - * ``` - */ - public ?int $paymentSourceId = null; - - - /** - * @var float|null The total price as stored in the database from last retrieval - * --- - * ```php - * echo $order->storedTotalPrice; - * ``` - * ```twig - * {{ order.storedTotalPrice }} - * ``` - */ - public ?float $storedTotalPrice = null; - - /** - * @var float|null The total as stored in the database from last retrieval - * --- - * ```php - * echo $order->storedTotal; - * ``` - * ```twig - * {{ order.storedTotal }} - * ``` - */ - public ?float $storedTotal = null; - - /** - * @var float|null The total paid as stored in the database from last retrieval - * --- - * ```php - * echo $order->storedTotalPaid; - * ``` - * ```twig - * {{ order.storedTotalPaid }} - * ``` - */ - public ?float $storedTotalPaid = null; - - /** - * @var float|null The item total as stored in the database from last retrieval - * --- - * ```php - * echo $order->storedItemTotal; - * ``` - * ```twig - * {{ order.storedItemTotal }} - * ``` - */ - public ?float $storedItemTotal = null; - - /** - * @var float|null The item subtotal as stored in the database from last retrieval - * @since 3.2.4 - * --- - * ```php - * echo $order->storedItemSubtotal; - * ``` - * ```twig - * {{ order.storedItemSubtotal }} - * ``` - */ - public ?float $storedItemSubtotal = null; - - /** - * @var float|null The total shipping cost adjustments as stored in the database from last retrieval - * --- - * ```php - * echo $order->storedTotalShippingCost; - * ``` - * ```twig - * {{ order.storedTotalShippingCost }} - * ``` - */ - public ?float $storedTotalShippingCost = null; - - /** - * @var float|null The total of discount adjustments as stored in the database from last retrieval - * --- - * ```php - * echo $order->storedTotalDiscount; - * ``` - * ```twig - * {{ order.storedTotalDiscount }} - * ``` - */ - public ?float $storedTotalDiscount = null; - - /** - * @var float|null The total tax adjustments as stored in the database from last retrieval - * --- - * ```php - * echo $order->storedTotalTax; - * ``` - * ```twig - * {{ order.storedTotalTax }} - * ``` - */ - public ?float $storedTotalTax = null; - - /** - * @var float|null The total tax included adjustments as stored in the database from last retrieval - * --- - * ```php - * echo $order->storedTotalTaxIncluded; - * ``` - * ```twig - * {{ order.storedTotalTaxIncluded }} - * ``` - */ - public ?float $storedTotalTaxIncluded = null; - - /** - * @var int|null The total quantity as stored in the database from last retrieval - * --- - * ```php - * echo $order->storedTotalQty; - * ``` - * ```twig - * {{ order.storedTotalQty }} - * ``` - */ - public ?int $storedTotalQty = null; - - /** - * @var string|null - * @see Order::setRecalculationMode() To set the current recalculation mode - * @see Order::getRecalculationMode() To get the current recalculation mode - * --- - * ```php - * echo $order->recalculationMode; - * ``` - * ```twig - * {{ order.recalculationMode }} - * ``` - */ - private ?string $_recalculationMode = null; - - /** - * @var AddressElement|null - * @see Order::setShippingAddress() To set the current shipping address - * @see Order::getShippingAddress() To get the current shipping address - * --- - * ```php - * if ($order->shippingAddress) { - * echo $order->shippingAddress->firstName; - * } - * ``` - * ```twig - * {% if order.shippingAddress %} - * {{ order.shippingAddress.firstName }} - * {% endif %} - * ``` - */ - private ?AddressElement $_shippingAddress = null; - - /** - * @var AddressElement|null - * @see Order::setBillingAddress() To set the current billing address - * @see Order::getBillingAddress() To get the current billing address - * --- - * ```php - * if ($order->billingAddress) { - * echo $order->billingAddress->firstName; - * } - * ``` - * ```twig - * {% if order.billingAddress %} - * {{ order.billingAddress.firstName }} - * {% endif %} - * ``` - */ - private ?AddressElement $_billingAddress = null; - - /** - * @var AddressElement|null - * @since 2.2 - */ - private ?AddressElement $_estimatedShippingAddress = null; - - /** - * @var AddressElement|null - * @since 2.2 - */ - private ?AddressElement $_estimatedBillingAddress = null; - - /** - * @var LineItem[] - * @see Order::setLineItems() To set the order line items - * @see Order::getLineItems() To get the order line items - * --- - * ```php - * foreach ($order->getLineItems() as $lineItem) { - * echo $lineItem->description'; - * } - * ``` - * ```twig - * {% for lineItem in order.lineItems %} - * {{ lineItem.description }} - * {% endfor %} - * ``` - */ - private array $_lineItems; - private array $_deletingLineItems = []; - - /** - * @var OrderAdjustment[]|null - * @see Order::setAdjustments() To set the order adjustments - * @see Order::setAdjustments() To get the order adjustments - * --- - * ```php - * foreach ($order->getAdjustments() as $adjustment) { - * echo $adjustment->amount'; - * } - * ``` - * ```twig - * {% for adjustment in order.adjustments %} - * {{ adjustment.amount }} - * {% endfor %} - * ``` - */ - private ?array $_orderAdjustments = null; - - /** - * @var string|null - * @see Order::setPaymentCurrency() To set the payment currency - * @see Order::getPaymentCurrency() To get the payment currency - * --- - * ```php - * echo $order->paymentCurrency; - * ``` - * ```twig - * {{ order.paymentCurrency }} - * ``` - */ - private ?string $_paymentCurrency = null; - - /** - * @var Transaction[]|null - * @see Order::getTransactions() - * --- - * ```php - * echo $order->transactions; - * ``` - * ```twig - * {{ order.transactions }} - * ``` - */ - private ?array $_transactions = null; - - /** - * @var User|null|false - * @see Order::getCustomer() - * @see Order::setCustomer() - * --- - * ```php - * echo $order->customer; - * ``` - * ```twig - * {{ order.customer }} - * ``` - */ - private User|null|false $_customer = null; - - /** - * @var float|null - * @see Order::setPaymentAmount() To set the order payment amount - * @see Order::getPaymentAmount() To get the order payment amount - * --- - * ```php - * echo $order->paymentAmount; - * ``` - * ```twig - * {{ order.paymentAmount }} - * ``` - */ - private ?float $_paymentAmount = null; - - /** - * Ability to cancel email sending to avoid email even being queued. - * - * @var bool - */ - public bool $suppressEmails = false; - - /** - * @inheritdoc - */ - public function init(): void - { - if ($this->orderLanguage === null) { - $this->orderLanguage = Craft::$app->language; - } - - if ($this->storeId === null) { - $this->storeId = Plugin::getInstance()->getStores()->getCurrentStore()->id; - } - - if ($this->orderSiteId === null) { - $storeSites = $this->getStore()->getSites(); - $primarySite = Craft::$app->getSites()->getPrimarySite(); - // Prefer the Craft primary site if it belongs to this store, otherwise use the first available site - $this->orderSiteId = $storeSites->firstWhere('id', $primarySite->id)?->id ?? $storeSites->first()->id; - } - - if ($this->currency === null) { - $this->currency = $this->getStore()->getCurrency(); - } - - // Better default for carts if the base currency changes (usually only happens in development) - if (!$this->isCompleted && $this->paymentCurrency && !Plugin::getInstance()->getPaymentCurrencies()->getPaymentCurrencyByIso($this->paymentCurrency, $this->getStore()->id)) { - $this->paymentCurrency = Plugin::getInstance()->getPaymentCurrencies()->getPrimaryPaymentCurrencyIso($this->getStore()->id); - } - - if ($this->origin === null) { - $this->origin = static::ORIGIN_WEB; - } - - if ($this->_recalculationMode === null) { - if ($this->isCompleted) { - $this->setRecalculationMode(self::RECALCULATION_MODE_NONE); - } else { - $this->setRecalculationMode(self::RECALCULATION_MODE_ALL); - } - } - - parent::init(); - } - - public function behaviors(): array - { - $behaviors = parent::behaviors(); - - $behaviors['currencyAttributes'] = [ - 'class' => CurrencyAttributeBehavior::class, - 'currencyAttributes' => $this->currencyAttributes(), - ]; - - return $behaviors; - } - - /** - * @return string - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Order'); - } - - /** - * @inheritdoc - */ - public static function lowerDisplayName(): string - { - return Craft::t('commerce', 'order'); - } - - /** - * @inheritdoc - */ - public static function pluralDisplayName(): string - { - return Craft::t('commerce', 'Orders'); - } - - /** - * @inheritdoc - */ - public static function pluralLowerDisplayName(): string - { - return Craft::t('commerce', 'orders'); - } - - /** - * @inheritdoc - */ - public function __toString(): string - { - return $this->reference ?: $this->getShortNumber(); - } - - /** - * @inheritdoc - */ - public function canSave(User $user): bool - { - return parent::canSave($user) || $user->can('commerce-editOrders'); - } - - /** - * @inheritdoc - */ - public function canView(User $user): bool - { - return parent::canView($user) || $user->can('commerce-manageOrders'); - } - - /** - * @inheritdoc - */ - public function canDuplicate(User $user): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function canDelete(User $user): bool - { - return parent::canDelete($user) || $user->can('commerce-deleteOrders'); - } - - /** - * @inheritdoc - */ - public function beforeValidate(): bool - { - // Set default gateway if none present and no payment source selected - if (!$this->gatewayId && !$this->paymentSourceId) { - $gateways = Plugin::getInstance()->getGateways()->getAllCustomerEnabledGateways(); - if ($gateways->isNotEmpty()) { - $gateway = $gateways->filter(fn(GatewayInterface $g) => $g->availableForUseWithOrder($this))->first(); - - if ($gateway) { - $this->gatewayId = $gateway->id; - } - } - } - - // If the gateway ID doesn't exist, just drop it. - if ($this->gatewayId && !$this->getGateway()) { - $this->gatewayId = null; - } - - return parent::beforeValidate(); - } - - /** - * @inheritdoc - */ - public function attributes(): array - { - $names = parent::attributes(); - $names[] = 'adjustmentSubtotal'; - $names[] = 'adjustmentsTotal'; - $names[] = 'customer'; - $names[] = 'customerId'; - $names[] = 'customerDeleted'; - $names[] = 'paymentCurrency'; - $names[] = 'paymentAmount'; - $names[] = 'isPaid'; - $names[] = 'itemSubtotal'; - $names[] = 'itemTotal'; - $names[] = 'lineItems'; - $names[] = 'orderAdjustments'; - $names[] = 'outstandingBalance'; - $names[] = 'paidStatus'; - $names[] = 'recalculationMode'; - $names[] = 'shortNumber'; - $names[] = 'totalPaid'; - $names[] = 'total'; - $names[] = 'totalPrice'; - $names[] = 'totalQty'; - $names[] = 'totalPromotionalAmount'; - $names[] = 'totalWeight'; - return $names; - } - - /** - * The attributes on the order that should be made available as formatted currency. - */ - public function currencyAttributes(): array - { - $attributes = []; - $attributes[] = 'adjustmentSubtotal'; - $attributes[] = 'adjustmentsTotal'; - $attributes[] = 'itemSubtotal'; - $attributes[] = 'itemTotal'; - $attributes[] = 'outstandingBalance'; - $attributes[] = 'paymentAmount'; - $attributes[] = 'totalPaid'; - $attributes[] = 'total'; - $attributes[] = 'totalPrice'; - $attributes[] = 'totalPromotionalAmount'; - $attributes[] = 'totalTax'; - $attributes[] = 'totalTaxIncluded'; - $attributes[] = 'totalShippingCost'; - $attributes[] = 'totalDiscount'; - $attributes[] = 'storedTotal'; - $attributes[] = 'storedTotalPrice'; - $attributes[] = 'storedTotalPaid'; - $attributes[] = 'storedItemTotal'; - $attributes[] = 'storedItemSubtotal'; - $attributes[] = 'storedTotalShippingCost'; - $attributes[] = 'storedTotalDiscount'; - $attributes[] = 'storedTotalTax'; - $attributes[] = 'storedTotalTaxIncluded'; - - return $attributes; - } - - public function fields(): array - { - $fields = parent::fields(); - - $datetimeAttributes = Component::datetimeAttributes($this); - - // @todo Commerce 6 - remove this and let the parent handle ISO-8601 serialization; update Vue components - // (OrderMeta.vue, DateOrderedInput.vue) to parse/format dates from ISO-8601 using the JS Intl API instead. - foreach ($datetimeAttributes as $attribute) { - $fields[$attribute] = static function($model, $attribute) { - if (!empty($model->$attribute)) { - $formatter = Craft::$app->getFormatter(); - - return [ - 'date' => $formatter->asDate($model->$attribute, Locale::LENGTH_SHORT), - 'time' => $formatter->asTime($model->$attribute, Locale::LENGTH_SHORT), - ]; - } - - return $model->$attribute; - }; - } - - $fields['email'] = 'email'; - $fields['paidStatusHtml'] = 'paidStatusHtml'; - $fields['customerLinkHtml'] = 'customerLinkHtml'; - $fields['orderStatusHtml'] = 'orderStatusHtml'; - $fields['totalTax'] = 'totalTax'; - $fields['totalTaxIncluded'] = 'totalTaxIncluded'; - $fields['totalShippingCost'] = 'totalShippingCost'; - $fields['totalDiscount'] = 'totalDiscount'; - - // @TODO Remove these deprecated `totalSaleAmount` aliases in Commerce 6.0 - $fields['totalSaleAmount'] = 'totalPromotionalAmount'; - $fields['totalSaleAmountAsCurrency'] = 'totalPromotionalAmountAsCurrency'; - - return $fields; - } - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $names = parent::extraFields(); - $names[] = 'adjustments'; - $names[] = 'availableShippingMethodOptions'; - $names[] = 'billingAddress'; - $names[] = 'customer'; - $names[] = 'estimatedBillingAddress'; - $names[] = 'estimatedShippingAddress'; - $names[] = 'gateway'; - $names[] = 'histories'; - $names[] = 'loadCartUrl'; - $names[] = 'nestedTransactions'; - $names[] = 'adminNotices'; - $names[] = 'notices'; - $names[] = 'orderSite'; - $names[] = 'orderStatus'; - $names[] = 'pdfUrl'; - $names[] = 'shippingAddress'; - $names[] = 'shippingMethod'; - $names[] = 'store'; - $names[] = 'totalCommittedStock'; - $names[] = 'transactions'; - return $names; - } - - /** - * @return Teller - * @throws InvalidConfigException - * @since 5.3.0 - */ - public function getTeller(): Teller - { - return Plugin::getInstance()->getCurrencies()->getTeller($this->currency); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return array_merge(parent::defineRules(), [ - // Address models are valid - [['billingAddress', 'shippingAddress'], 'validateAddress'], - [['billingAddress', 'shippingAddress'], 'validateAddressCountry'], - - // Are the addresses both being set to each other. - [ - ['billingAddress', 'shippingAddress'], 'validateAddressReuse', - 'when' => fn($model) => /** @var Order $model */ - !$model->isCompleted, - ], - - [['shippingAddress'], 'validateOrganizationTaxIdAsVatId', 'when' => fn(Order $order) => $order->getStore()->getValidateOrganizationTaxIdAsVatId() && !$order->getStore()->getUseBillingAddressForTax()], - [['billingAddress'], 'validateOrganizationTaxIdAsVatId', 'when' => fn(Order $order) => $order->getStore()->getValidateOrganizationTaxIdAsVatId() && $order->getStore()->getUseBillingAddressForTax()], - - // Line items are valid? - [['lineItems'], 'validateLineItems'], - - // Coupon Code valid? - [['couponCode'], 'validateCouponCode'], - - [['gatewayId'], 'number', 'integerOnly' => true], - [['gatewayId'], 'validateGatewayId'], - [['shippingAddressId'], 'number', 'integerOnly' => true], - [['billingAddressId'], 'number', 'integerOnly' => true], - - [['paymentCurrency'], 'validatePaymentCurrency'], - - [['paymentSourceId'], 'number', 'integerOnly' => true], - [['paymentSourceId'], 'validatePaymentSourceId'], - [['number', 'user', 'customer', 'storeId', 'orderSiteId', 'orderCompletedEmail', 'saveBillingAddressOnOrderComplete', 'saveShippingAddressOnOrderComplete', 'origin'], 'safe'], - ]); - } - - /** - * Automatically set addresses on the order if it's a cart and `autoSetNewCartAddresses` is `true`. - * - * - * @return bool returns true if order is mutated - * @throws Throwable - * @throws InvalidElementException - * @throws UnsupportedSiteException - * @since 3.4.14 - */ - public function autoSetAddresses(): bool - { - if ($this->isCompleted || !$this->getStore()->getAutoSetNewCartAddresses()) { - return false; - } - - /** @var User|CustomerBehavior|null $user */ - $user = $this->getCustomer(); - if (!$user) { - return false; - } - - $autoSetOccurred = false; - - if (!$this->_shippingAddress && !$this->shippingAddressId && $primaryShippingAddress = $user->getPrimaryShippingAddress()) { - $this->sourceShippingAddressId = $primaryShippingAddress->id; - $shippingAddress = Craft::$app->getElements()->duplicateElement($primaryShippingAddress, [ - 'owner' => $this, - 'primaryOwner' => $this, - ]); - $this->setShippingAddress($shippingAddress); - $autoSetOccurred = true; - } - - if (!$this->_billingAddress && !$this->billingAddressId && $primaryBillingAddress = $user->getPrimaryBillingAddress()) { - $this->sourceBillingAddressId = $primaryBillingAddress->id; - $billingAddress = Craft::$app->getElements()->duplicateElement($primaryBillingAddress, [ - 'owner' => $this, - 'primaryOwner' => $this, - ]); - $this->setBillingAddress($billingAddress); - $autoSetOccurred = true; - } - - return $autoSetOccurred; - } - - /** - * @return bool - * @throws InvalidConfigException - * @since 4.2 - */ - public function autoSetPaymentSource(): bool - { - if ($this->isCompleted || !$this->getStore()->getAutoSetPaymentSource() || $this->paymentSourceId || $this->gatewayId) { - return false; - } - - /** @var User|CustomerBehavior|null $customer */ - $customer = $this->getCustomer(); - - // Only set the payment source if there is a customer set and that is it the current user - if (!$customer || $customer->id !== Craft::$app->getUser()->getIdentity()?->id) { - return false; - } - - $paymentSource = $customer->getPrimaryPaymentSource(); - if (!$paymentSource) { - return false; - } - - $this->setPaymentSource($paymentSource); - return true; - } - - /** - * Auto set shipping method based on config settings and available options - * - * @return bool returns true if order is mutated - * @since 4.1 - */ - public function autoSetShippingMethod(): bool - { - if ($this->shippingMethodHandle || $this->isCompleted || !$this->getStore()->getAutoSetCartShippingMethodOption()) { - return false; - } - - $availableMethodOptions = $this->getAvailableShippingMethodOptions(); - if (empty($availableMethodOptions)) { - return false; - } - - $this->shippingMethodHandle = ArrayHelper::firstKey($availableMethodOptions); - - return true; - } - - /** - * Updates the paid status and paid date of the order, and marks as complete if the order is paid or authorized. - */ - public function updateOrderPaidInformation(): void - { - $this->_transactions = null; // clear order's transaction cache - - $paidInFull = !$this->hasOutstandingBalance(); - $authorizedInFull = $this->getTotalAuthorized() >= $this->getTotalPrice(); - - $justPaid = $paidInFull && $this->datePaid == null; - $justAuthorized = $authorizedInFull && $this->dateAuthorized == null; - - $completeTotal = $this->getTeller()->add($this->getTotalAuthorized(), $this->getTotalPaid()); - $canComplete = $this->getTeller()->greaterThan($completeTotal, 0); - - // If it is no longer paid in full, set datePaid to null - if (!$paidInFull) { - $this->datePaid = null; - } - - // If it is no longer authorized in full, set dateAuthorized to null - if (!$authorizedInFull) { - $this->dateAuthorized = null; - } - - // If it was just paid set the date paid to now. - if ($justPaid) { - $this->datePaid = new DateTime(); - } - - // If it was just paid and this is the first time, set the date first paid to now. - if ($justPaid && $this->dateFirstPaid === null) { - $this->dateFirstPaid = new DateTime(); - } - - // If it was just authorized set the date authorized to now. - if ($justAuthorized) { - $this->dateAuthorized = new DateTime(); - } - - // Lock for recalculation - $originalRecalculationMode = $this->getRecalculationMode(); - $this->setRecalculationMode(self::RECALCULATION_MODE_NONE); - - // Saving the order will update the datePaid as set above and also update the paidStatus. - Craft::$app->getElements()->saveElement($this, false); - - // If the order is now paid or authorized in full, lets mark it as complete if it has not already been. - if (!$this->isCompleted) { - $totalAuthorized = $this->getTotalAuthorized(); - if ($totalAuthorized >= $this->getTotalPrice() || $paidInFull || $canComplete) { - // We need to remove the payment source from the order now that it's paid - // This means the order needs new payment details for future payments: https://github.com/craftcms/commerce/issues/891 - // Payment information is still stored in the transactions. - $this->paymentSourceId = null; - - $this->markAsComplete(); - } - } - - if ($justPaid && $this->hasEventHandlers(self::EVENT_AFTER_ORDER_PAID)) { - $this->trigger(self::EVENT_AFTER_ORDER_PAID); - } - - if ($justAuthorized && $this->hasEventHandlers(self::EVENT_AFTER_ORDER_AUTHORIZED)) { - $this->trigger(self::EVENT_AFTER_ORDER_AUTHORIZED); - } - - // Restore the original recalculation mode, unless this call completed the order - // a completed order must stay locked at `RECALCULATION_MODE_NONE` rather than reverting to its cart mode. - if (!$this->isCompleted) { - $this->setRecalculationMode($originalRecalculationMode); - } - } - - /** - * Marks the order as complete and sets the default order status, then saves the order. - * - * @throws OrderStatusException - * @throws Exception - * @throws Throwable - * @throws ElementNotFoundException - */ - public function markAsComplete(): bool - { - // Use a mutex to make sure we check the order is not already complete due to a race condition. - $lockName = 'orderComplete:' . $this->id; - $mutex = Craft::$app->getMutex(); - if (!$mutex->acquire($lockName, 5)) { - throw new Exception('Unable to acquire a lock for completion of Order: ' . $this->id); - } - - // Now that we have a lock, make sure this order is not already completed. - if ($this->isCompleted) { - $mutex->release($lockName); - return true; - } - - // Try to catch where the order could be marked as completed twice at the same time, and thus cause a race condition. - $completedInDb = (new Query()) - ->select('id') - ->from([Table::ORDERS]) - ->where(['isCompleted' => true]) - ->andWhere(['id' => $this->id]) - ->exists(); - - if ($completedInDb) { - $mutex->release($lockName); - return true; - } - - $this->isCompleted = true; - $this->dateOrdered = new DateTime(); - - // Reset estimated address relations - $this->estimatedShippingAddressId = null; - $this->estimatedBillingAddressId = null; - $this->orderCompletedEmail = $this->getEmail(); - - $orderStatus = Plugin::getInstance()->getOrderStatuses()->getDefaultOrderStatusForOrder($this); - - // If the order status returned was overridden by a plugin, use the configured default order status if they give us a bogus one with no ID. - if ($orderStatus && $orderStatus->id) { - $this->orderStatusId = $orderStatus->id; - } else { - $mutex->release($lockName); - throw new OrderStatusException('Could not find a valid default order status.'); - } - - if ($this->reference == null) { - $referenceTemplate = $this->getStore()->getOrderReferenceFormat(); - - try { - $baseReference = Craft::$app->getView()->renderSandboxedObjectTemplate($referenceTemplate, $this); - - // Check if this reference already exists and append suffix if needed - $suffix = 0; - $testReference = $baseReference; - - while (true) { - $existingReference = (new Query()) - ->select('id') - ->from([Table::ORDERS]) - ->where(['reference' => $testReference]) - ->exists(); - - if (!$existingReference) { - // Reference is unique, use it - $this->reference = $testReference; - break; - } - - // Reference exists, increment suffix and try again - $suffix++; - $testReference = $baseReference . '-' . $suffix; - } - } catch (Throwable $exception) { - $mutex->release($lockName); - Craft::error('Unable to generate order completion reference for order ID: ' . $this->id . ', with format: ' . $referenceTemplate . ', error: ' . $exception->getMessage()); - throw $exception; - } - } - - // Raising the 'beforeCompleteOrder' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_COMPLETE_ORDER)) { - $this->trigger(self::EVENT_BEFORE_COMPLETE_ORDER); - } - - // Completed orders should no longer recalculate anything by default - $this->setRecalculationMode(static::RECALCULATION_MODE_NONE); - - $this->clearNotices(); // Customer notices are assessed as being delivered once the customer decides to complete the order. - $success = Craft::$app->getElements()->saveElement($this, false); - - if (!$success) { - Craft::error(Craft::t('commerce', 'Could not mark order {number} as complete. Order save failed during order completion with errors: {order}', - ['number' => $this->number, 'order' => json_encode($this->errors)]), __METHOD__); - - $mutex->release($lockName); - return false; - } - - $mutex->release($lockName); - - $this->afterOrderComplete(); - - return true; - } - - /** - * Called after the order successfully completes - */ - public function afterOrderComplete(): void - { - // Run order complete handlers directly. - Plugin::getInstance()->getDiscounts()->orderCompleteHandler($this); - Plugin::getInstance()->getCustomers()->orderCompleteHandler($this); - Plugin::getInstance()->getInventory()->orderCompleteHandler($this); - - foreach ($this->getLineItems() as $lineItem) { - Plugin::getInstance()->getLineItems()->orderCompleteHandler($lineItem, $this); - } - - // Persist any admin notices added by the handlers above. - $this->_saveNotices(); - - // Raising the 'afterCompleteOrder' event - if ($this->hasEventHandlers(self::EVENT_AFTER_COMPLETE_ORDER)) { - $this->trigger(self::EVENT_AFTER_COMPLETE_ORDER); - } - } - - /** - * Removes a specific line item from the order. - */ - public function removeLineItem(LineItem $lineItem): void - { - $lineItems = $this->getLineItems(); - foreach ($lineItems as $key => $item) { - if (($item->id !== null && $lineItem->id == $item->id) || $lineItem === $item) { - unset($lineItems[$key]); - $this->setLineItems($lineItems); - } - } - - if ($this->hasEventHandlers(self::EVENT_AFTER_REMOVE_LINE_ITEM)) { - $this->trigger(self::EVENT_AFTER_REMOVE_LINE_ITEM, new LineItemEvent([ - 'lineItem' => $lineItem, - ])); - } - } - - /** - * Adds a line item to the order. Updates the line item if the ID of that line item is already in the cart. - */ - public function addLineItem(LineItem $lineItem): void - { - $lineItems = $this->getLineItems(); - $isNew = ($lineItem->id === null); - - if ($isNew && $this->hasEventHandlers(self::EVENT_BEFORE_ADD_LINE_ITEM)) { - $lineItemEvent = new AddLineItemEvent(compact('lineItem', 'isNew')); - $this->trigger(self::EVENT_BEFORE_ADD_LINE_ITEM, $lineItemEvent); - - if (!$lineItemEvent->isValid) { - return; - } - } - - $replaced = false; - foreach ($lineItems as $key => $item) { - if ($lineItem->id && $item->id == $lineItem->id) { - $lineItems[$key] = $lineItem; - $replaced = true; - } - } - - if (!$replaced) { - array_unshift($lineItems, $lineItem); - } - - $this->setLineItems($lineItems); - - // Raising the 'afterAddLineItemToOrder' event - if ($this->hasEventHandlers(self::EVENT_AFTER_ADD_LINE_ITEM)) { - $this->trigger(self::EVENT_AFTER_ADD_LINE_ITEM, new LineItemEvent([ - 'lineItem' => $lineItem, - 'isNew' => !$replaced, - ])); - } - } - - /** - * Returns any line item with that purchasable - * - * @param Purchasable $purchasable - * @return Collection - */ - public function lineItemsByPurchasable(Purchasable $purchasable): Collection - { - return collect($this->getLineItems()) - ->filter(fn(LineItem $lineItem) => $lineItem->purchasableId == $purchasable->getId()); - } - - /** - * Gets the recalculation mode of the order - */ - public function getRecalculationMode(): string - { - return $this->_recalculationMode; - } - - /** - * Sets the recalculation mode of the order - */ - public function setRecalculationMode(string $value): void - { - $this->_recalculationMode = $value; - } - - /** - * Regenerates all adjusters and updates line items, depending on the current recalculationMode - * - * @throws Exception - */ - public function recalculate(): void - { - if (!$this->id) { - throw new InvalidCallException('Do not recalculate an order that has not been saved'); - } - - // create a new before relcalculate event - - - if ($this->hasErrors()) { - Craft::getLogger()->log(Craft::t('commerce', 'Do not call recalculate on the order (Number: {orderNumber}) if errors are present.', ['orderNumber' => $this->number]), Logger::LEVEL_INFO); - return; - } - - if ($this->getRecalculationMode() == self::RECALCULATION_MODE_NONE) { - return; - } - - if ($this->getRecalculationMode() == self::RECALCULATION_MODE_ALL) { - - // Make sure we set a default shipping method option - if (!$this->isCompleted && $this->getStore()->getAutoSetCartShippingMethodOption()) { - $availableMethodOptions = $this->getAvailableShippingMethodOptions(); - if (!$this->shippingMethodHandle || !isset($availableMethodOptions[$this->shippingMethodHandle])) { - $this->shippingMethodHandle = ArrayHelper::firstKey($availableMethodOptions); - } - } - - if (!$this->shippingMethodHandle) { - $this->shippingMethodName = null; - } else { - $shippingMethod = ArrayHelper::firstWhere($this->getAvailableShippingMethodOptions(), 'handle', $this->shippingMethodHandle); - if ($shippingMethod) { - $this->shippingMethodName = $shippingMethod->getName(); - } - } - - $recalculateOrder = false; - if ($this->hasEventHandlers(self::EVENT_BEFORE_LINE_ITEMS_REFRESHED)) { - $event = new OrderLineItemsRefreshEvent([ - 'lineItems' => $this->getLineItems(), - 'recalculate' => $recalculateOrder, - ]); - $this->trigger(self::EVENT_BEFORE_LINE_ITEMS_REFRESHED, $event); - - $this->setLineItems($event->lineItems); - $recalculateOrder = $event->recalculate; - } - - foreach ($this->getLineItems() as $item) { - $originalSalePrice = $item->getSalePrice(); - $originalSalePriceAsCurrency = $item->salePriceAsCurrency; - - if ($item->refresh()) { - if ($originalSalePrice > $item->salePrice) { - $message = Craft::t('commerce', 'The price of {description} was reduced from {originalSalePriceAsCurrency} to {newSalePriceAsCurrency}', ['originalSalePriceAsCurrency' => $originalSalePriceAsCurrency, 'newSalePriceAsCurrency' => $item->salePriceAsCurrency, 'description' => $item->getDescription()]); - /** @var OrderNotice $notice */ - $notice = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => [ - 'type' => 'lineItemSalePriceChanged', - 'attribute' => "lineItems.$item->id.salePrice", - 'message' => $message, - ], - ]); - $this->addNotice($notice); - } - - if ($originalSalePrice < $item->salePrice) { - $message = Craft::t('commerce', 'The price of {description} increased from {originalSalePriceAsCurrency} to {newSalePriceAsCurrency}', ['originalSalePriceAsCurrency' => $originalSalePriceAsCurrency, 'newSalePriceAsCurrency' => $item->salePriceAsCurrency, 'description' => $item->getDescription()]); - /** @var OrderNotice $notice */ - $notice = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => [ - 'type' => 'lineItemSalePriceChanged', - 'attribute' => "lineItems.$item->id.salePrice", - 'message' => $message, - ], - ]); - $this->addNotice($notice); - } - } else { - $message = Craft::t('commerce', '{description} is no longer available.', ['description' => $item->getDescription()]); - /** @var OrderNotice $notice */ - $notice = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => [ - 'message' => $message, - 'type' => 'lineItemRemoved', - 'attribute' => 'lineItems', - ], - ]); - $this->addNotice($notice); - $this->removeLineItem($item); - $recalculateOrder = true; - } - } - - // This is run in a validation, but need to run again incase the options - // data was changed on population of the line item by a plugin. - if (OrderHelper::mergeDuplicateLineItems($this)) { - $recalculateOrder = true; - } - - if ($this->hasEventHandlers(self::EVENT_AFTER_LINE_ITEMS_REFRESHED)) { - $event = new OrderLineItemsRefreshEvent([ - 'lineItems' => $this->getLineItems(), - 'recalculate' => $recalculateOrder, - ]); - $this->trigger(self::EVENT_AFTER_LINE_ITEMS_REFRESHED, $event); - - $this->setLineItems($event->lineItems); - $recalculateOrder = $event->recalculate; - } - - if ($recalculateOrder) { - $this->recalculate(); - return; - } - } - - if ($this->getRecalculationMode() == self::RECALCULATION_MODE_ALL || $this->getRecalculationMode() == self::RECALCULATION_MODE_ADJUSTMENTS_ONLY) { - //clear adjustments - $this->setAdjustments([]); - - foreach (Plugin::getInstance()->getOrderAdjustments()->getAdjusters() as $adjuster) { - /** @var string|AdjusterInterface $adjuster */ - $adjuster = Craft::createObject($adjuster); - $adjustments = $adjuster->adjust($this); - $this->setAdjustments(array_merge($this->getAdjustments(), $adjustments)); - } - } - - if ($this->getRecalculationMode() == self::RECALCULATION_MODE_ALL) { - // Since shipping adjusters run on the original price, pre discount, let's recalculate - // if the currently selected shipping method is now not available after adjustments have run. - $availableMethodOptions = $this->getAvailableShippingMethodOptions(); - if ($this->shippingMethodHandle && !isset($availableMethodOptions[$this->shippingMethodHandle])) { - $this->shippingMethodHandle = ArrayHelper::firstKey($availableMethodOptions); - $message = Craft::t('commerce', 'The previously-selected shipping method is no longer available.'); - $orderNotice = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => [ - 'type' => 'shippingMethodChanged', - 'attribute' => 'shippingMethodHandle', - 'message' => $message, - ], - ]); - - $this->addNotice($orderNotice); - $this->recalculate(); - } - } - } - - /** - * @return ShippingMethodOption[] - * - * @since 3.1 - */ - public function getAvailableShippingMethodOptions(): array - { - // Matching will contain the core shipping methods and any plugin dynamically returned shipping methods. - $methods = Plugin::getInstance()->getShippingMethods()->getMatchingShippingMethods($this); - $matchingMethodHandles = ArrayHelper::getColumn($methods, fn(ShippingMethodInterface $sm) => $sm->getHandle()); - - // Get all regular methods and add them to the list, for use only when the order is complete. - if ($this->isCompleted) { - $allShippingMethods = Plugin::getInstance()->getShippingMethods()->getAllShippingMethods() - ->keyBy(fn(ShippingMethodInterface $sm) => $sm->getHandle()) - ->filter(fn(ShippingMethodInterface $sm) => $sm->getIsEnabled()) - ->all(); - - $methods = ArrayHelper::merge($allShippingMethods, $methods); - } - - $availableShippingMethodOptions = []; - - foreach ($methods as $method) { - $option = new ShippingMethodOption(); - - $storeId = $this->storeId; - - if ($method instanceof ShippingMethod) { - // @TODO Remove this dateCreated/dateUpdated copy in Commerce 6.0 once ShippingMethodOption no longer exposes those attributes - foreach (['dateCreated', 'dateUpdated'] as $attribute) { - $option->$attribute = $method->$attribute; - } - - if ($method->storeId !== $storeId) { - continue; - } - } - - $matchesOrder = ArrayHelper::isIn($method->getHandle(), $matchingMethodHandles); - $option->setOrder($this); - $option->enabled = $method->getIsEnabled(); - $option->id = $method->getId(); - $option->name = $method->getName(); - $option->handle = $method->getHandle(); - $option->matchesOrder = $matchesOrder; - $option->price = $matchesOrder ? $method->getPriceForOrder($this) : 0; - $option->shippingMethod = $method; - $option->storeId = $storeId; - - // Add all methods if completed, and only the matching methods when it is not completed. - if ($this->isCompleted || $option->matchesOrder) { - $availableShippingMethodOptions[$option->handle] = $option; - } - } - - return $availableShippingMethodOptions; - } - - /** - * @return Collection - * @throws DeprecationException - * @throws InvalidConfigException - * @since 5.5 - */ - public function getAvailableGateways(): Collection - { - return Plugin::getInstance()->getGateways()->getAllCustomerEnabledGatewaysAndAvailableForUseWithOrder($this); - } - - /** - * @inheritdoc - */ - public function afterSave(bool $isNew): void - { - $lockKey = "order-after-save:$this->number"; - $mutex = Craft::$app->getMutex(); - if (!$mutex->acquire($lockKey, 15)) { - throw new MutexException($lockKey, 'Could not acquire a lock to save the order.'); - } - - try { - - // Make sure addresses are set before recalculation so that on the next page load - // the correct adjustments and totals are shown - if ($this->shippingSameAsBilling) { - $this->setShippingAddress($this->getBillingAddress()); - } - - if ($this->billingSameAsShipping) { - $this->setBillingAddress($this->getShippingAddress()); - } - - // @TODO Move recalculate() out of afterSave(); saving should not implicitly recalculate, and the always-recalc-on-save-when-incomplete behavior should be opt-in #COM-40 - $this->recalculate(); - - if (!$isNew) { - $orderRecord = OrderRecord::findOne($this->id); - - if (!$orderRecord) { - throw new Exception('Invalid order ID: ' . $this->id); - } - } else { - $orderRecord = new OrderRecord(); - $orderRecord->id = $this->id; - } - - $oldStatusId = $orderRecord->orderStatusId; - - $orderRecord->storeId = $this->storeId ?? Plugin::getInstance()->getStores()->getCurrentStore()->id; - $orderRecord->number = $this->number; - $orderRecord->reference = $this->reference; - $orderRecord->itemTotal = $this->getItemTotal(); - $orderRecord->itemSubtotal = $this->getItemSubtotal(); - $orderRecord->email = $this->getEmail() ?: ''; - $orderRecord->orderCompletedEmail = $this->orderCompletedEmail; - $orderRecord->isCompleted = $this->isCompleted; - - $dateOrdered = $this->dateOrdered; - if (!$dateOrdered && $orderRecord->isCompleted) { - $dateOrdered = Db::prepareDateForDb(new DateTime()); - } - $orderRecord->dateOrdered = $dateOrdered; - - $orderRecord->datePaid = $this->datePaid ?: null; - $orderRecord->dateFirstPaid = $this->dateFirstPaid ?: null; - $orderRecord->dateAuthorized = $this->dateAuthorized ?: null; - $orderRecord->shippingMethodHandle = $this->shippingMethodHandle ?? ''; - $orderRecord->shippingMethodName = $this->shippingMethodName ?? ''; - $orderRecord->paymentSourceId = $this->getPaymentSource() ? $this->getPaymentSource()->id : null; - $orderRecord->gatewayId = $this->gatewayId; - $orderRecord->orderStatusId = $this->orderStatusId; - $orderRecord->couponCode = $this->couponCode; - $orderRecord->total = $this->getTotal(); - $orderRecord->totalPrice = $this->getTotalPrice(); - $orderRecord->totalPaid = $this->getTotalPaid(); - $orderRecord->totalDiscount = $this->getTotalDiscount(); - $orderRecord->totalShippingCost = $this->getTotalShippingCost(); - $orderRecord->totalTax = $this->getTotalTax(); - $orderRecord->totalTaxIncluded = $this->getTotalTaxIncluded(); - $orderRecord->totalQty = $this->getTotalQty(); - $orderRecord->totalWeight = $this->getTotalWeight(); - $orderRecord->currency = $this->currency; - $orderRecord->lastIp = $this->lastIp; - $orderRecord->orderLanguage = $this->orderLanguage; - $orderRecord->orderSiteId = $this->orderSiteId; - $orderRecord->origin = $this->origin; - $orderRecord->paymentCurrency = $this->paymentCurrency; - $orderRecord->customerId = $this->getCustomerId(); - $orderRecord->customerDeleted = $this->getCustomerDeleted(); - $orderRecord->registerUserOnOrderComplete = $this->registerUserOnOrderComplete; - $orderRecord->saveBillingAddressOnOrderComplete = $this->saveBillingAddressOnOrderComplete; - $orderRecord->saveShippingAddressOnOrderComplete = $this->saveShippingAddressOnOrderComplete; - $orderRecord->returnUrl = $this->returnUrl; - $orderRecord->cancelUrl = $this->cancelUrl; - $orderRecord->message = $this->message; - $orderRecord->paidStatus = $this->getPaidStatus(); - $orderRecord->recalculationMode = $this->getRecalculationMode(); - $orderRecord->sourceShippingAddressId = $this->sourceShippingAddressId; - $orderRecord->sourceBillingAddressId = $this->sourceBillingAddressId; - $orderRecord->makePrimaryShippingAddress = $this->makePrimaryShippingAddress; - $orderRecord->makePrimaryBillingAddress = $this->makePrimaryBillingAddress; - - // We want to always have the same date as the element table, based on the logic for updating these in the element service i.e resaving - $orderRecord->dateUpdated = $this->dateUpdated; - $orderRecord->dateCreated = $this->dateCreated; - - $currentUser = Craft::$app->getUser()->getIdentity(); - $currentUserIsCustomer = ($currentUser && $this->getCustomer() && $currentUser->id == $this->getCustomer()->id); - - if ($shippingAddress = $this->getShippingAddress()) { - // If we only set the owner ID an element query will be triggered. If this is a brand-new order we will encounter an error - // This is because the order record has not been saved. - // We can avoid this by simply fully setting the owner on the address element. This is also a performance optimisation to avoid an extra query. - $shippingAddress->setPrimaryOwner($this); // Always ensure the address is owned by the order - $shippingAddress->title = Craft::t('commerce', 'Shipping Address'); // Ensure the address is labelled correctly - Craft::$app->getElements()->saveElement($shippingAddress, false); - $orderRecord->shippingAddressId = $shippingAddress->id; - $this->setShippingAddress($shippingAddress); - // Set primary shipping if asked - if ($this->makePrimaryShippingAddress && $currentUserIsCustomer && $this->sourceShippingAddressId) { - Plugin::getInstance()->getCustomers()->savePrimaryShippingAddressId($this->getCustomer(), $this->sourceShippingAddressId); - } - } else { - $orderRecord->shippingAddressId = null; - $this->setShippingAddress(null); - } - - if ($billingAddress = $this->getBillingAddress()) { - // If these were set to the same address element, we don't want the same address IDs - if ($shippingAddress && $billingAddress->id == $shippingAddress->id) { - $billingAddress = Craft::$app->getElements()->duplicateElement($billingAddress, - ['owner' => $this, 'title' => Craft::t('commerce', 'Billing Address')]); - } else { - // If we only set the owner ID an element query will be triggered. If this is a brand-new order we will encounter an error - // This is because the order record has not been saved. - // We can avoid this by simply fully setting the owner on the address element. This is also a performance optimisation to avoid an extra query. - $billingAddress->setOwner($this); // Always ensure the address is owned by the order - $billingAddress->title = Craft::t('commerce', 'Billing Address'); // Ensure the address is labelled correctly - Craft::$app->getElements()->saveElement($billingAddress, false); - } - - $orderRecord->billingAddressId = $billingAddress->id; - $this->setBillingAddress($billingAddress); - // Set primary billing if asked - if ($this->makePrimaryBillingAddress && $currentUserIsCustomer && $this->sourceBillingAddressId) { - Plugin::getInstance()->getCustomers()->savePrimaryBillingAddressId($this->getCustomer(), $this->sourceBillingAddressId); - } - } else { - $orderRecord->billingAddressId = null; - $this->setBillingAddress(null); - } - - if ($estimatedShippingAddress = $this->getEstimatedShippingAddress()) { - // If we only set the owner ID an element query will be triggered. If this is a brand-new order we will encounter an error - // This is because the order record has not been saved. - // We can avoid this by simply fully setting the owner on the address element. This is also a performance optimisation to avoid an extra query. - $estimatedShippingAddress->setPrimaryOwner($this); // Always ensure the address is owned by the order - Craft::$app->getElements()->saveElement($estimatedShippingAddress, false); - $orderRecord->estimatedShippingAddressId = $estimatedShippingAddress->id; - $this->setEstimatedShippingAddress($estimatedShippingAddress); - - // If estimate billing same as shipping set it here - if ($this->estimatedBillingSameAsShipping) { - $orderRecord->estimatedBillingAddressId = $estimatedShippingAddress->id; - $this->setEstimatedBillingAddress($estimatedShippingAddress); - } - } - - if (!$this->estimatedBillingSameAsShipping && $estimatedBillingAddress = $this->getEstimatedBillingAddress()) { - // If we only set the owner ID an element query will be triggered. If this is a brand-new order we will encounter an error - // This is because the order record has not been saved. - // We can avoid this by simply fully setting the owner on the address element. This is also a performance optimisation to avoid an extra query. - $estimatedBillingAddress->setOwner($this); // Always ensure the address is owned by the order - Craft::$app->getElements()->saveElement($estimatedBillingAddress, false); - $orderRecord->estimatedBillingAddressId = $estimatedBillingAddress->id; - $this->setEstimatedBillingAddress($estimatedBillingAddress); - } - - $orderRecord->save(false); - - $this->_saveAdjustments(); - $this->_saveLineItems(); - $this->_saveNotices(); - $this->_deleteOrphanedOrderAddresses(); - } catch (Exception $exception) { - $mutex->release($lockKey); - throw $exception; - } - - $mutex->release($lockKey); - - // We can do this after the lock - $this->_saveOrderHistory($oldStatusId, $orderRecord->orderStatusId); - - parent::afterSave($isNew); - } - - public function getShortNumber(): string - { - return substr($this->number, 0, 7); - } - - /** - * @inheritdoc - */ - public function getLink(?string $title = null, array $options = []): ?Markup - { - if ($title) { - $options['title'] = $title; - } - - $title = $title ?: ($this->reference ?: $this->getShortNumber()); - $link = Html::a($title, $this->getCpEditUrl(), $options); - - return Template::raw($link); - } - - /** - * @inheritdoc - */ - public function getCpEditUrl(): ?string - { - return UrlHelper::cpUrl('commerce/orders/' . $this->id); - } - - /** - * Returns the URL to the order's PDF invoice. - * - * @param string|null $option The option that should be available to the PDF template (e.g. "receipt") - * @param string|null $pdfHandle The handle of the PDF to use. If none is passed the default PDF is used. - * @param bool $inline Whether the PDF should be displayed inline in the browser (default: false) - * @return string The URL to the order's PDF invoice with a secure token - */ - public function getPdfUrl(string $option = null, string $pdfHandle = null, bool $inline = false): string - { - return Plugin::getInstance()->getPdfs()->getPdfUrl($this, $option, $pdfHandle, $inline); - } - - /** - * Returns the URL to the cart's load action url with a secure token. - * - * @return string|null The URL to the order's load cart URL, or null if the cart is an order - * @noinspection PhpUnused - */ - public function getLoadCartUrl(): ?string - { - if ($this->isCompleted) { - return null; - } - - return Plugin::getInstance()->getCarts()->getLoadCartUrl($this); - } - - /** - * Returns the order customer ID. - * - * @return int|null - * @since 4.0.0 - */ - public function getCustomerId(): ?int - { - return $this->_customerId; - } - - /** - * Sets the order customer ID. - * - * @param int|int[]|null $customerId - * @since 4.0.0 - */ - public function setCustomerId(mixed $customerId): void - { - if (is_array($customerId)) { - $this->_customerId = reset($customerId) ?: null; - } else { - $this->_customerId = $customerId; - } - - $this->_customer = null; - } - - /** - * @return bool - * @since 5.7.0 - */ - public function getCustomerDeleted(): bool - { - return $this->_customerDeleted && !$this->getCustomerId(); - } - - /** - * @param bool $customerDeleted - * @return void - * @since 5.7.0 - */ - public function setCustomerDeleted(bool $customerDeleted): void - { - $this->_customerDeleted = $customerDeleted; - } - - /** - * Returns the order's customer. - * - * --- - * ```php - * $customer = $order->customer; - * ``` - * ```twig - *

By {{ order.customer.name }}

- * ``` - * - * @return User|null - */ - public function getCustomer(): ?User - { - if (!isset($this->_customer)) { - if (!$this->getCustomerId()) { - return null; - } - - if (($this->_customer = Craft::$app->getUsers()->getUserById($this->getCustomerId())) === null) { - $this->_customer = false; - } - } - - return $this->_customer ?: null; - } - - /** - * Sets the order's customer. - * - * @param User|null $customer - */ - public function setCustomer(?User $customer = null): void - { - $this->_customer = $customer; - if ($this->_customer) { - $this->_customerId = $this->_customer->id; - } else { - $this->_customerId = null; - } - } - - /** - * @deprecated in 4.0.0. Use [[getCustomer()]] instead. - */ - public function getUser(): ?User - { - Craft::$app->getDeprecator()->log('Order::getUser()', 'The `Order::getUser()` is deprecated, use `Order::getCustomer()` instead.'); - return $this->getCustomer(); - } - - /** - * Sets the orders user based on the email address provided. - * - * @param string|null $email - * @throws Exception - * @deprecated in 4.3.0. Use [[setCustomer()]] instead. - */ - public function setEmail(?string $email): void - { - Craft::$app->getDeprecator()->log(__METHOD__, '`Order::setEmail()` has been deprecated use `Order::setCustomer()` instead.'); - if (!$email) { - $this->_customer = null; - $this->_customerId = null; - return; - } - - if ($this->_customer && $this->_customer->email === $email) { - return; - } - - $user = Craft::$app->getUsers()->ensureUserByEmail($email); - $this->setCustomer($user); - } - - /** - * Returns the email for this order. Will always be the customer's email if they exist. - * @return string|null - */ - public function getEmail(): ?string - { - return $this->getCustomer()?->email ?? $this->email ?? null; - } - - /** - * Returns a masked version of the email for this order. - * - * @param $length - * @return string - */ - public function getMaskedEmail(): string - { - if ($email = $this->getEmail()) { - return $this->_maskEmail($email); - } - - return ''; - } - - private function _maskEmail($email, $minLength = 3, $maxLength = 10, $mask = "***") - { - $atPos = strrpos($email, "@"); - $name = substr($email, 0, $atPos); - $len = strlen($name); - $domain = substr($email, $atPos); - - if (($len / 2) < $maxLength) { - $maxLength = ($len / 2); - } - - $shortenedEmail = (($len > $minLength) ? substr($name, 0, $maxLength) : ""); - return "{$shortenedEmail}{$mask}{$domain}"; - } - - /** - * @return bool - */ - public function getIsPaid(): bool - { - return !$this->hasOutstandingBalance() && $this->isCompleted; - } - - /** - * @noinspection PhpUnused - */ - public function getIsUnpaid(): bool - { - return $this->hasOutstandingBalance(); - } - - /** - * Returns the paymentAmount for this order. - * - * @throws CurrencyException - */ - public function getPaymentAmount(): float - { - $paymentAmount = $this->getOutstandingBalance(); - - // Only convert if we have differing currencies - if ($this->currency !== $this->getPaymentCurrency()) { - $teller = $this->getTeller(); - $tellerTo = Plugin::getInstance()->getCurrencies()->getTeller($this->getPaymentCurrency()); - $outstandingBalanceAmount = $teller->convertToMoney($this->getOutstandingBalance()); - $outstandingBalanceInPaymentCurrency = Plugin::getInstance()->getPaymentCurrencies()->convertAmount($outstandingBalanceAmount, $this->getPaymentCurrency(), $this->getStore()->id); - - $paymentAmount = (float)$tellerTo->convertToString($outstandingBalanceInPaymentCurrency); - } - - if (isset($this->_paymentAmount) && $this->_paymentAmount >= 0 && $this->_paymentAmount <= $paymentAmount) { - return $this->_paymentAmount; - } - - return $paymentAmount; - } - - /** - * Sets the order's payment amount in the order's currency. This amount is not persisted. - * This will remain null if set to zero or a negative number. - * - * @throws CurrencyException - * @throws InvalidConfigException - */ - public function setPaymentAmount(float $amount): void - { - $paymentCurrency = Plugin::getInstance()->getPaymentCurrencies()->getPaymentCurrencyByIso($this->getPaymentCurrency()); - $amount = Currency::round($amount, $paymentCurrency); - - if ($amount > 0) { - $this->_paymentAmount = $amount; - } - } - - /** - * Returns whether the payment amount currently set is a partial amount of the order's outstanding balance. - * - * @throws CurrencyException - * @throws InvalidConfigException - * @since 3.4.10 - */ - public function isPaymentAmountPartial(): bool - { - $paymentAmountInPrimaryCurrency = Plugin::getInstance()->getPaymentCurrencies()->convertCurrency($this->getPaymentAmount(), $this->getPaymentCurrency(), $this->currency, true); - - return $paymentAmountInPrimaryCurrency < $this->getOutstandingBalance(); - } - - /** - * What is the status of the orders payment - */ - public function getPaidStatus(): string - { - if ($this->getIsPaid() && - $this->getTeller()->greaterThan($this->getTotalPrice(), 0) && - $this->getTeller()->greaterThan($this->getTotalPaid(), $this->getTotalPrice()) - ) { - return self::PAID_STATUS_OVERPAID; - } - - if ($this->getIsPaid()) { - return self::PAID_STATUS_PAID; - } - - if ($this->getTeller()->greaterThan($this->getTotalPaid(), 0)) { - return self::PAID_STATUS_PARTIAL; - } - - return self::PAID_STATUS_UNPAID; - } - - /** - * Customer represented as HTML - * Customer User link represented as HTML - * - * @return string - * @since 3.0 - */ - public function getCustomerLinkHtml(): string - { - $html = ''; - if ($user = $this->getCustomer()) { - $email = Html::encode($user->email); - $html = Html::tag('a', $email, ['href' => $user->getCpEditUrl()]); - } - - return $html; - } - - /** - * @return string - * @throws InvalidConfigException - */ - public function getOrderStatusHtml(): string - { - if ($status = $this->getOrderStatus()) { - return $status->getLabelHtml(); - } - - return ''; - } - - /** - * Paid status represented as HTML - */ - public function getPaidStatusHtml(): string - { - return match ($this->getPaidStatus()) { - self::PAID_STATUS_OVERPAID => Cp::statusLabelHtml(['color' => 'blue', 'label' => Craft::t('commerce', 'Overpaid')]), - self::PAID_STATUS_PAID => Cp::statusLabelHtml(['color' => 'green', 'label' => Craft::t('commerce', 'Paid')]), - self::PAID_STATUS_PARTIAL => Cp::statusLabelHtml(['color' => 'orange', 'label' => Craft::t('commerce', 'Partial')]), - self::PAID_STATUS_UNPAID => Cp::statusLabelHtml(['color' => 'red', 'label' => Craft::t('commerce', 'Unpaid')]), - default => '', - }; - } - - /** - * Returns the raw total of the order, which is the total of all line items and adjustments. This number can be negative, so it is not the price of the order. - * - * @see Order::getTotalPrice() The actual total price of the order. - * - */ - public function getTotal(): float - { - $itemSubtotal = $this->getItemSubtotal(); - $adjustmentsTotal = $this->getAdjustmentsTotal(); - return (float)$this->getTeller()->add($itemSubtotal, $adjustmentsTotal); - } - - /** - * Get the total price of the order, whose minimum value is enforced by the configured {@link Store::getMinimumTotalPriceStrategy() strategy set for minimum total price}. - */ - public function getTotalPrice(): float - { - $total = (float)$this->getTeller()->add($this->getItemSubtotal(), $this->getAdjustmentsTotal()); - // Don't get the pre-rounded total. - $strategy = $this->getStore()->getMinimumTotalPriceStrategy(); - - if ($strategy === Store::MINIMUM_TOTAL_PRICE_STRATEGY_ZERO) { - return (float)$this->getTeller()->max(0, $total); - } - - if ($strategy === Store::MINIMUM_TOTAL_PRICE_STRATEGY_SHIPPING) { - return (float)$this->getTeller()->max($this->getTotalShippingCost(), $total); - } - - return $total; - } - - public function getItemTotal(): float - { - $total = 0; - $teller = $this->getTeller(); - foreach ($this->getLineItems() as $lineItem) { - $total = (float)$teller->add($total, $lineItem->getTotal()); - } - - return $total; - } - - /** - * @since 3.4 - */ - public function hasShippableItems(): bool - { - foreach ($this->getLineItems() as $item) { - if ($item->getIsShippable()) { - return true; - } - } - - return false; - } - - /** - * Returns the difference between the order amount and amount paid. - * - * @return float The outstanding balance. - */ - public function getOutstandingBalance(): float - { - return (float)$this->getTeller()->subtract($this->getTotalPrice(), $this->getTotalPaid()); - } - - /** - * @return bool Whether the order has an outstanding balance. - */ - public function hasOutstandingBalance(): bool - { - return $this->getTeller()->greaterThan($this->getOutstandingBalance(), 0); - } - - /** - * Returns the total `purchase` and `captured` transactions belonging to this order. - * - * @return float The total amount paid. - */ - public function getTotalPaid(): float - { - if ($this->id === null) { - return 0; - } - - if ($this->_transactions === null) { - $this->_transactions = Plugin::getInstance()->getTransactions()->getAllTransactionsByOrderId($this->id); - } - - $transactions = collect($this->_transactions); - - $paid = $transactions->filter(fn($transaction) => $transaction->status == TransactionRecord::STATUS_SUCCESS - && in_array($transaction->type, [TransactionRecord::TYPE_PURCHASE, TransactionRecord::TYPE_CAPTURE]))->sum('amount'); - - $refunded = $transactions->filter(fn($transaction) => $transaction->status == TransactionRecord::STATUS_SUCCESS - && $transaction->type == TransactionRecord::TYPE_REFUND)->sum('amount'); - - return (float)$this->getTeller()->subtract($paid, $refunded); - } - - /** - * @return float - */ - public function getTotalAuthorized(): float - { - if (!$this->id) { - return 0; - } - - $authorized = 0; - $captured = 0; - - if ($this->_transactions === null) { - $this->_transactions = Plugin::getInstance()->getTransactions()->getAllTransactionsByOrderId($this->id); - } - - foreach ($this->_transactions as $transaction) { - $isSuccess = ($transaction->status == TransactionRecord::STATUS_SUCCESS); - $isAuth = ($transaction->type == TransactionRecord::TYPE_AUTHORIZE); - $isCapture = ($transaction->type == TransactionRecord::TYPE_CAPTURE); - - if (!$isSuccess) { - continue; - } - - if ($isAuth) { - $authorized += $transaction->amount; - continue; - } - - if ($isCapture) { - $captured += $transaction->amount; - } - } - - return (float)$this->getTeller()->subtract($authorized, $captured); - } - - /** - * Returns whether this order is the user's current active cart. - * - * @throws ElementNotFoundException - * @throws Exception - * @throws Throwable - */ - public function getIsActiveCart(): bool - { - $cart = Plugin::getInstance()->getCarts()->getCart(); - - return $cart->id == $this->id; - } - - /** - * Returns whether the order has any items in it. - */ - public function getIsEmpty(): bool - { - return $this->getTotalQty() == 0; - } - - /** - * @noinspection PhpUnused - */ - public function hasLineItems(): bool - { - return (bool)$this->getLineItems(); - } - - /** - * Returns whether the order contains the given purchasable IDs. - * - * @param mixed $purchasableIds One or more purchasable IDs or purchasable models to check for. - * @param ContainsPurchasablesMatch $match The match mode. - * @return bool - */ - public function hasPurchasables(mixed $purchasableIds, ContainsPurchasablesMatch $match = ContainsPurchasablesMatch::Any): bool - { - if (!is_array($purchasableIds)) { - $purchasableIds = [$purchasableIds]; - } - - $orderPurchasableIds = collect($this->getLineItems()) - ->pluck('purchasableId') - ->filter(fn($id) => $id !== null); - - $requestedIds = collect($purchasableIds) - ->map(fn($id) => $id instanceof PurchasableInterface ? $id->getId() : $id) - ->filter(fn($id) => $id !== null); - - if ($match === ContainsPurchasablesMatch::Any) { - return $orderPurchasableIds->intersect($requestedIds)->isNotEmpty(); - } - - if ($match === ContainsPurchasablesMatch::Only) { - // If there are custom line items (null purchasableId), the order - // has purchasables beyond what was specified, so it can't be only. - $hasCustomLineItems = collect($this->getLineItems()) - ->pluck('purchasableId') - ->contains(null); - - if ($hasCustomLineItems) { - return false; - } - - return $orderPurchasableIds->diff($requestedIds)->isEmpty() - && $requestedIds->diff($orderPurchasableIds)->isEmpty(); - } - - // ContainsPurchasablesMatch::All — every requested purchasable must exist in the order - return $requestedIds->every(fn($id) => $orderPurchasableIds->contains($id)); - } - - - /** - * @return int - * @throws InvalidConfigException - * @throws DeprecationException - * @since 5.0.0 - */ - public function getTotalCommittedStock(): int - { - return Plugin::getInstance()->getInventory()->getInventoryFulfillmentLevels($this)->sum('committedQuantity') ?? 0; - } - - /** - * Returns total number of items. - */ - public function getTotalQty(): int - { - $qty = 0; - foreach ($this->getLineItems() as $item) { - $qty += $item->qty; - } - - return $qty; - } - - /** - * @return LineItem[] - */ - public function getLineItems(): array - { - if (!isset($this->_lineItems)) { - $lineItems = $this->id ? Plugin::getInstance()->getLineItems()->getAllLineItemsByOrderId($this->id) : []; - foreach ($lineItems as $lineItem) { - $lineItem->setOrder($this); - } - $this->_lineItems = $lineItems; - } - - return array_filter($this->_lineItems); - } - - /** - * @param LineItem[] $lineItems - */ - public function setLineItems(array $lineItems): void - { - $this->_lineItems = []; - - foreach ($lineItems as $lineItem) { - $lineItem->setOrder($this); - } - - $this->_lineItems = $lineItems; - } - - public function _getAdjustmentsTotalByType(array|string $types, bool $included = false): float|int - { - $amount = 0; - $teller = $this->getTeller(); - - if (is_string($types)) { - $types = StringHelper::split($types); - } - - foreach ($this->getAdjustments() as $adjustment) { - if ($adjustment->included == $included && in_array($adjustment->type, $types, false)) { - $amount = (float)$teller->add($amount, $adjustment->amount); - } - } - - return $amount; - } - - /** - * The total amount of tax adjustments that are additive taxes that affect total price. - * - * @return float - */ - public function getTotalTax(): float - { - return $this->_getAdjustmentsTotalByType('tax'); - } - - /** - * The total amount of tax adjustments on the order that are included in the price, and do not affect total price. - * - * @return float - */ - public function getTotalTaxIncluded(): float - { - return $this->_getAdjustmentsTotalByType('tax', true); - } - - /** - * The total amount of discount adjustments. - * - * @return float - */ - public function getTotalDiscount(): float - { - return $this->_getAdjustmentsTotalByType('discount'); - } - - /** - * The total amount of shipping adjustments. - * - * @return float - */ - public function getTotalShippingCost(): float - { - return $this->_getAdjustmentsTotalByType('shipping'); - } - - /** - * @noinspection PhpUnused - */ - public function getTotalWeight(): float - { - $weight = 0; - foreach ($this->getLineItems() as $item) { - $weight += ($item->qty * $item->weight); - } - - return $weight; - } - - /** - * Returns the total promotional amount. - * @since 5.0.0 - */ - public function getTotalPromotionalAmount(): float - { - $value = 0; - $teller = $this->getTeller(); - foreach ($this->getLineItems() as $item) { - $value = (float)$teller->add( - $value, - $teller->multiply($item->qty, $item->getPromotionalAmount()), - ); - } - - return $value; - } - - /** - * Returns the total sale amount. - * @deprecated in 5.0.0. Use [[getTotalPromotionalAmount()]] instead. - */ - public function getTotalSaleAmount(): float - { - Craft::$app->getDeprecator()->log(__METHOD__, '`getTotalSaleAmount()` method has been deprecated. Use `getTotalPromotionalAmount()` instead.'); - return $this->getTotalPromotionalAmount(); - } - - /** - * Returns the total of all line item's subtotals. - */ - public function getItemSubtotal(): float - { - $value = 0; - $teller = $this->getTeller(); - foreach ($this->getLineItems() as $item) { - $value = (float)$teller->add($value, $item->getSubtotal()); - } - - return $value; - } - - /** - * Returns the total of adjustments made to order. - * - * @return float - * @throws InvalidConfigException - * @noinspection PhpUnused - */ - public function getAdjustmentSubtotal(): float - { - $value = 0; - $teller = $this->getTeller(); - foreach ($this->getAdjustments() as $adjustment) { - if (!$adjustment->included) { - $value = (float)$teller->add($value, $adjustment->amount); - } - } - - return (float)$value; - } - - /** - * @return OrderAdjustment[]|null - * @throws InvalidConfigException - */ - public function getAdjustments(): ?array - { - if (isset($this->_orderAdjustments)) { - return $this->_orderAdjustments; - } - - if ($this->id) { - $this->setAdjustments(Plugin::getInstance()->getOrderAdjustments()->getAllOrderAdjustmentsByOrderId($this->id)); - } - - return $this->_orderAdjustments ?? []; - } - - /** - * @since 3.0 - */ - public function getAdjustmentsByType(string $type): array - { - $adjustments = []; - - foreach ($this->getAdjustments() as $adjustment) { - if ($adjustment->type === $type) { - $adjustments[] = $adjustment; - } - } - - return $adjustments; - } - - public function getOrderAdjustments(): array - { - $adjustments = $this->getAdjustments(); - $orderAdjustments = []; - - foreach ($adjustments as $adjustment) { - if (!$adjustment->getLineItem() && $adjustment->orderId == $this->id) { - $orderAdjustments[] = $adjustment; - } - } - - return $orderAdjustments; - } - - /** - * @param OrderAdjustment[] $adjustments - */ - public function setAdjustments(array $adjustments): void - { - $this->_orderAdjustments = []; - - foreach ($adjustments as $adjustment) { - $adjustment->setOrder($this); - } - - $this->_orderAdjustments = $adjustments; - } - - public function getAdjustmentsTotal(): float - { - $amount = 0; - $teller = $this->getTeller(); - foreach ($this->getAdjustments() as $adjustment) { - if (!$adjustment->included) { - $amount = (float)$teller->add($amount, $adjustment->amount); - } - } - - return $amount; - } - - /** - * * Get the shipping address on the order. - */ - public function getShippingAddress(): ?AddressElement - { - if (!isset($this->_shippingAddress) && $this->shippingAddressId) { - /** @var AddressElement|null $address */ - $address = AddressElement::find() - ->owner($this) - ->id($this->shippingAddressId) - ->one(); - - $this->_shippingAddress = $address; - } - - return $this->_shippingAddress; - } - - /** - * Set the shipping address on the order. - * - * @param AddressElement|array|null $address - */ - public function setShippingAddress(AddressElement|array|null $address): void - { - if ($address === null) { - $this->shippingAddressId = null; - $this->_shippingAddress = null; - return; - } - - if (is_array($address)) { - unset($address['id']); - $addressElement = $this->_shippingAddress ?: new AddressElement(); - $addressElement->setAttributes($address); - $this->_populateAddressNameAttributes($addressElement, $address); - $addressElement->setPrimaryOwner($this); - $address = $addressElement; - } - - if (!$address instanceof AddressElement) { - throw new InvalidArgumentException('Shipping address supplied is not an Address Element'); - } - - // Ensure that address can only belong to this order - if ($address->getPrimaryOwnerId() != $this->id) { - throw new InvalidArgumentException('Can not set a shipping address on the order that is not owned by the order.'); - } - - $this->shippingAddressId = $address->id; - $address->title = Craft::t('commerce', 'Shipping Address'); - $this->_shippingAddress = $address; - } - - /** - * @since 3.1 - */ - public function removeShippingAddress(): void - { - $this->shippingAddressId = null; - $this->_shippingAddress = null; - } - - /** - * @since 2.2 - */ - public function getEstimatedShippingAddress(): ?AddressElement - { - if (!isset($this->_estimatedShippingAddress) && $this->estimatedShippingAddressId) { - /** @var AddressElement|null $address */ - $address = AddressElement::find()->owner($this)->id($this->estimatedShippingAddressId)->one(); - $this->_estimatedShippingAddress = $address; - } - - return $this->_estimatedShippingAddress; - } - - /** - * @since 2.2 - */ - public function setEstimatedShippingAddress(AddressElement|array|null $address): void - { - if ($address === null) { - $this->estimatedShippingAddressId = null; - $this->_estimatedShippingAddress = null; - return; - } - - if (!$address instanceof AddressElement) { - $addressElement = new AddressElement(); - $addressElement->setAttributes($address); - $address = $addressElement; - } - - $this->estimatedShippingAddressId = $address->id; - $this->_estimatedShippingAddress = $address; - } - - /** - * Get the billing address on the order. - */ - public function getBillingAddress(): ?AddressElement - { - if (!isset($this->_billingAddress) && $this->billingAddressId) { - /** @var AddressElement|null $address */ - $address = AddressElement::find() - ->owner($this) - ->id($this->billingAddressId) - ->one(); - - $this->_billingAddress = $address; - } - - return $this->_billingAddress; - } - - /** - * Set the billing address on the order. - * - * @param AddressElement|array|null $address - */ - public function setBillingAddress(AddressElement|array|null $address): void - { - if ($address === null) { - $this->billingAddressId = null; - $this->_billingAddress = null; - return; - } - - if (is_array($address)) { - unset($address['id']); // only ever allow setting of the address data - $addressElement = $this->_billingAddress ?: new AddressElement(); - $addressElement->setAttributes($address); - $this->_populateAddressNameAttributes($addressElement, $address); - $addressElement->setPrimaryOwner($this); - $address = $addressElement; - } - - if (!$address instanceof AddressElement) { - throw new InvalidArgumentException('Billing address supplied is not an Address Element'); - } - - // Ensure that address can only belong to this order - if ($address->getPrimaryOwnerId() !== $this->id) { - throw new InvalidArgumentException('Can not set a billing address on the order that is not owned by the order.'); - } - - $address->ownerId = $this->id; - $this->billingAddressId = $address->id; - $address->title = Craft::t('commerce', 'Billing Address'); - $this->_billingAddress = $address; - } - - /** - * @since 3.1 - */ - public function removeBillingAddress(): void - { - $this->billingAddressId = null; - $this->_billingAddress = null; - } - - /** - * Returns whether the billing and shipping addresses' data matches - * - * @param string[]|null $attributes array of attributes names on which to match the addresses - * @return bool - * @since 4.1.0 - */ - public function hasMatchingAddresses(?array $attributes = null): bool - { - $addressAttributes = (new ReflectionClass(AddressInterface::class))->getMethods(); - $addressAttributes = array_map(static fn(ReflectionMethod $method) => // Remove `get` and lower case first character - lcfirst(substr($method->name, 3)), $addressAttributes); - - $relationCustomFieldHandles = []; - $customFieldHandles = array_map(static function(FieldInterface $field) use (&$relationCustomFieldHandles) { - if ($field instanceof BaseRelationField) { - $relationCustomFieldHandles[] = $field->handle; - } - - return $field->handle; - }, (new AddressElement())->getFieldLayout()->getCustomFields()); - - $nameTraitProperties = array_map(static fn(ReflectionProperty $property) => $property->name, (new ReflectionClass(NameTrait::class))->getProperties()); - - $toArrayHandles = [...$nameTraitProperties, ...$addressAttributes, ...$customFieldHandles]; - - if (!empty($attributes)) { - $toArrayHandles = array_intersect($toArrayHandles, $attributes); - } - - // Figure out if we need to do any extra work for custom fields - $toArrayRelationFields = !empty($relationCustomFieldHandles) ? array_intersect($toArrayHandles, $relationCustomFieldHandles) : []; - - $matchingShippingAddress = []; - if ($this->getShippingAddress() instanceof AddressElement) { - $matchingShippingAddress = $this->getShippingAddress()->toArray(array_diff($toArrayHandles, $toArrayRelationFields)); - } - - $matchingBillingAddress = []; - if ($this->getBillingAddress() instanceof AddressElement) { - $matchingBillingAddress = $this->getBillingAddress()->toArray(array_diff($toArrayHandles, $toArrayRelationFields)); - } - - // Add any relational custom fields to the matching arrays - if (!empty($toArrayRelationFields)) { - foreach ($toArrayRelationFields as $handle) { - if ($this->getShippingAddress() instanceof AddressElement) { - $matchingShippingAddress[$handle] = $this->getShippingAddress()->getFieldValue($handle)?->ids(); - } - - if ($this->getBillingAddress() instanceof AddressElement) { - $matchingBillingAddress[$handle] = $this->getBillingAddress()->getFieldValue($handle)?->ids(); - } - } - } - - return $matchingBillingAddress == $matchingShippingAddress; - } - - /** - * @since 2.2 - */ - public function getEstimatedBillingAddress(): ?AddressElement - { - if (!isset($this->_estimatedBillingAddress) && $this->estimatedBillingAddressId) { - /** @var AddressElement|null $address */ - $address = AddressElement::find()->owner($this)->id($this->estimatedBillingAddressId)->one(); - $this->_estimatedBillingAddress = $address; - } - - return $this->_estimatedBillingAddress; - } - - /** - * @since 2.2 - */ - public function setEstimatedBillingAddress(AddressElement|array|null $address): void - { - if ($address === null) { - $this->estimatedBillingAddressId = null; - $this->_estimatedBillingAddress = null; - return; - } - - if (!$address instanceof AddressElement) { - $addressElement = new AddressElement(); - $addressElement->setAttributes($address); - $address = $addressElement; - } - - $this->estimatedBillingAddressId = $address->id; - $this->_estimatedBillingAddress = $address; - } - - /** - * @return ShippingMethod|null - * @throws InvalidConfigException - * @deprecated in 3.4.18. Use `$shippingMethodHandle` or `$shippingMethodName` instead. - */ - public function getShippingMethod(): ?ShippingMethod - { - return Plugin::getInstance()->getShippingMethods()->getShippingMethodByHandle((string)$this->shippingMethodHandle); - } - - /** - * @return GatewayInterface|null - * @throws InvalidArgumentException - */ - public function getGateway(): ?GatewayInterface - { - if ($this->gatewayId === null && $this->paymentSourceId === null) { - return null; - } - - $gateway = null; - - // sources before gateways - if ($this->paymentSourceId) { - if ($paymentSource = Plugin::getInstance()->getPaymentSources()->getPaymentSourceById($this->paymentSourceId)) { - $gateway = Plugin::getInstance()->getGateways()->getGatewayById($paymentSource->gatewayId); - } - } else { - if ($this->gatewayId) { - $gateway = Plugin::getInstance()->getGateways()->getGatewayById((int)$this->gatewayId); - } - } - - return $gateway; - } - - /** - * Returns the current payment currency, and defaults to the primary currency if not set. - */ - public function getPaymentCurrency(): string - { - if ($this->_paymentCurrency === null) { - $this->_paymentCurrency = $this->getStore()->getCurrency(); - } - - return $this->_paymentCurrency; - } - - /** - * @param string $value the payment currency code - */ - public function setPaymentCurrency(string $value): void - { - $this->_paymentCurrency = $value; - } - - /** - * Returns the order's selected payment source if any. - * - * @throws InvalidConfigException if the payment source is being set by a guest customer. - * @throws InvalidArgumentException if the order is set to an invalid payment source. - */ - public function getPaymentSource(): ?PaymentSource - { - if ($this->paymentSourceId === null) { - return null; - } - - if (($user = $this->getCustomer()) === null) { - throw new InvalidConfigException('Guest customers can not set a payment source.'); - } - - if (($paymentSource = Plugin::getInstance()->getPaymentSources()->getPaymentSourceByIdAndUserId($this->paymentSourceId, $user->id)) === null) { - throw new InvalidArgumentException("Invalid payment source ID: $this->paymentSourceId"); - } - - return $paymentSource; - } - - /** - * Sets the order's selected payment source - */ - public function setPaymentSource(?PaymentSource $paymentSource): void - { - // Setting the payment source to null clears it - if ($paymentSource === null) { - $this->paymentSourceId = null; - return; - } - - // We are now dealing with a PaymentSource - $customer = $this->getCustomer(); - if ($customer?->id && $paymentSource->getCustomer()?->id !== $customer->id) { - throw new InvalidArgumentException('PaymentSource is not owned by the user of the order.'); - } - - $this->paymentSourceId = $paymentSource->id; - $this->gatewayId = null; - } - - /** - * Sets the order's selected gateway id. - */ - public function setGatewayId(int $gatewayId): void - { - $this->gatewayId = $gatewayId; - $this->paymentSourceId = null; - } - - /** - * @return OrderHistory[] - */ - public function getHistories(): array - { - if ($this->id === null) { - return []; - } - - $histories = Plugin::getInstance()->getOrderHistories()->getAllOrderHistoriesByOrderId($this->id); - - foreach ($histories as $history) { - $history->setOrder($this); - } - - return $histories; - } - - /** - * Set transactions on the order. Set to null to clear cache and force next getTransactions() call to get the latest transactions. - * - * @param Transaction[]|null $transactions - * @since 3.2.0 - */ - public function setTransactions(?array $transactions): void - { - $this->_transactions = $transactions; - } - - /** - * @return Transaction[] - */ - public function getTransactions(): array - { - if ($this->id === null) { - $this->_transactions = []; - } - - if ($this->_transactions === null) { - $transactions = Plugin::getInstance()->getTransactions()->getAllTransactionsByOrderId($this->id); - - foreach ($transactions as $transaction) { - $transaction->setOrder($this); - } - - $this->_transactions = $transactions; - } - - return $this->_transactions; - } - - /** - * @noinspection PhpUnused - */ - public function getLastTransaction(): ?Transaction - { - $transactions = $this->getTransactions(); - return count($transactions) ? array_pop($transactions) : null; - } - - /** - * Returns an array of transactions for the order that have child transactions set on them. - * - * @return Transaction[] - */ - public function getNestedTransactions(): array - { - // Transactions come in sorted by `id ASC`. - // Given that transactions cannot be modified, it means that parents will always come first. - // So we can just store a reference to them and build our tree in one pass. - $transactions = $this->getTransactions(); - - /** @var Transaction[] $referenceStore */ - $referenceStore = []; - $nestedTransactions = []; - - foreach ($transactions as $transaction) { - // We'll be adding all of the children in this loop, anyway, so we set the children list to an empty array. - // This way no db queries are triggered when transactions are queried for children. - $transaction->setChildTransactions([]); - if ($transaction->parentId && isset($referenceStore[$transaction->parentId])) { - $referenceStore[$transaction->parentId]->addChildTransaction($transaction); - } else { - $nestedTransactions[] = $transaction; - } - - $referenceStore[$transaction->id] = $transaction; - } - - return $nestedTransactions; - } - - /** - * @throws InvalidConfigException - */ - public function getOrderStatus(): ?OrderStatus - { - return $this->orderStatusId !== null ? Plugin::getInstance()->getOrderStatuses()->getOrderStatusById($this->orderStatusId, $this->storeId) : null; - } - - /** - * Get the site for the order. - * - * @since 3.2.9 - */ - public function getOrderSite(): ?Site - { - if (!$this->orderSiteId) { - return null; - } - - return Craft::$app->getSites()->getSiteById($this->orderSiteId); - } - - /** - * @inheritdoc - */ - public function getMetadata(): array - { - $metadata = []; - - if ($this->isCompleted) { - $metadata[Craft::t('commerce', 'Reference')] = Html::encode($this->reference); - $metadata[Craft::t('commerce', 'Date Ordered')] = Craft::$app->getFormatter()->asDatetime($this->dateOrdered, 'short'); - } - - $metadata[Craft::t('commerce', 'Coupon Code')] = Html::encode($this->couponCode); - - $orderSite = $this->getOrderSite(); - $metadata[Craft::t('commerce', 'Order Site')] = Html::encode($orderSite?->getName() ?? ''); - - $metadata[Craft::t('commerce', 'Shipping Method')] = Html::encode($this->shippingMethodName ?? ''); - - $metadata[Craft::t('app', 'ID')] = $this->id; - $metadata[Craft::t('commerce', 'Short Number')] = $this->getShortNumber(); - $metadata[Craft::t('commerce', 'Paid Status')] = $this->getPaidStatusHtml(); - $metadata[Craft::t('commerce', 'Total Price')] = $this->totalPriceAsCurrency; - $metadata[Craft::t('commerce', 'Paid Amount')] = $this->totalPaidAsCurrency; - $metadata[Craft::t('commerce', 'Origin')] = Html::encode($this->origin); - - return array_merge($metadata, parent::getMetadata()); - } - - /** - * @inheritdoc - */ - public function beforeDelete(): bool - { - if (!parent::beforeDelete()) { - return false; - } - - // Capture line items before the cascade delete fires so afterDelete() can refresh stock caches - if ($this->isCompleted) { - $this->_deletingLineItems = $this->getLineItems(); - } - - return true; - } - - /** - * @inheritdoc - */ - public function afterDelete(): void - { - parent::afterDelete(); - - if ($this->isCompleted) { - foreach ($this->_deletingLineItems as $lineItem) { - $purchasable = $lineItem->getPurchasable(); - if ($purchasable instanceof Purchasable && $purchasable::hasInventory() && $purchasable->inventoryTracked) { - Plugin::getInstance()->getPurchasables()->updateStoreStockCache($purchasable, true); - } - } - } - } - - /** - * Updates the adjustments, including deleting the old ones. - * - * @throws Exception - * @throws Throwable - * @throws StaleObjectException - */ - private function _saveAdjustments(): void - { - $newAdjustmentIds = []; - - foreach ($this->getAdjustments() as $adjustment) { - try { - // Don't run validation as validation of the adjustment should happen before saving the order - Plugin::getInstance()->getOrderAdjustments()->saveOrderAdjustment($adjustment, false); - } catch (OrderAdjustmentNotFoundException) { - // If the adjustment was not found, it means it may have previously existed but was already deleted (race condition). - // See: https://github.com/craftcms/commerce/issues/3283 - continue; - } - - $newAdjustmentIds[] = $adjustment->id; - $adjustment->orderId = $this->id; - } - - // Make sure all other adjustments have been cleaned up. - Db::delete( - Table::ORDERADJUSTMENTS, - ['and', ['orderId' => $this->id], ['not', ['id' => $newAdjustmentIds]]] - ); - } - - - /** - * @throws StaleObjectException - * @throws Throwable - */ - private function _saveNotices(): void - { - $previousNoticeIds = (new Query()) - ->select(['id']) - ->from([Table::ORDERNOTICES]) - ->where(['orderId' => $this->id]) - ->column(); - - $currentNoticeIds = []; - - // We are never updating a notice, just adding it or keeping it. - foreach (array_merge($this->getNotices(), $this->getAdminNotices()) as $notice) { - if ($notice->id === null) { - $orderNoticeEvent = new OrderNoticeEvent([ - 'orderNotice' => $notice, - ]); - - // Raising the 'beforeAddNoticeToOrder' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_APPLY_ADD_NOTICE)) { - $this->trigger(self::EVENT_BEFORE_APPLY_ADD_NOTICE, $orderNoticeEvent); - - if ($orderNoticeEvent->isValid === false) { - continue; - } - } - $noticeRecord = new OrderNoticeRecord(); - $noticeRecord->orderId = $notice->orderId; - $noticeRecord->type = $notice->type; - $noticeRecord->attribute = $notice->attribute; - $noticeRecord->message = $notice->message; - $noticeRecord->noticeType = $notice->noticeType->value; - if ($noticeRecord->save(false)) { - $notice->id = $noticeRecord->id; - } - } - - $currentNoticeIds[] = $notice->id; - } - - // Delete any notices that are no longer on the order - if ($deletableNoticeIds = array_diff($previousNoticeIds, $currentNoticeIds)) { - OrderNoticeRecord::deleteAll(['id' => $deletableNoticeIds]); - } - } - - /** - * Updates the line items, including deleting the old ones. - * - * @throws Throwable - */ - private function _saveLineItems(): void - { - // Line items that are currently in the DB - /** @var null|array|LineItemRecord[] $previousLineItems */ - $previousLineItems = LineItemRecord::find() - ->where(['orderId' => $this->id]) - ->all(); - - $currentLineItemIds = []; - - // Determine the line items that will be saved - foreach ($this->getLineItems() as $lineItem) { - // If the ID is null that's ok, it's a new line item and will be saved anyway - $currentLineItemIds[] = $lineItem->id; - } - - // Delete any line items that no longer will be saved on this order. - foreach ($previousLineItems as $previousLineItem) { - if (!in_array($previousLineItem->id, $currentLineItemIds, false)) { - $lineItem = Plugin::getInstance()->getLineItems()->getLineItemById($previousLineItem->id); - - $previousLineItem->delete(); - - if ($this->hasEventHandlers(self::EVENT_AFTER_APPLY_REMOVE_LINE_ITEM)) { - $this->trigger(self::EVENT_AFTER_APPLY_REMOVE_LINE_ITEM, new LineItemEvent([ - 'lineItem' => $lineItem, - ])); - } - } - } - - // Save the line items last, as we know that any possible duplicates are already removed. - // We also need to re-save any adjustments that didn't have a line item ID for a line item if it's new. - foreach ($this->getLineItems() as $lineItem) { - $originalId = $lineItem->id; - $lineItem->setOrder($this); // just in case. - - try { - // Don't run validation as validation of the line item should happen before saving the order - Plugin::getInstance()->getLineItems()->saveLineItem($lineItem, false); - } catch (LineItemNotFoundException) { - // If the line item was not found, it means it may have previously existed but was already deleted (race condition). - // See: https://github.com/craftcms/commerce/issues/3283 - continue; - } - - // Is this a new line item? - if ($originalId === null) { - // Raising the 'afterAddLineItemToOrder' event - if ($this->hasEventHandlers(self::EVENT_AFTER_APPLY_ADD_LINE_ITEM)) { - $this->trigger(self::EVENT_AFTER_APPLY_ADD_LINE_ITEM, new LineItemEvent([ - 'lineItem' => $lineItem, - 'isNew' => true, - ])); - } - } - - // Update any adjustments to this line item with the new line item ID. - foreach ($this->getAdjustments() as $adjustment) { - // Was the adjustment for this line item, but the line item ID didn't exist when the adjustment was made? - if ($adjustment->getLineItem() === $lineItem && !$adjustment->lineItemId) { - // Re-save the adjustment with the new line item ID, since it exists now. - $adjustment->lineItemId = $lineItem->id; - // Validation not needed as the adjustments are validated before the order is saved - try { - Plugin::getInstance()->getOrderAdjustments()->saveOrderAdjustment($adjustment, false); - } catch (OrderAdjustmentNotFoundException) { - // This can happen if the adjustment was removed during a race condition recalculation. - continue; - } - } - } - } - } - - /** - * Delete all addresses that are owned by the order but are not in use. - * - * @return void - * @throws Throwable - */ - private function _deleteOrphanedOrderAddresses(): void - { - if (!$this->id) { - return; - } - - $safeIds = array_filter([ - $this->getBillingAddress()?->id, - $this->getShippingAddress()?->id, - $this->getEstimatedBillingAddress()?->id, - $this->getEstimatedShippingAddress()?->id, - ]); - - $orphanedAddresses = AddressElement::find() - ->ownerId($this->id); - - if (!empty($safeIds)) { - ArrayHelper::prependOrAppend($safeIds, 'not', true); - $orphanedAddresses->id($safeIds); - } - - ($orphanedAddresses->collect())->each(function(AddressElement $address) { - Craft::$app->getElements()->deleteElement($address, true); - }); - } - - /** - * @param ?int $oldStatusId - * @param ?int $currentOrderStatId - * @return void - */ - private function _saveOrderHistory(?int $oldStatusId, ?int $currentOrderStatId): void - { - $hasNewStatus = ($oldStatusId !== $currentOrderStatId); - if ($this->isCompleted && $hasNewStatus) { - if (!Plugin::getInstance()->getOrderHistories()->createOrderHistoryFromOrder($this, $oldStatusId)) { - Craft::error('Error saving order history after order save.', __METHOD__); - } - } - } - - /** - * Sets the first and last name attributes on the address model if no full name is set. - * - * @param AddressElement $addressElement - * @param array $address - * @return void - */ - private function _populateAddressNameAttributes(AddressElement $addressElement, array $address): void - { - if (!isset($address['fullName']) || !$address['fullName']) { - $firstName = $address['firstName'] ?? null; - $lastName = $address['lastName'] ?? null; - - if ($firstName !== null || $lastName !== null) { - $addressElement->fullName = null; - $addressElement->firstName = $firstName ?? $addressElement->firstName; - $addressElement->lastName = $lastName ?? $addressElement->lastName; - } - } - } -} diff --git a/src/elements/Product.php b/src/elements/Product.php deleted file mode 100644 index acf2965d21..0000000000 --- a/src/elements/Product.php +++ /dev/null @@ -1,2220 +0,0 @@ - - * @since 2.0 - */ -class Product extends Element implements HasStoreInterface -{ - use StoreTrait; - - public const STATUS_LIVE = 'live'; - public const STATUS_PENDING = 'pending'; - public const STATUS_EXPIRED = 'expired'; - - /** - * @event ElementCriteriaEvent The event that is triggered when defining the parent selection criteria. - * @see _parentOptionCriteria() - * @since 5.2.0 - */ - public const EVENT_DEFINE_PARENT_SELECTION_CRITERIA = 'defineParentSelectionCriteria'; - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Product'); - } - - /** - * @inheritdoc - */ - public static function lowerDisplayName(): string - { - return Craft::t('commerce', 'product'); - } - - /** - * @inheritdoc - */ - public static function pluralDisplayName(): string - { - return Craft::t('commerce', 'Products'); - } - - /** - * @inheritdoc - */ - public static function pluralLowerDisplayName(): string - { - return Craft::t('commerce', 'products'); - } - - /** - * @inheritdoc - */ - public static function refHandle(): ?string - { - return 'product'; - } - - /** - * @inheritdoc - */ - public static function hasDrafts(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public static function trackChanges(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public static function hasTitles(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public static function hasUris(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public static function isLocalized(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public static function hasStatuses(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public static function statuses(): array - { - return [ - self::STATUS_LIVE => Craft::t('commerce', 'Live'), - self::STATUS_PENDING => Craft::t('commerce', 'Pending'), - self::STATUS_EXPIRED => Craft::t('commerce', 'Expired'), - self::STATUS_DISABLED => Craft::t('commerce', 'Disabled'), - ]; - } - - /** - * @inheritdoc - * @return ProductQuery The newly created [[ProductQuery]] instance. - */ - public static function find(): ElementQueryInterface - { - return new ProductQuery(static::class); - } - - /** - * @inheritdoc - * @return ProductCondition - */ - public static function createCondition(): ElementConditionInterface - { - return Craft::createObject(ProductCondition::class, [static::class]); - } - - /** - * @inheritdoc - */ - protected static function defineSources(string $context = null): array - { - if ($context == 'index') { - $productTypes = Plugin::getInstance()->getProductTypes()->getViewableProductTypes(); - $editable = true; - } else { - $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); - $editable = null; - } - - $productTypeIds = []; - - foreach ($productTypes as $productType) { - $productTypeIds[] = $productType->id; - } - - $sources = [ - [ - 'key' => '*', - 'label' => Craft::t('commerce', 'All products'), - 'criteria' => [ - 'typeId' => $productTypeIds, - 'editable' => $editable, - ], - 'defaultSort' => ['postDate', 'desc'], - ], - ]; - - $sources[] = ['heading' => Craft::t('commerce', 'Product Types')]; - - $user = Craft::$app->getUser()->getIdentity(); - - foreach ($productTypes as $productType) { - $key = 'productType:' . $productType->uid; - $canSaveProducts = $user && $user->can('commerce-saveProductType:' . $productType->uid); - - $sources[$key] = [ - 'key' => $key, - 'label' => Craft::t('site', $productType->name), - 'data' => [ - 'handle' => $productType->handle, - 'editable' => $canSaveProducts, - ], - 'criteria' => [ - 'typeId' => $productType->id, - 'editable' => $editable, - ], - // Get site ids enabled for this product type - 'sites' => $productType->getSiteIds(), - ]; - - if ($productType->isStructure) { - $sources[$key]['defaultSort'] = ['structure', 'asc']; - $sources[$key]['structureId'] = $productType->structureId; - $sources[$key]['structureEditable'] = $canSaveProducts; - } else { - $sources[$key]['defaultSort'] = ['postDate', 'desc']; - } - } - - return $sources; - } - - /** - * @inheritdoc - */ - public static function modifyCustomSource(array $config): array - { - try { - /** @var ProductCondition $condition */ - $condition = Craft::$app->getConditions()->createCondition($config['condition']); - } catch (InvalidConfigException) { - return $config; - } - - $rules = $condition->getConditionRules(); - - // see if it's limited to one product type - /** @var ProductTypeConditionRule|null $productTypeRule */ - $productTypeRule = ArrayHelper::firstWhere($rules, fn($rule) => $rule instanceof ProductTypeConditionRule); - $productTypeOptions = $productTypeRule?->getValues(); - - if ($productTypeOptions && count($productTypeOptions) === 1) { - $productType = Plugin::getInstance()->getProductTypes()->getProductTypeByUid(reset($productTypeOptions)); - if ($productType) { - $config['data']['handle'] = $productType->handle; - } - } - - return $config; - } - - /** - * @inheritdoc - */ - protected static function defineFieldLayouts(?string $source): array - { - if ($source === null || $source === '*') { - $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); - } else { - $productTypes = []; - if (preg_match('/^productType:(.+)$/', $source, $matches)) { - $productType = Plugin::getInstance()->getProductTypes()->getProductTypeByUid($matches[1]); - if ($productType) { - $productTypes[] = $productType; - } - } - } - - return array_map(fn(ProductType $productType) => $productType->getFieldLayout(), $productTypes); - } - - /** - * @inheritdoc - */ - protected static function defineActions(string $source = null): array - { - $elementsService = Craft::$app->getElements(); - // Get the selected site - $controller = Craft::$app->controller; - if ($controller instanceof ElementIndexesController) { - /** @var ElementQuery $elementQuery */ - $elementQuery = $controller->getElementQuery(); - } else { - $elementQuery = null; - } - $site = $elementQuery && $elementQuery->siteId - ? Craft::$app->getSites()->getSiteById($elementQuery->siteId) - : Craft::$app->getSites()->getCurrentSite(); - - // Get the section(s) we need to check permissions on - switch ($source) { - case '*': - { - $productTypes = Plugin::getInstance()->getProductTypes()->getViewableProductTypes(); - break; - } - default: - { - if (preg_match('/^productType:(\d+)$/', $source, $matches)) { - $productType = Plugin::getInstance()->getProductTypes()->getProductTypeById((int)$matches[1]); - - if ($productType) { - $productTypes = [$productType]; - } - } elseif (preg_match('/^productType:(.+)$/', $source, $matches)) { - $productType = Plugin::getInstance()->getProductTypes()->getProductTypeByUid($matches[1]); - - if ($productType) { - $productTypes = [$productType]; - } - } - } - } - - $actions = []; - - // Copy Reference Tag - $actions[] = Craft::$app->getElements()->createAction([ - 'type' => CopyReferenceTag::class, - ]); - - // Restore - $actions[] = Craft::$app->getElements()->createAction([ - 'type' => Restore::class, - 'successMessage' => Craft::t('commerce', 'Products restored.'), - 'partialSuccessMessage' => Craft::t('commerce', 'Some products restored.'), - 'failMessage' => Craft::t('commerce', 'Products not restored.'), - ]); - - if ($source === '*') { - // Delete - $actions[] = Delete::class; - } elseif (!empty($productTypes)) { - $userSession = Craft::$app->getUser(); - $currentUser = $userSession->getIdentity(); - - foreach ($productTypes as $productType) { - $canDelete = $currentUser->can('commerce-deleteProductType:' . $productType->uid); - $canCreate = $currentUser->can('commerce-createProductType:' . $productType->uid); - $canSave = $currentUser->can('commerce-saveProductType:' . $productType->uid); - - if ($canCreate && $canSave) { - // Duplicate - $actions[] = [ - 'type' => Duplicate::class, - 'asDrafts' => true, - ]; - } - - if ($canDelete) { - // Allow deletion - $deleteAction = Craft::$app->getElements()->createAction([ - 'type' => Delete::class, - 'confirmationMessage' => Craft::t('commerce', 'Are you sure you want to delete the selected product and its variants?'), - 'successMessage' => Craft::t('commerce', 'Products and Variants deleted.'), - ]); - $actions[] = $deleteAction; - } - - if ($canSave) { - $actions[] = SetStatus::class; - } - - if ( - $productType->isStructure && - $canCreate - ) { - if ($productType->maxLevels != 1) { - $actions[] = [ - 'type' => Duplicate::class, - 'asDrafts' => true, - 'deep' => true, - ]; - } - - $newProductUrl = 'commerce/products/' . $productType->handle . '/new'; - - if (Craft::$app->getIsMultiSite()) { - $newProductUrl .= '?site=' . $site->handle; - } - - $actions[] = $elementsService->createAction([ - 'type' => NewSiblingBefore::class, - 'newSiblingUrl' => $newProductUrl, - ]); - - $actions[] = $elementsService->createAction([ - 'type' => NewSiblingAfter::class, - 'newSiblingUrl' => $newProductUrl, - ]); - - if ($productType->maxLevels != 1) { - $actions[] = $elementsService->createAction([ - 'type' => NewChild::class, - 'maxLevels' => $productType->maxLevels, - 'newChildUrl' => $newProductUrl, - ]); - } - } - } - - if ($userSession->checkPermission('commerce-managePromotions')) { - if (Plugin::getInstance()->getSales()->canUseSales()) { - $actions[] = CreateSale::class; - } - - $actions[] = CreateDiscount::class; - } - } - - return $actions; - } - - /** - * @inheritdoc - */ - protected function safeActionMenuItems(): array - { - $actions = parent::safeActionMenuItems(); - - if ( - Craft::$app->getUser()->getIsAdmin() && - Craft::$app->getConfig()->getGeneral()->allowAdminChanges - ) { - // Product type settings - $productTypeEditId = sprintf('edit-product-type-%s', mt_rand()); - $actions[] = [ - 'id' => $productTypeEditId, - 'icon' => 'gear', - 'label' => Craft::t('commerce', 'Product type settings'), - ]; - - $view = Craft::$app->getView(); - $view->registerJsWithVars(fn($id, $params) => << { - $('#' + $id).on('activate', function() { - const params = $params; - new Craft.CpScreenSlideout('commerce/product-types/edit-product-type', {params}); - }); -})(); -JS, [ - $view->namespaceInputId($productTypeEditId), - ['productTypeId' => $this->typeId], - ]); - } - - return $actions; - } - - /** - * @inheritdoc - */ - protected static function includeSetStatusAction(): bool - { - return true; - } - - /** - * @inheritdoc - */ - protected static function defineSortOptions(): array - { - return [ - 'title' => Craft::t('commerce', 'Title'), - [ - 'label' => Craft::t('commerce', 'Post Date'), - 'orderBy' => 'postDate', - 'defaultDir' => 'desc', - ], - [ - 'label' => Craft::t('commerce', 'Expiry Date'), - 'orderBy' => 'expiryDate', - 'defaultDir' => 'desc', - ], - 'promotable' => Craft::t('commerce', 'Promotable?'), - 'defaultPrice' => Craft::t('commerce', 'Price'), - 'defaultSku' => Craft::t('commerce', 'SKU'), - [ - 'label' => Craft::t('app', 'Date Created'), - 'orderBy' => 'elements.dateCreated', - 'attribute' => 'dateCreated', - 'defaultDir' => 'desc', - ], - [ - 'label' => Craft::t('app', 'Date Updated'), - 'orderBy' => 'elements.dateUpdated', - 'attribute' => 'dateUpdated', - 'defaultDir' => 'desc', - ], - [ - 'label' => Craft::t('app', 'ID'), - 'orderBy' => 'elements.id', - 'attribute' => 'id', - ], - ]; - } - - /** - * @inheritdoc - */ - protected static function defineTableAttributes(): array - { - return [ - 'title' => ['label' => Craft::t('commerce', 'Product')], - 'status' => ['label' => Craft::t('commerce', 'Status')], - 'id' => ['label' => Craft::t('commerce', 'ID')], - 'type' => ['label' => Craft::t('commerce', 'Type')], - 'slug' => ['label' => Craft::t('commerce', 'Slug')], - 'uri' => ['label' => Craft::t('commerce', 'URI')], - 'postDate' => ['label' => Craft::t('commerce', 'Post Date')], - 'expiryDate' => ['label' => Craft::t('commerce', 'Expiry Date')], - 'stock' => ['label' => Craft::t('commerce', 'Stock')], - 'link' => ['label' => Craft::t('commerce', 'Link'), 'icon' => 'world'], - 'dateCreated' => ['label' => Craft::t('commerce', 'Date Created')], - 'dateUpdated' => ['label' => Craft::t('commerce', 'Date Updated')], - 'defaultPrice' => ['label' => Craft::t('commerce', 'Price')], - 'defaultPromotionalPrice' => ['label' => Craft::t('commerce', 'Promotional Price')], - 'defaultSku' => ['label' => Craft::t('commerce', 'SKU')], - 'defaultWeight' => ['label' => Craft::t('commerce', 'Weight')], - 'defaultLength' => ['label' => Craft::t('commerce', 'Length')], - 'defaultWidth' => ['label' => Craft::t('commerce', 'Width')], - 'defaultHeight' => ['label' => Craft::t('commerce', 'Height')], - 'variants' => ['label' => Craft::t('commerce', 'Variants')], - ]; - } - - /** - * @inheritdoc - */ - protected static function defineDefaultTableAttributes(string $source): array - { - $attributes = []; - - if ($source == '*') { - $attributes[] = 'type'; - } - - $attributes[] = 'status'; - $attributes[] = 'postDate'; - $attributes[] = 'expiryDate'; - $attributes[] = 'defaultPrice'; - $attributes[] = 'defaultSku'; - $attributes[] = 'link'; - - return $attributes; - } - - /** - * @inheritdoc - */ - public static function attributePreviewHtml(array $attribute): mixed - { - return match ($attribute['value']) { - 'defaultSku' => $attribute['placeholder'], - default => parent::attributePreviewHtml($attribute) - }; - } - - /** - * @inheritdoc - */ - protected static function defineCardAttributes(): array - { - return array_merge(parent::defineCardAttributes(), [ - 'defaultPrice' => [ - 'label' => Craft::t('commerce', 'Price'), - 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(123.99), - ], - 'defaultPromotionalPrice' => [ - 'label' => Craft::t('commerce', 'Promotional Price'), - 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(123.99), - ], - 'defaultSku' => [ - 'label' => Craft::t('commerce', 'SKU'), - 'placeholder' => Html::tag('code', 'SKU123'), - ], - ]); - } - - /** - * @inheritdoc - */ - protected static function defineDefaultCardAttributes(): array - { - return array_merge(parent::defineDefaultCardAttributes(), [ - 'defaultSku', - 'defaultPrice', - ]); - } - - /** - * @inheritdoc - */ - public static function eagerLoadingMap(array $sourceElements, string $handle): array|null|false - { - if ($handle == 'variants') { - $sourceElementIds = ArrayHelper::getColumn($sourceElements, 'id'); - $map = (new Query()) - ->select('ownerId as source, elementId as target') - ->from(\craft\db\Table::ELEMENTS_OWNERS) - ->where(['ownerId' => $sourceElementIds]) - ->orderBy('sortOrder asc') - ->all(); - - return [ - 'elementType' => Variant::class, - 'map' => $map, - ]; - } - - return parent::eagerLoadingMap($sourceElements, $handle); - } - - /** - * @inheritdoc - * @since 3.0 - */ - public static function gqlTypeNameByContext(mixed $context): string - { - /** @var ProductType $context */ - return $context->handle . '_Product'; - } - - /** - * @inheritdoc - * @since 3.0 - */ - public static function gqlScopesByContext(mixed $context): array - { - /** @var ProductType $context */ - return ['productTypes.' . $context->uid]; - } - - /** - * @inheritdoc - */ - public static function prepElementQueryForTableAttribute(ElementQueryInterface $elementQuery, string $attribute): void - { - // Only eager load variants for attributes that actually need them. - // Other variant-related attributes (defaultPrice, defaultSku, etc.) are already - // fetched via SQL JOINs in ProductQuery::beforePrepare() - if (in_array($attribute, ['variants', 'stock'], true)) { - $elementQuery->andWith('variants'); - } else { - parent::prepElementQueryForTableAttribute($elementQuery, $attribute); - } - } - - /** - * @var DateTime|null Post date - */ - public ?DateTime $postDate = null; - - /** - * @var DateTime|null Expiry date - */ - public ?DateTime $expiryDate = null; - - /** - * @var int|null Product type ID - */ - public ?int $typeId = null; - - /** - * @var int|null defaultVariantId - */ - public ?int $defaultVariantId = null; - - /** - * @var string|null Default SKU - */ - public ?string $defaultSku = null; - - /** - * @var float|null Default price - * @see getDefaultPrice() - * @see setDefaultPrice() - */ - private ?float $_defaultPrice = null; - - /** - * @var float|null - * @since 5.1.0 - */ - public ?float $defaultBasePrice = null; - - /** - * @var float|null - * @since 5.4.0 - */ - public ?float $defaultBasePromotionalPrice = null; - - /** - * @var float|null Default height - */ - public ?float $defaultHeight = null; - - /** - * @var float|null Default length - */ - public ?float $defaultLength = null; - - /** - * @var float|null Default width - */ - public ?float $defaultWidth = null; - - /** - * @var float|null Default weight - */ - public ?float $defaultWeight = null; - - /** - * @var TaxCategory|null Tax category - */ - public ?TaxCategory $taxCategory = null; - - /** - * @var string|null Name - */ - public ?string $name = null; - - /** - * @var VariantCollection|null This product’s variants - */ - private ?VariantCollection $_variants = null; - - /** - * @var NestedElementManager|null - * @see getVariantManager() - * @since 5.0.0 - */ - private ?NestedElementManager $_variantManager = null; - - /** - * @inheritdoc - * @since 5.1.0 - */ - public function currencyAttributes(): array - { - return ['defaultPrice', 'defaultBasePrice', 'defaultBasePromotionalPrice']; - } - - /** - * @throws InvalidConfigException - */ - public function behaviors(): array - { - $behaviors = parent::behaviors(); - - $behaviors['typecast'] = [ - 'class' => AttributeTypecastBehavior::class, - 'attributeTypes' => [ - 'id' => AttributeTypecastBehavior::TYPE_INTEGER, - ], - ]; - - $behaviors['currencyAttributes'] = [ - 'class' => CurrencyAttributeBehavior::class, - 'currencyAttributes' => $this->currencyAttributes(), - ]; - - return $behaviors; - } - - /** - * @param float|null $defaultPrice - * @return void - * @since 5.0.11 - */ - public function setDefaultPrice(?float $defaultPrice): void - { - $this->_defaultPrice = $defaultPrice; - } - - /** - * @return float|null - * @throws InvalidConfigException - * @since 5.0.11 - */ - public function getDefaultPrice(): ?float - { - return $this->_defaultPrice ?? $this->getDefaultVariant()?->price; - } - - public function canCreateDrafts(User $user): bool - { - // Everyone with view permissions can create drafts - return true; - } - - /** - * @inheritdoc - */ - public function hasRevisions(): bool - { - return $this->getType()->enableVersioning; - } - - /** - * @inheritdoc - */ - public function getPostEditUrl(): ?string - { - return UrlHelper::cpUrl('commerce/products'); - } - - /** - * @inheritdoc - */ - protected function cpRevisionsUrl(): ?string - { - return sprintf('%s/revisions', $this->cpEditUrl()); - } - - /** - * @inheritdoc - */ - public function getIsTitleTranslatable(): bool - { - return ($this->getType()->productTitleTranslationMethod !== Field::TRANSLATION_METHOD_NONE); - } - - /** - * @inheritdoc - */ - public function getTitleTranslationDescription(): ?string - { - return ElementHelper::translationDescription($this->getType()->productTitleTranslationMethod); - } - - /** - * @inheritdoc - */ - public function getTitleTranslationKey(): string - { - $type = $this->getType(); - return ElementHelper::translationKey($this, $type->productTitleTranslationMethod, $type->productTitleTranslationKeyFormat); - } - - /** - * @inheritdoc - */ - public function getIsSlugTranslatable(): bool - { - return ($this->getType()->slugTranslationMethod !== Field::TRANSLATION_METHOD_NONE); - } - - /** - * @inheritdoc - */ - public function getSlugTranslationDescription(): ?string - { - return ElementHelper::translationDescription($this->getType()->slugTranslationMethod); - } - - /** - * @inheritdoc - */ - public function getSlugTranslationKey(): string - { - $type = $this->getType(); - return ElementHelper::translationKey($this, $type->slugTranslationMethod, $type->slugTranslationKeyFormat); - } - - /** - * @inheritdoc - */ - public function __toString(): string - { - return (string)$this->title; - } - - /** - * @inheritdoc - */ - public function canView(User $user): bool - { - if (parent::canView($user)) { - return true; - } - - try { - $productType = $this->getType(); - } catch (\Exception) { - return false; - } - - return $user->can('commerce-viewProductType:' . $productType->uid); - } - - /** - * @inheritdoc - */ - public function canSave(User $user): bool - { - if (parent::canSave($user)) { - return true; - } - - try { - $productType = $this->getType(); - } catch (\Exception) { - return false; - } - - if ($this->getIsDraft()) { - /** @var static|DraftBehavior $this */ - return $this->canCreateDrafts($user); - } - - // New products require create permission - if (!$this->id) { - return $user->can('commerce-createProductType:' . $productType->uid); - } - - return $user->can('commerce-saveProductType:' . $productType->uid); - } - - /** - * @inheritdoc - */ - public function canDuplicate(User $user): bool - { - if (parent::canDuplicate($user)) { - return true; - } - - try { - $productType = $this->getType(); - } catch (\Exception) { - return false; - } - - return $user->can('commerce-createProductType:' . $productType->uid) - && $user->can('commerce-saveProductType:' . $productType->uid); - } - - /** - * @inheritdoc - */ - public function canDelete(User $user): bool - { - if (parent::canDelete($user)) { - return true; - } - - try { - $productType = $this->getType(); - } catch (\Exception) { - return false; - } - - return $user->can('commerce-deleteProductType:' . $productType->uid); - } - - /** - * @inheritdoc - */ - public function canDeleteForSite(User $user): bool - { - return Craft::$app->getElements()->canDelete($this, $user); - } - - /** - * @inheritdoc - */ - public function createAnother(): ?ElementInterface - { - return null; - } - - /** - * @inheritdoc - */ - protected function crumbs(): array - { - $productType = $this->getType(); - - $productTypes = Collection::make(Plugin::getInstance()->getProductTypes()->getViewableProductTypes()); - /** @var Collection $productTypeOptions */ - $productTypeOptions = $productTypes - ->map(fn(ProductType $t) => [ - 'label' => Craft::t('site', $t->name), - 'url' => "commerce/products/$t->handle", - 'selected' => $t->id === $productType->id, - ]); - - return [ - [ - 'label' => Craft::t('commerce', 'Products'), - 'url' => 'commerce/products', - ], - [ - 'menu' => [ - 'label' => Craft::t('commerce', 'Select product type'), - 'items' => $productTypeOptions->all(), - ], - ], - ]; - } - - /** - * @inheritdoc - */ - protected function uiLabel(): ?string - { - // This method is called in a few places before the product type is set - // If there isn't a type then fall back to the title - if ($this->typeId) { - $uiLabelFormat = $this->getType()->productUiLabelFormat; - if ($uiLabelFormat !== '{title}') { - $uiLabel = Craft::$app->getView()->renderSandboxedObjectTemplate($uiLabelFormat, $this); - if ($uiLabel !== '') { - return $uiLabel; - } - } - } - - if (!isset($this->title) || trim($this->title) === '') { - return Craft::t('app', 'Untitled {type}', [ - 'type' => self::lowerDisplayName(), - ]); - } - - return null; - } - - /** - * Returns the product's product type. - * - * @throws InvalidConfigException - */ - public function getType(): ProductType - { - if ($this->typeId === null) { - throw new InvalidConfigException('Product is missing its product type ID'); - } - - $productType = Plugin::getInstance()->getProductTypes()->getProductTypeById($this->typeId); - - if ($productType === null) { - throw new InvalidConfigException('Invalid product type ID: ' . $this->typeId); - } - - return $productType; - } - - public function getName(): ?string - { - return $this->title; - } - - /** - * @inheritdoc - */ - protected function cacheTags(): array - { - return [ - "productType:$this->typeId", - ]; - } - - /** - * @inheritdoc - */ - public function getUriFormat(): ?string - { - $productTypeSiteSettings = $this->getType()->getSiteSettings(); - - if (!isset($productTypeSiteSettings[$this->siteId])) { - throw new InvalidConfigException('The "' . $this->getType()->name . '" product type is not enabled for the "' . $this->getSite()->name . '" site.'); - } - - return $productTypeSiteSettings[$this->siteId]->uriFormat; - } - - /** - * @inheritdoc - */ - protected function cpEditUrl(): ?string - { - $productType = $this->getType(); - - $path = sprintf('commerce/products/%s/%s', $productType->handle, $this->getCanonicalId()); - - // Ignore homepage/temp slugs - if ($this->slug && !str_starts_with($this->slug, '__')) { - $path .= sprintf('-%s', str_replace('/', '-', $this->slug)); - } - - return $path; - } - - /** - * Returns the default variant. - * - * @param bool $includeDisabled - * @return Variant|null - * @throws InvalidConfigException - */ - public function getDefaultVariant(bool $includeDisabled = false): ?Variant - { - $defaultVariant = $this->getVariants($includeDisabled)->firstWhere('id', $this->defaultVariantId); - - return $defaultVariant ?: $this->getVariants($includeDisabled)->first(); - } - - /** - * Return the cheapest variant. - * - * @throws InvalidConfigException - * @noinspection PhpUnused - */ - public function getCheapestVariant(bool $includeDisabled = false): ?Variant - { - return $this->getVariants($includeDisabled)->cheapest(); - } - - /** - * Returns a collection of the product's variants. - * - * @param bool|null $includeDisabled - * @return VariantCollection - * @throws InvalidConfigException - */ - public function getVariants(?bool $includeDisabled = null): VariantCollection - { - if ($this->_variants === null) { - if (!$this->id) { - return VariantCollection::make(); - } - - /** @var self|null $duplicatingProduct */ - $duplicatingProduct = $this->duplicateOf; - if ($duplicatingProduct) { - $query = self::createVariantQuery($duplicatingProduct)->status(null); - } else { - $query = self::createVariantQuery($this)->status(null); - } - - $variants = $query->collect(); - - // Don't memoize empty collections in favour of a new query next time - if ($variants->isEmpty()) { - return $variants; - } - - $this->_variants = $variants; - $this->_variants->map(function(Variant $v) { - if (!$this->id) { - return $v; - } - - if ($v->primaryOwnerId === $this->id) { - $v->setPrimaryOwner($this); - } - - if ($v->ownerId === $this->id) { - $v->setOwner($this); - } - - return $v; - }); - } - - // When reordering variants we need to make sure disabled variants are included when calculating sort order - // @TODO Remove this controller-based default in Commerce 6.0 when `getVariants()` is updated to return an element query instance - $includeDisabled ??= Craft::$app->controller instanceof NestedElementsController; - - return $this->_variants->filter(fn(Variant $variant) => $includeDisabled || ($variant->getStatus() === self::STATUS_ENABLED)); - } - - /** - * @inheritdoc - */ - public function getSupportedSites(): array - { - if (!isset($this->typeId)) { - throw new InvalidConfigException('Require `typeId` must be set on the product.'); - } - - $productType = $this->getType(); - /** @var Site[] $allSites */ - $allSites = ArrayHelper::index(Craft::$app->getSites()->getAllSites(true), 'id'); - $sites = []; - - // If the product type is leaving it up to products to decide which sites to be propagated to, - // figure out which sites the product is currently saved in - if ( - ($this->duplicateOf->id ?? $this->id) && - $productType->propagationMethod === PropagationMethod::Custom - ) { - if ($this->id) { - $currentSites = self::find() - ->status(null) - ->id($this->id) - ->site('*') - ->select('elements_sites.siteId') - ->drafts(null) - ->provisionalDrafts(null) - ->revisions($this->getIsRevision()) - ->column(); - } else { - $currentSites = []; - } - - // If this is being duplicated from another element (e.g. a draft), include any sites the source element is saved to as well - if (!empty($this->duplicateOf->id)) { - array_push($currentSites, ...self::find() - ->status(null) - ->id($this->duplicateOf->id) - ->site('*') - ->select('elements_sites.siteId') - ->drafts(null) - ->provisionalDrafts(null) - ->revisions($this->duplicateOf->getIsRevision()) - ->column() - ); - } - - $currentSites = array_flip($currentSites); - } - - foreach ($productType->getSiteSettings() as $siteSettings) { - switch ($productType->propagationMethod) { - case PropagationMethod::None: - $include = $siteSettings->siteId == $this->siteId; - $propagate = true; - break; - case PropagationMethod::SiteGroup: - $include = $allSites[$siteSettings->siteId]->groupId == $allSites[$this->siteId]->groupId; - $propagate = true; - break; - case PropagationMethod::Language: - $include = $allSites[$siteSettings->siteId]->language == $allSites[$this->siteId]->language; - $propagate = true; - break; - case PropagationMethod::Custom: - $include = true; - // Only actually propagate to this site if it's the current site, or the product has been assigned - // a status for this site, or the product already exists for this site - $propagate = ( - $siteSettings->siteId == $this->siteId || - $this->getEnabledForSite($siteSettings->siteId) !== null || - isset($currentSites[$siteSettings->siteId]) - ); - break; - default: - $include = $propagate = true; - break; - } - - if ($include) { - $sites[] = [ - 'siteId' => $siteSettings->siteId, - 'propagate' => $propagate, - 'enabledByDefault' => $siteSettings->enabledByDefault, - ]; - } - } - - return $sites; - } - - /** - * Sets the variants on the product. Accepts an array of variant data keyed by variant ID or the string 'new'. - * - * @param VariantCollection|VariantQuery|array $variants - */ - public function setVariants(VariantCollection|VariantQuery|array $variants): void - { - if ($variants instanceof VariantQuery) { - // just unset our existing records - $this->_variants = null; - return; - } - - // Make sure each variant has an owner set in case of mass assignment of product and variants - if (is_array($variants)) { - foreach ($variants as &$variant) { - if ($variant instanceof Variant) { - continue; - } - - if (is_array($variant) && !isset($variant['owner'])) { - $variant = ['owner' => $this] + $variant; - } - } - } - - $this->_variants = $variants instanceof VariantCollection ? $variants : VariantCollection::make($variants); - } - - /** - * Returns a nested element manager for the product’s variants. - * - * @return NestedElementManager - * @since 5.0.0 - */ - public function getVariantManager(): NestedElementManager - { - if (!isset($this->_variantManager)) { - $this->_variantManager = new NestedElementManager( - Variant::class, - // @phpstan-ignore argument.type (will always be a Product) - fn(ElementInterface $product): VariantQuery => self::createVariantQuery($product), - [ - 'attribute' => 'variants', // dont change this: https://github.com/craftcms/commerce/issues/4314#issuecomment-4715539955 - 'propagationMethod' => $this->getType()->propagationMethod, - 'valueGetter' => fn() => $this->getVariants(true), - 'valueSetter' => fn($variants) => $this->setVariants($variants), - ], - ); - } - - return $this->_variantManager; - } - - /** - * @inheritdoc - */ - public function getStatus(): ?string - { - $status = parent::getStatus(); - - if ($status == self::STATUS_ENABLED && $this->postDate) { - $currentTime = DateTimeHelper::currentTimeStamp(); - $postDate = $this->postDate->getTimestamp(); - $expiryDate = $this->expiryDate?->getTimestamp(); - - if ($postDate <= $currentTime && ($expiryDate === null || $expiryDate > $currentTime)) { - return self::STATUS_LIVE; - } - - if ($postDate > $currentTime) { - return self::STATUS_PENDING; - } - - return self::STATUS_EXPIRED; - } - - return $status; - } - - /** - * @throws InvalidConfigException - * @noinspection PhpUnused - */ - public function getTotalStock(bool $includeDisabled = false): int - { - $stock = 0; - foreach ($this->getVariants($includeDisabled) as $variant) { - $stock += $variant->getStock(); - } - - return $stock; - } - - /** - * Returns whether at least one variant has unlimited stock. - * - * @throws InvalidConfigException - * @deprecated in 5.0.0 and will be removed in 6.0.0. Check each variant instead. - */ - public function getHasUnlimitedStock(bool $includeDisabled = false): bool - { - foreach ($this->getVariants($includeDisabled) as $variant) { - if (!$variant->inventoryTracked) { - return true; - } - } - - return false; - } - - /** - * @inheritdoc - * @since 3.0 - */ - public function getGqlTypeName(): string - { - return static::gqlTypeNameByContext($this->getType()); - } - - /** - * @inheritdoc - */ - public function setEagerLoadedElements(string $handle, array $elements, EagerLoadPlan $plan): void - { - if ($handle == 'variants') { - /** @var Variant[] $elements */ - $this->setVariants($elements); - } else { - parent::setEagerLoadedElements($handle, $elements, $plan); - } - } - - /** - * @inheritdoc - */ - protected function metaFieldsHtml(bool $static): string - { - $fields = []; - $view = Craft::$app->getView(); - $productType = $this->getType(); - // Slug - if ($productType->showSlugField) { - $fields[] = $this->slugFieldHtml($static); - } - - if ($productType->isStructure && $productType->maxLevels !== 1) { - $fields[] = (function() use ($static, $productType) { - if ($parentId = $this->getParentId()) { - $parent = Plugin::getInstance()->getProducts()->getProductById($parentId, $this->siteId, [ - 'drafts' => null, - 'draftOf' => false, - ]); - } else { - // If the entry already has structure data, use it. Otherwise, use its canonical entry - /** @var self|null $parent */ - $parent = self::find() - ->siteId($this->siteId) - ->ancestorOf($this->lft ? $this : ($this->getIsCanonical() ? $this->id : $this->getCanonical(true))) - ->ancestorDist(1) - ->drafts(null) - ->draftOf(false) - ->status(null) - ->one(); - } - - return Cp::elementSelectFieldHtml([ - 'label' => Craft::t('app', 'Parent'), - 'id' => 'parentId', - 'name' => 'parentId', - 'elementType' => self::class, - 'selectionLabel' => Craft::t('app', 'Choose'), - 'sources' => ["productType:$productType->uid"], - 'criteria' => $this->_parentOptionCriteria($productType), - 'limit' => 1, - 'elements' => $parent ? [$parent] : [], - 'disabled' => $static, - 'describedBy' => 'parentId-label', - 'errors' => $this->getErrors('parentId'), - ]); - })(); - } - - $isDeltaRegistrationActive = $view->getIsDeltaRegistrationActive(); - $view->setIsDeltaRegistrationActive(true); - $view->registerDeltaName('postDate'); - $view->registerDeltaName('expiryDate'); - $view->setIsDeltaRegistrationActive($isDeltaRegistrationActive); - - // Post Date - $fields[] = Cp::dateTimeFieldHtml([ - 'status' => $this->getAttributeStatus('postDate'), - 'label' => Craft::t('app', 'Post Date'), - 'id' => 'postDate', - 'name' => 'postDate', - 'value' => $this->_userPostDate(), - 'errors' => $this->getErrors('postDate'), - 'disabled' => $static, - ]); - - // Expiry Date - $fields[] = Cp::dateTimeFieldHtml([ - 'status' => $this->getAttributeStatus('expiryDate'), - 'label' => Craft::t('app', 'Expiry Date'), - 'id' => 'expiryDate', - 'name' => 'expiryDate', - 'value' => $this->expiryDate, - 'errors' => $this->getErrors('expiryDate'), - 'disabled' => $static, - ]); - - $fields[] = parent::metaFieldsHtml($static); - - return implode("\n", $fields); - } - - private function _parentOptionCriteria(ProductType $productType): array - { - $parentOptionCriteria = [ - 'siteId' => $this->siteId, - 'typeId' => $productType->id, - 'status' => null, - 'drafts' => null, - 'draftOf' => false, - ]; - - // Prevent the current entry, or any of its descendants, from being selected as a parent - if ($this->id) { - $excludeIds = self::find() - ->descendantOf($this) - ->drafts(null) - ->draftOf(false) - ->status(null) - ->ids(); - $excludeIds[] = $this->getCanonicalId(); - $parentOptionCriteria['id'] = array_merge(['not'], $excludeIds); - } - - if ($productType->maxLevels) { - if ($this->id) { - // Figure out how deep the ancestors go - $maxDepth = self::find() - ->select('level') - ->descendantOf($this) - ->status(null) - ->leaves() - ->scalar(); - $depth = 1 + ($maxDepth ?: $this->level) - $this->level; - } else { - $depth = 1; - } - - $parentOptionCriteria['level'] = sprintf('<=%s', $productType->maxLevels - $depth); - } - - // Fire a 'defineParentSelectionCriteria' event - if ($this->hasEventHandlers(self::EVENT_DEFINE_PARENT_SELECTION_CRITERIA)) { - $event = new ElementCriteriaEvent(['criteria' => $parentOptionCriteria]); - $this->trigger(self::EVENT_DEFINE_PARENT_SELECTION_CRITERIA, $event); - return $event->criteria; - } - - return $parentOptionCriteria; - } - - /** - * Returns the Post Date value that should be shown on the edit form. - * - * @return DateTime|null - */ - private function _userPostDate(): ?DateTime - { - if (!$this->postDate || ($this->getIsUnpublishedDraft() && $this->postDate == $this->dateCreated)) { - // Pretend the post date hasn't been set yet, even if it has - return null; - } - - return $this->postDate; - } - - /** - * @inheritdoc - */ - public function getMetadata(): array - { - $metadata = parent::getMetadata(); - - if (array_key_exists(Craft::t('app', 'Status'), $metadata)) { - unset($metadata[Craft::t('app', 'Status')]); - } - - return $metadata; - } - - /** - * @inheritDoc - */ - protected function searchKeywords(string $attribute): string - { - if ($attribute === 'sku') { - return $this->getVariants() - ->pluck('sku') - ->filter(fn(?string $sku) => $sku && !PurchasableHelper::isTempSku($sku)) - ->implode(' '); - } - - return parent::searchKeywords($attribute); - } - - /** - * @inheritdoc - */ - public function afterSave(bool $isNew): void - { - if (!$this->propagating) { - $productType = $this->getType(); - - if (!$isNew) { - $record = ProductRecord::findOne($this->id); - - if (!$record) { - throw new Exception('Invalid product ID: ' . $this->id); - } - } else { - $record = new ProductRecord(); - $record->id = $this->id; - } - - $record->postDate = $this->postDate; - $record->expiryDate = $this->expiryDate; - $record->typeId = $this->typeId; - - $defaultVariant = $this->getDefaultVariant(); - $record->defaultVariantId = $defaultVariant->id ?? null; - $record->defaultSku = $defaultVariant?->getSkuAsText() ?? ''; - $record->defaultPrice = $defaultVariant?->getBasePrice() ?? 0.0; - $record->defaultHeight = $defaultVariant->height ?? 0.0; - $record->defaultLength = $defaultVariant->length ?? 0.0; - $record->defaultWidth = $defaultVariant->width ?? 0.0; - $record->defaultWeight = $defaultVariant->weight ?? 0.0; - - // Make sure to update the object - $this->defaultVariantId = $defaultVariant->id ?? null; - $this->defaultSku = $defaultVariant?->getSkuAsText(); - $this->defaultPrice = $defaultVariant?->getBasePrice() ?? 0.0; - $this->defaultHeight = $defaultVariant->height ?? 0; - $this->defaultLength = $defaultVariant->length ?? 0; - $this->defaultWidth = $defaultVariant->width ?? 0; - $this->defaultWeight = $defaultVariant->weight ?? 0; - - // We want to always have the same date as the element table, based on the logic for updating these in the element service i.e resaving - $record->dateUpdated = $this->dateUpdated; - $record->dateCreated = $this->dateCreated; - - // Capture the dirty attributes from the record - $dirtyAttributes = array_keys($record->getDirtyAttributes()); - $record->save(false); - - $this->id = $record->id; - - $this->setDirtyAttributes($dirtyAttributes); - - if ($this->getIsCanonical() && - isset($this->typeId) && - $productType->isStructure - ) { - // Has the parent changed? - if ($this->hasNewParent()) { - $this->_placeInStructure($isNew, $productType); - } - - // Update the product's descendants, who may be using this product's URI in their own URIs - if (!$isNew) { - Craft::$app->getElements()->updateDescendantSlugsAndUris($this, true, true); - } - } - - // Queue job to resave variants if the variant title format references the product - if ($this->getIsCanonical() && - isset($this->typeId) && - !$productType->hasVariantTitleField && - $productType->variantTitleFormat && - StringHelper::containsAny($productType->variantTitleFormat, ['product.', 'owner.', 'primaryOwner.']) - ) { - Craft::$app->getQueue()->push(new \craft\commerce\queue\jobs\ResaveProductVariants([ - 'productId' => $this->id, - ])); - } - } - - parent::afterSave($isNew); - } - - private function _placeInStructure(bool $isNew, ProductType $productType): void - { - $parentId = $this->getParentId(); - $structuresService = Craft::$app->getStructures(); - - // If this is a provisional draft and its new parent matches the canonical product’s, just drop it from the structure - if ($this->isProvisionalDraft) { - $canonicalParentId = self::find() - ->select(['elements.id']) - ->ancestorOf($this->getCanonicalId()) - ->ancestorDist(1) - ->status(null) - ->scalar(); - - if ($parentId == $canonicalParentId) { - $structuresService->remove($this->structureId, $this); - return; - } - } - - $mode = $isNew ? Structures::MODE_INSERT : Structures::MODE_AUTO; - - if (!$parentId) { - if ($productType->defaultPlacement === ProductType::DEFAULT_PLACEMENT_BEGINNING) { - $structuresService->prependToRoot($this->structureId, $this, $mode); - } else { - $structuresService->appendToRoot($this->structureId, $this, $mode); - } - } else { - if ($productType->defaultPlacement === ProductType::DEFAULT_PLACEMENT_BEGINNING) { - $structuresService->prepend($this->structureId, $this, $this->getParent(), $mode); - } else { - $structuresService->append($this->structureId, $this, $this->getParent(), $mode); - } - } - } - - /** - * Updates the entry's title, if its entry type has a dynamic title format. - * - * @since 3.0.3 - * @see \craft\elements\Entry::updateTitle - */ - public function updateTitle(): void - { - $productType = $this->getType(); - - // check for null just incase the value comes back as 1, 0, true or false - if (!$productType->hasProductTitleField && $productType->hasProductTitleField !== null) { - // Make sure that the locale has been loaded in case the title format has any Date/Time fields - Craft::$app->getLocale(); - // Set Craft to the entry's site's language, in case the title format has any static translations - $language = Craft::$app->language; - Craft::$app->language = $this->getSite()->language; - $title = Craft::$app->getView()->renderSandboxedObjectTemplate($productType->productTitleFormat, $this); - if ($title !== '') { - $this->title = $title; - } - Craft::$app->language = $language; - } - } - - /** - * @inheritdoc - */ - public function beforeValidate(): bool - { - // We need to generate all variant sku formats before validating the product, - // since the product validates the uniqueness of all variants in memory. - $type = $this->getType(); - foreach ($this->getVariants(true) as $variant) { - if (!$variant->sku && $type->skuFormat) { - try { - $variant->sku = Craft::$app->getView()->renderSandboxedObjectTemplate($type->skuFormat, $variant); - } catch (\Exception $e) { - Craft::error('Craft Commerce could not generate the supplied SKU format: ' . $e->getMessage(), __METHOD__); - $variant->sku = ''; - } - - if ($variant->sku) { - $skuExistsQuery = function(string $sku, ?int $id) { - $query = (new Query()) - ->select(['sku']) - ->from(Table::PURCHASABLES) - ->where(['sku' => $sku]); - - // Make sure it isn't for the purchasable we are currently saving - if ($id) { - $query->andWhere(['not', ['id' => $id]]); - } - - return $query; - }; - - // Ensure there isn't a clash with an existing SKU when using auto formats - if ($skuExistsQuery($variant->sku, $variant->id)->exists()) { - // If there is a clash, we need to append a number to the end. - $baseSku = $variant->sku; - do { - $seq = Sequence::next('sku::' . $baseSku); - $newSku = $baseSku . '-' . $seq; - } while ($skuExistsQuery($newSku, $variant->id)->exists()); - - $variant->sku = $newSku; - } - } - } - } - - return parent::beforeValidate(); - } - - /** - * @inheritdoc - */ - public function beforeDelete(): bool - { - if (!parent::beforeDelete()) { - return false; - } - - $this->getVariantManager()->deleteNestedElements($this, $this->hardDelete); - - return true; - } - - /** - * @inheritDoc - */ - public function afterRestore(): void - { - $this->getVariantManager()->restoreNestedElements($this); - - parent::afterRestore(); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return array_merge(parent::defineRules(), [ - [['typeId'], 'number', 'integerOnly' => true], - [['postDate', 'expiryDate'], DateTimeValidator::class], - [['defaultPrice'], 'safe'], - [ - ['variants'], - function() { - if ($this->getVariants(true)->isEmpty()) { - $this->addError('variants', Craft::t('commerce', 'Must have at least one variant.')); - } - }, - 'skipOnEmpty' => false, - 'on' => self::SCENARIO_LIVE, - ], - [ - ['variants'], - function() { - $skus = []; - foreach ($this->getVariants(true) as $variant) { - if (isset($skus[$variant->sku])) { - $this->addError('variants', Craft::t('commerce', 'Not all SKUs are unique.')); - break; - } - $skus[$variant->sku] = true; - } - }, - 'on' => self::SCENARIO_LIVE, - ], - [ - ['variants'], - function() { - foreach ($this->getVariants(true) as $variant) { - if (!$variant->sku || PurchasableHelper::isTempSku($variant->sku)) { - $this->addError('variants', Craft::t('commerce', 'All variants must have a SKU.')); - break; - } - } - }, - 'on' => self::SCENARIO_LIVE, - ], - [ - ['variants'], - function() { - if ($this->getType()->maxVariants) { - $variantCount = count($this->getVariants(true)); - if ($variantCount > $this->getType()->maxVariants) { - $this->addError('variants', Craft::t('commerce', 'Too many variants for this product.')); - } - } - }, - ], - ]); - } - - /** - * @inheritdoc - */ - public function setAttributesFromRequest(array $values): void - { - // this is needed for Craft.NestedElementManager::markAsDirty() - if (isset($values['variants']) && $values['variants'] === '*') { - $this->setDirtyAttributes(['variants']); - unset($values['variants']); - } - - parent::setAttributesFromRequest($values); - } - - /** - * @inheritdoc - */ - public function getFieldLayout(): ?FieldLayout - { - try { - return $this->getType()->getProductFieldLayout(); - } catch (InvalidConfigException) { - // The product type was probably deleted - return null; - } - } - - /** - * @inheritdoc - */ - public function beforeSave(bool $isNew): bool - { - // Make sure the entry has at least one revision if the section has versioning enabled - if ($this->_shouldSaveRevision()) { - $hasRevisions = self::find() - ->revisionOf($this) - ->site('*') - ->status(null) - ->exists(); - if (!$hasRevisions) { - /** @var self|null $currentProduct */ - $currentProduct = self::find() - ->id($this->id) - ->site('*') - ->status(null) - ->one(); - - // May be null if the product is currently stored as an unpublished draft - if ($currentProduct) { - $revisionNotes = 'Revision from ' . Craft::$app->getFormatter()->asDatetime($currentProduct->dateUpdated); - Craft::$app->getRevisions()->createRevision($currentProduct, notes: $revisionNotes); - } - } - } - - $productType = $this->getType(); - // Set the structure ID for Element::attributes() and afterSave() - if ($productType->isStructure) { - $this->structureId = $productType->structureId; - - // Has the entry been assigned to a new parent? - if (!$this->duplicateOf && $this->hasNewParent()) { - if ($parentId = $this->getParentId()) { - $parentProduct = Plugin::getInstance()->getProducts()->getProductById($parentId, '*', [ - 'preferSites' => [$this->siteId], - 'drafts' => null, - 'draftOf' => false, - ]); - - if (!$parentProduct) { - throw new InvalidConfigException("Invalid parent ID: $parentId"); - } - } else { - $parentProduct = null; - } - - $this->setParent($parentProduct); - } - } - - // Make sure the field layout is set correctly - $this->fieldLayoutId = $this->getType()->fieldLayoutId; - - if ($this->enabled && !$this->postDate) { - // Default the post date to the current date/time - $this->postDate = new DateTime(); - // ...without the seconds - $this->postDate->setTimestamp($this->postDate->getTimestamp() - ($this->postDate->getTimestamp() % 60)); - } - - $this->updateTitle(); - - return parent::beforeSave($isNew); - } - - /** - * @inheritdoc - */ - protected static function defineSearchableAttributes(): array - { - return [ - 'defaultSku', - 'sku', - ]; - } - - /** - * @param Product $product - * @return VariantQuery - */ - private static function createVariantQuery(Product $product): VariantQuery - { - $query = Variant::find() - ->productId($product->id) - ->siteId($product->siteId) - ->orderBy(['sortOrder' => SORT_ASC]); - - if ($product->getIsRevision()) { - $query->revisions(null)->trashed(null); - } - - return $query; - } - - /** - * @inheritdoc - */ - protected function route(): array|string|null - { - // Make sure that the product is actually live - if (!$this->previewing && $this->getStatus() != self::STATUS_LIVE) { - return null; - } - - // Make sure the product type is set to have URLs for this site - $siteId = Craft::$app->getSites()->currentSite->id; - $productTypeSiteSettings = $this->getType()->getSiteSettings(); - - if (!isset($productTypeSiteSettings[$siteId]) || !$productTypeSiteSettings[$siteId]->hasUrls) { - return null; - } - - return [ - 'templates/render', [ - 'template' => $productTypeSiteSettings[$siteId]->template, - 'variables' => [ - 'product' => $this, - ], - ], - ]; - } - - /** - * @inheritdoc - */ - protected function previewTargets(): array - { - return array_map(function($previewTarget) { - $previewTarget['label'] = Craft::t('site', $previewTarget['label']); - return $previewTarget; - }, $this->getType()->previewTargets ?? []); - } - - /** - * @inheritdoc - */ - protected function attributeHtml(string $attribute): string - { - $productType = $this->getType(); - - switch ($attribute) { - case 'type': - { - return Craft::t('site', Html::encode($productType->name)); - } - case 'defaultSku': - { - if ($this->defaultSku === null) { - return ''; - } - - return Html::tag('code', PurchasableHelper::isTempSku($this->defaultSku) ? '' : Html::encode($this->defaultSku)); - } - case 'defaultPrice': - { - return $this->defaultBasePrice ? $this->defaultBasePriceAsCurrency : ''; - } - case 'defaultPromotionalPrice': - { - return $this->defaultBasePromotionalPrice ? $this->defaultBasePromotionalPriceAsCurrency : ''; - } - case 'stock': - { - $stock = 0; - $hasUnlimited = false; - - foreach ($this->getVariants(true) as $variant) { - $stock += $variant->getStock(); - if (!$variant->inventoryTracked) { - $hasUnlimited = true; - } - } - return $hasUnlimited ? '∞' . ($stock ? ' & ' . $stock : '') : ($stock ?: '0'); - } - case 'defaultWeight': - { - if ($productType->hasDimensions) { - return Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->weightUnits; - } - - return ''; - } - case 'defaultLength': - case 'defaultWidth': - case 'defaultHeight': - { - if ($productType->hasDimensions) { - return Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits; - } - - return ''; - } - case 'variants': - { - $value = $this->getVariants(true); - /** @var Variant|null $first */ - $first = $value->first(); - $html = $first ? Cp::elementChipHtml($first) : ''; - - if ($value->isNotEmpty() && $value->count() > 1) { - $otherItems = $value->filter(fn($v, $k) => $k > 0); - $otherHtml = $otherItems->map(fn($v) => Cp::elementChipHtml($v))->join(''); - - $html .= Html::tag('span', '+' . Craft::$app->getFormatter()->asInteger($otherItems->count()), [ - 'title' => $otherItems->map(fn($v) => $v->title)->join(', '), - 'class' => 'btn small', - 'role' => 'button', - 'onclick' => 'jQuery(this).replaceWith(' . Json::encode($otherHtml) . ')', - ]); - } - - return $html; - } - default: - { - return parent::attributeHtml($attribute); - } - } - } - - /** - * @inheritDoc - */ - public function setScenario($value): void - { - foreach ($this->getVariants() as $variant) { - $variant->setScenario($value); - } - - parent::setScenario($value); - } - - /** - * @inheritDoc - */ - public function afterPropagate(bool $isNew): void - { - $this->getVariantManager()->maintainNestedElements($this, $isNew); - parent::afterPropagate($isNew); - - // @TODO Collate purchasable IDs updated across the request and queue a single catalog pricing job, rather than one per product propagate - if (!$this->getIsDraft()) { - Plugin::getInstance()->getCatalogPricing()->createCatalogPricingJob([ - 'purchasableIds' => $this->getVariants()->pluck('id')->all(), - 'storeId' => $this->storeId, - ]); - } - - // Save a new revision? - if ($this->_shouldSaveRevision()) { - Craft::$app->getRevisions()->createRevision($this, notes: $this->revisionNotes); - } - } - - /** - * Returns whether the product should be saving revisions on save. - * - * @return bool - */ - private function _shouldSaveRevision(): bool - { - return ( - $this->id && - !$this->propagating && - !$this->resaving && - !$this->getIsDraft() && - !$this->getIsRevision() && - $this->getType()->enableVersioning - ); - } -} diff --git a/src/elements/Subscription.php b/src/elements/Subscription.php deleted file mode 100644 index 77807d34a5..0000000000 --- a/src/elements/Subscription.php +++ /dev/null @@ -1,751 +0,0 @@ - - * @copyright Copyright (c) 2015, Pixel & Tonic, Inc. - * @since 2.0 - */ -class Subscription extends Element -{ - /** - * @var string - */ - public const STATUS_ACTIVE = 'active'; - - /** - * @var string - */ - public const STATUS_EXPIRED = 'expired'; - - /** - * @var string - */ - public const STATUS_SUSPENDED = 'suspended'; - - /** - * @var int|null User id - */ - public ?int $userId = null; - - /** - * @var int|null Plan id - */ - public ?int $planId = null; - - /** - * @var int|null Gateway id - */ - public ?int $gatewayId = null; - - /** - * @var int|null Order id - */ - public ?int $orderId = null; - - /** - * @var string Subscription reference on the gateway - */ - public string $reference = ''; - - /** - * @var int Trial days granted - */ - public int $trialDays = 0; - - /** - * @var DateTime|null Date of next payment - */ - public ?DateTime $nextPaymentDate = null; - - /** - * @var bool Whether the subscription is canceled - */ - public bool $isCanceled = false; - - /** - * @var DateTime|null Time when subscription was canceled - */ - public ?DateTime $dateCanceled = null; - - /** - * @var bool Whether the subscription has expired - */ - public bool $isExpired = false; - - /** - * @var DateTime|null Time when subscription expired - */ - public ?DateTime $dateExpired = null; - - /** - * @var bool Whether the subscription has started - */ - public bool $hasStarted = false; - - /** - * @var bool Whether the subscription is on hold due to payment issues - */ - public bool $isSuspended = false; - - /** - * @var DateTime|null Time when subscription was put on hold - */ - public ?DateTime $dateSuspended = null; - - /** - * @var string|null The URL to return to after a subscription is created - */ - public ?string $returnUrl = null; - - /** - * @var SubscriptionGatewayInterface|null - */ - private ?SubscriptionGatewayInterface $_gateway = null; - - /** - * @var Plan|null - */ - private ?Plan $_plan = null; - - /** - * @var User|null - */ - private ?User $_user = null; - - /** - * @var Order|null - */ - private ?Order $_order = null; - - /** - * @var array|null The subscription data from gateway - */ - public ?array $_subscriptionData = null; - - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Subscription'); - } - - /** - * @inheritdoc - */ - public static function lowerDisplayName(): string - { - return Craft::t('commerce', 'subscription'); - } - - /** - * @inheritdoc - */ - public static function pluralDisplayName(): string - { - return Craft::t('commerce', 'Subscriptions'); - } - - /** - * @inheritdoc - */ - public static function pluralLowerDisplayName(): string - { - return Craft::t('commerce', 'subscriptions'); - } - - /** - * @return string - */ - public function __toString(): string - { - $plan = $this->getPlan(); - return Craft::t('commerce', 'Subscription to “{plan}”', ['plan' => $plan->name ?? '']); - } - - public function canView(User $user): bool - { - return parent::canView($user) || $user->can('commerce-manageSubscriptions'); - } - - public function canSave(User $user): bool - { - return parent::canView($user) || $user->can('commerce-manageSubscriptions'); - } - - /** - * Returns whether this subscription can be reactivated. - * - * @throws InvalidConfigException if gateway misconfigured - */ - public function canReactivate(): bool - { - return $this->isCanceled && !$this->isExpired && $this->getGateway()->supportsReactivation(); - } - - /** - * @inheritdoc - */ - public function getFieldLayout(): ?FieldLayout - { - return Craft::$app->getFields()->getLayoutByType(static::class); - } - - /** - * Returns whether this subscription is on trial. - * - * @throws Exception - */ - public function getIsOnTrial(): bool - { - if ($this->isExpired) { - return false; - } - - return $this->trialDays > 0 && time() <= $this->getTrialExpires()->getTimestamp(); - } - - /** - * Returns the subscription plan for this subscription - */ - public function getPlan(): ?Plan - { - if (!isset($this->_plan) && $this->planId) { - $this->_plan = Plugin::getInstance()->getPlans()->getPlanById($this->planId); - } - - return $this->_plan; - } - - /** - * Returns the User that is subscribed. - */ - public function getSubscriber(): ?User - { - if (!isset($this->_user) && $this->userId) { - // Include trashed users so soft-deleted subscribers still resolve. - $this->_user = Craft::$app->getElements()->getElementById($this->userId, User::class, criteria: ['trashed' => null]); - } - - return $this->_user; - } - - public function getSubscriptionData(): array - { - return $this->_subscriptionData ?? []; - } - - public function setSubscriptionData(array|string $data): void - { - $data = Json::decodeIfJson($data); - - $this->_subscriptionData = $data; - } - - /** - * Returns the datetime of trial expiry. - * - * @throws Exception - */ - public function getTrialExpires(): ?DateTIme - { - $created = clone $this->dateCreated; - return $created->add(new DateInterval('P' . $this->trialDays . 'D')); - } - - /** - * Returns the next payment amount with currency code as a string. - * - * @throws InvalidConfigException - */ - public function getNextPaymentAmount(): string - { - return $this->getGateway()->getNextPaymentAmount($this); - } - - /** - * Returns the order that included this subscription, if any. - */ - public function getOrder(): ?Order - { - if ($this->_order) { - return $this->_order; - } - - if ($this->orderId) { - return $this->_order = Plugin::getInstance()->getOrders()->getOrderById($this->orderId); - } - - return null; - } - - /** - * Returns the product type for the product tied to the license. - * - * @throws InvalidConfigException if gateway misconfigured - */ - public function getGateway(): ?SubscriptionGatewayInterface - { - if (!isset($this->_gateway) && $this->gatewayId) { - $gateway = Plugin::getInstance()->getGateways()->getGatewayById($this->gatewayId); - if (!$gateway instanceof SubscriptionGatewayInterface) { - throw new InvalidConfigException('The gateway set for subscription does not support subscriptions.'); - } - $this->_gateway = $gateway; - } - - return $this->_gateway; - } - - public function getPlanName(): string - { - return $this->getPlan()?->__toString() ?? ''; - } - - /** - * Returns possible alternative plans for this subscription - * - * @return Plan[] - */ - public function getAlternativePlans(): array - { - if ($this->gatewayId === null) { - return []; - } - - $plans = Plugin::getInstance()->getPlans()->getPlansByGatewayId($this->gatewayId); - - $currentPlan = $this->getPlan(); - - $alternativePlans = []; - - foreach ($plans as $plan) { - // For all plans that are not the current plan - if ($currentPlan && $plan->id !== $currentPlan->id && $plan->canSwitchFrom($currentPlan)) { - $alternativePlans[] = $plan; - } - } - - return $alternativePlans; - } - - /** - * @inheritdoc - */ - public function getCpEditUrl(): ?string - { - return UrlHelper::cpUrl('commerce/subscriptions/' . $this->id); - } - - /** - * Returns the link for editing the order that purchased this license. - */ - public function getOrderEditUrl(): string - { - if ($this->orderId) { - return UrlHelper::cpUrl('commerce/orders/' . $this->orderId); - } - - return ''; - } - - /** - * Returns an array of all payments for this subscription. - * - * @return SubscriptionPayment[] - * @throws InvalidConfigException - */ - public function getAllPayments(): array - { - return $this->getGateway()->getSubscriptionPayments($this); - } - - public function getName(): ?string - { - return Craft::t('commerce', 'Subscription to “{plan}”', ['plan' => $this->getPlanName()]); - } - - /** - * @inheritdoc - */ - public static function hasStatuses(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function getStatus(): ?string - { - if ($this->isExpired) { - return self::STATUS_EXPIRED; - } - - return $this->isSuspended ? self::STATUS_SUSPENDED : self::STATUS_ACTIVE; - } - - - /** - * @inheritdoc - */ - public static function defineSources(string $context = null): array - { - $plans = Plugin::getInstance()->getPlans()->getAllPlans(); - - $planIds = []; - - foreach ($plans as $plan) { - $planIds[] = $plan->id; - } - - - $sources = [ - '*' => [ - 'key' => '*', - 'label' => Craft::t('commerce', 'All active subscriptions'), - 'criteria' => ['planId' => $planIds], - 'defaultSort' => ['dateCreated', 'desc'], - ], - ]; - - $sources[] = ['heading' => Craft::t('commerce', 'Subscription plans')]; - - foreach ($plans as $plan) { - $key = 'plan:' . $plan->id; - - $sources[$key] = [ - 'key' => $key, - 'label' => $plan->name, - 'data' => [ - 'handle' => $plan->handle, - ], - 'criteria' => ['planId' => $plan->id], - ]; - } - - $sources[] = ['heading' => Craft::t('commerce', 'Subscriptions on hold')]; - - $criteriaFailedToStart = ['isSuspended' => true, 'hasStarted' => false]; - $sources[] = [ - 'key' => 'carts:failed-to-start', - 'label' => Craft::t('commerce', 'Failed to start'), - 'criteria' => $criteriaFailedToStart, - 'defaultSort' => ['commerce_subscriptions.dateUpdated', 'desc'], - ]; - - $criteriaPaymentIssue = ['isSuspended' => true, 'hasStarted' => true]; - $sources[] = [ - 'key' => 'carts:payment-issue', - 'label' => Craft::t('commerce', 'Payment method issue'), - 'criteria' => $criteriaPaymentIssue, - 'defaultSort' => ['commerce_subscriptions.dateUpdated', 'desc'], - ]; - - return $sources; - } - - /** - * @inheritdoc - */ - public static function eagerLoadingMap(array $sourceElements, string $handle): array|null|false - { - $sourceElementIds = ArrayHelper::getColumn($sourceElements, 'id'); - - if ($handle === 'subscriber') { - $map = (new Query()) - ->select('id as source, userId as target') - ->from(Table::SUBSCRIPTIONS) - ->where(['in', 'id', $sourceElementIds]) - ->all(); - - return [ - 'elementType' => User::class, - 'map' => $map, - ]; - } - - return parent::eagerLoadingMap($sourceElements, $handle); - } - - /** - * @inheritdoc - */ - public function setEagerLoadedElements(string $handle, array $elements, EagerLoadPlan $plan): void - { - if ($handle === 'order') { - $order = $elements[0] ?? null; - $this->_order = $order instanceof Order ? $order : null; - - return; - } - - if ($handle === 'subscriber') { - $user = $elements[0] ?? null; - $this->_user = $user instanceof User ? $user : null; - - return; - } - - parent::setEagerLoadedElements($handle, $elements, $plan); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return array_merge(parent::defineRules(), [ - [['userId', 'planId', 'gatewayId', 'reference', 'subscriptionData'], 'required'], - ]); - } - - /** - * @inheritdocs - */ - public static function statuses(): array - { - return [ - self::STATUS_ACTIVE => Craft::t('commerce', 'Active'), - self::STATUS_EXPIRED => Craft::t('commerce', 'Expired'), - ]; - } - - /** - * @inheritdoc - * @return SubscriptionQuery The newly created [[SubscriptionQuery]] instance. - */ - public static function find(): SubscriptionQuery - { - return new SubscriptionQuery(static::class); - } - - /** - * @inheritdoc - */ - public function afterSave(bool $isNew): void - { - if (!$isNew) { - $subscriptionRecord = SubscriptionRecord::findOne($this->id); - - if (!$subscriptionRecord) { - throw new InvalidConfigException('Invalid subscription id: ' . $this->id); - } - } else { - $subscriptionRecord = new SubscriptionRecord(); - $subscriptionRecord->id = $this->id; - } - - $subscriptionRecord->planId = $this->planId; - $subscriptionRecord->nextPaymentDate = $this->nextPaymentDate; - $subscriptionRecord->subscriptionData = $this->subscriptionData; - $subscriptionRecord->isCanceled = $this->isCanceled; - $subscriptionRecord->dateCanceled = $this->dateCanceled; - $subscriptionRecord->isExpired = $this->isExpired; - $subscriptionRecord->dateExpired = $this->dateExpired; - $subscriptionRecord->hasStarted = $this->hasStarted; - $subscriptionRecord->isSuspended = $this->isSuspended; - $subscriptionRecord->dateSuspended = $this->dateSuspended; - $subscriptionRecord->returnUrl = $this->returnUrl; - - // We want to always have the same date as the element table, based on the logic for updating these in the element service i.e resaving - $subscriptionRecord->dateUpdated = $this->dateUpdated; - $subscriptionRecord->dateCreated = $this->dateCreated; - - // Some properties of the subscription are immutable - if ($isNew) { - $subscriptionRecord->gatewayId = $this->gatewayId; - $subscriptionRecord->orderId = $this->orderId; - $subscriptionRecord->reference = $this->reference; - $subscriptionRecord->trialDays = $this->trialDays; - $subscriptionRecord->userId = $this->userId; - } - - $subscriptionRecord->save(false); - - parent::afterSave($isNew); - } - - /** - * Return a description of the billing issue (if any) with this subscription. - * - * @throws InvalidConfigException if not a subscription gateway anymore - * @noinspection PhpUnused - */ - public function getBillingIssueDescription(): string - { - return $this->getGateway()->getBillingIssueDescription($this); - } - - /** - * Return the form HTML for resolving the billing issue (if any) with this subscription. - * - * @throws InvalidConfigException if not a subscription gateway anymore - * @noinspection PhpUnused - */ - public function getBillingIssueResolveFormHtml(): string - { - return $this->getGateway()->getBillingIssueResolveFormHtml($this); - } - - /** - * Return whether this subscription has billing issues. - * - * @throws InvalidConfigException if not a subscription gateway anymore - */ - public function getHasBillingIssues(): bool - { - return $this->getGateway()->getHasBillingIssues($this); - } - - /** - * @inheritdoc - */ - protected static function defineTableAttributes(): array - { - return [ - 'title' => ['label' => Craft::t('commerce', 'Subscription plan')], - 'subscriber' => ['label' => Craft::t('commerce', 'Subscribing user')], - 'reference' => ['label' => Craft::t('commerce', 'Subscription reference')], - 'dateCanceled' => ['label' => Craft::t('commerce', 'Cancellation date')], - 'dateCreated' => ['label' => Craft::t('commerce', 'Subscription date')], - 'dateExpired' => ['label' => Craft::t('commerce', 'Expiry date')], - 'trialExpires' => ['label' => Craft::t('commerce', 'Trial expiry date')], - ]; - } - - /** - * @inheritdoc - */ - protected static function defineDefaultTableAttributes(string $source): array - { - $attributes = []; - - $attributes[] = 'subscriber'; - $attributes[] = 'orderLink'; - $attributes[] = 'dateCreated'; - - return $attributes; - } - - /** - * @inheritdoc - */ - protected static function defineSearchableAttributes(): array - { - return [ - 'subscriber', - 'plan', - ]; - } - - /** - * @inheritdoc - */ - protected function attributeHtml(string $attribute): string - { - switch ($attribute) { - case 'plan': - return $this->getPlanName(); - - case 'subscriber': - $subscriber = $this->getSubscriber(); - if (!$subscriber) { - return ''; - } - $url = $subscriber->getCpEditUrl(); - - return '' . Html::encode($subscriber) . ''; - - case 'orderLink': - $url = $this->getOrderEditUrl(); - - return $url ? '' . Craft::t('commerce', 'View order') . '' : ''; - - default: - { - return parent::attributeHtml($attribute); - } - } - } - - /** - * @inheritdoc - */ - protected static function defineSortOptions(): array - { - return [ - [ - 'label' => Craft::t('commerce', 'Subscription date'), - 'orderBy' => 'commerce_subscriptions.dateCreated', - 'attribute' => 'dateCreated', - 'defaultDir' => 'desc', - ], - [ - 'label' => Craft::t('app', 'ID'), - 'orderBy' => 'elements.id', - 'attribute' => 'id', - ], - ]; - } - - - /** - * @inheritdoc - */ - protected static function prepElementQueryForTableAttribute(ElementQueryInterface $elementQuery, string $attribute): void - { - match ($attribute) { - 'subscriber' => $elementQuery->andWith('subscriber'), - 'orderLink' => $elementQuery->andWith('order'), - default => parent::prepElementQueryForTableAttribute($elementQuery, $attribute), - }; - } -} diff --git a/src/elements/Transfer.php b/src/elements/Transfer.php deleted file mode 100644 index ec288053d1..0000000000 --- a/src/elements/Transfer.php +++ /dev/null @@ -1,937 +0,0 @@ -getOriginLocation() === null && $this->getDestinationLocation() === null) { - return Craft::t('commerce', 'Transfer'); - } - - return (string)Craft::t('commerce', '{from} to {to}', [ - 'from' => $this->getOriginLocation()->getUiLabel(), - 'to' => $this->getDestinationLocation()->getUiLabel(), - ]); - } - - /** - * @inheritdoc - */ - public static function hasDrafts(): bool - { - return false; - } - - /** - * @inheritdoc - */ - protected function metadata(): array - { - $additionalMeta = []; - - $additionalMeta[] = [ - Craft::t('commerce', 'Transfer Status') => \craft\helpers\Cp::statusIndicatorHtml($this->getTransferStatus()->label(), [ - 'color' => $this->getTransferStatus()->color(), - ]) . ' ' . Html::tag('span', $this->getTransferStatus()->label()), - ]; - - if ($this->getIsDraft() && !$this->isProvisionalDraft) { - $additionalMeta[] = [ - Craft::t('app', 'Status') => function() { - $icon = Html::tag('span', '', [ - 'data' => ['icon' => 'draft'], - 'aria' => ['hidden' => 'true'], - ]); - $label = Craft::t('app', 'Draft'); - return $icon . Html::tag('span', $label); - }, - ]; - } - - $additionalMeta[] = [ - Craft::t('commerce', 'Transfer Status') => \craft\helpers\Cp::statusIndicatorHtml($this->getTransferStatus()->label(), [ - 'color' => $this->getTransferStatus()->color(), - ]) . ' ' . Html::tag('span', $this->getTransferStatus()->label()), - ]; - - return ArrayHelper::merge(parent::metadata(), ...$additionalMeta); // @TODO Verify metadata merge order is correct (leftover IDE-generated stub comment) - } - - - /** - * @return ?InventoryLocation - * @throws \yii\base\InvalidConfigException - */ - public function getOriginLocation(): ?InventoryLocation - { - if (!$this->originLocationId) { - return null; - } - - return Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($this->originLocationId); - } - - /** - * @return ?InventoryLocation - * @throws \yii\base\InvalidConfigException - */ - public function getDestinationLocation(): ?InventoryLocation - { - if (!$this->destinationLocationId) { - return null; - } - - return Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($this->destinationLocationId); - } - - /** - * @return TransferStatusType - */ - public function getTransferStatus(): TransferStatusType - { - return $this->transferStatus; - } - - /** - * Updates the status to partial or received if all items have been received. - * - * @return void - */ - public function updateTransferStatus(): void - { - // only pending can being partial or received. - if ($this->isTransferDraft()) { - return; - } else { - $this->setTransferStatus(TransferStatusType::PENDING); - } - - if ($this->isAllReceived()) { - $this->setTransferStatus(TransferStatusType::RECEIVED); - } - - if ($this->getTotalReceived() > 0 && $this->getTotalReceived() < $this->getTotalQuantity()) { - $this->setTransferStatus(TransferStatusType::PARTIAL); - } - } - - /** - * @param TransferStatusType|string $status - * @return void - */ - public function setTransferStatus(TransferStatusType|string $status): void - { - if (is_string($status)) { - $status = TransferStatusType::from($status); - } - - $this->transferStatus = $status; - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Transfer'); - } - - /** - * @inheritdoc - */ - public static function lowerDisplayName(): string - { - return Craft::t('commerce', 'transfer'); - } - - /** - * @inheritdoc - */ - public static function pluralDisplayName(): string - { - return Craft::t('commerce', 'Transfers'); - } - - /** - * @inheritdoc - */ - public static function pluralLowerDisplayName(): string - { - return Craft::t('commerce', 'transfers'); - } - - /** - * @inheritdoc - */ - public static function refHandle(): ?string - { - return 'transfer'; - } - - /** - * @inheritdoc - */ - public static function trackChanges(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public static function hasTitles(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public static function hasContent(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public static function hasUris(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public static function isLocalized(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public static function hasStatuses(): bool - { - return false; - } - - /** - * @return TransferQuery - * @inheritdoc - */ - public static function find(): ElementQueryInterface - { - return Craft::createObject(TransferQuery::class, [static::class]); - } - - /** - * @inheritdoc - */ - public static function createCondition(): ElementConditionInterface - { - return Craft::createObject(TransferCondition::class, [static::class]); - } - - /** - * @inheritdoc - */ - protected static function includeSetStatusAction(): bool - { - return false; - } - - protected static function defineSortOptions(): array - { - return [ - 'title' => Craft::t('app', 'Title'), - 'slug' => Craft::t('app', 'Slug'), - 'uri' => Craft::t('app', 'URI'), - [ - 'label' => Craft::t('app', 'Date Created'), - 'orderBy' => 'elements.dateCreated', - 'attribute' => 'dateCreated', - 'defaultDir' => 'desc', - ], - [ - 'label' => Craft::t('app', 'Date Updated'), - 'orderBy' => 'elements.dateUpdated', - 'attribute' => 'dateUpdated', - 'defaultDir' => 'desc', - ], - [ - 'label' => Craft::t('app', 'ID'), - 'orderBy' => 'elements.id', - 'attribute' => 'id', - ], - // ... - ]; - } - - /** - * @inheritdoc - */ - protected static function defineTableAttributes(): array - { - return [ - 'id' => ['label' => Craft::t('app', 'ID')], - 'uid' => ['label' => Craft::t('app', 'UID')], - 'originLocation' => ['label' => Craft::t('commerce', 'Origin')], - 'destinationLocation' => ['label' => Craft::t('commerce', 'Destination')], - 'dateCreated' => ['label' => Craft::t('app', 'Date Created')], - 'dateUpdated' => ['label' => Craft::t('app', 'Date Updated')], - 'received' => ['label' => Craft::t('commerce', 'Received')], - ]; - } - - /** - * @inheritdoc - */ - protected static function defineDefaultTableAttributes(string $source): array - { - return [ - 'id', - 'dateCreated', - 'received', - ]; - } - - /** - * @inheritdoc - */ - protected function attributeHtml(string $attribute): string - { - return match ($attribute) { - 'originLocation' => $this->getOriginLocation()?->getUiLabel() ?? '', - 'destinationLocation' => $this->getDestinationLocation()?->getUiLabel() ?? '', - 'received' => $this->getTotalReceived() . '/' . $this->getTotalQuantity(), - default => parent::attributeHtml($attribute), - }; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - - if ($this->scenario == static::SCENARIO_LIVE) { - $rules = ArrayHelper::merge($rules, [ - [['originLocationId', 'destinationLocationId'], 'number', 'integerOnly' => true], - [['originLocationId', 'destinationLocationId'], 'required'], - ]); - - $rules[] = [['originLocationId'], 'validateLocations']; - $rules[] = [['details'], 'validateDetails']; - } - - return $rules; - } - - /** - * @param $attribute - * @param $params - * @param $validator - * @return void - */ - public function validateDetails($attribute, $params, $validator) - { - if ($this->sumDetailsQuanity() < 1) { - $this->addError($attribute, Craft::t('commerce', 'Transfer must have at least one item.')); - } - - foreach ($this->getDetails() as $detail) { - if (!$detail->validate()) { - $this->addModelErrors($detail, 'details'); - } - } - } - - /** - * @param $attribute - * @param $params - * @param $validator - * @return void - */ - public function validateLocations($attribute, $params, $validator) - { - if ($this->originLocationId == $this->destinationLocationId) { - $this->addError($attribute, Craft::t('commerce', 'Origin and destination cannot be the same.')); - } - } - - /** - * @inheritdoc - */ - public function getUriFormat(): ?string - { - return null; - } - - /** - * Define the sources for the transfer element index - * - * @param string|null $context - * @return array - */ - protected static function defineSources(string $context = null): array - { - $transferStatuses = TransferStatusType::cases(); - $transferStatusSources = []; - foreach ($transferStatuses as $status) { - $transferStatusSources[] = [ - 'key' => $status->value, - 'status' => $status->color(), - 'label' => Craft::t('commerce', $status->label()), - 'badgeCount' => Transfer::find()->transferStatus($status->value)->count(), - 'criteria' => [ - 'transferStatus' => $status->value, - ], - ]; - } - - return [ - [ - 'key' => '*', - 'label' => Craft::t('commerce', 'All Transfers'), - 'criteria' => [], - ], - [ - 'heading' => Craft::t('commerce', 'Transfer Status'), - ], - ...$transferStatusSources, - ]; - } - - /** - * - * @inheritdoc - */ - protected function previewTargets(): array - { - $previewTargets = []; - $url = $this->getUrl(); - if ($url) { - $previewTargets[] = [ - 'label' => Craft::t('app', 'Primary {type} page', [ - 'type' => self::lowerDisplayName(), - ]), - 'url' => $url, - ]; - } - return $previewTargets; - } - - /** - * @inheritdoc - */ - protected function safeActionMenuItems(): array - { - $safeActions = parent::safeActionMenuItems(); - - if ($this->isTransferDraft() && count($this->getDetails()) > 0) { - $safeActions['mark-as-pending'] = [ - 'action' => 'commerce/transfers/mark-as-pending', - 'label' => Craft::t('commerce', 'Mark as Pending'), - 'confirm' => Craft::t('commerce', 'Are you sure you want to mark this transfer as pending? This will show as incoming at the destination.'), - 'params' => [ - 'transferId' => $this->id, - ], - 'redirect' => 'commerce/inventory/transfers/' . $this->id, - ]; - } - - return $safeActions; - } - - /** - * @inheritdoc - */ - protected function route(): array|string|null - { - // Define how transfers should be routed when their URLs are requested - return [ - 'templates/render', - [ - 'template' => 'site/template/path', - 'variables' => ['transfer' => $this], - ], - ]; - } - - /** - * @inheritdoc - */ - public function canView(User $user): bool - { - if (parent::canView($user)) { - return true; - } - - return $user->can('commerce-manageTransfers'); - } - - /** - * @inheritdoc - */ - public function canSave(User $user): bool - { - if (parent::canSave($user)) { - return true; - } - - return $user->can('commerce-manageTransfers'); - } - - /** - * @inheritdoc - */ - public function canDuplicate(User $user): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function canDelete(User $user): bool - { - $canDelete = false; - - if (parent::canSave($user)) { - $canDelete = true; - } - - if ($this->getTransferStatus() === TransferStatusType::DRAFT) { - $canDelete = true; - } - - return $canDelete && $user->can('commerce-manageTransfers'); - } - - /** - * @inheritdoc - */ - public function canCreateDrafts(User $user): bool - { - return false; - } - - /** - * @inheritdoc - */ - protected function cpEditUrl(): ?string - { - return UrlHelper::cpUrl("commerce/inventory/transfers/{$this->getCanonicalId()}"); - } - - /** - * @inheritdoc - */ - public function getPostEditUrl(): ?string - { - return UrlHelper::cpUrl('commerce/inventory/transfers'); - } - - /** - * @inheritdoc - */ - public function prepareEditScreen(Response $response, string $containerId): void - { - $view = Craft::$app->getView(); - $view->registerAssetBundle(TransfersAsset::class); - - $view->registerJsWithVars(fn($containerId, $settingsJs) => <<registerJsWithVars(fn($id, $settings) => << { - e.preventDefault(); - const modal = new Craft.Commerce.ReceiveTransferScreen($settings); - modal.on('close', (e) => { - console.log('closed'); - }); -}); -JS, [ - $receiveInventoryButtonId, - ['params' => ['transferId' => $this->id]], - ]); - - if (!$this->isTransferDraft()) { - - /** @var Response|CpScreenResponseBehavior $response */ - $response->additionalButtonsHtml(Html::a( - Craft::t('commerce', 'Receive Inventory'), - '#', - [ - 'id' => $receiveInventoryButtonId, - 'class' => 'btn', - ] - )); - } - - /** @var Response|CpScreenResponseBehavior $response */ - $response->crumbs([ - [ - 'label' => Craft::t('commerce', 'Commerce'), - 'url' => UrlHelper::cpUrl('commerce'), - ], - [ - 'label' => self::pluralDisplayName(), - 'url' => UrlHelper::cpUrl('commerce/inventory/transfers'), - ], - ]); - - $response->selectedSubnavItem('inventory-transfers'); - } - - /** - * @return TransferDetail[] - */ - public function getDetails(): array - { - if ($this->_details === null) { - $this->_details = Plugin::getInstance()->getTransfers()->getTransferDetailsByTransferId($this->id); - } - - return $this->_details; - } - - public function addDetails(TransferDetail $details): void - { - $this->_details = $this->getDetails(); - $this->_details[] = $details; - } - - /** - * @param TransferDetail[]|array $value - * - * @return void - */ - public function setDetails(array $value): void - { - foreach ($value as $key => $detail) { - if (!$detail instanceof TransferDetail) { - $value[$key] = new TransferDetail($detail); - } - - $value[$key]->setTransfer($this); - - if (!$value[$key]->inventoryItemId) { - unset($value[$key]); - } - } - - $this->_details = $value; - } - - /** - * @return int - */ - public function sumDetailsQuanity(): int - { - $sum = 0; - foreach ($this->getDetails() as $detail) { - $sum += $detail->quantity; - } - return $sum; - } - - /** - * @param TransferDetail $detail - * @return void - */ - public function addDetail(TransferDetail $detail): void - { - if (!$this->_details) { - $this->_details = []; - } - - foreach ($this->_details as $existingDetail) { - if ($existingDetail->inventoryItemId == $detail->inventoryItemId) { - $existingDetail->quantity += $detail->quantity; - return; - } - } - - $this->_details[] = $detail; - } - - /** - * @inheritdoc - */ - public function getFieldLayout(): ?FieldLayout - { - return Plugin::getInstance()->getTransfers()->getFieldLayout(); - } - - /** - * @inheritdoc - */ - public function beforeValidate() - { - if ($this->transferStatus === null) { - $this->transferStatus = TransferStatusType::DRAFT; - } - - return parent::beforeValidate(); - } - - /** - * @inheritdoc - */ - public function afterSave(bool $isNew): void - { - if (!$this->propagating) { - $transferId = $this->getCanonicalId(); - $transferRecord = TransferRecord::findOne($transferId); - - if (!$transferRecord) { - $transferRecord = new TransferRecord(); - } - - $originalTransferStatus = $transferRecord->transferStatus; - - $transferRecord->id = $this->id; - $transferRecord->originLocationId = $this->originLocationId; - $transferRecord->destinationLocationId = $this->destinationLocationId; - $transferRecord->transferStatus = $this->getTransferStatus()->value ?? TransferStatusType::DRAFT->value; - - $transferRecord->save(false); - - if ($this->getTransferStatus() === TransferStatusType::PENDING && $originalTransferStatus == TransferStatusType::DRAFT->value) { - $inventoryUpdateCollection = new UpdateInventoryLevelCollection(); - foreach ($this->getDetails() as $detail) { - $inventoryUpdate1 = new UpdateInventoryLevelInTransfer(); - $inventoryUpdate1->type = InventoryTransactionType::INCOMING->value; - $inventoryUpdate1->updateAction = InventoryUpdateQuantityType::ADJUST; - $inventoryUpdate1->inventoryItemId = $detail->inventoryItemId; - $inventoryUpdate1->transferId = $this->id; - $inventoryUpdate1->inventoryLocationId = $this->destinationLocationId; - $inventoryUpdate1->quantity = $detail->quantity; - $inventoryUpdate1->note = Craft::t('commerce', 'Incoming transfer from Transfer ID: ') . $this->id; - - $inventoryUpdateCollection->push($inventoryUpdate1); - - $inventoryUpdate2 = new UpdateInventoryLevelInTransfer(); - $inventoryUpdate2->type = 'onHand'; - $inventoryUpdate2->updateAction = InventoryUpdateQuantityType::ADJUST; - $inventoryUpdate2->inventoryItemId = $detail->inventoryItemId; - $inventoryUpdate2->transferId = $this->id; - $inventoryUpdate2->inventoryLocationId = $this->originLocationId; - $inventoryUpdate2->quantity = $detail->quantity * -1; - $inventoryUpdate2->note = Craft::t('commerce', 'Outgoing transfer from Transfer ID: ') . $this->id; - - $inventoryUpdateCollection->push($inventoryUpdate2); - } - - Plugin::getInstance()->getInventory()->executeUpdateInventoryLevels($inventoryUpdateCollection); - } - - $existingDetailIds = (new Query()) - ->select('id') - ->from('{{%commerce_transferdetails}}') - ->where(['transferId' => $this->id]) - ->column(); - - $currentDetailIds = []; - - foreach ($this->getDetails() as $detail) { - if ($detail->id) { - $detailRecord = TransferDetailRecord::findOne($detail->id); - } else { - $detailRecord = new TransferDetailRecord(); - } - $detailRecord->transferId = $this->id; - $detailRecord->inventoryItemId = $detail->inventoryItemId; - $inventoryItem = $detail->inventoryItemId ? Plugin::getInstance()->getInventory()->getInventoryItemById($detail->inventoryItemId) : null; - $detailRecord->inventoryItemDescription = $inventoryItem?->sku ?? ''; - $detailRecord->quantity = $detail->quantity; - $detailRecord->quantityAccepted = $detail->quantityAccepted; - $detailRecord->quantityRejected = $detail->quantityRejected; - - $detailRecord->save(); - $detail->id = $detailRecord->id; - - $currentDetailIds[] = $detailRecord->id; - } - - $deletedDetailIds = array_diff($existingDetailIds, $currentDetailIds); - if (!empty($deletedDetailIds)) { - TransferDetailRecord::deleteAll(['id' => $deletedDetailIds]); - } - - $this->updateTransferStatus(); - $transferRecord->transferStatus = $this->getTransferStatus()->value; - - $transferRecord->save(false); - } - - parent::afterSave($isNew); - } - - /** - * @return bool - */ - public function isTransferDraft(): bool - { - return $this->getTransferStatus() === TransferStatusType::DRAFT; - } - - /** - * @return bool - */ - public function isTransferPending(): bool - { - return $this->getTransferStatus() === TransferStatusType::PENDING; - } - - /** - * @return bool - */ - public function isTransferPartial(): bool - { - return $this->getTransferStatus() === TransferStatusType::PARTIAL; - } - - /** - * @return bool - */ - public function isTransferReceived(): bool - { - return $this->getTransferStatus() === TransferStatusType::RECEIVED; - } - - /** - * @return int - */ - public function getTotalRejected(): int - { - $totalRejected = 0; - foreach ($this->getDetails() as $detail) { - $totalRejected += $detail->quantityRejected; - } - return $totalRejected; - } - - /** - * @return int - */ - public function getTotalAccepted(): int - { - $totalAccepted = 0; - foreach ($this->getDetails() as $detail) { - $totalAccepted += $detail->quantityAccepted; - } - return $totalAccepted; - } - - /** - * @return int - */ - public function getTotalReceived(): int - { - return $this->getTotalAccepted() + $this->getTotalRejected(); - } - - /** - * @return bool - */ - public function isAllReceived(): bool - { - foreach ($this->getDetails() as $detail) { - if ($detail->getReceived() < $detail->quantity) { - return false; - } - } - - return true; - } - - /** - * @return int - */ - public function getTotalQuantity(): int - { - $totalQuantity = 0; - foreach ($this->getDetails() as $detail) { - $totalQuantity += $detail->quantity; - } - return $totalQuantity; - } -} diff --git a/src/elements/Variant.php b/src/elements/Variant.php deleted file mode 100755 index 54f32378a6..0000000000 --- a/src/elements/Variant.php +++ /dev/null @@ -1,1536 +0,0 @@ - - * @since 2.0 - */ -class Variant extends Purchasable implements NestedElementInterface -{ - use NestedElementTrait { - eagerLoadingMap as traitEagerLoadingMap; - setPrimaryOwner as traitSetPrimaryOwner; - setOwner as traitSetOwner; - setEagerLoadedElements as traitSetEagerLoadedElements; - extraFields as traitExtraFields; - } - - /** - * @event craft\commerce\events\CustomizeVariantSnapshotFieldsEvent The event that is triggered before a variant’s field data is captured, which makes it possible to customize which fields are included in the snapshot. Custom fields are not included by default. - * - * This example adds every custom field to the variant snapshot: - * - * ```php - * use craft\commerce\elements\Variant; - * use craft\commerce\events\CustomizeVariantSnapshotFieldsEvent; - * use yii\base\Event; - * - * Event::on( - * Variant::class, - * Variant::EVENT_BEFORE_CAPTURE_VARIANT_SNAPSHOT, - * function(CustomizeVariantSnapshotFieldsEvent $event) { - * // @var Variant $variant - * $variant = $event->variant; - * // @var array|null $fields - * $fields = $event->fields; - * - * // Add every custom field to the snapshot - * if (($fieldLayout = $variant->getFieldLayout()) !== null) { - * foreach ($fieldLayout->getFields() as $field) { - * $fields[] = $field->handle; - * } - * } - * - * $event->fields = $fields; - * } - * ); - * ``` - */ - public const EVENT_BEFORE_CAPTURE_VARIANT_SNAPSHOT = 'beforeCaptureVariantSnapshot'; - - /** - * @event craft\commerce\events\CustomizeVariantSnapshotDataEvent The event that is triggered after a variant’s field data is captured. This makes it possible to customize, extend, or redact the data to be persisted on the variant instance. - * - * ```php - * use craft\commerce\elements\Variant; - * use craft\commerce\events\CustomizeVariantSnapshotDataEvent; - * use yii\base\Event; - * - * Event::on( - * Variant::class, - * Variant::EVENT_AFTER_CAPTURE_VARIANT_SNAPSHOT, - * function(CustomizeVariantSnapshotDataEvent $event) { - * // @var Variant $variant - * $variant = $event->variant; - * // @var array|null $fields - * $fields = $event->fields; - * - * // Modify or redact captured `$data` - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_CAPTURE_VARIANT_SNAPSHOT = 'afterCaptureVariantSnapshot'; - - /** - * @event craft\commerce\events\CustomizeProductSnapshotFieldsEvent The event that is triggered before a product’s field data is captured. This makes it possible to customize which fields are included in the snapshot. Custom fields are not included by default. - * - * This example adds every custom field to the product snapshot: - * - * ```php - * use craft\commerce\elements\Variant; - * use craft\commerce\elements\Product; - * use craft\commerce\events\CustomizeProductSnapshotFieldsEvent; - * use yii\base\Event; - * - * Event::on( - * Variant::class, - * Variant::EVENT_BEFORE_CAPTURE_PRODUCT_SNAPSHOT, - * function(CustomizeProductSnapshotFieldsEvent $event) { - * // @var Product $product - * $product = $event->product; - * // @var array|null $fields - * $fields = $event->fields; - * - * // Add every custom field to the snapshot - * if (($fieldLayout = $product->getFieldLayout()) !== null) { - * foreach ($fieldLayout->getFields() as $field) { - * $fields[] = $field->handle; - * } - * } - * - * $event->fields = $fields; - * } - * ); - * ``` - * - * ::: warning - * Add with care! A huge amount of custom fields/data will increase your database size. - * ::: - */ - public const EVENT_BEFORE_CAPTURE_PRODUCT_SNAPSHOT = 'beforeCaptureProductSnapshot'; - - /** - * @event craft\commerce\events\CustomizeProductSnapshotDataEvent The event that is triggered after a product’s field data is captured, which can be used to customize, extend, or redact the data to be persisted on the product instance. - * - * ```php - * use craft\commerce\elements\Variant; - * use craft\commerce\elements\Product; - * use craft\commerce\events\CustomizeProductSnapshotDataEvent; - * use yii\base\Event; - * - * Event::on( - * Variant::class, - * Variant::EVENT_AFTER_CAPTURE_PRODUCT_SNAPSHOT, - * function(CustomizeProductSnapshotDataEvent $event) { - * // @var Product $product - * $product = $event->product; - * // @var array $data - * $data = $event->fieldData; - * - * // Modify or redact captured `$data` - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_CAPTURE_PRODUCT_SNAPSHOT = 'afterCaptureProductSnapshot'; - - /** - * @var bool $isDefault - */ - public bool $isDefault = false; - - /** - * @var int|null $sortOrder - */ - public ?int $sortOrder = null; - - /** - * @var string|null - * @see getProductSlug() - * @see setProductSlug() - */ - private ?string $_productSlug = null; - - /** - * @var string|null - * @see getProductTypeHandle() - * @see setProductTypeHandle() - */ - private ?string $_productTypeHandle = null; - - /** - * @throws InvalidConfigException - */ - public function behaviors(): array - { - $behaviors = parent::behaviors(); - - $behaviors['currencyAttributes'] = [ - 'class' => CurrencyAttributeBehavior::class, - 'currencyAttributes' => $this->currencyAttributes(), - ]; - - return $behaviors; - } - - public function safeAttributes() - { - $attributes = parent::safeAttributes(); - $attributes[] = 'productId'; - - return $attributes; - } - - /** - * @inheritdoc - */ - public function init(): void - { - parent::init(); - $this->ownerType = Product::class; - } - - /** - * @inheritdoc - */ - protected function uiLabel(): ?string - { - $owner = $this->getOwner(); - if ($owner) { - $uiLabelFormat = $owner->getType()->variantUiLabelFormat; - if ($uiLabelFormat !== '{title}') { - $uiLabel = Craft::$app->getView()->renderSandboxedObjectTemplate($uiLabelFormat, $this); - if ($uiLabel !== '') { - return $uiLabel; - } - } - } - - return null; - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Product Variant'); - } - - /** - * @inheritdoc - */ - public static function lowerDisplayName(): string - { - return Craft::t('commerce', 'product variant'); - } - - /** - * @inheritdoc - */ - public static function pluralDisplayName(): string - { - return Craft::t('commerce', 'Product Variants'); - } - - /** - * @inheritdoc - */ - public static function pluralLowerDisplayName(): string - { - return Craft::t('commerce', 'product variants'); - } - - /** - * @inheritdoc - */ - public static function refHandle(): ?string - { - return 'variant'; - } - - /** - * @inheritdoc - */ - public function getIsTitleTranslatable(): bool - { - return ($this->getOwner()->getType()->variantTitleTranslationMethod !== Field::TRANSLATION_METHOD_NONE); - } - - /** - * @inheritdoc - */ - public function getTitleTranslationDescription(): ?string - { - return ElementHelper::translationDescription($this->getOwner()->getType()->variantTitleTranslationMethod); - } - - /** - * @inheritdoc - */ - public function getTitleTranslationKey(): string - { - $type = $this->getOwner()->getType(); - return ElementHelper::translationKey($this, $type->variantTitleTranslationMethod, $type->variantTitleTranslationKeyFormat); - } - - /** - * @inheritdoc - */ - public function canSave(User $user): bool - { - if (parent::canSave($user)) { - return true; - } - - $product = $this->getOwner(); - if ($product === null) { - return false; - } - - return $product->canSave($user); - } - - /** - * @inheritdoc - */ - public function canCopy(User $user): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function canDelete(User $user): bool - { - if (parent::canDelete($user)) { - return true; - } - - return $this->canSave($user); - } - - /** - * @return bool - * @todo Remove in Commerce 6.0 along with the deprecated `deletedWithProduct` property (use `deletedWithOwner` instead) - */ - public function getDeletedWithProduct(): bool - { - Craft::$app->getDeprecator()->log('Variant::getDeletedWithProduct()', 'The “deletedWithProduct” property has been deprecated. Use “deletedWithOwner” instead.'); - - return $this->deletedWithOwner; - } - - /** - * @param $value - * @return void - * @todo Remove in Commerce 6.0 along with the deprecated `deletedWithProduct` property (use `deletedWithOwner` instead) - */ - public function setDeletedWithProduct($value): void - { - return; - } - - /** - * @inheritdoc - */ - public function canDuplicate(User $user): bool - { - if (parent::canDuplicate($user)) { - return true; - } - - return $this->canSave($user); - } - - /** - * @inheritdoc - */ - protected static function includeSetStatusAction(): bool - { - return true; - } - - /** - * @inheritdoc - * @throws InvalidConfigException - */ - public function getIsAvailable(): bool - { - if ($this->getIsRevision()) { - return false; - } - - if ($this->getIsDraft()) { - return false; - } - - if ($this->getPrimaryOwner()->getIsDraft()) { - return false; - } - - if ($this->getPrimaryOwner()->status != Product::STATUS_LIVE) { - return false; - } - - return parent::getIsAvailable(); - } - - /** - * @inheritdoc - * @return VariantCondition - * @throws InvalidConfigException - */ - public static function createCondition(): ElementConditionInterface - { - return Craft::createObject(VariantCondition::class, [static::class]); - } - - /** - * @return void - * @noinspection PhpUnused - */ - public function validateMinQtyRange() - { - if ($this->minQty && $this->maxQty && $this->minQty > $this->maxQty) { - $this->addError('minQty', Craft::t('commerce', 'Min quantity must be less than max.')); - } - } - - /** - * @return void - * @noinspection PhpUnused - */ - public function validateMaxQtyRange() - { - if ($this->minQty && $this->maxQty && $this->maxQty < $this->minQty) { - $this->addError('maxQty', Craft::t('commerce', 'Max quantity must greater than min.')); - } - } - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $names = $this->traitExtraFields(); - $names[] = 'product'; - - return $names; - } - - /** - * @inheritdoc - */ - public function getFieldLayout(): ?FieldLayout - { - $fieldLayout = parent::getFieldLayout(); - - // If we have a field layout, try to set its provider from product type - if ($fieldLayout) { - $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); - $productType = collect($productTypes)->firstWhere('variantFieldLayoutId', $fieldLayout->id); - - if ($productType) { - $fieldLayout->provider = $productType; - return $fieldLayout; - } - } - - // Try to get field layout from owner's product type - try { - $owner = $this->getOwner(); - - return $owner === null - ? $fieldLayout - : $owner->getType()->getVariantFieldLayout(); - } catch (InvalidConfigException) { - // Product type was likely deleted - return null; - } - } - - /** - * @inheritdoc - */ - protected function metadata(): array - { - $metadata = parent::metadata(); - - $product = $this->getOwner(); - - if ($product) { - $metadata[Craft::t('commerce', 'Product')] = Cp::elementChipHtml($product, ['showActionMenu' => true]); - } - - return $metadata; - } - - /** - * @param int|null $productId - * @return void - * @since 5.0.0 - * @deprecated in 5.0.0. Use [[setOwnerId()]] instead. - */ - public function setProductId(?int $productId) - { - $this->setOwnerId($productId); - } - - /** - * @return int|null - * @throws InvalidConfigException - * @deprecated in 5.0.0. Use [[getOwnerId()]] instead. - * @since 5.0.0 - */ - public function getProductId(): ?int - { - return $this->getOwnerId(); - } - - /** - * @inheritdoc - */ - public function setPrimaryOwner(?ElementInterface $owner): void - { - if (!$owner instanceof Product) { - throw new InvalidArgumentException('Product variants can only be assigned to products.'); - } - - if ($owner->siteId) { - $this->siteId = $owner->siteId; - } - - $this->fieldLayoutId = $owner->getType()->variantFieldLayoutId; - - $this->traitSetPrimaryOwner($owner); - } - - /** - * @inheritdoc - */ - public function setOwner(?ElementInterface $owner): void - { - if (!$owner instanceof Product) { - throw new InvalidArgumentException('Product variants can only be assigned to products.'); - } - - if ($owner->siteId) { - $this->siteId = $owner->siteId; - } - - $this->fieldLayoutId = $owner->getType()->variantFieldLayoutId; - - $this->traitSetOwner($owner); - } - - /** - * Returns the product associated with this variant. - * - * @return Product|null The product associated with this variant, or null if it isn’t known - * @deprecated in 5.0.0. Use [[getOwner()]] instead. - */ - public function getProduct(): ?Product - { - /** @var Product|null */ - return $this->getOwner(); - } - - /** - * Sets the product associated with this variant. - * - * @param Product $product The product associated with this variant - * @deprecated in 5.0.0. Use [[setOwner()]] instead. - */ - public function setProduct(Product $product): void - { - $this->setOwner($product); - } - - /** - * @param string|null $productSlug - * @return void - * @since 5.0.0 - */ - public function setProductSlug(?string $productSlug): void - { - $this->_productSlug = $productSlug; - } - - /** - * @return string|null - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function getProductSlug(): ?string - { - if ($this->_productSlug === null) { - $product = $this->getOwner(); - - $this->_productSlug = $product?->slug ?? null; - } - - return $this->_productSlug; - } - - /** - * @param string|null $productTypeHandle - * @return void - * @since 5.0.0 - */ - public function setProductTypeHandle(?string $productTypeHandle): void - { - $this->_productTypeHandle = $productTypeHandle; - } - - /** - * @return string|null - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function getProductTypeHandle(): ?string - { - if ($this->_productTypeHandle === null) { - $product = $this->getOwner(); - - $this->_productTypeHandle = $product ? ($product->getType()?->handle ?? null) : null; - } - - return $this->_productTypeHandle; - } - - /** - * Returns the product title and variants title together for variable products. - * - * @throws Exception - * @throws InvalidConfigException - * @throws Throwable - */ - public function getDescription(): string - { - $description = $this->title; - - if ($format = $this->getOwner()->getType()->descriptionFormat) { - if ($rendered = Craft::$app->getView()->renderSandboxedObjectTemplate($format, $this)) { - $description = $rendered; - } - } - - // If title is not set yet default to blank string - return (string)$description; - } - - /** - * Updates the title based on titleFormat, or sets it to the same title as the product. - * - * @throws Exception - * @throws InvalidConfigException - * @throws Throwable - * @see \craft\elements\Entry::updateTitle - */ - public function updateTitle(Product $product): void - { - $type = $product->getType(); - // Use the product type's titleFormat if the title field is not shown - if (!$type->hasVariantTitleField && $type->variantTitleFormat) { - // Make sure that the locale has been loaded in case the title format has any Date/Time fields - Craft::$app->getLocale(); - // Set Craft to the product's site's language, in case the title format has any static translations - $language = Craft::$app->language; - Craft::$app->language = $this->getSite()->language; - $this->title = Craft::$app->getView()->renderSandboxedObjectTemplate($type->variantTitleFormat, $this); - Craft::$app->language = $language; - } - } - - - /** - * @throws Throwable - */ - public function updateSku(Product $product): void - { - $type = $product->getType(); - // If we have a blank SKU, generate from product type’s skuFormat - if (!$this->sku && $type->skuFormat) { - // Make sure that the locale has been loaded in case the title format has any Date/Time fields - Craft::$app->getLocale(); - // Set Craft to the product’s site’s language, in case the title format has any static translations - $language = Craft::$app->language; - Craft::$app->language = $this->getSite()->language; - $this->sku = Craft::$app->getView()->renderSandboxedObjectTemplate($type->skuFormat, $this); - - $skuExistsQuery = function(string $sku, ?int $id) { - $query = (new Query()) - ->select(['sku']) - ->from(Table::PURCHASABLES) - ->where(['sku' => $sku]); - - // Make sure it isn't for the purchasable we are currently saving - if ($id) { - $query->andWhere(['not', ['id' => $id]]); - } - - return $query; - }; - - // Ensure there isn't a clash with an existing SKU when using auto formats - if ($skuExistsQuery($this->getSku(), $this->id)->exists()) { - // If there is a clash, we need to append a number to the end. - do { - $seq = Sequence::next('sku::' . $this->sku); - $newSku = $this->sku . '-' . $seq; - } while ($skuExistsQuery($newSku, $this->id)->exists()); - - $this->sku = $newSku; - } - - Craft::$app->language = $language; - } - } - - /** - * @inheritdoc - */ - protected function cacheTags(): array - { - $tags = []; - - if ($primaryOwnerId = $this->getPrimaryOwnerId()) { - $tags[] = "element::{$primaryOwnerId}"; - $tags[] = "product:{$primaryOwnerId}"; - } - - $ownerId = $this->getOwnerId(); - if ($ownerId && $ownerId !== $primaryOwnerId) { - $tags[] = "element::{$ownerId}"; - } - - return $tags; - } - - /** - * @inheritdoc - */ - public function canView(User $user): bool - { - if (parent::canView($user)) { - return true; - } - - $product = $this->getOwner(); - if ($product === null) { - return false; - } - - return $product->canView($user); - } - - /** - * @inheritdoc - */ - public function getUrl(): ?string - { - if ($url = parent::getUrl()) { - return $url; - } - - // Default URL is the product's URL with the variant ID as a query parameter - $productUrl = $this->getOwner()?->getUrl(); - return $productUrl ? UrlHelper::urlWithParams($productUrl, ['variant' => $this->id]) : null; - } - - /** - * - * @throws InvalidConfigException - */ - public function getSnapshot(): array - { - $data = parent::getSnapshot(); - $data['cpEditUrl'] = $this->getCpEditUrl(); - - // Default Product custom field handles - $productFields = []; - $productFieldsEvent = new CustomizeProductSnapshotFieldsEvent([ - 'product' => $this->getOwner(), - 'fields' => $productFields, - ]); - - // Allow plugins to modify Product fields to be fetched - if ($this->hasEventHandlers(self::EVENT_BEFORE_CAPTURE_PRODUCT_SNAPSHOT)) { - $this->trigger(self::EVENT_BEFORE_CAPTURE_PRODUCT_SNAPSHOT, $productFieldsEvent); - } - - // Product Attributes - if ($product = $this->getOwner()) { - $productAttributes = $product->attributes(); - - // Remove custom fields - if (($fieldLayout = $product->getFieldLayout()) !== null) { - foreach ($fieldLayout->getCustomFields() as $field) { - ArrayHelper::removeValue($productAttributes, $field->handle); - } - } - - // Add back the custom fields they want - foreach ($productFieldsEvent->fields as $field) { - $productAttributes[] = $field; - } - - $data['product'] = $this->getOwner()->toArray($productAttributes, [], false); - - $productDataEvent = new CustomizeProductSnapshotDataEvent([ - 'product' => $this->getOwner(), - 'fieldData' => $data['product'], - ]); - } else { - $productDataEvent = new CustomizeProductSnapshotDataEvent([ - 'product' => $this->getOwner(), - 'fieldData' => [], - ]); - } - - // Allow plugins to modify captured Product data - if ($this->hasEventHandlers(self::EVENT_AFTER_CAPTURE_PRODUCT_SNAPSHOT)) { - $this->trigger(self::EVENT_AFTER_CAPTURE_PRODUCT_SNAPSHOT, $productDataEvent); - } - - $data['product'] = $productDataEvent->fieldData; - - // Default Variant custom field handles - $variantFields = []; - $variantFieldsEvent = new CustomizeVariantSnapshotFieldsEvent([ - 'variant' => $this, - 'fields' => $variantFields, - ]); - - // Allow plugins to modify fields to be fetched - if ($this->hasEventHandlers(self::EVENT_BEFORE_CAPTURE_VARIANT_SNAPSHOT)) { - $this->trigger(self::EVENT_BEFORE_CAPTURE_VARIANT_SNAPSHOT, $variantFieldsEvent); - } - - $variantAttributes = $this->attributes(); - - // Remove custom fields - if (($fieldLayout = $this->getFieldLayout()) !== null) { - foreach ($fieldLayout->getCustomFields() as $field) { - ArrayHelper::removeValue($variantAttributes, $field->handle); - } - } - - // Add back the custom fields they want - foreach ($variantFieldsEvent->fields as $field) { - $variantAttributes[] = $field; - } - - $variantData = $this->toArray($variantAttributes, [], false); - - $variantDataEvent = new CustomizeVariantSnapshotDataEvent([ - 'variant' => $this, - 'fieldData' => $variantData, - ]); - - // Allow plugins to modify captured Variant data - if ($this->hasEventHandlers(self::EVENT_AFTER_CAPTURE_VARIANT_SNAPSHOT)) { - $this->trigger(self::EVENT_AFTER_CAPTURE_VARIANT_SNAPSHOT, $variantDataEvent); - } - - return array_merge($variantDataEvent->fieldData, $data); - } - - /** - * @inheritdoc - * @throws InvalidConfigException - */ - public function hasFreeShipping(): bool - { - $isShippable = $this->getIsShippable(); // Same as Plugin::getInstance()->getPurchasables()->isPurchasableShippable since this has no context - return $isShippable && $this->freeShipping; - } - - /** - * @inheritdoc - * @return VariantQuery The newly created [[VariantQuery]] instance. - */ - public static function find(): VariantQuery - { - return new VariantQuery(static::class); - } - - /** - * @inheritdoc - */ - public static function hasStatuses(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public static function eagerLoadingMap(array $sourceElements, string $handle): array|null|false - { - switch ($handle) { - case 'product': - // Get the source element IDs - $sourceElementIds = []; - - foreach ($sourceElements as $sourceElement) { - $sourceElementIds[] = $sourceElement->id; - } - - $map = (new Query()) - ->select('id as source, primaryOwnerId as target') - ->from(Table::VARIANTS) - ->where(['in', 'id', $sourceElementIds]) - ->all(); - - return [ - 'elementType' => Product::class, - 'map' => $map, - 'criteria' => [ - 'status' => null, - ], - ]; - case 'owner': - case 'primaryOwner': - return array_merge( - self::traitEagerLoadingMap($sourceElements, $handle), - ['elementType' => Product::class], - ); - default: - return self::traitEagerLoadingMap($sourceElements, $handle); - } - } - - /** - * Returns a promotion category related to this element if the category is related to the product OR the variant. - * - * @throws InvalidConfigException - */ - public function getPromotionRelationSource(): array - { - return [$this->id, $this->getOwner()->id]; - } - - /** - * @throws InvalidConfigException - * @since 3.1 - */ - public function getGqlTypeName(): string - { - $product = $this->getOwner(); - - if (!$product) { - return 'Variant'; - } - - try { - $productType = $product->getType(); - } catch (Exception) { - return 'Variant'; - } - - return static::gqlTypeNameByContext($productType); - } - - /** - * @return string - * @since 3.1 - */ - public static function gqlTypeNameByContext(mixed $context): string - { - return $context->handle . '_Variant'; - } - - /** - * @param mixed $context - * @return array - * @since 3.1 - */ - public static function gqlScopesByContext(mixed $context): array - { - /** @var ProductType $context */ - return ['productTypes.' . $context->uid]; - } - - /** - * @inheritdoc - */ - public function getSupportedSites(): array - { - $owner = $this->getOwner(); - - if (!$owner) { - return [Craft::$app->getSites()->getPrimarySite()->id]; - } - - return $this->getOwner()->getSupportedSites(); - } - - /** - * @inheritdoc - * @throws Exception - */ - public function afterSave(bool $isNew): void - { - $ownerId = $this->getOwnerId(); - - if (!$this->propagating) { - if (!$isNew) { - $record = VariantRecord::findOne($this->id); - - if (!$record) { - throw new Exception('Invalid variant ID: ' . $this->id); - } - } else { - $record = new VariantRecord(); - $record->id = $this->id; - } - - $record->primaryOwnerId = $this->getPrimaryOwnerId(); - - if ($this->getOwner()->getIsCanonical()) { - $record->isDefault = $this->isDefault; - } - - // We want to always have the same date as the element table, based on the logic for updating these in the element service i.e resaving - $record->dateUpdated = $this->dateUpdated; - $record->dateCreated = $this->dateCreated; - - $record->save(false); - - if ($ownerId && $this->saveOwnership) { - if (!isset($this->sortOrder) && (!$isNew || $this->duplicateOf)) { - // figure out if we should proceed this way - // if we're dealing with an element that's being duplicated, and it has a draftId - // it means we're creating a draft of something - // if we're duplicating element via duplicate action - draftId would be empty - // Same as https://github.com/craftcms/cms/pull/14497/files - $elementId = null; - if ($this->duplicateOf) { - if ($this->draftId) { - $elementId = $this->duplicateOf->id; - } - } else { - // if we're not duplicating - use element's id - $elementId = $this->id; - } - if ($elementId) { - $this->sortOrder = (new Query()) - ->select('sortOrder') - ->from(CraftTable::ELEMENTS_OWNERS) - ->where([ - 'elementId' => $elementId, - 'ownerId' => $ownerId, - ]) - ->scalar() ?: null; - } - } - if (!isset($this->sortOrder)) { - $max = (new Query()) - ->from(['eo' => CraftTable::ELEMENTS_OWNERS]) - ->innerJoin(['v' => Table::VARIANTS], '[[v.id]] = [[eo.elementId]]') - ->where([ - 'eo.ownerId' => $ownerId, - ]) - ->max('[[eo.sortOrder]]'); - $this->sortOrder = $max ? $max + 1 : 1; - } - - $ownerIds = array_unique([ - $ownerId, - $this->getPrimaryOwnerId(), - ]); - - if (!$isNew) { - Db::delete(CraftTAble::ELEMENTS_OWNERS, [ - 'elementId' => $this->id, - 'ownerId' => $ownerIds, - ]); - } - - foreach ($ownerIds as $ownerId) { - Db::insert(CraftTAble::ELEMENTS_OWNERS, [ - 'elementId' => $this->id, - 'ownerId' => $ownerId, - 'sortOrder' => $this->sortOrder, - ]); - } - } - } - - parent::afterSave($isNew); - - if (!$this->propagating && $this->isDefault && $ownerId && $this->duplicateOf === null) { - // @TODO Remove this denormalized default-variant data write in Commerce 6.0; the product query now joins this data directly - $defaultData = [ - 'defaultVariantId' => $this->id, - 'defaultSku' => $this->getSkuAsText(), - 'defaultPrice' => $this->getBasePrice(), - 'defaultHeight' => $this->height, - 'defaultLength' => $this->length, - 'defaultWidth' => $this->width, - 'defaultWeight' => $this->weight, - ]; - // Update the product that owns this variant - Db::update(Table::PRODUCTS, $defaultData, ['id' => $ownerId]); - // Update any other product that references this variant as its default (split from the above to avoid deadlocks from non-deterministic lock ordering with OR-clauses) - Db::update(Table::PRODUCTS, $defaultData, ['and', ['defaultVariantId' => $this->id], ['not', ['id' => $ownerId]]]); - } - } - - /** - * @inheritdoc - * @throws InvalidConfigException - */ - public function setEagerLoadedElements(string $handle, array $elements, EagerLoadPlan $plan): void - { - if (in_array($handle, ['product', 'owner', 'primaryOwner'])) { - $product = $elements[0] ?? null; - if ($product instanceof Product) { - if ($handle == 'primaryOwner') { - $this->setPrimaryOwner($product); - } else { - $this->setOwner($product); - } - } - } else { - $this->traitSetEagerLoadedElements($handle, $elements, $plan); - } - } - - /** - * @inheritdoc - */ - public static function hasTitles(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public static function isSelectable(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public static function isLocalized(): bool - { - return true; - } - - /** - * @inheritdoc - * @throws Throwable - * @throws InvalidConfigException - */ - public function beforeValidate(): bool - { - $product = $this->getOwner(); - - // hold off on updating the title and SKU if we are creating the shell of the variant ready for editing - /** @phpstan-ignore-next-line don't need the `$this->getIsDraft()` on the right side but leaving for readability */ - if (!$this->getIsDraft() || ($this->getIsDraft() && $this->getScenario() !== self::SCENARIO_ESSENTIALS)) { - $this->updateTitle($product); - $this->updateSku($product); - } - - if (!$this->sku && $this->getScenario() === self::SCENARIO_DEFAULT) { - $this->setSku(PurchasableHelper::tempSku()); - } - - return parent::beforeValidate(); - } - - /** - * @throws InvalidConfigException - */ - public function beforeSave(bool $isNew): bool - { - $product = $this->getOwner(); - - // hold off on updating the title and SKU if we are creating the shell of the variant ready for editing - /** @phpstan-ignore-next-line don't need the `$this->getIsDraft()` on the right side but leaving for readability */ - if (!$this->getIsDraft() || ($this->getIsDraft() && $this->getScenario() !== self::SCENARIO_ESSENTIALS)) { - $this->updateTitle($product); - $this->updateSku($product); - } - - // Set the field layout - $productType = $product->getType(); - $this->fieldLayoutId = $productType->variantFieldLayoutId; - - // Validate shipping category ID is available for this product type - $availableShippingCategories = $this->availableShippingCategories(); - $availableShippingCategoryIds = ArrayHelper::getColumn($availableShippingCategories, 'id'); - - // If the current shipping category ID is not in the available categories, set it to the default one - $currentShippingCategoryId = $this->getShippingCategoryId(); - if (!in_array($currentShippingCategoryId, $availableShippingCategoryIds)) { - $defaultShippingCategory = Plugin::getInstance()->getShippingCategories()->getDefaultShippingCategory($this->getStoreId()); - $this->setShippingCategoryId($defaultShippingCategory->id); - } - - return parent::beforeSave($isNew); - } - - /** - * @inheritdoc - */ - public function afterAssignedId(): void - { - if (ElementHelper::isDraftOrRevision($this)) { - return; - } - - $product = $this->getOwner(); - $this->updateTitle($product); - } - - /** - * @inheritdoc - * @throws \yii\db\Exception - */ - public function beforeRestore(): bool - { - if (!parent::beforeRestore()) { - return false; - } - - // Check to see if any other purchasable has the same SKU and update this one before restore - $found = (new Query())->select(['[[p.sku]]', '[[e.id]]']) - ->from(Table::PURCHASABLES . ' p') - ->leftJoin(CraftTable::ELEMENTS . ' e', '[[p.id]]=[[e.id]]') - ->where(['[[e.dateDeleted]]' => null, '[[p.sku]]' => $this->getSku()]) - ->andWhere(['not', ['[[e.id]]' => $this->getId()]]) - ->count(); - - if ($found) { - // Set new SKU in memory - $this->sku = $this->getSku() . '-1'; - - // Update purchasable table with new SKU - Craft::$app->getDb()->createCommand()->update(Table::PURCHASABLES, - ['sku' => $this->sku], - ['id' => $this->getId()] - )->execute(); - } - - return true; - } - - /** - * @throws InvalidConfigException - * @since 2.2 - */ - public function getSearchKeywords(string $attribute): string - { - if ($attribute == 'productTitle') { - return $this->getOwner()->title ?? ''; - } - - return parent::getSearchKeywords($attribute); - } - - public function defineRules(): array - { - return array_merge(parent::defineRules(), [ - [['sku'], 'string', 'max' => 255], - [['sku'], 'required', 'on' => self::SCENARIO_LIVE], - [['basePrice'], 'validatePrice', 'on' => self::SCENARIO_LIVE, 'skipOnEmpty' => false], - [['price', 'weight', 'width', 'height', 'length'], 'number'], - // maxQty must be greater than minQty and minQty must be less than maxQty - [['minQty'], 'validateMinQtyRange', 'skipOnEmpty' => true], - [['maxQty'], 'validateMaxQtyRange', 'skipOnEmpty' => true], - [['stock', 'fieldId', 'ownerId', 'primaryOwnerId'], 'number'], - [['ownerId', 'primaryOwnerId', 'isDefault', 'deletedWithProduct'], 'safe'], - ]); - } - - /** - * @param string $attribute - * @param $params - * @param Validator $validator - */ - public function validatePrice(string $attribute, $params, Validator $validator): void - { - if ($this->$attribute === null) { - $message = Craft::t('yii', '{attribute} cannot be blank.', ['attribute' => $this->getAttributeLabel('price')]); - $validator->addError($this, 'price', $message); - } - } - - /** - * @inheritdoc - */ - protected function availableShippingCategories(): array - { - $allAvailableShippingCategories = parent::availableShippingCategories(); - - $productTypeId = $this->getPrimaryOwner()?->getType()->id; - - if (!$productTypeId) { - return [Plugin::getInstance()->getShippingCategories()->getDefaultShippingCategory($this->storeId)]; - } - - // Limit to only those for this product type - $categoryIds = collect(Plugin::getInstance()->getShippingCategories()->getShippingCategoriesByProductTypeId($productTypeId))->pluck('id')->toArray(); - $available = collect($allAvailableShippingCategories)->filter(fn(ShippingCategory $category) => in_array($category->id, $categoryIds)); - - if ($available->isEmpty()) { - return [Plugin::getInstance()->getShippingCategories()->getDefaultShippingCategory($this->storeId)]; - } - - return $available->toArray(); - } - - /** - * @inheritdoc - */ - protected function availableTaxCategories(): array - { - $allAvailableTaxCategories = parent::availableTaxCategories(); - - $productTypeId = $this->getPrimaryOwner()?->getType()->id; - - if (!$productTypeId) { - return [Plugin::getInstance()->getTaxCategories()->getDefaultTaxCategory()]; - } - - // Limit to only those for this product type - $categoryIds = collect(Plugin::getInstance()->getTaxCategories()->getTaxCategoriesByProductTypeId($productTypeId))->pluck('id')->toArray(); - $available = collect($allAvailableTaxCategories)->filter(fn(TaxCategory $category) => in_array($category->id, $categoryIds)); - - if ($available->isEmpty()) { - return [Plugin::getInstance()->getTaxCategories()->getDefaultTaxCategory()]; - } - - return $available->toArray(); - } - - /** - * @inheritdoc - */ - protected static function defineSources(string $context = null): array - { - $sources = Product::defineSources($context); - - // Ensure we don't inherit any product structure things from products. - foreach ($sources as $key => $source) { - $sources[$key]['defaultSort'] = ['postDate', 'desc']; - foreach (['structureId', 'structureEditable'] as $unsetKey) { - if (isset($sources[$key][$unsetKey])) { - unset($sources[$key][$unsetKey]); - } - } - } - - return $sources; - } - - protected static function defineActions(string $source): array - { - $actions = parent::defineActions($source); - // Restore - $actions[] = Craft::$app->getElements()->createAction([ - 'type' => Restore::class, - 'successMessage' => Craft::t('commerce', 'Variants restored.'), - 'partialSuccessMessage' => Craft::t('commerce', 'Some variants restored.'), - 'failMessage' => Craft::t('commerce', 'Variants not restored.'), - ]); - - if ($source === '__IMP__') { - $actions[] = ['type' => SetDefaultVariant::class]; - } - - // In case they are not running Craft 5.7+ - if (class_exists(Copy::class)) { - $actions[] = ['type' => Copy::class]; - } - - return $actions; - } - - /** - * @inheritdoc - */ - protected static function defineTableAttributes(): array - { - return array_merge(parent::defineTableAttributes(), [ - 'product' => Craft::t('commerce', 'Product'), - 'isDefault' => Craft::t('commerce', 'Default'), - 'promotable' => Craft::t('commerce', 'Promotable'), - ]); - } - - /** - * @inheritdoc - */ - protected static function defineDefaultTableAttributes(string $source): array - { - // Only add product as a `product` if we are viewing an implicit table - if ($source !== "__IMP__") { - $extras[] = 'product'; - } - $extras = ['isDefault']; - - return [...parent::defineDefaultTableAttributes($source), ...$extras]; - } - - /** - * @inheritdoc - */ - protected static function defineSearchableAttributes(): array - { - return [...parent::defineSearchableAttributes(), ...['productTitle']]; - } - - /** - * @inheritdoc - */ - protected static function defineCardAttributes(): array - { - return array_merge(parent::defineCardAttributes(), [ - 'product' => [ - 'label' => Craft::t('commerce', 'Product'), - ], - 'isDefault' => [ - 'label' => Craft::t('commerce', 'Default'), - ], - 'promotable' => [ - 'label' => Craft::t('commerce', 'Promotable'), - ], - ]); - } - - /** - * @inheritdoc - */ - protected function attributeHtml(string $attribute): string - { - if ($attribute === 'product') { - $product = $this->getOwner(); - if (!$product) { - return ''; - } - - return sprintf(' %s', $product->getStatus(), Html::encode($product->title)); - } - - if ($attribute === 'isDefault') { - if ($this->isDefault) { - $isDefault = Html::tag('span', '', [ - 'class' => 'checkbox-icon', - 'role' => 'img', - 'title' => Craft::t('app', 'Enabled'), - 'aria' => [ - 'label' => Craft::t('app', 'Enabled'), - ], - ]); - return $isDefault . Html::tag('span', ' ' . Craft::t('commerce', 'Default'), [ - 'class' => 'card-only-label', - 'style' => 'display:none;', - ]) . Html::tag('style', '.card-content .card-only-label { display: inline !important; }'); - } - } - - if ($attribute === 'promotable') { - if ($this->promotable) { - $promotable = Html::tag('span', '', [ - 'class' => 'checkbox-icon', - 'role' => 'img', - 'title' => Craft::t('app', 'Enabled'), - 'aria' => [ - 'label' => Craft::t('app', 'Enabled'), - ], - ]); - return $promotable . Html::tag('span', ' ' . Craft::t('commerce', 'Promotable'), [ - 'class' => 'card-only-label', - 'style' => 'display:none;', - ]) . Html::tag('style', '.card-content .card-only-label { display: inline !important; }'); - } - } - - return parent::attributeHtml($attribute); - } - - /** - * @inheritdoc - */ - protected function ownerType(): ?string - { - return Product::class; - } -} diff --git a/src/elements/actions/CopyLoadCartUrl.php b/src/elements/actions/CopyLoadCartUrl.php deleted file mode 100644 index 23486ce695..0000000000 --- a/src/elements/actions/CopyLoadCartUrl.php +++ /dev/null @@ -1,71 +0,0 @@ - - * @since 3.3 - * - * @property-read null $triggerHtml - * @property-read string $triggerLabel - */ -class CopyLoadCartUrl extends ElementAction -{ - // Public Methods - // ========================================================================= - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return Craft::t('commerce', 'Share cart…'); - } - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string - { - $type = Json::encode(static::class); - $actionUrl = Json::encode(UrlHelper::actionUrl('commerce/orders/get-load-cart-url')); - - $jsTemplate = <<<'JS' -(() => { - new Craft.ElementActionTrigger({ - type: %s, - batch: false, - validateSelection: function($selectedItems) - { - return !!$selectedItems.find('.element').data('number'); - }, - activate: function($selectedItems) - { - var number = $selectedItems.find('.element').data('number'); - Craft.sendActionRequest('GET', %s, {params: {number: number}}).then(function(response) { - Craft.ui.createCopyTextPrompt({ - label: Craft.t('commerce', 'Copy the URL'), - instructions: Craft.t('commerce', "This URL will load the cart into the user's session, making it the active cart."), - value: response.data.url, - }); - }); - } - }); -})(); -JS; - Craft::$app->getView()->registerJs(sprintf($jsTemplate, $type, $actionUrl)); - return null; - } -} diff --git a/src/elements/actions/CreateDiscount.php b/src/elements/actions/CreateDiscount.php deleted file mode 100644 index 05a1560e18..0000000000 --- a/src/elements/actions/CreateDiscount.php +++ /dev/null @@ -1,60 +0,0 @@ - - * @since 2.0 - */ -class CreateDiscount extends ElementAction -{ - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return Craft::t('commerce', 'Create discount…'); - } - - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string - { - $currentStore = Plugin::getInstance()->getStores()->getCurrentStore(); - $type = Json::encode(static::class); - $url = Json::encode('commerce/store-management/' . $currentStore->handle . '/discounts/new'); - $js = <<getView()->registerJs($js); - - return null; - } -} diff --git a/src/elements/actions/CreateSale.php b/src/elements/actions/CreateSale.php deleted file mode 100644 index a2aa94d78b..0000000000 --- a/src/elements/actions/CreateSale.php +++ /dev/null @@ -1,59 +0,0 @@ - - * @since 2.0 - */ -class CreateSale extends ElementAction -{ - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return Craft::t('commerce', 'Create sale…'); - } - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string - { - $currentStore = Plugin::getInstance()->getStores()->getCurrentStore(); - $type = Json::encode(static::class); - $url = Json::encode('commerce/store-management/' . $currentStore->handle . '/sales/new'); - $js = <<getView()->registerJs($js); - - return null; - } -} diff --git a/src/elements/actions/DownloadOrderPdfAction.php b/src/elements/actions/DownloadOrderPdfAction.php deleted file mode 100644 index bc77056c63..0000000000 --- a/src/elements/actions/DownloadOrderPdfAction.php +++ /dev/null @@ -1,202 +0,0 @@ - - * @since 3.2 - */ -class DownloadOrderPdfAction extends ElementAction -{ - public const TYPE_ZIP_ARCHIVE = 'zipArchive'; - public const TYPE_PDF_COLLATED = 'pdfCollated'; - - /** - * @inheritdoc - */ - public static function isDownload(): bool - { - return true; - } - - /** - * @var int|null - */ - public ?int $pdfId = null; - - /** - * @var string - */ - public string $downloadType = 'pdfCollated'; - - /** - * @var int|null - * @since 5.0.0 - */ - public ?int $storeId = null; - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return Craft::t('commerce', 'Download PDF'); - } - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string - { - if ($this->storeId === null) { - return ''; - } - - $allPdfs = Plugin::getInstance()->getPdfs()->getAllEnabledPdfs($this->storeId); - - $pdfs = []; - foreach ($allPdfs as $pdf) { - $pdfs[] = ['label' => Craft::t('site', $pdf->name), 'value' => $pdf->id]; - } - $pdfOptions = Json::encode($pdfs); - - $typeOptions = Json::encode([ - ['label' => Craft::t('commerce', 'ZIP file'), 'value' => self::TYPE_ZIP_ARCHIVE], - ['label' => Craft::t('commerce', 'Collated PDF'), 'value' => self::TYPE_PDF_COLLATED], - ]); - - $action = Json::encode(static::class); - - if (count($allPdfs) > 0) { - $js = << { - new Craft.Commerce.DownloadOrderPdfAction($('#download-order-pdf'), $pdfOptions, $typeOptions, $action); -})(); -JS; - Craft::$app->getView()->registerJs($js); - return Craft::$app->getView()->renderTemplate('commerce/_components/elementactions/DownloadOrderPdf/trigger'); - } - - return ''; - } - - /** - * @inheritdoc - * @throws Exception - * @throws HttpException - * @throws InvalidConfigException - * @throws RangeNotSatisfiableHttpException - * @throws Throwable - */ - public function performAction(ElementQueryInterface $query): bool - { - if ($this->storeId === null) { - throw new InvalidConfigException('Invalid store ID'); - } - - $pdfsService = Plugin::getInstance()->getPdfs(); - - $pdfId = $this->pdfId; - if ($pdfId === null) { - throw new InvalidConfigException("Invalid PDF ID"); - } - - $pdf = $pdfsService->getPdfById($pdfId, $this->storeId); - - if (!$pdf) { - throw new InvalidConfigException("Invalid PDF ID: '" . $pdfId . "'"); - } - - /** @var Order[] $orders */ - $orders = $query->all(); - - if (empty($orders)) { - return false; - } - - $response = Craft::$app->getResponse(); - - // Only one order, download single PDF - if (count($orders) === 1 && $this->downloadType == self::TYPE_PDF_COLLATED) { - $order = reset($orders); - $renderedPdf = $pdfsService->renderPdfForOrder($order, '', null, [], $pdf); - $filename = $this->_pdfFileName($pdf, $order); - $response->sendContentAsFile($renderedPdf, $filename); - return true; - } - - // Download collated in single PDF file - $merger = new Merger(); - if ($this->downloadType == self::TYPE_PDF_COLLATED) { - foreach ($orders as $order) { - $renderedPdf = $pdfsService->renderPdfForOrder($order, '', null, [], $pdf); - $merger->addRaw($renderedPdf); - } - $mergedPdf = $merger->merge(); - $response->sendContentAsFile($mergedPdf, 'Orders.pdf'); - return true; - } - - // If it is not collated, then it is a zip request - $zip = new ZipArchive(); - $zipPath = Craft::$app->getPath()->getTempPath() . '/' . StringHelper::UUID() . '.zip'; - - if ($zip->open($zipPath, ZipArchive::CREATE) !== true) { - throw new Exception('Cannot create zip at ' . $zipPath); - } - - foreach ($orders as $order) { - $renderedPdf = $pdfsService->renderPdfForOrder($order, '', null, [], $pdf); - $filename = $this->_pdfFileName($pdf, $order); - $zip->addFromString($filename, $renderedPdf); - } - - $zip->close(); - Craft::$app->getResponse()->sendContentAsFile(file_get_contents($zipPath), 'Orders.zip'); - FileHelper::unlink($zipPath); - - return true; - } - - /** - * Returns a PDF’s file name - * - * @throws Exception - * @throws Throwable - */ - private function _pdfFileName(Pdf $pdf, Order $order): string - { - $fileName = Craft::$app->getView()->renderSandboxedObjectTemplate($pdf->fileNameFormat, $order); - if (!$fileName) { - $fileName = $pdf->handle . '-' . $order->number; - } - - return $fileName . '.pdf'; - } -} diff --git a/src/elements/actions/SetDefaultVariant.php b/src/elements/actions/SetDefaultVariant.php deleted file mode 100644 index 1aea16d4a7..0000000000 --- a/src/elements/actions/SetDefaultVariant.php +++ /dev/null @@ -1,113 +0,0 @@ - - * @since 5.0.0 - */ -class SetDefaultVariant extends ElementAction -{ - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return Craft::t('commerce', 'Set default variant'); - } - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string - { - $type = Json::encode(static::class); - - $js = <<getView()->registerJs($js); - - return null; - } - - /** - * @inheritdoc - */ - public function performAction(ElementQueryInterface $query): bool - { - /** @var Variant|null $variant */ - $variant = $query->one(); - if (!$variant) { - $this->setMessage(Craft::t('commerce', 'Unable to find variant.')); - return false; - } - - $product = $variant->getOwner(); - if (!$product) { - $this->setMessage(Craft::t('commerce', 'Variant has no product.')); - return false; - } - - // Update product row - Craft::$app->getDb()->createCommand()->update( - Table::PRODUCTS, - [ - 'defaultVariantId' => $variant->id, - 'defaultSku' => $variant->sku, - 'defaultPrice' => $variant->getBasePrice(), - 'defaultHeight' => $variant->height, - 'defaultLength' => $variant->length, - 'defaultWidth' => $variant->width, - 'defaultWeight' => $variant->weight, - ], - ['id' => $product->id] - )->execute(); - - if ($product->getIsCanonical()) { - // Remove previous default - Craft::$app->getDb()->createCommand()->update( - Table::VARIANTS, - ['isDefault' => false], - ['primaryOwnerId' => $product->id] - )->execute(); - - // Add new default - Craft::$app->getDb()->createCommand()->update( - Table::VARIANTS, - ['isDefault' => true], - ['id' => $variant->id] - )->execute(); - } - - Craft::$app->getElements()->invalidateCachesForElement($product); - Craft::$app->getElements()->invalidateCachesForElement($variant); - - $this->setMessage(Craft::t('commerce', 'Default variant updated.')); - return true; - } -} diff --git a/src/elements/actions/UpdateOrderStatus.php b/src/elements/actions/UpdateOrderStatus.php deleted file mode 100644 index 4a37b32ba9..0000000000 --- a/src/elements/actions/UpdateOrderStatus.php +++ /dev/null @@ -1,142 +0,0 @@ - - * @since 2.0 - */ -class UpdateOrderStatus extends ElementAction -{ - /** - * @var int|null - */ - public ?int $orderStatusId = null; - - /** - * @var string - */ - public string $message = ''; - - /** - * @var bool Whether to suppress the sending of related order status emails - */ - public bool $suppressEmails = false; - - /** - * @inheritdoc - */ - public function getTriggerLabel(): string - { - return Craft::t('commerce', 'Update Order Status…'); - } - - /** - * @inheritdoc - */ - public function getTriggerHtml(): ?string - { - /** @var Site|StoreBehavior $cpSite */ - $cpSite = Cp::requestedSite(); - $orderStatuses = Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses($cpSite->getStore()->id) - ->map(function(OrderStatus $orderStatus) { - // Encode for output in JS - $orderStatus->name = Html::encode($orderStatus->name); - $orderStatus->color = Html::encode($orderStatus->color); - $orderStatus->description = Html::encode($orderStatus->description); - - return $orderStatus; - }); - - $orderStatuses = Json::encode(array_values($orderStatuses->all())); - $type = Json::encode(static::class); - - $js = <<getView()->registerJs($js); - - return null; - } - - /** - * @inheritdoc - */ - public function performAction(ElementQueryInterface $query): bool - { - $orders = $query->all(); - $orderCount = count($orders); - - $failureCount = 0; - foreach ($orders as $order) { - /** @var Order $order */ - $order->orderStatusId = $this->orderStatusId; - $order->message = $this->message; - $order->suppressEmails = $this->suppressEmails; - if (!Craft::$app->getElements()->saveElement($order)) { - $failureCount++; - } - } - - if ($failureCount > 0) { - $message = Craft::t('commerce', 'Failed updating order status on {num, plural, =1{order} other{orders}}.', ['num' => $failureCount]); - if ($orderCount === $failureCount) { - $message = Craft::t('commerce', 'Failed to update {num, plural, =1{order status} other{order statuses}}.', ['num' => $failureCount]); - } - - $this->setMessage($message); - return false; - } - - $this->setMessage(Craft::t('commerce', '{num, plural, =1{Order} other{Orders}} updated.', ['num' => $orderCount])); - - return true; - } -} diff --git a/src/elements/conditions/addresses/DiscountAddressCondition.php b/src/elements/conditions/addresses/DiscountAddressCondition.php deleted file mode 100644 index 536cd182dd..0000000000 --- a/src/elements/conditions/addresses/DiscountAddressCondition.php +++ /dev/null @@ -1,43 +0,0 @@ - - * @since 4.0.0 - */ -class DiscountAddressCondition extends ElementAddressCondition -{ - /** - * @inheritdoc - */ - public ?string $elementType = Address::class; - - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - return array_merge(parent::selectableConditionRules(), - [ - PostalCodeFormulaConditionRule::class, - ]); - } - - /** - * @param ElementQueryInterface $query - * @return void - * @throws NotSupportedException - */ - public function modifyQuery(ElementQueryInterface $query): void - { - throw new NotSupportedException('Discount Address Condition does not support element queries.'); - } -} diff --git a/src/elements/conditions/addresses/GatewayAddressCondition.php b/src/elements/conditions/addresses/GatewayAddressCondition.php deleted file mode 100644 index 857be5cdb6..0000000000 --- a/src/elements/conditions/addresses/GatewayAddressCondition.php +++ /dev/null @@ -1,28 +0,0 @@ - - * @since 5.5 - */ -class GatewayAddressCondition extends AddressCondition -{ - public function getBuilderHtml($readOnly = false): string - { - if ($readOnly) { - return Html::disableInputs(fn() => parent::getBuilderHtml()); - } - return parent::getBuilderHtml(); - } -} diff --git a/src/elements/conditions/addresses/PostalCodeFormulaConditionRule.php b/src/elements/conditions/addresses/PostalCodeFormulaConditionRule.php deleted file mode 100644 index 5edef57cac..0000000000 --- a/src/elements/conditions/addresses/PostalCodeFormulaConditionRule.php +++ /dev/null @@ -1,88 +0,0 @@ - - * @since 4.0.0 - * - */ -class PostalCodeFormulaConditionRule extends BaseTextConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Postal Code Formula'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return []; - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - throw new NotSupportedException('Discount Address Condition does not support element queries.'); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Address $address */ - $address = $element; - $formulasService = Plugin::getInstance()->getFormulas(); - $formula = $this->value; - $postalCode = $address->postalCode; - - try { - return (bool)$formulasService->evaluateCondition($formula, ['postalCode' => $postalCode], 'Postal code formula matching address'); - } catch (\Throwable) { - Craft::error('Error evaluating postal code formula: ' . $formula,'commerce'); - return false; - } - } - - public function operators(): array - { - return [ - self::OPERATOR_EQ, - ]; - } - - public function inputHtml(): string - { - return Html::hiddenLabel($this->getLabel(), 'value') . - Cp::textareaHtml([ - 'type' => $this->inputType(), - 'id' => 'value', - 'name' => 'value', - 'code' => 'value', - 'value' => $this->value, - 'autocomplete' => false, - 'class' => 'fullwidth code', - ]); - } -} diff --git a/src/elements/conditions/addresses/ZoneAddressCondition.php b/src/elements/conditions/addresses/ZoneAddressCondition.php deleted file mode 100644 index 0dc33e7fab..0000000000 --- a/src/elements/conditions/addresses/ZoneAddressCondition.php +++ /dev/null @@ -1,43 +0,0 @@ - - * @since 4.0.0 - */ -class ZoneAddressCondition extends ElementAddressCondition -{ - /** - * @inheritdoc - */ - public ?string $elementType = Address::class; - - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - return array_merge(parent::selectableConditionRules(), - [ - PostalCodeFormulaConditionRule::class, - ]); - } - - /** - * @param ElementQueryInterface $query - * @return void - * @throws NotSupportedException - */ - public function modifyQuery(ElementQueryInterface $query): void - { - throw new NotSupportedException('Discount Address Condition does not support element queries.'); - } -} diff --git a/src/elements/conditions/customers/CatalogPricingRuleCustomerCondition.php b/src/elements/conditions/customers/CatalogPricingRuleCustomerCondition.php deleted file mode 100644 index f45f1767cd..0000000000 --- a/src/elements/conditions/customers/CatalogPricingRuleCustomerCondition.php +++ /dev/null @@ -1,40 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricingRuleCustomerCondition extends UserCondition -{ - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - return array_merge( - array_filter(parent::selectableConditionRules(), static fn($type) => !in_array($type, [ - // Remove rules that don't make sense in this context - LastLoginDateConditionRule::class, - SiteConditionRule::class, - ], true) - ), - // Add additional rules - [ - CatalogPricingRuleCustomerConditionRule::class, - ] - ); - } -} diff --git a/src/elements/conditions/customers/CatalogPricingRuleCustomerConditionRule.php b/src/elements/conditions/customers/CatalogPricingRuleCustomerConditionRule.php deleted file mode 100644 index 57b5b2700b..0000000000 --- a/src/elements/conditions/customers/CatalogPricingRuleCustomerConditionRule.php +++ /dev/null @@ -1,75 +0,0 @@ - - * @since 5.5.0 - */ -class CatalogPricingRuleCustomerConditionRule extends BaseElementSelectConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - protected function elementType(): string - { - return User::class; - } - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Customer'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['id']; - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - /** @var UserQuery $query */ - $query->id($this->getElementIds()); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - /** @var User $element */ - return $this->matchValue($element->getId()); - } - - /** - * @inheritdoc - */ - protected function allowMultiple(): bool - { - return true; - } -} diff --git a/src/elements/conditions/customers/DiscountCustomerCondition.php b/src/elements/conditions/customers/DiscountCustomerCondition.php deleted file mode 100644 index a43437e0e1..0000000000 --- a/src/elements/conditions/customers/DiscountCustomerCondition.php +++ /dev/null @@ -1,33 +0,0 @@ - - * @since 4.0.0 - */ -class DiscountCustomerCondition extends UserElementCondition -{ - /** - * @inheritdoc - */ - public ?string $elementType = User::class; - - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - return array_merge(parent::selectableConditionRules(), [ - HasOrdersConditionRule::class, - SignedInConditionRule::class, - DiscountGroupConditionRule::class, - ]); - } -} diff --git a/src/elements/conditions/customers/HasOrdersConditionRule.php b/src/elements/conditions/customers/HasOrdersConditionRule.php deleted file mode 100644 index dee24165c2..0000000000 --- a/src/elements/conditions/customers/HasOrdersConditionRule.php +++ /dev/null @@ -1,172 +0,0 @@ - - * @since 4.2.0 - * - * @property null|array|OrderCondition $orderCondition - */ -class HasOrdersConditionRule extends BaseNumberConditionRule implements ElementConditionRuleInterface -{ - /** - * @var array|OrderCondition|null - */ - private OrderCondition|array|null $_orderCondition = null; - - /** - * @var array - */ - private static array $_orderConditionResults = []; - - public function getConfig(): array - { - return array_merge(parent::getConfig(), [ - 'orderCondition' => $this->getOrderCondition()->getConfig(), - ]); - } - - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['orderCondition'], 'safe']; - - return $rules; - } - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Has Orders'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['hasOrders']; - } - - /** - * @param ElementQueryInterface $query - * @return void - * @throws NotSupportedException - */ - public function modifyQuery(ElementQueryInterface $query): void - { - throw new NotSupportedException('Has orders condition rule does not support queries'); - } - - /** - * @return string - * @throws InvalidConfigException - */ - public function getHtml(): string - { - $html = Html::label(Craft::t('commerce', 'Total Orders'), options: [ - 'style' => [ - 'padding-top' => '0.25rem', - 'padding-bottom' => '0.5rem', - 'font-weight' => 'bold', - 'color' => '#596673', - 'display' => 'block', - ], - ]); - $html .= parent::getHtml(); - $html .= Html::tag('div', Craft::t('commerce', 'Match Orders'), [ - 'style' => [ - 'margin-top' => '1rem', - 'font-weight' => 'bold', - 'color' => '#596673', - ], - ]); - $html .= Html::tag('div', $this->getOrderCondition()->getBuilderHtml(), ['style' => ['margin-top' => '0.5rem']]); - - return $html; - } - - /** - * @param ElementInterface $element - * @return bool - * @throws InvalidConfigException - */ - public function matchElement(ElementInterface $element): bool - { - $orderQuery = Order::find()->customerId($element->id); - $this->getOrderCondition()->modifyQuery($orderQuery); - $key = md5(implode('||', [ - $element->id, - Json::encode($this), - Json::encode($orderQuery), - ])); - - if (!isset(self::$_orderConditionResults[$key])) { - self::$_orderConditionResults[$key] = $this->matchValue($orderQuery->count()); - } - - return self::$_orderConditionResults[$key]; - } - - /** - * @return OrderCondition - * @throws InvalidConfigException - */ - public function getOrderCondition(): OrderCondition - { - if ($this->_orderCondition === null) { - $this->_orderCondition = Craft::$app->getConditions()->createCondition(['class' => OrderCondition::class]); - - // Set default rules - /** @var CompletedConditionRule $completedConditionRule */ - $completedConditionRule = Craft::$app->getConditions()->createConditionRule([ - 'class' => CompletedConditionRule::class, - ]); - $completedConditionRule->value = true; - - $this->_orderCondition->addConditionRule($completedConditionRule); - } elseif (is_array($this->_orderCondition)) { - /** @var OrderCondition $orderCondition */ - $orderCondition = Craft::$app->getConditions()->createCondition($this->_orderCondition); - $this->_orderCondition = $orderCondition; - } - - $this->_orderCondition->id = 'hasOrdersOrderCondition'; - $this->_orderCondition->mainTag = 'div'; - $this->_orderCondition->name = 'orderCondition'; - // Exclude unwanted condition rules - $this->_orderCondition->queryParams = ['customerId']; - return $this->_orderCondition; - } - - /** - * @param OrderCondition|array|null $condition - */ - public function setOrderCondition(OrderCondition|array|null $condition): void - { - $this->_orderCondition = $condition; - } -} diff --git a/src/elements/conditions/customers/ShippingMethodCustomerCondition.php b/src/elements/conditions/customers/ShippingMethodCustomerCondition.php deleted file mode 100644 index b95bcacc43..0000000000 --- a/src/elements/conditions/customers/ShippingMethodCustomerCondition.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 5.4.0 - */ -class ShippingMethodCustomerCondition extends UserCondition -{ -} diff --git a/src/elements/conditions/customers/ShippingRuleCustomerCondition.php b/src/elements/conditions/customers/ShippingRuleCustomerCondition.php deleted file mode 100644 index 93b2a70654..0000000000 --- a/src/elements/conditions/customers/ShippingRuleCustomerCondition.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 5.4.0 - */ -class ShippingRuleCustomerCondition extends UserCondition -{ -} diff --git a/src/elements/conditions/customers/SignedInConditionRule.php b/src/elements/conditions/customers/SignedInConditionRule.php deleted file mode 100644 index 0ca4c9ce66..0000000000 --- a/src/elements/conditions/customers/SignedInConditionRule.php +++ /dev/null @@ -1,85 +0,0 @@ - - * @since 4.2.6 - * - * @property null|array|OrderCondition $orderCondition - */ -class SignedInConditionRule extends BaseLightswitchConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Signed In'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return []; - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - throw new NotSupportedException('Signed in condition rule does not support element queries.'); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - /** @var User $element */ - $currentUser = Craft::$app->getUser()->getIdentity(); - $isStoreAdministrator = $currentUser && $currentUser->can('accessCp') && $currentUser->can('commerce-editOrders'); - - // If the current user is a store admin, and they are editing an order - if ($isStoreAdministrator) { - if ($this->value && $element->getIsCredentialed()) { - return true; - } - - if (!$this->value && !$element->getIsCredentialed()) { - return true; - } - - return false; - } - - if (!$this->value && !$currentUser) { - return true; - } - - if ($this->value && $currentUser && $currentUser->id === $element->id) { - return true; - } - - return false; - } -} diff --git a/src/elements/conditions/orders/CompletedConditionRule.php b/src/elements/conditions/orders/CompletedConditionRule.php deleted file mode 100644 index fb028ef4d6..0000000000 --- a/src/elements/conditions/orders/CompletedConditionRule.php +++ /dev/null @@ -1,58 +0,0 @@ - - * @since 4.2.0 - */ -class CompletedConditionRule extends BaseLightswitchConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Completed'); - } - - public function getExclusiveQueryParams(): array - { - return ['isCompleted']; - } - - /** - * @param ElementQueryInterface $query - * @return void - */ - public function modifyQuery(ElementQueryInterface $query): void - { - /** @var OrderQuery $query */ - $query->isCompleted($this->value); - } - - /** - * @param ElementInterface $element - * @return bool - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Order $element */ - return $element->isCompleted === $this->value; - } -} diff --git a/src/elements/conditions/orders/ContainsPurchasablesConditionRule.php b/src/elements/conditions/orders/ContainsPurchasablesConditionRule.php deleted file mode 100644 index bc293b89d3..0000000000 --- a/src/elements/conditions/orders/ContainsPurchasablesConditionRule.php +++ /dev/null @@ -1,223 +0,0 @@ - - * @since 5.7.0 - * - * @method array|string|null paramValue(?callable $normalizeValue = null) - */ -class ContainsPurchasablesConditionRule extends BaseElementSelectConditionRule implements ElementConditionRuleInterface -{ - /** - * @var string - */ - public string $purchasableType = Variant::class; - - /** - * @var ContainsPurchasablesMatch - * @see getMatch() - * @see setMatch() - */ - private ContainsPurchasablesMatch $_match = ContainsPurchasablesMatch::Any; - - /** - * @return ContainsPurchasablesMatch - */ - public function getMatch(): ContainsPurchasablesMatch - { - return $this->_match; - } - - /** - * Yii2 setter — converts stored string values back to the enum on load. - */ - public function setMatch(ContainsPurchasablesMatch|string $value): void - { - $this->_match = $value instanceof ContainsPurchasablesMatch ? $value : ContainsPurchasablesMatch::from($value); - } - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Contains Purchasables'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['hasPurchasable']; - } - - /** - * @inheritdoc - */ - protected function elementType(): string - { - return $this->purchasableType; - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - $ids = $this->getElementIds(); - if (empty($ids)) { - return; - } - - /** @var OrderQuery $query */ - $query->containsPurchasables(['purchasables' => $ids, 'match' => $this->getMatch()]); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Order $element */ - return $element->hasPurchasables($this->getElementIds(), $this->getMatch()); - } - - /** - * @inheritdoc - */ - protected function allowMultiple(): bool - { - return true; - } - - public function getConfig(): array - { - return array_merge(parent::getConfig(), [ - 'purchasableType' => $this->purchasableType, - 'match' => $this->getMatch()->value, - ]); - } - - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['purchasableType', 'match'], 'safe']; - - return $rules; - } - - /** - * @inheritdoc - */ - protected function inputHtml(): string - { - $matchId = 'match'; - $purchasableTypeOptions = $this->_purchasableTypeOptions(); - - $purchasableTypeHtml = count($purchasableTypeOptions) === 1 - ? Html::hiddenInput('purchasableType', $purchasableTypeOptions[0]['value']) - : Cp::selectHtml([ - 'id' => 'purchasable-type', - 'name' => 'purchasableType', - 'options' => $purchasableTypeOptions, - 'value' => $this->purchasableType, - 'inputAttributes' => [ - 'hx' => [ - 'post' => UrlHelper::actionUrl('conditions/render'), - ], - ], - ]); - - return Html::hiddenLabel($this->getLabel(), $matchId) . - Html::tag('div', - Cp::selectHtml([ - 'id' => $matchId, - 'name' => 'match', - 'options' => $this->_matchOptions(), - 'value' => $this->getMatch()->value, - 'inputAttributes' => [ - 'hx' => [ - 'post' => UrlHelper::actionUrl('conditions/render'), - ], - ], - ]) . - $purchasableTypeHtml . - parent::inputHtml(), - [ - 'class' => ['flex', 'flex-start'], - ] - ); - } - - protected function selectionCondition(): ?ElementConditionInterface - { - return Craft::$app->getConditions()->createCondition(['class' => OrderCondition::class]); - } - - /** - * @return array - * @throws InvalidConfigException - */ - private function _purchasableTypeOptions(): array - { - $options = []; - - foreach (Plugin::getInstance()->getPurchasables()->getAllPurchasableElementTypes() as $elementType) { - /** @var string|ElementInterface $elementType */ - /** @phpstan-var class-string|ElementInterface $elementType */ - $options[] = [ - 'value' => $elementType, - 'label' => $elementType::displayName(), - ]; - } - - return $options; - } - - /** - * @return array - */ - private function _matchOptions(): array - { - return array_map( - fn(ContainsPurchasablesMatch $m) => ['value' => $m->value, 'label' => $m->label()], - ContainsPurchasablesMatch::cases() - ); - } - - /** - * @inheritdoc - */ - protected function elementSelectConfig(): array - { - return array_merge(parent::elementSelectConfig(), [ - 'showSiteMenu' => true, - ]); - } -} diff --git a/src/elements/conditions/orders/CouponCodeConditionRule.php b/src/elements/conditions/orders/CouponCodeConditionRule.php deleted file mode 100644 index a6c231899f..0000000000 --- a/src/elements/conditions/orders/CouponCodeConditionRule.php +++ /dev/null @@ -1,57 +0,0 @@ - - * @since 5.3.0 - */ -class CouponCodeConditionRule extends OrderTextValuesAttributeConditionRule -{ - public string $orderAttribute = 'couponCode'; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Coupon Code'); - } - - /** - * @inheritdoc - */ - protected function matchValue(mixed $value): bool - { - switch ($this->operator) { - case self::OPERATOR_EMPTY: - return !$value; - case self::OPERATOR_NOT_EMPTY: - return (bool)$value; - } - - if ($this->value === '') { - return true; - } - - return match ($this->operator) { - self::OPERATOR_EQ => strcasecmp($value, $this->value) === 0, - self::OPERATOR_NE => strcasecmp($value, $this->value) !== 0, - self::OPERATOR_BEGINS_WITH => is_string($value) && StringHelper::startsWith($value, $this->value, false), - self::OPERATOR_ENDS_WITH => is_string($value) && StringHelper::endsWith($value, $this->value, false), - self::OPERATOR_CONTAINS => is_string($value) && StringHelper::contains($value, $this->value, false), - default => throw new InvalidConfigException("Invalid operator: $this->operator"), - }; - } -} diff --git a/src/elements/conditions/orders/CustomerConditionRule.php b/src/elements/conditions/orders/CustomerConditionRule.php deleted file mode 100644 index 915c14d2ff..0000000000 --- a/src/elements/conditions/orders/CustomerConditionRule.php +++ /dev/null @@ -1,104 +0,0 @@ - - * @since 4.2.0 - * @todo Switch parent class to `BaseElementSelectConditionRule` in Commerce 6.0 once it supports negative matching (it currently lacks `OPERATOR_NOT_IN` support that this rule needs) - */ -class CustomerConditionRule extends BaseMultiSelectConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Customer'); - } - - /** - * @return array - * @deprecated in 4.3.1. - */ - protected function options(): array - { - return User::find() - ->status(null) - ->limit(null) - ->indexBy('id') - ->collect() - ->map(fn(User $customer) => $customer->fullName ? sprintf('%s (%s)', $customer->fullName, $customer->email) : $customer->email) - ->all(); - } - - /** - * @inheritDoc - */ - protected function inputHtml(): string - { - $users = User::find()->status(null)->limit(null)->id($this->values)->all(); - - return Cp::elementSelectHtml([ - 'name' => 'values', - 'elements' => $users, - 'elementType' => User::class, - 'sources' => null, - 'criteria' => null, - 'condition' => null, - 'single' => false, - ]); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['customerId']; - } - - /** - * @throws InvalidConfigException - */ - public function modifyQuery(ElementQueryInterface $query): void - { - /** @var OrderQuery $query */ - $paramValue = $this->paramValue(); - if ($this->operator === self::OPERATOR_NOT_IN) { - // Account for the fact the querying using a combination of `not` and `in` doesn't match `null` in the column - $query->andWhere(Db::parseParam(new Expression('coalesce([[commerce_orders.customerId]], -1)'), $paramValue)); - } else { - $query->customerId($paramValue); - } - } - - /** - * @throws InvalidConfigException - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Order $element */ - return $this->matchValue((string)$element->getCustomerId()); - } -} diff --git a/src/elements/conditions/orders/DateOrderedConditionRule.php b/src/elements/conditions/orders/DateOrderedConditionRule.php deleted file mode 100644 index eb791854e4..0000000000 --- a/src/elements/conditions/orders/DateOrderedConditionRule.php +++ /dev/null @@ -1,47 +0,0 @@ - - * @since 4.2.0 - */ -class DateOrderedConditionRule extends BaseDateRangeConditionRule implements ElementConditionRuleInterface -{ - public function getLabel(): string - { - return Craft::t('commerce', 'Date Ordered'); - } - - public function getExclusiveQueryParams(): array - { - return ['dateOrdered']; - } - - public function modifyQuery(ElementQueryInterface $query): void - { - /** @var OrderQuery $query */ - $query->dateOrdered($this->queryParamValue()); - } - - public function matchElement(ElementInterface $element): bool - { - /** @var Order $element */ - return $this->matchValue($element->dateOrdered); - } -} diff --git a/src/elements/conditions/orders/DiscountOrderCondition.php b/src/elements/conditions/orders/DiscountOrderCondition.php deleted file mode 100644 index 4b35288076..0000000000 --- a/src/elements/conditions/orders/DiscountOrderCondition.php +++ /dev/null @@ -1,62 +0,0 @@ - - * @since 4.0.0 - */ -class DiscountOrderCondition extends OrderCondition implements HasStoreInterface -{ - use StoreTrait; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['storeId'], 'safe']; - - return $rules; - } - - /** - * @return array - */ - protected function config(): array - { - return array_merge(parent::config(), $this->toArray(['storeId'])); - } - - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - $rules = array_merge(parent::selectableConditionRules(), []); - - // We don't need the condition to have the coupon code rule - ArrayHelper::removeValue($rules, CouponCodeConditionRule::class); - - return $rules; - } - - /** - * @param ElementQueryInterface $query - * @return void - * @throws NotSupportedException - */ - public function modifyQuery(ElementQueryInterface $query): void - { - throw new NotSupportedException('Discount Order Condition does not support element queries.'); - } -} diff --git a/src/elements/conditions/orders/DiscountedItemSubtotalConditionRule.php b/src/elements/conditions/orders/DiscountedItemSubtotalConditionRule.php deleted file mode 100644 index 5984f08bcd..0000000000 --- a/src/elements/conditions/orders/DiscountedItemSubtotalConditionRule.php +++ /dev/null @@ -1,64 +0,0 @@ - - * @since 5.0.0 - * - * @property-read float|int $orderAttributeValue - */ -class DiscountedItemSubtotalConditionRule extends OrderCurrencyValuesAttributeConditionRule -{ - public string $orderAttribute = 'itemSubtotal'; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Discounted Item Subtotal'); - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface|\yii\db\QueryInterface $query): void - { - throw new NotSupportedException('Discounted Item Subtotal condition rule does not support queries'); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Order $element */ - $discountAdjustments = []; - $discountAdjusters = Plugin::getInstance()->getOrderAdjustments()->getDiscountAdjusters(); - foreach ($discountAdjusters as $discountAdjuster) { - /** @var AdjusterInterface $discountAdjuster */ - $adjuster = new $discountAdjuster(); - $discountAdjustments = array_merge($discountAdjustments, $adjuster->adjust($element)); - } - - $discountAmount = 0; - foreach ($discountAdjustments as $adjustment) { - $discountAmount += $adjustment->amount; - } - - $itemTotal = $element->getItemSubtotal() + $discountAmount; - - return $this->matchValue($itemTotal); - } -} diff --git a/src/elements/conditions/orders/GatewayOrderCondition.php b/src/elements/conditions/orders/GatewayOrderCondition.php deleted file mode 100644 index 59ce77bed3..0000000000 --- a/src/elements/conditions/orders/GatewayOrderCondition.php +++ /dev/null @@ -1,28 +0,0 @@ - - * @since 5.4.0 - */ -class GatewayOrderCondition extends OrderCondition -{ - public function getBuilderHtml($readOnly = false): string - { - if ($readOnly) { - return Html::disableInputs(fn() => parent::getBuilderHtml()); - } - return parent::getBuilderHtml(); - } -} diff --git a/src/elements/conditions/orders/HasAdminNoticesConditionRule.php b/src/elements/conditions/orders/HasAdminNoticesConditionRule.php deleted file mode 100644 index cea7112ac1..0000000000 --- a/src/elements/conditions/orders/HasAdminNoticesConditionRule.php +++ /dev/null @@ -1,47 +0,0 @@ - - * @since 5.x - */ -class HasAdminNoticesConditionRule extends BaseLightswitchConditionRule implements ElementConditionRuleInterface -{ - public function getLabel(): string - { - return Craft::t('commerce', 'Has Admin Notices'); - } - - public function getExclusiveQueryParams(): array - { - return ['hasAdminNotices']; - } - - public function modifyQuery(ElementQueryInterface $query): void - { - /** @var OrderQuery $query */ - $query->hasAdminNotices($this->value); - } - - public function matchElement(ElementInterface $element): bool - { - /** @var Order $element */ - return $element->hasAdminNotices() === $this->value; - } -} diff --git a/src/elements/conditions/orders/HasPurchasableConditionRule.php b/src/elements/conditions/orders/HasPurchasableConditionRule.php deleted file mode 100644 index 247ed3ef2c..0000000000 --- a/src/elements/conditions/orders/HasPurchasableConditionRule.php +++ /dev/null @@ -1,164 +0,0 @@ - - * @since 4.2.0 - * - * @method array|string|null paramValue(?callable $normalizeValue = null) - */ -class HasPurchasableConditionRule extends BaseElementSelectConditionRule implements ElementConditionRuleInterface -{ - /** - * @var string - */ - public string $purchasableType = Variant::class; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Has Purchasable'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['hasPurchasable']; - } - - /** - * @inheritdoc - */ - protected function elementType(): string - { - return $this->purchasableType; - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - if ($this->getElementId() === null) { - return; - } - - /** @var OrderQuery $query */ - $query->hasPurchasables([$this->getElementId()]); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - return Order::find() - ->id($element->id) - ->hasPurchasables([$this->getElementId()]) - ->exists(); - } - - public function getConfig(): array - { - return array_merge(parent::getConfig(), [ - 'purchasableType' => $this->purchasableType, - ]); - } - - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['purchasableType'], 'safe']; - - return $rules; - } - - /** - * @inheritdoc - */ - protected function inputHtml(): string - { - $id = 'purchasable-type'; - return Html::hiddenLabel($this->getLabel(), $id) . - Html::tag('div', - Cp::selectHtml([ - 'id' => $id, - 'name' => 'purchasableType', - 'options' => $this->_purchasableTypeOptions(), - 'value' => $this->purchasableType, - 'inputAttributes' => [ - 'hx' => [ - 'post' => UrlHelper::actionUrl('conditions/render'), - ], - ], - ]) . - parent::inputHtml(), - [ - 'class' => ['flex', 'flex-start'], - ] - ); - } - - - protected function selectionCondition(): ?ElementConditionInterface - { - return Craft::$app->getConditions()->createCondition(['class' => OrderCondition::class]); - } - - /** - * @return array - * @throws InvalidConfigException - */ - private function _purchasableTypeOptions(): array - { - $options = []; - - foreach (Plugin::getInstance()->getPurchasables()->getAllPurchasableElementTypes() as $elementType) { - /** @var string|ElementInterface $elementType */ - /** @phpstan-var class-string|ElementInterface $elementType */ - $options[] = [ - 'value' => $elementType, - 'label' => $elementType::displayName(), - ]; - } - - return $options; - } - - /** - * @inerhitdoc - */ - protected function elementSelectConfig(): array - { - return array_merge(parent::elementSelectConfig(), [ - 'showSiteMenu' => true, - ]); - } -} diff --git a/src/elements/conditions/orders/ItemSubtotalConditionRule.php b/src/elements/conditions/orders/ItemSubtotalConditionRule.php deleted file mode 100644 index b574e78bc3..0000000000 --- a/src/elements/conditions/orders/ItemSubtotalConditionRule.php +++ /dev/null @@ -1,26 +0,0 @@ - - * @since 4.2.0 - * - * @property-read float|int $orderAttributeValue - */ -class ItemSubtotalConditionRule extends OrderCurrencyValuesAttributeConditionRule -{ - public string $orderAttribute = 'itemSubtotal'; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Item Subtotal'); - } -} diff --git a/src/elements/conditions/orders/ItemTotalConditionRule.php b/src/elements/conditions/orders/ItemTotalConditionRule.php deleted file mode 100644 index 3c6cbab538..0000000000 --- a/src/elements/conditions/orders/ItemTotalConditionRule.php +++ /dev/null @@ -1,26 +0,0 @@ - - * @since 4.2.0 - * - * @property-read float|int $orderAttributeValue - */ -class ItemTotalConditionRule extends OrderCurrencyValuesAttributeConditionRule -{ - public string $orderAttribute = 'itemTotal'; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Item Total'); - } -} diff --git a/src/elements/conditions/orders/OrderCondition.php b/src/elements/conditions/orders/OrderCondition.php deleted file mode 100644 index 9f38630eb7..0000000000 --- a/src/elements/conditions/orders/OrderCondition.php +++ /dev/null @@ -1,56 +0,0 @@ - - * @since 4.0.0 - */ -class OrderCondition extends ElementCondition -{ - /** - * @inheritdoc - */ - public ?string $elementType = Order::class; - - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - return array_merge(parent::selectableConditionRules(), [ - DateOrderedConditionRule::class, - CompletedConditionRule::class, - CouponCodeConditionRule::class, - CustomerConditionRule::class, - HasAdminNoticesConditionRule::class, - PaidConditionRule::class, - HasPurchasableConditionRule::class, - ContainsPurchasablesConditionRule::class, - ItemSubtotalConditionRule::class, - ItemTotalConditionRule::class, - OrderStatusConditionRule::class, - OrderSiteConditionRule::class, - PaymentGatewayConditionRule::class, - ReferenceConditionRule::class, - ShippingMethodConditionRule::class, - TotalDiscountConditionRule::class, - TotalPaidConditionRule::class, - TotalPriceConditionRule::class, - TotalQtyConditionRule::class, - TotalTaxConditionRule::class, - TotalConditionRule::class, - TotalWeightConditionRule::class, - ]); - } -} diff --git a/src/elements/conditions/orders/OrderCurrencyValuesAttributeConditionRule.php b/src/elements/conditions/orders/OrderCurrencyValuesAttributeConditionRule.php deleted file mode 100644 index da52e79b67..0000000000 --- a/src/elements/conditions/orders/OrderCurrencyValuesAttributeConditionRule.php +++ /dev/null @@ -1,130 +0,0 @@ - - * @since 4.2.0 - * - * @method ElementConditionInterface|HasStoreInterface getCondition() - * @property-read float|int $orderAttributeValue - */ -abstract class OrderCurrencyValuesAttributeConditionRule extends MoneyFieldConditionRule -{ - /** - * @var string - */ - public string $orderAttribute = ''; - - /** - * @var Currency|null - */ - public ?Currency $currency = null; - - /** - * @var int|null - */ - public ?int $subUnit = null; - - public function __construct($config = []) - { - $this->setFieldUid('not-applicable'); - parent::__construct($config); - } - - /** - * @inheritdoc - */ - public function getGroupLabel(): ?string - { - return null; - } - - /** - * @inheritdoc - */ - public function setCondition(ConditionInterface $condition): void - { - parent::setCondition($condition); - - if ($this->getCondition() instanceof HasStoreInterface) { - $this->currency = $this->getCondition()->getStore()->getCurrency(); - } else { - /** @var Site|StoreBehavior|null $currentSite */ - $currentSite = Craft::$app->getSites()->getCurrentSite(); - - if ($currentSite->getBehavior(StoreBehavior::class)) { - $this->currency = $currentSite?->getStore()->getCurrency(); - } - } - - if ($this->currency) { - $this->subUnit = Plugin::getInstance()->getCurrencies()->getSubunitFor($this->currency); - } - } - - /** - * @inheritdoc - */ - protected function field(): FieldInterface - { - // Mock a Money field - $field = new Money(); - $field->currency = $this->currency?->getCode() ?? $field->currency; - - return $field; - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return [$this->orderAttribute]; - } - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return 'Label not implemented'; - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - return $this->matchValue($element->{$this->orderAttribute}); - } - - /** - * @inheritdoc - */ - public function modifyQuery(QueryInterface $query): void - { - $query->{$this->orderAttribute}($this->paramValue()); - } -} diff --git a/src/elements/conditions/orders/OrderSiteConditionRule.php b/src/elements/conditions/orders/OrderSiteConditionRule.php deleted file mode 100644 index c6b1f69b5b..0000000000 --- a/src/elements/conditions/orders/OrderSiteConditionRule.php +++ /dev/null @@ -1,63 +0,0 @@ - - * @since 4.2.7 - */ -class OrderSiteConditionRule extends BaseMultiSelectConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Order Site'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['orderSiteId']; - } - - /** - * @inheritdoc - */ - protected function options(): array - { - return ArrayHelper::map(Craft::$app->getSites()->getAllSites(), 'id', 'name'); - } - - /** - * @inheritdoc - */ - public function modifyQuery(QueryInterface $query): void - { - /** @var OrderQuery $query */ - $query->orderSiteId($this->paramValue()); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Order $element */ - return $this->matchValue((string)$element->orderSiteId); - } -} diff --git a/src/elements/conditions/orders/OrderStatusConditionRule.php b/src/elements/conditions/orders/OrderStatusConditionRule.php deleted file mode 100644 index 69e3c9a6d9..0000000000 --- a/src/elements/conditions/orders/OrderStatusConditionRule.php +++ /dev/null @@ -1,76 +0,0 @@ - - * @since 4.2.0 - * - * @method array|string|null paramValue(?callable $normalizeValue = null) - */ -class OrderStatusConditionRule extends BaseMultiSelectConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Order Status'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['orderStatus']; - } - - /** - * @param ElementQueryInterface $query - * @return void - * @throws InvalidConfigException - */ - public function modifyQuery(ElementQueryInterface $query): void - { - $orderStatuses = Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses(); - - /** @var OrderQuery $query */ - $query->orderStatus($this->paramValue(fn(string $value) => ArrayHelper::firstWhere($orderStatuses, 'uid', $value)?->handle)); - } - - /** - * @param ElementInterface $element - * @return bool - * @throws InvalidConfigException - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Order $element */ - $orderStatusUid = $element->getOrderStatus()?->uid; - return $this->matchValue($orderStatusUid); - } - - protected function options(): array - { - return Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses()->mapWithKeys(fn($status) => [$status->uid => $status->name])->all(); - } -} diff --git a/src/elements/conditions/orders/OrderTextValuesAttributeConditionRule.php b/src/elements/conditions/orders/OrderTextValuesAttributeConditionRule.php deleted file mode 100644 index 0f94f88902..0000000000 --- a/src/elements/conditions/orders/OrderTextValuesAttributeConditionRule.php +++ /dev/null @@ -1,58 +0,0 @@ - - * @since 4.2.0 - * - * @property-read float|int $orderAttributeValue - */ -abstract class OrderTextValuesAttributeConditionRule extends BaseTextConditionRule implements ElementConditionRuleInterface -{ - public string $orderAttribute = ''; - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return [$this->orderAttribute]; - } - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return 'Label not implemented'; - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - return $this->matchValue($element->{$this->orderAttribute}); - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - $query->{$this->orderAttribute}($this->paramValue()); - } -} diff --git a/src/elements/conditions/orders/OrderValuesAttributeConditionRule.php b/src/elements/conditions/orders/OrderValuesAttributeConditionRule.php deleted file mode 100644 index 965003f8f7..0000000000 --- a/src/elements/conditions/orders/OrderValuesAttributeConditionRule.php +++ /dev/null @@ -1,58 +0,0 @@ - - * @since 4.0.0 - * - * @property-read float|int $orderAttributeValue - */ -abstract class OrderValuesAttributeConditionRule extends BaseNumberConditionRule implements ElementConditionRuleInterface -{ - public string $orderAttribute = ''; - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return [$this->orderAttribute]; - } - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return 'Label not implemented'; - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - return $this->matchValue($element->{$this->orderAttribute}); - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - $query->{$this->orderAttribute}($this->paramValue()); - } -} diff --git a/src/elements/conditions/orders/PaidConditionRule.php b/src/elements/conditions/orders/PaidConditionRule.php deleted file mode 100644 index afcf7c9bb8..0000000000 --- a/src/elements/conditions/orders/PaidConditionRule.php +++ /dev/null @@ -1,62 +0,0 @@ - - * @since 4.2.0 - */ -class PaidConditionRule extends BaseLightswitchConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Paid'); - } - - public function getExclusiveQueryParams(): array - { - return ['paid']; - } - - /** - * @param ElementQueryInterface $query - * @return void - */ - public function modifyQuery(ElementQueryInterface $query): void - { - /** @var OrderQuery $query */ - if ($this->value) { - $query->isPaid(); - } else { - $query->isUnpaid(); - } - } - - /** - * @param ElementInterface $element - * @return bool - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Order $element */ - return $this->value ? $element->getIsPaid() : $element->getIsUnpaid(); - } -} diff --git a/src/elements/conditions/orders/PaymentGatewayConditionRule.php b/src/elements/conditions/orders/PaymentGatewayConditionRule.php deleted file mode 100644 index 0c708e7c09..0000000000 --- a/src/elements/conditions/orders/PaymentGatewayConditionRule.php +++ /dev/null @@ -1,136 +0,0 @@ - - * @since 5.3.0 - */ -class PaymentGatewayConditionRule extends BaseMultiSelectConditionRule implements ElementConditionRuleInterface -{ - /** - * @var string|null Legacy single value property for backwards compatibility - * @deprecated Use getValues() instead - */ - public $value; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Payment Gateway'); - } - - /** - * @inheritdoc - */ - public function getConfig(): array - { - $config = parent::getConfig(); - - // For backwards compatibility: if there's a legacy 'value' property, convert it to 'values' - if (isset($config['value']) && !isset($config['values'])) { - $config['values'] = [$config['value']]; - unset($config['value']); - } - - return $config; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - - // For backwards compatibility: accept 'value' property and convert to 'values' - $rules[] = [['value'], 'safe']; - - return $rules; - } - - /** - * @inheritdoc - */ - public function setAttributes($values, $safeOnly = true): void - { - // For backwards compatibility: convert single 'value' to 'values' array - if (isset($values['value']) && !isset($values['values'])) { - $values['values'] = is_array($values['value']) ? $values['value'] : [$values['value']]; - unset($values['value']); - } - - parent::setAttributes($values, $safeOnly); - } - - /** - * Returns the single value for backwards compatibility - * @deprecated Use getValues() instead - * @return string|null - */ - public function getValue(): ?string - { - $values = $this->getValues(); - return !empty($values) ? reset($values) : null; - } - - /** - * Sets a single value for backwards compatibility - * @deprecated Use setValues() instead - * @param string|null $value - */ - public function setValue(?string $value): void - { - $this->setValues($value ? [$value] : []); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['gatewayId']; - } - - /** - * @inheritdoc - */ - protected function options(): array - { - return Plugin::getInstance()->getGateways()->getAllGateways()->mapWithKeys(fn($gateway) => [$gateway->uid => $gateway->name])->all(); - } - - /** - * @inheritdoc - */ - public function modifyQuery(QueryInterface $query): void - { - $gateways = Plugin::getInstance()->getGateways()->getAllGateways(); - - /** @var OrderQuery $query */ - $query->gatewayId($this->paramValue(fn($uid) => $gateways->firstWhere('uid', $uid)?->id)); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Order $element */ - $gatewayUid = $element->getGateway()?->uid ?? ''; - return $this->matchValue($gatewayUid); - } -} diff --git a/src/elements/conditions/orders/ReferenceConditionRule.php b/src/elements/conditions/orders/ReferenceConditionRule.php deleted file mode 100644 index 28bd84bcf6..0000000000 --- a/src/elements/conditions/orders/ReferenceConditionRule.php +++ /dev/null @@ -1,29 +0,0 @@ - - * @since 4.2.0 - */ -class ReferenceConditionRule extends OrderTextValuesAttributeConditionRule -{ - public string $orderAttribute = 'reference'; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Reference'); - } -} diff --git a/src/elements/conditions/orders/ShippingAddressZoneConditionRule.php b/src/elements/conditions/orders/ShippingAddressZoneConditionRule.php deleted file mode 100644 index cbf8c0e9e5..0000000000 --- a/src/elements/conditions/orders/ShippingAddressZoneConditionRule.php +++ /dev/null @@ -1,86 +0,0 @@ - - * @since 5.0.0 - */ -class ShippingAddressZoneConditionRule extends BaseMultiSelectConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Shipping Address Zone'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['shippingZone']; - } - - /** - * @inheritdoc - */ - protected function options(): array - { - /** @var ShippingRuleOrderCondition $condition */ - $condition = $this->getCondition(); - - return Plugin::getInstance()->getShippingZones()->getAllShippingZones($condition->storeId)->mapWithKeys(fn(ShippingAddressZone $zone) => [$zone->id => $zone->name])->all(); - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - throw new NotSupportedException('Shipping Address Zone condition rule does not support queries'); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - /** @var ShippingRuleOrderCondition $condition */ - $condition = $this->getCondition(); - /** @var Order $element */ - $shippingAddress = $element->getShippingAddress() ?? $element->getEstimatedShippingAddress(); - - if (!$shippingAddress) { - return false; - } - - /** @var ShippingAddressZone[] $shippingZones */ - $shippingZones = Plugin::getInstance()->getShippingZones()->getAllShippingZones($condition->storeId)->whereIn('id', $this->getValues())->all(); - - // Start on `true` or `false` depending on the operator - $match = $this->operator !== self::OPERATOR_IN; - foreach ($shippingZones as $shippingZone) { - if ($shippingZone->getCondition()->matchElement($shippingAddress)) { - $match = $this->operator === self::OPERATOR_IN; - break; - } - } - - return $match; - } -} diff --git a/src/elements/conditions/orders/ShippingMethodConditionRule.php b/src/elements/conditions/orders/ShippingMethodConditionRule.php deleted file mode 100644 index c20ed7407a..0000000000 --- a/src/elements/conditions/orders/ShippingMethodConditionRule.php +++ /dev/null @@ -1,63 +0,0 @@ - - * @since 4.2.0 - */ -class ShippingMethodConditionRule extends BaseMultiSelectConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Shipping Method'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return []; - } - - /** - * @inheritdoc - */ - protected function options(): array - { - return Plugin::getInstance()->getShippingMethods()->getAllShippingMethods()->mapWithKeys(fn($method) => [$method->handle => $method->name])->all(); - } - - /** - * @inheritdoc - */ - public function modifyQuery(QueryInterface $query): void - { - /** @var OrderQuery $query */ - $query->shippingMethodHandle($this->paramValue()); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Order $element */ - return $this->matchValue($element->shippingMethodHandle); - } -} diff --git a/src/elements/conditions/orders/ShippingMethodOrderCondition.php b/src/elements/conditions/orders/ShippingMethodOrderCondition.php deleted file mode 100644 index 85934b4bb5..0000000000 --- a/src/elements/conditions/orders/ShippingMethodOrderCondition.php +++ /dev/null @@ -1,84 +0,0 @@ - - * @since 5.0.0 - */ -class ShippingMethodOrderCondition extends OrderCondition implements HasStoreInterface -{ - use StoreTrait; - - /** - * @inheritdoc - */ - public ?string $elementType = Order::class; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['storeId'], 'safe']; - - return $rules; - } - - /** - * @return array - */ - protected function config(): array - { - return array_merge(parent::config(), $this->toArray(['storeId'])); - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - throw new NotSupportedException('Shipping Method Order Condition does not support queries'); - } - - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - $ruleTypes = parent::selectableConditionRules(); - - - foreach ($ruleTypes as $key => $ruleType) { - if (in_array($ruleType, [ - CompletedConditionRule::class, - DateOrderedConditionRule::class, - PaidConditionRule::class, - OrderStatusConditionRule::class, - ShippingMethodConditionRule::class, - TotalPaidConditionRule::class, - ])) { - unset($ruleTypes[$key]); - } - } - - $ruleTypes[] = DiscountedItemSubtotalConditionRule::class; - $ruleTypes[] = ShippingAddressZoneConditionRule::class; - - return $ruleTypes; - } -} diff --git a/src/elements/conditions/orders/ShippingRuleOrderCondition.php b/src/elements/conditions/orders/ShippingRuleOrderCondition.php deleted file mode 100644 index 1f7e37d584..0000000000 --- a/src/elements/conditions/orders/ShippingRuleOrderCondition.php +++ /dev/null @@ -1,84 +0,0 @@ - - * @since 5.0.0 - */ -class ShippingRuleOrderCondition extends OrderCondition implements HasStoreInterface -{ - use StoreTrait; - - /** - * @inheritdoc - */ - public ?string $elementType = Order::class; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['storeId'], 'safe']; - - return $rules; - } - - /** - * @return array - */ - protected function config(): array - { - return array_merge(parent::config(), $this->toArray(['storeId'])); - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - throw new NotSupportedException('Shipping Rule Order Condition does not support queries'); - } - - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - $ruleTypes = parent::selectableConditionRules(); - - - foreach ($ruleTypes as $key => $ruleType) { - if (in_array($ruleType, [ - CompletedConditionRule::class, - DateOrderedConditionRule::class, - PaidConditionRule::class, - OrderStatusConditionRule::class, - ShippingMethodConditionRule::class, - TotalPaidConditionRule::class, - ])) { - unset($ruleTypes[$key]); - } - } - - $ruleTypes[] = DiscountedItemSubtotalConditionRule::class; - $ruleTypes[] = ShippingAddressZoneConditionRule::class; - - return $ruleTypes; - } -} diff --git a/src/elements/conditions/orders/TotalConditionRule.php b/src/elements/conditions/orders/TotalConditionRule.php deleted file mode 100644 index d15adf0d02..0000000000 --- a/src/elements/conditions/orders/TotalConditionRule.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 4.2.0 - * - * @property-read float|int $orderAttributeValue - */ -class TotalConditionRule extends OrderCurrencyValuesAttributeConditionRule -{ - public string $orderAttribute = 'total'; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Total'); - } -} diff --git a/src/elements/conditions/orders/TotalDiscountConditionRule.php b/src/elements/conditions/orders/TotalDiscountConditionRule.php deleted file mode 100644 index 5ba674a3a8..0000000000 --- a/src/elements/conditions/orders/TotalDiscountConditionRule.php +++ /dev/null @@ -1,90 +0,0 @@ - - * @since 4.2.0 - * - * @property-read float|int $orderAttributeValue - */ -class TotalDiscountConditionRule extends OrderCurrencyValuesAttributeConditionRule -{ - public string $orderAttribute = 'totalDiscount'; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Total Discount'); - } - - /** - * @inheritdoc - */ - protected function operatorLabel(string $operator): string - { - return match ($operator) { - self::OPERATOR_EQ => Craft::t('app', 'equals'), - self::OPERATOR_NE => Craft::t('app', 'does not equal'), - self::OPERATOR_GT => Craft::t('app', 'is less than'), - self::OPERATOR_GTE => Craft::t('app', 'is less than or equals'), - self::OPERATOR_LT => Craft::t('app', 'is greater than'), - self::OPERATOR_LTE => Craft::t('app', 'is greater than or equals'), - default => $operator, - }; - } - - /** - * @inheritdoc - */ - protected function paramValue(): ?string - { - if ($this->value === '') { - return null; - } - - $value = $this->value; - if (is_numeric($value)) { - $value *= -1; - } - - $value = Db::escapeParam($value); - - return "$this->operator $value"; - } - - protected function matchValue(mixed $value): bool - { - if ($this->value === '') { - return true; - } - - $ruleValue = $this->value; - if (is_numeric($ruleValue)) { - $ruleValue *= -1; - } - - return match ($this->operator) { - self::OPERATOR_EQ => $value == $ruleValue, - self::OPERATOR_NE => $value != $ruleValue, - self::OPERATOR_LT => $value < $ruleValue, - self::OPERATOR_LTE => $value <= $ruleValue, - self::OPERATOR_GT => $value > $ruleValue, - self::OPERATOR_GTE => $value >= $ruleValue, - default => throw new InvalidConfigException("Invalid operator: $this->operator"), - }; - } -} diff --git a/src/elements/conditions/orders/TotalPaidConditionRule.php b/src/elements/conditions/orders/TotalPaidConditionRule.php deleted file mode 100644 index 2208b03eee..0000000000 --- a/src/elements/conditions/orders/TotalPaidConditionRule.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 4.2.0 - * - * @property-read float|int $orderAttributeValue - */ -class TotalPaidConditionRule extends OrderCurrencyValuesAttributeConditionRule -{ - public string $orderAttribute = 'totalPaid'; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Total Paid'); - } -} diff --git a/src/elements/conditions/orders/TotalPriceConditionRule.php b/src/elements/conditions/orders/TotalPriceConditionRule.php deleted file mode 100644 index 97852eb460..0000000000 --- a/src/elements/conditions/orders/TotalPriceConditionRule.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 4.0.0 - * - * @property-read float|int $orderAttributeValue - */ -class TotalPriceConditionRule extends OrderCurrencyValuesAttributeConditionRule -{ - public string $orderAttribute = 'totalPrice'; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Total Price'); - } -} diff --git a/src/elements/conditions/orders/TotalQtyConditionRule.php b/src/elements/conditions/orders/TotalQtyConditionRule.php deleted file mode 100644 index 1711291e5c..0000000000 --- a/src/elements/conditions/orders/TotalQtyConditionRule.php +++ /dev/null @@ -1,29 +0,0 @@ - - * @since 4.2.0 - */ -class TotalQtyConditionRule extends OrderValuesAttributeConditionRule -{ - public string $orderAttribute = 'totalQty'; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Total Qty'); - } -} diff --git a/src/elements/conditions/orders/TotalTaxConditionRule.php b/src/elements/conditions/orders/TotalTaxConditionRule.php deleted file mode 100644 index bc779e746d..0000000000 --- a/src/elements/conditions/orders/TotalTaxConditionRule.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 4.2.0 - * - * @property-read float|int $orderAttributeValue - */ -class TotalTaxConditionRule extends OrderCurrencyValuesAttributeConditionRule -{ - public string $orderAttribute = 'totalTax'; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Total Tax'); - } -} diff --git a/src/elements/conditions/orders/TotalWeightConditionRule.php b/src/elements/conditions/orders/TotalWeightConditionRule.php deleted file mode 100644 index f33e7a26c6..0000000000 --- a/src/elements/conditions/orders/TotalWeightConditionRule.php +++ /dev/null @@ -1,29 +0,0 @@ - - * @since 4.2.0 - */ -class TotalWeightConditionRule extends OrderValuesAttributeConditionRule -{ - public string $orderAttribute = 'totalWeight'; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Total Weight'); - } -} diff --git a/src/elements/conditions/products/CatalogPricingRuleProductCondition.php b/src/elements/conditions/products/CatalogPricingRuleProductCondition.php deleted file mode 100644 index e2828dfd28..0000000000 --- a/src/elements/conditions/products/CatalogPricingRuleProductCondition.php +++ /dev/null @@ -1,29 +0,0 @@ - - * @since 5.1.0 - */ -class CatalogPricingRuleProductCondition extends ProductCondition -{ - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - $rules = parent::selectableConditionRules(); - - return ArrayHelper::withoutValue($rules, ProductVariantHasUnlimitedStockConditionRule::class); - } -} diff --git a/src/elements/conditions/products/ProductCondition.php b/src/elements/conditions/products/ProductCondition.php deleted file mode 100644 index 169d8cade2..0000000000 --- a/src/elements/conditions/products/ProductCondition.php +++ /dev/null @@ -1,40 +0,0 @@ - - * @since 4.0.0 - */ -class ProductCondition extends ElementCondition -{ - /** - * @inheritdoc - */ - public ?string $elementType = Product::class; - - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - return array_merge(parent::selectableConditionRules(), [ - ProductTypeConditionRule::class, - ProductVariantSearchConditionRule::class, - ProductVariantSkuConditionRule::class, - ProductVariantStockConditionRule::class, - ProductVariantHasUnlimitedStockConditionRule::class, - ProductVariantPriceConditionRule::class, - ]); - } -} diff --git a/src/elements/conditions/products/ProductTypeConditionRule.php b/src/elements/conditions/products/ProductTypeConditionRule.php deleted file mode 100644 index b93a2085f7..0000000000 --- a/src/elements/conditions/products/ProductTypeConditionRule.php +++ /dev/null @@ -1,78 +0,0 @@ - - * @since 4.3.0 - */ -class ProductTypeConditionRule extends BaseMultiSelectConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Product Type'); - } - - /** - * @return array - */ - protected function options(): array - { - return collect(Plugin::getInstance()->getProductTypes()->getAllProductTypes()) - ->map(fn(ProductType $productType) => ['value' => $productType->uid, 'label' => $productType->name]) - ->all(); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['type']; - } - - /** - * @throws InvalidConfigException - */ - public function modifyQuery(ElementQueryInterface $query): void - { - $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); - - /** @var string[] $value */ - $value = $this->paramValue(fn(string $value) => ArrayHelper::firstWhere($productTypes, 'uid', $value)?->handle); - - /** @var ProductQuery $query */ - $query->type($value); - } - - /** - * @throws InvalidConfigException - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Product $element */ - return $this->matchValue($element->getType()->uid); - } -} diff --git a/src/elements/conditions/products/ProductVariantHasUnlimitedStockConditionRule.php b/src/elements/conditions/products/ProductVariantHasUnlimitedStockConditionRule.php deleted file mode 100644 index 8ddb457ca5..0000000000 --- a/src/elements/conditions/products/ProductVariantHasUnlimitedStockConditionRule.php +++ /dev/null @@ -1,71 +0,0 @@ - - * @since 4.3.0 - * @deprecated 5.0.0 - */ -class ProductVariantHasUnlimitedStockConditionRule extends BaseLightswitchConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Variant Has Untracked Stock'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['variantStock']; - } - - /** - * @param ElementQueryInterface $query - */ - public function modifyQuery(ElementQueryInterface $query): void - { - $variantQuery = Variant::find(); - $variantQuery->select(['commerce_variants.primaryOwnerId as id']); - $variantQuery->inventoryTracked(!$this->value); - - /** @var ProductQuery $query */ - $query->andWhere(['elements.id' => $variantQuery]); - } - - /** - * @param Product $element - */ - public function matchElement(ElementInterface $element): bool - { - foreach ($element->getVariants() as $variant) { - if ($this->matchValue(!$variant->inventoryTracked)) { - // Skip out early if we have a match - return true; - } - } - - return false; - } -} diff --git a/src/elements/conditions/products/ProductVariantInventoryTrackedConditionRule.php b/src/elements/conditions/products/ProductVariantInventoryTrackedConditionRule.php deleted file mode 100644 index fc8d73692f..0000000000 --- a/src/elements/conditions/products/ProductVariantInventoryTrackedConditionRule.php +++ /dev/null @@ -1,70 +0,0 @@ - - * @since 5.0.0 - */ -class ProductVariantInventoryTrackedConditionRule extends BaseLightswitchConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Variant Tracks Stock'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['variantStock']; - } - - /** - * @param ElementQueryInterface $query - */ - public function modifyQuery(ElementQueryInterface $query): void - { - $variantQuery = Variant::find(); - $variantQuery->select(['commerce_variants.primaryOwnerId as id']); - $variantQuery->inventoryTracked($this->value); - - /** @var ProductQuery $query */ - $query->andWhere(['elements.id' => $variantQuery]); - } - - /** - * @param Product $element - */ - public function matchElement(ElementInterface $element): bool - { - foreach ($element->getVariants() as $variant) { - if ($this->matchValue($variant->inventoryTracked)) { - // Skip out early if we have a match - return true; - } - } - - return false; - } -} diff --git a/src/elements/conditions/products/ProductVariantPriceConditionRule.php b/src/elements/conditions/products/ProductVariantPriceConditionRule.php deleted file mode 100644 index c1ffdec2e7..0000000000 --- a/src/elements/conditions/products/ProductVariantPriceConditionRule.php +++ /dev/null @@ -1,70 +0,0 @@ - - * @since 4.3.0 - */ -class ProductVariantPriceConditionRule extends BaseNumberConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Variant Price'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['variantPrice']; - } - - /** - * @param ElementQueryInterface $query - */ - public function modifyQuery(ElementQueryInterface $query): void - { - $variantQuery = Variant::find(); - $variantQuery->select(['commerce_variants.primaryOwnerId as id']); - $variantQuery->price($this->paramValue()); - - /** @var ProductQuery $query */ - $query->andWhere(['elements.id' => $variantQuery]); - } - - /** - * @param Product $element - */ - public function matchElement(ElementInterface $element): bool - { - foreach ($element->getVariants() as $variant) { - if ($this->matchValue($variant->price)) { - // Skip out early if we have a match - return true; - } - } - - return false; - } -} diff --git a/src/elements/conditions/products/ProductVariantSearchConditionRule.php b/src/elements/conditions/products/ProductVariantSearchConditionRule.php deleted file mode 100644 index a8f2eacdf4..0000000000 --- a/src/elements/conditions/products/ProductVariantSearchConditionRule.php +++ /dev/null @@ -1,98 +0,0 @@ - - * @since 4.7.0 - */ -class ProductVariantSearchConditionRule extends BaseTextConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Variant Search'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return []; - } - - /** - * @inheritdoc - */ - protected function operators(): array - { - return []; - } - - /** - * Returns the raw search value. - * - * Note we can't use [[paramValue()]] here because it prepends the operator - * (e.g. `=`) intended for [[\craft\helpers\Db::parseParam()]], which would - * corrupt the value once it's passed to [[\craft\elements\db\ElementQuery::search()]]. - * - * @return string - */ - private function searchValue(): string - { - return trim((string)$this->value); - } - - /** - * @param ElementQueryInterface $query - */ - public function modifyQuery(ElementQueryInterface $query): void - { - $variantQuery = Variant::find(); - $variantQuery->select(['commerce_variants.primaryOwnerId as id']); - $variantQuery->search($this->searchValue()); - - /** @var ProductQuery $query */ - $query->andWhere(['elements.id' => $variantQuery]); - } - - /** - * @param Product $element - * @return bool - * @throws InvalidConfigException - */ - public function matchElement(ElementInterface $element): bool - { - $variantIds = $element->getVariants()->pluck('id')->all(); - if (empty($variantIds)) { - return false; - } - - // Perform a variant query search to ensure it is the same process as `modifyQuery` - $variantQuery = Variant::find(); - $variantQuery->search($this->searchValue()); - $variantQuery->id($variantIds); - - return $variantQuery->count() > 0; - } -} diff --git a/src/elements/conditions/products/ProductVariantSkuConditionRule.php b/src/elements/conditions/products/ProductVariantSkuConditionRule.php deleted file mode 100644 index 18cbfd6216..0000000000 --- a/src/elements/conditions/products/ProductVariantSkuConditionRule.php +++ /dev/null @@ -1,70 +0,0 @@ - - * @since 4.3.0 - */ -class ProductVariantSkuConditionRule extends BaseTextConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Variant SKU'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return []; - } - - /** - * @param ElementQueryInterface $query - */ - public function modifyQuery(ElementQueryInterface $query): void - { - $variantQuery = Variant::find(); - $variantQuery->select(['commerce_variants.primaryOwnerId as id']); - $variantQuery->sku($this->paramValue()); - - /** @var ProductQuery $query */ - $query->andWhere(['elements.id' => $variantQuery]); - } - - /** - * @param Product $element - */ - public function matchElement(ElementInterface $element): bool - { - foreach ($element->getVariants() as $variant) { - if ($this->matchValue($variant->sku)) { - // Skip out early if we have a match - return true; - } - } - - return false; - } -} diff --git a/src/elements/conditions/products/ProductVariantStockConditionRule.php b/src/elements/conditions/products/ProductVariantStockConditionRule.php deleted file mode 100644 index 0cb9bd5183..0000000000 --- a/src/elements/conditions/products/ProductVariantStockConditionRule.php +++ /dev/null @@ -1,78 +0,0 @@ - - * @since 4.3.0 - */ -class ProductVariantStockConditionRule extends BaseNumberConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Variant Stock'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['variantStock']; - } - - /** - * @param ElementQueryInterface $query - */ - public function modifyQuery(ElementQueryInterface $query): void - { - /** @var VariantQuery $variantQuery */ - $variantQuery = Variant::find(); - $variantQuery->select(['commerce_variants.primaryOwnerId as id']); - $variantQuery->inventoryTracked(true); - $variantQuery->stock($this->paramValue()); - - /** @var ProductQuery $query */ - $query->andWhere(['elements.id' => $variantQuery]); - } - - /** - * @param Product $element - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Variant $variant */ - foreach ($element->getVariants() as $variant) { - if (!$variant::hasInventory()) { - return true; - } - - if ($variant->inventoryTracked === true && $this->matchValue($variant->getStock())) { - // Skip out early if we have a match - return true; - } - } - - return false; - } -} diff --git a/src/elements/conditions/purchasables/CatalogPricingCondition.php b/src/elements/conditions/purchasables/CatalogPricingCondition.php deleted file mode 100644 index 3a3c2a0fe9..0000000000 --- a/src/elements/conditions/purchasables/CatalogPricingCondition.php +++ /dev/null @@ -1,152 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricingCondition extends BaseCondition -{ - /** - * @var string[] The query params that available rules shouldn’t compete with. - */ - public array $queryParams = []; - - /** - * @var bool - */ - public bool $allPrices = false; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['allPrices'], 'safe']; - - return $rules; - } - - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - return [ - CatalogPricingPurchasableConditionRule::class, - CatalogPricingCustomerConditionRule::class, - ]; - } - - /** - * @inheritdoc - */ - protected function isConditionRuleSelectable(ConditionRuleInterface $rule): bool - { - if (!parent::isConditionRuleSelectable($rule)) { - return false; - } - - // Make sure the rule doesn't conflict with the existing params - $queryParams = array_merge($this->queryParams); - foreach ($this->getConditionRules() as $existingRule) { - /** @var CatalogPricingConditionRuleInterface $existingRule */ - array_push($queryParams, ...$existingRule->getExclusiveQueryParams()); - } - - $queryParams = array_flip($queryParams); - - if (method_exists($rule, 'getExclusiveQueryParams')) { - foreach ($rule->getExclusiveQueryParams() as $param) { - if (isset($queryParams[$param])) { - return false; - } - } - } - - return true; - } - - /** - * @inheritdoc - */ - public function getConfig(): array - { - $config = parent::getConfig(); - $config['allPrices'] = $this->allPrices; - - return $config; - } - - /** - * @inheritdoc - */ - public function modifyQuery(Query $query): void - { - $catalogPricingRuleIdWhere = ['or']; - - // If we are looking for all prices, we don't need to worry about the user's table - if (!$this->allPrices) { - $catalogPricingRuleIdWhere[] = ['catalogPricingRuleId' => null]; - $catalogPricingRuleIdWhere[] = ['catalogPricingRuleId' => (new Query()) - ->select(['cpr.id as cprid']) - ->from([Table::CATALOG_PRICING_RULES . ' cpr']) - ->leftJoin([Table::CATALOG_PRICING_RULES_USERS . ' cpru'], '[[cpr.id]] = [[cpru.catalogPricingRuleId]]') - ->where(['[[cpru.id]]' => null]) - ->groupBy(['[[cpr.id]]']), - ]; - } - - $rules = $this->getConditionRules(); - - if ($customerRule = ArrayHelper::firstWhere($rules, fn(ConditionRuleInterface $rule) => $rule instanceof CatalogPricingCustomerConditionRule)) { - /** @var CatalogPricingCustomerConditionRule $customerRule */ - // Sub query to figure out which catalog pricing rules are using user conditions - $catalogPricingRuleIdWhere[] = ['catalogPricingRuleId' => (new Query()) - ->select(['cpr.id as cprid']) - ->from([Table::CATALOG_PRICING_RULES . ' cpr']) - ->leftJoin([Table::CATALOG_PRICING_RULES_USERS . ' cpru'], '[[cpr.id]] = [[cpru.catalogPricingRuleId]]') - ->where(['[[cpru.userId]]' => $customerRule->customerId]) - ->andWhere(['not', ['[[cpru.id]]' => null]]) - ->groupBy(['[[cpr.id]]']), - ]; - - foreach ($rules as $key => $rule) { - if ($rule instanceof CatalogPricingCustomerConditionRule) { - unset($rules[$key]); - - // Can break here because there is only one customer condition rule - break; - } - } - } - - // Deal with all prices and filtering by customer - if (count($catalogPricingRuleIdWhere) > 1) { - $query->andWhere($catalogPricingRuleIdWhere); - } - - // Apply the rest of the rules - foreach ($rules as $rule) { - /** @var CatalogPricingConditionRuleInterface $rule */ - $rule->modifyQuery($query); - } - } -} diff --git a/src/elements/conditions/purchasables/CatalogPricingCustomerConditionRule.php b/src/elements/conditions/purchasables/CatalogPricingCustomerConditionRule.php deleted file mode 100644 index cb14a371b0..0000000000 --- a/src/elements/conditions/purchasables/CatalogPricingCustomerConditionRule.php +++ /dev/null @@ -1,99 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricingCustomerConditionRule extends BaseConditionRule implements CatalogPricingConditionRuleInterface -{ - /** - * @var int|null - */ - public ?int $customerId = null; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Customer'); - } - - /** - * @inheritdoc - */ - public function getConfig(): array - { - return array_merge(parent::getConfig(), [ - 'customerId' => $this->customerId, - ]); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['customerId'], 'safe']; - - return $rules; - } - - /** - * @inheritdoc - */ - protected function inputHtml(): string - { - return Html::hiddenLabel($this->getLabel(), 'customer') . - Html::tag('div', - Cp::elementSelectHtml([ - 'name' => 'customerId', - 'elements' => array_filter([$this->customerId]), - 'elementType' => User::class, - 'sources' => null, - 'criteria' => null, - 'single' => true, - ]), - [ - 'class' => ['flex', 'flex-start'], - ] - ); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['customer']; - } - - /** - * @inheritdoc - */ - public function modifyQuery(Query $query): void - { - return; - - // Doesn't modify the query as the modification - // of the query happens in `CatalogPricingCondition::modifyQuery()` for this rule - } -} diff --git a/src/elements/conditions/purchasables/CatalogPricingPurchasableConditionRule.php b/src/elements/conditions/purchasables/CatalogPricingPurchasableConditionRule.php deleted file mode 100644 index 23a7b90853..0000000000 --- a/src/elements/conditions/purchasables/CatalogPricingPurchasableConditionRule.php +++ /dev/null @@ -1,151 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricingPurchasableConditionRule extends BaseConditionRule implements CatalogPricingConditionRuleInterface -{ - /** - * @var array|null - * @see getElementIds() - * @see setElementIds - */ - private ?array $_elementIds = null; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Purchasable'); - } - - /** - * @param $value - * @return void - */ - public function setElementIds($value): void - { - $this->_elementIds = $value; - } - - /** - * @return array|null - */ - public function getElementIds(): ?array - { - if ($this->_elementIds === null) { - return null; - } - - $elementIds = []; - foreach ($this->_elementIds as $ids) { - $elementIds = array_merge($elementIds, $ids); - } - - return $elementIds; - } - - /** - * @inheritdoc - */ - public function getConfig(): array - { - return array_merge(parent::getConfig(), [ - 'elementIds' => $this->_elementIds, - ]); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['elementIds'], 'safe']; - - return $rules; - } - - /** - * @inheritdoc - */ - protected function inputHtml(): string - { - $id = 'purchasable'; - - $html = ''; - foreach (Plugin::getInstance()->getPurchasables()->getAllPurchasableElementTypes() as $purchasableType) { - /** @var PurchasableInterface|string $purchasableType */ - $elements = null; - if (!empty($this->_elementIds) && isset($this->_elementIds[$purchasableType]) && !empty($this->_elementIds[$purchasableType])) { - $elements = $purchasableType::find() - ->id($this->_elementIds[$purchasableType]) - ->status(null) - ->all(); - } - - $html .= Html::tag('div', - Html::beginTag('div') . - Html::tag('strong', $purchasableType::displayName()) . - Html::endTag('div') . - Cp::elementSelectHtml([ - 'name' => Html::namespaceInputName($purchasableType, 'elementIds'), - 'elements' => $elements, - 'elementType' => $purchasableType, - 'sources' => null, - 'criteria' => null, - 'single' => false, - ]) - ); - } - - return Html::hiddenLabel($this->getLabel(), $id) . - Html::tag('div', - $html, - [ - 'class' => ['flex', 'flex-start'], - ] - ); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['id']; - } - - /** - * @inheritdoc - */ - public function modifyQuery(Query $query): void - { - $ids = $this->getElementIds(); - if ($ids === null) { - return; - } - - $query->andWhere(['purchasableId' => $ids]); - } -} diff --git a/src/elements/conditions/purchasables/CatalogPricingRulePurchasableCategoryConditionRule.php b/src/elements/conditions/purchasables/CatalogPricingRulePurchasableCategoryConditionRule.php deleted file mode 100644 index dd56127f86..0000000000 --- a/src/elements/conditions/purchasables/CatalogPricingRulePurchasableCategoryConditionRule.php +++ /dev/null @@ -1,168 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricingRulePurchasableCategoryConditionRule extends BaseConditionRule implements ElementConditionRuleInterface -{ - public const CATEGORY_RELATIONSHIP_TYPE_SOURCE = 'sourceElement'; - public const CATEGORY_RELATIONSHIP_TYPE_TARGET = 'targetElement'; - public const CATEGORY_RELATIONSHIP_TYPE_BOTH = 'element'; - - /** - * @var string - */ - public string $categoryRelationshipType = self::CATEGORY_RELATIONSHIP_TYPE_BOTH; - - /** - * @var array|null - */ - public ?array $elementIds = null; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Purchasable Categories'); - } - - - /** - * @inheritdoc - */ - public function getConfig(): array - { - return array_merge(parent::getConfig(), [ - 'elementIds' => $this->elementIds, - 'categoryRelationshipType' => $this->categoryRelationshipType, - ]); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['elementIds', 'categoryRelationshipType'], 'safe']; - - return $rules; - } - - /** - * @inheritdoc - */ - protected function inputHtml(): string - { - $id = 'cpr-purchasable-category'; - - $elements = !empty($this->elementIds) ? Category::find()->id($this->elementIds)->all() : []; - return Html::hiddenLabel($this->getLabel(), $id) . - Html::tag('div', - Html::tag('div', - Cp::elementSelectHtml([ - 'name' => 'elementIds', - 'elements' => $elements, - 'elementType' => Category::class, - 'sources' => null, - 'criteria' => null, - 'single' => false, - ]) - ), - [ - 'class' => ['flex', 'flex-start'], - ] - ) . - Html::tag('div', - Html::a(Craft::t('app', 'Advanced'), null, [ - 'class' => array_filter(['fieldtoggle', $this->categoryRelationshipType !== self::CATEGORY_RELATIONSHIP_TYPE_BOTH ? 'expanded' : '']), - 'data-target' => 'category-relationship-type-advanced', - ]) . - Html::tag('div', - Cp::selectHtml([ - 'id' => 'categoryRelationshipType', - 'name' => 'categoryRelationshipType', - 'label' => Craft::t('commerce', 'Categories Relationship Type'), - 'instructions' => Craft::t('commerce', 'How the Purchasables and Categories are related, which determines the matching items. See [Relations Terminology]({link}).', [ - 'link' => 'https://craftcms.com/docs/4.x/relations.html#terminology', - ]), - 'options' => [ - self::CATEGORY_RELATIONSHIP_TYPE_SOURCE => Craft::t('commerce', 'Source - The purchasable relationship field is on the category'), - self::CATEGORY_RELATIONSHIP_TYPE_TARGET => Craft::t('commerce', 'Target - The category relationship field is on the purchasable'), - self::CATEGORY_RELATIONSHIP_TYPE_BOTH => Craft::t('commerce', 'Either (Default) - The relationship field is on the purchasable or the category'), - ], - 'value' => $this->categoryRelationshipType, - ]), - [ - 'class' => $this->categoryRelationshipType === self::CATEGORY_RELATIONSHIP_TYPE_BOTH ? 'hidden' : '', - 'id' => 'category-relationship-type-advanced', - ] - ), - ['style' => ['width' => '100%']] - ) - ; - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return []; - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - if ($this->elementIds === null) { - return; - } - - $query->andRelatedTo([$this->categoryRelationshipType => $this->elementIds]); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - if ($this->elementIds === null) { - return true; - } - - return Purchasable::find() - ->id($element->id ?: false) - ->site('*') - ->drafts($element->getIsDraft()) - ->provisionalDrafts($element->isProvisionalDraft) - ->revisions($element->getIsRevision()) - ->status(null) - ->relatedTo([$this->categoryRelationshipType => $this->elementIds]) - ->exists(); - } -} diff --git a/src/elements/conditions/purchasables/CatalogPricingRulePurchasableCondition.php b/src/elements/conditions/purchasables/CatalogPricingRulePurchasableCondition.php deleted file mode 100644 index 1d8566dc69..0000000000 --- a/src/elements/conditions/purchasables/CatalogPricingRulePurchasableCondition.php +++ /dev/null @@ -1,38 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricingRulePurchasableCondition extends ElementCondition -{ - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - $types = array_filter(parent::selectableConditionRules(), static fn($type) => !in_array($type, [ - SiteConditionRule::class, - ], true)); - - $types[] = PurchasableConditionRule::class; - $types[] = SkuConditionRule::class; - $types[] = PurchasableTypeConditionRule::class; - $types[] = CatalogPricingRulePurchasableCategoryConditionRule::class; - - return $types; - } -} diff --git a/src/elements/conditions/purchasables/PurchasableConditionRule.php b/src/elements/conditions/purchasables/PurchasableConditionRule.php deleted file mode 100644 index c118f96465..0000000000 --- a/src/elements/conditions/purchasables/PurchasableConditionRule.php +++ /dev/null @@ -1,177 +0,0 @@ - - * @since 5.0.0 - */ -class PurchasableConditionRule extends BaseConditionRule implements ElementConditionRuleInterface -{ - /** - * @var array|null - * @see getElementIds() - * @see setElementIds - */ - private ?array $_elementIds = null; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Purchasable'); - } - - /** - * @param $value - * @return void - */ - public function setElementIds($value): void - { - $this->_elementIds = $value; - } - - /** - * @return array|null - */ - public function getElementIds(): ?array - { - if ($this->_elementIds === null) { - return null; - } - - $elementIds = []; - foreach ($this->_elementIds as $ids) { - if (!is_array($ids) || empty($ids)) { - continue; - } - - $elementIds = array_merge($elementIds, $ids); - } - - return $elementIds; - } - - /** - * @inheritdoc - */ - public function getConfig(): array - { - return array_merge(parent::getConfig(), [ - 'elementIds' => $this->_elementIds, - ]); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['elementIds'], 'safe']; - - return $rules; - } - - /** - * @inheritdoc - */ - protected function inputHtml(): string - { - $id = 'purchasable'; - - $html = ''; - foreach (Plugin::getInstance()->getPurchasables()->getAllPurchasableElementTypes() as $purchasableType) { - /** @var PurchasableInterface|string $purchasableType */ - $elements = null; - if (!empty($this->_elementIds) && isset($this->_elementIds[$purchasableType]) && !empty($this->_elementIds[$purchasableType])) { - $elements = $purchasableType::find() - ->id($this->_elementIds[$purchasableType]) - ->site('*') - ->preferSites(array_filter([Cp::requestedSite()?->id])) - ->status(null) - ->unique() - ->all(); - } - - $html .= Html::tag('div', - Html::beginTag('div') . - Html::tag('strong', $purchasableType::displayName()) . - Html::endTag('div') . - Cp::elementSelectHtml([ - 'name' => Html::namespaceInputName($purchasableType, 'elementIds'), - 'elements' => $elements, - 'elementType' => $purchasableType, - 'sources' => null, - 'criteria' => null, - 'single' => false, - 'showSiteMenu' => true, - ]) - ); - } - - return Html::hiddenLabel($this->getLabel(), $id) . - Html::tag('div', - $html, - [ - 'class' => ['flex', 'flex-start'], - ] - ); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['id']; - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - $ids = $this->getElementIds(); - if ($ids === null) { - return; - } - - $query->id($ids); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - $ids = $this->getElementIds(); - if ($ids === null) { - return true; - } - - if (!is_array($ids)) { - return false; - } - - return in_array($element->id, $ids); - } -} diff --git a/src/elements/conditions/purchasables/PurchasableTypeConditionRule.php b/src/elements/conditions/purchasables/PurchasableTypeConditionRule.php deleted file mode 100644 index 770ffbb126..0000000000 --- a/src/elements/conditions/purchasables/PurchasableTypeConditionRule.php +++ /dev/null @@ -1,65 +0,0 @@ - - * @since 5.0.0 - */ -class PurchasableTypeConditionRule extends BaseMultiSelectConditionRule implements ElementConditionRuleInterface -{ - public function getLabel(): string - { - return Craft::t('commerce', 'Purchasable Type'); - } - - public function getExclusiveQueryParams(): array - { - return ['purchasableType']; - } - - public function modifyQuery(ElementQueryInterface $query): void - { - $query->andWhere(Db::parseParam('type',$this->paramValue())); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Purchasable $element */ - return $this->matchValue($element::class); - } - - /** - * @inheritdoc - */ - protected function options(): array - { - $elementTypes = Plugin::getInstance()->getPurchasables()->getAllPurchasableElementTypes(); - - $types = []; - foreach ($elementTypes as $elementType) { - $types[$elementType] = $elementType::displayName(); - } - - return $types; - } -} diff --git a/src/elements/conditions/purchasables/SkuConditionRule.php b/src/elements/conditions/purchasables/SkuConditionRule.php deleted file mode 100644 index d27fefd9c3..0000000000 --- a/src/elements/conditions/purchasables/SkuConditionRule.php +++ /dev/null @@ -1,46 +0,0 @@ -leftJoin(Table::PURCHASABLES . ' skuconpurch', '[[skuconpurch.id]] = [[elements.id]]'); - $query->andWhere(Db::parseParam('[[skuconpurch.sku]]',$this->paramValue())); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Purchasable $element */ - return $this->matchValue($element->getSku()); - } -} diff --git a/src/elements/conditions/transfers/TransferCondition.php b/src/elements/conditions/transfers/TransferCondition.php deleted file mode 100644 index 6ed8d2c7f9..0000000000 --- a/src/elements/conditions/transfers/TransferCondition.php +++ /dev/null @@ -1,18 +0,0 @@ - - * @since 4.0.0 - */ -class DiscountGroupConditionRule extends GroupConditionRule -{ - protected const OPERATOR_IN_ALL = 'inAll'; - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return \Craft::t('app', 'User Groups'); - } - - /** - * @inheritDoc - */ - protected function operators(): array - { - return array_merge(parent::operators(), [ - self::OPERATOR_IN_ALL, - ]); - } - - /** - * @inheritDoc - */ - protected function operatorLabel(string $operator): string - { - return match ($operator) { - self::OPERATOR_IN_ALL => 'is in all of', - default => parent::operatorLabel($operator) - }; - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - throw new NotSupportedException('Discount user group rule does not support element queries.'); - } - - public function getExclusiveQueryParams(): array - { - return []; - } - - /** - * Returns whether the condition rule matches the given value. - * - * @param string|string[]|null $value - * @return bool - */ - protected function matchValue(array|string|null $value): bool - { - if (!$this->getValues()) { - return true; - } - - if ($value === '' || $value === null) { - $value = []; - } else { - $value = (array)$value; - } - - return match ($this->operator) { - self::OPERATOR_IN => !empty(array_intersect($value, $this->getValues())), - self::OPERATOR_NOT_IN => empty(array_intersect($value, $this->getValues())), - self::OPERATOR_IN_ALL => empty(array_diff($this->getValues(), $value)), - default => throw new InvalidConfigException("Invalid operator: $this->operator"), - }; - } -} diff --git a/src/elements/conditions/variants/CatalogPricingRuleVariantCondition.php b/src/elements/conditions/variants/CatalogPricingRuleVariantCondition.php deleted file mode 100644 index 680f2c7376..0000000000 --- a/src/elements/conditions/variants/CatalogPricingRuleVariantCondition.php +++ /dev/null @@ -1,27 +0,0 @@ - - * @since 5.1.0 - */ -class CatalogPricingRuleVariantCondition extends VariantCondition -{ - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - return array_merge(parent::selectableConditionRules(), [ - CatalogPricingRuleVariantConditionRule::class, - ]); - } -} diff --git a/src/elements/conditions/variants/CatalogPricingRuleVariantConditionRule.php b/src/elements/conditions/variants/CatalogPricingRuleVariantConditionRule.php deleted file mode 100644 index 18f5129875..0000000000 --- a/src/elements/conditions/variants/CatalogPricingRuleVariantConditionRule.php +++ /dev/null @@ -1,18 +0,0 @@ - - * @since 5.5.0 - */ -class CatalogPricingRuleVariantConditionRule extends VariantConditionRule -{ -} diff --git a/src/elements/conditions/variants/ProductConditionRule.php b/src/elements/conditions/variants/ProductConditionRule.php deleted file mode 100644 index 87a693aec5..0000000000 --- a/src/elements/conditions/variants/ProductConditionRule.php +++ /dev/null @@ -1,86 +0,0 @@ - - * @since 5.3.0 - */ -class ProductConditionRule extends BaseElementSelectConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - protected function elementType(): string - { - return Product::class; - } - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Product'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['product', 'productId', 'primaryOwnerId', 'primaryOwner', 'owner', 'ownerId']; - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - /** @var VariantQuery $query */ - $query->ownerId($this->getElementIds()); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Variant $element */ - return $this->matchValue($element->getOwnerId()); - } - - /** - * @inheritdoc - */ - protected function allowMultiple(): bool - { - return true; - } - - /** - * @inerhitdoc - */ - protected function elementSelectConfig(): array - { - return array_merge(parent::elementSelectConfig(), [ - 'showSiteMenu' => true, - ]); - } -} diff --git a/src/elements/conditions/variants/VariantCondition.php b/src/elements/conditions/variants/VariantCondition.php deleted file mode 100644 index 49c3530b53..0000000000 --- a/src/elements/conditions/variants/VariantCondition.php +++ /dev/null @@ -1,32 +0,0 @@ - - * @since 4.0.0 - */ -class VariantCondition extends ElementCondition -{ - /** - * @inheritdoc - */ - public ?string $elementType = Variant::class; - - /** - * @inheritdoc - */ - protected function selectableConditionRules(): array - { - return array_merge(parent::selectableConditionRules(), [ - ProductConditionRule::class, - SkuConditionRule::class, - ]); - } -} diff --git a/src/elements/conditions/variants/VariantConditionRule.php b/src/elements/conditions/variants/VariantConditionRule.php deleted file mode 100644 index ddbc0c92c3..0000000000 --- a/src/elements/conditions/variants/VariantConditionRule.php +++ /dev/null @@ -1,85 +0,0 @@ - - * @since 5.5.0 - */ -class VariantConditionRule extends BaseElementSelectConditionRule implements ElementConditionRuleInterface -{ - /** - * @inheritdoc - */ - protected function elementType(): string - { - return Variant::class; - } - - /** - * @inheritdoc - */ - public function getLabel(): string - { - return Craft::t('commerce', 'Product Variant'); - } - - /** - * @inheritdoc - */ - public function getExclusiveQueryParams(): array - { - return ['id']; - } - - /** - * @inheritdoc - */ - public function modifyQuery(ElementQueryInterface $query): void - { - /** @var VariantQuery $query */ - $query->id($this->getElementIds()); - } - - /** - * @inheritdoc - */ - public function matchElement(ElementInterface $element): bool - { - /** @var Variant $element */ - return $this->matchValue($element->getId()); - } - - /** - * @inheritdoc - */ - protected function allowMultiple(): bool - { - return true; - } - - /** - * @inerhitdoc - */ - protected function elementSelectConfig(): array - { - return array_merge(parent::elementSelectConfig(), [ - 'showSiteMenu' => true, - ]); - } -} diff --git a/src/elements/db/DonationQuery.php b/src/elements/db/DonationQuery.php deleted file mode 100644 index 0852931c46..0000000000 --- a/src/elements/db/DonationQuery.php +++ /dev/null @@ -1,43 +0,0 @@ - - * @since 2.0 - * @doc-path donations.md - */ -class DonationQuery extends PurchasableQuery -{ - /** - * @inheritdoc - */ - protected function beforePrepare(): bool - { - $this->joinElementTable('commerce_donations'); - - $this->query->select([ - 'commerce_donations.id', - ]); - - if ($this->sku) { - $this->subQuery->andWhere(['commerce_donations.sku' => $this->sku]); - } - - return parent::beforePrepare(); - } -} diff --git a/src/elements/db/OrderQuery.php b/src/elements/db/OrderQuery.php deleted file mode 100644 index c3900848a3..0000000000 --- a/src/elements/db/OrderQuery.php +++ /dev/null @@ -1,1968 +0,0 @@ - - * @since 2.0 - * @doc-path orders-carts.md - * @replace {element} order - * @replace {elements} orders - * @replace {twig-method} craft.orders() - * @replace {myElement} myOrder - * @replace {element-class} \craft\commerce\elements\Order - */ -class OrderQuery extends ElementQuery -{ - /** - * @var mixed The order number of the resulting order. - */ - public mixed $number = null; - - /** - * @var mixed The short order number of the resulting order. - */ - public mixed $shortNumber = null; - - /** - * @var mixed The order reference of the resulting order. - * @used-by reference() - */ - public mixed $reference = null; - - /** - * @var mixed The order reference of the resulting order. - * @used-by couponCode() - */ - public mixed $couponCode = null; - - /** - * @var mixed The email address the resulting orders must have. - */ - public mixed $email = null; - - /** - * @var bool The completion status that the resulting orders must have. - */ - public ?bool $isCompleted = null; - - /** - * @var mixed The Date Ordered date that the resulting orders must have. - */ - public mixed $dateOrdered = null; - - /** - * @var mixed The Expiry Date that the resulting orders must have. - */ - public mixed $expiryDate = null; - - /** - * @var mixed The date the order was paid in full. - */ - public mixed $datePaid = null; - - /** - * @var mixed The date the order was first paid in full. - */ - public mixed $dateFirstPaid = null; - - /** - * @var mixed The date the order was authorized in full. - */ - public mixed $dateAuthorized = null; - - /** - * @var mixed The Order Status ID that the resulting orders must have. - */ - public mixed $orderStatusId = null; - - /** - * @var mixed The language the order was made that the resulting the order must have. - */ - public mixed $orderLanguage = null; - - /** - * @var mixed The Order Site ID that the resulting orders must have. - */ - public mixed $orderSiteId = null; - - /** - * @var mixed The origin the resulting orders must have. - */ - public mixed $origin = null; - - /** - * @var mixed The user ID that the resulting orders must have. - */ - public mixed $customerId = null; - - /** - * @var mixed The gateway ID that the resulting orders must have. - */ - public mixed $gatewayId = null; - - /** - * @var int|null The store ID that the resulting orders must have. - */ - public ?int $storeId = null; - - /** - * @var mixed The total of the order resulting orders must have. - * @since 4.2.0 - */ - public mixed $total = null; - - /** - * @var mixed The total price of the order resulting orders must have. - * @since 4.2.0 - */ - public mixed $totalPrice = null; - - /** - * @var mixed The total paid amount of the order resulting orders must have. - * @since 4.2.0 - */ - public mixed $totalPaid = null; - - /** - * @var mixed The total qty of the order resulting orders must have. - * @since 4.2.0 - */ - public mixed $totalQty = null; - - /** - * @var mixed The total weight of the order resulting orders must have. - * @since 5.0.0 - */ - public mixed $totalWeight = null; - - /** - * @var mixed The total discount of the order resulting orders must have. - * @since 4.2.0 - */ - public mixed $totalDiscount = null; - - /** - * @var mixed The total tax resulting orders must have. - * @since 4.2.0 - */ - public mixed $totalTax = null; - - /** - * @var mixed The total price of the items resulting orders must have. - * @since 4.2.0 - */ - public mixed $itemTotal = null; - - /** - * @var mixed The subtotal price of the items resulting orders must have. - * @since 4.2.0 - */ - public mixed $itemSubtotal = null; - - /** - * @var mixed The shipping method handle the resulting orders must have. - * @since 4.2.0 - */ - public mixed $shippingMethodHandle = null; - - /** - * @var bool|null Whether the order is paid - */ - public ?bool $isPaid = null; - - /** - * @var bool|null Whether the order is unpaid - */ - public ?bool $isUnpaid = null; - - /** - * @var mixed The resulting orders must contain these Purchasables. - */ - public mixed $hasPurchasables = null; - - /** - * @var array{purchasables: array, match: ContainsPurchasablesMatch}|null - */ - public ?array $containsPurchasables = null; - - /** - * @var bool|null Whether the order has any transactions - */ - public ?bool $hasTransactions = null; - - /** - * @var bool|null Whether the order has any line items. - */ - public ?bool $hasLineItems = null; - - /** - * @var bool|null Whether the order has any admin notices. - */ - public ?bool $hasAdminNotices = null; - - /** - * @var bool Eager loads all relational data (addresses, adjustments, users, line items, transactions) for the resulting orders. - */ - public bool $withAll = false; - - /** - * @var bool Eager loads the shipping and billing addressees on the resulting orders. - */ - public bool $withAddresses = false; - - /** - * @var bool Eager loads the order adjustments on the resulting orders. - */ - public bool $withAdjustments = false; - - /** - * @var bool Eager load the user on to the order. - */ - public bool $withCustomer = false; - - /** - * @var bool Eager loads the line items on the resulting orders. - */ - public bool $withLineItems = false; - - /** - * @var bool Eager loads the transactions on the resulting orders. - */ - public bool $withTransactions = false; - - /** - * @inheritdoc - */ - protected array $defaultOrderBy = ['commerce_orders.id' => SORT_ASC]; - - /** - * @inheritdoc - */ - public function __construct($elementType, array $config = []) - { - // Default orderBy - if (!isset($config['orderBy'])) { - $config['orderBy'] = 'commerce_orders.id'; - } - - parent::__construct($elementType, $config); - } - - /** - * Narrows the query results based on the order number. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'` | with a matching order number - * - * --- - * - * ```twig - * {# Fetch the requested {element} #} - * {% set orderNumber = craft.app.request.getQueryParam('number') %} - * {% set {element-var} = {twig-method} - * .number(orderNumber) - * .one() %} - * ``` - * - * ```php - * // Fetch the requested {element} - * $orderNumber = Craft::$app->request->getQueryParam('number'); - * ${element-var} = {php-method} - * ->number($orderNumber) - * ->one(); - * ``` - * - * @param string|array|null $value The property value. - * @return static self reference - */ - public function number(mixed $value): OrderQuery - { - $this->number = $value; - return $this; - } - - /** - * Narrows the query results based on the order short number. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'xxxxxxx'` | with a matching order number - * - * --- - * - * ```twig - * {# Fetch the requested {element} #} - * {% set orderNumber = craft.app.request.getQueryParam('shortNumber') %} - * {% set {element-var} = {twig-method} - * .shortNumber(orderNumber) - * .one() %} - * ``` - * - * ```php - * // Fetch the requested {element} - * $orderNumber = Craft::$app->request->getQueryParam('shortNumber'); - * ${element-var} = {php-method} - * ->shortNumber($orderNumber) - * ->one(); - * ``` - * - * @param string|array|null $value The property value. - * @return static self reference - * @since 2.2 - */ - public function shortNumber(mixed $value): OrderQuery - { - $this->shortNumber = $value; - return $this; - } - - /** - * Narrows the query results based on the order reference. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'Foo'` | with a reference of `Foo`. - * | `'Foo*'` | with a reference that begins with `Foo`. - * | `'*Foo'` | with a reference that ends with `Foo`. - * | `'*Foo*'` | with a reference that contains `Foo`. - * | `'not *Foo*'` | with a reference that doesn’t contain `Foo`. - * | `['*Foo*', '*Bar*']` | with a reference that contains `Foo` or `Bar`. - * | `['not', '*Foo*', '*Bar*']` | with a reference that doesn’t contain `Foo` or `Bar`. - * - * --- - * - * ```twig - * {# Fetch the requested {element} #} - * {% set orderReference = craft.app.request.getQueryParam('ref') %} - * {% set {element-var} = {twig-method} - * .reference(orderReference) - * .one() %} - * ``` - * - * ```php - * // Fetch the requested {element} - * $orderReference = Craft::$app->request->getQueryParam('ref'); - * ${element-var} = {php-method} - * ->reference($orderReference) - * ->one(); - * ``` - * - * @param string|null $value The property value - * @return static self reference - */ - public function reference(mixed $value): OrderQuery - { - $this->reference = $value; - return $this; - } - - /** - * Narrows the query results based on the order's coupon code. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `':empty:'` | that don’t have a coupon code. - * | `':notempty:'` | that have a coupon code. - * | `'Foo'` | with a coupon code of `Foo`. - * | `'Foo*'` | with a coupon code that begins with `Foo`. - * | `'*Foo'` | with a coupon code that ends with `Foo`. - * | `'*Foo*'` | with a coupon code that contains `Foo`. - * | `'not *Foo*'` | with a coupon code that doesn’t contain `Foo`. - * | `['*Foo*', '*Bar*']` | with a coupon code that contains `Foo` or `Bar`. - * | `['not', '*Foo*', '*Bar*']` | with a coupon code that doesn’t contain `Foo` or `Bar`. - * - * --- - * - * ```twig - * {# Fetch the requested {element} #} - * {% set {element-var} = {twig-method} - * .reference('foo') - * .one() %} - * ``` - * - * ```php - * // Fetch the requested {element} - * ${element-var} = {php-method} - * ->reference('foo') - * ->one(); - * ``` - * - * @param string|null $value The property value - * @return static self reference - */ - public function couponCode(mixed $value): OrderQuery - { - $this->couponCode = $value; - return $this; - } - - /** - * Narrows the query results based on the customers’ email addresses. - * - * Possible values include: - * - * | Value | Fetches {elements} with customers… - * | - | - - * | `'foo@bar.baz'` | with an email of `foo@bar.baz`. - * | `'not foo@bar.baz'` | not with an email of `foo@bar.baz`. - * | `'*@bar.baz'` | with an email that ends with `@bar.baz`. - * - * --- - * - * ```twig - * {# Fetch orders from customers with a .co.uk domain on their email address #} - * {% set {elements-var} = {twig-method} - * .email('*.co.uk') - * .all() %} - * ``` - * - * ```php - * // Fetch orders from customers with a .co.uk domain on their email address - * ${elements-var} = {php-method} - * ->email('*.co.uk') - * ->all(); - * ``` - * - * @param string|string[]|null $value The property value - * @return static self reference - */ - public function email(mixed $value): OrderQuery - { - $this->email = $value; - return $this; - } - - /** - * Narrows the query results to only orders that are completed. - * - * --- - * - * ```twig - * {# Fetch completed orders #} - * {% set {elements-var} = {twig-method} - * .isCompleted() - * .all() %} - * ``` - * - * ```php - * // Fetch completed orders - * ${elements-var} = {element-class}::find() - * ->isCompleted() - * ->all(); - * ``` - * - * @param bool $value The property value - * @return static self reference - */ - public function isCompleted(?bool $value = true): OrderQuery - { - $this->isCompleted = $value; - return $this; - } - - /** - * Narrows the query results based on the orders’ completion dates. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'>= 2018-04-01'` | that were completed on or after 2018-04-01. - * | `'< 2018-05-01'` | that were completed before 2018-05-01 - * | `['and', '>= 2018-04-04', '< 2018-05-01']` | that were completed between 2018-04-01 and 2018-05-01. - * - * --- - * - * ```twig - * {# Fetch {elements} that were completed recently #} - * {% set aWeekAgo = date('7 days ago')|atom %} - * - * {% set {elements-var} = {twig-method} - * .dateOrdered(">= #{aWeekAgo}") - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} that were completed recently - * $aWeekAgo = new \DateTime('7 days ago')->format(\DateTime::ATOM); - * - * ${elements-var} = {php-method} - * ->dateOrdered(">= {$aWeekAgo}") - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function dateOrdered(mixed $value): OrderQuery - { - $this->dateOrdered = $value; - return $this; - } - - /** - * Narrows the query results based on the orders’ paid dates. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'>= 2018-04-01'` | that were paid on or after 2018-04-01. - * | `'< 2018-05-01'` | that were paid before 2018-05-01 - * | `['and', '>= 2018-04-04', '< 2018-05-01']` | that were paid between 2018-04-01 and 2018-05-01. - * - * --- - * - * ```twig - * {# Fetch {elements} that were paid for recently #} - * {% set aWeekAgo = date('7 days ago')|atom %} - * - * {% set {elements-var} = {twig-method} - * .datePaid(">= #{aWeekAgo}") - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} that were paid for recently - * $aWeekAgo = new \DateTime('7 days ago')->format(\DateTime::ATOM); - * - * ${elements-var} = {php-method} - * ->datePaid(">= {$aWeekAgo}") - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function datePaid(mixed $value): OrderQuery - { - $this->datePaid = $value; - return $this; - } - - /** - * Narrows the query results based on the orders’ first paid dates. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'>= 2018-04-01'` | that were first paid on or after 2018-04-01. - * | `'< 2018-05-01'` | that were first paid before 2018-05-01 - * | `['and', '>= 2018-04-04', '< 2018-05-01']` | that were first paid between 2018-04-01 and 2018-05-01. - * - * --- - * - * ```twig - * {# Fetch {elements} that were first paid for recently #} - * {% set aWeekAgo = date('7 days ago')|atom %} - * - * {% set {elements-var} = {twig-method} - * .dateFirstPaid(">= #{aWeekAgo}") - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} that were first paid for recently - * $aWeekAgo = new \DateTime('7 days ago')->format(\DateTime::ATOM); - * - * ${elements-var} = {php-method} - * ->dateFirstPaid(">= {$aWeekAgo}") - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function dateFirstPaid(mixed $value): OrderQuery - { - $this->dateFirstPaid = $value; - return $this; - } - - /** - * Narrows the query results based on the orders’ authorized dates. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'>= 2018-04-01'` | that were authorized on or after 2018-04-01. - * | `'< 2018-05-01'` | that were authorized before 2018-05-01 - * | `['and', '>= 2018-04-04', '< 2018-05-01']` | that were completed between 2018-04-01 and 2018-05-01. - * - * --- - * - * ```twig - * {# Fetch {elements} that were authorized recently #} - * {% set aWeekAgo = date('7 days ago')|atom %} - * - * {% set {elements-var} = {twig-method} - * .dateAuthorized(">= #{aWeekAgo}") - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} that were authorized recently - * $aWeekAgo = new \DateTime('7 days ago')->format(\DateTime::ATOM); - * - * ${elements-var} = {php-method} - * ->dateAuthorized(">= {$aWeekAgo}") - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function dateAuthorized(mixed $value): OrderQuery - { - $this->dateAuthorized = $value; - return $this; - } - - /** - * Narrows the query results based on the orders’ expiry dates. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'>= 2020-04-01'` | that will expire on or after 2020-04-01. - * | `'< 2020-05-01'` | that will expire before 2020-05-01 - * | `['and', '>= 2020-04-04', '< 2020-05-01']` | that will expire between 2020-04-01 and 2020-05-01. - * - * --- - * - * ```twig - * {# Fetch {elements} expiring this month #} - * {% set nextMonth = date('first day of next month')|atom %} - * - * {% set {elements-var} = {twig-method} - * .expiryDate("< #{nextMonth}") - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} expiring this month - * $nextMonth = new \DateTime('first day of next month')->format(\DateTime::ATOM); - * - * ${elements-var} = {php-method} - * ->expiryDate("< {$nextMonth}") - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function expiryDate(mixed $value): OrderQuery - { - $this->expiryDate = $value; - return $this; - } - - /** - * Narrows the query results based on the order statuses. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'foo'` | with an order status with a handle of `foo`. - * | `'not foo'` | not with an order status with a handle of `foo`. - * | `['foo', 'bar']` | with an order status with a handle of `foo` or `bar`. - * | `['not', 'foo', 'bar']` | not with an order status with a handle of `foo` or `bar`. - * | a [[OrderStatus|OrderStatus]] object | with an order status represented by the object. - * - * --- - * - * ```twig - * {# Fetch shipped {elements} #} - * {% set {elements-var} = {twig-method} - * .orderStatus('shipped') - * .all() %} - * ``` - * - * ```php - * // Fetch shipped {elements} - * ${elements-var} = {php-method} - * ->orderStatus('shipped') - * ->all(); - * ``` - * - * @param string|string[]|OrderStatus|null $value The property value - * @return static self reference - */ - public function orderStatus(mixed $value): OrderQuery - { - if ($value instanceof OrderStatus) { - $this->orderStatusId = $value->id; - } elseif ($value !== null) { - $this->orderStatusId = (new Query()) - ->select(['id']) - ->from([Table::ORDERSTATUSES]) - ->where(Db::parseParam('handle', $value)) - ->column(); - } else { - $this->orderStatusId = null; - } - - return $this; - } - - /** - * Narrows the query results based on the shipping method handle. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'foo'` | with a shipping method with a handle of `foo`. - * | `'not foo'` | not with a shipping method with a handle of `foo`. - * | `['foo', 'bar']` | with a shipping method with a handle of `foo` or `bar`. - * | `['not', 'foo', 'bar']` | not with a shipping method with a handle of `foo` or `bar`. - * | a [[ShippingMethod|ShippingMethod]] object | with a shipping method represented by the object. - * - * --- - * - * ```twig - * {# Fetch collection shipping method {elements} #} - * {% set {elements-var} = {twig-method} - * .shippingMethodHandle('collection') - * .all() %} - * ``` - * - * ```php - * // Fetch collection shipping method {elements} - * ${elements-var} = {php-method} - * ->shippingMethodHandle('collection') - * ->all(); - * ``` - * - * @param string|string[]|null $value The property value - * @return static self reference - */ - public function shippingMethodHandle(mixed $value): OrderQuery - { - $this->shippingMethodHandle = $value; - return $this; - } - - /** - * Narrows the query results based on the order statuses, per their IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | with an order status with an ID of 1. - * | `'not 1'` | not with an order status with an ID of 1. - * | `[1, 2]` | with an order status with an ID of 1 or 2. - * | `['not', 1, 2]` | not with an order status with an ID of 1 or 2. - * - * --- - * - * ```twig - * {# Fetch {elements} with an order status with an ID of 1 #} - * {% set {elements-var} = {twig-method} - * .orderStatusId(1) - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} with an order status with an ID of 1 - * ${elements-var} = {php-method} - * ->orderStatusId(1) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function orderStatusId(mixed $value): OrderQuery - { - $this->orderStatusId = $value; - return $this; - } - - /** - * Narrows the query results based on the order language, per the language string provided. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'en'` | with an order language that is `'en'`. - * | `'not en'` | not with an order language that is not `'en'`. - * | `['en', 'en-us']` | with an order language that is `'en'` or `'en-us'`. - * | `['not', 'en']` | not with an order language that is not `'en'`. - * - * --- - * - * ```twig - * {# Fetch {elements} with an order language that is `'en'` #} - * {% set {elements-var} = {twig-method} - * .orderLanguage('en') - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} with an order language that is `'en'` - * ${elements-var} = {php-method} - * ->orderLanguage('en') - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function orderLanguage(mixed $value): OrderQuery - { - $this->orderLanguage = $value; - return $this; - } - - /** - * Narrows the query results based on the order language, per the language string provided. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | with an order site ID of 1. - * | `'not 1'` | not with an order site ID that is no 1. - * | `[1, 2]` | with an order site ID of 1 or 2. - * | `['not', 1, 2]` | not with an order site ID of 1 or 2. - * - * --- - * - * ```twig - * {# Fetch {elements} with an order site ID of 1 #} - * {% set {elements-var} = {twig-method} - * .orderSiteId(1) - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} with an order site ID of 1 - * ${elements-var} = {php-method} - * ->orderSiteId(1) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function orderSiteId(mixed $value): OrderQuery - { - $this->orderSiteId = $value; - return $this; - } - - /** - * Narrows the query results based on the origin. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'web'` | with an origin of `web`. - * | `'not remote'` | not with an origin of `remote`. - * | `['web', 'cp']` | with an order origin of `web` or `cp`. - * | `['not', 'remote', 'cp']` | not with an origin of `web` or `cp`. - * - * --- - * - * ```twig - * {# Fetch shipped {elements} #} - * {% set {elements-var} = {twig-method} - * .origin('web') - * .all() %} - * ``` - * - * ```php - * // Fetch shipped {elements} - * ${elements-var} = {php-method} - * ->origin('web') - * ->all(); - * ``` - * - * @param string|string[]|null $value The property value - * @return static self reference - */ - public function origin(mixed $value): OrderQuery - { - $this->origin = $value; - - return $this; - } - - /** - * Narrows the query results based on the gateway. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | a [[Gateway|Gateway]] object | with a gateway represented by the object. - * - * @param GatewayInterface|null $value The property value - * @return static self reference - */ - public function gateway(?GatewayInterface $value): OrderQuery - { - if ($value) { - /** @var Gateway $value */ - $this->gatewayId = $value->id; - } else { - $this->gatewayId = null; - } - - return $this; - } - - /** - * Narrows the query results based on the gateway, per its ID. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | with a gateway with an ID of 1. - * | `'not 1'` | not with a gateway with an ID of 1. - * | `[1, 2]` | with a gateway with an ID of 1 or 2. - * | `['not', 1, 2]` | not with a gateway with an ID of 1 or 2. - * - * @param mixed $value The property value - * @return static self reference - */ - public function gatewayId(mixed $value): OrderQuery - { - $this->gatewayId = $value; - return $this; - } - - /** - * Narrows the query results based on the customer’s user account. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | with a customer with a user account ID of 1. - * | a [[User|User]] object | with a customer with a user account represented by the object. - * - * --- - * - * ```twig - * {# Fetch the current user's orders #} - * {% set {elements-var} = {twig-method} - * .user(currentUser) - * .all() %} - * ``` - * - * ```php - * // Fetch the current user's orders - * $user = Craft::$app->user->getIdentity(); - * ${elements-var} = {php-method} - * ->user($user) - * ->all(); - * ``` - * - * @param User|int|null $value The property value - * @return static self reference - * @deprecated 4.0.0 in favor of [[customer()]] - */ - public function user(int|User|null $value): OrderQuery - { - Craft::$app->getDeprecator()->log('OrderQuery::user()', 'The `OrderQuery::user()` method is deprecated, use the `OrderQuery::customer()` method instead.'); - return $this->customer($value); - } - - /** - * Narrows the query results based on the customer’s user account. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | with a customer with a user account ID of 1. - * | a [[User|User]] object | with a customer with a user account represented by the object. - * | `'not 1'` | not the user account with an ID 1. - * | `[1, 2]` | with an user account ID of 1 or 2. - * | `['not', 1, 2]` | not with a user account ID of 1 or 2. - * - * --- - * - * ```twig - * {# Fetch the current user's orders #} - * {% set {elements-var} = {twig-method} - * .customer(currentUser) - * .all() %} - * ``` - * - * ```php - * // Fetch the current user's orders - * $user = Craft::$app->user->getIdentity(); - * ${elements-var} = {php-method} - * ->customer($user) - * ->all(); - * ``` - * - * @param User|int|null $value The property value - * @return static self reference - */ - public function customer(int|User|null $value): OrderQuery - { - if ($value instanceof User) { - $this->customerId = $value->id; - } else { - $this->customerId = $value; - } - - return $this; - } - - /** - * Narrows the query results based on the customer, per their user ID. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | with a user with an ID of 1. - * | `'not 1'` | not with a user with an ID of 1. - * | `[1, 2]` | with a user with an ID of 1 or 2. - * | `['not', 1, 2]` | not with a user with an ID of 1 or 2. - * - * --- - * - * ```twig - * {# Fetch the current user's orders #} - * {% set {elements-var} = {twig-method} - * .customerId(currentUser.id) - * .all() %} - * ``` - * - * ```php - * // Fetch the current user's orders - * $user = Craft::$app->user->getIdentity(); - * ${elements-var} = {php-method} - * ->customerId($user->id) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function customerId(mixed $value): OrderQuery - { - $this->customerId = $value; - return $this; - } - - /** - * Narrows the query results based on the total. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `10` | with a total price of $10. - * | `['and', 10, 20]` | an order with a total of $10 or $20. - * - * @param mixed $value The property value - * @return static self reference - * @since 4.2.0 - */ - public function total(mixed $value): OrderQuery - { - $this->total = $value; - return $this; - } - - /** - * Narrows the query results based on the total price. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `10` | with a total price of $10. - * | `['and', 10, 20]` | an order with a total price of $10 or $20. - * - * @param mixed $value The property value - * @return static self reference - * @since 4.2.0 - */ - public function totalPrice(mixed $value): OrderQuery - { - $this->totalPrice = $value; - return $this; - } - - /** - * Narrows the query results based on the total paid amount. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `10` | with a total paid amount of $10. - * | `['and', 10, 20]` | an order with a total paid amount of $10 or $20. - * - * @param mixed $value The property value - * @return static self reference - * @since 4.2.0 - */ - public function totalPaid(mixed $value): OrderQuery - { - $this->totalPaid = $value; - return $this; - } - - /** - * Narrows the query results based on the total qty of items. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `10` | with a total qty of 10. - * | `[10, 20]` | an order with a total qty of 10 or 20. - * - * @param mixed $value The property value - * @return static self reference - * @since 4.2.0 - */ - public function totalQty(mixed $value): OrderQuery - { - $this->totalQty = $value; - return $this; - } - - /** - * Narrows the query results based on the total weight of items. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `10` | with a total weight of 10. - * | `[10, 20]` | an order with a total weight of 10 or 20. - * - * @param mixed $value The property value - * @return static self reference - * @since 4.2.0 - */ - public function totalWeight(mixed $value): OrderQuery - { - $this->totalWeight = $value; - return $this; - } - - /** - * Narrows the query results based on the total discount. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `10` | with a total discount of 10. - * | `[10, 20]` | an order with a total discount of 10 or 20. - * - * @param mixed $value The property value - * @return static self reference - * @since 4.2.0 - */ - public function totalDiscount(mixed $value): OrderQuery - { - $this->totalDiscount = $value; - return $this; - } - - /** - * Narrows the query results based on the total tax. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `10` | with a total tax of 10. - * | `[10, 20]` | an order with a total tax of 10 or 20. - * - * @param mixed $value The property value - * @return static self reference - * @since 4.2.0 - */ - public function totalTax(mixed $value): OrderQuery - { - $this->totalTax = $value; - return $this; - } - - /** - * Narrows the query results based on the order’s item total. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `100` | with an item total of $100. - * | `'< 1000000'` | with an item total of less than $1,000,000. - * | `['>= 10', '< 100']` | with an item total of between $10 and $100. - - * - * @param mixed $value The property value - * @return static self reference - * @since 4.2.0 - */ - public function itemTotal(mixed $value): OrderQuery - { - $this->itemTotal = $value; - return $this; - } - - /** - * Narrows the query results based on the order’s item subtotal. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `100` | with an item subtotal of $100. - * | `'< 1000000'` | with an item subtotal of less than $1,000,000. - * | `['>= 10', '< 100']` | with an item subtotal of between $10 and $100. - - * - * @param mixed $value The property value - * @return static self reference - * @since 4.2.0 - */ - public function itemSubtotal(mixed $value): OrderQuery - { - $this->itemSubtotal = $value; - return $this; - } - - /** - * Narrows the query results to only orders that are paid. - * - * --- - * - * ```twig - * {# Fetch paid orders #} - * {% set {elements-var} = {twig-method} - * .isPaid() - * .all() %} - * ``` - * - * ```php - * // Fetch paid orders - * ${elements-var} = {element-class}::find() - * ->isPaid() - * ->all(); - * ``` - * - * @param bool|null $value The property value - * @return static self reference - */ - public function isPaid(?bool $value = true): OrderQuery - { - $this->isPaid = $value; - return $this; - } - - /** - * Narrows the query results to only orders that are not paid. - * - * --- - * - * ```twig - * {# Fetch unpaid orders #} - * {% set {elements-var} = {twig-method} - * .isUnpaid() - * .all() %} - * ``` - * - * ```php - * // Fetch unpaid orders - * ${elements-var} = {element-class}::find() - * ->isUnpaid() - * ->all(); - * ``` - * - * @param bool|null $value The property value - * @return static self reference - */ - public function isUnpaid(?bool $value = true): OrderQuery - { - $this->isUnpaid = $value; - return $this; - } - - /** - * Narrows the query results to only orders that have line items. - * - * --- - * - * ```twig - * {# Fetch orders that do or do not have line items #} - * {% set {elements-var} = {twig-method} - * .hasLineItems() - * .all() %} - * ``` - * - * ```php - * // Fetch unpaid orders - * ${elements-var} = {element-class}::find() - * ->hasLineItems() - * ->all(); - * ``` - * - * @param bool|null $value The property value - * @return static self reference - */ - public function hasLineItems(?bool $value = true): OrderQuery - { - $this->hasLineItems = $value; - return $this; - } - - public function hasAdminNotices(?bool $value = true): static - { - $this->hasAdminNotices = $value; - return $this; - } - - /** - * Narrows the query results to only carts that have at least one transaction. - * - * --- - * - * ```twig - * {# Fetch carts that have attempted payments #} - * {% set {elements-var} = {twig-method} - * .hasTransactions() - * .all() %} - * ``` - * - * ```php - * // Fetch carts that have attempted payments - * ${elements-var} = {element-class}::find() - * ->hasTransactions() - * ->all(); - * ``` - * - * @param bool|null $value The property value - * @return static self reference - */ - public function hasTransactions(?bool $value = true): OrderQuery - { - $this->hasTransactions = $value; - return $this; - } - - /** - * Narrows the query results to only orders that have certain purchasables. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | a [[PurchasableInterface|PurchasableInterface]] object | with a purchasable represented by the object. - * | an array of [[PurchasableInterface|PurchasableInterface]] objects | with all the purchasables represented by the objects. - * - * @param PurchasableInterface|array|null $value The property value - * @return static self reference - */ - public function hasPurchasables(mixed $value): OrderQuery - { - $this->hasPurchasables = $value; - - return $this; - } - - /** - * Narrows the query results based on whether orders contain specific purchasables, - * with support for 'any', 'all', and 'only' match modes. - * - * The `purchasables` key accepts a mixed array of integer IDs and/or - * [[PurchasableInterface]] objects: - * - * ```php - * // IDs only - * ->containsPurchasables(['purchasables' => [1, 2, 3], 'match' => 'any']) - * - * // Objects only - * ->containsPurchasables(['purchasables' => [$variant1, $variant2], 'match' => 'all']) - * - * // Mixed - * ->containsPurchasables(['purchasables' => [1, $variant2, 3], 'match' => 'only']) - * ``` - * - * @param array{purchasables: array, match: ContainsPurchasablesMatch} $value - * @return static self reference - */ - public function containsPurchasables(array $value): OrderQuery - { - $this->containsPurchasables = $value; - - return $this; - } - - /** - * Narrows the query results to only orders that are related to the given store. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | with a `storeId` of `1`. - * - * @param int|null $value - * @return static self reference - */ - public function storeId(?int $value): OrderQuery - { - $this->storeId = $value; - - return $this; - } - - /** - * Eager loads all relational data (addresses, adjustments, customers, line items, transactions) for the resulting orders. - * - * Possible values include: - * - * | Value | Fetches addresses, adjustments, customers, line items, transactions - * | - | - - * | bool | `true` to eager-load, `false` to not eager load. - * - * @param bool $value The property value - * @return static self reference - * - * @used-by withAll() - */ - public function withAll(bool $value = true): OrderQuery - { - $this->withAll = $value; - - return $this; - } - - /** - * Eager loads the shipping and billing addressees on the resulting orders. - * - * Possible values include: - * - * | Value | Fetches addresses - * | - | - - * | bool | `true` to eager-load, `false` to not eager load. - * - * @param bool $value The property value - * @return static self reference - * - * @used-by withAddresses() - */ - public function withAddresses(bool $value = true): OrderQuery - { - $this->withAddresses = $value; - - return $this; - } - - /** - * Eager loads the order adjustments on the resulting orders. - * - * Possible values include: - * - * | Value | Fetches adjustments - * | - | - - * | bool | `true` to eager-load, `false` to not eager load. - * - * @param bool $value The property value - * @return static self reference - * - * @used-by withAdjustments() - */ - public function withAdjustments(bool $value = true): OrderQuery - { - $this->withAdjustments = $value; - - return $this; - } - - /** - * Eager loads the user on the resulting orders. - * - * Possible values include: - * - * | Value | Fetches adjustments - * | - | - - * | bool | `true` to eager-load, `false` to not eager load. - * - * @param bool $value The property value - * @return static self reference - * - * @used-by withCustomer() - */ - public function withCustomer(bool $value = true): OrderQuery - { - $this->withCustomer = $value; - - return $this; - } - - /** - * Eager loads the line items on the resulting orders. - * - * Possible values include: - * - * | Value | Fetches line items - * | - | - - * | bool | `true` to eager-load, `false` to not eager load. - * - * @param bool $value The property value - * @return static self reference - * - * @used-by withLineItems() - */ - public function withLineItems(bool $value = true): OrderQuery - { - $this->withLineItems = $value; - - return $this; - } - - /** - * Eager loads the transactions on the resulting orders. - * - * Possible values include: - * - * | Value | Fetches transactions… - * | - | - - * | bool | `true` to eager-load, `false` to not eager load. - * - * @param bool $value The property value - * @return static self reference - * - * @used-by withTransactions() - */ - public function withTransactions(bool $value = true): OrderQuery - { - $this->withTransactions = $value; - - return $this; - } - - /** - * @inheritdoc - */ - public function populate($rows): array - { - // @TODO Remove in Commerce 6.0 once the `email` column is dropped from `commerce_orders` (email now lives on the customer) - // Remove `email` key from each row. - array_walk($rows, function(&$row) { - if (array_key_exists('email', $row)) { - unset($row['email']); - } - }); - - /** @var Order[] $orders */ - $orders = parent::populate($rows); - - // Eager-load anything? - if (!empty($orders) && !$this->asArray) { - - // Eager-load line items? - if ($this->withLineItems === true || $this->withAll) { - $orders = Plugin::getInstance()->getLineItems()->eagerLoadLineItemsForOrders($orders); - } - - // Eager-load transactions? - if ($this->withTransactions === true || $this->withAll) { - $orders = Plugin::getInstance()->getTransactions()->eagerLoadTransactionsForOrders($orders); - } - - // Eager-load adjustments? - if ($this->withAdjustments === true || $this->withAll) { - $orders = Plugin::getInstance()->getOrderAdjustments()->eagerLoadOrderAdjustmentsForOrders($orders); - } - - // Eager-load users? - if ($this->withCustomer === true || $this->withAll) { - $orders = Plugin::getInstance()->getCustomers()->eagerLoadCustomerForOrders($orders); - } - - // Eager-load addresses? - if ($this->withAddresses === true || $this->withAll) { - $orders = Plugin::getInstance()->getOrders()->eagerLoadAddressesForOrders($orders); - } - - $orders = Plugin::getInstance()->getOrderNotices()->eagerLoadOrderNoticesForOrders($orders); - } - - return $orders; - } - - /** - * @inheritdoc - */ - protected function beforePrepare(): bool - { - $this->joinElementTable('commerce_orders'); - - $this->query->select([ - 'commerce_orders.id', - 'commerce_orders.storeId', - 'commerce_orders.number', - 'commerce_orders.reference', - 'commerce_orders.couponCode', - 'commerce_orders.orderStatusId', - 'commerce_orders.dateOrdered', - - // @TODO Remove in Commerce 6.0 once the `email` column is dropped from `commerce_orders` (email now lives on the customer) - 'commerce_orders.email', - - 'commerce_orders.isCompleted', - 'commerce_orders.datePaid', - 'commerce_orders.dateFirstPaid', - 'commerce_orders.currency', - 'commerce_orders.paymentCurrency', - 'commerce_orders.lastIp', - 'commerce_orders.orderLanguage', - 'commerce_orders.message', - 'commerce_orders.returnUrl', - 'commerce_orders.cancelUrl', - 'commerce_orders.billingAddressId', - 'commerce_orders.shippingAddressId', - 'commerce_orders.estimatedBillingAddressId', - 'commerce_orders.estimatedShippingAddressId', - 'commerce_orders.sourceBillingAddressId', - 'commerce_orders.sourceShippingAddressId', - 'commerce_orders.shippingMethodHandle', - 'commerce_orders.gatewayId', - 'commerce_orders.paymentSourceId', - 'commerce_orders.customerId', - 'commerce_orders.customerDeleted', - 'commerce_orders.dateUpdated', - 'commerce_orders.registerUserOnOrderComplete', - 'commerce_orders.saveBillingAddressOnOrderComplete', - 'commerce_orders.saveShippingAddressOnOrderComplete', - 'commerce_orders.saveShippingAddressOnOrderComplete', - 'commerce_orders.makePrimaryShippingAddress', - 'commerce_orders.makePrimaryBillingAddress', - 'commerce_orders.recalculationMode', - 'commerce_orders.origin', - 'commerce_orders.dateAuthorized', - 'storedTotalPrice' => 'commerce_orders.totalPrice', - 'storedTotalPaid' => 'commerce_orders.totalPaid', - 'storedItemTotal' => 'commerce_orders.itemTotal', - 'storedTotalDiscount' => 'commerce_orders.totalDiscount', - 'storedTotalShippingCost' => 'commerce_orders.totalShippingCost', - 'storedTotalTax' => 'commerce_orders.totalTax', - 'storedTotalTaxIncluded' => 'commerce_orders.totalTaxIncluded', - 'storedItemSubtotal' => 'commerce_orders.itemSubtotal', - 'storedTotalQty' => 'commerce_orders.totalQty', - 'commerce_orders.shippingMethodName', - 'commerce_orders.orderSiteId', - 'commerce_orders.orderLanguage', - 'commerce_orders.orderCompletedEmail', - ]); - - // Addresses table joined for sorting purposes - $this->query->leftJoin(CraftTable::ADDRESSES . ' billing_address', '[[billing_address.id]] = [[commerce_orders.billingAddressId]]'); - $this->subQuery->leftJoin(CraftTable::ADDRESSES . ' billing_address', '[[billing_address.id]] = [[commerce_orders.billingAddressId]]'); - $this->query->leftJoin(CraftTable::ADDRESSES . ' shipping_address', '[[shipping_address.id]] = [[commerce_orders.shippingAddressId]]'); - $this->subQuery->leftJoin(CraftTable::ADDRESSES . ' shipping_address', '[[shipping_address.id]] = [[commerce_orders.shippingAddressId]]'); - - if (isset($this->number)) { - // If it's set to anything besides a non-empty string, abort the query - if (!is_string($this->number) || $this->number === '') { - return false; - } - $this->subQuery->andWhere(['commerce_orders.number' => $this->number]); - } - - if (isset($this->shortNumber)) { - // If it's set to anything besides a non-empty string, abort the query - if (!is_string($this->shortNumber) || $this->shortNumber === '') { - return false; - } - - $this->subQuery->andWhere(new Expression('LEFT([[commerce_orders.number]], 7) = :shortNumber', [':shortNumber' => $this->shortNumber])); - } - - if (isset($this->storeId) && $this->storeId) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.storeId', $this->storeId)); - } - - if (isset($this->origin) && $this->origin) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.origin', $this->origin)); - } - - if (isset($this->reference) && $this->reference) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.reference', $this->reference)); - } - - if (isset($this->couponCode)) { - // Coupon code criteria is case-insensitive like in the adjuster - $this->subQuery->andWhere(Db::parseParam('commerce_orders.couponCode', $this->couponCode, caseInsensitive: true)); - } - - if (isset($this->email) && $this->email) { - // Join and search the users table for email address - $this->subQuery->leftJoin(CraftTable::USERS . ' users', '[[users.id]] = [[commerce_orders.customerId]]'); - $this->subQuery->andWhere(Db::parseParam('users.email', $this->email, '=', true)); - } - - if (isset($this->isCompleted)) { - $this->subQuery->andWhere(Db::parseBooleanParam('commerce_orders.isCompleted', $this->isCompleted, false)); - } - - if (isset($this->dateAuthorized)) { - $this->subQuery->andWhere(Db::parseDateParam('commerce_orders.dateAuthorized', $this->datePaid)); - } - - if (isset($this->dateOrdered)) { - $this->subQuery->andWhere(Db::parseDateParam('commerce_orders.dateOrdered', $this->dateOrdered)); - } - - if (isset($this->datePaid)) { - $this->subQuery->andWhere(Db::parseDateParam('commerce_orders.datePaid', $this->datePaid)); - } - - if (isset($this->dateFirstPaid)) { - $this->subQuery->andWhere(Db::parseDateParam('commerce_orders.dateFirstPaid', $this->dateFirstPaid)); - } - - if (isset($this->expiryDate)) { - $this->subQuery->andWhere(Db::parseDateParam('commerce_orders.expiryDate', $this->expiryDate)); - } - - if (isset($this->orderStatusId)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.orderStatusId', $this->orderStatusId)); - } - - if (isset($this->shippingMethodHandle)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.shippingMethodHandle', $this->shippingMethodHandle)); - } - - if (isset($this->orderLanguage)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.orderLanguage', $this->orderLanguage)); - } - - if (isset($this->orderSiteId)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.orderSiteId', $this->orderSiteId)); - } - - if (isset($this->customerId)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.customerId', $this->customerId)); - } - - if (isset($this->gatewayId)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.gatewayId', $this->gatewayId)); - } - - if (isset($this->total)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.total', $this->total)); - } - - if (isset($this->totalPrice)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.totalPrice', $this->totalPrice)); - } - - if (isset($this->totalPaid)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.totalPaid', $this->totalPaid)); - } - - if (isset($this->itemTotal)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.itemTotal', $this->itemTotal)); - } - - if (isset($this->itemSubtotal)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.itemSubtotal', $this->itemSubtotal)); - } - - if (isset($this->totalQty)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.totalQty', $this->totalQty)); - } - - if (isset($this->totalWeight)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.totalWeight', $this->totalWeight)); - } - - if (isset($this->totalDiscount)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.totalDiscount', $this->totalDiscount)); - } - - if (isset($this->totalTax)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.totalTax', $this->totalTax)); - } - - // Allow true but not null - if (isset($this->isPaid) && $this->isPaid) { - $this->subQuery->andWhere(new Expression('[[commerce_orders.totalPaid]] >= [[commerce_orders.totalPrice]]')); - } - - // Allow true but not null - if (isset($this->isUnpaid) && $this->isUnpaid) { - $this->subQuery->andWhere(new Expression('[[commerce_orders.totalPaid]] < [[commerce_orders.totalPrice]]')); - } - - // Allow integer/PurchasableInterface object or array of integers/PurchasableInterface objects - if (isset($this->hasPurchasables)) { - $purchasableIds = []; - - if (!is_array($this->hasPurchasables)) { - $this->hasPurchasables = [$this->hasPurchasables]; - } - - foreach ($this->hasPurchasables as $purchasable) { - if ($purchasable instanceof PurchasableInterface) { - $purchasableIds[] = $purchasable->getId(); - } elseif (is_numeric($purchasable)) { - $purchasableIds[] = $purchasable; - } - } - - // Remove any blank purchasable IDs (if any) - $purchasableIds = array_filter($purchasableIds); - - $this->subQuery->andWhere([ - 'exists', - (new Query()) - ->from(['lineitems' => Table::LINEITEMS]) - ->where(new Expression('[[lineitems.orderId]] = [[elements.id]]')) - ->andWhere(['[[lineitems.purchasableId]]' => $purchasableIds]), - ]); - } - - if (isset($this->containsPurchasables)) { - $purchasableIds = []; - $purchasables = $this->containsPurchasables['purchasables']; - $match = $this->containsPurchasables['match']; - - if (!is_array($purchasables)) { - $purchasables = [$purchasables]; - } - - foreach ($purchasables as $purchasable) { - if ($purchasable instanceof PurchasableInterface) { - $purchasableIds[] = $purchasable->getId(); - } elseif (is_numeric($purchasable)) { - $purchasableIds[] = $purchasable; - } - } - - $purchasableIds = array_values(array_filter($purchasableIds)); - - if ($match === ContainsPurchasablesMatch::All || $match === ContainsPurchasablesMatch::Only) { - // Every requested purchasable must have its own line item (AND logic) - foreach ($purchasableIds as $id) { - $this->subQuery->andWhere([ - 'exists', - (new Query()) - ->from(['lineitems' => Table::LINEITEMS]) - ->where(new Expression('[[lineitems.orderId]] = [[elements.id]]')) - ->andWhere(['[[lineitems.purchasableId]]' => $id]), - ]); - } - - if ($match === ContainsPurchasablesMatch::Only) { - // No line items with a purchasable outside the set, and no custom line items - $this->subQuery->andWhere([ - 'not exists', - (new Query()) - ->from(['lineitems' => Table::LINEITEMS]) - ->where(new Expression('[[lineitems.orderId]] = [[elements.id]]')) - ->andWhere(['or', ['[[lineitems.purchasableId]]' => null], ['not', ['[[lineitems.purchasableId]]' => $purchasableIds]]]), - ]); - } - } else { - // ContainsPurchasablesMatch::Any: at least one of the purchasables must be in the order - $this->subQuery->andWhere([ - 'exists', - (new Query()) - ->from(['lineitems' => Table::LINEITEMS]) - ->where(new Expression('[[lineitems.orderId]] = [[elements.id]]')) - ->andWhere(['[[lineitems.purchasableId]]' => $purchasableIds]), - ]); - } - } - - // Allow true or false but not null - if (isset($this->hasTransactions)) { - $this->subQuery->andWhere([ - $this->hasTransactions ? 'exists' : 'not exists', - (new Query()) - ->from(['transactions' => Table::TRANSACTIONS]) - ->where(new Expression('[[transactions.orderId]] = [[elements.id]]')), - ]); - } - - // Allow true or false but not null - if (isset($this->hasLineItems)) { - $this->subQuery->andWhere([ - $this->hasLineItems ? 'exists' : 'not exists', - (new Query()) - ->from(['lineitems' => Table::LINEITEMS]) - ->where(new Expression('[[lineitems.orderId]] = [[elements.id]]')), - ]); - } - - if (isset($this->hasAdminNotices)) { - $this->subQuery->andWhere([ - $this->hasAdminNotices ? 'exists' : 'not exists', - (new Query()) - ->select([new Expression('1')]) - ->from(['adminNotices' => Table::ORDERNOTICES]) - ->where(new Expression('[[adminNotices.orderId]] = [[elements.id]]')) - ->andWhere(['adminNotices.noticeType' => OrderNoticeType::Admin->value]), - ]); - } - - return parent::beforePrepare(); - } -} diff --git a/src/elements/db/ProductQuery.php b/src/elements/db/ProductQuery.php deleted file mode 100644 index 17e51f00c7..0000000000 --- a/src/elements/db/ProductQuery.php +++ /dev/null @@ -1,1058 +0,0 @@ - - * - * @method Product[]|array all($db = null) - * @method Product|array|null one($db = null) - * @method Product|array|null nth(int $n, Connection $db = null) - * @author Pixel & Tonic, Inc. - * @since 2.0 - * @doc-path products-variants.md - * @prefix-doc-params - * @replace {element} product - * @replace {elements} products - * @replace {twig-method} craft.products() - * @replace {myElement} myProduct - * @replace {element-class} \craft\commerce\elements\Product - * @supports-site-params - * @supports-title-param - * @supports-slug-param - * @supports-uri-param - * @supports-status-param - * @supports-structure-params - */ -class ProductQuery extends ElementQuery -{ - /** - * @var bool|null Whether to only return products that the user has permission to view. - * @used-by editable() - */ - public ?bool $editable = null; - - /** - * @var bool|null Whether to only return products that the user has permission to save. - * @used-by savable() - * @since 5.6.0 - */ - public ?bool $savable = null; - - /** - * @var mixed The Post Date that the resulting products must have. - */ - public mixed $expiryDate = null; - - /** - * @var mixed The default price the resulting products must have. - */ - public mixed $defaultPrice = null; - - /** - * @var mixed The default height the resulting products must have. - */ - public mixed $defaultHeight = null; - - /** - * @var mixed The default length the resulting products must have. - */ - public mixed $defaultLength = null; - - /** - * @var mixed The default width the resulting products must have. - */ - public mixed $defaultWidth = null; - - /** - * @var mixed The default weight the resulting products must have. - */ - public mixed $defaultWeight = null; - - /** - * @var mixed The default sku the resulting products must have. - */ - public mixed $defaultSku = null; - - /** - * @var mixed only return products that match the resulting variant query. - */ - public mixed $hasVariant = null; - - /** - * @var mixed The Post Date that the resulting products must have. - */ - public mixed $postDate = null; - - /** - * @var mixed The product type ID(s) that the resulting products must have. - */ - public mixed $typeId = null; - - /** - * @inheritdoc - */ - protected array $defaultOrderBy = [ - 'commerce_products.postDate' => SORT_DESC, - 'elements.id' => SORT_DESC, - ]; - - /** - * @inheritdoc - */ - public function __construct($elementType, array $config = []) - { - // Default status - if (!isset($config['status'])) { - $config['status'] = 'live'; - } - - parent::__construct($elementType, $config); - } - - /** - * @inheritdoc - */ - public function init(): void - { - if (!isset($this->withStructure)) { - $this->withStructure = true; - } - - parent::init(); - } - - - /** - * @inheritdoc - */ - public function __set($name, $value) - { - match ($name) { - 'type' => $this->type($value), - 'before' => $this->before($value), - 'after' => $this->after($value), - 'defaultHeight' => $this->defaultHeight($value), - 'defaultLength' => $this->defaultLength($value), - 'defaultWidth' => $this->defaultWidth($value), - 'defaultWeight' => $this->defaultWeight($value), - 'defaultSku' => $this->defaultSku($value), - default => parent::__set($name, $value), - }; - } - - /** - * Narrows the query results based on the products’ default variant price. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `10` | of a price of 10. - * | `['and', '>= ' ~ 100, '<= ' ~ 2000]` | of a default variant price between 100 and 2000 - * - * --- - * - * ```twig - * {# Fetch {elements} of the product type with an ID of 1 #} - * {% set {elements-var} = {twig-method} - * .defaultPrice(1) - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} of the product type with an ID of 1 - * ${elements-var} = {php-method} - * ->defaultPrice(1) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function defaultPrice(mixed $value): static - { - $this->defaultPrice = $value; - - return $this; - } - - /** - * Narrows the query results based on the products’ default variant height dimension IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | of a type with a dimension of 1. - * | `'not 1'` | not a dimension of 1. - * | `[1, 2]` | of a a dimension 1 or 2. - * | `['and', '>= ' ~ 100, '<= ' ~ 2000]` | of a dimension between 100 and 2000 - * - * --- - * - * ```twig - * {# Fetch {elements} of the product default dimension of 1 #} - * {% set {elements-var} = {twig-method} - * .defaultHeight(1) - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} of the product default dimension of 1 - * ${elements-var} = {php-method} - * ->defaultHeight(1) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function defaultHeight(mixed $value): static - { - $this->defaultHeight = $value; - - return $this; - } - - /** - * Narrows the query results based on the products’ default variant length dimension IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | of a type with a dimension of 1. - * | `'not 1'` | not a dimension of 1. - * | `[1, 2]` | of a a dimension 1 or 2. - * | `['and', '>= ' ~ 100, '<= ' ~ 2000]` | of a dimension between 100 and 2000 - * - * --- - * - * ```twig - * {# Fetch {elements} of the product default dimension of 1 #} - * {% set {elements-var} = {twig-method} - * .defaultLength(1) - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} of the product default dimension of 1 - * ${elements-var} = {php-method} - * ->defaultLength(1) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function defaultLength(mixed $value): static - { - $this->defaultLength = $value; - - return $this; - } - - /** - * Narrows the query results based on the products’ default variant width dimension IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | of a type with a dimension of 1. - * | `'not 1'` | not a dimension of 1. - * | `[1, 2]` | of a a dimension 1 or 2. - * | `['and', '>= ' ~ 100, '<= ' ~ 2000]` | of a dimension between 100 and 2000 - * - * --- - * - * ```twig - * {# Fetch {elements} of the product default dimension of 1 #} - * {% set {elements-var} = {twig-method} - * .defaultWidth(1) - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} of the product default dimension of 1 - * ${elements-var} = {php-method} - * ->defaultWidth(1) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function defaultWidth(mixed $value): static - { - $this->defaultWidth = $value; - - return $this; - } - - /** - * Narrows the query results based on the products’ default variant weight dimension IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | of a type with a dimension of 1. - * | `'not 1'` | not a dimension of 1. - * | `[1, 2]` | of a a dimension 1 or 2. - * | `['and', '>= ' ~ 100, '<= ' ~ 2000]` | of a dimension between 100 and 2000 - * - * --- - * - * ```twig - * {# Fetch {elements} of the product default dimension of 1 #} - * {% set {elements-var} = {twig-method} - * .defaultWeight(1) - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} of the product default dimension of 1 - * ${elements-var} = {php-method} - * ->defaultWeight(1) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function defaultWeight(mixed $value): static - { - $this->defaultWeight = $value; - - return $this; - } - - /** - * Narrows the query results based on the default productvariants defaultSku - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `xxx-001` | of products default SKU of `xxx-001`. - * | `'not xxx-001'` | not a default SKU of `xxx-001`. - * | `['not xxx-001', 'not xxx-002']` | of a default SKU of xxx-001 or xxx-002. - * | `['not', `xxx-001`, `xxx-002`]` | not a product default SKU of `xxx-001` or `xxx-001`. - * - * --- - * - * ```twig - * {# Fetch {elements} of the product default SKU of `xxx-001` #} - * {% set {elements-var} = {twig-method} - * .defaultSku('xxx-001') - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} of the product default SKU of `xxx-001` - * ${elements-var} = {php-method} - * ->defaultSku('xxx-001') - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function defaultSku(mixed $value): static - { - $this->defaultSku = $value; - - return $this; - } - - /** - * Narrows the query results based on the products’ types. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'foo'` | of a type with a handle of `foo`. - * | `'not foo'` | not of a type with a handle of `foo`. - * | `['foo', 'bar']` | of a type with a handle of `foo` or `bar`. - * | `['not', 'foo', 'bar']` | not of a type with a handle of `foo` or `bar`. - * | an [[ProductType|ProductType]] object | of a type represented by the object. - * - * --- - * - * ```twig - * {# Fetch {elements} with a Foo product type #} - * {% set {elements-var} = {twig-method} - * .type('foo') - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} with a Foo product type - * ${elements-var} = {php-method} - * ->type('foo') - * ->all(); - * ``` - * - * @param ProductType|string|null|array $value The property value - * @return static self reference - */ - public function type(mixed $value): static - { - // If the value is a product type handle, swap it with the product type - if (is_string($value) && ($productType = Plugin::getInstance()->getProductTypes()->getProductTypeByHandle($value))) { - $value = $productType; - } - - if ($value instanceof ProductType) { - $this->typeId = [$value->id]; - } elseif ($value !== null) { - $this->typeId = (new Query()) - ->select(['id']) - ->from([Table::PRODUCTTYPES]) - ->where(Db::parseParam('handle', $value)) - ->column(); - } else { - $this->typeId = null; - } - - return $this; - } - - /** - * Narrows the query results to only products that were posted before a certain date. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'2018-04-01'` | that were posted before 2018-04-01. - * | a [[\DateTime|DateTime]] object | that were posted before the date represented by the object. - * - * --- - * - * ```twig - * {# Fetch {elements} posted before this month #} - * {% set firstDayOfMonth = date('first day of this month') %} - * - * {% set {elements-var} = {twig-method} - * .before(firstDayOfMonth) - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} posted before this month - * $firstDayOfMonth = new \DateTime('first day of this month'); - * - * ${elements-var} = {php-method} - * ->before($firstDayOfMonth) - * ->all(); - * ``` - * - * @param string|DateTime $value The property value - * @return static self reference - */ - public function before(DateTime|string $value): static - { - if ($value instanceof DateTime) { - $value = $value->format(DateTime::W3C); - } - - $this->postDate = ArrayHelper::toArray($this->postDate); - $this->postDate[] = '<' . $value; - - return $this; - } - - /** - * Narrows the query results to only products that were posted on or after a certain date. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'2018-04-01'` | that were posted after 2018-04-01. - * | a [[\DateTime|DateTime]] object | that were posted after the date represented by the object. - * - * --- - * - * ```twig - * {# Fetch {elements} posted this month #} - * {% set firstDayOfMonth = date('first day of this month') %} - * - * {% set {elements-var} = {twig-method} - * .after(firstDayOfMonth) - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} posted this month - * $firstDayOfMonth = new \DateTime('first day of this month'); - * - * ${elements-var} = {php-method} - * ->after($firstDayOfMonth) - * ->all(); - * ``` - * - * @param string|DateTime $value The property value - * @return static self reference - */ - public function after(DateTime|string $value): static - { - if ($value instanceof DateTime) { - $value = $value->format(DateTime::W3C); - } - - $this->postDate = ArrayHelper::toArray($this->postDate); - $this->postDate[] = '>=' . $value; - - return $this; - } - - /** - * Sets the [[$editable]] property. - * - * @param bool|null $value The property value (defaults to true) - * @return static self reference - * @uses $editable - */ - public function editable(?bool $value = true): static - { - $this->editable = $value; - return $this; - } - - /** - * Sets the [[$savable]] property. - * - * @param bool|null $value The property value (defaults to true) - * @return static self reference - * @uses $savable - * @since 5.6.0 - */ - public function savable(?bool $value = true): static - { - $this->savable = $value; - return $this; - } - - /** - * Narrows the query results based on the products’ types, per the types’ IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | of a type with an ID of 1. - * | `'not 1'` | not of a type with an ID of 1. - * | `[1, 2]` | of a type with an ID of 1 or 2. - * | `['not', 1, 2]` | not of a type with an ID of 1 or 2. - * - * --- - * - * ```twig - * {# Fetch {elements} of the product type with an ID of 1 #} - * {% set {elements-var} = {twig-method} - * .typeId(1) - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} of the product type with an ID of 1 - * ${elements-var} = {php-method} - * ->typeId(1) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function typeId(mixed $value): static - { - $this->typeId = $value; - return $this; - } - - /** - * Narrows the query results to only products that have certain variants. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | a [[VariantQuery]] object | with variants that match the query. - * | a configuration [[array]] for a [[VariantQuery]] | with variants that match the criteria. - * - * @param VariantQuery|array $value The property value - * @return static self reference - * @noinspection PhpUnused - */ - public function hasVariant(mixed $value): static - { - $this->hasVariant = $value; - return $this; - } - - /** - * Narrows the query results based on the products’ post dates. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'>= 2018-04-01'` | that were posted on or after 2018-04-01. - * | `'< 2018-05-01'` | that were posted before 2018-05-01 - * | `['and', '>= 2018-04-04', '< 2018-05-01']` | that were posted between 2018-04-01 and 2018-05-01. - * - * --- - * - * ```twig - * {# Fetch {elements} posted last month #} - * {% set start = date('first day of last month')|atom %} - * {% set end = date('first day of this month')|atom %} - * - * {% set {elements-var} = {twig-method} - * .postDate(['and', ">= #{start}", "< #{end}"]) - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} posted last month - * $start = new \DateTime('first day of next month')->format(\DateTime::ATOM); - * $end = new \DateTime('first day of this month')->format(\DateTime::ATOM); - * - * ${elements-var} = {php-method} - * ->postDate(['and', ">= {$start}", "< {$end}"]) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function postDate(mixed $value): static - { - $this->postDate = $value; - return $this; - } - - /** - * Narrows the query results based on the products’ expiry dates. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'>= 2020-04-01'` | that will expire on or after 2020-04-01. - * | `'< 2020-05-01'` | that will expire before 2020-05-01 - * | `['and', '>= 2020-04-04', '< 2020-05-01']` | that will expire between 2020-04-01 and 2020-05-01. - * - * --- - * - * ```twig - * {# Fetch {elements} expiring this month #} - * {% set nextMonth = date('first day of next month')|atom %} - * - * {% set {elements-var} = {twig-method} - * .expiryDate("< #{nextMonth}") - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} expiring this month - * $nextMonth = new \DateTime('first day of next month')->format(\DateTime::ATOM); - * - * ${elements-var} = {php-method} - * ->expiryDate("< {$nextMonth}") - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function expiryDate(mixed $value): static - { - $this->expiryDate = $value; - return $this; - } - - /** - * Narrows the query results based on the {elements}’ statuses. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'live'` _(default)_ | that are live. - * | `'pending'` | that are pending (enabled with a Post Date in the future). - * | `'expired'` | that are expired (enabled with an Expiry Date in the past). - * | `'disabled'` | that are disabled. - * | `['live', 'pending']` | that are live or pending. - * - * --- - * - * ```twig - * {# Fetch disabled {elements} #} - * {% set {elements-var} = {twig-method} - * .status('disabled') - * .all() %} - * ``` - * - * ```php - * // Fetch disabled {elements} - * ${elements-var} = {element-class}::find() - * ->status('disabled') - * ->all(); - * ``` - */ - public function status(array|string|null $value): static - { - parent::status($value); - return $this; - } - - /** - * @inheritdoc - */ - protected function afterPrepare(): bool - { - // Store dependent related joins to the sub query need to be done after the `elements_sites` is joined in the base `ElementQuery` class. - $customerId = Craft::$app->getUser()->getIdentity()?->id; - - $this->subQuery->leftJoin(['sitestores' => Table::SITESTORES], '[[elements_sites.siteId]] = [[sitestores.siteId]]'); - - if (Plugin::getInstance()->getCatalogPricingRules()->hasCatalogPricingRules()) { - $catalogPricesQuery = Plugin::getInstance() - ->getCatalogPricing() - ->createCatalogPricesQuery(userId: $customerId) - ->addSelect(['cp.purchasableId', 'cp.storeId']); - - $this->subQuery->leftJoin(['catalogprices' => $catalogPricesQuery], '[[catalogprices.purchasableId]] = [[commerce_products.defaultVariantId]] AND [[catalogprices.storeId]] = [[sitestores.storeId]]'); - } else { - // For speed in Postgres we only need this if the `defaultPrice` criteria is being used. - if (isset($this->defaultPrice)) { - $this->subQuery->leftJoin(['purchasablesstores' => Table::PURCHASABLES_STORES], '[[purchasablesstores.storeId]] = [[sitestores.storeId]] AND [[purchasablesstores.purchasableId]] = [[commerce_products.defaultVariantId]]'); - } - } - - return parent::afterPrepare(); - } - - /** - * @inheritdoc - * @throws QueryAbortedException - */ - protected function beforePrepare(): bool - { - $this->_normalizeTypeId(); - - // See if 'type' were set to invalid handles - if ($this->typeId === []) { - return false; - } - - $this->joinElementTable('commerce_products'); - - $this->query->select([ - 'commerce_products.id', - 'commerce_products.typeId', - 'commerce_products.postDate', - 'commerce_products.expiryDate', - 'purchasablesstores.basePrice as defaultBasePrice', - 'purchasablesstores.basePromotionalPrice as defaultBasePromotionalPrice', - 'commerce_products.defaultVariantId', - 'purchasables.sku as defaultSku', - 'purchasables.weight as defaultWeight', - 'purchasables.length as defaultLength', - 'purchasables.width as defaultWidth', - 'purchasables.height as defaultHeight', - 'sitestores.storeId', - ]); - - // Join in sites stores to get product's store for current request - $this->query->leftJoin(['sitestores' => Table::SITESTORES], '[[elements_sites.siteId]] = [[sitestores.siteId]]'); - $this->query->leftJoin(['purchasables' => Table::PURCHASABLES], '[[purchasables.id]] = [[commerce_products.defaultVariantId]]'); - $this->query->leftJoin(['purchasablesstores' => Table::PURCHASABLES_STORES], '[[purchasablesstores.purchasableId]] = [[commerce_products.defaultVariantId]] and [[sitestores.storeId]] = [[purchasablesstores.storeId]]'); - - // Tailor the query based on whether or not there is catalog pricing rules - if (Plugin::getInstance()->getCatalogPricingRules()->hasCatalogPricingRules()) { - $this->query->addSelect(['subquery.price as defaultPrice']); - $this->subQuery->addSelect(['catalogprices.price']); - - if (isset($this->defaultPrice)) { - $this->subQuery->andWhere(Db::parseParam('catalogprices.price', $this->defaultPrice)); - } - } else { - $this->query->addSelect(['purchasablesstores.basePrice as defaultPrice']); - - if (isset($this->defaultPrice)) { - $this->subQuery->andWhere(Db::parseParam('purchasablesstores.basePrice', $this->defaultPrice)); - } - } - - if (isset($this->postDate)) { - $this->subQuery->andWhere(Db::parseDateParam('commerce_products.postDate', $this->postDate)); - } - - if (isset($this->expiryDate)) { - $this->subQuery->andWhere(Db::parseDateParam('commerce_products.expiryDate', $this->expiryDate)); - } - - $this->_applyProductTypeIdParam(); - - if (isset($this->defaultHeight) || isset($this->defaultLength) || isset($this->defaultWidth) || isset($this->defaultWeight) || isset($this->defaultSku)) { - $this->subQuery->leftJoin(['purchasables' => Table::PURCHASABLES], '[[purchasables.id]] = [[commerce_products.defaultVariantId]]'); - } - - if (isset($this->defaultHeight)) { - $this->subQuery->andWhere(Db::parseParam('purchasables.height', $this->defaultHeight)); - } - - if (isset($this->defaultLength)) { - $this->subQuery->andWhere(Db::parseParam('purchasables.length', $this->defaultLength)); - } - - if (isset($this->defaultWidth)) { - $this->subQuery->andWhere(Db::parseParam('purchasables.width', $this->defaultWidth)); - } - - if (isset($this->defaultWeight)) { - $this->subQuery->andWhere(Db::parseParam('purchasables.weight', $this->defaultWeight)); - } - - if (isset($this->defaultSku)) { - $this->subQuery->andWhere(Db::parseParam('purchasables.sku', $this->defaultSku)); - } - - $this->_applyHasVariantParam(); - // Mirrors EntryQuery: "editable" means accessible in the editing UI (view permission), - // not necessarily savable. Use ->savable() to filter by save permission. - $this->_applyPermissionParam($this->editable, 'commerce-viewProductType'); - $this->_applyPermissionParam($this->savable, 'commerce-saveProductType'); - $this->_applyRefParam(); - - return parent::beforePrepare(); - } - - /** - * @inheritdoc - */ - protected function statusCondition(string $status): mixed - { - return ProductQueryHelper::statusCondition($status); - } - - /** - * Normalizes the typeId param to an array of IDs or null - */ - private function _normalizeTypeId(): void - { - if (empty($this->typeId)) { - $this->typeId = is_array($this->typeId) ? [] : null; - } elseif (is_numeric($this->typeId)) { - $this->typeId = [$this->typeId]; - } elseif (!is_array($this->typeId) || !ArrayHelper::isNumeric($this->typeId)) { - $this->typeId = (new Query()) - ->select(['id']) - ->from([Table::PRODUCTTYPES]) - ->where(Db::parseParam('id', $this->typeId)) - ->column(); - } - } - - /** - * Applies an authorization param to the query being prepared. - * - * @param bool|null $value - * @param string $permissionPrefix - * @throws QueryAbortedException - */ - private function _applyPermissionParam(?bool $value, string $permissionPrefix): void - { - if ($value === null) { - return; - } - - $user = Craft::$app->getUser()->getIdentity(); - - if (!$user) { - throw new QueryAbortedException(); - } - - $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); - - if (empty($productTypes)) { - return; - } - - $authorizedTypeIds = []; - - foreach ($productTypes as $productType) { - if ($user->can("$permissionPrefix:$productType->uid")) { - $authorizedTypeIds[] = $productType->id; - } - } - - if (count($authorizedTypeIds) === count($productTypes)) { - // They have access to everything - if (!$value) { - throw new QueryAbortedException(); - } - return; - } - - if (empty($authorizedTypeIds)) { - // They don't have access to anything - if ($value) { - throw new QueryAbortedException(); - } - return; - } - - $condition = ['commerce_products.typeId' => $authorizedTypeIds]; - - if (!$value) { - $condition = ['not', $condition]; - } - - $this->subQuery->andWhere($condition); - } - - /** - * Applies the 'productTypeId' param to the query being prepared. - */ - private function _applyProductTypeIdParam(): void - { - if ($this->typeId) { - $this->subQuery->andWhere(['commerce_products.typeId' => $this->typeId]); - - // Should we set the structureId param? - if ( - $this->withStructure !== false && - !isset($this->structureId) && - count($this->typeId) === 1 - ) { - $productType = Plugin::getInstance()->getProductTypes()->getProductTypeById(reset($this->typeId)); - if ($productType && $productType->isStructure) { - $this->structureId = $productType->structureId; - } else { - $this->withStructure = false; - } - } - } - } - - /** - * Applies the hasVariant query condition - */ - private function _applyHasVariantParam(): void - { - if ($this->hasVariant === null) { - return; - } - - if ($this->hasVariant instanceof VariantQuery) { - $variantQuery = $this->hasVariant; - } elseif (is_array($this->hasVariant)) { - $query = Variant::find(); - - $criteria = ProductQueryHelper::cleanseQueryCriteria($this->hasVariant); - - $variantQuery = Craft::configure($query, $criteria); - } else { - throw new QueryAbortedException('Invalid param used. ProductQuery::hasVariant param only expects a variant query or variant query config.'); - } - - $variantQuery->limit = null; - $variantQuery->select('commerce_variants.primaryOwnerId'); - - // Remove any blank product IDs (if any) - $variantQuery->andWhere(['not', ['commerce_variants.primaryOwnerId' => null]]); - - // Uses exists subquery for speed to check for the variant - $existsQuery = (new Query()) - ->from(['existssub' => $variantQuery]) - ->where(['existssub.primaryOwnerId' => new Expression('[[commerce_products.id]]')]); - $this->subQuery->andWhere(['exists', $existsQuery]); - } - - /** - * Applies the 'ref' param to the query being prepared. - */ - private function _applyRefParam(): void - { - if (!$this->ref) { - return; - } - - $refs = ArrayHelper::toArray($this->ref); - $joinSections = false; - $condition = ['or']; - - foreach ($refs as $ref) { - $parts = array_filter(explode('/', $ref)); - - if (!empty($parts)) { - if (count($parts) == 1) { - $condition[] = Db::parseParam('elements_sites.slug', $parts[0]); - } else { - $condition[] = [ - 'and', - Db::parseParam('commerce_producttypes.handle', $parts[0]), - Db::parseParam('elements_sites.slug', $parts[1]), - ]; - $joinSections = true; - } - } - } - - $this->subQuery->andWhere($condition); - - if ($joinSections) { - $this->subQuery->innerJoin(Table::PRODUCTTYPES . ' commerce_producttypes', '[[producttypes.id]] = [[products.typeId]]'); - } - } - - /** - * @inheritdoc - * @since 3.5.0 - */ - protected function cacheTags(): array - { - $tags = []; - - if ($this->typeId) { - foreach ($this->typeId as $typeId) { - $tags[] = "productType:$typeId"; - } - } - - return $tags; - } -} diff --git a/src/elements/db/PurchasableQuery.php b/src/elements/db/PurchasableQuery.php deleted file mode 100755 index 0b15d89ffa..0000000000 --- a/src/elements/db/PurchasableQuery.php +++ /dev/null @@ -1,923 +0,0 @@ - - * - * @method Purchasable[]|array all($db = null) - * @method Purchasable|array|null one($db = null) - * @method Purchasable|array|null nth(int $n, Connection $db = null) - * @since 5.0.0 - */ -abstract class PurchasableQuery extends ElementQuery -{ - protected array $defaultOrderBy = ['commerce_purchasables.sku' => SORT_ASC]; - - /** - * @var bool|null Whether the purchasable is available for purchase - */ - public ?bool $availableForPurchase = null; - - /** - * @var mixed the SKU of the variant - */ - public mixed $sku = null; - - /** - * @var mixed|null - */ - public mixed $price = null; - - /** - * @var mixed|null - */ - public mixed $promotionalPrice = null; - - /** - * @var bool|null - * @since 5.2.0 - */ - public bool|null $onPromotion = null; - - /** - * @var mixed|null - */ - public mixed $salePrice = null; - - /** - * @var mixed - */ - public mixed $width = false; - - /** - * @var mixed - */ - public mixed $height = false; - - /** - * @var mixed - */ - public mixed $length = false; - - /** - * @var mixed - */ - public mixed $weight = false; - - /** - * @var mixed - */ - public mixed $stock = null; - - /** - * @var bool|null - */ - public ?bool $hasStock = null; - - /** - * @var bool|null - */ - public ?bool $hasUnlimitedStock = null; - - /** - * @var mixed The shipping category ID(s) that the resulting products must have. - */ - public mixed $shippingCategoryId = null; - - /** - * @var mixed The tax category ID(s) that the resulting products must have. - */ - public mixed $taxCategoryId = null; - - /** - * @var int|false|null - */ - public int|false|null $forCustomer = null; - - /** - * @inheritdoc - */ - public function __set($name, $value) - { - match ($name) { - 'shippingCategory' => $this->shippingCategory($value), - default => parent::__set($name, $value), - }; - } - - /** - * Narrows the query results to only purchasables that are available for purchase. - * - * --- - * - * ```twig - * {# Fetch purchasables that are available for purchase #} - * {% set {elements-var} = {twig-method} - * .availableForPurchase() - * .all() %} - * ``` - * - * ```php - * // Fetch purchasables that are available for purchase - * ${elements-var} = {element-class}::find() - * ->availableForPurchase() - * ->all(); - * ``` - * - * @param bool|null $value The property value - * @return static self reference - */ - public function availableForPurchase(?bool $value = true): static - { - $this->availableForPurchase = $value; - return $this; - } - - /** - * Narrows the query results based on the {elements}’ SKUs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'foo'` | with a SKU of `foo`. - * | `'foo*'` | with a SKU that begins with `foo`. - * | `'*foo'` | with a SKU that ends with `foo`. - * | `'*foo*'` | with a SKU that contains `foo`. - * | `'not *foo*'` | with a SKU that doesn’t contain `foo`. - * | `['*foo*', '*bar*'` | with a SKU that contains `foo` or `bar`. - * | `['not', '*foo*', '*bar*']` | with a SKU that doesn’t contain `foo` or `bar`. - * - * --- - * - * ```twig - * {# Get the requested {element} SKU from the URL #} - * {% set requestedSlug = craft.app.request.getSegment(3) %} - * - * {# Fetch the {element} with that slug #} - * {% set {element-var} = {twig-method} - * .sku(requestedSlug|literal) - * .one() %} - * ``` - * - * ```php - * // Get the requested {element} SKU from the URL - * $requestedSlug = \Craft::$app->request->getSegment(3); - * - * // Fetch the {element} with that slug - * ${element-var} = {php-method} - * ->sku(\craft\helpers\Db::escapeParam($requestedSlug)) - * ->one(); - * ``` - * - * @return static self reference - */ - public function sku(mixed $value): static - { - $this->sku = $value; - return $this; - } - - /** - * Narrows the query results to only variants that have been set to unlimited stock. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `true` | with unlimited stock checked. - * | `false` | with unlimited stock not checked. - * - * @param bool|null $value - * @return static self reference - * @noinspection PhpUnused - */ - public mixed $inventoryTracked = null; - - /** - * Narrows the query results based on the variants’ stock. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `0` | with no stock. - * | `'>= 5'` | with a stock of at least 5. - * | `'< 10'` | with a stock of less than 10. - * - * @param mixed $value The property value - * @return static self reference - */ - public function stock(mixed $value): static - { - $this->stock = $value; - return $this; - } - - /** - * Narrows the query results to only variants that have stock. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `true` | with stock. - * | `false` | with no stock. - * - * @param bool|null $value - * @return static self reference - */ - public function hasStock(?bool $value = true): static - { - $this->hasStock = $value; - return $this; - } - - /** - * Narrows the pricing query results to only prices related for the specified customer. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | with user ID of `1`. - * | `false` | with prices for guest customers. - * | `null` | with prices for current user scenario. - * - * @param int|false|null $value - * @return static self reference - * @noinspection PhpUnused - */ - public function forCustomer(int|false|null $value = null): static - { - $this->forCustomer = $value; - return $this; - } - - /** - * Narrows the query results based on the variants’ width dimension. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `100` | with a width of 100. - * | `'>= 100'` | with a width of at least 100. - * | `'< 100'` | with a width of less than 100. - * - * @param mixed $value The property value - * @return static self reference - */ - public function width(mixed $value): static - { - $this->width = $value; - return $this; - } - - /** - * Narrows the query results based on the variants’ height dimension. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `100` | with a height of 100. - * | `'>= 100'` | with a height of at least 100. - * | `'< 100'` | with a height of less than 100. - * - * @param mixed $value The property value - * @return static self reference - */ - public function height(mixed $value): static - { - $this->height = $value; - return $this; - } - - /** - * Narrows the query results based on the variants’ length dimension. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `100` | with a length of 100. - * | `'>= 100'` | with a length of at least 100. - * | `'< 100'` | with a length of less than 100. - * - * @param mixed $value The property value - * @return static self reference - */ - public function length(mixed $value): static - { - $this->length = $value; - return $this; - } - - /** - * Narrows the query results based on the variants’ weight dimension. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `100` | with a weight of 100. - * | `'>= 100'` | with a weight of at least 100. - * | `'< 100'` | with a weight of less than 100. - * - * @param mixed $value The property value - * @return static self reference - */ - public function weight(mixed $value): static - { - $this->weight = $value; - return $this; - } - - /** - * Narrows the query results based on the purchasable’s price. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `100` | with a price of 100. - * | `'>= 100'` | with a price of at least 100. - * | `'< 100'` | with a price of less than 100. - * - * @param mixed $value The property value - * @return static self reference - */ - public function price(mixed $value): static - { - $this->price = $value; - return $this; - } - - /** - * Narrows the query results to only variants that have been set to not track stock. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `true` | with inventory tracked not checked. - * | `false` | with inventory tracked checked. - * - * @param bool|null $value - * @return static self reference - * @since 3.3.4 - * @noinspection PhpUnused - * @deprecated in 5.0.0. Use `inventoryTracked` instead. - */ - public function hasUnlimitedStock(?bool $value = true): static - { - $this->inventoryTracked = !$value; // reverse for backward compatibility - return $this; - } - - /** - * Narrows the query results to only variants that have been set to track stock. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `true` | with inventory tracked checked. - * | `false` | with inventory tracked not checked. - * - * @param bool|null $value - * @return static self reference - * @since 3.3.4 - * @noinspection PhpUnused - */ - public function inventoryTracked(?bool $value = true): static - { - $this->inventoryTracked = $value; - return $this; - } - - /** - * Narrows the query results based on the purchasable’s promotional price. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `100` | with a promotional price of 100. - * | `'>= 100'` | with a promotional price of at least 100. - * | `'< 100'` | with a promotional price of less than 100. - * - * @param mixed $value The property value - * @return static self reference - */ - public function promotionalPrice(mixed $value): static - { - $this->promotionalPrice = $value; - return $this; - } - - /** - * Narrows the query results based on the purchasable’s sale price. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `100` | with a sale price of 100. - * | `'>= 100'` | with a sale price of at least 100. - * | `'< 100'` | with a sale price of less than 100. - * - * @param mixed $value The property value - * @return static self reference - */ - public function salePrice(mixed $value): static - { - $this->salePrice = $value; - return $this; - } - - /** - * Narrows the query results based on the products’ shipping categories, per the shipping categories’ IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | of a shipping category with an ID of 1. - * | `'not 1'` | not of a shipping category with an ID of 1. - * | `[1, 2]` | of a shipping category with an ID of 1 or 2. - * | `['not', 1, 2]` | not of a shipping category with an ID of 1 or 2. - * - * --- - * - * ```twig - * {# Fetch {elements} of the shipping category with an ID of 1 #} - * {% set {elements-var} = {twig-method} - * .shippingCategoryId(1) - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} of the shipping category with an ID of 1 - * ${elements-var} = {php-method} - * ->shippingCategoryId(1) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function shippingCategoryId(mixed $value): static - { - $this->shippingCategoryId = $value; - return $this; - } - - /** - * Narrows the query results based on the products’ shipping category. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'foo'` | of a shipping category with a handle of `foo`. - * | `'not foo'` | not of a shipping category with a handle of `foo`. - * | `['foo', 'bar']` | of a shipping category with a handle of `foo` or `bar`. - * | `['not', 'foo', 'bar']` | not of a shipping category with a handle of `foo` or `bar`. - * | an [[ShippingCategory|ShippingCategory]] object | of a shipping category represented by the object. - * - * --- - * - * ```twig - * {# Fetch {elements} with a Foo shipping category #} - * {% set {elements-var} = {twig-method} - * .shippingCategory('foo') - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} with a Foo shipping category - * ${elements-var} = {php-method} - * ->shippingCategory('foo') - * ->all(); - * ``` - * - * @param ShippingCategory|string|null|array $value The property value - * @return static self reference - */ - public function shippingCategory(mixed $value): static - { - if ($value instanceof ShippingCategory) { - $this->shippingCategoryId = [$value->id]; - } elseif ($value !== null) { - $this->shippingCategoryId = (new Query()) - ->from(['shippingcategories' => Table::SHIPPINGCATEGORIES]) - ->where(['shippingcategories.id' => new Expression('[[purchasables_stores.shippingCategoryId]]')]) - ->andWhere(Db::parseParam('handle', $value)); - } else { - $this->shippingCategoryId = null; - } - - return $this; - } - - /** - * Narrows the query results based on the products’ tax categories, per the tax categories’ IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | of a tax category with an ID of 1. - * | `'not 1'` | not of a tax category with an ID of 1. - * | `[1, 2]` | of a tax category with an ID of 1 or 2. - * | `['not', 1, 2]` | not of a tax category with an ID of 1 or 2. - * - * --- - * - * ```twig - * {# Fetch {elements} of the tax category with an ID of 1 #} - * {% set {elements-var} = {twig-method} - * .taxCategoryId(1) - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} of the tax category with an ID of 1 - * ${elements-var} = {php-method} - * ->taxCategoryId(1) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function taxCategoryId(mixed $value): static - { - $this->taxCategoryId = $value; - return $this; - } - - /** - * Narrows the query results based on the products’ tax category. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'foo'` | of a tax category with a handle of `foo`. - * | `'not foo'` | not of a tax category with a handle of `foo`. - * | `['foo', 'bar']` | of a tax category with a handle of `foo` or `bar`. - * | `['not', 'foo', 'bar']` | not of a tax category with a handle of `foo` or `bar`. - * | an [[ShippingCategory|ShippingCategory]] object | of a tax category represented by the object. - * - * --- - * - * ```twig - * {# Fetch {elements} with a Foo tax category #} - * {% set {elements-var} = {twig-method} - * .taxCategory('foo') - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} with a Foo tax category - * ${elements-var} = {php-method} - * ->taxCategory('foo') - * ->all(); - * ``` - * - * @param TaxCategory|string|null|array $value The property value - * @return static self reference - */ - public function taxCategory(mixed $value): static - { - if ($value instanceof TaxCategory) { - $this->taxCategoryId = [$value->id]; - } elseif ($value !== null) { - $this->taxCategoryId = (new Query()) - ->from(['taxcategories' => Table::TAXCATEGORIES]) - ->where(['taxcategories.id' => new Expression('[[commerce_purchasables.taxCategoryId]]')]) - ->andWhere(Db::parseParam('handle', $value)); - } else { - $this->taxCategoryId = null; - } - - return $this; - } - - /** - * Return only purchasables with an active promotional price via catalog pricing rules (or which *do not* have an active promotional price). - * - * | Value | Fetches {elements}… - * | - | - - * | `true` | with a promotional price. - * | `false` | without a promotional price. - * | `null` | without taking into consideration the relationship between their price and promotional price. - * - * @param bool|null $value The property value - * @return static self reference - * @since 5.2.0 - */ - public function onPromotion(bool|null $value = true): static - { - $this->onPromotion = $value; - return $this; - } - - /** - * @inheritdoc - */ - protected function afterPrepare(): bool - { - // Store dependent related joins to the sub query need to be done after the `elements_sites` is joined in the base `ElementQuery` class. - $this->subQuery->leftJoin(['sitestores' => Table::SITESTORES], '[[elements_sites.siteId]] = [[sitestores.siteId]]'); - $this->subQuery->leftJoin(['purchasables_stores' => Table::PURCHASABLES_STORES], '[[purchasables_stores.storeId]] = [[sitestores.storeId]] AND [[purchasables_stores.purchasableId]] = [[commerce_purchasables.id]]'); - - // Only do the extra catalog pricing query join if we have catalog pricing rules. - if (Plugin::getInstance()->getCatalogPricingRules()->hasCatalogPricingRules()) { - $customerId = $this->forCustomer; - if ($customerId === null) { - $customerId = Craft::$app->getUser()->getIdentity()?->id; - } elseif ($customerId === false) { - $customerId = null; - } - - $catalogPricesQuery = Plugin::getInstance() - ->getCatalogPricing() - ->createCatalogPricesQuery(userId: $customerId) - ->addSelect(['cp.purchasableId', 'cp.storeId']); - - $this->subQuery->leftJoin(['catalogprices' => $catalogPricesQuery], '[[catalogprices.purchasableId]] = [[commerce_purchasables.id]] AND [[catalogprices.storeId]] = [[sitestores.storeId]]'); - } - - $this->subQuery->leftJoin(['inventoryitems' => Table::INVENTORYITEMS], '[[inventoryitems.purchasableId]] = [[commerce_purchasables.id]]'); - - return parent::afterPrepare(); - } - - /** - * @inheritdoc - */ - protected function beforePrepare(): bool - { - $this->joinElementTable('commerce_purchasables'); - $this->query->addSelect([ - 'commerce_purchasables.sku', - 'commerce_purchasables.width', - 'commerce_purchasables.height', - 'commerce_purchasables.length', - 'commerce_purchasables.weight', - 'commerce_purchasables.taxCategoryId', - 'purchasables_stores.availableForPurchase', - 'purchasables_stores.basePrice', - 'purchasables_stores.basePromotionalPrice', - 'purchasables_stores.freeShipping', - 'purchasables_stores.maxQty', - 'purchasables_stores.minQty', - 'purchasables_stores.inventoryTracked', - 'purchasables_stores.allowOutOfStockPurchases', - 'purchasables_stores.promotable', - 'purchasables_stores.shippingCategoryId', - 'inventoryitems.id as inventoryItemId', - ]); - - $this->query->leftJoin(Table::SITESTORES . ' sitestores', '[[elements_sites.siteId]] = [[sitestores.siteId]]'); - $this->query->leftJoin(Table::PURCHASABLES_STORES . ' purchasables_stores', '[[purchasables_stores.storeId]] = [[sitestores.storeId]] AND [[purchasables_stores.purchasableId]] = [[commerce_purchasables.id]]'); - $this->query->leftJoin(['inventoryitems' => Table::INVENTORYITEMS], '[[inventoryitems.purchasableId]] = [[commerce_purchasables.id]]'); - - // Only do the extra catalog pricing query join if we have catalog pricing rules. - if (Plugin::getInstance()->getCatalogPricingRules()->hasCatalogPricingRules()) { - $this->query->addSelect([ - 'subquery.price', - 'subquery.promotionalPrice as promotionalPrice', - 'subquery.salePrice as salePrice', - ]); - $this->subQuery->addSelect([ - 'catalogprices.price', - 'catalogprices.promotionalPrice', - 'catalogprices.salePrice', - ]); - - if (isset($this->price)) { - $this->subQuery->andWhere(Db::parseNumericParam('catalogprices.price', $this->price)); - } - - if (isset($this->promotionalPrice)) { - $this->subQuery->andWhere(Db::parseNumericParam('catalogprices.promotionalPrice', $this->promotionalPrice)); - } - - if (isset($this->onPromotion)) { - if ($this->onPromotion) { - $this->subQuery->andWhere(new Expression('[[catalogprices.promotionalPrice]] < [[catalogprices.price]]')); - } else { - // Commerce normalizes these when selecting/aggregating, so the values will actually be the same when a promotional price doesn't exist. This means it's not technically possible to distinguish between an *unset* promotional price and a promotional price that ended up being the same as the regular price. It’s also ambiguous when a pricing rule sets a `promotionalPrice` based on the original `price`! - $this->subQuery->andWhere(new Expression('[[catalogprices.price]] = [[catalogprices.promotionalPrice]]')); - } - } - - if (isset($this->salePrice)) { - $this->subQuery->andWhere(Db::parseNumericParam('catalogprices.salePrice' , $this->salePrice)); - } - } else { - // If Catalog pricing rules are not being used - $this->query->addSelect([ - 'purchasables_stores.basePrice as price', - 'purchasables_stores.basePromotionalPrice as promotionalPrice', - new Expression('CASE WHEN [[purchasables_stores.basePromotionalPrice]] < [[purchasables_stores.basePrice]] THEN [[purchasables_stores.basePromotionalPrice]] ELSE [[purchasables_stores.basePrice]] END as [[salePrice]]'), - new Expression('null as [[catalogPricingRuleId]]'), - ]); - - $this->subQuery->addSelect([ - 'purchasables_stores.basePrice as price', - 'purchasables_stores.basePromotionalPrice as promotionalPrice', - new Expression('CASE WHEN [[purchasables_stores.basePromotionalPrice]] < [[purchasables_stores.basePrice]] THEN [[purchasables_stores.basePromotionalPrice]] ELSE [[purchasables_stores.basePrice]] END as [[salePrice]]'), - ]); - - if (isset($this->price)) { - $this->subQuery->andWhere(Db::parseNumericParam('purchasables_stores.basePrice', $this->price)); - } - - if (isset($this->promotionalPrice)) { - $this->subQuery->andWhere(Db::parseNumericParam('purchasables_stores.basePromotionalPrice', $this->promotionalPrice)); - } - - if (isset($this->onPromotion)) { - if ($this->onPromotion) { - $this->subQuery->andWhere(new Expression('[[purchasables_stores.basePromotionalPrice]] < [[purchasables_stores.basePrice]]')); - } else { - $this->subQuery->andWhere(new Expression('[[purchasables_stores.basePrice]] < [[purchasables_stores.basePromotionalPrice]]')); - } - } - - if (isset($this->salePrice)) { - $this->subQuery->andWhere(Db::parseNumericParam(new Expression('CASE WHEN [[purchasables_stores.basePromotionalPrice]] < [[purchasables_stores.basePrice]] THEN [[purchasables_stores.basePromotionalPrice]] ELSE [[purchasables_stores.basePrice]] END') , $this->salePrice)); - } - } - - if (isset($this->sku)) { - $this->subQuery->andWhere(Db::parseParam('commerce_purchasables.sku', $this->sku)); - } - - // We don't join the inventory levels table, and rely on the caches store available total. - if (isset($this->stock)) { - $this->subQuery->andWhere(Db::parseParam('purchasables_stores.stock', $this->stock)); - } - - if (isset($this->inventoryTracked)) { - $this->subQuery->andWhere(Db::parseParam('purchasables_stores.inventoryTracked', $this->inventoryTracked)); - } - - if (isset($this->availableForPurchase)) { - $this->subQuery->andWhere(['purchasables_stores.availableForPurchase' => $this->availableForPurchase]); - } - - if (isset($this->sku)) { - $this->subQuery->andWhere(Db::parseParam('commerce_purchasables.sku', $this->sku)); - } - - if (isset($this->shippingCategoryId)) { - if ($this->shippingCategoryId instanceof Query) { - $shippingCategoryWhere = ['exists', $this->shippingCategoryId]; - } else { - $shippingCategoryWhere = Db::parseParam('purchasables_stores.shippingCategoryId', $this->shippingCategoryId); - } - - $this->subQuery->andWhere($shippingCategoryWhere); - } - - if (isset($this->taxCategoryId)) { - if ($this->taxCategoryId instanceof Query) { - $taxCategoryWhere = ['exists', $this->taxCategoryId]; - } else { - $taxCategoryWhere = Db::parseParam('commerce_purchasables.taxCategoryId', $this->taxCategoryId); - } - - $this->subQuery->andWhere($taxCategoryWhere); - } - - if ($this->width !== false) { - if ($this->width === null) { - $this->subQuery->andWhere(['commerce_purchasables.width' => $this->width]); - } else { - $this->subQuery->andWhere(Db::parseParam('commerce_purchasables.width', $this->width)); - } - } - - if ($this->height !== false) { - if ($this->height === null) { - $this->subQuery->andWhere(['commerce_purchasables.height' => $this->height]); - } else { - $this->subQuery->andWhere(Db::parseParam('commerce_purchasables.height', $this->height)); - } - } - - if ($this->length !== false) { - if ($this->length === null) { - $this->subQuery->andWhere(['commerce_purchasables.length' => $this->length]); - } else { - $this->subQuery->andWhere(Db::parseParam('commerce_purchasables.length', $this->length)); - } - } - - if ($this->weight !== false) { - if ($this->weight === null) { - $this->subQuery->andWhere(['commerce_purchasables.weight' => $this->weight]); - } else { - $this->subQuery->andWhere(Db::parseParam('commerce_purchasables.weight', $this->weight)); - } - } - - if (isset($this->hasStock)) { - if ($this->hasStock) { - $this->subQuery->andWhere([ - 'or', - ['purchasables_stores.inventoryTracked' => false], - [ - 'and', - ['not', ['purchasables_stores.inventoryTracked' => false]], - ['>', 'purchasables_stores.stock', 0], - ], - ]); - } else { - $this->subQuery->andWhere([ - 'and', - ['not', ['purchasables_stores.inventoryTracked' => false]], - ['<', 'purchasables_stores.stock', 1], - ]); - } - } - - return parent::beforePrepare(); - } - - /** - * @inheritdoc - */ - public function populate($rows): array - { - if (!empty($rows) && Plugin::getInstance()->getCatalogPricingRules()->hasCatalogPricingRules()) { - $row = ArrayHelper::firstValue($rows); - $store = Plugin::getInstance()->getStores()->getStoreBySiteId($row['siteId']); - $purchasableIds = ArrayHelper::getColumn($rows, 'id'); - $customerId = $this->forCustomer; - if ($customerId === null) { - $customerId = Craft::$app->getUser()->getIdentity()?->id; - } elseif ($customerId === false) { - $customerId = null; - } - $cprIds = Plugin::getInstance() - ->getCatalogPricing() - ->createCatalogPricesQuery(userId: $customerId, storeId: $store->id) - ->select([ - 'purchasableId', - 'storeId', - 'price', - new Expression('MIN([[catalogPricingRuleId]]) as [[catalogPricingRuleId]]'), - ]) - ->andWhere(['purchasableId' => $purchasableIds]) - ->andWhere(['not', ['catalogPricingRuleId' => null]]) - ->groupBy(['cp.purchasableId', 'cp.storeId', 'cp.price']) - ->all(); - - foreach ($cprIds as $cprId) { - foreach ($rows as &$row) { - if ($row['id'] == $cprId['purchasableId']) { - $row['catalogPricingRuleId'] = $cprId['catalogPricingRuleId']; - break; - } - } - } - } - - foreach ($rows as &$row) { - unset($row['salePrice']); - } - - return parent::populate($rows); - } -} diff --git a/src/elements/db/SubscriptionQuery.php b/src/elements/db/SubscriptionQuery.php deleted file mode 100644 index 6800c71062..0000000000 --- a/src/elements/db/SubscriptionQuery.php +++ /dev/null @@ -1,852 +0,0 @@ - - * @since 2.0 - * @doc-path subscriptions.md - * @replace {element} subscription - * @replace {elements} subscriptions - * @replace {twig-method} craft.subscriptions() - * @replace {myElement} mySubscription - * @replace {element-class} \craft\commerce\elements\Subscription - * @supports-status-param - */ -class SubscriptionQuery extends ElementQuery -{ - /** - * @var mixed The user id of the subscriber - */ - public mixed $userId = null; - - /** - * @var mixed The subscription plan id - */ - public mixed $planId = null; - - /** - * @var mixed The gateway id - */ - public mixed $gatewayId = null; - - /** - * @var mixed The id of the order that the license must be a part of. - */ - public mixed $orderId = null; - - /** - * @var mixed The gateway reference for subscription - */ - public mixed $reference = null; - - /** - * @var mixed Number of trial days for the subscription - */ - public mixed $trialDays = null; - - /** - * @var bool|null Whether the subscription is currently on trial. - */ - public ?bool $onTrial = null; - - /** - * @var mixed Time of next payment for the subscription - */ - public mixed $nextPaymentDate = null; - - /** - * @var bool|null Whether the subscription is canceled - */ - public ?bool $isCanceled = null; - - /** - * @var bool|null Whether the subscription is suspended - */ - public ?bool $isSuspended = null; - - /** - * @var mixed The date the subscription ceased to be active - */ - public mixed $dateSuspended = null; - - /** - * @var bool|null Whether the subscription has started - */ - public ?bool $hasStarted = null; - - /** - * @var mixed The time the subscription was canceled - */ - public mixed $dateCanceled = null; - - /** - * @var bool|null Whether the subscription has expired - */ - public ?bool $isExpired = null; - - /** - * @var mixed The date the subscription ceased to be active - */ - public mixed $dateExpired = null; - - /** - * @var array - */ - protected array $defaultOrderBy = ['commerce_subscriptions.dateCreated' => SORT_DESC]; - - /** - * @inheritdoc - */ - public function __construct(string $elementType, array $config = []) - { - // Default status - if (!array_key_exists('status', $config)) { - $config['status'] = Subscription::STATUS_ACTIVE; - } - - parent::__construct($elementType, $config); - } - - /** - * @inheritdoc - */ - public function __set($name, $value) - { - match ($name) { - 'user' => $this->user($value), - 'plan' => $this->plan($value), - default => parent::__set($name, $value), - }; - } - - /** - * Narrows the query results based on the subscriptions’ user accounts. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'foo'` | for a user account with a username of `foo` - * | `['foo', 'bar']` | for user accounts with a username of `foo` or `bar`. - * | a [[User|User]] object | for a user account represented by the object. - * - * --- - * - * ```twig - * {# Fetch the current user's subscriptions #} - * {% set {elements-var} = {twig-method} - * .user(currentUser) - * .all() %} - * ``` - * - * ```php - * // Fetch the current user's subscriptions - * $user = Craft::$app->user->getIdentity(); - * ${elements-var} = {php-method} - * ->user($user) - * ->all(); - * ``` - * - * @return static self reference - */ - public function user(mixed $value): SubscriptionQuery - { - if ($value instanceof User) { - $this->userId = $value->id; - } elseif ($value !== null) { - $this->userId = (new Query()) - ->select(['id']) - ->from(['{{%users}}']) - ->where(Db::parseParam('username', $value)) - ->column(); - } else { - $this->userId = null; - } - - return $this; - } - - /** - * Narrows the query results based on the subscription plan. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'foo'` | for a plan with a handle of `foo`. - * | `['foo', 'bar']` | for plans with a handle of `foo` or `bar`. - * | a [[Plan|Plan]] object | for a plan represented by the object. - * - * --- - * - * ```twig - * {# Fetch Supporter plan subscriptions #} - * {% set {elements-var} = {twig-method} - * .plan('supporter') - * .all() %} - * ``` - * - * ```php - * // Fetch Supporter plan subscriptions - * ${elements-var} = {php-method} - * ->plan('supporter') - * ->all(); - * ``` - * - * @return static self reference - */ - public function plan(mixed $value): SubscriptionQuery - { - if ($value instanceof Plan) { - $this->planId = $value->id; - } elseif ($value !== null) { - $this->planId = (new Query()) - ->select(['id']) - ->from([Table::PLANS]) - ->where(Db::parseParam('handle', $value)) - ->column(); - } else { - $this->planId = null; - } - - return $this; - } - - /** - * Narrows the query results based on the subscriptions’ user accounts’ IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | for a user account with an ID of 1. - * | `[1, 2]` | for user accounts with an ID of 1 or 2. - * | `['not', 1, 2]` | for user accounts not with an ID of 1 or 2. - * - * --- - * - * ```twig - * {# Fetch the current user's subscriptions #} - * {% set {elements-var} = {twig-method} - * .userId(currentUser.id) - * .all() %} - * ``` - * - * ```php - * // Fetch the current user's subscriptions - * $user = Craft::$app->user->getIdentity(); - * ${elements-var} = {php-method} - * ->userId($user->id) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function userId(mixed $value): SubscriptionQuery - { - $this->userId = $value; - return $this; - } - - /** - * Narrows the query results based on the subscription plans’ IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | for a plan with an ID of 1. - * | `[1, 2]` | for plans with an ID of 1 or 2. - * | `['not', 1, 2]` | for plans not with an ID of 1 or 2. - * - * @param mixed $value The property value - * @return static self reference - */ - public function planId(mixed $value): SubscriptionQuery - { - $this->planId = $value; - return $this; - } - - /** - * Narrows the query results based on the gateway, per its ID. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | with a gateway with an ID of 1. - * | `'not 1'` | not with a gateway with an ID of 1. - * | `[1, 2]` | with a gateway with an ID of 1 or 2. - * | `['not', 1, 2]` | not with a gateway with an ID of 1 or 2. - * - * @param mixed $value The property value - * @return static self reference - */ - public function gatewayId(mixed $value): SubscriptionQuery - { - $this->gatewayId = $value; - return $this; - } - - /** - * Narrows the query results based on the order, per its ID. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | with an order with an ID of 1. - * | `'not 1'` | not with an order with an ID of 1. - * | `[1, 2]` | with an order with an ID of 1 or 2. - * | `['not', 1, 2]` | not with an order with an ID of 1 or 2. - * - * @param mixed $value The property value - * @return static self reference - */ - public function orderId(mixed $value): SubscriptionQuery - { - $this->orderId = $value; - return $this; - } - - /** - * Narrows the query results based on the reference. - * - * @param mixed $value The property value - * @return static self reference - */ - public function reference(mixed $value): SubscriptionQuery - { - $this->reference = $value; - return $this; - } - - /** - * Narrows the query results based on the number of trial days. - * - * @param mixed $value The property value - * @return static self reference - */ - public function trialDays(mixed $value): SubscriptionQuery - { - $this->trialDays = $value; - return $this; - } - - /** - * Narrows the query results to only subscriptions that are on trial. - * - * --- - * - * ```twig - * {# Fetch trialed subscriptions #} - * {% set {elements-var} = {twig-method} - * .onTrial() - * .all() %} - * ``` - * - * ```php - * // Fetch trialed subscriptions - * ${elements-var} = {element-class}::find() - * ->isPaid() - * ->all(); - * ``` - * - * @param bool|null $value The property value - * @return static self reference - */ - public function onTrial(?bool $value = true): SubscriptionQuery - { - $this->onTrial = $value; - return $this; - } - - /** - * Narrows the query results based on the subscriptions’ next payment dates. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'>= 2018-04-01'` | with a next payment on or after 2018-04-01. - * | `'< 2018-05-01'` | with a next payment before 2018-05-01 - * | `['and', '>= 2018-04-04', '< 2018-05-01']` | with a next payment between 2018-04-01 and 2018-05-01. - * - * --- - * - * ```twig - * {# Fetch {elements} with a payment due soon #} - * {% set aWeekFromNow = date('+7 days')|atom %} - * - * {% set {elements-var} = {twig-method} - * .nextPaymentDate("< #{aWeekFromNow}") - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} with a payment due soon - * $aWeekFromNow = new \DateTime('+7 days')->format(\DateTime::ATOM); - * - * ${elements-var} = {php-method} - * ->nextPaymentDate("< {$aWeekFromNow}") - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function nextPaymentDate(mixed $value): SubscriptionQuery - { - $this->nextPaymentDate = $value; - return $this; - } - - /** - * Narrows the query results to only subscriptions that are canceled. - * - * --- - * - * ```twig - * {# Fetch canceled subscriptions #} - * {% set {elements-var} = {twig-method} - * .isCanceled() - * .all() %} - * ``` - * - * ```php - * // Fetch canceled subscriptions - * ${elements-var} = {element-class}::find() - * ->isCanceled() - * ->all(); - * ``` - * - * @param bool|null $value The property value - * @return static self reference - */ - public function isCanceled(?bool $value = true): SubscriptionQuery - { - $this->isCanceled = $value; - return $this; - } - - /** - * Narrows the query results based on the subscriptions’ cancellation date. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'>= 2018-04-01'` | that were canceled on or after 2018-04-01. - * | `'< 2018-05-01'` | that were canceled before 2018-05-01 - * | `['and', '>= 2018-04-04', '< 2018-05-01']` | that were canceled between 2018-04-01 and 2018-05-01. - * - * --- - * - * ```twig - * {# Fetch {elements} that were canceled recently #} - * {% set aWeekAgo = date('7 days ago')|atom %} - * - * {% set {elements-var} = {twig-method} - * .dateCanceled(">= #{aWeekAgo}") - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} that were canceled recently - * $aWeekAgo = new \DateTime('7 days ago')->format(\DateTime::ATOM); - * - * ${elements-var} = {php-method} - * ->dateCanceled(">= {$aWeekAgo}") - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function dateCanceled(mixed $value): SubscriptionQuery - { - $this->dateCanceled = $value; - return $this; - } - - /** - * Narrows the query results to only subscriptions that have started. - * - * --- - * - * ```twig - * {# Fetch started subscriptions #} - * {% set {elements-var} = {twig-method} - * .hasStarted() - * .all() %} - * ``` - * - * ```php - * // Fetch started subscriptions - * ${elements-var} = {element-class}::find() - * ->hasStarted() - * ->all(); - * ``` - * - * @param bool|null $value The property value - * @return static self reference - */ - public function hasStarted(?bool $value = true): SubscriptionQuery - { - $this->hasStarted = $value; - return $this; - } - - /** - * Narrows the query results to only subscriptions that are suspended. - * - * --- - * - * ```twig - * {# Fetch suspended subscriptions #} - * {% set {elements-var} = {twig-method} - * .isSuspended() - * .all() %} - * ``` - * - * ```php - * // Fetch suspended subscriptions - * ${elements-var} = {element-class}::find() - * ->isSuspended() - * ->all(); - * ``` - * - * @param bool|null $value The property value - * @return static self reference - */ - public function isSuspended(?bool $value = true): SubscriptionQuery - { - $this->isSuspended = $value; - return $this; - } - - /** - * Narrows the query results based on the subscriptions’ suspension date. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'>= 2018-04-01'` | that were suspended on or after 2018-04-01. - * | `'< 2018-05-01'` | that were suspended before 2018-05-01 - * | `['and', '>= 2018-04-04', '< 2018-05-01']` | that were suspended between 2018-04-01 and 2018-05-01. - * --- - * - * ```twig - * {# Fetch {elements} that were suspended recently #} - * {% set aWeekAgo = date('7 days ago')|atom %} - * - * {% set {elements-var} = {twig-method} - * .dateSuspended(">= #{aWeekAgo}") - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} that were suspended recently - * $aWeekAgo = new \DateTime('7 days ago')->format(\DateTime::ATOM); - * - * ${elements-var} = {php-method} - * ->dateSuspended(">= {$aWeekAgo}") - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function dateSuspended(mixed $value): SubscriptionQuery - { - $this->dateSuspended = $value; - return $this; - } - - /** - * Narrows the query results to only subscriptions that have expired. - * - * --- - * - * ```twig - * {# Fetch expired subscriptions #} - * {% set {elements-var} = {twig-method} - * .isExpired() - * .all() %} - * ``` - * - * ```php - * // Fetch expired subscriptions - * ${elements-var} = {element-class}::find() - * ->isExpired() - * ->all(); - * ``` - * - * @param bool|null $value The property value - * @return static self reference - */ - public function isExpired(?bool $value = true): SubscriptionQuery - { - $this->isExpired = $value; - - return $this; - } - - /** - * Narrows the query results based on the subscriptions’ expiration date. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'>= 2018-04-01'` | that expired on or after 2018-04-01. - * | `'< 2018-05-01'` | that expired before 2018-05-01 - * | `['and', '>= 2018-04-04', '< 2018-05-01']` | that expired between 2018-04-01 and 2018-05-01. - * - * --- - * - * ```twig - * {# Fetch {elements} that expired recently #} - * {% set aWeekAgo = date('7 days ago')|atom %} - * - * {% set {elements-var} = {twig-method} - * .dateExpired(">= #{aWeekAgo}") - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} that expired recently - * $aWeekAgo = new \DateTime('7 days ago')->format(\DateTime::ATOM); - * - * ${elements-var} = {php-method} - * ->dateExpired(">= {$aWeekAgo}") - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function dateExpired(mixed $value): SubscriptionQuery - { - $this->dateExpired = $value; - - return $this; - } - - /** - * Narrows the query results based on the {elements}’ statuses. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'active'` _(default)_ | that are active. - * | `'expired'` | that have expired. - * - * --- - * - * ```twig - * {# Fetch expired {elements} #} - * {% set {elements-var} = {twig-method} - * .status('expired') - * .all() %} - * ``` - * - * ```php - * // Fetch expired {elements} - * ${elements-var} = {element-class}::find() - * ->status('expired') - * ->all(); - * ``` - */ - public function status(array|string|null $value): static - { - parent::status($value); - if ($value === null) { - unset($this->isSuspended, $this->hasStarted); - } - - return $this; - } - - /** - * @inheritdoc - */ - protected function beforePrepare(): bool - { - // See if 'plan' were set to invalid handles - if ($this->planId === []) { - return false; - } - - $this->joinElementTable('commerce_subscriptions'); - $this->subQuery->innerJoin('{{%users}} users', '[[commerce_subscriptions.userId]] = [[users.id]]'); - - $this->query->select([ - 'commerce_subscriptions.dateCanceled', - 'commerce_subscriptions.dateExpired', - 'commerce_subscriptions.dateSuspended', - 'commerce_subscriptions.gatewayId', - 'commerce_subscriptions.hasStarted', - 'commerce_subscriptions.id', - 'commerce_subscriptions.isCanceled', - 'commerce_subscriptions.isExpired', - 'commerce_subscriptions.isSuspended', - 'commerce_subscriptions.nextPaymentDate', - 'commerce_subscriptions.orderId', - 'commerce_subscriptions.planId', - 'commerce_subscriptions.reference', - 'commerce_subscriptions.subscriptionData', - 'commerce_subscriptions.trialDays', - 'commerce_subscriptions.userId', - 'commerce_subscriptions.returnUrl', - ]); - - if (isset($this->userId)) { - $this->subQuery->andWhere(Db::parseParam('commerce_subscriptions.userId', $this->userId)); - } - - if (isset($this->planId)) { - $this->subQuery->andWhere(Db::parseParam('commerce_subscriptions.planId', $this->planId)); - } - - if (isset($this->gatewayId)) { - $this->subQuery->andWhere(Db::parseParam('commerce_subscriptions.gatewayId', $this->gatewayId)); - } - - if (isset($this->orderId)) { - $this->subQuery->andWhere(Db::parseParam('commerce_subscriptions.orderId', $this->orderId)); - } - - if (isset($this->reference)) { - $this->subQuery->andWhere(Db::parseParam('commerce_subscriptions.reference', $this->reference)); - } - - if (isset($this->trialDays)) { - $this->subQuery->andWhere(Db::parseParam('commerce_subscriptions.trialDays', $this->trialDays)); - } - - if (isset($this->nextPaymentDate)) { - $this->subQuery->andWhere(Db::parseDateParam('commerce_subscriptions.nextPaymentDate', $this->nextPaymentDate)); - } - - if (isset($this->isCanceled)) { - $this->subQuery->andWhere(Db::parseBooleanParam('commerce_subscriptions.isCanceled', $this->isCanceled, false)); - } - - if (isset($this->dateCanceled)) { - $this->subQuery->andWhere(Db::parseDateParam('commerce_subscriptions.dateCanceled', $this->dateCanceled)); - } - - // Apply default hasStarted/isSuspended filters when status is set (not null) - // and they haven't been explicitly overridden - if ($this->status !== null) { - $this->hasStarted ??= true; - $this->isSuspended ??= false; - } - - if (isset($this->hasStarted)) { - $this->subQuery->andWhere(Db::parseBooleanParam('commerce_subscriptions.hasStarted', $this->hasStarted, false)); - } - - if (isset($this->isSuspended)) { - $this->subQuery->andWhere(Db::parseBooleanParam('commerce_subscriptions.isSuspended', $this->isSuspended, false)); - } - - if (isset($this->dateSuspended)) { - $this->subQuery->andWhere(Db::parseDateParam('commerce_subscriptions.dateSuspended', $this->dateSuspended)); - } - - if (isset($this->isExpired)) { - $this->subQuery->andWhere(Db::parseBooleanParam('commerce_subscriptions.isExpired', $this->isExpired, false)); - } - - if (isset($this->dateExpired)) { - $this->subQuery->andWhere(Db::parseDateParam('commerce_subscriptions.dateExpired', $this->dateExpired)); - } - - if (isset($this->onTrial) && $this->onTrial === true) { - $this->subQuery->andWhere($this->_getTrialCondition(true)); - } elseif (isset($this->onTrial) && $this->onTrial === false) { - $this->subQuery->andWhere($this->_getTrialCondition(false)); - } - - return parent::beforePrepare(); - } - - /** - * @inheritdoc - */ - protected function statusCondition(string $status): mixed - { - return match ($status) { - Subscription::STATUS_ACTIVE => [ - 'commerce_subscriptions.isExpired' => '0', - ], - Subscription::STATUS_EXPIRED => [ - 'commerce_subscriptions.isExpired' => '1', - ], - default => parent::statusCondition($status), - }; - } - - /** - * @inheritdoc - * @deprecated in 4.0.0. `status(null)` should be used instead. - */ - public function anyStatus(): static - { - parent::status(null); - unset($this->isSuspended, $this->hasStarted); - - return $this; - } - - /** - * Returns the SQL condition to use for trial status. - * - * @param bool $onTrial - * @return mixed - */ - private function _getTrialCondition(bool $onTrial): mixed - { - if ($onTrial) { - if (Craft::$app->getDb()->getIsPgsql()) { - return new Expression("NOW() <= [[commerce_subscriptions.dateCreated]] + [[commerce_subscriptions.trialDays]] * INTERVAL '1 day'"); - } - - return new Expression('NOW() <= ADDDATE([[commerce_subscriptions.dateCreated]], [[commerce_subscriptions.trialDays]])'); - } - - if (Craft::$app->getDb()->getIsPgsql()) { - return new Expression("NOW() > [[commerce_subscriptions.dateCreated]] + [[commerce_subscriptions.trialDays]] * INTERVAL '1 day'"); - } - - return new Expression('NOW() > ADDDATE([[commerce_subscriptions.dateCreated]], [[commerce_subscriptions.trialDays]])'); - } -} diff --git a/src/elements/db/TransferQuery.php b/src/elements/db/TransferQuery.php deleted file mode 100644 index bdf77487c2..0000000000 --- a/src/elements/db/TransferQuery.php +++ /dev/null @@ -1,116 +0,0 @@ -value; - } - - $this->transferStatus = $value; - return $this; - } - - /** - * @param string|int|InventoryLocation|null $value - * @return static - */ - public function originLocation($value): self - { - if ($value instanceof InventoryLocation) { - $value = $value->id; - } - - $this->originLocation = $value; - return $this; - } - - /** - * @param string|int|InventoryLocation|null $value - * @return static - */ - public function destinationLocation($value): self - { - if ($value instanceof InventoryLocation) { - $value = $value->id; - } - - $this->destinationLocation = $value; - return $this; - } - - /** - * @var bool|null Whether to only return entries that the user has permission to save. - * @used-by savable() - * @since 4.4.0 - */ - public ?bool $savable = null; - - protected function beforePrepare(): bool - { - $this->joinElementTable(Table::TRANSFERS); - - // add selects - $this->query->select([ - 'commerce_transfers.transferStatus', - 'commerce_transfers.originLocationId', - 'commerce_transfers.destinationLocationId', - ]); - - if ($this->transferStatus) { - $this->subQuery->andWhere(['transferStatus' => $this->transferStatus]); - } - - if ($this->originLocation) { - $this->subQuery->andWhere(['originLocationId' => $this->originLocation]); - } - - if ($this->destinationLocation) { - $this->subQuery->andWhere(['destinationLocationId' => $this->destinationLocation]); - } - - return parent::beforePrepare(); - } - - /** - * @inheritdoc - */ - public function populate($rows): array - { - foreach ($rows as &$row) { - $row['transferStatus'] = TransferStatusType::from($row['transferStatus']); - } - return parent::populate($rows); - } -} diff --git a/src/elements/db/VariantQuery.php b/src/elements/db/VariantQuery.php deleted file mode 100755 index 5cb6fe0d75..0000000000 --- a/src/elements/db/VariantQuery.php +++ /dev/null @@ -1,985 +0,0 @@ - - * @since 2.0 - * @doc-path products-variants.md - * @prefix-doc-params - * @replace {element} variant - * @replace {elements} variants - * @replace {twig-method} craft.variants() - * @replace {myElement} myVariant - * @replace {element-class} \craft\commerce\elements\Variant - * @supports-site-params - * @supports-status-param - * @supports-title-param - */ -class VariantQuery extends PurchasableQuery -{ - use NestedElementQueryTrait { - cacheTags as nestedTraitCacheTags; - } - /** - * @inheritdoc - */ - protected array $defaultOrderBy = ['elements_owners.sortOrder' => SORT_ASC]; - - /** - * @var bool|null Whether to only return variants that the user has permission to view. - * @used-by editable() - */ - public ?bool $editable = null; - - /** - * @var bool|null Whether to only return variants that the user has permission to save. - * @used-by savable() - * @since 5.6.0 - */ - public ?bool $savable = null; - - /** - * @var bool|null - */ - public ?bool $hasSales = null; - - /** - * @var mixed only return variants that match the resulting product query. - */ - public mixed $hasProduct = null; - - /** - * @var bool|null - */ - public ?bool $isDefault = null; - - - /** - * @var mixed The primary owner element ID(s) that the resulting entries must belong to. - * @used-by primaryOwner() - * @used-by primaryOwnerId() - * @since 5.0.0 - */ - public mixed $primaryOwnerId = null; - - /** - * @var mixed|null - * @used-by owner() - * @used-by ownerId() - * @since 5.0.0 - */ - public mixed $ownerId = null; - - /** - * @var array|string|null The status the owner product must have. - * @used-by productStatus() - * @since 5.5.0 - */ - public array|string|null $productStatus = null; - - /** - * @var mixed - */ - public mixed $typeId = null; - - /** - * @var mixed - */ - public mixed $minQty = null; - - /** - * @var mixed - */ - public mixed $maxQty = null; - - /** - * @inheritdoc - */ - public function __construct($elementType, array $config = []) - { - // Default status - if (!isset($config['status'])) { - $config['status'] = Element::STATUS_ENABLED; - } - - parent::__construct($elementType, $config); - } - - /** - * @inheritdoc - */ - public function __set($name, $value) - { - match ($name) { - 'product' => $this->product($value), - 'productId' => $this->ownerId($value), - 'owner' => $this->owner($value), - 'primaryOwner' => $this->primaryOwner($value), - default => parent::__set($name, $value), - }; - } - - /** - * Narrows the query results based on the variants’ product. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | a [[Product|Product]] object | for a product represented by the object. - * - * @return static self reference - */ - public function product(mixed $value): VariantQuery - { - if ($value instanceof Product) { - $this->ownerId = [$value->id]; - } else { - $this->ownerId = $value; - } - return $this; - } - - /** - * Narrows the query results based on the variants’ owner. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | a [[Product|Product]] object | for a product represented by the object. - * - * @return static self reference - */ - public function owner(mixed $value): VariantQuery - { - if ($value instanceof ElementInterface) { - $this->ownerId = [$value->id]; - } else { - $this->ownerId = $value; - } - return $this; - } - - /** - * Narrows the query results based on the variants’ primary owner. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | a [[ElementInterface|ElementInterface]] object | for a product represented by the object. - * - * @return static self reference - */ - public function primaryOwner(mixed $value): VariantQuery - { - if ($value instanceof ElementInterface) { - $this->primaryOwnerId = [$value->id]; - } else { - $this->primaryOwnerId = $value; - } - return $this; - } - - /** - * Narrows the query results based on the variants’ products’ IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | for a product with an ID of 1. - * | `[1, 2]` | for product with an ID of 1 or 2. - * | `['not', 1, 2]` | for product not with an ID of 1 or 2. - * - * @return static self reference - */ - public function productId(mixed $value): VariantQuery - { - $this->ownerId = $value; - return $this; - } - - /** - * Narrows the query results based on the variants’ primary owners’ IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | for a primary owner with an ID of 1. - * | `[1, 2]` | for primary owner with an ID of 1 or 2. - * | `['not', 1, 2]` | for primary owner not with an ID of 1 or 2. - * - * @return static self reference - */ - public function primaryOwnerId(mixed $value): VariantQuery - { - $this->primaryOwnerId = $value; - return $this; - } - - /** - * Narrows the query results based on the variants’ owners’ IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | for an owner with an ID of 1. - * | `[1, 2]` | for owner with an ID of 1 or 2. - * | `['not', 1, 2]` | for owner not with an ID of 1 or 2. - * - * @return static self reference - */ - public function ownerId(mixed $value): VariantQuery - { - $this->ownerId = $value; - return $this; - } - - /** - * Narrows the query results based on the {elements}’ product’s statuses. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `'enabled'` _(default)_ | that are enabled. - * | `'disabled'` | that are disabled. - * | `['not', 'disabled']` | that are not disabled. - * - * --- - * - * ```twig - * {# Fetch {elements} with disabled products #} - * {% set {elements-var} = {twig-method} - * .productStatus('disabled') - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} with disabled products - * ${elements-var} = {php-method} - * ->productStatus('disabled') - * ->all(); - * ``` - * - * @param string|string[]|null $value The property value - * @return static self reference - * @since 5.5.0 - */ - public function productStatus(array|string|null $value): VariantQuery - { - $this->productStatus = $value; - return $this; - } - - /** - * Narrows the query results based on the variants’ product types, per their IDs. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `1` | for a product of a type with an ID of 1. - * | `[1, 2]` | for product of a type with an ID of 1 or 2. - * | `['not', 1, 2]` | for product of a type not with an ID of 1 or 2. - * - * @return static self reference - */ - public function typeId(mixed $value): VariantQuery - { - $this->typeId = $value; - return $this; - } - - /** - * Narrows the query results to only default variants. - * - * --- - * - * ```twig - * {# Fetch default variants #} - * {% set {elements-var} = {twig-method} - * .isDefault() - * .all() %} - * ``` - * - * ```php - * // Fetch default variants - * ${elements-var} = {element-class}::find() - * ->isDefault() - * ->all(); - * ``` - * - * @param bool|null $value The property value - * @return static self reference - */ - public function isDefault(?bool $value = true): VariantQuery - { - $this->isDefault = $value; - return $this; - } - - /** - * Narrows the query results to only variants that are on sale. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `true` | on sale - * | `false` | not on sale - * - * @param bool|null $value - * @return static self reference - */ - public function hasSales(?bool $value = true): VariantQuery - { - $this->hasSales = $value; - return $this; - } - - /** - * Narrows the query results to only variants for certain products. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | a [[ProductQuery|ProductQuery]] object | for products that match the query. - * - * @param mixed $value The property value - * @return static self reference - */ - public function hasProduct(mixed $value = []): VariantQuery - { - $this->hasProduct = $value; - return $this; - } - - /** - * Narrows the query results based on the variants’ min quantity. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `100` | with a minQty of 100. - * | `'>= 100'` | with a minQty of at least 100. - * | `'< 100'` | with a minQty of less than 100. - * - * @param mixed $value The property value - * @return static self reference - */ - public function minQty(mixed $value): VariantQuery - { - $this->minQty = $value; - return $this; - } - - /** - * Narrows the query results based on the variants’ max quantity. - * - * Possible values include: - * - * | Value | Fetches {elements}… - * | - | - - * | `100` | with a maxQty of 100. - * | `'>= 100'` | with a maxQty of at least 100. - * | `'< 100'` | with a maxQty of less than 100. - * - * @param mixed $value The property value - * @return static self reference - */ - public function maxQty(mixed $value): VariantQuery - { - $this->maxQty = $value; - return $this; - } - - /** - * Sets the [[$editable]] property. - * - * @param bool|null $value The property value (defaults to true) - * @return static self reference - * @uses $editable - */ - public function editable(?bool $value = true): static - { - $this->editable = $value; - return $this; - } - - /** - * Sets the [[$savable]] property. - * - * @param bool|null $value The property value (defaults to true) - * @return static self reference - * @uses $savable - * @since 5.6.0 - */ - public function savable(?bool $value = true): static - { - $this->savable = $value; - return $this; - } - - /** - * @param Connection|null $db - * @return VariantCollection - * @phpstan-ignore-next-line - */ - public function collect(?Connection $db = null): VariantCollection - { - /** @phpstan-ignore-next-line */ - return VariantCollection::make(parent::collect($db)); - } - - /** - * @inheritdoc - */ - protected function beforePrepare(): bool - { - try { - $this->primaryOwnerId = $this->_normalizeOwnerId($this->primaryOwnerId); - } catch (InvalidArgumentException) { - throw new InvalidConfigException('Invalid primaryOwnerId param value'); - } - - try { - $this->ownerId = $this->_normalizeOwnerId($this->ownerId); - } catch (InvalidArgumentException) { - throw new InvalidConfigException('Invalid ownerId param value'); - } - - $this->joinElementTable('commerce_variants'); - - $this->query->select([ - 'commerce_variants.id', - 'commerce_variants.primaryOwnerId', - 'isDefault' => new Expression('CASE WHEN [[commerce_variants]].[[id]] = [[commerce_products]].[[defaultVariantId]] THEN TRUE ELSE FALSE END'), - 'commerce_products_elements_sites.slug as productSlug', - 'commerce_producttypes.handle as productTypeHandle', - ]); - - // Join in the elements_owners table - $ownersCondition = [ - 'and', - '[[elements_owners.elementId]] = [[elements.id]]', - $this->ownerId ? ['elements_owners.ownerId' => $this->ownerId] : '[[elements_owners.ownerId]] = [[commerce_variants.primaryOwnerId]]', - ]; - - $this->query - ->addSelect([ - 'elements_owners.ownerId', - 'elements_owners.sortOrder', - ]) - ->innerJoin(['elements_owners' => CraftTable::ELEMENTS_OWNERS], $ownersCondition); - - $sortOrderIndex = Db::findIndex(CraftTable::ELEMENTS_OWNERS, ['sortOrder'], false); - // Forcing the use of the `sortOrder` index only when listing (no specific element/owner filter), - // so MySQL doesn't prefer it over targeted indexes when querying by ID. - $hasSpecificFilter = !empty($this->id) || !empty($this->ownerId) || !empty($this->primaryOwnerId); - if (Craft::$app->getDb()->getIsMysql() && $sortOrderIndex !== null && empty($this->orderBy) && !$hasSpecificFilter) { - $elementOwnersTable = Craft::$app->getDb()->schema->getRawTableName(\craft\db\Table::ELEMENTS_OWNERS); - $this->subQuery->innerJoin([new Expression('[[' . $elementOwnersTable . ']] AS elements_owners USE INDEX (' . $sortOrderIndex . ')')], $ownersCondition); - } else { - $this->subQuery->innerJoin(['elements_owners' => CraftTable::ELEMENTS_OWNERS], $ownersCondition); - } - - if ($this->primaryOwnerId) { - $this->subQuery->andWhere(['commerce_variants.primaryOwnerId' => $this->primaryOwnerId]); - } - - $this->query->leftJoin(Table::PRODUCTS . ' commerce_products', '[[elements_owners.ownerId]] = [[commerce_products.id]]'); - $this->query->leftJoin(Table::PRODUCTTYPES . ' commerce_producttypes', '[[commerce_products.typeId]] = [[commerce_producttypes.id]]'); - $this->query->leftJoin(CraftTable::ELEMENTS_SITES . ' commerce_products_elements_sites', '[[elements_owners.ownerId]] = [[commerce_products_elements_sites.elementId]] and [[commerce_products_elements_sites.siteId]] = [[elements_sites.siteId]]'); - - $this->subQuery->leftJoin(Table::PRODUCTS . ' commerce_products', '[[elements_owners.ownerId]] = [[commerce_products.id]]'); - $this->subQuery->leftJoin(Table::PRODUCTTYPES . ' commerce_producttypes', '[[commerce_products.typeId]] = [[commerce_producttypes.id]]'); - - if (isset($this->typeId)) { - $this->subQuery->andWhere(Db::parseParam('commerce_products.typeId', $this->typeId)); - } - - if (isset($this->productId)) { - $this->subQuery->andWhere(['commerce_variants.primaryOwnerId' => $this->productId]); - } - - if (isset($this->productStatus)) { - $this->_applyProductStatusParam(); - } - - if (isset($this->isDefault)) { - $this->subQuery->andWhere(Db::parseBooleanParam('isDefault', $this->isDefault, false)); - } - - if (isset($this->minQty)) { - $this->subQuery->andWhere(Db::parseParam('commerce_variants.minQty', $this->minQty)); - } - - if (isset($this->maxQty)) { - $this->subQuery->andWhere(Db::parseParam('commerce_variants.maxQty', $this->maxQty)); - } - - // If width, height or length is specified in the query we should only be looking for products that - // have a type which supports dimensions - if ($this->width !== false || $this->height !== false || $this->length !== false || $this->weight !== false) { - $this->subQuery->andWhere(Db::parseParam('commerce_producttypes.hasDimensions', 1)); - } - - if (isset($this->hasSales)) { - if (!Plugin::getInstance()->getSales()->canUseSales()) { - Craft::$app->getDeprecator()->log('VariantQuery::hasSales', 'The `hasSales` parameter and Sales have been deprecated, use Pricing Rules instead.'); - return false; - } - - $now = new DateTime(); - $activeSales = (new Query())->select([ - 'sales.id', - 'sales.allGroups', - 'sales.allPurchasables', - 'sales.allCategories', - 'sales.categoryRelationshipType', - ]) - ->from(Table::SALES . ' sales') - ->where([ - 'or', - // Only a from date - [ - 'and', - ['dateTo' => null], - ['not', ['dateFrom' => null]], - ['<=', 'dateFrom', Db::prepareDateForDb($now)], - ], - // Only a to date - [ - 'and', - ['dateFrom' => null], - ['not', ['dateTo' => null]], - ['>=', 'dateTo', Db::prepareDateForDb($now)], - ], - // no dates - [ - 'dateFrom' => null, - 'dateTo' => null, - ], - // to and from dates - [ - 'and', - ['not', ['dateFrom' => null]], - ['not', ['dateTo' => null]], - ['<=', 'dateFrom', Db::prepareDateForDb($now)], - ['>=', 'dateTo', Db::prepareDateForDb($now)], - ], - ]) - ->andWhere(['enabled' => true]) - ->orderBy('sortOrder asc') - ->all(); - - $allVariantsMatch = false; - foreach ($activeSales as $activeSale) { - if ($activeSale['allGroups'] == 1 && $activeSale['allPurchasables'] == 1 && $activeSale['allCategories'] == 1) { - $allVariantsMatch = true; - break; - } - } - - if (!$allVariantsMatch) { - $activeSaleIds = ArrayHelper::getColumn($activeSales, 'id'); - - // Only force user group restriction on site requests - if (Craft::$app->getRequest()->isSiteRequest) { - $user = Craft::$app->getUser()->getIdentity(); - $userGroupIds = []; - - if ($user) { - $userGroupIds = ArrayHelper::getColumn($user->getGroups(), 'id'); - } - - // If the user doesn't belong to any groups, remove sales that - // restrict by user group as these would never match - if (empty($userGroupIds)) { - foreach ($activeSales as $activeSale) { - if ($activeSale['allGroups'] == 0) { - ArrayHelper::removeValue($activeSaleIds, $activeSale['id']); - break; - } - } - } else { - // Exclude any sales that have a user group restriction that the current user is not part of - $userGroupSalesIds = (new Query()) - ->select('sales.id') - ->from(Table::SALES . ' sales') - ->leftJoin(Table::SALE_USERGROUPS . ' su', '[[su.saleId]] = [[sales.id]]') - ->where([ - 'sales.id' => $activeSaleIds, - 'userGroupId' => $userGroupIds, - ]) - ->column(); - - foreach ($activeSales as $activeSale) { - if ($activeSale['allGroups'] == 0 && !in_array($activeSale['id'], $userGroupSalesIds, false)) { - ArrayHelper::removeValue($activeSaleIds, $activeSale['id']); - } - } - } - } - - $activeSales = ArrayHelper::whereMultiple($activeSales, ['id' => $activeSaleIds]); - - // Check to see if we have any sales that match all products and categories - // so we can skip extra processing if needed - $allProductsAndCategoriesSales = ArrayHelper::whereMultiple($activeSales, ['allPurchasables' => 1, 'allCategories' => 1]); - $hasSalesVariantConditions = []; - $hasSalesProductConditions = []; - - if (empty($allProductsAndCategoriesSales)) { - $purchasableRestrictedSales = ArrayHelper::whereMultiple($activeSales, ['allPurchasables' => 0]); - $categoryRestrictedSales = ArrayHelper::whereMultiple($activeSales, ['allCategories' => 0]); - - $purchasableRestrictedQuery = (new Query()) - ->select('purchasableId') - ->from(Table::SALE_PURCHASABLES . ' sp') - ->where([ - 'saleId' => ArrayHelper::getColumn($purchasableRestrictedSales, 'id'), - ]); - $hasSalesVariantConditions[] = ['commerce_variants.id' => $purchasableRestrictedQuery]; - - if (!empty($categoryRestrictedSales)) { - $sourceSales = ArrayHelper::whereMultiple($categoryRestrictedSales, [ - 'categoryRelationshipType' => [ - Sale::CATEGORY_RELATIONSHIP_TYPE_SOURCE, - Sale::CATEGORY_RELATIONSHIP_TYPE_BOTH, - ], - ]); - $targetSales = ArrayHelper::whereMultiple($categoryRestrictedSales, [ - 'categoryRelationshipType' => [ - Sale::CATEGORY_RELATIONSHIP_TYPE_TARGET, - Sale::CATEGORY_RELATIONSHIP_TYPE_BOTH, - ], - ]); - - // Source relationships - if (!empty($sourceSales)) { - $sourceQueryProduct = (new Query()) - ->select('rel.sourceId') - ->from(Table::SALE_CATEGORIES . ' sc') - ->leftJoin(CraftTable::RELATIONS . ' rel', '[[rel.targetId]] = [[sc.categoryId]]') - ->leftJoin(CraftTable::ELEMENTS . ' elements', '[[elements.id]] = [[rel.sourceId]]') - ->leftJoin(CraftTable::ELEMENTS_SITES . ' es', '[[es.elementId]] = [[sc.categoryId]]') - ->where(['saleId' => ArrayHelper::getColumn($sourceSales, 'id')]) - ->andWhere(['elements.type' => Product::class]) - ->andWhere(Db::parseParam('es.siteId', $this->siteId)) - ->andWhere(['es.enabled' => true]); - $hasSalesProductConditions[] = ['commerce_variants.primaryOwnerId' => $sourceQueryProduct]; - - $sourceQueryVariant = (new Query()) - ->select('rel.sourceId') - ->from(Table::SALE_CATEGORIES . ' sc') - ->leftJoin(CraftTable::RELATIONS . ' rel', '[[rel.targetId]] = [[sc.categoryId]]') - ->leftJoin(CraftTable::ELEMENTS . ' elements', '[[elements.id]] = [[rel.sourceId]]') - ->leftJoin(CraftTable::ELEMENTS_SITES . ' es', '[[es.elementId]] = [[sc.categoryId]]') - ->where(['saleId' => ArrayHelper::getColumn($sourceSales, 'id')]) - ->andWhere(['elements.type' => Variant::class]) - ->andWhere(Db::parseParam('es.siteId', $this->siteId)) - ->andWhere(['es.enabled' => true]); - $hasSalesVariantConditions[] = ['commerce_variants.id' => $sourceQueryVariant]; - } - - // Target relationships - if (!empty($targetSales)) { - $targetQueryProduct = (new Query()) - ->select('rel.targetId') - ->from(Table::SALE_CATEGORIES . ' sc') - ->leftJoin(CraftTable::RELATIONS . ' rel', '[[rel.sourceId]] = [[sc.categoryId]]') - ->leftJoin(CraftTable::ELEMENTS . ' elements', '[[elements.id]] = [[rel.targetId]]') - ->leftJoin(CraftTable::ELEMENTS_SITES . ' es', '[[es.elementId]] = [[sc.categoryId]]') - ->where(['saleId' => ArrayHelper::getColumn($targetSales, 'id')]) - ->andWhere(['elements.type' => Product::class]) - ->andWhere(Db::parseParam('es.siteId', $this->siteId)) - ->andWhere(['es.enabled' => true]); - $hasSalesProductConditions[] = ['commerce_variants.primaryOwnerId' => $targetQueryProduct]; - - $targetQueryVariant = (new Query()) - ->select('rel.targetId') - ->from(Table::SALE_CATEGORIES . ' sc') - ->leftJoin(CraftTable::RELATIONS . ' rel', '[[rel.sourceId]] = [[sc.categoryId]]') - ->leftJoin(CraftTable::ELEMENTS . ' elements', '[[elements.id]] = [[rel.targetId]]') - ->leftJoin(CraftTable::ELEMENTS_SITES . ' es', '[[es.elementId]] = [[sc.categoryId]]') - ->where(['saleId' => ArrayHelper::getColumn($targetSales, 'id')]) - ->andWhere(['elements.type' => Variant::class]) - ->andWhere(Db::parseParam('es.siteId', $this->siteId)) - ->andWhere(['es.enabled' => true]); - $hasSalesVariantConditions[] = ['commerce_variants.id' => $targetQueryVariant]; - } - } - } - } - - $hasSalesCondition = ['or']; - if (!empty($hasSalesVariantConditions)) { - $hasSalesCondition[] = array_merge(['or'], $hasSalesVariantConditions); - } - - if (!empty($hasSalesProductConditions)) { - $hasSalesCondition[] = array_merge(['or'], $hasSalesProductConditions); - } - - if ($this->hasSales) { - $this->subQuery->andWhere(['purchasables_stores.promotable' => true]); - $this->subQuery->andWhere($hasSalesCondition); - } else { - $this->subQuery->andWhere(['not', $hasSalesCondition]); - } - } - - $this->_applyHasProductParam(); - $this->_applyEditableParam($this->editable, 'commerce-viewProductType'); - $this->_applyEditableParam($this->savable, 'commerce-saveProductType'); - - return parent::beforePrepare(); - } - - protected function afterPrepare(): bool - { - if (!parent::afterPrepare()) { - return false; - } - - // Due to how the element sites table are joined in the subquery we need to do this later in the process - if ($this->productStatus) { - $this->subQuery->leftJoin(CraftTable::ELEMENTS . ' product_elements', '[[product_elements.id]] = [[commerce_variants.primaryOwnerId]]'); - $this->subQuery->leftJoin(CraftTable::ELEMENTS_SITES . ' product_elements_sites', '[[product_elements_sites.elementId]] = [[commerce_variants.primaryOwnerId]] and [[product_elements_sites.siteId]] = [[elements_sites.siteId]]'); - } - - return true; - } - - /** - * Normalizes the primaryOwnerId param to an array of IDs or null - * - * @return int[]|null - * @throws InvalidArgumentException - */ - private function _normalizeOwnerId(mixed $value): ?array - { - if (empty($value)) { - return null; - } - if (is_numeric($value)) { - return [$value]; - } - if (!is_array($value) || !ArrayHelper::isNumeric($value)) { - throw new InvalidArgumentException(); - } - return $value; - } - - - /** - * Applies the hasProduct query condition - */ - private function _applyHasProductParam(): void - { - if (!isset($this->hasProduct)) { - return; - } - - if ($this->hasProduct instanceof ProductQuery) { - $productQuery = $this->hasProduct; - } elseif (is_array($this->hasProduct)) { - $productQuery = Product::find(); - - $criteria = ProductQueryHelper::cleanseQueryCriteria($this->hasProduct); - - $productQuery = Craft::configure($productQuery, $criteria); - } else { - return; - } - - $productQuery->limit = null; - $productQuery->select('commerce_products.id'); - - // Remove any blank product IDs (if any) - $productQuery->andWhere(['not', ['commerce_products.id' => null]]); - - $this->subQuery->andWhere(['commerce_variants.primaryOwnerId' => $productQuery]); - } - - /** - * Applies an authorization param to the query being prepared. - * - * @param bool|null $value - * @param string $permissionPrefix - * @throws QueryAbortedException - */ - private function _applyEditableParam(?bool $value, string $permissionPrefix): void - { - if ($value === null) { - return; - } - - $user = Craft::$app->getUser()->getIdentity(); - - if (!$user) { - throw new QueryAbortedException(); - } - - $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); - - if (empty($productTypes)) { - return; - } - - $authorizedTypeIds = []; - - foreach ($productTypes as $productType) { - if ($user->can("$permissionPrefix:$productType->uid")) { - $authorizedTypeIds[] = $productType->id; - } - } - - if (count($authorizedTypeIds) === count($productTypes)) { - // They have access to everything - if (!$value) { - throw new QueryAbortedException(); - } - return; - } - - if (empty($authorizedTypeIds)) { - // They don't have access to anything - if ($value) { - throw new QueryAbortedException(); - } - return; - } - - $condition = ['commerce_products.typeId' => $authorizedTypeIds]; - - if (!$value) { - $condition = ['not', $condition]; - } - - $this->subQuery->andWhere($condition); - } - - /** - * Applies the 'productStatus' param to the query being prepared. - * - * @since 5.5.0 - */ - private function _applyProductStatusParam(): void - { - if (!$this->productStatus) { - return; - } - - // Normalize the product status param - if (!is_array($this->productStatus)) { - $this->productStatus = StringHelper::split($this->productStatus); - } - - $statuses = array_merge($this->productStatus); - - $firstVal = strtolower(reset($statuses)); - if (in_array($firstVal, ['not', 'or'])) { - $glue = $firstVal; - array_shift($statuses); - if (!$statuses) { - return; - } - } else { - $glue = 'or'; - } - - if ($negate = ($glue === 'not')) { - $glue = 'and'; - } - - $condition = [$glue]; - - foreach ($statuses as $status) { - $status = strtolower($status); - - $statusCondition = ProductQueryHelper::statusCondition($status, 'product_'); - - if ($statusCondition === false) { - throw new QueryAbortedException('Unsupported status: ' . $status); - } - - if ($statusCondition !== null) { - if ($negate) { - $condition[] = ['not', $statusCondition]; - } else { - $condition[] = $statusCondition; - } - } - } - - $this->subQuery->andWhere($condition); - } - - /** - * @inheritdoc - * @since 3.5.0 - */ - protected function cacheTags(): array - { - $tags = []; - - if ($this->ownerId) { - foreach ($this->ownerId as $ownerId) { - $tags[] = "product:$ownerId"; - } - } - - array_push($tags, ...$this->nestedTraitCacheTags()); - - return $tags; - } -} diff --git a/src/elements/deletionblockers/SubscriptionCustomersDeletionBlocker.php b/src/elements/deletionblockers/SubscriptionCustomersDeletionBlocker.php deleted file mode 100644 index 71790c26a4..0000000000 --- a/src/elements/deletionblockers/SubscriptionCustomersDeletionBlocker.php +++ /dev/null @@ -1,87 +0,0 @@ - - * @since 5.7.0 - */ -class SubscriptionCustomersDeletionBlocker extends BaseDeletionBlocker -{ - public int $gatewayId; - public Collection $subscriptions; - - public function isActive(): bool - { - return $this->subscriptions->isNotEmpty(); - } - - public function getSummary(): string - { - return Craft::t('commerce', '{numSubscriptions, number} {numSubscriptions, plural, =1{subscription is} other{subscriptions are}} activated for the {numUsers, plural, =1{user} other{users}}.', [ - 'numSubscriptions' => $this->subscriptions->count(), - 'numUsers' => $this->elements->count(), - ]); - } - - public function getActions(): array - { - $numSubscriptions = $this->subscriptions->count(); - $subscriptionIds = $this->subscriptions->map(fn(Subscription $subscription) => $subscription->id)->all(); - - return [ - [ - 'icon' => 'trash', - 'label' => Craft::t('app', 'Delete {type}', [ - 'type' => $numSubscriptions === 1 ? Subscription::lowerDisplayName() : Subscription::pluralLowerDisplayName(), - ]), - 'destructive' => true, - 'callback' => Html::jsWithVars(fn($subscriptionIds, $gatewayId) => << { - resolve(ev.response.data.message); - }, - onCancel: () => { - reject(); - }, - }); - JS, [ - $subscriptionIds, - $this->gatewayId, - ]), - ], - ]; - } - - public function getDetails(): ?string - { - return Cp::elementIndexHtml(Subscription::class, [ - 'context' => 'pane', - 'sources' => false, - 'jsSettings' => [ - 'criteria' => [ - 'id' => $this->subscriptions->map(fn(Subscription $subscription) => $subscription->id)->all(), - 'status' => null, - ], - ], - ]); - } -} diff --git a/src/elements/traits/OrderElementTrait.php b/src/elements/traits/OrderElementTrait.php deleted file mode 100644 index 5f04251d32..0000000000 --- a/src/elements/traits/OrderElementTrait.php +++ /dev/null @@ -1,860 +0,0 @@ -getFields()->getLayoutByType(self::class); - } - - /** - * @inheritdoc - */ - protected function htmlAttributes(string $context): array - { - $attributes = parent::htmlAttributes($context); - $attributes['data'] = ['number' => $this->number]; - return $attributes; - } - - /** - * @inheritdoc - */ - protected function attributeHtml(string $attribute): string - { - switch ($attribute) { - case 'orderStatus': - { - return $this->getOrderStatus() ? $this->getOrderStatus()->getLabelHtml() : ''; - } - case 'customer': - { - return $this->getCustomerLinkHtml(); - } - case 'shippingFullName': - { - return $this->getShippingAddress() ? Html::encode($this->getShippingAddress()->fullName ?? '') : ''; - } - case 'shippingFirstName': - { - return $this->getShippingAddress() ? Html::encode($this->getShippingAddress()->firstName ?? '') : ''; - } - case 'shippingLastName': - { - return $this->getShippingAddress() ? Html::encode($this->getShippingAddress()->lastName ?? '') : ''; - } - case 'billingFullName': - { - return $this->getBillingAddress() ? Html::encode($this->getBillingAddress()->fullName ?? '') : ''; - } - case 'billingFirstName': - { - return $this->getBillingAddress() ? Html::encode($this->getBillingAddress()->firstName ?? '') : ''; - } - case 'billingLastName': - { - return $this->getBillingAddress() ? Html::encode($this->getBillingAddress()->lastName ?? '') : ''; - } - case 'shippingOrganizationName': - { - return $this->getShippingAddress() ? Html::encode($this->getShippingAddress()->organization ?? '') : ''; - } - case 'billingOrganizationName': - { - return $this->getBillingAddress() ? Html::encode($this->getBillingAddress()->organization ?? '') : ''; - } - case 'shippingMethodName': - { - return Html::encode($this->shippingMethodName ?? ''); - } - case 'gatewayName': - { - return Html::encode($this->getGateway()->name ?? ''); - } - case 'paidStatus': - { - return $this->getPaidStatusHtml(); - } - case 'totalPaid': - { - return $this->storedTotalPaidAsCurrency; - } - case 'itemTotal': - { - return $this->storedItemTotalAsCurrency; - } - case 'itemSubtotal': - { - return $this->storedItemSubtotalAsCurrency; - } - case 'totalQty': - { - return (string)$this->storedTotalQty; - } - case 'total': - { - return $this->totalAsCurrency; - } - case 'totalPrice': - { - return $this->storedTotalPriceAsCurrency; - } - case 'totalShippingCost': - { - return $this->storedTotalShippingCostAsCurrency; - } - case 'totalDiscount': - { - return $this->storedTotalDiscountAsCurrency; - } - case 'totalTax': - { - return $this->storedTotalTaxAsCurrency; - } - case 'totalIncludedTax': - { - return $this->storedTotalTaxIncludedAsCurrency; - } - case 'totals': - { - $miniTable = []; - - $miniTable[] = [ - 'label' => Craft::t('commerce', 'Qty'), - 'value' => $this->storedTotalQty, - ]; - - if ($this->itemSubtotal > 0) { - $miniTable[] = [ - 'label' => Craft::t('commerce', 'Items'), - 'value' => $this->itemSubtotalAsCurrency, - ]; - } - - if ($this->storedTotalDiscount < 0) { - $miniTable[] = [ - 'label' => Craft::t('commerce', 'Discounts'), - 'value' => $this->storedTotalDiscountAsCurrency, - ]; - } - - if ($this->storedTotalShippingCost > 0) { - $miniTable[] = [ - 'label' => Craft::t('commerce', 'Shipping'), - 'value' => $this->storedTotalShippingCostAsCurrency, - ]; - } - - if ($this->storedTotalTaxIncluded > 0) { - $miniTable[] = [ - 'label' => Craft::t('commerce', 'Tax (inc)'), - 'value' => $this->storedTotalTaxIncludedAsCurrency, - ]; - } - - if ($this->storedTotalTax > 0) { - $miniTable[] = [ - 'label' => Craft::t('commerce', 'Tax'), - 'value' => $this->storedTotalTaxAsCurrency, - ]; - } - - if ($this->storedTotalPrice > 0) { - $miniTable[] = [ - 'label' => Craft::t('commerce', 'Price'), - 'value' => $this->storedTotalPriceAsCurrency, - ]; - } - - return $this->_miniTable($miniTable); - } - case 'orderSite': - { - $site = Craft::$app->getSites()->getSiteById($this->orderSiteId); - return Html::encode($site->name ?? ''); - } - case 'hasAdminNotices': - { - if (!$this->hasAdminNotices()) { - return ''; - } - return Cp::statusLabelHtml(['color' => 'red', 'label' => Craft::t('commerce', 'Yes')]); - } - default: - { - return parent::attributeHtml($attribute); - } - } - } - - /** - * @inheritdoc - */ - protected static function defineSearchableAttributes(): array - { - return [ - 'billingFirstName', - 'billingLastName', - 'billingFullName', - 'billingAddress', - 'email', - 'number', - 'shippingFirstName', - 'shippingLastName', - 'shippingFullName', - 'shippingAddress', - 'shortNumber', - 'transactionReference', - 'username', - 'reference', - 'skus', - 'lineItemDescriptions', - 'customerName', - ]; - } - - /** - * @inheritdoc - * @noinspection PhpUnused - */ - public function getSearchKeywords(string $attribute): string - { - switch ($attribute) { - case 'billingFirstName': - return $this->billingAddress->firstName ?? ''; - case 'billingLastName': - return $this->billingAddress->lastName ?? ''; - case 'billingFullName': - return $this->billingAddress->fullName ?? ''; - case 'billingAddress': - $address = $this->getBillingAddress(); - return $address ? Craft::$app->getAddresses()->formatAddress($address) : ''; - case 'shippingFirstName': - return $this->shippingAddress->firstName ?? ''; - case 'shippingLastName': - return $this->shippingAddress->lastName ?? ''; - case 'shippingFullName': - return $this->shippingAddress->fullName ?? ''; - case 'shippingAddress': - $address = $this->getShippingAddress(); - return $address ? Craft::$app->getAddresses()->formatAddress($address) : ''; - case 'transactionReference': - return implode(' ', ArrayHelper::getColumn($this->getTransactions(), 'reference')); - case 'username': - return $this->getCustomer()->username ?? ''; - case 'skus': - return implode(' ', ArrayHelper::getColumn($this->getLineItems(), 'sku')); - case 'lineItemDescriptions': - return implode(' ', ArrayHelper::getColumn($this->getLineItems(), 'description')); - case 'customerName': - return $this->getCustomer()->fullName ?? ''; - default: - return parent::getSearchKeywords($attribute); - } - } - - - /** - * @inheritdoc - * @throws Exception - */ - protected static function defineSources(string $context = null): array - { - $siteHandle = Craft::$app->getRequest()->getParam('site'); - $site = $siteHandle ? Craft::$app->getSites()->getSiteByHandle($siteHandle) : Craft::$app->getSites()->getCurrentSite(); - /** @var StoreBehavior $site */ - $store = $site->getStore(); - $orderCriteria = ['isCompleted' => true, 'storeId' => $store->id]; - - $sources = [ - '*' => [ - 'key' => '*', - 'label' => Craft::t('commerce', 'All Orders'), - 'criteria' => $orderCriteria, - 'defaultSort' => ['dateOrdered', 'desc'], - 'data' => [ - 'date-attr' => 'dateOrdered', - ], - ], - ]; - - $edge = Plugin::getInstance()->getCarts()->getActiveCartEdgeDuration(); - - $criteriaActive = ['dateUpdated' => ['>= ' . $edge], 'isCompleted' => false]; - $criteriaInactive = ['dateUpdated' => ['< ' . $edge], 'isCompleted' => false]; - $criteriaAttemptedPayment = ['hasTransactions' => true, 'isCompleted' => false]; - - $orderStatuses = Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses($store->id)->all(); - - $sources[] = ['heading' => $store->getName()]; - - foreach ($orderStatuses as $orderStatus) { - $key = 'orderStatus:' . $orderStatus->handle; - - $sources[$key] = [ - 'key' => $key, - 'status' => $orderStatus->color, - 'label' => Craft::t('site', $orderStatus->name), - 'badgeCount' => 0, - 'criteria' => ArrayHelper::merge($orderCriteria, ['orderStatusId' => $orderStatus->id]), - 'defaultSort' => ['dateOrdered', 'desc'], - 'data' => [ - 'handle' => $orderStatus->handle, - 'date-attr' => 'dateOrdered', - ], - ]; - } - - $sources[] = [ - 'key' => 'carts:active:' . $store->handle, - 'label' => Craft::t('commerce', 'Active Carts'), - 'criteria' => ArrayHelper::merge($criteriaActive, ['storeId' => $store->id]), - 'defaultSort' => ['commerce_orders.dateUpdated', 'asc'], - 'data' => [ - 'handle' => 'cartsActive', - 'date-attr' => 'dateUpdated', - ], - ]; - - $sources[] = [ - 'key' => 'carts:inactive:' . $store->handle, - 'label' => Craft::t('commerce', 'Inactive Carts'), - 'criteria' => ArrayHelper::merge($criteriaInactive, ['storeId' => $store->id]), - 'defaultSort' => ['commerce_orders.dateUpdated', 'desc'], - 'data' => [ - 'handle' => 'cartsInactive', - 'date-attr' => 'dateUpdated', - ], - ]; - - $sources[] = [ - 'key' => 'carts:attempted-payment:' . $store->handle, - 'label' => Craft::t('commerce', 'Attempted Payments'), - 'criteria' => ArrayHelper::merge($criteriaAttemptedPayment, ['storeId' => $store->id]), - 'defaultSort' => ['commerce_orders.dateUpdated', 'desc'], - 'data' => [ - 'handle' => 'cartsAttemptedPayment', - 'date-attr' => 'dateUpdated', - ], - ]; - - return $sources; - } - - /** - * @inheritdoc - */ - protected static function defineActions(string $source): array - { - $actions = parent::defineActions($source); - - if (Craft::$app->getUser()->checkPermission('commerce-manageOrders')) { - /** @var StoreBehavior|Site $site */ - $site = Cp::requestedSite(); - $store = $site->getStore(); - // Remove nested "all" prefix if it exists at the start of the string - $source = str_starts_with($source, '*/') ? substr($source, 2) : $source; - - - $elementService = Craft::$app->getElements(); - - if ($store && Plugin::getInstance()->getPdfs()->getHasEnabledPdf($store->id)) { - $actions[] = $elementService->createAction([ - 'type' => DownloadOrderPdfAction::class, - 'storeId' => $store->id, - ]); - } - - if (Craft::$app->getUser()->checkPermission('commerce-deleteOrders')) { - $deleteAction = $elementService->createAction( - [ - 'type' => Delete::class, - 'confirmationMessage' => Craft::t('commerce', 'Are you sure you want to delete the selected orders?'), - 'successMessage' => Craft::t('commerce', 'Orders deleted.'), - ] - ); - $actions[] = $deleteAction; - } - - if (Craft::$app->getUser()->checkPermission('commerce-editOrders')) { - // Only allow mass updating order status when all selected are of the same status, and not carts. - $isStatus = strpos($source, 'orderStatus:'); - if ($isStatus === 0) { - $updateOrderStatusAction = $elementService->createAction([ - 'type' => UpdateOrderStatus::class, - ]); - $actions[] = $updateOrderStatusAction; - } - - $isStatus = strpos($source, 'carts:'); - if ($isStatus === 0) { - $updateOrderStatusAction = $elementService->createAction([ - 'type' => CopyLoadCartUrl::class, - ]); - $actions[] = $updateOrderStatusAction; - } - } - - if (Craft::$app->getUser()->checkPermission('commerce-deleteOrders')) { - // Restore - $actions[] = Craft::$app->getElements()->createAction([ - 'type' => Restore::class, - 'successMessage' => Craft::t('commerce', 'Orders restored.'), - 'partialSuccessMessage' => Craft::t('commerce', 'Some orders restored.'), - 'failMessage' => Craft::t('commerce', 'Orders not restored.'), - ]); - } - } - - return $actions; - } - - /** - * @inheritDoc - */ - protected static function defineExporters(string $source): array - { - $default = parent::defineExporters($source); - // Remove the standard expanded exporter and use our own - ArrayHelper::removeValue($default, CraftExpanded::class); - $default[] = Expanded::class; - - return $default; - } - - /** - * @inheritdoc - */ - protected static function defineTableAttributes(): array - { - return array_merge(parent::defineTableAttributes(), [ - 'reference' => ['label' => Craft::t('commerce', 'Reference')], - 'shortNumber' => ['label' => Craft::t('commerce', 'Short Number')], - 'number' => ['label' => Craft::t('commerce', 'Number')], - 'id' => ['label' => Craft::t('commerce', 'ID')], - 'orderStatus' => ['label' => Craft::t('commerce', 'Status')], - 'totals' => ['label' => Craft::t('commerce', 'All Totals')], - 'totalQty' => ['label' => Craft::t('commerce', 'Total Qty')], - 'total' => ['label' => Craft::t('commerce', 'Total')], - 'totalPrice' => ['label' => Craft::t('commerce', 'Total Price')], - 'totalPaid' => ['label' => Craft::t('commerce', 'Total Paid')], - 'totalDiscount' => ['label' => Craft::t('commerce', 'Total Discount')], - 'totalShippingCost' => ['label' => Craft::t('commerce', 'Total Shipping')], - 'totalTax' => ['label' => Craft::t('commerce', 'Total Tax')], - 'totalIncludedTax' => ['label' => Craft::t('commerce', 'Total Included Tax')], - 'dateOrdered' => ['label' => Craft::t('commerce', 'Date Ordered')], - 'datePaid' => ['label' => Craft::t('commerce', 'Date Paid')], - 'dateFirstPaid' => ['label' => Craft::t('commerce', 'Date First Paid')], - 'dateCreated' => ['label' => Craft::t('commerce', 'Date Created')], - 'dateUpdated' => ['label' => Craft::t('commerce', 'Date Updated')], - 'email' => ['label' => Craft::t('commerce', 'Email')], - 'customer' => ['label' => Craft::t('commerce', 'Customer')], - 'shippingFullName' => ['label' => Craft::t('commerce', 'Shipping Full Name')], - 'shippingFirstName' => ['label' => Craft::t('commerce', 'Shipping First Name')], - 'shippingLastName' => ['label' => Craft::t('commerce', 'Shipping Last Name')], - 'billingFullName' => ['label' => Craft::t('commerce', 'Billing Full Name')], - 'billingFirstName' => ['label' => Craft::t('commerce', 'Billing First Name')], - 'billingLastName' => ['label' => Craft::t('commerce', 'Billing Last Name')], - 'shippingOrganizationName' => ['label' => Craft::t('commerce', 'Shipping Business Name')], - 'billingOrganizationName' => ['label' => Craft::t('commerce', 'Billing Business Name')], - 'shippingMethodName' => ['label' => Craft::t('commerce', 'Shipping Method')], - 'gatewayName' => ['label' => Craft::t('commerce', 'Gateway')], - 'paidStatus' => ['label' => Craft::t('commerce', 'Paid Status')], - 'couponCode' => ['label' => Craft::t('commerce', 'Coupon Code')], - 'itemTotal' => ['label' => Craft::t('commerce', 'Item Total')], - 'itemSubtotal' => ['label' => Craft::t('commerce', 'Item Subtotal')], - 'orderSite' => ['label' => Craft::t('commerce', 'Order Site')], - 'hasAdminNotices' => ['label' => Craft::t('commerce', 'Admin Notices')], - ]); - } - - /** - * @inheritdoc - */ - protected static function defineDefaultTableAttributes(string $source = null): array - { - $attributes = []; - $attributes[] = 'order'; - - if (!str_starts_with($source, 'carts:')) { - // For orders (including order status sources) - $attributes[] = 'reference'; - if (!str_starts_with($source, 'orderStatus:')) { - // Only show status column when not filtered by status - $attributes[] = 'orderStatus'; - } - $attributes[] = 'customer'; - $attributes[] = 'dateOrdered'; - $attributes[] = 'datePaid'; - $attributes[] = 'dateFirstPaid'; - $attributes[] = 'totalPaid'; - $attributes[] = 'paidStatus'; - $attributes[] = 'totals'; - } else { - // For carts - $attributes[] = 'shortNumber'; - $attributes[] = 'dateUpdated'; - $attributes[] = 'totalPrice'; - } - - return $attributes; - } - - /** - * @inheritdoc - */ - public static function prepElementQueryForTableAttribute(ElementQueryInterface $elementQuery, string $attribute): void - { - /** @var OrderQuery $elementQuery */ - - match ($attribute) { - 'totals', 'total', 'totalPrice', 'totalDiscount', 'totalShippingCost', 'totalTax', 'totalIncludedTax' => $elementQuery->withAdjustments(), - 'totalPaid', 'paidStatus' => $elementQuery->withTransactions(), - 'shippingFullName', 'shippingFirstName', 'shippingLastName', 'billingFullName', 'billingFirstName', 'billingLastName', 'shippingOrganizationName', 'billingOrganizationName', 'shippingMethodName' => $elementQuery->withAddresses(), - 'email', 'customer' => $elementQuery->withCustomer(), - 'itemTotal', 'itemSubtotal' => $elementQuery->withLineItems(), - default => parent::prepElementQueryForTableAttribute($elementQuery, $attribute), - }; - } - - /** - * @inheritdoc - * @return OrderCondition - */ - public static function createCondition(): ElementConditionInterface - { - return Craft::createObject(OrderCondition::class, [static::class]); - } - - /** - * @inheritdoc - */ - protected static function defineSortOptions(): array - { - return [ - 'number' => Craft::t('commerce', 'Number'), - 'reference' => Craft::t('commerce', 'Reference'), - 'orderStatusId' => Craft::t('commerce', 'Order Status'), - 'totalPrice' => Craft::t('commerce', 'Total Price'), - 'totalPaid' => Craft::t('commerce', 'Total Paid'), - [ - 'label' => Craft::t('commerce', 'Shipping First Name'), - 'orderBy' => 'shipping_address.firstName', - 'attribute' => 'shippingFirstName', - ], - [ - 'label' => Craft::t('commerce', 'Shipping Last Name'), - 'orderBy' => 'shipping_address.lastName', - 'attribute' => 'shippingLastName', - ], - [ - 'label' => Craft::t('commerce', 'Shipping Full Name'), - 'orderBy' => 'shipping_address.fullName', - 'attribute' => 'shippingFullName', - ], - [ - 'label' => Craft::t('commerce', 'Billing First Name'), - 'orderBy' => 'billing_address.firstName', - 'attribute' => 'billingFirstName', - ], - [ - 'label' => Craft::t('commerce', 'Billing Last Name'), - 'orderBy' => 'billing_address.lastName', - 'attribute' => 'billingLastName', - ], - [ - 'label' => Craft::t('commerce', 'Billing Full Name'), - 'orderBy' => 'billing_address.fullName', - 'attribute' => 'billingFullName', - ], - [ - 'label' => Craft::t('commerce', 'Date Ordered'), - 'orderBy' => 'dateOrdered', - 'defaultDir' => 'desc', - ], - [ - 'label' => Craft::t('commerce', 'Date Updated'), - 'orderBy' => 'commerce_orders.dateUpdated', - 'attribute' => 'dateUpdated', - 'defaultDir' => 'desc', - ], - [ - 'label' => Craft::t('commerce', 'Date Paid'), - 'orderBy' => 'datePaid', - 'defaultDir' => 'desc', - ], - [ - 'label' => Craft::t('commerce', 'Date First Paid'), - 'orderBy' => 'dateFirstPaid', - 'defaultDir' => 'desc', - ], - 'couponCode' => Craft::t('commerce', 'Coupon Code'), - [ - 'label' => Craft::t('app', 'ID'), - 'orderBy' => 'elements.id', - 'attribute' => 'id', - ], - ]; - } - - /** - * @param array $miniTable Expects an array with rows of 'label', 'value' keys values. - */ - private function _miniTable(array $miniTable): string - { - $output = ''; - foreach ($miniTable as $row) { - $output .= ''; - $output .= ''; - $output .= ''; - $output .= ''; - } - $output .= '
' . $row['label'] . '' . $row['value'] . '
'; - - return $output; - } - - /** - * @inheritdoc - */ - public static function modifyCustomSource(array $config): array - { - try { - /** @var OrderCondition $condition */ - $condition = Craft::$app->getConditions()->createCondition($config['condition']); - } catch (InvalidConfigException) { - return $config; - } - - $rules = $condition->getConditionRules(); - - // see if it's limited to one product type - /** @var OrderStatusConditionRule|null $orderStatusConditionRule */ - $orderStatusConditionRule = ArrayHelper::firstWhere($rules, fn($rule) => $rule instanceof OrderStatusConditionRule); - $orderStatusOptions = $orderStatusConditionRule?->getValues(); - - /** @var StoreBehavior $currentSite */ - $currentSite = Cp::requestedSite(); - $store = $currentSite->getStore(); - - - if ($orderStatusOptions && count($orderStatusOptions) === 1) { - $orderStatus = Plugin::getInstance()->getOrderStatuses()->getOrderStatusByUid(reset($orderStatusOptions)); - - if ($store->id != $orderStatus->storeId) { - $config['disabled'] = true; - } - - if ($orderStatus) { - $config['status'] = $orderStatus->color; - } - } - - return $config; - } - - /** - * @inheritdoc - */ - protected static function defineCardAttributes(): array - { - /** @var OrderStatus $status */ - $status = Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses()->first(); - $site = Craft::$app->getSites()->getCurrentSite(); - $number = Plugin::getInstance()->getCarts()->generateCartNumber(); - - return array_merge(parent::defineCardAttributes(), [ - 'shortNumber' => [ - 'label' => Craft::t('commerce', 'Short Number'), - 'placeholder' => substr($number, 0, 7), - ], - 'number' => [ - 'label' => Craft::t('commerce', 'Number'), - 'placeholder' => $number, - ], - 'id' => [ - 'label' => Craft::t('commerce', 'ID'), - 'placeholder' => '12345', - ], - 'orderStatus' => [ - 'label' => Craft::t('commerce', 'Status'), - 'placeholder' => $status->getLabelHtml(), - ], - 'totalQty' => [ - 'label' => Craft::t('commerce', 'Total Qty'), - 'placeholder' => '10', - ], - 'total' => [ - 'label' => Craft::t('commerce', 'Total'), - 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(123.99), - ], - 'totalPrice' => [ - 'label' => Craft::t('commerce', 'Total Price'), - 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(123.99), - ], - 'totalPaid' => [ - 'label' => Craft::t('commerce', 'Total Paid'), - 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(123.99), - ], - 'totalDiscount' => [ - 'label' => Craft::t('commerce', 'Total Discount'), - 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(12.99), - ], - 'totalShippingCost' => [ - 'label' => Craft::t('commerce', 'Total Shipping'), - 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(9.99), - ], - 'totalTax' => [ - 'label' => Craft::t('commerce', 'Total Tax'), - 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(19.99), - ], - 'totalIncludedTax' => [ - 'label' => Craft::t('commerce', 'Total Included Tax'), - 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(19.99), - ], - 'dateOrdered' => [ - 'label' => Craft::t('commerce', 'Date Ordered'), - 'placeholder' => Craft::$app->getFormattingLocale()->getFormatter()->asDate(time(), 'short'), - ], - 'datePaid' => [ - 'label' => Craft::t('commerce', 'Date Paid'), - 'placeholder' => Craft::$app->getFormattingLocale()->getFormatter()->asDate(time(), 'short'), - ], - 'dateFirstPaid' => [ - 'label' => Craft::t('commerce', 'Date First Paid'), - 'placeholder' => Craft::$app->getFormattingLocale()->getFormatter()->asDate(time(), 'short'), - ], - 'dateUpdated' => [ - 'label' => Craft::t('commerce', 'Date Updated'), - 'placeholder' => Craft::$app->getFormattingLocale()->getFormatter()->asDate(time(), 'short'), - ], - 'email' => [ - 'label' => Craft::t('commerce', 'Email'), - 'placeholder' => 'user@example.com', - ], - 'customer' => [ - 'label' => Craft::t('commerce', 'Customer'), - 'placeholder' => Craft::t('commerce', 'Customer'), - ], - 'shippingFullName' => [ - 'label' => Craft::t('commerce', 'Shipping Full Name'), - 'placeholder' => Craft::t('commerce', 'Shipping Full Name'), - ], - 'shippingFirstName' => [ - 'label' => Craft::t('commerce', 'Shipping First Name'), - 'placeholder' => Craft::t('commerce', 'Shipping First Name'), - ], - 'shippingLastName' => [ - 'label' => Craft::t('commerce', 'Shipping Last Name'), - 'placeholder' => Craft::t('commerce', 'Shipping Last Name'), - ], - 'billingFullName' => [ - 'label' => Craft::t('commerce', 'Billing Full Name'), - 'placeholder' => Craft::t('commerce', 'Billing Full Name'), - ], - 'billingFirstName' => [ - 'label' => Craft::t('commerce', 'Billing First Name'), - 'placeholder' => Craft::t('commerce', 'Billing First Name'), - ], - 'billingLastName' => [ - 'label' => Craft::t('commerce', 'Billing Last Name'), - 'placeholder' => Craft::t('commerce', 'Billing Last Name'), - ], - 'shippingOrganizationName' => [ - 'label' => Craft::t('commerce', 'Shipping Business Name'), - 'placeholder' => Craft::t('commerce', 'Shipping Business Name'), - ], - 'billingOrganizationName' => [ - 'label' => Craft::t('commerce', 'Billing Business Name'), - 'placeholder' => Craft::t('commerce', 'Billing Business Name'), - ], - 'shippingMethodName' => [ - 'label' => Craft::t('commerce', 'Shipping Method'), - 'placeholder' => Craft::t('commerce', 'Shipping Method'), - ], - 'gatewayName' => [ - 'label' => Craft::t('commerce', 'Gateway'), - 'placeholder' => Craft::t('commerce', 'Gateway'), - ], - 'paidStatus' => [ - 'label' => Craft::t('commerce', 'Paid Status'), - 'placeholder' => Cp::statusLabelHtml(['color' => 'green', 'label' => Craft::t('commerce', 'Paid')]), - ], - 'couponCode' => [ - 'label' => Craft::t('commerce', 'Coupon Code'), - 'placeholder' => 'SAVE10', - ], - 'itemTotal' => [ - 'label' => Craft::t('commerce', 'Item Total'), - 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(99.99), - ], - 'itemSubtotal' => [ - 'label' => Craft::t('commerce', 'Item Subtotal'), - 'placeholder' => '¤' . Craft::$app->getFormattingLocale()->getFormatter()->asDecimal(89.99), - ], - 'orderSite' => [ - 'label' => Craft::t('commerce', 'Order Site'), - 'placeholder' => $site->name, - ], - 'reference' => [ - 'label' => Craft::t('commerce', 'Reference'), - 'placeholder' => 'ORD-XXXXX', - ], - ]); - } - - /** - * @inheritdoc - */ - protected static function defineDefaultCardAttributes(): array - { - return array_merge(parent::defineDefaultCardAttributes(), [ - 'reference', - 'orderStatus', - 'totalPrice', - ]); - } -} diff --git a/src/elements/traits/OrderNoticesTrait.php b/src/elements/traits/OrderNoticesTrait.php deleted file mode 100644 index da33130b01..0000000000 --- a/src/elements/traits/OrderNoticesTrait.php +++ /dev/null @@ -1,173 +0,0 @@ -_notices, fn(OrderNotice $n) => $n->noticeType === OrderNoticeType::Customer)); - return $this->_filterNotices($notices, $type, $attribute); - } - - /** - * Returns admin-only notices, optionally filtered by type and/or attribute. - * - * @param string|null $type - * @param string|null $attribute - * @return OrderNotice[] - * @since 5.x - */ - public function getAdminNotices(?string $type = null, ?string $attribute = null): array - { - $notices = array_values(array_filter($this->_notices, fn(OrderNotice $n) => $n->noticeType === OrderNoticeType::Admin)); - return $this->_filterNotices($notices, $type, $attribute); - } - - /** - * Adds a new notice - * - * @since 3.3 - */ - public function addNotice(OrderNotice $notice): void - { - $notice->setOrder($this); - $this->_notices[] = $notice; - } - - /** - * Returns the first non-admin notice matching the specified type or attribute. - * - * @param null $type - * @param null $attribute - * @since 3.3 - */ - public function getFirstNotice($type = null, $attribute = null): ?OrderNotice - { - return ArrayHelper::firstValue($this->getNotices($type, $attribute)); - } - - /** - * Adds a list of notices. - * - * @param OrderNotice[] $notices an array of notices. - * @since 3.3 - */ - public function addNotices(array $notices): void - { - foreach ($notices as $notice) { - $this->addNotice($notice); - } - } - - /** - * Removes notices matching the given criteria, scoped to the specified notice types. - * - * By default only customer notices are cleared, preserving admin notices for backwards compatibility. - * Pass one or more {@see OrderNoticeType} values to control which notice types are affected. - * - * @param string|null $type type name. Use null to remove notices for all types. - * @param string|null $attribute attribute name. Use null to remove notices for all attributes. - * @param OrderNoticeType|OrderNoticeType[]|null $noticeTypes Notice type(s) to clear. Defaults to customer notices only. - * @since 3.3 - */ - public function clearNotices(?string $type = null, ?string $attribute = null, array|OrderNoticeType|null $noticeTypes = null): void - { - if ($noticeTypes === null) { - $noticeTypes = [OrderNoticeType::Customer]; - } elseif ($noticeTypes instanceof OrderNoticeType) { - $noticeTypes = [$noticeTypes]; - } - - $targetNotices = array_values(array_filter($this->_notices, fn(OrderNotice $n) => in_array($n->noticeType, $noticeTypes))); - $preservedNotices = array_values(array_filter($this->_notices, fn(OrderNotice $n) => !in_array($n->noticeType, $noticeTypes))); - - if ($type === null && $attribute === null) { - $remaining = []; - } elseif ($type !== null && $attribute === null) { - $remaining = array_values(array_filter($targetNotices, fn(OrderNotice $n) => $n->type !== $type)); - } elseif ($type === null && $attribute !== null) { - $remaining = array_values(array_filter($targetNotices, fn(OrderNotice $n) => $n->attribute !== $attribute)); - } else { - $remaining = array_values(array_filter($targetNotices, fn(OrderNotice $n) => !($n->type === $type && $n->attribute === $attribute))); - } - - $this->_notices = array_merge($preservedNotices, $remaining); - } - - /** - * Returns a value indicating whether there are any non-admin notices. - * - * @param string|null $type type name. Use null to check all types. - * @param string|null $attribute attribute name. Use null to check all attributes. - * @return bool whether there is any notices. - * @since 3.3 - */ - public function hasNotices(?string $type = null, ?string $attribute = null): bool - { - return !empty($this->getNotices($type, $attribute)); - } - - /** - * Returns whether there are any admin notices. - * - * @since 5.x - */ - public function hasAdminNotices(): bool - { - return !empty($this->getAdminNotices()); - } - - /** - * Filters an array of notices by type and/or attribute. - * - * @param OrderNotice[] $notices - * @param string|null $type - * @param string|null $attribute - * @return OrderNotice[] - */ - private function _filterNotices(array $notices, ?string $type, ?string $attribute): array - { - if ($type === null && $attribute === null) { - return $notices; - } - - if ($type !== null && $attribute === null) { - return ArrayHelper::where($notices, 'type', $type); - } - - if ($type === null && $attribute !== null) { - return ArrayHelper::where($notices, 'attribute', $attribute); - } - - return ArrayHelper::where($notices, fn(OrderNotice $n) => $n->attribute === $attribute && $n->type === $type, true, true, true); - } -} diff --git a/src/elements/traits/OrderValidatorsTrait.php b/src/elements/traits/OrderValidatorsTrait.php deleted file mode 100644 index 038a14e8dd..0000000000 --- a/src/elements/traits/OrderValidatorsTrait.php +++ /dev/null @@ -1,188 +0,0 @@ - - */ -trait OrderValidatorsTrait -{ - /** - * @param string $attribute - * @param $params - * @param Validator $validator - */ - public function validateGatewayId(string $attribute, $params, Validator $validator): void - { - if ($this->gatewayId && !$this->getGateway()) { - $validator->addError($this, $attribute, Craft::t('commerce', 'Invalid gateway: {value}')); - } - } - - /** - * @param string $attribute - * @param $params - * @param Validator $validator - */ - public function validatePaymentSourceId(string $attribute, $params, Validator $validator): void - { - try { - // this will confirm the payment source is valid and belongs to the orders customer - $this->getPaymentSource(); - } catch (InvalidConfigException $e) { - Craft::$app->getErrorHandler()->logException($e); - $validator->addError($this, $attribute, Craft::t('commerce', 'Invalid payment source ID: {value}')); - } - } - - /** - * @param string $attribute - * @param $params - * @param Validator $validator - * @noinspection PhpUnused - */ - public function validatePaymentCurrency(string $attribute, $params, Validator $validator): void - { - try { - // this will confirm the payment source is valid and belongs to the orders customer - $this->getPaymentCurrency(); - } catch (InvalidConfigException) { - $validator->addError($this, $attribute, Craft::t('commerce', 'Invalid payment source ID: {value}')); - } - } - - /** - * Validates addresses, and also adds prefixed validation errors to order - * - * @param string $attribute the attribute being validated - * @throws InvalidConfigException - * @noinspection PhpUnused - * @throws InvalidConfigException - */ - public function validateAddress(string $attribute): void - { - /** @var Address|null $address */ - $address = $this->$attribute; - - // Set live scenario for addresses to match CP - $address?->setScenario(Address::SCENARIO_LIVE); - - if ($address && !$address->validate()) { - $this->addModelErrors($address, $attribute); - } - - $marketLocationCondition = $this->getStore()->getSettings()->getMarketAddressCondition(); - if ($address && count($marketLocationCondition->getConditionRules()) > 0 && !$marketLocationCondition->matchElement($address)) { - $this->addError($attribute, Craft::t('commerce', 'The address provided is outside the store’s market.')); - } - } - - /** - * Validates that address country is in the allowed list. - * - * @param string $attribute the attribute being validated - */ - public function validateAddressCountry(string $attribute): void - { - $address = $this->$attribute; - if ($address && $address->countryCode) { - $countriesList = array_keys($this->getStore()->getSettings()->getCountriesList()); - if (count($countriesList) && !in_array($address->countryCode, $countriesList, false)) { - $this->addError($attribute, Craft::t('commerce', 'Country not allowed.')); - } - } - } - - /** - * Validates that shipping address isn't being set to be the same as billing address, when billing address is set to be shipping address - * - * @param string $attribute the attribute being validated - */ - public function validateAddressReuse(string $attribute): void - { - if ($this->shippingSameAsBilling && $this->billingSameAsShipping) { - $this->addError($attribute, Craft::t('commerce', 'shippingSameAsBilling and billingSameAsShipping can’t both be set.')); - } - } - - /** - * Validates line items, and also adds prefixed validation errors to order - * - */ - public function validateLineItems(): void - { - OrderHelper::normalizeLineItemPurchasableAvailability($this); - OrderHelper::mergeDuplicateLineItems($this); - - foreach ($this->getLineItems() as $key => $lineItem) { - if (!$lineItem->validate()) { - $this->addModelErrors($lineItem, "lineItems.$key"); - } - } - } - - /** - * @param $attribute - * @throws InvalidConfigException - * @noinspection PhpUnused - */ - public function validateCouponCode($attribute): void - { - $recalculateAll = $this->recalculationMode == Order::RECALCULATION_MODE_ALL; - $recalculateAll = $recalculateAll || $this->recalculationMode == Order::RECALCULATION_MODE_ADJUSTMENTS_ONLY; - if ($recalculateAll && $this->$attribute && !Plugin::getInstance()->getDiscounts()->orderCouponAvailable($this, $explanation)) { - /** @var OrderNotice $notice */ - $notice = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => [ - 'type' => 'invalidCouponRemoved', - 'attribute' => $attribute, - 'message' => Craft::t('commerce', 'Coupon removed: {explanation}', [ - 'explanation' => $explanation, - ]), - ], - ]); - $this->addNotice($notice); - $this->$attribute = null; - } - } - - /** - * @param $attribute - * @return void - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function validateOrganizationTaxIdAsVatId($attribute): void - { - $address = $this->$attribute; - - // Skip on empty - if (!$address->organizationTaxId) { - return; - } - - if (Plugin::getInstance()->getVat()->isValidVatId($address->organizationTaxId)) { - return; - } - - $address->addError('organizationTaxId', Craft::t('commerce', 'Invalid VAT ID.')); - $this->addModelErrors($address, $attribute); - } -} diff --git a/src/engines/Tax.php b/src/engines/Tax.php deleted file mode 100644 index 4e25393f5d..0000000000 --- a/src/engines/Tax.php +++ /dev/null @@ -1,160 +0,0 @@ - - * @since 5.7.0 - */ -enum ContainsPurchasablesMatch: string -{ - use EnumHelpersTrait; - - case Any = 'any'; - case All = 'all'; - case Only = 'only'; - - public function label(): string - { - return match ($this) { - self::Any => Craft::t('commerce', 'any'), - self::All => Craft::t('commerce', 'all'), - self::Only => Craft::t('commerce', 'only'), - }; - } -} diff --git a/src/enums/InventoryTransactionType.php b/src/enums/InventoryTransactionType.php deleted file mode 100644 index 53c4b00621..0000000000 --- a/src/enums/InventoryTransactionType.php +++ /dev/null @@ -1,148 +0,0 @@ - Craft::t('commerce', 'Available'), - self::RESERVED => Craft::t('commerce', 'Reserved'), - self::DAMAGED => Craft::t('commerce', 'Damaged'), - self::SAFETY => Craft::t('commerce', 'Safety'), - self::QUALITY_CONTROL => Craft::t('commerce', 'Quality Control'), - self::COMMITTED => Craft::t('commerce', 'Committed'), - self::INCOMING => Craft::t('commerce', 'Incoming'), - self::FULFILLED => Craft::t('commerce', 'Fulfilled') - }; - } - - /** - * Can this transaction type go into the negative sum? - * - * @return bool - */ - public function canBeNegative(): bool - { - return $this === self::AVAILABLE || $this === self::COMMITTED || $this === self::INCOMING; - } - - /** - * @return InventoryTransactionType[] - */ - public static function onHand(): array - { - // on hand is unavailable + available + committed - return array_merge( - self::unavailable(), - self::available(), - self::committed() - ); - } - - /** - * @return InventoryTransactionType[] - */ - public static function unavailable(): array - { - return [ - self::RESERVED, - self::DAMAGED, - self::SAFETY, - self::QUALITY_CONTROL, - ]; - } - - /** - * @return InventoryTransactionType[] - */ - public static function available(): array - { - return [ - self::AVAILABLE, - ]; - } - - /** - * @return InventoryTransactionType[] - */ - public static function incoming(): array - { - return [ - self::INCOMING, - ]; - } - - /** - * @return InventoryTransactionType[] - */ - public static function committed(): array - { - return [ - self::COMMITTED, - ]; - } - - /** - * These are the types that can be manually moved between (Outside a transfer or purchase order or fulfillment). - * - * @return InventoryTransactionType[] - */ - public static function allowedManualMoveTransactionTypes(): array - { - return [ - // Unavailable - ...self::unavailable(), - - //available - ...self::available(), - ]; - } - - /** - * These are the types that can be manually moved between (Outside a transfer or purchase order or fulfillment). - * - * @return InventoryTransactionType[] - */ - public static function allowedManualAdjustmentTypes(): array - { - return [ - // Unavailable - ...self::unavailable(), - - //available - ...self::available(), - ]; - } -} diff --git a/src/enums/InventoryUpdateQuantityType.php b/src/enums/InventoryUpdateQuantityType.php deleted file mode 100644 index 34ee729fa8..0000000000 --- a/src/enums/InventoryUpdateQuantityType.php +++ /dev/null @@ -1,18 +0,0 @@ - - * @since 5.1.0 - */ -enum LineItemType: string -{ - use EnumHelpersTrait; - - case Custom = 'custom'; - - case Purchasable = 'purchasable'; - - /** - * @return array - */ - public static function types(): array - { - return array_combine(self::names(), self::cases()); - } - - /** - * @return string - */ - public function typeAsLabel(): string - { - return match ($this) { - self::Custom => Craft::t('commerce', 'Custom'), - self::Purchasable => Craft::t('commerce', 'Purchasable'), - }; - } -} diff --git a/src/enums/OrderNoticeType.php b/src/enums/OrderNoticeType.php deleted file mode 100644 index d74c30ca0b..0000000000 --- a/src/enums/OrderNoticeType.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 5.7.0 - */ -enum OrderNoticeType: string -{ - use EnumHelpersTrait; - - case Customer = 'customer'; - - case Admin = 'admin'; -} diff --git a/src/enums/TransferStatusType.php b/src/enums/TransferStatusType.php deleted file mode 100644 index f68a8f8515..0000000000 --- a/src/enums/TransferStatusType.php +++ /dev/null @@ -1,43 +0,0 @@ - Craft::t('commerce', 'Draft'), - self::PENDING => Craft::t('commerce', 'Pending'), - self::PARTIAL => Craft::t('commerce', 'Partial'), - self::RECEIVED => Craft::t('commerce', 'Received'), - }; - } - - public function color(): string - { - // for each case, return a nicer label - return match ($this) { - self::DRAFT => 'blue', - self::PENDING => 'yellow', - self::PARTIAL => 'orange', - self::RECEIVED => 'green', - }; - } -} diff --git a/src/errors/CurrencyException.php b/src/errors/CurrencyException.php deleted file mode 100644 index 428777695c..0000000000 --- a/src/errors/CurrencyException.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class CurrencyException extends Exception -{ -} diff --git a/src/errors/EmailException.php b/src/errors/EmailException.php deleted file mode 100644 index 0717e4aa3b..0000000000 --- a/src/errors/EmailException.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class EmailException extends Exception -{ -} diff --git a/src/errors/GatewayException.php b/src/errors/GatewayException.php deleted file mode 100644 index 120b7c6e71..0000000000 --- a/src/errors/GatewayException.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class GatewayException extends Exception -{ -} diff --git a/src/errors/LineItemException.php b/src/errors/LineItemException.php deleted file mode 100644 index 9c939f4cc6..0000000000 --- a/src/errors/LineItemException.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class LineItemException extends Exception -{ -} diff --git a/src/errors/LineItemNotFoundException.php b/src/errors/LineItemNotFoundException.php deleted file mode 100644 index b37faf8c63..0000000000 --- a/src/errors/LineItemNotFoundException.php +++ /dev/null @@ -1,27 +0,0 @@ - - * @since 4.9 - */ -class LineItemNotFoundException extends Exception -{ - /** - * @return string the user-friendly name of this exception - */ - public function getName(): string - { - return 'Line Item not found'; - } -} diff --git a/src/errors/NotImplementedException.php b/src/errors/NotImplementedException.php deleted file mode 100644 index dbf01d22cf..0000000000 --- a/src/errors/NotImplementedException.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class NotImplementedException extends BadMethodCallException -{ -} diff --git a/src/errors/OrderAdjustmentNotFoundException.php b/src/errors/OrderAdjustmentNotFoundException.php deleted file mode 100644 index b43f66c53b..0000000000 --- a/src/errors/OrderAdjustmentNotFoundException.php +++ /dev/null @@ -1,27 +0,0 @@ - - * @since 4.9 - */ -class OrderAdjustmentNotFoundException extends Exception -{ - /** - * @return string the user-friendly name of this exception - */ - public function getName(): string - { - return 'Line Item not found'; - } -} diff --git a/src/errors/OrderStatusException.php b/src/errors/OrderStatusException.php deleted file mode 100644 index ed14c63313..0000000000 --- a/src/errors/OrderStatusException.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class OrderStatusException extends Exception -{ -} diff --git a/src/errors/PaymentException.php b/src/errors/PaymentException.php deleted file mode 100644 index 944155f86d..0000000000 --- a/src/errors/PaymentException.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class PaymentException extends Exception -{ -} diff --git a/src/errors/PaymentSourceCreatedLaterException.php b/src/errors/PaymentSourceCreatedLaterException.php deleted file mode 100644 index a14d0a2142..0000000000 --- a/src/errors/PaymentSourceCreatedLaterException.php +++ /dev/null @@ -1,18 +0,0 @@ - - * @since 4.3 - */ -class PaymentSourceCreatedLaterException extends PaymentSourceException -{ -} diff --git a/src/errors/PaymentSourceException.php b/src/errors/PaymentSourceException.php deleted file mode 100644 index 01be0bfbd6..0000000000 --- a/src/errors/PaymentSourceException.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class PaymentSourceException extends Exception -{ -} diff --git a/src/errors/ProductTypeNotFoundException.php b/src/errors/ProductTypeNotFoundException.php deleted file mode 100644 index 3bb7c4f3ad..0000000000 --- a/src/errors/ProductTypeNotFoundException.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class ProductTypeNotFoundException extends Exception -{ -} diff --git a/src/errors/RefundException.php b/src/errors/RefundException.php deleted file mode 100644 index 3308044f3d..0000000000 --- a/src/errors/RefundException.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class RefundException extends Exception -{ -} diff --git a/src/errors/ShippingMethodException.php b/src/errors/ShippingMethodException.php deleted file mode 100644 index b3cc94ca65..0000000000 --- a/src/errors/ShippingMethodException.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class ShippingMethodException extends Exception -{ -} diff --git a/src/errors/StoreNotFoundException.php b/src/errors/StoreNotFoundException.php deleted file mode 100644 index b03496f570..0000000000 --- a/src/errors/StoreNotFoundException.php +++ /dev/null @@ -1,27 +0,0 @@ - - * @since 5.0.0 - */ -class StoreNotFoundException extends Exception -{ - /** - * @return string the user-friendly name of this exception - */ - public function getName(): string - { - return 'Store not found'; - } -} diff --git a/src/errors/SubscriptionException.php b/src/errors/SubscriptionException.php deleted file mode 100644 index 8e35e4991b..0000000000 --- a/src/errors/SubscriptionException.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class SubscriptionException extends Exception -{ -} diff --git a/src/errors/TransactionException.php b/src/errors/TransactionException.php deleted file mode 100644 index 16a14e4ec5..0000000000 --- a/src/errors/TransactionException.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class TransactionException extends Exception -{ -} diff --git a/src/etc/commands.php b/src/etc/commands.php deleted file mode 100644 index 87738cbbe0..0000000000 --- a/src/etc/commands.php +++ /dev/null @@ -1,32 +0,0 @@ - 'Commerce Orders', - 'type' => 'Link', - 'url' => UrlHelper::cpUrl('commerce/orders'), - ], - [ - 'name' => 'Commerce Products', - 'type' => 'Link', - 'url' => UrlHelper::cpUrl('commerce/products'), - ], - [ - 'name' => 'Commerce Promotions', - 'type' => 'Link', - 'url' => UrlHelper::cpUrl('commerce/promotions'), - ], - [ - 'name' => 'Commerce Settings', - 'type' => 'Link', - 'url' => UrlHelper::cpUrl('commerce/settings'), - ], -]; diff --git a/src/etc/currencies.php b/src/etc/currencies.php deleted file mode 100644 index ebf0edd3db..0000000000 --- a/src/etc/currencies.php +++ /dev/null @@ -1,1254 +0,0 @@ - [ - 'alphabeticCode' => 'AFN', - 'currency' => 'Afghani', - 'entity' => 'AFGHANISTAN', - 'minorUnit' => 2, - 'numericCode' => 971, - ], - 'EUR' => [ - 'alphabeticCode' => 'EUR', - 'currency' => 'Euro', - 'entity' => 'SPAIN', - 'minorUnit' => 2, - 'numericCode' => 978, - ], - 'ALL' => [ - 'alphabeticCode' => 'ALL', - 'currency' => 'Lek', - 'entity' => 'ALBANIA', - 'minorUnit' => 2, - 'numericCode' => 8, - ], - 'DZD' => [ - 'alphabeticCode' => 'DZD', - 'currency' => 'Algerian Dinar', - 'entity' => 'ALGERIA', - 'minorUnit' => 2, - 'numericCode' => 12, - ], - 'USD' => [ - 'alphabeticCode' => 'USD', - 'currency' => 'US Dollar', - 'entity' => 'VIRGIN ISLANDS (U.S.)', - 'minorUnit' => 2, - 'numericCode' => 840, - ], - 'AOA' => [ - 'alphabeticCode' => 'AOA', - 'currency' => 'Kwanza', - 'entity' => 'ANGOLA', - 'minorUnit' => 2, - 'numericCode' => 973, - ], - 'XCD' => [ - 'alphabeticCode' => 'XCD', - 'currency' => 'East Caribbean Dollar', - 'entity' => 'SAINT VINCENT AND THE GRENADINES', - 'minorUnit' => 2, - 'numericCode' => 951, - ], - 'ARS' => [ - 'alphabeticCode' => 'ARS', - 'currency' => 'Argentine Peso', - 'entity' => 'ARGENTINA', - 'minorUnit' => 2, - 'numericCode' => 32, - ], - 'AMD' => [ - 'alphabeticCode' => 'AMD', - 'currency' => 'Armenian Dram', - 'entity' => 'ARMENIA', - 'minorUnit' => 2, - 'numericCode' => 51, - ], - 'AWG' => [ - 'alphabeticCode' => 'AWG', - 'currency' => 'Aruban Florin', - 'entity' => 'ARUBA', - 'minorUnit' => 2, - 'numericCode' => 533, - ], - 'AUD' => [ - 'alphabeticCode' => 'AUD', - 'currency' => 'Australian Dollar', - 'entity' => 'TUVALU', - 'minorUnit' => 2, - 'numericCode' => 36, - ], - 'AZN' => [ - 'alphabeticCode' => 'AZN', - 'currency' => 'Azerbaijanian Manat', - 'entity' => 'AZERBAIJAN', - 'minorUnit' => 2, - 'numericCode' => 944, - ], - 'BSD' => [ - 'alphabeticCode' => 'BSD', - 'currency' => 'Bahamian Dollar', - 'entity' => 'BAHAMAS (THE)', - 'minorUnit' => 2, - 'numericCode' => 44, - ], - 'BHD' => [ - 'alphabeticCode' => 'BHD', - 'currency' => 'Bahraini Dinar', - 'entity' => 'BAHRAIN', - 'minorUnit' => 3, - 'numericCode' => 48, - ], - 'BDT' => [ - 'alphabeticCode' => 'BDT', - 'currency' => 'Taka', - 'entity' => 'BANGLADESH', - 'minorUnit' => 2, - 'numericCode' => 50, - ], - 'BBD' => [ - 'alphabeticCode' => 'BBD', - 'currency' => 'Barbados Dollar', - 'entity' => 'BARBADOS', - 'minorUnit' => 2, - 'numericCode' => 52, - ], - 'BYN' => [ - 'alphabeticCode' => 'BYN', - 'currency' => 'Belarusian Ruble', - 'entity' => 'BELARUS', - 'minorUnit' => 2, - 'numericCode' => 933, - ], - 'BYR' => [ - 'alphabeticCode' => 'BYR', - 'currency' => 'Belarusian Ruble', - 'entity' => 'BELARUS', - 'minorUnit' => 0, - 'numericCode' => 974, - ], - 'BZD' => [ - 'alphabeticCode' => 'BZD', - 'currency' => 'Belize Dollar', - 'entity' => 'BELIZE', - 'minorUnit' => 2, - 'numericCode' => 84, - ], - 'XOF' => [ - 'alphabeticCode' => 'XOF', - 'currency' => 'CFA Franc BCEAO', - 'entity' => 'TOGO', - 'minorUnit' => 0, - 'numericCode' => 952, - ], - 'BMD' => [ - 'alphabeticCode' => 'BMD', - 'currency' => 'Bermudian Dollar', - 'entity' => 'BERMUDA', - 'minorUnit' => 2, - 'numericCode' => 60, - ], - 'INR' => [ - 'alphabeticCode' => 'INR', - 'currency' => 'Indian Rupee', - 'entity' => 'INDIA', - 'minorUnit' => 2, - 'numericCode' => 356, - ], - 'BTN' => [ - 'alphabeticCode' => 'BTN', - 'currency' => 'Ngultrum', - 'entity' => 'BHUTAN', - 'minorUnit' => 2, - 'numericCode' => 64, - ], - 'BOB' => [ - 'alphabeticCode' => 'BOB', - 'currency' => 'Boliviano', - 'entity' => 'BOLIVIA (PLURINATIONAL STATE OF)', - 'minorUnit' => 2, - 'numericCode' => 68, - ], - 'BOV' => [ - 'alphabeticCode' => 'BOV', - 'currency' => 'Mvdol', - 'entity' => 'BOLIVIA (PLURINATIONAL STATE OF)', - 'minorUnit' => 2, - 'numericCode' => 984, - ], - 'BAM' => [ - 'alphabeticCode' => 'BAM', - 'currency' => 'Convertible Mark', - 'entity' => 'BOSNIA AND HERZEGOVINA', - 'minorUnit' => 2, - 'numericCode' => 977, - ], - 'BWP' => [ - 'alphabeticCode' => 'BWP', - 'currency' => 'Pula', - 'entity' => 'BOTSWANA', - 'minorUnit' => 2, - 'numericCode' => 72, - ], - 'NOK' => [ - 'alphabeticCode' => 'NOK', - 'currency' => 'Norwegian Krone', - 'entity' => 'SVALBARD AND JAN MAYEN', - 'minorUnit' => 2, - 'numericCode' => 578, - ], - 'BRL' => [ - 'alphabeticCode' => 'BRL', - 'currency' => 'Brazilian Real', - 'entity' => 'BRAZIL', - 'minorUnit' => 2, - 'numericCode' => 986, - ], - 'BND' => [ - 'alphabeticCode' => 'BND', - 'currency' => 'Brunei Dollar', - 'entity' => 'BRUNEI DARUSSALAM', - 'minorUnit' => 2, - 'numericCode' => 96, - ], - 'BGN' => [ - 'alphabeticCode' => 'BGN', - 'currency' => 'Bulgarian Lev', - 'entity' => 'BULGARIA', - 'minorUnit' => 2, - 'numericCode' => 975, - ], - 'BIF' => [ - 'alphabeticCode' => 'BIF', - 'currency' => 'Burundi Franc', - 'entity' => 'BURUNDI', - 'minorUnit' => 0, - 'numericCode' => 108, - ], - 'CVE' => [ - 'alphabeticCode' => 'CVE', - 'currency' => 'Cabo Verde Escudo', - 'entity' => 'CABO VERDE', - 'minorUnit' => 2, - 'numericCode' => 132, - ], - 'KHR' => [ - 'alphabeticCode' => 'KHR', - 'currency' => 'Riel', - 'entity' => 'CAMBODIA', - 'minorUnit' => 2, - 'numericCode' => 116, - ], - 'XAF' => [ - 'alphabeticCode' => 'XAF', - 'currency' => 'CFA Franc BEAC', - 'entity' => 'GABON', - 'minorUnit' => 0, - 'numericCode' => 950, - ], - 'CAD' => [ - 'alphabeticCode' => 'CAD', - 'currency' => 'Canadian Dollar', - 'entity' => 'CANADA', - 'minorUnit' => 2, - 'numericCode' => 124, - ], - 'KYD' => [ - 'alphabeticCode' => 'KYD', - 'currency' => 'Cayman Islands Dollar', - 'entity' => 'CAYMAN ISLANDS (THE)', - 'minorUnit' => 2, - 'numericCode' => 136, - ], - 'CLP' => [ - 'alphabeticCode' => 'CLP', - 'currency' => 'Chilean Peso', - 'entity' => 'CHILE', - 'minorUnit' => 0, - 'numericCode' => 152, - ], - 'CLF' => [ - 'alphabeticCode' => 'CLF', - 'currency' => 'Unidad de Fomento', - 'entity' => 'CHILE', - 'minorUnit' => 4, - 'numericCode' => 990, - ], - 'CNY' => [ - 'alphabeticCode' => 'CNY', - 'currency' => 'Yuan Renminbi', - 'entity' => 'CHINA', - 'minorUnit' => 2, - 'numericCode' => 156, - ], - 'COP' => [ - 'alphabeticCode' => 'COP', - 'currency' => 'Colombian Peso', - 'entity' => 'COLOMBIA', - 'minorUnit' => 2, - 'numericCode' => 170, - ], - 'COU' => [ - 'alphabeticCode' => 'COU', - 'currency' => 'Unidad de Valor Real', - 'entity' => 'COLOMBIA', - 'minorUnit' => 2, - 'numericCode' => 970, - ], - 'KMF' => [ - 'alphabeticCode' => 'KMF', - 'currency' => 'Comoro Franc', - 'entity' => 'COMOROS (THE)', - 'minorUnit' => 0, - 'numericCode' => 174, - ], - 'CDF' => [ - 'alphabeticCode' => 'CDF', - 'currency' => 'Congolese Franc', - 'entity' => 'CONGO (THE DEMOCRATIC REPUBLIC OF THE)', - 'minorUnit' => 2, - 'numericCode' => 976, - ], - 'NZD' => [ - 'alphabeticCode' => 'NZD', - 'currency' => 'New Zealand Dollar', - 'entity' => 'TOKELAU', - 'minorUnit' => 2, - 'numericCode' => 554, - ], - 'CRC' => [ - 'alphabeticCode' => 'CRC', - 'currency' => 'Costa Rican Colon', - 'entity' => 'COSTA RICA', - 'minorUnit' => 2, - 'numericCode' => 188, - ], - 'HRK' => [ - 'alphabeticCode' => 'HRK', - 'currency' => 'Kuna', - 'entity' => 'CROATIA', - 'minorUnit' => 2, - 'numericCode' => 191, - ], - 'CUP' => [ - 'alphabeticCode' => 'CUP', - 'currency' => 'Cuban Peso', - 'entity' => 'CUBA', - 'minorUnit' => 2, - 'numericCode' => 192, - ], - 'CUC' => [ - 'alphabeticCode' => 'CUC', - 'currency' => 'Peso Convertible', - 'entity' => 'CUBA', - 'minorUnit' => 2, - 'numericCode' => 931, - ], - 'ANG' => [ - 'alphabeticCode' => 'ANG', - 'currency' => 'Netherlands Antillean Guilder', - 'entity' => 'SINT MAARTEN (DUTCH PART)', - 'minorUnit' => 2, - 'numericCode' => 532, - ], - 'CZK' => [ - 'alphabeticCode' => 'CZK', - 'currency' => 'Czech Koruna', - 'entity' => 'CZECH REPUBLIC (THE)', - 'minorUnit' => 2, - 'numericCode' => 203, - ], - 'DKK' => [ - 'alphabeticCode' => 'DKK', - 'currency' => 'Danish Krone', - 'entity' => 'GREENLAND', - 'minorUnit' => 2, - 'numericCode' => 208, - ], - 'DJF' => [ - 'alphabeticCode' => 'DJF', - 'currency' => 'Djibouti Franc', - 'entity' => 'DJIBOUTI', - 'minorUnit' => 0, - 'numericCode' => 262, - ], - 'DOP' => [ - 'alphabeticCode' => 'DOP', - 'currency' => 'Dominican Peso', - 'entity' => 'DOMINICAN REPUBLIC (THE)', - 'minorUnit' => 2, - 'numericCode' => 214, - ], - 'EGP' => [ - 'alphabeticCode' => 'EGP', - 'currency' => 'Egyptian Pound', - 'entity' => 'EGYPT', - 'minorUnit' => 2, - 'numericCode' => 818, - ], - 'SVC' => [ - 'alphabeticCode' => 'SVC', - 'currency' => 'El Salvador Colon', - 'entity' => 'EL SALVADOR', - 'minorUnit' => 2, - 'numericCode' => 222, - ], - 'ERN' => [ - 'alphabeticCode' => 'ERN', - 'currency' => 'Nakfa', - 'entity' => 'ERITREA', - 'minorUnit' => 2, - 'numericCode' => 232, - ], - 'ETB' => [ - 'alphabeticCode' => 'ETB', - 'currency' => 'Ethiopian Birr', - 'entity' => 'ETHIOPIA', - 'minorUnit' => 2, - 'numericCode' => 230, - ], - 'FKP' => [ - 'alphabeticCode' => 'FKP', - 'currency' => 'Falkland Islands Pound', - 'entity' => 'FALKLAND ISLANDS (THE) [MALVINAS]', - 'minorUnit' => 2, - 'numericCode' => 238, - ], - 'FJD' => [ - 'alphabeticCode' => 'FJD', - 'currency' => 'Fiji Dollar', - 'entity' => 'FIJI', - 'minorUnit' => 2, - 'numericCode' => 242, - ], - 'XPF' => [ - 'alphabeticCode' => 'XPF', - 'currency' => 'CFP Franc', - 'entity' => 'WALLIS AND FUTUNA', - 'minorUnit' => 0, - 'numericCode' => 953, - ], - 'GMD' => [ - 'alphabeticCode' => 'GMD', - 'currency' => 'Dalasi', - 'entity' => 'GAMBIA (THE)', - 'minorUnit' => 2, - 'numericCode' => 270, - ], - 'GEL' => [ - 'alphabeticCode' => 'GEL', - 'currency' => 'Lari', - 'entity' => 'GEORGIA', - 'minorUnit' => 2, - 'numericCode' => 981, - ], - 'GHS' => [ - 'alphabeticCode' => 'GHS', - 'currency' => 'Ghana Cedi', - 'entity' => 'GHANA', - 'minorUnit' => 2, - 'numericCode' => 936, - ], - 'GIP' => [ - 'alphabeticCode' => 'GIP', - 'currency' => 'Gibraltar Pound', - 'entity' => 'GIBRALTAR', - 'minorUnit' => 2, - 'numericCode' => 292, - ], - 'GTQ' => [ - 'alphabeticCode' => 'GTQ', - 'currency' => 'Quetzal', - 'entity' => 'GUATEMALA', - 'minorUnit' => 2, - 'numericCode' => 320, - ], - 'GBP' => [ - 'alphabeticCode' => 'GBP', - 'currency' => 'Pound Sterling', - 'entity' => 'UNITED KINGDOM OF GREAT BRITAIN AND NORTHERN IRELAND (THE)', - 'minorUnit' => 2, - 'numericCode' => 826, - ], - 'GNF' => [ - 'alphabeticCode' => 'GNF', - 'currency' => 'Guinea Franc', - 'entity' => 'GUINEA', - 'minorUnit' => 0, - 'numericCode' => 324, - ], - 'GYD' => [ - 'alphabeticCode' => 'GYD', - 'currency' => 'Guyana Dollar', - 'entity' => 'GUYANA', - 'minorUnit' => 2, - 'numericCode' => 328, - ], - 'HTG' => [ - 'alphabeticCode' => 'HTG', - 'currency' => 'Gourde', - 'entity' => 'HAITI', - 'minorUnit' => 2, - 'numericCode' => 332, - ], - 'HNL' => [ - 'alphabeticCode' => 'HNL', - 'currency' => 'Lempira', - 'entity' => 'HONDURAS', - 'minorUnit' => 2, - 'numericCode' => 340, - ], - 'HKD' => [ - 'alphabeticCode' => 'HKD', - 'currency' => 'Hong Kong Dollar', - 'entity' => 'HONG KONG', - 'minorUnit' => 2, - 'numericCode' => 344, - ], - 'HUF' => [ - 'alphabeticCode' => 'HUF', - 'currency' => 'Hungarian Forint', - 'entity' => 'HUNGARY', - 'minorUnit' => 2, - 'numericCode' => 348, - ], - 'ISK' => [ - 'alphabeticCode' => 'ISK', - 'currency' => 'Iceland Krona', - 'entity' => 'ICELAND', - 'minorUnit' => 0, - 'numericCode' => 352, - ], - 'IDR' => [ - 'alphabeticCode' => 'IDR', - 'currency' => 'Rupiah', - 'entity' => 'INDONESIA', - 'minorUnit' => 2, - 'numericCode' => 360, - ], - 'XDR' => [ - 'alphabeticCode' => 'XDR', - 'currency' => 'SDR (Special Drawing Right)', - 'entity' => 'INTERNATIONAL MONETARY FUND (IMF) ', - 'minorUnit' => 0, - 'numericCode' => 960, - ], - 'IRR' => [ - 'alphabeticCode' => 'IRR', - 'currency' => 'Iranian Rial', - 'entity' => 'IRAN (ISLAMIC REPUBLIC OF)', - 'minorUnit' => 2, - 'numericCode' => 364, - ], - 'IQD' => [ - 'alphabeticCode' => 'IQD', - 'currency' => 'Iraqi Dinar', - 'entity' => 'IRAQ', - 'minorUnit' => 3, - 'numericCode' => 368, - ], - 'ILS' => [ - 'alphabeticCode' => 'ILS', - 'currency' => 'New Israeli Sheqel', - 'entity' => 'ISRAEL', - 'minorUnit' => 2, - 'numericCode' => 376, - ], - 'JMD' => [ - 'alphabeticCode' => 'JMD', - 'currency' => 'Jamaican Dollar', - 'entity' => 'JAMAICA', - 'minorUnit' => 2, - 'numericCode' => 388, - ], - 'JPY' => [ - 'alphabeticCode' => 'JPY', - 'currency' => 'Yen', - 'entity' => 'JAPAN', - 'minorUnit' => 0, - 'numericCode' => 392, - ], - 'JOD' => [ - 'alphabeticCode' => 'JOD', - 'currency' => 'Jordanian Dinar', - 'entity' => 'JORDAN', - 'minorUnit' => 3, - 'numericCode' => 400, - ], - 'KZT' => [ - 'alphabeticCode' => 'KZT', - 'currency' => 'Tenge', - 'entity' => 'KAZAKHSTAN', - 'minorUnit' => 2, - 'numericCode' => 398, - ], - 'KES' => [ - 'alphabeticCode' => 'KES', - 'currency' => 'Kenyan Shilling', - 'entity' => 'KENYA', - 'minorUnit' => 2, - 'numericCode' => 404, - ], - 'KPW' => [ - 'alphabeticCode' => 'KPW', - 'currency' => 'North Korean Won', - 'entity' => 'KOREA (THE DEMOCRATIC PEOPLE’S REPUBLIC OF)', - 'minorUnit' => 2, - 'numericCode' => 408, - ], - 'KRW' => [ - 'alphabeticCode' => 'KRW', - 'currency' => 'Won', - 'entity' => 'KOREA (THE REPUBLIC OF)', - 'minorUnit' => 0, - 'numericCode' => 410, - ], - 'KWD' => [ - 'alphabeticCode' => 'KWD', - 'currency' => 'Kuwaiti Dinar', - 'entity' => 'KUWAIT', - 'minorUnit' => 3, - 'numericCode' => 414, - ], - 'KGS' => [ - 'alphabeticCode' => 'KGS', - 'currency' => 'Som', - 'entity' => 'KYRGYZSTAN', - 'minorUnit' => 2, - 'numericCode' => 417, - ], - 'LAK' => [ - 'alphabeticCode' => 'LAK', - 'currency' => 'Kip', - 'entity' => 'LAO PEOPLE’S DEMOCRATIC REPUBLIC (THE)', - 'minorUnit' => 2, - 'numericCode' => 418, - ], - 'LBP' => [ - 'alphabeticCode' => 'LBP', - 'currency' => 'Lebanese Pound', - 'entity' => 'LEBANON', - 'minorUnit' => 2, - 'numericCode' => 422, - ], - 'LSL' => [ - 'alphabeticCode' => 'LSL', - 'currency' => 'Loti', - 'entity' => 'LESOTHO', - 'minorUnit' => 2, - 'numericCode' => 426, - ], - 'ZAR' => [ - 'alphabeticCode' => 'ZAR', - 'currency' => 'Rand', - 'entity' => 'SOUTH AFRICA', - 'minorUnit' => 2, - 'numericCode' => 710, - ], - 'LRD' => [ - 'alphabeticCode' => 'LRD', - 'currency' => 'Liberian Dollar', - 'entity' => 'LIBERIA', - 'minorUnit' => 2, - 'numericCode' => 430, - ], - 'LYD' => [ - 'alphabeticCode' => 'LYD', - 'currency' => 'Libyan Dinar', - 'entity' => 'LIBYA', - 'minorUnit' => 3, - 'numericCode' => 434, - ], - 'CHF' => [ - 'alphabeticCode' => 'CHF', - 'currency' => 'Swiss Franc', - 'entity' => 'SWITZERLAND', - 'minorUnit' => 2, - 'numericCode' => 756, - ], - 'MOP' => [ - 'alphabeticCode' => 'MOP', - 'currency' => 'Pataca', - 'entity' => 'MACAO', - 'minorUnit' => 2, - 'numericCode' => 446, - ], - 'MKD' => [ - 'alphabeticCode' => 'MKD', - 'currency' => 'Denar', - 'entity' => 'MACEDONIA (THE FORMER YUGOSLAV REPUBLIC OF)', - 'minorUnit' => 2, - 'numericCode' => 807, - ], - 'MGA' => [ - 'alphabeticCode' => 'MGA', - 'currency' => 'Malagasy Ariary', - 'entity' => 'MADAGASCAR', - 'minorUnit' => 2, - 'numericCode' => 969, - ], - 'MWK' => [ - 'alphabeticCode' => 'MWK', - 'currency' => 'Malawi Kwacha', - 'entity' => 'MALAWI', - 'minorUnit' => 2, - 'numericCode' => 454, - ], - 'MYR' => [ - 'alphabeticCode' => 'MYR', - 'currency' => 'Malaysian Ringgit', - 'entity' => 'MALAYSIA', - 'minorUnit' => 2, - 'numericCode' => 458, - ], - 'MVR' => [ - 'alphabeticCode' => 'MVR', - 'currency' => 'Rufiyaa', - 'entity' => 'MALDIVES', - 'minorUnit' => 2, - 'numericCode' => 462, - ], - 'MRO' => [ - 'alphabeticCode' => 'MRO', - 'currency' => 'Ouguiya', - 'entity' => 'MAURITANIA', - 'minorUnit' => 2, - 'numericCode' => 478, - ], - 'MUR' => [ - 'alphabeticCode' => 'MUR', - 'currency' => 'Mauritius Rupee', - 'entity' => 'MAURITIUS', - 'minorUnit' => 2, - 'numericCode' => 480, - ], - 'XUA' => [ - 'alphabeticCode' => 'XUA', - 'currency' => 'ADB Unit of Account', - 'entity' => 'MEMBER COUNTRIES OF THE AFRICAN DEVELOPMENT BANK GROUP', - 'minorUnit' => 0, - 'numericCode' => 965, - ], - 'MXN' => [ - 'alphabeticCode' => 'MXN', - 'currency' => 'Mexican Peso', - 'entity' => 'MEXICO', - 'minorUnit' => 2, - 'numericCode' => 484, - ], - 'MXV' => [ - 'alphabeticCode' => 'MXV', - 'currency' => 'Mexican Unidad de Inversion (UDI)', - 'entity' => 'MEXICO', - 'minorUnit' => 2, - 'numericCode' => 979, - ], - 'MDL' => [ - 'alphabeticCode' => 'MDL', - 'currency' => 'Moldovan Leu', - 'entity' => 'MOLDOVA (THE REPUBLIC OF)', - 'minorUnit' => 2, - 'numericCode' => 498, - ], - 'MNT' => [ - 'alphabeticCode' => 'MNT', - 'currency' => 'Tugrik', - 'entity' => 'MONGOLIA', - 'minorUnit' => 2, - 'numericCode' => 496, - ], - 'MAD' => [ - 'alphabeticCode' => 'MAD', - 'currency' => 'Moroccan Dirham', - 'entity' => 'WESTERN SAHARA', - 'minorUnit' => 2, - 'numericCode' => 504, - ], - 'MZN' => [ - 'alphabeticCode' => 'MZN', - 'currency' => 'Mozambique Metical', - 'entity' => 'MOZAMBIQUE', - 'minorUnit' => 2, - 'numericCode' => 943, - ], - 'MMK' => [ - 'alphabeticCode' => 'MMK', - 'currency' => 'Kyat', - 'entity' => 'MYANMAR', - 'minorUnit' => 2, - 'numericCode' => 104, - ], - 'NAD' => [ - 'alphabeticCode' => 'NAD', - 'currency' => 'Namibia Dollar', - 'entity' => 'NAMIBIA', - 'minorUnit' => 2, - 'numericCode' => 516, - ], - 'NPR' => [ - 'alphabeticCode' => 'NPR', - 'currency' => 'Nepalese Rupee', - 'entity' => 'NEPAL', - 'minorUnit' => 2, - 'numericCode' => 524, - ], - 'NIO' => [ - 'alphabeticCode' => 'NIO', - 'currency' => 'Cordoba Oro', - 'entity' => 'NICARAGUA', - 'minorUnit' => 2, - 'numericCode' => 558, - ], - 'NGN' => [ - 'alphabeticCode' => 'NGN', - 'currency' => 'Naira', - 'entity' => 'NIGERIA', - 'minorUnit' => 2, - 'numericCode' => 566, - ], - 'OMR' => [ - 'alphabeticCode' => 'OMR', - 'currency' => 'Rial Omani', - 'entity' => 'OMAN', - 'minorUnit' => 3, - 'numericCode' => 512, - ], - 'PKR' => [ - 'alphabeticCode' => 'PKR', - 'currency' => 'Pakistan Rupee', - 'entity' => 'PAKISTAN', - 'minorUnit' => 2, - 'numericCode' => 586, - ], - 'PAB' => [ - 'alphabeticCode' => 'PAB', - 'currency' => 'Balboa', - 'entity' => 'PANAMA', - 'minorUnit' => 2, - 'numericCode' => 590, - ], - 'PGK' => [ - 'alphabeticCode' => 'PGK', - 'currency' => 'Kina', - 'entity' => 'PAPUA NEW GUINEA', - 'minorUnit' => 2, - 'numericCode' => 598, - ], - 'PYG' => [ - 'alphabeticCode' => 'PYG', - 'currency' => 'Guarani', - 'entity' => 'PARAGUAY', - 'minorUnit' => 0, - 'numericCode' => 600, - ], - 'PEN' => [ - 'alphabeticCode' => 'PEN', - 'currency' => 'Sol', - 'entity' => 'PERU', - 'minorUnit' => 2, - 'numericCode' => 604, - ], - 'PHP' => [ - 'alphabeticCode' => 'PHP', - 'currency' => 'Philippine Peso', - 'entity' => 'PHILIPPINES (THE)', - 'minorUnit' => 2, - 'numericCode' => 608, - ], - 'PLN' => [ - 'alphabeticCode' => 'PLN', - 'currency' => 'Zloty', - 'entity' => 'POLAND', - 'minorUnit' => 2, - 'numericCode' => 985, - ], - 'QAR' => [ - 'alphabeticCode' => 'QAR', - 'currency' => 'Qatari Rial', - 'entity' => 'QATAR', - 'minorUnit' => 2, - 'numericCode' => 634, - ], - 'RON' => [ - 'alphabeticCode' => 'RON', - 'currency' => 'Romanian Leu', - 'entity' => 'ROMANIA', - 'minorUnit' => 2, - 'numericCode' => 946, - ], - 'RUB' => [ - 'alphabeticCode' => 'RUB', - 'currency' => 'Russian Ruble', - 'entity' => 'RUSSIAN FEDERATION (THE)', - 'minorUnit' => 2, - 'numericCode' => 643, - ], - 'RWF' => [ - 'alphabeticCode' => 'RWF', - 'currency' => 'Rwanda Franc', - 'entity' => 'RWANDA', - 'minorUnit' => 0, - 'numericCode' => 646, - ], - 'SHP' => [ - 'alphabeticCode' => 'SHP', - 'currency' => 'Saint Helena Pound', - 'entity' => 'SAINT HELENA, ASCENSION AND TRISTAN DA CUNHA', - 'minorUnit' => 2, - 'numericCode' => 654, - ], - 'WST' => [ - 'alphabeticCode' => 'WST', - 'currency' => 'Tala', - 'entity' => 'SAMOA', - 'minorUnit' => 2, - 'numericCode' => 882, - ], - 'STD' => [ - 'alphabeticCode' => 'STD', - 'currency' => 'Dobra', - 'entity' => 'SAO TOME AND PRINCIPE', - 'minorUnit' => 2, - 'numericCode' => 678, - ], - 'SAR' => [ - 'alphabeticCode' => 'SAR', - 'currency' => 'Saudi Riyal', - 'entity' => 'SAUDI ARABIA', - 'minorUnit' => 2, - 'numericCode' => 682, - ], - 'RSD' => [ - 'alphabeticCode' => 'RSD', - 'currency' => 'Serbian Dinar', - 'entity' => 'SERBIA', - 'minorUnit' => 2, - 'numericCode' => 941, - ], - 'SCR' => [ - 'alphabeticCode' => 'SCR', - 'currency' => 'Seychelles Rupee', - 'entity' => 'SEYCHELLES', - 'minorUnit' => 2, - 'numericCode' => 690, - ], - 'SLL' => [ - 'alphabeticCode' => 'SLL', - 'currency' => 'Leone', - 'entity' => 'SIERRA LEONE', - 'minorUnit' => 2, - 'numericCode' => 694, - ], - 'SGD' => [ - 'alphabeticCode' => 'SGD', - 'currency' => 'Singapore Dollar', - 'entity' => 'SINGAPORE', - 'minorUnit' => 2, - 'numericCode' => 702, - ], - 'XSU' => [ - 'alphabeticCode' => 'XSU', - 'currency' => 'Sucre', - 'entity' => 'SISTEMA UNITARIO DE COMPENSACION REGIONAL DE PAGOS SUCRE', - 'minorUnit' => 0, - 'numericCode' => 994, - ], - 'SBD' => [ - 'alphabeticCode' => 'SBD', - 'currency' => 'Solomon Islands Dollar', - 'entity' => 'SOLOMON ISLANDS', - 'minorUnit' => 2, - 'numericCode' => 90, - ], - 'SOS' => [ - 'alphabeticCode' => 'SOS', - 'currency' => 'Somali Shilling', - 'entity' => 'SOMALIA', - 'minorUnit' => 2, - 'numericCode' => 706, - ], - 'SSP' => [ - 'alphabeticCode' => 'SSP', - 'currency' => 'South Sudanese Pound', - 'entity' => 'SOUTH SUDAN', - 'minorUnit' => 2, - 'numericCode' => 728, - ], - 'LKR' => [ - 'alphabeticCode' => 'LKR', - 'currency' => 'Sri Lanka Rupee', - 'entity' => 'SRI LANKA', - 'minorUnit' => 2, - 'numericCode' => 144, - ], - 'SDG' => [ - 'alphabeticCode' => 'SDG', - 'currency' => 'Sudanese Pound', - 'entity' => 'SUDAN (THE)', - 'minorUnit' => 2, - 'numericCode' => 938, - ], - 'SRD' => [ - 'alphabeticCode' => 'SRD', - 'currency' => 'Surinam Dollar', - 'entity' => 'SURINAME', - 'minorUnit' => 2, - 'numericCode' => 968, - ], - 'SZL' => [ - 'alphabeticCode' => 'SZL', - 'currency' => 'Lilangeni', - 'entity' => 'SWAZILAND', - 'minorUnit' => 2, - 'numericCode' => 748, - ], - 'SEK' => [ - 'alphabeticCode' => 'SEK', - 'currency' => 'Swedish Krona', - 'entity' => 'SWEDEN', - 'minorUnit' => 2, - 'numericCode' => 752, - ], - 'CHE' => [ - 'alphabeticCode' => 'CHE', - 'currency' => 'WIR Euro', - 'entity' => 'SWITZERLAND', - 'minorUnit' => 2, - 'numericCode' => 947, - ], - 'CHW' => [ - 'alphabeticCode' => 'CHW', - 'currency' => 'WIR Franc', - 'entity' => 'SWITZERLAND', - 'minorUnit' => 2, - 'numericCode' => 948, - ], - 'SYP' => [ - 'alphabeticCode' => 'SYP', - 'currency' => 'Syrian Pound', - 'entity' => 'SYRIAN ARAB REPUBLIC', - 'minorUnit' => 2, - 'numericCode' => 760, - ], - 'TWD' => [ - 'alphabeticCode' => 'TWD', - 'currency' => 'New Taiwan Dollar', - 'entity' => 'TAIWAN (PROVINCE OF CHINA)', - 'minorUnit' => 2, - 'numericCode' => 901, - ], - 'TJS' => [ - 'alphabeticCode' => 'TJS', - 'currency' => 'Somoni', - 'entity' => 'TAJIKISTAN', - 'minorUnit' => 2, - 'numericCode' => 972, - ], - 'TZS' => [ - 'alphabeticCode' => 'TZS', - 'currency' => 'Tanzanian Shilling', - 'entity' => 'TANZANIA, UNITED REPUBLIC OF', - 'minorUnit' => 2, - 'numericCode' => 834, - ], - 'THB' => [ - 'alphabeticCode' => 'THB', - 'currency' => 'Baht', - 'entity' => 'THAILAND', - 'minorUnit' => 2, - 'numericCode' => 764, - ], - 'TOP' => [ - 'alphabeticCode' => 'TOP', - 'currency' => 'Pa’anga', - 'entity' => 'TONGA', - 'minorUnit' => 2, - 'numericCode' => 776, - ], - 'TTD' => [ - 'alphabeticCode' => 'TTD', - 'currency' => 'Trinidad and Tobago Dollar', - 'entity' => 'TRINIDAD AND TOBAGO', - 'minorUnit' => 2, - 'numericCode' => 780, - ], - 'TND' => [ - 'alphabeticCode' => 'TND', - 'currency' => 'Tunisian Dinar', - 'entity' => 'TUNISIA', - 'minorUnit' => 3, - 'numericCode' => 788, - ], - 'TRY' => [ - 'alphabeticCode' => 'TRY', - 'currency' => 'Turkish Lira', - 'entity' => 'TURKEY', - 'minorUnit' => 2, - 'numericCode' => 949, - ], - 'TMT' => [ - 'alphabeticCode' => 'TMT', - 'currency' => 'Turkmenistan New Manat', - 'entity' => 'TURKMENISTAN', - 'minorUnit' => 2, - 'numericCode' => 934, - ], - 'UGX' => [ - 'alphabeticCode' => 'UGX', - 'currency' => 'Uganda Shilling', - 'entity' => 'UGANDA', - 'minorUnit' => 0, - 'numericCode' => 800, - ], - 'UAH' => [ - 'alphabeticCode' => 'UAH', - 'currency' => 'Hryvnia', - 'entity' => 'UKRAINE', - 'minorUnit' => 2, - 'numericCode' => 980, - ], - 'AED' => [ - 'alphabeticCode' => 'AED', - 'currency' => 'UAE Dirham', - 'entity' => 'UNITED ARAB EMIRATES (THE)', - 'minorUnit' => 2, - 'numericCode' => 784, - ], - 'USN' => [ - 'alphabeticCode' => 'USN', - 'currency' => 'US Dollar (Next day)', - 'entity' => 'UNITED STATES OF AMERICA (THE)', - 'minorUnit' => 2, - 'numericCode' => 997, - ], - 'UYU' => [ - 'alphabeticCode' => 'UYU', - 'currency' => 'Peso Uruguayo', - 'entity' => 'URUGUAY', - 'minorUnit' => 2, - 'numericCode' => 858, - ], - 'UYI' => [ - 'alphabeticCode' => 'UYI', - 'currency' => 'Uruguay Peso en Unidades Indexadas (URUIURUI)', - 'entity' => 'URUGUAY', - 'minorUnit' => 0, - 'numericCode' => 940, - ], - 'UZS' => [ - 'alphabeticCode' => 'UZS', - 'currency' => 'Uzbekistan Sum', - 'entity' => 'UZBEKISTAN', - 'minorUnit' => 2, - 'numericCode' => 860, - ], - 'VUV' => [ - 'alphabeticCode' => 'VUV', - 'currency' => 'Vatu', - 'entity' => 'VANUATU', - 'minorUnit' => 0, - 'numericCode' => 548, - ], - 'VEF' => [ - 'alphabeticCode' => 'VEF', - 'currency' => 'Bolívar', - 'entity' => 'VENEZUELA (BOLIVARIAN REPUBLIC OF)', - 'minorUnit' => 2, - 'numericCode' => 937, - ], - 'VND' => [ - 'alphabeticCode' => 'VND', - 'currency' => 'Đồng', - 'entity' => 'VIET NAM', - 'minorUnit' => 0, - 'numericCode' => 704, - ], - 'YER' => [ - 'alphabeticCode' => 'YER', - 'currency' => 'Yemeni Rial', - 'entity' => 'YEMEN', - 'minorUnit' => 2, - 'numericCode' => 886, - ], - 'ZMW' => [ - 'alphabeticCode' => 'ZMW', - 'currency' => 'Zambian Kwacha', - 'entity' => 'ZAMBIA', - 'minorUnit' => 2, - 'numericCode' => 967, - ], - 'ZWL' => [ - 'alphabeticCode' => 'ZWL', - 'currency' => 'Zimbabwe Dollar', - 'entity' => 'ZIMBABWE', - 'minorUnit' => 2, - 'numericCode' => 932, - ], - 'XBA' => [ - 'alphabeticCode' => 'XBA', - 'currency' => 'Bond Markets Unit European Composite Unit (EURCO)', - 'entity' => 'ZZ01_Bond Markets Unit European_EURCO', - 'minorUnit' => 0, - 'numericCode' => 955, - ], - 'XBB' => [ - 'alphabeticCode' => 'XBB', - 'currency' => 'Bond Markets Unit European Monetary Unit (E.M.U.-6)', - 'entity' => 'ZZ02_Bond Markets Unit European_EMU-6', - 'minorUnit' => 0, - 'numericCode' => 956, - ], - 'XBC' => [ - 'alphabeticCode' => 'XBC', - 'currency' => 'Bond Markets Unit European Unit of Account 9 (E.U.A.-9)', - 'entity' => 'ZZ03_Bond Markets Unit European_EUA-9', - 'minorUnit' => 0, - 'numericCode' => 957, - ], - 'XBD' => [ - 'alphabeticCode' => 'XBD', - 'currency' => 'Bond Markets Unit European Unit of Account 17 (E.U.A.-17)', - 'entity' => 'ZZ04_Bond Markets Unit European_EUA-17', - 'minorUnit' => 0, - 'numericCode' => 958, - ], - 'XTS' => [ - 'alphabeticCode' => 'XTS', - 'currency' => 'Codes specifically reserved for testing purposes', - 'entity' => 'ZZ06_Testing_Code', - 'minorUnit' => 0, - 'numericCode' => 963, - ], - 'XAU' => [ - 'alphabeticCode' => 'XAU', - 'currency' => 'Gold', - 'entity' => 'ZZ08_Gold', - 'minorUnit' => 0, - 'numericCode' => 959, - ], - 'XPD' => [ - 'alphabeticCode' => 'XPD', - 'currency' => 'Palladium', - 'entity' => 'ZZ09_Palladium', - 'minorUnit' => 0, - 'numericCode' => 964, - ], - 'XPT' => [ - 'alphabeticCode' => 'XPT', - 'currency' => 'Platinum', - 'entity' => 'ZZ10_Platinum', - 'minorUnit' => 0, - 'numericCode' => 962, - ], - 'XAG' => [ - 'alphabeticCode' => 'XAG', - 'currency' => 'Silver', - 'entity' => 'ZZ11_Silver', - 'minorUnit' => 0, - 'numericCode' => 961, - ], -]; diff --git a/src/events/AddLineItemEvent.php b/src/events/AddLineItemEvent.php deleted file mode 100644 index aea85f50cd..0000000000 --- a/src/events/AddLineItemEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * @since 2.0 - */ -class AddLineItemEvent extends CancelableEvent -{ - /** - * @var LineItem The line item model. - */ - public LineItem $lineItem; - - /** - * @var bool If this is a new line item. - */ - public bool $isNew = false; -} diff --git a/src/events/CancelSubscriptionEvent.php b/src/events/CancelSubscriptionEvent.php deleted file mode 100644 index 6ce4e3e711..0000000000 --- a/src/events/CancelSubscriptionEvent.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 2.0 - */ -class CancelSubscriptionEvent extends CancelableEvent -{ - /** - * @var Subscription Subscription - */ - public Subscription $subscription; - - /** - * @var CancelSubscriptionForm parameters - */ - public CancelSubscriptionForm $parameters; -} diff --git a/src/events/CartEvent.php b/src/events/CartEvent.php deleted file mode 100644 index 3b1d7eac09..0000000000 --- a/src/events/CartEvent.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 2.0 - */ -class CartEvent extends CancelableEvent -{ - /** - * @var LineItem The line item model. - */ - public LineItem $lineItem; - - /** - * @var Order The order element - */ - public Order $order; -} diff --git a/src/events/CartPurgeEvent.php b/src/events/CartPurgeEvent.php deleted file mode 100644 index c0846e5d54..0000000000 --- a/src/events/CartPurgeEvent.php +++ /dev/null @@ -1,26 +0,0 @@ - - * @since 5.3 - */ -class CartPurgeEvent extends CancelableEvent -{ - /** - * @var Query The query that identifies the order IDs to be purged. - */ - public Query $inactiveCartsQuery; -} diff --git a/src/events/CommerceDebugPanelDataEvent.php b/src/events/CommerceDebugPanelDataEvent.php deleted file mode 100644 index c15ebb06a0..0000000000 --- a/src/events/CommerceDebugPanelDataEvent.php +++ /dev/null @@ -1,29 +0,0 @@ - - * @since 4.0 - */ -class CommerceDebugPanelDataEvent extends Event -{ - /** - * @var array - */ - public array $nav; - - /** - * @var array - */ - public array $content; -} diff --git a/src/events/CreateSubscriptionEvent.php b/src/events/CreateSubscriptionEvent.php deleted file mode 100644 index f3a39d2e9a..0000000000 --- a/src/events/CreateSubscriptionEvent.php +++ /dev/null @@ -1,37 +0,0 @@ - - * @since 2.0 - */ -class CreateSubscriptionEvent extends CancelableEvent -{ - /** - * @var User The subscribing user - */ - public User $user; - - /** - * @var Plan The subscription plan - */ - public Plan $plan; - - /** - * @var SubscriptionForm Additional parameters - */ - public SubscriptionForm $parameters; -} diff --git a/src/events/CustomizeProductSnapshotDataEvent.php b/src/events/CustomizeProductSnapshotDataEvent.php deleted file mode 100644 index 3217e5cf82..0000000000 --- a/src/events/CustomizeProductSnapshotDataEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * @since 2.0 - */ -class CustomizeProductSnapshotDataEvent extends Event -{ - /** - * @var Product The product - */ - public Product $product; - - /** - * @var array The captured data - */ - public array $fieldData; -} diff --git a/src/events/CustomizeProductSnapshotFieldsEvent.php b/src/events/CustomizeProductSnapshotFieldsEvent.php deleted file mode 100644 index abeec25687..0000000000 --- a/src/events/CustomizeProductSnapshotFieldsEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * @since 2.0 - */ -class CustomizeProductSnapshotFieldsEvent extends Event -{ - /** - * @var Product The product - */ - public Product $product; - - /** - * @var array|null The fields to be captured - */ - public ?array $fields = null; -} diff --git a/src/events/CustomizeVariantSnapshotDataEvent.php b/src/events/CustomizeVariantSnapshotDataEvent.php deleted file mode 100644 index 2331d07d1e..0000000000 --- a/src/events/CustomizeVariantSnapshotDataEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * @since 2.0 - */ -class CustomizeVariantSnapshotDataEvent extends Event -{ - /** - * @var Variant The variant - */ - public Variant $variant; - - /** - * @var array The captured data - */ - public array $fieldData; -} diff --git a/src/events/CustomizeVariantSnapshotFieldsEvent.php b/src/events/CustomizeVariantSnapshotFieldsEvent.php deleted file mode 100644 index 15dab1ae02..0000000000 --- a/src/events/CustomizeVariantSnapshotFieldsEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * @since 2.0 - */ -class CustomizeVariantSnapshotFieldsEvent extends Event -{ - /** - * @var Variant The variant - */ - public Variant $variant; - - /** - * @var array|null The fields to be captured - */ - public ?array $fields = null; -} diff --git a/src/events/DefaultLineItemStatusEvent.php b/src/events/DefaultLineItemStatusEvent.php deleted file mode 100644 index 7b8cd33ab5..0000000000 --- a/src/events/DefaultLineItemStatusEvent.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 2.0 - */ -class DefaultLineItemStatusEvent extends Event -{ - /** - * @var LineItemStatus|null The default line item status based on the line item - */ - public ?LineItemStatus $lineItemStatus = null; - - /** - * @var LineItem The line item used to determine the line item status. - */ - public LineItem $lineItem; -} diff --git a/src/events/DefaultOrderStatusEvent.php b/src/events/DefaultOrderStatusEvent.php deleted file mode 100644 index 9a7b0bb502..0000000000 --- a/src/events/DefaultOrderStatusEvent.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 2.0 - */ -class DefaultOrderStatusEvent extends Event -{ - /** - * @var OrderStatus The default order status based on the order - */ - public OrderStatus $orderStatus; - - /** - * @var Order The order used to determine the order status. - */ - public Order $order; -} diff --git a/src/events/DeleteStoreEvent.php b/src/events/DeleteStoreEvent.php deleted file mode 100644 index 985741abbd..0000000000 --- a/src/events/DeleteStoreEvent.php +++ /dev/null @@ -1,18 +0,0 @@ - - * @since 5.0.0 - */ -class DeleteStoreEvent extends StoreEvent -{ -} diff --git a/src/events/DiscountAdjustmentsEvent.php b/src/events/DiscountAdjustmentsEvent.php deleted file mode 100644 index ad7f1b4c60..0000000000 --- a/src/events/DiscountAdjustmentsEvent.php +++ /dev/null @@ -1,37 +0,0 @@ - - * @since 2.0 - */ -class DiscountAdjustmentsEvent extends CancelableEvent -{ - /** - * @var Order The order the discount generated the adjustments for. Do not mutate. - */ - public Order $order; - - /** - * @var Discount The discount that matched. - */ - public Discount $discount; - - /** - * @var OrderAdjustment[] The adjustments generated by the discount. - */ - public array $adjustments; -} diff --git a/src/events/DiscountEvent.php b/src/events/DiscountEvent.php deleted file mode 100644 index f31af08488..0000000000 --- a/src/events/DiscountEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * @since 2.0 - */ -class DiscountEvent extends Event -{ - /** - * @var Discount The discount model - */ - public Discount $discount; - - /** - * @var bool If this is a new discount - */ - public bool $isNew; -} diff --git a/src/events/EmailEvent.php b/src/events/EmailEvent.php deleted file mode 100644 index 06a6582409..0000000000 --- a/src/events/EmailEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * @since 2.0 - */ -class EmailEvent extends Event -{ - /** - * @var Email Email - */ - public Email $email; - - /** - * @var bool Whether the email is brand new. - */ - public bool $isNew = false; -} diff --git a/src/events/InventoryMovementEvent.php b/src/events/InventoryMovementEvent.php deleted file mode 100644 index 1c091c5d8d..0000000000 --- a/src/events/InventoryMovementEvent.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 5.5.0 - */ -class InventoryMovementEvent extends Event -{ - /** - * @var InventoryMovementInterface The inventory movement that was executed - */ - public InventoryMovementInterface $inventoryMovement; -} diff --git a/src/events/LineItemEvent.php b/src/events/LineItemEvent.php deleted file mode 100644 index a498dcb90b..0000000000 --- a/src/events/LineItemEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * @since 2.0 - */ -class LineItemEvent extends Event -{ - /** - * @var LineItem The line item model. - */ - public LineItem $lineItem; - - /** - * @var bool If this is a new line item. - */ - public bool $isNew = false; -} diff --git a/src/events/MailEvent.php b/src/events/MailEvent.php deleted file mode 100644 index c60810389d..0000000000 --- a/src/events/MailEvent.php +++ /dev/null @@ -1,48 +0,0 @@ - - * @since 2.0 - */ -class MailEvent extends CancelableEvent -{ - /** - * @var Message Craft email object - */ - public Message $craftEmail; - - /** - * @var Email Commerce email object - */ - public Email $commerceEmail; - - /** - * @var Order Commerce order - */ - public Order $order; - - /** - * @var OrderHistory|null The order history - */ - public ?OrderHistory $orderHistory = null; - - /** - * @var array Order data at the time the email sends. - */ - public ?array $orderData = null; -} diff --git a/src/events/MatchLineItemEvent.php b/src/events/MatchLineItemEvent.php deleted file mode 100644 index 46659563fc..0000000000 --- a/src/events/MatchLineItemEvent.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 2.0 - */ -class MatchLineItemEvent extends CancelableEvent -{ - /** - * @var LineItem The matched line item. - */ - public LineItem $lineItem; - - /** - * @var Discount The discount that matched. - */ - public Discount $discount; -} diff --git a/src/events/MatchOrderEvent.php b/src/events/MatchOrderEvent.php deleted file mode 100644 index 9aa7da004f..0000000000 --- a/src/events/MatchOrderEvent.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 3.1.5 - */ -class MatchOrderEvent extends CancelableEvent -{ - /** - * @var Order The matched order. - */ - public Order $order; - - /** - * @var Discount The discount that matched. - */ - public Discount $discount; -} diff --git a/src/events/ModifyCartInfoEvent.php b/src/events/ModifyCartInfoEvent.php deleted file mode 100644 index 6aeea47c47..0000000000 --- a/src/events/ModifyCartInfoEvent.php +++ /dev/null @@ -1,34 +0,0 @@ - - * @since 2.2 - */ -class ModifyCartInfoEvent extends Event -{ - /** - * @var array The cart info that is allowed to be modified - */ - public array $cartInfo = []; - - /** - * The cart object that can be used to modify the cart info. - * Do not mutate this object. - * - * @var Order|null - * @since 3.1.11 - */ - public ?Order $cart = null; -} diff --git a/src/events/ModifyPurchasablesTableQueryEvent.php b/src/events/ModifyPurchasablesTableQueryEvent.php deleted file mode 100644 index 723d36b22b..0000000000 --- a/src/events/ModifyPurchasablesTableQueryEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * @since 4.3.0 - */ -class ModifyPurchasablesTableQueryEvent extends Event -{ - /** - * @var Query - */ - public Query $query; - - /** - * @var string|null The search term that is being used in the query, if any - */ - public ?string $search = null; -} diff --git a/src/events/OrderLineItemsRefreshEvent.php b/src/events/OrderLineItemsRefreshEvent.php deleted file mode 100644 index 4e8b85a9fb..0000000000 --- a/src/events/OrderLineItemsRefreshEvent.php +++ /dev/null @@ -1,26 +0,0 @@ - - * @since 5.1.0 - */ -class OrderLineItemsRefreshEvent extends Event -{ - /** - * @var array - */ - public array $lineItems; - - public bool $recalculate = false; -} diff --git a/src/events/OrderNoticeEvent.php b/src/events/OrderNoticeEvent.php deleted file mode 100644 index a8cd54fe81..0000000000 --- a/src/events/OrderNoticeEvent.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 4.1.0 - */ -class OrderNoticeEvent extends CancelableEvent -{ - /** - * @var OrderNotice The line item model. - */ - public $orderNotice; -} diff --git a/src/events/OrderStatusEmailsEvent.php b/src/events/OrderStatusEmailsEvent.php deleted file mode 100644 index b18d895a13..0000000000 --- a/src/events/OrderStatusEmailsEvent.php +++ /dev/null @@ -1,36 +0,0 @@ - - * @since 4.0 - */ -class OrderStatusEmailsEvent extends CancelableEvent -{ - /** - * @var OrderHistory The order history - */ - public OrderHistory $orderHistory; - - /** - * @var Order The order - */ - public Order $order; - - /** - * @var array The emails to send - */ - public array $emails; -} diff --git a/src/events/OrderStatusEvent.php b/src/events/OrderStatusEvent.php deleted file mode 100644 index abe7893696..0000000000 --- a/src/events/OrderStatusEvent.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 2.0 - */ -class OrderStatusEvent extends Event -{ - /** - * @var OrderHistory The order history - */ - public OrderHistory $orderHistory; - - /** - * @var Order The order - */ - public Order $order; -} diff --git a/src/events/PaymentCurrencyRateEvent.php b/src/events/PaymentCurrencyRateEvent.php deleted file mode 100644 index 87200be420..0000000000 --- a/src/events/PaymentCurrencyRateEvent.php +++ /dev/null @@ -1,35 +0,0 @@ - - * @since 2.0 - */ -class PaymentSourceEvent extends CancelableEvent -{ - /** - * @var PaymentSource Payment source - */ - public PaymentSource $paymentSource; -} diff --git a/src/events/PdfEvent.php b/src/events/PdfEvent.php deleted file mode 100644 index a2179b04a2..0000000000 --- a/src/events/PdfEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * @since 2.0 - */ -class PdfEvent extends Event -{ - /** - * @var Pdf The PDF model associated with the event. - */ - public Pdf $pdf; - - /** - * @var bool Whether the PDF is brand new - */ - public bool $isNew = false; -} diff --git a/src/events/PdfRenderEvent.php b/src/events/PdfRenderEvent.php deleted file mode 100644 index 2b46d5364b..0000000000 --- a/src/events/PdfRenderEvent.php +++ /dev/null @@ -1,52 +0,0 @@ - - * @since 4.0 - */ -class PdfRenderEvent extends Event -{ - /** - * @var Order - */ - public Order $order; - - /** - * @var string - */ - public string $option; - - /** - * @var string - */ - public string $template; - - /** - * @var array - */ - public array $variables; - - /** - * @var string|null The rendered PDF - */ - public ?string $pdf = null; - - /** - * @var Pdf|null The configured PDF model used to render the PDF - * @since 5.0.12 - */ - public ?Pdf $sourcePdf = null; -} diff --git a/src/events/PdfRenderOptionsEvent.php b/src/events/PdfRenderOptionsEvent.php deleted file mode 100644 index 65e1b904c7..0000000000 --- a/src/events/PdfRenderOptionsEvent.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 3.2.10 - */ -class PdfRenderOptionsEvent extends Event -{ - /** - * @var Options - */ - public Options $options; -} diff --git a/src/events/PlanEvent.php b/src/events/PlanEvent.php deleted file mode 100644 index a6852084a1..0000000000 --- a/src/events/PlanEvent.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 2.0 - */ -class PlanEvent extends Event -{ - /** - * @var Plan Plan - */ - public Plan $plan; -} diff --git a/src/events/ProcessPaymentEvent.php b/src/events/ProcessPaymentEvent.php deleted file mode 100644 index a772638723..0000000000 --- a/src/events/ProcessPaymentEvent.php +++ /dev/null @@ -1,37 +0,0 @@ - - * @since 2.0 - */ -class ProductEvent extends CancelableEvent -{ - /** - * @var Product The address model - */ - public Product $product; - - /** - * @var bool If this is a new product - */ - public bool $isNew; -} diff --git a/src/events/ProductTypeEvent.php b/src/events/ProductTypeEvent.php deleted file mode 100644 index ca8fae2c92..0000000000 --- a/src/events/ProductTypeEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * @since 2.0 - */ -class ProductTypeEvent extends Event -{ - /** - * @var ProductType|null The product type model associated with the event. - */ - public ?ProductType $productType = null; - - /** - * @var bool Whether the product type is brand new - */ - public bool $isNew = false; -} diff --git a/src/events/PurchasableAvailableEvent.php b/src/events/PurchasableAvailableEvent.php deleted file mode 100644 index 5bbf0a93fc..0000000000 --- a/src/events/PurchasableAvailableEvent.php +++ /dev/null @@ -1,42 +0,0 @@ - - * @since 3.3.1 - */ -class PurchasableAvailableEvent extends Event -{ - /** - * @var Order|null The order element. - */ - public ?Order $order = null; - - /** - * @var PurchasableInterface The purchasable element. - */ - public PurchasableInterface $purchasable; - - /** - * @var User|null The user performing the check. - */ - public ?User $currentUser = null; - - /** - * @var bool Is this purchasable available to the order and current user. Default is: $event->purchasable->getIsAvailable() - */ - public bool $isAvailable; -} diff --git a/src/events/PurchasableOutOfStockPurchasesAllowedEvent.php b/src/events/PurchasableOutOfStockPurchasesAllowedEvent.php deleted file mode 100644 index 5b24e97113..0000000000 --- a/src/events/PurchasableOutOfStockPurchasesAllowedEvent.php +++ /dev/null @@ -1,42 +0,0 @@ - - * @since 5.3.0 - */ -class PurchasableOutOfStockPurchasesAllowedEvent extends Event -{ - /** - * @var Order|null The order element. - */ - public ?Order $order = null; - - /** - * @var PurchasableInterface The purchasable element. - */ - public PurchasableInterface $purchasable; - - /** - * @var User|null The user performing the check. - */ - public ?User $currentUser = null; - - /** - * @var bool Is this purchasable available to be purchased when out of stock - */ - public bool $outOfStockPurchasesAllowed = false; -} diff --git a/src/events/PurchasableShippableEvent.php b/src/events/PurchasableShippableEvent.php deleted file mode 100644 index 4e60f5be85..0000000000 --- a/src/events/PurchasableShippableEvent.php +++ /dev/null @@ -1,42 +0,0 @@ - - * @since 3.3.2 - */ -class PurchasableShippableEvent extends Event -{ - /** - * @var Order|null The order element. - */ - public ?Order $order = null; - - /** - * @var PurchasableInterface The purchasable element. - */ - public PurchasableInterface $purchasable; - - /** - * @var User|null The user performing the check. - */ - public ?User $currentUser = null; - - /** - * @var bool Is this purchasable shippable within the order and current user. Default is: $event->purchasable->getIsShippable() - */ - public bool $isShippable; -} diff --git a/src/events/PurchaseVariantEvent.php b/src/events/PurchaseVariantEvent.php deleted file mode 100644 index 129463f79c..0000000000 --- a/src/events/PurchaseVariantEvent.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 2.0 - */ -class PurchaseVariantEvent extends Event -{ - /** - * @var Variant The variant model - */ - public Variant $variant; -} diff --git a/src/events/PurgeAddressesEvent.php b/src/events/PurgeAddressesEvent.php deleted file mode 100644 index 1cb415bf2f..0000000000 --- a/src/events/PurgeAddressesEvent.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 3.3 - */ -class PurgeAddressesEvent extends CancelableEvent -{ - /** - * @var Query|null The query to get the purgeable addresses - */ - public ?Query $addressesQuery = null; -} diff --git a/src/events/RefundTransactionEvent.php b/src/events/RefundTransactionEvent.php deleted file mode 100644 index 0f89006ffd..0000000000 --- a/src/events/RefundTransactionEvent.php +++ /dev/null @@ -1,29 +0,0 @@ - - * @since 2.0 - */ -class RefundTransactionEvent extends TransactionEvent -{ - /** - * @var float The amount to refund - */ - public float $amount; - - /** - * @var Transaction The transaction created which is the refund - */ - public Transaction $refundTransaction; -} diff --git a/src/events/RegisterAvailableShippingMethodsEvent.php b/src/events/RegisterAvailableShippingMethodsEvent.php deleted file mode 100644 index 5753650b82..0000000000 --- a/src/events/RegisterAvailableShippingMethodsEvent.php +++ /dev/null @@ -1,63 +0,0 @@ - - * @since 3.0 - * - * @property array|\Illuminate\Support\Collection $shippingMethods - */ -class RegisterAvailableShippingMethodsEvent extends Event -{ - /** - * @var Order The order the shipping method should be available for - */ - public Order $order; - - /** - * @var Collection|null The shipping methods available to the order. - * @see getShippingMethods() - * @see setShippingMethods() - */ - private ?Collection $_shippingMethods = null; - - /** - * @param Collection|array $shippingMethods - * @return void - * @since 5.0.0 - */ - public function setShippingMethods(Collection|array $shippingMethods): void - { - if (!$shippingMethods instanceof Collection) { - $shippingMethods = collect($shippingMethods); - } - - $this->_shippingMethods = $shippingMethods; - } - - /** - * @return Collection - * @since 5.0.0 - */ - public function getShippingMethods(): Collection - { - if ($this->_shippingMethods === null) { - $this->_shippingMethods = collect(); - } - - return $this->_shippingMethods; - } -} diff --git a/src/events/ReportEvent.php b/src/events/ReportEvent.php deleted file mode 100644 index 311058ad09..0000000000 --- a/src/events/ReportEvent.php +++ /dev/null @@ -1,27 +0,0 @@ - - * @since 2.0 - */ -class ReportEvent extends Event -{ - public mixed $startDate = null; - public mixed $endDate = null; - public mixed $status = null; - public mixed $orderQuery = null; - public mixed $columns = null; - public mixed $orders = null; - public mixed $format = null; -} diff --git a/src/events/SaleEvent.php b/src/events/SaleEvent.php deleted file mode 100644 index 5b01b45cef..0000000000 --- a/src/events/SaleEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * @since 2.2 - */ -class SaleEvent extends Event -{ - /** - * @var Sale sale - */ - public Sale $sale; - - /** - * @var bool Whether the sale is brand new - */ - public bool $isNew = false; -} diff --git a/src/events/SaleMatchEvent.php b/src/events/SaleMatchEvent.php deleted file mode 100644 index 18c0505700..0000000000 --- a/src/events/SaleMatchEvent.php +++ /dev/null @@ -1,36 +0,0 @@ - - * @since 2.0 - */ -class SaleMatchEvent extends CancelableEvent -{ - /** - * @var Sale The sale - */ - public Sale $sale; - - /** - * @var PurchasableInterface The purchasable matched - */ - public PurchasableInterface $purchasable; - - /** - * @var bool If this is a new sale - */ - public bool $isNew; -} diff --git a/src/events/StoreEvent.php b/src/events/StoreEvent.php deleted file mode 100644 index 2d4f011a55..0000000000 --- a/src/events/StoreEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * @since 5.0.0 - */ -class StoreEvent extends CancelableEvent -{ - /** - * @var Store The store model associated with the event. - */ - public Store $store; - - /** - * @var bool Whether the store is brand new - */ - public bool $isNew = false; -} diff --git a/src/events/SubscriptionEvent.php b/src/events/SubscriptionEvent.php deleted file mode 100644 index f99f387141..0000000000 --- a/src/events/SubscriptionEvent.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 2.0 - */ -class SubscriptionEvent extends CancelableEvent -{ - /** - * @var Subscription Subscription - */ - public Subscription $subscription; -} diff --git a/src/events/SubscriptionPaymentEvent.php b/src/events/SubscriptionPaymentEvent.php deleted file mode 100644 index b11bd6bc36..0000000000 --- a/src/events/SubscriptionPaymentEvent.php +++ /dev/null @@ -1,37 +0,0 @@ - - * @since 2.0 - */ -class SubscriptionPaymentEvent extends Event -{ - /** - * @var Subscription Subscription - */ - public Subscription $subscription; - - /** - * @var SubscriptionPayment Subscription payment - */ - public SubscriptionPayment $payment; - - /** - * @var DateTime Date subscription paid until - */ - public DateTime $paidUntil; -} diff --git a/src/events/SubscriptionSwitchPlansEvent.php b/src/events/SubscriptionSwitchPlansEvent.php deleted file mode 100644 index 80426ac5da..0000000000 --- a/src/events/SubscriptionSwitchPlansEvent.php +++ /dev/null @@ -1,42 +0,0 @@ - - * @since 2.0 - */ -class SubscriptionSwitchPlansEvent extends CancelableEvent -{ - /** - * @var Plan The plan user is switching from - */ - public Plan $oldPlan; - - /** - * @var Subscription Subscription - */ - public Subscription $subscription; - - /** - * @var Plan The plan user is switching to - */ - public Plan $newPlan; - - /** - * @var SwitchPlansForm parameters - */ - public SwitchPlansForm $parameters; -} diff --git a/src/events/TaxEngineEvent.php b/src/events/TaxEngineEvent.php deleted file mode 100644 index 227685cfdc..0000000000 --- a/src/events/TaxEngineEvent.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 3.1 - */ -class TaxEngineEvent extends Event -{ - /** - * @var TaxEngineInterface The tax engine - */ - public TaxEngineInterface $engine; -} diff --git a/src/events/TaxIdValidatorsEvent.php b/src/events/TaxIdValidatorsEvent.php deleted file mode 100644 index dedfc8aca1..0000000000 --- a/src/events/TaxIdValidatorsEvent.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 5.3.0 - */ -class TaxIdValidatorsEvent extends Event -{ - /** - * @var TaxIdValidatorInterface[] Holds the registered tax ID validators. - */ - public array $validators = []; -} diff --git a/src/events/TransactionEvent.php b/src/events/TransactionEvent.php deleted file mode 100644 index 494cc0448b..0000000000 --- a/src/events/TransactionEvent.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 2.0 - */ -class TransactionEvent extends Event -{ - /** - * @var Transaction The transaction model - */ - public Transaction $transaction; -} diff --git a/src/events/UpdateInventoryLevelEvent.php b/src/events/UpdateInventoryLevelEvent.php deleted file mode 100644 index 4580f57082..0000000000 --- a/src/events/UpdateInventoryLevelEvent.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 5.5.0 - */ -class UpdateInventoryLevelEvent extends Event -{ - /** - * @var UpdateInventoryLevel The inventory level update that was executed - */ - public UpdateInventoryLevel $updateInventoryLevel; -} diff --git a/src/events/UpdatePrimaryPaymentSourceEvent.php b/src/events/UpdatePrimaryPaymentSourceEvent.php deleted file mode 100644 index 6fa0baa14a..0000000000 --- a/src/events/UpdatePrimaryPaymentSourceEvent.php +++ /dev/null @@ -1,35 +0,0 @@ - - * @since 4.2.8 - */ -class UpdatePrimaryPaymentSourceEvent extends Event -{ - /** - * @var ?int The previous payment source ID - */ - public ?int $previousPrimaryPaymentSourceId = null; - - /** - * @var ?int The new payment source ID - */ - public ?int $newPrimaryPaymentSourceId = null; - - /** - * @var User The user that the payment source belongs to - */ - public User $customer; -} diff --git a/src/events/UpgradeEvent.php b/src/events/UpgradeEvent.php deleted file mode 100644 index ff38f42d83..0000000000 --- a/src/events/UpgradeEvent.php +++ /dev/null @@ -1,28 +0,0 @@ - - */ -class UpgradeEvent extends Event -{ - /** - * @var array $columns - */ - public array $v3columnMap = []; - - /** - * @var array $v3tables - */ - public array $v3tables = []; -} diff --git a/src/events/WebhookEvent.php b/src/events/WebhookEvent.php deleted file mode 100644 index 561c6e48ca..0000000000 --- a/src/events/WebhookEvent.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 3.2.9 - */ -class WebhookEvent extends Event -{ - /** - * @var GatewayInterface - */ - public GatewayInterface $gateway; - - /** - * @var Response - */ - public Response $response; -} diff --git a/src/exports/Expanded.php b/src/exports/Expanded.php deleted file mode 100644 index 42b07322c5..0000000000 --- a/src/exports/Expanded.php +++ /dev/null @@ -1,66 +0,0 @@ - - * @since 3.2.7 - */ -class Expanded extends CraftExpanded -{ - /** - * @inheritdoc - */ - public function export(ElementQueryInterface $query): mixed - { - // This export should be identical to the parent, except for the additional extra fields - $extraAttributes = ['adjustments', 'billingAddress', 'shippingAddress', 'transactions']; - - // Eager-load as much as we can - $eagerLoadableFields = []; - foreach (Craft::$app->getFields()->getAllFields() as $field) { - if ($field instanceof EagerLoadingFieldInterface) { - $eagerLoadableFields[] = $field->handle; - } - } - - $data = []; - - /** @var OrderQuery $query */ - $query->with($eagerLoadableFields); - $query->withAll(); - - foreach ($query->each() as $element) { - // Get the basic array representation excluding custom fields - $attributes = array_flip($element->attributes()); - if (($fieldLayout = $element->getFieldLayout()) !== null) { - foreach ($fieldLayout->getCustomFields() as $field) { - unset($attributes[$field->handle]); - } - } - $elementArr = $element->toArray(array_keys($attributes), $extraAttributes); - if ($fieldLayout !== null) { - foreach ($fieldLayout->getCustomFields() as $field) { - $value = $element->getFieldValue($field->handle); - $elementArr[$field->handle] = $field->serializeValue($value, $element); - } - } - $data[] = $elementArr; - } - - return $data; - } -} diff --git a/src/exports/LineItemExport.php b/src/exports/LineItemExport.php deleted file mode 100644 index 4c9223a880..0000000000 --- a/src/exports/LineItemExport.php +++ /dev/null @@ -1,94 +0,0 @@ -ids(); - - $columns = [ - 'lineitems.id', - 'lineitems.orderId', - 'lineitems.purchasableId', - 'lineitems.description', - 'lineitems.sku', - 'lineitems.taxCategoryId', - 'lineitems.lineItemStatusId', - 'lineitems.shippingCategoryId', - 'lineitems.options', - 'lineitems.optionsSignature', - 'lineitems.price', - 'lineitems.promotionalAmount', - 'lineitems.salePrice', - 'lineitems.qty', - 'lineitems.subtotal', - 'totalTax' => (new CraftQuery()) - ->select('SUM([[amount]])') - ->from(Table::ORDERADJUSTMENTS . ' adjustments') - ->where(['and', '[[adjustments.orderId]] = [[lineitems.orderId]]', '[[adjustments.lineItemId]] = [[lineitems.id]]']) - ->andWhere(['type' => Tax::ADJUSTMENT_TYPE]) - ->andWhere(['included' => 0]), - 'totalTaxIncluded' => (new CraftQuery()) - ->select('SUM([[amount]])') - ->from(Table::ORDERADJUSTMENTS . ' adjustments') - ->where(['and', '[[adjustments.orderId]] = [[lineitems.orderId]]', '[[lineItemId]] = [[lineitems.id]]']) - ->andWhere(['type' => Tax::ADJUSTMENT_TYPE]) - ->andWhere(['included' => 1]), - 'totalShipping' => (new CraftQuery()) - ->select('SUM([[amount]])') - ->from(Table::ORDERADJUSTMENTS . ' adjustments') - ->where(['and', '[[adjustments.orderId]] = [[lineitems.orderId]]', '[[lineItemId]] = [[lineitems.id]]']) - ->andWhere(['type' => Shipping::ADJUSTMENT_TYPE]), - 'totalDiscount' => (new CraftQuery()) - ->select('SUM([[amount]])') - ->from(Table::ORDERADJUSTMENTS . ' adjustments') - ->where(['and', '[[adjustments.orderId]] = [[lineitems.orderId]]', '[[adjustments.lineItemId]] = [[lineitems.id]]']) - ->andWhere(['type' => Discount::ADJUSTMENT_TYPE]), - 'lineitems.total', - 'lineitems.weight', - 'lineitems.height', - 'lineitems.length', - 'lineitems.width', - 'lineitems.note', - 'lineitems.privateNote', - 'lineitems.snapshot', - 'lineitems.dateCreated', - 'lineitems.dateUpdated', - 'lineitems.uid', - ]; - - return (new CraftQuery()) - ->select($columns) - ->from(Table::LINEITEMS . ' lineitems') - ->leftJoin(Table::ORDERS . ' orders', '[[lineitems.orderId]] = [[orders.id]]') - ->where(['[[lineitems.orderId]]' => $orderIds]) - ->all(); - } -} diff --git a/src/exports/OrderExport.php b/src/exports/OrderExport.php deleted file mode 100644 index 1c8d2ce21f..0000000000 --- a/src/exports/OrderExport.php +++ /dev/null @@ -1,88 +0,0 @@ -ids(); - - $columns = [ - 'id', - 'number', - 'email', - 'gatewayId', - 'paymentSourceId', - 'customerId', - 'orderStatusId', - 'couponCode', - 'itemTotal', - 'totalTax' => (new CraftQuery()) - ->select('SUM([[amount]])') - ->from(Table::ORDERADJUSTMENTS) - ->where('[[orderId]] = ' . Table::ORDERS . '.[[id]]') - ->andWhere(['type' => Tax::ADJUSTMENT_TYPE]) - ->andWhere(['included' => 0]), - 'totalTaxIncluded' => (new CraftQuery()) - ->select('SUM([[amount]])') - ->from(Table::ORDERADJUSTMENTS) - ->where('[[orderId]] = ' . Table::ORDERS . '.[[id]]') - ->andWhere(['type' => Tax::ADJUSTMENT_TYPE]) - ->andWhere(['included' => 1]), - 'totalShipping' => (new CraftQuery()) - ->select('SUM([[amount]])') - ->from(Table::ORDERADJUSTMENTS) - ->where('[[orderId]] = ' . Table::ORDERS . '.[[id]]') - ->andWhere(['type' => Shipping::ADJUSTMENT_TYPE]), - 'totalDiscount' => (new CraftQuery()) - ->select('SUM([[amount]])') - ->from(Table::ORDERADJUSTMENTS) - ->where('[[orderId]] = ' . Table::ORDERS . '.[[id]]') - ->andWhere(['type' => Discount::ADJUSTMENT_TYPE]), - 'totalPrice', - 'totalPaid', - 'paidStatus', - 'isCompleted', - 'dateOrdered', - 'datePaid', - 'currency', - 'paymentCurrency', - 'lastIp', - 'orderLanguage', - 'message', - 'shippingMethodHandle', - ]; - - return (new CraftQuery()) - ->select($columns) - ->from(Table::ORDERS) - ->where(['id' => $orderIds]) - ->all(); - } -} diff --git a/src/fieldlayoutelements/ProductTitleField.php b/src/fieldlayoutelements/ProductTitleField.php deleted file mode 100644 index 372c182cea..0000000000 --- a/src/fieldlayoutelements/ProductTitleField.php +++ /dev/null @@ -1,77 +0,0 @@ - - * @since 3.2.0 - */ -class ProductTitleField extends TitleField -{ - /** - * @inheritdoc - */ - protected function selectorInnerHtml(): string - { - return - Html::tag('span', '', [ - 'class' => ['fld-product-title-field-icon', 'fld-field-hidden', 'hidden'], - ]) . - parent::selectorInnerHtml(); - } - - /** - * @inheritdoc - */ - protected function translatable(?ElementInterface $element = null, bool $static = false): bool - { - if (!$element instanceof Product) { - throw new \InvalidArgumentException(sprintf('%s can only be used in product field layouts.', self::class)); - } - - return $element->getType()->productTitleTranslationMethod !== Field::TRANSLATION_METHOD_NONE; - } - - /** - * @inheritdoc - */ - protected function translationDescription(?ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Product) { - throw new \InvalidArgumentException(sprintf('%s can only be used in product field layouts.', self::class)); - } - - return ElementHelper::translationDescription($element->getType()->productTitleTranslationMethod); - } - - /** - * @inheritdoc - */ - public function inputHtml(ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Product) { - throw new InvalidArgumentException('ProductTitleField can only be used in product field layouts.'); - } - - if (!$element->getType()->hasProductTitleField) { - return null; - } - - return parent::inputHtml($element, $static); - } -} diff --git a/src/fieldlayoutelements/PurchasableAllowedQtyField.php b/src/fieldlayoutelements/PurchasableAllowedQtyField.php deleted file mode 100644 index 302b2f29cb..0000000000 --- a/src/fieldlayoutelements/PurchasableAllowedQtyField.php +++ /dev/null @@ -1,86 +0,0 @@ - - * @since 5.0.0 - */ -class PurchasableAllowedQtyField extends BaseNativeField -{ - /** - * @inheritdoc - */ - public bool $mandatory = true; - - /** - * @inheritdoc - */ - public string $attribute = 'allowedQty'; - - /** - * @inheritdoc - */ - public function __construct(array $config = []) - { - unset($config['required']); - parent::__construct($config); - } - - /** - * @inheritdoc - */ - public function inputHtml(ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Purchasable) { - throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); - } - - return Html::beginTag('div', ['class' => 'flex']) . - Html::beginTag('div', ['class' => 'textwrapper']) . - Cp::textHtml([ - 'id' => 'minQty', - 'name' => 'minQty', - 'value' => $element->minQty, - 'placeholder' => Craft::t('commerce', 'Any'), - 'title' => Craft::t('commerce', 'Minimum allowed quantity'), - 'disabled' => $static, - ]) . - Html::endTag('div') . - Html::tag('div', Craft::t('commerce', 'to'), ['class' => 'label light']) . - Html::beginTag('div', ['class' => 'textwrapper']) . - Cp::textHtml([ - 'id' => 'maxQty', - 'name' => 'maxQty', - 'value' => $element->maxQty, - 'placeholder' => Craft::t('commerce', 'Any'), - 'title' => Craft::t('commerce', 'Maximum allowed quantity'), - 'disabled' => $static, - ]) . - Html::endTag('div') . - Html::endTag('div'); - } - - /** - * @inheritdoc - */ - protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string - { - return Craft::t('commerce', 'Allowed Qty'); - } -} diff --git a/src/fieldlayoutelements/PurchasableAvailableForPurchaseField.php b/src/fieldlayoutelements/PurchasableAvailableForPurchaseField.php deleted file mode 100644 index 455c920f7c..0000000000 --- a/src/fieldlayoutelements/PurchasableAvailableForPurchaseField.php +++ /dev/null @@ -1,83 +0,0 @@ - - * @since 5.0.0 - */ -class PurchasableAvailableForPurchaseField extends BaseNativeField -{ - /** - * @inheritdoc - */ - public bool $mandatory = true; - - /** - * @inheritdoc - */ - public string $attribute = 'availableForPurchase'; - - /** - * @var bool Whether the field should be checked by default when creating a new purchasable. - */ - public bool $defaultAvailableForPurchase = false; - - /** - * @inheritdoc - */ - public function __construct(array $config = []) - { - unset($config['required']); - parent::__construct($config); - } - - /** - * @inheritdoc - */ - public function inputHtml(ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Purchasable) { - throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); - } - - return PurchasableHelper::availableForPurchaseInputHtml($element->getIsFresh() ? $this->defaultAvailableForPurchase : $element->availableForPurchase, [ - 'disabled' => $static, - ]); - } - - public function settingsHtml(): string - { - return parent::settingsHtml() . Cp::lightswitchHtml( - [ - 'id' => 'defaultAvailableForPurchase', - 'name' => 'defaultAvailableForPurchase', - 'label' => Craft::t('app', 'Default Value'), - 'on' => $this->defaultAvailableForPurchase, - ] - ); - } - - /** - * @inheritdoc - */ - protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string - { - return Craft::t('commerce', 'Available for purchase'); - } -} diff --git a/src/fieldlayoutelements/PurchasableDimensionsField.php b/src/fieldlayoutelements/PurchasableDimensionsField.php deleted file mode 100644 index 89c693f4f2..0000000000 --- a/src/fieldlayoutelements/PurchasableDimensionsField.php +++ /dev/null @@ -1,105 +0,0 @@ - - * @since 5.0.0 - */ -class PurchasableDimensionsField extends BaseNativeField -{ - /** - * @inheritdoc - */ - public bool $mandatory = true; - - /** - * @inheritdoc - */ - public string $attribute = 'dimensions'; - - /** - * @inheritdoc - */ - protected function showLabel(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function showInForm(?ElementInterface $element = null): bool - { - if ($element instanceof Variant && !$element->getOwner()->getType()->hasDimensions) { - return false; - } - - return parent::showInForm($element); - } - - /** - * @inheritdoc - */ - public function inputHtml(ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Purchasable) { - throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); - } - - return Html::beginTag('div' , ['class' => 'flex']) . - Cp::fieldHtml(Cp::textHtml([ - 'id' => 'length', - 'name' => 'length', - 'value' => $element->length !== null ? Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($element->length) : '', - 'class' => 'text', - 'size' => 10, - 'unit' => Plugin::getInstance()->getSettings()->dimensionUnits, - 'disabled' => $static, - ]), ['id' => 'length', 'label' => Craft::t('commerce', 'Length')]) . - Cp::fieldHtml(Cp::textHtml([ - 'id' => 'width', - 'name' => 'width', - 'value' => $element->width !== null ? Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($element->width) : '', - 'class' => 'text', - 'size' => 10, - 'unit' => Plugin::getInstance()->getSettings()->dimensionUnits, - 'disabled' => $static, - ]), ['id' => 'width', 'label' => Craft::t('commerce', 'Width')]) . - Cp::fieldHtml(Cp::textHtml([ - 'id' => 'height', - 'name' => 'height', - 'value' => $element->height !== null ? Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($element->height) : '', - 'class' => 'text', - 'size' => 10, - 'unit' => Plugin::getInstance()->getSettings()->dimensionUnits, - 'disabled' => $static, - ]), ['id' => 'height', 'label' => Craft::t('commerce', 'Height')]) . - Html::endTag('div'); - } - - /** - * @inheritdoc - */ - protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string - { - return Craft::t('commerce', 'Dimensions'); - } -} diff --git a/src/fieldlayoutelements/PurchasableFreeShippingField.php b/src/fieldlayoutelements/PurchasableFreeShippingField.php deleted file mode 100644 index 3158fcd551..0000000000 --- a/src/fieldlayoutelements/PurchasableFreeShippingField.php +++ /dev/null @@ -1,69 +0,0 @@ - - * @since 5.0.0 - */ -class PurchasableFreeShippingField extends BaseNativeField -{ - /** - * @inheritdoc - */ - public bool $mandatory = true; - - /** - * @inheritdoc - */ - public string $attribute = 'freeShipping'; - - /** - * @inheritdoc - */ - public function __construct(array $config = []) - { - unset($config['required']); - parent::__construct($config); - } - - /** - * @inheritdoc - */ - public function inputHtml(ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Purchasable) { - throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); - } - - return Cp::lightswitchHtml([ - 'id' => 'free-shipping', - 'name' => 'freeShipping', - 'small' => true, - 'on' => $element->freeShipping, - 'disabled' => $static, - ]); - } - - /** - * @inheritdoc - */ - protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string - { - return Craft::t('commerce', 'Free Shipping'); - } -} diff --git a/src/fieldlayoutelements/PurchasablePriceField.php b/src/fieldlayoutelements/PurchasablePriceField.php deleted file mode 100755 index 9a987c23bb..0000000000 --- a/src/fieldlayoutelements/PurchasablePriceField.php +++ /dev/null @@ -1,241 +0,0 @@ - - * @since 5.0.0 - */ -class PurchasablePriceField extends BaseNativeField -{ - /** - * @inheritdoc - */ - public bool $mandatory = true; - - /** - * @inheritdoc - */ - public ?string $label = '__blank__'; - - /** - * @inheritdoc - */ - public string $attribute = 'price'; - - /** - * @inheritdoc - */ - public bool $required = true; - - /** - * @inheritdoc - */ - protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string - { - return Craft::t('commerce', 'Price'); - } - - /** - * @inheritdoc - */ - public function inputHtml(ElementInterface $element = null, bool $static = false): ?string - { - $view = Craft::$app->getView(); - $view->registerAssetBundle(HtmxAsset::class); - - if (!$element instanceof Purchasable) { - throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); - } - - $basePrice = $element->basePrice; - if (empty($element->getErrors('basePrice'))) { - if ($basePrice === null) { - $basePrice = 0; - } - - $basePrice = Craft::$app->getFormatter()->asDecimal($basePrice); - } - - $basePromotionalPrice = $element->basePromotionalPrice; - if (empty($element->getErrors('basePromotionalPrice')) && $basePromotionalPrice !== null) { - $basePromotionalPrice = Craft::$app->getFormatter()->asDecimal($basePromotionalPrice); - } - - $id = $view->namespaceInputId('commerce-purchasable-price-field'); - $priceNamespace = $view->namespaceInputName('basePrice'); - $promotionalPriceNamespace = $view->namespaceInputName('basePromotionalPrice'); - - /** @var CatalogPricingCondition $catalogPricingCondition */ - $catalogPricingCondition = Craft::$app->getConditions()->createCondition([ - 'class' => CatalogPricingCondition::class, - 'allPrices' => true, - ]); - - $purchasableConditionRule = Craft::$app->getConditions()->createConditionRule([ - 'class' => CatalogPricingPurchasableConditionRule::class, - 'elementIds' => [$element::class => [$element->id]], - ]); - $catalogPricingCondition->addConditionRule($purchasableConditionRule); - $conditionBuilderConfig = Json::encode($catalogPricingCondition->getConfig()); - - $view->registerAssetBundle(PurchasablePriceFieldAsset::class); - - $js = << { - new Craft.Commerce.PurchasablePriceField('$id', { - siteId: $element->siteId, - conditionBuilderConfig: $conditionBuilderConfig, - fieldNames: { - price: '$priceNamespace', - promotionalPrice: '$promotionalPriceNamespace', - } - }); -})(); -JS; - $view->registerJs($js, $view::POS_END); - - $canUseCatalogPricingRules = Plugin::getInstance()->getCatalogPricingRules()->canUseCatalogPricingRules(); - $toggleTitle = Craft::t('commerce', 'Show related sales'); - $toggleAttributes = ['class' => 'js-purchasable-toggle-container', 'style' => ['position' => 'relative']]; - $toggleContent = null; - - if ($canUseCatalogPricingRules) { - $toggleTitle = Craft::t('commerce', 'Show all prices'); - $toggleAttributes['data-init-prices'] = 'true'; - $toggleContent = PurchasableHelper::catalogPricingRulesTableByPurchasableId($element->id, $element->storeId) . - Html::beginTag('div', ['class' => 'flex']) . - // New catalog price button - Html::button(Craft::t('commerce', 'Add catalog price'), [ - 'class' => 'btn icon add js-cpr-slideout', - 'data-icon' => 'plus', - 'data-store-id' => $element->storeId, - 'data-store-handle' => $element->getStore()->handle, - 'data-purchasable-id' => $element->id, - ]) . - Cp::renderTemplate('commerce/prices/_status', [ - 'areCatalogPricingJobsRunning' => Plugin::getInstance()->getCatalogPricing()->areCatalogPricingJobsRunning(), - ]) . - Html::endTag('div'); - } else { - /** @var Sale[] $relatedSales */ - $relatedSales = Plugin::getInstance()->getSales()->getSalesRelatedToPurchasable($element); - - if (!empty($relatedSales)) { - $salesTags = []; - foreach ($relatedSales as $sale) { - $salesTags[] = Html::a($sale->name, $sale->getCpEditUrl()); - } - - $toggleContent = Html::tag('div', implode(', ', $salesTags)); - } - } - - $toggleContent = $static ? null : $toggleContent; - - $currency = $element->getStore()->getCurrency(); - - return Html::beginTag('div', [ - 'id' => 'commerce-purchasable-price-field', - 'class' => 'js-purchasable-price-field', - ]) . - Html::beginTag('div', ['class' => 'flex']) . - Cp::fieldHtml(Currency::moneyInputHtml($basePrice, [ - 'id' => 'base-price', - 'name' => 'basePrice', - 'currency' => $currency->getCode(), - 'currencyLabel' => $currency->getCode(), - 'required' => true, - 'errors' => $element->getErrors('basePrice'), - 'disabled' => $static, - 'size' => 12, - ]), [ - 'id' => 'base-price', - 'required' => true, - 'label' => Craft::t('commerce', 'Price'), - ]) . - - // Don't show base promotional price field if the system is still using sales - ($canUseCatalogPricingRules ? - Cp::fieldHtml(Currency::moneyInputHtml($basePromotionalPrice, [ - 'id' => 'base-promotional-price', - 'name' => 'basePromotionalPrice', - 'currency' => $currency->getCode(), - 'currencyLabel' => $currency->getCode(), - 'errors' => $element->getErrors('basePromotionalPrice'), - 'disabled' => $static, - 'size' => 12, - ]), [ - 'id' => 'promotional-price', - 'label' => Craft::t('commerce', 'Promotional Price'), - ]) : '') . - - Html::endTag('div') . - - // Hide the prices table if the element is a draft - ($toggleContent ? Html::beginTag('div', ['class' => $element->getIsDraft() ? 'hidden' : '' ]) . - Html::tag('div', - Html::tag('a', $toggleTitle, ['class' => 'fieldtoggle', 'data-target' => 'purchasable-toggle']) . - Html::beginTag('div', $toggleAttributes) . - Html::tag( - 'div', - // Prices table - $toggleContent, - [ - 'id' => 'purchasable-toggle', - 'class' => 'hidden', - ] - ) . - Html::tag('div', '', [ - 'class' => 'js-purchasable-toggle-loading hidden', - 'style' => [ - 'position' => 'absolute', - 'top' => 0, - 'left' => 0, - 'width' => '100%', - 'height' => '100%', - 'background-color' => 'rgba(255, 255, 255, 0.5)', - ], - ]) . - Html::tag('div', Html::tag('span', '', ['class' => 'spinner']), [ - 'class' => 'js-purchasable-toggle-loading flex hidden', - 'style' => [ - 'position' => 'absolute', - 'top' => 0, - 'left' => 0, - 'width' => '100%', - 'height' => '100%', - 'align-items' => 'center', - 'justify-content' => 'center', - ], - ]) . - Html::endTag('div') - ) . - Html::endTag('div') : '') . - Html::endTag('div'); - } -} diff --git a/src/fieldlayoutelements/PurchasablePromotableField.php b/src/fieldlayoutelements/PurchasablePromotableField.php deleted file mode 100644 index f3789bb2f8..0000000000 --- a/src/fieldlayoutelements/PurchasablePromotableField.php +++ /dev/null @@ -1,86 +0,0 @@ - - * @since 5.0.0 - */ -class PurchasablePromotableField extends BaseNativeField -{ - /** - * @inheritdoc - */ - public bool $mandatory = true; - - /** - * @inheritdoc - */ - public string $attribute = 'promotable'; - - /** - * @var bool Whether the field should be checked by default when creating a new purchasable. - */ - public bool $defaultPromotable = false; - - /** - * @inheritdoc - */ - public function __construct(array $config = []) - { - unset($config['required']); - parent::__construct($config); - } - - /** - * @inheritdoc - */ - public function inputHtml(ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Purchasable) { - throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); - } - - return Cp::lightswitchHtml([ - 'id' => 'promotable', - 'name' => 'promotable', - 'small' => true, - 'on' => $element->getIsFresh() ? $this->defaultPromotable : $element->promotable, - 'disabled' => $static, - ]); - } - - public function settingsHtml(): string - { - return parent::settingsHtml() . Cp::lightswitchHtml( - [ - 'id' => 'defaultPromotable', - 'name' => 'defaultPromotable', - 'label' => Craft::t('app', 'Default Value'), - 'on' => $this->defaultPromotable, - ] - ); - } - - /** - * @inheritdoc - */ - protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string - { - return Craft::t('commerce', 'Promotable'); - } -} diff --git a/src/fieldlayoutelements/PurchasableSkuField.php b/src/fieldlayoutelements/PurchasableSkuField.php deleted file mode 100644 index 3e44319074..0000000000 --- a/src/fieldlayoutelements/PurchasableSkuField.php +++ /dev/null @@ -1,68 +0,0 @@ - - * @since 5.0.0 - */ -class PurchasableSkuField extends BaseNativeField -{ - /** - * @inheritdoc - */ - public bool $mandatory = true; - - /** - * @inheritdoc - */ - public bool $required = true; - - /** - * @inheritdoc - */ - public string $attribute = 'sku'; - - /** - * @inheritdoc - */ - public function inputHtml(ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Purchasable) { - throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); - } - - $variantWithSkuFormula = $element instanceof Variant && $element->getOwner()->getType()->skuFormat !== null; - if ($variantWithSkuFormula && $element->getIsDraft() && $this->getScenario() === Element::SCENARIO_DEFAULT) { - return null; - } - - return PurchasableHelper::skuInputHtml($element->getSkuAsText(), [ - 'disabled' => $static, - ]); - } - - /** - * @inheritdoc - */ - protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string - { - return Craft::t('commerce', 'SKU'); - } -} diff --git a/src/fieldlayoutelements/PurchasableStockField.php b/src/fieldlayoutelements/PurchasableStockField.php deleted file mode 100644 index acf42ed71b..0000000000 --- a/src/fieldlayoutelements/PurchasableStockField.php +++ /dev/null @@ -1,262 +0,0 @@ - - * @since 5.0.0 - */ -class PurchasableStockField extends BaseNativeField -{ - /** - * @inheritdoc - */ - public bool $mandatory = true; - - /** - * @inheritdoc - */ - public string $attribute = 'stock'; - - /** - * @var bool Whether inventory should be tracked by default when creating a new purchasable. - */ - public bool $defaultInventoryTracked = false; - - /** - * @var bool Whether out of stock purchases should be allowed by default when creating a new purchasable. - */ - public bool $defaultAllowOutOfStockPurchases = false; - - /** - * @inheritdoc - */ - public function __construct(array $config = []) - { - unset($config['required']); - parent::__construct($config); - } - - /** - * @inheritdoc - */ - public function inputHtml(ElementInterface $element = null, bool $static = false): ?string - { - // If this is a revision get the canonical element to show the stock for. - // @TODO Re-evaluate swapping in the canonical element once revisions support tracking inventory independently - if ($element->getIsRevision()) { - $element = $element->getCanonical(); - } - - $view = Craft::$app->getView(); - $view->registerAssetBundle(InventoryAsset::class); - - /** @var Purchasable|null $element */ - if (!$element instanceof Purchasable) { - throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); - } - - $view = Craft::$app->getView(); - - $totalStock = $element->getStock(); - $inventoryLevels = Plugin::getInstance()->getInventory()->getInventoryLevelsForPurchasable($element); - - $availableStockLabel = Craft::t('commerce', '{total} saleable across {locationCount} location(s)', [ - 'total' => $totalStock, - 'locationCount' => $inventoryLevels->count(), - ]); - - $editInventoryItemId = sprintf('action-edit-inventory-item-%s', mt_rand()); - $view->registerJsWithVars(fn($id, $settings) => << { - e.preventDefault(); - const slideout = new Craft.CpScreenSlideout('commerce/inventory/item-edit', $settings); -}); -JS, [ - $view->namespaceInputId($editInventoryItemId), - ['params' => ['inventoryItemId' => $element->getInventoryItem()->id]], - ]); - - $inventoryLevelTableRows = ''; - /** @var InventoryLevel $inventoryLevel */ - foreach ($inventoryLevels as $inventoryLevel) { - - // Update the quantity button - $editUpdateQuantityInventoryItemId = sprintf('action-update-qty-%s', mt_rand()); - $updatedValueId = sprintf('updated-value-%s', mt_rand()); - $settings = [ - 'params' => [ - 'inventoryLocationId' => $inventoryLevel->getInventoryLocation()->id, - 'ids[]' => [$element->inventoryItemId], - 'type' => 'available', - ], - ]; - - $view->registerJsWithVars(fn($id, $updatedValueId, $settings) => << { - e.preventDefault(); - const slideout = new Craft.Commerce.UpdateInventoryLevelModal($settings); - slideout.on('submit', (e) => { - if(e.response.data.updatedItems.length > 0 && e.response.data.updatedItems[0].availableTotal !== undefined) { - $('#' + $updatedValueId).html(e.response.data.updatedItems[0].availableTotal); - } - }); -}); -JS, [ - $view->namespaceInputId($editUpdateQuantityInventoryItemId), - $view->namespaceInputId($updatedValueId), - $settings, - ]); - - $inventoryLevelTableRows .= Html::beginTag('tr') . - Html::beginTag('td') . - Html::encode($inventoryLevel->getInventoryLocation()->getUiLabel()) . - Html::endTag('td') . - Html::beginTag('td') . - Html::beginTag('div', ['class' => 'flex']) . - Html::tag('div', (string)$inventoryLevel->availableTotal, [ - 'id' => $updatedValueId, - ]) . - (!$static ? Html::tag('div', Html::button(Craft::t('commerce', ''), - [ - 'class' => 'btn menubtn action-btn', - 'id' => $editUpdateQuantityInventoryItemId, - ])) : '') . - Html::endTag('div') . - Html::endTag('td') . - (!$static ? Html::beginTag('td') . - (Craft::$app->getUser()->checkPermission('commerce-manageInventoryStockLevels') ? - Html::a( - Craft::t('commerce', 'Manage'), - UrlHelper::cpUrl('commerce/inventory/levels/' . $inventoryLevel->getInventoryLocation()->handle, [ - 'inventoryItemId' => $inventoryLevel->getInventoryItem()->id, - ]), - [ - 'target' => '_blank', - 'class' => 'btn small', - 'id' => $editUpdateQuantityInventoryItemId, - 'aria-label' => Craft::t('app', 'Open in a new tab'), - 'data-icon' => 'external', - ] - ) : '') : '') . - Html::endTag('td') . - Html::endTag('tr'); - } - - $inventoryLevelsTable = Html::beginTag('table', ['class' => 'data fullwidth', 'style' => 'margin-top:5px;']) . - Html::beginTag('thead') . - Html::beginTag('tr') . - Html::beginTag('th') . - Craft::t('commerce', 'Location') . - Html::endTag('th') . - Html::beginTag('th') . - Craft::t('commerce', 'Available') . - Html::endTag('th') . - - - (!$static ? Html::beginTag('th') . - Craft::t('commerce', 'Manage') . - Html::endTag('th') : '') . - - - Html::endTag('tr') . - Html::endTag('thead') . - - Html::beginTag('tbody') . - $inventoryLevelTableRows . - Html::beginTag('tr') . - Html::beginTag('td', ['colspan' => '2']) . - $availableStockLabel . - Html::endTag('td') . - - (!$static ? Html::beginTag('td') . - Html::a( - Craft::t('commerce', 'Edit'), - '#', - [ - 'class' => 'btn small', - 'id' => $editInventoryItemId, - 'aria-label' => Craft::t('app', 'Edit Inventory Item'), - 'data-icon' => 'edit', - ] - ) . - Html::endTag('td') : '') . - - Html::endTag('tr') . - Html::endTag('tbody') . - Html::endTag('table'); - - $inventoryItemTrackedId = sprintf('store-inventory-item-tracked-%s', mt_rand()); - $storeInventoryTrackedLightswitchConfig = [ - 'id' => 'store-inventory-item-tracked', - 'name' => 'inventoryTracked', - 'small' => true, - 'on' => $element->getIsFresh() ? $this->defaultInventoryTracked : $element->inventoryTracked, - 'toggle' => $inventoryItemTrackedId, - 'disabled' => $static, - ]; - - $storeAllowOutOfStockPurchasesLightswitchConfig = [ - 'label' => Craft::t('commerce', 'Allow out of stock purchases'), - 'id' => 'store-backorder-allowed', - 'name' => 'allowOutOfStockPurchases', - 'small' => true, - 'on' => $element->getIsFresh() ? $this->defaultAllowOutOfStockPurchases : $element->getIsOutOfStockPurchasingAllowed(), - 'disabled' => $static, - ]; - - - return Html::beginTag('div') . - Cp::lightswitchHtml($storeInventoryTrackedLightswitchConfig) . - Html::beginTag('div', ['id' => $inventoryItemTrackedId, 'class' => 'hidden']) . - $inventoryLevelsTable . - Cp::lightswitchFieldHtml($storeAllowOutOfStockPurchasesLightswitchConfig) . - Html::endTag('div') . - Html::endTag('div'); - } - - public function settingsHtml(): string - { - $lightSwitches = Cp::lightswitchHtml([ - 'id' => 'defaultInventoryTracked', - 'name' => 'defaultInventoryTracked', - 'label' => Craft::t('commerce', 'Track Inventory'), - 'on' => $this->defaultInventoryTracked, - ]) . - Cp::lightswitchHtml([ - 'id' => 'defaultAllowOutOfStockPurchases', - 'name' => 'defaultAllowOutOfStockPurchases', - 'label' => Craft::t('commerce', 'Allow out of stock purchases'), - 'on' => $this->defaultAllowOutOfStockPurchases, - ]); - - return parent::settingsHtml() . Cp::fieldHtml($lightSwitches, ['label' => Craft::t('app', 'Default Value')]); - } - - /** - * @inheritdoc - */ - protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string - { - return Craft::t('commerce', 'Track Inventory'); - } -} diff --git a/src/fieldlayoutelements/PurchasableWeightField.php b/src/fieldlayoutelements/PurchasableWeightField.php deleted file mode 100755 index 92429e9698..0000000000 --- a/src/fieldlayoutelements/PurchasableWeightField.php +++ /dev/null @@ -1,85 +0,0 @@ - - * @since 5.0.0 - */ -class PurchasableWeightField extends BaseNativeField -{ - /** - * @inheritdoc - */ - public bool $mandatory = true; - - /** - * @inheritdoc - */ - public string $attribute = 'weight'; - - /** - * @inheritdoc - */ - protected function showLabel(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function showInForm(?ElementInterface $element = null): bool - { - if ($element instanceof Variant && !$element->getOwner()->getType()->hasDimensions) { - return false; - } - - return parent::showInForm($element); - } - - /** - * @inheritdoc - */ - public function inputHtml(ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Purchasable) { - throw new InvalidArgumentException(static::class . ' can only be used in purchasable field layouts.'); - } - - return Cp::textHtml([ - 'id' => 'weight', - 'name' => 'weight', - 'value' => $element->weight !== null ? Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($element->weight) : '', - 'class' => 'text', - 'size' => 10, - 'unit' => Plugin::getInstance()->getSettings()->weightUnits, - 'placeholder' => Craft::t('commerce', 'Weight'), - 'disabled' => $static, - ]); - } - - /** - * @inheritdoc - */ - protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string - { - return Craft::t('commerce', 'Weight'); - } -} diff --git a/src/fieldlayoutelements/TransferManagementField.php b/src/fieldlayoutelements/TransferManagementField.php deleted file mode 100644 index 37866dce1a..0000000000 --- a/src/fieldlayoutelements/TransferManagementField.php +++ /dev/null @@ -1,306 +0,0 @@ - - * @since 5.0.0 - */ -class TransferManagementField extends BaseNativeField -{ - /** - * @inheritdoc - */ - public bool $mandatory = true; - - /** - * @inheritdoc - */ - public ?string $label = '__blank__'; - - /** - * @inheritdoc - */ - public bool $required = true; - - /** - * @inheritdoc - */ - public string $attribute = 'transfer-management'; - - /** - * @inheritdoc - */ - public function inputHtml(ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Transfer) { - throw new InvalidArgumentException('TransferLocationsField can only be used in transfer field layouts.'); - } - - if ($static) { - return self::renderStaticFieldHtml($element); - } else { - return self::renderFieldHtml($element); - } - } - - public static function renderStaticFieldHtml(Transfer $element, bool $static = false): string - { - $html = ''; - $currentUser = Craft::$app->getUser()->getIdentity(); - - $origin = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($element->originLocationId); - $destination = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($element->destinationLocationId); - - $html .= Html::tag('div', - Html::tag('div', - Cp::elementCardHtml($origin->getAddress()), ['class' => 'flex-grow']) . - Html::tag('div', - Cp::elementCardHtml($destination->getAddress()), ['class' => 'flex-grow']) - , ['class' => 'flex']); - - - $tableRows = ''; - - foreach ($element->getDetails() as $detail) { - $purchasable = $detail->getInventoryItem()?->getPurchasable(Cp::requestedSite()->id); - $tableRows .= Html::tag('tr', - Html::tag('td', ($purchasable ? Cp::chipHtml($purchasable, ['showActionMenu' => !$purchasable->getIsDraft() && $purchasable->canSave($currentUser)]) : Html::tag('span', $detail->inventoryItemDescription))) . - Html::tag('td', (string)$detail->quantityRejected, ['class' => 'rightalign']) . - Html::tag('td', (string)$detail->quantityAccepted, ['class' => 'rightalign']) . - Html::tag('td', $detail->getReceived() . '/' . $detail->quantity, ['class' => 'rightalign']) - ); - }; - - $totalRow = Html::tag('tr', - Html::tag('td') . - Html::tag('td', '') . - Html::tag('td', '') . - Html::tag('td', Craft::t('commerce', 'Total ') . ' ' . $element->getTotalReceived() . '/' . $element->getTotalQuantity(), ['class' => 'rightalign']) - ); - - $table = Html::tag('table', - Html::tag('thead', - Html::tag('tr', - Html::tag('th', Craft::t('commerce', 'Inventory Item')) . - Html::tag('th', Craft::t('commerce', 'Rejected'), ['class' => 'rightalign', 'style' => "width: 20%;"]) . - Html::tag('th', Craft::t('commerce', 'Accepted'), ['class' => 'rightalign', 'style' => "width: 20%;"]) . - Html::tag('th', Craft::t('commerce', 'Total'), ['class' => 'rightalign', 'style' => "width: 20%;"]) - ) - ) . - Html::tag('tbody', $tableRows . $totalRow) - , ['class' => 'data fullwidth'] - ); - - - $html .= Html::tag('hr') . $table; - - return $html; - } - - public static function renderFieldHtml(Transfer $element): string - { - // Only draft is editable - if (!$element->isTransferDraft()) { - return self::renderStaticFieldHtml($element); - } - - $currentUser = Craft::$app->getUser()->getIdentity(); - $view = Craft::$app->getView(); - $inventoryLocationOptions = Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocationsAsList(false); - $isHtmxRequest = Craft::$app->getRequest()->getHeaders()->has('HX-Request'); - - $allLocations = Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocations(); - $defaultFirstLocation = $allLocations->first(); - $defaultSecondLocation = $allLocations->skip(1)->first(); - - Craft::$app->getView()->registerAssetBundle(TransfersAsset::class); - - $namespacedId = $view->namespaceInputId('transfer-management'); - - $html = Html::beginTag('div', [ - 'id' => $namespacedId, - 'hx' => [ - 'ext' => 'craft-cp', - 'target' => '#' . $namespacedId, - 'include' => '#' . $namespacedId, - 'vals' => [ - 'action' => 'commerce/transfers/render-management', - 'transferId' => $element->id, - ], - ], - ]); - - $originLocationSelectFieldConfig = [ - 'label' => Craft::t('commerce', 'Origin'), - 'name' => 'originLocationId', // 'name' => 'fields[locations][originLocationId] - 'options' => $inventoryLocationOptions, - 'errors' => $element->getErrors('originLocationId'), - 'value' => $element->originLocationId ?? $defaultFirstLocation->id, - 'inputAttributes' => [ - 'hx' => [ - 'post' => '', - 'trigger' => 'change', - ], - ], - ]; - - $destinationLocationSelectFieldConfig = [ - 'label' => Craft::t('commerce', 'Destination'), - 'name' => 'destinationLocationId', - 'errors' => $element->getErrors('destinationLocationId'), - 'options' => $inventoryLocationOptions, - 'value' => $element->destinationLocationId ?? $defaultSecondLocation->id, - 'inputAttributes' => [ - 'hx' => [ - 'post' => '', - 'trigger' => 'change', - ], - ], - ]; - - $destinationLocationSelectField = Html::tag('div', Cp::selectFieldHtml($destinationLocationSelectFieldConfig), ['class' => 'flex-grow']); - $originLocationSelectField = Html::tag('div', Cp::selectFieldHtml($originLocationSelectFieldConfig), ['class' => 'flex-grow']); - - $html .= Html::tag('div', $originLocationSelectField . $destinationLocationSelectField, ['class' => 'flex']); - - $tableRows = ''; - $loop = 1; - - foreach ($element->getDetails() as $detail) { - $key = $detail->uid ?? StringHelper::UUID(); - $purchasable = $detail->getInventoryItem()?->getPurchasable(Cp::requestedSite()->id); - $tableRows .= Html::tag('tr', - Html::hiddenInput('details[' . $key . '][id]', (string)$detail->id) . - Html::hiddenInput('details[' . $key . '][uid]', $detail->uid) . - Html::hiddenInput('details[' . $key . '][inventoryItemId]', (string)$detail->inventoryItemId) . - Html::tag('td', ($purchasable ? Cp::chipHtml($purchasable, ['showActionMenu' => !$purchasable->getIsDraft() && $purchasable->canSave($currentUser)]) : Html::tag('span', $detail->inventoryItemDescription))) . - Html::tag('td', Cp::textHtml([ - 'type' => 'number', - 'name' => 'details[' . $key . '][quantity]', - 'value' => (string)$detail->quantity, - 'class' => 'text fullwidth', - 'errors' => $element->getErrors('details.' . $key . '.quantity'), - 'inputAttributes' => [ - 'hx' => [ - 'post' => '', - ], - ], - ])) . - Html::tag('td', Html::a('', '#', [ - 'hx' => [ - 'post' => '', - 'trigger' => 'click', - 'vals' => [ - 'removeInventoryItemUid' => $key, - ], - ], - 'class' => 'delete icon', - 'title' => Craft::t('app', 'Delete'), - 'aria-label' => Craft::t('app', 'Delete'), - 'role' => 'button', - ]), ['class' => 'thin']) - ); - }; - - // sum row - $tableRows .= Html::tag('tr', - Html::tag('td') . - Html::tag('td', $element->sumDetailsQuanity() . ' ' . Craft::t('commerce', 'Total')) . - Html::tag('td',) - ); - - $table = Html::tag('table', - Html::tag('thead', - Html::tag('tr', - Html::tag('th', Craft::t('commerce', 'Inventory Item')) . - Html::tag('th', Craft::t('commerce', 'Quantity'), ['style' => "width: 20%;"]) . - Html::tag('th', '') - ) - ) . - Html::tag('tbody', $tableRows) - , ['class' => 'data fullwidth'] - ); - - $html .= Cp::fieldHtml($table, [ - 'label' => Craft::t('commerce', 'Transfer Items'), - ]); - - if ($element->originLocationId) { - $sourceLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($element->originLocationId); - } else { - $sourceLocation = $defaultFirstLocation; - } - - $inventoryLevels = Plugin::getInstance()->getInventory()->getInventoryLocationLevels($sourceLocation)->sortByDesc([ - fn(InventoryLevel $level) => $level->onHandTotal, - ]); - $inventoryItemOptions = []; - - - /** @var InventoryLevel $level */ - foreach ($inventoryLevels as $level) { - $inventoryItemOptions[] = [ - 'label' => $level->getInventoryItem()->getSku() . ' (' . ($level->onHandTotal ? $level->onHandTotal . ' ' . Craft::t('commerce', 'on hand') : Craft::t('commerce', 'None on hand')) . ')', - 'value' => $level->getInventoryItem()->id, - 'disabled' => !($level->onHandTotal > 0), - ]; - } - - Craft::$app->getView()->startJsBuffer(); - - $addToItems = Html::tag('div', - - Cp::selectizeHtml([ - 'name' => 'newInventoryItemId', - 'options' => $inventoryItemOptions, - 'value' => '', - 'placeholder' => Craft::t('commerce', 'Select an item'), - ]) . - - Html::button(Craft::t('commerce', 'Add an item'), [ - 'class' => 'btn secondary', - 'hx' => [ - 'post' => '', - 'target' => '#' . $namespacedId, - 'trigger' => 'click', - 'vals' => [ - 'addItem' => true, - ], - ], - ]) - , ['class' => 'flex']); - - $html .= $addToItems; - $fieldJs = (string)$view->clearJsBuffer(false); - - if ($fieldJs) { - if ($isHtmxRequest) { - $html .= html::tag('script', $fieldJs, ['type' => 'text/javascript']); - } else { - $view->registerJs($fieldJs); - } - } - - return $html . Html::endTag('div'); - } -} diff --git a/src/fieldlayoutelements/UserAddressSettings.php b/src/fieldlayoutelements/UserAddressSettings.php deleted file mode 100644 index c234d43e59..0000000000 --- a/src/fieldlayoutelements/UserAddressSettings.php +++ /dev/null @@ -1,92 +0,0 @@ - - * @since 4.0.0 - */ -class UserAddressSettings extends BaseField -{ - /** - * @inheritdoc - */ - public function attribute(): string - { - return 'commerceSettings'; - } - - /** - * @inheritdoc - */ - public function mandatory(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function hasCustomWidth(): bool - { - return false; - } - - protected function useFieldset(): bool - { - return true; - } - - /** - * @inheritdoc - */ - protected function defaultLabel(ElementInterface $element = null, bool $static = false): ?string - { - return Craft::t('commerce', 'Commerce Settings'); - } - - /** - * @inheritdoc - */ - protected function inputHtml(?ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Address) { - throw new InvalidArgumentException('UserAddressSettings can only be used in the address field layout.'); - } - - /** @var Address|CustomerAddressBehavior $element */ - $owner = $element->getOwner(); - - if (!$owner instanceof User) { - return null; - } - - return - Cp::lightswitchFieldHtml([ - 'fieldLabel' => Craft::t('commerce', 'Use as the primary billing address'), - 'name' => 'isPrimaryBilling', - 'on' => $element->getIsPrimaryBilling(), - ]) . - Cp::lightswitchFieldHtml([ - 'fieldLabel' => Craft::t('commerce', 'Use as the primary shipping address'), - 'name' => 'isPrimaryShipping', - 'on' => $element->getIsPrimaryShipping(), - ]); - } -} diff --git a/src/fieldlayoutelements/VariantTitleField.php b/src/fieldlayoutelements/VariantTitleField.php deleted file mode 100644 index 2ca44fa4ab..0000000000 --- a/src/fieldlayoutelements/VariantTitleField.php +++ /dev/null @@ -1,77 +0,0 @@ - - * @since 3.2.0 - */ -class VariantTitleField extends TitleField -{ - /** - * @inheritdoc - */ - protected function selectorInnerHtml(): string - { - return - Html::tag('span', '', [ - 'class' => ['fld-variant-title-field-icon', 'fld-field-hidden', 'hidden'], - ]) . - parent::selectorInnerHtml(); - } - - /** - * @inheritdoc - */ - protected function translatable(?ElementInterface $element = null, bool $static = false): bool - { - if (!$element instanceof Variant) { - throw new \InvalidArgumentException(sprintf('%s can only be used in variant field layouts.', self::class)); - } - - return $element->getOwner()->getType()->variantTitleTranslationMethod !== Field::TRANSLATION_METHOD_NONE; - } - - /** - * @inheritdoc - */ - protected function translationDescription(?ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Variant) { - throw new \InvalidArgumentException(sprintf('%s can only be used in variant field layouts.', self::class)); - } - - return ElementHelper::translationDescription($element->getOwner()->getType()->variantTitleTranslationMethod); - } - - /** - * @inheritdoc - */ - public function inputHtml(ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Variant) { - throw new InvalidArgumentException('VariantTitleField can only be used in variant field layouts.'); - } - - if (!$element->getOwner()->getType()->hasVariantTitleField) { - return null; - } - - return parent::inputHtml($element, $static); - } -} diff --git a/src/fieldlayoutelements/VariantsField.php b/src/fieldlayoutelements/VariantsField.php deleted file mode 100644 index ceef5f02d6..0000000000 --- a/src/fieldlayoutelements/VariantsField.php +++ /dev/null @@ -1,74 +0,0 @@ - - * @since 3.2.0 - */ -class VariantsField extends BaseNativeField -{ - /** - * @inheritdoc - */ - public bool $mandatory = true; - - /** - * @inheritdoc - */ - public string $attribute = 'variants'; - - /** - * @inheritdoc - */ - public function hasCustomWidth(): bool - { - return false; - } - - /** - * @inheritdoc - */ - protected function defaultLabel(ElementInterface $element = null, bool $static = false): ?string - { - return Craft::t('commerce', 'Variants'); - } - - /** - * @inheritdoc - */ - protected function inputHtml(ElementInterface $element = null, bool $static = false): ?string - { - if (!$element instanceof Product) { - throw new InvalidArgumentException('ProductTitleField can only be used in product field layouts.'); - } - - Craft::$app->getView()->registerDeltaName($this->attribute()); - - $maxVariants = $element->getType()->maxVariants; - - return $element->getVariantManager()->getIndexHtml($element, [ - 'canCreate' => !$static, - 'canPaste' => !$static, - 'minElements' => 0, - 'maxElements' => $maxVariants ?? null, - 'allowedViewModes' => [ElementIndexViewMode::Cards, ElementIndexViewMode::Table], - 'sortable' => !$static, - 'fieldLayouts' => [$element->getType()->getVariantFieldLayout()], - ]); - } -} diff --git a/src/fields/Products.php b/src/fields/Products.php deleted file mode 100644 index 3bc9c204a0..0000000000 --- a/src/fields/Products.php +++ /dev/null @@ -1,114 +0,0 @@ - - * @since 2.0 - * - * @property-read array $contentGqlType - */ -class Products extends BaseRelationField -{ - /** - * @inheritdoc - */ - protected ?string $inputJsClass = 'Craft.Commerce.ProductSelectInput'; - - public function __construct(array $config = []) - { - // Never needed and allows us to instantiate the field while ignoring old setting until the Product field migration has run. - unset($config['targetLocale']); - parent::__construct($config); - } - - /** - * @inheritdoc - */ - public static function icon(): string - { - return 'tag'; - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Commerce Products'); - } - - /** - * @inheritdoc - */ - public static function defaultSelectionLabel(): string - { - return Craft::t('commerce', 'Add a product'); - } - - /** - * @inheritdoc - */ - protected function inputTemplateVariables(array|ElementQueryInterface $value = null, ?ElementInterface $element = null): array - { - Craft::$app->getView()->registerAssetBundle(CommerceCpAsset::class); - Craft::$app->getView()->registerAssetBundle(ProductIndexAsset::class); - - $variables = parent::inputTemplateVariables($value, $element); - - $sources = $this->getInputSources($element); - if (is_array($sources) && preg_match('/^productType:(.+)$/', reset($sources), $matches)) { - $productType = Plugin::getInstance()->getProductTypes()->getProductTypeByUid($matches[1]); - if ($productType) { - $variables['jsSettings']['productTypeId'] = (int)$productType->id; - } - } - - return $variables; - } - - /** - * @inheritdoc - * @since 3.1.4 - */ - public function getContentGqlType(): array|Type - { - return [ - 'name' => $this->handle, - 'type' => Type::listOf(ProductInterface::getType()), - 'args' => ProductArguments::getArguments(), - 'resolve' => ProductResolver::class . '::resolve', - 'complexity' => GqlHelper::relatedArgumentComplexity(GqlService::GRAPHQL_COMPLEXITY_EAGER_LOAD), - ]; - } - - /** - * @inheritdoc - */ - public static function elementType(): string - { - return Product::class; - } -} diff --git a/src/fields/Variants.php b/src/fields/Variants.php deleted file mode 100644 index 004536430c..0000000000 --- a/src/fields/Variants.php +++ /dev/null @@ -1,76 +0,0 @@ - - * @since 2.0 - * - * @property-read array $contentGqlType - */ -class Variants extends BaseRelationField -{ - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Commerce Variants'); - } - - /** - * @inheritdoc - */ - public static function icon(): string - { - return 'tags'; - } - - /** - * @inheritdoc - */ - public static function defaultSelectionLabel(): string - { - return Craft::t('commerce', 'Add a variant'); - } - - /** - * @inheritdoc - * @since 3.1.4 - */ - public function getContentGqlType(): array|Type - { - return [ - 'name' => $this->handle, - 'type' => Type::listOf(VariantInterface::getType()), - 'args' => VariantArguments::getArguments(), - 'resolve' => VariantResolver::class . '::resolve', - 'complexity' => GqlHelper::relatedArgumentComplexity(GqlService::GRAPHQL_COMPLEXITY_EAGER_LOAD), - ]; - } - - /** - * @inheritdoc - */ - public static function elementType(): string - { - return Variant::class; - } -} diff --git a/src/gateways/Dummy.php b/src/gateways/Dummy.php deleted file mode 100644 index 8add1f0790..0000000000 --- a/src/gateways/Dummy.php +++ /dev/null @@ -1,397 +0,0 @@ - - * @since 2.0 - */ -class Dummy extends SubscriptionGateway -{ - /** - * @inheritdoc - */ - public function getPaymentFormHtml(array $params): ?string - { - $paymentFormModel = $this->getPaymentFormModel(); - - if (Craft::$app->getConfig()->general->devMode) { - $paymentFormModel->firstName = 'Jenny'; - $paymentFormModel->lastName = 'Andrews'; - $paymentFormModel->number = '4242424242424242'; - $paymentFormModel->expiry = '01/' . date('Y', strtotime('+1 year')); - $paymentFormModel->cvv = '123'; - } - - $defaults = [ - 'paymentForm' => $paymentFormModel, - ]; - - $params = array_merge($defaults, $params); - - $view = Craft::$app->getView(); - $previousMode = $view->getTemplateMode(); - $view->setTemplateMode(View::TEMPLATE_MODE_CP); - $html = Craft::$app->getView()->renderTemplate('commerce/_components/gateways/_creditCardFields', $params); - $view->setTemplateMode($previousMode); - - return $html; - } - - /** - * @inheritdoc - */ - public function getPaymentFormModel(): DummyPaymentForm - { - return new DummyPaymentForm(); - } - - /** - * @inheritdoc - */ - public function authorize(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface - { - if (!$form instanceof CreditCardPaymentForm) { - throw new InvalidArgumentException(sprintf('%s only accepts %s objects passed to $form.', __METHOD__, CreditCardPaymentForm::class)); - } - - return new DummyRequestResponse($form); - } - - /** - * @inheritdoc - */ - public function capture(Transaction $transaction, string $reference): RequestResponseInterface - { - return new DummyRequestResponse(); - } - - /** - * @inheritdoc - */ - public function completeAuthorize(Transaction $transaction): RequestResponseInterface - { - return new DummyRequestResponse(); - } - - /** - * @inheritdoc - */ - public function completePurchase(Transaction $transaction): RequestResponseInterface - { - return new DummyRequestResponse(); - } - - /** - * @inheritdoc - */ - public function createPaymentSource(BasePaymentForm $sourceData, int $customerId): PaymentSource - { - /** @var CreditCardPaymentForm $sourceData */ - - $paymentSource = new PaymentSource(); - $paymentSource->customerId = $customerId; - $paymentSource->gatewayId = $this->id; - $paymentSource->token = StringHelper::randomString(); - $paymentSource->response = ''; - $paymentSource->description = 'Card ending with ' . StringHelper::last($sourceData->number, 4); - - return $paymentSource; - } - - /** - * @inheritdoc - */ - public function deletePaymentSource(string $token): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function purchase(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface - { - if (!$form instanceof CreditCardPaymentForm) { - throw new InvalidArgumentException(sprintf('%s only accepts %s objects passed to $form.', __METHOD__, CreditCardPaymentForm::class)); - } - - return new DummyRequestResponse($form); - } - - /** - * @inheritdoc - */ - public function processWebHook(): WebResponse - { - throw new NotSupportedException(self::class . ' does not support processWebhook()'); - } - - /** - * @inheritdoc - */ - public function refund(Transaction $transaction): RequestResponseInterface - { - $form = new DummyPaymentForm(); - - if ($transaction->note != 'fail') { - $form->number = '4242424242424242'; - } else { - $form->number = '378282246310005'; - } - - return new DummyRequestResponse($form); - } - - /** - * @inheritdoc - */ - public function supportsAuthorize(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsCapture(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsCompleteAuthorize(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsCompletePurchase(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsPaymentSources(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsPurchase(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsRefund(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsPartialRefund(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsWebhooks(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function getCancelSubscriptionFormHtml(Subscription $subscription): string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getCancelSubscriptionFormModel(): CancelSubscriptionForm - { - return new CancelSubscriptionForm(); - } - - /** - * @inheritdoc - */ - public function getPlanSettingsHtml(array $params = []): ?string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getPlanModel(): Plan - { - return new DummyPlan(); - } - - /** - * @inheritdoc - */ - public function getSubscriptionFormModel(): SubscriptionForm - { - return new SubscriptionForm(); - } - - /** - * @inheritdoc - */ - public function getSwitchPlansFormModel(): SwitchPlansForm - { - return new SwitchPlansForm(); - } - - /** - * @inheritdoc - */ - public function cancelSubscription(Subscription $subscription, CancelSubscriptionForm $parameters): SubscriptionResponseInterface - { - $response = new DummySubscriptionResponse(); - $response->setIsCanceled(true); - return $response; - } - - /** - * @inheritdoc - */ - public function getNextPaymentAmount(Subscription $subscription): string - { - return '-'; - } - - /** - * @inheritdoc - */ - public function getSubscriptionPayments(Subscription $subscription): array - { - return []; - } - - /** - * @inheritdoc - */ - public function getSubscriptionPlanByReference(string $reference): string - { - return 'dummy.plan'; - } - - /** - * @inheritdoc - */ - public function getSubscriptionPlans(): array - { - return []; - } - - /** - * @inheritdoc - */ - public function subscribe(User $user, Plan $plan, SubscriptionForm $parameters): SubscriptionResponseInterface - { - $subscription = new DummySubscriptionResponse(); - $subscription->setTrialDays($parameters->trialDays); - - return $subscription; - } - - /** - * @inheritdoc - */ - public function switchSubscriptionPlan(Subscription $subscription, Plan $plan, SwitchPlansForm $parameters): SubscriptionResponseInterface - { - return new DummySubscriptionResponse(); - } - - /** - * @inheritdoc - */ - public function supportsReactivation(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsPlanSwitch(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function getBillingIssueDescription(Subscription $subscription): string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getBillingIssueResolveFormHtml(Subscription $subscription): string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getHasBillingIssues(Subscription $subscription): bool - { - return false; - } -} diff --git a/src/gateways/Manual.php b/src/gateways/Manual.php deleted file mode 100644 index ee794e20c1..0000000000 --- a/src/gateways/Manual.php +++ /dev/null @@ -1,256 +0,0 @@ - - * @since 2.0 - * - * @property bool|string $onlyAllowForZeroPriceOrders - * @property-read null|string $settingsHtml - */ -class Manual extends Gateway -{ - /** - * @var bool - */ - private string|bool $_onlyAllowForZeroPriceOrders = false; - - public function getSettings(): array - { - $settings = parent::getSettings(); - $settings['onlyAllowForZeroPriceOrders'] = $this->getOnlyAllowForZeroPriceOrders(false); - - return $settings; - } - - /** - * @inheritdoc - */ - public function getPaymentFormHtml(array $params): ?string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getPaymentFormModel(): BasePaymentForm - { - return new OffsitePaymentForm(); - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - return Craft::$app->getView()->renderTemplate('commerce/gateways/manualGatewaySettings', ['gateway' => $this]); - } - - /** - * @inheritdoc - */ - public function authorize(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface - { - return new ManualRequestResponse(); - } - - /** - * @inheritdoc - */ - public function capture(Transaction $transaction, string $reference): RequestResponseInterface - { - return new ManualRequestResponse(); - } - - /** - * @inheritdoc - */ - public function completeAuthorize(Transaction $transaction): RequestResponseInterface - { - throw new NotImplementedException(Craft::t('commerce', 'This gateway does not support that functionality.')); - } - - /** - * @inheritdoc - */ - public function completePurchase(Transaction $transaction): RequestResponseInterface - { - throw new NotImplementedException(Craft::t('commerce', 'This gateway does not support that functionality.')); - } - - /** - * @inheritdoc - */ - public function createPaymentSource(BasePaymentForm $sourceData, int $customerId): PaymentSource - { - throw new NotImplementedException(Craft::t('commerce', 'This gateway does not support that functionality.')); - } - - /** - * @inheritdoc - */ - public function deletePaymentSource(string $token): bool - { - throw new NotImplementedException(Craft::t('commerce', 'This gateway does not support that functionality.')); - } - - /** - * @inheritdoc - */ - public function getPaymentTypeOptions(): array - { - return [ - 'authorize' => Craft::t('commerce', 'Authorize Only (Manually Capture)'), - ]; - } - - /** - * @inheritdoc - */ - public function purchase(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface - { - throw new NotImplementedException(Craft::t('commerce', 'This gateway does not support that functionality.')); - } - - /** - * @inheritdoc - */ - public function processWebHook(): WebResponse - { - throw new NotImplementedException(Craft::t('commerce', 'This gateway does not support that functionality.')); - } - - /** - * @inheritdoc - */ - public function refund(Transaction $transaction): RequestResponseInterface - { - return new ManualRequestResponse(); - } - - /** - * @inheritdoc - */ - public function supportsAuthorize(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsCapture(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsCompleteAuthorize(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsCompletePurchase(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsPaymentSources(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsPurchase(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsRefund(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsPartialRefund(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsWebhooks(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function availableForUseWithOrder(Order $order): bool - { - if ($this->getOnlyAllowForZeroPriceOrders() && $order->getTotalPrice() != 0) { - return false; - } - - return parent::availableForUseWithOrder($order); - } - - /** - * @param bool $parse - * @return bool|string - * @since 4.1.1 - */ - public function getOnlyAllowForZeroPriceOrders(bool $parse = true): bool|string - { - return $parse ? (App::parseBooleanEnv($this->_onlyAllowForZeroPriceOrders) ?? false) : $this->_onlyAllowForZeroPriceOrders; - } - - /** - * @param bool|string $onlyAllowForZeroPriceOrders - * @return void - * @since 4.1.1 - */ - public function setOnlyAllowForZeroPriceOrders(bool|string $onlyAllowForZeroPriceOrders): void - { - $this->_onlyAllowForZeroPriceOrders = $onlyAllowForZeroPriceOrders; - } -} diff --git a/src/gateways/MissingGateway.php b/src/gateways/MissingGateway.php deleted file mode 100644 index 7af34dc659..0000000000 --- a/src/gateways/MissingGateway.php +++ /dev/null @@ -1,193 +0,0 @@ - - * @since 2.0 - */ -class MissingGateway extends Gateway implements MissingComponentInterface -{ - use MissingComponentTrait; - - public function __set($name, $value) - { - } - - /** - * @inheritdoc - */ - public function getPaymentFormHtml(array $params): ?string - { - throw new NotSupportedException(); - } - - /** - * @inheritdoc - */ - public function getPaymentFormModel(): BasePaymentForm - { - throw new NotSupportedException(); - } - - /** - * @inheritdoc - */ - public function authorize(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface - { - throw new NotSupportedException(); - } - - /** - * @inheritdoc - */ - public function capture(Transaction $transaction, string $reference): RequestResponseInterface - { - throw new NotSupportedException(); - } - - /** - * @inheritdoc - */ - public function completeAuthorize(Transaction $transaction): RequestResponseInterface - { - throw new NotSupportedException(); - } - - /** - * @inheritdoc - */ - public function completePurchase(Transaction $transaction): RequestResponseInterface - { - throw new NotSupportedException(); - } - - /** - * @inheritdoc - */ - public function createPaymentSource(BasePaymentForm $sourceData, int $userId): PaymentSource - { - throw new NotSupportedException(); - } - - /** - * @inheritdoc - */ - public function deletePaymentSource(string $token): bool - { - throw new NotSupportedException(); - } - - /** - * @inheritdoc - */ - public function purchase(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface - { - throw new NotSupportedException(); - } - - /** - * @inheritdoc - */ - public function processWebHook(): WebResponse - { - throw new NotSupportedException(); - } - - /** - * @inheritdoc - */ - public function refund(Transaction $transaction): RequestResponseInterface - { - throw new NotSupportedException(); - } - - /** - * @inheritdoc - */ - public function supportsAuthorize(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsCapture(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsCompleteAuthorize(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsCompletePurchase(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsPaymentSources(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsPurchase(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsRefund(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsWebhooks(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsPartialRefund(): bool - { - return false; - } -} diff --git a/src/gql/arguments/elements/Product.php b/src/gql/arguments/elements/Product.php deleted file mode 100644 index 562697cc7d..0000000000 --- a/src/gql/arguments/elements/Product.php +++ /dev/null @@ -1,95 +0,0 @@ - - * @since 3.0 - */ -class Product extends ElementArguments -{ - /** - * @inheritdoc - */ - public static function getArguments(): array - { - return array_merge(parent::getArguments(), self::getContentArguments(), [ - 'defaultSku' => [ - 'name' => 'defaultSku', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the default SKU on the product.', - ], - 'defaultPrice' => [ - 'name' => 'defaultPrice', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the default price on the product.', - ], - 'defaultHeight' => [ - 'name' => 'defaultHeight', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the default height on the product.', - ], - 'defaultLength' => [ - 'name' => 'defaultLength', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the default length on the product.', - ], - 'defaultWidth' => [ - 'name' => 'defaultWidth', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the default width on the product.', - ], - 'defaultWeight' => [ - 'name' => 'defaultWeight', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the default weight on the product.', - ], - 'editable' => [ - 'name' => 'editable', - 'type' => Type::boolean(), - 'description' => 'Whether to only return products that the user has permission to edit.', - ], - 'type' => [ - 'name' => 'type', - 'type' => Type::listOf(Type::string()), - 'description' => 'Narrows the query results based on the product type the products belong to per the product type’s handles.', - ], - 'typeId' => [ - 'name' => 'typeId', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the product types the products belong to, per the product type IDs.', - ], - 'hasVariant' => [ - 'name' => 'hasVariant', - 'type' => Variant::getType(), - 'description' => 'Narrows the query results to only products that have certain variants.', - ], - ]); - } - - /** - * @inheritdoc - * @since 3.1.2 - */ - public static function getContentArguments(): array - { - $productTypeFieldArguments = Craft::$app->getGql()->getContentArguments(Plugin::getInstance()->getProductTypes()->getAllProductTypes(), ProductElement::class); - - return array_merge(parent::getContentArguments(), $productTypeFieldArguments); - } -} diff --git a/src/gql/arguments/elements/Variant.php b/src/gql/arguments/elements/Variant.php deleted file mode 100644 index 0ce460a3fb..0000000000 --- a/src/gql/arguments/elements/Variant.php +++ /dev/null @@ -1,167 +0,0 @@ - - * @since 3.1 - */ -class Variant extends ElementArguments -{ - /** - * @inheritdoc - */ - public static function getArguments(): array - { - return array_merge(parent::getArguments(), self::getContentArguments(), [ - 'promotable' => [ - 'name' => 'promotable', - 'type' => Type::boolean(), - 'description' => 'Whether to only return products that are promotable.', - ], - 'availableForPurchase' => [ - 'name' => 'availableForPurchase', - 'type' => Type::boolean(), - 'description' => 'Whether to only return products that are available to purchase.', - ], - 'freeShipping' => [ - 'name' => 'freeShipping', - 'type' => Type::boolean(), - 'description' => 'Whether to only return products that have free shipping.', - ], - 'hasProduct' => [ - 'name' => 'hasProduct', - 'type' => Product::getType(), - 'description' => 'Narrows the query results to only variants for certain products.', - ], - 'hasSales' => [ - 'name' => 'hasSales', - 'type' => Type::boolean(), - 'description' => 'Narrows the query results based on whether the variant has sales applied.', - ], - 'hasStock' => [ - 'name' => 'hasStock', - 'type' => Type::boolean(), - 'description' => 'Narrows the query results based on whether the variant has stock available.', - ], - 'isDefault' => [ - 'name' => 'isDefault', - 'type' => Type::boolean(), - 'description' => 'Narrows the query results based on the variants default status.', - ], - 'maxQty' => [ - 'name' => 'maxQty', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the variant’s maximum allowed quantity to be purchased.', - ], - 'minQty' => [ - 'name' => 'minQty', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the variant’s minimum allowed quantity to be purchased.', - ], - 'price' => [ - 'name' => 'price', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the variant’s price.', - ], - 'promotionalPrice' => [ - 'name' => 'promotionalPrice', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the variant’s promotional price.', - ], - 'onPromotion' => [ - 'name' => 'onPromotion', - 'type' => Type::boolean(), - 'description' => 'Narrows the query results based on whether the variant has a promotional price.', - ], - 'forCustomer' => [ - 'name' => 'forCustomer', - 'type' => IntFalse::getType(), - 'description' => 'Narrows the pricing query results to only prices related for the specified customer.', - ], - 'productId' => [ - 'name' => 'productId', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the variant’s product ID.', - ], - 'sku' => [ - 'name' => 'sku', - 'type' => Type::listOf(Type::string()), - 'description' => 'Narrows the query results based on the variant SKU.', - ], - 'stock' => [ - 'name' => 'stock', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on variant stock level.', - ], - 'typeId' => [ - 'name' => 'typeId', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the variant’s product’s type ID.', - ], - 'width' => [ - 'name' => 'width', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the variant’s width dimension.', - ], - 'height' => [ - 'name' => 'height', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the variant’s height dimension.', - ], - 'length' => [ - 'name' => 'length', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the variant’s length dimension.', - ], - 'weight' => [ - 'name' => 'weight', - 'type' => Type::listOf(QueryArgument::getType()), - 'description' => 'Narrows the query results based on the variant’s weight dimension.', - ], - ]); - } - - /** - * @inheritdoc - * @since 3.1.2 - */ - public static function getContentArguments(): array - { - return array_merge(parent::getContentArguments(), Plugin::getInstance()->getVariants()->getVariantGqlContentArguments()); - } - - /** - * @inheritdoc - * @since 5.5.0 - */ - public static function getStatusArguments(): array - { - $statusArguments = parent::getStatusArguments(); - - if (Gql::canQueryInactiveElements()) { - $statusArguments['productStatus'] = [ - 'name' => 'productStatus', - 'type' => Type::listOf(Type::string()), - 'description' => 'Narrows the query results based on the variants’ product’s statuses.', - ]; - } - - return $statusArguments; - } -} diff --git a/src/gql/handlers/HasProduct.php b/src/gql/handlers/HasProduct.php deleted file mode 100644 index a1b5e543ff..0000000000 --- a/src/gql/handlers/HasProduct.php +++ /dev/null @@ -1,33 +0,0 @@ - - * @since 5.6.5 - */ -class HasProduct extends ArgumentHandler -{ - protected string $argumentName = 'hasProduct'; - - /** - * @inheritdoc - */ - protected function handleArgument(mixed $argumentValue): mixed - { - if (is_array($argumentValue)) { - return $this->argumentManager->prepareArguments($argumentValue); - } - - return $argumentValue; - } -} diff --git a/src/gql/handlers/HasVariant.php b/src/gql/handlers/HasVariant.php deleted file mode 100644 index e36b4180d1..0000000000 --- a/src/gql/handlers/HasVariant.php +++ /dev/null @@ -1,33 +0,0 @@ - - * @since 5.6.5 - */ -class HasVariant extends ArgumentHandler -{ - protected string $argumentName = 'hasVariant'; - - /** - * @inheritdoc - */ - protected function handleArgument(mixed $argumentValue): mixed - { - if (is_array($argumentValue)) { - return $this->argumentManager->prepareArguments($argumentValue); - } - - return $argumentValue; - } -} diff --git a/src/gql/handlers/RelatedProducts.php b/src/gql/handlers/RelatedProducts.php deleted file mode 100644 index 49714dc334..0000000000 --- a/src/gql/handlers/RelatedProducts.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 5.6.0 - */ -class RelatedProducts extends RelationArgumentHandler -{ - protected string $argumentName = 'relatedToProducts'; - - /** - * @inheritdoc - */ - protected function handleArgument($argumentValue): mixed - { - $argumentValue = parent::handleArgument($argumentValue); - return $this->getIds(Product::class, $argumentValue); - } -} diff --git a/src/gql/handlers/RelatedVariants.php b/src/gql/handlers/RelatedVariants.php deleted file mode 100644 index d30a4c99de..0000000000 --- a/src/gql/handlers/RelatedVariants.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 5.6.0 - */ -class RelatedVariants extends RelationArgumentHandler -{ - protected string $argumentName = 'relatedToVariants'; - - /** - * @inheritdoc - */ - protected function handleArgument($argumentValue): mixed - { - $argumentValue = parent::handleArgument($argumentValue); - return $this->getIds(Variant::class, $argumentValue); - } -} diff --git a/src/gql/interfaces/elements/Product.php b/src/gql/interfaces/elements/Product.php deleted file mode 100644 index 8a3b5a49ac..0000000000 --- a/src/gql/interfaces/elements/Product.php +++ /dev/null @@ -1,179 +0,0 @@ - - * @since 3.0 - */ -class Product extends Element -{ - /** - * @inheritdoc - */ - public static function getTypeGenerator(): string - { - return ProductType::class; - } - - /** - * @inheritdoc - */ - public static function getType($fields = null): Type - { - if ($type = GqlEntityRegistry::getEntity(self::getName())) { - return $type; - } - - $type = GqlEntityRegistry::createEntity(self::getName(), new InterfaceType([ - 'name' => static::getName(), - 'fields' => self::class . '::getFieldDefinitions', - 'description' => 'This is the interface implemented by all products.', - 'resolveType' => fn(ProductElement $value) => $value->getGqlTypeName(), - ])); - - ProductType::generateTypes(); - - return $type; - } - - /** - * @inheritdoc - */ - public static function getName(): string - { - return 'ProductInterface'; - } - - /** - * @inheritdoc - */ - public static function getFieldDefinitions(): array - { - $productArguments = ProductArguments::getArguments(); - $structureProductTypeFieldArguments = [...$productArguments]; - - foreach (Gql::getSchemaContainedProductTypes() as $productType) { - $productTypeArguments = Craft::$app->getGql()->getFieldLayoutArguments($productType->getProductFieldLayout()); - if ($productType->isStructure) { - $structureProductTypeFieldArguments += $productTypeArguments; - } - } - - return Craft::$app->getGql()->prepareFieldDefinitions(array_merge(parent::getFieldDefinitions(), [ - 'defaultSku' => [ - 'name' => 'defaultSku', - 'type' => Type::string(), - 'description' => 'The SKU of the default variant for the product.', - ], - 'defaultPrice' => [ - 'name' => 'defaultPrice', - 'type' => Type::float(), - 'description' => 'The price of the default variant for the product.', - ], - 'defaultPriceAsCurrency' => [ - 'name' => 'defaultPriceAsCurrency', - 'type' => Type::string(), - 'description' => 'The formatted price of the default variant for the product.', - ], - 'defaultHeight' => [ - 'name' => 'defaultHeight', - 'type' => Type::float(), - 'description' => 'The height of the default variant for the product.', - ], - 'defaultLength' => [ - 'name' => 'defaultLength', - 'type' => Type::float(), - 'description' => 'The length of the default variant for the product.', - ], - 'defaultWidth' => [ - 'name' => 'defaultWidth', - 'type' => Type::float(), - 'description' => 'The width of the default variant for the product.', - ], - 'defaultWeight' => [ - 'name' => 'defaultWeight', - 'type' => Type::float(), - 'description' => 'The weight of the default variant for the product.', - ], - 'defaultVariant' => [ - 'name' => 'defaultVariant', - 'type' => Variant::getType(), - 'description' => 'The default variant for the product.', - ], - 'productTypeId' => [ - 'name' => 'productTypeId', - 'type' => Type::int(), - 'description' => 'The ID of the product type that contains the product.', - ], - 'productTypeHandle' => [ - 'name' => 'productTypeHandle', - 'type' => Type::string(), - 'description' => 'The handle of the product type that contains the product.', - ], - 'url' => [ - 'name' => 'url', - 'type' => Type::string(), - 'description' => 'The product’s full URL', - ], - 'variants' => [ - 'name' => 'variants', - 'type' => Type::listOf(Variant::getType()), - 'description' => 'The product’s variants.', - ], - 'localized' => [ - 'name' => 'localized', - 'args' => $productArguments, - 'type' => Type::nonNull(Type::listOf(Type::nonNull(static::getType()))), - 'description' => 'The same element in other locales.', - 'complexity' => Gql::eagerLoadComplexity(), - ], - 'children' => [ - 'name' => 'children', - 'args' => $structureProductTypeFieldArguments, - 'type' => Type::nonNull(Type::listOf(Type::nonNull(static::getType()))), - 'description' => 'The products’s children, if the product type is a structure. Accepts the same arguments as the `products` query.', - 'complexity' => Gql::relatedArgumentComplexity(GqlService::GRAPHQL_COMPLEXITY_EAGER_LOAD), - ], - 'descendants' => [ - 'name' => 'descendants', - 'args' => $structureProductTypeFieldArguments, - 'type' => Type::nonNull(Type::listOf(Type::nonNull(static::getType()))), - 'description' => 'The products’s descendants, if the product type is a structure. Accepts the same arguments as the `products` query.', - 'complexity' => Gql::relatedArgumentComplexity(GqlService::GRAPHQL_COMPLEXITY_EAGER_LOAD), - ], - 'parent' => [ - 'name' => 'parent', - 'args' => $structureProductTypeFieldArguments, - 'type' => static::getType(), - 'description' => 'The products’s parent, if the product type is a structure.', - 'complexity' => Gql::relatedArgumentComplexity(GqlService::GRAPHQL_COMPLEXITY_EAGER_LOAD), - ], - 'ancestors' => [ - 'name' => 'ancestors', - 'args' => $structureProductTypeFieldArguments, - 'type' => Type::nonNull(Type::listOf(Type::nonNull(static::getType()))), - 'description' => 'The products’s ancestors, if the product type is a structure. Accepts the same arguments as the `products` query.', - 'complexity' => Gql::relatedArgumentComplexity(GqlService::GRAPHQL_COMPLEXITY_EAGER_LOAD), - ], - ]), self::getName()); - } -} diff --git a/src/gql/interfaces/elements/Variant.php b/src/gql/interfaces/elements/Variant.php deleted file mode 100644 index ac95975b1b..0000000000 --- a/src/gql/interfaces/elements/Variant.php +++ /dev/null @@ -1,212 +0,0 @@ - - * @since 3.1 - */ -class Variant extends Element -{ - /** - * @inheritdoc - */ - public static function getTypeGenerator(): string - { - return VariantType::class; - } - - /** - * @inheritdoc - */ - public static function getType($fields = null): Type - { - if ($type = GqlEntityRegistry::getEntity(self::getName())) { - return $type; - } - - $type = GqlEntityRegistry::createEntity(self::getName(), new InterfaceType([ - 'name' => static::getName(), - 'fields' => self::class . '::getFieldDefinitions', - 'description' => 'This is the interface implemented by all variants.', - 'resolveType' => fn(VariantElement $value) => $value->getGqlTypeName(), - ])); - - VariantType::generateTypes(); - - return $type; - } - - /** - * @inheritdoc - */ - public static function getName(): string - { - return 'VariantInterface'; - } - - /** - * @inheritdoc - */ - public static function getFieldDefinitions(): array - { - return Craft::$app->getGql()->prepareFieldDefinitions(array_merge(parent::getFieldDefinitions(), [ - 'isDefault' => [ - 'name' => 'isDefault', - 'type' => Type::boolean(), - 'description' => 'If the variant is the default for the product.', - ], - 'isAvailable' => [ - 'name' => 'isAvailable', - 'type' => Type::boolean(), - 'description' => 'If the variant is available to be purchased.', - ], - 'price' => [ - 'name' => 'price', - 'type' => Type::float(), - 'description' => 'The price of the variant.', - ], - 'priceAsCurrency' => [ - 'name' => 'priceAsCurrency', - 'type' => Type::string(), - 'description' => 'The formatted price of the variant.', - ], - 'promotionalPrice' => [ - 'name' => 'promotionalPrice', - 'type' => Type::float(), - 'description' => 'The promotional price of the variant.', - ], - 'promotionalPriceAsCurrency' => [ - 'name' => 'promotionalPriceAsCurrency', - 'type' => Type::string(), - 'description' => 'The formatted promotional price of the variant.', - ], - 'salePrice' => [ - 'name' => 'salePrice', - 'type' => Type::float(), - 'description' => 'The sale price of the variant. CAUTION: This will not take into account sales that utilize user group conditions.', - ], - 'salePriceAsCurrency' => [ - 'name' => 'salePriceAsCurrency', - 'type' => Type::string(), - 'description' => 'The formatted sale price of the variant. CAUTION: This will not take into account sales that utilize user group conditions.', - ], - 'sales' => [ - 'name' => 'sales', - 'type' => Type::listOf(SaleType::getType()), - 'description' => 'The sales that apply to the variant. CAUTION: This will not take into account sales that utilize user group conditions.', - ], - 'sortOrder' => [ - 'name' => 'sortOrder', - 'type' => Type::int(), - 'description' => 'The sort order of the variant.', - ], - 'width' => [ - 'name' => 'width', - 'type' => Type::float(), - 'description' => 'The width of the variant.', - ], - 'height' => [ - 'name' => 'height', - 'type' => Type::float(), - 'description' => 'The height of the variant.', - ], - 'length' => [ - 'name' => 'length', - 'type' => Type::float(), - 'description' => 'The length of the variant.', - ], - 'weight' => [ - 'name' => 'weight', - 'type' => Type::float(), - 'description' => 'The weight of the variant.', - ], - 'stock' => [ - 'name' => 'stock', - 'type' => Type::int(), - 'description' => 'The stock level of the variant.', - ], - 'hasUnlimitedStock' => [ - 'name' => 'hasUnlimitedStock', - 'type' => Type::boolean(), - 'description' => 'If the variant has unlimited stock.', - ], - 'minQty' => [ - 'name' => 'minQty', - 'type' => Type::int(), - 'description' => 'The minimum allowed quantity to be purchased.', - ], - 'maxQty' => [ - 'name' => 'maxQty', - 'type' => Type::int(), - 'description' => 'The maximum allowed quantity to be purchased.', - ], - 'promotable' => [ - 'name' => 'promotable', - 'type' => Type::boolean(), - 'description' => 'If the product is promotable.', - ], - 'availableForPurchase' => [ - 'name' => 'availableForPurchase', - 'type' => Type::boolean(), - 'description' => 'If the product is available for purchase.', - ], - 'freeShipping' => [ - 'name' => 'freeShipping', - 'type' => Type::boolean(), - 'description' => 'If the product has free shipping.', - ], - 'shippingCategoryId' => [ - 'name' => 'shippingCategoryId', - 'type' => Type::int(), - 'description' => 'The ID of the variants’s shipping category.', - ], - 'productId' => [ - 'name' => 'productId', - 'type' => Type::int(), - 'description' => 'The ID of the variant’s parent product.', - ], - 'product' => [ - 'name' => 'product', - 'type' => Product::getType(), - 'description' => 'The variant’s parent product.', - ], - 'productTitle' => [ - 'name' => 'productTitle', - 'type' => Type::string(), - 'description' => 'The title of the variant’s parent product.', - ], - 'productTypeId' => [ - 'name' => 'productTypeId', - 'type' => Type::int(), - 'description' => 'The product type ID of the variant’s parent product.', - ], - 'sku' => [ - 'name' => 'sku', - 'type' => Type::string(), - 'description' => 'The SKU of the variant.', - ], - 'storeId' => [ - 'name' => 'storeId', - 'type' => Type::int(), - 'description' => 'The ID of the variant’s store.', - ], - ]), self::getName()); - } -} diff --git a/src/gql/queries/Product.php b/src/gql/queries/Product.php deleted file mode 100644 index de0d09aeec..0000000000 --- a/src/gql/queries/Product.php +++ /dev/null @@ -1,55 +0,0 @@ - - * @since 3.0 - */ -class Product extends Query -{ - /** - * @inheritdoc - */ - public static function getQueries(bool $checkToken = true): array - { - if ($checkToken && !GqlHelper::canQueryProducts()) { - return []; - } - - return [ - 'products' => [ - 'type' => Type::listOf(ProductInterface::getType()), - 'args' => ProductArguments::getArguments(), - 'resolve' => ProductResolver::class . '::resolve', - 'description' => 'This query is used to query for products.', - ], - 'productCount' => [ - 'type' => Type::nonNull(Type::int()), - 'args' => ProductArguments::getArguments(), - 'resolve' => ProductResolver::class . '::resolveCount', - 'description' => 'This query is used to return the number of products.', - ], - 'product' => [ - 'type' => ProductInterface::getType(), - 'args' => ProductArguments::getArguments(), - 'resolve' => ProductResolver::class . '::resolveOne', - 'description' => 'This query is used to query for a product.', - ], - ]; - } -} diff --git a/src/gql/queries/Variant.php b/src/gql/queries/Variant.php deleted file mode 100644 index 4b7caf4fa6..0000000000 --- a/src/gql/queries/Variant.php +++ /dev/null @@ -1,55 +0,0 @@ - - * @since 3.1 - */ -class Variant extends Query -{ - /** - * @inheritdoc - */ - public static function getQueries(bool $checkToken = true): array - { - if ($checkToken && !GqlHelper::canQueryProducts()) { - return []; - } - - return [ - 'variants' => [ - 'type' => Type::listOf(VariantInterface::getType()), - 'args' => VariantArguments::getArguments(), - 'resolve' => VariantResolver::class . '::resolve', - 'description' => 'This query is used to query for variants.', - ], - 'variantCount' => [ - 'type' => Type::nonNull(Type::int()), - 'args' => VariantArguments::getArguments(), - 'resolve' => VariantResolver::class . '::resolveCount', - 'description' => 'This query is used to return the number of variants.', - ], - 'variant' => [ - 'type' => VariantInterface::getType(), - 'args' => VariantArguments::getArguments(), - 'resolve' => VariantResolver::class . '::resolveOne', - 'description' => 'This query is used to query for a variant.', - ], - ]; - } -} diff --git a/src/gql/resolvers/elements/Product.php b/src/gql/resolvers/elements/Product.php deleted file mode 100644 index 050779e754..0000000000 --- a/src/gql/resolvers/elements/Product.php +++ /dev/null @@ -1,64 +0,0 @@ - - * @since 3.0 - */ -class Product extends ElementResolver -{ - /** - * @inheritdoc - */ - public static function prepareQuery(mixed $source, array $arguments, $fieldName = null): mixed - { - // If this is the beginning of a resolver chain, start fresh - if ($source === null) { - $query = ProductElement::find(); - // If not, get the prepared element query - } else { - $query = $source->$fieldName; - } - - // If it's preloaded, it's preloaded. - if (!$query instanceof ElementQuery) { - return $query; - } - - foreach ($arguments as $key => $value) { - if (method_exists($query, $key)) { - $query->$key($value); - } elseif (property_exists($query, $key)) { - $query->$key = $value; - } else { - // Catch custom field queries - $query->$key($value); - } - } - - $pairs = GqlHelper::extractAllowedEntitiesFromSchema(); - - if (!GqlHelper::canQueryProducts()) { - return []; - } - - $query->andWhere(['in', 'typeId', array_values(Db::idsByUids(Table::PRODUCTTYPES, $pairs['productTypes']))]); - - return $query; - } -} diff --git a/src/gql/resolvers/elements/Variant.php b/src/gql/resolvers/elements/Variant.php deleted file mode 100644 index 8b082487bd..0000000000 --- a/src/gql/resolvers/elements/Variant.php +++ /dev/null @@ -1,73 +0,0 @@ - - * @since 3.1 - */ -class Variant extends ElementResolver -{ - /** - * @inheritdoc - */ - public static function prepareQuery(mixed $source, array $arguments, $fieldName = null): mixed - { - // If this is the beginning of a resolver chain, start fresh - if ($source === null) { - $query = VariantElement::find(); - // If not, get the prepared element query - } else { - $query = $source->$fieldName; - } - - // If it's preloaded, it's preloaded. - if (!$query instanceof ElementQuery) { - return $query; - } - - foreach ($arguments as $key => $value) { - if (method_exists($query, $key)) { - $query->$key($value); - } elseif (property_exists($query, $key)) { - $query->$key = $value; - } else { - // Catch custom field queries - $query->$key($value); - } - } - - $pairs = GqlHelper::extractAllowedEntitiesFromSchema(); - - if (!GqlHelper::canQueryProducts()) { - return []; - } - - // For variant queries make sure we are only return those that have live products - // unless the schema allows querying inactive elements - if (!GqlHelper::canQueryInactiveElements() && $query instanceof VariantQuery) { - $query->productStatus(ProductElement::STATUS_LIVE); - } - - $query->innerJoin(Table::PRODUCTS . ' p', '[[p.id]] = [[commerce_variants.primaryOwnerId]]'); - $query->andWhere(['in', '[[p.typeId]]', array_values(Db::idsByUids(Table::PRODUCTTYPES, $pairs['productTypes']))]); - - return $query; - } -} diff --git a/src/gql/types/SaleType.php b/src/gql/types/SaleType.php deleted file mode 100644 index 9fb8610448..0000000000 --- a/src/gql/types/SaleType.php +++ /dev/null @@ -1,90 +0,0 @@ - - * @since 3.1.10 - */ -class SaleType extends ObjectType -{ - /** - * @return string - */ - public static function getName(): string - { - return 'Sale'; - } - - public static function getType(): Type - { - if ($type = GqlEntityRegistry::getEntity(self::getName())) { - return $type; - } - - return GqlEntityRegistry::createEntity(self::getName(), new self([ - 'name' => static::getName(), - 'fields' => self::class . '::getFieldDefinitions', - 'description' => '', - ])); - } - - public static function getFieldDefinitions(): array - { - return Craft::$app->getGql()->prepareFieldDefinitions([ - 'name' => [ - 'name' => 'name', - 'type' => Type::string(), - 'description' => 'The name of the sale as described in the control panel.', - ], - 'description' => [ - 'name' => 'description', - 'type' => Type::string(), - 'description' => 'Description of the sale.', - ], - 'apply' => [ - 'name' => 'apply', - 'type' => Type::string(), - 'description' => 'How the sale should be applied.', - ], - 'applyAmount' => [ - 'name' => 'applyAmount', - 'type' => Type::float(), - 'description' => 'The amount applied used by the apply option.', - ], - 'applyAmountAsPercent' => [ - 'name' => 'applyAmountAsPercent', - 'type' => Type::string(), - 'description' => 'The amount applied used by the apply option.', - ], - 'applyAmountAsFlat' => [ - 'name' => 'applyAmountAsFlat', - 'type' => Type::float(), - 'description' => 'The amount applied used by the apply option.', - ], - 'dateFrom' => [ - 'name' => 'dateFrom', - 'type' => DateTime::getType(), - 'description' => 'Start date of the sale.', - ], - 'dateTo' => [ - 'name' => 'dateTo', - 'type' => DateTime::getType(), - 'description' => 'Start date of the sale.', - ], - ], self::getName()); - } -} diff --git a/src/gql/types/elements/Product.php b/src/gql/types/elements/Product.php deleted file mode 100644 index 2db424b5c2..0000000000 --- a/src/gql/types/elements/Product.php +++ /dev/null @@ -1,48 +0,0 @@ - - * @since 3.0 - */ -class Product extends ElementType -{ - /** - * @inheritdoc - */ - public function __construct(array $config) - { - $config['interfaces'] = [ - ProductInterface::getType(), - ]; - - parent::__construct($config); - } - - /** - * @inheritdoc - */ - protected function resolve(mixed $source, array $arguments, mixed $context, ResolveInfo $resolveInfo): mixed - { - /** @var ProductElement $source */ - $fieldName = $resolveInfo->fieldName; - return match ($fieldName) { - 'productTypeHandle' => $source->getType()->handle, - 'productTypeId' => $source->getType()->id, - default => parent::resolve($source, $arguments, $context, $resolveInfo), - }; - } -} diff --git a/src/gql/types/elements/Variant.php b/src/gql/types/elements/Variant.php deleted file mode 100644 index 9e125b3b98..0000000000 --- a/src/gql/types/elements/Variant.php +++ /dev/null @@ -1,49 +0,0 @@ - - * @since 3.1 - */ -class Variant extends ElementType -{ - /** - * @inheritdoc - */ - public function __construct(array $config) - { - $config['interfaces'] = [ - VariantInterface::getType(), - ]; - - parent::__construct($config); - } - - /** - * @inheritdoc - */ - protected function resolve(mixed $source, array $arguments, mixed $context, ResolveInfo $resolveInfo): mixed - { - /** @var VariantElement $source */ - $fieldName = $resolveInfo->fieldName; - $product = $source->getOwner(); - return match ($fieldName) { - 'productTitle' => $product->title ?? '', - 'productTypeId' => $product->typeId ?? null, - default => parent::resolve($source, $arguments, $context, $resolveInfo), - }; - } -} diff --git a/src/gql/types/generators/ProductType.php b/src/gql/types/generators/ProductType.php deleted file mode 100644 index dea91814c1..0000000000 --- a/src/gql/types/generators/ProductType.php +++ /dev/null @@ -1,65 +0,0 @@ - - * @since 3.0 - */ -class ProductType implements GeneratorInterface -{ - /** - * @inheritdoc - */ - public static function generateTypes(mixed $context = null): array - { - $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); - $gqlTypes = []; - - foreach ($productTypes as $productType) { - /** @var ProductTypeModel $productType */ - $typeName = ProductElement::gqlTypeNameByContext($productType); - $requiredContexts = ProductElement::gqlScopesByContext($productType); - - if (!CommerceGqlHelper::isSchemaAwareOf($requiredContexts)) { - continue; - } - - $contentFields = $productType->getCustomFields(); - $contentFieldGqlTypes = []; - - /** @var Field $contentField */ - foreach ($contentFields as $contentField) { - $contentFieldGqlTypes[$contentField->handle] = $contentField->getContentGqlType(); - } - - $productTypeFields = Craft::$app->getGql()->prepareFieldDefinitions(array_merge(ProductInterface::getFieldDefinitions(), $contentFieldGqlTypes), $typeName); - - // Generate a type for each product type - $gqlTypes[$typeName] = GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new ProductTypeElement([ - 'name' => $typeName, - 'fields' => fn() => $productTypeFields, - ])); - } - - return $gqlTypes; - } -} diff --git a/src/gql/types/generators/VariantType.php b/src/gql/types/generators/VariantType.php deleted file mode 100644 index 971c051c00..0000000000 --- a/src/gql/types/generators/VariantType.php +++ /dev/null @@ -1,66 +0,0 @@ - - * @since 3.1 - */ -class VariantType implements GeneratorInterface -{ - /** - * @inheritdoc - */ - public static function generateTypes(mixed $context = null): array - { - $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); - $gqlTypes = []; - - foreach ($productTypes as $productType) { - /** @var ProductTypeModel $productType */ - $typeName = VariantElement::gqlTypeNameByContext($productType); - $requiredContexts = VariantElement::gqlScopesByContext($productType); - - if (!Gql::isSchemaAwareOf($requiredContexts)) { - continue; - } - - $layout = $productType->getVariantFieldLayout(); - $contentFields = $layout->getCustomFields(); - $contentFieldGqlTypes = []; - - /** @var Field $contentField */ - foreach ($contentFields as $contentField) { - $contentFieldGqlTypes[$contentField->handle] = $contentField->getContentGqlType(); - } - - $fields = Craft::$app->getGql()->prepareFieldDefinitions(array_merge(VariantInterface::getFieldDefinitions(), $contentFieldGqlTypes), $typeName); - - // Generate a type for each product type - $gqlTypes[$typeName] = GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new Variant([ - 'name' => $typeName, - 'fields' => fn() => $fields, - ])); - } - - return $gqlTypes; - } -} diff --git a/src/gql/types/input/IntFalse.php b/src/gql/types/input/IntFalse.php deleted file mode 100644 index 38ce007df3..0000000000 --- a/src/gql/types/input/IntFalse.php +++ /dev/null @@ -1,109 +0,0 @@ - - * @since 5.0.7 - */ -class IntFalse extends ScalarType -{ - public $name = 'IntFalse'; - - /** @var string */ - public $description = - 'The `IntFalse` scalar type represents non-fractional signed whole numeric -values. Int can represent values between -(2^31) and 2^31 - 1 Or `false`'; - - /** - * @var IntType|null - */ - private ?IntType $_intType = null; - - public function __construct(array $config = []) - { - $this->_intType = new IntType(); - - parent::__construct($config); - } - - /** - * Returns a singleton instance to ensure one type per schema. - * - * @return IntFalse - */ - public static function getType(): IntFalse - { - return GqlEntityRegistry::getOrCreate(static::getName(), fn() => new self()); - } - - /** - * @return string - */ - public static function getName(): string - { - return 'IntFalse'; - } - - /** - * @param $value - * @return false|int|mixed|null - * @throws Error - */ - public function serialize($value) - { - if (is_bool($value) && $value === false) { - return false; - } - - // If it isn't `false` use the `IntType` to serialize the value - return $this->_intType->serialize($value); - } - - /** - * @param $value - * @return int|false - * @throws Error - */ - public function parseValue($value): int|false - { - if (is_bool($value) && $value === false) { - return false; - } - - return $this->_intType->parseValue($value); - } - - /** - * @param $valueNode - * @param array|null $variables - * @return false|int|mixed - * @throws Error - */ - public function parseLiteral($valueNode, ?array $variables = null) - { - if ($valueNode instanceof BooleanValueNode) { - $val = $valueNode->value; - if ($val === false) { - return false; - } - - throw new Error(); - } - - return $this->_intType->parseLiteral($valueNode, $variables); - } -} diff --git a/src/gql/types/input/Product.php b/src/gql/types/input/Product.php deleted file mode 100644 index e7a416b095..0000000000 --- a/src/gql/types/input/Product.php +++ /dev/null @@ -1,34 +0,0 @@ - - * @since 3.2.4 - */ -class Product extends InputObjectType -{ - /** - * @return mixed - */ - public static function getType(): mixed - { - $typeName = 'ProductInput'; - - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new InputObjectType([ - 'name' => $typeName, - 'fields' => fn() => ProductArguments::getArguments(), - ])); - } -} diff --git a/src/gql/types/input/Variant.php b/src/gql/types/input/Variant.php deleted file mode 100644 index 6de000cc79..0000000000 --- a/src/gql/types/input/Variant.php +++ /dev/null @@ -1,34 +0,0 @@ - - * @since 3.1.11 - */ -class Variant extends InputObjectType -{ - /** - * @return mixed - */ - public static function getType(): mixed - { - $typeName = 'VariantInput'; - - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new InputObjectType([ - 'name' => $typeName, - 'fields' => fn() => VariantArguments::getArguments(), - ])); - } -} diff --git a/src/gql/types/input/criteria/ProductRelation.php b/src/gql/types/input/criteria/ProductRelation.php deleted file mode 100644 index 0bb5243aba..0000000000 --- a/src/gql/types/input/criteria/ProductRelation.php +++ /dev/null @@ -1,39 +0,0 @@ - - * @since 5.6.0 - */ -class ProductRelation extends InputObjectType -{ - /** - * @return mixed - */ - public static function getType(): mixed - { - $typeName = 'ProductRelationCriteriaInput'; - - return GqlEntityRegistry::getOrCreate($typeName, fn() => new InputObjectType([ - 'name' => $typeName, - 'fields' => fn() => [ - ...ProductArguments::getArguments(), - ...ProductArguments::getContentArguments(), - ...RelationCriteria::getArguments(), - ], - ])); - } -} diff --git a/src/gql/types/input/criteria/VariantRelation.php b/src/gql/types/input/criteria/VariantRelation.php deleted file mode 100644 index 24e6344d30..0000000000 --- a/src/gql/types/input/criteria/VariantRelation.php +++ /dev/null @@ -1,39 +0,0 @@ - - * @since 5.6.0 - */ -class VariantRelation extends InputObjectType -{ - /** - * @return mixed - */ - public static function getType(): mixed - { - $typeName = 'VariantRelationCriteriaInput'; - - return GqlEntityRegistry::getOrCreate($typeName, fn() => new InputObjectType([ - 'name' => $typeName, - 'fields' => fn() => [ - ...VariantArguments::getArguments(), - ...VariantArguments::getContentArguments(), - ...RelationCriteria::getArguments(), - ], - ])); - } -} diff --git a/src/helpers/Cp.php b/src/helpers/Cp.php deleted file mode 100644 index 9098e72dc3..0000000000 --- a/src/helpers/Cp.php +++ /dev/null @@ -1,71 +0,0 @@ - - * @since 5.0 - */ -class Cp -{ - /** - * Renders an inventory locations select field's HTML. - * - * @param array $config - * @return string - * @since 5.0.0 - */ - public static function inventoryLocationFieldHtml(array $config): string - { - $config['id'] ??= 'inventorylocationselect' . mt_rand(); - return CraftCp::fieldHtml('template:commerce/_includes/forms/inventoryLocationSelect.twig', $config); - } - - /** - * Renders a tax zone select field's HTML. - * - * @param array $config - * @return string - * @since 5.0.0 - */ - public static function taxZoneFieldHtml(array $config): string - { - $config['id'] ??= 'taxzoneselect' . mt_rand(); - return CraftCp::fieldHtml('template:commerce/_includes/forms/taxZoneSelect.twig', $config); - } - - /** - * Renders a tax category select field's HTML. - * - * @param array $config - * @return string - * @since 5.5.0 - */ - public static function taxCategoryFieldHtml(array $config): string - { - $config['id'] ??= 'taxcategoryselect' . mt_rand(); - return CraftCp::fieldHtml('template:commerce/_includes/forms/taxCategorySelect.twig', $config); - } - - /** - * Renders a shipping category select field's HTML. - * - * @param array $config - * @return string - * @since 5.5.0 - */ - public static function shippingCategoryFieldHtml(array $config): string - { - $config['id'] ??= 'shippingcategoryselect' . mt_rand(); - return CraftCp::fieldHtml('template:commerce/_includes/forms/shippingCategorySelect.twig', $config); - } -} diff --git a/src/helpers/Currency.php b/src/helpers/Currency.php deleted file mode 100644 index 9e6c044647..0000000000 --- a/src/helpers/Currency.php +++ /dev/null @@ -1,146 +0,0 @@ - - * @since 2.0 - */ -class Currency -{ - /** - * Rounds the amount as per the currency minor unit information. Not passing - * a currency model results in rounding in default currency. - * - * @param float $amount The amount as a decimal/float - * @param PaymentCurrency|string|MoneyCurrency|null $currency - * @return float - */ - public static function round(float $amount, PaymentCurrency|string|MoneyCurrency|null $currency = null): float - { - if (!$currency) { - $currency = Plugin::getInstance()->getStores()->getCurrentStore()->getCurrency(); - } - - if ($currency instanceof PaymentCurrency) { - $currency = new MoneyCurrency($currency->getAlphabeticCode()); - } - - if (is_string($currency)) { - $currency = new MoneyCurrency($currency); - } - - $moneyFormatter = new DecimalMoneyFormatter(new ISOCurrencies()); - return (float)$moneyFormatter->format(Plugin::getInstance()->getCurrencies()->getTeller($currency)->convertToMoney($amount)); - } - - /** - * @return int - * @throws CurrencyException - * @throws InvalidConfigException - */ - public static function defaultDecimals(): int - { - return Plugin::getInstance()->getPaymentCurrencies()->getPrimaryPaymentCurrency()->getSubUnit(); - } - - /** - * Formats and optionally converts a currency amount into the supplied valid payment currency as per the rate setup in payment currencies. - * - * @param $amount - * @param bool $convert - * @param bool $format - * @param bool $stripZeros - * @return string - * @throws CurrencyException - * @throws InvalidConfigException - */ - public static function formatAsCurrency($amount, mixed $currency = null, bool $convert = false, bool $format = true, bool $stripZeros = false): string - { - // return input if no currency passed, and both convert and format are false. - if (!$convert && !$format) { - return $amount; - } - - $currencyIso = Plugin::getInstance()->getStores()->getCurrentStore()->getCurrency(); - - if (is_string($currency)) { - $currencyIso = $currency; - } - - if ($currency instanceof PaymentCurrency) { - $currencyIso = $currency->iso; - } - - if ($convert) { - $currency = Plugin::getInstance()->getPaymentCurrencies()->getPaymentCurrencyByIso($currencyIso); - if (!$currency) { - throw new InvalidCallException('Trying to convert to a currency that is not configured'); - } - } - - if ($convert && $currencyIso !== Plugin::getInstance()->getStores()->getCurrentStore()->getCurrency()) { - $amount = Plugin::getInstance()->getPaymentCurrencies()->convert((float)$amount, $currencyIso); - } - - if ($format) { - $numberFormatter = new \NumberFormatter(Craft::$app->getFormattingLocale(), \NumberFormatter::CURRENCY); - - // Strip zeros if requested and only if the amount won't have any decimal places - if ($stripZeros && (int)$amount == $amount) { - $numberFormatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, 0); - $numberFormatter->setAttribute(\NumberFormatter::MIN_FRACTION_DIGITS, 0); - } - - $moneyFormatter = new IntlMoneyFormatter($numberFormatter, new ISOCurrencies()); - $money = Plugin::getInstance()->getCurrencies()->getTeller($currencyIso)->convertToMoney($amount); - - return $moneyFormatter->format($money); - } - - return (string)$amount; - } - - /** - * @param array $config - * @return string - * @throws InvalidConfigException - * @throws TemplateLoaderException - * @since 5.0.0 - */ - public static function moneyInputHtml(mixed $value, array $config = []): string - { - $config += [ - 'showCurrency' => true, - 'size' => 6, - 'decimals' => 2, - 'value' => $value, - ]; - - if (isset($config['currency'])) { - $config['decimals'] = Plugin::getInstance()->getCurrencies()->getSubunitFor($config['currency']); - } - - return Cp::moneyInputHtml($config); - } -} diff --git a/src/helpers/DebugPanel.php b/src/helpers/DebugPanel.php deleted file mode 100644 index 57a1d42a12..0000000000 --- a/src/helpers/DebugPanel.php +++ /dev/null @@ -1,92 +0,0 @@ - - * @since 4.0 - */ -class DebugPanel -{ - /** - * @param object $model - * @param string|null $name Name of the tab to be displayed. - * @param bool $prepend Whether to prepend the content tab. - * @return void - */ - public static function prependOrAppendModelTab(object $model, ?string $name = null, bool $prepend = false): void - { - if (!$name) { - $classSegments = explode('\\', $model::class); - $name = array_pop($classSegments); - - if (property_exists($model, 'id')) { - $name .= $model->id ? sprintf(' (ID: %s)', $model->id) : ' (New)'; - } - } - - $user = Craft::$app->getUser()->getIdentity(); - - // Skip out if there is no user or `devMode` isn't enabled - if (!$user || !Craft::$app->getConfig()->getGeneral()->devMode) { - return; - } - - // Skip out if this is a CP request and the user doesn't have the preference set to `true` - if ((Craft::$app->getRequest()->getIsCpRequest() && !$user->getPreference('enableDebugToolbarForCp'))) { - return; - } - - // Skip out if this is a site request and the user doesn't have the preference set to `true` - if (!Craft::$app->getRequest()->getIsCpRequest() && !$user->getPreference('enableDebugToolbarForSite')) { - return; - } - - Event::on(CommercePanel::class, CommercePanel::EVENT_AFTER_DATA_PREPARE, function(CommerceDebugPanelDataEvent $event) use ($name, $model, $prepend) { - $content = Craft::$app->getView()->render('@craft/commerce/views/debug/commerce/model', compact('model')); - - ArrayHelper::prependOrAppend($event->nav, $name, $prepend); - ArrayHelper::prependOrAppend($event->content, $content, $prepend); - }); - } - - /** - * @param string $attr - * @param string|null $label - * @return string - */ - public static function renderModelAttributeRow(string $attr, mixed $value, ?string $label = null): string - { - $label = $label ?: $attr; - - if (is_string($value)) { - if (str_contains($attr, 'html') || str_contains($attr, 'Html')) { - $output = Html::encode($value); - } else { - $output = $value; - } - } else { - $output = VarDumper::dumpAsString($value); - } - - return Html::tag('tr', - Html::tag('th', $label) - . Html::tag('td', Html::tag('code', $output)) - ); - } -} diff --git a/src/helpers/Gql.php b/src/helpers/Gql.php deleted file mode 100644 index 4472461ae2..0000000000 --- a/src/helpers/Gql.php +++ /dev/null @@ -1,46 +0,0 @@ - - * @since 3.0 - */ -class Gql extends GqlHelper -{ - /** - * Return true if active schema can query products. - */ - public static function canQueryProducts(): bool - { - $allowedEntities = self::extractAllowedEntitiesFromSchema(); - return isset($allowedEntities['productTypes']); - } - - /** - * @param GqlSchema|null $schema - * @return array|ProductType[] - * @throws InvalidConfigException - * @since 5.5.0 - */ - public static function getSchemaContainedProductTypes(?GqlSchema $schema = null): array - { - return array_filter( - Plugin::getInstance()->getProductTypes()->getAllProductTypes(), - fn(ProductType $productType) => static::isSchemaAwareOf("productTypes.$productType->uid", $schema), - ); - } -} diff --git a/src/helpers/LineItem.php b/src/helpers/LineItem.php deleted file mode 100644 index 72629a1728..0000000000 --- a/src/helpers/LineItem.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @since 2.1 - */ -class LineItem -{ - /** - * @return string The generated options signature - */ - public static function generateOptionsSignature(array $options = [], ?int $lineItemId = null): string - { - if ($lineItemId) { - $options['lineItemId'] = $lineItemId; - } - ksort($options); - return md5(Json::encode($options)); - } -} diff --git a/src/helpers/Locale.php b/src/helpers/Locale.php deleted file mode 100644 index 9c3ea73220..0000000000 --- a/src/helpers/Locale.php +++ /dev/null @@ -1,79 +0,0 @@ - - * @since 3.2.13 - */ -class Locale -{ - /** - * Set language of the application - * - * @param string $toLanguage - * @param string|null $formattingLocale - * @throws InvalidConfigException - * @todo rename the `$toLanguage` parameter to `$locale` in Commerce 6.0 - */ - public static function switchAppLanguage(string $toLanguage, ?string $formattingLocale = null): void - { - Craft::$app->language = $toLanguage; - $locale = Craft::$app->getI18n()->getLocaleById($toLanguage); - Craft::$app->set('locale', $locale); - - if ($formattingLocale !== null) { - $locale = Craft::$app->getI18n()->getLocaleById($formattingLocale); - } - - Craft::$app->set('formattingLocale', $locale); - } - - /** - * Get the created sites languages and all languages. - * - * @throws Exception - */ - public static function getSiteAndOtherLanguages(): array - { - $pdfLanguageOptions['siteLanguages']['optgroup'] = Craft::t('commerce', 'Site Languages'); - - $siteLanguageOptions = []; - // Get current site's locale - foreach (Craft::$app->getSites()->getAllSites() as $site) { - $locale = Craft::$app->getI18n()->getLocaleById($site->language); - - $siteLanguageOptions[$locale->getLanguageID()] = $site->name . ' - ' . $locale->getDisplayName(); - } - - $pdfLanguageOptions = array_merge($pdfLanguageOptions, $siteLanguageOptions); - - $pdfLanguageOptions['otherLanguages']['optgroup'] = Craft::t('commerce', 'Other Languages'); - - /** @var \craft\i18n\Locale[] $allLocales */ - $allLocales = ArrayHelper::index(Craft::$app->getI18n()->getAppLocales(), 'id'); - ArrayHelper::multisort($allLocales, 'displayName'); - - $allLocaleOptions = []; - - foreach ($allLocales as $locale) { - $allLocaleOptions[$locale->id] = $locale->getDisplayName(); - } - - $otherLocaleOptions = array_diff_key($allLocaleOptions, $siteLanguageOptions); - - return array_merge($pdfLanguageOptions, $otherLocaleOptions); - } -} diff --git a/src/helpers/Localization.php b/src/helpers/Localization.php deleted file mode 100644 index 16d50d40d4..0000000000 --- a/src/helpers/Localization.php +++ /dev/null @@ -1,46 +0,0 @@ - - * @since 3.4.10 - */ -abstract class Localization extends \craft\helpers\Localization -{ - /** - * Normalizes a percentage value into a float. - * - * @param int|float|string|null $number - * @return float|null - */ - public static function normalizePercentage(mixed $number): ?float - { - if ($number === null) { - return 0.0; - } - - if (!is_string($number)) { - return (float)$number; - } - - $pct = Craft::$app->getFormattingLocale()->getNumberSymbol(Locale::SYMBOL_PERCENT); - $number = trim($number, "$pct \t\n\r\0\x0B"); - - if ($number === '') { - return 0.0; - } - - return static::normalizeNumber($number) / 100; - } -} diff --git a/src/helpers/Order.php b/src/helpers/Order.php deleted file mode 100644 index 8d4b41e1e7..0000000000 --- a/src/helpers/Order.php +++ /dev/null @@ -1,114 +0,0 @@ - - * @since 2.1 - */ -class Order -{ - /** - * @return bool Were any line items merged? - */ - public static function mergeDuplicateLineItems(OrderElement $order): bool - { - $lineItems = $order->getLineItems(); - $lineItemsByKey = []; - - foreach ($lineItems as $lineItem) { - // Generate a key depending on line item type - if ($lineItem->type === LineItemType::Purchasable) { - $key = $lineItem->orderId . '-' . LineItemType::Purchasable->value . '-' . $lineItem->purchasableId . '-' . $lineItem->getOptionsSignature(); - } else { - $key = $lineItem->orderId . '-' . LineItemType::Custom->value . '-' . $lineItem->getSku() . '-' . $lineItem->getOptionsSignature(); - } - - if (!isset($lineItemsByKey[$key])) { - $lineItemsByKey[$key] = $lineItem; - continue; - } - - $lineItemsByKey[$key]->qty += $lineItem->qty; - $lineItemsByKey[$key]->note = trim(($lineItemsByKey[$key]->note ? $lineItemsByKey[$key]->note . ' - ' : '') . $lineItem->note, ' -'); - } - - $order->setLineItems(array_values($lineItemsByKey)); - - return count($lineItems) > count($lineItemsByKey); - } - - /** - * Removes any line items from the cart that are no longer available. - * If a line item is available but the quantity is more than the available stock, - * the quantity will be reduced to the available stock. - * A notice will be added to the cart for each change. - * - * @param OrderElement $order - * @return void - * @throws InvalidConfigException - * @since 4.9.3 - */ - public static function normalizeLineItemPurchasableAvailability(OrderElement $order): void - { - if ($order->isCompleted) { - return; - } - - foreach ($order->getLineItems() as $lineItem) { - if ($lineItem->type !== LineItemType::Purchasable) { - continue; - } - - /* @var $purchasable Purchasable */ - $purchasable = $lineItem->getPurchasable(); - if (!$purchasable || !Plugin::getInstance()->getPurchasables()->isPurchasableAvailable($purchasable, $order)) { - $message = Craft::t('commerce', '{description} is no longer available.', ['description' => $lineItem->getDescription()]); - /** @var OrderNotice $notice */ - $notice = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => [ - 'message' => $message, - 'type' => 'lineItemRemoved', - 'attribute' => 'lineItems', - ], - ]); - $order->addNotice($notice); - $order->removeLineItem($lineItem); - } elseif ($purchasable::hasInventory() && - !$purchasable->getIsOutOfStockPurchasingAllowed() && - $purchasable->inventoryTracked && - ($lineItem->qty > $purchasable->getStock()) && - $purchasable->getStock() > 0 - ) { - $message = Craft::t('commerce', '{description} only has {stock} in stock.', ['description' => $lineItem->getDescription(), 'stock' => $purchasable->getStock()]); - /** @var OrderNotice $notice */ - $notice = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => [ - 'type' => 'lineItemSalePriceChanged', - 'attribute' => "lineItems.$lineItem->id.qty", - 'message' => $message, - ], - ]); - $order->addNotice($notice); - $lineItem->qty = $purchasable->getStock(); - } - } - } -} diff --git a/src/helpers/PaymentForm.php b/src/helpers/PaymentForm.php deleted file mode 100644 index c30cba785e..0000000000 --- a/src/helpers/PaymentForm.php +++ /dev/null @@ -1,41 +0,0 @@ - - * @since 4.0 - */ -class PaymentForm -{ - public const PAYMENT_FORM_NAMESPACE = 'paymentForm'; - - /** - * Generate the payment form namespace prefix. - * - * @param string $gatewayHandle - * @return string - */ - public static function getPaymentFormNamespace(string $gatewayHandle): string - { - return sprintf('%s[%s]', self::PAYMENT_FORM_NAMESPACE, $gatewayHandle); - } - - /** - * Generate the payment form namespace for retrieve request params. - * - * @param string $gatewayHandle - * @return string - */ - public static function getPaymentFormParamName(string $gatewayHandle): string - { - return sprintf('%s.%s', self::PAYMENT_FORM_NAMESPACE, $gatewayHandle); - } -} diff --git a/src/helpers/ProductQuery.php b/src/helpers/ProductQuery.php deleted file mode 100644 index d557e28e95..0000000000 --- a/src/helpers/ProductQuery.php +++ /dev/null @@ -1,103 +0,0 @@ - - * @since 5.5.0 - */ -class ProductQuery -{ - /** - * @param string $status - * @param string $tablePrefix - * @return array|false - */ - public static function statusCondition(string $status, string $tablePrefix = ''): array|false - { - // Always consider “now” to be the current time @ 59 seconds into the minute. - // This makes entry queries more cacheable, since they only change once every minute (https://github.com/craftcms/cms/issues/5389), - // while not excluding any entries that may have just been published in the past minute (https://github.com/craftcms/cms/issues/7853). - $now = new DateTime(); - $now->setTime((int)$now->format('H'), (int)$now->format('i'), 59); - $currentTimeDb = Db::prepareDateForDb($now); - - return match ($status) { - Product::STATUS_LIVE => [ - 'and', - [ - $tablePrefix . 'elements.enabled' => true, - $tablePrefix . 'elements_sites.enabled' => true, - ], - ['<=', 'commerce_products.postDate', $currentTimeDb], - [ - 'or', - ['commerce_products.expiryDate' => null], - ['>', 'commerce_products.expiryDate', $currentTimeDb], - ], - ], - Product::STATUS_PENDING => [ - 'and', - [ - $tablePrefix . 'elements.enabled' => true, - $tablePrefix . 'elements_sites.enabled' => true, - ], - ['>', 'commerce_products.postDate', $currentTimeDb], - ], - Product::STATUS_EXPIRED => [ - 'and', - [ - $tablePrefix . 'elements.enabled' => true, - $tablePrefix . 'elements_sites.enabled' => true, - ], - ['not', ['commerce_products.expiryDate' => null]], - ['<=', 'commerce_products.expiryDate', $currentTimeDb], - ], - - // Taken from base `ElementQuery::statusCondition()` - Element::STATUS_ENABLED => [ - $tablePrefix . 'elements.enabled' => true, - $tablePrefix . 'elements_sites.enabled' => true, - ], - Element::STATUS_DISABLED => [ - 'or', - [$tablePrefix . 'elements.enabled' => false], - [$tablePrefix . 'elements_sites.enabled' => false], - ], - Element::STATUS_ARCHIVED => [$tablePrefix . 'elements.archived' => true], - default => false, - }; - } - - /** - * @param array $criteria - * @return array - * @since 5.6.0 - */ - public static function cleanseQueryCriteria(array $criteria): array - { - // Figure out if creating the query has come from a request where params are passed to a controller action - $controller = Craft::$app->controller; - if ($controller instanceof ElementIndexesController || $controller instanceof ElementSearchController) { - $criteria = ElementHelper::cleanseQueryCriteria($criteria); - } - - return $criteria; - } -} diff --git a/src/helpers/ProjectConfigData.php b/src/helpers/ProjectConfigData.php deleted file mode 100755 index f1e430e1e6..0000000000 --- a/src/helpers/ProjectConfigData.php +++ /dev/null @@ -1,213 +0,0 @@ - - * @since 2.1.3 - */ -class ProjectConfigData -{ - /** - * @var bool - */ - private static $_processedStores = false; - - /** - * Ensure all stores are processed. - * - * @param bool $force - * @since 5.0.3 - */ - public static function ensureAllStoresProcessed(bool $force = false): void - { - $projectConfig = Craft::$app->getProjectConfig(); - - if (self::$_processedStores || (!$force && !$projectConfig->getIsApplyingExternalChanges())) { - return; - } - - self::$_processedStores = true; - - $allStores = $projectConfig->get(Stores::CONFIG_STORES_KEY, true) ?? []; - - foreach ($allStores as $uid => $storeData) { - // Ensure store is processed - $projectConfig->processConfigChanges(Stores::CONFIG_STORES_KEY . '.' . $uid, $force); - } - } - - /** - * Return a rebuilt project config array - */ - public static function rebuildProjectConfig(): array - { - $output = []; - - $output[self::_getProjectConfigKey(Emails::CONFIG_EMAILS_KEY)] = self::_getEmailData(); - $output[self::_getProjectConfigKey(Pdfs::CONFIG_PDFS_KEY)] = self::_getPdfData(); - $output[self::_getProjectConfigKey(Gateways::CONFIG_GATEWAY_KEY)] = self::_rebuildGatewayProjectConfig(); - $output[self::_getProjectConfigKey(Stores::CONFIG_STORES_KEY)] = self::_getStoresData(); - $output[self::_getProjectConfigKey(Stores::CONFIG_SITESTORES_KEY)] = self::_getSiteStoresData(); - - $orderFieldLayout = Craft::$app->getFields()->getLayoutByType(OrderElement::class); - - if ($orderFieldLayoutConfig = $orderFieldLayout->getConfig()) { - $output['orders'] = [ - 'fieldLayouts' => [ - $orderFieldLayout->uid => $orderFieldLayoutConfig, - ], - ]; - } - - $output[self::_getProjectConfigKey(OrderStatuses::CONFIG_STATUSES_KEY)] = self::_getStatusData(); - $output[self::_getProjectConfigKey(LineItemStatuses::CONFIG_STATUSES_KEY)] = self::_getLineItemStatusData(); - $output[self::_getProjectConfigKey(ProductTypes::CONFIG_PRODUCTTYPES_KEY)] = self::_getProductTypeData(); - - $subscriptionFieldLayout = Craft::$app->getFields()->getLayoutByType(Subscription::class); - - if ($subscriptionFieldLayoutConfig = $subscriptionFieldLayout->getConfig()) { - $output['subscriptions'] = [ - 'fieldLayouts' => [ - $subscriptionFieldLayout->uid => $subscriptionFieldLayoutConfig, - ], - ]; - } - - return array_filter($output); - } - - /** - * @param string $key - * @return string - * @since 5.0.0 - */ - private static function _getProjectConfigKey(string $key): string - { - $configKeyPrefix = 'commerce.'; - return substr($key, strlen($configKeyPrefix)); - } - - /** - * Return gateway data config array. - */ - private static function _rebuildGatewayProjectConfig(): array - { - $data = []; - foreach (Plugin::getInstance()->getGateways()->getAllGateways() as $gateway) { - $data[$gateway->uid] = $gateway->getConfig(); - } - return $data; - } - - /** - * Return stores data config array. - */ - private static function _getStoresData(): array - { - $data = []; - foreach (Plugin::getInstance()->getStores()->getAllStores() as $store) { - $data[$store->uid] = $store->getConfig(); - } - return $data; - } - - private static function _getSiteStoresData(): array - { - $data = []; - foreach (Plugin::getInstance()->getStores()->getAllSiteStores() as $siteStore) { - $data[$siteStore->uid] = $siteStore->getConfig(); - } - return $data; - } - - /** - * Return product type data config array. - */ - private static function _getProductTypeData(): array - { - $data = []; - foreach (Plugin::getInstance()->getProductTypes()->getAllProductTypes() as $productType) { - $data[$productType->uid] = $productType->getConfig(); - } - - return $data; - } - - /** - * Return email data config array. - */ - private static function _getEmailData(): array - { - $data = []; - Plugin::getInstance()->getStores()->getAllStores()->each(function(Store $store) use (&$data) { - foreach (Plugin::getInstance()->getEmails()->getAllEmails($store->id) as $email) { - $data[$email->uid] = $email->getConfig(); - } - }); - return $data; - } - - /** - * Return PDF data config array. - */ - private static function _getPdfData(): array - { - $data = []; - Plugin::getInstance()->getStores()->getAllStores()->each(function(Store $store) use (&$data) { - foreach (Plugin::getInstance()->getPdfs()->getAllPdfs($store->id) as $pdf) { - $data[$pdf->uid] = $pdf->getConfig(); - } - }); - return $data; - } - - /** - * Return line item status data config array. - */ - private static function _getLineItemStatusData(): array - { - $data = []; - Plugin::getInstance()->getStores()->getAllStores()->each(function(Store $store) use (&$data) { - foreach (Plugin::getInstance()->getLineItemStatuses()->getAllLineItemStatuses($store->id) as $status) { - $data[$status->uid] = $status->getConfig(); - } - }); - return $data; - } - - /** - * Return order status data config array. - */ - private static function _getStatusData(): array - { - $data = []; - Plugin::getInstance()->getStores()->getAllStores()->each(function(Store $store) use (&$data) { - foreach (Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses($store->id) as $status) { - $data[$status->uid] = $status->getConfig(); - } - }); - - return $data; - } -} diff --git a/src/helpers/Purchasable.php b/src/helpers/Purchasable.php deleted file mode 100644 index a1aa26f6c4..0000000000 --- a/src/helpers/Purchasable.php +++ /dev/null @@ -1,108 +0,0 @@ - - * @since 3.2.8 - */ -class Purchasable -{ - public const TEMPORARY_SKU_PREFIX = '__temp_'; - - /** - * Generates a new temporary SKU. - * - * @since 3.2.8 - */ - public static function tempSku(): string - { - return static::TEMPORARY_SKU_PREFIX . StringHelper::randomString(); - } - - /** - * Returns whether the given SKU is temporary. - * - * @since 3.2.8 - */ - public static function isTempSku(string $sku): bool - { - return str_starts_with($sku, static::TEMPORARY_SKU_PREFIX); - } - - /** - * @param int $purchasableId - * @param int $storeId - * @param Collection|null $catalogPricing - * @return string - * @throws SiteNotFoundException - * @throws InvalidConfigException - */ - public static function catalogPricingRulesTableByPurchasableId(int $purchasableId, int $storeId, ?Collection $catalogPricing = null): string - { - $catalogPricing ??= Plugin::getInstance()->getCatalogPricing()->getCatalogPricesByPurchasableId($purchasableId, $storeId); - $catalogPricingRules = Plugin::getInstance()->getCatalogPricingRules()->getAllCatalogPricingRulesByPurchasableId($purchasableId, $storeId); - - if ($catalogPricingRules->isEmpty()) { - return ''; - } - - return Cp::renderTemplate('commerce/prices/_table', [ - 'catalogPrices' => $catalogPricing, - 'showPurchasable' => false, - 'removeMargin' => true, - ]); - } - - /** - * @param string|null $value - * @param array $config - * @return string - * @since 5.0.0 - */ - public static function skuInputHtml(?string $value = null, array $config = []): string - { - $config += [ - 'id' => 'sku', - 'name' => 'sku', - 'value' => $value, - 'placeholder' => Craft::t('commerce', 'Enter SKU'), - 'class' => 'code', - ]; - - return Cp::textHtml($config); - } - - /** - * @param bool $value - * @param array $config - * @return string - * @since 5.0.0 - */ - public static function availableForPurchaseInputHtml(bool $value, array $config = []): string - { - $config += [ - 'id' => 'available-for-purchase', - 'name' => 'availableForPurchase', - 'small' => true, - 'on' => $value, - ]; - - return Cp::lightswitchHtml($config); - } -} diff --git a/src/linktypes/Product.php b/src/linktypes/Product.php deleted file mode 100644 index 272bb71ffc..0000000000 --- a/src/linktypes/Product.php +++ /dev/null @@ -1,52 +0,0 @@ - - * @since 5.1.0 - */ -class Product extends BaseElementLinkType -{ - protected static function elementType(): string - { - return ProductElement::class; - } - - protected function availableSourceKeys(): array - { - $sources = []; - $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); - $sites = Craft::$app->getSites()->getAllSites(); - - foreach ($productTypes as $productType) { - $siteSettings = $productType->getSiteSettings(); - foreach ($sites as $site) { - if (isset($siteSettings[$site->id]) && $siteSettings[$site->id]->hasUrls) { - $sources[] = "productType:$productType->uid"; - break; - } - } - } - - $sources = array_values(array_unique($sources)); - - if (!empty($sources)) { - array_unshift($sources, '*'); - } - - return $sources; - } -} diff --git a/src/migrations/Install.php b/src/migrations/Install.php deleted file mode 100644 index e160e9dc97..0000000000 --- a/src/migrations/Install.php +++ /dev/null @@ -1,1523 +0,0 @@ - - * @since 2.0 - */ -class Install extends Migration -{ - /** - * @inheritdoc - */ - public function safeUp(): bool - { - $this->createTables(); - $this->createIndexes(); - $this->addForeignKeys(); - $this->insertDefaultData(); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - $this->dropForeignKeys(); - $this->dropTables(); - $this->dropProjectConfig(); - - $this->delete(CraftTable::FIELDLAYOUTS, ['type' => [ - Order::class, - Product::class, - Variant::class, - Subscription::class, - Transfer::class, - ]]); - - return true; - } - - /** - * Creates the tables for Craft Commerce - */ - public function createTables(): void - { - $this->archiveTableIfExists(Table::CATALOG_PRICING_RULES); - $this->createTable(Table::CATALOG_PRICING_RULES, [ - 'id' => $this->primaryKey(), - 'name' => $this->string()->notNull(), - 'description' => $this->text(), - 'storeId' => $this->integer()->notNull(), - 'dateFrom' => $this->dateTime(), - 'dateTo' => $this->dateTime(), - 'apply' => $this->enum('apply', ['toPercent', 'toFlat', 'byPercent', 'byFlat'])->notNull(), - 'applyAmount' => $this->decimal(14, 4)->notNull(), - 'applyPriceType' => $this->enum('applyPriceType', [CatalogPricingRule::APPLY_PRICE_TYPE_PRICE, CatalogPricingRule::APPLY_PRICE_TYPE_PROMOTIONAL_PRICE])->notNull(), - 'productCondition' => $this->text(), - 'variantCondition' => $this->text(), - 'purchasableCondition' => $this->text(), - 'customerCondition' => $this->text(), - 'enabled' => $this->boolean()->notNull()->defaultValue(true), - 'isPromotionalPrice' => $this->boolean()->notNull()->defaultValue(false), - 'metadata' => $this->text(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::CATALOG_PRICING_RULES_USERS); - $this->createTable(Table::CATALOG_PRICING_RULES_USERS, [ - 'id' => $this->primaryKey(), - 'catalogPricingRuleId' => $this->integer()->notNull(), - 'userId' => $this->integer()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::CATALOG_PRICING); - $this->createTable(Table::CATALOG_PRICING, [ - 'id' => $this->primaryKey(), - 'price' => $this->decimal(14, 4), // @TODO Consider storing as string to avoid float-precision issues - 'purchasableId' => $this->integer()->notNull(), - 'storeId' => $this->integer(), - 'catalogPricingRuleId' => $this->integer(), - 'userId' => $this->integer(), - 'dateFrom' => $this->dateTime(), - 'dateTo' => $this->dateTime(), - 'isPromotionalPrice' => $this->boolean()->defaultValue(false), - 'hasUpdatePending' => $this->boolean()->defaultValue(false), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::CATALOG_PRICING_QUEUE); - $this->createTable(Table::CATALOG_PRICING_QUEUE, [ - 'id' => $this->primaryKey(), - 'storeId' => $this->integer(), - 'type' => $this->enum('type', [CatalogPricingQueue::TYPE_PURCHASABLE, CatalogPricingQueue::TYPE_RULE])->notNull(), - 'ids' => $this->mediumText(), - 'reserved' => $this->boolean()->notNull()->defaultValue(false), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::CUSTOMERS); - $this->createTable(Table::CUSTOMERS, [ - 'id' => $this->primaryKey(), // Not used in v4 but is the old customerId - 'customerId' => $this->integer()->notNull(), // This is the User element ID - 'primaryBillingAddressId' => $this->integer(), - 'primaryShippingAddressId' => $this->integer(), - 'primaryPaymentSourceId' => $this->integer(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::COUPONS); - $this->createTable(Table::COUPONS, [ - 'id' => $this->primaryKey(), - 'code' => $this->string(), - 'discountId' => $this->integer()->notNull(), - 'uses' => $this->integer()->notNull()->defaultValue(0), - 'maxUses' => $this->integer(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::CUSTOMER_DISCOUNTUSES); - $this->createTable(Table::CUSTOMER_DISCOUNTUSES, [ - 'id' => $this->primaryKey(), - 'discountId' => $this->integer()->notNull(), - 'customerId' => $this->integer()->notNull(), - 'uses' => $this->integer()->notNull()->unsigned(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::EMAIL_DISCOUNTUSES); - $this->createTable(Table::EMAIL_DISCOUNTUSES, [ - 'id' => $this->primaryKey(), - 'discountId' => $this->integer()->notNull(), - 'email' => $this->string()->notNull(), - 'uses' => $this->integer()->notNull()->unsigned(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::DISCOUNT_PURCHASABLES); - $this->createTable(Table::DISCOUNT_PURCHASABLES, [ - 'id' => $this->primaryKey(), - 'discountId' => $this->integer()->notNull(), - 'purchasableId' => $this->integer()->notNull(), - 'purchasableType' => $this->string()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - // @TODO Rename to `discount_entries` table in Commerce 6.0, or remove if the purchasable condition builder fully replaces it - $this->archiveTableIfExists(Table::DISCOUNT_CATEGORIES); - $this->createTable(Table::DISCOUNT_CATEGORIES, [ - 'id' => $this->primaryKey(), - 'discountId' => $this->integer()->notNull(), - 'categoryId' => $this->integer()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::DISCOUNTS); - $this->createTable(Table::DISCOUNTS, [ - 'id' => $this->primaryKey(), - 'storeId' => $this->integer()->notNull(), - 'name' => $this->string()->notNull(), - 'description' => $this->text(), - 'couponFormat' => $this->string(20)->notNull()->defaultValue(Coupons::DEFAULT_COUPON_FORMAT), - 'orderCondition' => $this->text(), - 'customerCondition' => $this->text(), - 'shippingAddressCondition' => $this->text(), - 'billingAddressCondition' => $this->text(), - 'requireCouponCode' => $this->boolean()->notNull()->defaultValue(false), - 'perUserLimit' => $this->integer()->notNull()->defaultValue(0)->unsigned(), - 'perEmailLimit' => $this->integer()->notNull()->defaultValue(0)->unsigned(), - 'totalDiscountUses' => $this->integer()->notNull()->defaultValue(0)->unsigned(), - 'totalDiscountUseLimit' => $this->integer()->notNull()->defaultValue(0)->unsigned(), - 'dateFrom' => $this->dateTime(), - 'dateTo' => $this->dateTime(), - 'purchaseQty' => $this->integer()->notNull()->defaultValue(0), - 'purchaseTotal' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'maxPurchaseQty' => $this->integer()->notNull()->defaultValue(0), - 'baseDiscount' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'perItemDiscount' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'percentDiscount' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'percentageOffSubject' => $this->enum('percentageOffSubject', ['original', 'discounted'])->notNull(), - 'excludeOnPromotion' => $this->boolean()->notNull()->defaultValue(false), - 'hasFreeShippingForMatchingItems' => $this->boolean()->notNull()->defaultValue(false), - 'hasFreeShippingForOrder' => $this->boolean()->notNull()->defaultValue(false), - 'allPurchasables' => $this->boolean()->notNull()->defaultValue(false), - 'purchasableIds' => $this->text(), - 'allCategories' => $this->boolean()->notNull()->defaultValue(false), - 'categoryIds' => $this->text(), - 'appliedTo' => $this->enum('appliedTo', ['matchingLineItems', 'allLineItems'])->notNull()->defaultValue('matchingLineItems'), - 'categoryRelationshipType' => $this->enum('categoryRelationshipType', ['element', 'sourceElement', 'targetElement'])->notNull()->defaultValue('element'), - 'orderConditionFormula' => $this->text(), - 'enabled' => $this->boolean()->notNull()->defaultValue(true), - 'stopProcessing' => $this->boolean()->notNull()->defaultValue(false), - 'ignorePromotions' => $this->boolean()->notNull()->defaultValue(false), - 'sortOrder' => $this->integer(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::DONATIONS); - $this->createTable(Table::DONATIONS, [ - 'id' => $this->primaryKey(), - 'sku' => $this->string()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::EMAILS); - $this->createTable(Table::EMAILS, [ - 'id' => $this->primaryKey(), - 'storeId' => $this->integer(), - 'name' => $this->string()->notNull(), - 'senderAddress' => $this->string(), - 'senderName' => $this->string(), - 'subject' => $this->string()->notNull(), - 'recipientType' => $this->enum('recipientType', ['customer', 'custom'])->defaultValue('custom'), - 'to' => $this->string(), - 'bcc' => $this->string(), - 'cc' => $this->string(), - 'replyTo' => $this->string(), - 'enabled' => $this->boolean()->notNull()->defaultValue(true), - 'templatePath' => $this->string()->notNull(), - 'plainTextTemplatePath' => $this->string(), - 'pdfId' => $this->integer(), - 'language' => $this->string(), - 'renderSiteId' => $this->integer(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::PDFS); - $this->createTable(Table::PDFS, [ - 'id' => $this->primaryKey(), - 'storeId' => $this->integer(), - 'name' => $this->string()->notNull(), - 'handle' => $this->string()->notNull(), - 'description' => $this->string(), - 'templatePath' => $this->string()->notNull(), - 'fileNameFormat' => $this->string(), - 'paperOrientation' => $this->string()->defaultValue('portrait'), - 'paperSize' => $this->string()->defaultValue('letter'), - 'enabled' => $this->boolean()->notNull()->defaultValue(true), - 'isDefault' => $this->boolean()->notNull()->defaultValue(false), - 'sortOrder' => $this->integer(), - 'language' => $this->string(), - 'linkExpiry' => $this->integer()->notNull()->defaultValue(86400), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::GATEWAYS); - $this->createTable(Table::GATEWAYS, [ - 'id' => $this->primaryKey(), - 'type' => $this->string()->notNull(), - 'name' => $this->string()->notNull(), - 'handle' => $this->string()->notNull(), - 'settings' => $this->text(), - 'paymentType' => $this->enum('paymentType', ['authorize', 'purchase'])->notNull()->defaultValue('purchase'), - 'isFrontendEnabled' => $this->string(500)->notNull()->defaultValue('1'), - 'orderCondition' => $this->text(), - 'shippingAddressCondition' => $this->text(), - 'billingAddressCondition' => $this->text(), - 'isArchived' => $this->boolean()->notNull()->defaultValue(false), - 'dateArchived' => $this->dateTime(), - 'sortOrder' => $this->integer(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::INVENTORYITEMS); - $this->createTable(Table::INVENTORYITEMS, [ - 'id' => $this->primaryKey(), - 'purchasableId' => $this->integer()->notNull(), - 'countryCodeOfOrigin' => $this->string(), - 'administrativeAreaCodeOfOrigin' => $this->string(), - 'harmonizedSystemCode' => $this->string(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::INVENTORYLOCATIONS); - $this->createTable(Table::INVENTORYLOCATIONS, [ - 'id' => $this->primaryKey(), - 'handle' => $this->string()->notNull(), - 'name' => $this->string()->notNull(), - 'addressId' => $this->integer(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'dateDeleted' => $this->dateTime(), - 'uid' => $this->uid(), - ]); - - //INVENTORYLOCATIONS_STORES - $this->archiveTableIfExists(Table::INVENTORYLOCATIONS_STORES); - $this->createTable(Table::INVENTORYLOCATIONS_STORES, [ - 'id' => $this->primaryKey(), - 'inventoryLocationId' => $this->integer()->notNull(), - 'storeId' => $this->integer()->notNull(), - 'sortOrder' => $this->integer(), // per store - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::INVENTORYTRANSACTIONS); - $this->createTable(Table::INVENTORYTRANSACTIONS, [ - 'id' => $this->primaryKey(), - 'inventoryLocationId' => $this->integer()->notNull(), - 'inventoryItemId' => $this->integer()->notNull(), - 'movementHash' => $this->string()->notNull(), - 'quantity' => $this->integer()->notNull(), - 'type' => $this->enum('type', [ - 'incoming', - 'available', - 'committed', - 'reserved', - 'damaged', - 'safety', - 'fulfilled', - 'qualityControl', - ])->notNull(), - 'note' => $this->string(), - 'transferId' => $this->integer(), // Can be null - 'lineItemId' => $this->integer(), // Can be null - 'userId' => $this->integer(), // Can be null - 'dateCreated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::LINEITEMS); - $this->createTable(Table::LINEITEMS, [ - 'id' => $this->primaryKey(), - 'orderId' => $this->integer()->notNull(), - 'type' => $this->enum('type', ['purchasable', 'custom'])->defaultValue('purchasable')->notNull(), - 'purchasableId' => $this->integer(), - 'taxCategoryId' => $this->integer()->notNull(), - 'shippingCategoryId' => $this->integer()->notNull(), - 'description' => $this->text(), - 'options' => $this->text(), - 'optionsSignature' => $this->string()->notNull(), - 'price' => $this->decimal(14, 4)->notNull()->unsigned(), - 'promotionalPrice' => $this->decimal(14, 4)->null()->unsigned(), - 'promotionalAmount' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'salePrice' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'sku' => $this->string(), - 'weight' => $this->decimal(14, 4)->notNull()->defaultValue(0)->unsigned(), - 'height' => $this->decimal(14, 4)->notNull()->defaultValue(0)->unsigned(), - 'length' => $this->decimal(14, 4)->notNull()->defaultValue(0)->unsigned(), - 'width' => $this->decimal(14, 4)->notNull()->defaultValue(0)->unsigned(), - 'subtotal' => $this->decimal(14, 4)->notNull()->defaultValue(0)->unsigned(), - 'total' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'qty' => $this->integer()->notNull()->unsigned(), - 'note' => $this->text(), - 'privateNote' => $this->text(), - 'hasFreeShipping' => $this->boolean(), - 'isPromotable' => $this->boolean(), - 'isShippable' => $this->boolean(), - 'isTaxable' => $this->boolean(), - 'snapshot' => $this->longText(), - 'lineItemStatusId' => $this->integer(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::LINEITEMSTATUSES); - $this->createTable(Table::LINEITEMSTATUSES, [ - 'id' => $this->primaryKey(), - 'storeId' => $this->integer(), - 'name' => $this->string()->notNull(), - 'handle' => $this->string()->notNull(), - 'color' => $this->enum('color', ['green', 'orange', 'red', 'blue', 'yellow', 'pink', 'purple', 'turquoise', 'light', 'grey', 'black'])->notNull()->defaultValue('green'), - 'isArchived' => $this->boolean()->notNull()->defaultValue(false), - 'dateArchived' => $this->dateTime(), - 'sortOrder' => $this->integer(), - 'default' => $this->boolean()->notNull()->defaultValue(false), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::ORDERADJUSTMENTS); - $this->createTable(Table::ORDERADJUSTMENTS, [ - 'id' => $this->primaryKey(), - 'orderId' => $this->integer()->notNull(), - 'lineItemId' => $this->integer(), - 'type' => $this->string()->notNull(), - 'name' => $this->string(), - 'description' => $this->string(), - 'amount' => $this->decimal(14, 4)->notNull(), - 'included' => $this->boolean()->notNull()->defaultValue(false), - 'isEstimated' => $this->boolean()->notNull()->defaultValue(false), - 'sourceSnapshot' => $this->longText(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::ORDERNOTICES); - $this->createTable(Table::ORDERNOTICES, [ - 'id' => $this->primaryKey(), - 'orderId' => $this->integer()->notNull(), - 'type' => $this->string(), - 'attribute' => $this->string(), - 'message' => $this->text(), - 'noticeType' => $this->string()->notNull()->defaultValue('customer'), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::ORDERHISTORIES); - $this->createTable(Table::ORDERHISTORIES, [ - 'id' => $this->primaryKey(), - 'orderId' => $this->integer()->notNull(), - 'userId' => $this->integer(), - 'userName' => $this->string(), - 'prevStatusId' => $this->integer(), - 'newStatusId' => $this->integer(), - 'message' => $this->text(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::ORDERS); - $this->createTable(Table::ORDERS, [ - 'id' => $this->integer()->notNull(), - 'storeId' => $this->integer()->notNull(), - 'billingAddressId' => $this->integer(), - 'shippingAddressId' => $this->integer(), - 'estimatedBillingAddressId' => $this->integer(), - 'estimatedShippingAddressId' => $this->integer(), - 'sourceShippingAddressId' => $this->integer(), - 'sourceBillingAddressId' => $this->integer(), - 'gatewayId' => $this->integer(), - 'paymentSourceId' => $this->integer(), - 'customerId' => $this->integer(), // Customer ID is a User element ID - 'customerDeleted' => $this->boolean()->notNull()->defaultValue(false), - 'orderStatusId' => $this->integer(), - 'number' => $this->string(32), - 'reference' => $this->string(), - 'couponCode' => $this->string(), - 'itemTotal' => $this->decimal(14, 4)->defaultValue(0), - 'itemSubtotal' => $this->decimal(14, 4)->defaultValue(0), - 'totalQty' => $this->integer()->unsigned(), - 'totalWeight' => $this->decimal(14, 4)->defaultValue(0)->unsigned(), - 'total' => $this->decimal(14, 4)->defaultValue(0), - 'totalPrice' => $this->decimal(14, 4)->defaultValue(0), - 'totalPaid' => $this->decimal(14, 4)->defaultValue(0), - 'totalDiscount' => $this->decimal(14, 4)->defaultValue(0), - 'totalTax' => $this->decimal(14, 4)->defaultValue(0), - 'totalTaxIncluded' => $this->decimal(14, 4)->defaultValue(0), - 'totalShippingCost' => $this->decimal(14, 4)->defaultValue(0), - 'paidStatus' => $this->enum('paidStatus', ['paid', 'partial', 'unpaid', 'overPaid']), - 'email' => $this->string(), - 'orderCompletedEmail' => $this->string(), - 'isCompleted' => $this->boolean()->notNull()->defaultValue(false), - 'dateOrdered' => $this->dateTime(), - 'datePaid' => $this->dateTime(), - 'dateFirstPaid' => $this->dateTime(), - 'dateAuthorized' => $this->dateTime(), - 'currency' => $this->string(), - 'paymentCurrency' => $this->string(), - 'lastIp' => $this->string(), - 'orderLanguage' => $this->string(12)->notNull(), - 'origin' => $this->enum('origin', ['web', 'cp', 'remote'])->notNull()->defaultValue('web'), - 'message' => $this->text(), - 'registerUserOnOrderComplete' => $this->boolean()->notNull()->defaultValue(false), - 'saveBillingAddressOnOrderComplete' => $this->boolean()->notNull()->defaultValue(false), - 'makePrimaryBillingAddress' => $this->boolean()->notNull()->defaultValue(false), - 'saveShippingAddressOnOrderComplete' => $this->boolean()->notNull()->defaultValue(false), - 'makePrimaryShippingAddress' => $this->boolean()->notNull()->defaultValue(false), - 'recalculationMode' => $this->enum('recalculationMode', ['all', 'none', 'adjustmentsOnly'])->notNull()->defaultValue('all'), - 'returnUrl' => $this->text(), - 'cancelUrl' => $this->text(), - 'shippingMethodHandle' => $this->string()->notNull()->defaultValue(''), - 'shippingMethodName' => $this->string()->notNull()->defaultValue(''), - 'orderSiteId' => $this->integer(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - 'PRIMARY KEY([[id]])', - ]); - - $this->archiveTableIfExists(Table::ORDERSTATUS_EMAILS); - $this->createTable(Table::ORDERSTATUS_EMAILS, [ - 'id' => $this->primaryKey(), - 'orderStatusId' => $this->integer()->notNull(), - 'emailId' => $this->integer()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::ORDERSTATUSES); - $this->createTable(Table::ORDERSTATUSES, [ - 'id' => $this->primaryKey(), - 'storeId' => $this->integer(), - 'name' => $this->string()->notNull(), - 'handle' => $this->string()->notNull(), - 'color' => $this->enum('color', ['green', 'orange', 'red', 'blue', 'yellow', 'pink', 'purple', 'turquoise', 'light', 'grey', 'black'])->notNull()->defaultValue('green'), - 'description' => $this->string(), - 'dateDeleted' => $this->dateTime(), - 'sortOrder' => $this->integer(), - 'default' => $this->boolean()->notNull()->defaultValue(false), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::PAYMENTCURRENCIES); - $this->createTable(Table::PAYMENTCURRENCIES, [ - 'id' => $this->primaryKey(), - 'storeId' => $this->integer()->notNull(), - 'iso' => $this->string(3)->notNull(), - 'primary' => $this->boolean()->notNull()->defaultValue(false), - 'rate' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::PAYMENTSOURCES); - $this->createTable(Table::PAYMENTSOURCES, [ - 'id' => $this->primaryKey(), - 'customerId' => $this->integer()->notNull(), - 'gatewayId' => $this->integer()->notNull(), - 'token' => $this->string()->notNull(), - 'description' => $this->string(), - 'response' => $this->text(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::PLANS); - $this->createTable(Table::PLANS, [ - 'id' => $this->primaryKey(), - 'gatewayId' => $this->integer(), - 'planInformationId' => $this->integer()->null(), - 'name' => $this->string()->notNull(), - 'handle' => $this->string()->notNull(), - 'reference' => $this->string()->notNull(), - 'enabled' => $this->boolean()->notNull()->defaultValue(false), - 'planData' => $this->text(), - 'isArchived' => $this->boolean()->notNull()->defaultValue(false), - 'dateArchived' => $this->dateTime(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'sortOrder' => $this->integer(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::PRODUCTS); - $this->createTable(Table::PRODUCTS, [ - 'id' => $this->integer()->notNull(), - 'typeId' => $this->integer(), - 'defaultVariantId' => $this->integer(), - 'postDate' => $this->dateTime(), - 'expiryDate' => $this->dateTime(), - 'defaultSku' => $this->string(), - 'defaultPrice' => $this->decimal(14, 4), - 'defaultHeight' => $this->decimal(14, 4), - 'defaultLength' => $this->decimal(14, 4), - 'defaultWidth' => $this->decimal(14, 4), - 'defaultWeight' => $this->decimal(14, 4), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - 'PRIMARY KEY([[id]])', - ]); - - $this->archiveTableIfExists(Table::PRODUCTTYPES); - $this->createTable(Table::PRODUCTTYPES, [ - 'id' => $this->primaryKey(), - 'isStructure' => $this->boolean()->notNull()->defaultValue(false), - 'maxLevels' => $this->smallInteger()->unsigned(), - 'defaultPlacement' => $this->enum('defaultPlacement', [ProductType::DEFAULT_PLACEMENT_BEGINNING, ProductType::DEFAULT_PLACEMENT_END])->defaultValue('end')->notNull(), - 'structureId' => $this->integer(), - 'fieldLayoutId' => $this->integer(), - 'variantFieldLayoutId' => $this->integer(), - 'name' => $this->string()->notNull(), - 'handle' => $this->string()->notNull(), - 'enableVersioning' => $this->boolean()->defaultValue(false)->notNull(), - 'maxVariants' => $this->integer(), - 'hasDimensions' => $this->boolean()->notNull()->defaultValue(false), - - // Variant title stuff - 'hasVariantTitleField' => $this->boolean()->notNull()->defaultValue(true), - 'variantTitleFormat' => $this->string()->notNull(), - 'variantTitleTranslationMethod' => $this->string()->defaultValue('site')->notNull(), - 'variantTitleTranslationKeyFormat' => $this->string(), - 'variantUiLabelFormat' => $this->string()->notNull()->defaultValue('{title}'), - - // Product title stuff - 'hasProductTitleField' => $this->boolean()->notNull()->defaultValue(true), - 'productTitleFormat' => $this->string(), - 'productTitleTranslationMethod' => $this->string()->defaultValue('site')->notNull(), - 'productTitleTranslationKeyFormat' => $this->string(), - 'productUiLabelFormat' => $this->string()->notNull()->defaultValue('{title}'), - - // Slug stuff - 'showSlugField' => $this->boolean()->notNull()->defaultValue(true), - 'slugTranslationMethod' => $this->string()->notNull()->defaultValue('site'), - 'slugTranslationKeyFormat' => $this->string(), - - 'propagationMethod' => $this->string()->defaultValue(PropagationMethod::All->value)->notNull(), - 'previewTargets' => $this->json(), - - 'skuFormat' => $this->string(), - 'descriptionFormat' => $this->string(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::PRODUCTTYPES_SITES); - $this->createTable(Table::PRODUCTTYPES_SITES, [ - 'id' => $this->primaryKey(), - 'productTypeId' => $this->integer()->notNull(), - 'siteId' => $this->integer()->notNull(), - 'uriFormat' => $this->text(), - 'template' => $this->string(500), - 'hasUrls' => $this->boolean()->notNull()->defaultValue(false), - 'enabledByDefault' => $this->boolean()->defaultValue(true)->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::PRODUCTTYPES_SHIPPINGCATEGORIES); - $this->createTable(Table::PRODUCTTYPES_SHIPPINGCATEGORIES, [ - 'id' => $this->primaryKey(), - 'productTypeId' => $this->integer()->notNull(), - 'shippingCategoryId' => $this->integer()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::PRODUCTTYPES_TAXCATEGORIES); - $this->createTable(Table::PRODUCTTYPES_TAXCATEGORIES, [ - 'id' => $this->primaryKey(), - 'productTypeId' => $this->integer()->notNull(), - 'taxCategoryId' => $this->integer()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::PURCHASABLES); - $this->createTable(Table::PURCHASABLES, [ - 'id' => $this->primaryKey(), - 'sku' => $this->string()->notNull(), - 'description' => $this->text(), - 'width' => $this->decimal(14, 4), - 'height' => $this->decimal(14, 4), - 'length' => $this->decimal(14, 4), - 'weight' => $this->decimal(14, 4), - 'taxCategoryId' => $this->integer()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::PURCHASABLES_STORES); - $this->createTable(Table::PURCHASABLES_STORES, [ - 'id' => $this->primaryKey(), - 'purchasableId' => $this->integer()->notNull(), - 'storeId' => $this->integer()->notNull(), - 'basePrice' => $this->decimal(14, 4), // @TODO Consider storing as string to avoid float-precision issues - 'basePromotionalPrice' => $this->decimal(14, 4), // @TODO Consider storing as string to avoid float-precision issues - 'promotable' => $this->boolean()->notNull()->defaultValue(false), - 'availableForPurchase' => $this->boolean()->notNull()->defaultValue(true), - 'freeShipping' => $this->boolean()->notNull()->defaultValue(true), - 'inventoryTracked' => $this->boolean()->notNull()->defaultValue(true), - 'allowOutOfStockPurchases' => $this->boolean()->notNull()->defaultValue(false), - 'stock' => $this->integer(), // This is a summary value used for searching and sorting - 'tracked' => $this->boolean()->notNull()->defaultValue(false), - 'minQty' => $this->integer(), - 'maxQty' => $this->integer(), - 'shippingCategoryId' => $this->integer()->null(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::SALE_PURCHASABLES); - $this->createTable(Table::SALE_PURCHASABLES, [ - 'id' => $this->primaryKey(), - 'saleId' => $this->integer()->notNull(), - 'purchasableId' => $this->integer()->notNull(), - 'purchasableType' => $this->string()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - // @TODO Rename to `sale_entries` table in Commerce 6.0, or remove if the purchasable condition builder fully replaces it - $this->archiveTableIfExists(Table::SALE_CATEGORIES); - $this->createTable(Table::SALE_CATEGORIES, [ - 'id' => $this->primaryKey(), - 'saleId' => $this->integer()->notNull(), - 'categoryId' => $this->integer()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::SALE_USERGROUPS); - $this->createTable(Table::SALE_USERGROUPS, [ - 'id' => $this->primaryKey(), - 'saleId' => $this->integer()->notNull(), - 'userGroupId' => $this->integer()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::SALES); - $this->createTable(Table::SALES, [ - 'id' => $this->primaryKey(), - 'name' => $this->string()->notNull(), - 'description' => $this->text(), - 'dateFrom' => $this->dateTime(), - 'dateTo' => $this->dateTime(), - 'apply' => $this->enum('apply', ['toPercent', 'toFlat', 'byPercent', 'byFlat'])->notNull(), - 'applyAmount' => $this->decimal(14, 4)->notNull(), - 'allGroups' => $this->boolean()->notNull()->defaultValue(false), - 'allPurchasables' => $this->boolean()->notNull()->defaultValue(false), - 'allCategories' => $this->boolean()->notNull()->defaultValue(false), - 'categoryRelationshipType' => $this->enum('categoryRelationshipType', ['element', 'sourceElement', 'targetElement'])->notNull()->defaultValue('element'), - 'enabled' => $this->boolean()->notNull()->defaultValue(true), - 'ignorePrevious' => $this->boolean()->notNull()->defaultValue(false), - 'stopProcessing' => $this->boolean()->notNull()->defaultValue(false), - 'sortOrder' => $this->integer(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::SHIPPINGCATEGORIES); - $this->createTable(Table::SHIPPINGCATEGORIES, [ - 'id' => $this->primaryKey(), - 'storeId' => $this->integer()->notNull(), - 'name' => $this->string()->notNull(), - 'handle' => $this->string()->notNull(), - 'icon' => $this->string(), - 'color' => $this->string(), - 'description' => $this->string(), - 'default' => $this->boolean()->notNull()->defaultValue(false), - 'dateDeleted' => $this->dateTime(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::SHIPPINGMETHODS); - $this->createTable(Table::SHIPPINGMETHODS, [ - 'id' => $this->primaryKey(), - 'storeId' => $this->integer()->notNull(), - 'name' => $this->string()->notNull(), - 'handle' => $this->string()->notNull(), - 'icon' => $this->string(), - 'color' => $this->string(), - 'orderCondition' => $this->text(), - 'customerCondition' => $this->text(), - 'enabled' => $this->boolean()->notNull()->defaultValue(true), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::SHIPPINGRULE_CATEGORIES); - $this->createTable(Table::SHIPPINGRULE_CATEGORIES, [ - 'id' => $this->primaryKey(), - 'shippingRuleId' => $this->integer(), - 'shippingCategoryId' => $this->integer(), - 'condition' => $this->enum('condition', ['allow', 'disallow', 'require'])->notNull(), - 'perItemRate' => $this->decimal(14, 4), - 'weightRate' => $this->decimal(14, 4), - 'percentageRate' => $this->decimal(14, 4), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::SHIPPINGRULES); - $this->createTable(Table::SHIPPINGRULES, [ - 'id' => $this->primaryKey(), - 'methodId' => $this->integer()->notNull(), - 'name' => $this->string()->notNull(), - 'description' => $this->string(), - 'priority' => $this->integer()->notNull()->defaultValue(0), - 'enabled' => $this->boolean()->notNull()->defaultValue(true), - 'orderConditionFormula' => $this->text(), - 'orderCondition' => $this->text(), - 'customerCondition' => $this->text(), - 'baseRate' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'perItemRate' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'weightRate' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'percentageRate' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'minRate' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'maxRate' => $this->decimal(14, 4)->notNull()->defaultValue(0), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::SHIPPINGZONES); - $this->createTable(Table::SHIPPINGZONES, [ - 'id' => $this->primaryKey(), - 'storeId' => $this->integer(), - 'name' => $this->string()->notNull(), - 'description' => $this->string(), - 'condition' => $this->text(), - 'default' => $this->boolean()->notNull()->defaultValue(false), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::SITESTORES); - $this->createTable(Table::SITESTORES, [ - 'siteId' => $this->integer(), - 'storeId' => $this->integer()->null(), // defaults to primary store in app - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - 'PRIMARY KEY([[siteId]])', - ]); - - $this->archiveTableIfExists(Table::STORES); - $this->createTable(Table::STORES, [ - 'id' => $this->primaryKey(), - 'name' => $this->string()->notNull(), - 'handle' => $this->string()->notNull(), - 'primary' => $this->boolean()->notNull(), - 'currency' => $this->string()->notNull()->defaultValue('USD'), - 'autoSetCartShippingMethodOption' => $this->string()->notNull()->defaultValue('false'), - 'autoSetNewCartAddresses' => $this->string()->notNull()->defaultValue('false'), - 'autoSetPaymentSource' => $this->string()->notNull()->defaultValue('false'), - 'allowEmptyCartOnCheckout' => $this->string()->notNull()->defaultValue('false'), - 'allowCheckoutWithoutPayment' => $this->string()->notNull()->defaultValue('false'), - 'allowPartialPaymentOnCheckout' => $this->string()->notNull()->defaultValue('false'), - 'requireShippingAddressAtCheckout' => $this->string()->notNull()->defaultValue('false'), - 'requireBillingAddressAtCheckout' => $this->string()->notNull()->defaultValue('false'), - 'requireShippingMethodSelectionAtCheckout' => $this->string()->notNull()->defaultValue('false'), - 'useBillingAddressForTax' => $this->string()->notNull()->defaultValue('false'), - 'validateOrganizationTaxIdAsVatId' => $this->string()->notNull()->defaultValue('false'), - 'orderReferenceFormat' => $this->string(), - 'freeOrderPaymentStrategy' => $this->string()->defaultValue('complete'), - 'minimumTotalPriceStrategy' => $this->string()->defaultValue('default'), - 'sortOrder' => $this->integer(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::STORESETTINGS); - $this->createTable(Table::STORESETTINGS, [ - 'id' => $this->integer()->notNull(), - 'locationAddressId' => $this->integer(), - 'countries' => $this->text(), - 'marketAddressCondition' => $this->text(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - 'PRIMARY KEY([[id]])', - ]); - - $this->archiveTableIfExists(Table::SUBSCRIPTIONS); - $this->createTable(Table::SUBSCRIPTIONS, [ - 'id' => $this->primaryKey(), - 'userId' => $this->integer()->notNull(), - 'planId' => $this->integer(), - 'gatewayId' => $this->integer(), - 'orderId' => $this->integer(), - 'reference' => $this->string()->notNull(), - 'subscriptionData' => $this->text(), - 'trialDays' => $this->integer()->notNull(), - 'nextPaymentDate' => $this->dateTime(), - 'hasStarted' => $this->boolean()->notNull()->defaultValue(true), - 'isSuspended' => $this->boolean()->notNull()->defaultValue(false), - 'dateSuspended' => $this->dateTime(), - 'isCanceled' => $this->boolean()->notNull()->defaultValue(false), - 'dateCanceled' => $this->dateTime(), - 'isExpired' => $this->boolean()->notNull()->defaultValue(false), - 'returnUrl' => $this->text(), - 'dateExpired' => $this->dateTime(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::TAXCATEGORIES); - $this->createTable(Table::TAXCATEGORIES, [ - 'id' => $this->primaryKey(), - 'name' => $this->string()->notNull(), - 'handle' => $this->string()->notNull(), - 'icon' => $this->string(), - 'color' => $this->string(), - 'description' => $this->string(), - 'default' => $this->boolean()->notNull()->defaultValue(false), - 'dateDeleted' => $this->dateTime(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::TAXRATES); - $this->createTable(Table::TAXRATES, [ - 'id' => $this->primaryKey(), - 'storeId' => $this->integer()->notNull(), - 'taxZoneId' => $this->integer(), - 'isEverywhere' => $this->boolean()->notNull()->defaultValue(true), - 'taxCategoryId' => $this->integer()->null(), - 'name' => $this->string()->notNull(), - 'code' => $this->string(), - 'rate' => $this->decimal(14, 10)->notNull(), - 'include' => $this->boolean()->notNull()->defaultValue(false), - 'isVat' => $this->boolean()->notNull()->defaultValue(false), // Remove in Commerce 6 - 'taxIdValidators' => $this->text(), - 'removeIncluded' => $this->boolean()->notNull()->defaultValue(false), - 'removeVatIncluded' => $this->boolean()->notNull()->defaultValue(false), - 'taxable' => $this->enum('taxable', ['purchasable', 'price', 'shipping', 'price_shipping', 'order_total_shipping', 'order_total_price'])->notNull(), - 'enabled' => $this->boolean()->defaultValue(true)->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::TAXZONES); - $this->createTable(Table::TAXZONES, [ - 'id' => $this->primaryKey(), - 'storeId' => $this->integer()->notNull(), - 'name' => $this->string()->notNull(), - 'description' => $this->string(), - 'condition' => $this->text(), - 'default' => $this->boolean()->notNull()->defaultValue(false), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::TRANSACTIONS); - $this->createTable(Table::TRANSACTIONS, [ - 'id' => $this->primaryKey(), - 'orderId' => $this->integer()->notNull(), - 'parentId' => $this->integer(), - 'gatewayId' => $this->integer(), - 'userId' => $this->integer(), // Stays as userId since it could be a logged-in user or store administrator. So not just a customer. - 'hash' => $this->string(32), - 'type' => $this->enum('type', ['authorize', 'capture', 'purchase', 'refund'])->notNull(), - 'amount' => $this->decimal(14, 4), - 'paymentAmount' => $this->decimal(14, 4), - 'currency' => $this->string(), - 'paymentCurrency' => $this->string(), - 'paymentRate' => $this->decimal(14, 4), - 'status' => $this->enum('status', ['pending', 'redirect', 'success', 'failed', 'processing'])->notNull(), - 'reference' => $this->string(), - 'code' => $this->string(), - 'message' => $this->text(), - 'note' => $this->mediumText(), - 'response' => $this->text(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::TRANSFERS); - $this->createTable(Table::TRANSFERS, [ - 'id' => $this->primaryKey(), - 'transferStatus' => $this->enum('transferStatus', [ - 'draft', - 'pending', - 'partial', - 'received', - ])->notNull(), - 'originLocationId' => $this->integer(), - 'destinationLocationId' => $this->integer(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::TRANSFERDETAILS); - $this->createTable(Table::TRANSFERDETAILS, [ - 'id' => $this->primaryKey(), - 'transferId' => $this->integer()->notNull(), - 'inventoryItemId' => $this->integer(), - 'inventoryItemDescription' => $this->string()->notNull(), - 'quantity' => $this->integer()->notNull(), - 'quantityAccepted' => $this->integer()->notNull(), - 'quantityRejected' => $this->integer()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->archiveTableIfExists(Table::VARIANTS); - $this->createTable(Table::VARIANTS, [ - 'id' => $this->integer()->notNull(), - 'primaryOwnerId' => $this->integer(), - 'isDefault' => $this->boolean()->notNull()->defaultValue(false), - 'deletedWithProduct' => $this->boolean()->notNull()->defaultValue(false), // @TODO Remove in Commerce 6.0 - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - 'PRIMARY KEY([[id]])', - ]); - } - - /** - * Drop the tables - */ - public function dropTables(): void - { - $tables = $this->_getAllTableNames(); - foreach ($tables as $table) { - $this->dropTableIfExists($table); - } - } - - /** - * Deletes the project config entry. - */ - public function dropProjectConfig(): void - { - Craft::$app->projectConfig->remove('commerce'); - } - - /** - * Creates the indexes. - */ - public function createIndexes(): void - { - $this->createIndex(null, Table::CATALOG_PRICING, 'catalogPricingRuleId', false); - $this->createIndex(null, Table::CATALOG_PRICING, 'isPromotionalPrice', false); - $this->createIndex(null, Table::CATALOG_PRICING, 'purchasableId', false); - $this->createIndex(null, Table::CATALOG_PRICING, 'storeId', false); - $this->createIndex(null, Table::CATALOG_PRICING, 'userId', false); - $this->createIndex(null, Table::CATALOG_PRICING, ['purchasableId', 'storeId', 'isPromotionalPrice', 'price', 'catalogPricingRuleId', 'dateFrom', 'dateTo'], false); - $this->createIndex(null, Table::CATALOG_PRICING, ['purchasableId', 'storeId', 'isPromotionalPrice', 'price'], false); - $this->createIndex(null, Table::CATALOG_PRICING, ['purchasableId', 'storeId'], false); - $this->createIndex(null, Table::CATALOG_PRICING_QUEUE, 'reserved', false); - $this->createIndex(null, Table::CATALOG_PRICING_QUEUE, ['storeId', 'type', 'reserved'], false); - $this->createIndex(null, Table::CATALOG_PRICING_RULES, 'storeId', false); - $this->createIndex(null, Table::CATALOG_PRICING_RULES_USERS, 'catalogPricingRuleId', false); - $this->createIndex(null, Table::CATALOG_PRICING_RULES_USERS, 'userId', false); - $this->createIndex(null, Table::COUPONS, 'code', false); - $this->createIndex(null, Table::COUPONS, 'discountId', false); - $this->createIndex(null, Table::CUSTOMERS, 'customerId', true); - $this->createIndex(null, Table::CUSTOMERS, 'primaryBillingAddressId', false); - $this->createIndex(null, Table::CUSTOMERS, 'primaryPaymentSourceId', false); - $this->createIndex(null, Table::CUSTOMERS, 'primaryShippingAddressId', false); - $this->createIndex(null, Table::CUSTOMER_DISCOUNTUSES, 'discountId', false); - $this->createIndex(null, Table::CUSTOMER_DISCOUNTUSES, ['customerId', 'discountId'], true); - $this->createIndex(null, Table::DISCOUNTS, 'dateFrom', false); - $this->createIndex(null, Table::DISCOUNTS, 'dateTo', false); - $this->createIndex(null, Table::DISCOUNT_CATEGORIES, 'categoryId', false); - $this->createIndex(null, Table::DISCOUNT_CATEGORIES, ['discountId', 'categoryId'], true); - $this->createIndex(null, Table::DISCOUNT_PURCHASABLES, 'purchasableId', false); - $this->createIndex(null, Table::DISCOUNT_PURCHASABLES, ['discountId', 'purchasableId'], true); - $this->createIndex(null, Table::EMAILS, 'storeId', false); - $this->createIndex(null, Table::EMAIL_DISCOUNTUSES, ['discountId'], false); - $this->createIndex(null, Table::EMAIL_DISCOUNTUSES, ['email', 'discountId'], true); - $this->createIndex(null, Table::GATEWAYS, 'handle', false); - $this->createIndex(null, Table::GATEWAYS, 'isArchived', false); - $this->createIndex(null, Table::INVENTORYITEMS, 'purchasableId', true); - $this->createIndex(null, Table::INVENTORYTRANSACTIONS, 'inventoryItemId', false); - $this->createIndex(null, Table::INVENTORYTRANSACTIONS, 'lineItemId', false); - $this->createIndex(null, Table::INVENTORYTRANSACTIONS, 'transferId', false); - $this->createIndex(null, Table::INVENTORYTRANSACTIONS, 'userId', false); - $this->createIndex(null, Table::LINEITEMS, 'purchasableId', false); - $this->createIndex(null, Table::LINEITEMS, 'shippingCategoryId', false); - $this->createIndex(null, Table::LINEITEMS, 'taxCategoryId', false); - $this->createIndex(null, Table::LINEITEMS, ['orderId', 'purchasableId', 'optionsSignature'], true); - $this->createIndex(null, Table::LINEITEMSTATUSES, 'storeId', false); - $this->createIndex(null, Table::ORDERADJUSTMENTS, 'orderId', false); - $this->createIndex(null, Table::ORDERHISTORIES, 'newStatusId', false); - $this->createIndex(null, Table::ORDERHISTORIES, 'orderId', false); - $this->createIndex(null, Table::ORDERHISTORIES, 'prevStatusId', false); - $this->createIndex(null, Table::ORDERHISTORIES, 'userId', false); - $this->createIndex(null, Table::ORDERNOTICES, 'orderId', false); - $this->createIndex(null, Table::ORDERS, 'billingAddressId', false); - $this->createIndex(null, Table::ORDERS, 'customerId', false); - $this->createIndex(null, Table::ORDERS, 'email', false); - $this->createIndex(null, Table::ORDERS, 'estimatedBillingAddressId', false); - $this->createIndex(null, Table::ORDERS, 'estimatedShippingAddressId', false); - $this->createIndex(null, Table::ORDERS, 'gatewayId', false); - $this->createIndex(null, Table::ORDERS, 'number', true); - $this->createIndex(null, Table::ORDERS, 'orderStatusId', false); - $this->createIndex(null, Table::ORDERS, 'reference', false); - $this->createIndex(null, Table::ORDERS, 'shippingAddressId', false); - $this->createIndex(null, Table::ORDERS, 'sourceBillingAddressId', false); - $this->createIndex(null, Table::ORDERS, 'sourceShippingAddressId', false); - $this->createIndex(null, Table::ORDERS, 'storeId', false); - $this->createIndex(null, Table::ORDERSTATUSES, 'storeId', false); - $this->createIndex(null, Table::ORDERSTATUS_EMAILS, 'emailId', false); - $this->createIndex(null, Table::ORDERSTATUS_EMAILS, 'orderStatusId', false); - $this->createIndex(null, Table::PAYMENTCURRENCIES, 'iso', false); - $this->createIndex(null, Table::PDFS, 'handle', false); - $this->createIndex(null, Table::PDFS, 'storeId', false); - $this->createIndex(null, Table::PLANS, 'gatewayId', false); - $this->createIndex(null, Table::PLANS, 'handle', true); - $this->createIndex(null, Table::PLANS, 'reference', false); - $this->createIndex(null, Table::PRODUCTS, 'expiryDate', false); - $this->createIndex(null, Table::PRODUCTS, 'postDate', false); - $this->createIndex(null, Table::PRODUCTS, 'typeId', false); - $this->createIndex(null, Table::PRODUCTTYPES, 'structureId', false); - $this->createIndex(null, Table::PRODUCTTYPES, 'fieldLayoutId', false); - $this->createIndex(null, Table::PRODUCTTYPES, 'handle', true); - $this->createIndex(null, Table::PRODUCTTYPES, 'variantFieldLayoutId', false); - $this->createIndex(null, Table::PRODUCTTYPES_SHIPPINGCATEGORIES, 'shippingCategoryId', false); - $this->createIndex(null, Table::PRODUCTTYPES_SHIPPINGCATEGORIES, ['productTypeId', 'shippingCategoryId'], true); - $this->createIndex(null, Table::PRODUCTTYPES_SITES, 'siteId', false); - $this->createIndex(null, Table::PRODUCTTYPES_SITES, ['productTypeId', 'siteId'], true); - $this->createIndex(null, Table::PRODUCTTYPES_TAXCATEGORIES, 'taxCategoryId', false); - $this->createIndex(null, Table::PRODUCTTYPES_TAXCATEGORIES, ['productTypeId', 'taxCategoryId'], true); - $this->createIndex(null, Table::PURCHASABLES, 'sku', false); // Application layer enforces unique - $this->createIndex(null, Table::PURCHASABLES_STORES, 'purchasableId', false); // Application layer enforces unique - $this->createIndex(null, Table::PURCHASABLES_STORES, 'storeId', false); // Application layer enforces unique - $this->createIndex(null, Table::SALE_CATEGORIES, 'categoryId', false); - $this->createIndex(null, Table::SALE_CATEGORIES, ['saleId', 'categoryId'], true); - $this->createIndex(null, Table::SALE_PURCHASABLES, 'purchasableId', false); - $this->createIndex(null, Table::SALE_PURCHASABLES, ['saleId', 'purchasableId'], true); - $this->createIndex(null, Table::SALE_USERGROUPS, 'userGroupId', false); - $this->createIndex(null, Table::SALE_USERGROUPS, ['saleId', 'userGroupId'], true); - $this->createIndex(null, Table::SHIPPINGCATEGORIES, 'storeId', false); - $this->createIndex(null, Table::SHIPPINGMETHODS, 'name', false); - $this->createIndex(null, Table::SHIPPINGMETHODS, 'storeId', false); - $this->createIndex(null, Table::SHIPPINGRULES, 'methodId', false); - $this->createIndex(null, Table::SHIPPINGRULES, 'name', false); - $this->createIndex(null, Table::SHIPPINGRULE_CATEGORIES, 'shippingCategoryId', false); - $this->createIndex(null, Table::SHIPPINGRULE_CATEGORIES, 'shippingRuleId', false); - $this->createIndex(null, Table::SHIPPINGZONES, 'name', false); - $this->createIndex(null, Table::SHIPPINGZONES, 'storeId', false); - $this->createIndex(null, Table::SUBSCRIPTIONS, 'dateCreated', false); - $this->createIndex(null, Table::SUBSCRIPTIONS, 'dateExpired', false); - $this->createIndex(null, Table::SUBSCRIPTIONS, 'gatewayId', false); - $this->createIndex(null, Table::SUBSCRIPTIONS, 'nextPaymentDate', false); - $this->createIndex(null, Table::SUBSCRIPTIONS, 'planId', false); - $this->createIndex(null, Table::SUBSCRIPTIONS, 'reference', true); - $this->createIndex(null, Table::SUBSCRIPTIONS, 'userId', false); - $this->createIndex(null, Table::TAXRATES, 'storeId', false); - $this->createIndex(null, Table::TAXRATES, 'taxCategoryId', false); - $this->createIndex(null, Table::TAXRATES, 'taxZoneId', false); - $this->createIndex(null, Table::TAXZONES, 'name', false); - $this->createIndex(null, Table::TAXZONES, 'storeId', false); - $this->createIndex(null, Table::TRANSACTIONS, 'gatewayId', false); - $this->createIndex(null, Table::TRANSACTIONS, 'orderId', false); - $this->createIndex(null, Table::TRANSACTIONS, 'parentId', false); - $this->createIndex(null, Table::TRANSACTIONS, 'userId', false); - $this->createIndex(null, Table::TRANSACTIONS, 'hash', false); - $this->createIndex(null, Table::TRANSFERS, 'destinationLocationId', false); - $this->createIndex(null, Table::TRANSFERS, 'originLocationId', false); - $this->createIndex(null, Table::TRANSFERDETAILS, 'transferId', false); - $this->createIndex(null, Table::TRANSFERDETAILS, 'inventoryItemId', false); - $this->createIndex(null, Table::VARIANTS, 'primaryOwnerId', false); - } - - /** - * Adds the foreign keys. - */ - public function addForeignKeys(): void - { - $this->addForeignKey(null, Table::CATALOG_PRICING, ['catalogPricingRuleId'], Table::CATALOG_PRICING_RULES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::CATALOG_PRICING, ['purchasableId'], Table::PURCHASABLES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::CATALOG_PRICING, ['storeId'], Table::STORES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::CATALOG_PRICING, ['userId'], CraftTable::USERS, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::CATALOG_PRICING_QUEUE, ['storeId'], Table::STORES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::CATALOG_PRICING_RULES, ['storeId'], Table::STORES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::CATALOG_PRICING_RULES_USERS, ['catalogPricingRuleId'], Table::CATALOG_PRICING_RULES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::CATALOG_PRICING_RULES_USERS, ['userId'], CraftTable::USERS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::COUPONS, ['discountId'], Table::DISCOUNTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::CUSTOMERS, ['customerId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::CUSTOMERS, ['primaryBillingAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::CUSTOMERS, ['primaryPaymentSourceId'], Table::PAYMENTSOURCES, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::CUSTOMERS, ['primaryShippingAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::CUSTOMER_DISCOUNTUSES, ['customerId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::CUSTOMER_DISCOUNTUSES, ['discountId'], Table::DISCOUNTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::DISCOUNTS, 'storeId', Table::STORES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::DISCOUNT_CATEGORIES, ['categoryId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::DISCOUNT_CATEGORIES, ['discountId'], Table::DISCOUNTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::DISCOUNT_PURCHASABLES, ['discountId'], Table::DISCOUNTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::DISCOUNT_PURCHASABLES, ['purchasableId'], Table::PURCHASABLES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::DONATIONS, ['id'], '{{%elements}}', ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::EMAILS, ['pdfId'], Table::PDFS, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::EMAILS, ['storeId'], Table::STORES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::EMAILS, ['renderSiteId'], CraftTable::SITES, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::EMAIL_DISCOUNTUSES, ['discountId'], Table::DISCOUNTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::INVENTORYITEMS, 'purchasableId', Table::PURCHASABLES, 'id', 'CASCADE', null); - $this->addForeignKey(null, Table::INVENTORYLOCATIONS, 'addressId', CraftTable::ELEMENTS, 'id', 'CASCADE', null); - $this->addForeignKey(null, Table::INVENTORYLOCATIONS_STORES, 'inventoryLocationId', Table::INVENTORYLOCATIONS, 'id', 'CASCADE', null); - $this->addForeignKey(null, Table::INVENTORYLOCATIONS_STORES, 'storeId', Table::STORES, 'id', 'CASCADE', null); - $this->addForeignKey(null, Table::INVENTORYTRANSACTIONS, 'inventoryItemId', Table::INVENTORYITEMS, 'id', 'CASCADE', null); - $this->addForeignKey(null, Table::INVENTORYTRANSACTIONS, 'inventoryLocationId', Table::INVENTORYLOCATIONS, 'id', 'CASCADE', null); - $this->addForeignKey(null, Table::INVENTORYTRANSACTIONS, 'lineItemId', Table::LINEITEMS, 'id', 'CASCADE', null); - $this->addForeignKey(null, Table::INVENTORYTRANSACTIONS, 'transferId', Table::TRANSFERS, 'id', 'SET NULL', null); - $this->addForeignKey(null, Table::INVENTORYTRANSACTIONS, 'userId', CraftTable::USERS, 'id', 'SET NULL', null); - $this->addForeignKey(null, Table::INVENTORYTRANSACTIONS, 'transferId', Table::TRANSFERS, 'id', 'SET NULL', null); - $this->addForeignKey(null, Table::LINEITEMS, ['orderId'], Table::ORDERS, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::LINEITEMS, ['purchasableId'], '{{%elements}}', ['id'], 'SET NULL', 'CASCADE'); - $this->addForeignKey(null, Table::LINEITEMS, ['shippingCategoryId'], Table::SHIPPINGCATEGORIES, ['id'], null, 'CASCADE'); - $this->addForeignKey(null, Table::LINEITEMS, ['taxCategoryId'], Table::TAXCATEGORIES, ['id'], null, 'CASCADE'); - $this->addForeignKey(null, Table::LINEITEMSTATUSES, ['storeId'], Table::STORES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::ORDERADJUSTMENTS, ['orderId'], Table::ORDERS, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::ORDERHISTORIES, ['newStatusId'], Table::ORDERSTATUSES, ['id'], 'RESTRICT', 'CASCADE'); - $this->addForeignKey(null, Table::ORDERHISTORIES, ['orderId'], Table::ORDERS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::ORDERHISTORIES, ['prevStatusId'], Table::ORDERSTATUSES, ['id'], 'RESTRICT', 'CASCADE'); - $this->addForeignKey(null, Table::ORDERHISTORIES, ['userId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::ORDERNOTICES, ['orderId'], Table::ORDERS, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::ORDERS, ['billingAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::ORDERS, ['customerId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::ORDERS, ['estimatedBillingAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::ORDERS, ['estimatedShippingAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::ORDERS, ['gatewayId'], Table::GATEWAYS, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::ORDERS, ['id'], '{{%elements}}', ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::ORDERS, ['orderStatusId'], Table::ORDERSTATUSES, ['id'], 'RESTRICT', 'CASCADE'); - $this->addForeignKey(null, Table::ORDERS, ['paymentSourceId'], Table::PAYMENTSOURCES, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::ORDERS, ['shippingAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::ORDERS, ['storeId'], Table::STORES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::ORDERSTATUSES, ['storeId'], Table::STORES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::ORDERSTATUS_EMAILS, ['emailId'], Table::EMAILS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::ORDERSTATUS_EMAILS, ['orderStatusId'], Table::ORDERSTATUSES, ['id'], 'RESTRICT', 'CASCADE'); - $this->addForeignKey(null, Table::PAYMENTCURRENCIES, 'storeId', Table::STORES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::PAYMENTSOURCES, ['customerId'], CraftTable::ELEMENTS, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::PAYMENTSOURCES, ['gatewayId'], Table::GATEWAYS, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::PDFS, ['storeId'], Table::STORES, ['id'], 'CASCADE', null); - $this->addForeignKey(null, Table::PLANS, ['gatewayId'], Table::GATEWAYS, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::PLANS, ['planInformationId'], '{{%elements}}', 'id', 'SET NULL'); - $this->addForeignKey(null, Table::PRODUCTS, ['id'], '{{%elements}}', ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::PRODUCTS, ['typeId'], Table::PRODUCTTYPES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::PRODUCTS, ['defaultVariantId'], '{{%elements}}', ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::PRODUCTTYPES, ['fieldLayoutId'], '{{%fieldlayouts}}', ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::PRODUCTTYPES, ['variantFieldLayoutId'], '{{%fieldlayouts}}', ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::PRODUCTTYPES, ['structureId'], CraftTable::STRUCTURES, ['id'], 'SET NULL', null); - $this->addForeignKey(null, Table::PRODUCTTYPES_SHIPPINGCATEGORIES, ['productTypeId'], Table::PRODUCTTYPES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::PRODUCTTYPES_SHIPPINGCATEGORIES, ['shippingCategoryId'], Table::SHIPPINGCATEGORIES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::PRODUCTTYPES_SITES, ['productTypeId'], Table::PRODUCTTYPES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::PRODUCTTYPES_SITES, ['siteId'], '{{%sites}}', ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::PRODUCTTYPES_TAXCATEGORIES, ['productTypeId'], Table::PRODUCTTYPES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::PRODUCTTYPES_TAXCATEGORIES, ['taxCategoryId'], Table::TAXCATEGORIES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::PURCHASABLES, ['id'], '{{%elements}}', ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::PURCHASABLES, ['taxCategoryId'], Table::TAXCATEGORIES, ['id']); - $this->addForeignKey(null, Table::PURCHASABLES_STORES, ['purchasableId'], Table::PURCHASABLES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::PURCHASABLES_STORES, ['shippingCategoryId'], Table::SHIPPINGCATEGORIES, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::PURCHASABLES_STORES, ['purchasableId'], Table::PURCHASABLES, ['id'],'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::PURCHASABLES_STORES, ['storeId'], Table::STORES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::SALE_CATEGORIES, ['categoryId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::SALE_CATEGORIES, ['saleId'], Table::SALES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::SALE_PURCHASABLES, ['purchasableId'], Table::PURCHASABLES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::SALE_PURCHASABLES, ['saleId'], Table::SALES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::SALE_USERGROUPS, ['saleId'], Table::SALES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::SALE_USERGROUPS, ['userGroupId'], '{{%usergroups}}', ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::SHIPPINGCATEGORIES, ['storeId'], Table::STORES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::SHIPPINGMETHODS, ['storeId'], Table::STORES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::SHIPPINGRULES, ['methodId'], Table::SHIPPINGMETHODS, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::SHIPPINGRULE_CATEGORIES, ['shippingCategoryId'], Table::SHIPPINGCATEGORIES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::SHIPPINGRULE_CATEGORIES, ['shippingRuleId'], Table::SHIPPINGRULES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::SHIPPINGZONES, ['storeId'], Table::STORES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::STORESETTINGS, ['locationAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::STORESETTINGS, ['id'], Table::STORES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::SUBSCRIPTIONS, ['gatewayId'], Table::GATEWAYS, ['id'], 'RESTRICT'); - $this->addForeignKey(null, Table::SUBSCRIPTIONS, ['id'], '{{%elements}}', ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::SUBSCRIPTIONS, ['orderId'], Table::ORDERS, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::SUBSCRIPTIONS, ['planId'], Table::PLANS, ['id'], 'RESTRICT'); - $this->addForeignKey(null, Table::SUBSCRIPTIONS, ['userId'], CraftTable::USERS, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::TAXRATES, ['storeId'], Table::STORES, ['id'], 'CASCADE', null); - $this->addForeignKey(null, Table::TAXRATES, ['taxCategoryId'], Table::TAXCATEGORIES, ['id'], null, 'CASCADE'); - $this->addForeignKey(null, Table::TAXRATES, ['taxZoneId'], Table::TAXZONES, ['id'], null, 'CASCADE'); - $this->addForeignKey(null, Table::TAXZONES, ['storeId'], Table::STORES, ['id'], 'CASCADE', null); - $this->addForeignKey(null, Table::TRANSACTIONS, ['gatewayId'], Table::GATEWAYS, ['id'], null, 'CASCADE'); - $this->addForeignKey(null, Table::TRANSACTIONS, ['orderId'], Table::ORDERS, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::TRANSACTIONS, ['parentId'], Table::TRANSACTIONS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::TRANSACTIONS, ['userId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - $this->addForeignKey(null, Table::TRANSFERS, 'id', CraftTable::ELEMENTS, 'id', 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::TRANSFERDETAILS, 'transferId', Table::TRANSFERS, 'id', 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::TRANSFERDETAILS, 'inventoryItemId', Table::INVENTORYITEMS, 'id', 'SET NULL', 'CASCADE'); - $this->addForeignKey(null, Table::VARIANTS, ['id'], '{{%elements}}', ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::VARIANTS, ['primaryOwnerId'], Table::PRODUCTS, ['id'], 'CASCADE'); - } - - /** - * Removes the foreign keys. - */ - public function dropForeignKeys(): void - { - $tables = $this->_getAllTableNames(); - - foreach ($tables as $table) { - $this->_dropForeignKeyToAndFromTable($table); - } - } - - /** - * Insert the default data. - */ - public function insertDefaultData(): void - { - // Don't make the same config changes twice - $projectConfig = Craft::$app->getProjectConfig(); - $installedInProjectConfig = ($projectConfig->get('plugins.commerce', true) !== null); - $configExists = ($projectConfig->get('commerce', true) !== null); - - if (!$installedInProjectConfig && !$configExists) { - $this->_insertPrimaryStore(); - $this->_defaultGateways(); - } elseif ($installedInProjectConfig) { - - // Start fix for a bad commerce project config from the 5.0.0-beta.1 - // @TODO Remove this fix-up for the 5.0.0-beta.1 bad store key in Commerce 6.0 - $commerce = $projectConfig->get('commerce', true); - - foreach (array_keys($commerce) as $key) { - // Look for the bad store key - if (StringHelper::startsWith('stores',$key) && StringHelper::length($key) > 6) { - $uid = substr($key, 7); - // Move the data to the correct location for stores - $projectConfig->set(Stores::CONFIG_STORES_KEY . '.' . $uid, $commerce[$key]); - } - } - // Finish fix for a bad commerce project config from the 5.0.0-beta.1 - - // Install a primary store if it isn't in the config - $stores = $projectConfig->get(Stores::CONFIG_STORES_KEY, true); - if (!$configExists || !$stores || !ArrayHelper::firstWhere($stores, 'primary', true)) { - $this->_insertPrimaryStore(); - } - - // Install the default gateways if they aren't in the config - $gateways = $projectConfig->get(Gateways::CONFIG_GATEWAY_KEY, true); - if (!$configExists || !$gateways) { - $this->_defaultGateways(); - } - } - - // The following defaults are not stored in the project config. - $this->_defaultTaxCategories(); - $this->_defaultInventoryLocation(); - } - - /** - * Add a default Tax category. - */ - private function _defaultTaxCategories(): void - { - $data = [ - 'name' => 'General', - 'handle' => 'general', - 'default' => true, - ]; - $this->insert(TaxCategory::tableName(), $data); - } - - /** - * Add a default Inventory Location. - */ - private function _defaultInventoryLocation(): void - { - $inventoryLocation = new InventoryLocation(); - $inventoryLocation->name = 'Default'; - $inventoryLocation->handle = 'default'; - $inventoryLocation->save(false); - - // get primary store from db query - $storeId = (new Query()) - ->select(['id']) - ->from(Table::STORES) - ->where(['primary' => true]) - ->scalar(); - - if ($storeId) { - $this->insert(Table::INVENTORYLOCATIONS_STORES, [ - 'inventoryLocationId' => $inventoryLocation->id, - 'storeId' => $storeId, - 'sortOrder' => 1, - 'dateCreated' => Db::prepareDateForDb(new \DateTime()), - 'dateUpdated' => Db::prepareDateForDb(new \DateTime()), - ]); - } - } - - private function _insertPrimaryStore(): void - { - $store = Craft::createObject([ - 'class' => Store::class, - 'name' => 'Primary', - 'handle' => 'primary', - 'primary' => true, - 'currency' => 'USD', - ]); - - Plugin::getInstance()->getStores()->saveStore($store); - - foreach (Craft::$app->getSites()->getAllSites() as $site) { - $siteStore = Craft::createObject([ - 'class' => SiteStore::class, - 'siteId' => $site->id, - 'storeId' => $store->id, - ]); - Plugin::getInstance()->getStores()->saveSiteStore($siteStore, false); - } - } - - /** - * Add a payment method. - */ - private function _defaultGateways(): void - { - $data = [ - 'name' => 'Dummy', - 'handle' => 'dummy', - 'isFrontendEnabled' => true, - 'orderCondition' => [], - 'isArchived' => false, - ]; - $gateway = new Dummy($data); - Plugin::getInstance()->getGateways()->saveGateway($gateway); - } - - /** - * Returns if the table exists. - * - * @param string $tableName - * @return bool If the table exists. - * @throws NotSupportedException - */ - private function _tableExists(string $tableName): bool - { - $schema = $this->db->getSchema(); - $schema->refresh(); - - $rawTableName = $schema->getRawTableName($tableName); - $table = $schema->getTableSchema($rawTableName); - - return (bool)$table; - } - - /** - * @param $tableName - * @throws NotSupportedException - */ - private function _dropForeignKeyToAndFromTable($tableName): void - { - if ($this->_tableExists($tableName)) { - $this->dropAllForeignKeysToTable($tableName); - MigrationHelper::dropAllForeignKeysOnTable($tableName, $this); - } - } - - /** - * @return string[] - */ - private function _getAllTableNames(): array - { - $class = new ReflectionClass(Table::class); - return $class->getConstants(); - } -} diff --git a/src/migrations/m210614_073359_detailed_permission.php b/src/migrations/m210614_073359_detailed_permission.php deleted file mode 100644 index 746f598832..0000000000 --- a/src/migrations/m210614_073359_detailed_permission.php +++ /dev/null @@ -1,189 +0,0 @@ -_detailedPromotions(); - $this->_dropManageCustomersPermission(); - $this->_detailedProducts(); - $this->_projectConfigUpdates(); - } - - /** - * @inheritdoc - */ - public function safeDown() - { - echo "m210614_073359_detailed_permission cannot be reverted.\n"; - return false; - } - - private function _detailedPromotions() - { - // Create new promotion permissions - $this->insert(Table::USERPERMISSIONS, ['name' => 'commerce-editsales']); - $editSalesId = $this->db->getLastInsertID(); - - $this->insert(Table::USERPERMISSIONS, ['name' => 'commerce-createsales']); - $createSalesId = $this->db->getLastInsertID(); - - $this->insert(Table::USERPERMISSIONS, ['name' => 'commerce-deletesales']); - $deleteSalesId = $this->db->getLastInsertID(); - - $this->insert(Table::USERPERMISSIONS, ['name' => 'commerce-editdiscounts']); - $editDiscountsId = $this->db->getLastInsertID(); - - $this->insert(Table::USERPERMISSIONS, ['name' => 'commerce-creatediscounts']); - $createDiscountsId = $this->db->getLastInsertID(); - - $this->insert(Table::USERPERMISSIONS, ['name' => 'commerce-deletediscounts']); - $deleteDiscountsId = $this->db->getLastInsertID(); - - $permissionId = (new Query()) - ->select(['id']) - ->from([Table::USERPERMISSIONS]) - ->where(['name' => 'commerce-managepromotions']) - ->scalar(); - - if ($permissionId) { - $userPromotions = (new Query()) - ->select(['id', 'userId']) - ->from([Table::USERPERMISSIONS_USERS]) - ->where(['permissionId' => $permissionId]) - ->all(); - - foreach ($userPromotions as $userPromotion) { - $this->insert(Table::USERPERMISSIONS_USERS, ['userId' => $userPromotion['userId'], 'permissionId' => $editSalesId]); - $this->insert(Table::USERPERMISSIONS_USERS, ['userId' => $userPromotion['userId'], 'permissionId' => $createSalesId]); - $this->insert(Table::USERPERMISSIONS_USERS, ['userId' => $userPromotion['userId'], 'permissionId' => $deleteSalesId]); - $this->insert(Table::USERPERMISSIONS_USERS, ['userId' => $userPromotion['userId'], 'permissionId' => $editDiscountsId]); - $this->insert(Table::USERPERMISSIONS_USERS, ['userId' => $userPromotion['userId'], 'permissionId' => $createDiscountsId]); - $this->insert(Table::USERPERMISSIONS_USERS, ['userId' => $userPromotion['userId'], 'permissionId' => $deleteDiscountsId]); - } - - // Check if manage product type is ticked for user group permissions - $groupPromotions = (new Query()) - ->select(['id', 'permissionId', 'groupId']) - ->from([Table::USERPERMISSIONS_USERGROUPS]) - ->where(['permissionId' => $permissionId]) - ->all(); - - foreach ($groupPromotions as $groupPromotion) { - $this->insert(Table::USERPERMISSIONS_USERGROUPS, ['groupId' => $groupPromotion['groupId'], 'permissionId' => $editSalesId]); - $this->insert(Table::USERPERMISSIONS_USERGROUPS, ['groupId' => $groupPromotion['groupId'], 'permissionId' => $createSalesId]); - $this->insert(Table::USERPERMISSIONS_USERGROUPS, ['groupId' => $groupPromotion['groupId'], 'permissionId' => $deleteSalesId]); - $this->insert(Table::USERPERMISSIONS_USERGROUPS, ['groupId' => $groupPromotion['groupId'], 'permissionId' => $editDiscountsId]); - $this->insert(Table::USERPERMISSIONS_USERGROUPS, ['groupId' => $groupPromotion['groupId'], 'permissionId' => $createDiscountsId]); - $this->insert(Table::USERPERMISSIONS_USERGROUPS, ['groupId' => $groupPromotion['groupId'], 'permissionId' => $deleteDiscountsId]); - } - } - } - - /** - * @return void - */ - private function _dropManageCustomersPermission(): void - { - $this->delete(Table::USERPERMISSIONS, ['name' => 'commerce-managecustomers']); - } - - private function _detailedProducts() - { - // Get existing manage product type permission - $permissions = (new Query()) - ->select(['id', 'name']) - ->from([Table::USERPERMISSIONS]) - ->where(new Expression("LEFT([[name]], 26) = 'commerce-manageproducttype'")) - ->all(); - - if (count($permissions) > 0) { - foreach ($permissions as $permission) { - $permissionName = explode(':', $permission['name']); - $productTypeUid = $permissionName[1]; - - // Rename manage product type to edit product type - $newName = str_replace('commerce-manageproducttype', 'commerce-editproducttype', $permission['name']); - $this->update(Table::USERPERMISSIONS, ['name' => $newName], ['id' => $permission['id']], [], false); - - // Create new create product permission by product type - $this->insert(Table::USERPERMISSIONS, ['name' => 'commerce-createproducts:' . $productTypeUid]); - $createPermissionId = $this->db->getLastInsertID(); - - // Create new delete product permission by product type - $this->insert(Table::USERPERMISSIONS, ['name' => 'commerce-deleteproducts:' . $productTypeUid]); - $deletePermissionId = $this->db->getLastInsertID(); - - // Check if manage product type is ticked for user permissions - $manageProductTypes = (new Query()) - ->select(['id', 'permissionId', 'userId']) - ->from([Table::USERPERMISSIONS_USERS]) - ->where(['permissionId' => $permission['id']]) - ->all(); - // Add the new edit product child permissions for the same users - foreach ($manageProductTypes as $manageProductType) { - $this->insert(Table::USERPERMISSIONS_USERS, ['userId' => $manageProductType['userId'], 'permissionId' => $createPermissionId]); - $this->insert(Table::USERPERMISSIONS_USERS, ['userId' => $manageProductType['userId'], 'permissionId' => $deletePermissionId]); - } - - // Check if manage product type is ticked for user group permissions - $manageProductTypesForGroups = (new Query()) - ->select(['id', 'permissionId', 'groupId']) - ->from([Table::USERPERMISSIONS_USERGROUPS]) - ->where(['permissionId' => $permission['id']]) - ->all(); - // Add the new edit product child permissions for the same groups - foreach ($manageProductTypesForGroups as $manageProductType) { - // Create new create and delete product permission relationship with a group. - $this->insert(Table::USERPERMISSIONS_USERGROUPS, ['groupId' => $manageProductType['groupId'], 'permissionId' => $createPermissionId]); - $this->insert(Table::USERPERMISSIONS_USERGROUPS, ['groupId' => $manageProductType['groupId'], 'permissionId' => $deletePermissionId]); - } - } - - // No longer need this top level permission - $this->delete(Table::USERPERMISSIONS, ['name' => 'commerce-manageproducts']); - } - } - - private function _projectConfigUpdates() - { - // Make project config updates - $projectConfig = Craft::$app->getProjectConfig(); - - $groups = (new Query()) - ->select(['id', 'name', 'uid']) - ->from(['groups' => Table::USERGROUPS]) - ->all(); - - $setGroupPermissions = []; - - foreach ($groups as $group) { - $groupPermissions = (new Query()) - ->select(['up.name']) - ->from(['up_ug' => Table::USERPERMISSIONS_USERGROUPS]) - ->where(['up_ug.groupId' => $group['id']]) - ->innerJoin(['up' => Table::USERPERMISSIONS], '[[up.id]] = [[up_ug.permissionId]]') - ->column(); - - $setGroupPermissions[$group['uid']] = $groupPermissions; - } - - foreach ($setGroupPermissions as $uid => $setGroupPermission) { - $projectConfig->set('users.groups.' . $uid . '.permissions', $setGroupPermission); - } - } -} diff --git a/src/migrations/m210831_080542_rename_variant_title_format_field.php b/src/migrations/m210831_080542_rename_variant_title_format_field.php deleted file mode 100644 index c84e368788..0000000000 --- a/src/migrations/m210831_080542_rename_variant_title_format_field.php +++ /dev/null @@ -1,43 +0,0 @@ -renameColumn('{{%commerce_producttypes}}', 'titleFormat', 'variantTitleFormat'); - - $projectConfig = Craft::$app->getProjectConfig(); - - $productTypes = $projectConfig->get('commerce.productTypes') ?? []; - $muteEvents = $projectConfig->muteEvents; - $projectConfig->muteEvents = true; - - foreach ($productTypes as $uid => $productType) { - $productType['variantTitleFormat'] = $productType['titleFormat']; - unset($productType['titleFormat']); - $projectConfig->set("commerce.productTypes.$uid", $productType); - } - - $projectConfig->muteEvents = $muteEvents; - } - - /** - * @inheritdoc - */ - public function safeDown() - { - echo "m210831_080542_rename_variant_title_format_field cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m210901_211323_not_null_booleans.php b/src/migrations/m210901_211323_not_null_booleans.php deleted file mode 100644 index 0bc28bf671..0000000000 --- a/src/migrations/m210901_211323_not_null_booleans.php +++ /dev/null @@ -1,206 +0,0 @@ -updateColumns(); - $this->updateProjectConfig(); - return true; - } - - private function updateColumns(): void - { - $columns = [ - '{{%commerce_countries}}' => [ - 'isStateRequired' => false, - ], - '{{%commerce_discounts}}' => [ - 'excludeOnSale' => false, - 'hasFreeShippingForMatchingItems' => false, - 'hasFreeShippingForOrder' => false, - 'allPurchasables' => false, - 'allCategories' => false, - 'enabled' => true, - 'stopProcessing' => false, - ], - '{{%commerce_donations}}' => [ - 'availableForPurchase' => false, - ], - '{{%commerce_emails}}' => [ - 'enabled' => true, - ], - '{{%commerce_pdfs}}' => [ - 'enabled' => true, - 'isDefault' => false, - ], - '{{%commerce_gateways}}' => [ - 'isFrontendEnabled' => true, - 'isArchived' => false, - ], - '{{%commerce_lineitemstatuses}}' => [ - 'default' => false, - ], - '{{%commerce_orderadjustments}}' => [ - 'included' => false, - ], - '{{%commerce_orders}}' => [ - 'isCompleted' => false, - 'registerUserOnOrderComplete' => false, - ], - '{{%commerce_orderstatuses}}' => [ - 'default' => false, - ], - '{{%commerce_plans}}' => [ - 'enabled' => false, - 'isArchived' => false, - ], - '{{%commerce_products}}' => [ - 'promotable' => false, - 'availableForPurchase' => true, - 'freeShipping' => false, - ], - '{{%commerce_producttypes}}' => [ - 'hasDimensions' => false, - 'hasVariants' => false, - 'hasVariantTitleField' => true, - 'hasProductTitleField' => true, - ], - '{{%commerce_producttypes_sites}}' => [ - 'hasUrls' => false, - ], - '{{%commerce_sales}}' => [ - 'allGroups' => false, - 'allPurchasables' => false, - 'allCategories' => false, - 'enabled' => true, - 'ignorePrevious' => false, - 'stopProcessing' => false, - ], - '{{%commerce_shippingcategories}}' => [ - 'default' => false, - ], - '{{%commerce_shippingmethods}}' => [ - 'enabled' => true, - 'isLite' => false, - ], - '{{%commerce_shippingrules}}' => [ - 'enabled' => true, - 'isLite' => false, - ], - '{{%commerce_shippingzones}}' => [ - 'isCountryBased' => true, - ], - '{{%commerce_subscriptions}}' => [ - 'isCanceled' => false, - 'isExpired' => false, - ], - '{{%commerce_taxcategories}}' => [ - 'default' => false, - ], - '{{%commerce_taxrates}}' => [ - 'isEverywhere' => true, - 'include' => false, - 'isVat' => false, - 'removeIncluded' => false, - 'removeVatIncluded' => false, - 'isLite' => false, - ], - '{{%commerce_taxzones}}' => [ - 'isCountryBased' => true, - 'default' => false, - ], - '{{%commerce_variants}}' => [ - 'isDefault' => false, - 'hasUnlimitedStock' => false, - 'deletedWithProduct' => false, - ], - ]; - - $isPgsql = $this->db->getIsPgsql(); - - foreach ($columns as $table => $tableColumns) { - foreach ($tableColumns as $column => $defaultValue) { - // Set any null values to false - $this->update($table, [$column => false], [$column => null], [], false); - - // Add a NOT NULL constraint and default value - if ($isPgsql) { - // Manually construct the SQL for Postgres - // (see https://github.com/yiisoft/yii2/issues/12077) - $this->execute("ALTER TABLE $table ALTER COLUMN \"$column\" SET NOT NULL, " . - "ALTER COLUMN \"$column\" SET DEFAULT " . ($defaultValue ? 'TRUE' : 'FALSE')); - } else { - $this->alterColumn($table, $column, $this->boolean()->notNull()->defaultValue($defaultValue)); - } - } - } - } - - private function updateProjectConfig(): void - { - $projectConfig = Craft::$app->getProjectConfig(); - - $projectConfig->muteEvents = true; - - $keys = [ - Gateways::CONFIG_GATEWAY_KEY => [ - 'isFrontendEnabled', - 'isArchived', - ], - ProductTypes::CONFIG_PRODUCTTYPES_KEY => [ - 'hasDimensions', - 'hasVariants', - 'hasVariantTitleField', - 'hasProductTitleField', - ], - OrderStatuses::CONFIG_STATUSES_KEY => [ - 'default', - ], - Emails::CONFIG_EMAILS_KEY => [ - 'enabled', - ], - Pdfs::CONFIG_PDFS_KEY => [ - 'enabled', - 'isDefault', - ], - ]; - - foreach ($keys as $basePath => $itemKeys) { - $items = $projectConfig->get($basePath) ?? []; - foreach ($items as $uid => $item) { - foreach ($itemKeys as $key) { - $item[$key] = (bool)($item[$key] ?? false); - } - $projectConfig->set("$basePath.$uid", $item); - } - } - - $projectConfig->muteEvents = false; - } - - /** - * @inheritdoc - */ - public function safeDown() - { - echo "m210901_211323_not_null_booleans cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m210922_133729_add_discount_order_condition_builder.php b/src/migrations/m210922_133729_add_discount_order_condition_builder.php deleted file mode 100644 index bc653a5f72..0000000000 --- a/src/migrations/m210922_133729_add_discount_order_condition_builder.php +++ /dev/null @@ -1,32 +0,0 @@ -db->columnExists('{{%commerce_discounts}}', 'orderCondition')) { - $this->addColumn('{{%commerce_discounts}}', 'orderCondition', $this->text()->after('description')); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m210922_133729_add_discount_order_condition_builder cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m211118_101920_split_coupon_codes.php b/src/migrations/m211118_101920_split_coupon_codes.php deleted file mode 100644 index 51c7e80308..0000000000 --- a/src/migrations/m211118_101920_split_coupon_codes.php +++ /dev/null @@ -1,104 +0,0 @@ -db->tableExists('{{%commerce_coupons}}')) { - $this->createTable('{{%commerce_coupons}}', [ - 'id' => $this->primaryKey(), - 'code' => $this->string(), - 'discountId' => $this->integer()->notNull(), - 'uses' => $this->integer()->notNull()->defaultValue(0), - 'maxUses' => $this->integer(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->createIndex(null, '{{%commerce_coupons}}', 'discountId', false); - $this->createIndex(null, '{{%commerce_coupons}}', 'code', false); - - $this->addForeignKey(null, '{{%commerce_coupons}}', ['discountId'], '{{%commerce_discounts}}', ['id'], 'CASCADE', 'CASCADE'); - } - - if (!$this->db->columnExists('{{%commerce_discounts}}', 'couponFormat')) { - $this->addColumn('{{%commerce_discounts}}', 'couponFormat', $this->string(20)->notNull()->defaultValue(Coupons::DEFAULT_COUPON_FORMAT)); - } - - if (!(new Query())->from('{{%commerce_coupons}}')->exists()) { - // These could be one query, leaving as separate for now for readability - $discountsWithCodes = (new Query()) - ->select(['id', 'code', 'totalDiscountUseLimit', 'dateCreated', 'dateUpdated']) - ->from('{{%commerce_discounts}}') - ->where(['not', ['code' => null]]) - ->all(); - - $codeUsage = (new Query()) - ->select([new Expression('COUNT(*) as count'), 'couponCode as code']) - ->from('{{%commerce_orders}}') - ->where(['not', ['couponCode' => null]]) - ->groupBy('couponCode') - ->indexBy('code') - ->column(); - - if (!empty($discountsWithCodes)) { - $coupons = array_map(static function($discount) use ($codeUsage) { - $maxUses = $discount['totalDiscountUseLimit'] !== null && $discount['totalDiscountUseLimit'] > 0 - ? $discount['totalDiscountUseLimit'] - : null; - - $row['code'] = $discount['code']; - $row['discountId'] = $discount['id']; - $row['uses'] = $codeUsage[$discount['code']] ?? 0; - $row['maxUses'] = $maxUses; - $row['dateCreated'] = $discount['dateCreated']; - $row['dateUpdated'] = $discount['dateUpdated']; - $row['uid'] = StringHelper::UUID(); - - return $row; - }, $discountsWithCodes); - - $this->batchInsert('{{%commerce_coupons}}', [ - 'code', - 'discountId', - 'uses', - 'maxUses', - 'dateCreated', - 'dateUpdated', - 'uid', - ], $coupons); - } - } - - if ($this->db->columnExists('{{%commerce_discounts}}', 'code')) { - $this->dropIndexIfExists('{{%commerce_discounts}}', 'code', true); - $this->dropColumn('{{%commerce_discounts}}', 'code'); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m211118_101920_split_coupon_codes cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m220301_022054_user_addresses.php b/src/migrations/m220301_022054_user_addresses.php deleted file mode 100644 index 88cabc72ce..0000000000 --- a/src/migrations/m220301_022054_user_addresses.php +++ /dev/null @@ -1,183 +0,0 @@ -db->getIsPgsql(); - - /** - * Order Addresses - */ - $this->addColumn('{{%commerce_orders}}', 'sourceShippingAddressId', $this->integer()->after('estimatedShippingAddressId')); // no need for index as not queryable - $this->addColumn('{{%commerce_orders}}', 'sourceBillingAddressId', $this->integer()->after('estimatedBillingAddressId')); // no need for index as not queryable - - /** - * Zones - */ - $this->renameColumn('{{%commerce_taxzones}}', 'isCountryBased', 'v3isCountryBased'); - $this->renameColumn('{{%commerce_shippingzones}}', 'isCountryBased', 'v3isCountryBased'); - $this->renameColumn('{{%commerce_taxzones}}', 'zipCodeConditionFormula', 'v3zipCodeConditionFormula'); - $this->renameColumn('{{%commerce_shippingzones}}', 'zipCodeConditionFormula', 'v3zipCodeConditionFormula'); - $this->addColumn('{{%commerce_taxzones}}', 'condition', $this->text()); - $this->addColumn('{{%commerce_shippingzones}}', 'condition', $this->text()); - - /* - * Orders - */ - // Move the customerId to a temporary column, and relate the new customerId FK to the user element - $this->dropForeignKeyIfExists('{{%commerce_orders}}', ['customerId']); - $this->dropIndexIfExists('{{%commerce_orders}}', ['customerId']); - $this->renameColumn('{{%commerce_orders}}', 'customerId', 'v3customerId'); // move the data - $this->createIndex(null, '{{%commerce_orders}}', 'v3customerId', false); - - $this->addColumn('{{%commerce_orders}}', 'customerId', $this->integer()); - $this->createIndex(null, '{{%commerce_orders}}', 'customerId', false); - $this->addForeignKey(null, '{{%commerce_orders}}', ['customerId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - - // Move the billingAddressId to a temporary column, and relate the new billingAddressId FK to the address element - $this->dropForeignKeyIfExists('{{%commerce_orders}}', ['billingAddressId']); - $this->dropIndexIfExists('{{%commerce_orders}}', ['billingAddressId']); - $this->renameColumn('{{%commerce_orders}}', 'billingAddressId', 'v3billingAddressId'); // move the data - - $this->addColumn('{{%commerce_orders}}', 'billingAddressId', $this->integer()); - $this->addForeignKey(null, '{{%commerce_orders}}', ['billingAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - - // Move the shippingAddressId to a temporary column, and relate the new shippingAddressId FK to the address element - $this->dropForeignKeyIfExists('{{%commerce_orders}}', ['shippingAddressId']); - $this->dropIndexIfExists('{{%commerce_orders}}', ['shippingAddressId']); - $this->renameColumn('{{%commerce_orders}}', 'shippingAddressId', 'v3shippingAddressId'); // move the data - - $this->addColumn('{{%commerce_orders}}', 'shippingAddressId', $this->integer()); - $this->addForeignKey(null, '{{%commerce_orders}}', ['shippingAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - - // Move the estimatedBillingAddressId to a temporary column, and relate the new estimatedBillingAddressId FK to the address element - $this->dropForeignKeyIfExists('{{%commerce_orders}}', ['estimatedBillingAddressId']); - $this->dropIndexIfExists('{{%commerce_orders}}', ['estimatedBillingAddressId']); - $this->renameColumn('{{%commerce_orders}}', 'estimatedBillingAddressId', 'v3estimatedBillingAddressId'); // move the data - - $this->addColumn('{{%commerce_orders}}', 'estimatedBillingAddressId', $this->integer()); - $this->addForeignKey(null, '{{%commerce_orders}}', ['estimatedBillingAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - - // Move the estimatedShippingAddressId to a temporary column, and relate the new estimatedShippingAddressId FK to the address element - $this->dropForeignKeyIfExists('{{%commerce_orders}}', ['estimatedShippingAddressId']); - $this->dropIndexIfExists('{{%commerce_orders}}', ['estimatedShippingAddressId']); - $this->renameColumn('{{%commerce_orders}}', 'estimatedShippingAddressId', 'v3estimatedShippingAddressId'); // move the data - - $this->addColumn('{{%commerce_orders}}', 'estimatedShippingAddressId', $this->integer()); - $this->addForeignKey(null, '{{%commerce_orders}}', ['estimatedShippingAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - - /* - * Customers - */ - // Move the userId and ID to a temporary column, add the customerId column. - $this->dropForeignKeyIfExists('{{%commerce_customers}}', ['userId']); - $this->dropIndexIfExists('{{%commerce_customers}}', ['userId']); - $this->renameColumn('{{%commerce_customers}}', 'userId', 'v3userId'); // move the data - $this->dropForeignKeyIfExists('{{%commerce_customers}}', ['primaryBillingAddressId']); - $this->dropForeignKeyIfExists('{{%commerce_customers}}', ['primaryShippingAddressId']); - $this->renameColumn('{{%commerce_customers}}', 'primaryBillingAddressId', 'v3primaryBillingAddressId'); // move the data - $this->renameColumn('{{%commerce_customers}}', 'primaryShippingAddressId', 'v3primaryShippingAddressId'); // move the data - $this->addColumn('{{%commerce_customers}}', 'primaryBillingAddressId', $this->integer()); - $this->addColumn('{{%commerce_customers}}', 'primaryShippingAddressId', $this->integer()); - $this->addColumn('{{%commerce_customers}}', 'customerId', $this->integer()->null()); - - $this->addForeignKey(null, '{{%commerce_customers}}', ['primaryBillingAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - $this->addForeignKey(null, '{{%commerce_customers}}', ['primaryShippingAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - // Add the new primary customerId column with will share the same ID the user element ID - //$this->addColumn('{{%commerce_customers}}', 'customerId', $this->integer()); - $this->addForeignKey(null, '{{%commerce_customers}}', ['customerId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); - $this->createIndex(null, '{{%commerce_customers}}', 'customerId', true); - - - /** - * Customer Discount Uses - */ - $this->dropAllForeignKeysToTable('{{%commerce_customer_discountuses}}'); - $this->dropForeignKeyIfExists('{{%commerce_customer_discountuses}}', ['customerId']); - $this->dropForeignKeyIfExists('{{%commerce_customer_discountuses}}', ['discountId']); - $this->dropIndexIfExists('{{%commerce_customer_discountuses}}', ['customerId', 'discountId'], true); - $this->dropIndexIfExists('{{%commerce_customer_discountuses}}', ['discountId']); - $this->renameColumn('{{%commerce_customer_discountuses}}', 'customerId', 'v3customerId'); // move the data - - if ($isPgsql) { - // Manually construct the SQL for Postgres - // (see https://github.com/yiisoft/yii2/issues/12077) - $this->execute('alter table {{%commerce_customer_discountuses}} alter column [[v3customerId]] type integer, alter column [[v3customerId]] drop not null'); - } else { - $this->alterColumn('{{%commerce_customer_discountuses}}', 'v3customerId', $this->integer()->null()); - } - - $this->addColumn('{{%commerce_customer_discountuses}}', 'customerId', $this->integer()); - $this->addForeignKey(null, '{{%commerce_customer_discountuses}}', ['customerId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, '{{%commerce_customer_discountuses}}', ['discountId'], '{{%commerce_discounts}}', ['id'], 'CASCADE', 'CASCADE'); - $this->createIndex(null, '{{%commerce_customer_discountuses}}', ['customerId', 'discountId'], true); - $this->createIndex(null, '{{%commerce_customer_discountuses}}', 'discountId', false); - - /** - * Payment Sources - */ - $this->dropForeignKeyIfExists('{{%commerce_paymentsources}}', ['userId']); - $this->dropIndexIfExists('{{%commerce_paymentsources}}', ['userId']); - $this->renameColumn('{{%commerce_paymentsources}}', 'userId', 'customerId'); // was already a user ID - $this->addForeignKey(null, '{{%commerce_paymentsources}}', ['customerId'], CraftTable::ELEMENTS, ['id'], 'CASCADE'); - - /** - * Order Histories - */ - $this->dropForeignKeyIfExists('{{%commerce_orderhistories}}', ['customerId']); - $this->dropIndexIfExists('{{%commerce_orderhistories}}', ['customerId']); - $this->renameColumn('{{%commerce_orderhistories}}', 'customerId', 'v3customerId'); // move the data - - if ($isPgsql) { - // Manually construct the SQL for Postgres - // (see https://github.com/yiisoft/yii2/issues/12077) - $this->execute('alter table {{%commerce_orderhistories}} alter column [[v3customerId]] type integer, alter column [[v3customerId]] drop not null'); - } else { - $this->alterColumn('{{%commerce_orderhistories}}', 'v3customerId', $this->integer()->null()); - } - $this->createIndex(null, '{{%commerce_orderhistories}}', 'v3customerId', false); - - $this->addColumn('{{%commerce_orderhistories}}', 'userId', $this->integer()->null()); - $this->addForeignKey(null, '{{%commerce_orderhistories}}', ['userId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); - $this->createIndex(null, '{{%commerce_orderhistories}}', 'userId', false); - - $this->addColumn('{{%commerce_addresses}}', 'v4addressId', $this->integer()->null()); - - // Add new Store table - if (!Craft::$app->getDb()->tableExists('{{%commerce_stores}}')) { - $this->createTable('{{%commerce_stores}}', [ - 'id' => $this->primaryKey(), - 'locationAddressId' => $this->integer(), - 'countries' => $this->text(), - 'marketAddressCondition' => $this->text(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m220222_134640_address_user_schema_changes cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m220302_133730_add_discount_user_addresses_condition_builders.php b/src/migrations/m220302_133730_add_discount_user_addresses_condition_builders.php deleted file mode 100644 index 2e661c70e9..0000000000 --- a/src/migrations/m220302_133730_add_discount_user_addresses_condition_builders.php +++ /dev/null @@ -1,40 +0,0 @@ -db->columnExists('{{%commerce_discounts}}', 'customerCondition')) { - $this->addColumn('{{%commerce_discounts}}', 'customerCondition', $this->text()->after('orderCondition')); - } - - if (!$this->db->columnExists('{{%commerce_discounts}}', 'shippingAddressCondition')) { - $this->addColumn('{{%commerce_discounts}}', 'shippingAddressCondition', $this->text()->after('customerCondition')); - } - - if (!$this->db->columnExists('{{%commerce_discounts}}', 'billingAddressCondition')) { - $this->addColumn('{{%commerce_discounts}}', 'billingAddressCondition', $this->text()->after('shippingAddressCondition')); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m220302_133730_add_discount_user_addresses_condition_builders cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m220304_094835_discount_conditions.php b/src/migrations/m220304_094835_discount_conditions.php deleted file mode 100644 index 9aa1931faa..0000000000 --- a/src/migrations/m220304_094835_discount_conditions.php +++ /dev/null @@ -1,110 +0,0 @@ -select(['id', 'userGroupsCondition']) - ->from(['{{%commerce_discounts}}']) - ->indexBy('id') - ->all(); - - foreach ($discounts as $id => $discount) { - - /** - * Order condition - */ - $this->update('{{%commerce_discounts}}', [ - 'orderCondition' => Json::encode($orderCondition->getConfig()), - ], ['id' => $id]); - - /** - * User/Customer condition - */ - $discountsUserGroupIds = (new Query())->select(['dug.userGroupId']) - ->from('{{%commerce_discounts}} discounts') - ->leftJoin('{{%commerce_discount_usergroups}} dug', '[[dug.discountId]] = [[discounts.id]]') - ->where(['discounts.id' => $id]) - ->column(); - - $userGroupUids = Db::uidsByIds('{{%usergroups}}', $discountsUserGroupIds, $this->db); - - if ($discountsUserGroupIds && $userGroupUids && ($discount['userGroupsCondition'] != 'userGroupsAnyOrNone')) { - $userRules = []; - if ($discount['userGroupsCondition'] == 'userGroupsIncludeAll') { - $conditionRule = new DiscountGroupConditionRule(); - $conditionRule->setValues($userGroupUids); - $conditionRule->operator = 'inAll'; - $userRules[] = $conditionRule; - } elseif ($discount['userGroupsCondition'] == 'userGroupsIncludeAny') { - $conditionRule = new DiscountGroupConditionRule(); - $conditionRule->setValues($userGroupUids); - $conditionRule->operator = 'in'; - $userRules[] = $conditionRule; - } elseif ($discount['userGroupsCondition'] == 'userGroupsExcludeAny') { - $conditionRule = new DiscountGroupConditionRule(); - $conditionRule->setValues($userGroupUids); - $conditionRule->operator = 'ni'; - $userRules[] = $conditionRule; - } - $customerCondition->setConditionRules($userRules); - } - - $this->update('{{%commerce_discounts}}', [ - 'customerCondition' => Json::encode($customerCondition->getConfig()), - ], ['id' => $id]); - - /** - * Shipping Address condition - */ - $this->update('{{%commerce_discounts}}', [ - 'shippingAddressCondition' => Json::encode($shippingAddressCondition->getConfig()), - ], ['id' => $id]); - - /** - * Billing Address condition - */ - $this->update('{{%commerce_discounts}}', [ - 'billingAddressCondition' => Json::encode($billingAddressCondition->getConfig()), - ], ['id' => $id]); - } - - // No longer needed now that we have the condition builder - $this->dropTableIfExists('{{%commerce_discount_usergroups}}'); - $this->dropColumn('{{%commerce_discounts}}', 'userGroupsCondition'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m220304_094835_discount_conditions cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m220308_221717_orderhistory_name.php b/src/migrations/m220308_221717_orderhistory_name.php deleted file mode 100644 index abc150be0c..0000000000 --- a/src/migrations/m220308_221717_orderhistory_name.php +++ /dev/null @@ -1,50 +0,0 @@ -db->getIsPgsql(); - - if (!$this->db->columnExists('{{%commerce_orderhistories}}', 'userName')) { - $this->addColumn('{{%commerce_orderhistories}}', 'userName', $this->string()); - } - - // Allow null - $this->dropForeignKeyIfExists('{{%commerce_orderhistories}}', ['userId']); - $this->dropIndexIfExists('{{%commerce_orderhistories}}', ['userId']); - $this->alterColumn('{{%commerce_orderhistories}}', 'userId', $this->integer()); - - if ($isPgsql) { - // Manually construct the SQL for Postgres - // (see https://github.com/yiisoft/yii2/issues/12077) - $this->execute('alter table {{%commerce_orderhistories}} alter column [[userId]] type integer, alter column [[userId]] drop not null'); - } else { - $this->alterColumn('{{%commerce_orderhistories}}', 'userId', $this->integer()->null()); - } - - $this->addForeignKey(null, '{{%commerce_orderhistories}}', ['userId'], '{{%elements}}', ['id'], 'SET NULL'); - $this->createIndex(null, '{{%commerce_orderhistories}}', 'userId', false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m220308_221717_orderhistory_name cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m220329_075053_convert_gateway_frontend_enabled_column.php b/src/migrations/m220329_075053_convert_gateway_frontend_enabled_column.php deleted file mode 100644 index 41b2cb1f3d..0000000000 --- a/src/migrations/m220329_075053_convert_gateway_frontend_enabled_column.php +++ /dev/null @@ -1,29 +0,0 @@ -alterColumn('{{%commerce_gateways}}', 'isFrontendEnabled', $this->string(500)->notNull()->defaultValue('1')); - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m220329_075053_convert_gateway_frontend_enabled_column cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m220706_132118_add_purchasable_tax_type.php b/src/migrations/m220706_132118_add_purchasable_tax_type.php deleted file mode 100644 index 7bd6e53360..0000000000 --- a/src/migrations/m220706_132118_add_purchasable_tax_type.php +++ /dev/null @@ -1,44 +0,0 @@ -db->getIsPgsql()) { - // Manually construct the SQL for Postgres - $check = '[[taxable]] in ('; - foreach ($values as $i => $value) { - if ($i != 0) { - $check .= ','; - } - $check .= $this->db->quoteValue($value); - } - $check .= ')'; - $this->execute("alter table {{%commerce_taxrates}} drop constraint {{%commerce_taxrates_taxable_check}}, add check ({$check})"); - } else { - $this->alterColumn('{{%commerce_taxrates}}', 'taxable', $this->enum('taxable', $values)); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m220706_132118_add_purchasable_tax_type cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m220812_104819_add_primary_payment_source_column.php b/src/migrations/m220812_104819_add_primary_payment_source_column.php deleted file mode 100644 index 69648d4aaf..0000000000 --- a/src/migrations/m220812_104819_add_primary_payment_source_column.php +++ /dev/null @@ -1,35 +0,0 @@ -db->columnExists('{{%commerce_customers}}', 'primaryPaymentSourceId')) { - $this->addColumn('{{%commerce_customers}}', 'primaryPaymentSourceId', $this->integer()->after('primaryShippingAddressId')); - $this->createIndex(null, '{{%commerce_customers}}', 'primaryPaymentSourceId', false); - - $this->addForeignKey(null, '{{%commerce_customers}}', ['primaryPaymentSourceId'], '{{%commerce_paymentsources}}', ['id'], 'SET NULL'); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m220812_104819_add_primary_payment_source_column cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m220817_135050_add_purchase_total_back_if_missing.php b/src/migrations/m220817_135050_add_purchase_total_back_if_missing.php deleted file mode 100644 index 72fd34b54f..0000000000 --- a/src/migrations/m220817_135050_add_purchase_total_back_if_missing.php +++ /dev/null @@ -1,32 +0,0 @@ -db->columnExists('{{%commerce_discounts}}', 'purchaseTotal')) { - $this->addColumn('{{%commerce_discounts}}', 'purchaseTotal', $this->decimal(14, 4)->notNull()->defaultValue(0)); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m220817_135050_add_purchase_total_back_if_missing cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m220912_111800_add_order_total_qty_column.php b/src/migrations/m220912_111800_add_order_total_qty_column.php deleted file mode 100644 index 7dc1f5660e..0000000000 --- a/src/migrations/m220912_111800_add_order_total_qty_column.php +++ /dev/null @@ -1,55 +0,0 @@ -db->columnExists('{{%commerce_orders}}', 'totalQty')) { - $this->addColumn('{{%commerce_orders}}', 'totalQty', $this->integer()->unsigned()); - - if ($this->db->getIsMysql()) { - $this->execute(' - UPDATE {{%commerce_orders}} o - LEFT JOIN ( - SELECT [[orderId]], SUM([[qty]]) AS [[totalQty]] - FROM {{%commerce_lineitems}} - GROUP BY [[orderId]] - ) agg ON agg.[[orderId]] = o.[[id]] - SET o.[[totalQty]] = COALESCE(agg.[[totalQty]], 0) - '); - } else { - $this->execute(' - UPDATE {{%commerce_orders}} o - SET [[totalQty]] = COALESCE(agg.[[totalQty]], 0) - FROM ( - SELECT [[orderId]], SUM([[qty]]) AS [[totalQty]] - FROM {{%commerce_lineitems}} - GROUP BY [[orderId]] - ) agg - WHERE o.[[id]] = agg.[[orderId]] - '); - } - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m220912_111800_add_order_total_qty_column cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m221025_083940_add_purchasables_stores_table.php b/src/migrations/m221025_083940_add_purchasables_stores_table.php deleted file mode 100644 index 8511730b50..0000000000 --- a/src/migrations/m221025_083940_add_purchasables_stores_table.php +++ /dev/null @@ -1,170 +0,0 @@ -select(['id']) - ->from([Table::STORES]) - ->limit(1) - ->orderBy(['id' => SORT_ASC]) - ->scalar(); - - // Variants - $variantsToPurchasables = (new Query()) - ->select([ - 'v.id', - 'v.width', - 'v.height', - 'v.length', - 'v.weight', - 'p.taxCategoryId', - 'p.shippingCategoryId', - ]) - ->from([Table::VARIANTS . ' v']) - ->innerJoin([Table::PRODUCTS . ' p'], '[[p.id]] = [[v.productId]]') - ->all(); - - $variantsToPurchasablesStores = collect((new Query()) - ->select([ - 'v.id as purchasableId', - 'pur.price as basePrice', - 'v.stock', - 'v.hasUnlimitedStock', - 'v.minQty', - 'v.maxQty', - 'p.promotable', - 'p.availableForPurchase', - 'p.freeShipping', - 'v.dateUpdated', - 'v.dateCreated', - ]) - ->from(['v' => Table::VARIANTS]) - ->innerJoin(['p' => Table::PRODUCTS], '[[p.id]] = [[v.productId]]') - ->innerJoin(['pur' => Table::PURCHASABLES], '[[pur.id]] = [[v.id]]') - ->all()); - - $customPurchasablesToPurchasablesStores = collect((new Query()) - ->select([ - 'id as purchasableId', - 'price as basePrice', - ]) - ->from(Table::PURCHASABLES) - ->where(['not', ['id' => (new Query()) - ->select(['id']) - ->from(Table::VARIANTS), ], - ]) - ->all()); - - if (!$this->db->columnExists(Table::PURCHASABLES, 'width')) { - $this->addColumn(Table::PURCHASABLES, 'width', $this->decimal(14, 4)); - } - if (!$this->db->columnExists(Table::PURCHASABLES, 'height')) { - $this->addColumn(Table::PURCHASABLES, 'height', $this->decimal(14, 4)); - } - if (!$this->db->columnExists(Table::PURCHASABLES, 'length')) { - $this->addColumn(Table::PURCHASABLES, 'length', $this->decimal(14, 4)); - } - if (!$this->db->columnExists(Table::PURCHASABLES, 'weight')) { - $this->addColumn(Table::PURCHASABLES, 'weight', $this->decimal(14, 4)); - } - if (!$this->db->columnExists(Table::PURCHASABLES, 'taxCategoryId')) { - $this->addColumn(Table::PURCHASABLES, 'taxCategoryId', $this->integer()); - } - if (!$this->db->columnExists(Table::PURCHASABLES, 'shippingCategoryId')) { - $this->addColumn(Table::PURCHASABLES, 'shippingCategoryId', $this->integer()); - } - - $this->addForeignKey(null, Table::PURCHASABLES, ['taxCategoryId'], Table::TAXCATEGORIES, ['id']); - $this->addForeignKey(null, Table::PURCHASABLES, ['shippingCategoryId'], Table::SHIPPINGCATEGORIES, ['id']); - - $this->createTable(Table::PURCHASABLES_STORES, [ - 'id' => $this->primaryKey(), - 'purchasableId' => $this->integer()->notNull(), - 'storeId' => $this->integer()->notNull(), - 'basePrice' => $this->decimal(14, 4), // @TODO Consider storing as string to avoid float-precision issues - 'basePromotionalPrice' => $this->decimal(14, 4), // @TODO Consider storing as string to avoid float-precision issues - 'promotable' => $this->boolean()->notNull()->defaultValue(false), - 'availableForPurchase' => $this->boolean()->notNull()->defaultValue(true), - 'freeShipping' => $this->boolean()->notNull()->defaultValue(true), - 'stock' => $this->integer(), - 'hasUnlimitedStock' => $this->boolean()->notNull()->defaultValue(false), - 'minQty' => $this->integer(), - 'maxQty' => $this->integer(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->addForeignKey(null, Table::PURCHASABLES_STORES, ['purchasableId'], Table::PURCHASABLES, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::PURCHASABLES_STORES, ['storeId'], Table::STORES, ['id'], 'CASCADE'); - - if (!empty($variantsToPurchasables)) { - foreach ($variantsToPurchasables as $variantsToPurchasable) { - $this->update(Table::PURCHASABLES, $variantsToPurchasable, ['id' => $variantsToPurchasable['id']]); - } - } - - if ($variantsToPurchasablesStores->isNotEmpty()) { - $variantsToPurchasablesStores->each(function($variantToPurchasableStore) use ($storeId) { - $variantToPurchasableStore['storeId'] = $storeId; - $this->insert(Table::PURCHASABLES_STORES, $variantToPurchasableStore); - }); - } - - if ($customPurchasablesToPurchasablesStores->isNotEmpty()) { - $customPurchasablesToPurchasablesStores->each(function($customPurchasableToPurchasableStore) use ($storeId) { - $customPurchasableToPurchasableStore['storeId'] = $storeId; - $this->insert(Table::PURCHASABLES_STORES, $customPurchasableToPurchasableStore); - }); - } - - $this->dropIndexIfExists(Table::VARIANTS, 'sku', false); - - $this->dropColumn(Table::VARIANTS, 'price'); - $this->dropColumn(Table::VARIANTS, 'width'); - $this->dropColumn(Table::VARIANTS, 'height'); - $this->dropColumn(Table::VARIANTS, 'length'); - $this->dropColumn(Table::VARIANTS, 'weight'); - $this->dropColumn(Table::VARIANTS, 'stock'); - $this->dropColumn(Table::VARIANTS, 'hasUnlimitedStock'); - $this->dropColumn(Table::VARIANTS, 'minQty'); - $this->dropColumn(Table::VARIANTS, 'maxQty'); - $this->dropColumn(Table::VARIANTS, 'sku'); - - $this->dropForeignKeyIfExists(Table::PRODUCTS, 'taxCategoryId'); - $this->dropForeignKeyIfExists(Table::PRODUCTS, 'shippingCategoryId'); - $this->dropIndexIfExists(Table::PRODUCTS, 'taxCategoryId', false); - $this->dropIndexIfExists(Table::PRODUCTS, 'shippingCategoryId', false); - - $this->dropColumn(Table::PRODUCTS, 'promotable'); - $this->dropColumn(Table::PRODUCTS, 'taxCategoryId'); - $this->dropColumn(Table::PRODUCTS, 'shippingCategoryId'); - $this->dropColumn(Table::PRODUCTS, 'availableForPurchase'); - $this->dropColumn(Table::PRODUCTS, 'freeShipping'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m221206_083940_add_purchasables_stores_table cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m221026_105212_add_catalog_pricing_table.php b/src/migrations/m221026_105212_add_catalog_pricing_table.php deleted file mode 100644 index cb4654102f..0000000000 --- a/src/migrations/m221026_105212_add_catalog_pricing_table.php +++ /dev/null @@ -1,132 +0,0 @@ -db->tableExists('{{%commerce_catalogpricingrules}}')) { - $this->createTable('{{%commerce_catalogpricingrules}}', [ - 'id' => $this->primaryKey(), - 'name' => $this->string()->notNull(), - 'description' => $this->text(), - 'storeId' => $this->integer()->notNull(), - 'dateFrom' => $this->dateTime(), - 'dateTo' => $this->dateTime(), - 'apply' => $this->enum('apply', ['toPercent', 'toFlat', 'byPercent', 'byFlat'])->notNull(), - 'applyAmount' => $this->decimal(14, 4)->notNull(), - 'applyPriceType' => $this->enum('applyPriceType', [CatalogPricingRule::APPLY_PRICE_TYPE_PRICE, CatalogPricingRule::APPLY_PRICE_TYPE_PROMOTIONAL_PRICE])->notNull(), - 'purchasableCondition' => $this->text(), - 'customerCondition' => $this->text(), - 'enabled' => $this->boolean()->notNull()->defaultValue(true), - 'isPromotionalPrice' => $this->boolean()->notNull()->defaultValue(false), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->createIndex(null, '{{%commerce_catalogpricingrules}}', 'storeId', false); - $this->addForeignKey(null, '{{%commerce_catalogpricingrules}}', ['storeId'], Table::STORES, ['id'], 'CASCADE'); - } - - if (!$this->db->tableExists('{{%commerce_catalogpricingrules_users}}')) { - $this->createTable('{{%commerce_catalogpricingrules_users}}', [ - 'id' => $this->primaryKey(), - 'catalogPricingRuleId' => $this->integer()->notNull(), - 'userId' => $this->integer()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->createIndex(null, '{{%commerce_catalogpricingrules_users}}', 'catalogPricingRuleId', false); - $this->createIndex(null, '{{%commerce_catalogpricingrules_users}}', 'userId', false); - $this->addForeignKey(null, '{{%commerce_catalogpricingrules_users}}', ['userId'], \craft\db\Table::USERS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, '{{%commerce_catalogpricingrules_users}}', ['catalogPricingRuleId'], '{{%commerce_catalogpricingrules}}', ['id'], 'CASCADE', 'CASCADE'); - } - - if (!$this->db->tableExists($this->_tableName)) { - $this->createTable($this->_tableName, [ - 'id' => $this->primaryKey(), - 'price' => $this->decimal(14, 4), // @TODO Consider storing as string to avoid float-precision issues - 'purchasableId' => $this->integer()->notNull(), - 'storeId' => $this->integer(), - 'catalogPricingRuleId' => $this->integer(), - 'userId' => $this->integer(), - 'dateFrom' => $this->dateTime(), - 'dateTo' => $this->dateTime(), - 'isPromotionalPrice' => $this->boolean()->defaultValue(false), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->createIndex(null, $this->_tableName, 'purchasableId', false); - $this->createIndex(null, $this->_tableName, 'storeId', false); - $this->createIndex(null, $this->_tableName, 'catalogPricingRuleId', false); - $this->createIndex(null, $this->_tableName, 'userId', false); - - $this->addForeignKey(null, $this->_tableName, ['purchasableId'], Table::PURCHASABLES, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, $this->_tableName, ['storeId'], Table::STORES, ['id'], 'CASCADE'); - $this->addForeignKey(null, $this->_tableName, ['catalogPricingRuleId'], Table::CATALOG_PRICING_RULES, ['id'], 'CASCADE'); - $this->addForeignKey(null, $this->_tableName, ['userId'], \craft\db\Table::USERS, ['id'], 'CASCADE'); - } - - if ($this->db->columnExists('{{%commerce_purchasables}}', 'price')) { - $purchasablePrices = (new Query()) - ->select(['id as purchasableId', 'price', 'dateCreated', 'dateUpdated']) - ->from('{{%commerce_purchasables}}') - ->all(); - - if (!empty($purchasablePrices)) { - $storeId = (new Query()) - ->select(['id']) - ->from('{{%commerce_stores}}') - ->orderBy(['id' => SORT_ASC]) - ->scalar(); - - array_walk($purchasablePrices, function(&$purchasablePrice) use ($storeId) { - $purchasablePrice['storeId'] = $storeId; - $purchasablePrice['uid'] = StringHelper::UUID(); - }); - - // Chunk the insert to avoid memory issues with large datasets - $batchPurchasablePrices = array_chunk($purchasablePrices, 500); - - foreach ($batchPurchasablePrices as $batchPurchasablePrice) { - $this->batchInsert($this->_tableName, ['purchasableId', 'price', 'dateCreated', 'dateUpdated', 'storeId', 'uid'], $batchPurchasablePrice); - } - } - $this->dropColumn('{{%commerce_purchasables}}', 'price'); - } - - if ($this->db->columnExists('{{%commerce_variants}}', 'price')) { - $this->dropColumn('{{%commerce_variants}}', 'price'); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m221026_105212_add_catalog_pricing_table cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m221027_070322_add_tax_shipping_category_soft_delete.php b/src/migrations/m221027_070322_add_tax_shipping_category_soft_delete.php deleted file mode 100644 index 4fb4413e53..0000000000 --- a/src/migrations/m221027_070322_add_tax_shipping_category_soft_delete.php +++ /dev/null @@ -1,36 +0,0 @@ -db->columnExists('{{%commerce_taxcategories}}', 'dateDeleted')) { - $this->addColumn('{{%commerce_taxcategories}}', 'dateDeleted', $this->dateTime()->after('default')); - } - - if (!$this->db->columnExists('{{%commerce_shippingcategories}}', 'dateDeleted')) { - $this->addColumn('{{%commerce_shippingcategories}}', 'dateDeleted', $this->dateTime()->after('default')); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m221027_070322_add_tax_shipping_category_soft_delete cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m221027_074805_update_shipping_tax_category_indexes.php b/src/migrations/m221027_074805_update_shipping_tax_category_indexes.php deleted file mode 100644 index dfccd62737..0000000000 --- a/src/migrations/m221027_074805_update_shipping_tax_category_indexes.php +++ /dev/null @@ -1,31 +0,0 @@ -dropIndexIfExists('{{%commerce_taxcategories}}', 'handle', true); - $this->dropIndexIfExists('{{%commerce_shippingcategories}}', 'handle', true); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m221027_074805_update_shipping_tax_category_indexes cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m221028_192112_add_indexes_to_address_columns_on_orders.php b/src/migrations/m221028_192112_add_indexes_to_address_columns_on_orders.php deleted file mode 100644 index 475d9dac0f..0000000000 --- a/src/migrations/m221028_192112_add_indexes_to_address_columns_on_orders.php +++ /dev/null @@ -1,33 +0,0 @@ -createIndexIfMissing('{{%commerce_orders}}', 'billingAddressId', false); - $this->createIndexIfMissing('{{%commerce_orders}}', 'shippingAddressId', false); - $this->createIndexIfMissing('{{%commerce_orders}}', 'estimatedBillingAddressId', false); - $this->createIndexIfMissing('{{%commerce_orders}}', 'estimatedShippingAddressId', false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m221028_192112_add_indexes_to_address_columns_on_orders cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m221122_055724_move_general_settings_to_per_store_settings.php b/src/migrations/m221122_055724_move_general_settings_to_per_store_settings.php deleted file mode 100644 index e2a03bce68..0000000000 --- a/src/migrations/m221122_055724_move_general_settings_to_per_store_settings.php +++ /dev/null @@ -1,118 +0,0 @@ -db->columnExists(Table::STORES, 'autoSetNewCartAddresses')) { - $this->addColumn(Table::STORES, 'autoSetNewCartAddresses', $this->boolean()->notNull()->defaultValue(false)); - } - - if (!$this->db->columnExists(Table::STORES, 'autoSetCartShippingMethodOption')) { - $this->addColumn(Table::STORES, 'autoSetCartShippingMethodOption', $this->boolean()->notNull()->defaultValue(false)); - } - - if (!$this->db->columnExists(Table::STORES, 'autoSetPaymentSource')) { - $this->addColumn(Table::STORES, 'autoSetPaymentSource', $this->boolean()->notNull()->defaultValue(false)); - } - - if (!$this->db->columnExists(Table::STORES, 'allowEmptyCartOnCheckout')) { - $this->addColumn(Table::STORES, 'allowEmptyCartOnCheckout', $this->boolean()->notNull()->defaultValue(false)); - } - - if (!$this->db->columnExists(Table::STORES, 'allowCheckoutWithoutPayment')) { - $this->addColumn(Table::STORES, 'allowCheckoutWithoutPayment', $this->boolean()->notNull()->defaultValue(false)); - } - - if (!$this->db->columnExists(Table::STORES, 'allowPartialPaymentOnCheckout')) { - $this->addColumn(Table::STORES, 'allowPartialPaymentOnCheckout', $this->boolean()->notNull()->defaultValue(false)); - } - - if (!$this->db->columnExists(Table::STORES, 'requireShippingAddressAtCheckout')) { - $this->addColumn(Table::STORES, 'requireShippingAddressAtCheckout', $this->boolean()->notNull()->defaultValue(false)); - } - - if (!$this->db->columnExists(Table::STORES, 'requireBillingAddressAtCheckout')) { - $this->addColumn(Table::STORES, 'requireBillingAddressAtCheckout', $this->boolean()->notNull()->defaultValue(false)); - } - - if (!$this->db->columnExists(Table::STORES, 'requireShippingMethodSelectionAtCheckout')) { - $this->addColumn(Table::STORES, 'requireShippingMethodSelectionAtCheckout', $this->boolean()->notNull()->defaultValue(false)); - } - - if (!$this->db->columnExists(Table::STORES, 'useBillingAddressForTax')) { - $this->addColumn(Table::STORES, 'useBillingAddressForTax', $this->boolean()->notNull()->defaultValue(false)); - } - - if (!$this->db->columnExists(Table::STORES, 'validateOrganizationTaxIdAsVatId')) { - $this->addColumn(Table::STORES, 'validateOrganizationTaxIdAsVatId', $this->boolean()->notNull()->defaultValue(false)); - } - - if (!$this->db->columnExists(Table::STORES, 'orderReferenceFormat')) { - $this->addColumn(Table::STORES, 'orderReferenceFormat', $this->string()); - } - - if (!$this->db->columnExists(Table::STORES, 'freeOrderPaymentStrategy')) { - $this->addColumn(Table::STORES, 'freeOrderPaymentStrategy', $this->string()->defaultValue('complete')); - } - - if (!$this->db->columnExists(Table::STORES, 'minimumTotalPriceStrategy')) { - $this->addColumn(Table::STORES, 'minimumTotalPriceStrategy', $this->string()->defaultValue('default')); - } - - $projectConfig = Craft::$app->getProjectConfig(); - $commerceConfig = $projectConfig->get('plugins.commerce.settings', true); - $commerceFileConfig = Craft::$app->getConfig()->getConfigFromFile('commerce'); - - $commerceConfig = ArrayHelper::merge($commerceConfig, $commerceFileConfig); - - $data = [ - 'autoSetNewCartAddresses' => $commerceConfig['autoSetNewCartAddresses'] ?? false, - 'autoSetCartShippingMethodOption' => $commerceConfig['autoSetCartShippingMethodOption'] ?? false, - 'autoSetPaymentSource' => $commerceConfig['autoSetPaymentSource'] ?? false, - 'allowEmptyCartOnCheckout' => $commerceConfig['allowEmptyCartOnCheckout'] ?? false, - 'allowCheckoutWithoutPayment' => $commerceConfig['allowCheckoutWithoutPayment'] ?? false, - 'allowPartialPaymentOnCheckout' => $commerceConfig['allowPartialPaymentOnCheckout'] ?? false, - 'requireShippingAddressAtCheckout' => $commerceConfig['requireShippingAddressAtCheckout'] ?? false, - 'requireBillingAddressAtCheckout' => $commerceConfig['requireBillingAddressAtCheckout'] ?? false, - 'requireShippingMethodSelectionAtCheckout' => $commerceConfig['requireShippingMethodSelectionAtCheckout'] ?? false, - 'useBillingAddressForTax' => $commerceConfig['useBillingAddressForTax'] ?? false, - 'validateOrganizationTaxIdAsVatId' => $commerceConfig['validateOrganizationTaxIdAsVatId'] ?? $commerceConfig['validateBusinessTaxIdAsVatId'] ?? false, - 'orderReferenceFormat' => $commerceConfig['orderReferenceFormat'] ?? '{{number[:7]}}', - 'freeOrderPaymentStrategy' => $commerceConfig['freeOrderPaymentStrategy'] ?? 'complete', - 'minimumTotalPriceStrategy' => $commerceConfig['minimumTotalPriceStrategy'] ?? 'default', - ]; - - // set on all rows is safe since we only have one store - $this->update(Table::STORES, $data); - - // No need to update the project config as we only have one store at this stage and the multi-store migration - // will handle this. - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230324_080923_move_general_settings_to_per_store_settings cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m221122_055725_multi_store.php b/src/migrations/m221122_055725_multi_store.php deleted file mode 100644 index 6642694cd0..0000000000 --- a/src/migrations/m221122_055725_multi_store.php +++ /dev/null @@ -1,137 +0,0 @@ -db->tableExists(Table::STORESETTINGS)) { - $this->createTable(Table::STORESETTINGS, [ - 'id' => $this->integer()->notNull(), - 'locationAddressId' => $this->integer(), - 'countries' => $this->text(), - 'marketAddressCondition' => $this->text(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - 'PRIMARY KEY([[id]])', - ]); - - $this->addForeignKey(null, Table::STORESETTINGS, ['id'], Table::STORES, ['id'], 'CASCADE', 'CASCADE'); - } - - // get store settings from db - $storeSettings = (new Query()) - ->select(['id', 'locationAddressId', 'countries', 'marketAddressCondition']) - ->from([Table::STORES]) - ->one(); - - // Add the store settings from the old stores table - $this->insert(Table::STORESETTINGS, $storeSettings); - - $this->dropColumn(Table::STORES, 'locationAddressId'); - $this->dropColumn(Table::STORES, 'countries'); - $this->dropColumn(Table::STORES, 'marketAddressCondition'); - - // if column doesnt exist - if (!$this->db->columnExists(Table::STORES, 'name')) { - $this->addColumn(Table::STORES, 'name', $this->string()->defaultValue('')->notNull()); - } - if (!$this->db->columnExists(Table::STORES, 'handle')) { - $this->addColumn(Table::STORES, 'handle', $this->string()->defaultValue('')->notNull()); - } - if (!$this->db->columnExists(Table::STORES, 'primary')) { - $this->addColumn(Table::STORES, 'primary', $this->boolean()->defaultValue(false)->notNull()); - } - - $config = [ - 'name' => 'Primary Store', - 'handle' => 'primaryStore', - 'primary' => true, - ]; - - $this->update(table: Table::STORES, - columns: $config, - condition: ['id' => $storeSettings['id']], - updateTimestamp: false - ); - - $configKeys = [ - 'name', - 'handle', - 'primary', - 'allowCheckoutWithoutPayment', - 'allowEmptyCartOnCheckout', - 'allowPartialPaymentOnCheckout', - 'autoSetCartShippingMethodOption', - 'autoSetNewCartAddresses', - 'autoSetPaymentSource', - 'freeOrderPaymentStrategy', - 'minimumTotalPriceStrategy', - 'orderReferenceFormat', - 'requireBillingAddressAtCheckout', - 'requireShippingAddressAtCheckout', - 'requireShippingMethodSelectionAtCheckout', - 'useBillingAddressForTax', - 'validateOrganizationTaxIdAsVatId', - ]; - - $config = (new Query()) - ->select($configKeys) - ->from([Table::STORES]) - ->one(); - - $this->update(table: Table::STORES, - columns: $config, - condition: ['id' => $storeSettings['id']], - updateTimestamp: false - ); - - $storeUid = (new Query()) - ->select(['uid']) - ->from([Table::STORES]) - ->scalar(); - - - // Make project config updates - $projectConfig = Craft::$app->getProjectConfig(); - - $originalValue = $projectConfig->muteEvents; - $projectConfig->muteEvents = true; - - $projectConfig->set(Stores::CONFIG_STORES_KEY . '.' . $storeUid, - $config, - 'Migration creating the initial primary store in the project config'); - - $projectConfig->muteEvents = $originalValue; - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m221122_055725_multi_store cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m221122_155735_update_orders_shippingMethodHandle_default.php b/src/migrations/m221122_155735_update_orders_shippingMethodHandle_default.php deleted file mode 100644 index ac70f359d9..0000000000 --- a/src/migrations/m221122_155735_update_orders_shippingMethodHandle_default.php +++ /dev/null @@ -1,55 +0,0 @@ -update( - Table::ORDERS, - ['shippingMethodHandle' => ''], - ['shippingMethodHandle' => null], - updateTimestamp: false, - ); - - $this->update( - Table::ORDERS, - ['shippingMethodName' => ''], - ['shippingMethodName' => null], - updateTimestamp: false, - ); - - if ($this->db->getIsPgsql()) { - // Manually construct the SQL for Postgres - // (see https://github.com/yiisoft/yii2/issues/12077) - $this->execute(sprintf('ALTER TABLE %s ALTER COLUMN [[shippingMethodHandle]] SET NOT NULL', Table::ORDERS)); - $this->execute(sprintf("ALTER TABLE %s ALTER COLUMN [[shippingMethodHandle]] SET DEFAULT ''", Table::ORDERS)); - $this->execute(sprintf('ALTER TABLE %s ALTER COLUMN [[shippingMethodName]] SET NOT NULL', Table::ORDERS)); - $this->execute(sprintf("ALTER TABLE %s ALTER COLUMN [[shippingMethodName]] SET DEFAULT ''", Table::ORDERS)); - } else { - $this->alterColumn(Table::ORDERS, 'shippingMethodHandle', $this->string()->notNull()->defaultValue('')); - $this->alterColumn(Table::ORDERS, 'shippingMethodName', $this->string()->notNull()->defaultValue('')); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m221122_155735_update_orders_shippingMethodHandle_default cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m221124_114239_add_date_deleted_to_stores.php b/src/migrations/m221124_114239_add_date_deleted_to_stores.php deleted file mode 100644 index 7e2cfaff9e..0000000000 --- a/src/migrations/m221124_114239_add_date_deleted_to_stores.php +++ /dev/null @@ -1,33 +0,0 @@ -db->columnExists('{{%commerce_stores}}', 'dateDeleted')) { - $this->addColumn('{{%commerce_stores}}', 'dateDeleted', $this->dateTime()->null()); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m221124_114239_add_date_deleted_to_stores cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m221206_094303_add_store_to_order.php b/src/migrations/m221206_094303_add_store_to_order.php deleted file mode 100644 index 401334b410..0000000000 --- a/src/migrations/m221206_094303_add_store_to_order.php +++ /dev/null @@ -1,43 +0,0 @@ -select(['id']) - ->from(['{{%commerce_stores}}']) - ->where(['primary' => true]) - ->scalar(); - - // Add storeId to order table - if (!$this->db->columnExists('{{%commerce_orders}}', 'storeId')) { - $this->addColumn('{{%commerce_orders}}', 'storeId', $this->integer()->after('id')->defaultValue($primaryStoreId)->notNull()); - $this->addForeignKey(null, '{{%commerce_orders}}', ['storeId'], '{{%commerce_stores}}', ['id'], 'CASCADE', 'CASCADE'); - $this->createIndex(null, '{{%commerce_orders}}', ['storeId'], false); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m221206_094303_add_store_to_order cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m221213_052623_drop_lite.php b/src/migrations/m221213_052623_drop_lite.php deleted file mode 100644 index d0c787d5df..0000000000 --- a/src/migrations/m221213_052623_drop_lite.php +++ /dev/null @@ -1,38 +0,0 @@ -db->columnExists('{{%commerce_shippingmethods}}', 'isLite')) { - $this->dropColumn('{{%commerce_shippingmethods}}', 'isLite'); - } - if ($this->db->columnExists('{{%commerce_taxrates}}', 'isLite')) { - $this->dropColumn('{{%commerce_taxrates}}', 'isLite'); - } - if ($this->db->columnExists('{{%commerce_shippingrules}}', 'isLite')) { - $this->dropColumn('{{%commerce_shippingrules}}', 'isLite'); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m221213_052623_drop_lite cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m221213_070807_initial_storeId_records_transition.php b/src/migrations/m221213_070807_initial_storeId_records_transition.php deleted file mode 100644 index a17793350e..0000000000 --- a/src/migrations/m221213_070807_initial_storeId_records_transition.php +++ /dev/null @@ -1,53 +0,0 @@ -select(['id']) - ->from(['{{%commerce_stores}}']) - ->where(['primary' => true]) - ->scalar(); - - if (!$this->db->columnExists('{{%commerce_paymentcurrencies}}', 'storeId')) { - $this->addColumn('{{%commerce_paymentcurrencies}}', 'storeId', $this->integer()->after('id')->defaultValue($primaryStoreId)->notNull()); - $this->addForeignKey(null, '{{%commerce_paymentcurrencies}}', ['storeId'], '{{%commerce_stores}}', ['id'], 'CASCADE', 'CASCADE'); - $this->createIndex(null, '{{%commerce_paymentcurrencies}}', ['storeId'], false); - } - - if (!$this->db->columnExists('{{%commerce_donations}}', 'storeId')) { - $this->addColumn('{{%commerce_donations}}', 'storeId', $this->integer()->after('id')->defaultValue($primaryStoreId)->notNull()); - $this->addForeignKey(null, '{{%commerce_donations}}', ['storeId'], '{{%commerce_stores}}', ['id'], 'CASCADE', 'CASCADE'); - $this->createIndex(null, '{{%commerce_donations}}', ['storeId'], false); - } - - if (!$this->db->columnExists('{{%commerce_discounts}}', 'storeId')) { - $this->addColumn('{{%commerce_discounts}}', 'storeId', $this->integer()->after('id')->defaultValue($primaryStoreId)->notNull()); - $this->addForeignKey(null, '{{%commerce_discounts}}', ['storeId'], '{{%commerce_stores}}', ['id'], 'CASCADE', 'CASCADE'); - $this->createIndex(null, '{{%commerce_discounts}}', ['storeId'], false); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m221213_070807_initial_storeId_records_transition cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230103_122549_add_product_type_max_variants.php b/src/migrations/m230103_122549_add_product_type_max_variants.php deleted file mode 100755 index 605933eb70..0000000000 --- a/src/migrations/m230103_122549_add_product_type_max_variants.php +++ /dev/null @@ -1,68 +0,0 @@ -db->columnExists('{{%commerce_producttypes}}', 'maxVariants')) { - $this->addColumn('{{%commerce_producttypes}}', 'maxVariants', $this->integer()); - } - - if ($this->db->columnExists('{{%commerce_producttypes}}', 'hasVariants')) { - $this->update('{{%commerce_producttypes}}', ['maxVariants' => 1], ['hasVariants' => false]); - - $this->updateProjectConfig(); - - $this->dropColumn('{{%commerce_producttypes}}', 'hasVariants'); - } - - return true; - } - - private function updateProjectConfig(): void - { - $projectConfig = Craft::$app->getProjectConfig(); - - $projectConfig->muteEvents = true; - - $maxVariantProductTypes = (new Query()) - ->select(['id', 'maxVariants', 'uid']) - ->from(['{{%commerce_producttypes}}']) - ->all(); - - foreach ($maxVariantProductTypes as $productType) { - $config = $projectConfig->get(ProductTypes::CONFIG_PRODUCTTYPES_KEY . '.' . $productType['uid']); - if (array_key_exists('hasVariants', $config)) { - unset($config['hasVariants']); - } - - $config['maxVariants'] = $productType['maxVariants']; - $projectConfig->set(ProductTypes::CONFIG_PRODUCTTYPES_KEY . '.' . $productType['uid'], $config); - } - - $projectConfig->muteEvents = false; - } - - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230103_122549_add_product_type_max_variants cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230110_052712_site_stores.php b/src/migrations/m230110_052712_site_stores.php deleted file mode 100644 index 94ecf42343..0000000000 --- a/src/migrations/m230110_052712_site_stores.php +++ /dev/null @@ -1,74 +0,0 @@ -createTable('{{%commerce_site_stores}}', [ - 'siteId' => $this->integer(), - 'storeId' => $this->integer()->null(), // defaults to primary store in app - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - 'PRIMARY KEY([[siteId]])', - ]); - - // Get the primary store - $primaryStore = (new Query()) - ->select(['id', 'uid']) - ->from([Table::STORES]) - ->where(['primary' => true]) - ->one(); - - // Get all sites - $sites = (new Query()) - ->select(['id', 'handle', 'uid']) - ->from(\craft\db\Table::SITES) - ->where(['dateDeleted' => null]) - ->all(); - - // Create site stores records - foreach ($sites as $site) { - $this->insert('{{%commerce_site_stores}}', [ - 'siteId' => $site['id'], - 'storeId' => $primaryStore['id'], - 'uid' => $site['uid'], - ]); - - $projectConfig = \Craft::$app->getProjectConfig(); - - $configPath = Stores::CONFIG_SITESTORES_KEY . "." . $site['uid']; - $projectConfig->set( - $configPath, - // Mirror what the site store model `getConfig()` method returns - ['store' => $primaryStore['uid']], - "Save the “{$site['handle']}” commerce site store mapping" - ); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230110_052712_site_stores cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230111_112916_update_lineitems_table.php b/src/migrations/m230111_112916_update_lineitems_table.php deleted file mode 100755 index fd195a39ad..0000000000 --- a/src/migrations/m230111_112916_update_lineitems_table.php +++ /dev/null @@ -1,33 +0,0 @@ -addColumn(Table::LINEITEMS, 'promotionalPrice', $this->decimal(14, 4)->after('price')->null()->unsigned()); - - $this->renameColumn(Table::LINEITEMS, 'saleAmount', 'promotionalAmount'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230111_112916_update_lineitems_table cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230113_110914_remove_soft_delete.php b/src/migrations/m230113_110914_remove_soft_delete.php deleted file mode 100644 index 195e3a2bc4..0000000000 --- a/src/migrations/m230113_110914_remove_soft_delete.php +++ /dev/null @@ -1,32 +0,0 @@ -db->columnExists('{{%commerce_stores}}', 'dateDeleted')) { - $this->dropColumn('{{%commerce_stores}}', 'dateDeleted'); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230113_110914_remove_soft_delete cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230118_114424_add_purchasables_stores_indexes.php b/src/migrations/m230118_114424_add_purchasables_stores_indexes.php deleted file mode 100755 index 81d3a7590c..0000000000 --- a/src/migrations/m230118_114424_add_purchasables_stores_indexes.php +++ /dev/null @@ -1,32 +0,0 @@ -createIndexIfMissing(Table::PURCHASABLES_STORES, ['purchasableId']); - $this->createIndexIfMissing(Table::PURCHASABLES_STORES, ['storeId']); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230118_114424_add_purchasables_stores_indexes cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230126_105337_rename_discount_sales_references.php b/src/migrations/m230126_105337_rename_discount_sales_references.php deleted file mode 100755 index 51e54ce67c..0000000000 --- a/src/migrations/m230126_105337_rename_discount_sales_references.php +++ /dev/null @@ -1,32 +0,0 @@ -renameColumn(Table::DISCOUNTS, 'excludeOnSale', 'excludeOnPromotion'); - $this->renameColumn(Table::DISCOUNTS, 'ignoreSales', 'ignorePromotions'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230126_105337_rename_discount_sales_references cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230126_114655_add_catalog_pricing_rule_metadata_column.php b/src/migrations/m230126_114655_add_catalog_pricing_rule_metadata_column.php deleted file mode 100755 index a0fd28c4e0..0000000000 --- a/src/migrations/m230126_114655_add_catalog_pricing_rule_metadata_column.php +++ /dev/null @@ -1,31 +0,0 @@ -addColumn(Table::CATALOG_PRICING_RULES, 'metadata', $this->text()); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230126_114655_add_catalog_pricing_rule_metadata_column cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230208_130445_add_store_id_to_shipping_categories.php b/src/migrations/m230208_130445_add_store_id_to_shipping_categories.php deleted file mode 100644 index 3a8de0c9a4..0000000000 --- a/src/migrations/m230208_130445_add_store_id_to_shipping_categories.php +++ /dev/null @@ -1,41 +0,0 @@ -addColumn(Table::SHIPPINGCATEGORIES, 'storeId', $this->integer()); - $this->createIndex(null, Table::SHIPPINGCATEGORIES, ['storeId'], false); - $this->addForeignKey(null, Table::SHIPPINGCATEGORIES, ['storeId'], Table::STORES, ['id'], 'CASCADE', null); - - $primaryStoreId = (new Query()) - ->select(['id']) - ->from(Table::STORES) - ->where(['primary' => true]) - ->scalar(); - $this->update(Table::SHIPPINGCATEGORIES, ['storeId' => $primaryStoreId]); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230208_130445_add_store_id_to_shipping_categories cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230210_093749_add_store_id_to_shipping_methods.php b/src/migrations/m230210_093749_add_store_id_to_shipping_methods.php deleted file mode 100644 index 58504ebdc0..0000000000 --- a/src/migrations/m230210_093749_add_store_id_to_shipping_methods.php +++ /dev/null @@ -1,41 +0,0 @@ -addColumn(Table::SHIPPINGMETHODS, 'storeId', $this->integer()); - $this->createIndex(null, Table::SHIPPINGMETHODS, ['storeId'], false); - $this->addForeignKey(null, Table::SHIPPINGMETHODS, ['storeId'], Table::STORES, ['id'], 'CASCADE', null); - - $primaryStoreId = (new Query()) - ->select(['id']) - ->from(Table::STORES) - ->where(['primary' => true]) - ->scalar(); - $this->update(Table::SHIPPINGMETHODS, ['storeId' => $primaryStoreId]); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230210_093749_add_store_id_to_shipping_methods cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230210_141514_add_store_id_to_shipping_zones.php b/src/migrations/m230210_141514_add_store_id_to_shipping_zones.php deleted file mode 100644 index 518416776d..0000000000 --- a/src/migrations/m230210_141514_add_store_id_to_shipping_zones.php +++ /dev/null @@ -1,41 +0,0 @@ -addColumn(Table::SHIPPINGZONES, 'storeId', $this->integer()); - $this->createIndex(null, Table::SHIPPINGZONES, ['storeId'], false); - $this->addForeignKey(null, Table::SHIPPINGZONES, ['storeId'], Table::STORES, ['id'], 'CASCADE', null); - - $primaryStoreId = (new Query()) - ->select(['id']) - ->from(Table::STORES) - ->where(['primary' => true]) - ->scalar(); - $this->update(Table::SHIPPINGZONES, ['storeId' => $primaryStoreId]); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230210_141514_add_store_id_to_shipping_zones cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230214_094122_add_total_weight_column_to_orders.php b/src/migrations/m230214_094122_add_total_weight_column_to_orders.php deleted file mode 100644 index 4b453f7779..0000000000 --- a/src/migrations/m230214_094122_add_total_weight_column_to_orders.php +++ /dev/null @@ -1,54 +0,0 @@ -addColumn(Table::ORDERS, 'totalWeight', $this->decimal(14, 4)->defaultValue(0)->unsigned()); - - if ($this->db->getIsMysql()) { - $this->execute(' - UPDATE ' . Table::ORDERS . ' o - LEFT JOIN ( - SELECT [[orderId]], SUM([[weight]]) AS [[totalWeight]] - FROM ' . Table::LINEITEMS . ' - GROUP BY [[orderId]] - ) agg ON agg.[[orderId]] = o.[[id]] - SET o.[[totalWeight]] = COALESCE(agg.[[totalWeight]], 0) - '); - } else { - $this->execute(' - UPDATE ' . Table::ORDERS . ' o - SET [[totalWeight]] = COALESCE(agg.[[totalWeight]], 0) - FROM ( - SELECT [[orderId]], SUM([[weight]]) AS [[totalWeight]] - FROM ' . Table::LINEITEMS . ' - GROUP BY [[orderId]] - ) agg - WHERE o.[[id]] = agg.[[orderId]] - '); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230214_094122_add_total_weight_column_to_orders cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230214_095055_update_name_index_on_shipping_zones.php b/src/migrations/m230214_095055_update_name_index_on_shipping_zones.php deleted file mode 100644 index 8e216063f7..0000000000 --- a/src/migrations/m230214_095055_update_name_index_on_shipping_zones.php +++ /dev/null @@ -1,33 +0,0 @@ -getDb()); - $this->createIndex(null, Table::SHIPPINGZONES, ['name'], false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230214_095055_update_name_index_on_shipping_zones cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230215_083820_add_order_condition_to_shipping_rules.php b/src/migrations/m230215_083820_add_order_condition_to_shipping_rules.php deleted file mode 100644 index f6a76e6eb1..0000000000 --- a/src/migrations/m230215_083820_add_order_condition_to_shipping_rules.php +++ /dev/null @@ -1,31 +0,0 @@ -addColumn(Table::SHIPPINGRULES, 'orderCondition', $this->text()); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230215_083820_add_order_condition_to_shipping_rules cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230215_114552_migrate_shipping_rule_conditions_to_condition_builder.php b/src/migrations/m230215_114552_migrate_shipping_rule_conditions_to_condition_builder.php deleted file mode 100644 index a9d6b946aa..0000000000 --- a/src/migrations/m230215_114552_migrate_shipping_rule_conditions_to_condition_builder.php +++ /dev/null @@ -1,130 +0,0 @@ -select([ - 'id', - 'minQty', - 'maxQty', - 'minTotal', - 'maxTotal', - 'minMaxTotalType', - 'minWeight', - 'maxWeight', - 'shippingZoneId', - ]) - ->from(Table::SHIPPINGRULES) - ->all(); - - if (empty($shippingRules)) { - return true; - } - - $primaryStoreId = (new Query()) - ->select(['id']) - ->from(Table::STORES) - ->where(['primary' => true]) - ->scalar(); - - foreach ($shippingRules as $shippingRule) { - $orderCondition = new ShippingRuleOrderCondition(); - $orderCondition->storeId = $primaryStoreId; - - // Convert min/max qty to order condition rule - if ($shippingRule['minQty'] > 0 || $shippingRule['maxQty'] > 0) { - $orderCondition = $this->_setConditionRule(new TotalQtyConditionRule(), $orderCondition, $shippingRule['minQty'], $shippingRule['maxQty'], true); - } - - // Convert min/max item subtotal to condition rule - if ($shippingRule['minMaxTotalType'] === 'salePrice' && ($shippingRule['minTotal'] > 0 || $shippingRule['maxTotal'] > 0)) { - $orderCondition = $this->_setConditionRule(new ItemSubtotalConditionRule(), $orderCondition, $shippingRule['minTotal'], $shippingRule['maxTotal']); - } - - // Convert min/max item subtotal with discounts to condition rule - if ($shippingRule['minMaxTotalType'] === 'salePriceWithDiscounts' && ($shippingRule['minTotal'] > 0 || $shippingRule['maxTotal'] > 0)) { - $orderCondition = $this->_setConditionRule(new DiscountedItemSubtotalConditionRule(), $orderCondition, $shippingRule['minTotal'], $shippingRule['maxTotal']); - } - - // Convert min/max total weight to condition rule - if ($shippingRule['minWeight'] > 0 || $shippingRule['maxWeight'] > 0) { - $orderCondition = $this->_setConditionRule(new TotalWeightConditionRule(), $orderCondition, $shippingRule['minWeight'], $shippingRule['maxWeight']); - } - - // Convert shipping zone to condition rule - if ($shippingRule['shippingZoneId']) { - $rule = new ShippingAddressZoneConditionRule(); - $rule->values = [$shippingRule['shippingZoneId']]; - - $orderCondition->addConditionRule($rule); - } - - // Update shipping rule - if (!empty($orderCondition->getConditionRules())) { - $this->update(Table::SHIPPINGRULES, [ - 'orderCondition' => Db::prepareValueForDb($orderCondition->getConfig()), - ], [ - 'id' => $shippingRule['id'], - ]); - } - } - - return true; - } - - /** - * @param OrderValuesAttributeConditionRule|OrderCurrencyValuesAttributeConditionRule $rule - * @param ShippingRuleOrderCondition $orderCondition - * @param bool $adjustValues - * @return ShippingRuleOrderCondition - */ - private function _setConditionRule(OrderValuesAttributeConditionRule|OrderCurrencyValuesAttributeConditionRule $rule, ShippingRuleOrderCondition $orderCondition, mixed $min, mixed $max, bool $adjustValues = false): ShippingRuleOrderCondition - { - // Write this manually because at the moment the operator constants are all protected and not public - $rule->operator = 'between'; - - if ($max > 0) { - $rule->maxValue = $adjustValues ? $max - 1 : $max; - } - - if ($min > 0) { - $rule->value = $adjustValues ? $min - 1 : $min; - } - - $orderCondition->addConditionRule($rule); - - return $orderCondition; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230215_114552_migrate_shipping_rule_conditions_to_condition_builder cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230217_095845_remove_shipping_rules_columns.php b/src/migrations/m230217_095845_remove_shipping_rules_columns.php deleted file mode 100644 index 8ba1ef0cc8..0000000000 --- a/src/migrations/m230217_095845_remove_shipping_rules_columns.php +++ /dev/null @@ -1,40 +0,0 @@ -dropColumn(Table::SHIPPINGRULES, 'minQty'); - $this->dropColumn(Table::SHIPPINGRULES, 'maxQty'); - $this->dropColumn(Table::SHIPPINGRULES, 'minTotal'); - $this->dropColumn(Table::SHIPPINGRULES, 'maxTotal'); - $this->dropColumn(Table::SHIPPINGRULES, 'minMaxTotalType'); - $this->dropColumn(Table::SHIPPINGRULES, 'minWeight'); - $this->dropColumn(Table::SHIPPINGRULES, 'maxWeight'); - - $this->dropForeignKeyIfExists(Table::SHIPPINGRULES, 'shippingZoneId'); - $this->dropIndexIfExists(Table::SHIPPINGRULES, 'shippingZoneId'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230217_095845_remove_shipping_rules_columns cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230217_143255_add_shipping_method_order_condition.php b/src/migrations/m230217_143255_add_shipping_method_order_condition.php deleted file mode 100644 index 0d51916687..0000000000 --- a/src/migrations/m230217_143255_add_shipping_method_order_condition.php +++ /dev/null @@ -1,31 +0,0 @@ -addColumn(Table::SHIPPINGMETHODS, 'orderCondition', $this->text()); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230217_143255_add_shipping_method_order_condition cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230220_075106_add_store_id_to_tax_rates.php b/src/migrations/m230220_075106_add_store_id_to_tax_rates.php deleted file mode 100644 index 11cf290af9..0000000000 --- a/src/migrations/m230220_075106_add_store_id_to_tax_rates.php +++ /dev/null @@ -1,43 +0,0 @@ -addColumn(Table::TAXRATES, 'storeId', $this->integer()); - - $primaryStoreId = (new Query()) - ->select(['id']) - ->from(Table::STORES) - ->where(['primary' => true]) - ->scalar(); - - $this->update(Table::TAXRATES, ['storeId' => $primaryStoreId], ['storeId' => null], [], false); - - $this->addForeignKey(null, Table::TAXRATES, ['storeId'], Table::STORES, ['id'], 'CASCADE', null); - $this->createIndex(null, Table::TAXRATES, ['storeId'], false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230220_075106_add_store_id_to_tax_rates cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230220_080107_add_store_id_to_tax_zones.php b/src/migrations/m230220_080107_add_store_id_to_tax_zones.php deleted file mode 100644 index f9db699a4d..0000000000 --- a/src/migrations/m230220_080107_add_store_id_to_tax_zones.php +++ /dev/null @@ -1,43 +0,0 @@ -addColumn(Table::TAXZONES, 'storeId', $this->integer()); - - $primaryStoreId = (new Query()) - ->select(['id']) - ->from(Table::STORES) - ->where(['primary' => true]) - ->scalar(); - - $this->update(Table::TAXZONES, ['storeId' => $primaryStoreId], ['storeId' => null], [], false); - - $this->addForeignKey(null, Table::TAXZONES, ['storeId'], Table::STORES, ['id'], 'CASCADE', null); - $this->createIndex(null, Table::TAXZONES, ['storeId'], false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230220_080107_add_store_id_to_tax_zones cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230307_091520_add_sort_order_to_stores.php b/src/migrations/m230307_091520_add_sort_order_to_stores.php deleted file mode 100644 index 93ec499f7d..0000000000 --- a/src/migrations/m230307_091520_add_sort_order_to_stores.php +++ /dev/null @@ -1,33 +0,0 @@ -addColumn(Table::STORES, 'sortOrder', $this->integer()); - - $this->update(Table::STORES, ['sortOrder' => 1], ['primary' => true], [], false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230307_091520_add_sort_order_to_stores cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230308_084340_add_store_id_to_order_statuses.php b/src/migrations/m230308_084340_add_store_id_to_order_statuses.php deleted file mode 100644 index 72e1512014..0000000000 --- a/src/migrations/m230308_084340_add_store_id_to_order_statuses.php +++ /dev/null @@ -1,57 +0,0 @@ -addColumn(Table::ORDERSTATUSES, 'storeId', $this->integer()); - - $primaryStore = (new Query()) - ->select(['id', 'uid']) - ->from(Table::STORES) - ->where(['primary' => true]) - ->one(); - - $this->update(Table::ORDERSTATUSES, ['storeId' => $primaryStore['id']], ['storeId' => null], [], false); - - $this->addForeignKey(null, Table::ORDERSTATUSES, ['storeId'], Table::STORES, ['id'], 'CASCADE', null); - $this->createIndex(null, Table::ORDERSTATUSES, ['storeId'], false); - - $projectConfig = Craft::$app->getProjectConfig(); - - $orderStatuses = $projectConfig->get('commerce.orderStatuses') ?? []; - $muteEvents = $projectConfig->muteEvents; - $projectConfig->muteEvents = true; - - foreach ($orderStatuses as $statusUid => $orderStatus) { - $orderStatus['store'] = $primaryStore['uid']; - $projectConfig->set("commerce.orderStatuses.$statusUid", $orderStatus); - } - - $projectConfig->muteEvents = $muteEvents; - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230308_084340_add_store_id_to_order_statuses cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230310_102639_add_store_id_to_line_item_statuses.php b/src/migrations/m230310_102639_add_store_id_to_line_item_statuses.php deleted file mode 100644 index 45879eed15..0000000000 --- a/src/migrations/m230310_102639_add_store_id_to_line_item_statuses.php +++ /dev/null @@ -1,57 +0,0 @@ -addColumn(Table::LINEITEMSTATUSES, 'storeId', $this->integer()); - - $primaryStore = (new Query()) - ->select(['id', 'uid']) - ->from(Table::STORES) - ->where(['primary' => true]) - ->one(); - - $this->update(Table::LINEITEMSTATUSES, ['storeId' => $primaryStore['id']], ['storeId' => null], [], false); - - $this->addForeignKey(null, Table::LINEITEMSTATUSES, ['storeId'], Table::STORES, ['id'], 'CASCADE', null); - $this->createIndex(null, Table::LINEITEMSTATUSES, ['storeId'], false); - - $projectConfig = Craft::$app->getProjectConfig(); - - $lineItemStatuses = $projectConfig->get('commerce.lineItemStatuses') ?? []; - $muteEvents = $projectConfig->muteEvents; - $projectConfig->muteEvents = true; - - foreach ($lineItemStatuses as $statusUid => $lineItemStatus) { - $lineItemStatus['store'] = $primaryStore['uid']; - $projectConfig->set("commerce.lineItemStatuses.$statusUid", $lineItemStatus); - } - - $projectConfig->muteEvents = $muteEvents; - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230310_102639_add_store_id_to_line_item_statuses cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230313_095359_add_store_id_to_emails.php b/src/migrations/m230313_095359_add_store_id_to_emails.php deleted file mode 100644 index 7c5e1f8127..0000000000 --- a/src/migrations/m230313_095359_add_store_id_to_emails.php +++ /dev/null @@ -1,58 +0,0 @@ -addColumn(Table::EMAILS, 'storeId', $this->integer()); - - $primaryStore = (new Query()) - ->select(['id', 'uid']) - ->from(Table::STORES) - ->where(['primary' => true]) - ->one(); - - $this->update(Table::EMAILS, ['storeId' => $primaryStore['id']], ['storeId' => null], [], false); - - $this->addForeignKey(null, Table::EMAILS, ['storeId'], Table::STORES, ['id'], 'CASCADE', null); - $this->createIndex(null, Table::EMAILS, ['storeId'], false); - - $projectConfig = Craft::$app->getProjectConfig(); - - $emails = $projectConfig->get('commerce.emails') ?? []; - $muteEvents = $projectConfig->muteEvents; - $projectConfig->muteEvents = true; - - foreach ($emails as $emailUid => $email) { - $email['store'] = $primaryStore['uid']; - $projectConfig->set("commerce.emails.$emailUid", $email); - } - - $projectConfig->muteEvents = $muteEvents; - - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230313_095359_add_store_id_to_emails cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230317_102521_add_store_id_to_pdfs.php b/src/migrations/m230317_102521_add_store_id_to_pdfs.php deleted file mode 100644 index 49ae438562..0000000000 --- a/src/migrations/m230317_102521_add_store_id_to_pdfs.php +++ /dev/null @@ -1,58 +0,0 @@ -addColumn(Table::PDFS, 'storeId', $this->integer()); - - $primaryStore = (new Query()) - ->select(['id', 'uid']) - ->from(Table::STORES) - ->where(['primary' => true]) - ->one(); - - $this->update(Table::PDFS, ['storeId' => $primaryStore['id']], ['storeId' => null], [], false); - - $this->addForeignKey(null, Table::PDFS, ['storeId'], Table::STORES, ['id'], 'CASCADE', null); - $this->createIndex(null, Table::PDFS, ['storeId'], false); - - $projectConfig = Craft::$app->getProjectConfig(); - - $pdfs = $projectConfig->get('commerce.pdfs') ?? []; - $muteEvents = $projectConfig->muteEvents; - $projectConfig->muteEvents = true; - - foreach ($pdfs as $uid => $pdf) { - $pdf['store'] = $primaryStore['uid']; - $projectConfig->set("commerce.pdfs.$uid", $pdf); - } - - $projectConfig->muteEvents = $muteEvents; - - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230317_102521_add_store_id_to_pdfs cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230322_091615_move_email_settings_to_model.php b/src/migrations/m230322_091615_move_email_settings_to_model.php deleted file mode 100644 index 0e5f371ef9..0000000000 --- a/src/migrations/m230322_091615_move_email_settings_to_model.php +++ /dev/null @@ -1,61 +0,0 @@ -addColumn(Table::EMAILS, 'senderName', $this->string()->after('name')); - $this->addColumn(Table::EMAILS, 'senderAddress', $this->string()->after('name')); - - $commerceConfig = Craft::$app->getConfig()->getConfigFromFile('commerce'); - - if (empty($commerceConfig)) { - return true; - } - - $senderAddress = $commerceConfig['emailSenderAddress'] ?? null; - $senderName = $commerceConfig['emailSenderName'] ?? null; - - $this->update(Table::EMAILS, [ - 'senderAddress' => $senderAddress, - 'senderName' => $senderName, - ]); - - $projectConfig = Craft::$app->getProjectConfig(); - - $emails = $projectConfig->get('commerce.emails') ?? []; - $muteEvents = $projectConfig->muteEvents; - $projectConfig->muteEvents = true; - - foreach ($emails as $uid => $email) { - $email['senderAddress'] = $senderAddress; - $email['senderName'] = $senderName; - $projectConfig->set("commerce.emails.$uid", $email); - } - - $projectConfig->muteEvents = $muteEvents; - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230322_091615_move_email_settings_to_model cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230328_130343_move_pdf_settings_to_model.php b/src/migrations/m230328_130343_move_pdf_settings_to_model.php deleted file mode 100644 index 133080e2be..0000000000 --- a/src/migrations/m230328_130343_move_pdf_settings_to_model.php +++ /dev/null @@ -1,59 +0,0 @@ -addColumn(Table::PDFS, 'paperOrientation', $this->string()->defaultValue('portrait')); - $this->addColumn(Table::PDFS, 'paperSize', $this->string()->defaultValue('letter')); - - $commerceConfig = Craft::$app->getConfig()->getConfigFromFile('commerce'); - - if (empty($commerceConfig)) { - return true; - } - - $data = [ - 'paperOrientation' => $commerceConfig['pdfPaperOrientation'] ?? 'portrait', - 'paperSize' => $commerceConfig['pdfPaperSize'] ?? 'letter', - ]; - - $this->update(Table::PDFS, $data); - - $projectConfig = Craft::$app->getProjectConfig(); - - $pdfs = $projectConfig->get('commerce.pdfs') ?? []; - $muteEvents = $projectConfig->muteEvents; - $projectConfig->muteEvents = true; - - foreach ($pdfs as $uid => $pdf) { - $projectConfig->set("commerce.pdfs.$uid", array_merge($pdf, $data)); - } - - $projectConfig->muteEvents = $muteEvents; - - return true; - } - - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230328_130343_move_pdf_settings_to_model cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230525_081243_add_has_update_pending_property.php b/src/migrations/m230525_081243_add_has_update_pending_property.php deleted file mode 100644 index 68dad0cfab..0000000000 --- a/src/migrations/m230525_081243_add_has_update_pending_property.php +++ /dev/null @@ -1,31 +0,0 @@ -addColumn(Table::CATALOG_PRICING, 'hasUpdatePending', $this->boolean()->notNull()->defaultValue(false)); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230525_081243_add_has_update_pending_property cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230530_100604_add_complete_email_column.php b/src/migrations/m230530_100604_add_complete_email_column.php deleted file mode 100644 index 296db6ae22..0000000000 --- a/src/migrations/m230530_100604_add_complete_email_column.php +++ /dev/null @@ -1,36 +0,0 @@ -addColumn(Table::ORDERS, 'orderCompletedEmail', $this->string()); - - // Update existing data - $this->update(Table::ORDERS, ['orderCompletedEmail' => new Expression('email')], ['isCompleted' => true], [], false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230530_100604_add_complete_email_column cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230705_124845_add_save_address_columns.php b/src/migrations/m230705_124845_add_save_address_columns.php deleted file mode 100644 index bcf32d2e04..0000000000 --- a/src/migrations/m230705_124845_add_save_address_columns.php +++ /dev/null @@ -1,32 +0,0 @@ -addColumn(Table::ORDERS, 'saveBillingAddressOnOrderComplete', $this->boolean()->notNull()->defaultValue(false)); - $this->addColumn(Table::ORDERS, 'saveShippingAddressOnOrderComplete', $this->boolean()->notNull()->defaultValue(false)); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230705_124845_add_save_address_columns cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230719_082348_discount_nullable_conditions.php b/src/migrations/m230719_082348_discount_nullable_conditions.php deleted file mode 100644 index b4b8f6b16b..0000000000 --- a/src/migrations/m230719_082348_discount_nullable_conditions.php +++ /dev/null @@ -1,49 +0,0 @@ -getConfig()); - - $this->update(Table::DISCOUNTS, ['orderCondition' => null], ['orderCondition' => $orderConditionConfig], [], false); - - $customerCondition = new DiscountCustomerCondition(); - $customerConditionConfig = Json::encode($customerCondition->getConfig()); - - $this->update(Table::DISCOUNTS, ['customerCondition' => null], ['customerCondition' => $customerConditionConfig], [], false); - - $addressCondition = new DiscountAddressCondition(); - $addressConditionConfig = Json::encode($addressCondition->getConfig()); - - $this->update(Table::DISCOUNTS, ['billingAddressCondition' => null], ['billingAddressCondition' => $addressConditionConfig], [], false); - $this->update(Table::DISCOUNTS, ['shippingAddressCondition' => null], ['shippingAddressCondition' => $addressConditionConfig], [], false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230719_082348_discount_nullable_conditions cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230724_080855_entrify_promotions.php b/src/migrations/m230724_080855_entrify_promotions.php deleted file mode 100644 index 96084443f1..0000000000 --- a/src/migrations/m230724_080855_entrify_promotions.php +++ /dev/null @@ -1,43 +0,0 @@ -db); - Db::dropForeignKeyIfExists(Table::DISCOUNT_CATEGORIES, ['discountId'], $this->db); - Db::dropForeignKeyIfExists(Table::SALE_CATEGORIES, ['categoryId'], $this->db); - Db::dropForeignKeyIfExists(Table::SALE_CATEGORIES, ['saleId'], $this->db); - - // Add the FKs back but to the Elements table not the categories table - $this->addForeignKey(null, Table::DISCOUNT_CATEGORIES, ['categoryId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::DISCOUNT_CATEGORIES, ['discountId'], Table::DISCOUNTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::SALE_CATEGORIES, ['categoryId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::SALE_CATEGORIES, ['saleId'], Table::SALES, ['id'], 'CASCADE', 'CASCADE'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230724_080855_entrify_promotions cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230920_051125_move_primary_currency_to_store_settings.php b/src/migrations/m230920_051125_move_primary_currency_to_store_settings.php deleted file mode 100644 index 81d9f96928..0000000000 --- a/src/migrations/m230920_051125_move_primary_currency_to_store_settings.php +++ /dev/null @@ -1,72 +0,0 @@ -db->columnExists('{{%commerce_stores}}', 'currency')) { - $this->addColumn('{{%commerce_stores}}', 'currency', $this->string()->notNull()->defaultValue('USD')); - } - - $primaryCurrencyIso = (new Query()) - ->select('iso') - ->from('{{%commerce_paymentcurrencies}}') - ->where(['primary' => true]) - ->scalar(); - - $storeId = (new Query()) - ->select(['id']) - ->from(['{{%commerce_stores}}']) - ->scalar(); - - // update all stores record with currency - $this->update('{{%commerce_stores}}', ['currency' => $primaryCurrencyIso], ['id' => $storeId]); - - // Make project config updates - $projectConfig = \Craft::$app->getProjectConfig(); - - $storeUid = (new Query()) - ->select(['uid']) - ->from(['{{%commerce_stores}}']) - ->scalar(); - - // delete the primary payment currency and drop primary column from payment currencies - $this->dropColumn('{{%commerce_paymentcurrencies}}', 'primary'); - - $this->dropIndexIfExists(Table::PAYMENTCURRENCIES, 'iso', true); - $this->createIndex(null, Table::PAYMENTCURRENCIES, 'iso', false); - - // get store config - $config = $projectConfig->get(Stores::CONFIG_STORES_KEY . '.' . $storeUid); - - $config['currency'] = $primaryCurrencyIso; - $projectConfig->set(Stores::CONFIG_STORES_KEY . '.' . $storeUid, - $config, - 'Moving the primary currency to the store in the project config'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230920_051125_move_primary_currency_to_store_settings cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230928_095544_fix_unique_on_some_tables.php b/src/migrations/m230928_095544_fix_unique_on_some_tables.php deleted file mode 100644 index 8e15420278..0000000000 --- a/src/migrations/m230928_095544_fix_unique_on_some_tables.php +++ /dev/null @@ -1,35 +0,0 @@ -dropIndexIfExists(Table::SHIPPINGMETHODS, 'name', true); - $this->dropIndexIfExists(Table::TAXZONES, 'name', true); - - $this->createIndex(null, Table::SHIPPINGMETHODS, 'name', false); - $this->createIndex(null, Table::TAXZONES, 'name', false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230928_095544_fix_unique_on_some_tables cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m230928_155052_move_shipping_category_id_to_purchasable_stores.php b/src/migrations/m230928_155052_move_shipping_category_id_to_purchasable_stores.php deleted file mode 100644 index 28d2d91dc9..0000000000 --- a/src/migrations/m230928_155052_move_shipping_category_id_to_purchasable_stores.php +++ /dev/null @@ -1,65 +0,0 @@ -select(['id', 'shippingCategoryId'])->from(Table::PURCHASABLES)->all(); - - $this->dropForeignKeyIfExists(Table::PURCHASABLES, ['shippingCategoryId']); - $this->addColumn(Table::PURCHASABLES_STORES, 'shippingCategoryId', $this->integer()->null()); - - $cases = []; - foreach ($purchasablesShipping as $row) { - if (!$row['shippingCategoryId']) { - continue; - } - $cases[] = 'WHEN purchasableId = ' . $row['id'] . ' THEN ' . $row['shippingCategoryId']; - } - - foreach ($purchasablesShipping as $item) { - $this->update(Table::PURCHASABLES_STORES, ['shippingCategoryId' => $item['shippingCategoryId']], ['purchasableId' => $item['id']], [], false); - } - - // if (!empty($cases)) { - // $batches = array_chunk($cases, 5); - // foreach ($batches as $batch) { - // $this->update( - // Table::PURCHASABLES_STORES, - // ['shippingCategoryId' => new Expression(sprintf('(CASE %s END)', implode(' ', $batch)))], - // [], - // [], - // false, - // ); - // } - // } - - $this->addForeignKey(null, Table::PURCHASABLES_STORES, ['shippingCategoryId'], Table::SHIPPINGCATEGORIES, ['id']); - $this->dropColumn(Table::PURCHASABLES, 'shippingCategoryId'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m230928_155052_move_shipping_category_id_to_purchasable_stores cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m231006_034833_add_indexes_for_source_address_on_order.php b/src/migrations/m231006_034833_add_indexes_for_source_address_on_order.php deleted file mode 100644 index e12544f3fa..0000000000 --- a/src/migrations/m231006_034833_add_indexes_for_source_address_on_order.php +++ /dev/null @@ -1,32 +0,0 @@ -createIndex(null, Table::ORDERS, 'sourceBillingAddressId', false); - $this->createIndex(null, Table::ORDERS, 'sourceShippingAddressId', false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m231006_034833_add_indexes_for_source_address_on_order cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m231019_110814_update_variant_ownership.php b/src/migrations/m231019_110814_update_variant_ownership.php deleted file mode 100644 index 3506ab92e6..0000000000 --- a/src/migrations/m231019_110814_update_variant_ownership.php +++ /dev/null @@ -1,51 +0,0 @@ -select([ - 'id as elementId', - 'productId as ownerId', - new Expression('CASE WHEN [[sortOrder]] is NULL THEN 1 ELSE [[sortOrder]] END as [[sortOrder]]'), - ]) - ->from([Table::VARIANTS]) - ->all(); - - // Insert data in element owners - $this->batchInsert(CraftTable::ELEMENTS_OWNERS, ['elementId', 'ownerId', 'sortOrder'], $data); - - // Rename `productId` column - $this->renameColumn(Table::VARIANTS, 'productId', 'primaryOwnerId'); - - // Remove sort order - $this->dropColumn(Table::VARIANTS, 'sortOrder'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m231019_110814_update_variant_ownership cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m231110_081143_inventory_movement_table.php b/src/migrations/m231110_081143_inventory_movement_table.php deleted file mode 100644 index 1eefb0a2fa..0000000000 --- a/src/migrations/m231110_081143_inventory_movement_table.php +++ /dev/null @@ -1,217 +0,0 @@ -select(['id']) - ->from(Table::STORES) - ->where(['primary' => true]) - ->scalar(); - - // Gather the current purchasable stock counts - $stockCollection = collect((new Query()) - ->select([ - 'p.id as purchasableId', - new Expression('COALESCE([[ps.stock]], 0) as stock'), - new Expression('COALESCE([[ps.hasUnlimitedStock]], false) as unlimitedStock'), - ]) - ->from(['p' => Table::PURCHASABLES]) - ->leftJoin(['ps' => Table::PURCHASABLES_STORES], '[[p.id]] = [[ps.purchasableId]]') - ->where(['ps.storeId' => $primaryStoreId]) - ->limit(null) - ->all()); - - // Create the inventory items table, indexes and FKs - $this->createTable('{{%commerce_inventoryitems}}', [ - 'id' => $this->primaryKey(), - 'purchasableId' => $this->integer()->notNull(), - 'countryCodeOfOrigin' => $this->string(), - 'administrativeAreaCodeOfOrigin' => $this->string(), - 'harmonizedSystemCode' => $this->string(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - $this->createIndex(null, '{{%commerce_inventoryitems}}', 'purchasableId', true); - $this->addForeignKey(null, '{{%commerce_inventoryitems}}', 'purchasableId', '{{%commerce_purchasables}}', 'id', 'CASCADE', null); - - // Add the locations table - $this->createTable('{{%commerce_inventorylocations}}', [ - 'id' => $this->primaryKey(), - 'handle' => $this->string()->notNull(), - 'name' => $this->string()->notNull(), - 'addressId' => $this->integer(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'dateDeleted' => $this->dateTime(), - 'uid' => $this->uid(), - ]); - $this->addForeignKey(null, '{{%commerce_inventorylocations}}', 'addressId', '{{%addresses}}', 'id', 'CASCADE', null); - - // Create the transfers table - $this->createTable('{{%commerce_transfers}}', [ - 'id' => $this->primaryKey(), - 'transferStatus' => $this->enum('transferStatus', [ - 'draft', - 'pending', - 'partial', - 'received', - ])->notNull(), - 'originLocationId' => $this->integer(), - 'destinationLocationId' => $this->integer(), - 'uid' => $this->uid(), - ]); - - $this->createIndex(null, '{{%commerce_transfers}}', 'originLocationId', false); - $this->createIndex(null, '{{%commerce_transfers}}', 'destinationLocationId', false); - - // Create the commerce_inventory_movement table - $this->createTable('{{%commerce_inventorymovements}}', [ - 'id' => $this->primaryKey(), - 'inventoryLocationId' => $this->integer()->notNull(), - 'inventoryItemId' => $this->integer()->notNull(), - 'movementHash' => $this->string()->notNull(), - 'quantity' => $this->integer()->notNull(), - 'type' => $this->enum('type', [ - 'incoming', - 'available', - 'committed', - 'reserved', - 'damaged', - 'safety', - 'qualityControl', - ])->notNull(), - 'note' => $this->string(), - 'transferId' => $this->integer(), // Can be null - 'orderId' => $this->integer(), // Can be null - 'lineItemId' => $this->integer(), // Can be null - 'userId' => $this->integer(), // Can be null - 'dateCreated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - $this->createIndex(null, '{{%commerce_inventorymovements}}', 'inventoryItemId', false); - $this->createIndex(null, '{{%commerce_inventorymovements}}', 'transferId', false); - $this->createIndex(null, '{{%commerce_inventorymovements}}', 'orderId', false); - $this->createIndex(null, '{{%commerce_inventorymovements}}', 'lineItemId', false); - $this->createIndex(null, '{{%commerce_inventorymovements}}', 'userId', false); - - $this->addForeignKey(null, '{{%commerce_inventorymovements}}', 'inventoryItemId', '{{%commerce_inventoryitems}}', 'id', 'CASCADE', null); - $this->addForeignKey(null, '{{%commerce_inventorymovements}}', 'inventoryLocationId', '{{%commerce_inventorylocations}}', 'id', 'CASCADE', null); - $this->addForeignKey(null, '{{%commerce_inventorymovements}}', 'orderId', '{{%commerce_orders}}', 'id', 'SET NULL', null); - $this->addForeignKey(null, '{{%commerce_inventorymovements}}', 'lineItemId', '{{%commerce_lineitems}}', 'id', 'SET NULL', null); - $this->addForeignKey(null, '{{%commerce_inventorymovements}}', 'userId', '{{%users}}', 'id', 'SET NULL', null); - $this->addForeignKey(null, '{{%commerce_inventorymovements}}', 'transferId', '{{%commerce_transfers}}', 'id', 'SET NULL', null); - - - // get primary store - $primaryStore = (new Query()) - ->select(['id']) - ->from(Table::STORES) - ->where(['primary' => true]) - ->one(); - - // if no primaryStore found, use first one - if (!$primaryStore) { - $primaryStore = (new Query()) - ->select(['id']) - ->from(Table::STORES) - ->one(); - } - - // Get locationAddressId from store settings table - $locationAddressId = (new Query()) - ->select(['locationAddressId']) - ->from(Table::STORESETTINGS) - ->where(['id' => $primaryStore['id']]) - ->scalar(); - - // create default location - $this->insert('{{%commerce_inventorylocations}}', [ - 'name' => 'Default', - 'handle' => 'default', - 'addressId' => $locationAddressId ?: null, - 'dateCreated' => Db::prepareDateForDb(new \DateTime()), - 'dateUpdated' => Db::prepareDateForDb(new \DateTime()), - 'dateDeleted' => null, - 'uid' => StringHelper::UUID(), - ]); - $locationId = $this->db->getLastInsertID(); - - // Create an inventory item for each SKU - foreach ($stockCollection as $item) { - $now = Db::prepareDateForDb(new \DateTime()); - $this->insert('{{%commerce_inventoryitems}}', [ - 'purchasableId' => $item['purchasableId'], - 'dateCreated' => $now, - 'dateUpdated' => $now, - ]); - - $this->insert('{{%commerce_inventorymovements}}', [ - 'inventoryLocationId' => $locationId, - 'inventoryItemId' => $this->db->getLastInsertID(), - 'movementHash' => md5(uniqid((string)mt_rand(), true)), - 'quantity' => $item['stock'], - 'type' => 'available', - 'note' => 'count', - ]); - } - - // create inventory locations store relationship table - $this->createTable('{{%commerce_inventorylocations_stores}}', [ - 'id' => $this->primaryKey(), - 'inventoryLocationId' => $this->integer()->notNull(), - 'storeId' => $this->integer()->notNull(), - 'sortOrder' => $this->integer(), // per store - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->addForeignKey(null, '{{%commerce_inventorylocations_stores}}', 'inventoryLocationId', '{{%commerce_inventorylocations}}', 'id', 'CASCADE', null); - $this->addForeignKey(null, '{{%commerce_inventorylocations_stores}}', 'storeId', '{{%commerce_stores}}', 'id', 'CASCADE', null); - - // insert default location into store relationship table - $this->insert(Table::INVENTORYLOCATIONS_STORES, [ - 'inventoryLocationId' => $locationId, - 'storeId' => $primaryStore['id'], - 'sortOrder' => 1, - 'dateCreated' => Db::prepareDateForDb(new \DateTime()), - 'dateUpdated' => Db::prepareDateForDb(new \DateTime()), - ]); - - if ($this->db->columnExists('{{%commerce_purchasables_stores}}', 'hasUnlimitedStock')) { - $this->renameColumn(Table::PURCHASABLES_STORES, 'hasUnlimitedStock', 'inventoryTracked'); - } - - // Flip `inventoryTracked` column in purchasables stores table - $this->update('{{%commerce_purchasables_stores}}', ['inventoryTracked' => new Expression('NOT [[inventoryTracked]]')]); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m231110_081143_inventory_movement_table cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m231201_100454_update_discount_base_discount_type.php b/src/migrations/m231201_100454_update_discount_base_discount_type.php deleted file mode 100644 index bc9e960316..0000000000 --- a/src/migrations/m231201_100454_update_discount_base_discount_type.php +++ /dev/null @@ -1,35 +0,0 @@ -update(Table::DISCOUNTS, ['enabled' => false], ['not', ['baseDiscountType' => 'value']], updateTimestamp: false); - - // Remove `baseDiscountType` column - $this->dropColumn(Table::DISCOUNTS, 'baseDiscountType'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m231201_100454_update_discount_base_discount_type cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240119_073924_content_refactor_elements.php b/src/migrations/m240119_073924_content_refactor_elements.php deleted file mode 100644 index 6f25a04b3d..0000000000 --- a/src/migrations/m240119_073924_content_refactor_elements.php +++ /dev/null @@ -1,56 +0,0 @@ -updateElements( - (new Query())->from(Table::ORDERS), - Craft::$app->getFields()->getLayoutByType(Order::class) - ); - - // Migrate products and variants by product type - foreach (Plugin::getInstance()->getProductTypes()->getAllProductTypes() as $productType) { - // Update Products - $this->updateElements( - (new Query())->from(Table::PRODUCTS)->where(['typeId' => $productType->id]), - $productType->getProductFieldLayout() - ); - - // Update Variants - $this->updateElements( - (new Query())->from(Table::VARIANTS)->where([ - 'primaryOwnerId' => (new Query())->select('id')->from(Table::PRODUCTS)->where(['typeId' => $productType->id]), - ]), - $productType->getVariantFieldLayout() - ); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240119_073924_content_refactor_elements cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240119_075036_content_refactor_subscription_elements.php b/src/migrations/m240119_075036_content_refactor_subscription_elements.php deleted file mode 100644 index 9c8aeff79d..0000000000 --- a/src/migrations/m240119_075036_content_refactor_subscription_elements.php +++ /dev/null @@ -1,38 +0,0 @@ -updateElements( - (new Query())->from(Table::SUBSCRIPTIONS), - Craft::$app->getFields()->getLayoutByType(Subscription::class) - ); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240119_073924_content_refactor_elements cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240208_083054_add_purchasable_stores_purchasable_fk.php b/src/migrations/m240208_083054_add_purchasable_stores_purchasable_fk.php deleted file mode 100644 index d85be4d07e..0000000000 --- a/src/migrations/m240208_083054_add_purchasable_stores_purchasable_fk.php +++ /dev/null @@ -1,48 +0,0 @@ -select('id') - ->from('{{%commerce_purchasables}}'); - - $purchasables = (new Query()) - ->select('purchasableId') - ->from('{{%commerce_purchasables_stores}}') - ->where(['not in', 'purchasableId', $subQuery]) - ->column($this->db); // Assuming $this->db is your database connection - - // delete all purchasables in purchasable_stores table that is not in the purchasables table - $this->delete('{{%commerce_purchasables_stores}}', ['in', 'purchasableId', $purchasables]); - - $this->addForeignKey(null, '{{%commerce_purchasables_stores}}', ['purchasableId'], '{{%commerce_purchasables}}', ['id'],'CASCADE', 'CASCADE'); - - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240208_083054_add_purchasable_stores_purchasable_fk cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240219_194855_donation_multi_store.php b/src/migrations/m240219_194855_donation_multi_store.php deleted file mode 100644 index 70578f9b4b..0000000000 --- a/src/migrations/m240219_194855_donation_multi_store.php +++ /dev/null @@ -1,68 +0,0 @@ -select('id') - ->from(Table::STORES) - ->column(); - - // Get current donation data - $donations = (new Query()) - ->select('*') - ->from(Table::DONATIONS) - ->all(); - - foreach ($donations as $donation) { - foreach ($storeIds as $storeId) { - if (PurchasableStore::findOne(['purchasableId' => $donation['id'], 'storeId' => $storeId])) { - continue; - } - - $this->insert(Table::PURCHASABLES_STORES, [ - 'purchasableId' => $donation['id'], - 'storeId' => $storeId, - 'basePrice' => 0, - 'basePromotionalPrice' => null, - 'stock' => null, - 'inventoryTracked' => false, - 'minQty' => null, - 'maxQty' => null, - 'promotable' => false, - 'availableForPurchase' => $donation['availableForPurchase'], - 'freeShipping' => true, - 'shippingCategoryId' => null, - ]); - } - } - - // Remove `availableForPurchase` column from `commerce_donations` table - $this->dropColumn(Table::DONATIONS, 'availableForPurchase'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240219_194855_donation_multi_store cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240220_045806_product_versioning.php b/src/migrations/m240220_045806_product_versioning.php deleted file mode 100644 index b3b2bdfb67..0000000000 --- a/src/migrations/m240220_045806_product_versioning.php +++ /dev/null @@ -1,30 +0,0 @@ -addColumn(Table::PRODUCTTYPES, 'enableVersioning', $this->boolean()->defaultValue(false)->notNull()->after('handle')); - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240220_045806_product_versioning cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240220_105746_remove_store_from_donations_table.php b/src/migrations/m240220_105746_remove_store_from_donations_table.php deleted file mode 100644 index a31e77d9a7..0000000000 --- a/src/migrations/m240220_105746_remove_store_from_donations_table.php +++ /dev/null @@ -1,34 +0,0 @@ -db->columnExists(Table::DONATIONS, 'storeId')) { - $this->dropForeignKeyIfExists(Table::DONATIONS, 'storeId'); - $this->dropColumn(Table::DONATIONS, 'storeId'); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240220_105746_remove_store_from_donations_table cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240221_030027_transfer_items.php b/src/migrations/m240221_030027_transfer_items.php deleted file mode 100644 index 5dc049e4f2..0000000000 --- a/src/migrations/m240221_030027_transfer_items.php +++ /dev/null @@ -1,46 +0,0 @@ -createTable('{{%commerce_transfers_inventoryitems}}', [ - 'id' => $this->primaryKey(), - 'transferId' => $this->integer()->notNull(), - 'inventoryItemId' => $this->integer()->notNull(), - 'quantity' => $this->integer()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->createIndex(null, '{{%commerce_transfers_inventoryitems}}', 'inventoryItemId', false); - $this->createIndex(null, '{{%commerce_transfers_inventoryitems}}', 'transferId', false); - - $this->addForeignKey(null, '{{%commerce_transfers_inventoryitems}}', ['inventoryItemId'], '{{%commerce_inventoryitems}}', ['id'], 'CASCADE'); - $this->addForeignKey(null, '{{%commerce_transfers_inventoryitems}}', ['transferId'], '{{%commerce_inventoryitems}}', ['id'], 'CASCADE'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240221_030027_transfer_items cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240223_101158_update_recent_orders_widget_settings.php b/src/migrations/m240223_101158_update_recent_orders_widget_settings.php deleted file mode 100644 index fadae9cd62..0000000000 --- a/src/migrations/m240223_101158_update_recent_orders_widget_settings.php +++ /dev/null @@ -1,63 +0,0 @@ -select(['id', 'settings']) - ->from(Table::WIDGETS) - ->where(['type' => Orders::class]) - ->all(); - - // Get all order statuses - $orderStatuses = (new Query()) - ->select(['id', 'uid']) - ->from(\craft\commerce\db\Table::ORDERSTATUSES) - ->all(); - - // Update the widget settings to move from `orderStatusId` to `orderStatuses` - foreach ($widgets as $widget) { - $settings = Json::decodeIfJson($widget['settings']); - $orderStatusId = $settings['orderStatusId'] ?? null; - $settings['orderStatuses'] = []; - unset($settings['orderStatusId']); - - if ($orderStatusId !== null) { - $orderStatus = ArrayHelper::firstWhere($orderStatuses, 'id', $orderStatusId); - if ($orderStatus !== null) { - $settings['orderStatuses'][] = $orderStatus['uid']; - } - } - - $this->update(Table::WIDGETS, ['settings' => Json::encode($settings)], ['id' => $widget['id']]); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240223_101158_update_recent_orders_widget_settings cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240226_002943_remove_lite.php b/src/migrations/m240226_002943_remove_lite.php deleted file mode 100644 index 2f919ae5bf..0000000000 --- a/src/migrations/m240226_002943_remove_lite.php +++ /dev/null @@ -1,39 +0,0 @@ -db->columnExists('{{%commerce_shippingmethods}}', 'isLite')) { - $this->dropColumn('{{%commerce_shippingmethods}}', 'isLite'); - } - if ($this->db->columnExists('{{%commerce_shippingrules}}', 'isLite')) { - $this->dropColumn('{{%commerce_shippingrules}}', 'isLite'); - } - if ($this->db->columnExists('{{%commerce_taxrates}}', 'isLite')) { - $this->dropColumn('{{%commerce_taxrates}}', 'isLite'); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240226_002943_remove_lite cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240228_054005_rename_movements_table.php b/src/migrations/m240228_054005_rename_movements_table.php deleted file mode 100644 index 48074d9949..0000000000 --- a/src/migrations/m240228_054005_rename_movements_table.php +++ /dev/null @@ -1,31 +0,0 @@ -renameTable('{{%commerce_inventorymovements}}', '{{%commerce_inventorytransactions}}'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240228_054005_rename_movements_table cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240228_060604_add_fufilled_type_to_inventorytransactions.php b/src/migrations/m240228_060604_add_fufilled_type_to_inventorytransactions.php deleted file mode 100644 index f62fdaf892..0000000000 --- a/src/migrations/m240228_060604_add_fufilled_type_to_inventorytransactions.php +++ /dev/null @@ -1,30 +0,0 @@ -alterColumn('{{%commerce_inventorytransactions}}', 'type', $this->enum('type', ['available', 'reserved', 'damaged', 'safety', 'qualityControl', 'committed', 'fulfilled', 'incoming'])->notNull()); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240228_060604_add_fufilled_column_to_inventorytransactions cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240228_120911_drop_order_id_and_make_line_item_cascade.php b/src/migrations/m240228_120911_drop_order_id_and_make_line_item_cascade.php deleted file mode 100644 index ee2e7775df..0000000000 --- a/src/migrations/m240228_120911_drop_order_id_and_make_line_item_cascade.php +++ /dev/null @@ -1,40 +0,0 @@ -db->columnExists('{{%commerce_inventorytransactions}}', 'orderId')) { - $this->dropForeignKeyIfExists('{{%commerce_inventorytransactions}}', ['orderId']); - $this->dropColumn('{{%commerce_inventorytransactions}}', 'orderId'); - } - - // Make lineItemId cascade - if ($this->db->columnExists('{{%commerce_inventorytransactions}}', 'lineItemId')) { - $this->dropForeignKeyIfExists('{{%commerce_inventorytransactions}}', ['lineItemId']); - $this->addForeignKey(null, '{{%commerce_inventorytransactions}}', 'lineItemId', '{{%commerce_lineitems}}', 'id', 'CASCADE', null); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240228_120911_drop_order_id_and_make_line_item_cascade cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240301_113924_add_line_item_types.php b/src/migrations/m240301_113924_add_line_item_types.php deleted file mode 100644 index 66c2b23127..0000000000 --- a/src/migrations/m240301_113924_add_line_item_types.php +++ /dev/null @@ -1,34 +0,0 @@ -addColumn(Table::LINEITEMS, 'type', $this->enum('type', [ - 'purchasable', - 'custom', - ])->defaultValue('purchasable')->notNull()); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240301_113924_add_line_item_types cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240306_091057_move_element_ids_on_discount_to_columns.php b/src/migrations/m240306_091057_move_element_ids_on_discount_to_columns.php deleted file mode 100644 index 86a6adf15b..0000000000 --- a/src/migrations/m240306_091057_move_element_ids_on_discount_to_columns.php +++ /dev/null @@ -1,59 +0,0 @@ -addColumn($discountsTable, 'purchasableIds', $this->text()->after('allPurchasables')); - $this->addColumn($discountsTable, 'categoryIds', $this->text()->after('allCategories')); - - $purchasableIdsByDiscountId = (new Query()) - ->select(['discountId', 'purchasableId']) - ->from([$discountPurchasablesTables]) - ->collect(); - - $purchasableIdsByDiscountId = $purchasableIdsByDiscountId->groupBy('discountId')->map(fn($row) => array_column($row->toArray(), 'purchasableId')); - - $categoryIdsByDiscountId = (new Query()) - ->select(['discountId', 'categoryId']) - ->from([$discountCategoriesTable]) - ->collect(); - - $categoryIdsByDiscountId = $categoryIdsByDiscountId->groupBy('discountId')->map(fn($row) => array_column($row->toArray(), 'categoryId')); - - foreach ($purchasableIdsByDiscountId as $discountId => $purchasableIds) { - $this->update($discountsTable, ['purchasableIds' => Json::encode($purchasableIds)], ['id' => $discountId]); - } - - foreach ($categoryIdsByDiscountId as $discountId => $categoryIds) { - $this->update($discountsTable, ['categoryIds' => Json::encode($categoryIds)], ['id' => $discountId]); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240306_091057_move_element_ids_on_discount_to_columns cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240308_133451_tidy_shipping_categories.php b/src/migrations/m240308_133451_tidy_shipping_categories.php deleted file mode 100644 index 38fd250bd1..0000000000 --- a/src/migrations/m240308_133451_tidy_shipping_categories.php +++ /dev/null @@ -1,43 +0,0 @@ -select(['id']) - ->from(Table::STORES) - ->column(); - - $this->delete(Table::SHIPPINGCATEGORIES, ['not', ['storeId' => $storeIds]]); - - $this->dropForeignKeyIfExists(Table::SHIPPINGCATEGORIES, ['storeId']); - - $this->alterColumn(Table::SHIPPINGCATEGORIES, 'storeId', $this->integer()->notNull()); - - $this->addForeignKey(null, Table::SHIPPINGCATEGORIES, ['storeId'], Table::STORES, ['id'], 'CASCADE'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240308_133451_tidy_shipping_categories cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240313_131445_tidy_shipping_methods.php b/src/migrations/m240313_131445_tidy_shipping_methods.php deleted file mode 100644 index 01746a7a83..0000000000 --- a/src/migrations/m240313_131445_tidy_shipping_methods.php +++ /dev/null @@ -1,43 +0,0 @@ -select(['id']) - ->from(Table::STORES) - ->column(); - - $this->delete(Table::SHIPPINGMETHODS, ['not', ['storeId' => $storeIds]]); - - $this->dropForeignKeyIfExists(Table::SHIPPINGMETHODS, ['storeId']); - - $this->alterColumn(Table::SHIPPINGMETHODS, 'storeId', $this->integer()->notNull()); - - $this->addForeignKey(null, Table::SHIPPINGMETHODS, ['storeId'], Table::STORES, ['id'], 'CASCADE'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240313_131445_tidy_shipping_methods cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240315_072659_add_fk_cascade_fixes.php b/src/migrations/m240315_072659_add_fk_cascade_fixes.php deleted file mode 100644 index 051d3e9691..0000000000 --- a/src/migrations/m240315_072659_add_fk_cascade_fixes.php +++ /dev/null @@ -1,37 +0,0 @@ -addForeignKey(null, Table::STORESETTINGS, ['locationAddressId'], CraftTable::ELEMENTS, ['id'], 'SET NULL'); - - // There is no ability to drop the FK without knowing its name. - MigrationHelper::dropAllForeignKeysOnTable(Table::INVENTORYLOCATIONS); - $this->addForeignKey(null, Table::INVENTORYLOCATIONS, 'addressId', CraftTable::ELEMENTS, 'id', 'CASCADE', null); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240315_072659_add_fk_cascade_fixes cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240430_161804_add_index_to_transaction_hash.php b/src/migrations/m240430_161804_add_index_to_transaction_hash.php deleted file mode 100644 index b05e9d0622..0000000000 --- a/src/migrations/m240430_161804_add_index_to_transaction_hash.php +++ /dev/null @@ -1,31 +0,0 @@ -createIndexIfMissing(Table::TRANSACTIONS, 'hash', false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240430_161804_add_index_to_transaction_hash cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240507_081904_fix_store_pc_location.php b/src/migrations/m240507_081904_fix_store_pc_location.php deleted file mode 100644 index f14817a1af..0000000000 --- a/src/migrations/m240507_081904_fix_store_pc_location.php +++ /dev/null @@ -1,49 +0,0 @@ -getProjectConfig(); - - $allStores = $projectConfig->get(Stores::CONFIG_STORES_KEY) ?? []; - - if (!empty($allStores)) { - return true; - } - - $storeUid = (new Query())->select('uid')->from(Table::STORES)->scalar(); - - // Bad config key on purpose - $badCommerceConfig = $projectConfig->get(Stores::CONFIG_STORES_KEY . $storeUid); - - if ($badCommerceConfig) { - $projectConfig->set(Stores::CONFIG_STORES_KEY . '.' . $storeUid, $badCommerceConfig); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240507_081904_fix_store_pc_location cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240516_035616_update_permissions.php b/src/migrations/m240516_035616_update_permissions.php deleted file mode 100644 index b93cd0366e..0000000000 --- a/src/migrations/m240516_035616_update_permissions.php +++ /dev/null @@ -1,80 +0,0 @@ - "commerce-manageSubscriptions", - "commerce-manageDonationSettings" => "commerce-manageStoreSettings", - ]; - - // Now add the new permissions to existing users where applicable - foreach ($newPermissions as $oldPermission => $newPermission) { - $userIds = (new Query()) - ->select(['upu.userId']) - ->from(['upu' => Table::USERPERMISSIONS_USERS]) - ->innerJoin(['up' => Table::USERPERMISSIONS], '[[up.id]] = [[upu.permissionId]]') - ->where(['up.name' => $oldPermission]) - ->column($this->db); - if (!empty($userIds)) { - $insert = []; - foreach ((array)$newPermission as $name) { - $this->insert(Table::USERPERMISSIONS, [ - 'name' => $name, - ]); - $newPermissionId = $this->db->getLastInsertID(Table::USERPERMISSIONS); - foreach ($userIds as $userId) { - $insert[] = [$newPermissionId, $userId]; - } - } - $this->batchInsert(Table::USERPERMISSIONS_USERS, ['permissionId', 'userId'], $insert); - } - } - - // Don't make the same config changes twice - $projectConfig = Craft::$app->getProjectConfig(); - - foreach ($projectConfig->get('users.groups') ?? [] as $uid => $group) { - $groupPermissions = array_flip($group['permissions'] ?? []); - $changed = false; - foreach ($newPermissions as $oldPermission => $newPermission) { - if (isset($groupPermissions[$oldPermission])) { - foreach ((array)$newPermission as $name) { - $groupPermissions[$name] = true; - } - $changed = true; - } - } - if ($changed) { - $projectConfig->set("users.groups.{$uid}.permissions", array_keys($groupPermissions)); - } - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240516_035616_update_permissions cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240516_035617_update_currency_and_store_general_permissions.php b/src/migrations/m240516_035617_update_currency_and_store_general_permissions.php deleted file mode 100644 index 0aaf7c830f..0000000000 --- a/src/migrations/m240516_035617_update_currency_and_store_general_permissions.php +++ /dev/null @@ -1,80 +0,0 @@ - "commerce-manageStoreSettings", - "commerce-manageGeneralStoreSettings" => "commerce-manageStoreSettings", - ]; - - // Now add the new permissions to existing users where applicable - foreach ($newPermissions as $oldPermission => $newPermission) { - $userIds = (new Query()) - ->select(['upu.userId']) - ->from(['upu' => Table::USERPERMISSIONS_USERS]) - ->innerJoin(['up' => Table::USERPERMISSIONS], '[[up.id]] = [[upu.permissionId]]') - ->where(['up.name' => $oldPermission]) - ->column($this->db); - if (!empty($userIds)) { - $insert = []; - foreach ((array)$newPermission as $name) { - $this->insert(Table::USERPERMISSIONS, [ - 'name' => $name, - ]); - $newPermissionId = $this->db->getLastInsertID(Table::USERPERMISSIONS); - foreach ($userIds as $userId) { - $insert[] = [$newPermissionId, $userId]; - } - } - $this->batchInsert(Table::USERPERMISSIONS_USERS, ['permissionId', 'userId'], $insert); - } - } - - // Don't make the same config changes twice - $projectConfig = Craft::$app->getProjectConfig(); - - foreach ($projectConfig->get('users.groups') ?? [] as $uid => $group) { - $groupPermissions = array_flip($group['permissions'] ?? []); - $changed = false; - foreach ($newPermissions as $oldPermission => $newPermission) { - if (isset($groupPermissions[$oldPermission])) { - foreach ((array)$newPermission as $name) { - $groupPermissions[$name] = true; - } - $changed = true; - } - } - if ($changed) { - $projectConfig->set("users.groups.{$uid}.permissions", array_keys($groupPermissions)); - } - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240516_035616_update_permissions cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240528_124101_add_extra_lineitem_columns.php b/src/migrations/m240528_124101_add_extra_lineitem_columns.php deleted file mode 100644 index 53f1185f6d..0000000000 --- a/src/migrations/m240528_124101_add_extra_lineitem_columns.php +++ /dev/null @@ -1,37 +0,0 @@ -addColumn(Table::LINEITEMS, 'hasFreeShipping', $this->boolean()); - - $this->addColumn(Table::LINEITEMS, 'isPromotable', $this->boolean()); - - $this->addColumn(Table::LINEITEMS, 'isShippable', $this->boolean()); - - $this->addColumn(Table::LINEITEMS, 'isTaxable', $this->boolean()); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240528_124101_add_extra_lineitem_columns cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240529_095819_remove_commerce_user_field.php b/src/migrations/m240529_095819_remove_commerce_user_field.php deleted file mode 100644 index 7d08fe584b..0000000000 --- a/src/migrations/m240529_095819_remove_commerce_user_field.php +++ /dev/null @@ -1,53 +0,0 @@ -fields->getLayoutByType(\craft\elements\User::class); - - $tabs = $fieldLayout->getTabs(); - - foreach ($tabs as $tab) { - $newFields = []; - - foreach ($tab->elements as $element) { - if ($element::class !== $fieldClassName) { - $newFields[] = $element; - } - } - - $tab->setElements($newFields); - } - - // Save the modified field layout - Craft::$app->fields->saveLayout($fieldLayout); - - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240529_095819_remove_commerce_user_field cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240605_110755_add_title_translations_product_types.php b/src/migrations/m240605_110755_add_title_translations_product_types.php deleted file mode 100644 index ffe06b9922..0000000000 --- a/src/migrations/m240605_110755_add_title_translations_product_types.php +++ /dev/null @@ -1,34 +0,0 @@ -addColumn('{{%commerce_producttypes}}', 'productTitleTranslationKeyFormat', $this->string()->after('productTitleFormat')); - $this->addColumn('{{%commerce_producttypes}}', 'productTitleTranslationMethod', $this->string()->defaultValue('site')->notNull()->after('productTitleFormat')); - - $this->addColumn('{{%commerce_producttypes}}', 'variantTitleTranslationKeyFormat', $this->string()->after('variantTitleFormat')); - $this->addColumn('{{%commerce_producttypes}}', 'variantTitleTranslationMethod', $this->string()->defaultValue('site')->notNull()->after('variantTitleFormat')); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240605_110755_add_title_translations_product_types cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240619_082224_add_product_and_variant_conditions_to_catalog_pricing_rules.php b/src/migrations/m240619_082224_add_product_and_variant_conditions_to_catalog_pricing_rules.php deleted file mode 100644 index 0a158e5958..0000000000 --- a/src/migrations/m240619_082224_add_product_and_variant_conditions_to_catalog_pricing_rules.php +++ /dev/null @@ -1,32 +0,0 @@ -addColumn(Table::CATALOG_PRICING_RULES, 'productCondition', $this->text()->after('applyPriceType')); - $this->addColumn(Table::CATALOG_PRICING_RULES, 'variantCondition', $this->text()->after('applyPriceType')); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240619_082224_add_product_and_variant_conditions_to_catalog_pricing_rules cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240710_125204_ensure_shippingCategoryId_column_is_nullable.php b/src/migrations/m240710_125204_ensure_shippingCategoryId_column_is_nullable.php deleted file mode 100644 index 40407c2a7b..0000000000 --- a/src/migrations/m240710_125204_ensure_shippingCategoryId_column_is_nullable.php +++ /dev/null @@ -1,30 +0,0 @@ -alterColumn('{{%commerce_purchasables_stores}}', 'shippingCategoryId', $this->integer()->null()); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240710_125204_ensure_shippingCategoryId_column_is_nullable cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240711_092240_fix_fks.php b/src/migrations/m240711_092240_fix_fks.php deleted file mode 100644 index c2d6fe1664..0000000000 --- a/src/migrations/m240711_092240_fix_fks.php +++ /dev/null @@ -1,40 +0,0 @@ -dropForeignKeyIfExists('{{%commerce_catalogpricingrules}}', 'purchasableId'); - $this->dropIndexIfExists('{{%commerce_catalogpricingrules}}', 'purchasableId'); - - if ($this->db->columnExists('{{%commerce_catalogpricingrules}}', 'purchasableId')) { - $this->dropColumn('{{%commerce_catalogpricingrules}}', 'purchasableId'); - } - - // Fix constraint to set not null on delete - $this->dropForeignKeyIfExists('{{%commerce_purchasables_stores}}', 'shippingCategoryId'); - $this->addForeignKey(null, '{{%commerce_purchasables_stores}}', ['shippingCategoryId'], Table::SHIPPINGCATEGORIES, ['id'], 'SET NULL'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240711_092240_fix_fks cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240715_045506_drop_available_if_exists.php b/src/migrations/m240715_045506_drop_available_if_exists.php deleted file mode 100644 index e0d430471f..0000000000 --- a/src/migrations/m240715_045506_drop_available_if_exists.php +++ /dev/null @@ -1,33 +0,0 @@ -db->columnExists('{{%commerce_donations}}', 'availableForPurchase')) { - $this->dropColumn('{{%commerce_donations}}', 'availableForPurchase'); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240715_045506_drop_available_if_exists cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240717_044256_add_return_url_to_subscription.php b/src/migrations/m240717_044256_add_return_url_to_subscription.php deleted file mode 100644 index 42bcb8a594..0000000000 --- a/src/migrations/m240717_044256_add_return_url_to_subscription.php +++ /dev/null @@ -1,31 +0,0 @@ -addColumn('{{%commerce_subscriptions}}', 'returnUrl', $this->text()->after('isExpired')); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240717_044256_add_return_url_to_subscription cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240718_073046_remove_sortOrder_variants_column_if_exists.php b/src/migrations/m240718_073046_remove_sortOrder_variants_column_if_exists.php deleted file mode 100644 index 1683c32911..0000000000 --- a/src/migrations/m240718_073046_remove_sortOrder_variants_column_if_exists.php +++ /dev/null @@ -1,32 +0,0 @@ -db->columnExists('{{%commerce_variants}}', 'sortOrder')) { - $this->dropColumn('{{%commerce_variants}}', 'sortOrder'); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240718_073046_remove_sortOrder_variants_column_if_exists cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240808_090256_cascade_delete_variants_on_product_delete.php b/src/migrations/m240808_090256_cascade_delete_variants_on_product_delete.php deleted file mode 100644 index a0269ceb9f..0000000000 --- a/src/migrations/m240808_090256_cascade_delete_variants_on_product_delete.php +++ /dev/null @@ -1,41 +0,0 @@ -select('id') - ->from('{{%commerce_variants}}') - ->where(['primaryOwnerId' => null]); - $this->delete('{{%elements}}', ['id' => $allVariantsWithNullOwner]); - - // Should cascade delete variants when a product is deleted - $this->dropForeignKeyIfExists('{{%commerce_variants}}', ['primaryOwnerId']); - $this->addForeignKey(null, '{{%commerce_variants}}', ['primaryOwnerId'], '{{%commerce_products}}', ['id'], 'CASCADE'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240808_090256_cascade_delete_variants_on_product_delete cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240808_093934_product_type_propagation.php b/src/migrations/m240808_093934_product_type_propagation.php deleted file mode 100644 index d4cd47f29f..0000000000 --- a/src/migrations/m240808_093934_product_type_propagation.php +++ /dev/null @@ -1,33 +0,0 @@ -addColumn('{{%commerce_producttypes}}', 'propagationMethod', $this->string()->defaultValue(PropagationMethod::All->value)->after('productTitleTranslationKeyFormat')); - $this->addColumn('{{%commerce_producttypes_sites}}', 'enabledByDefault', $this->boolean()->defaultValue(true)->notNull()); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240808_093934_product_type_propagation cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240812_025615_add_transfer_details_table.php b/src/migrations/m240812_025615_add_transfer_details_table.php deleted file mode 100644 index 196ae82e19..0000000000 --- a/src/migrations/m240812_025615_add_transfer_details_table.php +++ /dev/null @@ -1,52 +0,0 @@ -dropTableIfExists('{{%commerce_transfers_inventoryitems}}'); - - $this->createTable('{{%commerce_transferdetails}}', [ - 'id' => $this->primaryKey(), - 'transferId' => $this->integer()->notNull(), - 'inventoryItemId' => $this->integer(), - 'inventoryItemDescription' => $this->string()->notNull(), - 'quantity' => $this->integer()->notNull(), - 'quantityAccepted' => $this->integer()->notNull(), - 'quantityRejected' => $this->integer()->notNull(), - 'uid' => $this->uid(), - ]); - - $this->createIndex(null, '{{%commerce_transferdetails}}', 'transferId', false); - $this->createIndex(null, '{{%commerce_transferdetails}}', 'inventoryItemId', false); - $this->addForeignKey(null, '{{%commerce_transferdetails}}', 'transferId', '{{%commerce_transfers}}', 'id', 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, '{{%commerce_transferdetails}}', 'inventoryItemId', '{{%commerce_inventoryitems}}', 'id', 'SET NULL', 'CASCADE'); - - // Add missing FK - $this->addForeignKey(null, '{{%commerce_transfers}}', 'id', '{{%elements}}', 'id', 'CASCADE', 'CASCADE'); - - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240812_025615_add_transfer_details_table cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240815_035618_fix_transfer_permission.php b/src/migrations/m240815_035618_fix_transfer_permission.php deleted file mode 100644 index 2745d46589..0000000000 --- a/src/migrations/m240815_035618_fix_transfer_permission.php +++ /dev/null @@ -1,79 +0,0 @@ - "commerce-manageTransfers", - ]; - - // Now add the new permissions to existing users where applicable - foreach ($newPermissions as $oldPermission => $newPermission) { - $userIds = (new Query()) - ->select(['upu.userId']) - ->from(['upu' => Table::USERPERMISSIONS_USERS]) - ->innerJoin(['up' => Table::USERPERMISSIONS], '[[up.id]] = [[upu.permissionId]]') - ->where(['up.name' => $oldPermission]) - ->column($this->db); - if (!empty($userIds)) { - $insert = []; - foreach ((array)$newPermission as $name) { - $this->insert(Table::USERPERMISSIONS, [ - 'name' => $name, - ]); - $newPermissionId = $this->db->getLastInsertID(Table::USERPERMISSIONS); - foreach ($userIds as $userId) { - $insert[] = [$newPermissionId, $userId]; - } - } - $this->batchInsert(Table::USERPERMISSIONS_USERS, ['permissionId', 'userId'], $insert); - } - } - - // Don't make the same config changes twice - $projectConfig = Craft::$app->getProjectConfig(); - - foreach ($projectConfig->get('users.groups') ?? [] as $uid => $group) { - $groupPermissions = array_flip($group['permissions'] ?? []); - $changed = false; - foreach ($newPermissions as $oldPermission => $newPermission) { - if (isset($groupPermissions[$oldPermission])) { - foreach ((array)$newPermission as $name) { - $groupPermissions[$name] = true; - } - $changed = true; - } - } - if ($changed) { - $projectConfig->set("users.groups.{$uid}.permissions", array_keys($groupPermissions)); - } - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240516_035616_update_permissions cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240830_081410_add_extra_indexes_to_catalog_pricing.php b/src/migrations/m240830_081410_add_extra_indexes_to_catalog_pricing.php deleted file mode 100644 index 8f1be5d64a..0000000000 --- a/src/migrations/m240830_081410_add_extra_indexes_to_catalog_pricing.php +++ /dev/null @@ -1,31 +0,0 @@ -createIndex(null, '{{%commerce_catalogpricing}}', ['purchasableId', 'storeId', 'isPromotionalPrice', 'price'], false); - $this->createIndex(null, '{{%commerce_catalogpricing}}', ['purchasableId', 'storeId', 'isPromotionalPrice', 'price', 'catalogPricingRuleId', 'dateFrom', 'dateTo'], false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240830_081410_add_extra_indexes_to_catalog_pricing cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240905_130549_add_require_coupon_code_discount_setting.php b/src/migrations/m240905_130549_add_require_coupon_code_discount_setting.php deleted file mode 100644 index 16ff03640f..0000000000 --- a/src/migrations/m240905_130549_add_require_coupon_code_discount_setting.php +++ /dev/null @@ -1,30 +0,0 @@ -addColumn('{{%commerce_discounts}}', 'requireCouponCode', $this->boolean()->notNull()->defaultValue(false)->after('billingAddressCondition')); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240905_130549_add_require_coupon_code_discount_setting cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240906_105809_update_existing_coupon_discounts.php b/src/migrations/m240906_105809_update_existing_coupon_discounts.php deleted file mode 100644 index 9ed9d6b602..0000000000 --- a/src/migrations/m240906_105809_update_existing_coupon_discounts.php +++ /dev/null @@ -1,36 +0,0 @@ -from('{{%commerce_coupons}}') - ->select(['discountId']) - ->groupBy('discountId'); - - $this->update('{{%commerce_discounts}}', ['requireCouponCode' => true], ['id' => $couponDiscountIds]); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240906_105809_update_existing_coupon_discounts cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240906_115901_add_orderable_to_product_types.php b/src/migrations/m240906_115901_add_orderable_to_product_types.php deleted file mode 100644 index 14360d26eb..0000000000 --- a/src/migrations/m240906_115901_add_orderable_to_product_types.php +++ /dev/null @@ -1,51 +0,0 @@ -db->columnExists('{{%commerce_producttypes}}', 'defaultPlacement')) { - $this->addColumn('{{%commerce_producttypes}}', 'defaultPlacement', $this->enum('defaultPlacement', [ - ProductType::DEFAULT_PLACEMENT_BEGINNING, - ProductType::DEFAULT_PLACEMENT_END, ] - )->defaultValue('end')->notNull()); - } - - if (!$this->db->columnExists('{{%commerce_producttypes}}', 'type')) { - $this->addColumn('{{%commerce_producttypes}}', 'type', $this->enum('type', [ - 'channel', - 'orderable', ] - )->defaultValue('channel')->notNull()); - } - - if (!$this->db->columnExists('{{%commerce_producttypes}}', 'structureId')) { - $this->addColumn('{{%commerce_producttypes}}', 'structureId', $this->integer()); - } - - $this->createIndex(null, '{{%commerce_producttypes}}', ['structureId'], false); - $this->addForeignKey(null, '{{%commerce_producttypes}}', ['structureId'], Table::STRUCTURES, ['id'], 'SET NULL', null); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240906_115901_add_orderable_to_product_types cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m240923_132625_remove_orphaned_variants_sites.php b/src/migrations/m240923_132625_remove_orphaned_variants_sites.php deleted file mode 100644 index d56046062f..0000000000 --- a/src/migrations/m240923_132625_remove_orphaned_variants_sites.php +++ /dev/null @@ -1,59 +0,0 @@ -select(['elementId', 'siteId']) - ->from('{{%elements_sites}}' . ' es') - ->innerJoin('{{%commerce_products}}' . ' p', '[[es.elementId]] = [[p.id]]') - ->collect(); - - // Group them by product ID - $siteIdsByProductId = $allProductsSites->groupBy('elementId')->map(fn($row) => collect($row)->pluck('siteId')->toArray() - ); - - // Find all existing combinations of variant and site IDs - $allVariantsSites = (new Query()) - ->select(['es.id', 'elementId', 'siteId', 'primaryOwnerId']) - ->from('{{%elements_sites}}' . ' es') - ->innerJoin('{{%commerce_variants}}' . ' v', '[[es.elementId]] = [[v.id]]') - ->collect(); - - // Find all variants that are not associated with any of their product's sites - $orphanedVariantsSites = array_values($allVariantsSites->filter(fn($row) => !in_array($row['siteId'], $siteIdsByProductId[$row['primaryOwnerId']]))->map(fn($row) => $row['id'])->toArray()); - - if (empty($orphanedVariantsSites)) { - return true; - } - - // Bulk delete the orphaned variants' site rows (if any) 1000 at a time - foreach (array_chunk($orphanedVariantsSites, 1000) as $chunk) { - $this->delete('{{%elements_sites}}', ['id' => $chunk]); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m240923_132625_remove_orphaned_variants_sites cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m241010_061430_rename_orderable_product_type_type.php b/src/migrations/m241010_061430_rename_orderable_product_type_type.php deleted file mode 100644 index e08dd4dc09..0000000000 --- a/src/migrations/m241010_061430_rename_orderable_product_type_type.php +++ /dev/null @@ -1,43 +0,0 @@ -db->createCommand('SELECT id, type FROM {{%commerce_producttypes}}')->queryAll(); - - if ($this->db->columnExists('{{%commerce_producttypes}}', 'type')) { - $this->dropColumn('{{%commerce_producttypes}}', 'type'); - } - - $this->addColumn('{{%commerce_producttypes}}', 'isStructure', $this->boolean()->notNull()->defaultValue(false)); - $this->addColumn('{{%commerce_producttypes}}', 'maxLevels', $this->smallInteger()->unsigned()); - - foreach ($productTypes as $productType) { - if ($productType['type'] == 'orderable') { - $this->update('{{%commerce_producttypes}}', ['isStructure' => true, 'maxLevels' => 1], ['id' => $productType['id']]); - } - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m241010_061430_rename_orderable_product_type_type cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m241017_072151_fix_temp_skus.php b/src/migrations/m241017_072151_fix_temp_skus.php deleted file mode 100644 index 24ce7036f5..0000000000 --- a/src/migrations/m241017_072151_fix_temp_skus.php +++ /dev/null @@ -1,42 +0,0 @@ -select(['id', 'sku']) - ->from('{{%commerce_purchasables}}') - ->where(['like', 'sku', '__temp_%', false]) - ->all(); - - // Need a unique one per purchasable - foreach ($purchasables as $purchasable) { - $newSku = Purchasable::tempSku(); - $this->update('{{%commerce_purchasables}}', ['sku' => $newSku], ['id' => $purchasable['id']]); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m241017_072151_fix_temp_skus cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m241022_075144_add_missing_variant_revision_records.php b/src/migrations/m241022_075144_add_missing_variant_revision_records.php deleted file mode 100644 index 1e003a6dc9..0000000000 --- a/src/migrations/m241022_075144_add_missing_variant_revision_records.php +++ /dev/null @@ -1,151 +0,0 @@ -select([ - 'e.id', - 'e.canonicalId', - 'e.revisionId', - 'es.siteId', - ]) - ->from('{{%elements}}' . ' e') - ->innerJoin('{{%elements_sites}}' . ' es', '[[e.id]] = [[es.elementId]]') - ->where(['type' => Variant::class]) - ->andWhere(['not', ['revisionId' => null]]) - ->collect(); - - $sitesStores = (new Query()) - ->select(['siteId', 'storeId']) - ->from('{{%commerce_site_stores}}') - ->collect(); - - /** @var Collection $variantsWithRevisions */ - $canonicalVariantIds = $variantsWithRevisions->pluck('canonicalId')->unique()->all(); - $revisionVariantElementIds = $variantsWithRevisions->pluck('id')->unique()->all(); - $nonCanonicalPurchasableRecords = []; - $nonCanonicalPurchasableStoreRecords = []; - - foreach (array_chunk($revisionVariantElementIds, 1000) as $chunk) { - $nonCanonicalPurchasableRecords += Purchasable::find()->where(['element.id' => $chunk])->indexBy('element.id')->all(); - $nonCanonicalPurchasableStoreRecords += PurchasableStore::find()->where(['purchasableId' => $chunk])->indexBy(fn(PurchasableStore $row) => $row['purchasableId'] . '-' . $row['storeId'])->all(); - } - - $canonicalVariantPurchasableRecords = []; - $canonicalVariantPurchasableStoreRecords = []; - - foreach (array_chunk($canonicalVariantIds, 1000) as $chunk) { - $canonicalVariantPurchasableRecords += Purchasable::find()->where(['element.id' => $chunk])->indexBy('element.id')->all(); - $canonicalVariantPurchasableStoreRecords += PurchasableStore::find()->where(['purchasableId' => $chunk])->indexBy(fn(PurchasableStore $row) => $row['purchasableId'] . '-' . $row['storeId'])->all(); - } - - $purchasableInserts = []; - $purchasableStoresInserts = []; - $date = Db::prepareDateForDb(new \DateTime()); - - foreach ($variantsWithRevisions as $v) { - $canonicalPurchasableRecord = $canonicalVariantPurchasableRecords[$v['canonicalId']] ?? null; - $nonCanonicalPurchasableRecord = $nonCanonicalPurchasableRecords[$v['id']] ?? null; - - // Skip if we can't find the canonical record or if a record exists for this variant ID - if (!$canonicalPurchasableRecord || $nonCanonicalPurchasableRecord) { - continue; - } - - // As we are looping over variants across sites we need to ensure we only insert a purchasable once - if (!($purchasableInserts[$v['id']] ?? null)) { - $purchasableInserts[$v['id']] = [ - 'id' => $v['id'], - 'description' => $canonicalPurchasableRecord['description'], - 'sku' => $canonicalPurchasableRecord['sku'], - 'width' => $canonicalPurchasableRecord['width'], - 'height' => $canonicalPurchasableRecord['height'], - 'length' => $canonicalPurchasableRecord['length'], - 'weight' => $canonicalPurchasableRecord['weight'], - 'dateCreated' => $date, - 'dateUpdated' => $date, - 'taxCategoryId' => $canonicalPurchasableRecord['taxCategoryId'], - 'uid' => StringHelper::UUID(), - ]; - } - - $storeId = $sitesStores->where('siteId', $v['siteId'])->pluck('storeId')->first(); - - $canonicalPurchasableStoreRecord = $canonicalVariantPurchasableStoreRecords[$v['canonicalId'] . '-' . $storeId] ?? null; - $nonCanonicalPurchaseStoreRecord = $nonCanonicalPurchasableStoreRecords[$v['id'] . '-' . $storeId] ?? null; - - // Skip if we can't find the canonical record or if a record exists for this variant ID and Store ID - if (!$canonicalPurchasableStoreRecord || $nonCanonicalPurchaseStoreRecord) { - continue; - } - - if (!($purchasableStoresInserts[$v['id'] . '-' . $storeId] ?? null)) { - $purchasableStoresInserts[$v['id'] . '-' . $storeId] = [ - 'purchasableId' => $v['id'], - 'storeId' => $storeId, - 'basePrice' => $canonicalPurchasableStoreRecord['basePrice'], - 'basePromotionalPrice' => $canonicalPurchasableStoreRecord['basePromotionalPrice'], - 'promotable' => $canonicalPurchasableStoreRecord['promotable'], - 'availableForPurchase' => $canonicalPurchasableStoreRecord['availableForPurchase'], - 'freeShipping' => $canonicalPurchasableStoreRecord['freeShipping'], - 'stock' => $canonicalPurchasableStoreRecord['stock'], - 'inventoryTracked' => $canonicalPurchasableStoreRecord['inventoryTracked'], - 'minQty' => $canonicalPurchasableStoreRecord['minQty'], - 'maxQty' => $canonicalPurchasableStoreRecord['maxQty'], - 'shippingCategoryId' => $canonicalPurchasableStoreRecord['shippingCategoryId'], - 'uid' => StringHelper::UUID(), - 'dateCreated' => $date, - 'dateUpdated' => $date, - ]; - } - } - - if (!empty($purchasableInserts)) { - foreach (array_chunk($purchasableInserts, 1000) as $purchasableInsertsChunk) { - Craft::$app->getDb()->createCommand() - ->batchInsert('{{%commerce_purchasables}}', array_keys($purchasableInsertsChunk[0]), $purchasableInsertsChunk) - ->execute(); - } - } - - if (!empty($purchasableStoresInserts)) { - foreach (array_chunk($purchasableStoresInserts, 1000) as $purchasableStoresInsertsChunk) { - Craft::$app->getDb()->createCommand() - ->batchInsert('{{%commerce_purchasables_stores}}', array_keys($purchasableStoresInsertsChunk[0]), $purchasableStoresInsertsChunk) - ->execute(); - } - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m241022_075144_add_missing_variant_revision_records cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m241128_174712_fix_maxLevels_structured_productTypes.php b/src/migrations/m241128_174712_fix_maxLevels_structured_productTypes.php deleted file mode 100644 index f07f69385d..0000000000 --- a/src/migrations/m241128_174712_fix_maxLevels_structured_productTypes.php +++ /dev/null @@ -1,43 +0,0 @@ -from(Table::PRODUCTTYPES) - ->where(['isStructure' => true]) - ->andWhere(['not', ['maxLevels' => null]]) - ->collect(); - - // Loop through and update the `maxLevels` column in the `structures` table - $structuredProductTypesWithMaxLevels->each(function($productType) { - $this->update(CraftTable::STRUCTURES, ['maxLevels' => $productType['maxLevels']], ['id' => $productType['structureId']]); - }); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m241128_174712_fix_maxLevels_structured_productTypes cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m241204_045158_enable_tax_rate.php b/src/migrations/m241204_045158_enable_tax_rate.php deleted file mode 100644 index d3fb160773..0000000000 --- a/src/migrations/m241204_045158_enable_tax_rate.php +++ /dev/null @@ -1,33 +0,0 @@ -db->columnExists('{{%commerce_taxrates}}', 'enabled')) { - $this->addColumn('{{%commerce_taxrates}}', 'enabled', $this->boolean()->notNull()->defaultValue(true)); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m241204_045158_enable_tax_rate cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m241204_091901_fix_store_environment_variables.php b/src/migrations/m241204_091901_fix_store_environment_variables.php deleted file mode 100644 index 279ac79e65..0000000000 --- a/src/migrations/m241204_091901_fix_store_environment_variables.php +++ /dev/null @@ -1,92 +0,0 @@ -from(Table::STORES) - ->all(); - - // Get the store settings for each store from the project config - $storeSettings = \Craft::$app->getProjectConfig()->get('commerce.stores'); - - - // Store properties to update - $storeProperties = [ - 'autoSetNewCartAddresses', - 'autoSetCartShippingMethodOption', - 'autoSetPaymentSource', - 'allowEmptyCartOnCheckout', - 'allowCheckoutWithoutPayment', - 'allowPartialPaymentOnCheckout', - 'requireShippingAddressAtCheckout', - 'requireBillingAddressAtCheckout', - 'requireShippingMethodSelectionAtCheckout', - 'useBillingAddressForTax', - 'validateOrganizationTaxIdAsVatId', - ]; - - // Update stores env var DB columns - foreach ($storeProperties as $storeProperty) { - $this->alterColumn(Table::STORES, $storeProperty, $this->string()->notNull()->defaultValue('false')); - } - - // Loop through each store and update values in the DB to match the PC values - foreach ($stores as $store) { - $storeSettingsForStore = $storeSettings[$store['uid']] ?? null; - - // If there isn't data in the PC for this store, skip it - if (!$storeSettingsForStore) { - continue; - } - - $updateData = []; - foreach ($storeProperties as $storeProperty) { - // If there isn't data in the PC for this store property, skip it - if (!isset($storeSettingsForStore[$storeProperty])) { - continue; - } - - // Parse the value from the PC - $envVarValue = App::parseBooleanEnv($storeSettingsForStore[$storeProperty]); - if ($envVarValue === null) { - continue; - } - - $updateData[$storeProperty] = $storeSettingsForStore[$storeProperty]; - } - - if (empty($updateData)) { - continue; - } - - $this->update(Table::STORES, $updateData, ['id' => $store['id']]); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m241204_091901_fix_store_environment_variables cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m241213_083338_update_promotional_price_in_line_items.php b/src/migrations/m241213_083338_update_promotional_price_in_line_items.php deleted file mode 100644 index 10d587e421..0000000000 --- a/src/migrations/m241213_083338_update_promotional_price_in_line_items.php +++ /dev/null @@ -1,48 +0,0 @@ -select('id') - ->from(Table::ORDERS) - ->where(['isCompleted' => true]); - - $lineItemsQuery = (new Query()) - ->select('id') - ->from(Table::LINEITEMS) - ->where(['orderId' => $ordersQuery]) - ->andWhere(['promotionalPrice' => null]) - ->andWhere(new Expression('[[salePrice]] < [[price]]')) - ->column(); - - foreach (array_chunk($lineItemsQuery, 1000) as $chunk) { - $this->update(Table::LINEITEMS, ['promotionalPrice' => new Expression('[[salePrice]]')], ['id' => $chunk]); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m241213_083338_update_promotional_price_in_line_items cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m241219_071723_add_inventory_backorder.php b/src/migrations/m241219_071723_add_inventory_backorder.php deleted file mode 100644 index ed36f697e9..0000000000 --- a/src/migrations/m241219_071723_add_inventory_backorder.php +++ /dev/null @@ -1,33 +0,0 @@ -db->columnExists(Table::PURCHASABLES_STORES, 'allowOutOfStockPurchases')) { - $this->addColumn(Table::PURCHASABLES_STORES, 'allowOutOfStockPurchases', $this->boolean()->after('inventoryTracked')->notNull()->defaultValue(false)); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m241219_071723_add_inventory_backorder cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m241220_082900_remove_inventory_for_non_inventory_purchasables.php b/src/migrations/m241220_082900_remove_inventory_for_non_inventory_purchasables.php deleted file mode 100644 index 8926ae77ca..0000000000 --- a/src/migrations/m241220_082900_remove_inventory_for_non_inventory_purchasables.php +++ /dev/null @@ -1,46 +0,0 @@ -select(['items.id AS id', 'elements.type AS type']) - ->from(['items' => Table::INVENTORYITEMS]) - ->leftJoin(['elements' => CraftTable::ELEMENTS], '[[items.purchasableId]] = [[elements.id]]') - ->all(); - - // Only remove the donation inventory items that shouldn't be there, can do others later. - foreach ($purchasables as $purchasable) { - if (is_subclass_of($purchasable['type'], Donation::class)) { - if (!$purchasable['type']::hasInventory()) { // should always be false, but just in case - $this->delete(Table::INVENTORYITEMS, ['id' => $purchasable['id']]); - } - } - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m241220_082900_remove_inventory_for_non_inventory_purchasables cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m250120_080035_move_to_tax_id_validators.php b/src/migrations/m250120_080035_move_to_tax_id_validators.php deleted file mode 100644 index 55366b03f5..0000000000 --- a/src/migrations/m250120_080035_move_to_tax_id_validators.php +++ /dev/null @@ -1,40 +0,0 @@ -addColumn('{{%commerce_taxrates}}', 'taxIdValidators', $this->text()->after('isVat')); - - $taxRates = (new \craft\db\Query()) - ->select(['id', 'isVat']) - ->from(['{{%commerce_taxrates}}']) - ->all(); - - foreach ($taxRates as $taxRate) { - $taxIdValidators = $taxRate['isVat'] ? ['craft\commerce\taxidvalidators\EuVatIdValidator'] : []; - $this->update('{{%commerce_taxrates}}', ['taxIdValidators' => json_encode($taxIdValidators)], ['id' => $taxRate['id']]); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m250120_080035_move_to_tax_id_validators cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m250128_083515_add_make_primary_addresses_to_orders.php b/src/migrations/m250128_083515_add_make_primary_addresses_to_orders.php deleted file mode 100644 index ec8403fa7a..0000000000 --- a/src/migrations/m250128_083515_add_make_primary_addresses_to_orders.php +++ /dev/null @@ -1,32 +0,0 @@ -addColumn(Table::ORDERS, 'makePrimaryShippingAddress', $this->boolean()->defaultValue(false)->after('saveShippingAddressOnOrderComplete')); - $this->addColumn(Table::ORDERS, 'makePrimaryBillingAddress', $this->boolean()->defaultValue(false)->after('saveBillingAddressOnOrderComplete')); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m250128_083515_add_make_primary_addresses_to_orders cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m250129_080909_fix_discount_conditions.php b/src/migrations/m250129_080909_fix_discount_conditions.php deleted file mode 100644 index ca43bf99a5..0000000000 --- a/src/migrations/m250129_080909_fix_discount_conditions.php +++ /dev/null @@ -1,55 +0,0 @@ -select(['id', 'orderCondition', 'storeId']) - ->from(Table::DISCOUNTS) - ->all(); - - foreach ($discounts as $discount) { - $discountId = $discount['id']; - $storeId = $discount['storeId']; - $orderConditionData = Json::decodeIfJson($discount['orderCondition']); - - if (!is_array($orderConditionData)) { - continue; - } - - if (!isset($orderConditionData['storeId'])) { - $orderConditionData['storeId'] = $storeId; - $orderConditionJson = Json::encode($orderConditionData); - $this->update(Table::DISCOUNTS, - ['orderCondition' => $orderConditionJson], - ['id' => $discountId] - ); - } - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m250129_080909_fix_discount_conditions cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m250210_125139_fix_cart_recalculation_modes.php b/src/migrations/m250210_125139_fix_cart_recalculation_modes.php deleted file mode 100644 index 666031d434..0000000000 --- a/src/migrations/m250210_125139_fix_cart_recalculation_modes.php +++ /dev/null @@ -1,35 +0,0 @@ -update(Table::ORDERS, ['recalculationMode' => Order::RECALCULATION_MODE_ALL], [ - 'recalculationMode' => Order::RECALCULATION_MODE_NONE, - 'isCompleted' => false, - ], [], false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m250210_125139_fix_cart_recalculation_modes cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m250301_120000_add_gateway_order_condition.php b/src/migrations/m250301_120000_add_gateway_order_condition.php deleted file mode 100644 index df4a9b22a7..0000000000 --- a/src/migrations/m250301_120000_add_gateway_order_condition.php +++ /dev/null @@ -1,64 +0,0 @@ -addColumn(Table::GATEWAYS, 'orderCondition', $this->text()); - - $projectConfig = \Craft::$app->getProjectConfig(); - - $projectConfig->muteEvents = true; - - $gateways = (new Query()) - ->select(['id', 'uid', 'isArchived']) - ->from(Table::GATEWAYS) - ->all(); - - foreach ($gateways as $gateway) { - $config = $projectConfig->get(Gateways::CONFIG_GATEWAY_KEY . '.' . $gateway['uid']); - - $orderCondition = [ - 'class' => 'craft\\commerce\\elements\\conditions\\orders\\GatewayOrderCondition', - 'conditionRules' => [], - ]; - - $this->update(Table::GATEWAYS, - ['orderCondition' => json_encode($orderCondition)], - ['id' => $gateway['id']] - ); - - if ($config && !$gateway['isArchived']) { - $config['orderCondition'] = $orderCondition; - $projectConfig->set(Gateways::CONFIG_GATEWAY_KEY . '.' . $gateway['uid'], $config); - } - } - - $projectConfig->muteEvents = false; - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m250301_120000_add_gateway_order_condition cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m250401_091214_add_shipping_method_customer_condition.php b/src/migrations/m250401_091214_add_shipping_method_customer_condition.php deleted file mode 100644 index a28354331b..0000000000 --- a/src/migrations/m250401_091214_add_shipping_method_customer_condition.php +++ /dev/null @@ -1,31 +0,0 @@ -addColumn(Table::SHIPPINGMETHODS, 'customerCondition', $this->text()); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m250401_091214_add_shipping_method_customer_condition cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m250403_134328_add_shipping_rule_customer_condition.php b/src/migrations/m250403_134328_add_shipping_rule_customer_condition.php deleted file mode 100644 index cbd37bc337..0000000000 --- a/src/migrations/m250403_134328_add_shipping_rule_customer_condition.php +++ /dev/null @@ -1,31 +0,0 @@ -addColumn(Table::SHIPPINGRULES, 'customerCondition', $this->text()); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m250403_134328_add_shipping_rule_customer_condition cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m250616_042356_fix_field_layout_id.php b/src/migrations/m250616_042356_fix_field_layout_id.php deleted file mode 100644 index e17a73c322..0000000000 --- a/src/migrations/m250616_042356_fix_field_layout_id.php +++ /dev/null @@ -1,97 +0,0 @@ -select([ - 'v.id as variantId', - 'v.primaryOwnerId as productId', - 'p.typeId as productTypeId', - ]) - ->from(['v' => Table::VARIANTS]) - ->innerJoin(['e' => CraftTable::ELEMENTS], '[[v.id]] = [[e.id]]') - ->innerJoin(['p' => Table::PRODUCTS], '[[v.primaryOwnerId]] = [[p.id]]') - ->where(['e.type' => Variant::class]) - ->andWhere(['e.fieldLayoutId' => null]) - ->all(); - - if (empty($variantsToFix)) { - return true; - } - - // Group variants by product type - $variantsByProductType = []; - foreach ($variantsToFix as $variant) { - $variantsByProductType[$variant['productTypeId']][] = $variant['variantId']; - } - - // Get valid field layout IDs for each product type - $productTypes = (new Query()) - ->select(['pt.id', 'pt.variantFieldLayoutId']) - ->from(['pt' => Table::PRODUCTTYPES]) - ->innerJoin(['fl' => CraftTable::FIELDLAYOUTS], '[[pt.variantFieldLayoutId]] = [[fl.id]]') // Ensure field layout exists - ->where(['pt.id' => array_keys($variantsByProductType)]) - ->andWhere(['not', ['pt.variantFieldLayoutId' => null]]) - ->indexBy('id') - ->all(); - - $db = Craft::$app->getDb(); - $transaction = $db->beginTransaction(); - - try { - // Update variants with the correct field layout ID - foreach ($variantsByProductType as $productTypeId => $variantIds) { - // Check if we have a valid field layout for this product type - if (!isset($productTypes[$productTypeId])) { - continue; - } - - $fieldLayoutId = $productTypes[$productTypeId]['variantFieldLayoutId']; - - // Update in batches - foreach (array_chunk($variantIds, 500) as $chunk) { - $db->createCommand() - ->update( - CraftTable::ELEMENTS, - ['fieldLayoutId' => $fieldLayoutId], - ['id' => $chunk] - ) - ->execute(); - } - } - - $transaction->commit(); - } catch (\Throwable $e) { - $transaction->rollBack(); - throw $e; - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m250616_042356_fix_field_layout_id cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m250617_105249_add_email_render_site_id.php b/src/migrations/m250617_105249_add_email_render_site_id.php deleted file mode 100644 index b6ea5f1756..0000000000 --- a/src/migrations/m250617_105249_add_email_render_site_id.php +++ /dev/null @@ -1,64 +0,0 @@ -db->columnExists(Table::EMAILS, 'renderSiteId')) { - return true; - } - - $this->addColumn(Table::EMAILS, 'renderSiteId', $this->integer()->after('language')); - - // Get the primary site ID - $primarySite = Craft::$app->getSites()->getPrimarySite(); - - // For all current emails set the `renderSiteId` to the primary site to keep the existing behavior - $this->db->createCommand()->update( - Table::EMAILS, - ['renderSiteId' => $primarySite->id], - ['renderSiteId' => null] - )->execute(); - - // Update the project config - $projectConfig = Craft::$app->getProjectConfig(); - - $emails = $projectConfig->get('commerce.emails') ?? []; - $muteEvents = $projectConfig->muteEvents; - $projectConfig->muteEvents = true; - - foreach ($emails as $emailUid => $email) { - $email['renderSite'] = $primarySite->uid; - $projectConfig->set("commerce.emails.$emailUid", $email); - } - - $projectConfig->muteEvents = $muteEvents; - - // Add foreign key - $this->addForeignKey(null, Table::EMAILS, ['renderSiteId'], CraftTable::SITES, ['id'], 'SET NULL', 'CASCADE'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m250617_105249_add_email_render_site_id cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m250701_054128_add_defaultVariant_idex_to_products.php b/src/migrations/m250701_054128_add_defaultVariant_idex_to_products.php deleted file mode 100644 index 56d5fea2dc..0000000000 --- a/src/migrations/m250701_054128_add_defaultVariant_idex_to_products.php +++ /dev/null @@ -1,53 +0,0 @@ -select(['id']) - ->from(\craft\db\Table::ELEMENTS) - ->where(['type' => Variant::class]); - - $this->update( - Table::PRODUCTS, - ['defaultVariantId' => null], - ['not', ['defaultVariantId' => $subQuery]], - [], - ); - - $this->addForeignKey( - null, - Table::PRODUCTS, - 'defaultVariantId', - \craft\db\Table::ELEMENTS, - 'id', - 'SET NULL', - null - ); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m250701_054128_add_defaultVariant_idex_to_products.php cannot be reverted.\n"; - - return true; - } -} diff --git a/src/migrations/m250721_130616_fix_gateway_order_condition_pc.php b/src/migrations/m250721_130616_fix_gateway_order_condition_pc.php deleted file mode 100644 index 7c872e9ab1..0000000000 --- a/src/migrations/m250721_130616_fix_gateway_order_condition_pc.php +++ /dev/null @@ -1,64 +0,0 @@ -getProjectConfig(); - - $projectConfig->muteEvents = true; - - // Fix gateways with missing order conditions - $gateways = (new Query()) - ->select(['id', 'uid', 'isArchived']) - ->from(Table::GATEWAYS) - ->where(['orderCondition' => null]) - ->all(); - - foreach ($gateways as $gateway) { - $config = $projectConfig->get(Gateways::CONFIG_GATEWAY_KEY . '.' . $gateway['uid']); - - $orderCondition = [ - 'class' => 'craft\\commerce\\elements\\conditions\\orders\\GatewayOrderCondition', - 'conditionRules' => [], - ]; - - $this->update(Table::GATEWAYS, - ['orderCondition' => json_encode($orderCondition)], - ['id' => $gateway['id']] - ); - - if ($config && !$gateway['isArchived']) { - $config['orderCondition'] = $orderCondition; - $projectConfig->set(Gateways::CONFIG_GATEWAY_KEY . '.' . $gateway['uid'], $config); - } - } - - $projectConfig->muteEvents = false; - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m250721_130616_fix_gateway_order_condition_pc cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m250731_020627_add_slug_options_to_product_types.php b/src/migrations/m250731_020627_add_slug_options_to_product_types.php deleted file mode 100644 index 63b7205d79..0000000000 --- a/src/migrations/m250731_020627_add_slug_options_to_product_types.php +++ /dev/null @@ -1,43 +0,0 @@ -db->columnExists(Table::PRODUCTTYPES, 'showSlugField')) { - $this->addColumn(Table::PRODUCTTYPES, 'showSlugField', $this->boolean()->notNull()->defaultValue(true)->after('productTitleTranslationKeyFormat')); - } - - // Add slugTranslationMethod column - if (!$this->db->columnExists(Table::PRODUCTTYPES, 'slugTranslationMethod')) { - $this->addColumn(Table::PRODUCTTYPES, 'slugTranslationMethod', $this->string()->notNull()->defaultValue('site')->after('showSlugField')); - } - - // Add slugTranslationKeyFormat column - if (!$this->db->columnExists(Table::PRODUCTTYPES, 'slugTranslationKeyFormat')) { - $this->addColumn(Table::PRODUCTTYPES, 'slugTranslationKeyFormat', $this->string()->after('slugTranslationMethod')); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - return true; - } -} diff --git a/src/migrations/m250815_120000_add_gateway_address_conditions.php b/src/migrations/m250815_120000_add_gateway_address_conditions.php deleted file mode 100644 index b9640400ed..0000000000 --- a/src/migrations/m250815_120000_add_gateway_address_conditions.php +++ /dev/null @@ -1,74 +0,0 @@ -addColumn(Table::GATEWAYS, 'billingAddressCondition', $this->text()); - $this->addColumn(Table::GATEWAYS, 'shippingAddressCondition', $this->text()); - - $projectConfig = \Craft::$app->getProjectConfig(); - - $projectConfig->muteEvents = true; - - $gateways = (new Query()) - ->select(['id', 'uid', 'isArchived']) - ->from(Table::GATEWAYS) - ->all(); - - foreach ($gateways as $gateway) { - $config = $projectConfig->get(Gateways::CONFIG_GATEWAY_KEY . '.' . $gateway['uid']); - - $billingAddressCondition = [ - 'class' => 'craft\\commerce\\elements\\conditions\\addresses\\GatewayAddressCondition', - 'conditionRules' => [], - ]; - - $shippingAddressCondition = [ - 'class' => 'craft\\commerce\\elements\\conditions\\addresses\\GatewayAddressCondition', - 'conditionRules' => [], - ]; - - $this->update(Table::GATEWAYS, - [ - 'billingAddressCondition' => json_encode($billingAddressCondition), - 'shippingAddressCondition' => json_encode($shippingAddressCondition), - ], - ['id' => $gateway['id']] - ); - - if ($config && !$gateway['isArchived']) { - $config['billingAddressCondition'] = $billingAddressCondition; - $config['shippingAddressCondition'] = $shippingAddressCondition; - $projectConfig->set(Gateways::CONFIG_GATEWAY_KEY . '.' . $gateway['uid'], $config); - } - } - - $projectConfig->muteEvents = false; - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m250815_120000_add_gateway_address_conditions cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m250919_111358_fix_methodId_shipping_rules_fk.php b/src/migrations/m250919_111358_fix_methodId_shipping_rules_fk.php deleted file mode 100644 index 4e50466800..0000000000 --- a/src/migrations/m250919_111358_fix_methodId_shipping_rules_fk.php +++ /dev/null @@ -1,33 +0,0 @@ -dropForeignKeyIfExists(Table::SHIPPINGRULES, ['methodId']); - - $this->addForeignKey(null, Table::SHIPPINGRULES, ['methodId'], Table::SHIPPINGMETHODS, ['id'], 'CASCADE'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m250919_111358_fix_methodId_shipping_rules_fk cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m251003_120720_add_preview_targets_to_product_types.php b/src/migrations/m251003_120720_add_preview_targets_to_product_types.php deleted file mode 100644 index 79b829de4d..0000000000 --- a/src/migrations/m251003_120720_add_preview_targets_to_product_types.php +++ /dev/null @@ -1,34 +0,0 @@ -db->columnExists(Table::PRODUCTTYPES, 'previewTargets')) { - $this->addColumn(Table::PRODUCTTYPES, 'previewTargets', $this->json()->after('propagationMethod')); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m251003_120720_add_preview_targets_to_product_types cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m251028_095831_add_order_date_first_paid_property.php b/src/migrations/m251028_095831_add_order_date_first_paid_property.php deleted file mode 100644 index 948cf347cf..0000000000 --- a/src/migrations/m251028_095831_add_order_date_first_paid_property.php +++ /dev/null @@ -1,31 +0,0 @@ -addColumn(Table::ORDERS, 'dateFirstPaid', $this->dateTime()->after('datePaid')); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m251028_095831_add_order_date_first_paid_property cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m251030_094827_add_link_expiry_to_pdfs.php b/src/migrations/m251030_094827_add_link_expiry_to_pdfs.php deleted file mode 100644 index a283826f4f..0000000000 --- a/src/migrations/m251030_094827_add_link_expiry_to_pdfs.php +++ /dev/null @@ -1,31 +0,0 @@ -addColumn(Table::PDFS, 'linkExpiry', $this->integer()->notNull()->defaultValue(86400)->after('language')); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m251030_094827_add_link_expiry_to_pdfs cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m251105_194014_add_icon_and_color_to_categories_and_methods.php b/src/migrations/m251105_194014_add_icon_and_color_to_categories_and_methods.php deleted file mode 100644 index 727b933f5f..0000000000 --- a/src/migrations/m251105_194014_add_icon_and_color_to_categories_and_methods.php +++ /dev/null @@ -1,53 +0,0 @@ -db->columnExists(Table::SHIPPINGMETHODS, 'icon')) { - $this->addColumn(Table::SHIPPINGMETHODS, 'icon', $this->string()->after('handle')); - } - if (!$this->db->columnExists(Table::SHIPPINGMETHODS, 'color')) { - $this->addColumn(Table::SHIPPINGMETHODS, 'color', $this->string()->after('icon')); - } - - // Add icon and color to shipping categories - if (!$this->db->columnExists(Table::SHIPPINGCATEGORIES, 'icon')) { - $this->addColumn(Table::SHIPPINGCATEGORIES, 'icon', $this->string()->after('handle')); - } - if (!$this->db->columnExists(Table::SHIPPINGCATEGORIES, 'color')) { - $this->addColumn(Table::SHIPPINGCATEGORIES, 'color', $this->string()->after('icon')); - } - - // Add icon and color to tax categories - if (!$this->db->columnExists(Table::TAXCATEGORIES, 'icon')) { - $this->addColumn(Table::TAXCATEGORIES, 'icon', $this->string()->after('handle')); - } - if (!$this->db->columnExists(Table::TAXCATEGORIES, 'color')) { - $this->addColumn(Table::TAXCATEGORIES, 'color', $this->string()->after('icon')); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m251105_194014_add_icon_and_color_to_categories_and_methods cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m251111_092942_ensure_catalog_pricing_indexes.php b/src/migrations/m251111_092942_ensure_catalog_pricing_indexes.php deleted file mode 100644 index 01b463eb96..0000000000 --- a/src/migrations/m251111_092942_ensure_catalog_pricing_indexes.php +++ /dev/null @@ -1,38 +0,0 @@ -createIndexIfMissing(Table::CATALOG_PRICING, 'catalogPricingRuleId', false); - $this->createIndexIfMissing(Table::CATALOG_PRICING, 'isPromotionalPrice', false); - $this->createIndexIfMissing(Table::CATALOG_PRICING, 'purchasableId', false); - $this->createIndexIfMissing(Table::CATALOG_PRICING, 'storeId', false); - $this->createIndexIfMissing(Table::CATALOG_PRICING, 'userId', false); - $this->createIndexIfMissing(Table::CATALOG_PRICING, ['purchasableId', 'storeId', 'isPromotionalPrice', 'price', 'catalogPricingRuleId', 'dateFrom', 'dateTo'], false); - $this->createIndexIfMissing(Table::CATALOG_PRICING, ['purchasableId', 'storeId', 'isPromotionalPrice', 'price'], false); - $this->createIndexIfMissing(Table::CATALOG_PRICING, ['purchasableId', 'storeId'], false); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m251111_092942_ensure_catalog_pricing_indexes cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m251112_120000_fix_null_gateway_order_condition.php b/src/migrations/m251112_120000_fix_null_gateway_order_condition.php deleted file mode 100644 index 38217422da..0000000000 --- a/src/migrations/m251112_120000_fix_null_gateway_order_condition.php +++ /dev/null @@ -1,65 +0,0 @@ -getProjectConfig(); - - $projectConfig->muteEvents = true; - - // Fix gateways with missing order conditions - $gateways = (new Query()) - ->select(['id', 'uid', 'isArchived']) - ->from(Table::GATEWAYS) - ->where(['orderCondition' => null]) - ->all(); - - if (!empty($gateways)) { - foreach ($gateways as $gateway) { - $config = $projectConfig->get(Gateways::CONFIG_GATEWAY_KEY . '.' . $gateway['uid']); - - $orderCondition = [ - 'class' => 'craft\\commerce\\elements\\conditions\\orders\\GatewayOrderCondition', - 'conditionRules' => [], - ]; - - $this->update(Table::GATEWAYS, - ['orderCondition' => json_encode($orderCondition)], - ['id' => $gateway['id']] - ); - - if ($config && !$gateway['isArchived']) { - $config['orderCondition'] = $orderCondition; - $projectConfig->set(Gateways::CONFIG_GATEWAY_KEY . '.' . $gateway['uid'], $config); - } - } - } - - $projectConfig->muteEvents = false; - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m251112_120000_fix_null_gateway_order_condition cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m260206_000000_add_ui_label_formats.php b/src/migrations/m260206_000000_add_ui_label_formats.php deleted file mode 100644 index 10cddc2415..0000000000 --- a/src/migrations/m260206_000000_add_ui_label_formats.php +++ /dev/null @@ -1,52 +0,0 @@ -db->columnExists(Table::PRODUCTTYPES, 'variantUiLabelFormat')) { - $this->addColumn( - Table::PRODUCTTYPES, - 'variantUiLabelFormat', - $this->string()->notNull()->defaultValue('{title}')->after('variantTitleTranslationKeyFormat') - ); - } - - if (!$this->db->columnExists(Table::PRODUCTTYPES, 'productUiLabelFormat')) { - $this->addColumn( - Table::PRODUCTTYPES, - 'productUiLabelFormat', - $this->string()->notNull()->defaultValue('{title}')->after('productTitleTranslationKeyFormat') - ); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - $this->dropColumn(Table::PRODUCTTYPES, 'variantUiLabelFormat'); - $this->dropColumn(Table::PRODUCTTYPES, 'productUiLabelFormat'); - - return true; - } -} diff --git a/src/migrations/m260226_120000_product_type_permissions.php b/src/migrations/m260226_120000_product_type_permissions.php deleted file mode 100644 index a701327eeb..0000000000 --- a/src/migrations/m260226_120000_product_type_permissions.php +++ /dev/null @@ -1,115 +0,0 @@ -select(['uid']) - ->from('{{%commerce_producttypes}}') - ->column($this->db); - - // Build the permission mapping - $map = []; // oldPermission => [newPermission, ...] - foreach ($productTypeUids as $uid) { - // commerce-editProductType → commerce-viewProductType + commerce-saveProductType - $map[strtolower("commerce-editProductType:$uid")] = [ - strtolower("commerce-viewProductType:$uid"), - strtolower("commerce-saveProductType:$uid"), - ]; - // commerce-createProducts → commerce-createProductType - $map[strtolower("commerce-createProducts:$uid")] = [ - strtolower("commerce-createProductType:$uid"), - ]; - // commerce-deleteProducts → commerce-deleteProductType - $map[strtolower("commerce-deleteProducts:$uid")] = [ - strtolower("commerce-deleteProductType:$uid"), - ]; - } - - // Migrate user permissions in the database - foreach ($map as $oldPermission => $newPermissions) { - // Find all users with the old permission - $userIds = (new Query()) - ->select(['upu.userId']) - ->from(['upu' => Table::USERPERMISSIONS_USERS]) - ->innerJoin(['up' => Table::USERPERMISSIONS], '[[up.id]] = [[upu.permissionId]]') - ->where(['up.name' => $oldPermission]) - ->column($this->db); - - $userIds = array_unique($userIds); - - if (!empty($userIds)) { - foreach ($newPermissions as $newPermission) { - // Delete the permission if it already exists - $this->delete(Table::USERPERMISSIONS, [ - 'name' => $newPermission, - ]); - - $this->insert(Table::USERPERMISSIONS, [ - 'name' => $newPermission, - ]); - $newPermissionId = $this->db->getLastInsertID(Table::USERPERMISSIONS); - - $insert = []; - foreach ($userIds as $userId) { - $insert[] = [$newPermissionId, $userId]; - } - - $this->batchInsert(Table::USERPERMISSIONS_USERS, ['permissionId', 'userId'], $insert); - } - } - } - - // Migrate project config for user groups - $projectConfig = Craft::$app->getProjectConfig(); - - foreach ($projectConfig->get('users.groups') ?? [] as $uid => $group) { - $groupPermissions = array_flip($group['permissions'] ?? []); - $save = false; - - foreach ($map as $oldPermission => $newPermissions) { - if (isset($groupPermissions[$oldPermission])) { - foreach ($newPermissions as $newPermission) { - $groupPermissions[$newPermission] = true; - } - $save = true; - } - } - - if ($save) { - $projectConfig->set("users.groups.$uid.permissions", array_keys($groupPermissions)); - } - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - // Permission migrations are not reversible - return true; - } -} diff --git a/src/migrations/m260327_000000_ensure_link_expiry_on_pdfs.php b/src/migrations/m260327_000000_ensure_link_expiry_on_pdfs.php deleted file mode 100644 index d2cafab9ad..0000000000 --- a/src/migrations/m260327_000000_ensure_link_expiry_on_pdfs.php +++ /dev/null @@ -1,33 +0,0 @@ -db->columnExists(Table::PDFS, 'linkExpiry')) { - $this->addColumn(Table::PDFS, 'linkExpiry', $this->integer()->notNull()->defaultValue(86400)->after('language')); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m260327_000000_ensure_link_expiry_on_pdfs cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m260407_000000_add_catalog_pricing_queue_table.php b/src/migrations/m260407_000000_add_catalog_pricing_queue_table.php deleted file mode 100644 index 0084068a2f..0000000000 --- a/src/migrations/m260407_000000_add_catalog_pricing_queue_table.php +++ /dev/null @@ -1,47 +0,0 @@ -db->tableExists(Table::CATALOG_PRICING_QUEUE)) { - $this->createTable(Table::CATALOG_PRICING_QUEUE, [ - 'id' => $this->primaryKey(), - 'storeId' => $this->integer(), - 'type' => $this->enum('type', [CatalogPricingQueue::TYPE_PURCHASABLE, CatalogPricingQueue::TYPE_RULE])->notNull(), - 'ids' => $this->mediumText(), - 'reserved' => $this->boolean()->notNull()->defaultValue(false), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), - 'uid' => $this->uid(), - ]); - } - - $this->createIndexIfMissing(Table::CATALOG_PRICING_QUEUE, 'reserved', false); - $this->createIndexIfMissing(Table::CATALOG_PRICING_QUEUE, ['storeId', 'type', 'reserved'], false); - $this->addForeignKey(null, Table::CATALOG_PRICING_QUEUE, ['storeId'], Table::STORES, ['id'], 'CASCADE', 'CASCADE'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m260407_000000_add_catalog_pricing_queue_table cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m260505_071943_add_orders_customerDeleted_column.php b/src/migrations/m260505_071943_add_orders_customerDeleted_column.php deleted file mode 100644 index 21be29d4f3..0000000000 --- a/src/migrations/m260505_071943_add_orders_customerDeleted_column.php +++ /dev/null @@ -1,36 +0,0 @@ -getDb()->columnExists(Table::ORDERS, 'customerDeleted')) { - return true; - } - - $this->addColumn(Table::ORDERS, 'customerDeleted', $this->boolean()->notNull()->defaultValue(false)->after('customerId')); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m260505_071943_add_orders_customerDeleted_column cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m260506_000000_fix_fulfilled_check_constraint.php b/src/migrations/m260506_000000_fix_fulfilled_check_constraint.php deleted file mode 100644 index e10eda361a..0000000000 --- a/src/migrations/m260506_000000_fix_fulfilled_check_constraint.php +++ /dev/null @@ -1,52 +0,0 @@ -db->getIsPgsql()) { - $tableNameQuoted = $this->db->quoteTableName('{{%commerce_inventorytransactions}}'); - $typeColumnQuoted = $this->db->quoteColumnName('type'); - - // Old constraint: auto-named by PostgreSQL when the table was created as commerce_inventorymovements - $oldConstraint = $this->db->getSchema()->getRawTableName('{{%commerce_inventorymovements}}') . '_type_check'; - // New constraint: added by the previous alterColumn migration (m240228_060604) - $newConstraint = 'commerce_inventorytransactions_type_check'; - - foreach ([$oldConstraint, $newConstraint] as $constraint) { - $this->db->createCommand( - "ALTER TABLE $tableNameQuoted DROP CONSTRAINT IF EXISTS " . $this->db->quoteColumnName($constraint) - )->execute(); - } - - $this->db->createCommand( - "ALTER TABLE $tableNameQuoted ADD CONSTRAINT commerce_inventorytransactions_type_check CHECK ($typeColumnQuoted IN ('available', 'reserved', 'damaged', 'safety', 'qualityControl', 'committed', 'fulfilled', 'incoming'))" - )->execute(); - } - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m260506_000000_fix_fulfilled_check_constraint cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m260507_000000_subscriptions_nullable_userId.php b/src/migrations/m260507_000000_subscriptions_nullable_userId.php deleted file mode 100644 index 44efa7fa47..0000000000 --- a/src/migrations/m260507_000000_subscriptions_nullable_userId.php +++ /dev/null @@ -1,35 +0,0 @@ -dropForeignKeyIfExists(Table::SUBSCRIPTIONS, ['userId']); - $this->addForeignKey(null, Table::SUBSCRIPTIONS, ['userId'], CraftTable::USERS, ['id'], 'CASCADE'); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m260507_000000_subscriptions_nullable_userId cannot be reverted.\n"; - return false; - } -} diff --git a/src/migrations/m260615_000000_add_notice_type_to_order_notices.php b/src/migrations/m260615_000000_add_notice_type_to_order_notices.php deleted file mode 100644 index c1b7d5dac5..0000000000 --- a/src/migrations/m260615_000000_add_notice_type_to_order_notices.php +++ /dev/null @@ -1,32 +0,0 @@ -db->columnExists(Table::ORDERNOTICES, 'noticeType')) { - $this->addColumn(Table::ORDERNOTICES, 'noticeType', $this->string()->notNull()->defaultValue('customer')); - } - - return true; - } - - public function safeDown(): bool - { - if ($this->db->columnExists(Table::ORDERNOTICES, 'noticeType')) { - $this->dropColumn(Table::ORDERNOTICES, 'noticeType'); - } - - return true; - } -} diff --git a/src/migrations/m260616_000000_rename_allVariants_changedattributes.php b/src/migrations/m260616_000000_rename_allVariants_changedattributes.php deleted file mode 100644 index a441882ac9..0000000000 --- a/src/migrations/m260616_000000_rename_allVariants_changedattributes.php +++ /dev/null @@ -1,63 +0,0 @@ -allVariants (which no longer exists), throwing an UnknownPropertyException - // when opening a product with a provisional draft. - // This migration renames any lingering 'allVariants' entries to 'variants' for Product elements. - - $productSubquery = (new Query()) - ->select(['id']) - ->from([Table::ELEMENTS]) - ->where(['type' => 'craft\commerce\elements\Product']); - - // Insert 'variants' rows for Products that have 'allVariants' but no existing 'variants' entry - $select = (new Query()) - ->select(['ca.elementId', 'ca.siteId', new Expression("'variants'"), 'ca.dateUpdated', 'ca.propagated', 'ca.userId']) - ->from(['ca' => '{{%changedattributes}}']) - ->where(['ca.attribute' => 'allVariants', 'ca.elementId' => $productSubquery]) - ->andWhere('NOT EXISTS (SELECT 1 FROM {{%changedattributes}} [[ca2]] WHERE [[ca2.elementId]] = [[ca.elementId]] AND [[ca2.siteId]] = [[ca.siteId]] AND [[ca2.attribute]] = \'variants\')'); - - [$sql, $params] = $this->db->getQueryBuilder()->build($select); - $table = $this->db->quoteTableName('{{%changedattributes}}'); - $this->db->createCommand( - "INSERT INTO $table ([[elementId]], [[siteId]], [[attribute]], [[dateUpdated]], [[propagated]], [[userId]]) $sql", - $params - )->execute(); - - // Delete all 'allVariants' rows for Products - $this->delete('{{%changedattributes}}', [ - 'attribute' => 'allVariants', - 'elementId' => $productSubquery, - ]); - - return true; - } - - /** - * @inheritdoc - */ - public function safeDown(): bool - { - echo "m260616_000000_rename_allVariants_changedattributes cannot be reverted.\n"; - return false; - } -} diff --git a/src/models/CatalogPricing.php b/src/models/CatalogPricing.php deleted file mode 100644 index 335e8530fc..0000000000 --- a/src/models/CatalogPricing.php +++ /dev/null @@ -1,176 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricing extends Model implements HasStoreInterface -{ - use StoreTrait; - - /** - * @var int|null - */ - public ?int $id = null; - - /** - * @var int|null - */ - public ?int $purchasableId = null; - - /** - * @var float|null - */ - public ?float $price = null; - - /** - * @var int|null - */ - public ?int $catalogPricingRuleId = null; - - /** - * @var \DateTime|null - */ - public ?\DateTime $dateFrom = null; - - /** - * @var \DateTime|null - */ - public ?\DateTime $dateTo = null; - - /** - * @var bool - */ - public bool $isPromotionalPrice = false; - - /** - * @var bool - */ - public bool $hasUpdatePending = false; - - /** - * @var string|null - */ - public ?string $uid = null; - - /** - * @var CatalogPricingRule|null - */ - private ?CatalogPricingRule $_catalogPricingRule = null; - - /** - * @var PurchasableInterface|null - */ - private ?PurchasableInterface $_purchasable = null; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [[ - 'catalogPricingRuleId', - 'dateFrom', - 'dateTo', - 'hasUpdatePending', - 'id', - 'isPromotionalPrice', - 'price', - 'purchasableId', - 'storeId', - 'uid', - ], 'safe']; - - return $rules; - } - - /** - * @throws InvalidConfigException - */ - public function behaviors(): array - { - $behaviors = parent::behaviors(); - - $behaviors['currencyAttributes'] = [ - 'class' => CurrencyAttributeBehavior::class, - 'currencyAttributes' => $this->currencyAttributes(), - ]; - - return $behaviors; - } - - /** - * @return array - */ - public function currencyAttributes(): array - { - return [ - 'price', - ]; - } - - /** - * @return PurchasableInterface|null - * @throws InvalidConfigException - * @throws SiteNotFoundException - */ - public function getPurchasable(): ?PurchasableInterface - { - if ($this->_purchasable !== null) { - return $this->_purchasable; - } - - if ($this->purchasableId === null || $this->storeId === null) { - return null; - } - - if (!$store = Plugin::getInstance()->getStores()->getStoreById($this->storeId)) { - throw new InvalidConfigException('Invalid store ID: ' . $this->storeId); - } - - // @TODO Resolve the correct site for the purchasable lookup rather than defaulting to the store's first site; catalog pricing is currently store-scoped but purchasables are site-aware - $site = $store->getSites()->first(); - - $this->_purchasable = Plugin::getInstance()->getPurchasables()->getPurchasableById($this->purchasableId, $site->id); - - return $this->_purchasable; - } - - /** - * @return CatalogPricingRule|null - * @throws InvalidConfigException - */ - public function getCatalogPricingRule(): ?CatalogPricingRule - { - if ($this->_catalogPricingRule !== null) { - return $this->_catalogPricingRule; - } - - if (!$this->catalogPricingRuleId) { - return null; - } - - $this->_catalogPricingRule = Plugin::getInstance()->getCatalogPricingRules()->getCatalogPricingRuleById($this->catalogPricingRuleId, $this->storeId); - - return $this->_catalogPricingRule; - } -} diff --git a/src/models/CatalogPricingRule.php b/src/models/CatalogPricingRule.php deleted file mode 100644 index 15c38f6feb..0000000000 --- a/src/models/CatalogPricingRule.php +++ /dev/null @@ -1,526 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricingRule extends Model implements HasStoreInterface -{ - use StoreTrait; - - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string|null Name - */ - public ?string $name = null; - - /** - * @var string|null Description - */ - public ?string $description = null; - - /** - * @var DateTime|null Date From - */ - public ?DateTime $dateFrom = null; - - /** - * @var DateTime|null Date To - */ - public ?DateTime $dateTo = null; - - /** - * @var string How the sale should be applied - */ - public string $apply = PricingCatalogRuleRecord::APPLY_BY_PERCENT; - - /** - * @var float|null The amount field used by the apply option - */ - public ?float $applyAmount = null; - - /** - * @var string - */ - public string $applyPriceType = PricingCatalogRuleRecord::APPLY_PRICE_TYPE_PRICE; - - /** - * @var ElementConditionInterface|null - * @see getCustomerCondition() - * @see setCustomerCondition() - */ - public null|ElementConditionInterface $_customerCondition = null; - - /** - * @var ElementConditionInterface|null - * @see getProductCondition() - * @see setProductCondition() - */ - public null|ElementConditionInterface $_productCondition = null; - /** - * @var ElementConditionInterface|null - * @see getVariantCondition() - * @see setVariantCondition() - */ - public null|ElementConditionInterface $_variantCondition = null; - - /** - * @var ElementConditionInterface|null - * @see getPurchasableCondition() - * @see setPurchasableCondition() - */ - public null|ElementConditionInterface $_purchasableCondition = null; - - /** - * @var bool Enabled - */ - public bool $enabled = true; - - /** - * @var bool - */ - public bool $isPromotionalPrice = false; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateCreated = null; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateUpdated = null; - - /** - * @var int[]|null Product Ids - */ - private ?array $_purchasableIds = null; - - /** - * @var int[]|null - */ - private ?array $_userIds = null; - - /** - * @var array - * @todo Remove the unused $_metadata property in Commerce 6.0 - */ - private array $_metadata = []; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['apply'], 'in', 'range' => ['toPercent', 'toFlat', 'byPercent', 'byFlat']], - [['enabled'], 'boolean'], - [['name', 'apply'], 'required'], - [[ - 'applyAmount', - 'applyPriceType', - 'customerCondition', - 'dateUpdated', - 'dateCreated', - 'dateFrom', - 'dateTo', - 'description', - 'id', - 'isPromotionalPrice', - 'metadata', - 'productCondition', - 'purchasableCondition', - 'storeId', - 'variantCondition', - ], 'safe'], - ]; - } - - public function getCpEditUrl(): string - { - return $this->getStore()->getStoreSettingsUrl('pricing-rules/' . $this->id); - } - - /** - * @return array - */ - public function extraFields(): array - { - $fields = parent::extraFields(); - $fields[] = 'purchasableIds'; - - return $fields; - } - - /** - * @return string - */ - public function getApplyAmountAsPercent(): string - { - return Craft::$app->getFormatter()->asPercent(-($this->applyAmount ?? 0.0)); - } - - /** - * @return string - */ - public function getApplyAmountAsFlat(): string - { - return $this->applyAmount !== null ? (string)($this->applyAmount * -1) : '0'; - } - - /** - * @param string|array $metadata - * @return void - */ - public function setMetadata(string|array $metadata): void - { - $metadata = Json::decodeIfJson($metadata); - - if (!is_array($metadata)) { - $metadata = []; - } - - $this->_metadata = $metadata; - } - - /** - * @return array - */ - public function getMetadata(): array - { - return $this->_metadata; - } - - /** - * @return int[]|null - */ - public function getPurchasableIds(): ?array - { - if ($this->_purchasableIds === null) { - $siteIds = $this->getStore()->getSites()->map(fn(Site $site) => $site->id)->all(); - $productVariantIds = null; - - if (!empty($this->getProductCondition()->getConditionRules())) { - $productQuery = Product::find(); - $productQuery->siteId($siteIds); - /** @var CatalogPricingRuleProductCondition $productCondition */ - $productCondition = $this->getProductCondition(); - $productCondition->modifyQuery($productQuery); - - $productVariantIds = []; - if ($productIds = $productQuery->ids()) { - $productVariantIdsQuery = Variant::find() - ->siteId($siteIds) - ->productId($productIds); - - // If the rule is generating a promotional price, we need to make sure the purchasable is promotable - if ($this->isPromotionalPrice) { - $productVariantIdsQuery->andWhere(Db::parseBooleanParam('purchasables_stores.promotable', true)); - } - - $productVariantIds = $productVariantIdsQuery->ids(); - } - } - - // If there are product condition rules and they have returned no variant IDs that means there are no products that matched - // We can skip out early as the rest of the conditions will not be met - if ($productVariantIds === []) { - $this->_purchasableIds = []; - return $this->_purchasableIds; - } - - $this->_purchasableIds = $productVariantIds; - - $variantIds = $productVariantIds; - if (!empty($this->getVariantCondition()->getConditionRules())) { - $variantQuery = Variant::find(); - $variantQuery->siteId($siteIds); - /** @var CatalogPricingRuleVariantCondition $variantCondition */ - $variantCondition = $this->getVariantCondition(); - $variantCondition->modifyQuery($variantQuery); - - // If the rule is generating a promotional price, we need to make sure the purchasable is promotable - if ($this->isPromotionalPrice) { - $variantQuery->andWhere(Db::parseBooleanParam('purchasables_stores.promotable', true)); - } - - // If there are product condition rules we need to ensure the variant is in the list of product variants - if ($productVariantIds !== null) { - $variantQuery->andWhere(['commerce_variants.id' => $productVariantIds]); - } - - $variantIds = $variantQuery->ids(); - } - - // If there are variant condition rules and they have returned no variant IDs that means there are no variants that matched - // We can skip out early as the rest of the conditions will not be met - if ($variantIds === []) { - $this->_purchasableIds = []; - return $this->_purchasableIds; - } - - $this->_purchasableIds = $variantIds; - - if (!empty($this->getPurchasableCondition()->getConditionRules())) { - $purchasableQuery = Purchasable::find(); - - /** @var CatalogPricingRulePurchasableCondition $purchasableCondition */ - $purchasableCondition = $this->getPurchasableCondition(); - $purchasableCondition->modifyQuery($purchasableQuery); - - // If there are product/variant condition rules we need to ensure the purchasable is in the list of product variants - if ($variantIds !== null) { - $purchasableQuery->andWhere(['id' => $variantIds]); - } - - // We are unable to use `siteId()` on the purchasable query as it is only the subquery part that is used. - - // If the rule is generating a promotional price, we need to make sure the purchasable is promotable - if ($this->isPromotionalPrice) { - $purchasableQuery->andWhere(Db::parseBooleanParam('purchasables_stores.promotable', true)); - } - - // Do this adjustment to the query once (was previously using `Event::once` but this caused issues in some edge cases) - $purchasableQuery->on(ElementQuery::EVENT_AFTER_PREPARE, [$this, 'afterPreparePurchasableQuery'], ['siteIds' => $siteIds]); - $this->_purchasableIds = $purchasableQuery->ids(); - $purchasableQuery->off(ElementQuery::EVENT_AFTER_PREPARE, [$this, 'afterPreparePurchasableQuery']); - } - - $this->_purchasableIds = $this->_purchasableIds !== null ? array_unique($this->_purchasableIds) : null; - } - - return $this->_purchasableIds; - } - - /** - * @param CancelableEvent $event - * @return void - * @since 5.5.1 - */ - public function afterPreparePurchasableQuery(CancelableEvent $event): void - { - foreach ($event->sender->subQuery->where as &$value) { - if (is_array($value) && isset($value['elements_sites.siteId'])) { - $value['elements_sites.siteId'] = $event->data['siteIds']; - } - } - - $event->sender->subQuery->join[] = ['LEFT JOIN', ['sitestores' => Table::SITESTORES], '[[elements_sites.siteId]] = [[sitestores.siteId]]']; - $event->sender->subQuery->join[] = ['LEFT JOIN', ['purchasables_stores' => Table::PURCHASABLES_STORES], '[[purchasables_stores.storeId]] = [[sitestores.storeId]] AND [[purchasables_stores.purchasableId]] = [[elements.id]]']; - } - - /** - * @return ElementConditionInterface - */ - public function getCustomerCondition(): ElementConditionInterface - { - $condition = $this->_customerCondition ?? new CatalogPricingRuleCustomerCondition(); - $condition->mainTag = 'div'; - $condition->name = 'customerCondition'; - - return $condition; - } - - /** - * @param ElementConditionInterface|string|array $condition - * @return void - * @throws InvalidConfigException - */ - public function setCustomerCondition(ElementConditionInterface|string|array $condition): void - { - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof ElementConditionInterface) { - $condition['class'] = CatalogPricingRuleCustomerCondition::class; - $condition = Craft::$app->getConditions()->createCondition($condition); - /** @var CatalogPricingRuleCustomerCondition $condition */ - } - $condition->forProjectConfig = false; - - $this->_customerCondition = $condition; - } - - /** - * @return ElementConditionInterface - */ - public function getPurchasableCondition(): ElementConditionInterface - { - $condition = $this->_purchasableCondition ?? new CatalogPricingRulePurchasableCondition(); - $condition->mainTag = 'div'; - $condition->name = 'purchasableCondition'; - - return $condition; - } - - /** - * @param ElementConditionInterface|string|array $condition - * @return void - * @throws InvalidConfigException - */ - public function setPurchasableCondition(ElementConditionInterface|string|array $condition): void - { - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof ElementConditionInterface) { - $condition['class'] = CatalogPricingRulePurchasableCondition::class; - $condition = Craft::$app->getConditions()->createCondition($condition); - /** @var CatalogPricingRulePurchasableCondition $condition */ - } - $condition->forProjectConfig = false; - - $this->_purchasableCondition = $condition; - } - - /** - * @return ElementConditionInterface - */ - public function getProductCondition(): ElementConditionInterface - { - $condition = $this->_productCondition ?? new CatalogPricingRuleProductCondition(); - $condition->mainTag = 'div'; - $condition->name = 'productCondition'; - $condition->elementType = Product::class; - - return $condition; - } - - /** - * @param ElementConditionInterface|string|array $condition - * @return void - * @throws InvalidConfigException - */ - public function setProductCondition(ElementConditionInterface|string|array $condition): void - { - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof ElementConditionInterface) { - $condition['class'] = CatalogPricingRuleProductCondition::class; - $condition = Craft::$app->getConditions()->createCondition($condition); - /** @var CatalogPricingRuleProductCondition $condition */ - } - $condition->forProjectConfig = false; - - $this->_productCondition = $condition; - } - - /** - * @return ElementConditionInterface - */ - public function getVariantCondition(): ElementConditionInterface - { - $condition = $this->_variantCondition ?? new CatalogPricingRuleVariantCondition(); - $condition->mainTag = 'div'; - $condition->name = 'variantCondition'; - $condition->elementType = Variant::class; - - return $condition; - } - - /** - * @param ElementConditionInterface|string|array $condition - * @return void - * @throws InvalidConfigException - */ - public function setVariantCondition(ElementConditionInterface|string|array $condition): void - { - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof ElementConditionInterface) { - $condition['class'] = CatalogPricingRuleVariantCondition::class; - $condition = Craft::$app->getConditions()->createCondition($condition); - /** @var CatalogPricingRuleVariantCondition $condition */ - } - $condition->forProjectConfig = false; - - $this->_variantCondition = $condition; - } - - /** - * @return int[]|null - */ - public function getUserIds(): ?array - { - if ($this->_userIds === null && !empty($this->getCustomerCondition()->getConditionRules())) { - $userQuery = User::find(); - $this->getCustomerCondition()->modifyQuery($userQuery); - $this->_userIds = $userQuery->ids(); - } - - return $this->_userIds; - } - - /** - * @param float $price - * @return float - */ - public function getRulePriceFromPrice(float $price): float - { - $price = match ($this->apply) { - PricingCatalogRuleRecord::APPLY_BY_PERCENT => $price * (1 + $this->applyAmount), - PricingCatalogRuleRecord::APPLY_BY_FLAT => $price + $this->applyAmount, - PricingCatalogRuleRecord::APPLY_TO_PERCENT => $price * -$this->applyAmount, - PricingCatalogRuleRecord::APPLY_TO_FLAT => -$this->applyAmount, - default => $price, - }; - - $price = (float)Plugin::getInstance()->getCurrencies()->getTeller($this->getStore()->getCurrency())->convertToString($price); - - return max($price, 0); - } -} diff --git a/src/models/Coupon.php b/src/models/Coupon.php deleted file mode 100644 index 2831dd4da4..0000000000 --- a/src/models/Coupon.php +++ /dev/null @@ -1,59 +0,0 @@ - - * @since 4.0 - */ -class Coupon extends Model -{ - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var int|null Discount ID - */ - public ?int $discountId = null; - - /** - * @var string|null The coupon code - */ - public ?string $code = null; - - /** - * @var int Number of times the coupon has been used - */ - public int $uses = 0; - - /** - * @var int|null Number of times the coupon has been used - */ - public ?int $maxUses = null; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['id', 'code', 'discountId', 'uses', 'maxUses'], 'safe']; - $rules[] = [['code'], 'required']; - $rules[] = [['code'], UniqueValidator::class, 'targetClass' => CouponRecord::class]; - - return $rules; - } -} diff --git a/src/models/Discount.php b/src/models/Discount.php deleted file mode 100644 index 1900cd86f9..0000000000 --- a/src/models/Discount.php +++ /dev/null @@ -1,739 +0,0 @@ - - * @since 2.0 - */ -class Discount extends Model implements HasStoreInterface -{ - use StoreTrait; - - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string Name of the discount - */ - public string $name = ''; - - /** - * @var string|null The description of this discount - */ - public ?string $description = null; - - /** - * @var string Format coupons should be generated with - * @since 4.0 - */ - public string $couponFormat = Coupons::DEFAULT_COUPON_FORMAT; - - /** - * @var ElementConditionInterface|null - * @see getOrderCondition() - * @see setOrderCondition() - */ - public null|ElementConditionInterface $_orderCondition = null; - - /** - * @var ElementConditionInterface|null - * @see getCustomerCondition() - * @see setCustomerCondition() - */ - public null|ElementConditionInterface $_customerCondition = null; - - /** - * @var ElementConditionInterface|null - * @see getShippingAddressCondition() - * @see setShippingAddressCondition() - */ - public null|ElementConditionInterface $_shippingAddressCondition = null; - - /** - * @var ElementConditionInterface|null - * @see getBillingAddressCondition() - * @see setBillingAddressCondition() - */ - public null|ElementConditionInterface $_billingAddressCondition = null; - - /** - * @var bool Requires a coupon code to be applied - * @since 5.2.0 - */ - public bool $requireCouponCode = false; - - /** - * @var int Per user coupon use limit - */ - public int $perUserLimit = 0; - - /** - * @var int Per email coupon use limit - */ - public int $perEmailLimit = 0; - - /** - * @var int Total use limit by users - * @since 3.0 - */ - public int $totalDiscountUseLimit = 0; - - /** - * @var int Total use counter; - * @since 3.0 - */ - public int $totalDiscountUses = 0; - - /** - * @var DateTime|null Date the discount is valid from - */ - public ?DateTime $dateFrom = null; - - /** - * @var DateTime|null Date the discount is valid to - */ - public ?DateTime $dateTo = null; - - /** - * @var float Total minimum spend on matching items - */ - public float $purchaseTotal = 0; - - /** - * @var string|null Condition that must match to match the order, null or empty string means match all - */ - public ?string $orderConditionFormula = null; - - /** - * @var int Total minimum qty of matching items - */ - public int $purchaseQty = 0; - - /** - * @var int Total maximum spend on matching items - */ - public int $maxPurchaseQty = 0; - - /** - * @var float Base amount of discount - */ - public float $baseDiscount = 0; - - /** - * @var float Amount of discount per item - */ - public float $perItemDiscount = 0.0; - - /** - * @var float Percentage of amount discount per item - */ - public float $percentDiscount = 0.0; - - /** - * @var string Whether the discount is off the original price, or the already discount price. - */ - public string $percentageOffSubject = DiscountRecord::TYPE_DISCOUNTED_SALEPRICE; - - /** - * @var bool Exclude the “On Promotion” Purchasables - */ - public bool $excludeOnPromotion = false; - - /** - * @var bool Matching products have free shipping. - */ - public bool $hasFreeShippingForMatchingItems = false; - - /** - * @var bool The whole order has free shipping. - */ - public bool $hasFreeShippingForOrder = false; - - /** - * @var bool Match all products - */ - public bool $allPurchasables = false; - - /** - * @var bool Match all product types - * - * @todo Rename $allCategories to $allEntries in Commerce 6.0 - */ - public bool $allCategories = false; - - /** - * @var string Type of relationship between Categories and Products - * - * @todo Rename $categoryRelationshipType to $entryRelationshipType in Commerce 6.0 - */ - public string $categoryRelationshipType = DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH; - - /** - * @var bool Discount enabled? - */ - public bool $enabled = true; - - /** - * @var bool stopProcessing - */ - public bool $stopProcessing = false; - - /** - * @var int|null sortOrder - */ - public ?int $sortOrder = 999999; - - /** - * @var DateTime|null - */ - public ?DateTime $dateCreated = null; - - /** - * @var DateTime|null - */ - public ?DateTime $dateUpdated = null; - - /** - * @var bool Discount ignores sales - */ - public bool $ignorePromotions = true; - - /** - * @var string What the per item amount and per item percentage off amounts can apply to - */ - public string $appliedTo = DiscountRecord::APPLIED_TO_MATCHING_LINE_ITEMS; - - /** - * @var int[] Product Ids - */ - private array $_purchasableIds; - - /** - * @var int[] Product Type IDs - */ - private array $_categoryIds; - - /** - * @var Coupon[]|null - * @since 4.0 - */ - private ?array $_coupons = null; - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $fields = parent::extraFields(); - $fields[] = 'purchasableIds'; - $fields[] = 'categoryIds'; - $fields[] = 'percentDiscountAsPercent'; - - return $fields; - } - - public function getCpEditUrl(): string - { - return $this->getStore()->getStoreSettingsUrl('discounts/' . $this->id); - } - - /** - * @param bool $exclude - * @return void - * @since 5.0.0 - * @deprecated in 5.0.0. Use `$excludeOnPromotion` instead. - */ - public function setExcludeOnSale(bool $exclude): void - { - Craft::$app->getDeprecator()->log(__METHOD__, 'Discount::$excludeOnSale is deprecated. Use Discount::$excludeOnPromotion instead.'); - $this->excludeOnPromotion = $exclude; - } - - /** - * @return bool - * @since 5.0.0 - * @deprecated in 5.0.0. Use `$excludeOnPromotion` instead. - */ - public function getExcludeOnSale(): bool - { - Craft::$app->getDeprecator()->log(__METHOD__, 'Discount::$excludeOnSale is deprecated. Use Discount::$excludeOnPromotion instead.'); - return $this->excludeOnPromotion; - } - - /** - * @return ElementConditionInterface - */ - public function getOrderCondition(): ElementConditionInterface - { - /** @var DiscountOrderCondition $condition */ - $condition = $this->_orderCondition ?? new DiscountOrderCondition(); - $condition->mainTag = 'div'; - $condition->name = 'orderCondition'; - $condition->storeId = $this->storeId; - - return $condition; - } - - /** - * @return bool - * @since 4.3.0 - */ - public function hasOrderCondition(): bool - { - if ($this->_orderCondition === null) { - return false; - } - - return !empty($this->getOrderCondition()->getConditionRules()); - } - - /** - * @param ElementConditionInterface|string|array|null $condition - * @return void - * @throws InvalidConfigException - */ - public function setOrderCondition(ElementConditionInterface|string|array|null $condition): void - { - if (empty($condition)) { - $this->_orderCondition = null; - return; - } - - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof ElementConditionInterface) { - $condition['class'] = DiscountOrderCondition::class; - /** @var DiscountOrderCondition $condition */ - $condition = Craft::$app->getConditions()->createCondition($condition); - } - $condition->forProjectConfig = false; - - $this->_orderCondition = $condition; - } - - /** - * @return ElementConditionInterface - */ - public function getCustomerCondition(): ElementConditionInterface - { - $condition = $this->_customerCondition ?? new DiscountCustomerCondition(); - $condition->mainTag = 'div'; - $condition->name = 'customerCondition'; - - return $condition; - } - - /** - * @return bool - * @since 4.3.0 - */ - public function hasCustomerCondition(): bool - { - if ($this->_customerCondition === null) { - return false; - } - - return !empty($this->getCustomerCondition()->getConditionRules()); - } - - /** - * @param ElementConditionInterface|string|array|null $condition - * @return void - * @throws InvalidConfigException - */ - public function setCustomerCondition(ElementConditionInterface|string|array|null $condition): void - { - if (empty($condition)) { - $this->_customerCondition = null; - return; - } - - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof ElementConditionInterface) { - $condition['class'] = DiscountCustomerCondition::class; - /** @var DiscountCustomerCondition $condition */ - $condition = Craft::$app->getConditions()->createCondition($condition); - } - $condition->forProjectConfig = false; - - $this->_customerCondition = $condition; - } - - /** - * @return ElementConditionInterface - */ - public function getShippingAddressCondition(): ElementConditionInterface - { - $condition = $this->_shippingAddressCondition ?? new DiscountAddressCondition(); - $condition->mainTag = 'div'; - $condition->id = 'shippingAddressCondition'; - $condition->name = 'shippingAddressCondition'; - - return $condition; - } - - /** - * @return bool - * @since 4.3.0 - */ - public function hasShippingAddressCondition(): bool - { - if ($this->_shippingAddressCondition === null) { - return false; - } - - return !empty($this->getShippingAddressCondition()->getConditionRules()); - } - - /** - * @param ElementConditionInterface|string|array|null $condition - * @return void - * @throws InvalidConfigException - */ - public function setShippingAddressCondition(ElementConditionInterface|string|array|null $condition): void - { - if (empty($condition)) { - $this->_shippingAddressCondition = null; - return; - } - - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof ElementConditionInterface) { - $condition['class'] = DiscountAddressCondition::class; - /** @var DiscountAddressCondition $condition */ - $condition = Craft::$app->getConditions()->createCondition($condition); - } - $condition->forProjectConfig = false; - - $this->_shippingAddressCondition = $condition; - } - - /** - * @return ElementConditionInterface - */ - public function getBillingAddressCondition(): ElementConditionInterface - { - $condition = $this->_billingAddressCondition ?? new DiscountAddressCondition(); - $condition->mainTag = 'div'; - $condition->id = 'billingAddressCondition'; - $condition->name = 'billingAddressCondition'; - - return $condition; - } - - /** - * @return bool - * @since 4.3.0 - */ - public function hasBillingAddressCondition(): bool - { - if ($this->_billingAddressCondition === null) { - return false; - } - - return !empty($this->getBillingAddressCondition()->getConditionRules()); - } - - /** - * @param ElementConditionInterface|string|array|null $condition - * @return void - * @throws InvalidConfigException - */ - public function setBillingAddressCondition(ElementConditionInterface|string|array|null $condition): void - { - if (empty($condition)) { - $this->_billingAddressCondition = null; - return; - } - - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof ElementConditionInterface) { - $condition['class'] = DiscountAddressCondition::class; - /** @var DiscountAddressCondition $condition */ - $condition = Craft::$app->getConditions()->createCondition($condition); - } - $condition->forProjectConfig = false; - - $this->_billingAddressCondition = $condition; - } - - /** - * @return int[] - */ - public function getCategoryIds(): array - { - if (!isset($this->_categoryIds)) { - $this->_loadCategoryRelations(); - } - - return $this->_categoryIds; - } - - /** - * @return int[] - */ - public function getPurchasableIds(): array - { - if (!isset($this->_purchasableIds)) { - $this->_loadPurchasableRelations(); - } - - return $this->_purchasableIds; - } - - /** - * Sets the related product type ids - * - * @param int[] $categoryIds - */ - public function setCategoryIds(array $categoryIds): void - { - $this->_categoryIds = array_unique($categoryIds); - } - - /** - * Sets the related product ids - * - * @param int[] $purchasableIds - */ - public function setPurchasableIds(array $purchasableIds): void - { - $this->_purchasableIds = array_unique($purchasableIds); - } - - /** - * @param bool $value - * @return void - */ - public function setHasFreeShippingForMatchingItems(bool $value): void - { - $this->hasFreeShippingForMatchingItems = $value; - } - - /** - * @return bool - */ - public function getHasFreeShippingForMatchingItems(): bool - { - return $this->hasFreeShippingForMatchingItems; - } - - /** - * @return array - * @throws InvalidConfigException - */ - public function getCoupons(): array - { - if ($this->_coupons === null && $this->id) { - $this->_coupons = Plugin::getInstance()->getCoupons()->getCouponsByDiscountId($this->id); - } - - return $this->_coupons ?? []; - } - - /** - * @param array $coupons - */ - public function setCoupons(array $coupons): void - { - $this->_coupons = $coupons; - } - - public function getPercentDiscountAsPercent(): string - { - return Craft::$app->getFormatter()->asPercent(-($this->percentDiscount ?? 0.0)); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['name', 'couponFormat'], 'required'], - [ - [ - 'perUserLimit', - 'perEmailLimit', - 'totalDiscountUseLimit', - 'totalDiscountUses', - 'purchaseQty', - 'maxPurchaseQty', - 'baseDiscount', - 'perItemDiscount', - 'percentDiscount', - ], 'number', 'skipOnEmpty' => false, - ], - [['coupons'], CouponsValidator::class, 'skipOnEmpty' => true], - [['couponFormat'], 'string', 'length' => [1, 20]], - [ - ['categoryRelationshipType'], - 'in', 'range' => [ - DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_SOURCE, - DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_TARGET, - DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH, - ], - ], - [ - ['appliedTo'], - 'in', - 'range' => [ - DiscountRecord::APPLIED_TO_MATCHING_LINE_ITEMS, - DiscountRecord::APPLIED_TO_ALL_LINE_ITEMS, - ], - ], - [ - 'hasFreeShippingForOrder', - function($attribute) { - if ($this->hasFreeShippingForMatchingItems && $this->hasFreeShippingForOrder) { - $this->addError($attribute, Craft::t('commerce', 'Free shipping can only be for whole order or matching items, not both.')); - } - }, - ], - [['orderConditionFormula'], 'string', 'length' => [1, 65000], 'skipOnEmpty' => true], - [ - 'orderConditionFormula', - function($attribute) { - if ($this->{$attribute}) { - $order = Order::find()->one(); - if (!$order) { - $order = new Order(); - } - - $fieldsAsArray = $order->getSerializedFieldValues(); - $orderAsArray = $order->toArray([], ['lineItems.snapshot', 'shippingAddress', 'billingAddress']); - $orderConditionParams = [ - 'order' => array_merge($orderAsArray, $fieldsAsArray), - ]; - - if (!Plugin::getInstance()->getFormulas()->validateConditionSyntax($this->{$attribute}, $orderConditionParams)) { - $this->addError($attribute, Craft::t('commerce', 'Invalid order condition syntax.')); - } - } - }, - ], - [[ - 'allCategories', - 'allPurchasables', - 'appliedTo', - 'baseDiscount', - 'baseDiscountType', - 'billingAddressCondition', - 'categoryIds', - 'categoryRelationshipType', - 'couponFormat', - 'customerCondition', - 'dateCreated', - 'dateFrom', - 'dateTo', - 'dateUpdated', - 'description', - 'enabled', - // @TODO Remove the legacy `excludeOnSale` field name in Commerce 6.0 (replaced by `excludeOnPromotion`) - 'excludeOnSale', - 'excludeOnPromotion', - 'hasFreeShippingForMatchingItems', - 'hasFreeShippingForOrder', - 'id', - 'ignoreSales', - 'maxPurchaseQty', - 'name', - 'orderCondition', - 'orderConditionFormula', - 'perEmailLimit', - 'perItemDiscount', - 'perUserLimit', - 'percentDiscount', - 'percentageOffSubject', - 'ignorePromotions', - 'purchasableIds', - 'purchaseQty', - 'purchaseTotal', - 'requireCouponCode', - 'shippingAddressCondition', - 'sortOrder', - 'stopProcessing', - 'storeId', - 'totalDiscountUseLimit', - 'totalDiscountUses', - ], 'safe'], - ]; - } - - /** - * Loads the related purchasable IDs into this discount - */ - private function _loadPurchasableRelations(): void - { - $purchasableIds = (new Query())->select(['dp.purchasableId']) - ->from(Table::DISCOUNTS . ' discounts') - ->leftJoin(Table::DISCOUNT_PURCHASABLES . ' dp', '[[dp.discountId]]=[[discounts.id]]') - ->where(['discounts.id' => $this->id]) - ->column(); - - $this->setPurchasableIds($purchasableIds); - } - - /** - * Loads the related category IDs into this discount - */ - private function _loadCategoryRelations(): void - { - $categoryIds = (new Query())->select(['dpt.categoryId']) - ->from(Table::DISCOUNTS . ' discounts') - ->leftJoin(Table::DISCOUNT_CATEGORIES . ' dpt', '[[dpt.discountId]]=[[discounts.id]]') - ->where(['discounts.id' => $this->id]) - ->column(); - - $this->setCategoryIds($categoryIds); - } -} diff --git a/src/models/Email.php b/src/models/Email.php deleted file mode 100644 index a13499a313..0000000000 --- a/src/models/Email.php +++ /dev/null @@ -1,420 +0,0 @@ - - * @since 2.0 - * - * @property-read string $pdfTemplatePath - * @property-read null|Pdf $pdf - * @property-read array $config - * @property ?string $bcc - * @property ?string $cc - * @property ?string $to - * @property string|null $senderAddress - */ -class Email extends Model implements HasStoreInterface -{ - use StoreTrait; - - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string|null Name - */ - public ?string $name = null; - - /** - * @var string|null Subject - */ - public ?string $subject = null; - - /** - * @var string Recipient Type - */ - public string $recipientType = EmailRecord::TYPE_CUSTOMER; - - /** - * @var string|null Reply to - */ - public ?string $replyTo = null; - - /** - * @var bool Is Enabled - */ - public bool $enabled = true; - - /** - * @var string|null Template path - */ - public ?string $templatePath = null; - - /** - * @var string|null Plain Text Template path - */ - public ?string $plainTextTemplatePath = null; - - /** - * @var int|null The PDF UID. - */ - public ?int $pdfId = null; - - /** - * @var string The language. - */ - public string $language = EmailRecord::LOCALE_ORDER_LANGUAGE; - - /** - * The site the email should be rendered in. Set to `null` to use the site the order was placed in. - * - * @var int|null - * @since 5.5.0 - */ - public ?int $renderSiteId = null; - - /** - * @var string|null - * @since 5.0.0 - * @see setSenderAddress() - * @see getSenderAddress() - */ - private ?string $_senderAddress = null; - - /** - * @var string|null - * @since 5.0.0 - * @see setSenderName() - * @see getSenderName() - */ - private ?string $_senderName = null; - - /** - * @var string|null - * @since 5.3.0 - * @see setBcc() - * @see getBcc() - */ - private ?string $_bcc = null; - - /** - * @var string|null - * @since 5.3.0 - * @see setBcc() - * @see getBcc() - */ - private ?string $_cc = null; - - /** - * @var string|null - * @since 5.3.0 - * @see setTo() - * @see getTo() - */ - private ?string $_to = null; - - /** - * @var string|null UID - */ - public ?string $uid = null; - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $fields = parent::extraFields(); - $fields[] = 'pdf'; - $fields[] = 'config'; - - return $fields; - } - - /** - * Determines the language this email is rendered in. - * - * @param Order|null $order - */ - public function getRenderLanguage(Order $order = null): string - { - $language = $this->language; - - if ($order == null && $language == EmailRecord::LOCALE_ORDER_LANGUAGE) { - throw new InvalidArgumentException('Can not get language for this email without providing an order'); - } - - if ($order && $language == EmailRecord::LOCALE_ORDER_LANGUAGE) { - $language = $order->orderLanguage; - } - - return $language; - } - - /** - * Determines the site this email is rendered in. - * - * @param Order|null $order - * @return Site - * @throws SiteNotFoundException - * @since 5.5.0 - */ - public function getRenderSite(Order $order = null): Site - { - $renderSiteId = $this->renderSiteId ?? $order?->orderSiteId; - - if ($renderSiteId !== null) { - return Craft::$app->getSites()->getSiteById($renderSiteId); - } - - return Craft::$app->getSites()->getPrimarySite(); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['subject', 'name', 'templatePath', 'language'], 'required'], - [['recipientType'], 'in', 'range' => [EmailRecord::TYPE_CUSTOMER, EmailRecord::TYPE_CUSTOM]], - [ - ['to'], - 'required', - 'when' => static fn($model) => $model->recipientType == EmailRecord::TYPE_CUSTOM, - ], - [ - [ - 'bcc', - 'cc', - 'enabled', - 'id', - 'language', - 'name', - 'pdfId', - 'plainTextTemplatePath', - 'recipientType', - 'renderSiteId', - 'replyTo', - 'senderAddress', - 'senderName', - 'storeId', - 'subject', - 'templatePath', - 'to', - 'uid', - ], - 'safe', - ], - ]; - } - - /** - * @throws InvalidConfigException - */ - public function getPdf(): ?Pdf - { - if (!$this->pdfId) { - return null; - } - return Plugin::getInstance()->getPdfs()->getPdfById($this->pdfId, $this->storeId); - } - - /** - * @param string|null $senderAddress - * @return void - * @since 5.0.0 - */ - public function setSenderAddress(?string $senderAddress): void - { - $this->_senderAddress = $senderAddress; - } - - /** - * @param bool $parse - * @return string|null Default email address Commerce system messages should be sent from. - * - * If `null` (default), Craft’s [MailSettings::$fromEmail](craft4:craft\models\MailSettings::$fromEmail) will be used. - * - * @since 5.0.0 - */ - public function getSenderAddress(bool $parse = true): ?string - { - if (!$parse) { - return $this->_senderAddress; - } - - if (!$senderAddress = App::parseEnv($this->_senderAddress)) { - $senderAddress = App::parseEnv(App::mailSettings()->fromEmail); - } - - return $senderAddress; - } - - /** - * @param string|null $bcc - * @return void - * @since 5.3.0 - */ - public function setBcc(?string $bcc): void - { - $this->_bcc = $bcc; - } - - /** - * @param bool $parse - * @return string|null Default bcc email address Commerce emails should be sent to. - * - * @since 5.3.0 - */ - public function getBcc(bool $parse = true): ?string - { - if (!$parse) { - return $this->_bcc; - } - - return App::parseEnv($this->_bcc); - } - - /** - * @param string|null $cc - * @return void - * @since 5.3.0 - */ - public function setCc(?string $cc): void - { - $this->_cc = $cc; - } - - /** - * @param bool $parse - * @return string|null Default cc email address Commerce emails should be sent to. - * - * @since 5.3.0 - */ - public function getCc(bool $parse = true): ?string - { - if (!$parse) { - return $this->_cc; - } - - return App::parseEnv($this->_cc); - } - - /** - * @param string|null $to - * @return void - * @since 5.3.0 - */ - public function setTo(?string $to): void - { - $this->_to = $to; - } - - /** - * @param bool $parse - * @return string|null Default to email address Commerce emails should be sent to. - * - * @since 5.3.0 - */ - public function getTo(bool $parse = true): ?string - { - if (!$parse) { - return $this->_to; - } - - return App::parseEnv($this->_to); - } - - /** - * @param string|null $senderName - * @return void - * @since 5.0.0 - */ - public function setSenderName(?string $senderName): void - { - $this->_senderName = $senderName; - } - - /** - * @param bool $parse - * @return string|null Placeholder value displayed for the sender name control panel settings field. - * - * If `null` (default), Craft’s [MailSettings::$fromName](craft4:craft\models\MailSettings::$fromName) will be used. - - * @since 5.0.0 - */ - public function getSenderName(bool $parse = true): ?string - { - if (!$parse) { - return $this->_senderName; - } - - if (!$senderName = App::parseEnv($this->_senderName)) { - $senderName = App::parseEnv(App::mailSettings()->fromName); - } - - return $senderName; - } - - /** - * Returns the field layout config for this email. - * - * @throws InvalidConfigException - * @since 3.2.0 - */ - public function getConfig(): array - { - return [ - 'bcc' => $this->getBcc(false) ?: null, - 'cc' => $this->getCc(false) ?: null, - 'senderAddress' => $this->getSenderAddress(false) ?: null, - 'senderName' => $this->getSenderName(false) ?: null, - 'enabled' => $this->enabled, - 'language' => $this->language, - 'name' => $this->name, - 'pdf' => $this->getPdf()?->uid, - 'plainTextTemplatePath' => $this->plainTextTemplatePath ?? null, - 'recipientType' => $this->recipientType, - 'renderSite' => $this->renderSiteId ? Craft::$app->getSites()->getSiteById($this->renderSiteId)?->uid ?? null : null, - 'replyTo' => $this->replyTo ?: null, - 'store' => $this->getStore()->uid, - 'subject' => $this->subject, - 'templatePath' => $this->templatePath ?: null, - 'to' => $this->getTo(false) ?: null, - ]; - } - - /** - * @return string - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function getCpEditUrl(): string - { - return UrlHelper::cpUrl('commerce/settings/emails/' . $this->getStore()->handle . '/' . $this->id); - } -} diff --git a/src/models/InventoryFulfillmentLevel.php b/src/models/InventoryFulfillmentLevel.php deleted file mode 100644 index fb3f68ac5c..0000000000 --- a/src/models/InventoryFulfillmentLevel.php +++ /dev/null @@ -1,84 +0,0 @@ -getInventory()->getInventoryItemById($this->inventoryItemId); - } - - /** - * @return InventoryLocation - */ - public function getInventoryLocation(): InventoryLocation - { - return Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($this->inventoryLocationId); - } - - public function getOrder(): Order - { - return Order::find()->id($this->getLineItem()->order)->status(null)->one(); - } - - public function getLineItem(): LineItem - { - if (!$this->lineItemId) { - throw new InvalidConfigException('InventoryFulfillmentLevel is not associated with a line item'); - } - - return Plugin::getInstance()->getLineItems()->getLineItemById($this->lineItemId); - } - - /** - * @return Purchasable - */ - public function getPurchasable(null|string|int $siteId = null): Purchasable - { - return $this->getInventoryItem()->getPurchasable($siteId); - } -} diff --git a/src/models/InventoryItem.php b/src/models/InventoryItem.php deleted file mode 100644 index 592393b132..0000000000 --- a/src/models/InventoryItem.php +++ /dev/null @@ -1,89 +0,0 @@ -_purchasable !== null) { - return $this->_purchasable; - } - - /** @phpstan-ignore-next-line */ - $this->_purchasable = Craft::$app->getElements()->getElementById(elementId: $this->purchasableId, siteId: $siteId); - - /** @phpstan-ignore-next-line */ - return $this->_purchasable; - } - - public function getSku(): string - { - return $this->getPurchasable('*')->sku; - } - - protected function defineRules(): array - { - return array_merge(parent::defineRules(), [ - // unique based on purchasableId - [['purchasableId'], 'unique', 'targetClass' => InventoryItem::class, 'targetAttribute' => ['purchasableId']], - [['sku'], 'unique', 'targetClass' => InventoryItem::class, 'targetAttribute' => ['sku']], - ]); - } -} diff --git a/src/models/InventoryLevel.php b/src/models/InventoryLevel.php deleted file mode 100644 index 708ecdac22..0000000000 --- a/src/models/InventoryLevel.php +++ /dev/null @@ -1,137 +0,0 @@ -{$type->value . 'Total'}; - } - - /** - * @return string - */ - public function getCpEditUrl(): string - { - return UrlHelper::cpUrl('commerce/inventory/levels'); - } - - /** - * @return InventoryItem - */ - public function getInventoryItem(): InventoryItem - { - if ($this->_inventoryItem === null) { - $this->_inventoryItem = Plugin::getInstance()->getInventory()->getInventoryItemById($this->inventoryItemId); - } - return $this->_inventoryItem; - } - - /** - * @param InventoryItem $inventoryItem - * @return void - */ - public function setInventoryItem(InventoryItem $inventoryItem): void - { - $this->_inventoryItem = $inventoryItem; - $this->inventoryItemId = $inventoryItem->id; - } - - /** - * @return InventoryLocation - */ - public function getInventoryLocation(): InventoryLocation - { - return Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($this->inventoryLocationId); - } - - /** - * @return Purchasable - */ - public function getPurchasable(null|string|int $siteId = null): Purchasable - { - return $this->getInventoryItem()->getPurchasable($siteId); - } -} diff --git a/src/models/InventoryLocation.php b/src/models/InventoryLocation.php deleted file mode 100644 index 26197ca304..0000000000 --- a/src/models/InventoryLocation.php +++ /dev/null @@ -1,214 +0,0 @@ - - * @since 5.0.0 - */ -class InventoryLocation extends Model implements Chippable, CpEditable, Actionable -{ - /** - * @var ?int - */ - public ?int $id = null; - - /** - * @var string - */ - public string $name = ''; - - /** - * @var string - */ - public string $handle = ''; - - /** - * @var DateTime|null - */ - public DateTime|null $dateCreated = null; - - /** - * @var DateTime|null - */ - public DateTime|null $dateUpdated = null; - - /** - * @var ?int - */ - public ?int $addressId = null; - - /** - * @var ?Address - */ - private ?Address $_address = null; - - /** - * @inheritdoc - */ - public static function get(int|string $id): ?static - { - /** @phpstan-ignore-next-line */ - return Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($id); - } - - /** - * @inheritdoc - */ - public function getUiLabel(): string - { - return Craft::t('site',$this->name); - } - - /** - * @return Address - */ - public function getAddress(): Address - { - if (!isset($this->_address)) { - if ($id = $this->addressId) { - /** @var Address $address */ - $address = Craft::$app->getElements()->getElementById($id); - $this->_address = $address; - } else { - $this->_address = new Address(); - $this->_address->countryCode = 'US'; - } - } - - $this->_address->title = $this->name; - - return $this->_address; - } - - /** - * @param Address $address - * @return void - */ - public function setAddress(Address $address): void - { - $this->setAddressId($address->id); - $this->_address = $address; - } - - /** - * @return string - */ - public function getAddressLine(): string - { - return $this->addressId ? ($this->getAddress()->addressLine1 . ' ' . $this->getAddress()->getCountryCode()) : ''; - } - - /** - * @param $id - * @return void - */ - public function setAddressId($id) - { - $this->addressId = $id; - } - - /** - * @return int|null - */ - public function getAddressId() - { - return $this->addressId; - } - - /** - * @return string - */ - public function getCpEditUrl(): string - { - return UrlHelper::cpUrl('commerce/inventory-locations/' . $this->id); - } - - /** - * @return string - */ - public function getCpManageInventoryUrl(): string - { - return UrlHelper::cpUrl('commerce/inventory/levels/' . $this->handle); - } - - /** - * @inheritdoc - */ - public function defineRules(): array - { - $rules = parent::defineRules(); - - $rules[] = [['name', 'handle'], 'required']; - $rules[] = [ - ['name'], - UniqueValidator::class, - 'targetClass' => InventoryLocationRecord::class, - 'targetAttribute' => 'name', - 'message' => Craft::t('yii', '{attribute} "{value}" has already been taken.'), - ]; - - $rules[] = [ - ['handle'], - UniqueValidator::class, - 'targetClass' => InventoryLocationRecord::class, - 'targetAttribute' => 'handle', - 'message' => Craft::t('yii', '{attribute} "{value}" has already been taken.'), - ]; - - $rules[] = [ - ['handle'], - HandleValidator::class, - 'reservedWords' => ['id', 'dateCreated', 'dateUpdated', 'uid', 'title', 'create'], - ]; - - return $rules; - } - - /** - * @inheritdoc - */ - public function getId(): string|int|null - { - return $this->id; - } - - /** - * @inerhitdoc - */ - public function getActionMenuItems(): array - { - $canManage = Craft::$app->getUser()->getIdentity()?->can('commerce-manageInventoryLocations') ?? false; - if (!$canManage) { - return []; - } - - return [ - [ - 'label' => Craft::t('commerce', 'Edit'), - 'url' => $this->getCpEditUrl(), - 'icon' => 'edit', - ], - ]; - } -} diff --git a/src/models/InventoryTransaction.php b/src/models/InventoryTransaction.php deleted file mode 100644 index cd8e293418..0000000000 --- a/src/models/InventoryTransaction.php +++ /dev/null @@ -1,152 +0,0 @@ -getInventory()->getInventoryItemById($this->inventoryItemId); - } - - /** - * @return InventoryLocation - */ - public function getInventoryLocation(): InventoryLocation - { - return Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($this->inventoryLocationId); - } - - /** - * @return Purchasable - */ - public function getPurchasable(): Purchasable - { - return $this->getInventoryItem()->getPurchasable(); - } - - /** - * @return ?Order - */ - public function getOrder(): ?Order - { - if (!$this->getLineItem()) { - return null; - } - - /** @var ?Order $order */ - $order = Order::find()->id($this->getLineItem()->orderId)->status(null)->one(); - - return $order; - } - - /** - * @return ?LineItem - */ - public function getLineItem(): ?LineItem - { - if ($this->lineItemId === null) { - return null; - } - - return Plugin::getInstance()->getLineItems()->getLineItemById($this->lineItemId); - } - - - /** - * @return ?Transfer - */ -// public function getTransfer(): ?Transfer -// { -// if (!$this->transferId) { -// return null; -// } -// -// /** @var ?Transfer $transfer */ -// $transfer = Transfer::find()->id($this->transferId)->status(null)->one(); -// -// return $transfer; -// } - - /** - * @return ?User - */ - public function getUser(): ?User - { - if (!$this->userId) { - return null; - } - - /** @var ?User $user */ - $user = User::find()->id($this->userId)->status(null)->one(); - - return $user; - } -} diff --git a/src/models/LineItem.php b/src/models/LineItem.php deleted file mode 100755 index 4506cbd657..0000000000 --- a/src/models/LineItem.php +++ /dev/null @@ -1,1197 +0,0 @@ - - * @since 2.0 - */ -class LineItem extends Model implements HasStoreInterface -{ - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var LineItemType - * @since 5.1.0 - */ - public LineItemType $type = LineItemType::Purchasable; - - /** - * @var string|null Description - */ - private ?string $_description = null; - - /** - * @var float Price is the original price of the purchasable - */ - private float $_price = 0; - - /** - * @var float|null - * @since 5.0.0 - */ - private ?float $_promotionalPrice = null; - - /** - * @var float|null Sale price is the price the line item will be sold for. - */ - private ?float $_salePrice = null; - - /** - * @var float Weight - */ - public float $weight = 0; - - /** - * @var float Length - */ - public float $length = 0; - - /** - * @var float Height - */ - public float $height = 0; - - /** - * @var float Width - */ - public float $width = 0; - - /** - * @var int Quantity - */ - public int $qty; - - /** - * @var array|null Snapshot - */ - private ?array $_snapshot = null; - - /** - * @var string SKU - */ - private ?string $_sku = null; - - /** - * @var string Note - */ - public string $note = ''; - - /** - * @var string Private Note - */ - public string $privateNote = ''; - - /** - * @var int|null Purchasable ID - */ - public ?int $purchasableId = null; - - /** - * @var int|null Order ID - */ - public ?int $orderId = null; - - /** - * @var int|null Line Item Status ID - */ - public ?int $lineItemStatusId = null; - - /** - * @var int|null Tax category ID - */ - public ?int $taxCategoryId = null; - - /** - * @var int|null Shipping category ID - */ - public ?int $shippingCategoryId = null; - - /** - * @var DateTime|null - * @since 2.2 - */ - public ?DateTime $dateCreated = null; - - /** - * @var DateTime|null - * @since 3.2.0 - */ - public ?DateTime $dateUpdated = null; - - /** - * @var string|null UID - */ - public ?string $uid = null; - - /** - * @var PurchasableInterface|null Purchasable - */ - private ?PurchasableInterface $_purchasable = null; - - /** - * @var Order|null - */ - private ?Order $_order = null; - - /** - * @var LineItemStatus|null Line item status - */ - private ?LineItemStatus $_lineItemStatus = null; - - /** - * @var array - */ - private array $_options = []; - - /** - * @var bool|null - * @see setIsPromotable() - * @see getIsPromotable() - * @since 5.1.0 - */ - private ?bool $_isPromotable = null; - - /** - * @var bool|null - * @see setHasFreeShipping() - * @see getHasFreeShipping() - * @since 5.1.0 - */ - private ?bool $_hasFreeShipping = null; - - /** - * @var bool|null - * @see setIsTaxable() - * @see getIsTaxable() - * @since 5.1.0 - */ - private ?bool $_isTaxable = null; - - /** - * @var bool|null - * @see setIsShippable() - * @see getIsShippable() - * @since 5.1.0 - */ - private ?bool $_isShippable = null; - - /** - * @inheritDoc - */ - public function init(): void - { - $this->note = LitEmoji::shortcodeToUnicode($this->note); - $this->privateNote = LitEmoji::shortcodeToUnicode($this->privateNote); - - parent::init(); - } - - /** - * @inheritDoc - */ - public function behaviors(): array - { - $behaviors = parent::behaviors(); - - $behaviors['currencyAttributes'] = [ - 'class' => CurrencyAttributeBehavior::class, - // We don’t want to get the currency from the order now, as this will cause additional order queries - // as the order is not set on the line item at the time of bahaviors attaching. - // Let’s let \craft\commerce\behaviors\CurrencyAttributeBehavior::getDefaultCurrency look it up at - // runtime when the order is likely already eager loaded. - 'defaultCurrency' => null, - 'currencyAttributes' => $this->currencyAttributes(), - ]; - - return $behaviors; - } - - /** - * @inheritdoc - * @throws StoreNotFoundException - */ - public function getStore(): Store - { - if (!$this->getOrder()) { - throw new StoreNotFoundException('Cannot determine line item store without an order assigned to the line item.'); - } - - return $this->getOrder()->getStore(); - } - - /** - * @throws InvalidConfigException - */ - public function getOrder(): ?Order - { - if (!isset($this->_order) && isset($this->orderId) && $this->orderId) { - $this->_order = Plugin::getInstance()->getOrders()->getOrderById($this->orderId); - } - - return $this->_order; - } - - /** - * @param Order $order - * @return void - */ - public function setOrder(Order $order): void - { - $this->orderId = $order->id; - $this->_order = $order; - } - - /** - * @throws InvalidConfigException - */ - public function getLineItemStatus(): ?LineItemStatus - { - if (!isset($this->_lineItemStatus) && isset($this->lineItemStatusId)) { - $lineItemStatus = Plugin::getInstance()->getLineItemStatuses(); - $this->_lineItemStatus = $lineItemStatus->getLineItemStatusById($this->lineItemStatusId, $this->getOrder()?->getStore()->id); - } - - return $this->_lineItemStatus; - } - - /** - * @param LineItemStatus|null $status - * @since 3.2.2 - */ - public function setLineItemStatus(LineItemStatus $status = null): void - { - if ($status !== null) { - $this->_lineItemStatus = $status; - $this->lineItemStatusId = (int)$status->id; - } else { - $this->lineItemStatusId = null; - $this->_lineItemStatus = null; - } - } - - /** - * Returns the options for the line item. - */ - public function getOptions(): array - { - return $this->_options; - } - - /** - * Set the options array on the line item. - */ - public function setOptions(array|string $options): void - { - $options = Json::decodeIfJson($options); - - if (!is_array($options)) { - $options = []; - } - - $cleanEmojiValues = static function(&$options) use (&$cleanEmojiValues) { - foreach ($options as $key => $value) { - if (is_array($value)) { - $cleanEmojiValues($value); - } else { - if (is_string($value)) { - $options[$key] = LitEmoji::unicodeToShortcode($value); - } - } - } - - return $options; - }; - - // @TODO Normalize emoji handling in options to a consistent shape across DB drivers (currently only stripped when MB4 is unsupported); breaking change targeted for Commerce 6.0 #COM-46 - if (Craft::$app->getDb()->getSupportsMb4()) { - $this->_options = $options; - } else { - $this->_options = $cleanEmojiValues($options); - } - } - - /** - * Returns the snapshot for the line item. - * - * @return array - * @since 5.0.0 - */ - public function getSnapshot(): array - { - return $this->_snapshot ?? []; - } - - /** - * Set the snapshot array on the line item. - * - * @param array|string $snapshot - * @return void - * @since 5.0.0 - */ - public function setSnapshot(array|string $snapshot): void - { - $snapshot = Json::decodeIfJson($snapshot); - - if (!is_array($snapshot)) { - $snapshot = []; - } - - $this->_snapshot = $snapshot; - } - - /** - * @return string - */ - public function getDescription(): string - { - if (!$this->_description) { - $snapshot = $this->getSnapshot(); - $this->_description = $snapshot['description'] ?? ''; - } - - return $this->_description; - } - - /** - * @param ?string $description - * @return void - */ - public function setDescription(?string $description): void - { - $this->_description = (string)$description; - } - - /** - * @return string - */ - public function getSku(): string - { - if ($this->_sku === null) { - $snapshot = $this->getSnapshot(); - $this->_sku = $snapshot['sku'] ?? ''; - } - - return $this->_sku ?? ''; - } - - /** - * @param ?string $sku - * @return void - */ - public function setSku(?string $sku): void - { - $this->_sku = (string)$sku; - } - - /** - * Returns a unique hash of the line item options - */ - public function getOptionsSignature(): string - { - $orderId = $this->getOrder()?->isCompleted ? $this->id : null; - - return LineItemHelper::generateOptionsSignature($this->_options, $orderId); - } - - /** - * @since 3.1.1 - */ - public function getPrice(): float - { - return CurrencyHelper::round($this->_price); - } - - /** - * @since 3.1.1 - */ - public function setPrice(float|int $price): void - { - $this->_price = $price; - // clear sale price cache - $this->_salePrice = null; - } - - /** - * @return float|null - * @since 5.0.0 - */ - public function getPromotionalPrice(): ?float - { - if ($this->_promotionalPrice === null) { - return null; - } - - return CurrencyHelper::round($this->_promotionalPrice); - } - - /** - * @param float|int|null $price - * @return void - * @since 5.0.0 - */ - public function setPromotionalPrice(float|int|null $price): void - { - $this->_promotionalPrice = $price; - // clear sale price cache - $this->_salePrice = null; - } - - /** - * @return float Sale Price - */ - public function getSalePrice(): float - { - if ($this->_salePrice === null) { - $this->_salePrice = $this->getOnPromotion() ? $this->getPromotionalPrice() : $this->getPrice(); - } - - return $this->_salePrice; - } - - /** - * @return float - * @throws DeprecationException - * @deprecated in 5.0.0. Use `getPromotionalAmount()` instead.) - */ - public function getSaleAmount(): float - { - Craft::$app->getDeprecator()->log(__METHOD__, 'LineItem `getSaleAmount()` method has been deprecated. Use `getPromotionalAmount()` instead.'); - return $this->getPromotionalAmount(); - } - - /** - * @return float - * @since 5.0.0 - */ - public function getPromotionalAmount(): float - { - if ($this->getPromotionalPrice() === null) { - return 0; - } - - return Currency::round($this->getPrice() - $this->getPromotionalPrice()); - } - - /** - * @inerhitdoc - */ - protected function defineRules(): array - { - $rules = [ - [ - [ - 'optionsSignature', - 'price', - 'promotionalAmount', - 'weight', - 'length', - 'height', - 'width', - 'qty', - 'taxCategoryId', - 'type', - 'shippingCategoryId', - ], 'required', - ], - [['snapshot'], 'required', 'when' => fn() => $this->type === LineItemType::Purchasable], - [['qty'], 'integer', 'min' => 1], - [['shippingCategoryId', 'taxCategoryId'], 'integer'], - [['price'], 'number', 'min' => 0], - [['promotionalPrice'], 'number', 'min' => 0, 'skipOnEmpty' => true], - [['orderId', 'purchasableId', 'hasFreeShipping', 'isPromotable', 'isShippable', 'isTaxable', 'type'], 'safe'], - ]; - - if ($this->type === LineItemType::Purchasable && $this->purchasableId) { - $order = $this->getOrder(); - /** @var PurchasableInterface|null $purchasable */ - $purchasable = Plugin::getInstance()->getPurchasables()->getPurchasableById($this->purchasableId, $order?->orderSiteId, $order?->getCustomer()?->id); - if ($purchasable && !empty($purchasableRules = $purchasable->getLineItemRules($this))) { - foreach ($purchasableRules as $rule) { - $rules[] = $this->_normalizePurchasableRule($rule, $purchasable); - } - } - } - - // @TODO Add a validation rule preventing qty from being reduced below the total fulfilled quantity across inventory locations when the order is complete - - return $rules; - } - - /** - * @return int - * @throws DeprecationException - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function getFulfilledTotalQuantity(): int - { - if ($order = $this->getOrder()) { - return Plugin::getInstance()->getInventory()->getInventoryFulfillmentLevels($order) - ->filter(fn($fulfillment) => $fulfillment->getLineItem()->id === $this->id) - ->sum('fulfilledQuantity'); - } - - return 0; - } - - /** - * Normalizes a purchasable’s validation rule. - * - * @param PurchasableInterface $purchasable - * @return mixed - */ - private function _normalizePurchasableRule(mixed $rule, PurchasableInterface $purchasable): mixed - { - if (isset($rule[1]) && $rule[1] instanceof Closure) { - $method = $rule[1]; - $method = $method->bindTo($purchasable); - $rule[1] = static function($attribute, $params, $validator, $current) use ($method) { - $method($attribute, $params, $validator, $current); - }; - } - - return $rule; - } - - /** - * @inheritdoc - */ - public function attributes(): array - { - $names = parent::attributes(); - ArrayHelper::removeValue($names, 'snapshot'); - - $names[] = 'type'; - $names[] = 'adjustments'; - $names[] = 'description'; - $names[] = 'hasFreeShipping'; - $names[] = 'isPromotable'; - $names[] = 'isShippable'; - $names[] = 'isTaxable'; - $names[] = 'options'; - $names[] = 'optionsSignature'; - $names[] = 'onPromotion'; - $names[] = 'price'; - $names[] = 'promotionalPrice'; - $names[] = 'salePrice'; - $names[] = 'sku'; - $names[] = 'total'; - - return $names; - } - - /** - * @inheritDoc - */ - public function fields(): array - { - $fields = parent::fields(); // get the currency and date fields formatted - $fields['subtotal'] = 'subtotal'; - - return $fields; - } - - /** - * @inheritdoc - */ - public function extraFields(): array - { - return array_values(array_filter([ - 'lineItemStatus', - 'order', - $this->type === LineItemType::Purchasable ? 'purchasable' : null, - 'shippingCategory', - 'snapshot', - 'taxCategory', - 'fulfilledTotalQuantity', - ], fn($value) => $value !== null)); - } - - /** - * The attributes on the order that should be made available as formatted currency. - */ - public function currencyAttributes(): array - { - $attributes = []; - $attributes[] = 'price'; - $attributes[] = 'promotionalPrice'; - $attributes[] = 'promotionalAmount'; - $attributes[] = 'salePrice'; - $attributes[] = 'subtotal'; - $attributes[] = 'total'; - $attributes[] = 'discount'; - $attributes[] = 'shippingCost'; - $attributes[] = 'tax'; - $attributes[] = 'taxIncluded'; - $attributes[] = 'adjustmentsTotal'; - - return $attributes; - } - - public function getSubtotal(): float - { - // Even though we validate salePrice as numeric, we still need to - // stop any exceptions from occurring when displaying subtotal on an order/lineitems with errors. - if (!is_numeric($this->salePrice)) { - $salePrice = 0; - } else { - $salePrice = $this->salePrice; - } - - return CurrencyHelper::round($this->qty * $salePrice); - } - - /** - * Returns the Purchasable’s sale price multiplied by the quantity of the line item, plus any adjustment belonging to this lineitem. - * - * @throws InvalidConfigException - */ - public function getTotal(): float - { - return (float)$this->order->getTeller()->add($this->getSubtotal(), $this->getAdjustmentsTotal()); - } - - /** - * @param string $taxable - * @return float - * @throws InvalidConfigException - */ - public function getTaxableSubtotal(string $taxable): float - { - return match ($taxable) { - TaxRateRecord::TAXABLE_SHIPPING => $this->getShippingCost(), - TaxRateRecord::TAXABLE_PRICE_SHIPPING => (float)$this->order->getTeller()->sum($this->getSubtotal(), $this->getDiscount() , $this->getShippingCost()), - default => (float)$this->order->getTeller()->add($this->getSubtotal() , $this->getDiscount()), // TaxRateRecord::TAXABLE_PRICE is default - }; - } - - /** - * @return bool - * @throws InvalidConfigException - * @throws SiteNotFoundException - * @throws Exception - * @since 5.1.0 - */ - public function refresh(): bool - { - if ($this->type === LineItemType::Custom) { - return true; - } - - return $this->_refreshFromPurchasable(); - } - - /** - * @return bool False when no related purchasable exists - * @throws DeprecationException - * @throws Exception - * @throws InvalidConfigException - * @throws SiteNotFoundException - * @deprecated in 5.1.0. Use `refresh()` instead. - */ - public function refreshFromPurchasable(): bool - { - Craft::$app->getDeprecator()->log(__METHOD__, '`LineItem::refreshFromPurchasable()` has been deprecated. Use `LineItem::refresh()` instead.'); - - if ($this->type === LineItemType::Custom) { - Craft::warning('Cannot refresh a custom line item from a purchasable', 'commerce'); - return true; - } - - return $this->_refreshFromPurchasable(); - } - - /** - * @return bool False when no related purchasable exists - * @throws Exception - * @throws InvalidConfigException - * @throws SiteNotFoundException - */ - private function _refreshFromPurchasable(): bool - { - if ($this->type === LineItemType::Custom) { - throw new Exception('Cannot refresh a custom line item from a purchasable'); - } - - if ($this->qty <= 0 && $this->id) { - return false; - } - - /* @var $purchasable Purchasable */ - $purchasable = $this->getPurchasable(); - if (!$purchasable || !Plugin::getInstance()->getPurchasables()->isPurchasableAvailable($purchasable, $this->getOrder())) { - return false; - } - - $this->_populateFromPurchasable($purchasable); - - return true; - } - - /** - * @param bool|null $hasFreeShipping - * @return void - * @since 5.1.0 - */ - public function setHasFreeShipping(?bool $hasFreeShipping): void - { - $this->_hasFreeShipping = $hasFreeShipping; - } - - /** - * @return bool - * @throws InvalidConfigException - * @throws SiteNotFoundException - * @since 5.1.0 - */ - public function getHasFreeShipping(): bool - { - // For purchasable line item types try and get the live data - if ($this->type === LineItemType::Purchasable && $this->getPurchasable()) { - return $this->getPurchasable()->hasFreeShipping(); - } - - return $this->_hasFreeShipping ?? false; - } - - /** - * @return PurchasableInterface|null - * @throws InvalidConfigException - * @throws SiteNotFoundException - */ - public function getPurchasable(): ?PurchasableInterface - { - if ($this->type === LineItemType::Custom) { - throw new InvalidConfigException('Cannot get a purchasable for a custom line item'); - } - - if (!isset($this->_purchasable) && isset($this->purchasableId)) { - $order = $this->getOrder(); - /** @var PurchasableInterface|null $purchasable */ - $purchasable = Plugin::getInstance()->getPurchasables()->getPurchasableById($this->purchasableId, $order?->orderSiteId, $order?->getCustomer()?->id); - - // If we are still using sales we need to make sure that the promotional price is set. - if (!Plugin::getInstance()->getCatalogPricingRules()->canUseCatalogPricingRules()) { - if ($purchasable instanceof Purchasable) { - $purchasable->loadSales($this->getOrder()); - } - } - - $this->_purchasable = $purchasable; - } - - return $this->_purchasable; - } - - /** - * @param PurchasableInterface $purchasable - * @return void - * @throws InvalidConfigException - */ - public function setPurchasable(PurchasableInterface $purchasable): void - { - $this->purchasableId = $purchasable->getId(); - $this->_purchasable = $purchasable; - $this->type = LineItemType::Purchasable; - } - - /** - * @param mixed|null $data - * @return void - * @throws InvalidConfigException - * @since 5.1.0 - */ - public function populate(mixed $data = null): void - { - if ($this->type === LineItemType::Custom) { - return; - } - - if ($data) { - $this->_populateFromPurchasable($data); - } - } - - /** - * @param PurchasableInterface $purchasable - * @return void - * @throws InvalidConfigException - * @deprecated in 5.0.0. Use `populate()` instead. - */ - public function populateFromPurchasable(PurchasableInterface $purchasable): void - { - Craft::$app->getDeprecator()->log(__METHOD__, '`LineItem::populateFromPurchasable()` has been deprecated. Use `LineItem::populate()` instead.'); - - if ($this->type === LineItemType::Custom) { - // @TODO Throw an exception instead of logging a warning when populating a custom line item from a purchasable, in Commerce 6.0 - Craft::warning('Cannot populate a custom line item from a purchasable', 'commerce'); - return; - } - - $this->_populateFromPurchasable($purchasable); - } - - /** - * @param PurchasableInterface $purchasable - * @throws Exception - * @throws InvalidConfigException - */ - private function _populateFromPurchasable(PurchasableInterface $purchasable): void - { - if ($this->type === LineItemType::Custom) { - throw new Exception('Cannot populate a custom line item from a purchasable'); - } - - // Set all things from the purchasable interface that are applicable to the line item. - $this->purchasableId = $purchasable->getId(); - $this->setPrice($purchasable->getPrice()); - $this->setPromotionalPrice($purchasable->getPromotionalPrice()); - $this->taxCategoryId = $purchasable->getTaxCategory()->id; - $this->shippingCategoryId = $purchasable->getShippingCategory()->id; - $this->setSku($purchasable->getSku()); - $this->setDescription($purchasable->getDescription()); - - // Check to see if there is a discount applied that ignores promotions for this line item - $ignorePromotions = false; - foreach (Plugin::getInstance()->getDiscounts()->getAllActiveDiscounts($this->getOrder()) as $discount) { - if (Plugin::getInstance()->getDiscounts()->matchLineItem($this, $discount, true)) { - // Break if matched discount is set to ignore promotions. - $ignorePromotions = $discount->ignorePromotions; - if ($ignorePromotions) { - break; - } - - // Break if matched discount is set to not apply any subsequent discounts. - if ($discount->stopProcessing) { - break; - } - } - } - - // One of the matching discounts has ignored promotions, so we want to remove any promotional price. - if ($ignorePromotions) { - $this->setPromotionalPrice(null); - } - - $snapshot = [ - // @TODO Move these common snapshot fields (price, sku, description, purchasableId, cpEditUrl, options) into the base purchasable's getSnapshot() in Commerce 6.0 - 'price' => $purchasable->getPrice(), - 'sku' => $purchasable->getSku(), - 'description' => $purchasable->getDescription(), - 'purchasableId' => $purchasable->getId(), - 'cpEditUrl' => '#', - 'options' => $this->getOptions(), - // Only add sales information to the snapshot if we are not ignoring promotions and they are still using the sales system. - 'sales' => $ignorePromotions || Plugin::getInstance()->getCatalogPricingRules()->canUseCatalogPricingRules() ? [] : Plugin::getInstance()->getSales()->getSalesForPurchasable($purchasable, $this->order), - ]; - - // Add our purchasable data to the snapshot, save our sales. - $purchasableSnapshot = $purchasable->getSnapshot(); - $this->setSnapshot(array_merge($purchasableSnapshot, $snapshot)); - - $purchasable->populateLineItem($this); - - $lineItemsService = Plugin::getInstance()->getLineItems(); - - if ($lineItemsService->hasEventHandlers($lineItemsService::EVENT_POPULATE_LINE_ITEM)) { - $lineItemsService->trigger($lineItemsService::EVENT_POPULATE_LINE_ITEM, new LineItemEvent([ - 'lineItem' => $this, - 'isNew' => !$this->id, - ])); - } - } - - /** - * @param bool|null $isPromotable - * @return void - * @since 5.1.0 - */ - public function setIsPromotable(?bool $isPromotable): void - { - $this->_isPromotable = $isPromotable; - } - - /** - * @return bool - * @throws InvalidConfigException - * @throws SiteNotFoundException - * @since 5.1.0 - */ - public function getIsPromotable(): bool - { - // For purchasable line item types try and get the live data - if ($this->type === LineItemType::Purchasable && $this->getPurchasable()) { - return $this->getPurchasable()->getIsPromotable(); - } - - return $this->_isPromotable ?? false; - } - - /** - * @return bool - * @since 5.0.0 - */ - public function getOnPromotion(): bool - { - return $this->getPromotionalAmount() > 0; - } - - /** - * @return bool - * @throws DeprecationException - * @deprecated in 5.0.0. Use `getOnPromotion()` instead. - */ - public function getOnSale(): bool - { - Craft::$app->getDeprecator()->log(__METHOD__, 'LineItem `' . __METHOD__ . '()` method has been deprecated. Use `getOnPromotion()` instead.'); - return $this->getOnPromotion(); - } - - /** - * @throws InvalidConfigException - */ - public function getTaxCategory(): TaxCategory - { - // Category may have been archived - $categories = Plugin::getInstance()->getTaxCategories()->getAllTaxCategories(true); - return ArrayHelper::firstWhere($categories, 'id', $this->taxCategoryId); - } - - /** - * @return ShippingCategory - * @throws InvalidConfigException - * @throws StoreNotFoundException - */ - public function getShippingCategory(): ShippingCategory - { - if (!isset($this->shippingCategoryId)) { - throw new InvalidConfigException('Line Item is missing its shipping category ID'); - } - - // Category may have been archived - $categories = Plugin::getInstance()->getShippingCategories()->getAllShippingCategories(withTrashed: true); - return ArrayHelper::firstWhere($categories, 'id', $this->shippingCategoryId); - } - - /** - * @return OrderAdjustment[] - * @throws InvalidConfigException - */ - public function getAdjustments(): array - { - $lineItemAdjustments = []; - - $adjustments = $this->getOrder()->getAdjustments(); - - foreach ($adjustments as $adjustment) { - // Since the line item may not yet be saved and won't have an ID, we need to check the adjuster references this as it's line item. - if (($adjustment->lineItemId && $adjustment->lineItemId == $this->id) || (!$adjustment->lineItemId && $adjustment->getLineItem() === $this)) { - $lineItemAdjustments[] = $adjustment; - } - } - - return $lineItemAdjustments; - } - - /** - * @throws InvalidConfigException - */ - public function getAdjustmentsTotal(bool $included = false): float - { - $amount = 0; - $teller = $this->_getTeller(); - foreach ($this->getAdjustments() as $adjustment) { - if ($adjustment->included == $included) { - $amount = (float)$teller->add($amount, $adjustment->amount); - } - } - - return $amount; - } - - /** - * @throws InvalidConfigException - */ - private function _getAdjustmentsTotalByType(string $type, bool $included = false): float|int - { - $amount = 0; - $teller = $this->_getTeller(); - foreach ($this->getAdjustments() as $adjustment) { - if ($adjustment->included == $included && $adjustment->type === $type) { - $amount = (float)$teller->add($amount, $adjustment->amount); - } - } - - return $amount; - } - - /** - * @param bool|null $isTaxable - * @return void - * @since 5.1.0 - */ - public function setIsTaxable(?bool $isTaxable): void - { - $this->_isTaxable = $isTaxable; - } - - /** - * @since 3.3.4 - */ - public function getIsTaxable(): bool - { - if ($this->type === LineItemType::Custom) { - return $this->_isTaxable ?? false; - } - - if (!$this->getPurchasable()) { - return $this->_isTaxable ?? true; // we have a default tax category so assume so. - } - - return $this->getPurchasable()->getIsTaxable(); - } - - /** - * @param bool|null $isShippable - * @return void - * @since 5.1.0 - */ - public function setIsShippable(?bool $isShippable): void - { - $this->_isShippable = $isShippable; - } - - /** - * @since 3.4 - */ - public function getIsShippable(): bool - { - if ($this->type === LineItemType::Custom) { - return $this->_isShippable ?? false; - } - - if (!$this->getPurchasable()) { - return $this->_isShippable ?? true; // we have a default shipping category so assume so. - } - - return Plugin::getInstance()->getPurchasables()->isPurchasableShippable($this->getPurchasable(), $this->getOrder()); - } - - /** - * @throws InvalidConfigException - */ - public function getTax(): float - { - return $this->_getAdjustmentsTotalByType('tax'); - } - - /** - * @throws InvalidConfigException - */ - public function getTaxIncluded(): float - { - return $this->_getAdjustmentsTotalByType('tax', true); - } - - /** - * @throws InvalidConfigException - */ - public function getShippingCost(): float - { - return $this->_getAdjustmentsTotalByType('shipping'); - } - - /** - * @throws InvalidConfigException - */ - public function getDiscount(): float - { - return $this->_getAdjustmentsTotalByType('discount'); - } - - /** - * @return Teller - * @throws InvalidConfigException - */ - private function _getTeller(): Teller - { - if (!$order = $this->getOrder()) { - throw new InvalidConfigException('Line Item requires an order to calculate costs.'); - } - - return $order->getTeller(); - } -} diff --git a/src/models/LineItemStatus.php b/src/models/LineItemStatus.php deleted file mode 100644 index 24beef919d..0000000000 --- a/src/models/LineItemStatus.php +++ /dev/null @@ -1,198 +0,0 @@ - - * @since 2.0 - */ -class LineItemStatus extends Model implements HasStoreInterface, Chippable -{ - use StoreTrait; - - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string|null Name - */ - public ?string $name = null; - - /** - * @var string|null Handle - */ - public ?string $handle = null; - - /** - * @var string Color - */ - public string $color = 'green'; - - /** - * @var int|null Sort order - */ - public ?int $sortOrder = null; - - /** - * @var bool Default status - */ - public bool $default = false; - - /** - * @var bool Whether the order status is archived. - */ - public bool $isArchived = false; - - /** - * @var DateTime|null Archived Date - */ - public ?DateTime $dateArchived = null; - - /** - * @var string|null UID - */ - public ?string $uid = null; - - /** - * @return string - */ - public function __toString() - { - return $this->getUiLabel(); - } - - public function getUiLabel(): string - { - return Craft::t('site', $this->name ?? ''); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['name', 'handle'], 'required'], - [['handle'], - UniqueValidator::class, - 'targetClass' => LineItemStatusRecord::class, - 'targetAttribute' => ['handle', 'storeId'], - 'filter' => ['isArchived' => false], - 'message' => '{attribute} "{value}" has already been taken.', - ], - [ - ['handle'], - HandleValidator::class, - 'reservedWords' => ['id', 'dateCreated', 'dateUpdated', 'uid', 'title', 'create'], - ], - [[ - 'id', - 'storeId', - 'name', - 'handle', - 'color', - 'sortOrder', - 'default', - 'isArchived', - 'dateArchived', - 'uid', - ], 'safe'], - ]; - } - - /** - * @inerhitdoc - */ - public function extraFields(): array - { - $fields = parent::extraFields(); - $fields[] = 'labelHtml'; - $fields[] = 'uiLabel'; - - return $fields; - } - - public function getCpEditUrl(): string - { - return UrlHelper::cpUrl('commerce/settings/lineitemstatuses/' . $this->getStore()->handle . '/' . $this->id); - } - - /** - * @return string - */ - public function getLabelHtml(): string - { - return Cp::statusLabelHtml([ - 'label' => Html::encode($this->getUiLabel()), - 'color' => Html::encode($this->color), - ]); - } - - /** - * Returns the config for this status. - * - * @since 3.2.2 - */ - public function getConfig(): array - { - return [ - 'store' => $this->getStore()->uid, - 'name' => $this->name, - 'handle' => $this->handle, - 'color' => $this->color, - 'sortOrder' => $this->sortOrder ?: 9999, - 'default' => $this->default, - ]; - } - - /** - * @inheritdoc - */ - public static function get(int|string $id): ?static - { - /** @var Site|StoreBehavior|null $site */ - $site = Cp::requestedSite(); - $storeId = $site?->getStore()->id ?? null; - - /** @phpstan-ignore-next-line */ - return Plugin::getInstance()->getLineItemStatuses()->getLineItemStatusById($id, $storeId); - } - - /** - * @inheritdoc - */ - public function getId(): string|int|null - { - return $this->id; - } -} diff --git a/src/models/OrderAdjustment.php b/src/models/OrderAdjustment.php deleted file mode 100644 index 61292b1f45..0000000000 --- a/src/models/OrderAdjustment.php +++ /dev/null @@ -1,207 +0,0 @@ - - * @since 2.0 - */ -class OrderAdjustment extends Model -{ - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string Name - */ - public string $name; - - /** - * @var string|null Description - */ - public ?string $description = null; - - /** - * @var string Type - */ - public string $type; - - /** - * @var float Amount - */ - public float $amount; - - /** - * @var bool Included - */ - public bool $included = false; - - /** - * @var mixed Adjuster options - */ - private mixed $_sourceSnapshot = []; - - /** - * @var int|null Order ID - */ - public ?int $orderId = null; - - /** - * @var int|null Line item ID this adjustment belongs to - */ - public ?int $lineItemId = null; - - /** - * @var bool Whether the adjustment is based of estimated data - */ - public bool $isEstimated = false; - - /** - * @var LineItem|null The line item this adjustment belongs to - */ - private ?LineItem $_lineItem = null; - - /** - * @var Order|null The order this adjustment belongs to - */ - private ?Order $_order = null; - - - public function behaviors(): array - { - $behaviors = parent::behaviors(); - - $behaviors['currencyAttributes'] = [ - 'class' => CurrencyAttributeBehavior::class, - 'defaultCurrency' => $this->getCurrency(), - 'currencyAttributes' => $this->currencyAttributes(), - ]; - - return $behaviors; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['type', 'amount', 'sourceSnapshot', 'orderId'], 'required'], - [['amount'], 'number'], - [['orderId'], 'integer'], - [['lineItemId'], 'integer'], - ]; - } - - /** - * @inheritdoc - */ - public function attributes(): array - { - $attributes = parent::attributes(); - $attributes[] = 'sourceSnapshot'; - - return $attributes; - } - - /** - * The attributes on the order that should be made available as formatted currency. - */ - public function currencyAttributes(): array - { - $attributes = []; - $attributes[] = 'amount'; - return $attributes; - } - - /** - * @return ?string - * @throws InvalidConfigException - */ - protected function getCurrency(): ?string - { - return $this->getOrder()?->currency; - } - - /** - * Gets the options for the line item. - */ - public function getSourceSnapshot(): array - { - return $this->_sourceSnapshot; - } - - /** - * Set the options array on the line item. - */ - public function setSourceSnapshot(array|string $snapshot): void - { - if (is_string($snapshot)) { - $snapshot = Json::decode($snapshot); - } - - if (!is_array($snapshot)) { - throw new InvalidArgumentException('Adjustment source snapshot must be an array.'); - } - - $this->_sourceSnapshot = $snapshot; - } - - /** - * @throws InvalidConfigException - */ - public function getLineItem(): ?LineItem - { - if ($this->_lineItem === null && isset($this->lineItemId) && $this->lineItemId) { - $this->_lineItem = Plugin::getInstance()->getLineItems()->getLineItemById($this->lineItemId); - } - - return $this->_lineItem; - } - - public function setLineItem(LineItem $lineItem): void - { - $this->_lineItem = $lineItem; - } - - /** - * @throws InvalidConfigException - */ - public function getOrder(): ?Order - { - if (!isset($this->_order) && isset($this->orderId) && $this->orderId) { - $this->_order = Plugin::getInstance()->getOrders()->getOrderById($this->orderId); - } - - return $this->_order; - } - - public function setOrder(Order $order): void - { - $this->_order = $order; - $this->orderId = $order->id; - } -} diff --git a/src/models/OrderHistory.php b/src/models/OrderHistory.php deleted file mode 100644 index 1b793bcd4f..0000000000 --- a/src/models/OrderHistory.php +++ /dev/null @@ -1,133 +0,0 @@ - - * @since 2.0 - */ -class OrderHistory extends Model -{ - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string|null Message - */ - public ?string $message = null; - - /** - * @var int Order ID - */ - public int $orderId; - - /** - * @var int|null Previous Status ID - */ - public ?int $prevStatusId = null; - - /** - * @var int|null New status ID - */ - public ?int $newStatusId = null; - - /** - * @var int|null User ID - */ - public ?int $userId = null; - - /** - * @var string|null User name or email - */ - public ?string $userName = ''; - - /** - * @var Datetime|null - */ - public ?DateTime $dateCreated = null; - - /** - * @var Order|null - */ - private ?Order $_order = null; - - /** - * @throws InvalidConfigException - */ - public function getOrder(): ?Order - { - if ($this->_order === null) { - $this->_order = Plugin::getInstance()->getOrders()->getOrderById($this->orderId); - } - - return $this->_order; - } - - public function setOrder(Order $order): void - { - $this->_order = $order; - $this->orderId = $order->id; - } - - /** - * @throws InvalidConfigException - */ - public function getPrevStatus(): ?OrderStatus - { - $orderStatuses = Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses($this->getOrder()?->storeId); - return ArrayHelper::firstWhere($orderStatuses, 'id', $this->prevStatusId); - } - - /** - * @throws InvalidConfigException - */ - public function getNewStatus(): ?OrderStatus - { - $orderStatuses = Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses($this->getOrder()?->storeId); - return ArrayHelper::firstWhere($orderStatuses, 'id', $this->newStatusId); - } - - /** - * @return User|null - */ - public function getUser(): ?User - { - if ($this->userId === null) { - return null; - } - - return Craft::$app->getUsers()->getUserById($this->userId); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['orderId', 'userId'], 'required'], - ]; - } -} diff --git a/src/models/OrderNotice.php b/src/models/OrderNotice.php deleted file mode 100644 index caef89c43d..0000000000 --- a/src/models/OrderNotice.php +++ /dev/null @@ -1,112 +0,0 @@ - - * @since 3.3 - */ -class OrderNotice extends Model -{ - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string Type - */ - public string $type; - - /** - * @var string Attribute - */ - public string $attribute; - - /** - * @var string Message - */ - public string $message; - - /** - * @var int|null Order ID - */ - public ?int $orderId = null; - - /** - * @var OrderNoticeType Whether this notice is for customers or admins only. - * @since 5.7.0 - */ - private OrderNoticeType $_noticeType = OrderNoticeType::Customer; - - /** - * @var Order|null The order this notice belongs to - */ - private ?Order $_order = null; - - /** - * @return string - */ - public function __toString() - { - return $this->message ?: ''; - } - - public function getNoticeType(): OrderNoticeType - { - return $this->_noticeType; - } - - public function setNoticeType(string|OrderNoticeType $noticeType): void - { - $this->_noticeType = $noticeType instanceof OrderNoticeType - ? $noticeType - : OrderNoticeType::from($noticeType); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['id', 'noticeType'], 'safe'], - [['type', 'message', 'attribute', 'orderId'], 'required'], - [['orderId'], 'integer'], - ]; - } - - public function setOrder(Order $order): void - { - $this->_order = $order; - $this->orderId = $order->id; - } - - /** - * @throws InvalidConfigException - */ - public function getOrder(): ?Order - { - if (!isset($this->_order) && $this->orderId) { - $this->_order = Plugin::getInstance()->getOrders()->getOrderById($this->orderId); - } - - return $this->_order; - } -} diff --git a/src/models/OrderStatus.php b/src/models/OrderStatus.php deleted file mode 100644 index b3099c50ec..0000000000 --- a/src/models/OrderStatus.php +++ /dev/null @@ -1,247 +0,0 @@ - - * @since 2.0 - */ -class OrderStatus extends Model implements HasStoreInterface, Chippable -{ - use SoftDeleteTrait { - SoftDeleteTrait::behaviors as softDeleteBehaviors; - } - use StoreTrait; - - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string|null Name - */ - public ?string $name = null; - - /** - * @var string|null Handle - */ - public ?string $handle = null; - - /** - * @var string Color - */ - public string $color = 'green'; - - /** - * @var string|null Description - */ - public ?string $description = null; - - /** - * @var int|null Sort order - */ - public ?int $sortOrder = null; - - /** - * @var bool Default status - */ - public bool $default = false; - - /** - * @var DateTime|null Date deleted - */ - public ?DateTime $dateDeleted = null; - - /** - * @var string|null UID - */ - public ?string $uid = null; - - public function behaviors(): array - { - return $this->softDeleteBehaviors(); - } - - /** - * @return string - */ - public function __toString() - { - return $this->getUiLabel(); - } - - /** - * @since 2.2 - * @deprecated in 5.6. Use [[getUiLabel()]] instead. - */ - public function getDisplayName(): string - { - return $this->getUiLabel(); - } - - /** - * @inheritdoc - */ - public function getUiLabel(): string - { - if ($this->dateDeleted !== null) { - return Craft::t('commerce', '{name} (Trashed)', ['name' => Craft::t('site', $this->name)]); - } - - return Craft::t('site', $this->name ?? ''); - } - - - protected function defineRules(): array - { - return [ - [['name', 'handle'], 'required'], - [['handle'], - UniqueValidator::class, - 'targetClass' => OrderStatusRecord::class, - 'targetAttribute' => ['handle', 'storeId'], - 'message' => '{attribute} "{value}" has already been taken.', - ], - [ - ['handle'], - HandleValidator::class, - 'reservedWords' => ['id', 'dateCreated', 'dateUpdated', 'uid', 'title', 'create'], - ], - [['id', 'color', 'description', 'default', 'sortOrder', 'dateDeleted', 'uid', 'storeId'], 'safe'], - ]; - } - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $fields = parent::extraFields(); - $fields[] = 'emails'; - $fields[] = 'emailIds'; - $fields[] = 'labelHtml'; - $fields[] = 'uiLabel'; - - return $fields; - } - - public function getCpEditUrl(): string - { - return UrlHelper::cpUrl('commerce/settings/orderstatuses/' . $this->getStore()->handle . '/' . $this->id); - } - - /** - * @throws InvalidConfigException - */ - public function getEmailIds(): array - { - return array_column($this->getEmails(), 'id'); - } - - /** - * @return Email[] - * @throws InvalidConfigException - */ - public function getEmails(): array - { - return $this->id ? Plugin::getInstance()->getEmails()->getAllEmailsByOrderStatusId($this->id) : []; - } - - public function getLabelHtml(): string - { - return Cp::statusLabelHtml([ - 'color' => Html::encode($this->color), - 'label' => Html::encode($this->getUiLabel()), - ]); - } - - /** - * @since 2.2 - */ - public function canDelete(): bool - { - /** @var OrderQuery $orderQuery */ - $orderQuery = Order::find()->trashed(null); - return !$orderQuery->orderStatus($this)->one() && !$this->default; - } - - /** - * Returns the config for this status. - * - * @since 5.0.3 - */ - public function getConfig(?array $emailIds = null): array - { - if ($emailIds === null) { - $emailIds = $this->getEmailIds(); - } - - $emails = !empty($emailIds) ? Db::uidsByIds(Table::EMAILS, $emailIds) : []; - return [ - 'name' => $this->name, - 'handle' => $this->handle, - 'color' => $this->color, - 'description' => $this->description, - 'sortOrder' => $this->sortOrder ?? 99, - 'default' => $this->default, - 'emails' => !empty($emails) ? array_combine($emails, $emails) : [], - 'store' => $this->getStore()->uid, - ]; - } - - /** - * @inheritdoc - */ - public static function get(int|string $id): ?static - { - /** @var Site|StoreBehavior|null $site */ - $site = Cp::requestedSite(); - $storeId = $site?->getStore()->id ?? null; - - /** @phpstan-ignore-next-line */ - return Plugin::getInstance()->getOrderStatuses()->getOrderStatusById($id, $storeId); - } - - /** - * @inheritdoc - */ - public function getId(): string|int|null - { - return $this->id; - } -} diff --git a/src/models/PaymentCurrency.php b/src/models/PaymentCurrency.php deleted file mode 100644 index a4e828d120..0000000000 --- a/src/models/PaymentCurrency.php +++ /dev/null @@ -1,203 +0,0 @@ - - * @since 2.0 - */ -class PaymentCurrency extends Model -{ - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var int|null Store ID - */ - public ?int $storeId = null; - - /** - * @var string|null ISO code - */ - public ?string $iso = null; - - /** - * @var float Exchange rate vs primary currency - */ - public float $rate = 1; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateCreated = null; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateUpdated = null; - - public function __toString(): string - { - return (string)$this->iso; - } - - /** - * @return Currency - */ - public function getCurrency(): Currency - { - return new Currency($this->iso); - } - - /** - * @return string - * @throws InvalidConfigException - */ - public function getCpEditUrl(): string - { - if ($this->storeId === null) { - return ''; - } - - $store = Plugin::getInstance()->getStores()->getStoreById($this->storeId); - if ($store === null) { - throw new InvalidConfigException('Invalid store ID: ' . $this->storeId); - } - - return UrlHelper::cpUrl(sprintf('commerce/store-management/%s/payment-currencies/%s', $store->handle, $this->id)); - } - - /** - * @inheritdoc - */ - public function attributes(): array - { - $names = parent::attributes(); - $names[] = 'minorUnit'; - $names[] = 'alphabeticCode'; - $names[] = 'currency'; - $names[] = 'numericCode'; - $names[] = 'entity'; - return $names; - } - - public function safeAttributes() - { - $names = parent::safeAttributes(); - return array_unique(array_merge(['id', 'storeId', 'iso', 'rate', 'dateCreated', 'dateUpdated'], $names)); - } - - /** - * @return string|null - */ - public function getAlphabeticCode(): ?string - { - return $this->iso; - } - - /** - * @return int|null - * @throws InvalidConfigException - */ - public function getNumericCode(): ?int - { - return Plugin::getInstance()->getCurrencies()->numericCodeFor($this->iso); - } - - public function getEntity(): ?string - { - // @TODO Implement getEntity() to return the country/region entity name from \craft\commerce\services\Currencies::$_isoCurrencies instead of an empty string - return ''; - } - - /** - * @return int|null - * @throws InvalidConfigException - * @deprecated Use getSubUnit() instead. - */ - public function getMinorUnit(): ?int - { - return $this->getSubUnit(); - } - - /** - * @return int|null - * @throws InvalidConfigException - */ - public function getSubUnit(): ?int - { - return Plugin::getInstance()->getCurrencies()->getSubunitFor($this->iso); - } - - /** - * Returns alias of getCurrency() - */ - public function getName(): ?string - { - return $this->iso; - } - - /** - * @return Store - * @throws InvalidConfigException - */ - public function getStore() - { - return Plugin::getInstance()->getStores()->getStoreById($this->storeId); - } - - /** - * @return bool - * @throws InvalidConfigException - */ - public function getPrimary(): bool - { - return $this->getCode() === $this->getStore()->getCurrency()->getCode(); - } - - /** - * @return string|null - */ - public function getCode() - { - return $this->iso; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['iso', 'rate'], 'required'], - [['iso'], UniqueValidator::class, 'targetClass' => PaymentCurrencyRecord::class, 'targetAttribute' => ['iso', 'storeId'], 'message' => '{attribute} "{value}" has already been taken.'], - ]; - } -} diff --git a/src/models/PaymentSource.php b/src/models/PaymentSource.php deleted file mode 100644 index 77d4db84c8..0000000000 --- a/src/models/PaymentSource.php +++ /dev/null @@ -1,142 +0,0 @@ - - * @since 2.0 - */ -class PaymentSource extends Model -{ - /** - * @var int|null Payment source ID - */ - public ?int $id = null; - - /** - * @var int The customer element ID - */ - public int $customerId; - - /** - * @var int The gateway ID. - */ - public int $gatewayId; - - /** - * @var string Token - */ - public string $token; - - /** - * @var string Description - */ - public string $description; - - /** - * @var string Response data - */ - public string $response; - - /** - * @var User|null $_user - */ - private ?User $_customer = null; - - /** - * @var GatewayInterface|null $_gateway - */ - private ?GatewayInterface $_gateway = null; - - - /** - * Returns the payment source token. - * - * @return string - */ - public function __toString() - { - return $this->token; - } - - /** - * Returns the user element associated with this payment source. - * - * @return User|null - */ - public function getCustomer(): ?User - { - if (!isset($this->_customer)) { - $this->_customer = Craft::$app->getUsers()->getUserById($this->customerId); - } - - return $this->_customer; - } - - /** - * @return bool - * @since 4.2 - */ - public function getIsPrimary(): bool - { - /** @var User|CustomerBehavior|null $customer */ - $customer = $this->getCustomer(); - return $customer && $customer->primaryPaymentSourceId === $this->id; - } - - /** - * @deprecated in 4.0.0. Use [[getCustomer()]] instead. - */ - public function getUser(): ?User - { - Craft::$app->getDeprecator()->log('PaymentSource::getUser()', 'The `PaymentSource::getUser()` is deprecated, use the `PaymentSource::getCustomer()` instead.'); - return $this->getCustomer(); - } - - /** - * Returns the gateway associated with this payment source. - * - * @return GatewayInterface|null - * @throws InvalidConfigException - */ - public function getGateway(): ?GatewayInterface - { - if ($this->_gateway === null && $this->gatewayId) { - $this->_gateway = Commerce::getInstance()->getGateways()->getGatewayById($this->gatewayId); - } - - return $this->_gateway; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['token'], UniqueValidator::class, 'targetAttribute' => ['gatewayId', 'token'], 'targetClass' => PaymentSourceRecord::class], - [['gatewayId', 'customerId', 'token', 'description'], 'required'], - [['id', 'response'], 'safe'], - ]; - } -} diff --git a/src/models/Pdf.php b/src/models/Pdf.php deleted file mode 100644 index d1180482c3..0000000000 --- a/src/models/Pdf.php +++ /dev/null @@ -1,234 +0,0 @@ - - * @since 3.2 - * - * @property-read array $config - */ -class Pdf extends Model implements HasStoreInterface -{ - use StoreTrait; - - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string|null Name - */ - public ?string $name = null; - - /** - * @var string|null Handle - */ - public ?string $handle = null; - - /** - * @var string|null Subject - */ - public ?string $description = null; - - /** - * @var bool Is Enabled - */ - public bool $enabled = true; - - /** - * @var bool Is default PDF for order - */ - public bool $isDefault = false; - - /** - * @var string Template path - */ - public string $templatePath = ''; - - /** - * @var string|null Filename format - */ - public ?string $fileNameFormat = null; - - /** - * @var int|null Sort order - */ - public ?int $sortOrder = null; - - /** - * @var string The orientation of the paper to use for generated order PDF files. - * - * Options are `'portrait'` and `'landscape'`. - * - * @since 5.0.0 - */ - public string $paperOrientation = PdfRecord::PAPER_ORIENTATION_PORTRAIT; - - /** - * @var string The size of the paper to use for generated order PDFs. - * - * The full list of supported paper sizes can be found [in the dompdf library](https://github.com/dompdf/dompdf/blob/master/src/Adapter/CPDF.php#L45). - * - * @since 5.0.0 - */ - public string $paperSize = 'letter'; - - /** - * @var string|null UID - */ - public ?string $uid = null; - - /** - * @var string locale language - */ - public string $language = PdfRecord::LOCALE_ORDER_LANGUAGE; - - - /** - * @return string - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function getCpEditUrl(): string - { - return UrlHelper::cpUrl('commerce/settings/pdfs/' . $this->getStore()->handle . '/' . $this->id); - } - - /** - * @var int How long (in seconds) a PDF download link should remain valid before expiring - * @since 4.10 - */ - public int $linkExpiry = 86400; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['name', 'handle', 'templatePath', 'language'], 'required'], - [['handle'], - UniqueValidator::class, - 'targetClass' => PdfRecord::class, - 'targetAttribute' => ['handle', 'storeId'], - 'message' => '{attribute} "{value}" has already been taken.', - ], - [['paperOrientation'], 'in', 'range' => [PdfRecord::PAPER_ORIENTATION_PORTRAIT, PdfRecord::PAPER_ORIENTATION_LANDSCAPE]], - [['paperSize'], 'in', 'range' => array_keys(CPDF::$PAPER_SIZES)], - [[ - 'description', - 'enabled', - 'fileNameFormat', - 'handle', - 'id', - 'isDefault', - 'language', - 'linkExpiry', - 'name', - 'paperOrientation', - 'paperSize', - 'sortOrder', - 'storeId', - 'templatePath', - 'uid', - ], 'safe'], - ]; - } - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $fields = parent::extraFields(); - $fields[] = 'config'; - - return $fields; - } - - /** - * Determines the language this PDF - * - * @param Order|null $order - */ - public function getRenderLanguage(Order $order = null): string - { - $language = $this->language; - - if ($order == null && $language == PdfRecord::LOCALE_ORDER_LANGUAGE) { - throw new InvalidArgumentException('Can not get language for this PDF without providing an order'); - } - - if ($order && $language == PdfRecord::LOCALE_ORDER_LANGUAGE) { - $language = $order->orderLanguage; - } - - return $language; - } - - /** - * Returns the field layout config for this email. - * - * @since 3.2.0 - */ - public function getConfig(): array - { - return [ - 'description' => $this->description, - 'enabled' => $this->enabled, - 'fileNameFormat' => $this->fileNameFormat ?? '', - 'handle' => $this->handle, - 'isDefault' => $this->isDefault, - 'language' => $this->language, - 'name' => $this->name, - 'paperOrientation' => $this->paperOrientation, - 'paperSize' => $this->paperSize, - 'sortOrder' => $this->sortOrder ?: 9999, - 'store' => $this->getStore()->uid, - 'templatePath' => $this->templatePath, - 'linkExpiry' => $this->linkExpiry, - ]; - } - - /** - * @return string[] - * @since 5.0.0 - */ - public static function getPaperOrientationOptions(): array - { - return [ - PdfRecord::PAPER_ORIENTATION_PORTRAIT => Craft::t('commerce', 'Portrait'), - PdfRecord::PAPER_ORIENTATION_LANDSCAPE => Craft::t('commerce', 'Landscape'), - ]; - } - - /** - * @return array - * @since 5.0.0 - */ - public static function getPaperSizeOptions(): array - { - return collect(CPDF::$PAPER_SIZES)->mapWithKeys(fn($value, $key) => [$key => $key])->all(); - } -} diff --git a/src/models/ProductType.php b/src/models/ProductType.php deleted file mode 100644 index 5d34392b43..0000000000 --- a/src/models/ProductType.php +++ /dev/null @@ -1,750 +0,0 @@ - - * @since 2.0 - */ -class ProductType extends Model implements FieldLayoutProviderInterface -{ - /** @since 5.2.0 */ - public const DEFAULT_PLACEMENT_BEGINNING = 'beginning'; - /** @since 5.2.0 */ - public const DEFAULT_PLACEMENT_END = 'end'; - - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string|null Name - */ - public ?string $name = null; - - /** - * @var string|null Handle - */ - public ?string $handle = null; - - /** - * @var bool Whether versioning should be enabled for this product type. - * @since 5.0.0 - */ - public bool $enableVersioning = false; - - /** - * @var bool Has dimension - */ - public bool $hasDimensions = false; - - /** - * @var int|null Maximum number of variants - */ - public ?int $maxVariants = null; - - /** - * @var bool Has variant title field - */ - public bool $hasVariantTitleField = true; - - /** - * @var string Variant title format - */ - public string $variantTitleFormat = '{product.title}'; - - /** - * @var string Variant UI label format - * @since 5.6.0 - */ - public string $variantUiLabelFormat = '{title}'; - - /** - * @var string Variant title translation method - * @phpstan-var Field::TRANSLATION_METHOD_NONE|Field::TRANSLATION_METHOD_SITE|Field::TRANSLATION_METHOD_SITE_GROUP|Field::TRANSLATION_METHOD_LANGUAGE|Field::TRANSLATION_METHOD_CUSTOM - * @since 5.1.0 - */ - public string $variantTitleTranslationMethod = Field::TRANSLATION_METHOD_SITE; - - /** - * @var string|null Variant title translation key format - * @since 5.1.0 - */ - public ?string $variantTitleTranslationKeyFormat = null; - - /** - * @var bool Has product title field? - */ - public bool $hasProductTitleField = true; - - /** - * @var string Product title format - */ - public string $productTitleFormat = ''; - - /** - * @var string Product UI label format - * @since 5.6.0 - */ - public string $productUiLabelFormat = '{title}'; - - /** - * @var string Product title translation method - * @phpstan-var Field::TRANSLATION_METHOD_NONE|Field::TRANSLATION_METHOD_SITE|Field::TRANSLATION_METHOD_SITE_GROUP|Field::TRANSLATION_METHOD_LANGUAGE|Field::TRANSLATION_METHOD_CUSTOM - * @since 5.1.0 - */ - public string $productTitleTranslationMethod = Field::TRANSLATION_METHOD_SITE; - - /** - * @var string|null Product title translation key format - * @since 5.1.0 - */ - public ?string $productTitleTranslationKeyFormat = null; - - /** - * @var bool Whether to show the Slug field - * @since 5.5.0 - */ - public bool $showSlugField = true; - - /** - * @var string Slug translation method - * @phpstan-var Field::TRANSLATION_METHOD_NONE|Field::TRANSLATION_METHOD_SITE|Field::TRANSLATION_METHOD_SITE_GROUP|Field::TRANSLATION_METHOD_LANGUAGE|Field::TRANSLATION_METHOD_CUSTOM - * @since 5.5.0 - */ - public string $slugTranslationMethod = Field::TRANSLATION_METHOD_SITE; - - /** - * @var string|null Slug translation key format - * @since 5.5.0 - */ - public ?string $slugTranslationKeyFormat = null; - - /** - * @var string|null SKU format - */ - public ?string $skuFormat = null; - - /** - * @var string Description format - */ - public string $descriptionFormat = '{product.title} - {title}'; - - /** - * @var string|null Template - */ - public ?string $template = null; - - /** - * @var bool Is this a structure product type - * @since 5.2.0 - */ - public bool $isStructure = false; - - /** - * @var ?int max levels of structure - * @since 5.2.0 - */ - public ?int $maxLevels = null; - - /** - * @var string Default placement - * @phpstan-var self::DEFAULT_PLACEMENT_BEGINNING|self::DEFAULT_PLACEMENT_END - * @since 5.2.0 - */ - public string $defaultPlacement = self::DEFAULT_PLACEMENT_END; - - /** - * @var int|null Structure ID - * @since 5.2.0 - */ - public ?int $structureId = null; - - /** - * @var int|null Field layout ID - */ - public ?int $fieldLayoutId = null; - - /** - * @var int|null Variant layout ID - */ - public ?int $variantFieldLayoutId = null; - - /** - * @var string|null UID - */ - public ?string $uid = null; - - /** - * @var array|null Preview targets - * @since 5.5.0 - */ - public ?array $previewTargets = null; - - /** - * @var TaxCategory[]|null - */ - private ?array $_taxCategories = null; - - /** - * @var ShippingCategory[]|null - */ - private ?array $_shippingCategories = null; - - /** - * @var ProductTypeSite[]|null - */ - private ?array $_siteSettings = null; - - /** - * @var PropagationMethod Propagation method - * - * This will be set to one of the following: - * - * - [[PropagationMethod::None]] – Only save products in the site they were created in - * - [[PropagationMethod::SiteGroup]] – Save products to other sites in the same site group - * - [[PropagationMethod::Language]] – Save products to other sites with the same language - * - [[PropagationMethod::Custom]] – Save products to other sites based on a custom [[$propagationKeyFormat|propagation key format]] - * - [[PropagationMethod::All]] – Save products to all sites supported by the owner element - * - * @since 5.1.0 - */ - public PropagationMethod $propagationMethod = PropagationMethod::All; - - /** - * @inheritdoc - */ - public function init(): void - { - parent::init(); - - if (!isset($this->previewTargets)) { - $this->previewTargets = [ - [ - 'label' => Craft::t('app', 'Primary {type} page', [ - 'type' => Product::lowerDisplayName(), - ]), - 'urlFormat' => '{url}', - ], - ]; - } - - if ($this->productTitleTranslationKeyFormat === '') { - $this->productTitleTranslationKeyFormat = null; - } - - if ($this->variantTitleTranslationKeyFormat === '') { - $this->variantTitleTranslationKeyFormat = null; - } - - if ($this->slugTranslationKeyFormat === '') { - $this->slugTranslationKeyFormat = null; - } - } - - /** - * @return null|string - */ - public function __toString() - { - return (string)$this->handle; - } - - /** - * @inerhitdoc - */ - public function getHandle(): ?string - { - return $this->handle; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['id', 'fieldLayoutId', 'variantFieldLayoutId', 'structureId'], 'number', 'integerOnly' => true], - [['name', 'handle'], 'required'], - [ - ['variantTitleFormat'], - 'required', - 'when' => static fn($model) => - /** @var static $model */ - !$model->hasVariantTitleField, - ], - [ - ['productTitleFormat'], - 'required', - 'when' => static fn($model) => - /** @var static $model */ - !$model->hasProductTitleField, - ], - [['name', 'handle', 'descriptionFormat'], 'string', 'max' => 255], - [['handle'], UniqueValidator::class, 'targetClass' => ProductTypeRecord::class, 'targetAttribute' => ['handle'], 'message' => 'Not Unique'], - [['handle'], HandleValidator::class, 'reservedWords' => ['id', 'dateCreated', 'dateUpdated', 'uid', 'title']], - [['maxVariants'], 'integer', 'min' => 1], - ['fieldLayout', 'validateFieldLayout'], - ['variantFieldLayout', 'validateVariantFieldLayout'], - ['siteSettings', 'required', 'message' => Craft::t('commerce','At least one site must be enabled for the product type.')], - [['isStructure', 'defaultPlacement', 'maxLevels', 'structureId', 'productUiLabelFormat', 'variantUiLabelFormat'], 'safe'], - [['previewTargets'], 'validatePreviewTargets'], - ]; - } - - public function getCpEditUrl(): string - { - return UrlHelper::cpUrl('commerce/settings/producttypes/' . $this->id); - } - - public function getCpEditVariantUrl(): string - { - return UrlHelper::cpUrl('commerce/settings/producttypes/' . $this->id . '/variant'); - } - - /** - * Returns the site IDs that are enabled for the product type. - * - * @return int[] - * @since 5.1.0 - */ - public function getSiteIds(): array - { - return array_keys($this->getSiteSettings()); - } - - /** - * Returns the product type's site-specific settings. - * - * @return ProductTypeSite[] - * @throws InvalidConfigException - */ - public function getSiteSettings(): array - { - if (isset($this->_siteSettings)) { - return $this->_siteSettings; - } - - if (!$this->id) { - return []; - } - - $this->setSiteSettings(ArrayHelper::index(Plugin::getInstance()->getProductTypes()->getProductTypeSites($this->id), 'siteId')); - - return $this->_siteSettings; - } - - /** - * Sets the product type's site-specific settings. - * - * @param ProductTypeSite[] $siteSettings - */ - public function setSiteSettings(array $siteSettings): void - { - $this->_siteSettings = $siteSettings; - - foreach ($this->_siteSettings as $settings) { - $settings->setProductType($this); - } - } - - /** - * @return ShippingCategory[] - * @throws InvalidConfigException - */ - public function getShippingCategories(): array - { - if ($this->_shippingCategories === null && $this->id) { - $this->_shippingCategories = Plugin::getInstance()->getShippingCategories()->getShippingCategoriesByProductTypeId($this->id); - } - - return $this->_shippingCategories ?? []; - } - - /** - * @param int[]|ShippingCategory[] $shippingCategories - * @throws InvalidConfigException - */ - public function setShippingCategories(array $shippingCategories): void - { - $categories = []; - foreach ($shippingCategories as $category) { - if (is_numeric($category)) { - if ($category = Plugin::getInstance()->getShippingCategories()->getShippingCategoryById($category)) { - $categories[$category->id] = $category; - } - } elseif ($category instanceof ShippingCategory) { - // Make sure it exists - if ($category = Plugin::getInstance()->getShippingCategories()->getShippingCategoryById($category->id)) { - $categories[$category->id] = $category; - } - } - } - - $this->_shippingCategories = $categories; - } - - /** - * @return TaxCategory[] - * @throws InvalidConfigException - */ - public function getTaxCategories(): array - { - if ($this->_taxCategories === null && $this->id) { - $this->_taxCategories = Plugin::getInstance()->getTaxCategories()->getTaxCategoriesByProductTypeId($this->id); - } - - return $this->_taxCategories ?? []; - } - - /** - * @param int[]|TaxCategory[] $taxCategories - * @throws InvalidConfigException - */ - public function setTaxCategories(array $taxCategories): void - { - $categories = []; - foreach ($taxCategories as $category) { - if (is_numeric($category)) { - if ($category = Plugin::getInstance()->getTaxCategories()->getTaxCategoryById($category)) { - $categories[$category->id] = $category; - } - } else { - if ($category instanceof TaxCategory) { - // Make sure it exists. - if ($category = Plugin::getInstance()->getTaxCategories()->getTaxCategoryById($category->id)) { - $categories[$category->id] = $category; - } - } - } - } - - $this->_taxCategories = $categories; - } - - /** - * @inheritdoc - */ - public function getFieldLayout(): FieldLayout - { - return $this->getProductFieldLayout(); - } - - /** - * @throws InvalidConfigException - */ - public function getProductFieldLayout(): FieldLayout - { - /** @var FieldLayoutBehavior $behavior */ - $behavior = $this->getBehavior('productFieldLayout'); - $fieldLayout = $behavior->getFieldLayout(); - - // If this product type has variants, make sure the Variants field is in the layout somewhere - if (!$fieldLayout->isFieldIncluded('variants')) { - $layoutTabs = $fieldLayout->getTabs(); - $variantTabName = Craft::t('commerce', 'Variants'); - if (ArrayHelper::contains($layoutTabs, 'name', $variantTabName)) { - $variantTabName .= ' ' . StringHelper::randomString(10); - } - - $contentTab = new FieldLayoutTab(); - $contentTab->setLayout($fieldLayout); - $contentTab->name = $variantTabName; - $contentTab->setElements([ - ['type' => VariantsField::class], - ]); - - $layoutTabs[] = $contentTab; - $fieldLayout->setTabs($layoutTabs); - } - - return $fieldLayout; - } - - /** - * Validate the field layout to make sure no fields with reserved words are used. - * - * @since 3.4 - */ - public function validateFieldLayout(): void - { - $fieldLayout = $this->getFieldLayout(); - - $fieldLayout->reservedFieldHandles = [ - 'cheapestVariant', - 'defaultVariant', - 'variants', - ]; - - if (!$fieldLayout->validate()) { - $this->addModelErrors($fieldLayout, 'fieldLayout'); - } - } - - /** - * Validate the variant field layout to make sure no fields with reserved words are used. - * - * @since 3.4 - */ - public function validateVariantFieldLayout(): void - { - $variantFieldLayout = $this->getVariantFieldLayout(); - - $variantFieldLayout->reservedFieldHandles = [ - 'availableForPurchase', - 'description', - 'freeShipping', - 'hasUnlimitedStock', - 'height', - 'length', - 'maxQty', - 'minQty', - 'price', - 'product', - 'promotable', - 'promotionalPrice', - 'sku', - 'stock', - 'weight', - 'width', - ]; - - if (!$variantFieldLayout->validate()) { - $this->addModelErrors($variantFieldLayout, 'variantFieldLayout'); - } - } - - /** - * Validates the preview targets. - * - * @since 5.5.0 - */ - public function validatePreviewTargets(): void - { - $hasErrors = false; - - foreach ($this->previewTargets as &$target) { - $target['label'] = trim($target['label']); - $target['urlFormat'] = trim($target['urlFormat']); - - if ($target['label'] === '') { - $target['label'] = ['value' => $target['label'], 'hasErrors' => true]; - $hasErrors = true; - } - } - unset($target); - - if ($hasErrors) { - $this->addError('previewTargets', Craft::t('app', 'All targets must have a label.')); - } - } - - /** - * @throws InvalidConfigException - */ - public function getVariantFieldLayout(): FieldLayout - { - /** @var FieldLayoutBehavior $behavior */ - $behavior = $this->getBehavior('variantFieldLayout'); - return $behavior->getFieldLayout(); - } - - /** - * @return string - * @deprecated 4.0.0 - */ - public function getTitleFormat(): string - { - Craft::$app->getDeprecator()->log('craft\commerce\models\ProductType::titleFormat', 'Getting `ProductType::titleFormat` has been deprecate. Use `ProductType::variantTitleFormat` instead.'); - return $this->variantTitleFormat; - } - - /** - * @param string $titleFormat - * @return void - * @throws DeprecationException - * @deprecated 4.0.0 - */ - public function setTitleFormat(string $titleFormat): void - { - Craft::$app->getDeprecator()->log('craft\commerce\models\ProductType::titleFormat', 'Setting `ProductType::titleFormat` has been deprecate. Use `ProductType::variantTitleFormat` instead.'); - $this->variantTitleFormat = $titleFormat; - } - - /** - * @return bool - * @deprecated 5.0.0 - */ - public function getHasVariants(): bool - { - Craft::$app->getDeprecator()->log('craft\commerce\models\ProductType::hasVariants', 'Use `ProductType::maxVariants > 1` instead.'); - return $this->maxVariants > 1; - } - - /** - * @inheritdoc - */ - protected function defineBehaviors(): array - { - $behaviors['productFieldLayout'] = [ - 'class' => FieldLayoutBehavior::class, - 'elementType' => Product::class, - 'idAttribute' => 'fieldLayoutId', - ]; - - $behaviors['variantFieldLayout'] = [ - 'class' => FieldLayoutBehavior::class, - 'elementType' => Variant::class, - 'idAttribute' => 'variantFieldLayoutId', - ]; - - return $behaviors; - } - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $fields = parent::extraFields(); - $fields[] = 'taxCategories'; - $fields[] = 'shippingCategories'; - $fields[] = 'siteSettings'; - - return $fields; - } - - /** - * Returns the product types’s config. - * - * @return array - * @since 5.2.0 - */ - public function getConfig(): array - { - $config = [ - 'name' => $this->name, - 'handle' => $this->handle, - 'enableVersioning' => $this->enableVersioning, - 'hasDimensions' => $this->hasDimensions, - 'maxVariants' => $this->maxVariants, - - // Variant title field - 'hasVariantTitleField' => $this->hasVariantTitleField, - 'variantTitleFormat' => $this->variantTitleFormat, - 'variantTitleTranslationMethod' => $this->variantTitleTranslationMethod, - 'variantTitleTranslationKeyFormat' => $this->variantTitleTranslationKeyFormat, - 'variantUiLabelFormat' => $this->variantUiLabelFormat, - - // Product title field - 'hasProductTitleField' => $this->hasProductTitleField, - 'productTitleFormat' => $this->productTitleFormat, - 'productTitleTranslationMethod' => $this->productTitleTranslationMethod, - 'productTitleTranslationKeyFormat' => $this->productTitleTranslationKeyFormat, - 'productUiLabelFormat' => $this->productUiLabelFormat, - - // Slug field - 'showSlugField' => $this->showSlugField, - 'slugTranslationMethod' => $this->slugTranslationMethod, - 'slugTranslationKeyFormat' => $this->slugTranslationKeyFormat, - - 'propagationMethod' => $this->propagationMethod->value, - - 'skuFormat' => $this->skuFormat, - 'descriptionFormat' => $this->descriptionFormat, - 'siteSettings' => [], - - 'isStructure' => $this->isStructure, - 'maxLevels' => $this->maxLevels, - 'defaultPlacement' => $this->defaultPlacement, - ]; - - if (!empty($this->previewTargets)) { - $config['previewTargets'] = ProjectConfigHelper::packAssociativeArray(array_values($this->previewTargets)); - } - - if ($this->isStructure) { - $config['structure'] = [ - 'uid' => $this->structureId ? Db::uidById(Table::STRUCTURES, $this->structureId) : StringHelper::UUID(), - ]; - } - - $generateLayoutConfig = function(FieldLayout $fieldLayout): array { - $fieldLayoutConfig = $fieldLayout->getConfig(); - - if ($fieldLayoutConfig) { - if (empty($fieldLayout->id)) { - $layoutUid = StringHelper::UUID(); - $fieldLayout->uid = $layoutUid; - } else { - $layoutUid = Db::uidById(CraftTable::FIELDLAYOUTS, $fieldLayout->id); - } - - return [$layoutUid => $fieldLayoutConfig]; - } - - return []; - }; - - $config['productFieldLayouts'] = $generateLayoutConfig($this->getFieldLayout()); - $config['variantFieldLayouts'] = $generateLayoutConfig($this->getVariantFieldLayout()); - - // Get the site settings - $allSiteSettings = $this->getSiteSettings(); - - foreach ($allSiteSettings as $siteId => $settings) { - $siteUid = Db::uidById(CraftTable::SITES, $siteId); - $config['siteSettings'][$siteUid] = [ - 'hasUrls' => $settings['hasUrls'], - 'enabledByDefault' => $settings['enabledByDefault'], - 'uriFormat' => $settings['uriFormat'], - 'template' => $settings['template'], - ]; - } - - return $config; - } -} diff --git a/src/models/ProductTypeSite.php b/src/models/ProductTypeSite.php deleted file mode 100644 index 04e361cfb1..0000000000 --- a/src/models/ProductTypeSite.php +++ /dev/null @@ -1,141 +0,0 @@ - - * @since 2.0 - */ -class ProductTypeSite extends Model -{ - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var int Product type ID - */ - public int $productTypeId; - - /** - * @var int Site ID - */ - public int $siteId; - - /** - * @var bool Has Urls - */ - public bool $hasUrls = false; - - /** - * @var string|null URL Format - */ - public ?string $uriFormat = null; - - /** - * @var string|null Template Path - */ - public ?string $template = null; - - /** - * @var bool Enabled by default - * @since 5.1.0 - */ - public bool $enabledByDefault = true; - - /** - * @var ProductType|null - */ - private ?ProductType $_productType = null; - - /** - * @var Site|null - */ - private ?Site $_site = null; - - /** - * @var bool - */ - public bool $uriFormatIsRequired = true; - - - /** - * Returns the Product Type. - * - * @throws InvalidConfigException if [[productTypeId]] is missing or invalid - */ - public function getProductType(): ProductType - { - if ($this->_productType !== null) { - return $this->_productType; - } - - if (!$this->productTypeId) { - throw new InvalidConfigException('Product type site is missing its product type ID'); - } - - if (($this->_productType = Plugin::getInstance()->getProductTypes()->getProductTypeById($this->productTypeId)) === null) { - throw new InvalidConfigException('Invalid product type ID: ' . $this->productTypeId); - } - - return $this->_productType; - } - - /** - * Sets the Product Type. - */ - public function setProductType(ProductType $productType): void - { - $this->_productType = $productType; - } - - /** - * @throws InvalidConfigException if [[siteId]] is missing or invalid - */ - public function getSite(): Site - { - if ($this->_site !== null) { - return $this->_site; - } - - if (!$this->siteId) { - throw new InvalidConfigException('Product type site is missing its site ID'); - } - - if (($this->_site = Craft::$app->getSites()->getSiteById($this->siteId)) === null) { - throw new InvalidConfigException('Invalid site ID: ' . $this->siteId); - } - - return $this->_site; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = []; - - if ($this->uriFormatIsRequired) { - $rules[] = ['uriFormat', 'required']; - } - - return $rules; - } -} diff --git a/src/models/PurchasableStore.php b/src/models/PurchasableStore.php deleted file mode 100644 index 4fa267b968..0000000000 --- a/src/models/PurchasableStore.php +++ /dev/null @@ -1,105 +0,0 @@ - - * @since 5.0.0 - */ -class PurchasableStore extends Model -{ - /** - * @var int|null - */ - public ?int $id = null; - - /** - * @var int|null - */ - public ?int $purchasableId = null; - - /** - * @var int|null - */ - public ?int $storeId = null; - - /** - * @var float|null - */ - public ?float $basePrice = null; - - /** - * @var float|null - */ - public ?float $basePromotionalPrice = null; - - /** - * @var int|null - */ - public ?int $stock = null; - - /** - * @var bool - */ - public bool $hasUnlimitedStock = false; - - /** - * @var int|null - */ - public ?int $minQty = null; - - /** - * @var int|null - */ - public ?int $maxQty = null; - - /** - * @var bool - */ - public bool $promotable = false; - - /** - * @var bool - */ - public bool $availableForPurchase = false; - - /** - * @var bool - * @since 5.3.0 - */ - public bool $allowOutOfStockPurchases = false; - - /** - * @var bool - */ - public bool $freeShipping = false; - - /** - * @var int|null - */ - public ?int $shippingCategoryId = null; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['purchasableId', 'storeId'], 'required']; - $rules[] = [['purchasableId', 'storeId', 'stock', 'minQty', 'maxQty'], 'integer']; - $rules[] = [['basePrice', 'basePromotionalPrice'], 'number']; - $rules[] = [['hasUnlimitedStock', 'promotable', 'availableForPurchase', 'freeShipping', 'allowOutOfStockPurchases'], 'boolean']; - $rules[] = [['shippingCategoryId'], 'safe']; - - return $rules; - } -} diff --git a/src/models/Sale.php b/src/models/Sale.php deleted file mode 100644 index ff41e2c444..0000000000 --- a/src/models/Sale.php +++ /dev/null @@ -1,268 +0,0 @@ - - * @since 2.0 - */ -class Sale extends Model -{ - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string|null Name - */ - public ?string $name = null; - - /** - * @var string|null Description - */ - public ?string $description = null; - - /** - * @var DateTime|null Date From - */ - public ?DateTime $dateFrom = null; - - /** - * @var DateTime|null Date To - */ - public ?DateTime $dateTo = null; - - /** - * @var string How the sale should be applied - */ - public string $apply = SaleRecord::APPLY_BY_PERCENT; - - /** - * @var float|null The amount field used by the apply option - */ - public ?float $applyAmount = null; - - /** - * @var bool ignore the previous sales that affect the purchasable - */ - public bool $ignorePrevious = false; - - /** - * @var bool should the sales system stop processing other sales after this one - */ - public bool $stopProcessing = false; - - /** - * @var bool Match all groups - */ - public bool $allGroups = false; - - /** - * @var bool Match all purchasables - */ - public bool $allPurchasables = false; - - /** - * @var bool Match all categories - */ - public bool $allCategories = false; - - /** - * @var string Type of relationship between Categories and Products - */ - public string $categoryRelationshipType = SaleRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH; - - /** - * @var bool Enabled - */ - public bool $enabled = true; - - /** - * @var int|null The order index of the application of the sale - */ - public ?int $sortOrder = null; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateCreated = null; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateUpdated = null; - - /** - * @var int[]|null Product Ids - */ - private ?array $_purchasableIds = null; - - /** - * @var int[]|null Product Type IDs - */ - private ?array $_categoryIds = null; - - /** - * @var int[]|null Group IDs - */ - private ?array $_userGroupIds = null; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['apply'], 'in', 'range' => ['toPercent', 'toFlat', 'byPercent', 'byFlat']], - [ - ['categoryRelationshipType'], - 'in', - 'range' => [ - SaleRecord::CATEGORY_RELATIONSHIP_TYPE_SOURCE, - SaleRecord::CATEGORY_RELATIONSHIP_TYPE_TARGET, - SaleRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH, - ], - ], - [['enabled'], 'boolean'], - [['name', 'apply', 'allGroups', 'allPurchasables', 'allCategories'], 'required'], - ]; - } - - public function getCpEditUrl(): string - { - // Sales cannot exist with multiple stores so we can just use the primary store - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - return $store->getStoreSettingsUrl('sales/' . $this->id); - } - - /** - * @return array - */ - public function extraFields(): array - { - $fields = parent::extraFields(); - $fields[] = 'purchasableIds'; - - return $fields; - } - - public function getApplyAmountAsPercent(): string - { - return Craft::$app->getFormatter()->asPercent(-($this->applyAmount ?? 0.0)); - } - - public function getApplyAmountAsFlat(): string - { - return $this->applyAmount !== null ? (string)($this->applyAmount * -1) : '0'; - } - - public function getCategoryIds(): array - { - if (!isset($this->_categoryIds)) { - $categoryIds = []; - if ($this->id) { - $categoryIds = (new Query())->select( - 'spt.categoryId') - ->from(Table::SALES . ' sales') - ->leftJoin(Table::SALE_CATEGORIES . ' spt', '[[spt.saleId]]=[[sales.id]]') - ->where(['sales.id' => $this->id]) - ->column(); - - $categoryIds = array_filter($categoryIds); - } - - $this->_categoryIds = $categoryIds; - } - - return $this->_categoryIds; - } - - public function getPurchasableIds(): array - { - if (!isset($this->_purchasableIds)) { - $purchasableIds = []; - if ($this->id) { - $purchasableIds = (new Query())->select( - '[[sp.purchasableId]]') - ->from(Table::SALES . ' sales') - ->leftJoin(Table::SALE_PURCHASABLES . ' sp', '[[sp.saleId]]=[[sales.id]]') - ->where(['sales.id' => $this->id]) - ->column(); - - $purchasableIds = array_filter($purchasableIds); - } - - $this->_purchasableIds = $purchasableIds; - } - - return $this->_purchasableIds; - } - - public function getUserGroupIds(): array - { - if (!isset($this->_userGroupIds)) { - $userGroupIds = []; - if ($this->id) { - $userGroupIds = (new Query())->select( - 'sug.userGroupId') - ->from(Table::SALES . ' sales') - ->leftJoin(Table::SALE_USERGROUPS . ' sug', '[[sug.saleId]]=[[sales.id]]') - ->where(['sales.id' => $this->id]) - ->column(); - $userGroupIds = array_filter($userGroupIds); - } - - $this->_userGroupIds = $userGroupIds; - } - - return $this->_userGroupIds; - } - - /** - * Sets the related category ids - */ - public function setCategoryIds(array $ids): void - { - $this->_categoryIds = array_unique($ids); - } - - /** - * Sets the related purchasable ids - */ - public function setPurchasableIds(array $purchasableIds): void - { - $this->_purchasableIds = array_unique($purchasableIds); - } - - /** - * Sets the related user group ids - */ - public function setUserGroupIds(array $userGroupIds): void - { - $this->_userGroupIds = array_unique($userGroupIds); - } -} diff --git a/src/models/Settings.php b/src/models/Settings.php deleted file mode 100644 index 9708dd8f80..0000000000 --- a/src/models/Settings.php +++ /dev/null @@ -1,352 +0,0 @@ - - * @since 2.0 - */ -class Settings extends Model -{ - public const VIEW_URI_ORDERS = 'commerce/orders'; - public const VIEW_URI_PRODUCTS = 'commerce/products'; - /** - * @since 5.0.0. - */ - public const VIEW_URI_INVENTORY = 'commerce/inventory'; - - /** - * @since 5.0.0. - */ - public const VIEW_URI_STORE_MANAGEMENT = 'commerce/store-management'; - - /** - * @deprecated in 5.0.0. - */ - public const VIEW_URI_CUSTOMERS = 'commerce/customers'; - - /** - * @deprecated in 5.0.0. - */ - public const VIEW_URI_PROMOTIONS = 'commerce/promotions'; - - /** - * @deprecated in 5.0.0. - */ - public const VIEW_URI_SHIPPING = 'commerce/shipping/shippingmethods'; - - /** - * @deprecated in 5.0.0. - */ - public const VIEW_URI_TAX = 'commerce/tax/taxrates'; - public const VIEW_URI_SUBSCRIPTIONS = 'commerce/subscriptions'; - - /** - * @var mixed How long a cart should go without being updated before it’s considered inactive. - * - * See [craft\helpers\ConfigHelper::durationInSeconds()](craft5:craft\helpers\ConfigHelper::durationInSeconds()) for a list of supported value types. - * - * @group Cart - * @since 2.2 - * @defaultAlt 1 hour - */ - public mixed $activeCartDuration = 3600; - - /** - * @var string Key to be used when returning cart information in a response. - * @group Cart - */ - public string $cartVariable = 'cart'; - - /** - * @var string Commerce’s default control panel view. (Defaults to order index.) - * @group System - * @since 2.2 - */ - public string $defaultView = 'commerce/orders'; - - /** - * @var string Unit type for dimension measurements. - * - * Options: - * - * - `'mm'` - * - `'cm'` - * - `'m'` - * - `'ft'` - * - `'in'` - * - * @group Units - */ - public string $dimensionUnits = 'mm'; - - /** - * @var string The path to the template that should be used to perform POST requests to offsite payment gateways. - * - * The template must contain a form that posts to the URL supplied by the `actionUrl` variable and outputs all hidden inputs with - * the `inputs` variable. - * - * ```twig - * - * - * - * - * Redirecting... - * - * - *
- *

Redirecting to payment page...

- *

- * {{ inputs|raw }} - * - *

- *
- * - * - * ``` - * - * ::: tip - * Since this template is simply used for redirecting, it only appears for a few seconds, so we suggest making it load fast with minimal - * images and inline styles to reduce HTTP requests. - * ::: - * - * If empty (default), each gateway will decide how to handle after-payment redirects. - * - * @group Payments - */ - public string $gatewayPostRedirectTemplate = ''; - - /** - * @var string|null Default URL to be loaded after using the [load cart controller action](https://craftcms.com/docs/commerce/5.x/system/orders-carts.html#loading-a-cart). - * - * If `null` (default), Craft’s default [`siteUrl`](config5:siteUrl) will be used. - * - * @group Cart - * @since 3.1 - */ - public ?string $loadCartRedirectUrl = null; - - /** - * @var int How long (in seconds) a cart recovery link should remain valid before expiring. - * Default is 604800 (7 days). - * - * @group Cart - * @since 5.7.0 - */ - public int $loadCartUrlExpiry = 604800; - - /** - * @var array|null ISO codes for supported payment currencies. - * - * See [Payment Currencies](https://craftcms.com/docs/commerce/5.x/system/payment-currencies.html). - * - * @group Payments - */ - public ?array $paymentCurrency = null; - - /** - * @var bool Whether to allow non-local images in generated order PDFs. - * @group Orders - */ - public bool $pdfAllowRemoteImages = false; - - /** - * @var bool Whether inactive carts should automatically be deleted from the database during garbage collection. - * - * ::: tip - * You can control how long a cart should go without being updated before it gets deleted [`purgeInactiveCartsDuration`](#purgeinactivecartsduration) setting. - * ::: - * - * @group Cart - */ - public bool $purgeInactiveCarts = true; - - /** - * @var mixed Default length of time before inactive carts are purged. (Defaults to 90 days.) - * - * See [craft\helpers\ConfigHelper::durationInSeconds()](craft5:craft\helpers\ConfigHelper::durationInSeconds()) for a list of supported value types. - * - * @group Cart - * @defaultAlt 90 days - */ - public mixed $purgeInactiveCartsDuration = 7776000; - - /** - * @var string URL for a user to resolve billing issues with their subscription. - * - * ::: tip - * The example templates include [a template for this page](https://github.com/craftcms/commerce/tree/5.x/example-templates/dist/shop/plans/update-billing-details.twig). - * ::: - * - * @group Orders - */ - public string $updateBillingDetailsUrl = ''; - - /** - * @var bool Whether the search index for a cart should be updated when saving the cart via `commerce/cart/*` controller actions. - * - * May be set to `false` to reduce performance impact on high-traffic sites. - * - * ::: warning - * Setting this to `false` will result in fewer index update queue jobs, but you’ll need to manually re-index orders to ensure up-to-date cart search results in the control panel. - * ::: - * - * @group Cart - * @since 3.1.5 - */ - public bool $updateCartSearchIndexes = true; - - /** - * @var string Units to be used for weight measurements. - * - * Options: - * - * - `'g'` - * - `'kg'` - * - `'lb'` - * - * @group Units - */ - public string $weightUnits = 'g'; - - /** - * @var bool Whether to validate custom fields when a cart is updated. - * - * Set to `true` to allow custom content fields to return validation errors when a cart is updated. - * - * @group Cart - * @since 3.0.12 - */ - public bool $validateCartCustomFieldsOnSubmission = false; - - /** - * @inheritDoc - */ - public function setAttributes($values, $safeOnly = true): void - { - unset( - $values['orderPdfFilenameFormat'], - $values['orderPdfPath'], - $values['emailSenderAddress'], - $values['emailSenderAddressPlaceholder'], - $values['emailSenderName'], - $values['emailSenderNamePlaceholder'], - $values['autoSetNewCartAddresses'], - $values['autoSetCartShippingMethodOption'], - $values['autoSetPaymentSource'], - $values['allowEmptyCartOnCheckout'], - $values['allowCheckoutWithoutPayment'], - $values['allowPartialPaymentOnCheckout'], - $values['orderReferenceFormat'], - $values['requireShippingAddressAtCheckout'], - $values['requireBillingAddressAtCheckout'], - $values['requireShippingMethodSelectionAtCheckout'], - $values['useBillingAddressForTax'], - $values['freeOrderPaymentStrategy'], - $values['minimumTotalPriceStrategy'], - $values['showEditUserCommerceTab'], - ); - parent::setAttributes($values, $safeOnly); - } - - /** - * Returns a key-value array of weight unit options and labels. - */ - public function getWeightUnitsOptions(): array - { - return [ - 'g' => Craft::t('commerce', 'Grams (g)'), - 'kg' => Craft::t('commerce', 'Kilograms (kg)'), - 'lb' => Craft::t('commerce', 'Pounds (lb)'), - ]; - } - - /** - * Returns a key-value array of dimension unit options and labels. - */ - public function getDimensionUnits(): array - { - return [ - 'mm' => Craft::t('commerce', 'Millimeters (mm)'), - 'cm' => Craft::t('commerce', 'Centimeters (cm)'), - 'm' => Craft::t('commerce', 'Meters (m)'), - 'ft' => Craft::t('commerce', 'Feet (ft)'), - 'in' => Craft::t('commerce', 'Inches (in)'), - ]; - } - - /** - * Returns the ISO payment currency for a given site, or the default site if no handle is provided. - * - * @param string|null $siteHandle - * @return string|null - * @throws CurrencyException - * @throws InvalidConfigException if the currency in the config file is not set up - * @throws SiteNotFoundException - */ - public function getPaymentCurrency(string $siteHandle = null): ?string - { - /** @var Site|StoreBehavior|null $site */ - $site = $siteHandle ? Craft::$app->getSites()->getSiteByHandle($siteHandle) : Craft::$app->getSites()->getPrimarySite(); - if (!$site) { - throw new InvalidConfigException("Invalid site: $siteHandle"); - } - - $paymentCurrency = ConfigHelper::localizedValue($this->paymentCurrency, $siteHandle); - $allPaymentCurrencies = Plugin::getInstance()->getPaymentCurrencies()->getAllPaymentCurrencies($site->getStore()->id); - - if ($paymentCurrency && !$allPaymentCurrencies->contains('iso', '==', $paymentCurrency)) { - throw new InvalidConfigException("Invalid payment currency: $paymentCurrency"); - } - - return $paymentCurrency; - } - - /** - * Returns a key-value array of default control panel view options and labels. - * - * @since 2.2 - */ - public function getDefaultViewOptions(): array - { - return [ - self::VIEW_URI_ORDERS => Craft::t('commerce', 'Orders'), - self::VIEW_URI_PRODUCTS => Craft::t('commerce', 'Products'), - self::VIEW_URI_INVENTORY => Craft::t('commerce', 'Inventory'), - self::VIEW_URI_STORE_MANAGEMENT => Craft::t('commerce', 'Store Management'), - self::VIEW_URI_SUBSCRIPTIONS => Craft::t('commerce', 'Subscriptions'), - ]; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['weightUnits', 'dimensionUnits'], 'required'], - ]; - } -} diff --git a/src/models/ShippingAddressZone.php b/src/models/ShippingAddressZone.php deleted file mode 100644 index 97692a973c..0000000000 --- a/src/models/ShippingAddressZone.php +++ /dev/null @@ -1,79 +0,0 @@ - - * @since 2.0 - * - * @property-read string $cpEditUrl - */ -class ShippingAddressZone extends Zone implements Chippable -{ - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['name'], UniqueValidator::class, 'targetClass' => ShippingZone::class, 'targetAttribute' => ['name', 'storeId']]; - - return $rules; - } - - /** - * @return string - * @throws InvalidConfigException - */ - public function getCpEditUrl(): string - { - return UrlHelper::cpUrl('commerce/store-management/' . $this->getStore()->handle . '/shippingzones/' . $this->id); - } - - /** - * @inheritdoc - */ - public static function get(int|string $id): ?static - { - foreach (Plugin::getInstance()->getStores()->getAllStores() as $store) { - $zone = Plugin::getInstance()->getShippingZones()->getShippingZoneById((int)$id, $store->id); - if ($zone !== null) { - /** @phpstan-ignore-next-line */ - return $zone; - } - } - return null; - } - - /** - * @inheritdoc - */ - public function getUiLabel(): string - { - return Craft::t('site', $this->name); - } - - /** - * @inheritdoc - */ - public function getId(): ?int - { - return $this->id; - } -} diff --git a/src/models/ShippingCategory.php b/src/models/ShippingCategory.php deleted file mode 100644 index a1413d0594..0000000000 --- a/src/models/ShippingCategory.php +++ /dev/null @@ -1,231 +0,0 @@ - - * @since 2.0 - */ -class ShippingCategory extends Model implements HasStoreInterface, Chippable, Colorable, Iconic -{ - use StoreTrait; - - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string|null Name - */ - public ?string $name = null; - - /** - * @var string|null Handle - */ - public ?string $handle = null; - - /** - * @var string|null Icon - */ - public ?string $icon = null; - - /** - * @var string|null Color - */ - public ?string $color = null; - - /** - * @var string|null Description - */ - public ?string $description = null; - - /** - * @var bool Default - */ - public bool $default = false; - - /** - * @var ProductType[]|null - */ - private ?array $_productTypes = null; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateCreated = null; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateUpdated = null; - - /** - * @var DateTime|null Date deleted - * @since 4.2.0.1 - */ - public ?DateTime $dateDeleted = null; - - /** - * Returns the name of this shipping category. - * - * @return string - */ - public function __toString() - { - return (string)$this->name; - } - - public function getCpEditUrl(): string - { - return $this->getStore()->getStoreSettingsUrl('shippingcategories/' . $this->id); - } - - /** - * @inheritdoc - */ - public static function get(int|string $id): ?static - { - /** @var Site|StoreBehavior|null $site */ - $site = Cp::requestedSite(); - $storeId = $site?->getStore()->id ?? null; - - /** @phpstan-ignore-next-line */ - return Plugin::getInstance()->getShippingCategories()->getShippingCategoryById($id, $storeId); - } - - /** - * @inheritdoc - */ - public function getUiLabel(): string - { - return Craft::t('site', $this->name); - } - - /** - * @inheritdoc - */ - public function getId(): ?int - { - return $this->id; - } - - /** - * @inheritdoc - */ - public function getIcon(): ?string - { - return $this->icon; - } - - /** - * @inheritdoc - */ - public function getColor(): ?Color - { - return $this->color ? Color::tryFrom($this->color) : null; - } - - /** - * @param ProductType[] $productTypes - */ - public function setProductTypes(array $productTypes): void - { - $this->_productTypes = $productTypes; - } - - /** - * @return ProductType[] - * @throws InvalidConfigException - */ - public function getProductTypes(): array - { - if (!isset($this->_productTypes) && $this->id) { - $this->_productTypes = Plugin::getInstance()->getProductTypes()->getProductTypesByShippingCategoryId($this->id); - } - - return $this->_productTypes ?? []; - } - - /** - * Helper method to just get the product type IDs - * - * @return int[] - * @throws InvalidConfigException - */ - public function getProductTypeIds(): array - { - return ArrayHelper::getColumn($this->getProductTypes(), 'id', false); - } - - protected function defineRules(): array - { - return [ - [['name', 'handle'], 'required'], - [['handle'], - UniqueValidator::class, - 'targetClass' => ShippingCategoryRecord::class, - 'targetAttribute' => ['handle', 'storeId'], - 'message' => '{attribute} "{value}" has already been taken.', - ], - [['handle'], HandleValidator::class], - [[ - 'dateCreated', - 'dateDeleted', - 'dateUpdated', - 'default', - 'description', - 'handle', - 'icon', - 'color', - 'id', - 'name', - 'storeId', - ], 'safe'], - ]; - } - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $fields = parent::extraFields(); - $fields[] = 'productTypes'; - $fields[] = 'productTypeIds'; - $fields[] = 'uiLabel'; - - return $fields; - } -} diff --git a/src/models/ShippingMethod.php b/src/models/ShippingMethod.php deleted file mode 100644 index 64d7996cb6..0000000000 --- a/src/models/ShippingMethod.php +++ /dev/null @@ -1,191 +0,0 @@ - - * @since 2.0 - */ -class ShippingMethod extends BaseShippingMethod implements Chippable, Colorable, Iconic, Statusable -{ - public function behaviors(): array - { - $behaviors = parent::behaviors(); - - $behaviors['typecast'] = [ - 'class' => AttributeTypecastBehavior::class, - 'attributeTypes' => [ - 'id' => AttributeTypecastBehavior::TYPE_INTEGER, - 'name' => AttributeTypecastBehavior::TYPE_STRING, - 'handle' => AttributeTypecastBehavior::TYPE_STRING, - 'enabled' => AttributeTypecastBehavior::TYPE_BOOLEAN, - ], - ]; - - return $behaviors; - } - - /** - * @inheritdoc - */ - public function getType(): string - { - return Craft::t('commerce', 'Custom'); - } - - /** - * @inheritdoc - */ - public function getId(): ?int - { - return $this->id; - } - - /** - * @inheritdoc - */ - public function getName(): string - { - return (string)$this->name; - } - - /** - * @inheritdoc - */ - public function getHandle(): string - { - return (string)$this->handle; - } - - /** - * @inheritdoc - */ - public function getShippingRules(): Collection - { - if ($this->id === null) { - return collect(); - } - - return Plugin::getInstance()->getShippingRules()->getAllShippingRulesByShippingMethodId($this->id); - } - - /** - * @inheritdoc - */ - public function getIsEnabled(): bool - { - return $this->enabled; - } - - /** - * @inheritdoc - */ - public function getCpEditUrl(): string - { - return $this->getStore()->getStoreSettingsUrl('shippingmethods/' . $this->id); - } - - /** - * @inheritdoc - */ - public static function get(int|string $id): ?static - { - /** @phpstan-ignore-next-line */ - return Plugin::getInstance()->getShippingMethods()->getShippingMethodById($id); - } - - /** - * @inheritdoc - */ - public function getUiLabel(): string - { - return Craft::t('site', $this->name); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['name', 'handle'], 'required']; - $rules[] = [['name'], UniqueValidator::class, 'targetClass' => ShippingMethodRecord::class, 'targetAttribute' => ['name', 'storeId']]; - $rules[] = [['handle'], UniqueValidator::class, - 'targetClass' => ShippingMethodRecord::class, - 'targetAttribute' => ['handle', 'storeId'], - 'message' => '{attribute} "{value}" has already been taken.', - ]; - - return $rules; - } - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $fields = parent::extraFields(); - $fields[] = 'shippingRules'; - - return $fields; - } - - /** - * @inheritdoc - */ - public function getIcon(): ?string - { - return $this->icon; - } - - /** - * @inheritdoc - */ - public function getColor(): ?Color - { - return $this->color ? Color::tryFrom($this->color) : null; - } - - /** - * @inheritdoc - */ - public static function statuses(): array - { - return [ - 'enabled' => ['label' => Craft::t('commerce', 'Enabled'), 'color' => 'green'], - 'disabled' => ['label' => Craft::t('commerce', 'Disabled'), 'color' => 'red'], - ]; - } - - /** - * @inheritdoc - */ - public function getStatus(): ?string - { - return $this->enabled ? 'enabled' : 'disabled'; - } -} diff --git a/src/models/ShippingMethodOption.php b/src/models/ShippingMethodOption.php deleted file mode 100644 index 57db9d9d88..0000000000 --- a/src/models/ShippingMethodOption.php +++ /dev/null @@ -1,94 +0,0 @@ - - * @since 3.1 - */ -class ShippingMethodOption extends ShippingMethod -{ - /** - * @var Order - */ - private Order $_order; - - /** - * @var float Price of the shipping method option - */ - public float $price; - - /** - * @var boolean - */ - public bool $matchesOrder; - - /** - * @var ?ShippingMethodInterface - * @since 4.3.1 - */ - public ?ShippingMethodInterface $shippingMethod = null; - - /** - * @throws InvalidConfigException - */ - public function behaviors(): array - { - $behaviors = parent::behaviors(); - - $behaviors['currencyAttributes'] = [ - 'class' => CurrencyAttributeBehavior::class, - 'currencyAttributes' => $this->currencyAttributes(), - ]; - - return $behaviors; - } - - /** - * The attributes on the order that should be made available as formatted currency. - */ - public function currencyAttributes(): array - { - $attributes = []; - $attributes[] = 'price'; - return $attributes; - } - - protected function getCurrency(): string - { - if (!isset($this->_order->currency)) { - throw new InvalidConfigException('Order doesn’t have a currency.'); - } - - return $this->_order->currency; - } - - public function getPrice(): float - { - return $this->price; - } - - /** - * @since 3.1.10 - */ - public function setOrder(Order $order): void - { - $this->_order = $order; - } -} diff --git a/src/models/ShippingRule.php b/src/models/ShippingRule.php deleted file mode 100644 index 059b6ab4f7..0000000000 --- a/src/models/ShippingRule.php +++ /dev/null @@ -1,518 +0,0 @@ - - * @since 2.0 - */ -class ShippingRule extends Model implements ShippingRuleInterface, HasStoreInterface -{ - use StoreTrait; - - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string|null Name - */ - public ?string $name = null; - - /** - * @var string|null Description - */ - public ?string $description = null; - - /** - * @var int|null Shipping method ID - */ - public ?int $methodId = null; - - /** - * @var int Priority - */ - public int $priority = 0; - - /** - * @var bool Enabled - */ - public bool $enabled = true; - - /** - * @var string|null Order Condition Formula - */ - public ?string $orderConditionFormula = ''; - - /** - * @var float Base rate - */ - public float $baseRate = 0; - - /** - * @var float Per item rate - */ - public float $perItemRate = 0; - - /** - * @var float Percentage rate - */ - public float $percentageRate = 0; - - /** - * @var float Weight rate - */ - public float $weightRate = 0; - - /** - * @var float Minimum Rate - */ - public float $minRate = 0; - - /** - * @var float Maximum rate - */ - public float $maxRate = 0; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateCreated = null; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateUpdated = null; - - /** - * @var ShippingRuleCategory[]|null - */ - private ?array $_shippingRuleCategories = null; - - /** - * @var string|array|ShippingRuleOrderCondition|null - * @see setOrderCondition() - * @see getOrderCondition() - * @since 5.0.0 - */ - private ShippingRuleOrderCondition|string|array|null $_orderCondition = null; - - /** - * @var string|array|ShippingRuleCustomerCondition|null - * @see setCustomerCondition() - * @see getCustomerCondition() - * @since 5.4.0 - */ - private ShippingRuleCustomerCondition|string|array|null $_customerCondition = null; - - /** - * @throws InvalidConfigException - */ - private function _getUniqueCategoryIdsInOrder(Order $order): array - { - $orderShippingCategories = []; - foreach ($order->getLineItems() as $lineItem) { - // Don't look at the shipping category of non-shippable products. - if (!$lineItem->getIsShippable()) { - continue; - } - - $orderShippingCategories[] = $lineItem->shippingCategoryId; - } - - return array_unique($orderShippingCategories); - } - - /** - * @param $shippingRuleCategories - * @return array - */ - private function _getRequiredAndDisallowedCategoriesFromRule($shippingRuleCategories): array - { - $disallowedCategories = []; - $requiredCategories = []; - foreach ($shippingRuleCategories as $ruleCategory) { - if ($ruleCategory->condition === ShippingRuleCategoryRecord::CONDITION_DISALLOW) { - $disallowedCategories[] = $ruleCategory->shippingCategoryId; - } - - if ($ruleCategory->condition === ShippingRuleCategoryRecord::CONDITION_REQUIRE) { - $requiredCategories[] = $ruleCategory->shippingCategoryId; - } - } - return [$disallowedCategories, $requiredCategories]; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [ - [ - 'name', - 'methodId', - 'priority', - 'enabled', - 'baseRate', - 'perItemRate', - 'weightRate', - 'percentageRate', - 'minRate', - 'maxRate', - ], - 'required', - ], - [['perItemRate', 'weightRate', 'percentageRate'], 'number'], - [['shippingRuleCategories'], 'validateShippingRuleCategories', 'skipOnEmpty' => true], - [['orderConditionFormula'], 'string', 'length' => [1, 65000], 'skipOnEmpty' => true], - [ - 'orderConditionFormula', - function($attribute) { - if ($this->{$attribute}) { - $order = Order::find()->one(); - if (!$order) { - $order = new Order(); - } - $orderAsArray = Plugin::getInstance()->getShippingMethods()->getSerializedOrderForMatchingRules($order); - $orderConditionParams = [ - 'order' => $orderAsArray, - ]; - if (!Plugin::getInstance()->getFormulas()->validateConditionSyntax($this->{$attribute}, $orderConditionParams)) { - $this->addError($attribute, Craft::t('commerce', 'Invalid order condition syntax.')); - } - } - }, - ], - [['id', 'customerCondition', 'orderCondition', 'description', 'storeId'], 'safe'], - ]; - } - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $fields = parent::extraFields(); - $fields[] = 'shippingRuleCategories'; - - return $fields; - } - - /** - * @inheritdoc - */ - public function getIsEnabled(): bool - { - return $this->enabled; - } - - /** - * @param ShippingRuleOrderCondition|string|array|null $condition - * @return void - * @throws InvalidConfigException - * @since 5.0.0 - */ - public function setOrderCondition(ShippingRuleOrderCondition|string|array|null $condition): void - { - if (empty($condition)) { - $this->_orderCondition = null; - return; - } - - $this->_orderCondition = $condition; - } - - /** - * @return ShippingRuleOrderCondition - * @since 5.0.0 - */ - public function getOrderCondition(): ShippingRuleOrderCondition - { - if ($this->_orderCondition instanceof ShippingRuleOrderCondition) { - return $this->_orderCondition; - } - - $condition = $this->_orderCondition ?? []; - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - $condition['class'] = ShippingRuleOrderCondition::class; - $condition = Craft::$app->getConditions()->createCondition($condition); - /** @var ShippingRuleOrderCondition $condition */ - $condition->forProjectConfig = false; - $condition->mainTag = 'div'; - $condition->name = 'orderCondition'; - $condition->storeId = $this->storeId; - - $this->_orderCondition = $condition; - - return $this->_orderCondition; - } - - /** - * @param ShippingRuleCustomerCondition|string|array|null $condition - * @return void - * @throws InvalidConfigException - * @since 5.4.0 - */ - public function setCustomerCondition(ShippingRuleCustomerCondition|string|array|null $condition): void - { - if (empty($condition)) { - $this->_customerCondition = null; - return; - } - - $this->_customerCondition = $condition; - } - - /** - * @return ShippingRuleCustomerCondition - * @throws InvalidConfigException - * @since 5.4.0 - */ - public function getCustomerCondition(): ShippingRuleCustomerCondition - { - if ($this->_customerCondition instanceof ShippingRuleCustomerCondition) { - return $this->_customerCondition; - } - - $condition = $this->_customerCondition ?? []; - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - $condition['class'] = ShippingRuleCustomerCondition::class; - $condition = Craft::$app->getConditions()->createCondition($condition); - /** @var ShippingRuleCustomerCondition $condition */ - $condition->forProjectConfig = false; - $condition->mainTag = 'div'; - $condition->name = 'customerCondition'; - $this->_customerCondition = $condition; - - return $this->_customerCondition; - } - - /** - * @inheritdoc - */ - public function matchOrder(Order $order): bool - { - if (!$this->enabled) { - return false; - } - - $lineItems = $order->getLineItems(); - - $nonShippableItems = []; - foreach ($lineItems as $item) { - if ($item->getIsShippable()) { - continue; - } - - $nonShippableItems[$item->id] = $item->id; - } - - $wholeOrderNonShippable = count($nonShippableItems) > 0 && count($lineItems) == count($nonShippableItems); - - if ($wholeOrderNonShippable) { - return false; - } - - $shippingRuleCategories = $this->getShippingRuleCategories(); - $orderShippingCategories = $this->_getUniqueCategoryIdsInOrder($order); - [$disallowedCategories, $requiredCategories] = $this->_getRequiredAndDisallowedCategoriesFromRule($shippingRuleCategories); - - // Does the order have any disallowed categories in the cart? - $result = array_intersect($orderShippingCategories, $disallowedCategories); - if (!empty($result)) { - return false; - } - - // Does the order have all required categories in the cart? - $result = !array_diff($requiredCategories, $orderShippingCategories); - if (!$result) { - return false; - } - - // Order condition builder match - if (!$this->getOrderCondition()->matchElement($order)) { - return false; - } - - $customer = $order->getCustomer(); - // If there is no customer on the order and there are customer conditions, we can't match. - if (!$customer && !empty($this->getCustomerCondition()->getConditionRules())) { - return false; - } - - // Match the method's customer condition. - if ($customer && !$this->getCustomerCondition()->matchElement($customer)) { - return false; - } - - // Evaluate the Twig formula last — it's the most expensive check. - if ($this->orderConditionFormula) { - $orderAsArray = Plugin::getInstance()->getShippingMethods()->getSerializedOrderForMatchingRules($order); - $orderConditionParams = [ - 'order' => $orderAsArray, - ]; - if (!Plugin::getInstance()->getFormulas()->evaluateCondition($this->orderConditionFormula, $orderConditionParams, 'Evaluate Shipping Rule Order Condition Formula')) { - return false; - } - } - - // all rules match - return true; - } - - /** - * @return ShippingRuleCategory[] - * @throws InvalidConfigException - */ - public function getShippingRuleCategories(): array - { - if ($this->_shippingRuleCategories === null && $this->id) { - $this->_shippingRuleCategories = Plugin::getInstance()->getShippingRuleCategories()->getShippingRuleCategoriesByRuleId($this->id); - } - - return $this->_shippingRuleCategories ?? []; - } - - /** - * @param ShippingRuleCategory[] $models - */ - public function setShippingRuleCategories(array $models): void - { - $this->_shippingRuleCategories = $models; - } - - /** - * @inheritdoc - */ - public function getOptions(): array - { - return $this->getAttributes(); - } - - /** - * @inheritdoc - */ - public function getPercentageRate(?int $shippingCategoryId = null): float - { - return $this->_getRate('percentageRate', $shippingCategoryId); - } - - /** - * @inheritdoc - */ - public function getPerItemRate(?int $shippingCategoryId = null): float - { - return $this->_getRate('perItemRate', $shippingCategoryId); - } - - /** - * @inheritdoc - */ - public function getWeightRate(?int $shippingCategoryId = null): float - { - return $this->_getRate('weightRate', $shippingCategoryId); - } - - /** - * @inheritdoc - */ - public function getBaseRate(): float - { - return (float)$this->baseRate; - } - - /**@inheritdoc - */ - public function getMaxRate(): float - { - return (float)$this->maxRate; - } - - /** - * @inheritdoc - */ - public function getMinRate(): float - { - return (float)$this->minRate; - } - - /** - * @inheritdoc - */ - public function getDescription(): string - { - return $this->description ?? ''; - } - - /** - * @since 3.2.7 - */ - public function validateShippingRuleCategories(string $attribute): void - { - $ruleCategories = $this->$attribute; - - if (!empty($ruleCategories)) { - foreach ($ruleCategories as $key => $ruleCategory) { - if (!$ruleCategory->validate()) { - $this->addModelErrors($ruleCategory, $attribute . '.' . $key); - } - } - } - } - - /** - * @param $attribute - * @param int|null $shippingCategoryId - * @return mixed - * @throws InvalidConfigException - */ - private function _getRate($attribute, ?int $shippingCategoryId = null): mixed - { - if (!$shippingCategoryId) { - return $this->$attribute; - } - - foreach ($this->getShippingRuleCategories() as $ruleCategory) { - if ($shippingCategoryId === $ruleCategory->shippingCategoryId && $ruleCategory->$attribute !== null) { - return $ruleCategory->$attribute; - } - } - - return $this->$attribute; - } -} diff --git a/src/models/ShippingRuleCategory.php b/src/models/ShippingRuleCategory.php deleted file mode 100644 index cb0c756da9..0000000000 --- a/src/models/ShippingRuleCategory.php +++ /dev/null @@ -1,96 +0,0 @@ - - * @since 2.0 - */ -class ShippingRuleCategory extends Model -{ - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var int Shipping rule ID - */ - public int $shippingRuleId; - - /** - * @var int Shipping category ID - */ - public int $shippingCategoryId; - - /** - * @var float|null Per item rate - */ - public ?float $perItemRate = null; - - /** - * @var float|null Weight rate - */ - public ?float $weightRate = null; - - /** - * @var float|null Percentage rate - */ - public ?float $percentageRate = null; - - /** - * @var string Condition - */ - public string $condition; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['condition'], 'in', 'range' => ['allow', 'disallow', 'require']], - [['perItemRate', 'weightRate', 'percentageRate'], 'number', 'skipOnEmpty' => true], - [ - [ - 'shippingRuleId', - 'shippingCategoryId', - 'condition', - 'perItemRate', - 'weightRate', - 'percentageRate', - ], - 'safe', - ], - ]; - } - - /** - * @throws InvalidConfigException - */ - public function getRule(): ShippingRule - { - return Plugin::getInstance()->getShippingRules()->getShippingRuleById($this->shippingRuleId); - } - - /** - * @throws InvalidConfigException - */ - public function getCategory(): ShippingCategory - { - return Plugin::getInstance()->getShippingCategories()->getShippingCategoryById($this->shippingCategoryId); - } -} diff --git a/src/models/SiteStore.php b/src/models/SiteStore.php deleted file mode 100644 index 7773d445bf..0000000000 --- a/src/models/SiteStore.php +++ /dev/null @@ -1,78 +0,0 @@ - - * @since 5.0 - */ -class SiteStore extends Model implements HasStoreInterface -{ - use StoreTrait; - - /** - * @var int Site ID - */ - public int $siteId; - - /** - * @var string|null Store UID - */ - public ?string $uid = null; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['storeId', 'siteId'], 'required']; - $rules[] = [['storeId', 'siteId'], 'safe']; - - return $rules; - } - - /** - * @return Site|null - */ - public function getSite() - { - return Craft::$app->getSites()->getSiteById($this->siteId); - } - - /** - * @return string|null - */ - public function getStoreUid() - { - if ($this->storeId && $uid = Db::uidById('{{%commerce_stores}}', $this->storeId)) { - return $uid; - } - - return null; - } - - /** - * Returns the project config data for this store. - */ - public function getConfig(): array - { - return [ - 'store' => $this->getStoreUid(), - ]; - } -} diff --git a/src/models/Store.php b/src/models/Store.php deleted file mode 100644 index 468eca6112..0000000000 --- a/src/models/Store.php +++ /dev/null @@ -1,777 +0,0 @@ - - * @since 4.0 - * - * @property-read StoreSettings|null $settings - * @property-write string $name - * @property-read array $config - */ -class Store extends Model -{ - public const MINIMUM_TOTAL_PRICE_STRATEGY_DEFAULT = 'default'; - public const MINIMUM_TOTAL_PRICE_STRATEGY_ZERO = 'zero'; - public const MINIMUM_TOTAL_PRICE_STRATEGY_SHIPPING = 'shipping'; - - public const FREE_ORDER_PAYMENT_STRATEGY_COMPLETE = 'complete'; - public const FREE_ORDER_PAYMENT_STRATEGY_PROCESS = 'process'; - - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string|null - */ - private ?string $_name = null; - - /** - * @var string|null Handle - */ - public ?string $handle = null; - - /** - * @var bool Primary store? - */ - public bool $primary = false; - - /** - * @var int Sort order - */ - public int $sortOrder = 99; - - private ?string $_currency = 'USD'; - - /** - * @var bool - * @see setAutoSetNewCartAddresses() - * @see getAutoSetNewCartAddresses() - */ - private bool|string $_autoSetNewCartAddresses = false; - - /** - * @var bool - * @see setAutoSetCartShippingMethodOption() - * @see getAutoSetCartShippingMethodOption() - */ - private bool|string $_autoSetCartShippingMethodOption = false; - - /** - * @var bool - * @see setAutoSetPaymentSource() - * @see getAutoSetPaymentSource() - */ - private bool|string $_autoSetPaymentSource = false; - - /** - * @var bool - * @see setAllowEmptyCartOnCheckout() - * @see getAllowEmptyCartOnCheckout() - */ - private bool|string $_allowEmptyCartOnCheckout = false; - - /** - * @inheritdoc - */ - public function attributes(): array - { - $names = parent::attributes(); - $names[] = 'name'; - $names[] = 'settings'; - return $names; - } - - /** - * @var bool - * @see setAllowCheckoutWithoutPayment() - * @see getAllowCheckoutWithoutPayment() - */ - private bool|string $_allowCheckoutWithoutPayment = false; - - /** - * @var bool - * @see setAllowPartialPaymentOnCheckout() - * @see getAllowPartialPaymentOnCheckout() - */ - private bool|string $_allowPartialPaymentOnCheckout = false; - - /** - * @var bool - * @see setRequireShippingAddressAtCheckout() - * @see getRequireShippingAddressAtCheckout() - */ - private bool|string $_requireShippingAddressAtCheckout = false; - - /** - * @var bool - * @see setRequireBillingAddressAtCheckout() - * @see getRequireBillingAddressAtCheckout() - */ - private bool|string $_requireBillingAddressAtCheckout = false; - - /** - * @var bool - * @see setRequireShippingMethodSelectionAtCheckout() - * @see getRequireShippingMethodSelectionAtCheckout() - */ - private bool|string $_requireShippingMethodSelectionAtCheckout = false; - - /** - * @var bool - * @see setUseBillingAddressForTax() - * @see getUseBillingAddressForTax() - */ - private bool|string $_useBillingAddressForTax = false; - - /** - * @var bool - * @see setValidateOrganizationTaxIdAsVatId() - * @see getValidateOrganizationTaxIdAsVatId() - */ - private bool|string $_validateOrganizationTaxIdAsVatId = false; - - /** - * @var string - * @see setOrderReferenceFormat() - * @see getOrderReferenceFormat() - */ - private string $_orderReferenceFormat = '{{number[:7]}}'; - - /** - * @var string - * @see setFreeOrderPaymentStrategy() - * @see getFreeOrderPaymentStrategy() - */ - private string $_freeOrderPaymentStrategy = 'complete'; - - /** - * @var string - * @see setMinimumTotalPriceStrategy() - * @see getMinimumTotalPriceStrategy() - */ - private string $_minimumTotalPriceStrategy = 'default'; - - /** - * @var string|null Store UID - */ - public ?string $uid = null; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['handle'], UniqueValidator::class, 'targetClass' => StoreRecord::class, 'targetAttribute' => ['handle']]; - $rules[] = [['name', 'handle'], 'required']; - $rules[] = [ - ['currency'], - // Only allow changing of currency if the store has no orders - function($attribute) { - $isCurrencyChanging = \craft\commerce\records\Store::findOne(['id' => $this->id, 'currency' => $this->$attribute]) === null; - - if (!$isCurrencyChanging) { - return; - } - - $hasOrders = Order::find() - ->trashed(null) - ->storeId($this->id) - ->exists(); - - if ($hasOrders) { - $this->addError($attribute, Craft::t('commerce', 'The primary currency cannot be changed after orders are placed.')); - } - }, - 'when' => fn() => $this->id, - ]; - $rules[] = [[ - 'allowCheckoutWithoutPayment', - 'allowEmptyCartOnCheckout', - 'allowPartialPaymentOnCheckout', - 'autoSetCartShippingMethodOption', - 'autoSetNewCartAddresses', - 'autoSetPaymentSource', - 'freeOrderPaymentStrategy', - 'id', - 'orderReferenceFormat', - 'primary', - 'requireBillingAddressAtCheckout', - 'requireShippingAddressAtCheckout', - 'requireShippingMethodSelectionAtCheckout', - 'sortOrder', - 'uid', - 'useBillingAddressForTax', - 'validateOrganizationTaxIdAsVatId', - ], 'safe']; - - return $rules; - } - - /** - * Returns the store’s name. - * - * @param bool $parse Whether to parse the name for an environment variable - * @return string - */ - public function getName(bool $parse = true): string - { - return ($parse ? App::parseEnv($this->_name) : $this->_name) ?? ''; - } - - /** - * Sets the store’s name. - * - * @param string $name - */ - public function setName(string $name): void - { - $this->_name = $name; - } - - /** - * @inheritdoc - */ - protected function defineBehaviors(): array - { - return [ - 'parser' => [ - 'class' => EnvAttributeParserBehavior::class, - 'attributes' => [ - 'name' => fn() => $this->getName(false), - ], - ], - ]; - } - - /** - * Gets the CP url to these stores settings - * - * @param string|null $path - * @return string - */ - public function getStoreSettingsUrl(?string $path = null): string - { - $path = $path ? '/' . $path : ''; - return UrlHelper::cpUrl('commerce/store-management/' . $this->handle . $path); - } - - /** - * @return StoreSettings - */ - public function getSettings(): StoreSettings - { - return Plugin::getInstance()->getStoreSettings()->getStoreSettingsById($this->id); - } - - /** - * Returns the sites that are related to this store. - * - * @return Collection - * @throws InvalidConfigException - */ - public function getSites(): Collection - { - return Plugin::getInstance()->getStores()->getAllSitesForStore($this); - } - - /** - * Returns the names of the sites related to this store - * - * @return Collection - * @throws InvalidConfigException - */ - public function getSiteNames(): Collection - { - return collect($this->getSites())->map(fn(Site $site) => $site->getName()); - } - - /** - * @inheritdoc - */ - public function attributeLabels(): array - { - return [ - 'name' => Craft::t('commerce', 'Name'), - 'commerce' => Craft::t('commerce', 'Handle'), - 'primary' => Craft::t('commerce', 'Primary'), - ]; - } - - /** - * Returns the project config data for this store. - */ - public function getConfig(): array - { - return [ - 'allowCheckoutWithoutPayment' => $this->getAllowCheckoutWithoutPayment(false), - 'allowEmptyCartOnCheckout' => $this->getAllowEmptyCartOnCheckout(false), - 'allowPartialPaymentOnCheckout' => $this->getAllowPartialPaymentOnCheckout(false), - 'autoSetCartShippingMethodOption' => $this->getAutoSetCartShippingMethodOption(false), - 'autoSetNewCartAddresses' => $this->getAutoSetNewCartAddresses(false), - 'autoSetPaymentSource' => $this->getAutoSetPaymentSource(false), - 'freeOrderPaymentStrategy' => $this->getFreeOrderPaymentStrategy(false), - 'handle' => $this->handle, - 'minimumTotalPriceStrategy' => $this->getMinimumTotalPriceStrategy(false), - 'name' => $this->_name, - 'orderReferenceFormat' => $this->getOrderReferenceFormat(false), - 'primary' => $this->primary, - 'requireBillingAddressAtCheckout' => $this->getRequireBillingAddressAtCheckout(false), - 'requireShippingAddressAtCheckout' => $this->getRequireShippingAddressAtCheckout(false), - 'requireShippingMethodSelectionAtCheckout' => $this->getRequireShippingMethodSelectionAtCheckout(false), - 'sortOrder' => $this->sortOrder, - 'useBillingAddressForTax' => $this->getUseBillingAddressForTax(false), - 'validateOrganizationTaxIdAsVatId' => $this->getValidateOrganizationTaxIdAsVatId(false), - 'currency' => $this->getCurrency()->getCode(), - ]; - } - - /** - * Returns a key-value array of `freeOrderPaymentStrategy` options and labels. - */ - public function getFreeOrderPaymentStrategyOptions(): array - { - return [ - self::FREE_ORDER_PAYMENT_STRATEGY_COMPLETE => Craft::t('commerce', 'Free orders complete immediately'), - self::FREE_ORDER_PAYMENT_STRATEGY_PROCESS => Craft::t('commerce', 'Free orders are processed by the payment gateway'), - ]; - } - - /** - * Returns a key-value array of `minimumTotalPriceStrategy` options and labels. - */ - public function getMinimumTotalPriceStrategyOptions(): array - { - return [ - self::MINIMUM_TOTAL_PRICE_STRATEGY_DEFAULT => Craft::t('commerce', 'Default - Allow the price to be negative if discounts are greater than the order value.'), - self::MINIMUM_TOTAL_PRICE_STRATEGY_ZERO => Craft::t('commerce', 'Zero - Minimum price is zero if discounts are greater than the order value.'), - self::MINIMUM_TOTAL_PRICE_STRATEGY_SHIPPING => Craft::t('commerce', 'Shipping - Minimum cost is the shipping cost, if the order price is less than the shipping cost.'), - ]; - } - - /** - * @param bool|string $autoSetNewCartAddresses - * @return void - */ - public function setAutoSetNewCartAddresses(bool|string $autoSetNewCartAddresses): void - { - $this->_autoSetNewCartAddresses = $autoSetNewCartAddresses; - } - - /** - * Whether the user’s primary shipping and billing addresses should be set automatically on new carts. - * - * @param bool $parse - * @return bool|string - */ - public function getAutoSetNewCartAddresses(bool $parse = true): bool|string - { - return $parse ? (App::parseBooleanEnv($this->_autoSetNewCartAddresses) ?? false) : $this->_autoSetNewCartAddresses; - } - - /** - * @param bool|string $autoSetCartShippingMethodOption - * @return void - */ - public function setAutoSetCartShippingMethodOption(bool|string $autoSetCartShippingMethodOption): void - { - $this->_autoSetCartShippingMethodOption = $autoSetCartShippingMethodOption; - } - - /** - * Whether the first available shipping method option should be set automatically on carts. - * - * @param bool $parse - * @return bool|string - */ - public function getAutoSetCartShippingMethodOption(bool $parse = true): bool|string - { - return $parse ? (App::parseBooleanEnv($this->_autoSetCartShippingMethodOption) ?? false) : $this->_autoSetCartShippingMethodOption; - } - - /** - * @param bool|string $autoSetPaymentSource - * @return void - */ - public function setAutoSetPaymentSource(bool|string $autoSetPaymentSource): void - { - $this->_autoSetPaymentSource = $autoSetPaymentSource; - } - - /** - * Whether the user’s primary payment source should be set automatically on new carts. - * - * @param bool $parse - * @return bool|string - */ - public function getAutoSetPaymentSource(bool $parse = true): bool|string - { - return $parse ? (App::parseBooleanEnv($this->_autoSetPaymentSource) ?? false) : $this->_autoSetPaymentSource; - } - - /** - * @param bool|string $allowEmptyCartOnCheckout - * @return void - */ - public function setAllowEmptyCartOnCheckout(bool|string $allowEmptyCartOnCheckout): void - { - $this->_allowEmptyCartOnCheckout = $allowEmptyCartOnCheckout; - } - - /** - * Whether carts are allowed to be empty on checkout. - * - * @param bool $parse - * @return bool|string - */ - public function getAllowEmptyCartOnCheckout(bool $parse = true): bool|string - { - return $parse ? (App::parseBooleanEnv($this->_allowEmptyCartOnCheckout) ?? false) : $this->_allowEmptyCartOnCheckout; - } - - /** - * @param bool|string $allowCheckoutWithoutPayment - * @return void - */ - public function setAllowCheckoutWithoutPayment(bool|string $allowCheckoutWithoutPayment): void - { - $this->_allowCheckoutWithoutPayment = $allowCheckoutWithoutPayment; - } - - /** - * Whether carts are can be marked as completed without a payment. - * - * @param bool $parse - * @return bool|string - */ - public function getAllowCheckoutWithoutPayment(bool $parse = true): bool|string - { - return $parse ? (App::parseBooleanEnv($this->_allowCheckoutWithoutPayment) ?? false) : $this->_allowCheckoutWithoutPayment; - } - - /** - * @param bool|string $allowPartialPaymentOnCheckout - * @return void - */ - public function setAllowPartialPaymentOnCheckout(bool|string $allowPartialPaymentOnCheckout): void - { - $this->_allowPartialPaymentOnCheckout = $allowPartialPaymentOnCheckout; - } - - /** - * Whether [partial payment](https://craftcms.com/docs/commerce/5.x/system/development/making-payments.html#checkout-with-partial-payment) can be made from the front end when the gateway allows them. - * - * The `false` default does not allow partial payments on the front end. - * - * @param bool $parse - * @return bool|string - */ - public function getAllowPartialPaymentOnCheckout(bool $parse = true): bool|string - { - return $parse ? (App::parseBooleanEnv($this->_allowPartialPaymentOnCheckout) ?? false) : $this->_allowPartialPaymentOnCheckout; - } - - /** - * @param bool|string $requireShippingAddressAtCheckout - * @return void - */ - public function setRequireShippingAddressAtCheckout(bool|string $requireShippingAddressAtCheckout): void - { - $this->_requireShippingAddressAtCheckout = $requireShippingAddressAtCheckout; - } - - /** - * @param bool $parse - * @return bool|string - */ - public function getRequireShippingAddressAtCheckout(bool $parse = true): bool|string - { - return $parse ? (App::parseBooleanEnv($this->_requireShippingAddressAtCheckout) ?? false) : $this->_requireShippingAddressAtCheckout; - } - - /** - * @param bool|string $requireBillingAddressAtCheckout - * @return void - */ - public function setRequireBillingAddressAtCheckout(bool|string $requireBillingAddressAtCheckout): void - { - $this->_requireBillingAddressAtCheckout = $requireBillingAddressAtCheckout; - } - - /** - * Whether a billing address is required before making payment on an order. - * - * @param bool $parse - * @return bool|string - */ - public function getRequireBillingAddressAtCheckout(bool $parse = true): bool|string - { - return $parse ? (App::parseBooleanEnv($this->_requireBillingAddressAtCheckout) ?? false) : $this->_requireBillingAddressAtCheckout; - } - - /** - * @param bool|string $requireShippingMethodSelectionAtCheckout - * @return void - */ - public function setRequireShippingMethodSelectionAtCheckout(bool|string $requireShippingMethodSelectionAtCheckout): void - { - $this->_requireShippingMethodSelectionAtCheckout = $requireShippingMethodSelectionAtCheckout; - } - - /** - * Whether shipping method selection is required before making payment on an order. - * - * @param bool $parse - * @return bool|string - */ - public function getRequireShippingMethodSelectionAtCheckout(bool $parse = true): bool|string - { - return $parse ? (App::parseBooleanEnv($this->_requireShippingMethodSelectionAtCheckout) ?? false) : $this->_requireShippingMethodSelectionAtCheckout; - } - - /** - * @param bool|string $useBillingAddressForTax - * @return void - */ - public function setUseBillingAddressForTax(bool|string $useBillingAddressForTax): void - { - $this->_useBillingAddressForTax = $useBillingAddressForTax; - } - - /** - * Whether taxes should be calculated based on the billing address instead of the shipping address. - * - * @param bool $parse - * @return bool|string - */ - public function getUseBillingAddressForTax(bool $parse = true): bool|string - { - return $parse ? (App::parseBooleanEnv($this->_useBillingAddressForTax) ?? false) : $this->_useBillingAddressForTax; - } - - /** - * @param bool|string $validateOrganizationTaxIdAsVatId - * @return void - */ - public function setValidateOrganizationTaxIdAsVatId(bool|string $validateOrganizationTaxIdAsVatId): void - { - $this->_validateOrganizationTaxIdAsVatId = $validateOrganizationTaxIdAsVatId; - } - - /** - * @param bool $parse - * @return bool|string Whether to enable validation requiring the `organizationTaxId` to be a valid VAT ID. - * - * When set to `false`, no validation is applied to `organizationTaxId`. - * - * When set to `true`, `organizationTaxId` must contain a valid VAT ID. - * - * ::: tip - * This setting strictly toggles input validation and has no impact on tax configuration or behavior elsewhere in the system. - * ::: - */ - public function getValidateOrganizationTaxIdAsVatId(bool $parse = true): bool|string - { - return $parse ? (App::parseBooleanEnv($this->_validateOrganizationTaxIdAsVatId) ?? false) : $this->_validateOrganizationTaxIdAsVatId; - } - - /** - * @param string|null $orderReferenceFormat - * @return void - */ - public function setOrderReferenceFormat(?string $orderReferenceFormat): void - { - if (!$orderReferenceFormat) { - return; - } - - $this->_orderReferenceFormat = $orderReferenceFormat; - } - - /** - * Human-friendly reference number format for orders. Result must be unique. - * - * See [Order Numbers](https://craftcms.com/docs/commerce/5.x/system/orders-carts.html#order-numbers). - * - * @param bool $parse - * @return string - */ - public function getOrderReferenceFormat(bool $parse = true): string - { - return $parse ? App::parseEnv($this->_orderReferenceFormat) : $this->_orderReferenceFormat; - } - - /** - * @param string $freeOrderPaymentStrategy - * @return void - */ - public function setFreeOrderPaymentStrategy(string $freeOrderPaymentStrategy): void - { - $this->_freeOrderPaymentStrategy = $freeOrderPaymentStrategy; - } - - /** - * How Commerce should handle free orders. - * - * The default `'complete'` setting automatically completes zero-balance orders without forwarding them to the payment gateway. - * - * The `'process'` setting forwards zero-balance orders to the payment gateway for processing. This can be useful if the customer’s balance - * needs to be updated or otherwise adjusted by the payment gateway. - * - * @param bool $parse - * @return string - */ - public function getFreeOrderPaymentStrategy(bool $parse = true): string - { - return $parse ? App::parseEnv($this->_freeOrderPaymentStrategy) : $this->_freeOrderPaymentStrategy; - } - - /** - * @param string $minimumTotalPriceStrategy - * @return void - */ - public function setMinimumTotalPriceStrategy(string $minimumTotalPriceStrategy): void - { - $this->_minimumTotalPriceStrategy = $minimumTotalPriceStrategy; - } - - /** - * How Commerce should handle minimum total price for an order. - * - * Options: - * - * - `'default'` [rounds](commerce4:\craft\commerce\helpers\Currency::round()) the sum of the item subtotal and adjustments. - * - `'zero'` returns `0` if the result from `'default'` would’ve been negative; minimum order total is `0`. - * - `'shipping'` returns the total shipping cost if the `'default'` result would’ve been negative; minimum order total equals shipping amount. - * - * @param bool $parse - * @return string - */ - public function getMinimumTotalPriceStrategy(bool $parse = true): string - { - return $parse ? App::parseEnv($this->_minimumTotalPriceStrategy) : $this->_minimumTotalPriceStrategy; - } - - /** - * @return void - * @throws DeprecationException - * @throws InvalidConfigException - * @deprecated in 5.0.0. Use [[Store::getSettings()->setCountries()]] instead. - */ - public function setCountries(mixed $countries): void - { - Craft::$app->getDeprecator()->log(__METHOD__, 'Store::setCountries() is deprecated. Use Store::getSettings()->setCountries() instead.'); - $this->getSettings()->setCountries($countries); - } - - /** - * @return string[] $countries - * @deprecated in 5.0.0. Use [[Store::getSettings()->getCountries()]] instead. - */ - public function getCountries(): array - { - Craft::$app->getDeprecator()->log(__METHOD__, 'Store::getCountries() is deprecated. Use Store::getSettings()->getCountries() instead.'); - return $this->getSettings()->getCountries(); - } - - /** - * @return array - * @throws DeprecationException - * @deprecated in 5.0.0. Use [[Store::getSettings()->getCountriesList()]] instead. - */ - public function getCountriesList(): array - { - Craft::$app->getDeprecator()->log(__METHOD__, 'Store::getCountriesList() has been deprecated. Use Store::getSettings()->getCountriesList() instead.'); - return $this->getSettings()->getCountriesList(); - } - - /** - * @return array - * @throws DeprecationException - * @deprecated in 5.0.0. Use [[Store::getSettings()->getAdministrativeAreasListByCountryCode()]] instead. - */ - public function getAdministrativeAreasListByCountryCode(): array - { - Craft::$app->getDeprecator()->log(__METHOD__, 'Store::getAdministrativeAreasListByCountryCode() has been deprecated. Use Store::getSettings()->getAdministrativeAreasListByCountryCode() instead.'); - return $this->getSettings()->getAdministrativeAreasListByCountryCode(); - } - - /** - * @return ZoneAddressCondition - * @deprecated in 5.0.0. Use [[Store::getSettings()->getMarketAddressCondition()]] instead. - */ - public function getMarketAddressCondition(): ZoneAddressCondition - { - Craft::$app->getDeprecator()->log(__METHOD__, 'Store::getMarketAddressCondition() has been deprecated. Use Store::getSettings()->getMarketAddressCondition() instead.'); - return $this->getSettings()->getMarketAddressCondition(); - } - - /** - * @return MoneyCurrency|null - */ - public function getCurrency(): ?MoneyCurrency - { - return $this->_currency ? (new MoneyCurrency($this->_currency)) : null; - } - - /** - * @param string|MoneyCurrency $currency - * @return void - */ - public function setCurrency(string|MoneyCurrency $currency): void - { - if ($currency instanceof MoneyCurrency) { - $currency = $currency->getCode(); - } - - $this->_currency = $currency; - } - - /** - * Returns the inventory locations related to this store. - * - * @return Collection - * @throws InvalidConfigException - * @throws \craft\errors\DeprecationException - */ - public function getInventoryLocations(): Collection - { - return Plugin::getInstance()->getInventoryLocations()->getInventoryLocations($this->id); - } - - /** - * @return array - * @throws InvalidConfigException - */ - public function getInventoryLocationsOptions(): array - { - return Plugin::getInstance()->getInventoryLocations()->getInventoryLocations($this->id)->map(fn($location) => ['value' => $location->id, 'label' => $location->getUiLabel()])->toArray(); - } -} diff --git a/src/models/StoreSettings.php b/src/models/StoreSettings.php deleted file mode 100644 index 40dc31ffb2..0000000000 --- a/src/models/StoreSettings.php +++ /dev/null @@ -1,252 +0,0 @@ - - * @since 5.0 - */ -class StoreSettings extends Model -{ - /** - * @var int - */ - public int $id; - - /** - * @var int|null - */ - private ?int $_locationAddressId = null; - - /** - * @var Address|null - */ - private ?Address $_locationAddress = null; - - /** - * @var array - */ - private array $_countries = []; - /** - * @var ?ZoneAddressCondition - */ - private ?ZoneAddressCondition $_marketAddressCondition = null; - - /** - * @inheritdoc - */ - public function attributes(): array - { - $names = parent::attributes(); - $names[] = 'locationAddressId'; - $names[] = 'countries'; - $names[] = 'marketAddressCondition'; - return $names; - } - - /** - * @inheritdoc - */ - public function safeAttributes(): array - { - return [ - 'id', - 'locationAddressId', - 'countries', - 'marketAddressCondition', - ]; - } - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $names = parent::extraFields(); - $names[] = 'locationAddress'; - - return $names; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - return $rules; - } - - /** - * Sets the store location address ID. - * - * @param null|int|int[] $locationAddressId - */ - public function setLocationAddressId(array|int|null $locationAddressId): void - { - if ($locationAddressId === null) { - $this->_locationAddressId = $this->getLocationAddress()->id; - } - - if (is_array($locationAddressId)) { - $this->_locationAddressId = ArrayHelper::firstValue($locationAddressId) ?: null; - } else { - $this->_locationAddressId = $locationAddressId; - } - } - - /** - * Returns the store location address ID. - * - * @return int|null - */ - public function getLocationAddressId(): ?int - { - return $this->_locationAddressId; - } - - /** - * @return ?Address - */ - public function getLocationAddress(): ?Address - { - if (!isset($this->_locationAddress)) { - if ($this->_locationAddressId && $location = Address::findOne($this->_locationAddressId)) { - $this->_locationAddress = $location; - } else { - $storeLocationAddress = new Address(); - $storeLocationAddress->title = 'Store'; - $storeLocationAddress->countryCode = 'US'; - if (Craft::$app->getElements()->saveElement($storeLocationAddress, false)) { - $this->setLocationAddress($storeLocationAddress); - StoreSettingsRecord::updateAll(['locationAddressId' => $this->_locationAddressId], ['id' => $this->id]); - } else { - throw new \Exception('Could not save store location address'); - } - } - - return $this->_locationAddress; - } - - return $this->_locationAddress; - } - - /** - * Sets the store's location address. - * - * @param Address|null $locationAddress - */ - public function setLocationAddress(?Address $locationAddress = null): void - { - $this->_locationAddress = $locationAddress; - $this->setLocationAddressId($locationAddress?->id); - } - - /** - * @return string[] $countries - */ - public function getCountries(): array - { - return $this->_countries ?? []; - } - - /** - * @return void - * @throws InvalidConfigException - */ - public function setCountries(mixed $countries): void - { - $countries ??= []; - $countries = Json::decodeIfJson($countries); - - if (!is_array($countries)) { - throw new InvalidConfigException('Countries must be an array.'); - } - - $this->_countries = $countries; - } - - /** - * @return array - */ - public function getCountriesList(): array - { - $all = Craft::$app->getAddresses()->getCountryRepository()->getList(Craft::$app->language); - return array_filter($all, fn($fieldHandle) => in_array($fieldHandle, $this->getCountries(), true), ARRAY_FILTER_USE_KEY); - } - - /** - * @return array - */ - public function getAdministrativeAreasListByCountryCode(): array - { - if (empty($this->_countries)) { - return []; - } - - $administrativeAreas = []; - foreach ($this->_countries as $countryCode) { - $administrativeAreas[$countryCode] = Craft::$app->getAddresses()->getSubdivisionRepository()->getList([$countryCode]); - } - - return $administrativeAreas; - } - - /** - * @return ZoneAddressCondition - */ - public function getMarketAddressCondition(): ZoneAddressCondition - { - /** @var ZoneAddressCondition $condition */ - $condition = $this->_marketAddressCondition ?? Craft::$app->getConditions()->createCondition(ZoneAddressCondition::class); - return $condition; - } - - /** - * @param ZoneAddressCondition|string|array|null $condition - * @return void - */ - public function setMarketAddressCondition(ZoneAddressCondition|string|array|null $condition): void - { - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - $condition = Craft::$app->getConditions()->createCondition($condition); - } - - if (is_array($condition)) { - $condition = Craft::$app->getConditions()->createCondition($condition); - } - - if ($condition === null) { - $condition = Craft::$app->getConditions()->createCondition(ZoneAddressCondition::class); - } - - $condition->forProjectConfig = false; - - /** @var ZoneAddressCondition $condition */ - $this->_marketAddressCondition = $condition; - } -} diff --git a/src/models/TaxAddressZone.php b/src/models/TaxAddressZone.php deleted file mode 100644 index e1d9d252e2..0000000000 --- a/src/models/TaxAddressZone.php +++ /dev/null @@ -1,83 +0,0 @@ - - * @since 2.0 - * - * @property-read string $cpEditUrl - */ -class TaxAddressZone extends Zone implements Chippable -{ - /** - * @var bool Default - */ - public bool $default = false; - - /** - * @inheritdoc - */ - public static function get(int|string $id): ?static - { - foreach (Plugin::getInstance()->getStores()->getAllStores() as $store) { - $zone = Plugin::getInstance()->getTaxZones()->getTaxZoneById((int)$id, $store->id); - if ($zone !== null) { - /** @phpstan-ignore-next-line */ - return $zone; - } - } - return null; - } - - /** - * @return string - * @throws InvalidConfigException - */ - public function getCpEditUrl(): string - { - return $this->getStore()->getStoreSettingsUrl('taxzones/' . $this->id); - } - - /** - * @inheritdoc - */ - public function getUiLabel(): string - { - return \Craft::t('site', $this->name); - } - - /** - * @inheritdoc - */ - public function getId(): ?int - { - return $this->id; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['name'], UniqueValidator::class, 'targetClass' => TaxZone::class, 'targetAttribute' => ['name', 'storeId']]; - $rules[] = [['default'], 'safe']; - - return $rules; - } -} diff --git a/src/models/TaxCategory.php b/src/models/TaxCategory.php deleted file mode 100644 index 7eea393682..0000000000 --- a/src/models/TaxCategory.php +++ /dev/null @@ -1,244 +0,0 @@ - - * @since 2.0 - */ -class TaxCategory extends Model implements Chippable, Colorable, Iconic -{ - /** - * @var int|null ID; - */ - public ?int $id = null; - - /** - * @var string|null Name - */ - public ?string $name = null; - - /** - * @var string|null Handle - */ - public ?string $handle = null; - - /** - * @var string|null Icon - */ - public ?string $icon = null; - - /** - * @var string|null Color - */ - public ?string $color = null; - - /** - * @var string|null Description - */ - public ?string $description = null; - - /** - * @var bool Default - */ - public bool $default = false; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateCreated = null; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateUpdated = null; - - /** - * @var DateTime|null Date deleted - * @since 4.2.0.1 - */ - public ?DateTime $dateDeleted = null; - - /** - * @var array|null Product Types - */ - private ?array $_productTypes = null; - - - /** - * Returns the name of this tax category. - * - * @return string - */ - public function __toString() - { - return (string)$this->name; - } - - /** - * @inheritdoc - */ - public static function get(int|string $id): ?static - { - /** @phpstan-ignore-next-line */ - return Plugin::getInstance()->getTaxCategories()->getTaxCategoryById($id); - } - - /** - * @inheritdoc - */ - public function getUiLabel(): string - { - return Craft::t('site', $this->name); - } - - /** - * @inheritdoc - */ - public function getId(): ?int - { - return $this->id; - } - - /** - * @inheritdoc - */ - public function getIcon(): ?string - { - return $this->icon; - } - - /** - * @inheritdoc - */ - public function getColor(): ?Color - { - return $this->color ? Color::tryFrom($this->color) : null; - } - - /** - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - * @throws StoreNotFoundException - */ - public function getTaxRates(?int $storeId = null): Collection - { - return Plugin::getInstance()->getTaxRates()->getAllTaxRates($storeId)->where('taxCategoryId', $this->id); - } - - /** - * @param int|null $storeId - * @return string - * @throws InvalidConfigException - */ - public function getCpEditUrl(?int $storeId = null): string - { - if ($storeId === null || !$store = Plugin::getInstance()->getStores()->getStoreById($storeId)) { - $store = Plugin::getInstance()->getStores()->getPrimaryStore(); - } - - return $store->getStoreSettingsUrl('taxcategories/' . $this->id); - } - - /** - * @param ProductType[] $productTypes - */ - public function setProductTypes(array $productTypes): void - { - $this->_productTypes = $productTypes; - } - - /** - * @return ProductType[] - * @throws InvalidConfigException - */ - public function getProductTypes(): array - { - if ($this->_productTypes === null && $this->id) { - $this->_productTypes = Plugin::getInstance()->getProductTypes()->getProductTypesByTaxCategoryId($this->id); - } - - return $this->_productTypes ?? []; - } - - /** - * Helper method to just get the product type IDs - * - * @return int[] - * @throws InvalidConfigException - */ - public function getProductTypeIds(): array - { - return ArrayHelper::getColumn($this->getProductTypes(), 'id'); - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $engine = Plugin::getInstance()->getTaxes()->getEngine(); - $isStandardTaxEngine = $engine instanceof Tax; - return [ - [['handle'], 'required'], - [['handle'], UniqueValidator::class, 'targetClass' => TaxCategoryRecord::class], - [['handle'], HandleValidator::class, 'when' => fn($model) => $isStandardTaxEngine], - [[ - 'id', - 'name', - 'handle', - 'icon', - 'color', - 'description', - 'default', - 'dateCreated', - 'dateUpdated', - 'dateDeleted', - ], 'safe'], - ]; - } - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $fields = parent::extraFields(); - $fields[] = 'productTypes'; - $fields[] = 'productTypeIds'; - $fields[] = 'taxRates'; - - return $fields; - } -} diff --git a/src/models/TaxRate.php b/src/models/TaxRate.php deleted file mode 100644 index c2266dc0fe..0000000000 --- a/src/models/TaxRate.php +++ /dev/null @@ -1,327 +0,0 @@ - - * @since 2.0 - */ -class TaxRate extends Model implements HasStoreInterface, Chippable -{ - use StoreTrait; - - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var string|null Human-friendly name for the tax rate - */ - public ?string $name = null; - - /** - * @var string|null Optional code used for internal reference - * @since 2.2 - */ - public ?string $code = null; - - /** - * @var float Rate percentage applied to the taxable subject - */ - public float $rate = .00; - - /** - * @var bool Whether the tax amount should be included in the subject price - */ - public bool $include = false; - - /** - * @var bool Whether the included tax amount should be removed from disqualified subject prices - * @since 3.4 - */ - public bool $removeIncluded = false; - - /** - * @var bool Whether an included VAT ID tax amount should be removed from VAT-disqualified subject prices - * @since 3.4 - */ - public bool $removeVatIncluded = false; - - /** - * @var string The subject to which `$rate` should be applied. Options: - * - `price` – line item price - * - `shipping` – line item shipping cost - * - `price_shipping` – line item price and shipping cost - * - `order_total_shipping` – order total shipping cost - * - `order_total_price` – order total taxable price (line item subtotal + total discounts + - * total shipping) - */ - public string $taxable = 'price'; - - /** - * @var int|null Tax category ID - */ - public ?int $taxCategoryId = null; - - /** - * @var int|null Tax zone ID - */ - public ?int $taxZoneId = null; - - /** - * @var array Tax ID Validators - */ - public array $taxIdValidators = []; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateCreated = null; - - /** - * @var DateTime|null - * @since 3.4 - */ - public ?DateTime $dateUpdated = null; - - /** - * @var bool Whether the tax rate is enabled - */ - public bool $enabled = true; - - /** - * @var TaxCategory|null - */ - private ?TaxCategory $_taxCategory = null; - - /** - * @var TaxAddressZone|null - */ - private ?TaxAddressZone $_taxZone = null; - - /** - * @inheritdoc - */ - public function attributes(): array - { - $names = parent::attributes(); - $names[] = 'isVat'; // @TODO Remove the deprecated `isVat` attribute in Commerce 6.0 - return $names; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['name'], 'required']; - $rules[] = [ - ['taxCategoryId'], - 'required', - 'when' => fn($model): bool => !in_array($model->taxable, TaxRateRecord::ORDER_TAXABALES, true), - ]; - $rules[] = [[ - 'code', - 'id', - 'include', - 'isVat', - 'name', - 'rate', - 'taxIdValidators', - 'removeIncluded', - 'removeVatIncluded', - 'storeId', - 'taxable', - 'taxCategoryId', - 'taxZoneId', - 'enabled', - ], 'safe']; - - return $rules; - } - - /** - * @inheritdoc - */ - public function extraFields(): array - { - $fields = parent::extraFields(); - $fields[] = 'taxCategory'; - $fields[] = 'taxZone'; - $fields[] = 'rateAsPercent'; - $fields[] = 'isEverywhere'; - - return $fields; - } - - /** - * @inheritdoc - */ - public static function get(int|string $id): ?static - { - /** @phpstan-ignore-next-line */ - return Plugin::getInstance()->getTaxRates()->getTaxRateById($id); - } - - /** - * @inheritdoc - */ - public function getUiLabel(): string - { - return Craft::t('site', $this->name); - } - - /** - * @inheritdoc - */ - public function getId(): ?int - { - return $this->id; - } - - /** - * Returns the tax rate's control panel edit page URL. - * - * @return string - * @throws InvalidConfigException - */ - public function getCpEditUrl(): string - { - return $this->getStore()->getStoreSettingsUrl('taxrates/' . $this->id); - } - - /** - * Returns `$rate` formatted as a percentage. - * - * @return string - */ - public function getRateAsPercent(): string - { - return Craft::$app->getFormatter()->asPercent($this->rate); - } - - /** - * Returns the designated Tax Zone for the rate, or `null` if none has been designated. - * - * @return TaxAddressZone|null - * @throws InvalidConfigException - */ - public function getTaxZone(): ?TaxAddressZone - { - if ($this->_taxZone === null && $this->taxZoneId) { - $this->_taxZone = Plugin::getInstance()->getTaxZones()->getTaxZoneById($this->taxZoneId, $this->storeId); - } - - return $this->_taxZone; - } - - /** - * Returns the designated Tax Category for the rate, or `null` if none has been designated. - * - * @return TaxCategory|null - * @throws InvalidConfigException - */ - public function getTaxCategory(): ?TaxCategory - { - if (!isset($this->_taxCategory) && $this->taxCategoryId) { - $this->_taxCategory = Plugin::getInstance()->getTaxCategories()->getTaxCategoryById($this->taxCategoryId); - } - - return $this->_taxCategory; - } - - /** - * Returns `true` is this tax rate isn’t limited by zone. - * - * @return bool Whether this tax rate applies to any zone - * @throws InvalidConfigException - */ - public function getIsEverywhere(): bool - { - return !$this->getTaxZone(); - } - - /** - * @return bool - * @deprecated in 5.3.0 - */ - public function getIsVat(): bool - { - // Don't throw deprecation log as `isVat` is still set as an attribute so will be called when the model is serialized. - return $this->hasTaxIdValidators(); - } - - /** - * @param bool $isVat - * @throws DeprecationException - * @deprecated in 5.3.0 - */ - public function setIsVat(bool $isVat): void - { - Craft::$app->getDeprecator()->log(__METHOD__, 'TaxRate::setIsVat() is deprecated.'); - } - - /** - * @return bool - * @since 5.3.0 - */ - public function hasTaxIdValidators(): bool - { - return count($this->taxIdValidators) > 0; - } - - /** - * @param string $className - * @return bool - * @since 5.3.0 - */ - public function hasTaxIdValidator(string $className): bool - { - return in_array($className, $this->taxIdValidators, true); - } - - /** - * @return TaxIdValidatorInterface[] - * @throws InvalidConfigException - * @since 5.3.0 - */ - public function getSelectedEnabledTaxIdValidators(): array - { - $selectedValidators = $this->taxIdValidators; - $validators = Plugin::getInstance()->getTaxes()->getEnabledTaxIdValidators(); - $activeValidators = []; - foreach ($validators as $validator) { - if (in_array($validator::class, $selectedValidators)) { - $activeValidators[] = $validator; - } - } - return $activeValidators; - } -} diff --git a/src/models/Transaction.php b/src/models/Transaction.php deleted file mode 100644 index 47915508d4..0000000000 --- a/src/models/Transaction.php +++ /dev/null @@ -1,355 +0,0 @@ - - * @since 2.0 - */ -class Transaction extends Model -{ - /** - * @var int|null ID - */ - public ?int $id = null; - - /** - * @var int|null Order ID - */ - public ?int $orderId = null; - - /** - * @var int|null Parent transaction ID - */ - public ?int $parentId = null; - - /** - * This is the user who made the transaction. It could be the customer if logged in, or a store administrator. - * - * @var int|null User ID - */ - public ?int $userId = null; - - /** - * @var string|null Hash - */ - public ?string $hash = null; - - /** - * @var int|null Gateway ID - */ - public ?int $gatewayId = null; - - /** - * @var string|null Currency - */ - public ?string $currency = null; - - /** - * The payment amount in the payment currency. - * Multiplying this by the `paymentRate`, give you the `amount`. - * - * @var float Payment Amount - */ - public float $paymentAmount; - - /** - * @var string|null Payment currency - */ - public ?string $paymentCurrency = null; - - /** - * @var float Payment Rate - */ - public float $paymentRate; - - /** - * @var string|null Transaction Type - */ - public ?string $type = null; - - /** - * The amount in the currency (which is the currency of the order) - * - * @var float Amount - */ - public float $amount; - - /** - * @var string|null Status - */ - public ?string $status = null; - - /** - * @var string|null reference - */ - public ?string $reference = null; - - /** - * @var string|null Code - */ - public ?string $code = null; - - /** - * @var string|null Message - */ - public ?string $message = null; - - /** - * @var string Note - */ - public string $note = ''; - - /** - * @var mixed Response - */ - public mixed $response = null; - - /** - * @var DateTime|null The date that the transaction was created - */ - public ?DateTIme $dateCreated = null; - - /** - * @var DateTime|null The date that the transaction was last updated - */ - public ?DateTIme $dateUpdated = null; - - /** - * @var Gateway|null - */ - private ?Gateway $_gateway = null; - - /** - * @var Transaction|null - */ - private ?Transaction $_parentTransaction = null; - - /** - * @var Order|null - */ - private ?Order $_order = null; - - /** - * @var Transaction[]|null - */ - private ?array $_children = null; - - - /** - * @inheritdoc - */ - public function __construct($attributes = []) - { - // generate unique hash - $this->hash = md5(uniqid((string)mt_rand(), true)); - - parent::__construct($attributes); - } - - /** - * @inheritdoc - */ - public function init(): void - { - $primaryCurrency = Plugin::getInstance()->getPaymentCurrencies()->getPrimaryPaymentCurrencyIso(); - - if (!isset($this->currency)) { - $this->currency = $primaryCurrency; - } - - if (!isset($this->paymentCurrency)) { - $this->paymentCurrency = $primaryCurrency; - } - - parent::init(); - } - - public function behaviors(): array - { - $behaviors = parent::behaviors(); - - $behaviors['currencyAttributes'] = [ - 'class' => CurrencyAttributeBehavior::class, - 'defaultCurrency' => $this->currency, - 'currencyAttributes' => $this->currencyAttributes(), - 'attributeCurrencyMap' => [ - 'paymentAmount' => $this->paymentCurrency, - ], - ]; - - return $behaviors; - } - - /** - * @return array - */ - public function currencyAttributes(): array - { - return [ - 'amount', - 'paymentAmount', - 'refundableAmount', - ]; - } - - /** - * @inheritdoc - */ - public function attributes(): array - { - $names = parent::attributes(); - ArrayHelper::removeValue($names, 'response'); - return $names; - } - - /** - * @inheritDoc - */ - public function extraFields(): array - { - return [ - 'response', - ]; - } - - /** - * @throws InvalidConfigException - */ - public function canCapture(): bool - { - return Plugin::getInstance()->getTransactions()->canCaptureTransaction($this); - } - - /** - * @throws InvalidConfigException - */ - public function canRefund(): bool - { - return Plugin::getInstance()->getTransactions()->canRefundTransaction($this); - } - - /** - * @throws InvalidConfigException - */ - public function getRefundableAmount(): float - { - return Plugin::getInstance()->getTransactions()->refundableAmountForTransaction($this); - } - - /** - * @throws InvalidConfigException - */ - public function getParent(): ?Transaction - { - if (null === $this->_parentTransaction && $this->parentId) { - $this->_parentTransaction = Plugin::getInstance()->getTransactions()->getTransactionById($this->parentId); - } - - return $this->_parentTransaction; - } - - /** - * @throws InvalidConfigException - */ - public function getOrder(): ?Order - { - if (!isset($this->_order) && $this->orderId) { - $this->_order = Plugin::getInstance()->getOrders()->getOrderById($this->orderId); - } - - return $this->_order; - } - - public function setOrder(Order $order): void - { - $this->_order = $order; - $this->orderId = $order->id; - } - - /** - * @throws InvalidConfigException - */ - public function getGateway(): ?Gateway - { - if (!isset($this->_gateway) && $this->gatewayId) { - $this->_gateway = Plugin::getInstance()->getGateways()->getGatewayById($this->gatewayId); - } - - return $this->_gateway; - } - - public function setGateway(Gateway $gateway): void - { - $this->_gateway = $gateway; - } - - /** - * Returns child transactions. - * - * @return Transaction[] - * @throws InvalidConfigException - */ - public function getChildTransactions(): array - { - if (!isset($this->_children) && $this->id) { - $this->_children = Plugin::getInstance()->getTransactions()->getChildrenByTransactionId($this->id); - } - - return $this->_children ?? []; - } - - /** - * Adds a child transaction. - */ - public function addChildTransaction(Transaction $transaction): void - { - if (null === $this->_children) { - $this->_children = []; - } - - $this->_children[] = $transaction; - } - - /** - * Sets child transactions. - */ - public function setChildTransactions(array $transactions): void - { - $this->_children = $transactions; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['type', 'status', 'orderId'], 'required'], - ]; - } -} diff --git a/src/models/TransferDetail.php b/src/models/TransferDetail.php deleted file mode 100644 index 031ba7099d..0000000000 --- a/src/models/TransferDetail.php +++ /dev/null @@ -1,87 +0,0 @@ -transferId) { - $this->_transfer = Transfer::findOne($this->transferId); - } - - if ($this->inventoryItemId) { - $inventoryItem = Plugin::getInstance()->getInventory()->getInventoryItemById($this->inventoryItemId); - $this->inventoryItemDescription = $inventoryItem->getSku(); - } - } - - public function getReceived(): int - { - return $this->quantityAccepted + $this->quantityRejected; - } - - /** - * @return ?InventoryItem - */ - public function getInventoryItem(): ?InventoryItem - { - if ($this->inventoryItemId === null) { - return null; - } - - return Plugin::getInstance()->getInventory()->getInventoryItemById($this->inventoryItemId); - } - - /** - * @return Transfer - */ - public function getTransfer(): Transfer - { - return $this->_transfer; - } - - /** - * @return void - */ - public function setTransfer(Transfer $transfer): void - { - $this->transferId = $transfer->id; - $this->_transfer = $transfer; - } - - public function defineRules(): array - { - return [ - [['quantity'], 'number', 'integerOnly' => true, 'min' => 1, 'max' => 99999, 'when' => fn() => $this->getTransfer()->transferStatus === TransferStatusType::DRAFT], - ]; - } -} diff --git a/src/models/inventory/DeactivateInventoryLocation.php b/src/models/inventory/DeactivateInventoryLocation.php deleted file mode 100644 index 1c0b7cf8d7..0000000000 --- a/src/models/inventory/DeactivateInventoryLocation.php +++ /dev/null @@ -1,102 +0,0 @@ -select(['id']) - ->from([Table::INVENTORYLOCATIONS]) - ->where(['id' => $this->inventoryLocation->id, 'dateDeleted' => null]) - ->exists(); - - if (!$exists) { - $this->addError($attribute, \Craft::t('commerce','Inventory location is already deactivated.')); - } - }, - ]; - - $rules[] = [ - ['inventoryLocation'], - function($attribute, $params, $validator) { - // Look through all the stores and see if they only have 1 location and it's the one we are deactivating - $stores = Plugin::getInstance()->getStores()->getAllStores(); - foreach ($stores as $store) { - $locations = $store->getInventoryLocations(); - if ($locations->count() == 1 && $locations->contains('id', $this->inventoryLocation->id)) { - $this->addError($attribute, \Craft::t('commerce','This is the last location for the {store} store.', ['store' => $store->getName()])); - } - } - }, - ]; - - $rules[] = [ - ['inventoryLocation'], - function($attribute, $params, $validator) { - if ($this->hasOutStandingCommittedStock()) { - $this->addError($attribute, \Craft::t('commerce','Inventory location has committed stock, the order(s) must first be fulfilled.')); - } - }, - ]; - - $rules[] = [ - ['inventoryLocation'], - function($attribute, $params, $validator) { - if ($this->hasOutStandingIncomingStock()) { - $this->addError($attribute, \Craft::t('commerce','Inventory location has incoming stock, the transfer(s) must first be completed.')); - } - }, - ]; - - return $rules; - } - - public function hasOutStandingCommittedStock(): bool - { - $committedTotal = Plugin::getInstance()->getInventory()->getInventoryLocationLevels($this->inventoryLocation) - ->sum('committedTotal'); - - return $committedTotal > 0; - } - - public function hasOutStandingIncomingStock(): bool - { - $incomingTotal = Plugin::getInstance()->getInventory()->getInventoryLocationLevels($this->inventoryLocation) - ->sum('incomingTotal'); - - return $incomingTotal > 0; - } -} diff --git a/src/models/inventory/InventoryCommittedMovement.php b/src/models/inventory/InventoryCommittedMovement.php deleted file mode 100644 index 16e76b3989..0000000000 --- a/src/models/inventory/InventoryCommittedMovement.php +++ /dev/null @@ -1,42 +0,0 @@ -fromInventoryTransactionType !== InventoryTransactionType::AVAILABLE && $this->toInventoryTransactionType !== InventoryTransactionType::COMMITTED) { - $validator->addError($this, $attribute, 'Invalid committed transaction types'); - } - }, - ]; - - $rules[] = [ - ['fromInventoryLocation', 'toInventoryLocation'], - function($attribute, $params, $validator) { - if ($this->fromInventoryLocation->id !== $this->toInventoryLocation->id) { - $validator->addError($this, $attribute, 'The from and to inventory locations must be the same.'); - } - }, - ]; - - return $rules; - } -} diff --git a/src/models/inventory/InventoryFulfillMovement.php b/src/models/inventory/InventoryFulfillMovement.php deleted file mode 100644 index db79fc5272..0000000000 --- a/src/models/inventory/InventoryFulfillMovement.php +++ /dev/null @@ -1,42 +0,0 @@ -fromInventoryTransactionType !== InventoryTransactionType::COMMITTED && $this->toInventoryTransactionType !== InventoryTransactionType::FULFILLED) { - $validator->addError($this, $attribute, 'Invalid Restock transaction type'); - } - }, - ]; - - $rules[] = [ - ['fromInventoryLocation', 'toInventoryLocation'], - function($attribute, $params, $validator) { - if ($this->fromInventoryLocation->id !== $this->toInventoryLocation->id) { - $validator->addError($this, $attribute, 'The from and to inventory locations must be the same.'); - } - }, - ]; - - return $rules; - } -} diff --git a/src/models/inventory/InventoryLocationDeactivatedMovement.php b/src/models/inventory/InventoryLocationDeactivatedMovement.php deleted file mode 100644 index bd7234c3c0..0000000000 --- a/src/models/inventory/InventoryLocationDeactivatedMovement.php +++ /dev/null @@ -1,53 +0,0 @@ -fromInventoryLocation->id === $this->toInventoryLocation->id) { - $validator->addError($this, $attribute, \Craft::t('commerce','The from and to inventory locations must be different.')); - } - }, - ]; - - $rules[] = [ - ['fromInventoryTransactionType'], - function($attribute, $params, $validator) { - if (!in_array($this->fromInventoryTransactionType, InventoryTransactionType::allowedManualMoveTransactionTypes(), true)) { - $validator->addError($this, $attribute, 'Can not move between these inventory types.'); - } - - if (!in_array($this->toInventoryTransactionType, InventoryTransactionType::allowedManualMoveTransactionTypes(), true)) { - $validator->addError($this, $attribute, 'Can not move between these inventory types.'); - } - }, - ]; - - return $rules; - } -} diff --git a/src/models/inventory/InventoryManualMovement.php b/src/models/inventory/InventoryManualMovement.php deleted file mode 100644 index 7d0ed0a7ec..0000000000 --- a/src/models/inventory/InventoryManualMovement.php +++ /dev/null @@ -1,121 +0,0 @@ -{$attribute}->canBeNegative() && $this->fromLocationAfterQuantity() < 0) { - $validator->addError($this, $attribute, 'The {inventoryLocation} inventory location’s {type} stock would drop below zero.', - [ - 'inventoryLocation' => $this->fromInventoryLocation->getUiLabel(), - 'type' => $this->{$attribute}->typeAsLabel(), - ] - ); - } - }, - ]; - - $rules[] = [ - ['toInventoryTransactionType'], - function($attribute, $params, $validator) { - if (!$this->{$attribute}->canBeNegative() && $this->toLocationAfterQuantity() < 0) { - $validator->addError($this, $attribute, 'The {inventoryLocation} inventory location stock of {type} would drop below zero.', - [ - 'inventoryLocation' => $this->toInventoryLocation->getUiLabel(), - 'type' => $this->{$attribute}->typeAsLabel(), - ] - ); - } - }, - ]; - - $rules[] = [ - ['fromInventoryLocation', 'toInventoryLocation'], - function($attribute, $params, $validator) { - if ($this->fromInventoryLocation->id !== $this->toInventoryLocation->id) { - $validator->addError($this, $attribute, 'The from and to inventory locations must be the same.'); - } - }, - ]; - - $rules[] = [ - ['toInventoryTransactionType'], - function($attribute, $params, $validator) { - if ($this->isManualMovement() && - ( - !in_array($this->fromInventoryTransactionType, InventoryTransactionType::allowedManualMoveTransactionTypes()) || - !in_array($this->toInventoryTransactionType, InventoryTransactionType::allowedManualMoveTransactionTypes()) - ) - ) { - $validator->addError($this, $attribute, \Craft::t('commerce','Can not move between these inventory types.')); - } - }, - ]; - - return $rules; - } - - /** - * @return int - */ - public function fromLocationAfterQuantity(): int - { - return (new Query()) - ->select(['quantity' => new \yii\db\Expression('COALESCE(SUM(quantity), 0) - :quantity')]) - ->from(Table::INVENTORYTRANSACTIONS) - ->where([ - 'type' => $this->fromInventoryTransactionType->value, - 'inventoryItemId' => $this->inventoryItemId, - 'inventoryLocationId' => $this->fromInventoryLocation->id, - ]) - ->params([':quantity' => $this->quantity]) - ->scalar(); - } - - /** - * Determines if this is a manual movement between available and unavailable inventory. - * - * @return bool - */ - public function isManualMovement(): bool - { - return ( - $this->lineItemId === null && $this->transferId === null - ); - } - - /** - * @return int - */ - public function toLocationAfterQuantity(): int - { - return (new Query()) - ->select(['quantity' => new \yii\db\Expression('COALESCE(SUM(quantity), 0) + :quantity')]) - ->from(Table::INVENTORYTRANSACTIONS) - ->where([ - 'type' => $this->toInventoryTransactionType->value, - 'inventoryItemId' => $this->inventoryItemId, - 'inventoryLocationId' => $this->toInventoryLocation->id, - ]) - ->params([':quantity' => $this->quantity]) - ->scalar(); - } -} diff --git a/src/models/inventory/InventoryRestockMovement.php b/src/models/inventory/InventoryRestockMovement.php deleted file mode 100644 index 1258dd3380..0000000000 --- a/src/models/inventory/InventoryRestockMovement.php +++ /dev/null @@ -1,42 +0,0 @@ -fromInventoryTransactionType !== InventoryTransactionType::COMMITTED || $this->toInventoryTransactionType !== InventoryTransactionType::AVAILABLE) { - $validator->addError($this, $attribute, 'Invalid Restock transaction type'); - } - }, - ]; - - $rules[] = [ - ['fromInventoryLocation', 'toInventoryLocation'], - function($attribute, $params, $validator) { - if ($this->fromInventoryLocation->id !== $this->toInventoryLocation->id) { - $validator->addError($this, $attribute, 'The from and to inventory locations must be the same.'); - } - }, - ]; - - return $rules; - } -} diff --git a/src/models/inventory/InventoryTransferMovement.php b/src/models/inventory/InventoryTransferMovement.php deleted file mode 100644 index cbe7d8506d..0000000000 --- a/src/models/inventory/InventoryTransferMovement.php +++ /dev/null @@ -1,12 +0,0 @@ - [...InventoryTransactionType::allowedManualAdjustmentTypes(), 'onHand']], - [['updateAction'], 'in', 'range' => InventoryUpdateQuantityType::values()], - ]); - } -} diff --git a/src/models/inventory/UpdateInventoryLevelInTransfer.php b/src/models/inventory/UpdateInventoryLevelInTransfer.php deleted file mode 100644 index e3e5b5c6ed..0000000000 --- a/src/models/inventory/UpdateInventoryLevelInTransfer.php +++ /dev/null @@ -1,29 +0,0 @@ - - * @since 2.0 - */ -abstract class BasePaymentForm extends Model -{ - public bool $savePaymentSource = false; - - /** - * Populate the payment form from a payment form. - * - * @param PaymentSource $paymentSource the source to ue - * @throws NotSupportedException if not supported by current gateway. - */ - public function populateFromPaymentSource(PaymentSource $paymentSource): void - { - throw new NotSupportedException(); - } -} diff --git a/src/models/payments/CreditCardPaymentForm.php b/src/models/payments/CreditCardPaymentForm.php deleted file mode 100644 index 77ffd4f95d..0000000000 --- a/src/models/payments/CreditCardPaymentForm.php +++ /dev/null @@ -1,119 +0,0 @@ - - * @since 2.0 - */ -class CreditCardPaymentForm extends BasePaymentForm -{ - /** - * @var string|null First name - */ - public ?string $firstName = null; - - /** - * @var string|null Last name - */ - public ?string $lastName = null; - - /** - * @var string|null Card number - */ - public ?string $number = null; - - /** - * @var string|null Expiry month - */ - public ?string $month = null; - - /** - * @var string|null Expiry year - */ - public ?string $year = null; - - /** - * @var string|null CVV number - */ - public ?string $cvv = null; - - /** - * @var string|null Token - */ - public ?string $token = null; - - /** - * @var string|null Expiry date - */ - public ?string $expiry = null; - - /** - * @var bool - */ - public bool $threeDSecure = false; - - /** - * @inheritdoc - */ - public function setAttributes($values, $safeOnly = true): void - { - parent::setAttributes($values, $safeOnly); - - $this->number = preg_replace('/\D/', '', $values['number'] ?? ''); - - if (isset($values['expiry'])) { - $expiry = explode('/', $values['expiry']); - - if (isset($expiry[0])) { - $this->month = trim($expiry[0]); - } - - if (isset($expiry[1])) { - $this->year = trim($expiry[1]); - } - } - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['firstName', 'lastName', 'month', 'year', 'cvv', 'number'], 'required'], - [['month'], 'integer', 'integerOnly' => true, 'min' => 1, 'max' => 12], - [['year'], 'integer', 'integerOnly' => true, 'min' => date('Y'), 'max' => (int)date('Y') + 12], - [['cvv'], 'integer', 'integerOnly' => true], - [['cvv'], 'string', 'length' => [3, 4]], - [['number'], 'integer', 'integerOnly' => true], - [['number'], 'string', 'max' => 19], - [['number'], 'creditCardLuhn'], - ]; - } - - /** - * @param string $attribute - */ - public function creditCardLuhn(string $attribute): void - { - $str = ''; - foreach (array_reverse(str_split($this->$attribute)) as $i => $c) { - /** @var int $c */ - $str .= ($i % 2) ? $c * 2 : $c; - } - - if (array_sum(str_split($str)) % 10 !== 0) { - $this->addError($attribute, Craft::t('commerce', 'Not a valid credit card number.')); - } - } -} diff --git a/src/models/payments/DummyPaymentForm.php b/src/models/payments/DummyPaymentForm.php deleted file mode 100644 index ce24c9b4c6..0000000000 --- a/src/models/payments/DummyPaymentForm.php +++ /dev/null @@ -1,36 +0,0 @@ - - * @since 2.0 - */ -class DummyPaymentForm extends CreditCardPaymentForm -{ - public function populateFromPaymentSource(PaymentSource $paymentSource): void - { - $this->token = (string)$paymentSource->id; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - if ($this->token) { - return []; //No validation of form if using a token - } - - return parent::defineRules(); - } -} diff --git a/src/models/payments/OffsitePaymentForm.php b/src/models/payments/OffsitePaymentForm.php deleted file mode 100644 index 3fd90fb63a..0000000000 --- a/src/models/payments/OffsitePaymentForm.php +++ /dev/null @@ -1,18 +0,0 @@ - - * @since 2.0 - */ -class OffsitePaymentForm extends BasePaymentForm -{ -} diff --git a/src/models/responses/Dummy.php b/src/models/responses/Dummy.php deleted file mode 100644 index f3109d606e..0000000000 --- a/src/models/responses/Dummy.php +++ /dev/null @@ -1,133 +0,0 @@ - - * @since 2.0 - */ -class Dummy implements RequestResponseInterface -{ - /** - * @var bool - */ - private bool $_success = true; - - public function __construct(?CreditCardPaymentForm $form = null) - { - if ($form === null) { - $this->_success = false; - return; - } - - // Token populated? This is a "payment source" so no need to fail anything - if ($form->token) { - return; - } - - $number = (string)$form->number; - $isValid = ((int)substr($number, -1) % 2 === 0); - - if (!$isValid) { - $this->_success = false; - } - } - - /** - * @inheritdoc - */ - public function isSuccessful(): bool - { - return $this->_success; - } - - /** - * @inheritdoc - */ - public function isRedirect(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function getRedirectMethod(): string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getRedirectData(): array - { - return []; - } - - /** - * @inheritdoc - */ - public function getRedirectUrl(): string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getTransactionReference(): string - { - return date('Y-m-d-H-i-s'); - } - - /** - * @inheritdoc - */ - public function getCode(): string - { - return $this->_success ? '' : 'payment.failed'; - } - - /** - * @inheritdoc - */ - public function getMessage(): string - { - return $this->_success ? '' : Craft::t('commerce', 'Dummy gateway payment failed.'); - } - - /** - * @inheritdoc - */ - public function redirect(): void - { - } - - /** - * @inheritdoc - */ - public function getData(): mixed - { - return ''; - } - - /** - * @inheritdoc - */ - public function isProcessing(): bool - { - return false; - } -} diff --git a/src/models/responses/DummySubscriptionResponse.php b/src/models/responses/DummySubscriptionResponse.php deleted file mode 100644 index 26cef472c2..0000000000 --- a/src/models/responses/DummySubscriptionResponse.php +++ /dev/null @@ -1,98 +0,0 @@ - - * @since 2.0 - */ -class DummySubscriptionResponse implements SubscriptionResponseInterface -{ - /** - * @var bool Whether this subscription is canceled - */ - private bool $_isCanceled = false; - - /** - * @var int Amount of trial days - */ - private int $_trialDays = 0; - - public function setIsCanceled(bool $isCanceled): void - { - $this->_isCanceled = $isCanceled; - } - - public function setTrialDays(int $trialDays): void - { - $this->_trialDays = $trialDays; - } - - /** - * @inheritdoc - */ - public function getData(): mixed - { - return ['dummyData' => StringHelper::randomString()]; - } - - /** - * @inheritdoc - */ - public function getReference(): string - { - return StringHelper::randomString(); - } - - /** - * @inheritdoc - */ - public function getTrialDays(): int - { - return $this->_trialDays; - } - - /** - * @inheritdoc - */ - public function getNextPaymentDate(): DateTime - { - return (new DateTime())->add(new DateInterval('P1Y')); - } - - /** - * @inheritdoc - */ - public function isCanceled(): bool - { - return $this->_isCanceled; - } - - /** - * @inheritdoc - */ - public function isScheduledForCancellation(): bool - { - return $this->_isCanceled; - } - - /** - * @inheritdoc - */ - public function isInactive(): bool - { - return false; - } -} diff --git a/src/models/responses/Manual.php b/src/models/responses/Manual.php deleted file mode 100644 index 92018e001c..0000000000 --- a/src/models/responses/Manual.php +++ /dev/null @@ -1,106 +0,0 @@ - - * @since 2.0 - */ -class Manual implements RequestResponseInterface -{ - /** - * @inheritdoc - */ - public function isSuccessful(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function isRedirect(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function getRedirectMethod(): string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getRedirectData(): array - { - return []; - } - - /** - * @inheritdoc - */ - public function getRedirectUrl(): string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getTransactionReference(): string - { - return date('Y-m-d-H-i-s'); - } - - /** - * @inheritdoc - */ - public function getCode(): string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getMessage(): string - { - return ''; - } - - /** - * @inheritdoc - */ - public function redirect(): void - { - } - - /** - * @inheritdoc - */ - public function getData(): mixed - { - return ''; - } - - /** - * @inheritdoc - */ - public function isProcessing(): bool - { - return false; - } -} diff --git a/src/models/subscriptions/CancelSubscriptionForm.php b/src/models/subscriptions/CancelSubscriptionForm.php deleted file mode 100644 index a823f82380..0000000000 --- a/src/models/subscriptions/CancelSubscriptionForm.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class CancelSubscriptionForm extends Model -{ -} diff --git a/src/models/subscriptions/DummyPlan.php b/src/models/subscriptions/DummyPlan.php deleted file mode 100644 index 7af55354d8..0000000000 --- a/src/models/subscriptions/DummyPlan.php +++ /dev/null @@ -1,29 +0,0 @@ - - * @since 2.0 - */ -class DummyPlan extends Plan -{ - /** - * @inheritdoc - * @todo Fix typo: rename $currentPlant parameter to $currentPlan in Commerce 6.0 - */ - public function canSwitchFrom(PlanInterface $currentPlant): bool - { - return true; - } -} diff --git a/src/models/subscriptions/SubscriptionForm.php b/src/models/subscriptions/SubscriptionForm.php deleted file mode 100644 index 51e8a73461..0000000000 --- a/src/models/subscriptions/SubscriptionForm.php +++ /dev/null @@ -1,36 +0,0 @@ - - * @since 2.0 - */ -class SubscriptionForm extends Model -{ - /** - * Trial days for the subscription. - * - * @var int - */ - public int $trialDays = 0; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - return [ - [['trialDays'], 'integer', 'integerOnly' => true, 'min' => 0], - ]; - } -} diff --git a/src/models/subscriptions/SubscriptionPayment.php b/src/models/subscriptions/SubscriptionPayment.php deleted file mode 100644 index c2380c6473..0000000000 --- a/src/models/subscriptions/SubscriptionPayment.php +++ /dev/null @@ -1,51 +0,0 @@ - - * @since 2.0 - */ -class SubscriptionPayment extends Model -{ - /** - * @var float payment amount - */ - public float $paymentAmount; - - /** - * @var Currency payment currency - */ - public Currency $paymentCurrency; - - /** - * @var DateTime time of payment in UTC - */ - public DateTime $paymentDate; - - /** - * @var string the payment reference on gateway - */ - public string $paymentReference; - - /** - * @var bool whether payment has been collected - */ - public bool $paid = false; - - /** - * @var string the gateway response text - */ - public string $response; -} diff --git a/src/models/subscriptions/SwitchPlansForm.php b/src/models/subscriptions/SwitchPlansForm.php deleted file mode 100644 index 6ea0af5a60..0000000000 --- a/src/models/subscriptions/SwitchPlansForm.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class SwitchPlansForm extends Model -{ -} diff --git a/src/plugin/Routes.php b/src/plugin/Routes.php deleted file mode 100644 index 7361d6bd6a..0000000000 --- a/src/plugin/Routes.php +++ /dev/null @@ -1,179 +0,0 @@ - - * @since 2.0 - */ -trait Routes -{ - /** - * @since 3.1.10 - */ - private function _registerSiteRoutes(): void - { - Event::on(UrlManager::class, UrlManager::EVENT_REGISTER_SITE_URL_RULES, function(RegisterUrlRulesEvent $event) { - $event->rules['commerce/webhooks/process-webhook/gateway/'] = 'commerce/webhooks/process-webhook'; - }); - } - - /** - * @since 2.0 - */ - private function _registerCpRoutes(): void - { - Event::on(UrlManager::class, UrlManager::EVENT_REGISTER_CP_URL_RULES, function(RegisterUrlRulesEvent $event) { - $event->rules['commerce'] = ['template' => 'commerce/index']; - - // User edit screen - $event->rules['myaccount/commerce'] = 'commerce/users/index'; - $event->rules['users//commerce'] = 'commerce/users/index'; - - // Products / Variants - $event->rules['commerce/products'] = 'commerce/products/product-index'; - $event->rules['commerce/variants'] = 'commerce/variants/index'; - $event->rules['commerce/products/'] = 'commerce/products/product-index'; - $event->rules['commerce/variants/'] = 'commerce/variants/index'; - $event->rules['commerce/variants/'] = 'elements/edit'; - $event->rules['commerce/products//new'] = 'commerce/products/create'; - $event->rules['commerce/products//'] = 'elements/edit'; - - $event->rules['commerce/subscriptions'] = 'commerce/subscriptions/index'; - $event->rules['commerce/subscriptions/'] = 'commerce/subscriptions/index'; - $event->rules['commerce/subscriptions/'] = 'commerce/subscriptions/edit'; - - // Subscription plans - $event->rules['commerce/subscription-plans'] = 'commerce/plans/plan-index'; - $event->rules['commerce/subscription-plans/'] = 'commerce/plans/edit-plan'; - $event->rules['commerce/subscription-plans/new'] = 'commerce/plans/edit-plan'; - - // Product Types - $event->rules['commerce/settings/producttypes'] = 'commerce/product-types/product-type-index'; - $event->rules['commerce/settings/producttypes/'] = 'commerce/product-types/edit-product-type'; - $event->rules['commerce/settings/producttypes/new'] = 'commerce/product-types/edit-product-type'; - - // Orders - $event->rules['commerce/orders'] = 'commerce/orders/order-index'; - $event->rules['commerce/orders/'] = 'commerce/orders/edit-order'; - - $event->rules['commerce/orders//create'] = 'commerce/orders/create'; - - $event->rules['commerce/orders/'] = 'commerce/orders/order-index'; - - // Settings - - $event->rules['commerce/settings/stores'] = 'commerce/stores/stores-index'; - $event->rules['commerce/settings/stores/new'] = 'commerce/stores/edit-store'; - $event->rules['commerce/settings/stores/'] = 'commerce/stores/edit-store'; - - $event->rules['commerce/settings/sites'] = 'commerce/stores/edit-site-stores'; - - $event->rules['commerce/settings/general'] = 'commerce/settings/edit'; - - $event->rules['commerce/settings/ordersettings'] = 'commerce/order-settings/edit'; - - $event->rules['commerce/settings/transfers'] = 'commerce/settings/edit-transfer-settings'; - - $event->rules['commerce/settings/subscriptions'] = 'commerce/settings/edit-subscription-settings'; - - $event->rules['commerce/settings/gateways'] = 'commerce/gateways/index'; - $event->rules['commerce/settings/gateways/new'] = 'commerce/gateways/edit'; - $event->rules['commerce/settings/gateways/'] = 'commerce/gateways/edit'; - - $event->rules['commerce/settings/emails'] = 'commerce/emails/index'; - $event->rules['commerce/settings/emails//new'] = 'commerce/emails/edit'; - $event->rules['commerce/settings/emails//'] = 'commerce/emails/edit'; - - $event->rules['commerce/settings/pdfs'] = 'commerce/pdfs/index'; - $event->rules['commerce/settings/pdfs//new'] = 'commerce/pdfs/edit'; - $event->rules['commerce/settings/pdfs//'] = 'commerce/pdfs/edit'; - - $event->rules['commerce/settings/orderstatuses'] = 'commerce/order-statuses/index'; - $event->rules['commerce/settings/orderstatuses//new'] = 'commerce/order-statuses/edit'; - $event->rules['commerce/settings/orderstatuses//'] = 'commerce/order-statuses/edit'; - - $event->rules['commerce/settings/lineitemstatuses'] = 'commerce/line-item-statuses/index'; - $event->rules['commerce/settings/lineitemstatuses//new'] = 'commerce/line-item-statuses/edit'; - $event->rules['commerce/settings/lineitemstatuses//'] = 'commerce/line-item-statuses/edit'; - - // Store Settings - $event->rules['commerce/store-management'] = 'commerce/store-management/index'; // Redirects to the first store - $event->rules['commerce/store-management/'] = 'commerce/store-management/edit'; - - $event->rules['commerce/store-management//payment-currencies'] = 'commerce/payment-currencies/index'; - $event->rules['commerce/store-management//payment-currencies/new'] = 'commerce/payment-currencies/edit'; - $event->rules['commerce/store-management//payment-currencies/'] = 'commerce/payment-currencies/edit'; - - // Shipping - $event->rules['commerce/store-management//shippingzones'] = 'commerce/shipping-zones/index'; - $event->rules['commerce/store-management//shippingzones/new'] = 'commerce/shipping-zones/edit'; - $event->rules['commerce/store-management//shippingzones/'] = 'commerce/shipping-zones/edit'; - - $event->rules['commerce/store-management//shippingcategories'] = 'commerce/shipping-categories/index'; - $event->rules['commerce/store-management//shippingcategories/new'] = 'commerce/shipping-categories/edit'; - $event->rules['commerce/store-management//shippingcategories/'] = 'commerce/shipping-categories/edit'; - - $event->rules['commerce/store-management//shippingmethods'] = 'commerce/shipping-methods/index'; - $event->rules['commerce/store-management//shippingmethods/new'] = 'commerce/shipping-methods/edit'; - $event->rules['commerce/store-management//shippingmethods/'] = 'commerce/shipping-methods/edit'; - $event->rules['commerce/store-management//shippingmethods//shippingrules/new'] = 'commerce/shipping-rules/edit'; - $event->rules['commerce/store-management//shippingmethods//shippingrules/'] = 'commerce/shipping-rules/edit'; - - // Taxes - $event->rules['commerce/store-management//taxcategories'] = 'commerce/tax-categories/index'; - $event->rules['commerce/store-management//taxcategories/new'] = 'commerce/tax-categories/edit'; - $event->rules['commerce/store-management//taxcategories/'] = 'commerce/tax-categories/edit'; - - $event->rules['commerce/store-management//taxzones'] = 'commerce/tax-zones/index'; - $event->rules['commerce/store-management//taxzones/new'] = 'commerce/tax-zones/edit'; - $event->rules['commerce/store-management//taxzones/'] = 'commerce/tax-zones/edit'; - $event->rules['commerce/store-management//taxrates'] = 'commerce/tax-rates/index'; - $event->rules['commerce/store-management//taxrates/new'] = 'commerce/tax-rates/edit'; - $event->rules['commerce/store-management//taxrates/'] = 'commerce/tax-rates/edit'; - - // Sales - $event->rules['commerce/store-management//sales'] = 'commerce/sales/index'; - $event->rules['commerce/store-management//sales/new'] = 'commerce/sales/edit'; - $event->rules['commerce/store-management//sales/'] = 'commerce/sales/edit'; - - // Discounts - $event->rules['commerce/store-management//discounts'] = 'commerce/discounts/index'; - $event->rules['commerce/store-management//discounts/new'] = 'commerce/discounts/edit'; - $event->rules['commerce/store-management//discounts/'] = 'commerce/discounts/edit'; - - // Pricing - $event->rules['commerce/store-management//pricing-rules'] = 'commerce/catalog-pricing-rules/index'; - $event->rules['commerce/store-management//pricing-rules/new'] = 'commerce/catalog-pricing-rules/edit'; - $event->rules['commerce/store-management//pricing-rules/'] = 'commerce/catalog-pricing-rules/edit'; - - // Inventory - $event->rules['commerce/inventory'] = 'commerce/inventory/edit-location-levels'; // redirect to the first location - $event->rules['commerce/inventory/levels'] = 'commerce/inventory/edit-location-levels'; // redirect to the first location - - $event->rules['commerce/inventory/item/'] = 'commerce/inventory/item-edit'; - $event->rules['commerce/inventory/levels/'] = 'commerce/inventory/edit-location-levels'; - - $event->rules['commerce/inventory-locations'] = 'commerce/inventory-locations/index'; - $event->rules['commerce/inventory-locations/new'] = 'commerce/inventory-locations/edit'; - $event->rules['commerce/inventory-locations/'] = 'commerce/inventory-locations/edit'; - - $event->rules['commerce/inventory/transfers'] = 'commerce/transfers/index'; - $event->rules['commerce/inventory/transfers/'] = 'elements/edit'; - - // Donations - $event->rules['commerce/donations'] = 'commerce/donations/edit'; - }); - } -} diff --git a/src/plugin/Services.php b/src/plugin/Services.php deleted file mode 100644 index 2c350cfbf2..0000000000 --- a/src/plugin/Services.php +++ /dev/null @@ -1,607 +0,0 @@ - - * @since 2.0 - */ -trait Services -{ - /** - * Returns the cart service - * - * @return Carts The cart service - * @throws InvalidConfigException - */ - public function getCarts(): Carts - { - return $this->get('carts'); - } - - /** - * Returns the coupons service - * - * @return Coupons The countries service - * @throws InvalidConfigException - */ - public function getCoupons(): Coupons - { - return $this->get('coupons'); - } - - /** - * Returns the currencies service - * - * @return Currencies The currencies service - * @throws InvalidConfigException - */ - public function getCurrencies(): Currencies - { - return $this->get('currencies'); - } - - /** - * Returns the customers service - * - * @return Customers The customers service - * @throws InvalidConfigException - */ - public function getCustomers(): Customers - { - return $this->get('customers'); - } - - /** - * Returns the discounts service - * - * @return Discounts The discounts service - * @throws InvalidConfigException - */ - public function getDiscounts(): Discounts - { - return $this->get('discounts'); - } - - /** - * Returns the emails service - * - * @return Emails The emails service - * @throws InvalidConfigException - */ - public function getEmails(): Emails - { - return $this->get('emails'); - } - - /** - * Returns the formulas service - * - * @return Formulas the formulas service - * @throws InvalidConfigException - * @since 2.2 - */ - public function getFormulas(): Formulas - { - return $this->get('formulas'); - } - - /** - * Returns the gateways service - * - * @return Gateways The gateways service - * @throws InvalidConfigException - */ - public function getGateways(): Gateways - { - return $this->get('gateways'); - } - - /** - * Returns the inventory service - * - * @return Inventory The inventory service - * @throws InvalidConfigException - */ - public function getInventory(): Inventory - { - return $this->get('inventory'); - } - - /** - * Returns the inventory locations service - * - * @return InventoryLocations The inventory locations service - * @throws InvalidConfigException - */ - public function getInventoryLocations(): InventoryLocations - { - return $this->get('inventoryLocations'); - } - - /** - * Returns the lineItems service - * - * @return LineItems The lineItems service - * @throws InvalidConfigException - */ - public function getLineItems(): LineItems - { - return $this->get('lineItems'); - } - - /** - * Returns the lineItems statuses service - * - * @return LineItemStatuses The lineItems service - * @throws InvalidConfigException - */ - public function getLineItemStatuses(): LineItemStatuses - { - return $this->get('lineItemStatuses'); - } - - /** - * Returns the orderAdjustments service - * - * @return OrderAdjustments The orderAdjustments service - * @throws InvalidConfigException - */ - public function getOrderAdjustments(): OrderAdjustments - { - return $this->get('orderAdjustments'); - } - - /** - * Returns the orderHistories service - * - * @return OrderHistories The orderHistories service - * @throws InvalidConfigException - */ - public function getOrderHistories(): OrderHistories - { - return $this->get('orderHistories'); - } - - /** - * Returns the orders service - * - * @return Orders The orders service - * @throws InvalidConfigException - */ - public function getOrders(): Orders - { - return $this->get('orders'); - } - - /** - * Returns the OrderNotices service - * - * @return OrderNotices The OrderNotices service - * @throws InvalidConfigException - */ - public function getOrderNotices(): OrderNotices - { - return $this->get('orderNotices'); - } - - /** - * Returns the OrderStatuses service - * - * @return OrderStatuses The OrderStatuses service - * @throws InvalidConfigException - */ - public function getOrderStatuses(): OrderStatuses - { - return $this->get('orderStatuses'); - } - - /** - * Returns the paymentCurrencies service - * - * @return PaymentCurrencies The paymentCurrencies service - * @throws InvalidConfigException - */ - public function getPaymentCurrencies(): PaymentCurrencies - { - return $this->get('paymentCurrencies'); - } - - /** - * Returns the payments service - * - * @return Payments The payments service - * @throws InvalidConfigException - */ - public function getPayments(): Payments - { - return $this->get('payments'); - } - - /** - * Returns the payment sources service - * - * @return PaymentSources The payment sources service - * @throws InvalidConfigException - */ - public function getPaymentSources(): PaymentSources - { - return $this->get('paymentSources'); - } - - /** - * Returns the PDFs service - * - * @return Pdfs The PDFs service - * @throws InvalidConfigException - */ - public function getPdfs(): Pdfs - { - return $this->get('pdfs'); - } - - /** - * Returns the payment sources service - * - * @return Plans The subscription plans service - * @throws InvalidConfigException - */ - public function getPlans(): Plans - { - return $this->get('plans'); - } - - /** - * Returns the catalog pricing service - * - * @return CatalogPricing - * @throws InvalidConfigException - */ - public function getCatalogPricing(): CatalogPricing - { - return $this->get('catalogPricing'); - } - - /** - * Returns the catalog pricing rules service - * - * @return CatalogPricingRules - * @throws InvalidConfigException - */ - public function getCatalogPricingRules(): CatalogPricingRules - { - return $this->get('catalogPricingRules'); - } - - /** - * Returns the products service - * - * @return Products The products service - * @throws InvalidConfigException - */ - public function getProducts(): Products - { - return $this->get('products'); - } - - /** - * Returns the productTypes service - * - * @return ProductTypes The productTypes service - * @throws InvalidConfigException - */ - public function getProductTypes(): ProductTypes - { - return $this->get('productTypes'); - } - - /** - * Returns the purchasables service - * - * @return Purchasables The purchasables service - * @throws InvalidConfigException - */ - public function getPurchasables(): Purchasables - { - return $this->get('purchasables'); - } - - /** - * Returns the sales service - * - * @return Sales The sales service - * @throws InvalidConfigException - */ - public function getSales(): Sales - { - return $this->get('sales'); - } - - /** - * Returns the shippingMethods service - * - * @return ShippingMethods The shippingMethods service - * @throws InvalidConfigException - */ - public function getShippingMethods(): ShippingMethods - { - return $this->get('shippingMethods'); - } - - /** - * Returns the shippingRules service - * - * @return ShippingRules The shippingRules service - * @throws InvalidConfigException - */ - public function getShippingRules(): ShippingRules - { - return $this->get('shippingRules'); - } - - /** - * Returns the shippingRules service - * - * @return ShippingRuleCategories The shippingRuleCategories service - * @throws InvalidConfigException - */ - public function getShippingRuleCategories(): ShippingRuleCategories - { - return $this->get('shippingRuleCategories'); - } - - /** - * Returns the shippingCategories service - * - * @return ShippingCategories The shippingCategories service - * @throws InvalidConfigException - */ - public function getShippingCategories(): ShippingCategories - { - return $this->get('shippingCategories'); - } - - /** - * Returns the shippingZones service - * - * @return ShippingZones The shippingZones service - * @throws InvalidConfigException - */ - public function getShippingZones(): ShippingZones - { - return $this->get('shippingZones'); - } - - /** - * Returns the store service - * - * @return StoreSettings The store service - * @throws InvalidConfigException - */ - public function getStoreSettings(): StoreSettings - { - return $this->get('storeSettings'); - } - - /** - * Returns the stores service - * - * @return Stores The stores service - * @throws InvalidConfigException - */ - public function getStores(): Stores - { - return $this->get('stores'); - } - - /** - * Returns the stores service - * - * @return Store The store service - * @throws InvalidConfigException - */ - public function getStore(): Store - { - return $this->get('store'); - } - - /** - * Returns the subscriptions service - * - * @return Subscriptions The subscriptions service - * @throws InvalidConfigException - */ - public function getSubscriptions(): Subscriptions - { - return $this->get('subscriptions'); - } - - /** - * Returns the taxes service - * - * @return Taxes The taxes service - * @throws InvalidConfigException - */ - public function getTaxes(): Taxes - { - return $this->get('taxes'); - } - - /** - * Returns the taxCategories service - * - * @return TaxCategories The taxCategories service - * @throws InvalidConfigException - */ - public function getTaxCategories(): TaxCategories - { - return $this->get('taxCategories'); - } - - /** - * Returns the taxRates service - * - * @return TaxRates The taxRates service - * @throws InvalidConfigException - */ - public function getTaxRates(): TaxRates - { - return $this->get('taxRates'); - } - - /** - * Returns the taxZones service - * - * @return TaxZones The taxZones service - * @throws InvalidConfigException - */ - public function getTaxZones(): TaxZones - { - return $this->get('taxZones'); - } - - /** - * Returns the transactions service - * - * @return Transactions The transactions service - * @throws InvalidConfigException - */ - public function getTransactions(): Transactions - { - return $this->get('transactions'); - } - - /** - * Returns the transfers service - * - * @return Transfers The transfers service - * @throws InvalidConfigException - */ - public function getTransfers(): Transfers - { - return $this->get('transfers'); - } - - /** - * Returns the variants service - * - * @return Variants The variants service - * @throws InvalidConfigException - */ - public function getVariants(): Variants - { - return $this->get('variants'); - } - - /** - * Returns the VAT service - * - * @return Vat The VAT service - * @throws InvalidConfigException - */ - public function getVat(): Vat - { - return $this->get('vat'); - } - - /** - * Returns the webhooks service - * - * @return Webhooks The variants service - * @throws InvalidConfigException - * @since 3.1.9 - */ - public function getWebhooks(): Webhooks - { - return $this->get('webhooks'); - } -} diff --git a/src/queue/jobs/CatalogPricing.php b/src/queue/jobs/CatalogPricing.php deleted file mode 100644 index dff7406709..0000000000 --- a/src/queue/jobs/CatalogPricing.php +++ /dev/null @@ -1,90 +0,0 @@ -getCatalogPricing(); - $isConsolidatedJob = $this->storeId === null && $this->purchasableIds === null && $this->catalogPricingRuleIds === null; - $catalogPricingRules = null; - $reservedRowId = null; - - // @TODO: remove these properties and behaviour at next breaking change - $storeId = $this->storeId; - $purchasableIds = $this->purchasableIds; - $catalogPricingRuleIds = $this->catalogPricingRuleIds; - - if ($isConsolidatedJob) { - // New method of processing catalog pricing via queue table: reserve a row and process based on its type and IDs - $reservedRecord = $catalogPricingService->reserveCatalogPricingQueueRow(); - - if (!$reservedRecord) { - return; - } - - $reservedRowId = $reservedRecord->id; - $storeId = $reservedRecord->storeId; - - if ($reservedRecord->type === CatalogPricingQueueRecord::TYPE_PURCHASABLE) { - // Specific purchasable IDs: regenerate against all applicable rules - $purchasableIds = $reservedRecord->getIds(); - } elseif ($reservedRecord->type === CatalogPricingQueueRecord::TYPE_RULE) { - $catalogPricingRuleIds = $reservedRecord->getIds(); - } else { - throw new \UnexpectedValueException("Unrecognized catalog pricing queue row type: {$reservedRecord->type}"); - } - } - - if (!empty($catalogPricingRuleIds)) { - $catalogPricingRules = Plugin::getInstance()->getCatalogPricingRules() - ->getAllCatalogPricingRules($storeId) - ->whereIn('id', $catalogPricingRuleIds) - ->all(); - } - - try { - $catalogPricingService->generateCatalogPrices($purchasableIds, $catalogPricingRules, queue: $queue); - - if ($reservedRowId) { - $catalogPricingService->deleteCatalogPricingQueueRowById($reservedRowId); - } - } catch (\Throwable $e) { - if ($reservedRowId) { - $catalogPricingService->releaseCatalogPricingQueueRowById($reservedRowId); - } - - throw $e; - } - } - - protected function defaultDescription(): ?string - { - return 'Generating catalog pricing.'; - } -} diff --git a/src/records/CatalogPricing.php b/src/records/CatalogPricing.php deleted file mode 100644 index 3a87d1cf3a..0000000000 --- a/src/records/CatalogPricing.php +++ /dev/null @@ -1,41 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricing extends ActiveRecord -{ - use StoreRecordTrait; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::CATALOG_PRICING; - } -} diff --git a/src/records/CatalogPricingQueue.php b/src/records/CatalogPricingQueue.php deleted file mode 100644 index c73226f842..0000000000 --- a/src/records/CatalogPricingQueue.php +++ /dev/null @@ -1,84 +0,0 @@ - - * @since 5.7.0 - */ -class CatalogPricingQueue extends ActiveRecord -{ - /** - * Row type for purchasable-ID-based catalog pricing work. - */ - public const TYPE_PURCHASABLE = 'purchasable'; - - /** - * Row type for rule-ID-based (or full-regeneration) catalog pricing work. - */ - public const TYPE_RULE = 'rule'; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::CATALOG_PRICING_QUEUE; - } - - /** - * Returns the decoded IDs array from the JSON column value. - * - * @return array|null - */ - public function getIds(): ?array - { - $raw = $this->getAttribute('ids'); - - if ($raw === null || $raw === '') { - return null; - } - - $decoded = Json::decodeIfJson($raw); - - return is_array($decoded) ? $decoded : null; - } - - /** - * Encodes the IDs array to JSON and stores it in the column. - * - * @param array|null $ids - */ - public function setIds(?array $ids): void - { - $this->setAttribute('ids', $ids !== null ? Json::encode($ids) : null); - } - - /** - * @return ActiveQueryInterface - */ - public function getStore(): ActiveQueryInterface - { - return $this->hasOne(Store::class, ['id' => 'storeId']); - } -} diff --git a/src/records/CatalogPricingRule.php b/src/records/CatalogPricingRule.php deleted file mode 100644 index c835a3eaaa..0000000000 --- a/src/records/CatalogPricingRule.php +++ /dev/null @@ -1,67 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricingRule extends ActiveRecord -{ - use StoreRecordTrait; - - public const APPLY_BY_PERCENT = 'byPercent'; - public const APPLY_BY_FLAT = 'byFlat'; - public const APPLY_TO_PERCENT = 'toPercent'; - public const APPLY_TO_FLAT = 'toFlat'; - public const APPLY_PRICE_TYPE_PRICE = 'price'; - public const APPLY_PRICE_TYPE_PROMOTIONAL_PRICE = 'promotionalPrice'; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::CATALOG_PRICING_RULES; - } - - /** - * @throws InvalidConfigException - */ - public function getUsers(): ActiveQueryInterface - { - return $this->hasMany(User::class, ['id' => 'userId'])->viaTable(Table::CATALOG_PRICING_RULES_USERS, ['catalogPricingRuleId' => 'id']); - } -} diff --git a/src/records/CatalogPricingRuleUser.php b/src/records/CatalogPricingRuleUser.php deleted file mode 100644 index 23b1cff3b8..0000000000 --- a/src/records/CatalogPricingRuleUser.php +++ /dev/null @@ -1,61 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricingRuleUser extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::CATALOG_PRICING_RULES_USERS; - } - - /** - * @inheritdoc - */ - public function rules(): array - { - return [ - [['catalogPricingRuleId', 'userId'], 'unique', 'targetAttribute' => ['catalogPricingRuleId', 'userId']], - ]; - } - - /** - * @return ActiveQueryInterface - */ - public function getCatalogPricingRule(): ActiveQueryInterface - { - return $this->hasOne(CatalogPricingRule::class, ['id' => 'catalogPricingRuleId']); - } - - /** - * @noinspection PhpUnused - */ - public function getUser(): ActiveQueryInterface - { - return $this->hasOne(User::class, ['id' => 'userId']); - } -} diff --git a/src/records/Coupon.php b/src/records/Coupon.php deleted file mode 100644 index c697b7316c..0000000000 --- a/src/records/Coupon.php +++ /dev/null @@ -1,33 +0,0 @@ - - * @since 4.0 - */ -class Coupon extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::COUPONS; - } -} diff --git a/src/records/Customer.php b/src/records/Customer.php deleted file mode 100644 index 24784b37fd..0000000000 --- a/src/records/Customer.php +++ /dev/null @@ -1,70 +0,0 @@ - - * @since 4.0 - */ -class Customer extends ActiveRecord -{ - /** - * @inheritdoc - */ - public function rules(): array - { - return [ - [ - [ - 'customerId', - 'primaryBillingAddressId', - 'primaryShippingAddressId', - 'primaryPaymentSourceId', - ], 'safe', - ], - ]; - } - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::CUSTOMERS; - } - - public function getPrimaryBillingAddress(): ActiveQueryInterface - { - return $this->hasOne(Element::class, ['id' => 'primaryBillingAddressId']); - } - - public function getPrimaryShippingAddress(): ActiveQueryInterface - { - return $this->hasOne(Element::class, ['id' => 'primaryShippingAddressId']); - } - - public function getPrimaryPaymentSource(): ActiveQueryInterface - { - return $this->hasOne(PaymentSource::class, ['id' => 'primaryPaymentSourceId']); - } -} diff --git a/src/records/CustomerDiscountUse.php b/src/records/CustomerDiscountUse.php deleted file mode 100644 index daf03c286d..0000000000 --- a/src/records/CustomerDiscountUse.php +++ /dev/null @@ -1,49 +0,0 @@ - - * @since 2.0 - */ -class CustomerDiscountUse extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::CUSTOMER_DISCOUNTUSES; - } - - public function getDiscount(): ActiveQueryInterface - { - return $this->hasOne(Discount::class, ['id', 'discountId']); - } - - /** - * @return ActiveQueryInterface - */ - public function getCustomer(): ActiveQueryInterface - { - return $this->hasOne(Element::class, ['id', 'customerId']); - } -} diff --git a/src/records/Discount.php b/src/records/Discount.php deleted file mode 100644 index a6913b3928..0000000000 --- a/src/records/Discount.php +++ /dev/null @@ -1,112 +0,0 @@ - - * @since 2.0 - */ -class Discount extends ActiveRecord -{ - use StoreRecordTrait; - - public const TYPE_ORIGINAL_SALEPRICE = 'original'; - public const TYPE_DISCOUNTED_SALEPRICE = 'discounted'; - - public const CATEGORY_RELATIONSHIP_TYPE_SOURCE = 'sourceElement'; - public const CATEGORY_RELATIONSHIP_TYPE_TARGET = 'targetElement'; - public const CATEGORY_RELATIONSHIP_TYPE_BOTH = 'element'; - - public const APPLIED_TO_MATCHING_LINE_ITEMS = 'matchingLineItems'; - public const APPLIED_TO_ALL_LINE_ITEMS = 'allLineItems'; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::DISCOUNTS; - } - - /** - * @noinspection PhpUnused - */ - public function getDiscountPurchasables(): ActiveQueryInterface - { - return $this->hasMany(DiscountPurchasable::class, ['discountId' => 'id']); - } - - public function getDiscountCategories(): ActiveQueryInterface - { - return $this->hasMany(DiscountCategory::class, ['discountId' => 'id']); - } - - public function getGroups(): ActiveQueryInterface - { - return $this->hasMany(UserGroup::class, ['id' => 'discountId'])->via('discountUserGroups'); - } - - public function getPurchasables(): ActiveQueryInterface - { - return $this->hasMany(Purchasable::class, ['id' => 'discountId'])->via('discountPurchasables'); - } - - public function getCategories(): ActiveQueryInterface - { - return $this->hasMany(Category::class, ['id' => 'discountId'])->via('discountCategories'); - } -} diff --git a/src/records/DiscountCategory.php b/src/records/DiscountCategory.php deleted file mode 100644 index dcc2ff23d5..0000000000 --- a/src/records/DiscountCategory.php +++ /dev/null @@ -1,45 +0,0 @@ - - * @since 2.0 - */ -class DiscountCategory extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::DISCOUNT_CATEGORIES; - } - - public function getDiscount(): ActiveQueryInterface - { - return $this->hasOne(Discount::class, ['id' => 'discountId']); - } - - public function getCategory(): ActiveQueryInterface - { - return $this->hasOne(Category::class, ['id' => 'categoryId']); - } -} diff --git a/src/records/DiscountPurchasable.php b/src/records/DiscountPurchasable.php deleted file mode 100644 index 7856dbe1c5..0000000000 --- a/src/records/DiscountPurchasable.php +++ /dev/null @@ -1,45 +0,0 @@ - - * @since 2.0 - */ -class DiscountPurchasable extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::DISCOUNT_PURCHASABLES; - } - - public function getDiscount(): ActiveQueryInterface - { - return $this->hasOne(Discount::class, ['id' => 'discountId']); - } - - public function getPurchasable(): ActiveQueryInterface - { - return $this->hasOne(Purchasable::class, ['id' => 'purchasableId']); - } -} diff --git a/src/records/Donation.php b/src/records/Donation.php deleted file mode 100644 index 8ec72a1e25..0000000000 --- a/src/records/Donation.php +++ /dev/null @@ -1,40 +0,0 @@ - - * @since 2.0 - */ -class Donation extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::DONATIONS; - } - - public function getElement(): ActiveQueryInterface - { - return $this->hasOne(Element::class, ['id', 'id']); - } -} diff --git a/src/records/Email.php b/src/records/Email.php deleted file mode 100644 index 0eca86e947..0000000000 --- a/src/records/Email.php +++ /dev/null @@ -1,53 +0,0 @@ - - * @since 2.0 - */ -class Email extends ActiveRecord -{ - use StoreRecordTrait; - - public const LOCALE_ORDER_LANGUAGE = 'orderLanguage'; - - public const TYPE_CUSTOMER = 'customer'; - public const TYPE_CUSTOM = 'custom'; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::EMAILS; - } -} diff --git a/src/records/EmailDiscountUse.php b/src/records/EmailDiscountUse.php deleted file mode 100644 index 0416392111..0000000000 --- a/src/records/EmailDiscountUse.php +++ /dev/null @@ -1,41 +0,0 @@ - - * @since 2.0 - */ -class EmailDiscountUse extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::EMAIL_DISCOUNTUSES; - } - - public function getDiscount(): ActiveQueryInterface - { - return $this->hasOne(Discount::class, ['id', 'discountId']); - } -} diff --git a/src/records/Gateway.php b/src/records/Gateway.php deleted file mode 100644 index bfc269160d..0000000000 --- a/src/records/Gateway.php +++ /dev/null @@ -1,42 +0,0 @@ - - * @since 2.0 - */ -class Gateway extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::GATEWAYS; - } -} diff --git a/src/records/InventoryItem.php b/src/records/InventoryItem.php deleted file mode 100644 index fc47048c47..0000000000 --- a/src/records/InventoryItem.php +++ /dev/null @@ -1,41 +0,0 @@ - - * @since 5.0.0 - */ -class InventoryItem extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::INVENTORYITEMS; - } - - public function getPurchasable(): ActiveQueryInterface - { - return $this->hasOne(Purchasable::class, ['id' => 'purchasableId']); - } -} diff --git a/src/records/InventoryLocation.php b/src/records/InventoryLocation.php deleted file mode 100644 index 92b3778e09..0000000000 --- a/src/records/InventoryLocation.php +++ /dev/null @@ -1,36 +0,0 @@ - ['handle']], - ]; - } -} diff --git a/src/records/LineItem.php b/src/records/LineItem.php deleted file mode 100644 index b0f1b098ba..0000000000 --- a/src/records/LineItem.php +++ /dev/null @@ -1,89 +0,0 @@ - - * @since 2.0 - */ -class LineItem extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::LINEITEMS; - } - - public function getOrder(): ActiveQueryInterface - { - return $this->hasOne(Order::class, ['id' => 'orderId']); - } - - public function getPurchasable(): ActiveQueryInterface - { - return $this->hasOne(Element::class, ['id' => 'purchasableId']); - } - - public function getTaxCategory(): ActiveQueryInterface - { - return $this->hasOne(TaxCategory::class, ['id' => 'taxCategoryId']); - } - - public function getShippingCategory(): ActiveQueryInterface - { - return $this->hasOne(ShippingCategory::class, ['id' => 'shippingCategoryId']); - } - - public function getLineItemStatus(): ActiveQueryInterface - { - return $this->hasOne(LineItemStatus::class, ['id' => 'lineItemStatusId']); - } -} diff --git a/src/records/LineItemStatus.php b/src/records/LineItemStatus.php deleted file mode 100644 index 3f8188d5ac..0000000000 --- a/src/records/LineItemStatus.php +++ /dev/null @@ -1,41 +0,0 @@ - - * @since 2.0 - */ -class LineItemStatus extends ActiveRecord -{ - use StoreRecordTrait; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::LINEITEMSTATUSES; - } -} diff --git a/src/records/Order.php b/src/records/Order.php deleted file mode 100644 index 3927c40a61..0000000000 --- a/src/records/Order.php +++ /dev/null @@ -1,148 +0,0 @@ - - * @since 2.0 - */ -class Order extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::ORDERS; - } - - public function getLineItems(): ActiveQueryInterface - { - return $this->hasMany(LineItem::class, ['orderId' => 'id']); - } - - public function getTransactions(): ActiveQueryInterface - { - return $this->hasMany(Transaction::class, ['orderId' => 'id']); - } - - public function getHistories(): ActiveQueryInterface - { - return $this->hasMany(OrderHistory::class, ['orderId' => 'id']); - } - - public function getBillingAddress(): ActiveQueryInterface - { - return $this->hasOne(Element::class, ['id' => 'billingAddressId']); - } - - public function getShippingAddress(): ActiveQueryInterface - { - return $this->hasOne(Element::class, ['id' => 'shippingAddressId']); - } - - public function getDiscount(): ActiveQueryInterface - { - return $this->hasOne(Discount::class, ['code' => 'couponCode']); - } - - public function getGateway(): ActiveQueryInterface - { - return $this->hasOne(Gateway::class, ['id' => 'gatewayId']); - } - - public function getPaymentSource(): ActiveQueryInterface - { - return $this->hasOne(PaymentSource::class, ['id' => 'paymentSourceId']); - } - - public function getCustomer(): ActiveQueryInterface - { - return $this->hasOne(User::class, ['id' => 'customerId']); - } - - public function getElement(): ActiveQueryInterface - { - return $this->hasOne(Element::class, ['id' => 'id']); - } - - public function getOrderStatus(): ActiveQueryInterface - { - return $this->hasOne(OrderStatus::class, ['id' => 'orderStatusId']); - } -} diff --git a/src/records/OrderAdjustment.php b/src/records/OrderAdjustment.php deleted file mode 100644 index da0c165813..0000000000 --- a/src/records/OrderAdjustment.php +++ /dev/null @@ -1,45 +0,0 @@ - - * @since 2.0 - */ -class OrderAdjustment extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::ORDERADJUSTMENTS; - } - - public function getOrder(): ActiveQueryInterface - { - return $this->hasOne(Order::class, ['id' => 'orderId']); - } -} diff --git a/src/records/OrderHistory.php b/src/records/OrderHistory.php deleted file mode 100644 index 6ff64e1f2c..0000000000 --- a/src/records/OrderHistory.php +++ /dev/null @@ -1,72 +0,0 @@ - - * @since 2.0 - */ -class OrderHistory extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::ORDERHISTORIES; - } - - public function getOrder(): ActiveQueryInterface - { - return $this->hasOne(Order::class, ['id' => 'orderId']); - } - - /** - * @noinspection PhpUnused - */ - public function getPrevStatus(): ActiveQueryInterface - { - return $this->hasOne(OrderStatus::class, ['id' => 'prevStatusId']); - } - - /** - * @noinspection PhpUnused - */ - public function getNewStatus(): ActiveQueryInterface - { - return $this->hasOne(OrderStatus::class, ['id' => 'newStatusId']); - } - - /** - * @return ActiveQueryInterface - */ - public function getUser(): ActiveQueryInterface - { - return $this->hasOne(User::class, ['id' => 'userId']); - } -} diff --git a/src/records/OrderNotice.php b/src/records/OrderNotice.php deleted file mode 100644 index 2474956b38..0000000000 --- a/src/records/OrderNotice.php +++ /dev/null @@ -1,41 +0,0 @@ - - * @since 3.3 - */ -class OrderNotice extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::ORDERNOTICES; - } - - public function getOrder(): ActiveQueryInterface - { - return $this->hasOne(Order::class, ['id' => 'orderId']); - } -} diff --git a/src/records/OrderStatus.php b/src/records/OrderStatus.php deleted file mode 100644 index e8567c4928..0000000000 --- a/src/records/OrderStatus.php +++ /dev/null @@ -1,55 +0,0 @@ - - * @since 2.0 - */ -class OrderStatus extends ActiveRecord -{ - use SoftDeleteTrait; - use StoreRecordTrait; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::ORDERSTATUSES; - } - - /** - * @throws InvalidConfigException - */ - public function getEmails(): ActiveQueryInterface - { - return $this->hasMany(Email::class, ['id' => 'emailId'])->viaTable(Table::ORDERSTATUS_EMAILS, ['orderStatusId' => 'id']); - } -} diff --git a/src/records/OrderStatusEmail.php b/src/records/OrderStatusEmail.php deleted file mode 100644 index 7f68eb3381..0000000000 --- a/src/records/OrderStatusEmail.php +++ /dev/null @@ -1,43 +0,0 @@ - - * @since 2.0 - */ -class OrderStatusEmail extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::ORDERSTATUS_EMAILS; - } - - public function getOrderStatus(): ActiveQueryInterface - { - return $this->hasOne(OrderStatus::class, ['id' => 'orderStatusId']); - } - - public function getEmail(): ActiveQueryInterface - { - return $this->hasOne(Email::class, ['id' => 'emailId']); - } -} diff --git a/src/records/PaymentCurrency.php b/src/records/PaymentCurrency.php deleted file mode 100644 index 8e9a960cf1..0000000000 --- a/src/records/PaymentCurrency.php +++ /dev/null @@ -1,33 +0,0 @@ - - * @since 2.0 - */ -class PaymentCurrency extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::PAYMENTCURRENCIES; - } -} diff --git a/src/records/PaymentSource.php b/src/records/PaymentSource.php deleted file mode 100644 index 868d3f6c14..0000000000 --- a/src/records/PaymentSource.php +++ /dev/null @@ -1,59 +0,0 @@ - - * @since 2.0 - */ -class PaymentSource extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::PAYMENTSOURCES; - } - - /** - * Return the payment source's gateway - * - * @return ActiveQueryInterface The relational query object. - */ - public function getGateway(): ActiveQueryInterface - { - return $this->hasOne(Gateway::class, ['id' => 'gatewayId']); - } - - - /** - * Return the payment source's owner customer/user. - * - * @return ActiveQueryInterface The relational query object. - */ - public function getUser(): ActiveQueryInterface - { - return $this->hasOne(Element::class, ['id' => 'customerId']); - } -} diff --git a/src/records/Pdf.php b/src/records/Pdf.php deleted file mode 100644 index 551fef69dd..0000000000 --- a/src/records/Pdf.php +++ /dev/null @@ -1,57 +0,0 @@ - - * @since 3.2 - */ -class Pdf extends ActiveRecord -{ - use StoreRecordTrait; - - public const LOCALE_ORDER_LANGUAGE = 'orderLanguage'; - - /** - * @since 5.0.0 - */ - public const PAPER_ORIENTATION_PORTRAIT = 'portrait'; - - /** - * @since 5.0.0 - */ - public const PAPER_ORIENTATION_LANDSCAPE = 'landscape'; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::PDFS; - } -} diff --git a/src/records/Plan.php b/src/records/Plan.php deleted file mode 100644 index eaa69caaff..0000000000 --- a/src/records/Plan.php +++ /dev/null @@ -1,51 +0,0 @@ - - * @since 2.0 - */ -class Plan extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::PLANS; - } - - /** - * Return the subscription plan's gateway - * - * @return ActiveQueryInterface The relational query object. - */ - public function getGateway(): ActiveQueryInterface - { - return $this->hasOne(Gateway::class, ['gatewayId' => 'id']); - } -} diff --git a/src/records/Product.php b/src/records/Product.php deleted file mode 100644 index e4a2b26cfa..0000000000 --- a/src/records/Product.php +++ /dev/null @@ -1,60 +0,0 @@ - - * @since 2.0 - */ -class Product extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::PRODUCTS; - } - - public function getVariants(): ActiveQueryInterface - { - return $this->hasMany(Variant::class, ['productId' => 'id']); - } - - public function getElement(): ActiveQueryInterface - { - return $this->hasOne(Element::class, ['id' => 'id']); - } - - public function getType(): ActiveQueryInterface - { - return $this->hasOne(ProductType::class, ['id' => 'productTypeId']); - } -} diff --git a/src/records/ProductType.php b/src/records/ProductType.php deleted file mode 100644 index bbaa63efb3..0000000000 --- a/src/records/ProductType.php +++ /dev/null @@ -1,100 +0,0 @@ - - * @since 2.0 - */ -class ProductType extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::PRODUCTTYPES; - } - - public function getProductTypesShippingCategories(): ActiveQueryInterface - { - return $this->hasMany(ProductTypeShippingCategory::class, ['productTypeId' => 'id']); - } - - public function getShippingCategories(): ActiveQueryInterface - { - return $this->hasMany(ShippingCategory::class, ['id' => 'shippingCategoryId']) - ->via('productTypesShippingCategories'); - } - - public function getProductTypesTaxCategories(): ActiveQueryInterface - { - return $this->hasMany(ProductTypeTaxCategory::class, ['productTypeId' => 'id']); - } - - public function getTaxCategories(): ActiveQueryInterface - { - return $this->hasMany(TaxCategory::class, ['id' => 'taxCategoryId']) - ->via('productTypesTaxCategories'); - } - - public function getFieldLayout(): ActiveQueryInterface - { - return $this->hasOne(FieldLayout::class, ['id' => 'fieldLayoutId']); - } - - /** - * @noinspection PhpUnused - */ - public function getVariantFieldLayout(): ActiveQueryInterface - { - return $this->hasOne(FieldLayout::class, ['id' => 'variantFieldLayoutId']); - } -} diff --git a/src/records/ProductTypeShippingCategory.php b/src/records/ProductTypeShippingCategory.php deleted file mode 100644 index 4633dfd01d..0000000000 --- a/src/records/ProductTypeShippingCategory.php +++ /dev/null @@ -1,43 +0,0 @@ - - * @since 2.0 - */ -class ProductTypeShippingCategory extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::PRODUCTTYPES_SHIPPINGCATEGORIES; - } - - public function getProductType(): ActiveQueryInterface - { - return $this->hasOne(ProductType::class, ['id', 'productTypeId']); - } - - public function getShippingCategory(): ActiveQueryInterface - { - return $this->hasOne(ShippingCategory::class, ['id', 'shippingCategoryId']); - } -} diff --git a/src/records/ProductTypeSite.php b/src/records/ProductTypeSite.php deleted file mode 100644 index 08191f52d0..0000000000 --- a/src/records/ProductTypeSite.php +++ /dev/null @@ -1,49 +0,0 @@ - - * @since 2.0 - */ -class ProductTypeSite extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::PRODUCTTYPES_SITES; - } - - public function getProductType(): ActiveQueryInterface - { - return $this->hasOne(ProductType::class, ['id', 'productTypeId']); - } - - public function getSite(): ActiveQueryInterface - { - return $this->hasOne(Site::class, ['id', 'siteId']); - } -} diff --git a/src/records/ProductTypeTaxCategory.php b/src/records/ProductTypeTaxCategory.php deleted file mode 100644 index 14d752072a..0000000000 --- a/src/records/ProductTypeTaxCategory.php +++ /dev/null @@ -1,43 +0,0 @@ - - * @since 2.0 - */ -class ProductTypeTaxCategory extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::PRODUCTTYPES_TAXCATEGORIES; - } - - public function getProductType(): ActiveQueryInterface - { - return $this->hasOne(ProductType::class, ['id', 'productTypeId']); - } - - public function getTaxCategory(): ActiveQueryInterface - { - return $this->hasOne(TaxCategory::class, ['id', 'taxCategoryId']); - } -} diff --git a/src/records/Purchasable.php b/src/records/Purchasable.php deleted file mode 100644 index 88de1ff67e..0000000000 --- a/src/records/Purchasable.php +++ /dev/null @@ -1,63 +0,0 @@ - - * @since 2.0 - */ -class Purchasable extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::PURCHASABLES; - } - - public static function find(): \craft\db\ActiveQuery - { - return parent::find() - ->innerJoinWith(['element element']) - ->where(['element.dateDeleted' => null]); - } - - public static function findWithTrashed(): ActiveQuery - { - return static::find()->where([]); - } - - public static function findTrashed(): ActiveQuery - { - return static::find()->where(['not', ['element.dateDeleted' => null]]); - } - - public function getElement(): ActiveQueryInterface - { - return $this->hasOne(Element::class, ['id' => 'id']); - } -} diff --git a/src/records/PurchasableStore.php b/src/records/PurchasableStore.php deleted file mode 100644 index 696d70fa25..0000000000 --- a/src/records/PurchasableStore.php +++ /dev/null @@ -1,54 +0,0 @@ - - * @since 5.0.0 - */ -class PurchasableStore extends ActiveRecord -{ - use StoreRecordTrait; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::PURCHASABLES_STORES; - } - - /** - * @return ActiveQueryInterface - */ - public function getPurchasable(): ActiveQueryInterface - { - return $this->hasOne(Purchasable::class, ['id' => 'purchasableId']); - } -} diff --git a/src/records/Sale.php b/src/records/Sale.php deleted file mode 100644 index de9405a0f6..0000000000 --- a/src/records/Sale.php +++ /dev/null @@ -1,83 +0,0 @@ - - * @since 2.0 - */ -class Sale extends ActiveRecord -{ - public const APPLY_BY_PERCENT = 'byPercent'; - public const APPLY_BY_FLAT = 'byFlat'; - public const APPLY_TO_PERCENT = 'toPercent'; - public const APPLY_TO_FLAT = 'toFlat'; - - public const CATEGORY_RELATIONSHIP_TYPE_SOURCE = 'sourceElement'; - public const CATEGORY_RELATIONSHIP_TYPE_TARGET = 'targetElement'; - public const CATEGORY_RELATIONSHIP_TYPE_BOTH = 'element'; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::SALES; - } - - /** - * @throws InvalidConfigException - */ - public function getGroups(): ActiveQueryInterface - { - return $this->hasMany(UserGroup::class, ['id' => 'userGroupId'])->viaTable(Table::SALE_USERGROUPS, ['saleId' => 'id']); - } - - /** - * @throws InvalidConfigException - */ - public function getPurchasables(): ActiveQueryInterface - { - return $this->hasMany(Purchasable::class, ['id' => 'purchasableId'])->viaTable(Table::SALE_PURCHASABLES, ['saleId' => 'id']); - } - - /** - * @throws InvalidConfigException - */ - public function getCategories(): ActiveQueryInterface - { - return $this->hasMany(Category::class, ['id' => 'categoryId'])->viaTable(Table::SALE_CATEGORIES, ['saleId' => 'id']); - } -} diff --git a/src/records/SaleCategory.php b/src/records/SaleCategory.php deleted file mode 100644 index e514c520be..0000000000 --- a/src/records/SaleCategory.php +++ /dev/null @@ -1,55 +0,0 @@ - - * @since 2.0 - */ -class SaleCategory extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::SALE_CATEGORIES; - } - - /** - * @inheritdoc - */ - public function rules(): array - { - return [ - [['saleId', 'categoryId'], 'unique', 'targetAttribute' => ['saleId', 'categoryId']], - ]; - } - - public function getSale(): ActiveQueryInterface - { - return $this->hasOne(Sale::class, ['saleId' => 'id']); - } - - public function getCategory(): ActiveQueryInterface - { - return $this->hasOne(Category::class, ['saleId' => 'id']); - } -} diff --git a/src/records/SalePurchasable.php b/src/records/SalePurchasable.php deleted file mode 100644 index dedc194183..0000000000 --- a/src/records/SalePurchasable.php +++ /dev/null @@ -1,55 +0,0 @@ - - * @since 2.0 - */ -class SalePurchasable extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::SALE_PURCHASABLES; - } - - /** - * @inheritdoc - */ - public function rules(): array - { - return [ - [['saleId', 'purchasableId'], 'unique', 'targetAttribute' => ['saleId', 'purchasableId']], - ]; - } - - public function getSale(): ActiveQueryInterface - { - return $this->hasOne(Sale::class, ['saleId' => 'id']); - } - - public function getPurchasable(): ActiveQueryInterface - { - return $this->hasOne(Purchasable::class, ['saleId' => 'id']); - } -} diff --git a/src/records/SaleUserGroup.php b/src/records/SaleUserGroup.php deleted file mode 100644 index 8cde2b3239..0000000000 --- a/src/records/SaleUserGroup.php +++ /dev/null @@ -1,58 +0,0 @@ - - * @since 2.0 - */ -class SaleUserGroup extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::SALE_USERGROUPS; - } - - /** - * @inheritdoc - */ - public function rules(): array - { - return [ - [['saleId', 'userGroupId'], 'unique', 'targetAttribute' => ['saleId', 'userGroupId']], - ]; - } - - public function getSale(): ActiveQueryInterface - { - return $this->hasOne(Sale::class, ['saleId' => 'id']); - } - - /** - * @noinspection PhpUnused - */ - public function getUserGroup(): ActiveQueryInterface - { - return $this->hasOne(UserGroup::class, ['saleId' => 'id']); - } -} diff --git a/src/records/ShippingCategory.php b/src/records/ShippingCategory.php deleted file mode 100644 index 15bd3bb7e6..0000000000 --- a/src/records/ShippingCategory.php +++ /dev/null @@ -1,41 +0,0 @@ - - * @since 2.0 - */ -class ShippingCategory extends ActiveRecord -{ - use SoftDeleteTrait; - use StoreRecordTrait; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::SHIPPINGCATEGORIES; - } -} diff --git a/src/records/ShippingMethod.php b/src/records/ShippingMethod.php deleted file mode 100644 index 43f284bfd3..0000000000 --- a/src/records/ShippingMethod.php +++ /dev/null @@ -1,47 +0,0 @@ - - * @since 2.0 - */ -class ShippingMethod extends ActiveRecord -{ - use StoreRecordTrait; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::SHIPPINGMETHODS; - } - - public function getRules(): ActiveQueryInterface - { - return $this->hasMany(ShippingRule::class, ['shippingMethodId' => 'id']); - } -} diff --git a/src/records/ShippingRule.php b/src/records/ShippingRule.php deleted file mode 100644 index 8e9cce2df3..0000000000 --- a/src/records/ShippingRule.php +++ /dev/null @@ -1,60 +0,0 @@ - - * @since 2.0 - */ -class ShippingRule extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::SHIPPINGRULES; - } - - /** - * @inheritdoc - */ - public function rules(): array - { - return [ - [['name'], 'required'], - ]; - } - - public function getMethod(): ActiveQueryInterface - { - return $this->hasOne(ShippingZone::class, ['id' => 'shippingMethodId']); - } -} diff --git a/src/records/ShippingRuleCategory.php b/src/records/ShippingRuleCategory.php deleted file mode 100644 index a8e765a354..0000000000 --- a/src/records/ShippingRuleCategory.php +++ /dev/null @@ -1,55 +0,0 @@ - - * @since 2.0 - */ -class ShippingRuleCategory extends ActiveRecord -{ - public const CONDITION_ALLOW = 'allow'; - public const CONDITION_DISALLOW = 'disallow'; - public const CONDITION_REQUIRE = 'require'; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::SHIPPINGRULE_CATEGORIES; - } - - /** - * @noinspection PhpUnused - */ - public function getShippingRule(): ActiveQueryInterface - { - return $this->hasOne(ShippingRule::class, ['id' => 'shippingRuleId']); - } - - public function getShippingCategory(): ActiveQueryInterface - { - return $this->hasOne(ShippingCategory::class, ['id' => 'shippingCategoryId']); - } -} diff --git a/src/records/ShippingZone.php b/src/records/ShippingZone.php deleted file mode 100644 index 2b9d8b3c0e..0000000000 --- a/src/records/ShippingZone.php +++ /dev/null @@ -1,36 +0,0 @@ - - * @since 2.0 - */ -class ShippingZone extends ActiveRecord -{ - use StoreRecordTrait; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::SHIPPINGZONES; - } -} diff --git a/src/records/SiteStore.php b/src/records/SiteStore.php deleted file mode 100644 index 8243d1fee0..0000000000 --- a/src/records/SiteStore.php +++ /dev/null @@ -1,41 +0,0 @@ - - * @since 4.0 - */ -class SiteStore extends ActiveRecord -{ - use StoreRecordTrait; - - /** - * @inheritDoc - */ - public static function primaryKey(): array - { - return ['siteId']; - } - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::SITESTORES; - } -} diff --git a/src/records/Store.php b/src/records/Store.php deleted file mode 100644 index eeb2735eee..0000000000 --- a/src/records/Store.php +++ /dev/null @@ -1,48 +0,0 @@ - - * @since 4.0 - */ -class Store extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::STORES; - } -} diff --git a/src/records/StoreSettings.php b/src/records/StoreSettings.php deleted file mode 100644 index 5f24dc8ffc..0000000000 --- a/src/records/StoreSettings.php +++ /dev/null @@ -1,44 +0,0 @@ - - * @since 4.0 - */ -class StoreSettings extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::STORESETTINGS; - } - - /** - * Returns the store's location - * - * @return ActiveQueryInterface The relational query object. - */ - public function getStoreLocation(): ActiveQueryInterface - { - return $this->hasOne(Address::class, ['id' => 'locationAddressId']); - } -} diff --git a/src/records/Subscription.php b/src/records/Subscription.php deleted file mode 100644 index b89dc1f440..0000000000 --- a/src/records/Subscription.php +++ /dev/null @@ -1,81 +0,0 @@ - - * @since 2.0 - */ -class Subscription extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::SUBSCRIPTIONS; - } - - /** - * Return the subscription's gateway - * - * @return ActiveQueryInterface The relational query object. - */ - public function getGateway(): ActiveQueryInterface - { - return $this->hasOne(Gateway::class, ['gatewayId' => 'id']); - } - - /** - * Return the subscription's user - * - * @return ActiveQueryInterface The relational query object. - */ - public function getUser(): ActiveQueryInterface - { - return $this->hasOne(User::class, ['userId' => 'id']); - } - - /** - * Return the subscription's plan - * - * @return ActiveQueryInterface The relational query object. - */ - public function getPlan(): ActiveQueryInterface - { - return $this->hasOne(Plan::class, ['planId' => 'id']); - } -} diff --git a/src/records/TaxCategory.php b/src/records/TaxCategory.php deleted file mode 100644 index 5ed1d3c214..0000000000 --- a/src/records/TaxCategory.php +++ /dev/null @@ -1,45 +0,0 @@ - - * @since 2.0 - */ -class TaxCategory extends ActiveRecord -{ - use SoftDeleteTrait; - - public static function tableName(): string - { - return Table::TAXCATEGORIES; - } - - /** - * @inheritdoc - */ - public function rules(): array - { - return [ - [['handle'], 'required'], - ]; - } -} diff --git a/src/records/TaxRate.php b/src/records/TaxRate.php deleted file mode 100644 index 43526cea1b..0000000000 --- a/src/records/TaxRate.php +++ /dev/null @@ -1,100 +0,0 @@ - - * @since 2.0 - */ -class TaxRate extends ActiveRecord -{ - use StoreRecordTrait; - - /** - * @var string Tax subject is line item price. - */ - public const TAXABLE_PURCHASABLE = 'purchasable'; - - /** - * @var string Tax subject is line item price. - */ - public const TAXABLE_PRICE = 'price'; - - /** - * @var string Tax subject is line item shipping cost. - */ - public const TAXABLE_SHIPPING = 'shipping'; - - /** - * @var string Tax subject is line item price and shipping cost. - */ - public const TAXABLE_PRICE_SHIPPING = 'price_shipping'; - - /** - * @var string Tax subject is order total shipping cost. - */ - public const TAXABLE_ORDER_TOTAL_SHIPPING = 'order_total_shipping'; - - /** - * @var string Tax subject is order total price. - */ - public const TAXABLE_ORDER_TOTAL_PRICE = 'order_total_price'; - - /** - * @var array Order-specific tax subject options. - */ - public const ORDER_TAXABALES = [ - self::TAXABLE_ORDER_TOTAL_PRICE, - self::TAXABLE_ORDER_TOTAL_SHIPPING, - ]; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::TAXRATES; - } - - /** - * @noinspection PhpUnused - */ - public function getTaxZone(): ActiveQueryInterface - { - return $this->hasOne(TaxZone::class, ['id' => 'taxZoneId']); - } - - public function getTaxCategory(): ActiveQueryInterface - { - return $this->hasOne(TaxCategory::class, ['id' => 'taxCategoryId']); - } -} diff --git a/src/records/TaxZone.php b/src/records/TaxZone.php deleted file mode 100644 index e8acde36a5..0000000000 --- a/src/records/TaxZone.php +++ /dev/null @@ -1,37 +0,0 @@ - - * @since 2.0 - */ -class TaxZone extends ActiveRecord -{ - use StoreRecordTrait; - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::TAXZONES; - } -} diff --git a/src/records/Transaction.php b/src/records/Transaction.php deleted file mode 100644 index d35e45d761..0000000000 --- a/src/records/Transaction.php +++ /dev/null @@ -1,89 +0,0 @@ - - * @since 2.0 - */ -class Transaction extends ActiveRecord -{ - public const TYPE_AUTHORIZE = 'authorize'; - public const TYPE_CAPTURE = 'capture'; - public const TYPE_PURCHASE = 'purchase'; - public const TYPE_REFUND = 'refund'; - public const STATUS_PENDING = 'pending'; - public const STATUS_REDIRECT = 'redirect'; - public const STATUS_PROCESSING = 'processing'; - public const STATUS_SUCCESS = 'success'; - public const STATUS_FAILED = 'failed'; - - - /** - * @var int $total - */ - public int $total = 0; - - - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::TRANSACTIONS; - } - - public function getParent(): ActiveQueryInterface - { - return $this->hasOne(self::class, ['id' => 'parentId']); - } - - public function getGateway(): ActiveQueryInterface - { - return $this->hasOne(Gateway::class, ['id' => 'gatewayId']); - } - - public function getOrder(): ActiveQueryInterface - { - return $this->hasOne(Order::class, ['id' => 'orderId']); - } - - public function getUser(): ActiveQueryInterface - { - return $this->hasOne(User::class, ['id' => 'userId']); - } -} diff --git a/src/records/Transfer.php b/src/records/Transfer.php deleted file mode 100644 index 59e87c2d39..0000000000 --- a/src/records/Transfer.php +++ /dev/null @@ -1,21 +0,0 @@ - - * @since 2.0 - */ -class Variant extends ActiveRecord -{ - /** - * @inheritdoc - */ - public static function tableName(): string - { - return Table::VARIANTS; - } - - public function getProduct(): ActiveQueryInterface - { - return $this->hasOne(Product::class, ['id', 'productId']); - } - - public function getElement(): ActiveQueryInterface - { - return $this->hasOne(Element::class, ['id', 'id']); - } -} diff --git a/src/services/Carts.php b/src/services/Carts.php deleted file mode 100644 index e5a0e7c21c..0000000000 --- a/src/services/Carts.php +++ /dev/null @@ -1,673 +0,0 @@ - - * @since 2.0 - */ -class Carts extends Component -{ - /** - * @event CartPurgeEvent The event that is triggered before the carts are purged. - * - * This example modifies the query to only purge carts with a total price of 0. - * You can also set the `isValid` property to `false` to prevent the carts from being purged. - * - * ```php - * use craft\commerce\events\CartPurgeEvent; - * use craft\commerce\services\Carts; - * use yii\base\Event; - * - * Event::on( - * Carts::class, - * Carts::EVENT_BEFORE_PURGE_INACTIVE_CARTS, - * function(CartPurgeEvent $event) { - * $event->inactiveCartsQuery = $event->inactiveCartsQuery->andWhere(['totalPrice' => 0]); - * } - * ); - * ``` - */ - public const EVENT_BEFORE_PURGE_INACTIVE_CARTS = 'beforePurgeInactiveCarts'; - - /** - * @var array The configuration of the cart cookie. - * @since 4.0.0 - * @see setSessionCartNumber() - */ - public array $cartCookie = []; - - /** - * @var int The expiration duration of the cart cookie, in seconds. (Defaults to one year.) - * @since 4.0.0 - * @see setSessionCartNumber() - */ - public int $cartCookieDuration = 31536000; - - /** - * @var Order|null - */ - private ?Order $_cart = null; - - /** - * @var string|null The current cart number - */ - private string|false|null $_cartNumber = null; - - /** - * Useful for debugging how many times the cart is being requested during a request. - * - * @var int The number of times the cart was requested. - */ - private int $_getCartCount = 0; - - /** - * Initializes the cart service - * - * @return void - * @throws MissingComponentException - */ - public function init() - { - parent::init(); - - $currentStore = Plugin::getInstance()->getStores()->getCurrentStore(); - - // Complete the cart cookie config - if (!isset($this->cartCookie['name'])) { - $this->cartCookie['name'] = md5(sprintf('Craft.%s.%s.%s', self::class, Craft::$app->id, $currentStore->handle)) . '_commerce_cart'; - } - - $request = Craft::$app->getRequest(); - if (!$request->getIsConsoleRequest()) { - $this->cartCookie = Craft::cookieConfig($this->cartCookie); - - $session = Craft::$app->getSession(); - - // Also check pre Commerce 4.0 for a cart number in the session just in case. - if (($session->getHasSessionId() || $session->getIsActive()) && $session->has('commerce_cart')) { - $this->setSessionCartNumber($session->get('commerce_cart')); - $session->remove('commerce_cart'); - } - } - } - - /** - * Get the current cart for this session. - * - * @param bool $forceSave Force the cart. - * @throws ElementNotFoundException - * @throws Exception - * @throws Throwable - */ - public function getCart(bool $forceSave = false): Order - { - $this->loadCookie(); // @TODO Audit other public runtime entry points (e.g. forgetCart, restorePreviousCartForCurrentUser) to see if they also need loadCookie() called first - - $this->_getCartCount++; //useful when debugging - $currentUser = Craft::$app->getUser()->getIdentity(); - - // If there is no cart set for this request, and we can't get a cart from session, create one. - if (!isset($this->_cart) && !$this->_cart = $this->_getCart()) { - $cartAttributes = [ - 'number' => $this->getSessionCartNumber(), - 'orderSiteId' => Craft::$app->getSites()->getCurrentSite()->id, - 'storeId' => Plugin::getInstance()->getStores()->getCurrentStore()->id, - ]; - - if ($currentUser) { - $cartAttributes['customer'] = $currentUser; // Will ensure the email is also set - } - - $this->_cart = Craft::createObject([ - 'class' => Order::class, - 'attributes' => $cartAttributes, - ]); - } elseif ($this->_cart->orderSiteId != Craft::$app->getSites()->getCurrentSite()->id) { - $this->_cart->orderSiteId = Craft::$app->getSites()->getCurrentSite()->id; - $forceSave = true; - } - - // Just in case the cart go put into a non all recalculation mode - if ($this->_cart->getRecalculationMode() !== Order::RECALCULATION_MODE_ALL) { - $this->_cart->setRecalculationMode(Order::RECALCULATION_MODE_ALL); - $forceSave = true; - } - - $autoSetAddresses = false; - // We only want to call autoSetAddresses() if we have a authed cart customer - if ($currentUser && $currentUser->id == $this->_cart->customerId) { - $autoSetAddresses = $this->_cart->autoSetAddresses(); - } - $autoSetShippingMethod = $this->_cart->autoSetShippingMethod(); - $autoSetPaymentSource = $this->_cart->autoSetPaymentSource(); - if ($autoSetAddresses || $autoSetShippingMethod || $autoSetPaymentSource) { - $forceSave = true; - } - - // Ensure the session knows what the current cart is. - $this->setSessionCartNumber($this->_cart->number); - - // Track the things that might change on this cart - $originalIp = $this->_cart->lastIp; - $originalOrderLanguage = $this->_cart->orderLanguage; - $originalSiteId = $this->_cart->orderSiteId; - $originalPaymentCurrency = $this->_cart->paymentCurrency; - $originalUserId = $this->_cart->getCustomerId(); - - // These values should always be kept up to date when a cart is retrieved from session. - $this->_cart->lastIp = Craft::$app->getRequest()->getUserIP(); - $this->_cart->orderLanguage = Craft::$app->language; - $this->_cart->orderSiteId = Craft::$app->getSites()->getHasCurrentSite() ? Craft::$app->getSites()->getCurrentSite()->id : Craft::$app->getSites()->getPrimarySite()->id; - $this->_cart->paymentCurrency = $this->_getCartPaymentCurrencyIso(); - $this->_cart->origin = Order::ORIGIN_WEB; - - // Switch the cart customer if needed - if ($currentUser && ($this->_cart->getCustomer() === null || ($currentUser->email && $currentUser->email !== $this->_cart->getEmail()))) { - $this->_cart->setCustomer($currentUser); - } - - $hasIpChanged = $originalIp != $this->_cart->lastIp; - $hasOrderLanguageChanged = $originalOrderLanguage != $this->_cart->orderLanguage; - $hasOrderSiteIdChanged = $originalSiteId != $this->_cart->orderSiteId; - $hasPaymentCurrencyChanged = $originalPaymentCurrency != $this->_cart->paymentCurrency; - $hasUserChanged = $originalUserId != $this->_cart->getCustomerId(); - - $hasSomethingChangedOnCart = ($hasIpChanged || $hasOrderLanguageChanged || $hasUserChanged || $hasPaymentCurrencyChanged || $hasOrderSiteIdChanged); - - // If the cart has already been saved (has an ID), then only save if something else changed. - if (($this->_cart->id && $hasSomethingChangedOnCart) || $forceSave) { - Craft::$app->getElements()->saveElement($this->_cart, false); - } - - return $this->_cart; - } - - /** - * Returns the existing cart for this session without creating one, setting cookies, or touching the session. - * Returns null if no cart cookie is present or no matching cart exists. - * - * @since 5.7.0 - */ - public function peekCart(): ?Order - { - if (isset($this->_cart)) { - return $this->_cart; - } - - if ($this->_cartNumber === false) { - return null; - } - - if (!$this->_cartNumber) { - $cookieNumber = Craft::$app->getRequest()->getCookies()->getValue($this->cartCookie['name'], false); - if (!$cookieNumber) { - return null; - } - $this->_cartNumber = $cookieNumber; - } - - /** @var Order|null $cart */ - $cart = Order::find() - ->number($this->_cartNumber) - ->storeId(Plugin::getInstance()->getStores()->getCurrentStore()->id) - ->isCompleted(false) - ->trashed(false) - ->one(); - - if (!$cart) { - return null; - } - - // Don't return a cart that belongs to a credentialed user who isn't currently logged in - // as that user, unless this session has been authorized to use it (e.g. loaded via a valid - // load-cart token). Mirrors the privacy check in _getCart(), but without forgetting the cart - // (which would set a Set-Cookie header and defeat the purpose of this method). - $cartCustomer = $cart->getCustomer(); - if ($cartCustomer && $cartCustomer->getIsCredentialed()) { - $authorizedForCredentialedCart = Craft::$app->getSession()->get('commerce:anonymousCartWithCredentialedCustomer:' . $cart->number, false); - if (!$authorizedForCredentialedCart) { - $currentUser = Craft::$app->getUser()->getIdentity(); - if (!$currentUser || $currentUser->id != $cartCustomer->id) { - return null; - } - } - } - - $this->_cart = $cart; - return $this->_cart; - } - - /** - * Get the current cart for this session. - */ - private function _getCart(): ?Order - { - $number = $this->getSessionCartNumber(); - /** @var Order|null $cart */ - $cart = Order::find() - ->withLineItems() - ->withAdjustments() - ->number($number) - ->storeId(Plugin::getInstance()->getStores()->getCurrentStore()->id) - ->trashed(null) - ->status(null) - ->one(); - - // If the cart is already completed or trashed, forget the cart and start again. - if ($cart && ($cart->isCompleted || $cart->trashed)) { - $this->forgetCart(); - return null; - } - - $currentUser = Craft::$app->getUser()->getIdentity(); - - $cartCustomer = $cart?->getCustomer(); - - // Is this session authorized to use a cart that belongs to a credentialed user? This is the - // case when an anonymous user submitted the credentialed user's email to the cart (see - // CartController::actionUpdate()), or when the cart was loaded via a valid load-cart token - // (see CartController::actionLoadCart()). - $authorizedForCredentialedCart = $cart && Craft::$app->getSession()->get('commerce:anonymousCartWithCredentialedCustomer:' . $cart->number, false); - - if ($cart && $cartCustomer && $cartCustomer->getIsCredentialed() && - !$authorizedForCredentialedCart && - ( - // Forget cart if they are not logged-in. - !$currentUser - || - // Forget cart if the logged-in user is not the same as the cart customer. - $currentUser->id != $cartCustomer->id - ) - ) { - $this->forgetCart(); - return null; - } - - return $cart; - } - - /** - * Forgets the cart in the current session. - */ - public function forgetCart(): void - { - $this->_cart = null; - // Force a new cart number to be generated when next requested. - $this->_cartNumber = false; - if (!Craft::$app->getRequest()->getIsConsoleRequest()) { - $cookie = Craft::createObject(array_merge($this->cartCookie, [ - 'class' => Cookie::class, - ])); - - Craft::$app->getResponse()->getCookies()->remove($cookie, true); - } - } - - /** - * Generates a new random cart number and returns it. - * - * @since 2.0 - */ - public function generateCartNumber(): string - { - return bin2hex(random_bytes(16)); - } - - /** - * Calculates the date of the active cart duration edge. - * - * @throws \Exception - * @since 2.2 - */ - public function getActiveCartEdgeDuration(): string - { - $edge = new DateTime(); - $activeCartDuration = ConfigHelper::durationInSeconds(Plugin::getInstance()->getSettings()->activeCartDuration); - $interval = DateTimeHelper::secondsToInterval($activeCartDuration); - $edge->sub($interval); - return $edge->format(DateTime::ATOM); - } - - /** - * @since 3.1 - * @deprecated in 4.0.0. The cookie name is available via [[$cartCookie]] `['name']`. - */ - public function getCartName(): string - { - return $this->cartCookie['name']; - } - - /** - * Returns whether there is a cart number in the session. - * - * @throws MissingComponentException - * @since 2.1.11 - */ - public function getHasSessionCartNumber(): bool - { - if ($this->_cartNumber === false) { - return false; - } - - if ($this->_cartNumber === null) { - $request = Craft::$app->getRequest(); - $requestCookies = $request->getCookies(); - - return $requestCookies->getValue($this->cartCookie['name'], false) !== false; - } - - return true; - } - - /** - * Get the session cart number or generates one if none exists. - * - */ - protected function getSessionCartNumber(): string - { - if (!Craft::$app->getRequest()->getIsConsoleRequest()) { - $request = Craft::$app->getRequest(); - $requestCookies = $request->getCookies(); - - // Only try to retrieve the cart number from the cookie if `_cartNumber` is `null`. - if ($this->_cartNumber === null && $cookieNumber = $requestCookies->getValue($this->cartCookie['name'])) { - $this->_cartNumber = $cookieNumber; - } - } - - // A `null` or `false` value means we need to generate a new cart number. - if ($this->_cartNumber === null || $this->_cartNumber === false) { - $this->_cartNumber = $this->generateCartNumber(); - } - - /// Just in case the current cart is not the one in session, clear the cached cart. - if ($this->_cart && $this->_cart->number !== $this->_cartNumber) { - $this->_cart = null; - } - - return $this->_cartNumber; - } - - /** - * Set the session cart number. - */ - public function setSessionCartNumber(string $cartNumber): void - { - if (!Craft::$app->getRequest()->getIsConsoleRequest()) { - $this->_cartNumber = $cartNumber; - $cookie = Craft::createObject(array_merge($this->cartCookie, [ - 'class' => Cookie::class, - 'value' => $cartNumber, - 'expire' => time() + $this->cartCookieDuration, - ])); - Craft::$app->getResponse()->getCookies()->add($cookie); - } - } - - /** - * Returns a URL to load a cart with a secure token. - * - * @param Order $cart The cart to generate the load URL for - * @return string The URL with secure token - * @since 5.7.0 - */ - public function getLoadCartUrl(Order $cart): string - { - $linkExpiry = Plugin::getInstance()->getSettings()->loadCartUrlExpiry; - $expiryDate = DateTimeHelper::currentUTCDateTime()->add(DateTimeHelper::secondsToInterval($linkExpiry)); - - $token = Craft::$app->getTokens()->createToken([ - 'commerce/cart/load-cart', - ['cartNumber' => $cart->number], - ], expiryDate: $expiryDate); - - $request = Craft::$app->getRequest(); - $isCpRequest = $request->getIsCpRequest(); - - if ($isCpRequest) { - $request->setIsCpRequest(false); - } - - try { - return UrlHelper::actionUrl('commerce/cart/load-cart', [ - 'number' => $cart->number, - 'code' => $token, - ]); - } finally { - if ($isCpRequest) { - $request->setIsCpRequest($isCpRequest); - } - } - } - - /** - * Restores previous cart for the current user if their current cart is empty. - * Ideally this is only used when a user logs in. - * - * @throws ElementNotFoundException - * @throws Exception - * @throws MissingComponentException - * @throws Throwable - */ - public function restorePreviousCartForCurrentUser(): void - { - $currentUser = Craft::$app->getUser()->getIdentity(); - $currentStoreId = Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if (!$currentUser) { - return; - } - - // If the current cart is empty see if the logged-in user has a previous cart - // Get any cart that is not empty, is not trashed or complete, and belongings to the user - /** @var Order|null $previousCartsWithLineItems */ - $previousCartsWithLineItems = Order::find() - ->customer($currentUser) - ->isCompleted(false) - ->hasLineItems() - ->trashed(false) - ->storeId($currentStoreId) - ->one(); - - /** @var Order|null $anyPreviousCart */ - $anyPreviousCart = Order::find() - ->customer($currentUser) - ->isCompleted(false) - ->trashed(false) - ->storeId($currentStoreId) - ->one(); - - /** @var Order|null $currentCartInSession */ - $currentCartInSession = Order::find() - ->number($this->getSessionCartNumber()) - ->isCompleted(false) - ->hasLineItems() - ->trashed(false) - ->storeId($currentStoreId) - ->one(); - - /** - * Cart restoring preference order: - * 1. Give the cart in session to the current customer if they are logging in and there are items in the cart - * 2. Restore a previous cart belonging to the customer that has line items - * 3. Restore any other previous cart for the customer - */ - if ($currentCartInSession) { - // Give the cart to the current customer if they are logging in and there are items in the cart - // Call get cart as this will switch the user and save it if needed - $this->getCart(); - } elseif ($previousCartsWithLineItems) { - // Restore previous cart that has line items - $this->_cart = $previousCartsWithLineItems; - $this->setSessionCartNumber($previousCartsWithLineItems->number); - } elseif ($anyPreviousCart) { - // Finally try to restore any other previous cart for the customer - $this->_cart = $anyPreviousCart; - $this->setSessionCartNumber($anyPreviousCart->number); - } - } - - /** - * Removes all carts that are incomplete and older than the config setting. - * - * @return int The number of carts purged from the database - * @throws \Exception - * @throws Throwable - */ - public function purgeIncompleteCarts(): int - { - if (!Plugin::getInstance()->getSettings()->purgeInactiveCarts) { - return 0; - }; - - $configInterval = ConfigHelper::durationInSeconds(Plugin::getInstance()->getSettings()->purgeInactiveCartsDuration); - $edge = new DateTime(); - $interval = DateTimeHelper::secondsToInterval($configInterval); - $edge->sub($interval); - - $cartIdsQuery = (new Query()) - ->select(['orders.id']) - ->where(['not', ['isCompleted' => true]]) - ->andWhere('[[orders.dateUpdated]] <= :edge', ['edge' => Db::prepareDateForDb($edge)]) - ->from(['orders' => Table::ORDERS]); - - $event = new CartPurgeEvent([ - 'inactiveCartsQuery' => $cartIdsQuery, - ]); - - if ($this->hasEventHandlers(self::EVENT_BEFORE_PURGE_INACTIVE_CARTS)) { - $this->trigger(self::EVENT_BEFORE_PURGE_INACTIVE_CARTS, $event); - } - - if (!$event->isValid) { - return 0; - } - - // The searchindex table is probably MyISAM, though - Craft::$app->getDb()->createCommand() - ->delete('{{%searchindex}}', ['elementId' => $event->inactiveCartsQuery]) - ->execute(); - - // Taken from craft\services\Elements::deleteElement(); Using the method directly - // takes too many resources since it retrieves the order before deleting it. - // Delete the elements table rows, which will cascade across all other InnoDB tables - Craft::$app->getDb()->createCommand() - ->delete('{{%elements}}', ['id' => $event->inactiveCartsQuery]) - ->execute(); - - return $cartIdsQuery->count(); - } - - /** - * @return void - * @throws SiteNotFoundException - * @throws InvalidConfigException - */ - protected function loadCookie(): void - { - $currentStore = Plugin::getInstance()->getStores()->getCurrentStore(); - - // Complete the cart cookie config - if (!isset($this->cartCookie['name'])) { - $this->cartCookie['name'] = md5(sprintf('Craft.%s.%s.%s', self::class, Craft::$app->id, $currentStore->handle)) . '_commerce_cart'; - } - - // Don't restore from cookie if the cart was explicitly forgotten this request. - if ($this->_cartNumber === false) { - return; - } - - $request = Craft::$app->getRequest(); - if (!$request->getIsConsoleRequest()) { - $this->cartCookie = Craft::cookieConfig($this->cartCookie); - - $requestCookies = $request->getCookies(); - - // If we have a cart cookie, assign it to the cart number. - if ($requestCookies->has($this->cartCookie['name'])) { - $this->setSessionCartNumber($requestCookies->getValue($this->cartCookie['name'])); - } - } - } - - /** - * Gets the current payment currency ISO code - * @todo in Commerce 6.0, replace the COMMERCE_PAYMENT_CURRENCY constant with a proper per-store config setting and surface validation errors instead of throwing InvalidConfigException - */ - private function _getCartPaymentCurrencyIso(): string - { - if ($this->_cart) { - // Is the payment currency locked to the constant - if (defined('COMMERCE_PAYMENT_CURRENCY')) { - $paymentCurrencies = Plugin::getInstance()->getPaymentCurrencies()->getAllPaymentCurrencies($this->_cart->storeId); - // if not in array - if (!$paymentCurrencies->contains('iso', '==', COMMERCE_PAYMENT_CURRENCY)) { - throw new InvalidConfigException('The COMMERCE_PAYMENT_CURRENCY constant is not set to a valid payment currency.'); - } - - $this->_cart->paymentCurrency = COMMERCE_PAYMENT_CURRENCY; - } - - return $this->_cart->paymentCurrency; - } - - return Plugin::getInstance()->getPaymentCurrencies()->getPrimaryPaymentCurrencyIso(); - } - - /** - * @param ModelEvent $event - * @return void - * @throws MissingComponentException - * @throws Throwable - */ - public function afterSaveUserHandler(ModelEvent $event): void - { - $segments = Craft::$app->getRequest()->getActionSegments(); - $userSaveSegments = ['users', 'save-user']; - $isUserSaveAction = $segments == $userSaveSegments; - - // we have a cart number, currently anon, and the current action being executed is user save - if (!Craft::$app->getUser()->getIdentity() && - !Craft::$app->getRequest()->getIsCpRequest() && - $isUserSaveAction - ) { - $currentCartNumber = $this->getSessionCartNumber(); - // Set the session flag to preserve the cart for this user - Craft::$app->getSession()->set('commerce:anonymousCartWithCredentialedCustomer:' . $currentCartNumber, true); - } - } -} diff --git a/src/services/CatalogPricing.php b/src/services/CatalogPricing.php deleted file mode 100755 index 99c6f60d72..0000000000 --- a/src/services/CatalogPricing.php +++ /dev/null @@ -1,786 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricing extends Component -{ - /** - * @var array|null - */ - private ?array $_allCatalogPrices = null; - - /** - * @param Queue|QueueInterface|null $queue - * @param float $progress - * @param string|null $label - * @return void - */ - private function setQueueProgress(Queue|QueueInterface|null $queue, float $progress, ?string $label = null): void - { - if ($queue instanceof QueueInterface) { - $queue->setProgress((int)$progress, $label); - } - } - - /** - * @param array|null $purchasableIds - * @param CatalogPricingRule[]|null $catalogPricingRules - * @param bool $showConsoleOutput - * @param Queue|QueueInterface|null $queue - * @return void - * @throws Exception - * @throws InvalidConfigException - */ - public function generateCatalogPrices(?array $purchasableIds = null, ?array $catalogPricingRules = null, bool $showConsoleOutput = false, Queue|QueueInterface $queue = null): void - { - $chunkSize = 1000; - $this->setQueueProgress($queue, 10, 'Retrieving purchasables'); - - $isAllPurchasables = $purchasableIds === null; - if ($isAllPurchasables) { - $purchasableIds = (new Query()) - ->select(['purchasables.id']) - ->from(Table::PURCHASABLES . ' purchasables') - ->innerJoin(\craft\db\Table::ELEMENTS . ' e', '[[e.id]] = [[purchasables.id]]') - // Make sure we aren't putting and draft or revision purchasables in the catalog pricing table - ->where(['e.revisionId' => null]) - ->andWhere(['e.draftId' => null]) - ->column(); - } else { - // If purchasable IDs have been passed in remove all IDs that are revisions or drafts - $allowedPurchasableIds = []; - // Chunk through the IDs to avoid hitting the int limit in the where clause - foreach (array_chunk($purchasableIds, 2000) as $purchasableIdsChunk) { - $allowedPurchasableIds = array_merge($allowedPurchasableIds, (new Query()) - ->select(['purchasables.id']) - ->from(Table::PURCHASABLES . ' purchasables') - ->innerJoin(\craft\db\Table::ELEMENTS . ' e', '[[e.id]] = [[purchasables.id]]') - ->where(['e.revisionId' => null]) - ->andWhere(['e.draftId' => null]) - ->andWhere(['purchasables.id' => $purchasableIdsChunk]) - ->column()); - } - - $purchasableIds = $allowedPurchasableIds; - } - - if (empty($purchasableIds)) { - return; - } - - // Rules with user ID records - $cprWithUserIds = (new Query()) - ->select(['catalogPricingRuleId']) - ->from(Table::CATALOG_PRICING_RULES_USERS) - ->groupBy('catalogPricingRuleId') - ->column(); - - // @TODO Consider marking catalog prices for the affected purchasables as pending here so consumers can detect a stale state while regeneration is in progress - - $cprStartTime = microtime(true); - if ($showConsoleOutput) { - Console::stdout(PHP_EOL . 'Generating price data from catalog pricing rules... '); - } - - $this->setQueueProgress($queue, 20, 'Generating catalog pricing data'); - $catalogPricing = []; - foreach (Plugin::getInstance()->getStores()->getAllStores() as $store) { - $priceByPurchasableId = (new Query()) - ->select(['purchasableId', 'basePrice', 'basePromotionalPrice']) - ->from([Table::PURCHASABLES_STORES]) - ->where(['storeId' => $store->id]) - ->indexBy('purchasableId') - ->all(); - - $runCatalogPricingRules = $catalogPricingRules ?? Plugin::getInstance()->getCatalogPricingRules()->getAllActiveCatalogPricingRules($store->id)->all(); - - foreach ($runCatalogPricingRules as $catalogPricingRule) { - // Skip rule processing if it isn't for this store. - // This is in case incompatible rules were passed in. - if ($catalogPricingRule->storeId !== $store->id) { - continue; - } - - // Skip rule if the rule is not enabled - if (!$catalogPricingRule->enabled) { - continue; - } - - // Skip if the rule has user conditions but didn't generate any applicable users - if (!empty($catalogPricingRule->getCustomerCondition()->getConditionRules()) && !in_array($catalogPricingRule->id, $cprWithUserIds, true)) { - continue; - } - - // If `getPurchasableIds()` is `null` this means all purchasables - if ($catalogPricingRule->getPurchasableIds() === null) { - $applyPurchasableIds = $purchasableIds; - } else { - $applyPurchasableIds = $isAllPurchasables ? $catalogPricingRule->getPurchasableIds() : array_intersect($catalogPricingRule->getPurchasableIds(), $purchasableIds); - } - - if (empty($applyPurchasableIds)) { - continue; - } - - foreach ($applyPurchasableIds as $purchasableId) { - if (!isset($priceByPurchasableId[$purchasableId])) { - continue; - } - - $catalogPrice = Plugin::getInstance()->getCatalogPricingRules()->generateRulePriceFromPrice($priceByPurchasableId[$purchasableId]['basePrice'], $priceByPurchasableId[$purchasableId]['basePromotionalPrice'], $catalogPricingRule); - - if ($catalogPrice === null) { - continue; - } - - $catalogPricing[] = [ - $purchasableId, // purchasableId - $catalogPrice, // price - $store->id, // storeId - $catalogPricingRule->isPromotionalPrice, // isPromotionalPrice - $catalogPricingRule->id, // catalogPricingRuleId - $catalogPricingRule->dateFrom ? Db::prepareDateForDb($catalogPricingRule->dateFrom) : null, // dateFrom - $catalogPricingRule->dateTo ? Db::prepareDateForDb($catalogPricingRule->dateTo) : null, // dateTo - false, // hasUpdatePending - ]; - } - } - } - - $cprExecutionLength = microtime(true) - $cprStartTime; - if ($showConsoleOutput) { - Console::stdout('done!'); - Console::stdout(PHP_EOL . 'Created ' . count($catalogPricing) . ' rule price data in ' . round($cprExecutionLength, 2) . ' seconds' . PHP_EOL); - } - - $this->setQueueProgress($queue, 40, 'Clearing existing catalog prices'); - $transaction = Craft::$app->getDb()->beginTransaction(); - // Truncate the catalog pricing table - if (!$isAllPurchasables || !empty($catalogPricingRules)) { - // If purchasable IDs are passed in or catalog pricing rules are passed in - // only delete the rows for those purchasable IDs and catalog pricing rules - foreach (array_chunk($purchasableIds, 1000) as $purchasableIdsChunk) { - $where = ['purchasableId' => $purchasableIdsChunk]; - - // If passing catalog pricing rules only delete the rows for those rules - if (!empty($catalogPricingRules)) { - $where['catalogPricingRuleId'] = ArrayHelper::getColumn($catalogPricingRules, 'id'); - } - - Craft::$app->getDb()->createCommand() - ->delete(Table::CATALOG_PRICING, $where) - ->execute(); - } - } else { - Craft::$app->getDb()->createCommand()->truncateTable(Table::CATALOG_PRICING)->execute(); - } - - // If there are no specific catalog pricing rules passed in then copy the base prices into the catalog pricing table - if (empty($catalogPricingRules)) { - $this->setQueueProgress($queue, 60, 'Copying base prices to catalog pricing'); - $total = count($purchasableIds); - $baseStateTime = microtime(true); - $count = 1; - // Copy base prices into the catalog pricing table with a query for speed - // Batch through the purchasable IDs as we don't know what is passed in and don't want to hit the int limit in the where clause - foreach (array_chunk($purchasableIds, $chunkSize) as $purchasableIdsChunk) { - $fromCount = Craft::$app->getFormatter()->asDecimal($count, 0); - $toCount = ($count + ($chunkSize - 1)) > count($purchasableIds) ? $total : Craft::$app->getFormatter()->asDecimal($count + count($purchasableIdsChunk) - 1, 0); - if ($showConsoleOutput) { - Console::stdout(PHP_EOL . sprintf('Generating base prices rows for purchasables %s to %s of %s... ', $fromCount, $toCount, $total)); - } - - $uuidFunction = Craft::$app->getDb()->getIsPgsql() ? 'gen_random_uuid()' : 'UUID()'; - - $schema = Craft::$app->getDb()->getSchema(); - $catalogPricingTable = $schema->getRawTableName(Table::CATALOG_PRICING); - $commercePurchasablesStoresTable = $schema->getRawTableName(Table::PURCHASABLES_STORES); - - $insert = Craft::$app->getDb()->createCommand()->setSql(' - INSERT INTO [[' . $catalogPricingTable . ']] ([[price]], [[purchasableId]], [[storeId]], [[uid]], [[dateCreated]], [[dateUpdated]]) - SELECT [[basePrice]], [[purchasableId]], [[storeId]], ' . $uuidFunction . ', NOW(), NOW() FROM [[' . $commercePurchasablesStoresTable . ']] - WHERE [[purchasableId]] IN (' . implode(',', $purchasableIdsChunk) . ') - '); - $insert->execute(); - - $insert = Craft::$app->getDb()->createCommand()->setSql(' - INSERT INTO [[' . $catalogPricingTable . ']] ([[price]], [[purchasableId]], [[storeId]], [[isPromotionalPrice]], [[uid]], [[dateCreated]], [[dateUpdated]]) - SELECT [[basePromotionalPrice]], [[purchasableId]], [[storeId]], true, ' . $uuidFunction . ', NOW(), NOW() FROM [[' . $commercePurchasablesStoresTable . ']] - WHERE (NOT ([[basePromotionalPrice]] is null)) AND [[purchasableId]] IN (' . implode(',', $purchasableIdsChunk) . ') - '); - $insert->execute(); - - if ($showConsoleOutput) { - Console::stdout('done!'); - } - $count += $chunkSize; - } - $baseExecutionLength = microtime(true) - $baseStateTime; - if ($showConsoleOutput) { - Console::stdout(PHP_EOL . 'Generated ' . $total . ' base prices in ' . round($baseExecutionLength, 2) . ' seconds' . PHP_EOL); - } - } - - $this->setQueueProgress($queue, 80, 'Inserting catalog pricing'); - // Batch through `$catalogPricing` and insert into the catalog pricing table - if (!empty($catalogPricing)) { - $count = 1; - $startTime = microtime(true); - $total = Craft::$app->getFormatter()->asDecimal(count($catalogPricing), 0); - foreach (array_chunk($catalogPricing, $chunkSize) as $catalogPricingChunk) { - $fromCount = Craft::$app->getFormatter()->asDecimal($count, 0); - $toCount = ($count + ($chunkSize - 1)) > count($catalogPricing) ? $total : Craft::$app->getFormatter()->asDecimal($count + count($catalogPricingChunk) - 1, 0); - if ($showConsoleOutput) { - Console::stdout(PHP_EOL . sprintf('Inserting catalog pricing rule prices rows %s to %s of %s... ', $fromCount, $toCount, $total)); - } - Craft::$app->getDb()->createCommand()->batchInsert(Table::CATALOG_PRICING, [ - 'purchasableId', - 'price', - 'storeId', - 'isPromotionalPrice', - 'catalogPricingRuleId', - 'dateFrom', - 'dateTo', - 'hasUpdatePending', - ], $catalogPricingChunk)->execute(); - $count += $chunkSize; - if ($showConsoleOutput) { - Console::stdout('done!'); - } - } - - $executionLength = microtime(true) - $startTime; - if ($showConsoleOutput) { - Console::stdout(PHP_EOL . 'Generated ' . $total . ' prices in ' . round($executionLength, 2) . ' seconds' . PHP_EOL); - } - } - - $transaction->commit(); - $this->setQueueProgress($queue, 100); - } - - /** - * Return the catalog price for a purchasable. - * - * @param int $purchasableId - * @param int|null $storeId - * @param int|null $userId - * @param bool $isPromotionalPrice - * @return float|null - * @throws InvalidConfigException - */ - public function getCatalogPrice(int $purchasableId, ?int $storeId = null, ?int $userId = null, bool $isPromotionalPrice = false): ?float - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - $userKey = $userId ?? 'all'; - $promoKey = $isPromotionalPrice ? 'promo' : 'standard'; - $key = 'catalog-price-' . implode('-', [$storeId, $userKey, $promoKey]); - - if ($this->_allCatalogPrices === null || !isset($this->_allCatalogPrices[$key])) { - $query = $this->createCatalogPricesQuery($userId, $storeId) - ->addSelect(['purchasableId']) - ->indexBy('purchasableId') - ->collect(); - - $this->_allCatalogPrices[$key] = $query->pluck($isPromotionalPrice ? 'promotionalPrice' : 'price', 'purchasableId'); - } - - return $this->_allCatalogPrices[$key][$purchasableId] ?? null; - } - - /** - * @param int $purchasableId - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - * @throws SiteNotFoundException - */ - public function getCatalogPricesByPurchasableId(int $purchasableId, ?int $storeId = null): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - $allPriceRows = $this->createCatalogPricesQuery(storeId: $storeId, allPrices: true) - // Override select to prevent `min`/grouping - ->select([ - 'id', 'price', 'purchasableId', 'storeId', 'isPromotionalPrice', 'catalogPricingRuleId', 'dateFrom', 'dateTo', 'uid', - ]) - ->andWhere(['purchasableId' => $purchasableId]) - ->andWhere(['not', ['catalogPricingRuleId' => null]]) - ->all(); - - $allPrices = []; - foreach ($allPriceRows as $catalogPrice) { - $allPrices[] = Craft::createObject(['class' => CatalogPricingModel::class, 'attributes' => $catalogPrice]); - } - - return collect($allPrices); - } - - /** - * @param int $storeId - * @param CatalogPricingCondition|null $conditionBuilder - * @param string|null $searchText - * @param int|null $limit - * @param int|null $offset - * @param bool $includeBasePrices - * @return Collection - * @throws InvalidConfigException - */ - public function getCatalogPrices(int $storeId, ?CatalogPricingCondition $conditionBuilder = null, bool $includeBasePrices = true, ?string $searchText = null, ?int $limit = null, ?int $offset = null): Collection - { - $query = $this->_createCatalogPricesQuery($storeId, $conditionBuilder, $includeBasePrices, $searchText, $limit, $offset) - ->select([ - 'price', 'purchasableId', 'storeId', 'isPromotionalPrice', 'catalogPricingRuleId', 'dateFrom', 'dateTo', 'cp.uid', - ]); - - $query->orderBy('purchasableId ASC, catalogPricingRuleId ASC'); - $results = $query->all(); - - $catalogPrices = []; - foreach ($results as $result) { - $catalogPrices[] = Craft::createObject([ - 'class' => CatalogPricingModel::class, - 'attributes' => $result, - ]); - } - - return collect($catalogPrices); - } - - public function getCatalogPricesPageInfo(int $storeId, ?CatalogPricingCondition $conditionBuilder = null, bool $includeBasePrices = true, ?string $searchText = null, int $limit = 100, int $offset = 0) - { - $results = $this->_createCatalogPricesQuery($storeId, $conditionBuilder, $includeBasePrices, $searchText) - ->select(['purchasableId']) - ->groupBy(['purchasableId']) - ->all(); - - $total = count($results); - - return [ - 'first' => $offset + 1, - 'last' => $offset + $limit, - 'total' => $total, - 'prevUrl' => null, - 'nextUrl' => null, - ]; - } - - /** - * @param int|array|null $catalogPricingRuleId - * @param int|array|null $purchasableId - * @param int|array|null $storeId - * @return void - * @throws Exception - */ - public function markPricesAsUpdatePending(int|array|null $catalogPricingRuleId = null, int|array|null $purchasableId = null, int|array|null $storeId = null): void - { - $conditions = []; - - if ($catalogPricingRuleId !== null) { - $conditions['catalogPricingRuleId'] = $catalogPricingRuleId; - } - - if ($purchasableId !== null) { - $conditions['purchasableId'] = $purchasableId; - } - - if ($storeId !== null) { - $conditions['storeId'] = $storeId; - } - - Craft::$app->getDb()->createCommand() - ->update(Table::CATALOG_PRICING, ['hasUpdatePending' => true], $conditions) - ->execute(); - } - - /** - * @param int $storeId - * @param CatalogPricingCondition|null $conditionBuilder - * @param string|null $searchText - * @param bool $includeBasePrices - * @param int|null $limit - * @param int|null $offset - * @return Query - * @throws InvalidConfigException - */ - private function _createCatalogPricesQuery(int $storeId, ?CatalogPricingCondition $conditionBuilder = null, bool $includeBasePrices = true, ?string $searchText = null, ?int $limit = null, ?int $offset = null): Query - { - $query = Plugin::getInstance()->getCatalogPricing()->createCatalogPricesQuery(storeId: $storeId, allPrices: true, condition: $conditionBuilder); - - if ($includeBasePrices === false) { - $query->andWhere(['not', ['catalogPricingRuleId' => null]]); - } - - $subQuery = (new Query()) - ->from(Table::PURCHASABLES) - ->select(['id']); - - if ($limit) { - $subQuery->limit($limit); - } - - if ($offset) { - $subQuery->offset($offset); - } - - if ($searchText) { - $likeOperator = Craft::$app->getDb()->getIsPgsql() ? 'ilike' : 'like'; - $subQuery->andWhere([$likeOperator, 'purchasables.description', $searchText]); - } - - $query->innerJoin(['purchasables' => $subQuery], '[[purchasables.id]] = [[cp.purchasableId]]'); - - // If there is a condition builder, modify the query - $conditionBuilder?->modifyQuery($query); - - return $query; - } - - /** - * @param ModelEvent $event - * @return void - * @throws InvalidConfigException - * @since 5.0.0 - * @deprecated in 5.5.0 - */ - public function afterSavePurchasableHandler(ModelEvent $event): void - { - $purchasable = $event->sender; - if (!$purchasable instanceof Purchasable || $purchasable->propagating || $purchasable->getIsDraft() || $purchasable->getIsRevision()) { - return; - } - - $this->createCatalogPricingJob(['purchasableIds' => [$purchasable->id], 'storeId' => $purchasable->storeId]); - } - - /** - * @param array $config - * @param int $priority - * @return void - * @throws InvalidConfigException - */ - public function createCatalogPricingJob(array $config = [], int $priority = 100): void - { - $catalogPricingRuleIds = $this->_normalizeIds($config['catalogPricingRuleIds'] ?? null); - $purchasableIds = $this->_normalizeIds($config['purchasableIds'] ?? null); - - if ($catalogPricingRuleIds === [] && $purchasableIds === []) { - return; - } - - $storeId = $config['storeId'] ?? null; - $this->markPricesAsUpdatePending($catalogPricingRuleIds, $purchasableIds, $storeId); - - // Queue purchasable-based and rule-based work into separate rows so they are never cross-contaminated. - // Catalog pricing rules determine which purchasables are relevant, so the two must be processed independently. - - if (!empty($purchasableIds) || ($purchasableIds === null && empty($catalogPricingRuleIds))) { - // Specific purchasable IDs: these will be regenerated against all applicable rules - $this->_queueCatalogPricingIds($storeId, CatalogPricingQueueRecord::TYPE_PURCHASABLE, $purchasableIds); - } - - if (!empty($catalogPricingRuleIds)) { - $this->_queueCatalogPricingIds($storeId, CatalogPricingQueueRecord::TYPE_RULE, $catalogPricingRuleIds); - } - - QueueHelper::push(Craft::createObject(CatalogPricingJob::class), $priority); - } - - /** - * @return bool - */ - public function areCatalogPricingJobsRunning(): bool - { - return (new Query()) - ->from(Table::CATALOG_PRICING_QUEUE) - ->exists(); - } - - /** - * Reserves one pending queue row for processing. - * - * @return CatalogPricingQueueRecord|null - * @since 5.7.0 - */ - public function reserveCatalogPricingQueueRow(): ?CatalogPricingQueueRecord - { - $mutex = Craft::$app->getMutex(); - - // Use the same lock as the write methods so that reservation and inserts/merges are fully serialised. - // Non-blocking: if a write operation is currently holding the lock, return null and let the next - // queue job execution pick up the row instead. - if (!$mutex->acquire('catalogpricingqueue', 0)) { - return null; - } - - try { - $pendingId = (new Query()) - ->select(['id']) - ->from(Table::CATALOG_PRICING_QUEUE) - ->where(['reserved' => false]) - ->orderBy(['id' => SORT_ASC]) - ->scalar(); - - if (!$pendingId) { - return null; - } - - /** @var CatalogPricingQueueRecord|null $record */ - $record = CatalogPricingQueueRecord::findOne(['id' => (int)$pendingId, 'reserved' => false]); - - if (!$record) { - return null; - } - - $record->reserved = true; - $record->save(false); - - return $record; - } finally { - $mutex->release('catalogpricingqueue'); - } - } - - /** - * @param int $id - * @return void - * @throws Exception - * @since 5.7.0 - */ - public function releaseCatalogPricingQueueRowById(int $id): void - { - $record = CatalogPricingQueueRecord::findOne($id); - if ($record) { - $record->reserved = false; - $record->save(false); - } - } - - /** - * @param int $id - * @return void - * @since 5.7.0 - */ - public function deleteCatalogPricingQueueRowById(int $id): void - { - CatalogPricingQueueRecord::deleteAll(['id' => $id]); - } - - /** - * Queues catalog pricing regeneration IDs by row type, merging into any existing unreserved row - * for the same store and type. - * - * @param int|null $storeId - * @param string $type - * @param array|null $ids - * @return void - * @throws Exception - * @throws \RuntimeException if the queue mutex cannot be acquired - */ - private function _queueCatalogPricingIds(?int $storeId, string $type, ?array $ids): void - { - $mutex = Craft::$app->getMutex(); - - if (!$mutex->acquire('catalogpricingqueue', 5)) { - throw new \RuntimeException('Unable to acquire the catalog pricing queue mutex.'); - } - - try { - // Merge into an existing unreserved row for the same store and type. - // _mergeIdSets keeps null when either side is null (broader scope wins). - /** @var CatalogPricingQueueRecord|null $pendingRecord */ - $pendingRecord = CatalogPricingQueueRecord::findOne([ - 'storeId' => $storeId, - 'type' => $type, - 'reserved' => false, - ]); - - if ($pendingRecord) { - // Merge IDs, preserving null to represent the broader "all IDs" scope. - $pendingIds = $pendingRecord->getIds(); - $ids = ($pendingIds === null || $ids === null) - ? null - : $this->_normalizeIds(array_merge($pendingIds, $ids)); - - $pendingRecord->setIds($ids); - $pendingRecord->save(false); - - return; - } - - $record = new CatalogPricingQueueRecord(); - $record->storeId = $storeId; - $record->type = $type; - $record->setIds($ids); - $record->reserved = false; - $record->save(false); - } finally { - $mutex->release('catalogpricingqueue'); - } - } - - /** - * @param array|null $ids - * @return array|null - * @since 5.7.0 - */ - private function _normalizeIds(?array $ids): ?array - { - if ($ids === null) { - return null; - } - - $ids = array_map(fn(mixed $id) => (int)$id, $ids); - $ids = array_values(array_unique(array_filter($ids, fn(int $id) => $id > 0))); - sort($ids, SORT_NUMERIC); - - return $ids; - } - - - /** - * Creates query for catalog pricing. - * - * @param int|null $userId - * @param int|string|null $storeId - * @param bool|null $isPromotionalPrice - * @param bool $allPrices - * @param CatalogPricingCondition|null $condition - * @return Query - * @throws InvalidConfigException - * @throws DeprecationException - * @deprecated in 5.1.0. Use `createCatalogPricesQuery()` instead. - */ - public function createCatalogPricingQuery(?int $userId = null, int|string|null $storeId = null, ?bool $isPromotionalPrice = null, bool $allPrices = false, ?CatalogPricingCondition $condition = null): Query - { - Craft::$app->getDeprecator()->log(__METHOD__, 'CatalogPricing `' . __METHOD__ . '()` method has been deprecated. Use `createCatalogPricesQuery()` instead.'); - $query = (new Query()) - ->select([new Expression('MIN(price) as price')]) - ->from([Table::CATALOG_PRICING . ' cp']); - - // Use condition builder to tweak the query for reusability - $condition ??= Craft::$app->getConditions()->createCondition([ - 'class' => CatalogPricingCondition::class, - 'allPrices' => $allPrices, - ]); - - if ($userId) { - $condition->addConditionRule(Craft::$app->getConditions()->createConditionRule([ - 'class' => CatalogPricingCustomerConditionRule::class, - 'customerId' => $userId, - ])); - } - - $condition->modifyQuery($query); - - $query - ->andWhere(['or', ['dateFrom' => null], ['<=', 'dateFrom', Db::prepareDateForDb(new DateTime())]]) - ->andWhere(['or', ['dateTo' => null], ['>=', 'dateTo', Db::prepareDateForDb(new DateTime())]]) - ->orderBy(['purchasableId' => SORT_ASC, 'price' => SORT_ASC]); - - // If we're not getting all prices, we need to group by purchasableId and storeId - if (!$allPrices) { - $query->groupBy(['purchasableId', 'storeId']); - } - - if ($storeId) { - $query->andWhere(['storeId' => $storeId]); - } - - if ($isPromotionalPrice !== null) { - $query->andWhere(['isPromotionalPrice' => $isPromotionalPrice]); - } - - return $query; - } - - /** - * Returns rows of purchasable prices. - * - * @param int|null $userId - * @param int|string|null $storeId - * @param bool $allPrices - * @param CatalogPricingCondition|null $condition - * @return Query - * @throws InvalidConfigException - * @since 5.1.0 - */ - public function createCatalogPricesQuery(?int $userId = null, int|string|null $storeId = null, bool $allPrices = false, ?CatalogPricingCondition $condition = null): Query - { - $query = (new Query()) - ->select([ - new Expression('MIN(CASE WHEN [[isPromotionalPrice]] = FALSE THEN [[price]] END) AS [[price]]'), - new Expression('MIN(CASE WHEN [[isPromotionalPrice]] = TRUE THEN [[price]] END) AS [[promotionalPrice]]'), - new Expression('MIN([[price]]) AS [[salePrice]]'), - ]) - ->from([Table::CATALOG_PRICING . ' cp']); - - // Use condition builder to tweak the query for reusability - $condition ??= Craft::$app->getConditions()->createCondition([ - 'class' => CatalogPricingCondition::class, - 'allPrices' => $allPrices, - ]); - - if ($userId) { - $condition->addConditionRule(Craft::$app->getConditions()->createConditionRule([ - 'class' => CatalogPricingCustomerConditionRule::class, - 'customerId' => $userId, - ])); - } - - $condition->modifyQuery($query); - - $query - ->andWhere(['or', ['dateFrom' => null], ['<=', 'dateFrom', Db::prepareDateForDb(new DateTime())]]) - ->andWhere(['or', ['dateTo' => null], ['>=', 'dateTo', Db::prepareDateForDb(new DateTime())]]); - - // If we're not getting all prices, we need to group by purchasableId and storeId - if (!$allPrices) { - $query->groupBy(['purchasableId', 'storeId']); - } - - if ($storeId) { - $query->andWhere(['storeId' => $storeId]); - } - - return $query; - } -} diff --git a/src/services/CatalogPricingRules.php b/src/services/CatalogPricingRules.php deleted file mode 100644 index 03da1a10f3..0000000000 --- a/src/services/CatalogPricingRules.php +++ /dev/null @@ -1,398 +0,0 @@ - - * @since 5.0.0 - */ -class CatalogPricingRules extends Component -{ - /** - * @var bool|null - */ - private ?bool $_hasCatalogPricingRules = null; - - /** - * @return bool - * @throws InvalidConfigException - */ - public function hasCatalogPricingRules(): bool - { - if (!$this->canUseCatalogPricingRules()) { - return false; - } - - if ($this->_hasCatalogPricingRules === null) { - $this->_hasCatalogPricingRules = $this->_createCatalogPricingRuleQuery()->exists(); - } - - return (bool)$this->_hasCatalogPricingRules; - } - - /** - * @var Collection[]|null - */ - private ?array $_allCatalogPricingRules = null; - - /** - * @return bool - * @throws InvalidConfigException - */ - public function canUseCatalogPricingRules(): bool - { - if (!empty(Plugin::getInstance()->getSales()->getAllSales())) { - return false; - } - - return true; - } - - /** - * Get a catalog pricing rule by its ID. - * - * @param int $id - * @param int|null $storeId - * @return CatalogPricingRule|null - * @throws InvalidConfigException - * @throws SiteNotFoundException - */ - public function getCatalogPricingRuleById(int $id, ?int $storeId = null): ?CatalogPricingRule - { - return $this->getAllCatalogPricingRules($storeId)->firstWhere('id', $id); - } - - /** - * Get all catalog pricing rules. - * - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - * @throws SiteNotFoundException - */ - public function getAllCatalogPricingRules(?int $storeId = null): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if ($this->_allCatalogPricingRules === null || !isset($this->_allCatalogPricingRules[$storeId])) { - $query = $this->_createCatalogPricingRuleQuery() - ->where(['storeId' => $storeId]); - - $results = $query->all(); - - if ($this->_allCatalogPricingRules === null) { - $this->_allCatalogPricingRules = []; - } - - $models = $this->_createCatalogPricingRuleModels($results); - $this->_allCatalogPricingRules[$storeId] = collect($models); - } - - return $this->_allCatalogPricingRules[$storeId]; - } - - /** - * @param int $purchasableId - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - * @throws SiteNotFoundException - */ - public function getAllCatalogPricingRulesByPurchasableId(int $purchasableId, ?int $storeId = null): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - // @TODO Benchmark this lookup under load and add per-purchasable memoization if it becomes a hot path - $catalogPricingRules = $this->_createCatalogPricingRuleQuery() - ->andWhere(['id' => (new Query()) - ->select(['catalogPricingRuleId']) - ->from([Table::CATALOG_PRICING]) - ->where(['purchasableId' => $purchasableId]), - ]) - ->andWhere(['storeId' => $storeId]) - ->all(); - - return $this->_createCatalogPricingRuleModels($catalogPricingRules); - } - - /** - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - */ - public function getAllEnabledCatalogPricingRules(?int $storeId = null): Collection - { - return $this->getAllCatalogPricingRules($storeId)->where(fn(CatalogPricingRule $pcr) => $pcr->enabled); - } - - /** - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - */ - public function getAllActiveCatalogPricingRules(?int $storeId = null): Collection - { - return $this->getAllEnabledCatalogPricingRules($storeId)->where(fn(CatalogPricingRule $pcr) => - // If there are no dates or rule is currently in the date range add it to the active list - ($pcr->dateFrom === null || $pcr->dateFrom->getTimestamp() <= time()) && ($pcr->dateTo === null || $pcr->dateTo->getTimestamp() >= time())); - } - - /** - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - */ - public function getAllCatalogPricingRulesWithUserConditions(?int $storeId = null): Collection - { - return $this->getAllCatalogPricingRules($storeId)->where(fn(CatalogPricingRule $pcr) => !empty($pcr->getCustomerCondition()->getConditionRules())); - } - - /** - * @param float|null $basePrice - * @param float|null $basePromotionalPrice - * @param CatalogPricingRule $catalogPricingRule - * @return float|null - */ - public function generateRulePriceFromPrice(?float $basePrice, ?float $basePromotionalPrice, CatalogPricingRule $catalogPricingRule): ?float - { - $price = null; - - // A third option may be required for catalog pricing rules that allow store admins to select `salePrice`. - // So that just want to create a catalog price from the `price` or the `promotionalPrice` if there is one. - if ($catalogPricingRule->applyPriceType === CatalogPricingRuleRecord::APPLY_PRICE_TYPE_PRICE) { - $price = $basePrice; - } elseif ($catalogPricingRule->applyPriceType === CatalogPricingRuleRecord::APPLY_PRICE_TYPE_PROMOTIONAL_PRICE) { - // Skip if there is no promotional price - if ($basePromotionalPrice === null) { - return null; - } - $price = $basePromotionalPrice; - } - - if ($price === null) { - return null; - } - - return $catalogPricingRule->getRulePriceFromPrice($price); - } - - /** - * @param ModelEvent $event - * @return void - * @throws InvalidConfigException - * @throws \yii\db\Exception - */ - public function afterSaveUserHandler(ModelEvent|UserGroupsAssignEvent $event): void - { - $stores = Plugin::getInstance()->getStores()->getAllStores(); - - foreach ($stores as $store) { - $rules = $this->getAllCatalogPricingRulesWithUserConditions($store->id); - if ($rules->isEmpty()) { - continue; - } - - /** @var User $user */ - $user = $event instanceof ModelEvent ? $event->sender : Craft::$app->getUsers()->getUserById($event->userId); - $rules->each(function(CatalogPricingRule $rule) use ($user) { - $customerCondition = $rule->getCustomerCondition(); - if ($customerCondition->matchElement($user)) { - if (!CatalogPricingRuleUser::find()->where(['userId' => $user->id, 'catalogPricingRuleId' => $rule->id])->exists()) { - Craft::$app->getDb()->createCommand() - ->insert(Table::CATALOG_PRICING_RULES_USERS, ['userId' => $user->id, 'catalogPricingRuleId' => $rule->id]) - ->execute(); - } - } else { - CatalogPricingRuleUser::deleteAll(['userId' => $user->id, 'catalogPricingRuleId' => $rule->id]); - } - }); - } - } - - /** - * Save a Catalog Pricing Rule. - * - * @param bool $runValidation should we validate this before saving. - * @throws Exception - * @throws \Exception - */ - public function saveCatalogPricingRule(CatalogPricingRule $catalogPricingRule, bool $runValidation = true): bool - { - $isNew = !$catalogPricingRule->id; - - if ($isNew) { - $record = Craft::createObject(CatalogPricingRuleRecord::class); - } else { - $record = CatalogPricingRuleRecord::findOne($catalogPricingRule->id); - - if (!$record) { - throw new Exception(Craft::t('commerce', 'No catalog pricing rule exists with the ID “{id}”', - ['id' => $catalogPricingRule->id])); - } - } - - if ($runValidation && !$catalogPricingRule->validate()) { - Craft::info('Catalog pricing rule not saved due to validation error.', __METHOD__); - - return false; - } - - // This was previously in a loops using an array of attributes, but this way gives actual references to the properties in the code - $record->apply = $catalogPricingRule->apply; - $record->applyAmount = $catalogPricingRule->applyAmount; - $record->applyPriceType = $catalogPricingRule->applyPriceType; - $record->dateFrom = $catalogPricingRule->dateFrom; - $record->dateTo = $catalogPricingRule->dateTo; - $record->description = $catalogPricingRule->description; - $record->enabled = $catalogPricingRule->enabled; - $record->isPromotionalPrice = $catalogPricingRule->isPromotionalPrice; - $record->name = $catalogPricingRule->name; - $record->storeId = $catalogPricingRule->storeId; - $record->metadata = $catalogPricingRule->getMetadata(); - - $record->customerCondition = $catalogPricingRule->getCustomerCondition()->getConfig(); - $record->productCondition = $catalogPricingRule->getProductCondition()->getConfig(); - $record->variantCondition = $catalogPricingRule->getVariantCondition()->getConfig(); - $record->purchasableCondition = $catalogPricingRule->getPurchasableCondition()->getConfig(); - - $db = Craft::$app->getDb(); - $transaction = $db->beginTransaction(); - - try { - $record->save(false); - $catalogPricingRule->id = $record->id; - - CatalogPricingRuleUser::deleteAll(['catalogPricingRuleId' => $catalogPricingRule->id]); - - // Batch insert user relationships in case we are dealing with a large number - $userIds = $catalogPricingRule->getUserIds() ?? []; - foreach (array_chunk($userIds, 1000) as $userIdsChunk) { - $userRecords = []; - foreach ($userIdsChunk as $userId) { - $userRecords[] = [$catalogPricingRule->id, $userId]; - } - $db->createCommand() - ->batchInsert( - Table::CATALOG_PRICING_RULES_USERS, - ['catalogPricingRuleId', 'userId'], - $userRecords - ) - ->execute(); - } - - $transaction->commit(); - - Plugin::getInstance()->getCatalogPricing()->createCatalogPricingJob([ - 'catalogPricingRuleIds' => [$catalogPricingRule->id], - 'storeId' => $catalogPricingRule->storeId, - ]); - - $this->_clearCaches(); - - return true; - } catch (\Exception $e) { - $transaction->rollBack(); - throw $e; - } - } - - /** - * Delete a catalog pricing rule by its id. - * - * @param int $id - * @return bool - * @throws StaleObjectException - * @throws \Throwable - */ - public function deleteCatalogPricingRuleById(int $id): bool - { - $record = CatalogPricingRuleRecord::findOne($id); - - if (!$record) { - return false; - } - - $this->_clearCaches(); - return (bool)$record->delete(); - } - - protected function _createCatalogPricingRuleQuery(): ?Query - { - return (new Query()) - ->select([ - 'apply', - 'applyAmount', - 'applyPriceType', - 'customerCondition', - 'dateCreated', - 'dateFrom', - 'dateTo', - 'dateUpdated', - 'description', - 'enabled', - 'id', - 'isPromotionalPrice', - 'metadata', - 'name', - 'productCondition', - 'purchasableCondition', - 'storeId', - 'variantCondition', - ]) - ->from(Table::CATALOG_PRICING_RULES); - } - - /** - * Clear memoization caches - */ - protected function _clearCaches(): void - { - $this->_allCatalogPricingRules = null; - $this->_hasCatalogPricingRules = null; - } - - /** - * Takes the results array from a `_createCatalogPricingRuleQuery()` call and creates a collection of CatalogPricingRule models. - * - * @param array $rows - * @return Collection - */ - protected function _createCatalogPricingRuleModels(array $rows): Collection - { - return collect($rows)->map(function($row) { - $row['customerCondition'] ??= ''; - $row['productCondition'] ??= ''; - $row['purchasableCondition'] ??= ''; - $row['variantCondition'] ??= ''; - - return Craft::createObject(CatalogPricingRule::class, ['config' => ['attributes' => $row]]); - })->keyBy('id'); - } -} diff --git a/src/services/Coupons.php b/src/services/Coupons.php deleted file mode 100644 index a76fa6c5eb..0000000000 --- a/src/services/Coupons.php +++ /dev/null @@ -1,265 +0,0 @@ - - * @since 4.0 - * - * @property-read null|array $allCodes - */ -class Coupons extends Component -{ - public const COUPON_FORMAT_REPLACEMENT_CHAR = '#'; - public const DEFAULT_COUPON_FORMAT = '######'; - public const CHARS_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - public const CHARS_LOWER = 'abcdefghijklmnopqrstuvwxyz'; - public const CHARS_NUMBERS = '0123456789'; - public const CHARS_SPECIAL = '!@#$%^&*()-_=+[]{}|;:,.<>/?~'; - - /** - * @var array|null - */ - private ?array $_allCodes = null; - - /** - * @return array|null - */ - public function getAllCodes(): ?array - { - if ($this->_allCodes !== null) { - return $this->_allCodes; - } - - $this->_allCodes = $this->_createCouponQuery() - ->indexBy('id') - ->select(['coupons.code']) - ->column(); - - return $this->_allCodes; - } - - /** - * @param string $code - * @return Coupon|null - * @throws InvalidConfigException - */ - public function getCouponByCode(string $code): ?Coupon - { - $coupon = $this->_createCouponQuery() - ->where(['code' => $code]) - ->one(); - - return $coupon ? Craft::createObject(Coupon::class, ['config' => ['attributes' => $coupon]]) : null; - } - - /** - * @param int $discountId - * @return Coupon[] - * @throws InvalidConfigException - */ - public function getCouponsByDiscountId(int $discountId): array - { - $coupons = $this->_createCouponQuery() - ->where(['discountId' => $discountId]) - ->all(); - - foreach ($coupons as &$coupon) { - $coupon = Craft::createObject(Coupon::class, ['config' => ['attributes' => $coupon]]); - } - - return $coupons; - } - - /** - * @param int $count - * @param string $format - * @param array $existingCodes - * @return string[] - * @throws Exception - */ - public function generateCouponCodes(int $count = 1, string $format = self::DEFAULT_COUPON_FORMAT, array $existingCodes = []): array - { - // Count the number of # characters in the format - $numReplacementChars = strlen($format) - strlen(str_replace(self::COUPON_FORMAT_REPLACEMENT_CHAR, '', $format)); - $numPossibleCodes = strlen(self::CHARS_UPPER) ** $numReplacementChars; - - if ($numPossibleCodes < $count) { - // @TODO Replace this generic Exception with a typed one (e.g. CouponException or InvalidArgumentException) so callers can distinguish format-too-restrictive failures - throw new Exception('The format is too restrictive to generate enough unique codes.'); - } - - $existingCodes = array_unique([...$existingCodes, ...$this->getAllCodes()]); - $coupons = []; - - for ($i = 1; $i <= $count; $i++) { - $code = preg_replace_callback('/([' . self::COUPON_FORMAT_REPLACEMENT_CHAR . ']+)/', static function($matches) { - $length = strlen($matches[0]); - return StringHelper::randomStringWithChars(self::CHARS_UPPER, $length); - }, $format); - - if (!empty($existingCodes) && in_array($code, $existingCodes, true)) { - $i--; - continue; - } - $coupons[] = $code; - $existingCodes[] = $code; - } - - return $coupons; - } - - /** - * @param int $id - * @return bool - * @throws Throwable - * @throws StaleObjectException - */ - public function deleteCouponById(int $id): bool - { - $couponRecord = CouponRecord::findOne($id); - - if (!$couponRecord) { - return false; - } - - return (bool)$couponRecord->delete(); - } - - /** - * @param Discount $discount - * @return bool - * @throws InvalidConfigException - * @since 4.0 - */ - public function saveDiscountCoupons(Discount $discount): bool - { - if (!$discount->id) { - throw new Exception('Discount must be saved before it can have coupons'); - } - - // Get currently saved coupon IDs from the DB - $existingCouponIds = $this->_createCouponQuery() - ->select(['id']) - ->where(['discountId' => $discount->id]) - ->column(); - - $couponIds = []; - foreach ($discount->getCoupons() as $key => $coupon) { - $coupon->discountId = $discount->id; - - if (!Plugin::getInstance()->getCoupons()->saveCoupon($coupon)) { - $discount->addModelErrors($coupon, 'coupon.' . $key); - } - - if ($coupon->id) { - $couponIds[] = $coupon->id; - } - } - - $return = !$discount->hasErrors(); - - if (empty($existingCouponIds) || $existingCouponIds === $couponIds) { - return $return; - } - - $deleteableCouponIds = array_diff($existingCouponIds, $couponIds); - if (empty($deleteableCouponIds)) { - return $return; - } - - foreach ($deleteableCouponIds as $deleteableCouponId) { - $this->deleteCouponById($deleteableCouponId); - } - - return $return; - } - - /** - * @param Coupon $coupon - * @param bool $runValidation - * @return bool - * @throws BadRequestHttpException - */ - public function saveCoupon(Coupon $coupon, bool $runValidation = true): bool - { - if ($coupon->id) { - $record = CouponRecord::findOne($coupon->id); - - if (!$record) { - throw new BadRequestHttpException("Invalid coupon ID: $coupon->id"); - } - } else { - $record = new CouponRecord(); - } - - if ($runValidation && !$coupon->validate()) { - Craft::info('Coupon not saved due to validation error.', __METHOD__); - - return false; - } - - $record->code = $coupon->code; - $record->discountId = $coupon->discountId; - $record->uses = $coupon->uses; - $record->maxUses = $coupon->maxUses; - - // Save it! - $record->save(false); - - // Now that we have a record ID, save it on the model - $coupon->id = $record->id; - - $this->clearCache(); - - return true; - } - - /** - * @return void - */ - protected function clearCache(): void - { - $this->_allCodes = null; - } - - /** - * Returns a Query object prepped for retrieving Coupons. - * - * @return Query The query object. - */ - private function _createCouponQuery(): Query - { - return (new Query()) - ->select([ - 'coupons.id', - 'coupons.code', - 'coupons.uses', - 'coupons.maxUses', - 'coupons.discountId', - ]) - ->from([Table::COUPONS . ' coupons']); - } -} diff --git a/src/services/Currencies.php b/src/services/Currencies.php deleted file mode 100644 index c3fd7e470d..0000000000 --- a/src/services/Currencies.php +++ /dev/null @@ -1,127 +0,0 @@ - - * @since 2.0 - */ -class Currencies extends Component -{ - private ?ISOCurrencies $_isoCurrencies = null; - - public function init() - { - $this->_isoCurrencies = new ISOCurrencies(); - } - - /** - * @var array - */ - private array $_tellersByIso = []; - - /** - * @param \Money\Currency|string $currency - * @return Teller - */ - public function getTeller(\Money\Currency|string $currency): Teller - { - if (is_string($currency)) { - $currency = new \Money\Currency($currency); - } - - $parser = new DecimalMoneyParser($this->_isoCurrencies); - $formatter = new DecimalMoneyFormatter($this->_isoCurrencies); - $roundingMode = Money::ROUND_HALF_UP; - - $iso = $currency->getCode(); - if (isset($this->_tellersByIso[$iso])) { - return $this->_tellersByIso[$iso]; - } - - $this->_tellersByIso[$iso] = new \Money\Teller( - $currency, - $parser, - $formatter, - $roundingMode - ); - - return $this->_tellersByIso[$iso]; - } - - /** - * Get a currency by it's ISO code. - * - * @param string $iso - * @return \Money\Currency|null - */ - public function getCurrencyByIso(string $iso): ?\Money\Currency - { - return $this->getAllCurrencies()->first(fn(\Money\Currency $currency) => $currency->getCode() == $iso); - } - - - /** - * Get a list of all available currencies. - * - * @return Collection<\Money\Currency> - */ - public function getAllCurrencies(): Collection - { - return collect($this->_isoCurrencies); - } - - /** - * @return array - */ - public function getAllCurrenciesList(): array - { - return $this->getAllCurrencies()->map(fn($currency) => [ - 'label' => $currency->getCode(), // @TODO Resolve a localized currency name (e.g. via Intl/Locale) and use it as the label instead of the ISO code - 'value' => $currency->getCode(), - ])->toArray(); - } - - /** - * @param Currency|string $currency - * @return int - */ - public function getSubunitFor(Currency|string $currency) - { - if (is_string($currency)) { - $currency = $this->getCurrencyByIso($currency); - } - - return $this->_isoCurrencies->subunitFor($currency); - } - - /** - * @param Currency|string $currency - * @return int - */ - public function numericCodeFor(Currency|string $currency) - { - if (is_string($currency)) { - $currency = $this->getCurrencyByIso($currency); - } - - return $this->_isoCurrencies->numericCodeFor($currency); - } -} diff --git a/src/services/Customers.php b/src/services/Customers.php deleted file mode 100644 index 72389c9a41..0000000000 --- a/src/services/Customers.php +++ /dev/null @@ -1,508 +0,0 @@ - - * @since 2.0 - */ -class Customers extends Component -{ - // Events - // ------------------------------------------------------------------------- - - /** - * @event UpdatePrimaryPaymentSourceEvent The event that is triggered when a primary payment method is saved. - * - * ```php - * use craft\elements\User; - * use craft\commerce\services\Customers; - * use craft\commerce\events\UpdatePrimaryPaymentSourceEvent; - * use yii\base\Event; - * - * Event::on( - * Customers::class, - * Customers::EVENT_UPDATE_PRIMARY_PAYMENT_SOURCE, - * function(UpdatePrimaryPaymentSourceEvent $event) { - * $previousPrimaryPaymentSourceId = $event->previousPrimaryPaymentSourceId; - * $newPrimaryPaymentSourceId = $event->newPrimaryPaymentSourceId; - * // @var User|CustomerBehavior $customer - * $customer = $event->customer; - * // ... - * } - * ); - * ``` - */ - public const EVENT_UPDATE_PRIMARY_PAYMENT_SOURCE = 'updatePrimaryPaymentSource'; - - /** - * @param User $user - * @param int|null $addressId - * @return bool - */ - public function savePrimaryShippingAddressId(User $user, ?int $addressId): bool - { - $customerRecord = $this->ensureCustomer($user); - $customerRecord->primaryShippingAddressId = $addressId; - /** @var User|CustomerBehavior $user */ - $user->primaryShippingAddressId = $addressId; - return $customerRecord->save(); - } - - /** - * @param User $user - * @param int|null $addressId - * @return bool - */ - public function savePrimaryBillingAddressId(User $user, ?int $addressId): bool - { - $customerRecord = $this->ensureCustomer($user); - $customerRecord->primaryBillingAddressId = $addressId; - /** @var User|CustomerBehavior $user */ - $user->primaryBillingAddressId = $addressId; - return $customerRecord->save(); - } - - /** - * @param User $user - * @param int|null $paymentSourceId - * @return bool - * @since 4.2 - */ - public function savePrimaryPaymentSourceId(User $user, ?int $paymentSourceId): bool - { - $customerRecord = $this->ensureCustomer($user); - - $originalPaymentSourceId = $customerRecord->primaryPaymentSourceId; - - // Only save customer record if the source is not already primary - if ($customerRecord->primaryPaymentSourceId == $paymentSourceId) { - return true; - } - - $customerRecord->primaryPaymentSourceId = $paymentSourceId; - - if (!$customerRecord->save()) { - return false; - } - - /** @var User|CustomerBehavior $user */ - $user->primaryPaymentSourceId = $paymentSourceId; - - if ($originalPaymentSourceId != $paymentSourceId) { - $event = new UpdatePrimaryPaymentSourceEvent([ - 'previousPrimaryPaymentSourceId' => $originalPaymentSourceId, - 'newPrimaryPaymentSourceId' => $paymentSourceId, - 'customer' => $user, - ]); - - // trigger the update primary payment source event - $this->trigger(self::EVENT_UPDATE_PRIMARY_PAYMENT_SOURCE, $event); - } - - return true; - } - - /** - * Handle user login - */ - public function loginHandler(): void - { - $impersonating = Craft::$app->getSession()->get(User::IMPERSONATE_KEY) !== null; - // Don't allow transition of current cart to a user that is being impersonated. - if ($impersonating) { - Plugin::getInstance()->getCarts()->forgetCart(); - } - - Plugin::getInstance()->getCarts()->restorePreviousCartForCurrentUser(); - } - - /** - * Sets the last used addresses on the customer on order completion. - * - * Consolidates any other orders using the same email address. - * - * Duplicates the address records used for the order so they are independent to the - * customers address book. - * - * @param Order $order - */ - public function orderCompleteHandler(Order $order): void - { - // Create a user account if requested - if ($order->registerUserOnOrderComplete) { - $this->_activateUserFromOrder($order); - } - - // Did they want to save addresses to the customers address book? - if ($order->saveBillingAddressOnOrderComplete || $order->saveShippingAddressOnOrderComplete) { - $this->_saveAddressesFromOrder($order); - } - - // clear the primary address flags if they were set as it only applies to the cart - if ($order->makePrimaryBillingAddress || $order->makePrimaryShippingAddress) { - OrderRecord::updateAll([ - 'makePrimaryBillingAddress' => false, - 'makePrimaryShippingAddress' => false, - ], - [ - 'id' => $order->id, - ] - ); - } - } - - /** - * @param array|Order[] $orders - * @return Order[] - * @since 3.2.0 - */ - public function eagerLoadCustomerForOrders(array $orders): array - { - $customerIds = ArrayHelper::getColumn($orders, 'customerId'); - /** @var User[] $users */ - $users = User::find()->id($customerIds)->limit(null)->indexBy('id')->all(); - - foreach ($orders as $key => $order) { - $customerId = $order->getCustomerId(); - if (isset($users[$customerId])) { - $order->setCustomer($users[$customerId]); - $orders[$key] = $order; - } - } - - return $orders; - } - - /** - * Returns a customer record by a user element, creating one if none already exists. - * - * @param User $user - * @return CustomerRecord - */ - public function ensureCustomer(User $user): CustomerRecord - { - /** @var CustomerRecord|null $customerRecord */ - $customerRecord = CustomerRecord::find()->where(['customerId' => $user->id])->one(); - if (!$customerRecord) { - $customerRecord = new CustomerRecord(); - $customerRecord->customerId = $user->id; - $customerRecord->save(); - } - - return $customerRecord; - } - - /** - * @return bool Whether the data moved successfully - * @throws ElementNotFoundException|\yii\db\Exception - * @since 4.1.0 - */ - public function transferCustomerData(User $fromCustomer, User $toCustomer): bool - { - $fromId = $fromCustomer->id; - $toId = $toCustomer->id; - - /** @var User|null $fromUser */ - $fromUser = User::find()->id($fromId)->one(); - /** @var User|null $toUser */ - $toUser = User::find()->id($toId)->one(); - - if ($fromUser === null) { - throw new ElementNotFoundException('User ID:', $fromId); - } - - if ($toUser === null) { - throw new ElementNotFoundException('User ID:', $toId); - } - - $userRefs = [ - Table::ORDERHISTORIES => 'userId', - Table::SUBSCRIPTIONS => 'userId', - Table::TRANSACTIONS => 'userId', - Table::ORDERS => 'customerId', - Table::PAYMENTSOURCES => 'customerId', - ]; - - foreach ($userRefs as $table => $column) { - Db::update($table, [ - $column => $toId, - ], [ - $column => $fromId, - ], [], false); - } - - $previousUses = (new Query())->select(['discountId', 'uses'])->from(Table::CUSTOMER_DISCOUNTUSES)->where(['customerId' => $fromId])->pairs(); - $toUses = (new Query())->select(['discountId', 'uses'])->from(Table::CUSTOMER_DISCOUNTUSES)->where(['customerId' => $toId])->pairs(); - - foreach ($previousUses as $discountId => $uses) { - if (isset($toUses[$discountId])) { - Db::update( - table: Table::CUSTOMER_DISCOUNTUSES, - columns: ['uses' => new Expression("uses + $uses")], - condition: [ - 'customerId' => $toId, - 'discountId' => $discountId, - ], - params: [], - updateTimestamp: false - ); - } else { - Db::insert( - table: Table::CUSTOMER_DISCOUNTUSES, - columns: [ - 'uses' => $uses, - 'customerId' => $toId, - 'discountId' => $discountId, - ] - ); - } - - // Remove uses from fromCustomer - Db::update( - table: Table::CUSTOMER_DISCOUNTUSES, - columns: ['uses' => 0], - condition: [ - 'customerId' => $fromId, - 'discountId' => $discountId, - ], - params: [], - updateTimestamp: false - ); - } - - - $fromEmail = $fromUser->email; - $toEmail = $toUser->email; - - $emailRefs = [ - Table::ORDERS => 'email', - ]; - - foreach ($emailRefs as $table => $column) { - Db::update($table, [ - $column => $toEmail, - ], [ - $column => $fromEmail, - ], [], false); - } - - return true; - } - - /** - * - * @param Order $order - * @return void - * @throws \Throwable - * @throws InvalidElementException - * @throws UnsupportedSiteException - */ - private function _saveAddressesFromOrder(Order $order): void - { - // Only for completed orders - if ($order->isCompleted === false) { - return; - } - - // Check for a credentialed user - if ($order->getCustomer() === null || !$order->getCustomer()->getIsCredentialed()) { - return; - } - - $saveBillingAddress = $order->saveBillingAddressOnOrderComplete && $order->sourceBillingAddressId === null && $order->billingAddressId; - $saveShippingAddress = $order->saveShippingAddressOnOrderComplete && $order->sourceShippingAddressId === null && $order->shippingAddressId; - $newSourceBillingAddressId = null; - $newSourceShippingAddressId = null; - - if ($saveBillingAddress && $saveShippingAddress && $order->hasMatchingAddresses()) { - // Only save one address if they are matching - $newAddress = Craft::$app->getElements()->duplicateElement( - $order->getBillingAddress(), - [ - 'primaryOwner' => $order->getCustomer(), - 'owner' => $order->getCustomer(), - ] - ); - $newSourceBillingAddressId = $newAddress->id; - $newSourceShippingAddressId = $newAddress->id; - } else { - if ($saveBillingAddress) { - $newBillingAddress = Craft::$app->getElements()->duplicateElement($order->getBillingAddress(), - [ - 'primaryOwner' => $order->getCustomer(), - 'owner' => $order->getCustomer(), - ] - ); - $newSourceBillingAddressId = $newBillingAddress->id; - } - - if ($saveShippingAddress) { - $newShippingAddress = Craft::$app->getElements()->duplicateElement( - $order->getShippingAddress(), - [ - 'primaryOwner' => $order->getCustomer(), - 'owner' => $order->getCustomer(), - ] - ); - $newSourceShippingAddressId = $newShippingAddress->id; - } - } - - if ($newSourceBillingAddressId) { - $order->sourceBillingAddressId = $newSourceBillingAddressId; - } - - if ($newSourceShippingAddressId) { - $order->sourceShippingAddressId = $newSourceShippingAddressId; - } - - // Since we saved the primary addresses, we can now set the primary if they chose that also - if ($order->makePrimaryShippingAddress && $order->sourceShippingAddressId) { - $this->savePrimaryShippingAddressId($order->getCustomer(), $order->sourceShippingAddressId); - } - - if ($order->makePrimaryBillingAddress && $order->sourceBillingAddressId) { - $this->savePrimaryBillingAddressId($order->getCustomer(), $order->sourceBillingAddressId); - } - - // Manually update the order DB record to avoid looped element saves - if ($newSourceBillingAddressId || $newSourceShippingAddressId) { - \craft\commerce\records\Order::updateAll([ - 'sourceBillingAddressId' => $order->sourceBillingAddressId, - 'sourceShippingAddressId' => $order->sourceShippingAddressId, - ], - [ - 'id' => $order->id, - ] - ); - } - } - - /** - * Makes sure the user has an email address and sets them to pending and sends the activation email - */ - private function _activateUserFromOrder(Order $order): void - { - $user = $order->getCustomer(); - if (!$user || $user->active || $user->locked || $user->suspended) { - return; - } - - $billingAddress = $order->getBillingAddress(); - $shippingAddress = $order->getShippingAddress(); - - if (!$user->fullName) { - $user->fullName = $billingAddress?->fullName ?? $shippingAddress?->fullName ?? ''; - } - - $user->username = $order->getEmail(); - $user->pending = true; - $user->setScenario(Element::SCENARIO_ESSENTIALS); - - // @TODO Remove this property_exists guard once Commerce requires a Craft version where User::$affiliatedSiteId always exists - if (property_exists($user, 'affiliatedSiteId')) { - $user->affiliatedSiteId = $order->orderSiteId; - } - - if (Craft::$app->getElements()->saveElement($user)) { - Craft::$app->getUsers()->assignUserToDefaultGroup($user); - - Event::once(Mailer::class, Mailer::EVENT_BEFORE_PREP, function(MailEvent $event) use ($user) { - if (!$event->message instanceof Message) { - return; - } - - if ($event->message->key !== 'account_activation') { - return; - } - - if ($event->message->siteId === null && property_exists($user, 'affiliatedSiteId') && $user->affiliatedSiteId) { - $event->message->siteId = $user->affiliatedSiteId; - } - }); - - $emailSent = Craft::$app->getUsers()->sendActivationEmail($user); - - if (!$emailSent) { - Craft::warning('"registerUserOnOrderComplete" used to create the user, but couldn’t send an activation email. Check your email settings.', __METHOD__); - } - - if ($billingAddress || $shippingAddress) { - $newAttributes = [ - 'owner' => $user, - 'primaryOwner' => $user, - ]; - - // If there is only one address make sure we don't add duplicates to the user - if ($order->hasMatchingAddresses()) { - $newAttributes['title'] = Craft::t('app', 'Address'); - $shippingAddress = null; - } - - // Copy addresses to user - if ($billingAddress) { - $newBillingAddress = Craft::$app->getElements()->duplicateElement($billingAddress, $newAttributes); - - /** - * Because we are cloning from an order address the `CustomerAddressBehavior` hasn't been instantiated - * therefore we are unable to simply set the `isPrimaryBilling` property when specifying the new attributes during duplication. - */ - if (!$newBillingAddress->hasErrors()) { - $this->savePrimaryBillingAddressId($user, $newBillingAddress->id); - - if ($order->hasMatchingAddresses()) { - $this->savePrimaryShippingAddressId($user, $newBillingAddress->id); - } - } - } - - if ($shippingAddress) { - $newShippingAddress = Craft::$app->getElements()->duplicateElement($shippingAddress, $newAttributes); - - /** - * Because we are cloning from an order address the `CustomerAddressBehavior` hasn't been instantiated - * therefore we are unable to simply set the `isPrimaryShipping` property when specifying the new attributes during duplication. - */ - if (!$newShippingAddress->hasErrors()) { - $this->savePrimaryShippingAddressId($user, $newShippingAddress->id); - } - } - } - } else { - $errors = $user->getErrors(); - Craft::warning('Could not create user on order completion.', __METHOD__); - Craft::warning($errors, __METHOD__); - } - } -} diff --git a/src/services/Discounts.php b/src/services/Discounts.php deleted file mode 100644 index 69cad478ff..0000000000 --- a/src/services/Discounts.php +++ /dev/null @@ -1,1498 +0,0 @@ - - * @since 2.0y - */ -class Discounts extends Component -{ - /** - * @event DiscountEvent The event that is triggered before a discount is saved. - * - * ```php - * use craft\commerce\events\DiscountEvent; - * use craft\commerce\services\Discounts; - * use craft\commerce\models\Discount; - * use yii\base\Event; - * - * Event::on( - * Discounts::class, - * Discounts::EVENT_BEFORE_SAVE_DISCOUNT, - * function(DiscountEvent $event) { - * // @var Discount $discount - * $discount = $event->discount; - * // @var bool $isNew - * $isNew = $event->isNew; - * - * // Let an external CRM know about a client’s new discount - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_SAVE_DISCOUNT = 'beforeSaveDiscount'; - - /** - * @event DiscountEvent The event that is triggered after a discount is saved. - * - * ```php - * use craft\commerce\events\DiscountEvent; - * use craft\commerce\services\Discounts; - * use craft\commerce\models\Discount; - * use yii\base\Event; - * - * Event::on( - * Discounts::class, - * Discounts::EVENT_AFTER_SAVE_DISCOUNT, - * function(DiscountEvent $event) { - * // @var Discount $discount - * $discount = $event->discount; - * // @var bool $isNew - * $isNew = $event->isNew; - * - * // Set this discount as default in an external CRM - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_SAVE_DISCOUNT = 'afterSaveDiscount'; - - /** - * @event DiscountEvent The event that is triggered after a discount is deleted. - * - * ```php - * use craft\commerce\events\DiscountEvent; - * use craft\commerce\services\Discounts; - * use craft\commerce\models\Discount; - * use yii\base\Event; - * - * Event::on( - * Discounts::class, - * Discounts::EVENT_AFTER_DELETE_DISCOUNT, - * function(DiscountEvent $event) { - * // @var Discount $discount - * $discount = $event->discount; - * - * // Remove this discount from a payment gateway - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_DELETE_DISCOUNT = 'afterDeleteDiscount'; - - /** - * @event MatchLineItemEvent The event that is triggered when a line item is matched with a discount. - * - * This event will be raised if all standard conditions are met. - * You may set the `isValid` property to `false` on the event to prevent the matching of the discount to the line item. - * - * ```php - * use craft\commerce\services\Discounts; - * use craft\commerce\events\MatchLineItemEvent; - * use craft\commerce\models\Discount; - * use craft\commerce\models\LineItem; - * use yii\base\Event; - * - * Event::on( - * Discounts::class, - * Discounts::EVENT_DISCOUNT_MATCHES_LINE_ITEM, - * function(MatchLineItemEvent $event) { - * // @var LineItem $lineItem - * $lineItem = $event->lineItem; - * // @var Discount $discount - * $discount = $event->discount; - * - * // Check some business rules and prevent a match in special cases - * // ... - * } - * ); - * ``` - */ - public const EVENT_DISCOUNT_MATCHES_LINE_ITEM = 'discountMatchesLineItem'; - - /** - * @event MatchOrderEvent The event that is triggered when an order is matched with a discount. - * - * You may set the `isValid` property to `false` on the event to prevent the matching of the discount with the order. - * - * ```php - * use craft\commerce\services\Discounts; - * use craft\commerce\events\MatchOrderEvent; - * use craft\commerce\models\Discount; - * use craft\commerce\elements\Order; - * use yii\base\Event; - * - * Event::on( - * Discounts::class, - * Discounts::EVENT_DISCOUNT_MATCHES_ORDER, - * function(MatchOrderEvent $event) { - * // @var Order $order - * $order = $event->order; - * // @var Discount $discount - * $discount = $event->discount; - * - * // Check some business rules and prevent a match in special cases - * // ... $event->isValid = false; // set to false if you want it to NOT match as it would. - * } - * ); - * ``` - */ - public const EVENT_DISCOUNT_MATCHES_ORDER = 'discountMatchesOrder'; - - /** - * @var Collection[]|null - */ - private ?array $_allDiscounts = null; - - /** - * @var Discount[][]|null - */ - private ?array $_activeDiscountsByKey = null; - - /** - * @var array|null - */ - private ?array $_matchingLineItemCategoryCondition = null; - - /** - * Get a discount by its ID. - * - * @param int $id - * @param int|null $storeId - * @return Discount|null - * @throws InvalidConfigException - * @throws StoreNotFoundException - */ - public function getDiscountById(int $id, ?int $storeId = null): ?Discount - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - // Keep this as a query for the performance boost - $discounts = $this->_createDiscountQuery() - ->andWhere(['[[discounts.id]]' => $id]) - ->andWhere(['storeId' => $storeId]) - ->all(); - - if (!$discounts) { - return null; - } - - return ArrayHelper::firstValue($this->_populateDiscounts($discounts)); - } - - /** - * Get all discounts. - * - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - * @throws StoreNotFoundException - */ - public function getAllDiscounts(?int $storeId = null): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if ($this->_allDiscounts === null || !isset($this->_allDiscounts[$storeId])) { - $discounts = $this->_createDiscountQuery() - ->where(['storeId' => $storeId]) - ->all(); - - if ($this->_allDiscounts === null) { - $this->_allDiscounts = []; - } - - if (!empty($discounts)) { - $this->_allDiscounts[$storeId] = collect($this->_populateDiscounts($discounts)); - } else { - $this->_allDiscounts[$storeId] = collect(); - } - } - - return $this->_allDiscounts[$storeId]; - } - - /** - * Get all currently active discounts - * We pass the Order to attempt ot optimize the query to only possible discounts that might match, - * eliminating ones that definitely will not match. - * - * @param Order|null $order - * @return Discount[] - * @throws \Exception - * @since 2.2.14 - */ - public function getAllActiveDiscounts(?Order $order = null): array - { - $purchasableIds = []; - if ($order) { - $purchasableIds = Collection::make($order->getLineItems())->pluck('purchasableId')->unique()->all(); - } - - // Date condition for use with key - if ($order && $order->dateOrdered) { - $date = $order->dateOrdered; - } else { - // We use a round the time so we can have a cache within the same request (rounded to 1 minute flat, no seconds) - $date = new DateTime(); - $date->setTime((int)$date->format('H'), (int)(round($date->format('i') / 1) * 1)); - } - - $store = $order ? $order->getStore() : Plugin::getInstance()->getStores()->getCurrentStore(); - - // Coupon condition key - $couponKey = ($order && $order->couponCode) ? $order->couponCode : '*'; - $dateKey = DateTimeHelper::toIso8601($date); - $storeKey = $order ? $order->getStore()->id : '*'; - $purchasablesKey = !empty($purchasableIds) ? md5(serialize($purchasableIds)) : '*'; - $itemSubtotalKey = $order ? $order->getItemSubtotal() : '*'; - $orderTotalQtyKey = $order ? $order->getTotalQty() : '*'; - $orderEmailKey = ($order && $order->getEmail()) ? $order->getEmail() : '*'; - - $cacheKey = implode(':', [ - $couponKey, - $dateKey, - $storeKey, - $purchasablesKey, - $itemSubtotalKey, - $orderTotalQtyKey, - $orderEmailKey, - ]); - - $cacheKeyMd5 = md5($cacheKey); - - if (isset($this->_activeDiscountsByKey[$cacheKeyMd5])) { - return $this->_activeDiscountsByKey[$cacheKeyMd5]; - } - - $discountQuery = $this->_createDiscountQuery() - // Restricted by enabled discounts - ->where([ - 'enabled' => true, - ]) - // Restricted by store - ->andWhere(['storeId' => $store->id]) - // Restrict by things that a definitely not in date - ->andWhere([ - 'or', - ['dateFrom' => null], - ['<=', 'dateFrom', Db::prepareDateForDb($date)], - ]) - ->andWhere([ - 'or', - ['dateTo' => null], - ['>=', 'dateTo', Db::prepareDateForDb($date)], - ]) - ->andWhere([ - 'or', - ['totalDiscountUseLimit' => 0], - ['<', 'totalDiscountUses', new Expression('[[totalDiscountUseLimit]]')], - ]); - - // Pre-qualify discounts based on purchase total - if ($order) { - if ($order->getEmail()) { - $emailUsesSubQuery = (new Query()) - ->select([new Expression('COALESCE(SUM([[edu.uses]]), 0)')]) - ->from(['edu' => Table::EMAIL_DISCOUNTUSES]) - ->where(new Expression('[[edu.discountId]] = [[discounts.id]]')) - ->andWhere(['email' => $order->getEmail()]); - - $discountQuery->andWhere([ - 'or', - ['perEmailLimit' => 0], - ['and', ['>', 'perEmailLimit', 0], ['>', 'perEmailLimit', $emailUsesSubQuery]], - ]); - } else { - $discountQuery->andWhere(['perEmailLimit' => 0]); - } - - - $discountQuery->andWhere([ - 'or', - ['purchaseTotal' => 0], - ['and', ['allPurchasables' => true], ['allCategories' => true], ['<=', 'purchaseTotal', $order->getItemSubtotal()]], - ['allPurchasables' => false], - ['allCategories' => false], - ]); - - $discountQuery->andWhere([ - 'or', - ['purchaseQty' => 0, 'maxPurchaseQty' => 0], - ['and', ['allPurchasables' => true], ['allCategories' => true], ['>', 'purchaseQty', 0], ['maxPurchaseQty' => 0], ['<=', 'purchaseQty', $order->getTotalQty()]], - ['and', ['allPurchasables' => true], ['allCategories' => true], ['>', 'maxPurchaseQty', 0], ['purchaseQty' => 0], ['>=', 'maxPurchaseQty', $order->getTotalQty()]], - ['and', ['allPurchasables' => true], ['allCategories' => true], ['>', 'maxPurchaseQty', 0], ['>', 'purchaseQty', 0], ['<=', 'purchaseQty', $order->getTotalQty()], ['>=', 'maxPurchaseQty', $order->getTotalQty()]], - ['allPurchasables' => false], - ['allCategories' => false], - ]); - } - - $couponSubQuery = (new Query()) - ->from(Table::COUPONS) - ->leftJoin(Table::DISCOUNTS . ' disc', '[[disc.id]] = [[discountId]]') - ->where(new Expression('[[discountId]] = [[discounts.id]]')); - - // If the order has a coupon code let's only get discounts for that code, or discounts that do not require a code - if ($order && $order->couponCode) { - if (Craft::$app->getDb()->getIsPgsql()) { - $codeWhere = ['ilike', 'code', $order->couponCode]; - } else { - $codeWhere = ['code' => $order->couponCode]; - } - - $discountQuery->andWhere( - [ - 'or', - // Find discount where the coupon code matches - [ - 'exists', (clone $couponSubQuery) - ->andWhere(['requireCouponCode' => true]) - ->andWhere($codeWhere) - ->andWhere([ - 'or', - ['maxUses' => null], - new Expression('[[uses]] < [[maxUses]]'), - ] - ), - ], - // OR find discounts that do not have a coupon code requirement - ['requireCouponCode' => false], - ] - ); - } elseif ($order && !$order->couponCode) { - // only discounts that do not have a coupon code requirement - $discountQuery->andWhere(['requireCouponCode' => false]); - } - - if ($order && !empty($purchasableIds)) { - $matchPurchasableSubQuery = (new Query()) - ->from(['subdp' => Table::DISCOUNT_PURCHASABLES]) - ->where(new Expression('[[subdp.discountId]] = [[discounts.id]]')) - ->andWhere(['[[subdp.purchasableId]]' => $purchasableIds]); - - $discountQuery->andWhere( - [ - 'or', - ['allPurchasables' => true], - [ - 'exists', $matchPurchasableSubQuery, - ], - ] - ); - } - - $discountResults = $discountQuery->all(); - $discounts = $this->_populateDiscounts($discountResults); - $this->_activeDiscountsByKey[$cacheKeyMd5] = $discounts; - - return $this->_activeDiscountsByKey[$cacheKeyMd5]; - } - - /** - * Is discount coupon available to the order - * - * @param string|null $explanation - * @throws InvalidConfigException - * @throws \Exception - */ - public function orderCouponAvailable(Order $order, string &$explanation = null): bool - { - $discount = $this->getDiscountByCode($order->couponCode, $order->storeId); - - if (!$discount) { - $explanation = Craft::t('commerce', 'Coupon not valid.'); - return false; - } - - if (!$discount->requireCouponCode) { - $explanation = Craft::t('commerce', 'Coupon not valid.'); - return false; - } - - if (!$this->_isDiscountCouponCodeValid($order, $discount)) { - $explanation = Craft::t('commerce', 'Coupon not valid.'); - return false; - } - - if ($discount->hasOrderCondition() && !$discount->getOrderCondition()->matchElement($order)) { - $explanation = Craft::t('commerce', 'Coupon can not apply discount to this order.'); - - return false; - } - - if ($discount->hasCustomerCondition() && (!$order->getCustomer() || !$discount->getCustomerCondition()->matchElement($order->getCustomer()))) { - $explanation = Craft::t('commerce', 'Coupon can not apply discount to this order due to customer mismatch.'); - return false; - } - - if ($discount->hasShippingAddressCondition() && (!$order->getShippingAddress() || !$discount->getShippingAddressCondition()->matchElement($order->getShippingAddress()))) { - $explanation = Craft::t('commerce', 'Coupon can not apply discount to this order due to address mismatch.'); - return false; - } - - if ($discount->hasBillingAddressCondition() && (!$order->getBillingAddress() || !$discount->getBillingAddressCondition()->matchElement($order->getBillingAddress()))) { - $explanation = Craft::t('commerce', 'Coupon can not apply discount to this order due to address mismatch.'); - return false; - } - - if (!$this->_isDiscountConditionFormulaValid($order, $discount)) { - $explanation = Craft::t('commerce', 'Discount is not allowed for the order'); - return false; - } - - - if (!$this->_isDiscountDateValid($order, $discount)) { - $explanation = Craft::t('commerce', 'Discount is out of date.'); - return false; - } - - if (!$this->_isDiscountTotalUseLimitValid($discount)) { - $explanation = Craft::t('commerce', 'Discount use has reached its limit.'); - return false; - } - - if (!$this->_isDiscountPerUserUsageValid($discount, $order->getCustomer())) { - $explanation = Craft::t('commerce', 'This coupon is for registered users and limited to {limit} uses.', [ - 'limit' => $discount->perUserLimit, - ]); - return false; - } - - if (!$this->_isDiscountEmailRequirementValid($discount, $order)) { - $explanation = Craft::t('commerce', 'This coupon requires an email address.'); - return false; - } - - if (!$this->_isDiscountPerEmailLimitValid($discount, $order)) { - $explanation = Craft::t('commerce', 'This coupon is limited to {limit} uses.', [ - 'limit' => $discount->perEmailLimit, - ]); - return false; - } - - return true; - } - - /** - * Returns an enabled discount by its code, regardless of the discount's `requireCouponCode` value. - * - * @throws \Exception - */ - public function getDiscountByCode(?string $code, ?int $storeId = null): ?Discount - { - if ($code === null || $code === '') { - return null; - } - - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - $query = $this->_createDiscountQuery()->where(['storeId' => $storeId]); - $query->innerJoin(Table::COUPONS . ' coupons', '[[coupons.discountId]] = [[discounts.id]]'); - if (Craft::$app->getDb()->getIsPgsql()) { - $query->andWhere(['ilike', '[[coupons.code]]', $code]); - } else { - $query->andWhere(['[[coupons.code]]' => $code]); - } - $discounts = $query->all(); - - if (!$discounts) { - return null; - } - - return ArrayHelper::firstWhere($this->_populateDiscounts($discounts), fn(Discount $discount) => $discount->enabled && - ArrayHelper::contains($discount->getCoupons(), fn(Coupon $coupon) => strcasecmp($coupon->code, $code) === 0)); - } - - /** - * @since 2.2 - */ - public function getDiscountsRelatedToPurchasable(PurchasableInterface $purchasable): array - { - $discounts = []; - - if ($purchasable->getId()) { - // @TODO Optimize this loop on stores with many discounts; the per-discount Category/Entry relatedTo queries make it O(discounts) and can be slow - foreach ($this->getAllDiscounts($purchasable->getStoreId()) as $discount) { - // Get discount by related purchasable - $purchasableIds = $discount->getPurchasableIds(); - $id = $purchasable->getId(); - - // Get discount by related category - $relatedTo = [$discount->categoryRelationshipType => $purchasable->getPromotionRelationSource()]; - $categoryIds = $discount->getCategoryIds(); - $relatedCategories = Category::find()->id($categoryIds)->relatedTo($relatedTo)->ids(); - $relatedEntries = Entry::find()->id($categoryIds)->relatedTo($relatedTo)->ids(); - $relatedCategoriesOrEntries = array_merge($relatedCategories, $relatedEntries); - - if (in_array($id, $purchasableIds, false) || !empty($relatedCategoriesOrEntries)) { - $discounts[$discount->id] = $discount; - } - } - } - - return $discounts; - } - - /** - * Match a line item against a discount. - * - * @throws \Exception - */ - public function matchLineItem(LineItem $lineItem, Discount $discount, bool $matchOrder = false): bool - { - if ($matchOrder && !$this->matchOrder($lineItem->order, $discount)) { - return false; - } - - $siteId = $lineItem->order->orderSiteId ?? Craft::$app->getSites()->getCurrentSite()->id; - - if ($lineItem->getOnPromotion() && $discount->excludeOnPromotion) { - return false; - } - - if (!$lineItem->getIsPromotable()) { - return false; - } - - if ($lineItem->type === LineItemType::Purchasable) { - // can't match something not promotable - /** @var Purchasable|null $purchasable */ - $purchasable = $lineItem->getPurchasable(); - - if (!$discount->allPurchasables && !in_array($purchasable->id, $discount->getPurchasableIds(), false)) { - return false; - } - - // @TODO Rename Discount::$allCategories to $allEntries in Commerce 6.0 to reflect the entryfication (categoryIds may now reference entry IDs) - if (!$discount->allCategories) { - $key = 'relationshipType:' . $discount->categoryRelationshipType . ':purchasableId:' . $purchasable->getId() . ':categoryIds:' . implode('|', $discount->getCategoryIds()); - - if (!isset($this->_matchingLineItemCategoryCondition[$key])) { - $relatedTo = [$discount->categoryRelationshipType => $purchasable->getPromotionRelationSource()]; - - $relatedEntries = Entry::find()->siteId($siteId)->relatedTo($relatedTo)->ids(); - $relatedCategories = Category::find()->siteId($siteId)->relatedTo($relatedTo)->ids(); - - $relatedCategoriesOrEntries = array_merge($relatedEntries, $relatedCategories); - $purchasableIsRelateToOneOrMoreCategories = (bool)array_intersect($relatedCategoriesOrEntries, $discount->getCategoryIds()); - if (!$purchasableIsRelateToOneOrMoreCategories) { - return $this->_matchingLineItemCategoryCondition[$key] = false; - } - $this->_matchingLineItemCategoryCondition[$key] = true; - } elseif ($this->_matchingLineItemCategoryCondition[$key] === false) { - return false; - } - } - } - - $event = new MatchLineItemEvent(compact('lineItem', 'discount')); - - if ($this->hasEventHandlers(self::EVENT_DISCOUNT_MATCHES_LINE_ITEM)) { - $this->trigger(self::EVENT_DISCOUNT_MATCHES_LINE_ITEM, $event); - } - - return $event->isValid; - } - - /** - * @throws \Exception - */ - public function matchOrder(Order $order, Discount $discount): bool - { - if (!$discount->enabled) { - return false; - } - - $allItemsMatch = ($discount->allPurchasables && $discount->allCategories); - - if ($discount->hasOrderCondition() && !$discount->getOrderCondition()->matchElement($order)) { - return false; - } - - if ($discount->hasCustomerCondition() && (!$order->getCustomer() || !$discount->getCustomerCondition()->matchElement($order->getCustomer()))) { - return false; - } - - if ($discount->hasShippingAddressCondition() && (!$order->getShippingAddress() || !$discount->getShippingAddressCondition()->matchElement($order->getShippingAddress()))) { - return false; - } - - if ($discount->hasBillingAddressCondition() && (!$order->getBillingAddress() || !$discount->getBillingAddressCondition()->matchElement($order->getBillingAddress()))) { - return false; - } - - if (!$this->_isDiscountCouponCodeValid($order, $discount)) { - return false; - } - - if (!$this->_isDiscountDateValid($order, $discount)) { - return false; - } - - if (!$this->_isDiscountTotalUseLimitValid($discount)) { - return false; - } - - if (!$this->_isDiscountPerUserUsageValid($discount, $order->getCustomer())) { - return false; - } - - if (!$this->_isDiscountEmailRequirementValid($discount, $order)) { - return false; - } - - if (!$this->_isDiscountPerEmailLimitValid($discount, $order)) { - return false; - } - - if (!$this->_isDiscountConditionFormulaValid($order, $discount)) { - return false; - } - - if ($allItemsMatch && $discount->purchaseTotal > 0 && $order->getItemSubtotal() < $discount->purchaseTotal) { - return false; - } - - if ($allItemsMatch && $discount->purchaseQty > 0 && $order->getTotalQty() < $discount->purchaseQty) { - return false; - } - - if ($allItemsMatch && $discount->maxPurchaseQty > 0 && $order->getTotalQty() > $discount->maxPurchaseQty) { - return false; - } - - // Check to see if we need to match on data related to the lineItems - if (!$discount->allPurchasables || !$discount->allCategories) { - - // Get matching line items but don't match the order again - $matchingItems = collect($order->getLineItems()) - ->filter(fn($item) => $this->matchLineItem($item, $discount)); - - if ($matchingItems->isEmpty()) { - return false; - } - - $matchingQty = $matchingItems->sum('qty'); - $matchingTotal = $matchingItems->sum('subtotal'); - - if ($discount->purchaseTotal > 0 && $matchingTotal < $discount->purchaseTotal) { - return false; - } - - if ($discount->purchaseQty > 0 && $matchingQty < $discount->purchaseQty) { - return false; - } - - if ($discount->maxPurchaseQty > 0 && $matchingQty > $discount->maxPurchaseQty) { - return false; - } - } - - // Raise the 'beforeMatchLineItem' event - $event = new MatchOrderEvent(compact('order', 'discount')); - - if ($this->hasEventHandlers(self::EVENT_DISCOUNT_MATCHES_ORDER)) { - $this->trigger(self::EVENT_DISCOUNT_MATCHES_ORDER, $event); - } - - return $event->isValid; - } - - - /** - * Save a discount. - * - * @param Discount $model the discount being saved - * @param bool $runValidation should we validate this discount before saving. - * @throws \Exception - */ - public function saveDiscount(Discount $model, bool $runValidation = true): bool - { - $isNew = !$model->id; - - if ($model->id) { - $record = DiscountRecord::findOne($model->id); - - if (!$record) { - throw new Exception(Craft::t('commerce', 'No discount exists with the ID “{id}”', ['id' => $model->id])); - } - } else { - $record = new DiscountRecord(); - } - - // Make sure the datetime attributes are populated before firing the event - if (!$isNew) { - $model->dateCreated = DateTimeHelper::toDateTime($record->dateCreated); - $model->dateUpdated = DateTimeHelper::toDateTime($record->dateUpdated); - } - - // Raise the beforeSaveDiscount event - if ($this->hasEventHandlers(self::EVENT_BEFORE_SAVE_DISCOUNT)) { - $this->trigger(self::EVENT_BEFORE_SAVE_DISCOUNT, new DiscountEvent([ - 'discount' => $model, - 'isNew' => $isNew, - ])); - } - - if ($runValidation && !$model->validate()) { - Craft::info('Discount not saved due to validation error.', __METHOD__); - - return false; - } - - $record->storeId = $model->storeId; - $record->name = $model->name; - $record->description = $model->description; - $record->dateFrom = $model->dateFrom; - $record->dateTo = $model->dateTo; - $record->enabled = $model->enabled; - $record->stopProcessing = $model->stopProcessing; - $record->orderCondition = $model->hasOrderCondition() ? $model->getOrderCondition()->getConfig() : null; - $record->customerCondition = $model->hasCustomerCondition() ? $model->getCustomerCondition()->getConfig() : null; - $record->shippingAddressCondition = $model->hasShippingAddressCondition() ? $model->getShippingAddressCondition()->getConfig() : null; - $record->billingAddressCondition = $model->hasBillingAddressCondition() ? $model->getBillingAddressCondition()->getConfig() : null; - $record->requireCouponCode = $model->requireCouponCode; - $record->orderConditionFormula = $model->orderConditionFormula; - $record->purchaseQty = $model->purchaseQty; - $record->maxPurchaseQty = $model->maxPurchaseQty; - $record->baseDiscount = $model->baseDiscount; - $record->purchaseTotal = $model->purchaseTotal; - $record->perItemDiscount = $model->perItemDiscount; - $record->percentDiscount = $model->percentDiscount; - $record->percentageOffSubject = $model->percentageOffSubject; - $record->hasFreeShippingForMatchingItems = $model->hasFreeShippingForMatchingItems; - $record->hasFreeShippingForOrder = $model->hasFreeShippingForOrder; - $record->excludeOnPromotion = $model->excludeOnPromotion; - $record->perUserLimit = $model->perUserLimit; - $record->perEmailLimit = $model->perEmailLimit; - $record->totalDiscountUseLimit = $model->totalDiscountUseLimit; - $record->ignorePromotions = $model->ignorePromotions; - $record->appliedTo = $model->appliedTo; - $record->purchasableIds = $model->getPurchasableIds(); - $record->categoryIds = $model->getCategoryIds(); - - // If the discount is new, set the sort order to be at the top of the list. - // We will ensure the sort orders are sequential when we save the discount. - $sortOrder = $record->sortOrder ?: 0; - - $record->sortOrder = $sortOrder; - $record->couponFormat = $model->couponFormat; - - $record->categoryRelationshipType = $model->categoryRelationshipType; - if ($record->allCategories = $model->allCategories) { - $model->setCategoryIds([]); - $record->categoryIds = null; - } - if ($record->allPurchasables = $model->allPurchasables) { - $model->setPurchasableIds([]); - $record->purchasableIds = null; - } - - $db = Craft::$app->getDb(); - $transaction = $db->beginTransaction(); - - try { - $record->save(false); - $model->id = $record->id; - - // Update datetime attributes after save - $model->dateCreated = DateTimeHelper::toDateTime($record->dateCreated); - $model->dateUpdated = DateTimeHelper::toDateTime($record->dateUpdated); - - DiscountPurchasableRecord::deleteAll(['discountId' => $model->id]); - DiscountCategoryRecord::deleteAll(['discountId' => $model->id]); - - $siteIds = $model->getStore()->getSites()->pluck('id')->all(); - - foreach ($model->getCategoryIds() as $categoryId) { - $relation = new DiscountCategoryRecord(); - $relation->categoryId = $categoryId; - $relation->discountId = $model->id; - $relation->save(false); - } - - foreach ($model->getPurchasableIds() as $purchasableId) { - $relation = new DiscountPurchasableRecord(); - $element = Craft::$app->getElements()->getElementById($purchasableId, siteId: $siteIds); - $relation->purchasableType = $element::class; - $relation->purchasableId = $purchasableId; - $relation->discountId = $model->id; - $relation->save(false); - } - - Plugin::getInstance()->getCoupons()->saveDiscountCoupons($model); - $transaction->commit(); - - // After saving the discount, ensure the sort order for all discounts is sequential - $this->ensureSortOrder($model->storeId); - - // Raise the afterSaveDiscount event - if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_DISCOUNT)) { - $this->trigger(self::EVENT_AFTER_SAVE_DISCOUNT, new DiscountEvent([ - 'discount' => $model, - 'isNew' => $isNew, - ])); - } - - // Reset internal cache - $this->_allDiscounts = null; - $this->_activeDiscountsByKey = null; - $this->_matchingLineItemCategoryCondition = null; - - return true; - } catch (\Exception $e) { - $transaction->rollBack(); - throw $e; - } - } - - /** - * Delete a discount by its ID. - * - * @throws Throwable - * @throws StaleObjectException - */ - public function deleteDiscountById(int $id): bool - { - $discountRecord = DiscountRecord::findOne($id); - - if (!$discountRecord) { - return false; - } - - // Get the Discount model before deletion to pass to the Event. - $discount = $this->getDiscountById($id, $discountRecord->storeId); - $storeId = $discount->storeId; - - $result = (bool)$discountRecord->delete(); - - //Raise the afterDeleteDiscount event - if ($result) { - // Ensure discount table sort order - $this->ensureSortOrder($storeId); - - if ($this->hasEventHandlers(self::EVENT_AFTER_DELETE_DISCOUNT)) { - $this->trigger(self::EVENT_AFTER_DELETE_DISCOUNT, new DiscountEvent([ - 'discount' => $discount, - 'isNew' => false, - ])); - } - } - - // Reset internal cache - $this->_allDiscounts = null; - $this->_activeDiscountsByKey = null; - $this->_matchingLineItemCategoryCondition = null; - - return $result; - } - - /** - * @return void - * @throws \yii\db\Exception - * @since 4.4.0 - */ - public function ensureSortOrder(?int $storeId = null): void - { - // @TODO Iterate over all stores when no storeId is passed, so sort order is normalized per-store rather than only for the current store - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - $table = Table::DISCOUNTS; - - $isPsql = Craft::$app->getDb()->getIsPgsql(); - - // Make all discount uses with their correct user - if ($isPsql) { - $sql = <<getDb()->createCommand($sql)->execute(); - - // Reset internal cache - $this->_allDiscounts = null; - $this->_activeDiscountsByKey = null; - } - - /** - * @throws \yii\db\Exception - * @since 4.0 - */ - public function clearCustomerUsageHistoryById(int $id): void - { - $db = Craft::$app->getDb(); - - $db->createCommand() - ->delete(Table::CUSTOMER_DISCOUNTUSES, ['discountId' => $id]) - ->execute(); - - // Reset internal cache - $this->_allDiscounts = null; - $this->_activeDiscountsByKey = null; - } - - /** - * @throws \yii\db\Exception - * @since 3.0 - */ - public function clearEmailUsageHistoryById(int $id): void - { - $db = Craft::$app->getDb(); - - $db->createCommand() - ->delete(Table::EMAIL_DISCOUNTUSES, ['discountId' => $id]) - ->execute(); - - // Reset internal cache - $this->_allDiscounts = null; - $this->_activeDiscountsByKey = null; - } - - /** - * Clear total discount uses - * - * @throws \yii\db\Exception - * @since 3.0 - */ - public function clearDiscountUsesById(int $id): void - { - $db = Craft::$app->getDb(); - $db->createCommand() - ->update(Table::DISCOUNTS, ['totalDiscountUses' => 0], ['id' => $id]) - ->execute(); - - // Reset internal cache - $this->_allDiscounts = null; - $this->_activeDiscountsByKey = null; - } - - /** - * Reorder discounts by an array of ids. - * - * @throws \yii\db\Exception - */ - public function reorderDiscounts(array $ids): bool - { - foreach ($ids as $sortOrder => $id) { - Craft::$app->getDb()->createCommand() - ->update(Table::DISCOUNTS, ['sortOrder' => $sortOrder + 1], ['id' => $id]) - ->execute(); - } - - // Reset internal cache - $this->_allDiscounts = null; - $this->_activeDiscountsByKey = null; - - return true; - } - - /** - * Appends a coupon code to an existing discount. - * - * @param int $discountId The discount ID - * @param string|Coupon $coupon The coupon code to append or a Coupon model - * @param int|null $maxUses The maximum number of times this coupon can be used (null for unlimited) - only used if $coupon is a string - * @return bool Whether the coupon was successfully added - * @throws Exception if the discount doesn't exist or doesn't require a coupon code - * @throws InvalidConfigException - */ - public function appendCouponCode(int $discountId, string|Coupon $coupon, ?int $maxUses = null): bool - { - $discount = $this->getDiscountById($discountId); - - if (!$discount) { - throw new Exception('No discount exists with the ID "' . $discountId . '"'); - } - - if (!$discount->requireCouponCode) { - throw new Exception('The discount with ID "' . $discountId . '" does not require a coupon code'); - } - - // If a string was passed, create a new coupon model - if (is_string($coupon)) { - $couponModel = new Coupon(); - $couponModel->discountId = $discountId; - $couponModel->code = $coupon; - $couponModel->maxUses = $maxUses; - $couponModel->uses = 0; - } else { - // Use the provided coupon model - $couponModel = $coupon; - $couponModel->discountId = $discountId; - } - - // Save the coupon - $result = Plugin::getInstance()->getCoupons()->saveCoupon($couponModel); - - if ($result) { - // Reset internal cache - $this->_allDiscounts = null; - $this->_activeDiscountsByKey = null; - } - - return $result; - } - - /** - * Email usage stats for discount - * - * @return array return in the format ['uses' => int, 'emails' => int] - */ - public function getEmailUsageStatsById(int $id): array - { - return (new Query()) - ->select(['COALESCE(SUM(uses), 0) as uses', 'COUNT(email) as emails']) - ->from(Table::EMAIL_DISCOUNTUSES) - ->where(['discountId' => $id]) - ->one(); - } - - /** - * User usage stats for discount - * - * @param int $id - * @return array in the format ['uses' => int, 'users' => int] - */ - public function getCustomerUsageStatsById(int $id): array - { - return (new Query()) - ->select(['COALESCE(SUM(uses), 0) as uses', 'COUNT([[customerId]]) as users']) - ->from(Table::CUSTOMER_DISCOUNTUSES) - ->where(['[[discountId]]' => $id]) - ->one(); - } - - /** - * Updates discount uses counters. - * - * @throws \yii\db\Exception - */ - public function orderCompleteHandler(Order $order): void - { - $discountAdjustments = $order->getAdjustmentsByType(DiscountAdjuster::ADJUSTMENT_TYPE); - - if (empty($discountAdjustments)) { - return; - } - - /* We only need to make counter updates once for each discount. A discount - might be returned multiple times due to it being a lineItem adjustment */ - $discounts = []; - /** @var OrderAdjustment $discountAdjustment */ - foreach ($discountAdjustments as $discountAdjustment) { - $snapshot = $discountAdjustment->sourceSnapshot ?? null; - if (!$snapshot || !isset($snapshot['discountUseId']) || isset($discounts[$snapshot['discountUseId']])) { - continue; - } - - $discounts[$snapshot['discountUseId']] = $snapshot; - } - - if (empty($discounts)) { - return; - } - - $user = $order->getCustomer(); - foreach ($discounts as $discount) { - // Count if there was a user on this order that has authentication - if ($user && $user->getIsCredentialed()) { - $userDiscountUseRecord = CustomerDiscountUse::find()->where(['customerId' => $user->id, 'discountId' => $discount['discountUseId']])->one(); - - if (!$userDiscountUseRecord) { - $userDiscountUseRecord = Craft::createObject(CustomerDiscountUse::class); - Craft::configure($userDiscountUseRecord, [ - 'customerId' => $user->id, - 'discountId' => $discount['discountUseId'], - 'uses' => 1, - ]); - $userDiscountUseRecord->save(); - } else { - Craft::$app->getDb()->createCommand() - ->update(Table::CUSTOMER_DISCOUNTUSES, [ - 'uses' => new Expression('[[uses]] + 1'), - ], [ - 'customerId' => $order->getCustomerId(), - 'discountId' => $discount['discountUseId'], - ]) - ->execute(); - } - } - - // Count email usage - $emailDiscountUseRecord = EmailDiscountUseRecord::find()->where(['email' => $order->getEmail(), 'discountId' => $discount['discountUseId']])->one(); - if (!$emailDiscountUseRecord) { - $emailDiscountUseRecord = new EmailDiscountUseRecord(); - $emailDiscountUseRecord->email = $order->getEmail(); - $emailDiscountUseRecord->discountId = $discount['discountUseId']; - $emailDiscountUseRecord->uses = 1; - $emailDiscountUseRecord->save(); - } else { - Craft::$app->getDb()->createCommand() - ->update(Table::EMAIL_DISCOUNTUSES, [ - 'uses' => new Expression('[[uses]] + 1'), - ], [ - 'email' => $order->getEmail(), - 'discountId' => $discount['discountUseId'], - ]) - ->execute(); - } - - // Update the total uses - Craft::$app->getDb()->createCommand() - ->update(Table::DISCOUNTS, [ - 'totalDiscountUses' => new Expression('[[totalDiscountUses]] + 1'), - ], [ - 'id' => $discount['discountUseId'], - ]) - ->execute(); - - // Check if the total use limit has been exceeded (race condition / oversell scenario) - if (($discount['totalDiscountUseLimit'] ?? 0) > 0) { - $updatedUses = (new Query()) - ->select(['totalDiscountUses']) - ->from([Table::DISCOUNTS]) - ->where(['id' => $discount['discountUseId']]) - ->scalar(); - if ($updatedUses > $discount['totalDiscountUseLimit']) { - $notice = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => [ - 'type' => 'discountUsageExceeded', - 'attribute' => 'couponCode', - 'message' => Craft::t('commerce', 'The discount "{name}" has exceeded its total usage limit of {limit}.', [ - 'name' => $discount['name'] ?? $discount['discountUseId'], - 'limit' => $discount['totalDiscountUseLimit'], - ]), - 'noticeType' => OrderNoticeType::Admin, - ], - ]); - $order->addNotice($notice); - } - } - - // if there was a coupon on the order update its usage - if ($order->couponCode && $coupon = CouponRecord::findOne(['code' => $order->couponCode, 'discountId' => $discount['discountUseId']])) { - Craft::$app->getDb()->createCommand() - ->update(Table::COUPONS, [ - 'uses' => new Expression('[[uses]] + 1'), - ], [ - 'id' => $coupon->id, - ]) - ->execute(); - - // Check if the coupon's max uses has been exceeded - if ($coupon->maxUses !== null && ($coupon->uses + 1) > $coupon->maxUses) { - $notice = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => [ - 'type' => 'couponUsageExceeded', - 'attribute' => 'couponCode', - 'message' => Craft::t('commerce', 'The coupon "{code}" has exceeded its usage limit of {limit}.', [ - 'code' => $order->couponCode, - 'limit' => $coupon->maxUses, - ]), - 'noticeType' => OrderNoticeType::Admin, - ], - ]); - $order->addNotice($notice); - } - } - - // Reset internal cache - $this->_allDiscounts = null; - $this->_activeDiscountsByKey = null; - } - } - - - /** - * @param Order $order - * @param Discount $discount - * @return bool - * @throws InvalidConfigException - */ - private function _isDiscountCouponCodeValid(Order $order, Discount $discount): bool - { - // If the discount does not require a coupon code, it's valid - if (!$discount->requireCouponCode) { - return true; - } - - $coupons = $discount->getCoupons(); - // Protect against empty coupon code list if the discount requires a coupon code - if (empty($coupons)) { - return false; - } - - $return = ArrayHelper::firstWhere($coupons, static fn(Coupon $coupon) => (strcasecmp($coupon->code, $order->couponCode) == 0) && ($coupon->maxUses === null || $coupon->maxUses > $coupon->uses)); - return (bool)$return; - } - - /** - * @throws \Exception - */ - private function _isDiscountDateValid(Order $order, Discount $discount): bool - { - $now = new DateTime(); - - if ($order->isCompleted && $order->dateOrdered) { - $now = $order->dateOrdered; - } - - $from = $discount->dateFrom; - $to = $discount->dateTo; - - return !(($from && $from > $now) || ($to && $to < $now)); - } - - /** - * @throws InvalidConfigException - * @throws LoaderError - * @throws SyntaxError - */ - private function _isDiscountConditionFormulaValid(Order $order, Discount $discount): bool - { - if ($discount->orderConditionFormula) { - $fieldsAsArray = $order->getSerializedFieldValues(); - $orderAsArray = $order->toArray([], ['lineItems.snapshot', 'shippingAddress', 'billingAddress']); - $orderConditionParams = [ - 'order' => array_merge($orderAsArray, $fieldsAsArray), - ]; - return Plugin::getInstance()->getFormulas()->evaluateCondition($discount->orderConditionFormula, $orderConditionParams, 'Evaluate Order Discount Condition Formula'); - } - - return true; - } - - private function _isDiscountTotalUseLimitValid(Discount $discount): bool - { - if ($discount->totalDiscountUseLimit > 0) { - if ($discount->totalDiscountUses >= $discount->totalDiscountUseLimit) { - return false; - } - } - - return true; - } - - /** - * @param Discount $discount - * @param User|null $user - * @return bool - */ - private function _isDiscountPerUserUsageValid(Discount $discount, ?User $user): bool - { - if ($discount->perUserLimit > 0) { - if (!$user) { - return false; - } - - if (Craft::$app->getRequest()->getIsSiteRequest()) { - $currentUser = Craft::$app->getUser()->getIdentity(); - $isCustomerCurrentUser = ($currentUser && $currentUser->id == $user->id); - - if (!$isCustomerCurrentUser) { - return false; - } - } - - $usage = (new Query()) - ->select(['uses']) - ->from([Table::CUSTOMER_DISCOUNTUSES]) - ->where(['[[customerId]]' => $user->id, 'discountId' => $discount->id]) - ->scalar(); - - if ($usage && $usage >= $discount->perUserLimit) { - return false; - } - } - - return true; - } - - private function _isDiscountEmailRequirementValid(Discount $discount, Order $order): bool - { - if ($discount->perEmailLimit > 0 && !$order->getEmail()) { - return false; - } - - return true; - } - - private function _isDiscountPerEmailLimitValid(Discount $discount, Order $order): bool - { - if ($discount->perEmailLimit > 0 && $order->getEmail()) { - $usage = (new Query()) - ->select(['uses']) - ->from([Table::EMAIL_DISCOUNTUSES]) - ->where(['email' => $order->getEmail(), 'discountId' => $discount->id]) - ->scalar(); - - if ($usage && $usage >= $discount->perEmailLimit) { - return false; - } - } - - return true; - } - - /** - * @param array $discounts - * @return array - * @throws InvalidConfigException - * @since 2.2.14 - */ - private function _populateDiscounts(array $discounts): array - { - foreach ($discounts as &$discount) { - // @TODO Remove this manual JSON decoding / default-value massaging once the Discount setters accept raw DB values (JSON strings, nulls) directly - - $discount['purchasableIds'] = !empty($discount['purchasableIds']) ? Json::decodeIfJson($discount['purchasableIds'], true) : []; - // IDs can be either category ID or entry ID due to the entryfication - $discount['categoryIds'] = !empty($discount['categoryIds']) ? Json::decodeIfJson($discount['categoryIds'], true) : []; - $discount['orderCondition'] ??= ''; - $discount['customerCondition'] ??= ''; - $discount['billingAddressCondition'] ??= ''; - $discount['shippingAddressCondition'] ??= ''; - - $discount = Craft::createObject([ - 'class' => Discount::class, - 'attributes' => $discount, - ]); - } - - return $discounts; - } - - /** - * Returns a Query object prepped for retrieving discounts - */ - private function _createDiscountQuery(): Query - { - $query = (new Query()) - ->select([ - '[[discounts.allCategories]]', - '[[discounts.allPurchasables]]', - '[[discounts.appliedTo]]', - '[[discounts.baseDiscount]]', - '[[discounts.categoryRelationshipType]]', - '[[discounts.couponFormat]]', - '[[discounts.dateCreated]]', - '[[discounts.dateFrom]]', - '[[discounts.dateTo]]', - '[[discounts.dateUpdated]]', - '[[discounts.description]]', - '[[discounts.enabled]]', - '[[discounts.excludeOnPromotion]]', - '[[discounts.hasFreeShippingForMatchingItems]]', - '[[discounts.hasFreeShippingForOrder]]', - '[[discounts.id]]', - '[[discounts.ignorePromotions]]', - '[[discounts.maxPurchaseQty]]', - '[[discounts.name]]', - '[[discounts.orderCondition]]', - '[[discounts.orderConditionFormula]]', - '[[discounts.percentageOffSubject]]', - '[[discounts.percentDiscount]]', - '[[discounts.perEmailLimit]]', - '[[discounts.perItemDiscount]]', - '[[discounts.perUserLimit]]', - '[[discounts.purchaseTotal]]', - '[[discounts.purchaseQty]]', - '[[discounts.requireCouponCode]]', - '[[discounts.sortOrder]]', - '[[discounts.stopProcessing]]', - '[[discounts.storeId]]', - '[[discounts.totalDiscountUseLimit]]', - '[[discounts.totalDiscountUses]]', - '[[discounts.customerCondition]]', - '[[discounts.shippingAddressCondition]]', - '[[discounts.billingAddressCondition]]', - '[[discounts.purchasableIds]]', - '[[discounts.categoryIds]]', - ]) - ->from(['discounts' => Table::DISCOUNTS]) - ->orderBy(['sortOrder' => SORT_ASC]) - ->leftJoin(Table::DISCOUNT_PURCHASABLES . ' dp', '[[dp.discountId]]=[[discounts.id]]') - ->leftJoin(Table::DISCOUNT_CATEGORIES . ' dpt', '[[dpt.discountId]]=[[discounts.id]]') - ->groupBy(['discounts.id']); - - return $query; - } -} diff --git a/src/services/Emails.php b/src/services/Emails.php deleted file mode 100644 index 46452bd49c..0000000000 --- a/src/services/Emails.php +++ /dev/null @@ -1,1007 +0,0 @@ - - * @since 2.0 - */ -class Emails extends Component -{ - /** - * @event MailEvent The event that is raised before an email is sent. - * You may set [[MailEvent::isValid]] to `false` to prevent the email from being sent. - * - * Plugins can get notified before an email is being sent out. - * - * ```php - * use craft\commerce\events\MailEvent; - * use craft\commerce\services\Emails; - * use yii\base\Event; - * - * Event::on( - * Emails::class, - * Emails::EVENT_BEFORE_SEND_MAIL, - * function(MailEvent $event) { - * // @var Message $message - * $message = $event->craftEmail; - * // @var Email $email - * $email = $event->commerceEmail; - * // @var Order $order - * $order = $event->order; - * // @var OrderHistory $history - * $history = $event->orderHistory; - * - * // Use `$event->isValid = false` to prevent sending - * // based on some business rules or client preferences - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_SEND_MAIL = 'beforeSendEmail'; - - /** - * @event MailEvent The event that is raised after an email is sent - * - * Plugins can get notified after an email has been sent out. - * - * ```php - * use craft\commerce\events\MailEvent; - * use craft\commerce\services\Emails; - * use yii\base\Event; - * - * Event::on( - * Emails::class, - * Emails::EVENT_AFTER_SEND_MAIL, - * function(MailEvent $event) { - * // @var Message $message - * $message = $event->craftEmail; - * // @var Email $email - * $email = $event->commerceEmail; - * // @var Order $order - * $order = $event->order; - * // @var OrderHistory $history - * $history = $event->orderHistory; - * - * // Add the email address to an external CRM - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_SEND_MAIL = 'afterSendEmail'; - - /** - * @event EmailEvent The event that is triggered before an email is saved. - * - * ```php - * use craft\commerce\events\EmailEvent; - * use craft\commerce\services\Emails; - * use craft\commerce\models\Email; - * use yii\base\Event; - * - * Event::on( - * Emails::class, - * Emails::EVENT_BEFORE_SAVE_EMAIL, - * function(EmailEvent $event) { - * // @var Email $email - * $email = $event->email; - * // @var bool $isNew - * $isNew = $event->isNew; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_SAVE_EMAIL = 'beforeSaveEmail'; - - /** - * @event EmailEvent The event that is triggered after an email is saved. - * - * ```php - * use craft\commerce\events\EmailEvent; - * use craft\commerce\services\Emails; - * use craft\commerce\models\Email; - * use yii\base\Event; - * - * Event::on( - * Emails::class, - * Emails::EVENT_AFTER_SAVE_EMAIL, - * function(EmailEvent $event) { - * // @var Email $email - * $email = $event->email; - * // @var bool $isNew - * $isNew = $event->isNew; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_SAVE_EMAIL = 'afterSaveEmail'; - - /** - * @event EmailEvent The event that is triggered before an email is deleted. - * - * ```php - * use craft\commerce\events\EmailEvent; - * use craft\commerce\services\Emails; - * use craft\commerce\models\Email; - * use yii\base\Event; - * - * Event::on( - * Emails::class, - * Emails::EVENT_BEFORE_DELETE_EMAIL, - * function(EmailEvent $event) { - * // @var Email $email - * $email = $event->email; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_DELETE_EMAIL = 'beforeDeleteEmail'; - - /** - * @event EmailEvent The event that is triggered after an email is deleted. - * ```php - * use craft\commerce\events\EmailEvent; - * use craft\commerce\services\Emails; - * use craft\commerce\models\Email; - * use yii\base\Event; - * - * Event::on( - * Emails::class, - * Emails::EVENT_AFTER_DELETE_EMAIL, - * function(EmailEvent $event) { - * // @var Email $email - * $email = $event->email; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_DELETE_EMAIL = 'afterDeleteEmail'; - - public const CONFIG_EMAILS_KEY = 'commerce.emails'; - - /** - * @var Collection[]|null - * @since 5.0.0 - */ - private ?array $_allEmails = null; - - /** - * Get an email by its ID. - */ - public function getEmailById(int $id, ?int $storeId = null): ?Email - { - return $this->getAllEmails($storeId)->firstWhere('id', $id); - } - - /** - * Get all emails. - * - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - * @throws SiteNotFoundException - */ - public function getAllEmails(?int $storeId = null): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if ($this->_allEmails === null || !isset($this->_allEmails[$storeId])) { - $results = $this->_createEmailQuery() - ->where(['storeId' => $storeId]) - ->all(); - - // Start with a blank slate if it isn't memoized - if ($this->_allEmails === null) { - $this->_allEmails = []; - } - - foreach ($results as $result) { - $email = Craft::createObject([ - 'class' => Email::class, - 'attributes' => $result, - ]); - - if (!isset($this->_allEmails[$email->storeId])) { - $this->_allEmails[$email->storeId] = collect(); - } - - $this->_allEmails[$email->storeId]->push($email); - } - } - - if (!isset($this->_allEmails[$storeId])) { - return collect(); - } - - return $this->_allEmails[$storeId]; - } - - /** - * Get all emails that are enabled. - * - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - * @throws SiteNotFoundException - */ - public function getAllEnabledEmails(?int $storeId = null): Collection - { - return $this->getAllEmails($storeId)->where('enabled', true); - } - - /** - * Save an email. - * - * @throws Exception - * @throws ErrorException - * @throws NotSupportedException - * @throws ServerErrorHttpException - */ - public function saveEmail(Email $email, bool $runValidation = true): bool - { - $isNewEmail = !(bool)$email->id; - - // Fire a 'beforeSaveEmail' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_SAVE_EMAIL)) { - $this->trigger(self::EVENT_BEFORE_SAVE_EMAIL, new EmailEvent([ - 'email' => $email, - 'isNew' => $isNewEmail, - ])); - } - - if ($runValidation && !$email->validate()) { - Craft::info('Email not saved due to validation error(s).', __METHOD__); - return false; - } - - if ($isNewEmail) { - $email->uid = StringHelper::UUID(); - } - - $configPath = self::CONFIG_EMAILS_KEY . '.' . $email->uid; - $configData = $email->getConfig(); - Craft::$app->getProjectConfig()->set($configPath, $configData); - - if ($isNewEmail) { - $email->id = Db::idByUid(Table::EMAILS, $email->uid); - } - - return true; - } - - - /** - * Handle email status change. - * - * @throws Throwable if reasons - */ - public function handleChangedEmail(ConfigEvent $event): void - { - ProjectConfigData::ensureAllStoresProcessed(); - - $emailUid = $event->tokenMatches[0]; - $data = $event->newValue; - - $pdfUid = $data['pdf'] ?? null; - if ($pdfUid) { - Craft::$app->getProjectConfig()->processConfigChanges(Pdfs::CONFIG_PDFS_KEY . '.' . $pdfUid); - } - - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - $emailRecord = $this->_getEmailRecord($emailUid); - $isNewEmail = $emailRecord->getIsNewRecord(); - $store = Plugin::getInstance()->getStores()->getStoreByUid($data['store']); - $renderSite = array_key_exists('renderSite', $data) && $data['renderSite'] !== null ? Craft::$app->getSites()->getSiteByUid($data['renderSite']) : null; - - $emailRecord->storeId = $store->id; - $emailRecord->name = $data['name']; - $emailRecord->subject = $data['subject']; - $emailRecord->recipientType = $data['recipientType']; - $emailRecord->to = $data['to']; - $emailRecord->bcc = $data['bcc']; - $emailRecord->cc = $data['cc'] ?? null; - $emailRecord->replyTo = $data['replyTo'] ?? null; - $emailRecord->enabled = $data['enabled']; - $emailRecord->senderAddress = $data['senderAddress']; - $emailRecord->senderName = $data['senderName']; - $emailRecord->templatePath = $data['templatePath']; - $emailRecord->plainTextTemplatePath = $data['plainTextTemplatePath'] ?? null; - $emailRecord->uid = $emailUid; - $emailRecord->pdfId = $pdfUid ? Db::idByUid(Table::PDFS, $pdfUid) : null; - $emailRecord->language = $data['language'] ?? EmailRecord::LOCALE_ORDER_LANGUAGE; - $emailRecord->renderSiteId = $renderSite?->id ?? null; - - $emailRecord->save(false); - - $transaction->commit(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - - // Fire a 'afterSaveEmail' event - if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_EMAIL)) { - $this->trigger(self::EVENT_AFTER_SAVE_EMAIL, new EmailEvent([ - 'email' => $this->getEmailById($emailRecord->id, $emailRecord->storeId), - 'isNew' => $isNewEmail, - ])); - } - - $this->clearCache(); - } - - /** - * Delete an email by its ID. - */ - public function deleteEmailById(int $id): bool - { - $email = EmailRecord::findOne($id); - - if ($email) { - // Fire a 'beforeDeleteEmail' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_DELETE_EMAIL)) { - $this->trigger(self::EVENT_BEFORE_DELETE_EMAIL, new EmailEvent([ - 'email' => $this->getEmailById($id, $email->storeId), - ])); - } - - Craft::$app->getProjectConfig()->remove(self::CONFIG_EMAILS_KEY . '.' . $email->uid); - } - - return true; - } - - /** - * Handle email getting deleted. - * - * @throws Throwable - * @throws StaleObjectException - */ - public function handleDeletedEmail(ConfigEvent $event): void - { - $uid = $event->tokenMatches[0]; - $emailRecord = $this->_getEmailRecord($uid); - - if (!$emailRecord->id) { - return; - } - - $email = $this->getEmailById($emailRecord->id, $emailRecord->storeId); - $emailRecord->delete(); - - // Fire a 'beforeDeleteEmail' event - if ($this->hasEventHandlers(self::EVENT_AFTER_DELETE_EMAIL)) { - $this->trigger(self::EVENT_AFTER_DELETE_EMAIL, new EmailEvent([ - 'email' => $email, - ])); - } - - $this->clearCache(); - } - - /** - * Send a commerce email. - * - * @param array|null $orderData Since the order may have changed by the time the email sends. - * @param string $error The reason this method failed. - * @return bool $result - * @throws Exception - * @throws Throwable - * @throws InvalidConfigException - */ - public function sendEmail(Email $email, Order $order, ?OrderHistory $orderHistory = null, ?array $orderData = null, string &$error = ''): bool - { - if (!$email->enabled) { - $error = Craft::t('commerce', 'Email is not enabled.'); - return false; - } - - if ($email->storeId !== $order->getStore()->id) { - $error = Craft::t('commerce', 'Email unavailable.'); - return false; - } - - // Set Craft to the site template mode - $view = Craft::$app->getView(); - $oldTemplateMode = $view->getTemplateMode(); - $view->setTemplateMode($view::TEMPLATE_MODE_SITE); - $option = 'email'; - $generalConfig = Craft::$app->getConfig()->getGeneral(); - // Temporarily disable lazy transform generation - $generateTransformsBeforePageLoad = $generalConfig->generateTransformsBeforePageLoad; - $generalConfig->generateTransformsBeforePageLoad = true; - - // Make sure date vars are in the correct format - $dateFields = ['dateOrdered', 'datePaid', 'dateFirstPaid']; - foreach ($dateFields as $dateField) { - if (isset($order->{$dateField}) && !($order->{$dateField} instanceof DateTime) && $order->{$dateField}) { - $order->{$dateField} = DateTimeHelper::toDateTime($order->{$dateField}); - } - } - - //sending emails - $renderVariables = compact('order', 'orderHistory', 'option', 'orderData'); - - $mailer = Craft::$app->getMailer(); - /** @var Message $newEmail */ - $newEmail = Craft::createObject(['class' => $mailer->messageClass, 'mailer' => $mailer]); - - $originalLanguage = Craft::$app->language; - $originalFormattingLanguage = Craft::$app->formattingLocale; - $emailLanguage = $email->getRenderLanguage($order); - $emailSite = $email->getRenderSite($order); - - Locale::switchAppLanguage($emailLanguage); - - $fromEmail = $email->getSenderAddress(); - $fromName = $email->getSenderName(); - - if ($fromEmail) { - $newEmail->setFrom($fromEmail); - } - - if ($fromName && $fromEmail) { - $newEmail->setFrom([$fromEmail => $fromName]); - } - - if ($email->recipientType == EmailRecord::TYPE_CUSTOMER) { - if ($order->getCustomer()) { - $newEmail->setTo($order->getEmail()); - } - } - - if ($email->recipientType == EmailRecord::TYPE_CUSTOM) { - // To: - try { - $emails = $view->renderSandboxedString($email->getTo(), $renderVariables); - $emails = preg_split('/[\s,]+/', $emails); - - $newEmail->setTo($emails); - } catch (\Exception $e) { - Craft::$app->getErrorHandler()->logException($e); - - $error = Craft::t('commerce', 'Email template parse error for custom email “{email}” in “To:”. Order: “{order}”. Template error: “{message}” {file}:{line}', [ - 'email' => $email->name, - 'order' => $order->getShortNumber(), - 'message' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - Craft::error($error, __METHOD__); - - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - } - - if (!$newEmail->getTo()) { - $error = Craft::t('commerce', 'Email error. No email address found for order. Order: “{order}”', ['order' => $order->getShortNumber()]); - Craft::error($error, __METHOD__); - - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - - // BCC: - if ($bccSetting = $email->getBcc()) { - try { - $bcc = $view->renderSandboxedString($bccSetting, $renderVariables); - $bcc = str_replace(';', ',', $bcc); - $bcc = preg_split('/[\s,]+/', $bcc); - - if (array_filter($bcc)) { - $newEmail->setBcc($bcc); - } - } catch (\Exception $e) { - Craft::$app->getErrorHandler()->logException($e); - - $error = Craft::t('commerce', 'Email template parse error for email “{email}” in “BCC:”. Order: “{order}”. Template error: “{message}” {file}:{line}', [ - 'email' => $email->name, - 'order' => $order->getShortNumber(), - 'message' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - Craft::error($error, __METHOD__); - - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - } - - // CC: - if ($ccSetting = $email->getCc()) { - try { - $cc = $view->renderSandboxedString($ccSetting, $renderVariables); - $cc = str_replace(';', ',', $cc); - $cc = preg_split('/[\s,]+/', $cc); - - if (array_filter($cc)) { - $newEmail->setCc($cc); - } - } catch (\Exception $e) { - Craft::$app->getErrorHandler()->logException($e); - - $error = Craft::t('commerce', 'Email template parse error for email “{email}” in “CC:”. Order: “{order}”. Template error: “{message}” {file}:{line}', [ - 'email' => $email->name, - 'order' => $order->getShortNumber(), - 'message' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - Craft::error($error, __METHOD__); - - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - } - - if ($email->replyTo) { - // Reply To: - try { - $newEmail->setReplyTo($view->renderSandboxedString($email->replyTo, $renderVariables)); - } catch (\Exception $e) { - Craft::$app->getErrorHandler()->logException($e); - - $error = Craft::t('commerce', 'Email template parse error for email “{email}” in “ReplyTo:”. Order: “{order}”. Template error: “{message}” {file}:{line}', [ - 'email' => $email->name, - 'order' => $order->getShortNumber(), - 'message' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - Craft::error($error, __METHOD__); - - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - } - - // Subject: - try { - $newEmail->setSubject($view->renderSandboxedString($email->subject, $renderVariables)); - } catch (\Exception $e) { - Craft::$app->getErrorHandler()->logException($e); - - $error = Craft::t('commerce', 'Email template parse error for email “{email}” in “Subject:”. Order: “{order}”. Template error: “{message}” {file}:{line}', [ - 'email' => $email->name, - 'order' => $order->getShortNumber(), - 'message' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - Craft::error($error, __METHOD__); - - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - - // Template Path - try { - $templatePath = $view->renderSandboxedString($email->templatePath, $renderVariables); - } catch (\Exception $e) { - Craft::$app->getErrorHandler()->logException($e); - - $error = Craft::t('commerce', 'Email template path parse error for email “{email}” in “Template Path”. Order: “{order}”. Template error: “{message}” {file}:{line}', [ - 'email' => $email->name, - 'order' => $order->getShortNumber(), - 'message' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - Craft::error($error, __METHOD__); - - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - - // Email Body - if (!$view->doesTemplateExist($templatePath)) { - $error = Craft::t('commerce', 'Email template does not exist at “{templatePath}” which resulted in “{templateParsedPath}” for email “{email}”. Order: “{order}”.', [ - 'templatePath' => $email->templatePath, - 'templateParsedPath' => $templatePath, - 'email' => $email->name, - 'order' => $order->getShortNumber(), - ]); - Craft::error($error, __METHOD__); - - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - // Plain Text Template Path - $plainTextTemplatePath = null; - - if ($email->plainTextTemplatePath) { - try { - $plainTextTemplatePath = $view->renderSandboxedString($email->plainTextTemplatePath, $renderVariables); - } catch (\Exception $e) { - Craft::$app->getErrorHandler()->logException($e); - - $error = Craft::t('commerce', 'Email plain text template path parse error for email “{email}” in “Template Path”. Order: “{order}”. Template error: “{message}” {file}:{line}', [ - 'email' => $email->name, - 'order' => $order->getShortNumber(), - 'message' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - Craft::error($error, __METHOD__); - - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - - // Plain Text Body - if ($plainTextTemplatePath && !$view->doesTemplateExist($plainTextTemplatePath)) { - $error = Craft::t('commerce', 'Email plain text template does not exist at “{templatePath}” which resulted in “{templateParsedPath}” for email “{email}”. Order: “{order}”.', [ - 'templatePath' => $email->plainTextTemplatePath, - 'templateParsedPath' => $plainTextTemplatePath, - 'email' => $email->name, - 'order' => $order->getShortNumber(), - ]); - Craft::error($error, __METHOD__); - - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - } - - if ($pdf = $email->getPdf()) { - // Email Body - if (!$view->doesTemplateExist($pdf->templatePath)) { - $error = Craft::t('commerce', 'Email PDF template does not exist at “{templatePath}” for email “{email}”. Order: “{order}”.', [ - 'templatePath' => $pdf->templatePath, - 'email' => $email->name, - 'order' => $order->getShortNumber(), - ]); - Craft::error($error, __METHOD__); - - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - - try { - $renderedPdf = Plugin::getInstance()->getPdfs()->renderPdfForOrder($order, 'email', null, [], $pdf); - - $tempPath = Assets::tempFilePath('pdf'); - - file_put_contents($tempPath, $renderedPdf); - - $fileName = ''; - $defaultFileName = $pdf->handle . '-' . $order->number; - if ($pdf->fileNameFormat) { - try { - $fileName = $view->renderSandboxedObjectTemplate($pdf->fileNameFormat, $order); - } catch (\Throwable) { - $fileName = $defaultFileName; - } - } - - if (!$fileName) { - $fileName = $defaultFileName; - } - - // Attachment information - $options = ['fileName' => $fileName . '.pdf', 'contentType' => 'application/pdf']; - $newEmail->attach($tempPath, $options); - } catch (\Exception $e) { - Craft::$app->getErrorHandler()->logException($e); - - $error = Craft::t('commerce', 'Email PDF generation error for email “{email}”. Order: “{order}”. PDF Template error: “{message}” {file}:{line}', [ - 'email' => $email->name, - 'order' => $order->getShortNumber(), - 'message' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - Craft::error($error, __METHOD__); - - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - } - - $originalSiteId = Craft::$app->getSites()->getCurrentSite()->id; - Craft::$app->getSites()->setCurrentSite($emailSite); - - // Render HTML body - try { - $body = $view->renderTemplate($templatePath, $renderVariables); - $newEmail->setHtmlBody($body); - } catch (\Exception $e) { - Craft::$app->getErrorHandler()->logException($e); - - $error = Craft::t('commerce', 'Email template parse error for email “{email}”. Order: “{order}”. Template error: “{message}” {file}:{line}', [ - 'email' => $email->name, - 'order' => $order->getShortNumber(), - 'message' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - Craft::error($error, __METHOD__); - - Craft::$app->getSites()->setCurrentSite($originalSiteId); - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - - // Render Plain Text body - if ($plainTextTemplatePath) { - try { - $plainTextBody = $view->renderTemplate($plainTextTemplatePath, $renderVariables); - $newEmail->setTextBody($plainTextBody); - } catch (\Exception $e) { - Craft::$app->getErrorHandler()->logException($e); - - $error = Craft::t('commerce', 'Email plain text template parse error for email “{email}”. Order: “{order}”. Template error: “{message}” {file}:{line}', [ - 'email' => $email->name, - 'order' => $order->getShortNumber(), - 'message' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - Craft::error($error, __METHOD__); - - Craft::$app->getSites()->setCurrentSite($originalSiteId); - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - } - - try { - //raising event - $event = new MailEvent([ - 'craftEmail' => $newEmail, - 'commerceEmail' => $email, - 'order' => $order, - 'orderHistory' => $orderHistory, - 'orderData' => $orderData, - ]); - $this->trigger(self::EVENT_BEFORE_SEND_MAIL, $event); - - if (!$event->isValid) { - $notice = Craft::t('commerce', 'Email “{email}” for order {order} was cancelled.', [ - 'email' => $email->name, - 'order' => $order->getShortNumber(), - ]); - - Craft::info($notice, __METHOD__); - - Craft::$app->getSites()->setCurrentSite($originalSiteId); - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - // Plugins that stop a email being sent should not declare that the sending failed, just that it would blocking of the send. - // The blocking of the send will still be logged as an error though for now. - // @TODO Clean up this behavior in Commerce 6.0 so plugins that block a send can signal "blocked" distinctly from "failed" without it being logged as an error #COM-49 - // https://github.com/craftcms/commerce/issues/1842 - return true; - } - - if (!Craft::$app->getMailer()->send($newEmail)) { - $error = Craft::t('commerce', 'Commerce email “{email}” could not be sent for order “{order}”.', [ - 'email' => $email->name, - 'order' => $order->getShortNumber(), - ]); - - Craft::error($error, __METHOD__); - - Craft::$app->getSites()->setCurrentSite($originalSiteId); - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - } catch (\Exception $e) { - Craft::$app->getErrorHandler()->logException($e); - - $error = Craft::t('commerce', 'Email “{email}” could not be sent for order “{order}”. Error: {error} {file}:{line}', [ - 'error' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'email' => $email->name, - 'order' => $order->getShortNumber(), - ]); - - Craft::error($error, __METHOD__); - - Craft::$app->getSites()->setCurrentSite($originalSiteId); - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - return false; - } - - // Raise an 'afterSendEmail' event - if ($this->hasEventHandlers(self::EVENT_AFTER_SEND_MAIL)) { - $this->trigger(self::EVENT_AFTER_SEND_MAIL, new MailEvent([ - 'craftEmail' => $newEmail, - 'commerceEmail' => $email, - 'order' => $order, - 'orderHistory' => $orderHistory, - 'orderData' => $orderData, - ])); - } - - Craft::$app->getSites()->setCurrentSite($originalSiteId); - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - $view->setTemplateMode($oldTemplateMode); - $generalConfig->generateTransformsBeforePageLoad = $generateTransformsBeforePageLoad; - - // Clear out the temp PDF file if it was created. - if (!empty($tempPath)) { - unlink($tempPath); - } - - return true; - } - - /** - * Get all emails by an order status ID. - * - * @return Email[] - */ - public function getAllEmailsByOrderStatusId(int $id): array - { - $results = $this->_createEmailQuery() - ->innerJoin(Table::ORDERSTATUS_EMAILS . ' statusEmails', '[[emails.id]] = [[statusEmails.emailId]]') - ->innerJoin(Table::ORDERSTATUSES . ' orderStatuses', '[[statusEmails.orderStatusId]] = [[orderStatuses.id]]') - ->where(['orderStatuses.id' => $id]) - ->all(); - - $emails = []; - - foreach ($results as $row) { - $emails[] = new Email($row); - } - - return $emails; - } - - - /** - * Returns a Query object prepped for retrieving Emails. - */ - private function _createEmailQuery(): Query - { - return (new Query()) - ->select([ - 'emails.bcc', - 'emails.cc', - 'emails.enabled', - 'emails.id', - 'emails.language', - 'emails.name', - 'emails.pdfId', - 'emails.plainTextTemplatePath', - 'emails.recipientType', - 'emails.renderSiteId', - 'emails.replyTo', - 'emails.senderAddress', - 'emails.senderName', - 'emails.storeId', - 'emails.subject', - 'emails.templatePath', - 'emails.to', - 'emails.uid', - ]) - ->orderBy('emails.name') - ->from([Table::EMAILS . ' emails']); - } - - - /** - * Gets an email record by uid. - */ - private function _getEmailRecord(string $uid): EmailRecord - { - if ($email = EmailRecord::findOne(['uid' => $uid])) { - return $email; - } - - return new EmailRecord(); - } - - /** - * @return void - * @since 5.0.0 - */ - protected function clearCache(): void - { - $this->_allEmails = null; - } -} diff --git a/src/services/Formulas.php b/src/services/Formulas.php deleted file mode 100644 index b26df1dbe5..0000000000 --- a/src/services/Formulas.php +++ /dev/null @@ -1,278 +0,0 @@ - - * @since 2.2 - */ -class Formulas extends Component -{ - /** - * @var Environment - */ - private Environment $_twigEnv; - - /** - * @var array Request-level cache for condition evaluation results, keyed by formula+params hash. - */ - private array $_conditionResults = []; - - /** - * Initialize formulas - */ - public function init(): void - { - $tags = $this->_getTags(); - $filters = $this->_getFilters(); - $functions = $this->_getFunctions(); - $methods = $this->_getMethods(); - $properties = $this->_getProperties(); - - $policy = new SecurityPolicy($tags, $filters, $methods, $properties, $functions); - $loader = new FilesystemLoader(); - $sandbox = new SandboxExtension($policy, true); - - $this->_twigEnv = new Environment($loader); - $this->_twigEnv->addExtension($sandbox); - } - - /** - * @param string $condition The condition which will be tested for correct syntax - * @param array $params data passed into the formula - */ - public function validateConditionSyntax(string $condition, array $params): bool - { - try { - $this->evaluateCondition($condition, $params, Craft::t('commerce', 'Validating condition syntax')); - } catch (Exception) { - return false; - } - - return true; - } - - /** - * @param string $formula The formula which will be tested for correct syntax - * @param array $params data passed into the formula - */ - public function validateFormulaSyntax(string $formula, array $params): bool - { - try { - $this->evaluateFormula($formula, $params, null, Craft::t('commerce', 'Validating formula syntax')); - } catch (Exception) { - return false; - } - - return true; - } - - /** - * @param array $params data passed into the condition - * @param string $name The name of the formula, useful for locating template errors in logs and exceptions - * @return bool - * @throws SyntaxError - * @throws LoaderError - */ - public function evaluateCondition(string $formula, array $params, string $name = 'Evaluate Condition'): bool - { - if ($this->_hasDisallowedStrings($formula, ['{%', '%}', '{{', '}}'])) { - throw new SyntaxError('Tags are not allowed in a condition formula.'); - } - - $formulaHash = md5($formula); - $paramsHash = md5(Json::encode($params)); - $requestKey = $formulaHash . $paramsHash; - - if (isset($this->_conditionResults[$requestKey])) { - return $this->_conditionResults[$requestKey]; - } - - $cacheKey = [ - 'formula' => $formulaHash, - 'params' => $paramsHash, - ]; - - $cachedResult = Craft::$app->getCache()->get($cacheKey); - if ($cachedResult !== false) { - return $this->_conditionResults[$requestKey] = ($cachedResult === 'TRUE'); - } - - $twigCode = '{% if '; - $twigCode .= $formula; - $twigCode .= ' %}TRUE{% else %}FALSE{% endif %}'; - - $template = $this->_twigEnv->createTemplate($twigCode, $name); - $output = $template->render($params); - - Craft::$app->getCache()->set($cacheKey, $output); - - return $this->_conditionResults[$requestKey] = ($output === 'TRUE'); - } - - /** - * @param string $formula - * @param array $params data passed into the condition - * @param string|null $setType the type of the response data, passing nothing will leave as a string. Uses \settype(). - * @param string|null $name The name of the formula, useful for locating template errors in logs and exceptions - * @return mixed - * @throws SyntaxError - * @throws LoaderError - */ - public function evaluateFormula(string $formula, array $params, ?string $setType = null, ?string $name = 'Inline formula'): mixed - { - $formula = trim($formula); - - $template = $this->_twigEnv->createTemplate($formula, $name); - $result = $template->render($params); - - if ($setType === null) { - return $result; - } - - settype($result, $setType); - return $result; - } - - private function _hasDisallowedStrings(string $code, array $disallowedStrings = []): bool - { - foreach ($disallowedStrings as $disallowedString) { - if (stripos($code, (string) $disallowedString) !== false) { - return true; - } - } - return false; - } - - private function _getTags(): array - { - return [ - //'apply', - //'autoescape', - //'block', - //'deprecated', - //'do', - //'embed', - //'extends', - //'flush', - 'for', - //'from', - 'if', - //'import', - //'include', - //'macro', - //'sandbox', - 'set', - //'use', - //'verbatim', - //'with', - ]; - } - - private function _getFilters(): array - { - return [ - 'abs', - //'batch', - 'capitalize', - //'column', - //'convert_encoding', - //'country_name', - //'country_timezones', - //'currency_name', - //'currency_symbol', - //'data_uri', - 'date', - //'date_modify', - //'default', - //'escape', - 'filter', - 'first', - //'format', - //'format_currency', - //'format_date', - //'format_datetime', - //'format_number', - //'format_time', - //'inky', - //'inline_css', - 'join', - //'json_encode', - 'keys', - //'language_name', - 'last', - 'length', - //'locale_name', - //'lower', - 'map', - //'markdown', - 'merge', - //'nl2br', - //'number_format', - //'raw', - 'reduce', - 'replace', - 'reverse', - 'round', - 'slice', - 'sort', - //'spaceless', - 'split', - //'striptags', - //'timezone_name', - //'title', - 'trim', - 'upper', - //'url_encode', - ]; - } - - private function _getFunctions(): array - { - return [ - //'attribute', - //'block', - //'constant', - //'cycle', - 'date', - //'dump', - //'html_classes', - //'include', - 'max', - 'min', - //'parent', - 'random', - 'range', - //'source', - //'template_from_string', - ]; - } - - private function _getMethods(): array - { - return []; - } - - private function _getProperties(): array - { - return []; - } -} diff --git a/src/services/Gateways.php b/src/services/Gateways.php deleted file mode 100644 index 33ff8ca7e0..0000000000 --- a/src/services/Gateways.php +++ /dev/null @@ -1,530 +0,0 @@ - - * @since 2.0 - */ -class Gateways extends Component -{ - /** - * @var array|null Gateway setting overrides - */ - private ?array $_overrides = null; - - /** - * @var Collection|null All gateways - */ - private ?Collection $_allGateways = null; - - /** - * @event RegisterComponentTypesEvent The event that is triggered for the registration of additional gateways. - * - * This example registers a custom gateway instance of the `MyGateway` class: - * - * ```php - * use craft\events\RegisterComponentTypesEvent; - * use craft\commerce\services\Purchasables; - * use yii\base\Event; - * - * Event::on( - * Gateways::class, - * Gateways::EVENT_REGISTER_GATEWAY_TYPES, - * function(RegisterComponentTypesEvent $event) { - * $event->types[] = MyGateway::class; - * } - * ); - * ``` - */ - public const EVENT_REGISTER_GATEWAY_TYPES = 'registerGatewayTypes'; - - public const CONFIG_GATEWAY_KEY = 'commerce.gateways'; - - - /** - * Returns all registered gateway types. - * - * @return string[] - */ - public function getAllGatewayTypes(): array - { - $gatewayTypes = [ - Dummy::class, - Manual::class, - ]; - - $event = new RegisterComponentTypesEvent([ - 'types' => $gatewayTypes, - ]); - $this->trigger(self::EVENT_REGISTER_GATEWAY_TYPES, $event); - - return $event->types; - } - - /** - * Returns all customer enabled gateways. - * - * @return Collection All gateways that are enabled for frontend - * @throws DeprecationException - * @throws InvalidConfigException - */ - public function getAllCustomerEnabledGateways(): Collection - { - return $this->getAllGateways()->filter(fn(Gateway $gateway) => $gateway->getIsFrontendEnabled()); - } - - /** - * Returns all customer enabled gateways and allowed for the order/cart. - * - * @return Collection All gateways that are enabled for frontend and allowed for the order/cart. - * @throws DeprecationException - * @throws InvalidConfigException - */ - public function getAllCustomerEnabledGatewaysAndAvailableForUseWithOrder(Order $order): Collection - { - return $this->getAllCustomerEnabledGateways()->filter(fn(Gateway $gateway) => $gateway->availableForUseWithOrder($order)); - } - - /** - * Returns all subscription gateways. - * - * @return Collection All Subscription gateways - * @throws DeprecationException - * @throws InvalidConfigException - */ - public function getAllSubscriptionGateways(): Collection - { - return $this->getAllGateways()->where(fn(Gateway $gateway) => $gateway instanceof SubscriptionGateway); - } - - /** - * Returns all gateways - * - * @return Collection All gateways - * @throws DeprecationException - * @throws InvalidConfigException - */ - public function getAllGateways(): Collection - { - return $this->_getAllGateways()->where('isArchived', false); - } - - /** - * @return array - * @throws DeprecationException - * @throws InvalidConfigException - * @sine 5.3.0 - */ - public function getAllArchivedGateways(): array - { - return ArrayHelper::where($this->_getAllGateways(), 'isArchived', true); - } - - /** - * Archives a gateway by its ID. - * - * @param int $id gateway ID - * @return bool Whether the archiving was successful or not - * @throws ErrorException - * @throws Exception - * @throws InvalidConfigException - * @throws NotSupportedException - * @throws ServerErrorHttpException - * @throws \yii\db\Exception - */ - public function archiveGatewayById(int $id): bool - { - /** @var Gateway $gateway */ - $gateway = $this->getGatewayById($id); - $gateway->isArchived = true; - - if (!$this->saveGateway($gateway)) { - return false; - } - - // remove all payment sources for this gateway - // this will also remove them as the payment source for a cart - Craft::$app->getDb()->createCommand() - ->delete(Table::PAYMENTSOURCES, ['gatewayId' => $id]) - ->execute(); - - // Clear this as the selected gateway from all active carts and orders - Craft::$app->getDb()->createCommand() - ->update(Table::ORDERS, - [ - 'gatewayId' => null, - 'paymentSourceId' => null, - ], - [ - 'gatewayId' => $id, - ], [], false) - ->execute(); - - - return true; - } - - /** - * Returns a gateway by its ID. - * - * @param int $id - * @return Gateway|null The gateway or null if not found. - * @throws DeprecationException - * @throws InvalidConfigException - */ - public function getGatewayById(int $id): ?Gateway - { - return $this->_getAllGateways()->firstWhere('id', $id); - } - - /** - * Returns a gateway by its handle. - * - * @param string $handle - * @return Gateway|null The gateway or null if not found. - * @throws DeprecationException - * @throws InvalidConfigException - */ - public function getGatewayByHandle(string $handle): ?Gateway - { - return $this->_getAllGateways()->firstWhere('handle', $handle); - } - - /** - * Saves a gateway. - * - * @param Gateway $gateway The gateway to be saved. - * @param bool $runValidation Whether the gateway should be validated - * @return bool Whether the gateway was saved successfully or not. - * @throws Exception - * @throws InvalidConfigException - * @throws ErrorException - * @throws NotSupportedException - * @throws ServerErrorHttpException - */ - public function saveGateway(Gateway $gateway, bool $runValidation = true): bool - { - $isNewGateway = $gateway->getIsNew(); - - if ($runValidation && !$gateway->validate()) { - Craft::info('Gateway not saved due to validation error.', __METHOD__); - return false; - } - - if ($isNewGateway) { - $gatewayUid = StringHelper::UUID(); - } else { - $gatewayUid = $gateway->uid; - } - - $existingGateway = $this->getGatewayByHandle($gateway->handle); - - if ($existingGateway && (!$gateway->id || $gateway->id != $existingGateway->id)) { - $gateway->addError('handle', Craft::t('commerce', 'That handle is already in use.')); - return false; - } - - $projectConfig = Craft::$app->getProjectConfig(); - - if ($gateway->isArchived) { - $configData = null; - } else { - $configData = $gateway->getConfig(); - } - - $configPath = self::CONFIG_GATEWAY_KEY . '.' . $gatewayUid; - $projectConfig->set($configPath, $configData); - - if ($isNewGateway) { - $gateway->id = Db::idByUid(Table::GATEWAYS, $gatewayUid); - } - - $this->_allGateways = null; // reset cache - - return true; - } - - /** - * Handle gateway change - * - * @throws Throwable if reasons - */ - public function handleChangedGateway(ConfigEvent $event): void - { - $gatewayUid = $event->tokenMatches[0]; - $data = $event->newValue; - - // Bail if the data is not a valid gateway config array - if (!is_array($data)) { - return; - } - - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - $gatewayRecord = $this->_getGatewayRecord($gatewayUid); - - $gatewayRecord->name = $data['name']; - $gatewayRecord->handle = $data['handle']; - $gatewayRecord->type = $data['type']; - $gatewayRecord->settings = $data['settings'] ?? null; - $gatewayRecord->sortOrder = $data['sortOrder']; - $gatewayRecord->paymentType = $data['paymentType']; - if ($data['isFrontendEnabled'] === null || is_bool($data['isFrontendEnabled'])) { - $data['isFrontendEnabled'] = $data['isFrontendEnabled'] ? '1' : '0'; - } - - $gatewayRecord->isFrontendEnabled = $data['isFrontendEnabled']; - $gatewayRecord->orderCondition = $data['orderCondition'] ?? null; - $gatewayRecord->billingAddressCondition = $data['billingAddressCondition'] ?? null; - $gatewayRecord->shippingAddressCondition = $data['shippingAddressCondition'] ?? null; - $gatewayRecord->isArchived = false; - $gatewayRecord->dateArchived = null; - $gatewayRecord->uid = $gatewayUid; - - // Save the volume - $gatewayRecord->save(false); - - $transaction->commit(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - } - - /** - * Handle gateway being archived - * - * @throws Throwable if reasons - */ - public function handleArchivedGateway(ConfigEvent $event): void - { - $gatewayUid = $event->tokenMatches[0]; - - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - $gatewayRecord = $this->_getGatewayRecord($gatewayUid); - - $gatewayRecord->isArchived = true; - $gatewayRecord->dateArchived = Db::prepareDateForDb(new DateTime()); - - // Save the volume - $gatewayRecord->save(false); - - $transaction->commit(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - } - - /** - * Reorders gateways by ids. - * - * @param array $ids Array of gateways. - * @return bool Always true. - * @throws ErrorException - * @throws Exception - * @throws InvalidConfigException - * @throws NotSupportedException - * @throws ServerErrorHttpException - */ - public function reorderGateways(array $ids): bool - { - $projectConfig = Craft::$app->getProjectConfig(); - - $uidsByIds = Db::uidsByIds(Table::GATEWAYS, $ids); - - foreach ($ids as $gatewayOrder => $gatewayId) { - if (!empty($uidsByIds[$gatewayId])) { - $gatewayUid = $uidsByIds[$gatewayId]; - $projectConfig->set(self::CONFIG_GATEWAY_KEY . '.' . $gatewayUid . '.sortOrder', $gatewayOrder + 1); - } - } - - $this->_allGateways = null; // reset cache - - return true; - } - - /** - * Creates a gateway with a given config - * - * @param string|array $config The gateway’s class name, or its config, with a `type` value and optionally a `settings` value - * @return Gateway The gateway - * @throws DeprecationException - * @throws InvalidConfigException - */ - public function createGateway(string|array $config): Gateway - { - if (is_string($config)) { - $config = ['type' => $config]; - } - - // Are they overriding any settings? - if (!empty($config['handle']) && ($override = $this->getGatewayOverrides($config['handle'])) !== null) { - // Save a reference to the original config in case the gateway type is missing - $originalConfig = $config; - - // Apply the settings early so the overrides don't get overridden - $config = array_merge(ComponentHelper::mergeSettings($config), $override); - } - - try { - if ($config['type'] == MissingGateway::class) { - throw new MissingComponentException('Missing Gateway Class.'); - } - - /** @var Gateway $gateway */ - $gateway = ComponentHelper::createComponent($config, GatewayInterface::class); - } catch (MissingComponentException $e) { - $config['errorMessage'] = $e->getMessage(); - $config['expectedType'] = $config['type']; - unset($config['type']); - - $gateway = new MissingGateway($config); - } - - return $gateway; - } - - /** - * Returns any custom gateway settings form config file. - * - * @param string $handle The gateway handle - * @throws DeprecationException - * @deprecated in 3.3. Overriding gateway settings using the `commerce-gateways.php` file has been deprecated. Use the gateway’s config file instead. - */ - public function getGatewayOverrides(string $handle): ?array - { - if ($this->_overrides === null) { - $this->_overrides = Craft::$app->getConfig()->getConfigFromFile('commerce-gateways'); - } - - $overrides = $this->_overrides[$handle] ?? null; - - if ($overrides != null) { - Craft::$app->getDeprecator()->log('craft.commerce.gateways.getGatewayOverrides()', 'Overriding gateway settings using the `commerce-gateways.php` file has been deprecated. Use the gateway’s config file instead.'); - } - - return $overrides; - } - - - /** - * Returns a Query object prepped for retrieving gateways. - * - * @return Query The query object. - */ - private function _createGatewayQuery(): Query - { - $query = (new Query()) - ->select([ - 'dateArchived', - 'handle', - 'id', - 'isArchived', - 'isFrontendEnabled', - 'name', - 'paymentType', - 'settings', - 'sortOrder', - 'type', - 'uid', - ]) - ->orderBy(['sortOrder' => SORT_ASC]) - ->from([Table::GATEWAYS]); - - // @TODO Remove these columnExists checks in Commerce 6.0 once the schema guarantees orderCondition / billingAddressCondition / shippingAddressCondition columns on the gateways table - $db = Craft::$app->getDb(); - if ($db->columnExists(Table::GATEWAYS, 'orderCondition')) { - $query->addSelect('orderCondition'); - } - if ($db->columnExists(Table::GATEWAYS, 'billingAddressCondition')) { - $query->addSelect('billingAddressCondition'); - } - if ($db->columnExists(Table::GATEWAYS, 'shippingAddressCondition')) { - $query->addSelect('shippingAddressCondition'); - } - - return $query; - } - - /** - * Gets a gateway's record by uid. - */ - private function _getGatewayRecord(string $uid): GatewayRecord - { - if ($gateway = GatewayRecord::findOne(['uid' => $uid])) { - return $gateway; - } - - return new GatewayRecord(); - } - - /** - * @return Collection - * @throws DeprecationException - * @throws InvalidConfigException - */ - private function _getAllGateways(): Collection - { - if ($this->_allGateways === null) { - $results = $this->_createGatewayQuery() - ->all(); - - if ($this->_allGateways === null) { - $this->_allGateways = collect(); - } - - $gateways = []; - foreach ($results as $result) { - $gateways[] = $this->createGateway($result); - } - - $this->_allGateways = collect($gateways)->keyBy('id'); - } - - return $this->_allGateways; - } -} diff --git a/src/services/Inventory.php b/src/services/Inventory.php deleted file mode 100644 index a124e3356d..0000000000 --- a/src/services/Inventory.php +++ /dev/null @@ -1,985 +0,0 @@ - - * @since 5.0.0 - */ -class Inventory extends Component -{ - /** - * @event UpdateInventoryLevelEvent The event that is triggered after an inventory level update is executed. - * - * ```php - * use craft\commerce\events\UpdateInventoryLevelEvent; - * use craft\commerce\services\Inventory; - * use craft\commerce\models\inventory\UpdateInventoryLevel; - * use yii\base\Event; - * - * Event::on( - * Inventory::class, - * Inventory::EVENT_AFTER_EXECUTE_UPDATE_INVENTORY_LEVEL, - * function(UpdateInventoryLevelEvent $event) { - * // @var UpdateInventoryLevel $updateInventoryLevel - * $updateInventoryLevel = $event->updateInventoryLevel; - * } - * ); - * ``` - */ - public const EVENT_AFTER_EXECUTE_UPDATE_INVENTORY_LEVEL = 'afterExecuteUpdateInventoryLevel'; - - /** - * @event InventoryMovementEvent The event that is triggered after an inventory movement is executed. - * - * ```php - * use craft\commerce\events\InventoryMovementEvent; - * use craft\commerce\services\Inventory; - * use craft\commerce\base\InventoryMovementInterface; - * use yii\base\Event; - * - * Event::on( - * Inventory::class, - * Inventory::EVENT_AFTER_EXECUTE_INVENTORY_MOVEMENT, - * function(InventoryMovementEvent $event) { - * // @var InventoryMovementInterface $inventoryMovement - * $inventoryMovement = $event->inventoryMovement; - * } - * ); - * ``` - */ - public const EVENT_AFTER_EXECUTE_INVENTORY_MOVEMENT = 'afterExecuteInventoryMovement'; - - /** - * @param Purchasable $purchasable - * @return Collection - */ - public function getInventoryLevelsForPurchasable(Purchasable $purchasable): Collection - { - $inventoryLevels = collect(); - - if (!$purchasable->id) { - return $inventoryLevels; // empty collection - } - - // Self-heal a missing inventory item id so callers get accurate levels - // even when the purchasable was loaded before its row was created. - if (!$purchasable->inventoryItemId && $purchasable::hasInventory()) { - $this->getInventoryItemByPurchasable($purchasable); - } - - if (!$purchasable->inventoryItemId) { - return $inventoryLevels; // empty collection - } - - $storeId = $purchasable->getStore()->id; - $storeInventoryLocations = Plugin::getInstance()->getInventoryLocations()->getInventoryLocations($storeId); - - foreach ($storeInventoryLocations as $inventoryLocation) { - $inventoryLevel = $this->getInventoryLevel($purchasable->inventoryItemId, $inventoryLocation->id); - - if (!$inventoryLevel) { - continue; - } - $inventoryLevels->push($inventoryLevel); - } - - return $inventoryLevels; - } - - /** - * @param Purchasable $purchasable - * @return InventoryItem - */ - public function getInventoryItemByPurchasable(Purchasable $purchasable): InventoryItem - { - // Self-heal: if the purchasable has somehow ended up without an associated - // inventory item (e.g. due to a draft-apply or duplicate path that didn't - // create one), find or create one before returning. - if (!$purchasable->inventoryItemId && $purchasable->id) { - $record = $this->ensureInventoryItemRecord($purchasable); - if ($record) { - $purchasable->inventoryItemId = $record->id; - } - } - - return $this->getInventoryItemById($purchasable->inventoryItemId); - } - - /** - * Finds or creates the inventory item record for the given purchasable, always - * keyed by its canonical id so drafts and revisions resolve to the same row as - * their canonical. Returns null if the purchasable type does not track inventory - * or there is no canonical id yet. - * - * @param Purchasable $purchasable - * @return InventoryItemRecord|null - * @since 5.6.4 - */ - public function ensureInventoryItemRecord(Purchasable $purchasable): ?InventoryItemRecord - { - if (!$purchasable::hasInventory()) { - return null; - } - - $canonicalId = $purchasable->getCanonicalId(); - if (!$canonicalId) { - return null; - } - - /** @var InventoryItemRecord|null $record */ - $record = InventoryItemRecord::find() - ->where(['purchasableId' => $canonicalId]) - ->one(); - - if (!$record) { - $record = new InventoryItemRecord(); - $record->purchasableId = $canonicalId; - $record->countryCodeOfOrigin = ''; - $record->administrativeAreaCodeOfOrigin = ''; - $record->harmonizedSystemCode = ''; - $record->save(); - } - - return $record; - } - - /** - * @param int $id - * @return InventoryItem - */ - public function getInventoryItemById(int $id): InventoryItem - { - $inventoryItem = $this->getInventoryItemQuery() - ->where(['id' => $id]) - ->one(); - - return $this->_populateInventoryItem($inventoryItem); - } - - /** - * @param array $ids - * @return Collection - */ - public function getInventoryItemsByIds(array $ids): Collection - { - $inventoryItemsResults = $this->getInventoryItemQuery() - ->where(['id' => $ids]) - ->all(); - - $inventoryItems = collect(); - foreach ($inventoryItemsResults as $inventoryItem) { - $inventoryItems->push($this->_populateInventoryItem($inventoryItem)); - } - - return $inventoryItems; - } - - /** - * Returns an inventory level model which is the sum of all inventory movements types for an item in a location. - * - * @param InventoryItem|int $inventoryItem - * @param InventoryLocation|int $inventoryLocation - * @param bool $withTrashed - * @return ?InventoryLevel - */ - public function getInventoryLevel(InventoryItem|int $inventoryItem, InventoryLocation|int $inventoryLocation, bool $withTrashed = false): ?InventoryLevel - { - $inventoryItemId = $inventoryItem instanceof InventoryItem ? $inventoryItem->id : $inventoryItem; - $inventoryLocationId = $inventoryLocation instanceof InventoryLocation ? $inventoryLocation->id : $inventoryLocation; - - $result = $this->getInventoryLevelQuery(withTrashed: $withTrashed, inventoryLocationId: $inventoryLocationId) - ->andWhere([ - 'inventoryLocationId' => $inventoryLocationId, - 'inventoryItemId' => $inventoryItemId, - ])->one(); - - if (!$result) { - return null; - } - - return $this->_populateInventoryLevel($result); - } - - /** - * @param InventoryItem $inventoryItem - * @param bool $validate - * @return bool - * @throws InvalidConfigException - */ - public function saveInventoryItem(InventoryItem $inventoryItem, bool $validate = true): bool - { - /** @var ?InventoryItemRecord $inventoryItemRecord */ - $inventoryItemRecord = InventoryItemRecord::find() - ->where(['id' => $inventoryItem->id]) - ->one(); - - if ($inventoryItemRecord === null) { - throw new InvalidConfigException('No inventory item exists with the ID “' . $inventoryItem->id . '”'); - } - - $inventoryItemRecord->purchasableId = $inventoryItem->purchasableId; - $inventoryItemRecord->countryCodeOfOrigin = $inventoryItem->countryCodeOfOrigin; - $inventoryItemRecord->administrativeAreaCodeOfOrigin = $inventoryItem->administrativeAreaCodeOfOrigin; - $inventoryItemRecord->harmonizedSystemCode = $inventoryItem->harmonizedSystemCode; - - return $inventoryItemRecord->save(); - } - - /** - * @param array $data - * @return InventoryItem - */ - private function _populateInventoryItem(array $data): InventoryItem - { - return new InventoryItem($data); - } - - /** - * @param array $data - * @return InventoryTransaction - */ - private function _populateInventoryTransaction(array $data): InventoryTransaction - { - return new InventoryTransaction($data); - } - - /** - * @param array $data - * @return InventoryLevel - */ - private function _populateInventoryLevel(array $data): InventoryLevel - { - unset($data['purchasableId']); - return new InventoryLevel($data); - } - - /** - * @param array $data - * @return InventoryFulfillmentLevel - */ - private function _populateInventoryFulfillmentLevel(array $data): InventoryFulfillmentLevel - { - return new InventoryFulfillmentLevel($data); - } - - /** - * @param InventoryLocation $inventoryLocation - * @param bool $withTrashed - * @return Collection - * @throws InvalidConfigException - */ - public function getInventoryLocationLevels(InventoryLocation $inventoryLocation, bool $withTrashed = false): Collection - { - $levels = $this->getInventoryLevelQuery(withTrashed: $withTrashed, inventoryLocationId: $inventoryLocation->id) - ->andWhere(['inventoryLocationId' => $inventoryLocation->id]) - ->andWhere(['not', ['elements.id' => null]]) - ->collect(); - - $inventoryItems = Plugin::getInstance()->getInventory()->getInventoryItemsByIds($levels->pluck('inventoryItemId')->unique()->toArray()); - return $levels->map(function($level) use ($inventoryItems) { - $inventoryLevel = $this->_populateInventoryLevel($level); - if ($item = $inventoryItems->firstWhere('id', $level['inventoryItemId'])) { - $inventoryLevel->setInventoryItem($item); - } - return $inventoryLevel; - }); - } - - /** - * Returns the totals for inventory items grouped by location and purchasable/inventoryItem. - * - * @param int|null $limit - * @param int|null $offset - * @param bool $withTrashed - * @return Query - */ - public function getInventoryLevelQuery(?int $limit = null, ?int $offset = null, bool $withTrashed = false, ?int $inventoryLocationId = null): Query - { - $inventoryTotals = (new Query()) - ->select([ - 'inventoryLocationId' => '[[il.id]]', - 'inventoryItemId' => '[[ii.id]]', - 'type' => '[[it.type]]', - 'quantity' => (new Expression('COALESCE(SUM([[it.quantity]]), 0)')), - ]) - ->from(['il' => Table::INVENTORYLOCATIONS]) // we want a record for every location and... - ->join('CROSS JOIN', ['ii' => Table::INVENTORYITEMS]) // ...every inventory item - ->leftJoin(['it' => Table::INVENTORYTRANSACTIONS], "[[il.id]] = [[it.inventoryLocationId]] AND [[ii.id]] = [[it.inventoryItemId]]") - ->groupBy(['[[il.id]]', '[[ii.id]]', '[[it.type]]']); - - // Scoping the location in the subquery prevents the CROSS JOIN from expanding - // to all locations × all items before the outer WHERE can filter it down. - if ($inventoryLocationId !== null) { - $inventoryTotals->andWhere(['il.id' => $inventoryLocationId]); - } - - $query = (new Query()) - ->select([ - '[[ii.id]] as inventoryItemId', - '[[ii.purchasableId]] as purchasableId', - '[[it.inventoryLocationId]] as inventoryLocationId', - 'SUM(CASE WHEN [[it.type]] = \'available\' THEN [[it.quantity]] ELSE 0 END) as availableTotal', - 'SUM(CASE WHEN [[it.type]] = \'committed\' THEN [[it.quantity]] ELSE 0 END) as committedTotal', - 'SUM(CASE WHEN [[it.type]] = \'reserved\' THEN [[it.quantity]] ELSE 0 END) as reservedTotal', - 'SUM(CASE WHEN [[it.type]] = \'damaged\' THEN [[it.quantity]] ELSE 0 END) as damagedTotal', - 'SUM(CASE WHEN [[it.type]] = \'safety\' THEN [[it.quantity]] ELSE 0 END) as safetyTotal', - 'SUM(CASE WHEN [[it.type]] = \'qualityControl\' THEN [[it.quantity]] ELSE 0 END) as qualityControlTotal', - 'SUM(CASE WHEN [[it.type]] = \'incoming\' THEN [[it.quantity]] ELSE 0 END) as incomingTotal', - 'SUM(CASE WHEN [[it.type]] IN (\'qualityControl\',\'safety\',\'damaged\',\'reserved\') THEN [[it.quantity]] ELSE 0 END) as unavailableTotal', - 'SUM(CASE WHEN [[it.type]] IN (\'qualityControl\',\'safety\',\'damaged\',\'reserved\', \'available\', \'committed\') THEN [[it.quantity]] ELSE 0 END) as onHandTotal', - ]) - ->from(['ii' => Table::INVENTORYITEMS]) - ->leftJoin(['it' => $inventoryTotals], '[[it.inventoryItemId]] = [[ii.id]]') - ->groupBy(["[[ii.id]]", "[[ii.purchasableId]]", "[[it.inventoryLocationId]]"]) - ->limit($limit) - ->offset($offset); - - $query->leftJoin( - ['elements' => CraftTable::ELEMENTS], - '[[ii.purchasableId]] = [[elements.id]] AND [[elements.draftId]] IS NULL AND [[elements.revisionId]] IS NULL' - ); - - if (!$withTrashed) { - $query->andWhere(['elements.dateDeleted' => null]); - } - - return $query; - } - - /** - * @return Query - */ - public function getInventoryItemQuery(): Query - { - return (new Query()) - ->select([ - 'id', - 'purchasableId', - 'countryCodeOfOrigin', - 'administrativeAreaCodeOfOrigin', - 'harmonizedSystemCode', - ]) - ->from(Table::INVENTORYITEMS); - } - - /** - * @param UpdateInventoryLevelCollection $updateInventoryLevels - * @return bool - * @throws Exception - */ - public function executeUpdateInventoryLevels(UpdateInventoryLevelCollection $updateInventoryLevels): bool - { - if ($updateInventoryLevels->count() < 1) { - return true; - } - - $transaction = Craft::$app->getDb()->beginTransaction(); - - try { - foreach ($updateInventoryLevels as $updateInventoryLevel) { - if ($updateInventoryLevel->updateAction === InventoryUpdateQuantityType::SET) { - $this->_setInventoryLevel($updateInventoryLevel); - } else { - $this->_adjustInventoryLevel($updateInventoryLevel); - } - } - - $transaction->commit(); - - // @TODO Consider pushing updateStoreStockCache() into a queued job so inventory updates don't block on cache regeneration - // Update all purchasables stock - foreach ($updateInventoryLevels->getPurchasables() as $purchasable) { - Plugin::getInstance()->getPurchasables()->updateStoreStockCache($purchasable, true); - } - - // Trigger event for each successful update - foreach ($updateInventoryLevels as $updateInventoryLevel) { - if ($this->hasEventHandlers(self::EVENT_AFTER_EXECUTE_UPDATE_INVENTORY_LEVEL)) { - $this->trigger(self::EVENT_AFTER_EXECUTE_UPDATE_INVENTORY_LEVEL, new UpdateInventoryLevelEvent([ - 'updateInventoryLevel' => $updateInventoryLevel, - ])); - } - } - - return true; - } catch (\Exception $e) { - $transaction->rollBack(); - throw $e; - } - } - - /** - * @param int $inventoryItemId - * @param int $quantity - * @param array $updateInventoryLevelAttributes - * @return void - * @throws Exception - * @throws InvalidConfigException - * @since 5.3.0 - */ - public function updateInventoryLevel(int $inventoryItemId, int $quantity, array $updateInventoryLevelAttributes = []) - { - $updateInventoryLevelAttributes += [ - 'quantity' => $quantity, - 'updateAction' => InventoryUpdateQuantityType::SET, - 'inventoryLocationId' => Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocations()->first()->id, - 'type' => InventoryTransactionType::AVAILABLE->value, - ]; - - $updateInventoryLevel = new UpdateInventoryLevel($updateInventoryLevelAttributes); - $updateInventoryLevel->inventoryItemId = $inventoryItemId; - - $updateInventoryLevels = UpdateInventoryLevelCollection::make(); - $updateInventoryLevels->push($updateInventoryLevel); - - Plugin::getInstance()->getInventory()->executeUpdateInventoryLevels($updateInventoryLevels); - } - - /** - * @param Purchasable $purchasable - * @param int $quantity - * @param array $updateInventoryLevelAttributes - * @return void - * @throws Exception - * @throws InvalidConfigException - * @throws \craft\errors\DeprecationException - * @since 5.3.0 - */ - public function updatePurchasableInventoryLevel(Purchasable $purchasable, int $quantity, array $updateInventoryLevelAttributes = []) - { - $inventoryLocation = $purchasable->getStore()->getInventoryLocations()->first(); - - if (!$inventoryLocation) { - // If no inventory location exists, we can't update inventory - // @TODO Change this method's return type and either return false or throw a typed exception when no inventory location is available, so callers can react instead of silently succeeding - return; - } - - $updateInventoryLevelAttributes += [ - 'quantity' => $quantity, - 'updateAction' => InventoryUpdateQuantityType::SET, - 'inventoryItemId' => $purchasable->inventoryItemId, - 'inventoryLocationId' => $inventoryLocation->id, - 'type' => InventoryTransactionType::AVAILABLE->value, - ]; - - $this->updateInventoryLevel($purchasable->inventoryItemId, $quantity, $updateInventoryLevelAttributes); - - // Clear the stock cache for the class instance - unset($purchasable->stock); // set _stock to null - } - - /** - * @param UpdateInventoryLevel|UpdateInventoryLevelInTransfer $updateInventoryLevel - * @return bool - */ - private function _setInventoryLevel(UpdateInventoryLevel|UpdateInventoryLevelInTransfer $updateInventoryLevel): bool - { - $tableName = Table::INVENTORYTRANSACTIONS; - - if ($updateInventoryLevel->type === 'onHand') { - $types = collect(InventoryTransactionType::onHand())->pluck('value'); - } else { - $types = [$updateInventoryLevel->type]; - } - $quantityQuery = (new Query()) - ->select([':quantity - COALESCE(SUM(quantity), 0)']) - ->from($tableName) - ->where([ - 'type' => $types, - 'inventoryItemId' => $updateInventoryLevel->inventoryItemId, - 'inventoryLocationId' => $updateInventoryLevel->inventoryLocationId, - ]) - ->params([':quantity' => $updateInventoryLevel->quantity]) - ->scalar(); - - $type = $updateInventoryLevel->type; - if ($updateInventoryLevel->type === 'onHand') { - $type = InventoryTransactionType::AVAILABLE->value; - } - - $data = [ - 'quantity' => $quantityQuery, - 'type' => $type, - 'inventoryItemId' => $updateInventoryLevel->inventoryItemId, - 'inventoryLocationId' => $updateInventoryLevel->inventoryLocationId, - 'note' => $updateInventoryLevel->note, - 'movementHash' => $this->getMovementHash(), - 'dateCreated' => Db::prepareDateForDb(new \DateTime()), - 'userId' => Craft::$app->getUser()->getIdentity()?->id, - ]; - - if ($updateInventoryLevel instanceof UpdateInventoryLevelInTransfer) { - $data['transfer'] = $updateInventoryLevel->transferId; - } - - Craft::$app->db->createCommand() - ->insert($tableName, $data)->execute(); - - return true; - } - - /** - * @param UpdateInventoryLevel|UpdateInventoryLevelInTransfer $updateInventoryLevel - * @return bool - */ - private function _adjustInventoryLevel(UpdateInventoryLevel|UpdateInventoryLevelInTransfer $updateInventoryLevel): bool - { - $tableName = Table::INVENTORYTRANSACTIONS; - - $type = $updateInventoryLevel->type; - if ($updateInventoryLevel->type === 'onHand') { - $type = 'available'; - } - - Craft::$app->db->createCommand() - ->insert($tableName, [ - 'quantity' => $updateInventoryLevel->quantity, - 'type' => $type, - 'inventoryItemId' => $updateInventoryLevel->inventoryItemId, - 'inventoryLocationId' => $updateInventoryLevel->inventoryLocationId, - 'movementHash' => $this->getMovementHash(), - 'dateCreated' => Db::prepareDateForDb(new \DateTime()), - 'note' => $updateInventoryLevel->note, - ]) - ->execute(); - - return true; - } - - /** - * - * @param InventoryMovementCollection $inventoryMovements - * @return bool - * @throws \yii\db\Exception - */ - public function executeInventoryMovements(InventoryMovementCollection $inventoryMovements): bool - { - $tableName = Table::INVENTORYTRANSACTIONS; - - $db = Craft::$app->getDb(); - $transaction = $db->beginTransaction(); - - try { - /** @var InventoryMovementInterface $inventoryMovement */ - foreach ($inventoryMovements as $inventoryMovement) { - if (!$inventoryMovement->isValid()) { - $transaction->rollBack(); - return false; - } - - $movementDate = Db::prepareDateForDb(new \DateTime()); - - // First insert operation - $fromInsertResult = $db->createCommand() - ->insert($tableName, [ - 'quantity' => -$inventoryMovement->getQuantity(), - 'type' => $inventoryMovement->getFromInventoryTransactionType()->value, - 'inventoryItemId' => $inventoryMovement->getInventoryItem()->id, - 'inventoryLocationId' => $inventoryMovement->getFromInventoryLocation()->id, - 'movementHash' => $inventoryMovement->getInventoryMovementHash(), - 'dateCreated' => $movementDate, - 'transferId' => $inventoryMovement->getTransferId(), - 'lineItemId' => $inventoryMovement->getLineItemId(), - 'userId' => $inventoryMovement->getUserId(), - 'note' => $inventoryMovement->getNote(), - ]) - ->execute(); - - if (!$fromInsertResult) { - $transaction->rollBack(); - return false; - } - - // Second insert operation - $toInsertResult = $db->createCommand() - ->insert($tableName, [ - 'quantity' => $inventoryMovement->getQuantity(), - 'type' => $inventoryMovement->getToInventoryTransactionType()->value, - 'inventoryItemId' => $inventoryMovement->getInventoryItem()->id, - 'inventoryLocationId' => $inventoryMovement->getToInventoryLocation()->id, - 'movementHash' => $inventoryMovement->getInventoryMovementHash(), - 'dateCreated' => $movementDate, - 'transferId' => $inventoryMovement->getTransferId(), - 'lineItemId' => $inventoryMovement->getLineItemId(), - 'userId' => $inventoryMovement->getUserId(), - 'note' => $inventoryMovement->getNote(), - ]) - ->execute(); - - if (!$toInsertResult) { - $transaction->rollBack(); - return false; - } - } - - $transaction->commit(); - - // @TODO Consider pushing the per-movement updateStoreStockCache() calls into a queued job so large batch movements don't block on cache regeneration - foreach ($inventoryMovements as $inventoryMovement) { - // Update all purchasables stock - $purchasable = $inventoryMovement->getInventoryItem()->getPurchasable(); - if ($purchasable) { - Plugin::getInstance()->getPurchasables()->updateStoreStockCache($purchasable, true); - } - } - - // Trigger event for each successful movement - foreach ($inventoryMovements as $inventoryMovement) { - if ($this->hasEventHandlers(self::EVENT_AFTER_EXECUTE_INVENTORY_MOVEMENT)) { - $this->trigger(self::EVENT_AFTER_EXECUTE_INVENTORY_MOVEMENT, new InventoryMovementEvent([ - 'inventoryMovement' => $inventoryMovement, - ])); - } - } - - return true; - } catch (\Exception $e) { - $transaction->rollBack(); - throw $e; - } - } - - - /** - * @return string - */ - public function getMovementHash(): string - { - return md5(uniqid((string)mt_rand(), true)); - } - - /** - * @param InventoryItem|int $inventoryItem - * @param InventoryLocation|int $inventoryLocation - * @return array - */ - public function getUnfulfilledOrders(InventoryItem|int $inventoryItem, InventoryLocation|int $inventoryLocation): array - { - $inventoryItemId = $inventoryItem instanceof InventoryItem ? $inventoryItem->id : $inventoryItem; - $inventoryLocationId = $inventoryLocation instanceof InventoryLocation ? $inventoryLocation->id : $inventoryLocation; - - $inventoryLevel = $this->getInventoryLevel($inventoryItemId, $inventoryLocationId); - - if ($inventoryLevel->committedTotal <= 0) { - return []; - } - - // Get orders that have line items for this inventory level item - $orderIds = (new Query()) - ->select(['lineItems.orderId']) - ->from(['lineItems' => Table::LINEITEMS]) - ->leftJoin(['orders' => Table::ORDERS], '[[lineItems.orderId]] = [[orders.id]]') - ->leftJoin(['it' => Table::INVENTORYTRANSACTIONS], '[[it.lineItemId]] = [[lineItems.id]]') - ->where(['orders.isCompleted' => true]) - ->andWhere(['it.inventoryItemId' => $inventoryItemId]) - ->andWhere(['it.inventoryLocationId' => $inventoryLocationId]) - ->andWhere(['it.type' => InventoryTransactionType::COMMITTED->value]) - ->addSelect(['lineItems.qty']) - ->groupBy(['lineItems.orderId', 'lineItems.id', 'lineItems.qty']) - ->having(new Expression('SUM([[it.quantity]]) >= [[lineItems.qty]]')) - ->column(); - - return Order::find() - ->id($orderIds) - ->all(); - } - - /** - * @return Query - */ - public function getTransactionQuery(): Query - { - return (new Query()) - ->select([ - 'inventoryLocationId', - 'inventoryItemId', - 'movementHash', - 'quantity', - 'type', - 'note', - 'transferId', - 'lineItemId', - 'userId', - 'dateCreated', - ]) - ->orderBy(['dateCreated' => SORT_DESC]) - ->from(Table::INVENTORYTRANSACTIONS); - } - - /** - * @param InventoryItem $inventoryItem - * @param InventoryLocation $inventoryLocation - * @return Collection - */ - public function getInventoryTransactions(InventoryItem $inventoryItem, InventoryLocation $inventoryLocation): Collection - { - $transactions = $this->getTransactionQuery() - ->where(['inventoryItemId' => $inventoryItem->id, 'inventoryLocationId' => $inventoryLocation->id]) - ->all(); - - foreach ($transactions as $key => $transaction) { - $transactions[$key] = $this->_populateInventoryTransaction($transaction); - } - - return collect($transactions); - } - - /** - * @param Order $order - * @return Collection - * @throws InvalidConfigException - * @throws \craft\errors\DeprecationException - */ - public function getInventoryFulfillmentLevels(Order $order): Collection - { - // We don’t limit this to the orders store locations since we want to show all locations that have historical inventory for the order. - $locations = Plugin::getInstance()->getInventoryLocations()->getAllInventoryLocations(); - - $inventoryFulfillmentLevels = []; - foreach ($locations as $location) { - $data = (new Query()) - ->select([ - '[[it.lineItemId]]', - '[[it.inventoryItemId]]', - '[[it.inventoryLocationId]]', - - 'SUM(CASE WHEN (([[it.type]] = :committedType AND quantity > 0) OR ([[it.type]] = :fulfilledType AND quantity < 0)) THEN [[quantity]] ELSE 0 END) AS committedQuantity', - - 'SUM(CASE WHEN [[it.type]] = :committedType THEN [[quantity]] ELSE 0 END) AS outstandingCommittedQuantity', - 'SUM(CASE WHEN [[it.type]] = :fulfilledType THEN [[quantity]] ELSE 0 END) AS fulfilledQuantity', - ]) - ->from(['it' => Table::INVENTORYTRANSACTIONS]) - ->andWhere([ - '[[li.orderId]]' => $order->id, - '[[it.inventoryLocationId]]' => $location->id, - ]) - ->andWhere(['or', - ['it.type' => InventoryTransactionType::COMMITTED->value], - ['it.type' => InventoryTransactionType::FULFILLED->value], - ]) - ->groupBy([ - '[[it.lineItemId]]', - '[[it.inventoryItemId]]', - '[[it.inventoryLocationId]]', - ]) - ->params([ - ':committedType' => InventoryTransactionType::COMMITTED->value, - ':fulfilledType' => InventoryTransactionType::FULFILLED->value, - ]) - ->innerJoin(['li' => Table::LINEITEMS], '[[li.id]] = [[it.lineItemId]]') - ->all(); - - foreach ($data as $row) { - $inventoryFulfillmentLevels[] = $this->_populateInventoryFulfillmentLevel($row); - } - } - - return collect($inventoryFulfillmentLevels); - } - - /** - * @param Order $order - * @return void - * @throws InvalidConfigException - * @throws \yii\db\Exception - */ - public function orderCompleteHandler(Order $order) - { - /** @var Collection[] $allInventoryLevels */ - $allInventoryLevels = []; - $qtyLineItem = []; - foreach ($order->getLineItems() as $lineItem) { - if ($lineItem->type === LineItemType::Custom) { - // Skip custom line items - continue; - } - - $purchasable = $lineItem->getPurchasable(); - // Don't reduce stock of unlimited items. - - if (!$purchasable::hasInventory()) { - continue; - } - - if ($purchasable->inventoryTracked) { - if (!isset($qtyLineItem[$purchasable->id])) { - $qtyLineItem[$purchasable->id] = 0; - } - $qtyLineItem[$purchasable->id] += $lineItem->qty; - $allInventoryLevels[$purchasable->id] = $purchasable->getInventoryLevels(); - } - } - - $selectedInventoryLevelForItem = []; - /** - * @var int $purchasableId - * @var Collection $inventoryLevels - */ - foreach ($allInventoryLevels as $purchasableId => $inventoryLevels) { - foreach ($inventoryLevels as $level) { - if (!isset($selectedInventoryLevelForItem[$purchasableId])) { - $selectedInventoryLevelForItem[$purchasableId] = $level; - - if ($level->availableTotal >= $qtyLineItem[$purchasableId]) { - break; - } - continue; - } - - if ($level->availableTotal >= $qtyLineItem[$purchasableId]) { - $selectedInventoryLevelForItem[$purchasableId] = $level; - break; - } - } - } - - $movements = InventoryMovementCollection::make(); - - $reserveAmountByPurchasableId = []; - $availableTotalByPurchasableIdAndLocationId = []; - - // Loop through line items and create committed movements for the selected inventory location - foreach ($order->getLineItems() as $lineItem) { - if (isset($selectedInventoryLevelForItem[$lineItem->purchasableId])) { - $level = $selectedInventoryLevelForItem[$lineItem->purchasableId]; - - if (!isset($reserveAmountByPurchasableId[$lineItem->purchasableId])) { - $availableTotalByPurchasableIdAndLocationId[$lineItem->purchasableId . '-' . $level->inventoryLocationId] = $level->availableTotal; - $reserveAmountByPurchasableId[$lineItem->purchasableId] = []; - } - - if ($lineItem->qty > $availableTotalByPurchasableIdAndLocationId[$lineItem->purchasableId . '-' . $level->inventoryLocationId]) { - $totalToReserveForLineItem = $lineItem->qty - $availableTotalByPurchasableIdAndLocationId[$lineItem->purchasableId . '-' . $level->inventoryLocationId]; - $reserveAmountByPurchasableId[$lineItem->purchasableId][$lineItem->id] = $totalToReserveForLineItem; - $availableTotalByPurchasableIdAndLocationId[$lineItem->purchasableId . '-' . $level->inventoryLocationId] = 0; - } else { - $availableTotalByPurchasableIdAndLocationId[$lineItem->purchasableId . '-' . $level->inventoryLocationId] -= $lineItem->qty; - } - - $inventoryCommittedMovement = new InventoryCommittedMovement(); - $inventoryCommittedMovement->inventoryItemId = $level->inventoryItemId; - $inventoryCommittedMovement->fromInventoryLocation = $level->getInventoryLocation(); - $inventoryCommittedMovement->toInventoryLocation = $level->getInventoryLocation(); - $inventoryCommittedMovement->fromInventoryTransactionType = InventoryTransactionType::AVAILABLE; - $inventoryCommittedMovement->toInventoryTransactionType = InventoryTransactionType::COMMITTED; - $inventoryCommittedMovement->quantity = $lineItem->qty; - $inventoryCommittedMovement->lineItemId = $lineItem->id; - - $movements->push($inventoryCommittedMovement); - } - } - - // Loop through reserve amounts to reserve the remaining stock in the other inventory locations - foreach ($reserveAmountByPurchasableId as $purchasableId => $r) { - foreach ($r as $lineItemId => $qty) { - foreach ($allInventoryLevels[$purchasableId] as $level) { - if ($level === $selectedInventoryLevelForItem[$purchasableId]) { - continue; - } - - if (!isset($availableTotalByPurchasableIdAndLocationId[$purchasableId . '-' . $level->inventoryLocationId])) { - $availableTotalByPurchasableIdAndLocationId[$purchasableId . '-' . $level->inventoryLocationId] = $level->availableTotal; - } - - $canReserveFullQty = $qty <= $availableTotalByPurchasableIdAndLocationId[$purchasableId . '-' . $level->inventoryLocationId]; - $qtyToReserve = $canReserveFullQty ? $qty : $availableTotalByPurchasableIdAndLocationId[$purchasableId . '-' . $level->inventoryLocationId]; - - if ($qtyToReserve < 1) { - break; - } - - $availableTotalByPurchasableIdAndLocationId[$purchasableId . '-' . $level->inventoryLocationId] -= $qtyToReserve; - - $inventoryManualMovement = new InventoryManualMovement(); - $inventoryManualMovement->inventoryItemId = $level->inventoryItemId; - $inventoryManualMovement->fromInventoryLocation = $level->getInventoryLocation(); - $inventoryManualMovement->toInventoryLocation = $level->getInventoryLocation(); - $inventoryManualMovement->fromInventoryTransactionType = InventoryTransactionType::AVAILABLE; - $inventoryManualMovement->toInventoryTransactionType = InventoryTransactionType::RESERVED; - $inventoryManualMovement->quantity = $qtyToReserve; - $inventoryManualMovement->lineItemId = $lineItemId; - - $movements->push($inventoryManualMovement); - - $qty -= $qtyToReserve; - if ($qty <= 0) { - break; - } - } - } - } - - $this->executeInventoryMovements($movements); - - foreach ($selectedInventoryLevelForItem as $key => $inventoryLevel) { - if ($purchasable = Craft::$app->getElements()->getElementById($key)) { - if ($purchasable instanceof Purchasable) { - Plugin::getInstance()->getPurchasables()->updateStoreStockCache($purchasable, true); - - // If the purchasable doesn't allow out of stock purchases, check whether the movement - // pushed available stock below zero (e.g. due to concurrent orders). - if (!$purchasable->allowOutOfStockPurchases) { - $freshLevel = $this->getInventoryLevel($inventoryLevel->inventoryItemId, $inventoryLevel->inventoryLocationId); - if ($freshLevel && $freshLevel->availableTotal < 0) { - $notice = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => [ - 'type' => 'inventoryBelowZero', - 'attribute' => 'lineItems', - 'message' => Craft::t('commerce', 'Available inventory for "{description}" has gone below zero.', [ - 'description' => $purchasable->getDescription(), - ]), - 'noticeType' => OrderNoticeType::Admin, - ], - ]); - $order->addNotice($notice); - } - } - } - } - } - } -} diff --git a/src/services/InventoryLocations.php b/src/services/InventoryLocations.php deleted file mode 100644 index 1b0dc030f0..0000000000 --- a/src/services/InventoryLocations.php +++ /dev/null @@ -1,387 +0,0 @@ - - * @since 5.0 - */ -class InventoryLocations extends Component -{ - /** - * @var Collection|null - */ - private ?Collection $_allLocations = null; - - /** - * @var Collection|null - */ - private ?Collection $_allLocationsWithTrashed = null; - - /** - * @var array> Inventory location IDs for a store, indexed by store ID. - */ - private array $_inventoryLocationIdsByStore = []; - - /** - * Returns all inventory locations. - * - * @param bool $withTrashed - * @return Collection All locations - * @throws DeprecationException - * @throws InvalidConfigException - */ - public function getAllInventoryLocations(bool $withTrashed = false): Collection - { - return $this->_getAllInventoryLocations($withTrashed); - } - - /** - * Returns all inventory locations as a list. - * - * @param bool $withTrashed - * @return array All locations as key value list - * @throws DeprecationException - * @throws InvalidConfigException - * @since 5.1.0 - */ - public function getAllInventoryLocationsAsList(bool $withTrashed = false): array - { - return $this->getAllInventoryLocations($withTrashed)->mapWithKeys(fn(InventoryLocation $location) => [$location->id => $location->getUiLabel()])->toArray(); - } - - /** - * Returns an inventory location by its ID. - * - * @param int $id - * @param bool $withTrashed - * @return InventoryLocation|null The inventory location or null if not found. - */ - public function getInventoryLocationById(int $id, bool $withTrashed = false): ?InventoryLocation - { - return $this->_getAllInventoryLocations($withTrashed)->firstWhere('id', $id); - } - - /** - * Gets all inventory locations for a store in order of configuration. - * - * @param ?int $storeId - * - * @return Collection - */ - public function getInventoryLocations(?int $storeId = null, bool $withTrashed = false): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if (!isset($this->_inventoryLocationIdsByStore[$storeId])) { - $this->_inventoryLocationIdsByStore[$storeId] = (new Query()) - ->select(['inventoryLocationId']) - ->from([Table::INVENTORYLOCATIONS_STORES]) - ->orderBy(['sortOrder' => SORT_ASC]) - ->where(['storeId' => $storeId]) - ->column(); - } - - $locationIds = $this->_inventoryLocationIdsByStore[$storeId]; - - // Keep the order of the locationIds - return $this->_getAllInventoryLocations($withTrashed)->whereIn('id', $locationIds)->sortBy(fn($inventoryLocation) => array_search($inventoryLocation->id, $locationIds)); - } - - /** - * Stores the relationship between a Store and its Inventory Locations, ordered by preference. - * - * @param Store $store - * @param array $inventoryLocationIds - * @return bool - * @throws Throwable - * @throws \yii\db\Exception - */ - public function saveStoreInventoryLocations(Store $store, array $inventoryLocationIds): bool - { - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - // Delete existing - Craft::$app->getDb()->createCommand() - ->delete(Table::INVENTORYLOCATIONS_STORES, ['storeId' => $store->id]) - ->execute(); - - $order = 1; - foreach ($inventoryLocationIds as $inventoryLocationId) { - Craft::$app->getDb()->createCommand() - ->insert(Table::INVENTORYLOCATIONS_STORES, [ - 'storeId' => $store->id, - 'inventoryLocationId' => $inventoryLocationId, - 'sortOrder' => $order++, - ]) - ->execute(); - } - - $transaction->commit(); - - // Clear memoization cache - $this->_inventoryLocationIdsByStore = []; - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - - return true; - } - - /** - * @return bool - * @throws Throwable - * @throws \yii\db\Exception - */ - public function executeDeactivateInventoryLocation(DeactivateInventoryLocation $deactivateInventoryLocation): bool - { - // This will ensure that the location has no committed stock or incoming stock before deactivating it. - if (!$deactivateInventoryLocation->validate()) { - return false; - } - - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - /** @var SoftDeleteBehavior $inventoryLocationRecord */ - $inventoryLocationRecord = InventoryLocationRecord::findOne($deactivateInventoryLocation->inventoryLocation->id); - - // Get draft transfers that are destinations for the deactivated inventory location -// /** @var Transfer $draftTransfers */ -// $draftTransfers = Transfer::find() -// ->transferStatus(TransferStatusType::DRAFT) -// ->destinationLocation($deactivateInventoryLocation->inventoryLocation) -// ->all(); - - // Switch the draft transfer to the new destination location -// foreach ($draftTransfers as $draftTransfer) { -// $draftTransfer->destinationLocationId = $deactivateInventoryLocation->destinationInventoryLocation->id; -// Craft::$app->getElements()->saveElement($draftTransfer, false); -// } - - // @TODO Reassign any draft purchase orders that target the deactivated inventory location to the destination location (mirroring the draft transfer handling above) - - $inventoryLevels = Plugin::getInstance()->getInventory()->getInventoryLocationLevels($deactivateInventoryLocation->inventoryLocation); - /** @var InventoryLevel $inventoryLevel */ - foreach ($inventoryLevels as $inventoryLevel) { - $movements = new InventoryMovementCollection(); - foreach (InventoryTransactionType::allowedManualMoveTransactionTypes() as $type) { - if ($inventoryLevel->getTotal($type) > 0) { - $inventoryMovement = new InventoryLocationDeactivatedMovement(); - $inventoryMovement->fromInventoryLocation = $deactivateInventoryLocation->inventoryLocation; - $inventoryMovement->toInventoryLocation = $deactivateInventoryLocation->destinationInventoryLocation; - $inventoryMovement->inventoryItemId = $inventoryLevel->inventoryItemId; - $inventoryMovement->quantity = $inventoryLevel->getTotal($type); - $inventoryMovement->fromInventoryTransactionType = $type; - $inventoryMovement->toInventoryTransactionType = $type; - $inventoryMovement->userId = Craft::$app->getUser()->getIdentity()?->id; - $inventoryMovement->note = Craft::t('commerce', 'Movement from deactivated inventory location'); - $movements->add($inventoryMovement); - } - } - - if ($movements->count() > 0) { - if (!Plugin::getInstance()->getInventory()->executeInventoryMovements($movements)) { - throw new \Exception('Failed to move inventory from deactivated location'); - } - } - } - - $transaction->commit(); - // Finally soft delete it now that it's all migrated - $inventoryLocationRecord->softDelete(); - - // Clear memoization cache - $this->_allLocations = null; - $this->_allLocationsWithTrashed = null; - } catch (Throwable $e) { - $transaction->rollBack(); - - throw $e; - } - - return true; - } - - /** - * Returns a location by its handle. - * - * @param string $handle - * @return InventoryLocation|null The location or null if not found. - * @throws DeprecationException - * @throws InvalidConfigException - */ - public function getInventoryLocationByHandle(string $handle): ?InventoryLocation - { - return $this->getAllInventoryLocations()->firstWhere('handle', $handle); - } - - /** - * Saves an inventory location. - * - */ - public function saveInventoryLocation(InventoryLocation $inventoryLocation, bool $runValidation = true): bool - { - $isNewLocation = !$inventoryLocation->id; - - if ($runValidation && !$inventoryLocation->validate()) { - Craft::info('Inventory Location not saved due to validation error.', __METHOD__); - return false; - } - - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - - /** @var ?InventoryLocationRecord $locationRecord */ - $locationRecord = InventoryLocationRecord::find() - ->where(['id' => $inventoryLocation->id]) - ->one(); - - if ($locationRecord === null) { - $locationRecord = new InventoryLocationRecord(); - } - - $locationRecord->name = $inventoryLocation->name; - $locationRecord->handle = $inventoryLocation->handle; - $locationRecord->addressId = $inventoryLocation->getAddress()->id; - - // Save the inventory location - $locationRecord->save(false); - - if ($isNewLocation) { - $inventoryLocation->id = $locationRecord->id; - } - - $transaction->commit(); - - // Clear memoization cache - $this->_allLocations = null; - $this->_allLocationsWithTrashed = null; - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - - return true; - } - - /** - * Returns a Query object prepped for retrieving locations. - * - * @return Query The query object. - */ - private function _createInventoryLocationsQuery(bool $withTrashed = false): Query - { - $query = (new Query()) - ->select([ - 'id', - 'name', - 'handle', - 'addressId', - 'dateCreated', - 'dateUpdated', - ]) - ->orderBy(['name' => SORT_ASC]) - ->from([Table::INVENTORYLOCATIONS]); - - if (!$withTrashed) { - $query->where(['dateDeleted' => null]); - } - - return $query; - } - - /** - * @return Collection - */ - private function _getAllInventoryLocations(bool $withTrashed = false): Collection - { - if ($withTrashed) { - if ($this->_allLocationsWithTrashed === null) { - $results = $this->_createInventoryLocationsQuery($withTrashed) - ->all(); - - $locations = []; - foreach ($results as $result) { - $locations[] = new InventoryLocation($result); - } - - $this->_allLocationsWithTrashed = collect($locations); - } - - return $this->_allLocationsWithTrashed; - } - - if ($this->_allLocations === null) { - $results = $this->_createInventoryLocationsQuery($withTrashed) - ->all(); - - $locations = []; - foreach ($results as $result) { - $locations[] = new InventoryLocation($result); - } - - $this->_allLocations = collect($locations); - } - - return $this->_allLocations; - } - - /** - * @param AuthorizationCheckEvent $event - * @return void - */ - public function authorizeInventoryLocationAddressView(AuthorizationCheckEvent $event): void - { - if (!$event->element instanceof Address) { - return; - } - - if ($this->getAllInventoryLocations(true)->firstWhere('addressId', $event->element->getCanonicalId()) === null) { - return; - } - - $event->authorized = true; - } - - public function authorizeInventoryLocationAddressEdit(AuthorizationCheckEvent $event): void - { - if (!$event->element instanceof Address) { - return; - } - - if ($this->getAllInventoryLocations(true)->firstWhere('addressId', $event->element->getCanonicalId()) === null) { - return; - } - - $event->authorized = true; - } -} diff --git a/src/services/LineItemStatuses.php b/src/services/LineItemStatuses.php deleted file mode 100644 index 6cfb580667..0000000000 --- a/src/services/LineItemStatuses.php +++ /dev/null @@ -1,371 +0,0 @@ - - * @since 2.0 - */ -class LineItemStatuses extends Component -{ - /** - * @event DefaultLineItemStatusEvent The event that is triggered when getting a default status for a line item. - * You may set [[DefaultLineItemStatusEvent::lineItemStatus]] to a desired LineItemStatus to override the default status set in control panel. - * - * Plugins can get notified when a default line item status is being fetched - * - * ```php - * use craft\commerce\events\DefaultLineItemStatusEvent; - * use craft\commerce\services\LineItemStatuses; - * use yii\base\Event; - * - * Event::on(LineItemStatuses::class, LineItemStatuses::EVENT_DEFAULT_LINE_ITEM_STATUS, function(DefaultLineItemStatusEvent $e) { - * // Perhaps determine a better default line item status than the one set in control panel - * }); - * ``` - */ - public const EVENT_DEFAULT_LINE_ITEM_STATUS = 'defaultLineItemStatus'; - - public const CONFIG_STATUSES_KEY = 'commerce.lineItemStatuses'; - - /** - * @var array|null - * @since 5.0.0 - */ - private ?array $_allLineItemStatuses = null; - - /** - * Get line item status by its handle. - */ - public function getLineItemStatusByHandle(string $handle, ?int $storeId = null): ?LineItemStatus - { - return $this->getAllLineItemStatuses($storeId)->firstWhere('handle', $handle); - } - - /** - * Get default lineItem status ID from the DB - * - * @noinspection PhpUnused - */ - public function getDefaultLineItemStatusId(?int $storeId = null): ?int - { - return $this->getDefaultLineItemStatus($storeId)?->id; - } - - /** - * Get default lineItem status from the DB - */ - public function getDefaultLineItemStatus(?int $storeId = null): ?LineItemStatus - { - return $this->getAllLineItemStatuses($storeId)->firstWhere('default', true); - } - - /** - * Get the default lineItem status for a particular lineItem. Defaults to the default lineItem status as configured - * in the control panel. - */ - public function getDefaultLineItemStatusForLineItem(LineItem $lineItem): ?LineItemStatus - { - if (!$order = $lineItem->getOrder()) { - return null; - } - - $lineItemStatus = $this->getDefaultLineItemStatus($order->getStore()->id); - - $event = new DefaultLineItemStatusEvent(); - $event->lineItemStatus = $lineItemStatus; - $event->lineItem = $lineItem; - - $this->trigger(self::EVENT_DEFAULT_LINE_ITEM_STATUS, $event); - - return $event->lineItemStatus; - } - - /** - * Save the line item status. - * - * @param bool $runValidation should we validate this line item status before saving. - * @throws Exception - * @throws ErrorException - */ - public function saveLineItemStatus(LineItemStatus $lineItemStatus, bool $runValidation = true): bool - { - $isNewStatus = !$lineItemStatus->id; - - if ($runValidation && !$lineItemStatus->validate()) { - Craft::info('Line item status not saved due to validation error.', __METHOD__); - - return false; - } - - if ($isNewStatus) { - $statusUid = StringHelper::UUID(); - } else { - $statusUid = Db::uidById(Table::LINEITEMSTATUSES, $lineItemStatus->id); - } - - // Make sure no statuses that are not archived share the handle - // @TODO Confirm LineItemStatus validation already enforces handle uniqueness per store and remove this duplicate runtime check if so - $existingStatus = $this->getLineItemStatusByHandle($lineItemStatus->handle, $lineItemStatus->storeId); - - if ($existingStatus && (!$lineItemStatus->id || $lineItemStatus->id !== $existingStatus->id)) { - $lineItemStatus->addError('handle', Craft::t('commerce', 'That handle is already in use')); - return false; - } - - $projectConfig = Craft::$app->getProjectConfig(); - - if ($lineItemStatus->isArchived) { - $configData = null; - } else { - $configData = $lineItemStatus->getConfig(); - } - - $configPath = self::CONFIG_STATUSES_KEY . '.' . $statusUid; - $projectConfig->set($configPath, $configData); - - if ($isNewStatus) { - $lineItemStatus->id = Db::idByUid(Table::LINEITEMSTATUSES, $statusUid); - } - - $this->_clearCaches(); - - return true; - } - - /** - * Handle line item status change. - * - * @throws Throwable if reasons - */ - public function handleChangedLineItemStatus(ConfigEvent $event): void - { - ProjectConfigData::ensureAllStoresProcessed(); - - $statusUid = $event->tokenMatches[0]; - $data = $event->newValue; - - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - $statusRecord = $this->_getLineItemStatusRecord($statusUid); - $store = Plugin::getInstance()->getStores()->getStoreByUid($data['store']); - - $statusRecord->storeId = $store->id; - $statusRecord->name = $data['name']; - $statusRecord->handle = $data['handle']; - $statusRecord->color = $data['color']; - $statusRecord->sortOrder = $data['sortOrder'] ?? 99; - $statusRecord->default = $data['default']; - $statusRecord->uid = $statusUid; - $statusRecord->isArchived = false; - $statusRecord->dateArchived = null; - - $statusRecord->save(false); - - if ($statusRecord->default) { - LineItemStatusRecord::updateAll(['default' => 0], ['and', - ['not', ['id' => $statusRecord->id]], - ['storeId' => $statusRecord->storeId], - ]); - } - - $transaction->commit(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - } - - /** - * Archive an line item status by it's id. - * - * @throws Throwable - */ - public function archiveLineItemStatusById(int $id, ?int $storeId = null): bool - { - $status = $this->getLineItemStatusById($id, $storeId); - if ($status) { - $status->isArchived = true; - return $this->saveLineItemStatus($status); - } - return false; - } - - - /** - * Handle line item status being archived - * - * @throws Throwable if reasons - */ - public function handleArchivedLineItemStatus(ConfigEvent $event): void - { - $lineItemStatusUid = $event->tokenMatches[0]; - - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - $lineItemStatusRecord = $this->_getLineItemStatusRecord($lineItemStatusUid); - - $lineItemStatusRecord->isArchived = true; - $lineItemStatusRecord->dateArchived = Db::prepareDateForDb(new DateTime()); - - // Save the volume - $lineItemStatusRecord->save(false); - - $transaction->commit(); - - $this->_clearCaches(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - } - - /** - * Returns all Order Statuses - * - * @param int|null $storeId - * @return Collection - * @throws SiteNotFoundException - * @throws InvalidConfigException - */ - public function getAllLineItemStatuses(?int $storeId = null): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if ($this->_allLineItemStatuses === null || !isset($this->_allLineItemStatuses[$storeId])) { - $results = $this->_createLineItemStatusesQuery() - ->andWhere(['storeId' => $storeId]) - ->all(); - - // Start with a blank slate if it isn't memoized - if ($this->_allLineItemStatuses === null) { - $this->_allLineItemStatuses = []; - } - - foreach ($results as $result) { - $lineItemStatus = Craft::createObject([ - 'class' => LineItemStatus::class, - 'attributes' => $result, - ]); - - if (!isset($this->_allLineItemStatuses[$lineItemStatus->storeId])) { - $this->_allLineItemStatuses[$lineItemStatus->storeId] = collect(); - } - - $this->_allLineItemStatuses[$lineItemStatus->storeId]->push($lineItemStatus); - } - } - - return $this->_allLineItemStatuses[$storeId] ?? collect(); - } - - /** - * Get a line item status by ID - */ - public function getLineItemStatusById(int $id, ?int $storeId = null): ?LineItemStatus - { - return $this->getAllLineItemStatuses($storeId)->firstWhere('id', $id); - } - - /** - * Reorders the line item statuses. - * - * @throws Exception - * @throws ErrorException - * @throws NotSupportedException - * @throws ServerErrorHttpException - */ - public function reorderLineItemStatuses(array $ids): bool - { - $projectConfig = Craft::$app->getProjectConfig(); - - $uidsByIds = Db::uidsByIds(Table::LINEITEMSTATUSES, $ids); - - foreach ($ids as $lineItemStatus => $statusId) { - if (!empty($uidsByIds[$statusId])) { - $statusUid = $uidsByIds[$statusId]; - $projectConfig->set(self::CONFIG_STATUSES_KEY . '.' . $statusUid . '.sortOrder', $lineItemStatus + 1); - } - } - - $this->_clearCaches(); - - return true; - } - - /** - * Returns a Query object prepped for retrieving line item statuses - */ - private function _createLineItemStatusesQuery(): Query - { - return (new Query()) - ->select([ - 'color', - 'default', - 'handle', - 'id', - 'name', - 'sortOrder', - 'storeId', - 'uid', - ]) - ->where(['isArchived' => false]) - ->orderBy('sortOrder') - ->from([Table::LINEITEMSTATUSES]); - } - - /** - * Gets an lineitem status' record by uid. - */ - private function _getLineItemStatusRecord(string $uid): LineItemStatusRecord - { - if ($lineItemStatus = LineItemStatusRecord::findOne(['uid' => $uid])) { - return $lineItemStatus; - } - - return new LineItemStatusRecord(); - } - - /** - * Clear all memoization - * - * @since 3.2.5 - */ - public function _clearCaches(): void - { - $this->_allLineItemStatuses = null; - } -} diff --git a/src/services/LineItems.php b/src/services/LineItems.php deleted file mode 100644 index 52e945b769..0000000000 --- a/src/services/LineItems.php +++ /dev/null @@ -1,587 +0,0 @@ - - * @since 2.0 - */ -class LineItems extends Component -{ - /** - * @event LineItemEvent The event that is triggered before a line item is saved. - * - * ```php - * use craft\commerce\events\LineItemEvent; - * use craft\commerce\services\LineItems; - * use craft\commerce\models\LineItem; - * use yii\base\Event; - * - * Event::on( - * LineItems::class, - * LineItems::EVENT_BEFORE_SAVE_LINE_ITEM, - * function(LineItemEvent $event) { - * // @var LineItem $lineItem - * $lineItem = $event->lineItem; - * // @var bool $isNew - * $isNew = $event->isNew; - * - * // Notify a third party service about changes to an order - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_SAVE_LINE_ITEM = 'beforeSaveLineItem'; - - /** - * @event LineItemEvent The event that is triggered after a line item is saved. - * - * ```php - * use craft\commerce\events\LineItemEvent; - * use craft\commerce\services\LineItems; - * use craft\commerce\models\LineItem; - * use yii\base\Event; - * - * Event::on( - * LineItems::class, - * LineItems::EVENT_AFTER_SAVE_LINE_ITEM, - * function(LineItemEvent $event) { - * // @var LineItem $lineItem - * $lineItem = $event->lineItem; - * // @var bool $isNew - * $isNew = $event->isNew; - * - * // Reserve stock - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_SAVE_LINE_ITEM = 'afterSaveLineItem'; - - /** - * @event LineItemEvent The event that is triggered after a line item has been created from a purchasable. - * - * ```php - * use craft\commerce\events\LineItemEvent; - * use craft\commerce\services\LineItems; - * use craft\commerce\models\LineItem; - * use yii\base\Event; - * - * Event::on( - * LineItems::class, - * LineItems::EVENT_CREATE_LINE_ITEM, - * function(LineItemEvent $event) { - * // @var LineItem $lineItem - * $lineItem = $event->lineItem; - * // @var bool $isNew - * $isNew = $event->isNew; - * - * // Call a third party service based on the line item options - * // ... - * } - * ); - * ``` - */ - public const EVENT_CREATE_LINE_ITEM = 'createLineItem'; - - /** - * @event LineItemEvent The event that is triggered as a line item is being populated from a purchasable. - * - * ```php - * use craft\commerce\events\LineItemEvent; - * use craft\commerce\services\LineItems; - * use craft\commerce\models\LineItem; - * use yii\base\Event; - * - * Event::on( - * LineItems::class, - * LineItems::EVENT_POPULATE_LINE_ITEM, - * function(LineItemEvent $event) { - * // @var LineItem $lineItem - * $lineItem = $event->lineItem; - * // @var bool $isNew - * $isNew = $event->isNew; - * - * // Modify the price of a line item - * // ... - * } - * ); - * ``` - */ - public const EVENT_POPULATE_LINE_ITEM = 'populateLineItem'; - - /** - * Returns an order's line items, per the order's ID. - * - * @param int $orderId the order's ID - * @return LineItem[] An array of all the line items for the matched order. - */ - public function getAllLineItemsByOrderId(int $orderId): array - { - $results = $this->_createLineItemQuery() - ->where(['orderId' => $orderId]) - ->all(); - - $lineItems = []; - - foreach ($results as $result) { - $result['snapshot'] = Json::decodeIfJson($result['snapshot']); - $lineItem = new LineItem($result); - $lineItems[] = $lineItem; - } - - return $lineItems; - } - - /** - * Takes an order, a purchasable ID, options, and resolves it to a line item. - * - * If a line item is found for that order ID with those exact options, that line item is - * returned. Otherwise, a new line item is returned. - * - * @param Order $order - * @param int $purchasableId the purchasable's ID - * @param array $options Options for the line item - * @return LineItem - * @throws \Exception - */ - public function resolveLineItem(Order $order, int $purchasableId, array $options = [], array $params = []): LineItem - { - $signature = LineItemHelper::generateOptionsSignature($options); - - $result = $order->id ? $this->_createLineItemQuery() - ->where([ - 'orderId' => $order->id, - 'purchasableId' => $purchasableId, - 'optionsSignature' => $signature, - ]) - ->one() : null; - - if ($result) { - $lineItem = new LineItem($result); - } else { - $params = array_merge([ - 'qty' => 1, - 'options' => $options, - 'note' => '', - 'purchasableId' => $purchasableId, - ], $params); - - $lineItem = $this->create($order, $params); - } - - return $lineItem; - } - - /** - * @param Order $order - * @param string $sku - * @param array $options - * @return LineItem - * @throws Exception - * @throws InvalidConfigException - * @throws SiteNotFoundException - * @since 5.1.0 - */ - public function resolveCustomLineItem(Order $order, string $sku, array $options = []): LineItem - { - $signature = LineItemHelper::generateOptionsSignature($options); - - $result = $order->id ? $this->_createLineItemQuery() - ->where([ - 'orderId' => $order->id, - 'sku' => $sku, - 'optionsSignature' => $signature, - 'type' => LineItemType::Custom->value, - ]) - ->one() : null; - - if ($result) { - $lineItem = new LineItem($result); - } else { - $lineItem = $this->create($order, [ - 'sku' => $sku, - 'options' => $options, - ], LineItemType::Custom); - } - - return $lineItem; - } - - /** - * Save a line item. - * - * @param LineItem $lineItem The line item to save. - * @param bool $runValidation Whether the Line Item should be validated. - * @throws Throwable - */ - public function saveLineItem(LineItem $lineItem, bool $runValidation = true): bool - { - $isNewLineItem = !$lineItem->id; - - if ($isNewLineItem) { - $lineItemRecord = new LineItemRecord(); - } else { - $lineItemRecord = LineItemRecord::findOne($lineItem->id); - - if (!$lineItemRecord) { - throw new LineItemNotFoundException('Line with ID ”' . $lineItem->id . '“ not found!'); - } - } - - // Raise a 'beforeSaveLineItem' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_SAVE_LINE_ITEM)) { - $this->trigger(self::EVENT_BEFORE_SAVE_LINE_ITEM, new LineItemEvent([ - 'lineItem' => $lineItem, - 'isNew' => $isNewLineItem, - ])); - } - - if ($runValidation && !$lineItem->validate()) { - Craft::info('Line Item not saved due to validation error(s).', __METHOD__); - return false; - } - - $lineItemRecord->type = $lineItem->type->value; - - // Set the default for type dependent properties - $lineItemRecord->hasFreeShipping = null; - $lineItemRecord->isPromotable = null; - $lineItemRecord->isShippable = null; - $lineItemRecord->isTaxable = null; - - // Save this information for all line item types, even though live lookups will happen for line items with purchasables - $lineItemRecord->hasFreeShipping = $lineItem->getHasFreeShipping(); - $lineItemRecord->isPromotable = $lineItem->getIsPromotable(); - $lineItemRecord->isShippable = $lineItem->getIsShippable(); - $lineItemRecord->isTaxable = $lineItem->getIsTaxable(); - - $lineItemRecord->purchasableId = $lineItem->purchasableId; - $lineItemRecord->orderId = $lineItem->orderId; - $lineItemRecord->taxCategoryId = $lineItem->taxCategoryId; - $lineItemRecord->shippingCategoryId = $lineItem->shippingCategoryId; - $lineItemRecord->sku = $lineItem->getSku(); - $lineItemRecord->description = $lineItem->getDescription(); - - $lineItemRecord->options = $lineItem->getOptions(); - $lineItemRecord->optionsSignature = $lineItem->getOptionsSignature(); - - $lineItemRecord->qty = $lineItem->qty; - $lineItemRecord->price = $lineItem->price; - $lineItemRecord->promotionalPrice = $lineItem->promotionalPrice; - - $lineItemRecord->weight = $lineItem->weight; - $lineItemRecord->width = $lineItem->width; - $lineItemRecord->length = $lineItem->length; - $lineItemRecord->height = $lineItem->height; - - $lineItemRecord->snapshot = $lineItem->getSnapshot(); - $lineItemRecord->note = LitEmoji::unicodeToShortcode($lineItem->note); - $lineItemRecord->privateNote = LitEmoji::unicodeToShortcode($lineItem->privateNote); - $lineItemRecord->lineItemStatusId = $lineItem->lineItemStatusId; - - $lineItemRecord->promotionalAmount = $lineItem->promotionalAmount; - $lineItemRecord->salePrice = $lineItem->salePrice; - $lineItemRecord->total = $lineItem->getTotal(); - $lineItemRecord->subtotal = $lineItem->getSubtotal(); - - if ($lineItem->uid) { - $lineItemRecord->uid = $lineItem->uid; - } - - if (!$lineItem->hasErrors()) { - $db = Craft::$app->getDb(); - $transaction = $db->beginTransaction(); - - try { - $success = $lineItemRecord->save(false); - - if ($success) { - $dateCreated = DateTimeHelper::toDateTime($lineItemRecord->dateCreated); - $dateUpdated = DateTimeHelper::toDateTime($lineItemRecord->dateUpdated); - $lineItem->dateCreated = $dateCreated; - $lineItem->dateUpdated = $dateUpdated; - $lineItem->uid = $lineItemRecord->uid; - - if ($isNewLineItem) { - $lineItem->id = $lineItemRecord->id; - } - - $transaction->commit(); - } - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - - if ($success && $this->hasEventHandlers(self::EVENT_AFTER_SAVE_LINE_ITEM)) { - $this->trigger(self::EVENT_AFTER_SAVE_LINE_ITEM, new LineItemEvent([ - 'lineItem' => $lineItem, - 'isNew' => $isNewLineItem, - ])); - } - - return $success; - } - - return false; - } - - /** - * Get a line item by its ID. - * - * @param int $id the line item ID - * @return LineItem|null Line item or null, if not found. - */ - public function getLineItemById(int $id): ?LineItem - { - $result = $this->_createLineItemQuery() - ->where(['id' => $id]) - ->one(); - - if ($result) { - // Unpack the snapshot - $result['snapshot'] = Json::decodeIfJson($result['snapshot']); - } - - return $result ? new LineItem($result) : null; - } - - /** - * Create a line item. - * - * @param Order $order The order the line item is associated with - * @param int $purchasableId The ID of the purchasable the line item represents - * @param array $options Options to set on the line item - * @param int $qty The quantity to set on the line item - * @param string $note The note on the line item - * @param string|null $uid - * @throws \Exception - * @deprecated in 5.1.0. Use [[create()]] instead. - */ - public function createLineItem(Order $order, int $purchasableId, array $options, int $qty = 1, string $note = '', string $uid = null): LineItem - { - Craft::$app->getDeprecator()->log(__METHOD__, 'LineItems::createLineItem() has been deprecated. Use LineItems::create() instead.'); - $lineItem = new LineItem(); - $lineItem->qty = $qty; - $lineItem->setOptions($options); - $lineItem->note = $note; - $lineItem->uid = $uid ?: StringHelper::UUID(); - $lineItem->setOrder($order); - - $forCustomer = $order->customerId ?? false; - $purchasable = Plugin::getInstance()->getPurchasables()->getPurchasableById($purchasableId, $order->orderSiteId, $forCustomer); - - if ($purchasable) { - $lineItem->setPurchasable($purchasable); - $lineItem->populate($purchasable); - } else { - throw new InvalidArgumentException('Invalid purchasable ID'); - } - - // Raise a 'createLineItem' event - if ($this->hasEventHandlers(self::EVENT_CREATE_LINE_ITEM)) { - $this->trigger(self::EVENT_CREATE_LINE_ITEM, new LineItemEvent([ - 'lineItem' => $lineItem, - 'isNew' => true, - ])); - } - - $lineItem->refresh(); - - return $lineItem; - } - - /** - * @param Order $order - * @param array $params - * @param LineItemType $type - * @return LineItem - * @throws Exception - * @throws SiteNotFoundException - * @throws InvalidConfigException - * @since 5.1.0 - */ - public function create(Order $order, array $params = [], LineItemType $type = LineItemType::Purchasable): LineItem - { - $params = array_merge([ - 'qty' => 1, - 'options' => [], - 'note' => '', - 'uid' => StringHelper::UUID(), - ], $params); - - $params['order'] = $order; - $params['type'] = $type; - - if ($type === LineItemType::Purchasable && empty($params['purchasableId']) && empty($params['purchasable'])) { - throw new InvalidArgumentException('Purchasable ID or Purchasable must be set'); - } - - $params['class'] = LineItem::class; - /** @var LineItem $lineItem */ - $lineItem = Craft::createObject($params); - - if ($lineItem->type === LineItemType::Purchasable) { - $purchasable = $lineItem->getPurchasable(); - - if ($purchasable) { - $lineItem->setPurchasable($purchasable); - $lineItem->populate($purchasable); - } else { - throw new InvalidArgumentException('Invalid purchasable ID'); - } - } else { - $lineItem->populate(); - } - - // Raise a 'createLineItem' event - if ($this->hasEventHandlers(self::EVENT_CREATE_LINE_ITEM)) { - $this->trigger(self::EVENT_CREATE_LINE_ITEM, new LineItemEvent([ - 'lineItem' => $lineItem, - 'isNew' => true, - ])); - } - - $lineItem->refresh(); - - return $lineItem; - } - - /** - * Deletes all line items associated with an order, per the order's ID. - * - * @param int $orderId the order's ID - * @return bool whether any line items were deleted - */ - public function deleteAllLineItemsByOrderId(int $orderId): bool - { - return (bool)LineItemRecord::deleteAll(['orderId' => $orderId]); - } - - /** - * @param array|Order[] $orders - * @return Order[] - * @since 3.2.0 - */ - public function eagerLoadLineItemsForOrders(array $orders): array - { - $orderIds = ArrayHelper::getColumn($orders, 'id'); - $lineItemsResults = $this->_createLineItemQuery()->andWhere(['orderId' => $orderIds])->all(); - - $lineItems = []; - - foreach ($lineItemsResults as $result) { - $result['snapshot'] = Json::decodeIfJson($result['snapshot']); - $lineItem = new LineItem($result); - $lineItems[$lineItem->orderId] ??= []; - $lineItems[$lineItem->orderId][] = $lineItem; - } - - foreach ($orders as $key => $order) { - if (isset($lineItems[$order->id])) { - $order->setLineItems($lineItems[$order->id]); - $orders[$key] = $order; - } - } - - return $orders; - } - - /** - * - * @throws Throwable - * @since 3.2.5 - */ - public function orderCompleteHandler(LineItem $lineItem, Order $order): void - { - // Called the after order complete method for the purchasable if there is one - if ($lineItem->type === LineItemType::Purchasable && $lineItem->getPurchasable()) { - $lineItem->getPurchasable()->afterOrderComplete($order, $lineItem); - } - - // Retrieve the default status for the current line item. This is a chance for - // developers to hook into an event for finer control - $defaultStatus = Plugin::getInstance()->getLineItemStatuses()->getDefaultLineItemStatusForLineItem($lineItem); - if (!$defaultStatus) { - return; - } - - // Set the status ID and save the line item - $lineItem->setLineItemStatus($defaultStatus); - $this->saveLineItem($lineItem, false); - } - - /** - * Returns a Query object prepped for retrieving line items. - * - * @return Query The query object. - */ - private function _createLineItemQuery(): Query - { - return (new Query()) - ->select([ - 'dateCreated', - 'dateUpdated', - 'description', - 'hasFreeShipping', - 'height', - 'id', - 'isPromotable', - 'isShippable', - 'isTaxable', - 'length', - 'lineItemStatusId', - 'note', - 'options', - 'orderId', - 'price', - 'promotionalPrice', - 'privateNote', - 'purchasableId', - 'qty', - 'shippingCategoryId', - 'sku', - 'snapshot', - 'taxCategoryId', - 'type', - 'uid', - 'weight', - 'width', - ]) - ->from([Table::LINEITEMS . ' lineItems']) - ->orderBy('dateCreated DESC'); - } -} diff --git a/src/services/OrderAdjustments.php b/src/services/OrderAdjustments.php deleted file mode 100644 index c412a44073..0000000000 --- a/src/services/OrderAdjustments.php +++ /dev/null @@ -1,290 +0,0 @@ - - * @since 2.0 - */ -class OrderAdjustments extends Component -{ - /** - * @event RegisterComponentTypesEvent The event that is triggered for registration of additional adjusters. - * - * ```php - * use craft\events\RegisterComponentTypesEvent; - * use craft\commerce\services\OrderAdjustments; - * use yii\base\Event; - * - * Event::on( - * OrderAdjustments::class, - * OrderAdjustments::EVENT_REGISTER_ORDER_ADJUSTERS, - * function(RegisterComponentTypesEvent $event) { - * $event->types[] = MyAdjuster::class; - * } - * ); - * ``` - */ - public const EVENT_REGISTER_ORDER_ADJUSTERS = 'registerOrderAdjusters'; - - /** - * @event RegisterComponentTypesEvent The event that is triggered for registration of additional adjusters. - * @since 3.1.9 - * - * ```php - * use craft\events\RegisterComponentTypesEvent; - * use craft\commerce\services\OrderAdjustments; - * use yii\base\Event; - * - * Event::on( - * OrderAdjustments::class, - * OrderAdjustments::EVENT_REGISTER_DISCOUNT_ADJUSTERS, - * function(RegisterComponentTypesEvent $event) { - * $event->types[] = MyDiscountAdjuster::class; - * } - * ); - * ``` - */ - public const EVENT_REGISTER_DISCOUNT_ADJUSTERS = 'registerDiscountAdjusters'; - - - /** - * Get all order adjusters. - * - * @return class-string[] - * @throws InvalidConfigException - */ - public function getAdjusters(): array - { - $adjusters = []; - - $adjusters[] = Shipping::class; - - foreach ($this->getDiscountAdjusters() as $discountAdjuster) { - $adjusters[] = $discountAdjuster; - } - - $taxEngine = Plugin::getInstance()->getTaxes()->getEngine(); - $adjusters[] = $taxEngine->taxAdjusterClass(); - - - $event = new RegisterComponentTypesEvent([ - 'types' => $adjusters, - ]); - - if ($this->hasEventHandlers(self::EVENT_REGISTER_ORDER_ADJUSTERS)) { - $this->trigger(self::EVENT_REGISTER_ORDER_ADJUSTERS, $event); - } - - return $event->types; - } - - public function getOrderAdjustmentById(int $id): ?OrderAdjustment - { - $row = $this->_createOrderAdjustmentQuery() - ->where(['id' => $id]) - ->one(); - - if (!$row) { - return null; - } - - $row['sourceSnapshot'] = Json::decodeIfJson($row['sourceSnapshot']); - return new OrderAdjustment($row); - } - - /** - * Get all order adjustments by order's ID. - * - * @return OrderAdjustment[] - */ - public function getAllOrderAdjustmentsByOrderId(int $orderId): array - { - $rows = $this->_createOrderAdjustmentQuery() - ->where(['orderId' => $orderId]) - ->all(); - - $adjustments = []; - - foreach ($rows as $row) { - $row['sourceSnapshot'] = Json::decodeIfJson($row['sourceSnapshot']); - $adjustments[] = new OrderAdjustment($row); - } - - return $adjustments; - } - - /** - * Save an order adjustment. - * - * @param bool $runValidation Whether the Order Adjustment should be validated - * @throws Exception - */ - public function saveOrderAdjustment(OrderAdjustment $orderAdjustment, bool $runValidation = true): bool - { - $newAdjustment = !$orderAdjustment->id; - - if ($newAdjustment) { - $record = new OrderAdjustmentRecord(); - } else { - $record = OrderAdjustmentRecord::findOne($orderAdjustment->id); - - if (!$record) { - throw new OrderAdjustmentNotFoundException('Order Adjustment with ID ”' . $orderAdjustment->id . '“ not found!'); - } - } - - if ($runValidation && !$orderAdjustment->validate()) { - Craft::info('Order Adjustment not saved due to validation error(s).', __METHOD__); - return false; - } - - $record->name = $orderAdjustment->name; - $record->type = $orderAdjustment->type; - $record->description = $orderAdjustment->description; - $record->amount = $orderAdjustment->amount; - $record->included = $orderAdjustment->included; - $record->sourceSnapshot = $orderAdjustment->getSourceSnapshot(); - $record->lineItemId = $orderAdjustment->getLineItem()->id ?? null; - $record->orderId = $orderAdjustment->getOrder()->id ?? null; - $record->isEstimated = $orderAdjustment->isEstimated; - - $record->save(false); - - // Update the model with the latest IDs - $orderAdjustment->id = $record->id; - $orderAdjustment->orderId = $record->orderId; - $orderAdjustment->lineItemId = $record->lineItemId; - - return true; - } - - - /** - * Delete all adjustments belonging to an order by its ID. - * - * @noinspection PhpUnused - */ - public function deleteAllOrderAdjustmentsByOrderId(int $orderId): bool - { - return (bool)OrderAdjustmentRecord::deleteAll(['orderId' => $orderId]); - } - - /** - * Delete an order adjustment by its ID. - * - * @throws Throwable - * @throws StaleObjectException - * @noinspection PhpUnused - */ - public function deleteOrderAdjustmentByAdjustmentId(int $adjustmentId): bool - { - $orderAdjustment = OrderAdjustmentRecord::findOne($adjustmentId); - - if (!$orderAdjustment) { - return false; - } - - return $orderAdjustment->delete(); - } - - /** - * @param array|Order[] $orders - * @return Order[] - * @since 3.2.0 - */ - public function eagerLoadOrderAdjustmentsForOrders(array $orders): array - { - $orderIds = ArrayHelper::getColumn($orders, 'id'); - $orderAdjustmentResults = $this->_createOrderAdjustmentQuery()->andWhere(['orderId' => $orderIds])->all(); - - $orderAdjustments = []; - - foreach ($orderAdjustmentResults as $result) { - $result['sourceSnapshot'] = Json::decodeIfJson($result['sourceSnapshot']); - $adjustment = new OrderAdjustment($result); - - $orderAdjustments[$adjustment->orderId] ??= []; - $orderAdjustments[$adjustment->orderId][] = $adjustment; - } - - foreach ($orders as $key => $order) { - if (isset($orderAdjustments[$order->id])) { - $order->setAdjustments($orderAdjustments[$order->id]); - $orders[$key] = $order; - } - } - - return $orders; - } - - /** - * Returns a Query object prepped for retrieving Order Adjustment. - * - * @return Query The query object. - */ - private function _createOrderAdjustmentQuery(): Query - { - return (new Query()) - ->select([ - 'amount', - 'description', - 'id', - 'included', - 'isEstimated', - 'lineItemId', - 'name', - 'orderId', - 'sourceSnapshot', - 'type', - ]) - ->from([Table::ORDERADJUSTMENTS]); - } - - /** - * @return class-string[] - */ - public function getDiscountAdjusters(): array - { - $discountEvent = new RegisterComponentTypesEvent([ - 'types' => [], - ]); - - if ($this->hasEventHandlers(self::EVENT_REGISTER_DISCOUNT_ADJUSTERS)) { - $this->trigger(self::EVENT_REGISTER_DISCOUNT_ADJUSTERS, $discountEvent); - } - - $discountEvent->types[] = Discount::class; - - return $discountEvent->types; - } -} diff --git a/src/services/OrderHistories.php b/src/services/OrderHistories.php deleted file mode 100644 index 5f336bd942..0000000000 --- a/src/services/OrderHistories.php +++ /dev/null @@ -1,232 +0,0 @@ - - * @since 2.0 - */ -class OrderHistories extends Component -{ - /** - * @event OrderStatusEvent The event that is triggered when an order status is changed. - * - * Plugins can get notified when an order status is changed - * - * ```php - * use craft\commerce\events\OrderStatusEvent; - * use craft\commerce\services\OrderHistories; - * use craft\commerce\models\OrderHistory; - * use craft\commerce\elements\Order; - * use yii\base\Event; - * - * Event::on( - * OrderHistories::class, - * OrderHistories::EVENT_ORDER_STATUS_CHANGE, - * function(OrderStatusEvent $event) { - * // @var OrderHistory $orderHistory - * $orderHistory = $event->orderHistory; - * // @var Order $order - * $order = $event->order; - * - * // Let the delivery department know the order’s ready to be delivered - * // ... - * } - * ); - * ``` - */ - public const EVENT_ORDER_STATUS_CHANGE = 'orderStatusChange'; - - /** - * Get order history by its ID. - */ - public function getOrderHistoryById(int $id): ?OrderHistory - { - $result = $this->_createOrderHistoryQuery() - ->where(['id' => $id]) - ->one(); - - return $result ? new OrderHistory($result) : null; - } - - /** - * Get all order histories by an order ID. - * - * @param int $id orderId - * @return OrderHistory[] - */ - public function getAllOrderHistoriesByOrderId(int $id): array - { - $rows = $this->_createOrderHistoryQuery() - ->where(['orderId' => $id]) - ->orderBy('dateCreated desc, id desc') - ->all(); - - $histories = []; - - foreach ($rows as $row) { - $histories[] = new OrderHistory($row); - } - - return $histories; - } - - /** - * Create an order history from an order. - * - * @throws Exception - * @throws InvalidConfigException - * @throws MissingComponentException - */ - public function createOrderHistoryFromOrder(Order $order, ?int $oldStatusId): bool - { - $orderHistoryModel = new OrderHistory(); - $orderHistoryModel->orderId = $order->id; - $orderHistoryModel->prevStatusId = $oldStatusId; - $orderHistoryModel->newStatusId = $order->orderStatusId; - - // By default the user who changed the status is the same as the user who placed the order - $userId = $order->getCustomerId(); - - // If the user is logged in, use the current user - if (!Craft::$app->request->isConsoleRequest - && !Craft::$app->getResponse()->isSent - && (Craft::$app->getSession()->getHasSessionId() || Craft::$app->getSession()->getIsActive()) - && $currentUser = Craft::$app->getUser()->getIdentity() - ) { - $userId = $currentUser->id; - } - - if ($userId) { - $user = Craft::$app->getUsers()->getUserById($userId); - if ($user) { - $orderHistoryModel->userId = $userId; - $orderHistoryModel->userName = $user->fullName ?? $user->email; - } else { - $orderHistoryModel->userName = $order->getEmail(); - } - } - - $orderHistoryModel->message = $order->message; - - if (!$this->saveOrderHistory($orderHistoryModel)) { - return false; - } - - Plugin::getInstance()->getOrderStatuses()->statusChangeHandler($order, $orderHistoryModel); - - // Raising 'orderStatusChange' event - if ($this->hasEventHandlers(self::EVENT_ORDER_STATUS_CHANGE)) { - $this->trigger(self::EVENT_ORDER_STATUS_CHANGE, new OrderStatusEvent([ - 'orderHistory' => $orderHistoryModel, - 'order' => $order, - ])); - } - - return true; - } - - /** - * Save an order history. - * - * @param bool $runValidation Whether the Order Adjustment should be validated - * @throws Exception - */ - public function saveOrderHistory(OrderHistory $model, bool $runValidation = true): bool - { - if ($model->id) { - $record = OrderHistoryRecord::findOne($model->id); - - if (!$record) { - throw new Exception(Craft::t('commerce', 'No order history exists with the ID “{id}”', - ['id' => $model->id])); - } - } else { - $record = new OrderHistoryRecord(); - } - - if ($runValidation && !$model->validate()) { - Craft::info('Order history not saved due to validation error.', __METHOD__); - - return false; - } - - $record->message = $model->message; - $record->newStatusId = $model->newStatusId; - $record->prevStatusId = $model->prevStatusId; - $record->userId = $model->userId; - $record->userName = $model->userName; - $record->orderId = $model->orderId; - - // Save it! - $record->save(false); - - // Now that we have a record ID, save it on the model - $model->id = $record->id; - $model->dateCreated = DateTimeHelper::toDateTime($record->dateCreated); - - return true; - } - - /** - * Delete an order history by its ID. - * - * @throws Throwable - * @throws StaleObjectException - * @noinspection PhpUnused - */ - public function deleteOrderHistoryById(int $id): bool - { - $orderHistory = OrderHistoryRecord::findOne($id); - - if ($orderHistory) { - return (bool)$orderHistory->delete(); - } - - return false; - } - - - /** - * Returns a Query object prepped for retrieving Order History. - * - * @return Query The query object. - */ - private function _createOrderHistoryQuery(): Query - { - return (new Query()) - ->select([ - 'userId', - 'dateCreated', - 'id', - 'message', - 'newStatusId', - 'orderId', - 'prevStatusId', - ]) - ->from([Table::ORDERHISTORIES]); - } -} diff --git a/src/services/OrderNotices.php b/src/services/OrderNotices.php deleted file mode 100644 index 66c7bb276a..0000000000 --- a/src/services/OrderNotices.php +++ /dev/null @@ -1,82 +0,0 @@ - - * @since 3.3 - */ -class OrderNotices extends Component -{ - /** - * @param array|Order[] $orders - * @return Order[] - * @throws InvalidConfigException - * @since 3.3 - */ - public function eagerLoadOrderNoticesForOrders(array $orders): array - { - $orderIds = ArrayHelper::getColumn($orders, 'id'); - $orderNoticesResults = $this->_createOrderNoticeQuery()->andWhere(['orderId' => $orderIds])->all(); - $orderNotices = []; - - foreach ($orderNoticesResults as $result) { - - /** @var OrderNotice $notice */ - $notice = Craft::createObject([ - 'class' => OrderNotice::class, - 'attributes' => $result, - ]); - - $orderNotices[$notice->orderId] ??= []; - $orderNotices[$notice->orderId][] = $notice; - } - - foreach ($orders as $key => $order) { - /** @var Order $order */ - if (isset($orderNotices[$order->id])) { - $order->addNotices($orderNotices[$order->id]); - $orders[$key] = $order; - } - } - - return $orders; - } - - /** - * Returns a Query object prepped for retrieving Order Adjustment. - * - * @return Query The query object. - */ - private function _createOrderNoticeQuery(): Query - { - return (new Query()) - ->select([ - 'attribute', - 'noticeType', - 'id', - 'message', - 'orderId', - 'type', - ]) - ->from([Table::ORDERNOTICES]); - } -} diff --git a/src/services/OrderStatuses.php b/src/services/OrderStatuses.php deleted file mode 100644 index 05aa964aa3..0000000000 --- a/src/services/OrderStatuses.php +++ /dev/null @@ -1,573 +0,0 @@ - - * @since 2.0 - */ -class OrderStatuses extends Component -{ - /** - * @event DefaultOrderStatusEvent The event that is triggered when a default order status is being fetched. - * - * Set the event object’s `orderStatus` property to override the default status set in the control panel. - * - * ```php - * use craft\commerce\events\DefaultOrderStatusEvent; - * use craft\commerce\services\OrderStatuses; - * use craft\commerce\models\OrderStatus; - * use craft\commerce\elements\Order; - * use yii\base\Event; - * - * Event::on( - * OrderStatuses::class, - * OrderStatuses::EVENT_DEFAULT_ORDER_STATUS, - * function(DefaultOrderStatusEvent $event) { - * // @var OrderStatus $status - * $status = $event->orderStatus; - * // @var Order $order - * $order = $event->order; - * - * // Choose a more appropriate order status than the control panel default - * // ... - * } - * ); - * ``` - */ - public const EVENT_DEFAULT_ORDER_STATUS = 'defaultOrderStatus'; - - /** - * @event OrderStatusEmailsEvent The email event that is triggered when an order status is changed. - * - * Plugins can get notified when an order status is changed - * - * ```php - * use craft\commerce\events\OrderStatusEmailsEvent; - * use craft\commerce\services\OrderStatuses; - * use craft\commerce\models\OrderHistory; - * use craft\commerce\elements\Order; - * use yii\base\Event; - * - * Event::on( - * OrderStatuses::class, - * OrderStatuses::EVENT_ORDER_STATUS_CHANGE_EMAILS, - * function(OrderStatusEmailsEvent $event) { - * // @var OrderHistory $orderHistory - * $orderHistory = $event->orderHistory; - * // @var Order $order - * $order = $event->order; - * - * // Let the delivery department know the order’s ready to be delivered - * // ... - * } - * ); - * ``` - */ - public const EVENT_ORDER_STATUS_CHANGE_EMAILS = 'orderStatusChangeEmails'; - - public const CONFIG_STATUSES_KEY = 'commerce.orderStatuses'; - - /** - * @var Collection[]|null - */ - private ?array $_allOrderStatuses = null; - - /** - * Returns all Order Statuses - * - * @param int|null $storeId - * @param bool $withTrashed - * @return Collection - * @throws InvalidConfigException - * @throws SiteNotFoundException - * @since 2.2 - */ - public function getAllOrderStatuses(?int $storeId = null, bool $withTrashed = false): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if ($this->_allOrderStatuses === null || !isset($this->_allOrderStatuses[$storeId])) { - $results = $this->_createOrderStatusesQuery(true) - ->where(['storeId' => $storeId]) - ->all(); - - if ($this->_allOrderStatuses === null) { - $this->_allOrderStatuses = []; - } - - foreach ($results as $result) { - $orderStatus = Craft::createObject([ - 'class' => OrderStatus::class, - 'attributes' => $result, - ]); - - if (!isset($this->_allOrderStatuses[$orderStatus->storeId])) { - $this->_allOrderStatuses[$orderStatus->storeId] = collect(); - } - - $this->_allOrderStatuses[$orderStatus->storeId]->push($orderStatus); - } - } - - if (!isset($this->_allOrderStatuses[$storeId])) { - return collect(); - } - - return $this->_allOrderStatuses[$storeId]->filter(fn(OrderStatus $os) => (!$withTrashed && $os->dateDeleted === null) || $withTrashed); - } - - /** - * Get an order status by ID - */ - public function getOrderStatusById(int $id, ?int $storeId = null): ?OrderStatus - { - return $this->getAllOrderStatuses($storeId)->firstWhere('id', $id); - } - - /** - * Get an order status by ID - */ - public function getOrderStatusByUid(string $uid, ?int $storeId = null): ?OrderStatus - { - return $this->getAllOrderStatuses($storeId)->firstWhere('uid', $uid); - } - - /** - * Get order status by its handle. - */ - public function getOrderStatusByHandle(string $handle, ?int $storeId = null): ?OrderStatus - { - return $this->getAllOrderStatuses($storeId)->firstWhere('handle', $handle); - } - - /** - * Get default order status from the DB - */ - public function getDefaultOrderStatus(?int $storeId = null): ?OrderStatus - { - return $this->getAllOrderStatuses($storeId)->firstWhere('default', true); - } - - /** - * Get default order status ID from the DB - * - * @noinspection PhpUnused - */ - public function getDefaultOrderStatusId(?int $storeId = null): ?int - { - return $this->getDefaultOrderStatus($storeId)?->id; - } - - /** - * Get the default order status for a particular order. Defaults to the control-panel-configured default order status. - */ - public function getDefaultOrderStatusForOrder(Order $order): ?OrderStatus - { - $orderStatus = $this->getDefaultOrderStatus($order->storeId); - - $event = new DefaultOrderStatusEvent([ - 'orderStatus' => $orderStatus, - 'order' => $order, - ]); - - if ($this->hasEventHandlers(self::EVENT_DEFAULT_ORDER_STATUS)) { - $this->trigger(self::EVENT_DEFAULT_ORDER_STATUS, $event); - } - - return $event->orderStatus; - } - - /** - * @since 3.0.11 - */ - public function getOrderCountByStatus(?int $storeId = null): array - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - $countGroupedByStatusId = (new Query()) - ->select(['[[o.orderStatusId]]', 'count(o.id) as orderCount']) - ->where([ - '[[o.isCompleted]]' => true, - '[[e.dateDeleted]]' => null, - '[[o.storeId]]' => $storeId, - ]) - ->from([Table::ORDERS . ' o']) - ->innerJoin([CraftTable::ELEMENTS . ' e'], '[[o.id]] = [[e.id]]') - ->groupBy(['[[o.orderStatusId]]']) - ->indexBy('orderStatusId') - ->all(); - - // For those not in the groupBy - $allStatuses = $this->getAllOrderStatuses($storeId); - foreach ($allStatuses as $status) { - if (!isset($countGroupedByStatusId[$status->id])) { - $countGroupedByStatusId[$status->id] = [ - 'orderStatusId' => $status->id, - 'handle' => $status->handle, - 'orderCount' => 0, - ]; - } - - // Make sure all have their handle - $countGroupedByStatusId[$status->id]['handle'] = $status->handle; - } - - return $countGroupedByStatusId; - } - - /** - * Save the order status. - * - * @param bool $runValidation should we validate this order status before saving. - * @throws Exception - */ - public function saveOrderStatus(OrderStatus $orderStatus, array $emailIds = [], bool $runValidation = true, $force = false): bool - { - $isNewStatus = !(bool)$orderStatus->id; - - if ($runValidation && !$orderStatus->validate()) { - Craft::info('Order status not saved due to validation error.', __METHOD__); - - return false; - } - - if ($isNewStatus) { - $statusUid = StringHelper::UUID(); - } else { - $statusUid = Db::uidById(Table::ORDERSTATUSES, $orderStatus->id); - } - - $otherStatuses = $this->getAllOrderStatuses($orderStatus->storeId)->where('uid', '!=', $statusUid)->all(); - - // if this is the only order status, set it as the default - $orderStatus->default = empty($otherStatuses) ? true : $orderStatus->default; - - $projectConfig = Craft::$app->getProjectConfig(); - - if ($orderStatus->dateDeleted) { - $configData = null; - } else { - $configData = $orderStatus->getConfig($emailIds); - } - - $configPath = self::CONFIG_STATUSES_KEY . '.' . $statusUid; - $projectConfig->set($configPath, $configData, force: $force); - - if ($isNewStatus) { - $orderStatus->id = Db::idByUid(Table::ORDERSTATUSES, $statusUid); - $orderStatus->uid = $statusUid; - } - - $this->_allOrderStatuses = null; - - // Make sure this is the only default - if ($orderStatus->default) { - foreach ($otherStatuses as $otherStatus) { - $otherStatus->default = false; - $this->saveOrderStatus($otherStatus, $otherStatus->getEmailIds(), false, true); - } - } - - return true; - } - - /** - * Handle order status change. - * - * @return void - * @throws Throwable if reasons - */ - public function handleChangedOrderStatus(ConfigEvent $event) - { - ProjectConfigData::ensureAllStoresProcessed(); - - $statusUid = $event->tokenMatches[0]; - $data = $event->newValue; - - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - $statusRecord = $this->_getOrderStatusRecord($statusUid); - - // Get store by uid and convert `$data['store']` to `storeId` - $store = Plugin::getInstance()->getStores()->getStoreByUid($data['store']); - - $statusRecord->name = $data['name']; - $statusRecord->storeId = $store->id; - $statusRecord->handle = $data['handle']; - $statusRecord->color = $data['color']; - $statusRecord->description = $data['description'] ?? null; - $statusRecord->sortOrder = $data['sortOrder'] ?? 99; - $statusRecord->default = $data['default']; - $statusRecord->uid = $statusUid; - - // Save the status - if ($wasTrashed = (bool)$statusRecord->dateDeleted) { - $statusRecord->restore(); - } else { - $statusRecord->save(false); - } - - $connection = Craft::$app->getDb(); - // Drop them all and we will recreate the new ones. - $connection->createCommand()->delete(Table::ORDERSTATUS_EMAILS, ['orderStatusId' => $statusRecord->id])->execute(); - - if (!empty($data['emails'])) { - foreach ($data['emails'] as $emailUid) { - Craft::$app->projectConfig->processConfigChanges(Emails::CONFIG_EMAILS_KEY . '.' . $emailUid); - } - - $emailIds = Db::idsByUids(Table::EMAILS, $data['emails']); - - foreach ($emailIds as $emailId) { - $connection->createCommand() - ->insert(Table::ORDERSTATUS_EMAILS, [ - 'orderStatusId' => $statusRecord->id, - 'emailId' => $emailId, - ]) - ->execute(); - } - } - - $transaction->commit(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - } - - /** - * Delete an order status by it's id. - * - * @throws Throwable - */ - public function deleteOrderStatusById(int $id, ?int $storeId = null): bool - { - $statuses = $this->getAllOrderStatuses($storeId); - $orderStatus = $this->getOrderStatusById($id, $storeId); - - // Can only delete if we have one that can remain as the default - if (count($statuses) < 2 || $orderStatus == null) { - return false; - } - - // Prevent deletion of order status if there are orders with this status - $orderCounts = $this->getOrderCountByStatus($storeId); - if (!isset($orderCounts[$id]) || $orderCounts[$id]['orderCount'] > 0) { - return false; - } - - Craft::$app->getProjectConfig()->remove(self::CONFIG_STATUSES_KEY . '.' . $orderStatus->uid); - return true; - } - - - /** - * Handle order status being deleted - * - * @return void - * @throws Throwable if reasons - */ - public function handleDeletedOrderStatus(ConfigEvent $event) - { - $orderStatusUid = $event->tokenMatches[0]; - - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - $orderStatusRecord = $this->_getOrderStatusRecord($orderStatusUid); - - // Save the volume - $orderStatusRecord->softDelete(); - - $transaction->commit(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - - // Clear caches - $this->_allOrderStatuses = null; - } - - /** - * Prune a deleted email from order statuses. - */ - public function pruneDeletedEmail(EmailEvent $event) - { - $emailUid = $event->email->uid; - - $projectConfig = Craft::$app->getProjectConfig(); - $statuses = $projectConfig->get(self::CONFIG_STATUSES_KEY); - - // Loop through the volumes and prune the UID from field layouts. - if (is_array($statuses)) { - foreach ($statuses as $orderStatusUid => $orderStatus) { - $projectConfig->remove(self::CONFIG_STATUSES_KEY . '.' . $orderStatusUid . '.emails.' . $emailUid); - } - } - } - - /** - * Handler for order status change event - * - * @param Order $order - * @param OrderHistory $orderHistory - * @throws InvalidConfigException - */ - public function statusChangeHandler(Order $order, OrderHistory $orderHistory): void - { - $status = $this->getOrderStatusById($order->orderStatusId, $order->storeId); - - if ($status === null) { - return; - } - - // Raising 'beforeOrderStatusChange' event - $event = new OrderStatusEmailsEvent([ - 'orderHistory' => $orderHistory, - 'order' => $order, - 'emails' => $status->getEmails(), - 'isValid' => !$order->suppressEmails, - ]); - - if ($this->hasEventHandlers(self::EVENT_ORDER_STATUS_CHANGE_EMAILS)) { - $this->trigger(self::EVENT_ORDER_STATUS_CHANGE_EMAILS, $event); - } - - if (!$event->isValid || empty($event->emails)) { - // Don't send emails - return; - } - - $originalLanguage = Craft::$app->language; - $originalFormattingLocale = Craft::$app->formattingLocale; - - foreach ($event->emails as $email) { - if (!$email->enabled) { - continue; - } - - // Set language by email's set locale - // We need to do this here since $order->toArray() uses the locale to format asCurrency attributes - $language = $email->getRenderLanguage($event->order); - Locale::switchAppLanguage($language); - - Queue::push(new SendEmail([ - 'orderId' => $event->order->id, - 'commerceEmailId' => $email->id, - 'orderHistoryId' => $event->orderHistory->id, - 'orderData' => $event->order->toArray(), - ]), 100); - } - - // Set previous language back - Locale::switchAppLanguage($originalLanguage, $originalFormattingLocale->id); - } - - /** - * Reorders the order statuses. - * - * @throws Exception - * @throws ErrorException - * @throws NotSupportedException - * @throws ServerErrorHttpException - */ - public function reorderOrderStatuses(array $ids): bool - { - $projectConfig = Craft::$app->getProjectConfig(); - - $uidsByIds = Db::uidsByIds(Table::ORDERSTATUSES, $ids); - - foreach ($ids as $orderStatus => $statusId) { - if (!empty($uidsByIds[$statusId])) { - $statusUid = $uidsByIds[$statusId]; - $projectConfig->set(self::CONFIG_STATUSES_KEY . '.' . $statusUid . '.sortOrder', $orderStatus + 1); - } - } - - return true; - } - - - /** - * Returns a Query object prepped for retrieving order statuses - * - * @param bool $withTrashed - * @return Query - */ - private function _createOrderStatusesQuery(bool $withTrashed = false): Query - { - $query = (new Query()) - ->select([ - 'color', - 'dateDeleted', - 'default', - 'description', - 'handle', - 'id', - 'name', - 'sortOrder', - 'storeId', - 'uid', - ]) - ->orderBy('sortOrder') - ->from([Table::ORDERSTATUSES]); - - if (!$withTrashed) { - $query->where(['dateDeleted' => null]); - } - - return $query; - } - - /** - * Gets an order status' record by uid. - */ - private function _getOrderStatusRecord(string $uid): OrderStatusRecord - { - /** @var ?OrderStatusRecord $orderStatus */ - $orderStatus = OrderStatusRecord::findWithTrashed()->where(['uid' => $uid])->one(); - return $orderStatus ?: new OrderStatusRecord(); - } -} diff --git a/src/services/Orders.php b/src/services/Orders.php deleted file mode 100644 index 543d1bd2ee..0000000000 --- a/src/services/Orders.php +++ /dev/null @@ -1,322 +0,0 @@ - - * @since 2.0 - */ -class Orders extends Component -{ - public const CONFIG_FIELDLAYOUT_KEY = 'commerce.orders.fieldLayouts'; - - /** - * Handle field layout change - * - * @throws Exception - */ - public function handleChangedFieldLayout(ConfigEvent $event): void - { - $data = $event->newValue; - - ProjectConfigHelper::ensureAllFieldsProcessed(); - $fieldsService = Craft::$app->getFields(); - - if (empty($data) || empty(reset($data))) { - // Delete the field layout - $fieldsService->deleteLayoutsByType(Order::class); - return; - } - - // Save the field layout - $layout = FieldLayout::createFromConfig(reset($data)); - $layout->id = $fieldsService->getLayoutByType(Order::class)->id; - $layout->type = Order::class; - $layout->uid = key($data); - $fieldsService->saveLayout($layout, false); - } - - /** - * Handle field layout being deleted - */ - public function handleDeletedFieldLayout(): void - { - Craft::$app->getFields()->deleteLayoutsByType(Order::class); - } - - /** - * Get an order by its ID. - * - * @param int $id - * @return ?Order - */ - public function getOrderById(int $id): ?Order - { - if (!$id) { - return null; - } - - return Order::find()->id($id)->status(null)->one(); - } - - /** - * Get an order by its number. - */ - public function getOrderByNumber(string $number): ?Order - { - return Order::find()->number($number)->one(); - } - - /** - * Get all orders by their customer. - * - * @param int|User $customer - * @return Order[]|null - */ - public function getOrdersByCustomer(User|int $customer): ?array - { - if (!$customer) { - return null; - } - - $query = Order::find(); - if ($customer instanceof User) { - $query->customer($customer); - } else { - $query->customerId($customer); - } - $query->isCompleted(); - $query->limit(null); - - return $query->all(); - } - - /** - * Get all orders by their email. - * - * @return Order[]|null - */ - public function getOrdersByEmail(string $email): ?array - { - return Order::find()->email($email)->isCompleted()->limit(null)->all(); - } - - /** - * @param array|Order[] $orders - * @return Order[] - * @since 4.0.0 - */ - public function eagerLoadAddressesForOrders(array $orders): array - { - $shippingAddressIds = array_filter(ArrayHelper::getColumn($orders, 'shippingAddressId')); - $billingAddressIds = array_filter(ArrayHelper::getColumn($orders, 'billingAddressId')); - $ids = array_unique(array_merge($shippingAddressIds, $billingAddressIds)); - - // Query addresses as array to avoid instantiating elements immediately - $query = Address::find() - ->id($ids) - ->indexBy('id') - ->asArray(); - /** @var array $addresses */ - $addresses = $query->all(); - - foreach ($orders as $key => $order) { - if (isset($order['shippingAddressId'], $addresses[$order['shippingAddressId']])) { - $data = $addresses[$order['shippingAddressId']]; - $data['owner'] = $order; - /** @var Address $address */ - $address = $query->createElement($data); - - $order->setShippingAddress($address); - } - - if (isset($order['billingAddressId'], $addresses[$order['billingAddressId']])) { - $data = $addresses[$order['billingAddressId']]; - $data['owner'] = $order; - - /** @var Address $address */ - $address = $query->createElement($data); - - $order->setBillingAddress($address); - } - - $orders[$key] = $order; - } - - return $orders; - } - - /** - * Prevent deleting a user if they have any orders. - * - * @param DefineElementDeletionBlockersEvent $event the event. - */ - public function beforeDeleteUserHandler(DefineElementDeletionBlockersEvent $event): void - { - $event->blockers[] = new OrderCustomersDeletionBlocker($event->elements, $event->hardDelete); - } - - /** - * Reassigns orders to a new customer. - * - * @param int|int[] $oldUserId - * @param int $newUserId - * @return int The number of affected orders - * @throws \yii\db\Exception - * @since 5.7.0 - */ - public function reassignOrders(int|array $oldUserId, int $newUserId): int - { - $newUserEmail = (new Query()) - ->select(['email']) - ->from(\craft\db\Table::USERS) - ->where(['id' => $newUserId]) - ->scalar(); - - if (!$newUserEmail) { - throw new InvalidArgumentException('Unable to reassign user id: ' . $newUserId); - } - - $count = Db::update(Table::ORDERS, [ - 'customerId' => $newUserId, - 'email' => $newUserEmail, - ], [ - 'customerId' => $oldUserId, - ], [], false); - - // Invalidate all order caches - Craft::$app->getElements()->invalidateCachesForElementType(Order::class); - - return $count; - } - - /** - * @param int|int[] $orderIds - * @param array $dataToRemove - * @return int - * @throws \yii\db\Exception - * @since 5.7.0 - */ - public function removeCustomerData(int|array $orderIds, array $dataToRemove = ['customerId', 'email']): int - { - $allowedRemovalKeys = [ - 'customerId', - 'email', - 'billingAddressId', - 'shippingAddressId', - 'orderCompletedEmail', - ]; - - $data = []; - foreach ($dataToRemove as $key) { - if (!in_array($key, $allowedRemovalKeys)) { - continue; - } - - // Make sure we are setting the `customerDeleted` flag when removing the `customerId` - if ($key === 'customerId') { - $data['customerDeleted'] = true; - } - - $data[$key] = null; - } - - $count = Db::update(Table::ORDERS, $data, [ - 'id' => $orderIds, - ], [], false); - - Craft::$app->getElements()->invalidateCachesForElementType(Order::class); - - return $count; - } - - /** - * @param ModelEvent $event - * @return void - * @throws Exception - * @throws \Throwable - * @throws ElementNotFoundException - * @throws InvalidElementException - * @throws UnsupportedSiteException - * @since 4.2.11 - */ - public function afterSaveAddressHandler(ModelEvent $event): void - { - - /** @var Address $address */ - $address = $event->sender; - if ($address->getIsDraft()) { - return; - } - - // Find all orders using this address as a source - $idQuery = (new Query()) - ->select(['id']) - ->from(Table::ORDERS) - ->where(['sourceBillingAddressId' => $address->id]) - ->orWhere(['sourceShippingAddressId' => $address->id]); - - /** @var Order[] $carts */ - $carts = Order::find() - ->where(['commerce_orders.id' => $idQuery]) - ->isCompleted(false) - ->all(); - - if (empty($carts)) { - return; - } - - foreach ($carts as $cart) { - // Update the billing address - if ($cart->sourceBillingAddressId === $address->id) { - $newBillingAddress = Craft::$app->getElements()->duplicateElement($address, [ - 'primaryOwner' => $cart, - 'owner' => $cart, - 'title' => Craft::t('commerce', 'Billing Address'), - ]); - $cart->billingAddressId = $newBillingAddress->id; - } - - // Update the shipping address - if ($cart->sourceShippingAddressId === $address->id) { - $newShippingAddress = Craft::$app->getElements()->duplicateElement($address, [ - 'primaryOwner' => $cart, - 'owner' => $cart, - 'title' => Craft::t('commerce', 'Shipping Address'), - ]); - $cart->shippingAddressId = $newShippingAddress->id; - } - - // Save the cart to trigger events and recalculations. - Craft::$app->getElements()->saveElement($cart, false); - } - } -} diff --git a/src/services/PaymentCurrencies.php b/src/services/PaymentCurrencies.php deleted file mode 100644 index 91a96168d7..0000000000 --- a/src/services/PaymentCurrencies.php +++ /dev/null @@ -1,363 +0,0 @@ - - * @since 2.0 - */ -class PaymentCurrencies extends Component -{ - /** - * @event PaymentCurrencyRateEvent The event that is triggered when a payment currency rate is being resolved. - * Set `$event->rate` to override the rate used for conversions and historical transaction snapshots. - * @since 5.7.0 - */ - public const EVENT_DEFINE_PAYMENT_CURRENCY_RATE = 'definePaymentCurrencyRate'; - - /** - * @var null|Collection[] - */ - private ?array $_allPaymentCurrencies = null; - - /** - * Returns the rate for a payment currency, after giving event handlers a chance to override it. - * - * @since 5.7.0 - */ - public function getRateFor(PaymentCurrency $currency, ?Transaction $transaction = null): float - { - $event = new PaymentCurrencyRateEvent([ - 'rate' => $currency->rate, - 'paymentCurrency' => $currency, - 'transaction' => $transaction, - ]); - - $this->trigger(self::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $event); - - return $event->rate; - } - - /** - * Get payment currency by its ID. - * - * @throws InvalidConfigException if currency has invalid iso code defined - */ - public function getPaymentCurrencyById(int $id, ?int $storeId = null): ?PaymentCurrency - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - $all = $this->getAllPaymentCurrencies($storeId); - - return $all->where('id', $id)->first(); - } - - /** - * Get all payment currencies. - * - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - * @throws SiteNotFoundException - */ - public function getAllPaymentCurrencies(?int $storeId = null): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if ($this->_allPaymentCurrencies === null || !isset($this->_allPaymentCurrencies[$storeId])) { - $results = $this->_createPaymentCurrencyQuery() - ->orderBy(['iso' => SORT_ASC]) - ->where(['storeId' => $storeId]) - ->all(); - - if ($this->_allPaymentCurrencies === null) { - $this->_allPaymentCurrencies = []; - } - - foreach ($results as $result) { - $paymentCurrency = Craft::createObject([ - 'class' => PaymentCurrency::class, - 'attributes' => $result, - ]); - - if (!isset($this->_allPaymentCurrencies[$paymentCurrency->storeId])) { - $this->_allPaymentCurrencies[$paymentCurrency->storeId] = collect(); - } - - $this->_allPaymentCurrencies[$paymentCurrency->storeId]->push($paymentCurrency); - } - } - - return $this->_allPaymentCurrencies[$storeId] ?? collect(); - } - - /** - * Get a payment currency by its ISO code. - * - * @param string $iso - * @param int|null $storeId - * @return PaymentCurrency|null - * @throws CurrencyException if currency does not exist with that iso code - * @throws InvalidConfigException - * @throws SiteNotFoundException - */ - public function getPaymentCurrencyByIso(string $iso, ?int $storeId = null): ?PaymentCurrency - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - return $this->getAllPaymentCurrencies($storeId)->firstWhere('iso', $iso); - } - - /** - * Return the primary currencies ISO code as a string. - */ - public function getPrimaryPaymentCurrencyIso(?int $storeId = null): string - { - return $this->getPrimaryPaymentCurrency($storeId)?->iso ?? 'USD'; - } - - /** - * Returns the primary currency all prices are entered as. - * - * @throws CurrencyException - * @throws InvalidConfigException - */ - public function getPrimaryPaymentCurrency(?int $storeId = null): ?PaymentCurrency - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - $storeCurrency = Plugin::getInstance()->getStores()->getStoreById($storeId)->getCurrency(); - - return $this->getAllPaymentCurrencies($storeId)->firstWhere(fn(PaymentCurrency $currency) => $currency->getCode() == $storeCurrency->getCode()); - } - - /** - * Returns the non primary payment currencies - * - * @return Collection - * @throws CurrencyException - * @throws InvalidConfigException - */ - public function getNonPrimaryPaymentCurrencies(?int $storeId = null): Collection - { - $storeCurrency = Plugin::getInstance()->getStores()->getStoreById($storeId)->getCurrency(); - - return $this->getAllPaymentCurrencies($storeId)->where(fn(PaymentCurrency $currency) => $currency->getCode() != $storeCurrency->getCode()); - } - - /** - * Convert an amount in site's primary currency to a different currency by its ISO code. - * - * @param float $amount This is the unit of price in the primary store currency - * @throws CurrencyException if currency not found by its ISO code - * @throws InvalidConfigException - */ - public function convert(float $amount, string $currency): float - { - $destinationCurrency = $this->getPaymentCurrencyByIso($currency); - - if (!$destinationCurrency) { - throw new CurrencyException('No payment currency found with ISO code: ' . $currency); - } - - return $this->convertCurrency($amount, $this->getPrimaryPaymentCurrencyIso(), $currency); - } - - /** - * Convert an amount between currencies based on rates configured. - * - * @param float $amount - * @param string $fromCurrency - * @param string $toCurrency - * @param bool $round - * @return float - * @throws CurrencyException if currency not found by its ISO code - * @throws InvalidConfigException - * @deprecated 5.0.0 - */ - public function convertCurrency(float $amount, string $fromCurrency, string $toCurrency, bool $round = false): float - { - $fromCurrency = $this->getPaymentCurrencyByIso($fromCurrency); - $toCurrency = $this->getPaymentCurrencyByIso($toCurrency); - - if (!$fromCurrency) { - throw new CurrencyException('Currency not found: ' . $fromCurrency); - } - - if (!$toCurrency) { - throw new CurrencyException('Currency not found: ' . $toCurrency); - } - - if ($this->getPrimaryPaymentCurrency()->iso != $fromCurrency) { - // now the amount is in the primary currency - $amount /= $this->getRateFor($fromCurrency); - } - - $result = $amount * $this->getRateFor($toCurrency); - - if ($round) { - return CurrencyHelper::round($result, $toCurrency); - } - - return $result; - } - - - /** - * Save a payment currency. - * - * @param bool $runValidation should we validate this payment currency before saving. - * @throws Exception - */ - public function savePaymentCurrency(PaymentCurrency $model, bool $runValidation = true): bool - { - if ($model->id) { - $record = PaymentCurrencyRecord::findOne($model->id); - - if (!$record) { - throw new Exception(Craft::t('commerce', 'No currency exists with the ID “{id}”', - ['id' => $model->id])); - } - } else { - $record = new PaymentCurrencyRecord(); - } - - if ($runValidation && !$model->validate()) { - Craft::info('Payment currency not saved due to validation error.', __METHOD__); - - return false; - } - - $originalIso = $record->iso; - $record->iso = strtoupper($model->iso); - $record->storeId = $model->storeId; - // If this rate is primary, the rate must be 1 since it is now the rate all prices are enter in as. - $record->rate = $model->getPrimary() ? 1 : $model->rate; - - $record->save(false); - - // Now that we have a record ID, save it on the model - $model->id = $record->id; - - return true; - } - - /** - * Delete a payment currency by its ID. - * - * @param int $id - * @return bool - * @throws StaleObjectException - */ - public function deletePaymentCurrencyById(int $id): bool - { - $paymentCurrency = PaymentCurrencyRecord::findOne($id); - - if (!$paymentCurrency) { - return false; - } - - $baseCurrency = Plugin::getInstance()->getPaymentCurrencies()->getPrimaryPaymentCurrency($paymentCurrency->storeId); - - Db::update(Table::ORDERS, ['paymentCurrency' => $baseCurrency->iso], ['paymentCurrency' => $paymentCurrency->iso, 'storeId' => $paymentCurrency->storeId]); - - return $paymentCurrency->delete(); - } - - private function _getExchange(?int $storeId = null) - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - $storeCurrency = Plugin::getInstance()->getStores()->getStoreById($storeId)->getCurrency(); - $nonPrimaryCurrencies = $this->getNonPrimaryPaymentCurrencies($storeId)->mapWithKeys(fn(PaymentCurrency $currency) => [$currency->iso => (string)$this->getRateFor($currency)]); - - $exchange = [$storeCurrency->getCode() => $nonPrimaryCurrencies->all()]; - - // Reverse all the rates so we have to opposite conversions - foreach ($nonPrimaryCurrencies->all() as $iso => $rate) { - $exchange[$iso] = [$storeCurrency->getCode() => (string)(1 / (float)$rate)]; - } - - return new FixedExchange($exchange); - } - - /** - * @param Money $amount - * @param Currency|string $currency - * @param int|null $storeId - * @return Money - * @throws CurrencyException - * @throws InvalidConfigException - * @throws \craft\errors\SiteNotFoundException - * @since 5.0.0 - */ - public function convertAmount(Money $amount, Currency|string $currency, ?int $storeId = null): Money - { - if (is_string($currency)) { - $currency = new Currency($currency); - } - - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - $fromPaymentCurrency = $this->getPaymentCurrencyByIso($amount->getCurrency(), $storeId); - $toPaymentCurrency = $this->getPaymentCurrencyByIso($currency, $storeId); - - if (!$fromPaymentCurrency || !$toPaymentCurrency) { - throw new CurrencyException('Currency not found in store: ' . $currency); - } - - $converter = new Converter(new ISOCurrencies(), $this->_getExchange($storeId)); - return $converter->convert($amount, $toPaymentCurrency->getCurrency()); - } - - /** - * Returns a Query object prepped for retrieving Emails - */ - private function _createPaymentCurrencyQuery(): Query - { - return (new Query()) - ->select([ - 'dateCreated', - 'dateUpdated', - 'id', - 'iso', - 'storeId', - 'rate', - ]) - ->from([Table::PAYMENTCURRENCIES]); - } -} diff --git a/src/services/PaymentSources.php b/src/services/PaymentSources.php deleted file mode 100644 index 8542df7adb..0000000000 --- a/src/services/PaymentSources.php +++ /dev/null @@ -1,391 +0,0 @@ - - * @since 2.0 - */ -class PaymentSources extends Component -{ - /** - * @event PaymentSourceEvent The event that is triggered when a payment source is deleted. - * - * ```php - * use craft\commerce\events\PaymentSourceEvent; - * use craft\commerce\services\PaymentSources; - * use craft\commerce\models\PaymentSource; - * use yii\base\Event; - * - * Event::on( - * PaymentSources::class, - * PaymentSources::EVENT_DELETE_PAYMENT_SOURCE, - * function(PaymentSourceEvent $event) { - * // @var PaymentSource $source - * $source = $event->paymentSource; - * - * // Warn a user they don’t have any valid payment sources saved - * // ... - * } - * ); - * ``` - */ - public const EVENT_DELETE_PAYMENT_SOURCE = 'deletePaymentSource'; - - /** - * @event PaymentSourceEvent The event that is triggered before a payment source is added. - * - * ```php - * use craft\commerce\events\PaymentSourceEvent; - * use craft\commerce\services\PaymentSources; - * use craft\commerce\models\PaymentSource; - * use yii\base\Event; - * - * Event::on( - * PaymentSources::class, - * PaymentSources::EVENT_BEFORE_SAVE_PAYMENT_SOURCE, - * function(PaymentSourceEvent $event) { - * // @var PaymentSource $source - * $source = $event->paymentSource; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_SAVE_PAYMENT_SOURCE = 'beforeSavePaymentSource'; - - /** - * @event PaymentSourceEvent The event that is triggered after a payment source is added. - * - * ```php - * use craft\commerce\events\PaymentSourceEvent; - * use craft\commerce\services\PaymentSources; - * use craft\commerce\models\PaymentSource; - * use yii\base\Event; - * - * Event::on( - * PaymentSources::class, - * PaymentSources::EVENT_AFTER_SAVE_PAYMENT_SOURCE, - * function(PaymentSourceEvent $event) { - * // @var PaymentSource $source - * $source = $event->paymentSource; - * - * // Settle any outstanding balance - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_SAVE_PAYMENT_SOURCE = 'afterSavePaymentSource'; - - /** - * Returns a customer's payment sources, per the customer's ID. - * - * @param int|null $customerId the user's ID - * @param int|null $gatewayId the gateway's ID - * @return Collection - * @throws InvalidConfigException - * @throws SiteNotFoundException - * @noinspection PhpUnused - */ - public function getAllPaymentSourcesByCustomerId(?int $customerId = null, ?int $gatewayId = null): Collection - { - if ($customerId === null) { - return collect(); - } - - $query = $this->_createPaymentSourcesQuery() - ->innerJoin(['gateways' => Table::GATEWAYS], 'gateways.id = [[ps.gatewayId]]') - ->where(['customerId' => $customerId]); - - if ($gatewayId) { - $query->andWhere(['gatewayId' => $gatewayId]); - } - - $results = $query->all(); - - $sources = []; - - foreach ($results as $result) { - $sources[] = Craft::createObject([ - 'class' => PaymentSource::class, - 'attributes' => $result, - ]); - } - - return collect($sources); - } - - /** - * Returns all payment sources for a gateway. - * - * @param int|null $gatewayId the gateway's ID - * @return Collection - * @throws InvalidConfigException - */ - public function getAllPaymentSourcesByGatewayId(int $gatewayId = null): Collection - { - if ($gatewayId === null) { - return collect(); - } - - $results = $this->_createPaymentSourcesQuery() - ->where(['gatewayId' => $gatewayId]) - ->all(); - - $sources = []; - - foreach ($results as $result) { - $sources[] = Craft::createObject([ - 'class' => PaymentSource::class, - 'attributes' => $result, - ]); - } - - return collect($sources); - } - - /** - * Returns a customer's payment sources on a gateway, per the customer/user's ID. - * - * @param int|null $gatewayId the gateway's ID - * @param int|null $customerId the user's ID - * @return Collection - * @throws InvalidConfigException - */ - public function getAllGatewayPaymentSourcesByCustomerId(int $gatewayId = null, int $customerId = null): Collection - { - if ($gatewayId === null || $customerId === null) { - return collect(); - } - - $results = $this->_createPaymentSourcesQuery() - ->where(['customerId' => $customerId]) - ->andWhere(['gatewayId' => $gatewayId]) - ->all(); - - $sources = []; - - foreach ($results as $result) { - $sources[] = Craft::createObject([ - 'class' => PaymentSource::class, - 'attributes' => $result, - ]); - } - - return collect($sources); - } - - /** - * Returns a payment source by its gateways token - * - * @param string $token the payment gateway's token - * @param int $gatewayId the gateway's ID - * @return PaymentSource|null - * @throws InvalidConfigException - */ - public function getPaymentSourceByTokenAndGatewayId(string $token, int $gatewayId): ?PaymentSource - { - $result = $this->_createPaymentSourcesQuery() - ->where(['token' => $token]) - ->andWhere(['gatewayId' => $gatewayId]) - ->one(); - - return $result ? Craft::createObject(['class' => PaymentSource::class, 'attributes' => $result]) : null; - } - - /** - * Returns a payment source by its ID. - * - * @param int $sourceId the source ID - * @return PaymentSource|null - * @throws InvalidConfigException - * @throws SiteNotFoundException - */ - public function getPaymentSourceById(int $sourceId): ?PaymentSource - { - $result = $this->_createPaymentSourcesQuery() - ->where(['[[ps.id]]' => $sourceId]) - ->innerJoin(['gateways' => Table::GATEWAYS], 'gateways.id = [[ps.gatewayId]]') // ensure it is a gateway payment source - ->one(); - - return $result ? Craft::createObject(['class' => PaymentSource::class, 'attributes' => $result]) : null; - } - - /** - * Returns a payment source by its ID and user ID. - * - * @param int $sourceId the source ID - * @param int $userId the source's user ID - */ - public function getPaymentSourceByIdAndUserId(int $sourceId, int $userId): ?PaymentSource - { - $result = $this->_createPaymentSourcesQuery() - ->where(['id' => $sourceId]) - ->andWhere(['customerId' => $userId]) - ->one(); - - return $result ? new PaymentSource($result) : null; - } - - /** - * Creates a payment source for a user in the gateway based on a payment form. - * - * @param int $customerId the user's ID - * @param GatewayInterface $gateway the gateway - * @param BasePaymentForm $paymentForm the payment form to use - * @param string|null $sourceDescription the payment form to use - * @return PaymentSource The saved payment source. - * @throws InvalidConfigException - * @throws PaymentSourceException If unable to create the payment source - */ - public function createPaymentSource(int $customerId, GatewayInterface $gateway, BasePaymentForm $paymentForm, string $sourceDescription = null, bool $makePrimarySource = false): PaymentSource - { - $source = $gateway->createPaymentSource($paymentForm, $customerId); - - $source->customerId = $customerId; - - if (!empty($sourceDescription)) { - $source->description = $sourceDescription; - } - - if (!$this->savePaymentSource($source)) { - throw new PaymentSourceException(Craft::t('commerce', 'Could not create the payment source.')); - } - - if ($makePrimarySource) { - Plugin::getInstance()->getCustomers()->savePrimaryPaymentSourceId($source->getCustomer(), $source->id); - } - - return $source; - } - - /** - * Saves a payment source. - * - * @param PaymentSource $paymentSource The payment source being saved. - * @param bool $runValidation should we validate this payment source before saving. - * @return bool Whether the payment source was saved successfully - * @throws InvalidConfigException if the payment source couldn't be found - */ - public function savePaymentSource(PaymentSource $paymentSource, bool $runValidation = true): bool - { - if ($paymentSource->id) { - $record = PaymentSourceRecord::findOne($paymentSource->id); - - if (!$record) { - throw new InvalidConfigException(Craft::t('commerce', 'No payment source exists with the ID “{id}”', - ['id' => $paymentSource->id])); - } - } else { - $record = new PaymentSourceRecord(); - } - - // fire a 'beforeSavePaymentSource' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_SAVE_PAYMENT_SOURCE)) { - $this->trigger(self::EVENT_BEFORE_SAVE_PAYMENT_SOURCE, new PaymentSourceEvent([ - 'paymentSource' => $paymentSource, - ])); - } - - if ($runValidation && !$paymentSource->validate()) { - Craft::info('Payment source not saved due to validation error.', __METHOD__); - - return false; - } - - $record->customerId = $paymentSource->customerId; - $record->gatewayId = $paymentSource->gatewayId; - $record->token = $paymentSource->token; - $record->description = $paymentSource->description; - $record->response = $paymentSource->response; - - // Save it! - $record->save(false); - - // Now that we have a record ID, save it on the model - $paymentSource->id = $record->id; - - // fire a 'afterSavePaymentSource' event - if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_PAYMENT_SOURCE)) { - $this->trigger(self::EVENT_AFTER_SAVE_PAYMENT_SOURCE, new PaymentSourceEvent([ - 'paymentSource' => $paymentSource, - ])); - } - - return true; - } - - /** - * Delete a payment source by its ID. - * - * @param int $id The ID - * @throws Throwable in case something went wrong when deleting. - */ - public function deletePaymentSourceById(int $id): bool - { - $record = PaymentSourceRecord::findOne($id); - - if ($record) { - $gateway = Plugin::getInstance()->getGateways()->getGatewayById($record->gatewayId); - - $gateway?->deletePaymentSource($record->token); - - $paymentSource = $this->getPaymentSourceById($id); - - // Fire an 'deletePaymentSource' event. - if ($this->hasEventHandlers(self::EVENT_DELETE_PAYMENT_SOURCE)) { - $this->trigger(self::EVENT_DELETE_PAYMENT_SOURCE, new PaymentSourceEvent([ - 'paymentSource' => $paymentSource, - ])); - } - - return (bool)$record->delete(); - } - - return false; - } - - /** - * Returns a Query object prepped for retrieving gateways. - * - * @return Query The query object. - */ - private function _createPaymentSourcesQuery(): Query - { - return (new Query()) - ->select([ - 'ps.description', - 'ps.gatewayId', - 'ps.id', - 'ps.response', - 'ps.token', - 'ps.customerId', - ]) - ->from(['ps' => Table::PAYMENTSOURCES]); - } -} diff --git a/src/services/Payments.php b/src/services/Payments.php deleted file mode 100644 index 111304616c..0000000000 --- a/src/services/Payments.php +++ /dev/null @@ -1,644 +0,0 @@ - - * @since 2.0 - */ -class Payments extends Component -{ - /** - * @event TransactionEvent The event that is triggered when a complete-payment request is made. - * After this event, the customer will be redirected offsite or be redirected to the order success returnUrl. - * - * ```php - * use craft\commerce\events\TransactionEvent; - * use craft\commerce\services\Payments; - * use craft\commerce\models\Transaction; - * use yii\base\Event; - * - * Event::on( - * Payments::class, - * Payments::EVENT_AFTER_COMPLETE_PAYMENT, - * function(TransactionEvent $event) { - * // @var Transaction $transaction - * $transaction = $event->transaction; - * - * // Check whether it was an authorize transaction - * // and make sure that warehouse team is on top of it - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_COMPLETE_PAYMENT = 'afterCompletePayment'; - - /** - * @event TransactionEvent The event that is triggered before a payment transaction is captured. - * - * ```php - * use craft\commerce\events\TransactionEvent; - * use craft\commerce\services\Payments; - * use craft\commerce\models\Transaction; - * use yii\base\Event; - * - * Event::on( - * Payments::class, - * Payments::EVENT_BEFORE_CAPTURE_TRANSACTION, - * function(TransactionEvent $event) { - * // @var Transaction $transaction - * $transaction = $event->transaction; - * - * // Check that shipment’s ready before capturing - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_CAPTURE_TRANSACTION = 'beforeCaptureTransaction'; - - /** - * @event TransactionEvent The event that is triggered after a payment transaction is captured. - * - * ```php - * use craft\commerce\events\TransactionEvent; - * use craft\commerce\services\Payments; - * use craft\commerce\models\Transaction; - * use yii\base\Event; - * - * Event::on( - * Payments::class, - * Payments::EVENT_AFTER_CAPTURE_TRANSACTION, - * function(TransactionEvent $event) { - * // @var Transaction $transaction - * $transaction = $event->transaction; - * - * // Notify the warehouse we're ready to ship - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_CAPTURE_TRANSACTION = 'afterCaptureTransaction'; - - /** - * @event TransactionEvent The event that is triggered before a transaction is refunded. - * - * ```php - * use craft\commerce\events\RefundTransactionEvent; - * use craft\commerce\services\Payments; - * use yii\base\Event; - * - * Event::on( - * Payments::class, - * Payments::EVENT_BEFORE_REFUND_TRANSACTION, - * function(RefundTransactionEvent $event) { - * // @var float $amount - * $amount = $event->amount; - * - * // Do something else if the refund amount’s >50% of the transaction - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_REFUND_TRANSACTION = 'beforeRefundTransaction'; - - /** - * @event TransactionEvent The event that is triggered after a transaction is refunded. - * - * ```php - * use craft\commerce\events\RefundTransactionEvent; - * use craft\commerce\services\Payments; - * use yii\base\Event; - * - * Event::on( - * Payments::class, - * Payments::EVENT_AFTER_REFUND_TRANSACTION, - * function(RefundTransactionEvent $event) { - * // @var float $amount - * $amount = $event->amount; - * - * // Do something else if the refund amount’s >50% of the transaction - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_REFUND_TRANSACTION = 'afterRefundTransaction'; - - /** - * @event ProcessPaymentEvent The event that is triggered before a payment is processed. - * - * You may set the `isValid` property to `false` on the event to prevent the payment from being processed. - * - * ```php - * use craft\commerce\events\ProcessPaymentEvent; - * use craft\commerce\services\Payments; - * use craft\commerce\elements\Order; - * use craft\commerce\models\payments\BasePaymentForm; - * use craft\commerce\models\Transaction; - * use craft\commerce\base\RequestResponseInterface; - * use yii\base\Event; - * - * Event::on( - * Payments::class, - * Payments::EVENT_BEFORE_PROCESS_PAYMENT, - * function(ProcessPaymentEvent $event) { - * // @var Order $order - * $order = $event->order; - * // @var BasePaymentForm $form - * $form = $event->form; - * // @var Transaction $transaction - * $transaction = $event->transaction; - * // @var RequestResponseInterface $response - * $response = $event->response; - * - * // Check some business rules to see whether the transaction is allowed - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_PROCESS_PAYMENT = 'beforeProcessPaymentEvent'; - - /** - * @event ProcessPaymentEvent The event that is triggered after a payment is processed. - * - * ```php - * use craft\commerce\events\ProcessPaymentEvent; - * use craft\commerce\services\Payments; - * use craft\commerce\elements\Order; - * use craft\commerce\models\payments\BasePaymentForm; - * use craft\commerce\models\Transaction; - * use craft\commerce\base\RequestResponseInterface; - * use yii\base\Event; - * - * Event::on( - * Payments::class, - * Payments::EVENT_AFTER_PROCESS_PAYMENT, - * function(ProcessPaymentEvent $event) { - * // @var Order $order - * $order = $event->order; - * // @var BasePaymentForm $form - * $form = $event->form; - * // @var Transaction $transaction - * $transaction = $event->transaction; - * // @var RequestResponseInterface $response - * $response = $event->response; - * - * // Let the accounting department know an order transaction went through - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_PROCESS_PAYMENT = 'afterProcessPaymentEvent'; - - /** - * Process a payment. - * - * @param Order $order the order for which the payment is. - * @param BasePaymentForm $form the payment form. - * @param string|null &$redirect a string parameter by reference that will contain the redirect URL, if any - * @param Transaction|null &$transaction the transaction - * @param array|null &$redirectData the additional data the gateway might need to redirect the user to the payment page. This is useful for ajax payment responses. - * @return void - * @throws InvalidConfigException - * @throws PaymentException if the payment was unsuccessful - * @throws TransactionException - * @throws CurrencyException - */ - public function processPayment(Order $order, BasePaymentForm $form, ?string &$redirect, ?Transaction &$transaction, ?array &$redirectData = []): void - { - // Raise the 'beforeProcessPaymentEvent' event - $event = new ProcessPaymentEvent(compact('order', 'form')); - - $this->trigger(self::EVENT_BEFORE_PROCESS_PAYMENT, $event); - - if (!$event->isValid) { - // This error potentially is going to be displayed in the frontend, so we have to be vague about it. - // Long story short - a plugin said "no." - throw new PaymentException(Craft::t('commerce', 'Unable to make payment at this time.')); - } - - // Order could have zero totalPrice and already considered 'paid'. Free orders complete immediately. - $paymentStrategy = $order->getStore()->getFreeOrderPaymentStrategy(); - if (!$order->hasOutstandingBalance() && !$order->datePaid && $paymentStrategy === Store::FREE_ORDER_PAYMENT_STRATEGY_COMPLETE) { - $order->updateOrderPaidInformation(); - - if ($order->isCompleted) { - return; - } - } - - $gateway = $order->getGateway(); - if (!$gateway) { - throw new InvalidConfigException(Craft::t('commerce', 'Missing Gateway')); - } - - //choosing default action - $defaultAction = $gateway->paymentType; - $defaultAction = ($defaultAction === TransactionRecord::TYPE_PURCHASE) ? $defaultAction : TransactionRecord::TYPE_AUTHORIZE; - - if ($defaultAction === TransactionRecord::TYPE_AUTHORIZE) { - if (!$gateway->supportsAuthorize()) { - throw new PaymentException(Craft::t('commerce', 'Gateway doesn’t support authorize')); - } - } elseif (!$gateway->supportsPurchase()) { - throw new PaymentException(Craft::t('commerce', 'Gateway doesn’t support purchase')); - } - - //creating order, transaction and request - $transaction = Plugin::getInstance()->getTransactions()->createTransaction($order, null, $defaultAction); - - try { - $response = match ($defaultAction) { - TransactionRecord::TYPE_PURCHASE => $gateway->purchase($transaction, $form), - TransactionRecord::TYPE_AUTHORIZE => $gateway->authorize($transaction, $form), - }; - - $this->_updateTransaction($transaction, $response); - - if ($this->hasEventHandlers(self::EVENT_AFTER_PROCESS_PAYMENT)) { - $this->trigger(self::EVENT_AFTER_PROCESS_PAYMENT, new ProcessPaymentEvent(compact('order', 'transaction', 'form', 'response'))); - } - - // For redirects or unsuccessful transactions, save the transaction before bailing - if ($response->isRedirect()) { - $this->_handleRedirect($response, $redirect, $redirectData); - return; - } - - if (!in_array($transaction->status, [TransactionRecord::STATUS_SUCCESS, TransactionRecord::STATUS_PROCESSING])) { - throw new PaymentException($transaction->message); - } - - // Success! - $order->updateOrderPaidInformation(); - } catch (Exception $e) { - $transaction->status = TransactionRecord::STATUS_FAILED; - $transaction->message = $e->getMessage(); - - // If this transactions is already saved, don't even try. - if (!$transaction->id) { - $this->_saveTransaction($transaction); - } - - Craft::$app->getErrorHandler()->logException($e); - throw new PaymentException($e->getMessage(), $e->getCode(), $e); - } - } - - /** - * Capture a transaction. - * - * @param Transaction $transaction the transaction to capture. - * @throws TransactionException if something went wrong when saving the transaction - */ - public function captureTransaction(Transaction $transaction): Transaction - { - // Raise 'beforeCaptureTransaction' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_CAPTURE_TRANSACTION)) { - $this->trigger(self::EVENT_BEFORE_CAPTURE_TRANSACTION, new TransactionEvent([ - 'transaction' => $transaction, - ])); - } - - $transaction = $this->_capture($transaction); - - // Raise 'afterCaptureTransaction' event - if ($this->hasEventHandlers(self::EVENT_AFTER_CAPTURE_TRANSACTION)) { - $this->trigger(self::EVENT_AFTER_CAPTURE_TRANSACTION, new TransactionEvent([ - 'transaction' => $transaction, - ])); - } - - return $transaction; - } - - /** - * Refund a transaction. - * - * @param Transaction $transaction the transaction to refund. - * @param float|null $amount the amount to refund or null for full amount. - * @param string $note the administrators note on the refund - * @throws RefundException if something went wrong during the refund. - */ - public function refundTransaction(Transaction $transaction, ?float $amount = null, string $note = ''): Transaction - { - // Raise 'beforeRefundTransaction' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_REFUND_TRANSACTION)) { - $this->trigger(self::EVENT_BEFORE_REFUND_TRANSACTION, new RefundTransactionEvent(compact('transaction', 'amount'))); - } - - $refundTransaction = $this->_refund($transaction, $amount, $note); - - /// Raise 'afterRefundTransaction' event - if ($this->hasEventHandlers(self::EVENT_AFTER_REFUND_TRANSACTION)) { - $this->trigger(self::EVENT_AFTER_REFUND_TRANSACTION, new RefundTransactionEvent(compact('transaction', 'refundTransaction', 'amount'))); - } - - return $refundTransaction; - } - - /** - * Process return from off-site payment. - * - * @param Transaction $transaction - * @param string|null &$customError - * @return bool - * @throws CurrencyException - * @throws ExitException - * @throws InvalidConfigException - * @throws LoaderError - * @throws RuntimeError - * @throws SyntaxError - * @throws Throwable - * @throws TransactionException - * @throws \craft\commerce\errors\OrderStatusException - * @throws \craft\errors\ElementNotFoundException - * @throws \yii\base\Exception - */ - public function completePayment(Transaction $transaction, ?string &$customError): bool - { - // Only transactions with the status of "redirect" can be completed - if (!in_array($transaction->status, [TransactionRecord::STATUS_REDIRECT, TransactionRecord::STATUS_SUCCESS], true)) { - $customError = $transaction->message; - - return false; - } - - $transactionLockName = 'commerceTransaction:' . $transaction->hash; - $mutex = Craft::$app->getMutex(); - - if (!$mutex->acquire($transactionLockName, 15)) { - throw new Exception('Unable to acquire a lock for transaction: ' . $transaction->hash); - } - - // Make sure we have the latest transaction data - $transaction = Plugin::getInstance()->getTransactions()->getTransactionByHash($transaction->hash); - - // If it's successful already, we're good. - if (Plugin::getInstance()->getTransactions()->isTransactionSuccessful($transaction)) { - $transaction->order->updateOrderPaidInformation(); - $mutex->release($transactionLockName); - return true; - } - - // Load payment driver for the transaction we are trying to complete - $gateway = $transaction->getGateway(); - - switch ($transaction->type) { - case TransactionRecord::TYPE_PURCHASE: - $response = $gateway->completePurchase($transaction); - break; - case TransactionRecord::TYPE_AUTHORIZE: - $response = $gateway->completeAuthorize($transaction); - break; - default: - $mutex->release($transactionLockName); - return false; - } - - $childTransaction = Plugin::getInstance()->getTransactions()->createTransaction(null, $transaction); - $this->_updateTransaction($childTransaction, $response); - - // Success can mean 2 things in this context. - // 1) The transaction completed successfully with the gateway, and is now marked as complete. - // 2) The result of the gateway request was successful but also got a redirect response. We now need to redirect if $redirect is not null. - $success = $response->isSuccessful() || $response->isProcessing(); - $isParentTransactionRedirect = ($transaction->status === TransactionRecord::STATUS_REDIRECT); - - if ($success) { - if ($transaction->status === TransactionRecord::STATUS_SUCCESS || ($isParentTransactionRedirect && $childTransaction->status == TransactionRecord::STATUS_SUCCESS)) { - $transaction->order->updateOrderPaidInformation(); - } - - if ($isParentTransactionRedirect && $childTransaction->status == TransactionRecord::STATUS_PROCESSING) { - $transaction->order->markAsComplete(); - } - } - - if ($this->hasEventHandlers(self::EVENT_AFTER_COMPLETE_PAYMENT)) { - $this->trigger(self::EVENT_AFTER_COMPLETE_PAYMENT, new TransactionEvent([ - 'transaction' => $transaction, - ])); - } - - $redirectData = []; - if ($response->isRedirect() && $transaction->status === TransactionRecord::STATUS_REDIRECT) { - $mutex->release($transactionLockName); - $this->_handleRedirect($response, $redirect, $redirectData); - Craft::$app->getResponse()->redirect($redirect); - Craft::$app->end(); - } - - if (!$success) { - $customError = $response->getMessage(); - } - - $mutex->release($transactionLockName); - - return $success; - } - - /** - * Handles a redirect. - * - * @param RequestResponseInterface $response - * @param string|null $redirect - * @param array|null $redirectData - * @throws ExitException - * @throws LoaderError - * @throws RuntimeError - * @throws SyntaxError - * @throws \yii\base\Exception - */ - private function _handleRedirect(RequestResponseInterface $response, ?string &$redirect, ?array &$redirectData): void - { - // If the gateway tells is it is a GET redirect, let them - if ($response->getRedirectMethod() === 'GET') { - $redirect = $response->getRedirectUrl(); - $redirectData = $response->getRedirectData(); - } else { - $gatewayPostRedirectTemplate = Plugin::getInstance()->getSettings()->gatewayPostRedirectTemplate; - - if (!empty($gatewayPostRedirectTemplate)) { - $variables = []; - $hiddenFields = ''; - - // Gather all post hidden data inputs. - foreach ($response->getRedirectData() as $key => $value) { - $hiddenFields .= sprintf('', htmlentities($key, ENT_QUOTES, 'UTF-8', false), htmlentities($value, ENT_QUOTES, 'UTF-8', false)) . "\n"; - } - - $variables['inputs'] = $hiddenFields; - - // Set the action url to the responses redirect url - $variables['actionUrl'] = $response->getRedirectUrl(); - - // Set Craft to the site template mode - $templatesService = Craft::$app->getView(); - $oldTemplateMode = $templatesService->getTemplateMode(); - $templatesService->setTemplateMode($templatesService::TEMPLATE_MODE_SITE); - - $template = $templatesService->renderPageTemplate($gatewayPostRedirectTemplate, $variables); - - // Restore the original template mode - $templatesService->setTemplateMode($oldTemplateMode); - - // Send the template back to the user. - ob_start(); - echo $template; - Craft::$app->end(); - } - - // Let the gateway's response redirect us - $response->redirect(); - } - } - - /** - * Process a capture or refund exception. - * - * @throws TransactionException if unable to save transaction - * @throws InvalidConfigException - */ - private function _capture(Transaction $parent): Transaction - { - $child = Plugin::getInstance()->getTransactions()->createTransaction(null, $parent, TransactionRecord::TYPE_CAPTURE); - - $gateway = $parent->getGateway(); - - try { - $response = $gateway->capture($child, (string)$parent->reference); - $this->_updateTransaction($child, $response); - } catch (Exception $e) { - $child->status = TransactionRecord::STATUS_FAILED; - $child->message = $e->getMessage(); - $this->_saveTransaction($child); - - Craft::$app->getErrorHandler()->logException($e); - } - - return $child; - } - - /** - * Process a capture or refund exception. - * - * @param float|null $amount - * @param string $note the administrators note on the refund - * @throws RefundException if anything goes wrong during a refund - */ - private function _refund(Transaction $parent, float $amount = null, string $note = ''): Transaction - { - try { - $gateway = $parent->getGateway(); - - if (!$gateway->supportsRefund()) { - throw new SubscriptionException(Craft::t('commerce', 'Gateway doesn’t support refunds.')); - } - - if ($amount < $parent->paymentAmount && !$gateway->supportsPartialRefund()) { - throw new SubscriptionException(Craft::t('commerce', 'Gateway doesn’t support partial refunds.')); - } - - $child = Plugin::getInstance()->getTransactions()->createTransaction(null, $parent, TransactionRecord::TYPE_REFUND); - - // If amount is not supplied refund the full amount - $child->paymentAmount = Currency::round($amount, $child->currency) ?: $parent->getRefundableAmount(); - - // Calculate amount in the primary currency - $child->amount = Currency::round($child->paymentAmount / $parent->paymentRate, $child->currency); - $child->note = $note; - - $gateway = $parent->getGateway(); - - try { - $response = $gateway->refund($child); - $this->_updateTransaction($child, $response); - } catch (Throwable $exception) { - Craft::error(Craft::t('commerce', 'Error refunding transaction: {transactionHash}', ['transactionHash' => $parent->hash]), 'commerce'); - $child->status = TransactionRecord::STATUS_FAILED; - $child->message = $exception->getMessage(); - $this->_saveTransaction($child); - } - - return $child; - } catch (Throwable $exception) { - throw new RefundException($exception->getMessage()); - } - } - - /** - * Save a transaction. - * - * @param Transaction $child - * @throws TransactionException - */ - private function _saveTransaction(Transaction $child): void - { - if (!Plugin::getInstance()->getTransactions()->saveTransaction($child)) { - throw new TransactionException('Error saving transaction: ' . implode(', ', $child->getFirstErrors())); - } - } - - /** - * Updates a transaction. - */ - private function _updateTransaction(Transaction $transaction, RequestResponseInterface $response): void - { - if ($response->isSuccessful()) { - $transaction->status = TransactionRecord::STATUS_SUCCESS; - } elseif ($response->isProcessing()) { - $transaction->status = TransactionRecord::STATUS_PROCESSING; - } elseif ($response->isRedirect()) { - $transaction->status = TransactionRecord::STATUS_REDIRECT; - } else { - $transaction->status = TransactionRecord::STATUS_FAILED; - } - - $transaction->response = $response->getData(); - $transaction->code = $response->getCode(); - $transaction->reference = $response->getTransactionReference(); - $transaction->message = $response->getMessage(); - - $this->_saveTransaction($transaction); - } -} diff --git a/src/services/Pdfs.php b/src/services/Pdfs.php deleted file mode 100644 index d0225495e0..0000000000 --- a/src/services/Pdfs.php +++ /dev/null @@ -1,713 +0,0 @@ - - * @since 2.0 - * - * @property-read null|Pdf $defaultPdf - * @property-read Pdf[] $allEnabledPdfs - * @property-read bool $hasEnabledPdf - * @property-read null|Pdf[] $allPdfs - */ -class Pdfs extends Component -{ - /** - * @var Pdf[]|null - */ - private ?array $_allPdfs = null; - - /** - * @event PdfEvent The event that is triggered before an pdf is saved. - * - * ```php - * use craft\commerce\events\PdfEvent; - * use craft\commerce\services\Pdfs; - * use craft\commerce\models\Pdf; - * use yii\base\Event; - * - * Event::on( - * Pdfs::class, - * Pdfs::EVENT_BEFORE_SAVE_PDF, - * function(PdfEvent $event) { - * // @var Pdf $pdf - * $pdf = $event->pdf; - * // @var bool $isNew - * $isNew = $event->isNew; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_SAVE_PDF = 'beforeSavePdf'; - - /** - * @event PdfEvent The event that is triggered after an PDF is saved. - * - * ```php - * use craft\commerce\events\PdfEvent; - * use craft\commerce\services\Pdfs; - * use craft\commerce\models\Pdf; - * use yii\base\Event; - * - * Event::on( - * Pdfs::class, - * Pdfs::EVENT_AFTER_SAVE_PDF, - * function(PdfEvent $event) { - * // @var Pdf $pdf - * $pdf = $event->pdf; - * // @var bool $isNew - * $isNew = $event->isNew; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_SAVE_PDF = 'afterSavePdf'; - - /** - * @event PdfRenderEvent The event that is triggered before an order’s PDF is rendered. - * - * Event handlers can customize PDF rendering by modifying several properties on the event object: - * - * | Property | Value | - * | ----------- | ------------------------------------------------------------------------------------------------------------------------- | - * | `order` | populated [Order](api:craft\commerce\elements\Order) model | - * | `template` | optional Twig template path (string) to be used for rendering | - * | `variables` | populated with the variables available to the template used for rendering | - * | `option` | optional string for the template that can be used to show different details based on context (example: `receipt`, `ajax`) | - * - * ```php - * use craft\commerce\events\PdfRenderEvent; - * use craft\commerce\services\Pdf; - * use yii\base\Event; - * - * Event::on( - * Pdf::class, - * Pdf::EVENT_BEFORE_RENDER_PDF, - * function(PdfRenderEvent $event) { - * // Modify `$event->order`, `$event->option`, `$event->template`, - * // and `$event->variables` to customize what gets rendered into a PDF - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_RENDER_PDF = 'beforeRenderPdf'; - - /** - * @event PdfRenderEvent The event that is triggered after an order’s PDF has been rendered. - * - * Event handlers can override Commerce’s PDF generation by setting the `pdf` property on the event to a custom-rendered PDF string. The event properties will be the same as those from `beforeRenderPdf`, but `pdf` will contain a rendered PDF string and is the only one for which setting a value will make any difference for the resulting PDF output. - * - * ```php - * use craft\commerce\events\PdfRenderEvent; - * use craft\commerce\services\Pdf; - * use yii\base\Event; - * - * Event::on( - * Pdf::class, - * Pdf::EVENT_AFTER_RENDER_PDF, - * function(PdfRenderEvent $event) { - * // Add a watermark to the PDF or forward it to the accounting department - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_RENDER_PDF = 'afterRenderPdf'; - - /** - * @event PdfRenderOptionsEvent The event that allows additional setting of pdf render options. - * @since 3.2.10 - * - * ```php - * use craft\commerce\events\PdfRenderOptionsEvent; - * use craft\commerce\services\Pdfs; - * use yii\base\Event; - * - * Event::on( - * Pdfs::class, - * Pdfs::EVENT_MODIFY_RENDER_OPTIONS, - * function (PdfRenderOptionsEvent $event) { - * $storagePath = Craft::$app->getPath()->getStoragePath(); - * - * // E.g. of setting additional render options. - * $event->options->setChroot($storagePath); - * } - * ); - *``` - */ - public const EVENT_MODIFY_RENDER_OPTIONS = 'modifyRenderOptions'; - - /** - * @event PdfEvent The event that is triggered before a pdf is deleted. - * - * ```php - * use craft\commerce\events\PdfEvent; - * use craft\commerce\services\Pdfs; - * use craft\commerce\models\Pdf; - * use yii\base\Event; - * - * Event::on( - * Pdfs::class, - * Pdfs::EVENT_BEFORE_DELETE_PDF, - * function(PdfEvent $event) { - * // @var Pdf $pdf - * $pdf = $event->pdf; - * - * // ... - * } - * ); - * ``` - * - * @since 4.0.0 - */ - public const EVENT_BEFORE_DELETE_PDF = 'beforeDeletePdf'; - - public const CONFIG_PDFS_KEY = 'commerce.pdfs'; - - /** - * @param int|null $storeId - * @return Collection - * @throws SiteNotFoundException - * @throws InvalidConfigException - * @since 3.2 - */ - public function getAllPdfs(?int $storeId = null): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if ($this->_allPdfs === null || !isset($this->_allPdfs[$storeId])) { - $results = $this->_createPdfsQuery() - ->where(['storeId' => $storeId]) - ->all(); - - // Start with a blank slate if it isn't memoized, or we're fetching all shipping categories - if ($this->_allPdfs === null) { - $this->_allPdfs = []; - } - - foreach ($results as $result) { - $pdf = Craft::createObject([ - 'class' => Pdf::class, - 'attributes' => $result, - ]); - - if (!isset($this->_allPdfs[$pdf->storeId])) { - $this->_allPdfs[$pdf->storeId] = collect(); - } - - $this->_allPdfs[$pdf->storeId]->push($pdf); - } - } - - return $this->_allPdfs[$storeId] ?? collect(); - } - - /** - * @since 3.2 - */ - public function getHasEnabledPdf(?int $storeId = null): bool - { - return $this->getAllPdfs($storeId)->contains('enabled', true); - } - - /** - * @param int|null $storeId - * @return Collection - * @since 3.2 - */ - public function getAllEnabledPdfs(?int $storeId = null): Collection - { - return $this->getAllPdfs($storeId)->where('enabled', true); - } - - /** - * @since 3.2 - */ - public function getDefaultPdf(?int $storeId = null): ?Pdf - { - return $this->getAllPdfs($storeId)->firstWhere('isDefault', true); - } - - /** - * @since 3.2 - */ - public function getPdfByHandle(string $handle, ?int $storeId = null): ?Pdf - { - return $this->getAllPdfs($storeId)->firstWhere('handle', $handle); - } - - /** - * Get an PDF by its ID. - * - * @since 3.2 - */ - public function getPdfById(int $id, ?int $storeId = null): ?Pdf - { - return $this->getAllPdfs($storeId)->firstWhere('id', $id); - } - - /** - * Save an PDF. - * - * @throws Exception - * @throws ErrorException - * @throws NotSupportedException - * @throws ServerErrorHttpException - * @since 3.2 - */ - public function savePdf(Pdf $pdf, bool $runValidation = true): bool - { - $isNewPdf = !(bool)$pdf->id; - - // Fire a 'beforeSavePdf' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_SAVE_PDF)) { - $this->trigger(self::EVENT_BEFORE_SAVE_PDF, new PdfEvent([ - 'pdf' => $pdf, - 'isNew' => $isNewPdf, - ])); - } - - if ($runValidation && !$pdf->validate()) { - Craft::info('Pdf not saved due to validation error(s).', __METHOD__); - return false; - } - - if ($isNewPdf) { - $pdf->uid = StringHelper::UUID(); - } - - $configPath = self::CONFIG_PDFS_KEY . '.' . $pdf->uid; - $configData = $pdf->getConfig(); - Craft::$app->getProjectConfig()->set($configPath, $configData); - - if ($isNewPdf) { - $pdf->id = Db::idByUid(Table::PDFS, $pdf->uid); - } - - return true; - } - - /** - * Handle PDF status change. - * - * @throws \yii\db\Exception - * @since 3.2 - */ - public function handleChangedPdf(ConfigEvent $event): void - { - ProjectConfigData::ensureAllStoresProcessed(); - - $pdfUid = $event->tokenMatches[0]; - $data = $event->newValue; - - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - $pdfRecord = $this->_getPdfRecord($pdfUid); - $isNewPdf = $pdfRecord->getIsNewRecord(); - $store = Plugin::getInstance()->getStores()->getStoreByUid($data['store']); - - $pdfRecord->storeId = $store->id; - $pdfRecord->name = $data['name']; - $pdfRecord->handle = $data['handle']; - $pdfRecord->description = $data['description']; - $pdfRecord->templatePath = $data['templatePath'] ?? ''; - $pdfRecord->fileNameFormat = $data['fileNameFormat'] ?? ''; - $pdfRecord->enabled = $data['enabled']; - $pdfRecord->sortOrder = $data['sortOrder']; - $pdfRecord->isDefault = $data['isDefault']; - $pdfRecord->language = $data['language'] ?? PdfRecord::LOCALE_ORDER_LANGUAGE; - $pdfRecord->paperOrientation = $data['paperOrientation'] ?? PdfRecord::PAPER_ORIENTATION_PORTRAIT; - $pdfRecord->paperSize = $data['paperSize'] ?? 'letter'; - $pdfRecord->linkExpiry = $data['linkExpiry'] ?? 86400; - - $pdfRecord->uid = $pdfUid; - - $pdfRecord->save(false); - - if ($pdfRecord->isDefault) { - PdfRecord::updateAll(['isDefault' => false], ['and', - ['not', ['id' => $pdfRecord->id]], - ['storeId' => $pdfRecord->storeId], - ]); - } - - $transaction->commit(); - } catch (\Exception $e) { - $transaction->rollBack(); - throw $e; - } - - // Fire a 'afterSavePdf' event - if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_PDF)) { - $this->trigger(self::EVENT_AFTER_SAVE_PDF, new PdfEvent([ - 'pdf' => $this->getPdfById($pdfRecord->id, $pdfRecord->storeId), - 'isNew' => $isNewPdf, - ])); - } - - $this->_allPdfs = null; // clear cache - } - - /** - * Delete an PDF by its ID. - * - * @since 3.2 - */ - public function deletePdfById(int $id): bool - { - $pdf = PdfRecord::findOne($id); - - if ($pdf) { - // Fire a 'beforeDeletePdf' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_DELETE_PDF)) { - $this->trigger(self::EVENT_BEFORE_DELETE_PDF, new PdfEvent([ - 'pdf' => $this->getPdfById($pdf->id, $pdf->storeId), - ])); - } - Craft::$app->getProjectConfig()->remove(self::CONFIG_PDFS_KEY . '.' . $pdf->uid); - } - - return true; - } - - /** - * Handle email getting deleted. - * - * @throws Throwable - * @throws StaleObjectException - * @since 3.2 - */ - public function handleDeletedPdf(ConfigEvent $event): void - { - $uid = $event->tokenMatches[0]; - $pdfRecord = $this->_getPdfRecord($uid); - - if (!$pdfRecord->id) { - return; - } - - $pdfRecord->delete(); - } - - /** - * @throws ErrorException - * @throws Exception - * @throws NotSupportedException - * @throws ServerErrorHttpException - * @since 3.2 - */ - public function reorderPdfs(array $ids): bool - { - // @TODO Fire BEFORE_REORDER_PDFS / AFTER_REORDER_PDFS events around this loop so plugins can react to PDF sort order changes - // @TODO Align this reorder implementation with how other Commerce features handle reordering (project config-driven, single transaction, consistent event names) - foreach ($ids as $index => $id) { - if ($pdf = $this->getPdfById($id)) { - $pdf->sortOrder = $index + 1; - $this->savePdf($pdf, false); - } - } - - $this->_allPdfs = null; // clear cache - - return true; - } - - /** - * Returns a token-based URL for downloading an order's PDF. - * - * This URL is compatible with the DownloadsController::actionPdf() method - * and includes a secure token for anonymous access. - * - * @param Order $order The order to generate the PDF URL for - * @param string|null $option The option that should be available to the PDF template (e.g. "receipt") - * @param string|null $pdfHandle The handle of the PDF to use. If none is passed the default PDF is used. - * @param bool $inline Whether the PDF should be displayed inline in the browser (default: false) - * @return string The URL to download the order's PDF with a secure token - * @since 4.9.5 - */ - public function getPdfUrl(Order $order, string $option = null, string $pdfHandle = null, bool $inline = false): string - { - // Load the PDF to get its link expiry setting - if ($pdfHandle) { - $pdf = $this->getPdfByHandle($pdfHandle); - } else { - $pdf = $this->getDefaultPdf(); - } - - if (!$pdf) { - throw new \InvalidArgumentException("Can not find a PDF to generate URL."); - } - - $expiryDate = (new \DateTime())->add(new \DateInterval('PT' . $pdf->linkExpiry . 'S')); - - $token = Craft::$app->getTokens()->createToken( - ['commerce/downloads/pdf', ['orderNumber' => $order->number]], - null, - $expiryDate - ); - - // Build the URL parameters - $params = [ - 'number' => $order->number, - 'code' => $token, - ]; - - if ($pdfHandle !== null) { - $params['pdfHandle'] = $pdfHandle; - } - - if ($option) { - $params['option'] = $option; - } - - if ($inline) { - $params['inline'] = true; - } - - $request = Craft::$app->getRequest(); - $isCpRequest = $request->getIsCpRequest(); - - if ($isCpRequest) { - $request->setIsCpRequest(false); - } - - try { - return UrlHelper::actionUrl('commerce/downloads/pdf', $params); - } finally { - if ($isCpRequest) { - $request->setIsCpRequest($isCpRequest); - } - } - } - - /** - * Returns a rendered PDF object for the order. - * - * @param Order $order The order you want passed into the PDFs `order` variable. - * @param string $option A string you want passed into the PDFs `option` variable. - * @param string|null $templatePath The path to the template file in the site templates folder that DOMPDF will use to render the PDF. - * @param array $variables Variables available to the pdf html template. Available to template by the array keys. - * @param Pdf|null $pdf The PDF you want to render. This will override the templatePath argument. - * @return string The PDF data. - * @throws Exception - */ - public function renderPdfForOrder(Order $order, string $option = '', string $templatePath = null, array $variables = [], Pdf $pdf = null): string - { - if ($pdf instanceof Pdf) { - $templatePath = $pdf->templatePath; - } - - if (!$templatePath) { - $templatePath = Plugin::getInstance()->getPdfs()->getDefaultPdf()->templatePath; - } - - // Trigger a 'beforeRenderPdf' event - $event = new PdfRenderEvent([ - 'order' => $order, - 'option' => $option, - 'template' => $templatePath, - 'variables' => $variables, - 'sourcePdf' => $pdf, - ]); - $this->trigger(self::EVENT_BEFORE_RENDER_PDF, $event); - - if ($event->pdf !== null) { - return $event->pdf; - } - - $variables = $event->variables; - $variables['order'] = $event->order; - $variables['option'] = $event->option; - - // Set Craft to the site template mode - $view = Craft::$app->getView(); - $originalLanguage = Craft::$app->language; - $originalFormattingLanguage = Craft::$app->formattingLocale; - $pdfLanguage = $pdf?->getRenderLanguage($order) ?? $originalLanguage; - - // @TODO Fire a BEFORE_SWITCH_PDF_LANGUAGE event here so plugins can override or observe the language used when rendering the PDF - Locale::switchAppLanguage($pdfLanguage); - - $oldTemplateMode = $view->getTemplateMode(); - $view->setTemplateMode(View::TEMPLATE_MODE_SITE); - - if (!$event->template || !$view->doesTemplateExist($event->template)) { - // Restore the original template mode - $view->setTemplateMode($oldTemplateMode); - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - - throw new Exception('PDF template file does not exist.'); - } - - try { - // @TODO Fire a BEFORE_RENDER_PDF_TEMPLATE event around the renderTemplate() call so plugins can inspect or modify variables/template right before HTML is generated - $html = $view->renderTemplate($event->template, $variables); - } catch (\Exception $e) { - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - // Set the pdf html to the render error. - Craft::error('Order PDF render error. Order number: ' . $order->getShortNumber() . '. ' . $e->getMessage()); - Craft::$app->getErrorHandler()->logException($e); - $html = Craft::t('commerce', 'An error occurred while generating this PDF.'); - } - - Locale::switchAppLanguage($originalLanguage, $originalFormattingLanguage->id); - // Restore the original template mode - $view->setTemplateMode($oldTemplateMode); - - // Set the config options - $pathService = Craft::$app->getPath(); - $dompdfTempDir = $pathService->getTempPath() . DIRECTORY_SEPARATOR . 'commerce_dompdf'; - $dompdfFontCache = $pathService->getCachePath() . DIRECTORY_SEPARATOR . 'commerce_dompdf'; - $dompdfLogFile = $pathService->getLogPath() . DIRECTORY_SEPARATOR . 'commerce_dompdf.htm'; - - // Ensure directories are created - FileHelper::createDirectory($dompdfTempDir); - FileHelper::createDirectory($dompdfFontCache); - - if (!FileHelper::isWritable($dompdfLogFile)) { - throw new ErrorException("Unable to write to file: $dompdfLogFile"); - } - - if (!FileHelper::isWritable($dompdfFontCache)) { - throw new ErrorException("Unable to write to folder: $dompdfFontCache"); - } - - if (!FileHelper::isWritable($dompdfTempDir)) { - throw new ErrorException("Unable to write to folder: $dompdfTempDir"); - } - - $isRemoteEnabled = Plugin::getInstance()->getSettings()->pdfAllowRemoteImages; - - $options = new Options(); - $options->setTempDir($dompdfTempDir); - $options->setFontCache($dompdfFontCache); - $options->setLogOutputFile($dompdfLogFile); - $options->setIsRemoteEnabled($isRemoteEnabled); - - if ($pdf instanceof Pdf) { - $options->setDefaultPaperOrientation($pdf->paperOrientation); - $options->setDefaultPaperSize($pdf->paperSize); - } - - $renderOptionsEvent = new PdfRenderOptionsEvent([ - 'options' => $options, - ]); - - // Set additional render options - if ($this->hasEventHandlers(self::EVENT_MODIFY_RENDER_OPTIONS)) { - $this->trigger(self::EVENT_MODIFY_RENDER_OPTIONS, $renderOptionsEvent); - } - - // Create and render the PDF - $dompdf = new Dompdf($renderOptionsEvent->options); - $dompdf->loadHtml($html); - $dompdf->render(); - - // Trigger an 'afterRenderPdf' event - $afterEvent = new PdfRenderEvent([ - 'order' => $event->order, - 'option' => $event->option, - 'template' => $event->template, - 'variables' => $variables, - 'pdf' => $dompdf->output(), - 'sourcePdf' => $pdf, - ]); - $this->trigger(self::EVENT_AFTER_RENDER_PDF, $afterEvent); - - return $afterEvent->pdf; - } - - /** - * Gets an PDF record by uid. - * - * @since 3.2 - */ - private function _getPdfRecord(string $uid): PdfRecord - { - if ($pdf = PdfRecord::findOne(['uid' => $uid])) { - return $pdf; - } - - return new PdfRecord(); - } - - /** - * Returns a Query object prepped for retrieving PDFs. - * - * @since 3.2 - */ - private function _createPdfsQuery(): Query - { - $query = (new Query()) - ->select([ - 'description', - 'enabled', - 'fileNameFormat', - 'handle', - 'id', - 'isDefault', - 'language', - 'name', - 'paperOrientation', - 'paperSize', - 'sortOrder', - 'storeId', - 'templatePath', - 'uid', - ]) - ->orderBy('name') - ->from([Table::PDFS]) - ->orderBy(['sortOrder' => SORT_ASC]); - - // @TODO Remove this columnExists check in Commerce 6.0 once the schema guarantees the linkExpiry column on the pdfs table - if (Craft::$app->getDb()->columnExists(Table::PDFS, 'linkExpiry')) { - $query->addSelect('linkExpiry'); - } - - return $query; - } -} diff --git a/src/services/Plans.php b/src/services/Plans.php deleted file mode 100644 index 09964d6060..0000000000 --- a/src/services/Plans.php +++ /dev/null @@ -1,396 +0,0 @@ - - * @since 2.0 - * - * @property array|Plan[] $allEnabledPlans - * @property array|Plan[] $allPlans - */ -class Plans extends Component -{ - /** - * @event PlanEvent The event that is triggered when a plan is archived. - * - * Plugins can get notified whenever a subscription plan is being archived. - * This is useful as sometimes this can be triggered by an action on the gateway. - * - * ```php - * use craft\commerce\events\PlanEvent; - * use craft\commerce\services\Plans; - * use yii\base\Event; - * - * Event::on(Plans::class, Plans::EVENT_ARCHIVE_PLAN, function(PlanEvent $e) { - * // Do something as the plan is being retired. - * }); - * ``` - */ - public const EVENT_ARCHIVE_PLAN = 'archivePlan'; - - /** - * @event PlanEvent The event that is triggered before a plan is saved. - * - * Plugins can get notified before a subscription plan is being saved. - * - * ```php - * use craft\commerce\events\PlanEvent; - * use craft\commerce\services\Plans; - * use yii\base\Event; - * - * Event::on(Plans::class, Plans::EVENT_BEFORE_SAVE_PLAN, function(PlanEvent $e) { - * // Do something - * }); - * ``` - */ - public const EVENT_BEFORE_SAVE_PLAN = 'beforeSavePlan'; - - /** - * @event PlanEvent The event that is triggered after a plan is saved. - * - * Plugins can get notified after a subscription plan is being saved. - * - * ```php - * use craft\commerce\events\PlanEvent; - * use craft\commerce\services\Plans; - * use yii\base\Event; - * - * Event::on(Plans::class, Plans::EVENT_AFTER_SAVE_PLAN, function(PlanEvent $e) { - * // Do something - * }); - * ``` - */ - public const EVENT_AFTER_SAVE_PLAN = 'afterSavePlan'; - - /** - * Memoized array of plans. - * - * @var Plan[]|null - * @since 3.2.8 - */ - private ?array $_allPlans = null; - - /** - * Returns all subscription plans - * - * @return Plan[] - */ - public function getAllPlans(): array - { - return ArrayHelper::where($this->_getAllPlans(), 'isArchived', false); - } - - /** - * Returns all enabled subscription plans - * - * @return Plan[] - * @noinspection PhpUnused - */ - public function getAllEnabledPlans(): array - { - return ArrayHelper::whereMultiple($this->_getAllPlans(), ['enabled' => true, 'isArchived' => false]); - } - - /** - * Return all subscription plans for a gateway. - * - * @return Plan[] - */ - public function getPlansByGatewayId(int $gatewayId): array - { - return ArrayHelper::whereMultiple($this->_getAllPlans(), ['gatewayId' => $gatewayId, 'isArchived' => false]); - } - - /** - * Return all subscription plans for a gateway. - * - * @return Plan[] - * @deprecated in 4.0. Use [[getPlansByGatewayId]] instead. - * @todo remove in Commerce 6.0 - */ - public function getAllGatewayPlans(int $gatewayId): array - { - return $this->getPlansByGatewayId($gatewayId); - } - - /** - * Returns a subscription plan by its id. - * - * @param int $planId The plan id. - */ - public function getPlanById(int $planId): ?Plan - { - return ArrayHelper::firstWhere($this->_getAllPlans(), 'id', $planId); - } - - /** - * Returns a subscription plan by its uid. - * - * @param string $planUid The plan uid. - */ - public function getPlanByUid(string $planUid): ?Plan - { - return ArrayHelper::firstWhere($this->_getAllPlans(), 'uid', $planUid); - } - - /** - * Returns a subscription plan by its handle. - * - * @param string $handle the plan handle - * @noinspection PhpUnused - */ - public function getPlanByHandle(string $handle): ?Plan - { - return ArrayHelper::firstValue(ArrayHelper::whereMultiple($this->_getAllPlans(), ['handle' => $handle, 'isArchived' => false])); - } - - /** - * Returns a subscription plan by its reference. - * - * @param string $reference the plan reference - */ - public function getPlanByReference(string $reference): ?Plan - { - return ArrayHelper::firstWhere($this->_getAllPlans(), 'reference', $reference); - } - - /** - * Returns plans which use the provided Entry for its "information" - * - * @param int $entryId The Entry ID to search by - * @return Plan[] - * @noinspection PhpUnused - */ - public function getPlansByInformationEntryId(int $entryId): array - { - return ArrayHelper::where($this->_getAllPlans(), 'planInformationId', $entryId); - } - - /** - * Save a subscription plan - * - * @param Plan $plan The payment source being saved. - * @param bool $runValidation should we validate this plan before saving. - * @return bool Whether the plan was saved successfully - * @throws InvalidConfigException if subscription plan not found by id. - */ - public function savePlan(Plan $plan, bool $runValidation = true): bool - { - if ($plan->id) { - $record = PlanRecord::findOne($plan->id); - - if (!$record) { - throw new InvalidConfigException(Craft::t('commerce', 'No subscription plan exists with the ID “{id}”', ['id' => $plan->id])); - } - } else { - $record = new PlanRecord(); - } - - // fire a 'beforeSavePlan' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_SAVE_PLAN)) { - $this->trigger(self::EVENT_BEFORE_SAVE_PLAN, new PlanEvent([ - 'plan' => $plan, - ])); - } - - if ($runValidation && !$plan->validate()) { - Craft::info('Subscription plan not saved due to validation error.', __METHOD__); - - return false; - } - - $record->gatewayId = $plan->gatewayId; - $record->name = $plan->name; - $record->handle = $plan->handle; - $record->planInformationId = $plan->planInformationId; - $record->reference = $plan->reference; - $record->planData = $plan->planData; - $record->enabled = $plan->enabled; - $record->isArchived = $plan->isArchived; - $record->dateArchived = Db::prepareDateForDb($plan->dateArchived); - $record->sortOrder = $plan->sortOrder ?? 99; - - // Save it! - $record->save(false); - - // Now that we have a record ID, save it on the model - $plan->id = $record->id; - - // Fire an 'afterSavePlan' event. - if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_PLAN)) { - $this->trigger(self::EVENT_AFTER_SAVE_PLAN, new PlanEvent([ - 'plan' => $plan, - ])); - } - - // Reset cache/memoization - $this->_allPlans = null; - - return true; - } - - /** - * Archive a subscription plan by its id. - * - * @param int $id The id - * @throws InvalidConfigException - */ - public function archivePlanById(int $id): bool - { - $plan = $this->getPlanById($id); - - if (!$plan) { - return false; - } - - // Fire an 'archivePlan' event. - if ($this->hasEventHandlers(self::EVENT_ARCHIVE_PLAN)) { - $this->trigger(self::EVENT_ARCHIVE_PLAN, new PlanEvent([ - 'plan' => $plan, - ])); - } - - $plan->isArchived = true; - $plan->dateArchived = DateTimeHelper::now(); - - return $this->savePlan($plan); - } - - /** - * Reorders subscription plans by ids. - * - * @param array $ids Array of plans. - * @return bool Always true. - * @throws Exception - */ - public function reorderPlans(array $ids): bool - { - $command = Craft::$app->getDb()->createCommand(); - - foreach ($ids as $planOrder => $planId) { - $command->update(Table::PLANS, ['sortOrder' => $planOrder + 1], ['id' => $planId])->execute(); - } - - // Reset cache/memoization - $this->_allPlans = null; - - return true; - } - - - /** - * Returns a Query object prepped for retrieving gateways. - * - * @return Query The query object. - */ - private function _createPlansQuery(): Query - { - return (new Query()) - ->select([ - 'p.dateArchived', - 'p.dateCreated', - 'p.dateUpdated', - 'p.enabled', - 'p.gatewayId', - 'p.handle', - 'p.id', - 'p.isArchived', - 'p.name', - 'p.planData', - 'p.planInformationId', - 'p.reference', - 'p.sortOrder', - 'p.uid', - ]) - ->leftJoin(['g' => Table::GATEWAYS], '[[g.id]] = [[p.gatewayId]]') - ->where(['g.isArchived' => false]) - ->orderBy(['sortOrder' => SORT_ASC]) - ->from(['p' => Table::PLANS]); - } - - /** - * Populate an array of plans from their database table rows - * - * @return Plan[] - */ - private function _populatePlans(array $results): array - { - $plans = []; - - foreach ($results as $result) { - try { - $plans[] = $this->_populatePlan($result); - } catch (InvalidConfigException) { - continue; // Just skip this - } - } - - return $plans; - } - - /** - * Populate a payment plan model from database table row. - * - * @throws InvalidConfigException if the gateway does not support subscriptions - */ - private function _populatePlan(array $result): Plan - { - $gateway = Plugin::getInstance()->getGateways()->getGatewayById($result['gatewayId']); - - if (!$gateway instanceof SubscriptionGateway) { - throw new InvalidConfigException('This gateway does not support subscriptions'); - } - - $plan = $gateway->getPlanModel(); - - $plan->setAttributes($result, false); - - return $plan; - } - - /** - * Get all plans memoized. - * - * @return array - * @since 3.2.8 - */ - private function _getAllPlans(): array - { - if ($this->_allPlans === null) { - $this->_allPlans = []; - $plans = $this->_createPlansQuery()->all(); - - if (!empty($plans)) { - $plans = $this->_populatePlans($plans); - foreach ($plans as $plan) { - $this->_allPlans[$plan->id] = $plan; - } - } - } - - return $this->_allPlans; - } -} diff --git a/src/services/ProductTypes.php b/src/services/ProductTypes.php deleted file mode 100755 index 31de70e866..0000000000 --- a/src/services/ProductTypes.php +++ /dev/null @@ -1,1069 +0,0 @@ - - * @since 2.0 - */ -class ProductTypes extends Component -{ - /** - * @event ProductTypeEvent The event that is triggered before a product type is saved. - * - * ```php - * use craft\commerce\events\ProductTypeEvent; - * use craft\commerce\services\ProductTypes; - * use craft\commerce\models\ProductType; - * use yii\base\Event; - * - * Event::on( - * ProductTypes::class, - * ProductTypes::EVENT_BEFORE_SAVE_PRODUCTTYPE, - * function(ProductTypeEvent $event) { - * // @var ProductType|null $productType - * $productType = $event->productType; - * - * // Create an audit trail of this action - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_SAVE_PRODUCTTYPE = 'beforeSaveProductType'; - - /** - * @event ProductTypeEvent The event that is triggered after a product type has been saved. - * - * ```php - * use craft\commerce\events\ProductTypeEvent; - * use craft\commerce\services\ProductTypes; - * use craft\commerce\models\ProductType; - * use yii\base\Event; - * - * Event::on( - * ProductTypes::class, - * ProductTypes::EVENT_AFTER_SAVE_PRODUCTTYPE, - * function(ProductTypeEvent $event) { - * // @var ProductType|null $productType - * $productType = $event->productType; - * - * // Prepare some third party system for a new product type - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_SAVE_PRODUCTTYPE = 'afterSaveProductType'; - - public const CONFIG_PRODUCTTYPES_KEY = 'commerce.productTypes'; - - /** - * @var array|null - */ - private ?array $_allProductTypes = null; - - /** - * @var ProductTypeSite[][] - */ - private array $_siteSettingsByProductId = []; - - /** - * @var array interim storage for product types being saved via control panel - */ - private array $_savingProductTypes = []; - - - /** - * Returns all editable product types. - * - * @return ProductType[] An array of all the editable product types. - * @deprecated in 5.7.0. Use [[getViewableProductTypes()]] instead. - */ - public function getEditableProductTypes(): array - { - Craft::$app->getDeprecator()->log(__METHOD__, '`ProductTypes::getEditableProductTypes()` has been deprecated. Use `getViewableProductTypes()` instead.'); - return $this->getViewableProductTypes(); - } - - /** - * Returns all viewable product types. - * - * @return ProductType[] An array of all the viewable product types. - */ - public function getViewableProductTypes(): array - { - if (Craft::$app->getRequest()->getIsConsoleRequest()) { - return $this->getAllProductTypes(); - } - - $user = Craft::$app->getUser()->getIdentity(); - - if (!$user) { - return []; - } - - $viewableProductTypeIds = $this->getViewableProductTypeIds(); - $viewableProductTypes = []; - - foreach ($this->getAllProductTypes() as $productType) { - if (in_array($productType->id, $viewableProductTypeIds)) { - $viewableProductTypes[] = $productType; - } - } - - return $viewableProductTypes; - } - - /** - * Returns all product type IDs that are editable by the current user. - * - * @return array An array of all the editable product types' IDs. - * @deprecated in 5.7.0. Use [[getViewableProductTypeIds()]] instead. - */ - public function getEditableProductTypeIds(bool $anySite = false): array - { - Craft::$app->getDeprecator()->log(__METHOD__, '`ProductTypes::getEditableProductTypeIds()` has been deprecated. Use `getViewableProductTypeIds()` instead.'); - return $this->getViewableProductTypeIds($anySite); - } - - /** - * Returns all product type IDs that are viewable by the current user. - * - * @return array An array of all the viewable product types' IDs. - */ - public function getViewableProductTypeIds(bool $anySite = false): array - { - $viewableIds = []; - $user = Craft::$app->getUser()->getIdentity(); - $allProductTypes = $this->getAllProductTypes(); - - $cpSite = Cp::requestedSite(); - - foreach ($allProductTypes as $productType) { - if (!$user->can('commerce-viewProductType:' . $productType->uid)) { - continue; - } - - if (!$anySite && $cpSite && !isset($productType->getSiteSettings()[$cpSite->id])) { - continue; - } - - $viewableIds[] = $productType->id; - } - - return $viewableIds; - } - - /** - * Returns all product type IDs that are creatable by the current user. - * - * @return array - * @throws InvalidConfigException - */ - public function getCreatableProductTypeIds(): array - { - $creatableIds = []; - $user = Craft::$app->getUser()->getIdentity(); - $allProductTypes = $this->getAllProductTypes(); - - foreach ($allProductTypes as $productType) { - if ($user->can('commerce-createProductType:' . $productType->uid)) { - $creatableIds[] = $productType->id; - } - } - - return $creatableIds; - } - - /** - * Returns all creatable product types. - * @return array - * @throws InvalidConfigException - */ - public function getCreatableProductTypes(): array - { - $creatableProductTypeIds = $this->getCreatableProductTypeIds(); - $creatableProductTypes = []; - - foreach ($this->getAllProductTypes() as $productType) { - if (in_array($productType->id, $creatableProductTypeIds)) { - $creatableProductTypes[] = $productType; - } - } - - return $creatableProductTypes; - } - - /** - * Returns all the product type IDs. - * - * @return array An array of all the product types' IDs. - */ - public function getAllProductTypeIds(): array - { - return collect($this->getAllProductTypes())->pluck('id')->all(); - } - - /** - * Returns all product types. - * - * @return ProductType[] An array of all product types. - */ - public function getAllProductTypes(): array - { - if ($this->_allProductTypes !== null) { - return $this->_allProductTypes; - } - - $this->_allProductTypes = []; - - $results = $this->_createProductTypeQuery()->all(); - foreach ($results as $result) { - $this->_allProductTypes[] = new ProductType($result); - } - - return $this->_allProductTypes; - } - - /** - * Returns a product type by its handle. - * - * @param string $handle The product type's handle. - * @return ProductType|null The product type or `null`. - */ - public function getProductTypeByHandle(string $handle): ?ProductType - { - return collect($this->getAllProductTypes())->where('handle', $handle)->first(); - } - - /** - * Returns an array of product type site settings for a product type by its ID. - * - * @param int $productTypeId the product type ID - * @return array The product type settings. - */ - public function getProductTypeSites(int $productTypeId): array - { - $db = Craft::$app->getDb(); - if (!isset($this->_siteSettingsByProductId[$productTypeId])) { - $query = (new Query()) - ->select([ - 'hasUrls', - 'id', - 'productTypeId', - 'siteId', - 'template', - 'uriFormat', - ]) - ->from(Table::PRODUCTTYPES_SITES) - ->where(['productTypeId' => $productTypeId]); - - if ($db->columnExists(Table::PRODUCTTYPES_SITES, 'enabledByDefault')) { - $query->addSelect('enabledByDefault'); - } - - $rows = $query->all(); - - $this->_siteSettingsByProductId[$productTypeId] = []; - - foreach ($rows as $row) { - $this->_siteSettingsByProductId[$productTypeId][] = new ProductTypeSite($row); - } - } - - return $this->_siteSettingsByProductId[$productTypeId]; - } - - /** - * Saves a product type. - * - * @param ProductType $productType The product type model. - * @param bool $runValidation If validation should be ran. - * @return bool Whether the product type was saved successfully. - * @throws Throwable if reasons - */ - public function saveProductType(ProductType $productType, bool $runValidation = true): bool - { - $isNewProductType = !$productType->id; - - // Fire a 'beforeSaveProductType' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_SAVE_PRODUCTTYPE)) { - $this->trigger(self::EVENT_BEFORE_SAVE_PRODUCTTYPE, new ProductTypeEvent([ - 'productType' => $productType, - 'isNew' => $isNewProductType, - ])); - } - - if ($runValidation && !$productType->validate()) { - Craft::info('Product type not saved due to validation error.', __METHOD__); - - return false; - } - - if ($isNewProductType) { - $productType->uid = StringHelper::UUID(); - } else { - /** @var ProductTypeRecord|null $existingProductTypeRecord */ - $existingProductTypeRecord = ProductTypeRecord::find() - ->where(['id' => $productType->id]) - ->one(); - - if (!$existingProductTypeRecord) { - throw new ProductTypeNotFoundException("No product type exists with the ID '$productType->id'"); - } - - $productType->uid = $existingProductTypeRecord->uid; - } - - $this->_savingProductTypes[$productType->uid] = $productType; - - $projectConfig = Craft::$app->getProjectConfig(); - - $configData = $productType->getConfig(); - - $configPath = self::CONFIG_PRODUCTTYPES_KEY . '.' . $productType->uid; - $projectConfig->set($configPath, $configData); - - if ($isNewProductType) { - $productType->id = Db::idByUid(Table::PRODUCTTYPES, $productType->uid); - } - - return true; - } - - /** - * Handle a product type change. - * - * @throws Throwable if reasons - */ - public function handleChangedProductType(ConfigEvent $event): void - { - $productTypeUid = $event->tokenMatches[0]; - $data = $event->newValue; - $shouldResaveProducts = false; - - // Make sure fields and sites are processed - ProjectConfigHelper::ensureAllSitesProcessed(); - ProjectConfigHelper::ensureAllFieldsProcessed(); - - $db = Craft::$app->getDb(); - $transaction = $db->beginTransaction(); - - try { - $siteData = $data['siteSettings']; - - // Basic data - $productTypeRecord = $this->_getProductTypeRecord($productTypeUid); - $isNewProductType = $productTypeRecord->getIsNewRecord(); - $fieldsService = Craft::$app->getFields(); - - $productTypeRecord->uid = $productTypeUid; - $productTypeRecord->name = $data['name']; - $productTypeRecord->handle = $data['handle']; - $productTypeRecord->enableVersioning = $data['enableVersioning'] ?? false; - $productTypeRecord->hasDimensions = $data['hasDimensions']; - - $productTypeRecord->productTitleTranslationMethod = $data['productTitleTranslationMethod'] ?? 'site'; - $productTypeRecord->productTitleTranslationKeyFormat = $data['productTitleTranslationKeyFormat'] ?? ''; - - $productTypeRecord->propagationMethod = $data['propagationMethod'] ?? PropagationMethod::All->value; - - // Resave products if propagation method has changed - if ($productTypeRecord->propagationMethod != $productTypeRecord->getOldAttribute('propagationMethod')) { - $shouldResaveProducts = true; - } - - $productTypeRecord->variantTitleTranslationMethod = $data['variantTitleTranslationMethod'] ?? 'site'; - $productTypeRecord->variantTitleTranslationKeyFormat = $data['variantTitleTranslationKeyFormat'] ?? ''; - - // Variant title fields - $hasVariantTitleField = $data['hasVariantTitleField']; - $variantTitleFormat = $data['variantTitleFormat'] ?? '{product.title}'; - if ($productTypeRecord->variantTitleFormat != $variantTitleFormat || - $productTypeRecord->hasVariantTitleField != $hasVariantTitleField) { - $shouldResaveProducts = true; - } - $productTypeRecord->variantTitleFormat = $variantTitleFormat; - $productTypeRecord->hasVariantTitleField = $hasVariantTitleField; - $productTypeRecord->variantUiLabelFormat = $data['variantUiLabelFormat'] ?? '{title}'; - - // Product title fields - $hasProductTitleField = $data['hasProductTitleField']; - $productTitleFormat = $data['productTitleFormat'] ?? 'Title'; - if ($productTypeRecord->productTitleFormat != $productTitleFormat || - $productTypeRecord->hasProductTitleField != $hasProductTitleField) { - $shouldResaveProducts = true; - } - $productTypeRecord->productTitleFormat = $productTitleFormat; - $productTypeRecord->hasProductTitleField = $hasProductTitleField; - $productTypeRecord->productUiLabelFormat = $data['productUiLabelFormat'] ?? '{title}'; - - // Slug fields - $productTypeRecord->showSlugField = $data['showSlugField'] ?? true; - $productTypeRecord->slugTranslationMethod = $data['slugTranslationMethod'] ?? 'site'; - $productTypeRecord->slugTranslationKeyFormat = $data['slugTranslationKeyFormat'] ?? null; - - if ($productTypeRecord->maxVariants != $data['maxVariants']) { - $shouldResaveProducts = true; - } - $productTypeRecord->maxVariants = $data['maxVariants']; - - $skuFormat = $data['skuFormat'] ?? ''; - if ($productTypeRecord->skuFormat != $skuFormat) { - $shouldResaveProducts = true; - } - $productTypeRecord->skuFormat = $skuFormat; - - $descriptionFormat = $data['descriptionFormat'] ?? ''; - if ($productTypeRecord->descriptionFormat != $descriptionFormat) { - $shouldResaveProducts = true; - } - $productTypeRecord->descriptionFormat = $descriptionFormat; - $productTypeRecord->isStructure = $data['isStructure'] ?? false; - $productTypeRecord->maxLevels = $data['maxLevels'] ?? null; - $productTypeRecord->defaultPlacement = $data['defaultPlacement'] ?? ProductType::DEFAULT_PLACEMENT_BEGINNING; - if ($productTypeRecord->isStructure != $productTypeRecord->getOldAttribute('isStructure')) { - $shouldResaveProducts = true; - } - - // Preview targets - if (!empty($data['previewTargets'])) { - $productTypeRecord->previewTargets = ProjectConfigHelper::unpackAssociativeArray($data['previewTargets']); - } else { - $productTypeRecord->previewTargets = null; - } - - if (!empty($data['productFieldLayouts']) && !empty($config = reset($data['productFieldLayouts']))) { - // Save the main field layout - $layout = FieldLayout::createFromConfig($config); - $layout->id = $productTypeRecord->fieldLayoutId; - $layout->type = Product::class; - $layout->uid = key($data['productFieldLayouts']); - $fieldsService->saveLayout($layout, false); - $productTypeRecord->fieldLayoutId = $layout->id; - } elseif ($productTypeRecord->fieldLayoutId) { - // Delete the main field layout - $fieldsService->deleteLayoutById($productTypeRecord->fieldLayoutId); - $productTypeRecord->fieldLayoutId = null; - } - - if (!empty($data['variantFieldLayouts']) && !empty($config = reset($data['variantFieldLayouts']))) { - // Save the variant field layout - $layout = FieldLayout::createFromConfig($config); - $layout->id = $productTypeRecord->variantFieldLayoutId; - $layout->type = Variant::class; - $layout->uid = key($data['variantFieldLayouts']); - $fieldsService->saveLayout($layout, false); - $productTypeRecord->variantFieldLayoutId = $layout->id; - } elseif ($productTypeRecord->variantFieldLayoutId) { - // Delete the variant field layout - $fieldsService->deleteLayoutById($productTypeRecord->variantFieldLayoutId); - $productTypeRecord->variantFieldLayoutId = null; - } - - if ($productTypeRecord->isStructure) { - // Save the structure - $structureUid = $data['structure']['uid']; - $structure = Craft::$app->getStructures()->getStructureByUid($structureUid, true) ?? new Structure(['uid' => $structureUid]); - $isNewStructure = empty($structure->id); - $structure->maxLevels = $data['maxLevels'] ?? null; - Craft::$app->getStructures()->saveStructure($structure); - $productTypeRecord->structureId = $structure->id; - } else { - if ($productTypeRecord->structureId) { - // Delete the old one - Craft::$app->getStructures()->deleteStructureById($productTypeRecord->structureId); - } - - $productTypeRecord->structureId = null; - $isNewStructure = false; - } - - $productTypeRecord->save(false); - - // Update the site settings - // ----------------------------------------------------------------- - - $sitesNowWithoutUrls = []; - $sitesWithNewUriFormats = []; - /** @var array $allOldSiteSettingsRecords */ - $allOldSiteSettingsRecords = []; - - if (!$isNewProductType) { - /** @var array $allOldSiteSettingsRecords */ - $allOldSiteSettingsRecords = ProductTypeSiteRecord::find() - ->where(['productTypeId' => $productTypeRecord->id]) - ->indexBy('siteId') - ->all(); - } - - $siteIdMap = Db::idsByUids('{{%sites}}', array_keys($siteData)); - - /** @var array $siteSettings */ - foreach ($siteData as $siteUid => $siteSettings) { - $siteId = $siteIdMap[$siteUid]; - - // Was this already selected? - if (!$isNewProductType && isset($allOldSiteSettingsRecords[$siteId])) { - $siteSettingsRecord = $allOldSiteSettingsRecords[$siteId]; - } else { - $siteSettingsRecord = new ProductTypeSiteRecord(); - $siteSettingsRecord->productTypeId = $productTypeRecord->id; - $siteSettingsRecord->siteId = $siteId; - } - - $siteSettingsRecord->enabledByDefault = (bool)($siteSettings['enabledByDefault'] ?? true); - - if ($siteSettingsRecord->hasUrls = $siteSettings['hasUrls']) { - $siteSettingsRecord->uriFormat = $siteSettings['uriFormat']; - $siteSettingsRecord->template = $siteSettings['template']; - } else { - $siteSettingsRecord->uriFormat = null; - $siteSettingsRecord->template = null; - } - - if (!$siteSettingsRecord->getIsNewRecord()) { - // Did it used to have URLs, but not anymore? - if ($siteSettingsRecord->isAttributeChanged('hasUrls', false) && !$siteSettings['hasUrls']) { - $sitesNowWithoutUrls[] = $siteId; - } - - // Does it have URLs, and has its URI format changed? - if ($siteSettings['hasUrls'] && $siteSettingsRecord->isAttributeChanged('uriFormat', false)) { - $sitesWithNewUriFormats[] = $siteId; - } - } - - $siteSettingsRecord->save(false); - } - - if (!$isNewProductType) { - // Drop any site settings that are no longer being used, as well as the associated product/element - // site rows - $affectedSiteUids = array_keys($siteData); - - foreach ($allOldSiteSettingsRecords as $siteId => $siteSettingsRecord) { - $siteUid = array_search($siteId, $siteIdMap, false); - if (!in_array($siteUid, $affectedSiteUids, false)) { - $siteSettingsRecord->delete(); - $shouldResaveProducts = true; - } - } - } - - // If the section was just converted to a Structure, - // add the existing entries to the structure - // ----------------------------------------------------------------- - - if ( - $productTypeRecord->isStructure && - !$isNewProductType && - $isNewStructure - ) { - $this->_populateNewStructure($productTypeRecord); - } - - // Finally, deal with the existing products... - // ----------------------------------------------------------------- - - if (!$isNewProductType) { - // Get all the product IDs in this group - $productIds = Product::find() - ->typeId($productTypeRecord->id) - ->status(null) - ->limit(null) - ->ids(); - - // Are there any sites left? - if (!empty($siteData)) { - // Drop the old product URIs for any site settings that don't have URLs - if (!empty($sitesNowWithoutUrls)) { - $db->createCommand() - ->update( - '{{%elements_sites}}', - ['uri' => null], - [ - 'elementId' => $productIds, - 'siteId' => $sitesNowWithoutUrls, - ]) - ->execute(); - } elseif (!empty($sitesWithNewUriFormats)) { - foreach ($productIds as $productId) { - App::maxPowerCaptain(); - - // Loop through each of the changed sites and update all of the products' slugs and - // URIs - foreach ($sitesWithNewUriFormats as $siteId) { - $product = Product::find() - ->id($productId) - ->siteId($siteId) - ->status(null) - ->one(); - - if ($product) { - Craft::$app->getElements()->updateElementSlugAndUri($product, false, false); - } - } - } - } - } - } - - $transaction->commit(); - - if ($shouldResaveProducts) { - Craft::$app->getQueue()->push(new ResaveElements([ - 'elementType' => Product::class, - 'criteria' => [ - 'siteId' => '*', - 'status' => null, - 'typeId' => $productTypeRecord->id, - ], - ])); - } - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - - // Clear caches - $this->_allProductTypes = null; - unset($this->_siteSettingsByProductId[$productTypeRecord->id]); - - // Fire an 'afterSaveProductType' event - if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_PRODUCTTYPE)) { - $this->trigger(self::EVENT_AFTER_SAVE_PRODUCTTYPE, new ProductTypeEvent([ - 'productType' => $this->getProductTypeById($productTypeRecord->id), - 'isNew' => empty($this->_savingProductTypes[$productTypeUid]), - ])); - } - } - - /** - * Adds existing products to a newly-created structure, if the product type was just converted to Orderable. - * - * @param ProductTypeRecord $productTypeRecord - * @throws Exception if reasons - * @see saveProductType() - */ - private function _populateNewStructure(ProductTypeRecord $productTypeRecord): void - { - // Add all the products to the structure - $query = Product::find() - ->typeId($productTypeRecord->id) - ->drafts(null) - ->draftOf(false) - ->site('*') - ->unique() - ->status(null) - ->orderBy(['id' => SORT_ASC]) - ->withStructure(false); - - $structuresService = Craft::$app->getStructures(); - - foreach (Db::each($query) as $product) { - /** @var Product $product */ - $structuresService->appendToRoot($productTypeRecord->structureId, $product, Structures::MODE_INSERT); - } - } - - /** - * Returns all product types by a tax category id. - */ - public function getProductTypesByTaxCategoryId(int $taxCategoryId): array - { - $rows = $this->_createProductTypeQuery() - ->innerJoin(Table::PRODUCTTYPES_TAXCATEGORIES . ' productTypeTaxCategories', '[[productTypes.id]] = [[productTypeTaxCategories.productTypeId]]') - ->where(['productTypeTaxCategories.taxCategoryId' => $taxCategoryId]) - ->all(); - - $productTypes = []; - - foreach ($rows as $row) { - $productTypes[$row['id']] = new ProductType($row); - } - - return $productTypes; - } - - /** - * Returns all product types by a shipping category id. - */ - public function getProductTypesByShippingCategoryId(int $shippingCategoryId): array - { - $rows = $this->_createProductTypeQuery() - ->innerJoin(Table::PRODUCTTYPES_SHIPPINGCATEGORIES . ' productTypeShippingCategories', '[[productTypes.id]] = [[productTypeShippingCategories.productTypeId]]') - ->where(['productTypeShippingCategories.shippingCategoryId' => $shippingCategoryId]) - ->all(); - - $productTypes = []; - - foreach ($rows as $row) { - $productTypes[$row['id']] = new ProductType($row); - } - - return $productTypes; - } - - /** - * Deletes a product type by its ID. - * - * @param int $id the product type's ID - * @return bool Whether the product type was deleted successfully. - * @throws Throwable if reasons - */ - public function deleteProductTypeById(int $id): bool - { - $productType = $this->getProductTypeById($id); - Craft::$app->getProjectConfig()->remove(self::CONFIG_PRODUCTTYPES_KEY . '.' . $productType->uid); - return true; - } - - /** - * Handle a product type getting deleted. - * - * @throws Throwable if reasons - */ - public function handleDeletedProductType(ConfigEvent $event): void - { - $uid = $event->tokenMatches[0]; - $productTypeRecord = $this->_getProductTypeRecord($uid); - - if (!$productTypeRecord->id) { - return; - } - - $db = Craft::$app->getDb(); - $transaction = $db->beginTransaction(); - - try { - $products = Product::find() - ->typeId($productTypeRecord->id) - ->status(null) - ->limit(null) - ->all(); - - foreach ($products as $product) { - Craft::$app->getElements()->deleteElement($product); - } - - $fieldLayoutId = $productTypeRecord->fieldLayoutId; - $variantFieldLayoutId = $productTypeRecord->variantFieldLayoutId; - Craft::$app->getFields()->deleteLayoutById($fieldLayoutId); - - if ($variantFieldLayoutId) { - Craft::$app->getFields()->deleteLayoutById($variantFieldLayoutId); - } - - $productTypeRecord->delete(); - $transaction->commit(); - } catch (Throwable $e) { - $transaction->rollBack(); - - throw $e; - } - - // Clear caches - $this->_allProductTypes = null; - unset($this->_siteSettingsByProductId[$productTypeRecord->id]); - } - - /** - * Prune a deleted site from category group site settings. - */ - public function pruneDeletedSite(DeleteSiteEvent $event): void - { - $siteUid = $event->site->uid; - - $projectConfig = Craft::$app->getProjectConfig(); - $productTypes = $projectConfig->get(self::CONFIG_PRODUCTTYPES_KEY); - - // Loop through the product types and prune the UID from field layouts. - if (is_array($productTypes)) { - foreach ($productTypes as $productTypeUid => $productType) { - $projectConfig->remove(self::CONFIG_PRODUCTTYPES_KEY . '.' . $productTypeUid . '.siteSettings.' . $siteUid); - } - } - } - - /** - * @deprecated in 3.4.17. Unused fields will be pruned automatically as field layouts are resaved. - */ - public function pruneDeletedField(): void - { - } - - /** - * Returns a product type by its ID. - * - * @param int $productTypeId the product type's ID - * @return ProductType|null either the product type or `null` - */ - public function getProductTypeById(int $productTypeId): ?ProductType - { - return collect($this->getAllProductTypes())->where('id', $productTypeId)->first(); - } - - /** - * Returns a product type by its UID. - * - * @param string $uid the product type's UID - * @return ProductType|null either the product type or `null` - */ - public function getProductTypeByUid(string $uid): ?ProductType - { - return collect($this->getAllProductTypes())->where('uid', $uid)->first(); - } - - /** - * Returns whether a product type's products have URLs, and if the template path is valid. - * - * @param ProductType $productType The product for which to validate the template. - * @param int $siteId The site for which to valid for - * @return bool Whether the template is valid. - * @throws Exception - */ - public function isProductTypeTemplateValid(ProductType $productType, int $siteId): bool - { - $productTypeSiteSettings = $productType->getSiteSettings(); - - if (isset($productTypeSiteSettings[$siteId]) && $productTypeSiteSettings[$siteId]->hasUrls && $productTypeSiteSettings[$siteId]->template) { - // Set Craft to the site template mode - $view = Craft::$app->getView(); - $oldTemplateMode = $view->getTemplateMode(); - $view->setTemplateMode($view::TEMPLATE_MODE_SITE); - - // Does the template exist? - $templateExists = Craft::$app->getView()->doesTemplateExist($productTypeSiteSettings[$siteId]->template); - - // Restore the original template mode - $view->setTemplateMode($oldTemplateMode); - - if ($templateExists) { - return true; - } - } - - return false; - } - - /** - * Adds a new product type setting row when a Site is added to Craft. - * - * @param SiteEvent $event The event that triggered this. - * @throws Exception - * @throws ErrorException - * @throws InvalidConfigException - * @throws NotSupportedException - * @throws ServerErrorHttpException - */ - public function afterSaveSiteHandler(SiteEvent $event): void - { - if ($event->isNew && isset($event->oldPrimarySiteId)) { - $oldPrimarySiteUid = Db::uidById(CraftTable::SITES, $event->oldPrimarySiteId); - $projectConfig = Craft::$app->getProjectConfig(); - $existingProductTypeSettings = $projectConfig->get(self::CONFIG_PRODUCTTYPES_KEY); - - if (!$projectConfig->getIsApplyingExternalChanges() && is_array($existingProductTypeSettings)) { - foreach ($existingProductTypeSettings as $productTypeUid => $settings) { - $primarySiteSettings = $settings['siteSettings'][$oldPrimarySiteUid] ?? null; - if ($primarySiteSettings === null) { - continue; - } - - $configPath = self::CONFIG_PRODUCTTYPES_KEY . '.' . $productTypeUid . '.siteSettings.' . $event->site->uid; - $projectConfig->set($configPath, $primarySiteSettings); - } - } - } - } - - /** - * Returns a Query object prepped for retrieving purchasables. - * - * @return Query The query object. - */ - private function _createProductTypeQuery(): Query - { - $query = (new Query()) - ->select([ - 'productTypes.descriptionFormat', - 'productTypes.fieldLayoutId', - 'productTypes.handle', - 'productTypes.hasDimensions', - 'productTypes.hasProductTitleField', - 'productTypes.hasVariantTitleField', - 'productTypes.id', - 'productTypes.name', - 'productTypes.maxVariants', - 'productTypes.productTitleFormat', - 'productTypes.skuFormat', - 'productTypes.uid', - 'productTypes.variantFieldLayoutId', - ]) - ->from([Table::PRODUCTTYPES . ' productTypes']); - - // @TODO Remove this columnExists check in Commerce 6.0 once the schema guarantees the `variantTitleFormat` column on the producttypes table (was renamed from `titleFormat`) - $db = Craft::$app->getDb(); - if ($db->columnExists(Table::PRODUCTTYPES, 'variantTitleFormat')) { - $query->addSelect('productTypes.variantTitleFormat'); - } else { - $query->addSelect('productTypes.titleFormat'); - } - - /** @since 5.0 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'enableVersioning')) { - $query->addSelect('productTypes.enableVersioning'); - } - - /** @since 5.2 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'isStructure')) { - $query->addSelect('productTypes.isStructure'); - $query->addSelect('productTypes.maxLevels'); - } - - /** @since 5.2 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'defaultPlacement')) { - $query->addSelect('productTypes.defaultPlacement'); - } - - /** @since 5.2 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'structureId')) { - $query->addSelect('productTypes.structureId'); - } - - /** @since 5.1 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'productTitleTranslationMethod')) { - $query->addSelect('productTypes.productTitleTranslationMethod'); - } - - /** @since 5.1 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'productTitleTranslationKeyFormat')) { - $query->addSelect('productTypes.productTitleTranslationKeyFormat'); - } - - if ($db->columnExists(Table::PRODUCTTYPES, 'variantTitleTranslationMethod')) { - $query->addSelect('productTypes.variantTitleTranslationMethod'); - } - - /** @since 5.1 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'variantTitleTranslationKeyFormat')) { - $query->addSelect('productTypes.variantTitleTranslationKeyFormat'); - } - - /** @since 5.1 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'propagationMethod')) { - $query->addSelect('productTypes.propagationMethod'); - } - - /** @since 5.5 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'showSlugField')) { - $query->addSelect('productTypes.showSlugField'); - } - - /** @since 5.5 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'slugTranslationMethod')) { - $query->addSelect('productTypes.slugTranslationMethod'); - } - - /** @since 5.5 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'slugTranslationKeyFormat')) { - $query->addSelect('productTypes.slugTranslationKeyFormat'); - } - - /** @since 5.5 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'previewTargets')) { - $query->addSelect('productTypes.previewTargets'); - } - - /** @since 5.6 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'variantUiLabelFormat')) { - $query->addSelect('productTypes.variantUiLabelFormat'); - } - - /** @since 5.6 */ - if ($db->columnExists(Table::PRODUCTTYPES, 'productUiLabelFormat')) { - $query->addSelect('productTypes.productUiLabelFormat'); - } - - return $query; - } - - /** - * Gets a product type's record by uid. - */ - private function _getProductTypeRecord(string $uid): ProductTypeRecord - { - if ($productType = ProductTypeRecord::findOne(['uid' => $uid])) { - return $productType; - } - - return new ProductTypeRecord(); - } - - /** - * Check if user has product type permission. - * - * @param User $user - * @param ProductType $productType - * @param string|null $checkPermissionName detailed product type permission. - * @return bool - * @deprecated in 5.7.0. Use `$user->can()` directly instead. - */ - public function hasPermission(User $user, ProductType $productType, ?string $checkPermissionName = null): bool - { - Craft::$app->getDeprecator()->log(__METHOD__, '`ProductTypes::hasPermission()` has been deprecated. Use `$user->can()` directly instead. Note that permission names have changed: `commerce-editProductType:{uid}` is now `commerce-viewProductType:{uid}` and `commerce-saveProductType:{uid}`; `commerce-createProducts:{uid}` is now `commerce-createProductType:{uid}`; `commerce-deleteProducts:{uid}` is now `commerce-deleteProductType:{uid}`.'); - - if ($checkPermissionName !== null) { - return $user->can($checkPermissionName . ':' . $productType->uid); - } - - return $user->can('commerce-viewProductType:' . $productType->uid); - } -} diff --git a/src/services/Products.php b/src/services/Products.php deleted file mode 100644 index 02d500161f..0000000000 --- a/src/services/Products.php +++ /dev/null @@ -1,73 +0,0 @@ - - * @since 2.0 - */ -class Products extends Component -{ - /** - * Returns a product by its ID. - * - * @param int $id - * @param array|int|string|null $siteId - * @return Product|null - */ - public function getProductById(int $id, array|int|string $siteId = null, array $criteria = []): ?Product - { - if (!$id) { - return null; - } - - // Get the structure ID - if (!isset($criteria['structureId'])) { - $criteria['structureId'] = (new Query()) - ->select(['productTypes.structureId']) - ->from(['products' => \craft\commerce\db\Table::PRODUCTS]) - ->innerJoin(['productTypes' => \craft\commerce\db\Table::PRODUCTTYPES], '[[productTypes.id]] = [[products.typeId]]') - ->where(['products.id' => $id]) - ->scalar(); - } - - return Craft::$app->getElements()->getElementById($id, Product::class, $siteId, $criteria); - } - - /** - * Handle a Site being saved. - */ - public function afterSaveSiteHandler(SiteEvent $event): void - { - if ( - $event->isNew && - isset($event->oldPrimarySiteId) && - Craft::$app->getPlugins()->isPluginInstalled(Plugin::getInstance()->id) - ) { - Queue::push(new PropagateElements([ - 'elementType' => Product::class, - 'criteria' => [ - 'siteId' => $event->oldPrimarySiteId, - 'status' => null, - ], - 'siteId' => $event->site->id, - ])); - } - } -} diff --git a/src/services/Purchasables.php b/src/services/Purchasables.php deleted file mode 100644 index 64ec30af61..0000000000 --- a/src/services/Purchasables.php +++ /dev/null @@ -1,322 +0,0 @@ - - * @since 2.0 - * - * @property array|string[] $allPurchasableElementTypes - */ -class Purchasables extends Component -{ - /** - * @event PurchasableOutOfStockPurchasesAllowedEvent The event that is triggered when checking if the purchasable can be purchased when out of stock. - * - * This example allows users of a certain group to purchase out of stock items. - * - * ```php - * use craft\commerce\events\PurchasableAvailableEvent; - * use craft\commerce\services\Purchasables; - * use yii\base\Event; - * - * Event::on( - * Purchasables::class, - * Purchasables::EVENT_PURCHASABLE_ALLOW_OUT_OF_STOCK_PURCHASES, - * function(PurchasableOutOfStockPurchasesAllowedEvent $event) { - * if($order && $user = $order->getUser()){ - * if($user->isInGroup(1)){ - * $event->outOfStockPurchasesAllowed = true; - * } - * } - * } - * ); - * ``` - */ - public const EVENT_PURCHASABLE_OUT_OF_STOCK_PURCHASES_ALLOWED = 'allowOutOfStockPurchases'; - - /** - * @event PurchasableAvailableEvent The event that is triggered when the availability of a purchasables is checked. - * - * This example stop users of a certain group from having the purchasable be available to them in their order. - * - * ```php - * use craft\commerce\events\PurchasableAvailableEvent; - * use craft\commerce\services\Purchasables; - * use yii\base\Event; - * - * Event::on( - * Purchasables::class, - * Purchasables::EVENT_PURCHASABLE_AVAILABLE, - * function(PurchasableAvailableEvent $event) { - * if($order && $user = $order->getUser()){ - * $event->isAvailable = $event->isAvailable && !$user->isInGroup(1); // Group ID 1 not allowed to have purchasable in the cart. - * } - * } - * ); - * ``` - */ - public const EVENT_PURCHASABLE_AVAILABLE = 'purchasableAvailable'; - - /** - * @event PurchasableShippableEvent The event that is triggered when determining whether a purchasable may be shipped. - * - * This example prevents the purchasable from being shippable in a specific user group's orders: - * - * ```php - * use craft\commerce\events\PurchasableShippableEvent; - * use craft\commerce\services\Purchasables; - * use yii\base\Event; - * - * Event::on( - * Purchasables::class, - * Purchasables::EVENT_PURCHASABLE_SHIPPABLE, - * function(PurchasableShippableEvent $event) { - * if($order && $user = $order->getUser()){ - * $event->isShippable = $event->is && !$user->isInGroup(1); - * } - * } - * ); - * ``` - */ - public const EVENT_PURCHASABLE_SHIPPABLE = 'purchasableShippable'; - - /** - * @event RegisterComponentTypesEvent The event that is triggered for registration of additional purchasables. - * - * This example adds an instance of `MyPurchasable` to the event object’s `types` array: - * - * ```php - * use craft\events\RegisterComponentTypesEvent; - * use craft\commerce\services\Purchasables; - * use yii\base\Event; - * - * Event::on( - * Purchasables::class, - * Purchasables::EVENT_REGISTER_PURCHASABLE_ELEMENT_TYPES, - * function(RegisterComponentTypesEvent $event) { - * $event->types[] = MyPurchasable::class; - * } - * ); - * ``` - */ - public const EVENT_REGISTER_PURCHASABLE_ELEMENT_TYPES = 'registerPurchasableElementTypes'; - - /** - * Memoization of purchasables by ID to avoid duplicate queries. - * - * @var Collection|null - */ - private ?Collection $_purchasableById = null; - - - /** - * @param Purchasable $purchasable - * @param Order|null $order - * @param User|null $currentUser - * @return bool - * @throws Throwable - * @since 5.3.0 - */ - public function isPurchasableOutOfStockPurchasingAllowed(Purchasable $purchasable, Order $order = null, User $currentUser = null): bool - { - if ($currentUser === null) { - $currentUser = Craft::$app->getUser()->getIdentity(); - } - - $outOfStockPurchasesAllowed = $purchasable->allowOutOfStockPurchases; - - $event = new PurchasableOutOfStockPurchasesAllowedEvent(compact('order', 'purchasable', 'currentUser', 'outOfStockPurchasesAllowed')); - - if ($this->hasEventHandlers(self::EVENT_PURCHASABLE_OUT_OF_STOCK_PURCHASES_ALLOWED)) { - $this->trigger(self::EVENT_PURCHASABLE_OUT_OF_STOCK_PURCHASES_ALLOWED, $event); - } - - return $event->outOfStockPurchasesAllowed; - } - - /** - * @param Order|null $order - * @param User|null $currentUser - * @since 3.3.1 - */ - public function isPurchasableAvailable(PurchasableInterface $purchasable, Order $order = null, User $currentUser = null): bool - { - if ($currentUser === null) { - $currentUser = Craft::$app->getUser()->getIdentity(); - } - $isAvailable = $purchasable->getIsAvailable(); - - $event = new PurchasableAvailableEvent(compact('order', 'purchasable', 'currentUser', 'isAvailable')); - - if ($this->hasEventHandlers(self::EVENT_PURCHASABLE_AVAILABLE)) { - $this->trigger(self::EVENT_PURCHASABLE_AVAILABLE, $event); - } - - return $event->isAvailable; - } - - /** - * @param Order|null $order - * @param User|null $currentUser - * @since 3.3.2 - */ - public function isPurchasableShippable(PurchasableInterface $purchasable, Order $order = null, User $currentUser = null): bool - { - if ($currentUser === null) { - $currentUser = Craft::$app->getUser()->getIdentity(); - } - $isShippable = $purchasable->getIsShippable(); - - $event = new PurchasableShippableEvent(compact('order', 'purchasable', 'currentUser', 'isShippable')); - - if ($this->hasEventHandlers(self::EVENT_PURCHASABLE_SHIPPABLE)) { - $this->trigger(self::EVENT_PURCHASABLE_SHIPPABLE, $event); - } - - return $event->isShippable; - } - - /** - * Updated the cached stock value for the purchasable in a store. - * - * @param Purchasable $purchasable - * @param bool $allSites Update across all sites (stores). - * @return void - * @throws \yii\base\InvalidConfigException - * @throws \yii\db\Exception - */ - public function updateStoreStockCache(Purchasable $purchasable, bool $allSites = false): void - { - if ($allSites) { - $purchasables = $purchasable::find() - ->siteId('*') - ->id($purchasable->id) - ->status(null)->all(); - } else { - $purchasables = [$purchasable]; - } - - /** @var Purchasable $purchasable */ - foreach ($purchasables as $purchasable) { - $stock = Plugin::getInstance()->getInventory()->getInventoryLevelsForPurchasable($purchasable)->sum('availableTotal'); - - Craft::$app->getDb()->createCommand() - ->update( - table: Table::PURCHASABLES_STORES, - columns: ['stock' => $stock], - condition: ['purchasableId' => $purchasable->id, 'storeId' => $purchasable->getStore()->id]) - ->execute(); - - // Since we are updating the stock directly in the database, clear the cache - Craft::$app->getElements()->invalidateCachesForElement($purchasable); - } - } - - /** - * Delete a purchasable by its ID. - * - * @throws Throwable - * @noinspection PhpUnused - */ - public function deletePurchasableById(int $purchasableId): bool - { - $this->_purchasableById?->pull($purchasableId); - - return Craft::$app->getElements()->deleteElementById($purchasableId); - } - - /** - * Get a purchasable by its ID. - * - * @param int $purchasableId - * @param int|null $siteId - * @param int|false|null $forCustomer - * @return PurchasableInterface|null - * @throws SiteNotFoundException - */ - public function getPurchasableById(int $purchasableId, ?int $siteId = null, int|false|null $forCustomer = null): ?PurchasableInterface - { - // @TODO Verify that returning the memoized purchasable regardless of the requested $siteId / $forCustomer is safe, or scope the cache key by those args - if ($this->_purchasableById !== null && $this->_purchasableById->has($purchasableId)) { - return $this->_purchasableById->get($purchasableId); - } - - $siteId ??= Craft::$app->getSites()->getCurrentSite()->id; - $elementType = Craft::$app->getElements()->getElementTypeById($purchasableId); - - if ($elementType === null || !class_exists($elementType)) { - return null; - } - - $query = Craft::$app->getElements()->createElementQuery($elementType) - ->id($purchasableId) - ->siteId($siteId) - ->status(null) - ->drafts(null) - ->provisionalDrafts(null) - ->revisions(null); - - if ($query instanceof PurchasableQuery) { - $query->forCustomer($forCustomer); - } - - $purchasable = $query->one(); - if ($purchasable && !$purchasable instanceof PurchasableInterface) { - throw new InvalidArgumentException(sprintf('Element %s does not implement %s', $purchasableId, PurchasableInterface::class)); - } - - if ($this->_purchasableById === null) { - $this->_purchasableById = collect(); - } - - $this->_purchasableById->put($purchasableId, $purchasable); - - return $purchasable; - } - - /** - * Returns all available purchasable element classes. - * - * @return string[] The available purchasable element classes. - */ - public function getAllPurchasableElementTypes(): array - { - $purchasableElementTypes = [ - Variant::class, - ]; - - $event = new RegisterComponentTypesEvent([ - 'types' => $purchasableElementTypes, - ]); - $this->trigger(self::EVENT_REGISTER_PURCHASABLE_ELEMENT_TYPES, $event); - - return $event->types; - } -} diff --git a/src/services/Sales.php b/src/services/Sales.php deleted file mode 100644 index 1d16a0da2e..0000000000 --- a/src/services/Sales.php +++ /dev/null @@ -1,727 +0,0 @@ - - * @since 2.0 - */ -class Sales extends Component -{ - /** - * @event SaleMatchEvent The event that is triggered before Commerce attempts to match a sale to a purchasable. - * - * The `isValid` event property can be set to `false` to prevent the application of the matched sale. - * - * ```php - * use craft\commerce\events\SaleMatchEvent; - * use craft\commerce\services\Sales; - * use craft\commerce\base\PurchasableInterface; - * use craft\commerce\models\Sale; - * use yii\base\Event; - * - * Event::on( - * Sales::class, - * Sales::EVENT_BEFORE_MATCH_PURCHASABLE_SALE, - * function(SaleMatchEvent $event) { - * // @var Sale $sale - * $sale = $event->sale; - * // @var PurchasableInterface $purchasable - * $purchasable = $event->purchasable; - * // @var bool $isNew - * $isNew = $event->isNew; - * - * // Use custom business logic to exclude purchasable from sale - * // with `$event->isValid = false` - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_MATCH_PURCHASABLE_SALE = 'beforeMatchPurchasableSale'; - - /** - * @event SaleEvent The event that is triggered before a sale is saved. - * @since 2.2 - * - * ```php - * use craft\commerce\events\SaleEvent; - * use craft\commerce\services\Sales; - * use craft\commerce\models\Sale; - * use yii\base\Event; - * - * Event::on( - * Sales::class, - * Sales::EVENT_BEFORE_SAVE_SALE, - * function(SaleEvent $event) { - * // @var Sale $sale - * $sale = $event->sale; - * // @var bool $isNew - * $isNew = $event->isNew; - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_SAVE_SALE = 'beforeSaveSale'; - - /** - * @event SaleEvent The event that is triggered after a sale is saved. - * @since 2.2 - * - * ```php - * use craft\commerce\events\SaleEvent; - * use craft\commerce\services\Sales; - * use craft\commerce\models\Sale; - * use yii\base\Event; - * - * Event::on( - * Sales::class, - * Sales::EVENT_BEFORE_SAVE_SALE, - * function(SaleEvent $event) { - * // @var Sale $sale - * $sale = $event->sale; - * // @var bool $isNew - * $isNew = $event->isNew; - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_SAVE_SALE = 'afterSaveSale'; - - /** - * @event SaleEvent The event that is triggered after a sale is deleted. - * - * ```php - * use craft\commerce\events\SaleEvent; - * use craft\commerce\services\Sales; - * use craft\commerce\models\Sale; - * use yii\base\Event; - * - * Event::on( - * Sales::class, - * Sales::EVENT_AFTER_DELETE_SALE, - * function(SaleEvent $event) { - * // @var Sale $sale - * $sale = $event->sale; - * - * // do something - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_DELETE_SALE = 'afterDeleteSale'; - - /** - * @var Sale[]|null - */ - private ?array $_allSales = null; - - /** - * @var Sale[]|null - */ - private ?array $_allActiveSales = null; - - /** - * @var array - */ - private array $_purchasableSaleMatch = []; - - /** - * @return bool - * @throws InvalidConfigException - * @throws SiteNotFoundException - * @since 5.0.0 - */ - public function canUseSales(): bool - { - $singleStore = Plugin::getInstance()->getStores()->getAllStores()->count() === 1; - $noCatalogPricingRules = Plugin::getInstance()->getCatalogPricingRules()->getAllCatalogPricingRules()->isEmpty(); - return $singleStore && $noCatalogPricingRules; - } - - /** - * Get a sale by its ID. - */ - public function getSaleById(int $id): ?Sale - { - foreach ($this->getAllSales() as $sale) { - if ($sale->id == $id) { - return $sale; - } - } - - return null; - } - - /** - * Get all sales. - * - * @return Sale[] - */ - public function getAllSales(): array - { - if (!isset($this->_allSales)) { - $sales = (new Query())->select([ - 'sales.id', - 'sales.name', - 'sales.description', - 'sales.dateFrom', - 'sales.dateTo', - 'sales.apply', - 'sales.applyAmount', - 'sales.stopProcessing', - 'sales.ignorePrevious', - 'sales.allGroups', - 'sales.allPurchasables', - 'sales.allCategories', - 'sales.sortOrder', - 'sales.categoryRelationshipType', - 'sales.enabled', - 'sales.dateCreated', - 'sales.dateUpdated', - 'sp.purchasableId', - 'spt.categoryId', - 'sug.userGroupId', - ]) - ->from(Table::SALES . ' sales') - ->leftJoin(Table::SALE_PURCHASABLES . ' sp', '[[sp.saleId]] = [[sales.id]]') - ->leftJoin(Table::SALE_CATEGORIES . ' spt', '[[spt.saleId]] = [[sales.id]]') - ->leftJoin(Table::SALE_USERGROUPS . ' sug', '[[sug.saleId]] = [[sales.id]]') - ->orderBy(['sales.sortOrder' => 'ASC']) - ->all(); - - $allSalesById = []; - $purchasables = []; - $categories = []; - $groups = []; - - foreach ($sales as $sale) { - $id = $sale['id']; - if ($sale['purchasableId']) { - $purchasables[$id][] = $sale['purchasableId']; - } - - if ($sale['categoryId']) { - $categories[$id][] = $sale['categoryId']; - } - - if ($sale['userGroupId']) { - $groups[$id][] = $sale['userGroupId']; - } - - unset($sale['purchasableId'], $sale['userGroupId'], $sale['categoryId']); - - if (!isset($allSalesById[$id])) { - $allSalesById[$id] = new Sale($sale); - } - } - - foreach ($allSalesById as $id => $sale) { - $sale->setPurchasableIds($purchasables[$id] ?? []); - $sale->setCategoryIds($categories[$id] ?? []); - $sale->setUserGroupIds($groups[$id] ?? []); - } - - $this->_allSales = $allSalesById; - } - - return $this->_allSales; - } - - /** - * Returns the sales that match the purchasable. - * - * @param Order|null $order - * @return Sale[] - * @throws InvalidConfigException - */ - public function getSalesForPurchasable(PurchasableInterface $purchasable, Order $order = null): array - { - $matchedSales = []; - - foreach ($this->_getAllEnabledSales() as $sale) { - if ($this->matchPurchasableAndSale($purchasable, $sale, $order)) { - $matchedSales[] = $sale; - - if ($sale->stopProcessing) { - break; - } - } - } - - return $matchedSales; - } - - /** - * @param PurchasableInterface $purchasable - * @return array - */ - public function getSalesRelatedToPurchasable(PurchasableInterface $purchasable): array - { - /** @var Purchasable $purchasable */ - $sales = []; - $id = $purchasable->getId(); - - if ($id) { - foreach ($this->getAllSales() as $sale) { - // Get related by product specifically - $purchasableIds = $sale->getPurchasableIds(); - - // Get related via category - $relatedTo = [$sale->categoryRelationshipType => $purchasable->getPromotionRelationSource()]; - $saleCategories = $sale->getCategoryIds(); - - $relatedCategories = Category::find() - ->id($saleCategories) - ->relatedTo($relatedTo) - ->siteId($purchasable->siteId) - ->ids(); - $relatedEntries = Entry::find() - ->id($saleCategories) - ->relatedTo($relatedTo) - ->siteId($purchasable->siteId) - ->ids(); - $relatedCategoriesOrEntries = array_merge($relatedCategories, $relatedEntries); - - if (in_array($id, $purchasableIds, false) || !empty($relatedCategoriesOrEntries)) { - $sales[] = $sale; - } - } - } - - return $sales; - } - - /** - * Returns the salePrice of the purchasable based on all the sales. - * - * @param Order|null $order - */ - public function getSalePriceForPurchasable(PurchasableInterface $purchasable, Order $order = null): float - { - $sales = $this->getSalesForPurchasable($purchasable, $order); - $originalPrice = $purchasable->getPrice(); - - $takeOffAmount = 0; - $newPrice = null; - - /** @var Sale $sale */ - foreach ($sales as $sale) { - switch ($sale->apply) { - case SaleRecord::APPLY_BY_PERCENT: - // applyAmount is stored as a negative already - $takeOffAmount += ($sale->applyAmount * $originalPrice); - if ($sale->ignorePrevious) { - $newPrice = $originalPrice + ($sale->applyAmount * $originalPrice); - } - break; - case SaleRecord::APPLY_TO_PERCENT: - // applyAmount needs to be reversed since it is stored as negative - $newPrice = (-$sale->applyAmount * $originalPrice); - break; - case SaleRecord::APPLY_BY_FLAT: - // applyAmount is stored as a negative already - $takeOffAmount += $sale->applyAmount; - if ($sale->ignorePrevious) { - // applyAmount is always negative so add the negative amount to the original price for the new price. - $newPrice = $originalPrice + $sale->applyAmount; - } - break; - case SaleRecord::APPLY_TO_FLAT: - // applyAmount needs to be reversed since it is stored as negative - $newPrice = -$sale->applyAmount; - break; - } - - // If the stop processing flag is true, it must been the last - // since the sales for this purchasable would have returned it last. - if ($sale->stopProcessing) { - break; - } - } - - $salePrice = ($originalPrice + $takeOffAmount); - - // A newPrice has been set so use it. - if ($newPrice !== null) { - $salePrice = $newPrice; - } - - if ($salePrice < 0) { - $salePrice = 0; - } - - return CurrencyHelper::round($salePrice); - } - - /** - * Match a product and a sale and return the result. - * - * @param Order|null $order - * @throws InvalidConfigException - */ - public function matchPurchasableAndSale(PurchasableInterface $purchasable, Sale $sale, Order $order = null): bool - { - /** @var Purchasable $purchasable */ - $purchasableId = $purchasable->id; - $saleId = $sale->id; - - if (!isset($this->_purchasableSaleMatch[$purchasableId])) { - $this->_purchasableSaleMatch[$purchasableId] = []; - } - - if (!isset($this->_purchasableSaleMatch[$purchasableId][$saleId])) { - $this->_purchasableSaleMatch[$purchasableId][$saleId] = null; - } - - // Only use memoized data if we are matching outside of the context of an order - if (!$order && $this->_purchasableSaleMatch[$purchasableId][$saleId] !== null) { - return $this->_purchasableSaleMatch[$purchasableId][$saleId]; - } - - // default response is no match - $this->_purchasableSaleMatch[$purchasableId][$saleId] = false; - - // can't match something not promotable - if (!$purchasable->getIsPromotable()) { - return false; - } - - // Purchasable ID match - if (!$sale->allPurchasables && !in_array($purchasable->getId(), $sale->getPurchasableIds(), false)) { - return false; - } - - $date = new DateTime(); - - if ($order) { - // Date we care about in the context of an order is the date the order was placed. - // If the order is still a cart, use the current date time. - $date = $order->isCompleted ? $order->dateOrdered : $date; - } - - if ($sale->dateFrom && $sale->dateFrom >= $date) { - return false; - } - - if ($sale->dateTo && $sale->dateTo <= $date) { - return false; - } - - if ($order) { - $user = $order->getCustomer(); - - if (!$sale->allGroups) { - // User group condition means we have to have a real user - if (null === $user) { - return false; - } - // User groups of the order's user - $userGroups = ArrayHelper::getColumn($user->getGroups(), 'id'); - if (!$userGroups || !array_intersect($userGroups, $sale->getUserGroupIds())) { - return false; - } - } - } - - // Are we dealing with the current session outside of any cart/order context - if (!$order && !$sale->allGroups) { - // User groups of the currently logged in user - $userGroups = null; - if ($currentUser = Craft::$app->getUser()->getIdentity()) { - $userGroups = ArrayHelper::getColumn($currentUser->getGroups(), 'id'); - } - - if (!$userGroups || !array_intersect($userGroups, $sale->getUserGroupIds())) { - return false; - } - } - - // Category match - if (!$sale->allCategories) { - $relatedTo = [$sale->categoryRelationshipType => $purchasable->getPromotionRelationSource()]; - $saleCategories = $sale->getCategoryIds(); - $relatedCategories = Category::find() - ->id($saleCategories) - ->relatedTo($relatedTo) - ->siteId($purchasable->siteId) - ->ids(); - $relatedEntries = Entry::find() - ->id($saleCategories) - ->relatedTo($relatedTo) - ->siteId($purchasable->siteId) - ->ids(); - $relatedCategoriesOrEntries = array_merge($relatedCategories, $relatedEntries); - if (empty($relatedCategoriesOrEntries)) { - return false; - } - } - - $saleMatchEvent = new SaleMatchEvent(compact('sale', 'purchasable')); - - // Raising the 'beforeMatchPurchasableSale' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_MATCH_PURCHASABLE_SALE)) { - $this->trigger(self::EVENT_BEFORE_MATCH_PURCHASABLE_SALE, $saleMatchEvent); - } - - // If an order has been supplied we do not want to memoize the match - if ($order) { - unset($this->_purchasableSaleMatch[$purchasableId][$saleId]); - return $saleMatchEvent->isValid; - } - - $this->_purchasableSaleMatch[$purchasableId][$saleId] = $saleMatchEvent->isValid; - return $this->_purchasableSaleMatch[$purchasableId][$saleId]; - } - - /** - * Save a Sale. - * - * @param bool $runValidation should we validate this before saving. - * @throws Exception - * @throws \Exception - */ - public function saveSale(Sale $model, bool $runValidation = true): bool - { - $isNewSale = !$model->id; - - if ($isNewSale) { - $record = new SaleRecord(); - } else { - $record = SaleRecord::findOne($model->id); - - if (!$record) { - throw new Exception(Craft::t('commerce', 'No sale exists with the ID “{id}”', - ['id' => $model->id])); - } - } - - if ($runValidation && !$model->validate()) { - Craft::info('Sale not saved due to validation error.', __METHOD__); - - return false; - } - - $fields = [ - 'name', - 'description', - 'dateFrom', - 'dateTo', - 'apply', - 'applyAmount', - 'stopProcessing', - 'ignorePrevious', - 'categoryRelationshipType', - 'enabled', - ]; - foreach ($fields as $field) { - $record->$field = $model->$field; - } - - if ($record->allGroups = $model->allGroups) { - $model->setUserGroupIds([]); - } - if ($record->allCategories = $model->allCategories) { - $model->setCategoryIds([]); - } - if ($record->allPurchasables = $model->allPurchasables) { - $model->setPurchasableIds([]); - } - - // Make sure `dateCreated` and `dateUpdated` are set on the model - if (!$isNewSale) { - $model->dateCreated = DateTimeHelper::toDateTime($record->dateCreated); - $model->dateUpdated = DateTimeHelper::toDateTime($record->dateUpdated); - } - - // Fire an 'beforeSaveSection' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_SAVE_SALE)) { - $this->trigger(self::EVENT_BEFORE_SAVE_SALE, new SaleEvent([ - 'sale' => $model, - 'isNew' => $isNewSale, - ])); - } - - $db = Craft::$app->getDb(); - $transaction = $db->beginTransaction(); - - try { - $record->save(false); - $model->id = $record->id; - - // Update datetime attributes - $model->dateCreated = DateTimeHelper::toDateTime($record->dateCreated); - $model->dateUpdated = DateTimeHelper::toDateTime($record->dateUpdated); - - SaleUserGroupRecord::deleteAll(['saleId' => $model->id]); - SalePurchasableRecord::deleteAll(['saleId' => $model->id]); - SaleCategoryRecord::deleteAll(['saleId' => $model->id]); - - foreach ($model->getUserGroupIds() as $groupId) { - $relation = new SaleUserGroupRecord(); - $relation->userGroupId = $groupId; - $relation->saleId = $model->id; - $relation->save(); - } - - foreach ($model->getCategoryIds() as $categoryId) { - $relation = new SaleCategoryRecord(); - $relation->categoryId = $categoryId; - $relation->saleId = $model->id; - $relation->save(); - } - - foreach ($model->getPurchasableIds() as $purchasableId) { - $relation = new SalePurchasableRecord(); - $relation->purchasableId = $purchasableId; - $purchasable = Craft::$app->getElements()->getElementById($purchasableId, null, null, ['trashed' => null]); - $relation->purchasableType = $purchasable::class; - $relation->saleId = $model->id; - $relation->save(); - - Craft::$app->getElements()->invalidateCachesForElement($purchasable); - } - - $transaction->commit(); - - $this->_clearCaches(); - - // Fire an 'beforeSaveSection' event - if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_SALE)) { - $this->trigger(self::EVENT_AFTER_SAVE_SALE, new SaleEvent([ - 'sale' => $model, - 'isNew' => $isNewSale, - ])); - } - - return true; - } catch (\Exception $e) { - $transaction->rollBack(); - throw $e; - } - } - - /** - * Reorder Sales based on a list of ids. - * - * @param int[] $ids - * @return bool - * @throws \yii\db\Exception - */ - public function reorderSales(array $ids): bool - { - foreach ($ids as $sortOrder => $id) { - Craft::$app->getDb()->createCommand() - ->update(Table::SALES, ['sortOrder' => $sortOrder + 1], ['id' => $id]) - ->execute(); - } - - $this->_clearCaches(); - - return true; - } - - /** - * Delete a sale by its id. - * - * @param int $id - * @return bool - * @throws StaleObjectException - */ - public function deleteSaleById(int $id): bool - { - $saleRecord = SaleRecord::findOne($id); - - if (!$saleRecord) { - return false; - } - - $sale = $this->getSaleById($saleRecord->id); - - $this->_clearCaches(); - $result = (bool)$saleRecord->delete(); - - //Raise the afterDeleteSale event - if ($result && $this->hasEventHandlers(self::EVENT_AFTER_DELETE_SALE)) { - $this->trigger(self::EVENT_AFTER_DELETE_SALE, new SaleEvent([ - 'sale' => $sale, - 'isNew' => false, - ])); - } - - - return $result; - } - - /** - * Get all enabled sales. - * - * @return array - */ - private function _getAllEnabledSales(): array - { - if (!isset($this->_allActiveSales)) { - $sales = $this->getAllSales(); - $activeSales = []; - foreach ($sales as $sale) { - if ($sale->enabled) { - $activeSales[] = $sale; - } - } - - $this->_allActiveSales = $activeSales; - } - - return $this->_allActiveSales; - } - - /** - * Clear memoization caches - * - * @since 3.1.4 - */ - private function _clearCaches(): void - { - $this->_allActiveSales = null; - $this->_allSales = null; - $this->_purchasableSaleMatch = []; - } -} diff --git a/src/services/ShippingCategories.php b/src/services/ShippingCategories.php deleted file mode 100644 index cdcd84ea8d..0000000000 --- a/src/services/ShippingCategories.php +++ /dev/null @@ -1,391 +0,0 @@ - - * @since 2.0 - */ -class ShippingCategories extends Component -{ - /** - * @var Collection[]|null - */ - private ?array $_allShippingCategories = null; - - /** - * Returns all Shipping Categories - * - * @param int|null $storeId - * @param bool $withTrashed - * @return Collection - * @throws InvalidConfigException - * @throws StoreNotFoundException - */ - public function getAllShippingCategories(?int $storeId = null, bool $withTrashed = false): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if ($this->_allShippingCategories === null || !isset($this->_allShippingCategories[$storeId])) { - $results = $this->_createShippingCategoryQuery(true) - ->where(['storeId' => $storeId]) - ->all(); - - if ($this->_allShippingCategories === null) { - $this->_allShippingCategories = []; - } - - foreach ($results as $result) { - $shippingCategory = Craft::createObject([ - 'class' => ShippingCategory::class, - 'attributes' => $result, - ]); - - if (!isset($this->_allShippingCategories[$shippingCategory->storeId])) { - $this->_allShippingCategories[$shippingCategory->storeId] = collect(); - } - - $this->_allShippingCategories[$shippingCategory->storeId]->push($shippingCategory); - } - } - - if (!isset($this->_allShippingCategories[$storeId])) { - return collect(); - } - - return $this->_allShippingCategories[$storeId]->filter(fn(ShippingCategory $sc) => (!$withTrashed && $sc->dateDeleted === null) || $withTrashed); - } - - /** - * Returns all Shipping category names, by ID. - * - * @throws InvalidConfigException - */ - public function getAllShippingCategoriesAsList(?int $storeId = null): array - { - $categories = $this->getAllShippingCategories($storeId); - - return $categories->mapWithKeys(fn(ShippingCategory $category) => [$category->id => $category->getUiLabel()])->all(); - } - - /** - * Get a shipping category by its ID. - * - * @param int $shippingCategoryId - * @param int|null $storeId - * @return ShippingCategory|null - * @throws InvalidConfigException - */ - public function getShippingCategoryById(int $shippingCategoryId, ?int $storeId = null): ?ShippingCategory - { - $shippingCategories = $this->getAllShippingCategories($storeId); - $first = $shippingCategories->firstWhere('id', $shippingCategoryId); - return $first; - } - - /** - * Get a shipping category by its handle. - * - * @noinspection PhpUnused - * @throws InvalidConfigException - */ - public function getShippingCategoryByHandle(string $shippingCategoryHandle, ?int $storeId = null): ?ShippingCategory - { - return $this->getAllShippingCategories($storeId)->firstWhere('handle', $shippingCategoryHandle); - } - - /** - * Returns the default shipping category. - * - * @throws InvalidConfigException - */ - public function getDefaultShippingCategory(int $storeId): ShippingCategory - { - $categories = $this->getAllShippingCategories($storeId); - - $default = $categories->firstWhere('default', true); - - if (!$default) { - $default = $categories->first(); - } - - if (!$default) { - throw new InvalidConfigException('Commerce must have at least one (default) shipping category set up.'); - } - - return $default; - } - - /** - * @param bool $runValidation should we validate this before saving. - * @throws Exception - * @throws \Exception - */ - public function saveShippingCategory(ShippingCategory $shippingCategory, bool $runValidation = true): bool - { - if ($shippingCategory->id) { - $record = ShippingCategoryRecord::findOne($shippingCategory->id); - - if (!$record) { - throw new Exception(Craft::t('commerce', 'No shipping category exists with the ID “{id}”', - ['id' => $shippingCategory->id])); - } - } else { - $record = new ShippingCategoryRecord(); - } - - if ($runValidation && !$shippingCategory->validate()) { - Craft::info('Shipping category not saved due to validation error.', __METHOD__); - - return false; - } - - $record->name = $shippingCategory->name; - $record->storeId = $shippingCategory->storeId; - $record->handle = $shippingCategory->handle; - $record->description = $shippingCategory->description; - $record->icon = $shippingCategory->icon; - $record->color = $shippingCategory->color; - $record->default = $shippingCategory->default; - - // Save it! - $record->save(false); - - // Now that we have a record ID, save it on the model - $shippingCategory->id = $record->id; - - // If this was the default make all others not the default. - if ($shippingCategory->default) { - $condition = [ - 'and', - ['storeId' => $record->storeId], - ['not', ['id' => $record->id]], - ]; - ShippingCategoryRecord::updateAll(['default' => false], $condition); - } - - // Product type IDs this shipping category is available to - $currentProductTypeIds = (new Query()) - ->select(['productTypeId']) - ->from([Table::PRODUCTTYPES_SHIPPINGCATEGORIES]) - ->where(['shippingCategoryId' => $shippingCategory->id]) - ->column(); - - // Newly set product types this shipping category is available to - $newProductTypeIds = ArrayHelper::getColumn($shippingCategory->getProductTypes(), 'id'); - - // Find product types that are being removed from this shipping category - $removedProductTypeIds = array_diff($currentProductTypeIds, $newProductTypeIds); - - // Update purchasables to default shipping category when product types are removed - if (!empty($removedProductTypeIds)) { - $defaultShippingCategory = $this->getDefaultShippingCategory($shippingCategory->storeId); - - // Get all variant purchasables that currently have this shipping category but whose product type is being removed - $purchasableIds = (new Query()) - ->select(['ps.purchasableId']) - ->from(['ps' => Table::PURCHASABLES_STORES]) - ->innerJoin(['v' => Table::VARIANTS], '[[ps.purchasableId]] = [[v.id]]') - ->innerJoin(['p' => Table::PRODUCTS], '[[v.primaryOwnerId]] = [[p.id]]') - ->where([ - 'ps.shippingCategoryId' => $shippingCategory->id, - 'ps.storeId' => $shippingCategory->storeId, - 'p.typeId' => $removedProductTypeIds, - ]) - ->column(); - - if (!empty($purchasableIds)) { - // Update these purchasables to use the default shipping category - Craft::$app->getDb()->createCommand() - ->update( - Table::PURCHASABLES_STORES, - ['shippingCategoryId' => $defaultShippingCategory->id], - [ - 'purchasableId' => $purchasableIds, - 'storeId' => $shippingCategory->storeId, - 'shippingCategoryId' => $shippingCategory->id, - ] - ) - ->execute(); - } - } - - foreach ($currentProductTypeIds as $oldProductTypeId) { - // If we are removing a product type for this shipping category the products of that type should be re-saved - if (!in_array($oldProductTypeId, $newProductTypeIds, false)) { - // Re-save all variants that no longer have this shipping category available to them - $this->_resaveVariantsByProductTypeId($oldProductTypeId); - } - } - - foreach ($newProductTypeIds as $newProductTypeId) { - // If we are adding a product type for this shipping category the products of that type should be re-saved - if (!in_array($newProductTypeId, $currentProductTypeIds, false)) { - // Re-save all variants when assigning this shipping category available to them - $this->_resaveVariantsByProductTypeId($newProductTypeId); - } - } - - // Remove existing Categories <-> ProductType relationships - Craft::$app->getDb()->createCommand()->delete(Table::PRODUCTTYPES_SHIPPINGCATEGORIES, ['shippingCategoryId' => $shippingCategory->id])->execute(); - - // Add back the new categories - foreach ($shippingCategory->getProductTypes() as $productType) { - $data = ['productTypeId' => (int)$productType->id, 'shippingCategoryId' => (int)$shippingCategory->id]; - Craft::$app->getDb()->createCommand()->insert(Table::PRODUCTTYPES_SHIPPINGCATEGORIES, $data)->execute(); - } - - // Clear Service cache - $this->_allShippingCategories = null; - - return true; - } - - /** - * Re-save variants by product type id - */ - private function _resaveVariantsByProductTypeId(int $productTypeId): void - { - Craft::$app->getQueue()->push(new ResaveElements([ - 'elementType' => Variant::class, - 'updateSearchIndex' => false, - 'criteria' => [ - 'typeId' => $productTypeId, - 'siteId' => '*', - 'unique' => true, - 'status' => null, - ], - ])); - } - - /** - * @throws Throwable - * @throws StaleObjectException - */ - public function deleteShippingCategoryById(int $id): bool - { - /** @var ShippingCategoryRecord|SoftDeleteBehavior|null $shippingCategory */ - $shippingCategory = ShippingCategoryRecord::findOne($id); - - if ($shippingCategory === null || $shippingCategory->default) { - return false; - } - - if ($shippingCategory->softDelete()) { - $this->_allShippingCategories = null; - return true; - } - - return false; - } - - /** - * @param int $productTypeId - * @return array - * @throws InvalidConfigException - */ - public function getShippingCategoriesByProductTypeId(int $productTypeId): array - { - $rows = $this->_createShippingCategoryQuery() - ->innerJoin(Table::PRODUCTTYPES_SHIPPINGCATEGORIES . ' productTypeShippingCategories', '[[shippingCategories.id]] = [[productTypeShippingCategories.shippingCategoryId]]') - ->andWhere(['productTypeShippingCategories.productTypeId' => $productTypeId]) - ->all(); - - // Always need at least the default category - if (empty($rows)) { - try { - // @TODO Stop relying on the default shipping category as a fallback here; either ensure product types always have at least one category linked, or surface the empty state to the caller - $shippingCategory = $this->getAllShippingCategories()->firstWhere('default', true); - } catch (InvalidConfigException) { - return []; - } - - return [$shippingCategory->id => $shippingCategory]; - } - - $shippingCategories = []; - - foreach ($rows as $row) { - $key = $row['id']; - $shippingCategories[$key] = new ShippingCategory($row); - } - - return $shippingCategories; - } - - /** - * @return void - * @since 5.0.0 - */ - public function clearCaches(): void - { - $this->_allShippingCategories = null; - } - - /** - * Returns a Query object prepped for retrieving shipping categories. - * - * @param bool $withTrashed - * @return Query - */ - private function _createShippingCategoryQuery(bool $withTrashed = false): Query - { - $query = (new Query()) - ->select([ - 'shippingCategories.dateCreated', - 'shippingCategories.dateDeleted', - 'shippingCategories.dateUpdated', - 'shippingCategories.default', - 'shippingCategories.description', - 'shippingCategories.handle', - 'shippingCategories.id', - 'shippingCategories.name', - 'shippingCategories.storeId', - ]) - ->from([Table::SHIPPINGCATEGORIES . ' shippingCategories']); - - // Only add icon and color if the columns exist (for pre-migration compatibility) - $db = Craft::$app->getDb(); - $schema = $db->getSchema(); - $tableSchema = $schema->getTableSchema(Table::SHIPPINGCATEGORIES); - - if ($tableSchema && $tableSchema->getColumn('icon') !== null) { - $query->addSelect(['shippingCategories.icon', 'shippingCategories.color']); - } - - if (!$withTrashed) { - $query->where(['dateDeleted' => null]); - } - - return $query; - } -} diff --git a/src/services/ShippingMethods.php b/src/services/ShippingMethods.php deleted file mode 100644 index 6adaf9582a..0000000000 --- a/src/services/ShippingMethods.php +++ /dev/null @@ -1,322 +0,0 @@ - - * @since 2.0 - */ -class ShippingMethods extends Component -{ - /** - * @event RegisterShippingMethods The event that is triggered for registration of additional shipping methods. - * - * This example adds an instance of `MyShippingMethod` to the event object’s `shippingMethods` array: - * - * ```php - * use craft\events\RegisterComponentTypesEvent; - * use craft\commerce\services\ShippingMethods; - * use yii\base\Event; - * - * Event::on( - * ShippingMethods::class, - * ShippingMethods::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS, - * function(RegisterComponentTypesEvent $event) { - * $event->shippingMethods[] = MyShippingMethod::class; - * } - * ); - * ``` - */ - public const EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS = 'registerAvailableShippingMethods'; - - /** - * @var null|Collection[] - */ - private ?array $_allShippingMethods = null; - - /** - * @var array - */ - private array $_serializedOrdersByNumber = []; - - /** - * Returns the Commerce managed shipping methods stored in the database. - * - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - */ - public function getAllShippingMethods(?int $storeId = null): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if ($this->_allShippingMethods === null || !isset($this->_allShippingMethods[$storeId])) { - $results = $this->_createShippingMethodQuery() - ->where(['storeId' => $storeId]) - ->all(); - - if ($this->_allShippingMethods === null) { - $this->_allShippingMethods = []; - } - - foreach ($results as $result) { - $shippingMethod = Craft::createObject([ - 'class' => ShippingMethod::class, - 'attributes' => $result, - ]); - - if (!isset($this->_allShippingMethods[$shippingMethod->storeId])) { - $this->_allShippingMethods[$shippingMethod->storeId] = collect(); - } - - $this->_allShippingMethods[$shippingMethod->storeId]->push($shippingMethod); - } - } - - return $this->_allShippingMethods[$storeId] ?? collect(); - } - - /** - * Get a shipping method by its handle. - */ - public function getShippingMethodByHandle(string $shippingMethodHandle, ?int $storeId = null): ?ShippingMethod - { - return $this->getAllShippingMethods($storeId)->firstWhere('handle', $shippingMethodHandle); - } - - /** - * Get a shipping method by its ID. - */ - public function getShippingMethodById(int $shippingMethodId, ?int $storeId = null): ?ShippingMethod - { - return $this->getAllShippingMethods($storeId)->firstWhere('id', $shippingMethodId); - } - - /** - * Get all available shipping methods to the order. - * - * @return ShippingMethod[] - */ - public function getMatchingShippingMethods(Order $order): array - { - $matchingMethods = []; - - $methods = $this->getAllShippingMethods($order->storeId); - - $event = new RegisterAvailableShippingMethodsEvent([ - 'shippingMethods' => $methods, - 'order' => $order, - ]); - - if ($this->hasEventHandlers(self::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS)) { - $this->trigger(self::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS, $event); - } - - /** @var ShippingMethod $method */ - foreach ($event->getShippingMethods() as $method) { - if ($method->getIsEnabled() && $method->matchOrder($order)) { - // Now we know the method matches, let's get the price - $totalPrice = $method->getPriceForOrder($order); - - $matchingMethods[$method->getHandle()] = [ - 'method' => $method, - 'price' => $totalPrice, // Store the price so we can sort on it before returning - ]; - } - } - - // Sort by price. Using the cached price and don't call `$method->getPriceForOrder($order);` again. - uasort($matchingMethods, static fn($a, $b) => $a['price'] <=> $b['price']); - - $shippingMethods = []; - foreach ($matchingMethods as $shippingMethod) { - $method = $shippingMethod['method']; - $shippingMethods[$method->getHandle()] = $method; // Keep the key being the handle of the method for front-end use. - - // Clear the matching cache in case things change in the future - if ($method instanceof \craft\commerce\base\ShippingMethod) { - $method->clearMatchingShippingRuleCache(); - } - } - - // Clear the memoized data so next time we watch to match rules, we get fresh data. - $this->_serializedOrdersByNumber = []; - - return $shippingMethods; - } - - /** - * Creates an order as an array for matching rules. - * We do this centrally here so that we can clear the memoized data centrally. - * - * @param Order $order - * @return array - * @since 4.7.0 - */ - public function getSerializedOrderForMatchingRules(Order $order): array - { - if (isset($this->_serializedOrdersByNumber[$order->number])) { - return $this->_serializedOrdersByNumber[$order->number]; - } - - $fieldsAsArray = $order->getSerializedFieldValues(); - $orderAsArray = $order->toArray([], ['lineItems.snapshot', 'shippingAddress', 'billingAddress']); - $this->_serializedOrdersByNumber[$order->number] = array_merge($orderAsArray, $fieldsAsArray); - return $this->_serializedOrdersByNumber[$order->number]; - } - - /** - * Get a matching shipping rule for Order and shipping method. - * - * @noinspection PhpUnused - */ - public function getMatchingShippingRule(Order $order, ShippingMethodInterface $method): ?ShippingRuleInterface - { - return $method->getMatchingShippingRule($order); - } - - /** - * Save a shipping method. - * - * @param bool $runValidation should we validate this method before saving. - * @throws Exception - */ - public function saveShippingMethod(ShippingMethod $model, bool $runValidation = true): bool - { - if ($model->id) { - $record = ShippingMethodRecord::findOne($model->id); - - if (!$record) { - throw new Exception(Craft::t('commerce', 'No shipping method exists with the ID “{id}”', - ['id' => $model->id])); - } - } else { - $record = new ShippingMethodRecord(); - } - - if ($runValidation && !$model->validate()) { - Craft::info('Shipping method not saved due to validation error.', __METHOD__); - - return false; - } - - $record->storeId = $model->storeId; - $record->name = $model->name; - $record->handle = $model->handle; - $record->icon = $model->icon; - $record->color = $model->color; - $record->orderCondition = $model->getOrderCondition()->getConfig(); - $record->customerCondition = $model->getCustomerCondition()->getConfig(); - $record->enabled = $model->enabled; - - $record->validate(); - $model->addErrors($record->getErrors()); - - // Save it! - $record->save(false); - - // Now that we have a record ID, save it on the model - $model->id = $record->id; - - $this->clearCache(); - - return true; - } - - /** - * Delete a shipping method by its ID. - * - * @param int $shippingMethodId - * @return bool - * @throws Throwable - */ - public function deleteShippingMethodById(int $shippingMethodId): bool - { - // Delete all rules first. - $db = Craft::$app->getDb(); - $transaction = $db->beginTransaction(); - - try { - $rules = Plugin::getInstance()->getShippingRules()->getAllShippingRulesByShippingMethodId($shippingMethodId); - - foreach ($rules as $rule) { - Plugin::getInstance()->getShippingRules()->deleteShippingRuleById($rule->id); - } - - $record = ShippingMethodRecord::findOne($shippingMethodId); - $record->delete(); - - $transaction->commit(); - $this->clearCache(); - return true; - } catch (\Exception) { - $transaction->rollBack(); - - return false; - } - } - - /** - * Returns a Query object prepped for retrieving shipping methods. - */ - private function _createShippingMethodQuery(): Query - { - $query = (new Query()) - ->select([ - 'dateCreated', - 'dateUpdated', - 'enabled', - 'handle', - 'id', - 'name', - 'orderCondition', - 'customerCondition', - 'storeId', - ]) - ->from([Table::SHIPPINGMETHODS]); - - // Only add icon and color if the columns exist (for pre-migration compatibility) - $db = Craft::$app->getDb(); - $schema = $db->getSchema(); - $tableSchema = $schema->getTableSchema(Table::SHIPPINGMETHODS); - - if ($tableSchema && $tableSchema->getColumn('icon') !== null) { - $query->addSelect(['icon', 'color']); - } - - return $query; - } - - /** - * @return void - * @since 5.0.0 - */ - protected function clearCache(): void - { - $this->_allShippingMethods = null; - } -} diff --git a/src/services/ShippingRuleCategories.php b/src/services/ShippingRuleCategories.php deleted file mode 100644 index 818df44149..0000000000 --- a/src/services/ShippingRuleCategories.php +++ /dev/null @@ -1,200 +0,0 @@ - - * @since 2.0 - */ -class ShippingRuleCategories extends Component -{ - /** - * @var array|null - */ - private ?array $_shippingRuleCategories = null; - - /** - * Returns shipping rule category data without instantiating the classes for performances purposes - * - * @return array - */ - public function getAllShippingRuleCategoriesData(): array - { - if ($this->_shippingRuleCategories === null) { - $data = $this->_createShippingRuleCategoriesQuery()->all(); - - if (!empty($data)) { - $ruleCategories = []; - foreach ($data as $row) { - if (!isset($ruleCategories[$row['shippingRuleId']])) { - $ruleCategories[$row['shippingRuleId']] = []; - } - - $ruleCategories[$row['shippingRuleId']][$row['shippingCategoryId']] = $row; - } - - $this->_shippingRuleCategories = $ruleCategories; - } - } - - return $this->_shippingRuleCategories ?? []; - } - - /** - * Returns an array of shipping rules categories per the rule's ID. - * - * @param int $ruleId the rule's ID - * @return ShippingRuleCategory[] An array of matched shipping rule categories. - */ - public function getShippingRuleCategoriesByRuleId(int $ruleId): array - { - $rules = []; - - $shippingRuleCategories = $this->getAllShippingRuleCategoriesData(); - if (!isset($shippingRuleCategories[$ruleId])) { - return []; - } - - foreach ($shippingRuleCategories[$ruleId] as $row) { - if ($row instanceof ShippingRuleCategory) { - $rules[$row->shippingCategoryId] = $row; - continue; - } - - $id = $row['shippingCategoryId']; - $rules[$id] = new ShippingRuleCategory($row); - } - - $this->_shippingRuleCategories[$ruleId] = $rules; - - return $rules; - } - - /** - * Returns an array of shipping rule categories indexed by rule ID. - * - * @param int[] $ruleIds - * @return array - * @since 5.6.0 - */ - public function getShippingRuleCategoriesByRuleIds(array $ruleIds): array - { - if (empty($ruleIds)) { - return []; - } - - $categoriesByRuleId = []; - - $rows = $this->_createShippingRuleCategoriesQuery() - ->where(['shippingRuleId' => $ruleIds]) - ->all(); - - foreach ($rows as $row) { - $ruleId = $row['shippingRuleId']; - $categoryId = $row['shippingCategoryId']; - $categoriesByRuleId[$ruleId][$categoryId] = new ShippingRuleCategory($row); - } - - return $categoriesByRuleId; - } - - /** - * Save a shipping rule category. - * - * @param ShippingRuleCategory $model The shipping rule model. - * @param bool $runValidation should we validate this rule category before saving. - * @return bool Whether the save was successful. - */ - public function createShippingRuleCategory(ShippingRuleCategory $model, bool $runValidation = true): bool - { - if ($runValidation && !$model->validate()) { - Craft::info('Shipping rule category not saved due to validation error.', __METHOD__); - - return false; - } - - $record = new ShippingRuleCategoryRecord(); - - $fields = [ - 'shippingRuleId', - 'shippingCategoryId', - 'condition', - 'perItemRate', - 'weightRate', - 'percentageRate', - ]; - - foreach ($fields as $field) { - $record->$field = $model->$field; - } - - // Save it! - $record->save(false); - - // Now that we have a record ID, save it on the model - $model->id = $record->id; - - $this->_shippingRuleCategories = null; - - return true; - } - - /** - * Delete a shipping rule category by its ID. - * - * @param int $id the shipping rule category ID. - * @return bool Whether the category was deleted successfully. - * @throws Throwable - * @throws StaleObjectException - * @noinspection PhpUnused - */ - public function deleteShippingRuleCategoryById(int $id): bool - { - $record = ShippingRuleCategoryRecord::findOne($id); - - if ($record) { - // Clear cache if required - $this->_shippingRuleCategories = null; - - return (bool)$record->delete(); - } - - return false; - } - - /** - * Returns a Query object prepped for retrieving shipping rule categories. - * - * @return Query The query object. - */ - private function _createShippingRuleCategoriesQuery(): Query - { - return (new Query()) - ->select([ - 'condition', - 'id', - 'percentageRate', - 'perItemRate', - 'shippingCategoryId', - 'shippingRuleId', - 'weightRate', - ]) - ->from([Table::SHIPPINGRULE_CATEGORIES]); - } -} diff --git a/src/services/ShippingRules.php b/src/services/ShippingRules.php deleted file mode 100644 index aca8d827e7..0000000000 --- a/src/services/ShippingRules.php +++ /dev/null @@ -1,269 +0,0 @@ - - * @since 2.0 - */ -class ShippingRules extends Component -{ - /** - * @var null|Collection - */ - private ?Collection $_allShippingRules = null; - - /** - * Get all shipping rules. - * - * @return Collection - * @throws InvalidConfigException - */ - public function getAllShippingRules(): Collection - { - // @TODO Confirm this per-instance memoization is correct given multi-store contexts; consider keying by storeId if shipping rules diverge across stores - if ($this->_allShippingRules !== null) { - return $this->_allShippingRules; - } - - $results = $this->_createShippingRulesQuery()->all(); - $allShippingRules = []; - - foreach ($results as $result) { - $result['orderCondition'] ??= ''; - $allShippingRules[] = Craft::createObject([ - 'class' => ShippingRule::class, - 'attributes' => $result, - ]); - } - - $this->_allShippingRules = collect($allShippingRules); - - // Eager load shipping rule categories - $this->_eagerLoadShippingRuleCategories($this->_allShippingRules); - - return $this->_allShippingRules; - } - - /** - * Get all shipping rules by a shipping method ID. - * - * @param int $id - * @return Collection - * @throws InvalidConfigException - */ - public function getAllShippingRulesByShippingMethodId(int $id): Collection - { - return $this->getAllShippingRules()->where('methodId', $id); - } - - /** - * Get a shipping rule by its ID. - */ - public function getShippingRuleById(int $id): ?ShippingRule - { - return $this->getAllShippingRules()->firstWhere('id', $id); - } - - /** - * Save a shipping rule. - * - * @param bool $runValidation should we validate this rule before saving. - * @throws Exception - */ - public function saveShippingRule(ShippingRule $model, bool $runValidation = true): bool - { - if ($model->id) { - $record = ShippingRuleRecord::findOne($model->id); - - if (!$record) { - throw new Exception(Craft::t('commerce', 'No shipping rule exists with the ID “{id}”', - ['id' => $model->id])); - } - } else { - $record = new ShippingRuleRecord(); - } - - if ($runValidation && !$model->validate()) { - Craft::info('Shipping rule not saved due to validation error.', __METHOD__); - - return false; - } - - $fields = [ - 'name', - 'description', - 'methodId', - 'enabled', - 'orderConditionFormula', - 'baseRate', - 'perItemRate', - 'weightRate', - 'percentageRate', - 'minRate', - 'maxRate', - ]; - foreach ($fields as $field) { - $record->$field = $model->$field; - } - - $record->orderCondition = $model->getOrderCondition()->getConfig(); - $record->customerCondition = $model->getCustomerCondition()->getConfig(); - - if (empty($record->priority) && empty($model->priority)) { - $count = ShippingRuleRecord::find()->where(['methodId' => $model->methodId])->count(); - $record->priority = $model->priority = $count + 1; - } elseif ($model->priority) { - $record->priority = $model->priority; - } else { - $model->priority = $record->priority; - } - - // Save it! - $record->save(false); - - // Now that we have a record ID, save it on the model - $model->id = $record->id; - - ShippingRuleCategoryRecord::deleteAll(['shippingRuleId' => $model->id]); - - // Generate a rule category record for all categories regardless of data submitted - foreach (Plugin::getInstance()->getShippingCategories()->getAllShippingCategories($model->storeId) as $shippingCategory) { - $ruleCategory = $model->getShippingRuleCategories()[$shippingCategory->id] ?? null; - if ($ruleCategory) { - $ruleCategory = new ShippingRuleCategory([ - 'shippingRuleId' => $model->id, - 'shippingCategoryId' => $shippingCategory->id, - 'condition' => $ruleCategory->condition, - 'perItemRate' => $ruleCategory->perItemRate, - 'weightRate' => $ruleCategory->weightRate, - 'percentageRate' => $ruleCategory->percentageRate, - ]); - } else { - $ruleCategory = new ShippingRuleCategory([ - 'shippingRuleId' => $model->id, - 'shippingCategoryId' => $shippingCategory->id, - 'condition' => ShippingRuleCategoryRecord::CONDITION_ALLOW, - ]); - } - - Plugin::getInstance()->getShippingRuleCategories()->createShippingRuleCategory($ruleCategory, $runValidation); - } - - $this->_allShippingRules = null; // clear cache - - return true; - } - - /** - * Reorders shipping rules by the given array of IDs. - * - * @throws \yii\db\Exception - */ - public function reorderShippingRules(array $ids): bool - { - foreach ($ids as $sortOrder => $id) { - Craft::$app->getDb()->createCommand()->update(Table::SHIPPINGRULES, ['priority' => $sortOrder + 1], ['id' => $id])->execute(); - } - $this->_allShippingRules = null; // clear cache - - return true; - } - - /** - * Deletes a shipping rule by an ID. - * - * @throws Throwable - * @throws StaleObjectException - */ - public function deleteShippingRuleById(int $id): bool - { - $record = ShippingRuleRecord::findOne($id); - - if ($record) { - return (bool)$record->delete(); - } - - $this->_allShippingRules = null; // clear cache - - return false; - } - - /** - * Returns a Query object prepped for retrieving shipping rules. - */ - private function _createShippingRulesQuery(): Query - { - $query = (new Query()) - ->select([ - 'shippingrules.baseRate', - 'shippingrules.description', - 'shippingrules.enabled', - 'shippingrules.id', - 'shippingrules.maxRate', - 'shippingrules.methodId', - 'shippingrules.minRate', - 'shippingrules.name', - 'shippingrules.orderConditionFormula', - 'shippingrules.orderCondition', - 'shippingrules.customerCondition', - 'shippingrules.percentageRate', - 'shippingrules.perItemRate', - 'shippingrules.priority', - 'shippingrules.weightRate', - 'methods.storeId', - ]) - ->orderBy(['methodId' => SORT_ASC, 'priority' => SORT_ASC]) - ->from(Table::SHIPPINGRULES . ' shippingrules') - ->innerJoin(Table::SHIPPINGMETHODS . ' methods', '[[methods.id]] = [[shippingrules.methodId]]'); - - return $query; - } - - /** - * Eager loads shipping rule categories for a collection of shipping rules. - * - * @param Collection $shippingRules - */ - private function _eagerLoadShippingRuleCategories(Collection $shippingRules): void - { - $ruleIds = $shippingRules->pluck('id')->filter()->all(); - - if (empty($ruleIds)) { - return; - } - - $categoriesByRuleId = Plugin::getInstance() - ->getShippingRuleCategories() - ->getShippingRuleCategoriesByRuleIds($ruleIds); - - foreach ($shippingRules as $rule) { - if ($rule->id !== null) { - $rule->setShippingRuleCategories($categoriesByRuleId[$rule->id] ?? []); - } - } - } -} diff --git a/src/services/ShippingZones.php b/src/services/ShippingZones.php deleted file mode 100644 index 32c0212f0f..0000000000 --- a/src/services/ShippingZones.php +++ /dev/null @@ -1,166 +0,0 @@ - - * @since 2.0 - */ -class ShippingZones extends Component -{ - /** - * @var Collection[] - */ - private ?array $_allZones = null; - - /** - * Get all shipping zones. - * - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - */ - public function getAllShippingZones(?int $storeId = null): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if ($this->_allZones === null || !isset($this->_allZones[$storeId])) { - $results = $this->_createQuery()->where(['storeId' => $storeId])->all(); - - if ($this->_allZones === null) { - $this->_allZones = []; - } - - foreach ($results as $result) { - $shippingAddressZone = Craft::createObject([ - 'class' => ShippingAddressZone::class, - 'attributes' => $result, - ]); - - if (!isset($this->_allZones[$shippingAddressZone->storeId])) { - $this->_allZones[$shippingAddressZone->storeId] = collect(); - } - - $this->_allZones[$shippingAddressZone->storeId]->push($shippingAddressZone); - } - } - - return $this->_allZones[$storeId] ?? collect(); - } - - /** - * Get a shipping zone by its ID. - */ - public function getShippingZoneById(int $id, ?int $storeId = null): ?ShippingAddressZone - { - return $this->getAllShippingZones($storeId)->firstWhere('id', $id); - } - - /** - * Save a shipping zone. - * - * @param bool $runValidation should we validate this zone before saving - * @throws \Exception - * @throws Exception - */ - public function saveShippingZone(ShippingAddressZone $model, bool $runValidation = true): bool - { - if ($model->id) { - $record = ShippingZoneRecord::findOne($model->id); - - if (!$record) { - throw new Exception(Craft::t('commerce', 'No shipping zone exists with the ID “{id}”', ['id' => $model->id])); - } - } else { - $record = new ShippingZoneRecord(); - } - - if ($runValidation && !$model->validate()) { - Craft::info('Shipping zone not saved due to validation error.', __METHOD__); - - return false; - } - - //setting attributes - $record->name = $model->name; - $record->storeId = $model->storeId; - $record->description = $model->description; - $record->condition = $model->getCondition()->getConfig(); - $this->_clearCaches(); - - $record->save(); - $model->id = $record->id; - - return true; - } - - /** - * @throws Throwable - * @throws StaleObjectException - */ - public function deleteShippingZoneById(int $id): bool - { - $record = ShippingZoneRecord::findOne($id); - - if ($record) { - $result = (bool)$record->delete(); - if ($result) { - $this->_clearCaches(); - } - - return $result; - } - - return false; - } - - /** - * Returns a Query object prepped for retrieving shipping zones. - */ - private function _createQuery(): Query - { - return (new Query()) - ->select([ - 'condition', - 'dateCreated', - 'dateUpdated', - 'description', - 'id', - 'name', - 'storeId', - ]) - ->orderBy('name') - ->from([Table::SHIPPINGZONES]); - } - - /** - * Clear memoization. - * - * @since 3.2.5 - */ - private function _clearCaches(): void - { - $this->_allZones = []; - } -} diff --git a/src/services/Store.php b/src/services/Store.php deleted file mode 100644 index a5746abe87..0000000000 --- a/src/services/Store.php +++ /dev/null @@ -1,40 +0,0 @@ - - * @deprecated in 5.0.0. Use [[Stores]] service instead. - * @since 4.0 - */ -class Store extends Component -{ - /** - * Returns the current store. - * - * @return StoreModel - * @throws SiteNotFoundException - * @throws InvalidConfigException - * @deprecated in 5.0.0. Use [[Stores::getCurrentStore()]] instead. - */ - public function getStore(): StoreModel - { - Craft::$app->getDeprecator()->log(__METHOD__, 'craft\commerce\services\Store::getStore() has been deprecated. Use craft\commerce\services\Stores::getCurrentStore() instead.'); - return Plugin::getInstance()->getStores()->getCurrentStore(); - } -} diff --git a/src/services/StoreSettings.php b/src/services/StoreSettings.php deleted file mode 100644 index ee49462cb1..0000000000 --- a/src/services/StoreSettings.php +++ /dev/null @@ -1,182 +0,0 @@ - - * @since 5.0 - */ -class StoreSettings extends Component -{ - /** - * @var Collection|null - */ - private ?Collection $_allStoreSettings = null; - - /** - * Returns the store record. - * - * @param int $id - * @return StoreSettingsModel - */ - public function getStoreSettingsById(int $id): StoreSettingsModel - { - $store = Plugin::getInstance()->getStores()->getStoreById($id); - - if (!$store) { - throw new InvalidConfigException('Store not found'); - } - - $storeSettings = $this->getAllStoreSettings()->firstWhere('id', $id); - - if (!$storeSettings) { - $storeSettingsRecord = new StoreSettingsRecord(); - $storeSettingsRecord->id = $id; - - /** @var StoreSettingsModel $storeSettings */ - $storeSettings = Craft::createObject([ - 'class' => StoreSettingsModel::class, - 'id' => $storeSettingsRecord->id, - ]); - - // Create a new blank store location - $locationAddress = $storeSettings->getLocationAddress(); - $storeSettingsRecord->locationAddressId = $locationAddress->id; - - $storeSettingsRecord->save(); - - - $this->getAllStoreSettings()->put($storeSettings->id, $storeSettings); - } - - return $storeSettings; - } - - /** - * @return Collection - * @throws InvalidConfigException - */ - public function getAllStoreSettings(): Collection - { - if ($this->_allStoreSettings === null) { - $this->_allStoreSettings = collect(); - $storeSettings = $this->_createStoreSettingsQuery()->all(); - - foreach ($storeSettings as $storeSetting) { - $this->_allStoreSettings->put($storeSetting['id'], Craft::createObject([ - 'class' => StoreSettingsModel::class, - 'attributes' => $storeSetting, - ])); - } - } - - return $this->_allStoreSettings ?? collect(); - } - - /** - * Saves the store - * - * @param StoreSettingsModel $storeSettings - * @return bool - * @throws InvalidConfigException - */ - public function saveStoreSettings(StoreSettingsModel $storeSettings): bool - { - $storeSettingsRecord = StoreSettingsRecord::findOne($storeSettings->id); - - if (!$storeSettingsRecord) { - throw new InvalidConfigException('Invalid store ID'); - } - - $storeSettingsRecord->countries = $storeSettings->countries; - $storeSettingsRecord->marketAddressCondition = $storeSettings->marketAddressCondition->getConfig(); - - if (!$storeSettingsRecord->save()) { - return false; - } - - $this->getAllStoreSettings()->put($storeSettings->id, $storeSettings); - return true; - } - - /** - * @param AuthorizationCheckEvent $event - * @return void - */ - public function authorizeStoreLocationView(AuthorizationCheckEvent $event): void - { - if (!$storeSettingsRecord = $this->_checkStoreLocationAuthorization($event)) { - return; - } - - // @TODO Authorize the current user against the store from $storeSettingsRecord (e.g. "commerce-manageStore:" permission) rather than always granting view access - $event->authorized = true; - } - - /** - * @param AuthorizationCheckEvent $event - * @return void - */ - public function authorizeStoreLocationEdit(AuthorizationCheckEvent $event): void - { - if (!$storeSettingsRecord = $this->_checkStoreLocationAuthorization($event)) { - return; - } - - // @TODO Authorize the current user against the store from $storeSettingsRecord (e.g. "commerce-manageStore:" permission) rather than always granting edit access - $event->authorized = true; - } - - /** - * @param AuthorizationCheckEvent $event - * @return StoreSettingsRecord|false - */ - private function _checkStoreLocationAuthorization(AuthorizationCheckEvent $event): StoreSettingsRecord|false - { - if (!$event->element instanceof Address) { - return false; - } - - $storeSettings = StoreSettingsRecord::findOne(['locationAddressId' => $event->element->getCanonicalId()]); - if (!$storeSettings) { - return false; - } - - return $storeSettings; - } - - /** - * Returns a Query object prepped for retrieving the store. - */ - private function _createStoreSettingsQuery(): Query - { - return (new Query()) - ->select([ - 'id', - 'marketAddressCondition', - 'locationAddressId', - 'countries', - ]) - ->from([Table::STORESETTINGS]); - } -} diff --git a/src/services/Stores.php b/src/services/Stores.php deleted file mode 100644 index 4a23d3528a..0000000000 --- a/src/services/Stores.php +++ /dev/null @@ -1,974 +0,0 @@ - - * @since 5.0.0 - * - * @property-read Store $primaryStore - * @property-read Collection $allStores - */ -class Stores extends Component -{ - /** - * @event DeleteStoreEvent The event that is triggered before a store is deleted. - * - * You may set [[\craft\events\CancelableEvent::$isValid]] to `false` to prevent the store from getting deleted. - * - * ```php - * use craft\commerce\events\DeleteStoreEvent; - * use craft\commerce\models\Store; - * use craft\commerce\services\Stores; - * use yii\base\Event; - * - * Event::on( - * Stores::class, - * Stores::EVENT_BEFORE_DELETE_STORE, - * function(DeleteStoreEvent $event) { - * // @var Store $store - * $store = $event->store; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_DELETE_STORE = 'beforeDeleteStore'; - - /** - * @event DeleteStoreEvent The event that is triggered after a store is deleted - * - * ```php - * use craft\commerce\events\DeleteStoreEvent; - * use craft\commerce\models\Store; - * use craft\commerce\services\Stores; - * use yii\base\Event; - * - * Event::on( - * Stores::class, - * Stores::EVENT_AFTER_DELETE_STORE, - * function(DeleteStoreEvent $event) { - * // @var Store $store - * $store = $event->store; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_DELETE_STORE = 'afterDeleteStore'; - - /** - * @event DeleteStoreEvent The event that is triggered before a store delete is applied to the database. - * - * ```php - * use craft\commerce\events\DeleteStoreEvent; - * use craft\commerce\models\Store; - * use craft\commerce\services\Stores; - * use yii\base\Event; - * - * Event::on( - * Stores::class, - * Stores::EVENT_BEFORE_APPLY_STORE_DELETE, - * function(DeleteStoreEvent $event) { - * // @var Store $store - * $store = $event->store; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_APPLY_STORE_DELETE = 'beforeApplyStoreDelete'; - - /** - * @event StoreEvent The event that is triggered before a store is saved. - * - * ```php - * use craft\commerce\events\StoreEvent; - * use craft\commerce\models\Store; - * use craft\commerce\services\Stores; - * use yii\base\Event; - * - * Event::on( - * Stores::class, - * Stores::EVENT_BEFORE_SAVE_STORE, - * function(StoreEvent $event) { - * // @var Store $store - * $store = $event->store; - * // @var bool $isNew - * $isNew = $event->isNew; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_SAVE_STORE = 'beforeSaveStore'; - - /** - * @event StoreEvent The event that is triggered after a store is saved. - * - * ```php - * use craft\commerce\events\StoreEvent; - * use craft\commerce\models\Store; - * use craft\commerce\services\Stores; - * use yii\base\Event; - * - * Event::on( - * Stores::class, - * Stores::EVENT_AFTER_SAVE_STORE, - * function(StoreEvent $event) { - * // @var Store $store - * $store = $event->store; - * // @var bool $isNew - * $isNew = $event->isNew; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_SAVE_STORE = 'afterSaveStore'; - - /** - * The project config path to stores data - */ - public const CONFIG_STORES_KEY = 'commerce.stores'; - - /** - * The project config path to site stores data - */ - public const CONFIG_SITESTORES_KEY = 'commerce.sitestores'; - - /** - * @var Collection|null - */ - private ?Collection $_allStores = null; - - /** - * @var Collection|null - */ - private ?Collection $_allStoresBySiteId = null; - - /** - * @var Collection|null - */ - private ?Collection $_allSiteStores = null; - - /** - * @return void - */ - private function _loadAllStores(): void - { - if (isset($this->_allStores)) { - return; - } - - $results = $this->_createStoreQuery()->all(); - $siteStores = $this->_createSiteStoresQuery() - ->select(['storeId', 'siteId']) - ->all(); - - $allStores = []; - $allStoresBySiteId = []; - - foreach ($results as $row) { - $store = Craft::createObject(array_merge(['class' => Store::class], $row)); - - $allStores[] = $store; - - foreach (ArrayHelper::where($siteStores, 'storeId', $store->id) as $siteStore) { - $allStoresBySiteId[$siteStore['siteId']] = $store; - } - } - - $this->_allStores = collect($allStores); - $this->_allStoresBySiteId = collect($allStoresBySiteId); - } - - /** - * Returns the current store. - * - * @return Store the current store - * @throws SiteNotFoundException - */ - public function getCurrentStore(): Store - { - return $this->getStoreBySiteId(Craft::$app->getSites()->getCurrentSite()->id) ?? $this->getPrimaryStore(); - } - - /** - * @return Collection - */ - public function getAllStores(): Collection - { - if ($this->_allStores === null) { - $this->_loadAllStores(); - } - - return $this->_allStores ?? collect(); - } - - /** - * @param int $id - * @return Store|null - */ - public function getStoreById(int $id): ?Store - { - return $this->getAllStores()->firstWhere('id', $id); - } - - /** - * @param string $uid - * @return Store|null - */ - public function getStoreByUid(string $uid): ?Store - { - return $this->getAllStores()->firstWhere('uid', $uid); - } - - /** - * @param int $siteId - * @return Store|null - */ - public function getStoreBySiteId(int $siteId): ?Store - { - if ($this->_allStoresBySiteId === null) { - // Population of `_allStoresBySiteId` is done in `_loadAllStores()` - $this->_loadAllStores(); - } - - return $this->_allStoresBySiteId?->get($siteId); - } - - /** - * @param string $handle - * @return Store|null - */ - public function getStoreByHandle(string $handle): ?Store - { - return $this->getAllStores()->firstWhere('handle', $handle); - } - - /** - * Returns a collections of stores that are available to a user. - * - * @param int $userId - * @return Collection - * @throws InvalidConfigException - */ - public function getStoresByUserId(int $userId): Collection - { - $user = Craft::$app->getUsers()->getUserById($userId); - - if (!$user) { - throw new InvalidConfigException('Invalid user ID: ' . $userId); - } - - $allStores = $this->getAllStores(); - if (!Craft::$app->getIsMultiSite()) { - return $allStores; - } - - return $allStores->filter(function(Store $store) use ($user) { - $siteUids = $store->getSites()->map(fn(Site $site) => $site->uid); - - foreach ($siteUids as $siteUid) { - if ($user->can('editSite:' . $siteUid)) { - return true; - } - } - - return false; - }); - } - - /** - * Saves a store. - * - * @param Store $store The store to be saved - * @param bool $runValidation Whether the store should be validated - * @return bool - * @throws BusyResourceException - * @throws StaleResourceException - * @throws ErrorException - * @throws YiiBaseException - * @throws InvalidConfigException - * @throws NotSupportedException - * @throws ServerErrorHttpException - */ - public function saveStore(Store $store, bool $runValidation = true): bool - { - $isNewStore = !$store->id; - - // Fire a 'beforeSaveStore' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_SAVE_STORE)) { - $this->trigger(self::EVENT_BEFORE_SAVE_STORE, new StoreEvent([ - 'store' => $store, - 'isNew' => $isNewStore, - ])); - } - - if ($runValidation && !$store->validate()) { - Craft::info('Store not saved due to validation error.', __METHOD__); - return false; - } - - if ($isNewStore) { - $store->uid = StringHelper::UUID(); - } elseif (!$store->uid) { - $store->uid = Db::uidById(Table::STORES, $store->id); - } - - $projectConfigService = Craft::$app->getProjectConfig(); - $configPath = self::CONFIG_STORES_KEY . "." . $store->uid; - $projectConfigService->set( - $configPath, - $store->getConfig(), - "Save the “{$store->handle}” store" - ); - - // Now that we have a store ID, save it on the model - if ($isNewStore) { - $store->id = Db::idByUid(Table::STORES, $store->uid); - - // Create any default data we need for the store - $orderStatus = Craft::createObject([ - 'class' => OrderStatus::class, - 'attributes' => [ - 'name' => 'New', - 'handle' => 'new', - 'color' => 'green', - 'default' => true, - 'storeId' => $store->id, - ], - ]); - Plugin::getInstance()->getOrderStatuses()->saveOrderStatus($orderStatus); - } - - // Update the other primary store. - if ($store->primary) { - foreach ($projectConfigService->get(self::CONFIG_STORES_KEY) as $uid => $config) { - if ($uid !== $store->uid && isset($config['primary']) && $config['primary'] === true) { - $configPath = self::CONFIG_STORES_KEY . '.' . $uid; - $config['primary'] = false; // Set the other to false - $projectConfigService->set( - $configPath, - $config, - "Set the “{$config['name']}” store to not be primary" - ); - } - } - } - - $this->refreshStores(); - - return true; - } - - /** - * @param int $storeId - * @return bool - * @throws Exception - */ - public function deleteStoreById(int $storeId): bool - { - $store = $this->getStoreById($storeId); - - if (!$store) { - return false; - } - - return $this->deleteStore($store); - } - - /** - * @param Store $store - * @return bool - * @throws Exception - */ - public function deleteStore(Store $store): bool - { - // Make sure this isn't the primary site - if ($store->id === $this->getPrimaryStore()?->id) { - throw new Exception('You cannot delete the primary store.'); - } - - // Fire a 'beforeDeleteStore' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_DELETE_STORE)) { - $this->trigger(self::EVENT_BEFORE_DELETE_STORE, new DeleteStoreEvent([ - 'store' => $store, - ])); - } - - $path = self::CONFIG_STORES_KEY . '.' . $store->uid; - Craft::$app->getProjectConfig()->remove($path, "Delete the “{$store->handle}” store"); - - return true; - } - - /** - * Handle store status change. - * - * @param ConfigEvent $event - * @return void - * @throws Throwable - * @throws YiiDbException - */ - public function handleChangedStore(ConfigEvent $event): void - { - $storeUid = $event->tokenMatches[0]; - $data = $event->newValue; - - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - $storeRecord = $this->_getStoreRecord($storeUid); - $isNewStore = $storeRecord->getIsNewRecord(); - - $storeRecord->uid = $storeUid; - $storeRecord->name = $data['name']; - $storeRecord->handle = $data['handle']; - $storeRecord->primary = $data['primary']; - - $storeRecord->autoSetNewCartAddresses = ($data['autoSetNewCartAddresses'] ?? false); - $storeRecord->autoSetCartShippingMethodOption = ($data['autoSetCartShippingMethodOption'] ?? false); - $storeRecord->autoSetPaymentSource = ($data['autoSetPaymentSource'] ?? false); - $storeRecord->allowEmptyCartOnCheckout = ($data['allowEmptyCartOnCheckout'] ?? false); - $storeRecord->allowCheckoutWithoutPayment = ($data['allowCheckoutWithoutPayment'] ?? false); - $storeRecord->allowPartialPaymentOnCheckout = ($data['allowPartialPaymentOnCheckout'] ?? false); - $storeRecord->requireShippingAddressAtCheckout = ($data['requireShippingAddressAtCheckout'] ?? false); - $storeRecord->requireBillingAddressAtCheckout = ($data['requireBillingAddressAtCheckout'] ?? false); - $storeRecord->requireShippingMethodSelectionAtCheckout = ($data['requireShippingMethodSelectionAtCheckout'] ?? false); - $storeRecord->useBillingAddressForTax = ($data['useBillingAddressForTax'] ?? false); - $storeRecord->validateOrganizationTaxIdAsVatId = ($data['validateOrganizationTaxIdAsVatId'] ?? false); - $storeRecord->freeOrderPaymentStrategy = ($data['freeOrderPaymentStrategy'] ?? 'complete'); - $storeRecord->minimumTotalPriceStrategy = ($data['minimumTotalPriceStrategy'] ?? 'default'); - $storeRecord->orderReferenceFormat = ($data['orderReferenceFormat'] ?? '{{number[:7]}}'); - $storeRecord->currency = ($data['currency'] ?? null); - $storeRecord->sortOrder = ($data['sortOrder'] ?? 99); - - $storeRecord->save(false); - - $transaction->commit(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - - // Did the primary site just change? - if ($data['primary']) { - Db::update(Table::STORES, ['primary' => false], ['not', ['id' => $storeRecord->id]]); - Db::update(Table::STORES, ['primary' => true], ['id' => $storeRecord->id]); - } - - $paymentCurrency = Plugin::getInstance()->getPaymentCurrencies()->getPaymentCurrencyByIso($data['currency'] ?? '', $storeRecord->id); - if (!$paymentCurrency) { - $data = [ - 'iso' => $data['currency'] ?? 'USD', - 'storeId' => $storeRecord->id, - 'rate' => 1, - ]; - Craft::$app->getDb()->createCommand()->insert(PaymentCurrency::tableName(), $data)->execute(); - } - - if (Plugin::getInstance()->getShippingCategories()->getAllShippingCategories($storeRecord->id)->isEmpty()) { - $data = [ - 'name' => 'General', - 'handle' => 'general', - 'default' => true, - 'storeId' => $storeRecord->id, - ]; - Craft::$app->getDb()->createCommand()->insert(ShippingCategory::tableName(), $data)->execute(); - } - - $this->refreshStores(); - - // Fire a 'afterSaveStore' event - if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_STORE)) { - $this->trigger(self::EVENT_AFTER_SAVE_STORE, new StoreEvent([ - 'store' => $this->getStoreById($storeRecord->id), - 'isNew' => $isNewStore, - ])); - } - } - - /** - * Handle a deleted Store. - * - * @param ConfigEvent $event - * @throws Throwable - * @throws YiiDbException - */ - public function handleDeletedStore(ConfigEvent $event): void - { - $storeUid = $event->tokenMatches[0]; - $storeRecord = $this->_getStoreRecord($storeUid); - - if (!$storeRecord->id) { - return; - } - - /** @var Store $store */ - $store = $this->getStoreById($storeRecord->id); - - // Fire a 'beforeApplyStoreDelete' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_APPLY_STORE_DELETE)) { - $this->trigger(self::EVENT_BEFORE_APPLY_STORE_DELETE, new DeleteStoreEvent([ - 'store' => $store, - ])); - } - - $transaction = Craft::$app->getDb()->beginTransaction(); - - try { - $locationAddressId = $store->getSettings()->getLocationAddressId(); - - Craft::$app->getDb()->createCommand() - ->delete(Table::STORES, ['id' => $storeRecord->id]) - ->execute(); - - // Delete store address - if ($locationAddressId) { - Craft::$app->getElements()->deleteElementById($locationAddressId, Address::class, hardDelete: true); - } - - $transaction->commit(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - - // Refresh stores - $this->refreshStores(); - - // Make sure any site store for this store is reassigned to the primary store - $siteStores = collect($this->getAllSiteStores())->where('storeId', $store->id)->all(); - foreach ($siteStores as $siteStore) { - $siteStore->storeId = $this->getPrimaryStore()->id; - $this->saveSiteStore($siteStore); - } - - // Fire an 'afterDeleteStore' event - if ($this->hasEventHandlers(self::EVENT_AFTER_DELETE_STORE)) { - $this->trigger(self::EVENT_AFTER_DELETE_STORE, new DeleteStoreEvent([ - 'store' => $store, - ])); - } - } - - /** - * Refresh the status of all stores based on the DB data. - * - * @return void - */ - public function refreshStores(): void - { - $this->_allStores = null; - $this->_allStoresBySiteId = null; - $this->_loadAllStores(); - } - - /** - * Returns the primary store. - * - * @return Store|null - */ - public function getPrimaryStore(): ?Store - { - return $this->getAllStores()->firstWhere('primary', true); - } - - /** - * @param array $ids - * @return bool - * @throws BusyResourceException - * @throws ErrorException - * @throws InvalidConfigException - * @throws NotSupportedException - * @throws ServerErrorHttpException - * @throws StaleResourceException - * @throws YiiBaseException - */ - public function reorderStores(array $ids): bool - { - $projectConfig = Craft::$app->getProjectConfig(); - - $uidsByIds = Db::uidsByIds(Table::STORES, $ids); - - foreach ($ids as $sortOrder => $id) { - if (!empty($uidsByIds[$id])) { - $uid = $uidsByIds[$id]; - $projectConfig->set(self::CONFIG_STORES_KEY . '.' . $uid . '.sortOrder', $sortOrder + 1); - } - } - - $this->refreshStores(); - - return true; - } - - /** - * Gets a store record by uid. - * - * @param string $uid - * @return StoreRecord - */ - private function _getStoreRecord(string $uid): StoreRecord - { - if ($store = StoreRecord::findOne(['uid' => $uid])) { - return $store; - } - - return new StoreRecord(); - } - - /** - * Returns a Query object prepped for retrieving the stores. - * - * @return Query - */ - private function _createStoreQuery(): Query - { - $selectColumns = [ - 'handle', - 'id', - 'name', - 'primary', - 'uid', - ]; - - // Added to avoid migration issues, as settings were moved after stores table creation - // @TODO Remove this schemaVersion guard in Commerce 6.0 once all installs are past schema 5.0.72 and the store settings columns are guaranteed to exist - $commerce = Craft::$app->getPlugins()->getStoredPluginInfo('commerce'); - - if ($commerce && version_compare($commerce['schemaVersion'], '5.0.72', '>=')) { - $selectColumns = array_merge($selectColumns, [ - 'allowCheckoutWithoutPayment', - 'allowEmptyCartOnCheckout', - 'allowPartialPaymentOnCheckout', - 'autoSetCartShippingMethodOption', - 'autoSetNewCartAddresses', - 'autoSetPaymentSource', - 'currency', - 'freeOrderPaymentStrategy', - 'minimumTotalPriceStrategy', - 'orderReferenceFormat', - 'requireBillingAddressAtCheckout', - 'requireShippingAddressAtCheckout', - 'requireShippingMethodSelectionAtCheckout', - 'sortOrder', - 'useBillingAddressForTax', - 'validateOrganizationTaxIdAsVatId', - ]); - } - - $query = (new Query()) - ->select($selectColumns) - ->from([Table::STORES]); - - if ($commerce && version_compare($commerce['schemaVersion'], '5.0.72', '>=')) { - $query->orderBy(['sortOrder' => SORT_ASC]); - } - - return $query; - } - - /** - * @param Store $store - * @return Collection - */ - public function getAllSitesForStore(Store $store): Collection - { - $sites = Craft::$app->getSites()->getAllSites(); - - return $this->getAllSiteStores() - ->filter(fn(SiteStore $siteStore) => $siteStore->storeId == $store->id) - ->map(fn(SiteStore $siteStore) => ArrayHelper::firstWhere($sites, 'id', $siteStore->siteId)); - } - - /** - * @return Collection - */ - public function getAllSiteStores(): Collection - { - if ($this->_allSiteStores !== null) { - return $this->_allSiteStores; - } - - $siteStores = []; - foreach ($this->_createSiteStoresQuery()->all() as $store) { - $siteStores[] = new SiteStore($store); - } - - return !empty($siteStores) ? $this->_allSiteStores = collect($siteStores) : collect(); - } - - /** - * Returns sites that are assigned to more than one store assigned, so that other new stores can use them. - * - * @return array - */ - public function getSiteIdsAvailableForAssignmentToNewStores(): array - { - // Sites that are assigned to more than one store - $subQuery = (new Query()) - ->select('storeId') - ->from(Table::SITESTORES) - ->groupBy('storeId') - ->having(['>', new Expression('COUNT([[storeId]])'), 1]); - - return (new Query()) - ->select('siteId') - ->from(Table::SITESTORES) - ->where(['IN', 'storeId', $subQuery]) - ->groupBy('siteId') - ->column(); - } - - /** - * @param SiteStore $siteStore - * @param bool $runValidation - * @return bool - * @throws BusyResourceException - * @throws ErrorException - * @throws InvalidConfigException - * @throws NotSupportedException - * @throws ServerErrorHttpException - * @throws StaleResourceException - * @throws YiiBaseException - */ - public function saveSiteStore(SiteStore $siteStore, bool $runValidation = true): bool - { - if ($runValidation && !$siteStore->validate()) { - Craft::info('Site store mapping not saved due to validation error.', __METHOD__); - return false; - } - - // We use the same UID as the site since we only have one record per site. - // This also makes it easier to see what site a store is mapped to in the project config. - $craftSite = Craft::$app->getSites()->getSiteById($siteStore->siteId); - if (!$craftSite) { - throw new InvalidConfigException('Invalid site ID: ' . $siteStore->siteId); - } - - if (!$siteStore->uid) { - $siteStore->uid = Db::uidById(CraftTable::SITES, $siteStore->siteId); - } - - $projectConfigService = Craft::$app->getProjectConfig(); - $configPath = self::CONFIG_SITESTORES_KEY . "." . $siteStore->uid; - $projectConfigService->set( - $configPath, - $siteStore->getConfig(), - "Save the “{$craftSite->handle}” commerce site store mapping" - ); - - $this->refreshStores(); - - return true; - } - - /** - * Handle site store mapping change. - * - * @param ConfigEvent $event - * @return void - * @throws Throwable - * @throws YiiDbException - */ - public function handleChangedSiteStore(ConfigEvent $event): void - { - ProjectConfigHelper::ensureAllSitesProcessed(); - ProjectConfigData::ensureAllStoresProcessed(); - - $siteStoreUid = $event->tokenMatches[0]; - $data = $event->newValue; - - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - $siteStoreRecord = SiteStoreRecord::findOne(['uid' => $siteStoreUid]); - - if (!$siteStoreRecord) { - $siteStoreRecord = new SiteStoreRecord(); - } - - $siteStoreRecord->siteId = Db::idByUid(CraftTable::SITES, $siteStoreUid); - $siteStoreRecord->storeId = Db::idByUid(Table::STORES, $data['store']); - $siteStoreRecord->uid = $siteStoreUid; - - $siteStoreRecord->save(false); - - $transaction->commit(); - - $this->refreshStores(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - } - - /** - * Handle a deleted Store. - * - * @param ConfigEvent $event - * @throws Throwable - * @throws YiiDbException - */ - public function handleDeletedSiteStore(ConfigEvent $event): void - { - $storeStoreUid = $event->tokenMatches[0]; - $siteStoreRecord = SiteStoreRecord::findOne(['uid' => $storeStoreUid]); // site_stores uses the site UID - - if (!$siteStoreRecord) { - return; - } - - $transaction = Craft::$app->getDb()->beginTransaction(); - - try { - Craft::$app->getDb()->createCommand() - ->delete(Table::SITESTORES, ['siteId' => $siteStoreRecord->siteId]) - ->execute(); - - $transaction->commit(); - - $this->refreshStores(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } - } - - /** - * - * @param SiteEvent $event - * @return void - * @throws BusyResourceException - * @throws ErrorException - * @throws InvalidConfigException - * @throws NotSupportedException - * @throws ServerErrorHttpException - * @throws StaleResourceException - * @throws YiiBaseException - */ - public function afterSaveCraftSiteHandler(SiteEvent $event): void - { - $siteStore = SiteStoreRecord::findOne(['siteId' => $event->site->id]); - - // Only create it if it doesn't exist. - // The saving of the store does not currently change the store relation, but if it did, - // we would need to mutate the existing record. - if (!$siteStore) { - $siteStore = new SiteStore(); - $siteStore->siteId = $event->site->id; - $siteStore->storeId = $this->getPrimaryStore()->id; - $siteStore->uid = $event->site->uid; - $this->saveSiteStore($siteStore); - } - } - - /** - * @param SiteEvent $event - * @return void - * @throws BusyResourceException - * @throws ErrorException - * @throws InvalidConfigException - * @throws NotSupportedException - * @throws ServerErrorHttpException - * @throws StaleResourceException - * @throws YiiBaseException - */ - public function afterDeleteCraftSiteHandler(SiteEvent $event): void - { - $siteStores = $this->getAllSiteStores(); - $siteStore = $siteStores->firstWhere('siteId', $event->site->id); - - if (!$siteStore) { - return; - } - - $store = $this->getStoreById($siteStore->storeId); - - $isStoreOrphaned = true; - foreach ($siteStores as $ss) { - if ($ss->siteId !== $siteStore->siteId && $ss->storeId === $siteStore->storeId) { - $isStoreOrphaned = false; - break; - } - } - - // If this was the primary store, make another the primary - if ($store->primary && $isStoreOrphaned) { - // make another store primary - $store = $this->getAllStores()->firstWhere('primary', false); - $store->primary = true; - $this->saveStore($store); - } - - // Delete the old siteStore record - Craft::$app->getProjectConfig()->remove(self::CONFIG_SITESTORES_KEY . '.' . $siteStore->uid); - } - - /** - * @return Query - */ - private function _createSiteStoresQuery(): Query - { - // get the site stores - return (new Query()) - ->select([ - 'siteId', - 'storeId', - 'uid', - ]) - ->from([Table::SITESTORES]); - } -} diff --git a/src/services/Subscriptions.php b/src/services/Subscriptions.php deleted file mode 100644 index 28e3b5198a..0000000000 --- a/src/services/Subscriptions.php +++ /dev/null @@ -1,786 +0,0 @@ - - * @since 2.0 - */ -class Subscriptions extends Component -{ - /** - * @event SubscriptionEvent The event that is triggered after a subscription has expired. - * - * ```php - * use craft\commerce\events\SubscriptionEvent; - * use craft\commerce\services\Subscriptions; - * use craft\commerce\elements\Subscription; - * use yii\base\Event; - * - * Event::on( - * Subscriptions::class, - * Subscriptions::EVENT_AFTER_EXPIRE_SUBSCRIPTION, - * function(SubscriptionEvent $event) { - * // @var Subscription $subscription - * $subscription = $event->subscription; - * - * // Make a call to third party service to de-authorize a user - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_EXPIRE_SUBSCRIPTION = 'afterExpireSubscription'; - - /** - * @event CreateSubscriptionEvent The event that is triggered before a subscription is created. - * - * You may set the `isValid` property to `false` on the event to prevent the user from being subscribed to the plan. - * - * ```php - * use craft\commerce\events\CreateSubscriptionEvent; - * use craft\commerce\services\Subscriptions; - * use craft\elements\User; - * use craft\commerce\base\Plan; - * use craft\commerce\models\subscriptions\SubscriptionForm; - * use yii\base\Event; - * - * Event::on( - * Subscriptions::class, - * Subscriptions::EVENT_BEFORE_CREATE_SUBSCRIPTION, - * function(CreateSubscriptionEvent $event) { - * // @var User $user - * $user = $event->user; - * // @var Plan $plan - * $plan = $event->plan; - * // @var SubscriptionForm $params - * $params = $event->parameters; - * - * // Set the trial days based on some business logic - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_CREATE_SUBSCRIPTION = 'beforeCreateSubscription'; - - /** - * @event SubscriptionEvent The event that is triggered after a subscription is created. - * - * ```php - * use craft\commerce\events\SubscriptionEvent; - * use craft\commerce\services\Subscriptions; - * use craft\commerce\elements\Subscription; - * use yii\base\Event; - * - * Event::on( - * Subscriptions::class, - * Subscriptions::EVENT_AFTER_CREATE_SUBSCRIPTION, - * function(SubscriptionEvent $event) { - * // @var Subscription $subscription - * $subscription = $event->subscription; - * - * // Call a third party service to authorize a user - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_CREATE_SUBSCRIPTION = 'afterCreateSubscription'; - - /** - * @event SubscriptionEvent TThe event that is triggered before a subscription gets reactivated. - * - * You may set the `isValid` property to `false` on the event to prevent the subscription from being reactivated. - * - * ```php - * use craft\commerce\events\SubscriptionEvent; - * use craft\commerce\services\Subscriptions; - * use craft\commerce\elements\Subscription; - * use yii\base\Event; - * - * Event::on( - * Subscriptions::class, - * Subscriptions::EVENT_BEFORE_REACTIVATE_SUBSCRIPTION, - * function(SubscriptionEvent $event) { - * // @var Subscription $subscription - * $subscription = $event->subscription; - * - * // Use business logic to determine whether the user can reactivate - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_REACTIVATE_SUBSCRIPTION = 'beforeReactivateSubscription'; - - /** - * @event SubscriptionEvent The event that is triggered after a subscription gets reactivated. - * - * ```php - * use craft\commerce\events\SubscriptionEvent; - * use craft\commerce\services\Subscriptions; - * use craft\commerce\elements\Subscription; - * use yii\base\Event; - * - * Event::on( - * Subscriptions::class, - * Subscriptions::EVENT_AFTER_REACTIVATE_SUBSCRIPTION, - * function(SubscriptionEvent $event) { - * // @var Subscription $subscription - * $subscription = $event->subscription; - * - * // Re-authorize the user with a third-party service - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_REACTIVATE_SUBSCRIPTION = 'afterReactivateSubscription'; - - /** - * @event SubscriptionSwitchPlansEvent The event that is triggered before a subscription is switched to a different plan. - * - * You may set the `isValid` property to `false` on the event to prevent the switch from happening. - * - * ```php - * use craft\commerce\events\SubscriptionSwitchPlansEvent; - * use craft\commerce\services\Subscriptions; - * use craft\commerce\base\Plan; - * use craft\commerce\elements\Subscription; - * use craft\commerce\models\subscriptions\SwitchPlansForm; - * use yii\base\Event; - * - * Event::on( - * Subscriptions::class, - * Subscriptions::EVENT_BEFORE_SWITCH_SUBSCRIPTION_PLAN, - * function(SubscriptionSwitchPlansEvent $event) { - * // @var Subscription $subscription - * $subscription = $event->subscription; - * // @var Plan $oldPlan - * $oldPlan = $event->oldPlan; - * // @var Plan $newPlan - * $newPlan = $event->newPlan; - * // @var SwitchPlansForm $params - * $params = $event->parameters; - * - * // Modify the switch parameters based on some business logic - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_SWITCH_SUBSCRIPTION_PLAN = 'beforeSwitchSubscriptionPlan'; - - /** - * @event SubscriptionSwitchPlansEvent The event that is triggered after a subscription gets switched to a different plan. - * - * ```php - * use craft\commerce\events\SubscriptionSwitchPlansEvent; - * use craft\commerce\services\Subscriptions; - * use craft\commerce\base\Plan; - * use craft\commerce\elements\Subscription; - * use craft\commerce\models\subscriptions\SwitchPlansForm; - * use yii\base\Event; - * - * Event::on( - * Subscriptions::class, - * Subscriptions::EVENT_AFTER_SWITCH_SUBSCRIPTION_PLAN, - * function(SubscriptionSwitchPlansEvent $event) { - * // @var Subscription $subscription - * $subscription = $event->subscription; - * // @var Plan $oldPlan - * $oldPlan = $event->oldPlan; - * // @var Plan $newPlan - * $newPlan = $event->newPlan; - * // @var SwitchPlansForm $params - * $params = $event->parameters; - * - * // Adjust the user’s permissions on a third party service - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_SWITCH_SUBSCRIPTION_PLAN = 'afterSwitchSubscriptionPlan'; - - /** - * @event CancelSubscriptionEvent The event that is triggered before a subscription is canceled. - * - * You may set the `isValid` property to `false` on the event to prevent the subscription from being canceled. - * - * ```php - * use craft\commerce\events\CancelSubscriptionEvent; - * use craft\commerce\services\Subscriptions; - * use craft\commerce\elements\Subscription; - * use craft\commerce\models\subscriptions\CancelSubscriptionForm; - * use yii\base\Event; - * - * Event::on( - * Subscriptions::class, - * Subscriptions::EVENT_BEFORE_CANCEL_SUBSCRIPTION, - * function(CancelSubscriptionEvent $event) { - * // @var Subscription $subscription - * $subscription = $event->subscription; - * // @var CancelSubscriptionForm $params - * $params = $event->parameters; - * - * // Check whether the user is permitted to cancel the subscription - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_CANCEL_SUBSCRIPTION = 'beforeCancelSubscription'; - - /** - * @event CancelSubscriptionEvent The event that is triggered after a subscription gets canceled. - * - * ```php - * use craft\commerce\events\CancelSubscriptionEvent; - * use craft\commerce\services\Subscriptions; - * use craft\commerce\elements\Subscription; - * use craft\commerce\models\subscriptions\CancelSubscriptionForm; - * use yii\base\Event; - * - * Event::on( - * Subscriptions::class, - * Subscriptions::EVENT_AFTER_CANCEL_SUBSCRIPTION, - * function(CancelSubscriptionEvent $event) { - * // @var Subscription $subscription - * $subscription = $event->subscription; - * // @var CancelSubscriptionForm $params - * $params = $event->parameters; - * - * // Refund the user for the remainder of the subscription - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_CANCEL_SUBSCRIPTION = 'afterCancelSubscription'; - - /** - * @event SubscriptionEvent The event that is triggered before a subscription gets updated. Typically this event is fired when subscription data is updated on the gateway. - * - * ```php - * use craft\commerce\events\SubscriptionEvent; - * use craft\commerce\services\Subscriptions; - * use craft\commerce\elements\Subscription; - * use yii\base\Event; - * - * Event::on( - * Subscriptions::class, - * Subscriptions::EVENT_BEFORE_UPDATE_SUBSCRIPTION, - * function(SubscriptionEvent $event) { - * // @var Subscription $subscription - * $subscription = $event->subscription; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_UPDATE_SUBSCRIPTION = 'beforeUpdateSubscription'; - - /** - * @event SubscriptionPaymentEvent The event that is triggered when a subscription payment is received. - * - * ```php - * use craft\commerce\events\SubscriptionPaymentEvent; - * use craft\commerce\services\Subscriptions; - * use craft\commerce\elements\Subscription; - * use craft\commerce\models\subscriptions\SubscriptionPayment; - * use DateTime; - * use yii\base\Event; - * - * Event::on( - * Subscriptions::class, - * Subscriptions::EVENT_RECEIVE_SUBSCRIPTION_PAYMENT, - * function(SubscriptionPaymentEvent $event) { - * // @var Subscription $subscription - * $subscription = $event->subscription; - * // @var SubscriptionPayment $payment - * $payment = $event->payment; - * // @var DateTime $until - * $until = $event->paidUntil; - * - * // Update loyalty reward data - * // ... - * } - * ); - * ``` - */ - public const EVENT_RECEIVE_SUBSCRIPTION_PAYMENT = 'receiveSubscriptionPayment'; - - public const CONFIG_FIELDLAYOUT_KEY = 'commerce.subscriptions.fieldLayouts'; - - - /** - * Handle field layout change - * - * @throws Exception - */ - public function handleChangedFieldLayout(ConfigEvent $event): void - { - $data = $event->newValue; - - ProjectConfigHelper::ensureAllFieldsProcessed(); - $fieldsService = Craft::$app->getFields(); - - if (empty($data) || empty(reset($data))) { - // Delete the field layout - $fieldsService->deleteLayoutsByType(Subscription::class); - return; - } - - // Save the field layout - $layout = FieldLayout::createFromConfig(reset($data)); - $layout->id = $fieldsService->getLayoutByType(Subscription::class)->id; - $layout->type = Subscription::class; - $layout->uid = key($data); - $fieldsService->saveLayout($layout, false); - } - - /** - * @deprecated in 3.4.17. Unused fields will be pruned automatically as field layouts are resaved. - */ - public function pruneDeletedField(): void - { - } - - /** - * Handle field layout being deleted - */ - public function handleDeletedFieldLayout(): void - { - Craft::$app->getFields()->deleteLayoutsByType(Subscription::class); - } - - /** - * Prevent deleting a user if they have any subscriptions - active or otherwise. - * - * @param DefineElementDeletionBlockersEvent $event the event. - */ - public function beforeDeleteUserHandler(DefineElementDeletionBlockersEvent $event): void - { - /** @var ElementCollection $subscriptions */ - $subscriptions = Subscription::find() - ->userId($event->elements->ids()->all()) - ->status(null) - ->limit(null) - ->collect(); - - foreach ($subscriptions->groupBy(fn(Subscription $subscription) => (string)($subscription->gatewayId ?? 0)) as $gatewaySubscriptions) { - /** @var Subscription $first */ - $first = $gatewaySubscriptions->first(); - $gateway = $first->getGateway(); - - if (!$gateway instanceof SubscriptionGatewayInterface) { - continue; - } - - $event->blockers[] = new SubscriptionCustomersDeletionBlocker( - $event->elements, - $event->hardDelete, - [ - 'gatewayId' => $first->gatewayId, - 'subscriptions' => $gatewaySubscriptions, - ] - ); - } - } - - /** - * Expire a subscription. - * - * @param Subscription $subscription subscription to expire - * @param DateTime|null $dateTime expiry date time - * @return bool whether successfully expired subscription - * @throws ElementNotFoundException - * @throws Exception - * @throws Throwable if cannot expire subscription - */ - public function expireSubscription(Subscription $subscription, DateTime $dateTime = null): bool - { - $subscription->isExpired = true; - $subscription->dateExpired = $dateTime; - - if (!$subscription->dateExpired) { - $subscription->dateExpired = DateTimeHelper::toDateTime('now'); - } - - Craft::$app->getElements()->saveElement($subscription, false); - - // fire an 'expireSubscription' event - if ($this->hasEventHandlers(self::EVENT_AFTER_EXPIRE_SUBSCRIPTION)) { - $this->trigger(self::EVENT_AFTER_EXPIRE_SUBSCRIPTION, new SubscriptionEvent([ - 'subscription' => $subscription, - ])); - } - - return true; - } - - /** - * Returns subscription count for a plan. - */ - public function getSubscriptionCountByPlanId(int $planId): int - { - return SubscriptionRecord::find()->where(['planId' => $planId])->count(); - } - - /** - * Returns subscription count for a plan. - * - * @deprecated in 4.0. Use [[getSubscriptionCountByPlanId]] instead. - */ - public function getSubscriptionCountForPlanById(int $planId): int - { - return $this->getSubscriptionCountByPlanId($planId); - } - - /** - * Return true if the user has any subscriptions at all, even expired ones. - */ - public function doesUserHaveSubscriptions(int $userId): bool - { - return (bool)SubscriptionRecord::find()->where(['userId' => $userId])->count(); - } - - /** - * Return true if the user has any subscriptions at all, even expired ones. - * - * @deprecated in 4.0. Use [[doesUserHaveSubscriptions]] instead. - */ - public function doesUserHaveAnySubscriptions(int $userId): bool - { - return $this->doesUserHaveSubscriptions($userId); - } - - /** - * Subscribe a user to a subscription plan. - * - * @param User $user the user subscribing to a plan - * @param Plan $plan the plan the user is being subscribed to - * @param SubscriptionForm $parameters array of additional parameters to use - * @param array $fieldValues array of content field values to set - * @return Subscription the subscription - * @throws ElementNotFoundException - * @throws Exception - * @throws InvalidConfigException if the gateway does not support subscriptions - * @throws SubscriptionException if something went wrong during subscription - * @throws Throwable - */ - public function createSubscription(User $user, Plan $plan, SubscriptionForm $parameters, array $fieldValues = []): Subscription - { - $gateway = $plan->getGateway(); - - // fire a 'beforeCreateSubscription' event - $event = new CreateSubscriptionEvent(compact('user', 'plan', 'parameters')); - $this->trigger(self::EVENT_BEFORE_CREATE_SUBSCRIPTION, $event); - - if (!$event->isValid) { - $error = Craft::t('commerce', 'Subscription for {user} to {plan} prevented by a plugin.', [ - 'user' => $user->getFriendlyName(), - 'plan' => (string)$plan, - ]); - - Craft::error($error, __METHOD__); - - throw new SubscriptionException(Craft::t('commerce', 'Unable to subscribe at this time.')); - } - - $response = $gateway->subscribe($user, $plan, $event->parameters); - - $failedToStart = $response->isInactive(); - - $subscription = new Subscription(); - $subscription->userId = $user->id; - $subscription->planId = $plan->id; - $subscription->gatewayId = $plan->gatewayId; - $subscription->orderId = null; - $subscription->reference = $response->getReference(); - $subscription->trialDays = $response->getTrialDays(); - $subscription->nextPaymentDate = $response->getNextPaymentDate(); - $subscription->subscriptionData = $response->getData(); - $subscription->isCanceled = false; - $subscription->isExpired = false; - $subscription->hasStarted = !$failedToStart; - $subscription->isSuspended = $failedToStart; - - if ($failedToStart) { - $subscription->dateSuspended = DateTimeHelper::toDateTime('now'); - } - - $subscription->setFieldValues($fieldValues); - - Craft::$app->getElements()->saveElement($subscription, false); - - // Fire an 'afterCreateSubscription' event. - if ($this->hasEventHandlers(self::EVENT_AFTER_CREATE_SUBSCRIPTION)) { - $this->trigger(self::EVENT_AFTER_CREATE_SUBSCRIPTION, new SubscriptionEvent([ - 'subscription' => $subscription, - ])); - } - - return $subscription; - } - - /** - * Reactivate a subscription. - * - * @throws InvalidConfigException if the gateway does not support subscriptions - * @throws Throwable - * @throws ElementNotFoundException - * @throws Exception - */ - public function reactivateSubscription(Subscription $subscription): bool - { - $gateway = $subscription->getGateway(); - - if (!$gateway instanceof SubscriptionGatewayInterface) { - throw new InvalidConfigException('Gateway does not support subscriptions.'); - } - - // fire a 'beforeReactivateSubscription' event - $event = new SubscriptionEvent([ - 'subscription' => $subscription, - ]); - $this->trigger(self::EVENT_BEFORE_REACTIVATE_SUBSCRIPTION, $event); - - if (!$event->isValid) { - $error = Craft::t('commerce', 'Could not reactivate “{reference}”.', [ - 'reference' => $subscription->reference, - ]); - - Craft::error($error, __METHOD__); - - return false; - } - - $response = $gateway->reactivateSubscription($subscription); - - if (!$response->isScheduledForCancellation()) { - $subscription->isCanceled = false; - $subscription->dateCanceled = null; - $subscription->subscriptionData = $response->getData(); - - Craft::$app->getElements()->saveElement($subscription, false); - - // Fire a 'afterReactivateSubscription' event. - if ($this->hasEventHandlers(self::EVENT_AFTER_REACTIVATE_SUBSCRIPTION)) { - $this->trigger(self::EVENT_AFTER_REACTIVATE_SUBSCRIPTION, new SubscriptionEvent([ - 'subscription' => $subscription, - ])); - } - - return true; - } - - return false; - } - - /** - * Switch a subscription to a different subscription plan. - * - * @param Subscription $subscription the subscription to modify - * @param Plan $plan the plan to change the subscription to - * @param SwitchPlansForm $parameters additional parameters to use - * @throws ElementNotFoundException - * @throws Exception - * @throws InvalidConfigException - * @throws Throwable - */ - public function switchSubscriptionPlan(Subscription $subscription, Plan $plan, SwitchPlansForm $parameters): bool - { - $gateway = $subscription->getGateway(); - - if (!$gateway instanceof SubscriptionGatewayInterface) { - throw new InvalidConfigException('Gateway does not support subscriptions.'); - } - - $oldPlan = $subscription->getPlan(); - - if (!$plan->canSwitchFrom($oldPlan)) { - throw new InvalidConfigException('The migration between these plans is not allowed.'); - } - - // fire a 'beforeSwitchSubscriptionPlan' event - $event = new SubscriptionSwitchPlansEvent([ - 'oldPlan' => $oldPlan, - 'subscription' => $subscription, - 'newPlan' => $plan, - 'parameters' => $parameters, - ]); - $this->trigger(self::EVENT_BEFORE_SWITCH_SUBSCRIPTION_PLAN, $event); - - if (!$event->isValid) { - $error = Craft::t('commerce', 'Could not switch “{reference}” to “{plan}”.', [ - 'reference' => $subscription->reference, - 'plan' => $plan->reference, - ]); - - Craft::error($error, __METHOD__); - - return false; - } - - $response = $gateway->switchSubscriptionPlan($subscription, $plan, $parameters); - - $subscription->planId = $plan->id; - $subscription->nextPaymentDate = $response->getNextPaymentDate(); - $subscription->subscriptionData = $response->getData(); - $subscription->isCanceled = false; - $subscription->isExpired = false; - - Craft::$app->getElements()->saveElement($subscription); - - // fire an 'afterSwitchSubscriptionPlan' event - if ($this->hasEventHandlers(self::EVENT_AFTER_SWITCH_SUBSCRIPTION_PLAN)) { - $this->trigger(self::EVENT_AFTER_SWITCH_SUBSCRIPTION_PLAN, new SubscriptionSwitchPlansEvent([ - 'oldPlan' => $oldPlan, - 'subscription' => $subscription, - 'newPlan' => $plan, - 'parameters' => $parameters, - ])); - } - - return true; - } - - /** - * Cancel a subscription. - * - * @throws InvalidConfigException if the gateway does not support subscriptions - * @throws SubscriptionException if something went wrong when canceling subscription - */ - public function cancelSubscription(Subscription $subscription, CancelSubscriptionForm $parameters): bool - { - $gateway = $subscription->getGateway(); - - if (!$gateway instanceof SubscriptionGatewayInterface) { - throw new InvalidConfigException('Gateway does not support subscriptions.'); - } - - // fire a 'beforeCancelSubscription' event - $event = new CancelSubscriptionEvent(compact('subscription', 'parameters')); - $this->trigger(self::EVENT_BEFORE_CANCEL_SUBSCRIPTION, $event); - - if (!$event->isValid) { - $error = Craft::t('commerce', 'Could not cancel “{reference}”.', [ - 'reference' => $subscription->reference, - ]); - - Craft::error($error, __METHOD__); - - return false; - } - - $response = $gateway->cancelSubscription($subscription, $parameters); - - if ($response->isCanceled() || $response->isScheduledForCancellation()) { - if ($response->isScheduledForCancellation()) { - $subscription->isCanceled = true; - $subscription->dateCanceled = DateTimeHelper::toDateTime('now'); - } - - if ($response->isCanceled()) { - $subscription->isExpired = true; - $subscription->isCanceled = true; - $subscription->dateCanceled = DateTimeHelper::toDateTime('now'); - $subscription->dateExpired = DateTimeHelper::toDateTime('now'); - } - - $subscription->setSubscriptionData($response->getData()); - - try { - Craft::$app->getElements()->saveElement($subscription, false); - - // fire an 'afterCancelSubscription' event - if ($this->hasEventHandlers(self::EVENT_AFTER_CANCEL_SUBSCRIPTION)) { - $this->trigger(self::EVENT_AFTER_CANCEL_SUBSCRIPTION, new CancelSubscriptionEvent(compact('subscription', 'parameters'))); - } - } catch (Throwable $exception) { - Craft::warning('Failed to cancel subscription ' . $subscription->reference . ': ' . $exception->getMessage()); - - throw new SubscriptionException(Craft::t('commerce', 'Unable to cancel subscription at this time.')); - } - } - - return true; - } - - /** - * Update a subscription. - * - * @throws Throwable - * @throws ElementNotFoundException - * @throws Exception - */ - public function updateSubscription(Subscription $subscription): bool - { - if ($this->hasEventHandlers(self::EVENT_BEFORE_UPDATE_SUBSCRIPTION)) { - $this->trigger(self::EVENT_BEFORE_UPDATE_SUBSCRIPTION, new SubscriptionEvent([ - 'subscription' => $subscription, - ])); - } - - return Craft::$app->getElements()->saveElement($subscription); - } - - /** - * Receive a payment for a subscription - * - * @throws Throwable - * @throws ElementNotFoundException - * @throws Exception - */ - public function receivePayment(Subscription $subscription, SubscriptionPayment $payment, DateTime $paidUntil): bool - { - if ($this->hasEventHandlers(self::EVENT_RECEIVE_SUBSCRIPTION_PAYMENT)) { - $this->trigger(self::EVENT_RECEIVE_SUBSCRIPTION_PAYMENT, new SubscriptionPaymentEvent(compact('subscription', 'payment', 'paidUntil'))); - } - - $subscription->nextPaymentDate = $paidUntil; - - return Craft::$app->getElements()->saveElement($subscription); - } -} diff --git a/src/services/TaxCategories.php b/src/services/TaxCategories.php deleted file mode 100644 index 3495f0921f..0000000000 --- a/src/services/TaxCategories.php +++ /dev/null @@ -1,309 +0,0 @@ - - * @since 2.0 - */ -class TaxCategories extends Component -{ - /** - * @var TaxCategory[]|null - */ - private ?array $_allTaxCategories = null; - - /** - * @var TaxCategory[]|null - */ - private ?array $_allTaxCategoriesWithTrashed = null; - - /** - * Returns all Tax Categories - * @param bool $withTrashed - * @return TaxCategory[] - */ - public function getAllTaxCategories(bool $withTrashed = false): array - { - if ($this->_allTaxCategories === null || $this->_allTaxCategoriesWithTrashed === null) { - $results = $this->_createTaxCategoryQuery(true)->all(); - - $this->_allTaxCategories = []; - foreach ($results as $result) { - $taxCategory = new TaxCategory($result); - - if (!$taxCategory->dateDeleted) { - $this->_allTaxCategories[] = $taxCategory; - } - $this->_allTaxCategoriesWithTrashed[] = $taxCategory; - } - } - - return $withTrashed ? $this->_allTaxCategoriesWithTrashed : $this->_allTaxCategories; - } - - /** - * Get a tax category by its ID. - */ - public function getTaxCategoryById(int $taxCategoryId): ?TaxCategory - { - $categories = $this->getAllTaxCategories(); - - return ArrayHelper::firstWhere($categories, 'id', $taxCategoryId); - } - - /** - * Get a tax category by its handle. - * - * @noinspection PhpUnused - */ - public function getTaxCategoryByHandle(string $taxCategoryHandle): ?TaxCategory - { - $categories = $this->getAllTaxCategories(); - - return ArrayHelper::firstWhere($categories, 'handle', $taxCategoryHandle); - } - - /** - * Returns all Tax category names, indexed by ID. - */ - public function getAllTaxCategoriesAsList(): array - { - $categories = $this->getAllTaxCategories(); - - return ArrayHelper::map($categories, 'id', 'uiLabel'); - } - - /** - * Get the default tax category - * - * @throws InvalidConfigException - */ - public function getDefaultTaxCategory(): TaxCategory - { - $categories = $this->getAllTaxCategories(); - - $default = ArrayHelper::firstWhere($categories, 'default', true); - - if (!$default) { - $default = ArrayHelper::firstValue($categories); - } - - if (!$default) { - throw new InvalidConfigException('Commerce must have at least one (default) tax category set up.'); - } - - return $default; - } - - /** - * Save a tax category. - * - * @param bool $runValidation should we validate this state before saving. - * @throws Exception - * @throws \Exception - */ - public function saveTaxCategory(TaxCategory $taxCategory, bool $runValidation = true): bool - { - if ($taxCategory->id) { - $record = TaxCategoryRecord::findOne($taxCategory->id); - - if (!$record) { - throw new Exception(Craft::t('commerce', 'No tax category exists with the ID “{id}”', - ['id' => $taxCategory->id])); - } - } else { - $record = new TaxCategoryRecord(); - } - - if ($runValidation && !$taxCategory->validate()) { - Craft::info('Tax category not saved due to validation error.', __METHOD__); - - return false; - } - - $record->name = $taxCategory->name; - $record->handle = $taxCategory->handle; - $record->description = $taxCategory->description; - $record->icon = $taxCategory->icon; - $record->color = $taxCategory->color; - $record->default = $taxCategory->default; - - // Save it! - $record->save(false); - - // Now that we have a record ID, save it on the model - $taxCategory->id = $record->id; - - // If this was the default make all others not the default. - if ($taxCategory->default) { - TaxCategoryRecord::updateAll(['default' => false], ['not', ['id' => $record->id]]); - } - - // Product type IDs this tax category is available to - $currentProductTypeIds = (new Query()) - ->select(['productTypeId']) - ->from([Table::PRODUCTTYPES_TAXCATEGORIES]) - ->where(['taxCategoryId' => $taxCategory->id]) - ->column(); - - // Newly set product types this tax category is available to - $newProductTypeIds = ArrayHelper::getColumn($taxCategory->getProductTypes(), 'id'); - - foreach ($currentProductTypeIds as $oldProductTypeId) { - // If we are removing a product type for this tax category the products of that type should be re-saved - if (!in_array($oldProductTypeId, $newProductTypeIds, false)) { - // Re-save all products that no longer have this tax category available to them - $this->_resaveProductsByProductTypeId($oldProductTypeId); - } - } - - foreach ($newProductTypeIds as $newProductTypeId) { - // If we are adding a product type for this tax category the products of that type should be re-saved - if (!in_array($newProductTypeId, $currentProductTypeIds, false)) { - // Re-save all products when assigning this tax category available to them - $this->_resaveProductsByProductTypeId($newProductTypeId); - } - } - - // Remove existing Categories <-> ProductType relationships - Craft::$app->getDb()->createCommand()->delete(Table::PRODUCTTYPES_TAXCATEGORIES, ['taxCategoryId' => $record->id])->execute(); - - foreach ($taxCategory->getProductTypes() as $productType) { - $data = ['productTypeId' => (int)$productType->id, 'taxCategoryId' => $taxCategory->id]; - Craft::$app->getDb()->createCommand()->insert(Table::PRODUCTTYPES_TAXCATEGORIES, $data)->execute(); - } - - // Clear Service cache - $this->_allTaxCategories = null; - - return true; - } - - /** - * Re-save products by product type id - */ - private function _resaveProductsByProductTypeId(int $productTypeId): void - { - Craft::$app->getQueue()->push(new ResaveElements([ - 'elementType' => Product::class, - 'criteria' => [ - 'typeId' => $productTypeId, - 'siteId' => '*', - 'unique' => true, - 'status' => null, - ], - ])); - } - - /** - * @param int $id - * @return bool - * @throws StaleObjectException - */ - public function deleteTaxCategoryById(int $id): bool - { - /** @var TaxCategoryRecord|SoftDeleteBehavior|null $taxCategory */ - $taxCategory = TaxCategoryRecord::findOne($id); - - if ($taxCategory === null || $taxCategory->default) { - return false; - } - - if ($taxCategory->softDelete()) { - $this->_allTaxCategories = null; - return true; - } - - return false; - } - - /** - * @param int $productTypeId - * @return array - */ - public function getTaxCategoriesByProductTypeId(int $productTypeId): array - { - $rows = $this->_createTaxCategoryQuery() - ->innerJoin(Table::PRODUCTTYPES_TAXCATEGORIES . ' productTypeTaxCategories', '[[taxCategories.id]] = [[productTypeTaxCategories.taxCategoryId]]') - ->andWhere(['productTypeTaxCategories.productTypeId' => $productTypeId]) - ->all(); - - if (empty($rows)) { - try { - $taxCategory = $this->getDefaultTaxCategory(); - } catch (InvalidConfigException) { - return []; - } - - return [$taxCategory->id => $taxCategory]; - } - - $taxCategories = []; - - foreach ($rows as $row) { - $key = $row['id']; - $taxCategories[$key] = new TaxCategory($row); - } - - return $taxCategories; - } - - /** - * Returns a Query object prepped for retrieving tax categories. - */ - private function _createTaxCategoryQuery(bool $withTrashed = false): Query - { - $query = (new Query()) - ->select([ - 'taxCategories.dateCreated', - 'taxCategories.dateDeleted', - 'taxCategories.dateUpdated', - 'taxCategories.default', - 'taxCategories.description', - 'taxCategories.handle', - 'taxCategories.id', - 'taxCategories.name', - ]) - ->from([Table::TAXCATEGORIES . ' taxCategories']); - - // Only add icon and color if the columns exist (for pre-migration compatibility) - $db = Craft::$app->getDb(); - $schema = $db->getSchema(); - $tableSchema = $schema->getTableSchema(Table::TAXCATEGORIES); - - if ($tableSchema && $tableSchema->getColumn('icon') !== null) { - $query->addSelect(['taxCategories.icon', 'taxCategories.color']); - } - - if (!$withTrashed) { - $query->where(['dateDeleted' => null]); - } - - return $query; - } -} diff --git a/src/services/TaxRates.php b/src/services/TaxRates.php deleted file mode 100644 index 75458289ae..0000000000 --- a/src/services/TaxRates.php +++ /dev/null @@ -1,248 +0,0 @@ - - * @since 2.0 - */ -class TaxRates extends Component -{ - /** - * @var Collection[]|null - */ - private ?array $_allTaxRates = null; - - /** - * Returns an array of all existing tax rates. - * - * @param int|null $storeId - * @return Collection - * @throws StoreNotFoundException - * @throws InvalidConfigException - */ - public function getAllTaxRates(?int $storeId = null): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if ($this->_allTaxRates === null || !isset($this->_allTaxRates[$storeId])) { - $results = $this->_createTaxRatesQuery() - ->where(['storeId' => $storeId]) - ->all(); - - if ($this->_allTaxRates === null) { - $this->_allTaxRates = []; - } - - foreach ($results as $result) { - $taxRate = Craft::createObject([ - 'class' => TaxRate::class, - 'attributes' => $result, - ]); - - if (!isset($this->_allTaxRates[$taxRate->storeId])) { - $this->_allTaxRates[$taxRate->storeId] = collect(); - } - - $this->_allTaxRates[$taxRate->storeId]->push($taxRate); - } - } - - return $this->_allTaxRates[$storeId] ?? collect(); - } - - /** - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - * @throws StoreNotFoundException - * @since 5.3.0 - */ - public function getAllEnabledTaxRates(?int $storeId = null): Collection - { - return $this->getAllTaxRates($storeId)->where('enabled', true); - } - - /** - * Returns an array of all rates belonging to the specified zone. - * - * @param int $taxZoneId The ID of the tax zone whose rates we’d like returned - * @param int|null $storeId - * @return Collection - * @throws InvalidConfigException - * @throws StoreNotFoundException - */ - public function getTaxRatesByTaxZoneId(int $taxZoneId, ?int $storeId = null): Collection - { - return $this->getAllTaxRates($storeId)->where('taxZoneId', $taxZoneId); - } - - /** - * Returns a tax rate by ID. - * - * @param int $id The ID of the desired tax rate - * @param int|null $storeId - * @return ?TaxRate - * @throws InvalidConfigException - * @throws StoreNotFoundException - */ - public function getTaxRateById(int $id, ?int $storeId = null): ?TaxRate - { - return $this->getAllTaxRates($storeId)->firstWhere('id', $id); - } - - /** - * Saves a tax rate. - * - * @param TaxRate $model The tax rate model to be saved - * @param bool $runValidation Whether we should validate this rate before saving - * @return bool - * @throws Exception - * @throws \Exception - */ - public function saveTaxRate(TaxRate $model, bool $runValidation = true): bool - { - if ($model->id) { - $record = TaxRateRecord::findOne($model->id); - - if (!$record) { - throw new Exception(Craft::t('commerce', 'No tax rate exists with the ID “{id}”', - ['id' => $model->id])); - } - } else { - $record = new TaxRateRecord(); - } - - if ($runValidation && !$model->validate()) { - Craft::info('Tax rate not saved due to validation error.', __METHOD__); - - return false; - } - - $record->name = $model->name; - $record->code = $model->code; - $record->rate = $model->rate; - $record->storeId = $model->storeId; - - // if not an included tax, then can not be removed. - $record->include = $model->include; - $record->isVat = $model->hasTaxIdValidators(); - $record->removeIncluded = !$record->include ? false : $model->removeIncluded; - $record->removeVatIncluded = (!$record->include || !$record->isVat) ? false : $model->removeVatIncluded; - $record->taxable = $model->taxable; - $record->taxCategoryId = $model->taxCategoryId; - $record->taxZoneId = $model->taxZoneId ?: null; - $record->isEverywhere = $model->getIsEverywhere(); - $record->enabled = $model->enabled; - $record->taxIdValidators = $model->taxIdValidators; - - if (!$record->isEverywhere && $record->taxZoneId && empty($record->getErrors('taxZoneId'))) { - $taxZone = Plugin::getInstance()->getTaxZones()->getTaxZoneById($record->taxZoneId, $record->storeId); - - if (!$taxZone) { - throw new Exception(Craft::t('commerce', 'No tax zone exists with the ID “{id}”', ['id' => $record->taxZoneId])); - } - - if ($record->removeIncluded && !$taxZone->default) { - $model->addError('removeIncluded', Craft::t('commerce', 'Removable included tax rates are only allowed for the default tax zone.')); - - return false; - } - } - - // Save it! - $record->save(false); - - // Now that we have a record ID, save it on the model - $model->id = $record->id; - $this->clearCache(); - - return true; - } - - /** - * Deletes a tax rate by ID. - * - * @throws Throwable - * @throws StaleObjectException - */ - public function deleteTaxRateById(int $id): bool - { - $record = TaxRateRecord::findOne($id); - - if ($record) { - $this->clearCache(); - return (bool)$record->delete(); - } - - return false; - } - - /** - * Returns a Query object prepped for retrieving tax rates - */ - private function _createTaxRatesQuery(): Query - { - $query = (new Query()) - ->select([ - 'code', - 'dateCreated', - 'dateUpdated', - 'id', - 'include', - 'name', - 'rate', - 'removeIncluded', - 'removeVatIncluded', - 'storeId', - 'taxable', - 'taxCategoryId', - 'taxZoneId', - ]) - ->orderBy(['include' => SORT_DESC, 'isVat' => SORT_DESC]) - ->from([Table::TAXRATES]); - - // if enabled column exists add the select - if (Craft::$app->getDb()->columnExists(Table::TAXRATES, 'enabled')) { - $query->addSelect(['enabled']); - } - - // add taxIdValidators select - if (Craft::$app->getDb()->columnExists(Table::TAXRATES, 'taxIdValidators')) { - $query->addSelect(['taxIdValidators']); - } - - return $query; - } - - /** - * @return void - * @since 5.0.0 - */ - protected function clearCache(): void - { - $this->_allTaxRates = null; - } -} diff --git a/src/services/TaxZones.php b/src/services/TaxZones.php deleted file mode 100644 index 58c62bdad2..0000000000 --- a/src/services/TaxZones.php +++ /dev/null @@ -1,181 +0,0 @@ - - * @since 2.0 - */ -class TaxZones extends Component -{ - /** - * @var Collection[] - */ - private ?array $_allZones = null; - - /** - * Get all tax zones. - * - * @param int|null $storeId - * @return Collection - * @throws StoreNotFoundException - * @throws InvalidConfigException - */ - public function getAllTaxZones(?int $storeId = null): Collection - { - $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; - - if ($this->_allZones === null || !isset($this->_allZones[$storeId])) { - $results = $this->_createQuery() - ->where(['storeId' => $storeId]) - ->all(); - - if ($this->_allZones === null) { - $this->_allZones = []; - } - - foreach ($results as $result) { - $taxRate = Craft::createObject([ - 'class' => TaxAddressZone::class, - 'attributes' => $result, - ]); - - if (!isset($this->_allZones[$taxRate->storeId])) { - $this->_allZones[$taxRate->storeId] = collect(); - } - - $this->_allZones[$taxRate->storeId]->push($taxRate); - } - } - - return $this->_allZones[$storeId] ?? collect(); - } - - /** - * Get a tax zone by its ID. - */ - public function getTaxZoneById(int $id, ?int $storeId = null): ?TaxAddressZone - { - return $this->getAllTaxZones($storeId)->firstWhere('id', $id); - } - - /** - * Save a tax zone. - * - * @param bool $runValidation should we validate this zone before saving - * @throws \Exception - * @throws Exception - */ - public function saveTaxZone(TaxAddressZone $model, bool $runValidation = true): bool - { - if ($model->id) { - $record = TaxZoneRecord::findOne($model->id); - - if (!$record) { - throw new Exception(Craft::t('commerce', 'No tax zone exists with the ID “{id}”', ['id' => $model->id])); - } - } else { - $record = new TaxZoneRecord(); - } - - if ($runValidation && !$model->validate()) { - Craft::info('Tax zone not saved due to validation error.', __METHOD__); - - return false; - } - - //setting attributes - $record->storeId = $model->storeId; - $record->name = $model->name; - $record->description = $model->description; - $record->default = $model->default; - $record->condition = $model->getCondition()->getConfig(); - - $record->save(); - - $model->id = $record->id; - - // If this was the default make all others not the default. - if ($model->default) { - TaxZoneRecord::updateAll( - ['default' => false], - ['and', ['not', ['id' => $model->id]], ['storeId' => $model->storeId]]); - } - - $this->_clearCaches(); - - return true; - } - - /** - * @throws Throwable - * @throws StaleObjectException - */ - public function deleteTaxZoneById(int $id): bool - { - $record = TaxZoneRecord::findOne($id); - - if ($record) { - $result = (bool)$record->delete(); - if ($result) { - $this->_clearCaches(); - } - - return $result; - } - - return false; - } - - /** - * Returns a Query object prepped for retrieving tax zones. - */ - private function _createQuery(): Query - { - return (new Query()) - ->select([ - 'condition', - 'dateCreated', - 'dateUpdated', - 'default', - 'description', - 'id', - 'name', - 'storeId', - ]) - ->orderBy('name') - ->from([Table::TAXZONES]); - } - - /** - * Clear memoization. - * - * @since 3.2.5 - */ - private function _clearCaches(): void - { - $this->_allZones = []; - } -} diff --git a/src/services/Taxes.php b/src/services/Taxes.php deleted file mode 100644 index 74211ccb52..0000000000 --- a/src/services/Taxes.php +++ /dev/null @@ -1,276 +0,0 @@ -validators[] = new MyTaxIdValidator(); - * } - * ); - * ``` - */ - public const EVENT_REGISTER_TAX_ID_VALIDATORS = 'registerTaxIdValidators'; - - /** - * @event TaxEngineEvent The event that is triggered when determining the tax engine. - * @since 3.1 - * - * ```php - * use craft\commerce\base\TaxEngineInterface; - * use craft\commerce\engines\Tax; - * use craft\commerce\events\TaxEngineEvent; - * use craft\commerce\services\Taxes; - * use yii\base\Event; - * - * Event::on( - * Taxes::class, - * Taxes::EVENT_REGISTER_TAX_ENGINE, - * function(TaxEngineEvent $event) { - * // @var TaxEngineInterface $currentEngine - * $currentEngine = $event->engine; - * - * // Set a new tax engine on `$event->engine` - * // ... - * } - * ); - * ``` - */ - public const EVENT_REGISTER_TAX_ENGINE = 'registerTaxEngine'; - - /** - * @var ?TaxEngineInterface $engine The tax engine - */ - private ?TaxEngineInterface $_taxEngine = null; - - /** - * @return Collection - * @throws InvalidConfigException - * @since 5.3.0 - */ - public function getTaxIdValidators(): Collection - { - $validators = []; - $validators[] = new EuVatIdValidator(); - - $event = new TaxIdValidatorsEvent([ - 'validators' => $validators, - ]); - - if ($this->hasEventHandlers(self::EVENT_REGISTER_TAX_ID_VALIDATORS)) { - $this->trigger(self::EVENT_REGISTER_TAX_ID_VALIDATORS, $event); - } - - foreach ($event->validators as $validator) { - if (!$validator instanceof TaxIdValidatorInterface) { - throw new InvalidConfigException('Tax ID validator must implement TaxIdValidatorInterface'); - } - } - - return collect($event->validators); - } - - /** - * @return Collection - * @throws InvalidConfigException - */ - public function getEnabledTaxIdValidators(): Collection - { - return $this->getTaxIdValidators()->filter(fn(TaxIdValidatorInterface $validator) => $validator::isEnabled()); - } - - /** - * Get the current tax engine. - */ - public function getEngine(): TaxEngineInterface - { - if ($this->_taxEngine !== null) { - return $this->_taxEngine; - } - - $event = new TaxEngineEvent(['engine' => new Tax()]); - - if ($this->hasEventHandlers(self::EVENT_REGISTER_TAX_ENGINE)) { - $this->trigger(self::EVENT_REGISTER_TAX_ENGINE, $event); - } - - // Give plugins a chance to register the tax engine - if (!$event->engine instanceof TaxEngineInterface) { - throw new InvalidConfigException('No tax engine has been registered.'); - } - - $this->_taxEngine = $event->engine; - - return $this->_taxEngine; - } - - /** - * @inheritDoc - */ - public function taxAdjusterClass(): string - { - return $this->getEngine()->taxAdjusterClass(); - } - - /** - * @inheritDoc - */ - public function viewTaxCategories(): bool - { - return $this->getEngine()->viewTaxCategories(); - } - - /** - * @inheritDoc - */ - public function createTaxCategories(): bool - { - return $this->getEngine()->createTaxCategories(); - } - - /** - * @inheritDoc - */ - public function editTaxCategories(): bool - { - return $this->getEngine()->editTaxCategories(); - } - - /** - * @inheritDoc - */ - public function deleteTaxCategories(): bool - { - return $this->getEngine()->deleteTaxCategories(); - } - - /** - * @inheritDoc - */ - public function taxCategoryActionHtml(): string - { - return $this->getEngine()->taxCategoryActionHtml(); - } - - /** - * @inheritDoc - */ - public function viewTaxZones(): bool - { - return $this->getEngine()->viewTaxZones(); - } - - /** - * @inheritDoc - */ - public function editTaxZones(): bool - { - return $this->getEngine()->editTaxZones(); - } - - /** - * @inheritDoc - */ - public function viewTaxRates(): bool - { - return $this->getEngine()->viewTaxRates(); - } - - /** - * @inheritDoc - */ - public function editTaxRates(): bool - { - return $this->getEngine()->editTaxRates(); - } - - /** - * @inheritDoc - */ - public function cpTaxNavSubItems(): array - { - return $this->getEngine()->cpTaxNavSubItems(); - } - - /** - * @inheritDoc - */ - public function createTaxZones(): bool - { - return $this->getEngine()->createTaxZones(); - } - - /** - * @inheritDoc - */ - public function deleteTaxZones(): bool - { - return $this->getEngine()->deleteTaxZones(); - } - - /** - * @inheritDoc - */ - public function taxZoneActionHtml(): string - { - return $this->getEngine()->taxZoneActionHtml(); - } - - /** - * @inheritDoc - */ - public function createTaxRates(): bool - { - return $this->getEngine()->createTaxRates(); - } - - /** - * @inheritDoc - */ - public function deleteTaxRates(): bool - { - return $this->getEngine()->deleteTaxRates(); - } - - /** - * @inheritDoc - */ - public function taxRateActionHtml(): string - { - return $this->getEngine()->taxRateActionHtml(); - } -} diff --git a/src/services/Transactions.php b/src/services/Transactions.php deleted file mode 100644 index f994771c60..0000000000 --- a/src/services/Transactions.php +++ /dev/null @@ -1,565 +0,0 @@ - - * @since 2.0 - */ -class Transactions extends Component -{ - /** - * @event TransactionEvent The event that is triggered after a transaction has been saved. - * - * ```php - * use craft\commerce\events\TransactionEvent; - * use craft\commerce\services\Transactions; - * use craft\commerce\models\Transaction; - * use yii\base\Event; - * - * Event::on( - * Transactions::class, - * Transactions::EVENT_AFTER_SAVE_TRANSACTION, - * function(TransactionEvent $event) { - * // @var Transaction $transaction - * $transaction = $event->transaction; - * - * // Run custom logic for failed transactions - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_SAVE_TRANSACTION = 'afterSaveTransaction'; - - /** - * @event TransactionEvent The event that is triggered after a transaction has been created. - * - * ```php - * use craft\commerce\events\TransactionEvent; - * use craft\commerce\services\Transactions; - * use craft\commerce\models\Transaction; - * use yii\base\Event; - * - * Event::on( - * Transactions::class, - * Transactions::EVENT_AFTER_CREATE_TRANSACTION, - * function(TransactionEvent $event) { - * // @var Transaction $transaction - * $transaction = $event->transaction; - * - * // Run custom logic depending on the transaction type - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_CREATE_TRANSACTION = 'afterCreateTransaction'; - - - /** - * Returns true if a specific transaction can be refunded. - * - * @param Transaction $transaction the transaction - */ - public function canCaptureTransaction(Transaction $transaction): bool - { - // Can only capture successful authorize transactions - if ($transaction->type !== TransactionRecord::TYPE_AUTHORIZE || $transaction->status !== TransactionRecord::STATUS_SUCCESS) { - return false; - } - - $gateway = $transaction->getGateway(); - - if (!$gateway) { - return false; - } - - if (!$gateway->supportsCapture()) { - return false; - } - - // And only if we don't have a successful refund transaction for this order already - return !$this->_createTransactionQuery() - ->where([ - 'type' => TransactionRecord::TYPE_CAPTURE, - 'status' => TransactionRecord::STATUS_SUCCESS, - 'orderId' => $transaction->orderId, - 'parentId' => $transaction->id, - ]) - ->exists(); - } - - /** - * Returns true if a specific transaction can be refunded. - * - * @param Transaction $transaction the transaction - */ - public function canRefundTransaction(Transaction $transaction): bool - { - // Can refund only successful purchase or capture transactions - if (!in_array($transaction->type, [TransactionRecord::TYPE_PURCHASE, TransactionRecord::TYPE_CAPTURE], true)) { - return false; - } - - if ($transaction->status !== TransactionRecord::STATUS_SUCCESS) { - return false; - } - - $gateway = $transaction->getGateway(); - - if (!$gateway) { - return false; - } - - if (!$gateway->supportsRefund()) { - return false; - } - - // Allow gateways to help determine if a transaction can be refunded - if (!$gateway->transactionSupportsRefund($transaction)) { - return false; - } - - return ($this->refundableAmountForTransaction($transaction) > 0); - } - - /** - * Return the refundable amount for a transaction. - */ - public function refundableAmountForTransaction(Transaction $transaction): float - { - // We need to use the payment currency to calculate the refundable amount - $teller = Plugin::getInstance()->getCurrencies()->getTeller($transaction->paymentCurrency); - - $amount = (new Query()) - ->where([ - 'type' => TransactionRecord::TYPE_REFUND, - 'status' => TransactionRecord::STATUS_SUCCESS, - 'orderId' => $transaction->orderId, - 'parentId' => $transaction->id, - ]) - ->from([Table::TRANSACTIONS]) - ->sum('[[paymentAmount]]'); - - return (float)$teller->subtract($transaction->paymentAmount, $amount); - } - - /** - * Create a transaction either from an order or a parent transaction. At least one must be present. - * - * @param Order|null $order Order that the transaction is a part of. Ignored, if `$parentTransaction` is specified. - * @param Transaction|null $parentTransaction Parent transaction, if this transaction is a child. Required, if `$order` is not specified. - * @param string|null $typeOverride The type of transaction. If set, this overrides the type of the parent transaction, or sets the type when no parentTransaction is passed. - * @throws TransactionException if neither `$order` or `$parentTransaction` is specified. - * @throws CurrencyException - * @throws InvalidConfigException - */ - public function createTransaction(Order $order = null, Transaction $parentTransaction = null, ?string $typeOverride = null): Transaction - { - if (!$order && !$parentTransaction) { - throw new TransactionException('Tried to create a transaction without order or parent transaction'); - } - - $transaction = new Transaction(); - $transaction->status = TransactionRecord::STATUS_PENDING; - - if ($parentTransaction) { - // Assume parent values instead of Order values. - $transaction->parentId = $parentTransaction->id; - $transaction->gatewayId = $parentTransaction->gatewayId; - $transaction->amount = $parentTransaction->amount; - $transaction->currency = $parentTransaction->currency; - $transaction->paymentAmount = $parentTransaction->paymentAmount; - $transaction->paymentCurrency = $parentTransaction->paymentCurrency; - $transaction->paymentRate = $parentTransaction->paymentRate; - $transaction->setOrder($parentTransaction->getOrder()); - $transaction->reference = $parentTransaction->reference; - $transaction->type = $parentTransaction->type; - } else { - $paymentCurrency = Plugin::getInstance()->getPaymentCurrencies()->getPaymentCurrencyByIso($order->paymentCurrency, $order->getStore()->id); - $currency = Plugin::getInstance()->getPaymentCurrencies()->getPaymentCurrencyByIso($order->currency, $order->getStore()->id); - - /** @var Gateway $gateway */ - $gateway = $order->getGateway(); - $transaction->gatewayId = $gateway->id; - - // Gets the outstanding balance, unless the order had a paymentAmount set in this request - $transaction->currency = $currency->iso; - $transaction->paymentCurrency = $paymentCurrency->iso; - - // Payment amount is the amount in the paymentCurrency - $transaction->paymentAmount = Currency::round($order->getPaymentAmount(), $paymentCurrency); - $amount = $transaction->paymentAmount; - - if ($currency->iso !== $paymentCurrency->iso) { - $tellerTo = Plugin::getInstance()->getCurrencies()->getTeller($paymentCurrency); - $paymentAmount = $tellerTo->convertToMoney($transaction->paymentAmount); - $amount = Plugin::getInstance()->getPaymentCurrencies()->convertAmount($paymentAmount, $currency, $order->getStore()->id); - $amount = (float)$tellerTo->convertToString($amount); - } - - // Amount is always in the base currency - $transaction->amount = $amount; - - $transaction->setOrder($order); - - // Capture historical rate - $transaction->paymentRate = Plugin::getInstance()->getPaymentCurrencies()->getRateFor($paymentCurrency, $transaction); - } - - $user = Craft::$app->getUser()->getIdentity(); - - if ($user) { - $transaction->userId = $user->id; - } - - if ($typeOverride) { - $transaction->type = $typeOverride; - } - - // Raise 'afterCreateTransaction' event - if ($this->hasEventHandlers(self::EVENT_AFTER_CREATE_TRANSACTION)) { - $this->trigger(self::EVENT_AFTER_CREATE_TRANSACTION, new TransactionEvent([ - 'transaction' => $transaction, - ])); - } - - return $transaction; - } - - /** - * Delete a transaction. - * - * @param Transaction $transaction the transaction to delete - * @throws Throwable - * @throws StaleObjectException - * @deprecated in 4.0. Use [[deleteTransactionById]] instead. - */ - public function deleteTransaction(Transaction $transaction): bool - { - $record = TransactionRecord::findOne($transaction->id); - - if ($record) { - return (bool)$record->delete(); - } - - return false; - } - - /** - * Delete a transaction by id. - * - * @param int $id the transaction ID - * @throws Throwable - * @throws StaleObjectException - */ - public function deleteTransactionById(int $id): bool - { - $record = TransactionRecord::findOne($id); - - if ($record) { - return (bool)$record->delete(); - } - - return false; - } - - /** - * @param int $orderId the order's ID - * @return array - * @noinspection PhpUnused - */ - public function getAllTopLevelTransactionsByOrderId(int $orderId): array - { - $transactions = $this->getAllTransactionsByOrderId($orderId); - - foreach ($transactions as $key => $transaction) { - // Remove transactions that have a parentId - if ($transaction->parentId) { - unset($transactions[$key]); - } - } - - return $transactions; - } - - /** - * Returns all transactions for an order, per the order's ID. - * - * @param int $orderId the order's ID - * @return Transaction[] - */ - public function getAllTransactionsByOrderId(int $orderId): array - { - $rows = $this->_createTransactionQuery() - ->where(['orderId' => $orderId]) - ->all(); - - $transactions = []; - - foreach ($rows as $row) { - $transactions[] = new Transaction($row); - } - - return $transactions; - } - - /** - * Get all children transactions, per a parent transaction's ID. - * - * @param int $transactionId the parent transaction's ID - */ - public function getChildrenByTransactionId(int $transactionId): array - { - $rows = $this->_createTransactionQuery() - ->where(['parentId' => $transactionId]) - ->all(); - - $transactions = []; - - foreach ($rows as $row) { - $transactions[] = new Transaction($row); - } - - return $transactions; - } - - /** - * Get a transaction by its hash. - * - * @param string $hash the hash of transaction - */ - public function getTransactionByHash(string $hash): ?Transaction - { - $result = $this->_createTransactionQuery() - ->where(['hash' => $hash]) - ->one(); - - return $result ? new Transaction($result) : null; - } - - /** - * Get a transaction by its reference and status. - * - * @param string $reference the transaction reference - * @param string $status the transaction status - */ - public function getTransactionByReferenceAndStatus(string $reference, string $status): ?Transaction - { - $result = $this->_createTransactionQuery() - ->where(compact('reference', 'status')) - ->one(); - - return $result ? new Transaction($result) : null; - } - - /** - * Get a transaction by its reference. - * - * @param string $reference the transaction reference - * @return Transaction|null - */ - public function getTransactionByReference(string $reference): ?Transaction - { - $result = $this->_createTransactionQuery() - ->where(compact('reference')) - ->one(); - - return $result ? new Transaction($result) : null; - } - - /** - * Get a transaction by its ID. - * - * @param int $id the ID of transaction - */ - public function getTransactionById(int $id): ?Transaction - { - $result = $this->_createTransactionQuery() - ->where(['id' => $id]) - ->one(); - - return $result ? new Transaction($result) : null; - } - - /** - * Returns true if a transaction or a direct child of the transaction is successful. - */ - public function isTransactionSuccessful(Transaction $transaction): bool - { - if ($transaction->status === TransactionRecord::STATUS_SUCCESS) { - return true; - } - - return $this->_createTransactionQuery() - ->where([ - 'parentId' => $transaction->id, - 'status' => TransactionRecord::STATUS_SUCCESS, - 'orderId' => $transaction->orderId, - ]) - ->exists(); - } - - /** - * Save a transaction. - * - * @param Transaction $model the transaction model - * @param bool $runValidation should we validate this transaction before saving. - * @throws Throwable - * @throws TransactionException if an attempt is made to modify an existing transaction - * @throws OrderStatusException - * @throws ElementNotFoundException - * @throws Exception - */ - public function saveTransaction(Transaction $model, bool $runValidation = true): bool - { - if ($model->id) { - throw new TransactionException('Transactions cannot be modified.'); - } - - if ($runValidation && !$model->validate()) { - Craft::info('Transaction not saved due to validation error.', __METHOD__); - - return false; - } - - $fields = [ - 'orderId', - 'hash', - 'gatewayId', - 'type', - 'status', - 'amount', - 'currency', - 'paymentAmount', - 'paymentCurrency', - 'paymentRate', - 'reference', - 'message', - 'note', - 'code', - 'response', - 'userId', - 'parentId', - ]; - - $record = new TransactionRecord(); - - foreach ($fields as $field) { - $record->$field = $model->$field; - } - - $record->save(false); - $model->id = $record->id; - - if ($model->status === TransactionRecord::STATUS_SUCCESS) { - $model->order->updateOrderPaidInformation(); - } - - if ($model->status === TransactionRecord::STATUS_PROCESSING) { - $model->order->markAsComplete(); - } - - $model->getOrder()->setTransactions(null); // clear the local cache of transactions from the order. - - // Raise 'afterSaveTransaction' event - if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_TRANSACTION)) { - $this->trigger(self::EVENT_AFTER_SAVE_TRANSACTION, new TransactionEvent([ - 'transaction' => $model, - ])); - } - - return true; - } - - /** - * @param array|Order[] $orders - * @return Order[] - * @since 3.2.0 - */ - public function eagerLoadTransactionsForOrders(array $orders): array - { - $orderIds = array_filter(ArrayHelper::getColumn($orders, 'id')); - $transactionResults = $this->_createTransactionQuery()->andWhere(['orderId' => $orderIds])->all(); - - $transactions = []; - - foreach ($transactionResults as $result) { - $transaction = new Transaction($result); - $transactions[$transaction->orderId] ??= []; - $transactions[$transaction->orderId][] = $transaction; - } - - foreach ($orders as $key => $order) { - if (isset($transactions[$order->id])) { - $order->setTransactions($transactions[$order->id]); - $orders[$key] = $order; - } - } - - return $orders; - } - - /** - * Returns a Query object prepped for retrieving Transactions. - * - * @return Query The query object. - */ - private function _createTransactionQuery(): Query - { - return (new Query()) - ->select([ - 'amount', - 'code', - 'currency', - 'dateCreated', - 'dateUpdated', - 'gatewayId', - 'hash', - 'id', - 'message', - 'note', - 'orderId', - 'parentId', - 'paymentAmount', - 'paymentCurrency', - 'paymentRate', - 'reference', - 'response', - 'status', - 'type', - 'userId', - ]) - ->from([Table::TRANSACTIONS]) - ->orderBy(['id' => SORT_ASC]); - } -} diff --git a/src/services/Transfers.php b/src/services/Transfers.php deleted file mode 100644 index 7bbe31063e..0000000000 --- a/src/services/Transfers.php +++ /dev/null @@ -1,127 +0,0 @@ -newValue; - - ProjectConfigHelper::ensureAllFieldsProcessed(); - $fieldsService = Craft::$app->getFields(); - - if (empty($data) || empty(reset($data))) { - // Delete the field layout - $fieldsService->deleteLayoutsByType(Transfer::class); - return; - } - - // Save the field layout - $layout = FieldLayout::createFromConfig(reset($data)); - $layout->id = $fieldsService->getLayoutByType(Transfer::class)->id; - $layout->type = Transfer::class; - $layout->uid = key($data); - $fieldsService->saveLayout($layout, false); - } - - /** - * Handle field layout being deleted - */ - public function handleDeletedFieldLayout(): void - { - Craft::$app->getFields()->deleteLayoutsByType(Transfer::class); - } - - /** - * @return FieldLayout - */ - public function getFieldLayout(): FieldLayout - { - $fieldLayout = Craft::$app->getFields()->getLayoutByType(Transfer::class); - - if (!$fieldLayout->isFieldIncluded('transfer-management')) { - $layoutTabs = $fieldLayout->getTabs(); - $transfersTabName = Craft::t('commerce', 'Manage'); - if (ArrayHelper::contains($layoutTabs, 'name', $transfersTabName)) { - $transfersTabName .= ' ' . StringHelper::randomString(10); - } - - $contentTab = new FieldLayoutTab(); - $contentTab->setLayout($fieldLayout); - $contentTab->name = $transfersTabName; - $contentTab->setElements([ - ['type' => TransferManagementField::class], - ]); - - $layoutTabs[] = $contentTab; - $fieldLayout->setTabs($layoutTabs); - } - - return $fieldLayout; - } - - /** - * @param int $transferId - * @return array - */ - public function getTransferDetailsByTransferId(int $transferId): array - { - $results = $this->_createTransferDetailsQuery() - ->where(['transferId' => $transferId]) - ->all(); - - $transferDetails = []; - - foreach ($results as $result) { - $transferDetails[] = new TransferDetail($result); - } - - return $transferDetails; - } - - /** - * @return Query - */ - private function _createTransferDetailsQuery(): Query - { - return (new Query()) - ->select([ - 'id', - 'transferId', - 'inventoryItemId', - 'inventoryItemDescription', - 'quantity', - 'quantityAccepted', - 'quantityRejected', - 'uid', - ]) - ->from([Table::TRANSFERDETAILS]); - } -} diff --git a/src/services/Variants.php b/src/services/Variants.php deleted file mode 100644 index 3136478288..0000000000 --- a/src/services/Variants.php +++ /dev/null @@ -1,96 +0,0 @@ - - * @since 2.0 - */ -class Variants extends Component -{ - /** - * @var array - * @since 3.1.4 - */ - private array $_contentFieldCache = []; - - /** - * Returns a product's variants, per the product's ID. - * - * @param int $productId product ID - * @param int|null $siteId Site ID for which to return the variants. Defaults to `null` which is current site. - * @return Variant[] - */ - public function getAllVariantsByProductId(int $productId, int $siteId = null, bool $includeDisabled = true): array - { - $variantQuery = Variant::find() - ->productId($productId) - ->limit(null) - ->siteId($siteId); - - if ($includeDisabled) { - $variantQuery->status(null); - } - - return $variantQuery->all(); - } - - /** - * Returns a variant by its ID. - * - * @param int $variantId The variant’s ID. - * @param int|null $siteId The site ID for which to fetch the variant. Defaults to `null` which is current site. - */ - public function getVariantById(int $variantId, int $siteId = null): ?Variant - { - return Craft::$app->getElements()->getElementById($variantId, Variant::class, $siteId); - } - - /** - * @throws InvalidConfigException - * @since 3.1.4 - */ - public function getVariantGqlContentArguments(): array - { - if (empty($this->_contentFieldCache)) { - $contentArguments = []; - - foreach (Plugin::getInstance()->getProductTypes()->getAllProductTypes() as $productType) { - if (!GqlCommerceHelper::isSchemaAwareOf(Variant::gqlScopesByContext($productType))) { - continue; - } - - $fieldLayout = $productType->getVariantFieldLayout(); - foreach ($fieldLayout->getCustomFields() as $contentField) { - if (!$contentField instanceof GqlInlineFragmentFieldInterface) { - $contentArguments[$contentField->handle] = [ - 'name' => $contentField->handle, - 'type' => Type::listOf(QueryArgument::getType()), - ]; - } - } - } - - $this->_contentFieldCache = $contentArguments; - } - - return $this->_contentFieldCache; - } -} diff --git a/src/services/Vat.php b/src/services/Vat.php deleted file mode 100644 index 5c24784555..0000000000 --- a/src/services/Vat.php +++ /dev/null @@ -1,82 +0,0 @@ - - * @since 5.0.0 - */ -class Vat extends Component -{ - /** - * @var string - */ - protected string $cacheKeyPrefix = 'commerce:validVatId:'; - - /** - * @var mixed Allows for the possibility of a custom validator - */ - protected mixed $validator; - - /** - * @param string $vatId - * @return bool - */ - public function isValidVatId(string $vatId): bool - { - // Do we have a valid VAT ID in our cache? - $validOrganizationTaxId = Craft::$app->getCache()->exists($this->cacheKeyPrefix . $vatId); - - // If we do not have a valid VAT ID in cache, see if we can get one from the API - if (!$validOrganizationTaxId) { - try { - $validators = Plugin::getInstance()->getTaxes()->getEnabledTaxIdValidators(); - foreach ($validators as $validator) { - if ($validator->validateFormat($vatId) && $validator->validate($vatId)) { - $validOrganizationTaxId = true; - break; - } - } - } catch (Exception $e) { - Craft::error('Communication with VAT API failed: ' . $e->getMessage(), __METHOD__); - - $validOrganizationTaxId = false; - } - } - - if (!$validOrganizationTaxId) { - // Clean up if the API returned false and the item was still in cache - Craft::$app->getCache()->delete($this->cacheKeyPrefix . $vatId); - return false; - } - - Craft::$app->getCache()->set($this->cacheKeyPrefix . $vatId, '1'); - return true; - } - - /** - * @return Validator - * @deprecated in 5.3.0 use Taxes::getEnabledTaxIdValidators() instead - */ - protected function getVatValidator(): Validator - { - if (!isset($this->validator)) { - $this->validator = new Validator(); - } - - return $this->validator; - } -} diff --git a/src/services/Webhooks.php b/src/services/Webhooks.php deleted file mode 100644 index 2ea03ca8c6..0000000000 --- a/src/services/Webhooks.php +++ /dev/null @@ -1,126 +0,0 @@ - - * @since 3.1.9 - */ -class Webhooks extends Component -{ - /** - * @event WebhookEvent The event that is triggered before a Webhook is processed. - * @since 3.2.9 - * - * ```php - * use craft\commerce\events\WebhookEvent; - * use craft\commerce\services\Webhooks; - * use craft\commerce\base\GatewayInterface; - * use yii\base\Event; - * - * Event::on( - * Webhooks::class, - * Webhooks::EVENT_BEFORE_PROCESS_WEBHOOK, - * function(WebhookEvent $event) { - * // @var GatewayInterface $gateway - * $gateway = $event->gateway; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_BEFORE_PROCESS_WEBHOOK = 'beforeProcessWebhook'; - - /** - * @event WebhookEvent The event that is triggered after a Webhook is processed. - * @since 3.2.9 - * - * ```php - * use craft\commerce\events\WebhookEvent; - * use craft\commerce\services\Webhooks; - * use yii\base\Event; - * - * Event::on( - * Webhooks::class, - * Webhooks::EVENT_AFTER_PROCESS_WEBHOOK, - * function(WebhookEvent $event) { - * // @var Response $response - * $response = $event->response; - * - * // ... - * } - * ); - * ``` - */ - public const EVENT_AFTER_PROCESS_WEBHOOK = 'afterProcessWebhook'; - - /** - * @throws Exception - */ - public function processWebhook(GatewayInterface $gateway): Response - { - // Fire a 'beforeProcessWebhook' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_PROCESS_WEBHOOK)) { - $this->trigger(self::EVENT_BEFORE_PROCESS_WEBHOOK, new WebhookEvent([ - 'gateway' => $gateway, - ])); - } - - $transactionHash = $gateway->getTransactionHashFromWebhook(); - $useMutex = (bool)$transactionHash; - $transactionLockName = 'commerceTransaction:' . $transactionHash; - $mutex = Craft::$app->getMutex(); - - if ($useMutex && !$mutex->acquire($transactionLockName, 15)) { - throw new Exception('Unable to acquire a lock for transaction: ' . $transactionHash); - } - - try { - if ($gateway->supportsWebhooks()) { - $response = $gateway->processWebhook(); - } else { - throw new BadRequestHttpException('Gateway not found or does not support webhooks.'); - } - } catch (Throwable $exception) { - $message = 'Exception while processing webhook: ' . $exception->getMessage() . "\n"; - $message .= 'Exception thrown in ' . $exception->getFile() . ':' . $exception->getLine() . "\n"; - $message .= 'Stack trace:' . "\n" . $exception->getTraceAsString(); - - Craft::error($message, 'commerce'); - - $response = Craft::$app->getResponse(); - $response->setStatusCodeByException($exception); - } - - if ($useMutex) { - $mutex->release($transactionLockName); - } - - // Fire a 'afterProcessWebhook' event - if ($this->hasEventHandlers(self::EVENT_AFTER_PROCESS_WEBHOOK)) { - $this->trigger(self::EVENT_AFTER_PROCESS_WEBHOOK, new WebhookEvent([ - 'gateway' => $gateway, - 'response' => $response, - ])); - } - - return $response; - } -} diff --git a/src/stats/AverageOrderTotal.php b/src/stats/AverageOrderTotal.php deleted file mode 100644 index b422f71ca1..0000000000 --- a/src/stats/AverageOrderTotal.php +++ /dev/null @@ -1,36 +0,0 @@ - - * @since 3.0 - */ -class AverageOrderTotal extends Stat -{ - /** - * @inheritdoc - */ - protected string $_handle = 'averageOrderTotal'; - - /** - * @inheritDoc - */ - public function getData(): string|int|bool|null - { - $query = $this->_createStatQuery(); - $query->select([new Expression('ROUND(SUM([[total]]) / COUNT([[orders.id]]), 4) as averageOrderTotal')]); - - return $query->scalar(); - } -} diff --git a/src/stats/NewCustomers.php b/src/stats/NewCustomers.php deleted file mode 100644 index 4e67b4bd48..0000000000 --- a/src/stats/NewCustomers.php +++ /dev/null @@ -1,50 +0,0 @@ - - * @since 3.0 - */ -class NewCustomers extends Stat -{ - /** - * @inheritdoc - */ - protected string $_handle = 'newCustomers'; - - /** - * @inheritDoc - */ - public function getData(): string|int|bool|null - { - $query = $this->_createStatQuery(); - - // Subquery to find customers who have orders before the start date - $existingCustomersQuery = (new Query()) - ->select(['customerId']) - ->from(Table::ORDERS) - ->where(['isCompleted' => true]) - ->andWhere(['not', ['customerId' => null]]) - ->andWhere(['<', 'dateOrdered', Db::prepareDateForDb($this->getStartDate())]); - - $query->select([new Expression('COUNT(DISTINCT [[customerId]]) as newCustomers')]) - ->andWhere(['not', ['customerId' => null]]) - ->andWhere(['not in', 'customerId', $existingCustomersQuery]); - - return $query->scalar(); - } -} diff --git a/src/stats/RepeatCustomers.php b/src/stats/RepeatCustomers.php deleted file mode 100644 index bd1482c890..0000000000 --- a/src/stats/RepeatCustomers.php +++ /dev/null @@ -1,48 +0,0 @@ - - * @since 3.0 - */ -class RepeatCustomers extends Stat -{ - /** - * @inheritdoc - */ - protected string $_handle = 'repeatingCustomers'; - - /** - * @inheritDoc - */ - public function getData(): array - { - $total = (int)$this->_createStatQuery() - ->select(['customerId']) - ->groupBy('customerId') - ->count(); - - $repeatRows = $this->_createStatQuery() - ->select([new Expression('COUNT([[orders.id]])')]) - ->groupBy('customerId') - ->column(); - - - $repeat = count(array_filter($repeatRows, static fn($row) => $row > 1)); - - $percentage = round($total ? ($repeat / $total) * 100 : 0); - - return compact('total', 'repeat', 'percentage'); - } -} diff --git a/src/stats/TopCustomers.php b/src/stats/TopCustomers.php deleted file mode 100644 index a2c472e7c7..0000000000 --- a/src/stats/TopCustomers.php +++ /dev/null @@ -1,95 +0,0 @@ - - * @since 3.0 - */ -class TopCustomers extends Stat -{ - /** - * @inheritdoc - */ - protected string $_handle = 'topCustomers'; - - /** - * @var string Type of start either 'total' or 'average'. - */ - public string $type = 'total'; - - /** - * @var int Number of customers to show. - */ - public int $limit = 5; - - /** - * @inheritDoc - */ - public function __construct(string $dateRange = null, string $type = null, $startDate = null, $endDate = null, ?int $storeId = null) - { - if ($type) { - $this->type = $type; - } - - parent::__construct($dateRange, $startDate, $endDate, $storeId); - } - - /** - * @inheritDoc - */ - public function getData(): array - { - $topCustomers = $this->_createStatQuery() - ->select([ - 'average' => new Expression('ROUND((SUM([[total]]) / COUNT([[orders.id]])), 4)'), - 'count' => new Expression('COUNT([[orders.id]])'), - 'customerId', - 'total' => new Expression('SUM([[total]])'), - 'users.email', - ]) - ->innerJoin(Table::USERS . ' users', '[[orders.customerId]] = [[users.id]]') - ->groupBy(['[[orders.customerId]]', '[[users.email]]']) - ->limit($this->limit); - - if ($this->type == 'average') { - $topCustomers->orderBy(new Expression('ROUND((SUM([[total]]) / COUNT([[orders.id]])), 4) DESC')); - } else { - $topCustomers->orderBy(new Expression('SUM([[total]]) DESC')); - } - - return $topCustomers->all(); - } - - /** - * @inheritDoc - */ - public function getHandle(): string - { - return $this->_handle . $this->type; - } - - /** - * @inheritDoc - */ - public function prepareData($data): mixed - { - foreach ($data as &$topCustomer) { - $topCustomer['customer'] = Craft::$app->getUsers()->getUserById($topCustomer['customerId']); - } - - return $data; - } -} diff --git a/src/stats/TopProductTypes.php b/src/stats/TopProductTypes.php deleted file mode 100644 index e1b6afbe3e..0000000000 --- a/src/stats/TopProductTypes.php +++ /dev/null @@ -1,110 +0,0 @@ - - * @since 3.0 - */ -class TopProductTypes extends Stat -{ - /** - * @inheritdoc - */ - protected string $_handle = 'topProductTypes'; - - /** - * @var string Type either 'qty' or 'revenue'. - */ - public string $type = 'qty'; - - /** - * @var int Number of customers to show. - */ - public int $limit = 5; - - /** - * @inheritDoc - */ - public function __construct(string $dateRange = null, string $type = null, $startDate = null, $endDate = null, ?int $storeId = null) - { - $this->type = $type ?? $this->type; - - parent::__construct($dateRange, $startDate, $endDate, $storeId); - } - - /** - * @inheritDoc - */ - public function getData(): array - { - $primarySite = Craft::$app->getSites()->getPrimarySite(); - $selectTotalQty = new Expression('SUM([[li.qty]]) as qty'); - $orderByQty = new Expression('SUM([[li.qty]]) DESC'); - $selectTotalRevenue = new Expression('SUM([[li.total]]) as revenue'); - $orderByRevenue = new Expression('SUM([[li.total]]) DESC'); - - $viewableProductTypeIds = Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(); - - $results = $this->_createStatQuery() - ->select([ - '[[pt.id]] as id', - '[[pt.name]]', - $selectTotalQty, - $selectTotalRevenue, - ]) - ->leftJoin(Table::LINEITEMS . ' li', '[[li.orderId]] = [[orders.id]]') - ->leftJoin(Table::PURCHASABLES . ' p', '[[p.id]] = [[li.purchasableId]]') - ->leftJoin(Table::VARIANTS . ' v', '[[v.id]] = [[p.id]]') - ->leftJoin(Table::PRODUCTS . ' pr', '[[pr.id]] = [[v.primaryOwnerId]]') - ->leftJoin(Table::PRODUCTTYPES . ' pt', '[[pt.id]] = [[pr.typeId]]') - ->leftJoin(CraftTable::ELEMENTS_SITES . ' es', [ - 'and', - '[[es.elementId]] = [[v.primaryOwnerId]]', - ['es.siteId' => $primarySite->id], - ]) - ->andWhere(['not', ['pt.name' => null]]) - ->andWhere(['pt.id' => $viewableProductTypeIds]) - ->groupBy('[[pt.id]]') - ->orderBy($this->type == 'revenue' ? $orderByRevenue : $orderByQty) - ->limit($this->limit); - - return $results->all(); - } - - /** - * @inheritDoc - */ - public function getHandle(): string - { - return $this->_handle . $this->type; - } - - /** - * @inheritDoc - */ - public function prepareData($data): mixed - { - if (!empty($data)) { - foreach ($data as &$row) { - $row['productType'] = ($row['id']) ? Plugin::getInstance()->getProductTypes()->getProductTypeById((int)$row['id']) : null; - } - } - - return $data; - } -} diff --git a/src/stats/TopProducts.php b/src/stats/TopProducts.php deleted file mode 100644 index 08975ba76b..0000000000 --- a/src/stats/TopProducts.php +++ /dev/null @@ -1,300 +0,0 @@ - - * @since 3.0 - */ -class TopProducts extends Stat -{ - /** - * Stat returned based on quantity. - * - * @since 3.4 - */ - public const TYPE_QTY = 'qty'; - - /** - * Stat returned based on revenue. - * - * @since 3.4 - */ - public const TYPE_REVENUE = 'revenue'; - - /** - * @since 3.4 - */ - public const REVENUE_OPTION_DISCOUNT = 'discount'; - - /** - * @since 3.4 - */ - public const REVENUE_OPTION_TAX = 'tax'; - - /** - * @since 3.4 - */ - public const REVENUE_OPTION_TAX_INCLUDED = 'tax_included'; - - /** - * @since 3.4 - */ - public const REVENUE_OPTION_SHIPPING = 'shipping'; - - /** - * @inheritdoc - */ - protected string $_handle = 'topProducts'; - - /** - * @var string Type either 'qty' or 'revenue'. - */ - public string $type = self::TYPE_QTY; - - /** - * @var int Number of products to show. - */ - public int $limit = 5; - - /** - * Options to be used when when calculating revenue total. - * - * @var string[] - * @since 3.4 - */ - public array $revenueOptions = []; - - /** - * Default options for calculating revenue total. - * - * @var string[] - * @since 3.4 - */ - private array $_defaultRevenueOptions = [ - self::REVENUE_OPTION_DISCOUNT, - self::REVENUE_OPTION_TAX, - self::REVENUE_OPTION_TAX_INCLUDED, - self::REVENUE_OPTION_SHIPPING, - ]; - - /** - * Used for the correct function name `IFNUll` vs `COALESCE` difference between DB engines. - * - * @var string - */ - private string $_ifNullDbFunc; - - /** - * @inheritDoc - */ - public function __construct(string $dateRange = null, string $type = null, $startDate = null, $endDate = null, array $revenueOptions = null, ?int $storeId = null) - { - $this->_ifNullDbFunc = Craft::$app->getDb()->getIsPgsql() ? 'COALESCE' : 'IFNULL'; - - if ($type) { - $this->type = $type; - } - - // Set defaults - $this->revenueOptions = $this->_defaultRevenueOptions; - if (is_array($revenueOptions)) { - $this->revenueOptions = $revenueOptions; - } - - parent::__construct($dateRange, $startDate, $endDate, $storeId); - } - - /** - * @inheritDoc - */ - public function getData(): array - { - $primarySite = Craft::$app->getSites()->getPrimarySite(); - - $select = [ - '[[v.primaryOwnerId]] as id', - '[[es.title]]', - new Expression('SUM([[li.qty]]) as qty'), - new Expression('SUM([[li.total]]) as revenue'), - new Expression('SUM([[li.subtotal]]) as revenue_subtotal'), - $this->getAdjustmentsSelect(), - ]; - - $topProducts = $this->_createStatQuery() - ->select($select) - ->leftJoin(Table::LINEITEMS . ' li', '[[li.orderId]] = [[orders.id]]') - ->leftJoin(Table::PURCHASABLES . ' p', '[[p.id]] = [[li.purchasableId]]') - ->leftJoin(Table::VARIANTS . ' v', '[[v.id]] = [[p.id]]') - ->leftJoin(Table::PRODUCTS . ' pr', '[[pr.id]] = [[v.primaryOwnerId]]') - ->leftJoin(Table::PRODUCTTYPES . ' pt', '[[pt.id]] = [[pr.typeId]]') - ->leftJoin(CraftTable::ELEMENTS_SITES . ' es', [ - 'and', - '[[es.elementId]] = [[v.primaryOwnerId]]', - ['es.siteId' => $primarySite->id], - ]) - ->leftJoin(['adjustments' => $this->createAdjustmentsSubQuery()], '[[v.primaryOwnerId]] = [[adjustments.primaryOwnerId]]') - ->groupBy($this->getGroupBy()) - ->orderBy($this->getOrderBy()) - ->andWhere(['not', ['[[v.primaryOwnerId]]' => null]]) - ->limit($this->limit); - - return $topProducts->all(); - } - - /** - * @inheritDoc - */ - public function getHandle(): string - { - $handle = $this->_handle . $this->type; - - foreach ($this->revenueOptions as $revenueOption) { - $handle .= '-' . $revenueOption; - } - - return $handle; - } - - /** - * @inheritDoc - */ - public function prepareData($data): mixed - { - if (!empty($data)) { - foreach ($data as &$row) { - if ($row['id']) { - $row['product'] = Plugin::getInstance()->getProducts()->getProductById($row['id']); - } - } - } - - return $data; - } - - /** - * Create select statement for a stat type `custom` based on the options chosen. - * - * @since 3.4 - */ - protected function getAdjustmentsSelect(): Expression - { - $select = 'SUM([[li.subtotal]])'; - - if (is_array($this->revenueOptions)) { - if (in_array(self::REVENUE_OPTION_DISCOUNT, $this->revenueOptions, true)) { - $select .= '+ [[adjustments.discount]]'; - } - - if (!in_array(self::REVENUE_OPTION_TAX_INCLUDED, $this->revenueOptions, true)) { - $select .= '- [[adjustments.tax_included]]'; - } - - if (!in_array(self::REVENUE_OPTION_TAX, $this->revenueOptions, true)) { - $select .= '- [[adjustments.tax]]'; - } - - if (!in_array(self::REVENUE_OPTION_SHIPPING, $this->revenueOptions, true)) { - $select .= '- [[adjustments.shipping]]'; - } - } - - $select = $this->_ifNullDbFunc . '(' . $select . ', SUM([[li.subtotal]]))'; - - return new Expression($select . ' as revenue_custom'); - } - - /** - * Create the adjustments sub query for use with revenue calculation. - * - * @since 3.4 - */ - protected function createAdjustmentsSubQuery(): Query - { - $types = []; - foreach ($this->revenueOptions as $revenueOption) { - $types[] = str_starts_with($revenueOption, 'tax') ? 'tax' : $revenueOption; - } - $types = array_unique($types); - - return (new Query()) - ->select([ - '[[v.primaryOwnerId]]', - 'discount' => new Expression($this->_ifNullDbFunc . '(SUM(CASE WHEN [[oa.type]]=\'discount\' THEN amount END), 0)'), - 'shipping' => new Expression($this->_ifNullDbFunc . '(SUM(CASE WHEN [[oa.type]]=\'shipping\' THEN amount END), 0)'), - 'tax' => new Expression($this->_ifNullDbFunc . '(SUM(CASE WHEN [[oa.type]]=\'tax\' AND included=false THEN amount END), 0)'), - 'tax_included' => new Expression($this->_ifNullDbFunc . '(SUM(CASE WHEN [[oa.type]]=\'tax\' AND included=true THEN amount END), 0)'), - ]) - ->from(Table::ORDERADJUSTMENTS . ' oa') - ->leftJoin(Table::LINEITEMS . ' li', '[[li.id]] = [[lineItemId]]') - ->leftJoin(Table::VARIANTS . ' v', '[[v.id]] = [[li.purchasableId]]') - ->where(['not', ['lineItemId' => null]]) - ->andWhere(['not', ['[[v.primaryOwnerId]]' => null]]) - ->andWhere(['[[oa.type]]' => $types]) - ->groupBy('[[v.primaryOwnerId]]'); - } - - /** - * Return the order by clause for the data query. - * - * @since 3.4 - */ - protected function getOrderBy(): Expression - { - if ($this->type === self::TYPE_QTY) { - return new Expression('SUM([[li.qty]]) DESC'); - } - - // Order by custom revenue options if not all options are selected. - if ($this->type === self::TYPE_REVENUE && count(array_intersect($this->_defaultRevenueOptions, $this->revenueOptions)) !== count($this->_defaultRevenueOptions)) { - return new Expression('[[revenue_custom]] DESC'); - } - - return new Expression('SUM([[li.total]]) DESC'); - } - - /** - * Return group by statement based on state type. - * - * @since 3.4 - */ - protected function getGroupBy(): string - { - $groupBy = '[[v.primaryOwnerId]], [[es.title]]'; - - if (is_array($this->revenueOptions)) { - if (in_array(self::REVENUE_OPTION_DISCOUNT, $this->revenueOptions, true)) { - $groupBy .= ', [[adjustments.discount]]'; - } - - if (!in_array(self::REVENUE_OPTION_TAX_INCLUDED, $this->revenueOptions, true)) { - $groupBy .= ', [[adjustments.tax_included]]'; - } - - if (!in_array(self::REVENUE_OPTION_TAX, $this->revenueOptions, true)) { - $groupBy .= ', [[adjustments.tax]]'; - } - - if (!in_array(self::REVENUE_OPTION_SHIPPING, $this->revenueOptions, true)) { - $groupBy .= ', [[adjustments.shipping]]'; - } - } - - return $groupBy; - } -} diff --git a/src/stats/TopPurchasables.php b/src/stats/TopPurchasables.php deleted file mode 100644 index 8d0eba4417..0000000000 --- a/src/stats/TopPurchasables.php +++ /dev/null @@ -1,89 +0,0 @@ - - * @since 3.0 - */ -class TopPurchasables extends Stat -{ - /** - * @inheritdoc - */ - protected string $_handle = 'topPurchasables'; - - /** - * @var string Type either 'qty' or 'revenue'. - */ - public string $type = 'qty'; - - /** - * @var int Number of customers to show. - */ - public int $limit = 5; - - /** - * @inheritDoc - */ - public function __construct(string $dateRange = null, string $type = null, $startDate = null, $endDate = null, ?int $storeId = null) - { - $this->type = $type ?? $this->type; - - parent::__construct($dateRange, $startDate, $endDate, $storeId); - } - - /** - * @inheritDoc - */ - public function getData(): array - { - $selectTotalQty = new Expression('SUM([[li.qty]]) as qty'); - $orderByQty = new Expression('SUM([[li.qty]]) DESC'); - $selectTotalRevenue = new Expression('SUM([[li.total]]) as revenue'); - $orderByRevenue = new Expression('SUM([[li.total]]) DESC'); - - $viewableProductTypeIds = Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(); - - $topPurchasables = $this->_createStatQuery() - ->select([ - '[[li.purchasableId]]', - '[[p.description]]', - '[[p.sku]]', - $selectTotalQty, - $selectTotalRevenue, - ]) - ->leftJoin(Table::LINEITEMS . ' li', '[[li.orderId]] = [[orders.id]]') - ->leftJoin(Table::PURCHASABLES . ' p', '[[p.id]] = [[li.purchasableId]]') - ->leftJoin(Table::VARIANTS . ' v', '[[v.id]] = [[p.id]]') - ->leftJoin(Table::PRODUCTS . ' pr', '[[pr.id]] = [[v.primaryOwnerId]]') - ->leftJoin(Table::PRODUCTTYPES . ' pt', '[[pt.id]] = [[pr.typeId]]') - ->andWhere(['pt.id' => $viewableProductTypeIds]) - ->groupBy('[[li.purchasableId]], [[p.sku]], [[p.description]]') - ->orderBy($this->type == 'revenue' ? $orderByRevenue : $orderByQty) - ->addOrderBy('sku ASC') - ->limit($this->limit); - - return $topPurchasables->all(); - } - - /** - * @inheritDoc - */ - public function getHandle(): string - { - return $this->_handle . $this->type; - } -} diff --git a/src/stats/TotalOrders.php b/src/stats/TotalOrders.php deleted file mode 100644 index ad28831fa1..0000000000 --- a/src/stats/TotalOrders.php +++ /dev/null @@ -1,45 +0,0 @@ - - * @since 3.0 - */ -class TotalOrders extends Stat -{ - /** - * @inheritdoc - */ - protected string $_handle = 'totalOrders'; - - /** - * @inheritDoc - */ - public function getData(): array - { - $query = $this->_createStatQuery(); - $query->select([new Expression('COUNT([[orders.id]]) as total')]); - - $chartData = $this->_createChartQuery([ - new Expression('COUNT([[orders.id]]) as total'), - ], [ - 'total' => 0, - ]); - - return [ - 'total' => $query->scalar(), - 'chart' => $chartData, - ]; - } -} diff --git a/src/stats/TotalOrdersByCountry.php b/src/stats/TotalOrdersByCountry.php deleted file mode 100644 index 4b193a895c..0000000000 --- a/src/stats/TotalOrdersByCountry.php +++ /dev/null @@ -1,121 +0,0 @@ - - * @since 3.0 - */ -class TotalOrdersByCountry extends Stat -{ - /** - * @inheritdoc - */ - protected string $_handle = 'totalOrdersByCountry'; - - /** - * @var string Type of stat e.g. 'shipping' or 'billing'. - */ - public string $type = 'shipping'; - - public int $limit = 5; - - /** - * @inheritDoc - */ - public function __construct(string $dateRange = null, string $type = null, $startDate = null, $endDate = null, ?int $storeId = null) - { - $this->type = $type ?? $this->type; - - parent::__construct($dateRange, $startDate, $endDate, $storeId); - } - - /** - * @inheritDoc - */ - public function getData(): array - { - $query = $this->_createStatQuery(); - $query->select([ - 'countryCode' => ($this->type == 'billing' ? '[[b.countryCode]]' : '[[s.countryCode]]'), - 'total' => new Expression('COUNT([[orders.id]])'), - ]); - $query->leftJoin(CraftTable::ADDRESSES . ' s', '[[s.id]] = [[orders.shippingAddressId]]'); - $query->leftJoin(CraftTable::ADDRESSES . ' b', '[[b.id]] = [[orders.billingAddressId]]'); - - if ($this->type == 'billing') { - $query->andWhere(['not', ['[[b.countryCode]]' => null]]); - $query->groupBy('[[b.countryCode]]'); - } else { - $query->andWhere(['not', ['[[s.countryCode]]' => null]]); - $query->groupBy('[[s.countryCode]]'); - } - - $query->orderBy(new Expression('COUNT([[orders.id]]) DESC')); - $query->limit($this->limit); - $rows = $query->all(); - - if (count($rows) < $this->limit) { - return $rows; - } - - $countryCodes = ArrayHelper::getColumn($rows, 'countryCode', false); - - $otherCountries = $this->_createStatQuery() - ->select([ - 'total' => new Expression('COUNT([[orders.id]])'), - 'countryCode' => new Expression('NULL'), - ]) - ->leftJoin(CraftTable::ADDRESSES . ' s', '[[s.id]] = [[orders.shippingAddressId]]') - ->leftJoin(CraftTable::ADDRESSES . ' b', '[[b.id]] = [[orders.billingAddressId]]') - ->andWhere(['not', [($this->type == 'billing' ? '[[b.countryCode]]' : '[[s.countryCode]]') => $countryCodes]]) - ->one(); - - if (empty($otherCountries)) { - return $rows; - } - - $otherCountries['name'] = Craft::t('commerce', 'Other countries'); - $rows[] = $otherCountries; - - return $rows; - } - - /** - * @inheritDoc - */ - public function getHandle(): string - { - return $this->_handle . $this->type; - } - - /** - * @inheritDoc - */ - public function prepareData($data): mixed - { - if (!empty($data)) { - foreach ($data as &$row) { - if (!$row['countryCode']) { - continue; - } - $row['name'] = Craft::$app->getAddresses()->getCountryRepository()->get($row['countryCode'])->getName(); - } - } - - return $data; - } -} diff --git a/src/stats/TotalRevenue.php b/src/stats/TotalRevenue.php deleted file mode 100644 index dbdcf36ae8..0000000000 --- a/src/stats/TotalRevenue.php +++ /dev/null @@ -1,63 +0,0 @@ - - * @since 3.0 - */ -class TotalRevenue extends Stat -{ - /** - * @since 4.1.0 - */ - public const TYPE_TOTAL = 'total'; - - /** - * @since 4.1.0 - */ - public const TYPE_TOTAL_PAID = 'totalPaid'; - - /** - * @var string - * @since 4.1.0 - */ - public string $type = self::TYPE_TOTAL; - - /** - * @inheritdoc - */ - protected string $_handle = 'totalRevenue'; - - /** - * @inheritDoc - */ - public function getData(): ?array - { - $allowedTypes = [self::TYPE_TOTAL, self::TYPE_TOTAL_PAID]; - if (!in_array($this->type, $allowedTypes, true)) { - $this->type = self::TYPE_TOTAL; - } - - return $this->_createChartQuery( - [ - new Expression(sprintf('SUM([[%s]]) as revenue', $this->type)), - new Expression('COUNT([[orders.id]]) as count'), - ], - [ - 'revenue' => 0, - 'count' => 0, - ] - ); - } -} diff --git a/src/taxidvalidators/EuVatIdValidator.php b/src/taxidvalidators/EuVatIdValidator.php deleted file mode 100644 index 1a3f892a61..0000000000 --- a/src/taxidvalidators/EuVatIdValidator.php +++ /dev/null @@ -1,132 +0,0 @@ - - */ -class EuVatIdValidator implements TaxIdValidatorInterface -{ - public const API_URL = 'https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number'; - - /** - * Regular expression patterns per country code - * - * @var array - * @link http://ec.europa.eu/taxation_customs/vies/faq.html?locale=lt#item_11 - */ - private array $_patterns = [ - 'AT' => 'U[A-Z\d]{8}', - 'BE' => '(0|1)\d{9}', - 'BG' => '\d{9,10}', - 'CY' => '\d{8}[A-Z]', - 'CZ' => '\d{8,10}', - 'DE' => '\d{9}', - 'DK' => '(\d{2} ?){3}\d{2}', - 'EE' => '\d{9}', - 'EL' => '\d{9}', - 'ES' => '([A-Z]\d{7}[A-Z]|\d{8}[A-Z]|[A-Z]\d{8})', - 'EU' => '\d{9}', - 'FI' => '\d{8}', - 'FR' => '[A-Z\d]{2}\d{9}', - 'GB' => '(\d{9}|\d{12}|(GD|HA)\d{3})', - 'HR' => '\d{11}', - 'HU' => '\d{8}', - 'IE' => '((\d{7}[A-Z]{1,2})|(\d[A-Z]\d{5}[A-Z]))', - 'IT' => '\d{11}', - 'LT' => '(\d{9}|\d{12})', - 'LU' => '\d{8}', - 'LV' => '\d{11}', - 'MT' => '\d{8}', - 'NL' => '\d{9}B\d{2}', - 'PL' => '\d{10}', - 'PT' => '\d{9}', - 'RO' => '\d{2,10}', - 'SE' => '\d{12}', - 'SI' => '\d{8}', - 'SK' => '\d{10}', - 'SM' => '\d{5}', - ]; - - public static function displayName(): string - { - return \Craft::t('commerce', 'EU VAT ID'); - } - - private function _splitNumber(string $idNumber): array - { - $vatNumber = strtoupper($idNumber); - $country = substr($vatNumber, 0, 2); - $number = substr($vatNumber, 2); - - return [$country, $number]; - } - - public function validateFormat(string $idNumber): bool - { - [$country, $number] = $this->_splitNumber($idNumber); - - if (!isset($this->_patterns[$country])) { - return false; - } - - return preg_match('/^' . $this->_patterns[$country] . '$/', $number) > 0; - } - - public function validateExistence(string $idNumber): bool - { - [$country, $number] = $this->_splitNumber($idNumber); - - try { - $client = Craft::createGuzzleClient(); - $response = $client->post(self::API_URL, [ - 'headers' => [ - 'Content-Type' => 'application/json', - ], - 'body' => json_encode([ - 'countryCode' => $country, - 'vatNumber' => $number, - ]), - ]); - - $responseBody = json_decode($response->getBody(), true); - if ($response->getStatusCode() !== 200) { - return false; - } - - if (!isset($responseBody['valid']) || $responseBody['valid'] !== true) { - return false; - } - - return true; - } catch (\Exception $e) { - \Craft::error($e->getMessage(), __METHOD__); - } - - return false; - } - - /** - * @inheritdoc - */ - public static function isEnabled(): bool - { - return true; - } - - public function validate(string $idNumber): bool - { - try { - return $this->validateFormat($idNumber) && $this->validateExistence($idNumber); - } catch (\Exception $e) { - \Craft::error('Error validating EU VAT ID: ' . $e->getMessage()); - return false; - } - } -} diff --git a/src/templates/index.twig b/src/templates/index.twig deleted file mode 100644 index 7528f11938..0000000000 --- a/src/templates/index.twig +++ /dev/null @@ -1,64 +0,0 @@ -{% set permissionsToView = { - 'commerce/orders': 'commerce-manageOrders', - 'commerce/subscriptions': 'commerce-manageSubscriptions', - 'commerce/inventory' : 'commerce-manageInventoryStockLevels', - 'commerce/store-management' : 'commerce-manageStoreSettings', - - 'commerce/promotions': 'commerce-managePromotions', - 'commerce/shipping/shippingmethods': 'commerce-manageShipping', - 'commerce/tax/taxrates': 'commerce-manageTaxes', -} %} - -{% set primaryStore = craft.commerce.stores.getPrimaryStore() %} -{% set deprecatedRoutesToNewRoute = { - 'commerce/promotions': "commerce/store-management/#{primaryStore.handle}/discounts", - 'commerce/shipping/shippingmethods': "commerce/store-management/#{primaryStore.handle}/shippingmethods", - 'commerce/tax/taxrates': "commerce/store-management/#{primaryStore.handle}/taxrates", -} %} - -{% set permission = permissionsToView[craft.commerce.settings.defaultView] ?? null %} -{% if craft.commerce.settings.defaultView and permission and currentUser.can(permission) %} - {% if craft.commerce.settings.defaultView in deprecatedRoutesToNewRoute|keys %} - {% redirect deprecatedRoutesToNewRoute[craft.commerce.settings.defaultView] %} - {% endif %} - - {% redirect craft.commerce.settings.defaultView %} -{% endif %} - -{% if craft.commerce.settings.defaultView and craft.commerce.settings.defaultView == 'commerce/products' and craft.commerce.productTypes.editableProductTypes|length > 0 %} - {% redirect 'commerce/products' %} -{% endif %} - -{% if currentUser.can('commerce-manageOrders') %} - {% redirect 'commerce/orders' %} -{% endif %} - -{% if craft.commerce.productTypes.editableProductTypes|length > 0 %} - {% redirect 'commerce/products' %} -{% endif %} - -{% if currentUser.can('commerce-manageStoreSettings') %} - {% redirect "commerce/store-management" %} -{% endif %} - -{% if currentUser.can('commerce-manageInventoryStockLevels') %} - {% redirect "commerce/inventory" %} -{% endif %} - -{% if currentUser.can('commerce-managePromotions') %} - {% redirect "commerce/store-management/#{primaryStore.handle}/discounts" %} -{% endif %} - -{% if currentUser.can('commerce-manageShipping') %} - {% redirect "commerce/store-management/#{primaryStore.handle}/shippingmethods" %} -{% endif %} - -{% if currentUser.can('commerce-manageTaxes') %} - {% redirect "commerce/store-management/#{primaryStore.handle}/taxrates" %} -{% endif %} - -{% if currentUser.can('commerce-manageSubscriptions') %} - {% redirect 'commerce/subscriptions' %} -{% endif %} - -{% exit 403 %} diff --git a/src/templates/inventory-locations/_edit.twig b/src/templates/inventory-locations/_edit.twig deleted file mode 100644 index 0382f77b7a..0000000000 --- a/src/templates/inventory-locations/_edit.twig +++ /dev/null @@ -1,9 +0,0 @@ -{% namespace 'inventoryLocationAddress' %} -{{ form.render()|raw }} -{% endnamespace %} - -{% hook "cp.commerce.inventoryLocation.edit" %} - -{% if not inventoryLocation.id %} -{% js "new Craft.HandleGenerator('##{'name'|namespaceInputId}', '##{'handle'|namespaceInputId}');" %} -{% endif %} \ No newline at end of file diff --git a/src/templates/promotions/sales/_edit.twig b/src/templates/promotions/sales/_edit.twig deleted file mode 100644 index 85d040001d..0000000000 --- a/src/templates/promotions/sales/_edit.twig +++ /dev/null @@ -1,360 +0,0 @@ -{% extends "commerce/_layouts/store-management" %} -{% set isIndex = false %} - -{% set crumbs = [ - { label: 'Commerce'|t('commerce'), url: url('commerce') }, - { label: "Store Management"|t('commerce'), url: url('commerce/store-management/#{storeHandle}') }, - { label: "Sales"|t('commerce'), url: url("commerce/store-management/#{storeHandle}/sales") }, -] %} - -{% set fullPageForm = true %} - -{% import "_includes/forms" as forms %} -{% import "commerce/_includes/forms/commerceForms" as commerceForms %} - -{% set mainFormAttributes = { - id: 'saleform', - method: 'post', - 'accept-charset': 'UTF-8' -} %} - -{% set formActions = [{ - label: 'Save and continue editing'|t('app'), - redirect: (isNewSale ? "commerce/store-management/#{storeHandle}/sales/{id}" : sale.getCpEditUrl())|hash, - retainScroll: true, - shortcut: true, -}] %} - -{% set actionClasses = "" %} -{% if (sale.getErrors('applyAmount') or sale.getErrors('apply')) %} - {% set actionClasses = "error" %} -{% endif %} - -{% set matchingItemsClasses = "" %} -{% if false %} - {% set matchingItemsClasses = "error" %} -{% endif %} - -{% set saleClasses = "" %} -{% if(sale.getErrors('name')) %} - {% set saleClasses = "error" %} -{% endif %} - -{% set tabs = { - sale: {'label':'Sale'|t('commerce'),'url':'#sale','class': saleClasses}, - matchingItems: {'label':'Matching Items'|t('commerce'),'url':'#matching-items'}, - conditions: {'label':'Conditions'|t('commerce'),'url':'#conditions'}, - actions: {'label':'Actions'|t('commerce'),'url':'#actions','class': actionClasses} -} %} - -{% hook "cp.commerce.sales.edit" %} - -{% block details %} - -
- {{ forms.lightSwitchField({ - label: "Enable this sale"|t('commerce'), - id: 'enabled', - name: 'enabled', - value: 1, - on: sale.enabled, - checked: sale.enabled, - errors: sale.getErrors('enabled'), - instructions: 'Whether this sale should be available for use, regardless of other conditions.'|t('commerce') - }) }} -
- - {% if sale and sale.id %} -
-
-
{{ "Created at"|t('app') }}
-
{{ sale.dateCreated|datetime('short') }}
-
-
-
{{ "Updated at"|t('app') }}
-
{{ sale.dateUpdated|datetime('short') }}
-
-
- {% endif %} - - {% hook "cp.commerce.sales.edit.details" %} -{% endblock %} - -{% block content %} - - {{ redirectInput("commerce/store-management/#{storeHandle}/sales") }} - {% if sale.id %} - - - {% endif %} - -
- {{ forms.textField({ - first: true, - label: "Name"|t('commerce'), - instructions: "What this sale will be called in the control panel."|t('commerce'), - id: 'name', - name: 'name', - value: sale.name, - errors: sale.getErrors('name'), - autofocus: true, - required: true, - }) }} - - {{ forms.textField({ - label: "Description"|t('commerce'), - instructions: "Sale description."|t('commerce'), - id: 'description', - name: 'description', - value: sale.description, - errors: sale.getErrors('description'), - }) }} - -
- - - - - - - - {% hook "cp.commerce.sales.edit.content" %} -{% endblock %} - -{% js %} -$(function() { - $('#groups, #productTypes').selectize({ - plugins: ['remove_button'], - dropdownParent: 'body' - }); - - $("form").submit(function() { - $("input[name=ignorePrevious]").prop('disabled', false); - if ($("input[name=ignorePrevious]").prop('checked') == true) { - $("#ignorePrevious-field").css('opacity', 0.25); - } - }); - - $('select[name=apply]').change(function() { - - if (this.value == 'byPercent' || this.value == 'toPercent') { - $('#applyAmount-percent-symbol').removeClass('hidden'); - $('#applyAmount-currency-symbol').addClass('hidden'); - }else{ - $('#applyAmount-percent-symbol').addClass('hidden'); - $('#applyAmount-currency-symbol').removeClass('hidden'); - } - - if (this.value == 'toFlat' || this.value == 'toPercent') { - $('input[name=ignorePrevious]').prop('disabled', true); - $('#ignorePrevious').prop('disabled', true); - $('#ignorePrevious').addClass('disabled', true); - } - if (this.value != 'toFlat' && this.value != 'toPercent') { - $('input[name=ignorePrevious]').prop('disabled', false); - $('#ignorePrevious').prop('disabled', false); - $('#ignorePrevious').removeClass('disabled', true); - } - }); -}); -{% endjs %} diff --git a/src/templates/settings/general/index.twig b/src/templates/settings/general/index.twig deleted file mode 100644 index e9b939e88d..0000000000 --- a/src/templates/settings/general/index.twig +++ /dev/null @@ -1,82 +0,0 @@ -{# @var settings \craft\commerce\models\Settings #} -{% extends "commerce/_layouts/settings" %} - -{% set selectedTab = 'settings' %} -{% set fullPageForm = not readOnly %} - -{% set crumbs = [ - { label: 'Commerce'|t('commerce'), url: url('commerce') }, -] %} - -{% import "_includes/forms" as forms %} - -{% from _self import configWarning %} - -{% block content %} -

{{ "General Settings"|t('commerce') }}

- -
- - {% if not readOnly %} - {{ actionInput('commerce/settings/save-settings') }} - {{ redirectInput('commerce/settings/general') }} - {% endif %} - -

{{ 'Units'|t('commerce') }}

- {{ forms.selectField({ - label: "Weight Unit"|t('commerce'), - instructions: "The unit of measurement that should be used when specifying product weights."|t('commerce'), - name: 'settings[weightUnits]', - value: settings.weightUnits, - options: settings.getWeightUnitsOptions(), - errors: settings.getErrors('weightUnits'), - required: true, - disabled: readOnly, - warning: configWarning('weightUnits', 'commerce'), - }) }} - - {{ forms.selectField({ - label: "Dimension Unit"|t('commerce'), - instructions: "The unit of measurement that should be used when specifying product dimensions."|t('commerce'), - name: 'settings[dimensionUnits]', - value: settings.dimensionUnits, - options: settings.getDimensionUnits(), - errors: settings.getErrors('dimensionUnits'), - required: true, - disabled: readOnly, - warning: configWarning('dimensionUnits', 'commerce'), - }) }} - -
-

{{ 'Subscription Settings'|t('commerce') }}

- {{ forms.autosuggestField({ - label: "Billing detail update URL"|t('commerce'), - instructions: "The URL to the page for updating billing details for a subscription, as well as handling 3DS authentication."|t('commerce'), - id: 'updateBillingDetailsUrl', - name: 'settings[updateBillingDetailsUrl]', - value: settings.updateBillingDetailsUrl, - errors: settings.getErrors('updateBillingDetailsUrl'), - required: false, - suggestEnvVars: true, - suggestAliases: true, - disabled: readOnly, - placeholder: "//example.com/subscriptions/updateBillingDetails", - warning: configWarning('updateBillingDetailsUrl', 'commerce'), - }) }} - -
-

{{ 'Control Panel Settings'|t('commerce') }}

- {{ forms.selectField({ - label: "Default View"|t('commerce'), - instructions: "Default Commerce control panel view. If the user does not have permission it will fall back to a location they can access."|t('commerce'), - name: 'settings[defaultView]', - value: settings.defaultView, - options: settings.getDefaultViewOptions(), - errors: settings.getErrors('defaultView'), - disabled: readOnly, - required: true, - warning: configWarning('defaultView', 'commerce'), - }) }} -
- -{% endblock %} diff --git a/src/templates/settings/subscriptions/_edit.twig b/src/templates/settings/subscriptions/_edit.twig deleted file mode 100644 index 36bbdf1d17..0000000000 --- a/src/templates/settings/subscriptions/_edit.twig +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "commerce/_layouts/settings" %} - -{% import "_includes/forms" as forms %} - -{% set selectedItem = 'subscriptions' %} - -{% set crumbs = [ - { label: 'Commerce'|t('commerce'), url: url('commerce') }, -] %} - -{% set fullPageForm = not readOnly %} - -{% if readOnly %} - {% set contentNotice = readOnlyNotice() %} -{% endif %} - -{% block content %} - {% if not readOnly %} - {{ actionInput('commerce/settings/save-subscription-settings') }} - {{ csrfInput() }} - {% endif %} - {{ redirectInput('commerce/settings/subscriptions') }} - - {{ forms.fieldLayoutDesignerField({ - fieldLayout: fieldLayout, - withCardViewDesigner: true, - disabled: readOnly, - }) }} - -{% endblock %} diff --git a/src/templates/store-management/discounts/_edit.twig b/src/templates/store-management/discounts/_edit.twig deleted file mode 100644 index 962bfedae9..0000000000 --- a/src/templates/store-management/discounts/_edit.twig +++ /dev/null @@ -1,654 +0,0 @@ - -{% set fullPageForm = true %} - -{% import "_includes/forms" as forms %} -{% import "commerce/_includes/forms/commerceForms" as commerceForms %} - -{% set mainFormAttributes = { - id: 'discountform', - method: 'post', - 'accept-charset': 'UTF-8' -} %} - -{% set formActions = [ - { - label: 'Save and continue editing'|t('app'), - redirect: (isNewDiscount ? 'commerce/store-management/#{storeHandle}/discounts/{id}' : discount.getCpEditUrl())|hash, - retainScroll: true, - shortcut: true, - }] -%} - -{% set couponsTable = { - name: 'coupons', - id: 'coupons-table', - cols: { - id: { - type: 'singleline', - heading: 'id'|t('app'), - class: 'hidden', - }, - code: { - type: 'singleline', - heading: 'Code'|t('commerce'), - }, - uses: { - type: 'singleline', - heading: 'Uses'|t('commerce'), - }, - maxUses: { - type: 'singleline', - heading: 'Max Uses'|t('commerce'), - info: 'Leave blank for unlimited uses.'|t('commerce'), - }, - }, - defaultValues: { uses: 0 } -} %} - -{% hook "cp.commerce.discounts.edit" %} - -{% block content %} - {% set formAttributes = { - id: 'discountform', - method: 'post', - 'accept-charset': 'UTF-8', - data: { - saveshortcut: true, - 'saveshortcut-redirect': "commerce/store-management/#{storeHandle}/discounts"|hash, - 'confirm-unload': true - }, - } %} - - {{ hiddenInput('storeId', discount.storeId) }} - {% if discount.id %} - - - {% endif %} - -
- {{ forms.textField({ - first: true, - label: "Name"|t('commerce'), - instructions: "What this discount will be called in the control panel."|t('commerce'), - id: 'name', - name: 'name', - value: discount.name, - errors: discount.getErrors('name'), - autofocus: true, - required: true, - }) }} - - {{ forms.textField({ - label: "Description"|t('commerce'), - instructions: "Discount description."|t('commerce'), - id: 'description', - name: 'description', - value: discount.description, - errors: discount.getErrors('description'), - }) }} - - {% hook "cp.commerce.discount.edit" %} -
- - - - - - - - - - {% hook "cp.commerce.discounts.edit.content" %} -{% endblock %} - -{% js %} -$(function() { - - $('#code').on('keyup blur', function(event) { - if (this.value.length === 0) { - $('#coupon-fields').addClass('hidden'); - } else { - $('#coupon-fields').removeClass('hidden'); - } - }); - - function disableShippingSwitch() { - $('#hasFreeShippingForMatchingItems').data('lightswitch').turnOff(); - $('input[name="hasFreeShippingForMatchingItems"]').prop("disabled", true); - $('#hasFreeShippingForMatchingItems').prop("disabled", true); - $("#hasFreeShippingForMatchingItems").addClass("disabled"); - } - - function enableShippingSwitch() { - $('input[name="hasFreeShippingForMatchingItems"]').prop("disabled", false); - $('#hasFreeShippingForMatchingItems').prop("disabled", false); - $("#hasFreeShippingForMatchingItems").removeClass("disabled"); - } - - if ($('input[name="hasFreeShippingForOrder"]').val() == 1) { - disableShippingSwitch(); - } - - $('#hasFreeShippingForOrder').click(function() { - if ($('input[name="hasFreeShippingForOrder"]').val() == 1) { - disableShippingSwitch(); - } else { - enableShippingSwitch(); - } - }); - - $('.clear-btn.discount-clear-use').click(function(event) { - var $this = $(this); - var $spinner = $($this.data('spinner')); - var $field = $($this.data('field')); - var type = $this.data('type'); - var r = confirm(Craft.t('commerce', 'Are you sure you want to clear this discount usage counter?')); - - if (r == true) { - $spinner.toggleClass('hidden'); - $.ajax({ - type: "POST", - dataType: 'json', - headers: { - "X-CSRF-Token": '{{ craft.app.request.csrfToken }}', - }, - url: '', - data: { - 'action' : 'commerce/discounts/clear-discount-uses', - 'id': '{{ discount.id ?? '' }}', - 'type': type - }, - success: function(data){ - $spinner.toggleClass('hidden'); - $field.val(''); - Craft.cp.displayNotice(Craft.t('commerce', 'Counter has been cleared.')); - $this.attr('disabled', 'disabled').prop('disabled', 'disabled'); - } - }); - } - }); - - new Craft.Commerce.Coupons('#commerce-coupons', { - couponFormat: "{{ discount.couponFormat|e('js') }}", - table: { - name: "{{ couponsTable.name|namespaceInputName|e('js') }}", - cols: {{ couponsTable.cols|json_encode|raw }}, - defaultValues: {{ couponsTable.defaultValues|json_encode|raw }} - }, - }); -}); -{% endjs %} diff --git a/src/templates/subscriptions/_edit.twig b/src/templates/subscriptions/_edit.twig deleted file mode 100644 index 61028a15b9..0000000000 --- a/src/templates/subscriptions/_edit.twig +++ /dev/null @@ -1,247 +0,0 @@ -{% extends "_layouts/cp" %} - -{% set selectedSubnavItem = "subscriptions" %} -{% set bodyClass = (bodyClass is defined ? bodyClass~' ' : '') ~ "commercesubscriptions commercesubscriptionsedit" %} - -{% set title = subscription %} - -{% set crumbs = [ - { label: 'Commerce'|t('commerce'), url: url('commerce') }, - { label: "Subscriptions"|t('commerce'), url: url('commerce/subscriptions') } -] %} - -{% import "_includes/forms" as forms %} - -{% block header %} - {{ block('pageTitle') }} -
- - {% block actionButton %} -
- -
- {% endblock %} - -{% endblock %} - -{% block content %} -
-
- - - {{ redirectInput('commerce/subscriptions') }} - {{ csrfInput() }} - {{ fieldsHtml|raw }} -
- -
-
-

{{ 'Manage subscription'|t('commerce') }}

- - {% if subscription.gateway.supportsPlanSwitch and not subscription.isCanceled and not subscription.isExpired %} - {% set plans = subscription.alternativePlans %} - {% set planOptions = [{label: 'Pick a plan'|t('commerce'), value: ''}] %} - - {% for plan in plans %} - {% set planOptions = planOptions|merge([{label: plan.name, value:plan.id}]) %} - {% endfor %} - - {{ forms.selectField({ - label: 'Switch plan'|t('commerce'), - options: planOptions, - id: 'switchPlans' - }) }} - - {% for plan in plans %} - - {% endfor %} - {% else %} -
{{ 'Cannot switch plans for this subscription.'|t('commerce') }}
- {% endif %} - - {% if subscription.canReactivate() %} -
- - - {{ redirectInput(continueEditingUrl) }} - {{ csrfInput() }} - - -
- {% endif %} - -
- - {% if not subscription.isCanceled and not subscription.isExpired %} -
-

{{ 'Cancel subscription'|t('commerce') }}

- -
- - - {{ redirectInput(continueEditingUrl) }} - {{ csrfInput() }} - - {{ subscription.plan.getGateway().getCancelSubscriptionFormHtml(subscription)|raw }} - -
-
-
- -
-
- {% endif %} - -
-

Payment history

- {% set payments = subscription.getAllPayments() %} - - - - - - - - - - - - {% for payment in payments %} - {% set info = [ - { label: "Reference", type: 'code', value: payment.paymentReference }, - { label: "Gateway response", type: 'response', value: payment.response|raw }, - ] %} - - - - - - - {% endfor %} - - -
{{ 'Invoice date'|t('commerce') }}{{ 'Invoice amount'|t('commerce') }}{{ 'Status'|t('commerce') }}{{ 'Info'|t('commerce') }}s
{{ payment.paymentDate|datetime }}{{ payment.paymentCurrency }} {{ payment.paymentAmount }} - {{ payment.paid ? 'Paid'|t('commerce') : 'Unpaid'|t('commerce') }} -
- - {% if not subscription.isCanceled and not subscription.isExpired %} -
- - - {{ redirectInput(continueEditingUrl) }} - {{ csrfInput() }} - -
-
-
-
- {% endif %} -
- - {% hook 'cp.commerce.subscriptions.edit.content' %} -
-
-{% endblock %} - - -{% block details %} - -
- - - -
-
{{ 'Plan'|t('commerce') }}
-
{{ subscription.getPlan().name }}
-
- -
-
{{ 'Reference'|t('commerce') }}
-
- {% include '_includes/forms/copytext' with { - id: 'commerce-sub-reference', - buttonId: 'commerce-sub-reference-copy-btn', - value: subscription.reference, - class: ['code', not subscription.reference ? 'disabled' : '']|filter, - - } %} -
-
- -
-
{{ 'Created'|t('commerce') }}
-
{{ subscription.dateCreated|datetime }}
-
- -
-
{{ 'Trial days credited'|t('commerce') }}
-
{{ subscription.trialDays }}
-
- -{% if subscription.trialDays %} -
-
{{ 'Trial expiration'|t('commerce') }}
-
{{ subscription.trialExpires|datetime }}
-
-{% endif %} - -
-
{{ 'Next payment'|t('commerce') }}
-
{{ subscription.nextPaymentDate|datetime }}
-
- -
-
{{ 'Expiry'|t('commerce') }}
-
{{ subscription.dateExpired ? subscription.dateExpired|datetime : '' }}
-
- -
-
{{ 'Cancellation'|t('commerce') }}
-
{{ subscription.dateCanceled ? subscription.dateCanceled|datetime : '' }}
-
- -
-
{{ 'Billing issues'|t('commerce') }}
-
{{ subscription.getBillingIssueDescription() }}
-
- -
-{% hook 'cp.commerce.subscriptions.edit.meta' %} -{% endblock %} - -{% js %} - $(document).ready(function () { - $('#switchPlans').on('change', function (ev) { - $('.switchPlansForm').addClass('hidden'); - $('#switch-'+ev.currentTarget.value).removeClass('hidden'); - }); - - $.each($('.tableRowInfo'), function () { - new Craft.Commerce.TableRowAdditionalInfoIcon(this); - }); - - $('#saveCustomFieldsSubmit').click(function(){ - $('form#customFields').submit(); - }); - }); - -{% endjs %} - -{% do view.registerAssetBundle("craft\\web\\assets\\prismjs\\PrismJsAsset") %} diff --git a/src/templates/subscriptions/_index.twig b/src/templates/subscriptions/_index.twig deleted file mode 100644 index 436d1c7d80..0000000000 --- a/src/templates/subscriptions/_index.twig +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "_layouts/elementindex" %} - -{% set title = "Subscriptions"|t('commerce') %} -{% set docTitle = title~' - '~'Commerce' %} -{% set elementType = 'craft\\commerce\\elements\\Subscription' %} -{% set selectedTab = 'subscriptions' %} -{% set selectedSubnavItem = "subscriptions" %} -{% set bodyClass = (bodyClass is defined ? bodyClass~' ' : '') ~ "commercesubscriptions commercesubscriptionsindex" %} - -{% set crumbs = [ - { label: 'Commerce'|t('commerce'), url: url('commerce') }, -] %} - -{% js %} - if (typeof Craft.Commerce === 'undefined') { - Craft.Commerce = {}; - } - -{% endjs %} diff --git a/src/templates/subscriptions/plans/_edit.twig b/src/templates/subscriptions/plans/_edit.twig deleted file mode 100644 index 0558c24b70..0000000000 --- a/src/templates/subscriptions/plans/_edit.twig +++ /dev/null @@ -1,77 +0,0 @@ -{% import "_includes/forms" as forms %} - -{% block content %} - {% if gatewayOptions|length > 0 or plan is not null %} - {% if plan is not null and plan.id %} - - {% endif %} - -
- {{ forms.textField({ - first: true, - label: "Name"|t('commerce'), - instructions: "What this subscription plan will be called in the control panel."|t('commerce'), - id: 'name', - name: 'name', - value: plan ? plan.name : '', - errors: plan ? plan.getErrors('name') : [], - autofocus: true, - required: true - }) }} - - {{ forms.textField({ - label: "Handle"|t('commerce'), - instructions: "How you’ll refer to this subscription plan in the templates."|t('commerce'), - id: 'handle', - class: 'code', - name: 'handle', - value: plan ? plan.handle : '', - errors: plan ? plan.getErrors('handle') : [], - required: true - }) }} - - {{ forms.elementSelectField({ - elementType: entryElementType, - elements: (plan and plan.planInformationId) ? craft.entries.status(null).id(plan.planInformationId).all() : null, - instructions: "The entry that contains the description for this subscription’s plan."|t('commerce'), - id: 'planInformation', - label: "Description"|t(','), - class: 'ltr', - name: 'planInformation', - limit: 1 - }) }} - - {{ forms.selectField({ - label: "Gateway"|t('commerce'), - instructions: "The payment gateway that will be used for the subscription plan."|t('commerce'), - id: 'gatewayId', - class: 'gateway-select code ltr', - name: 'gatewayId', - value: plan ? plan.gatewayId, - options: gatewayOptions, - errors: plan ? plan.getErrors('gatewayId') : [] - }) }} - - {% for gateway in supportedGateways %} - {% set isCurrent = plan and (gateway.id == plan.gatewayId) %} - -
- {% namespace 'gateway['~gateway.id~']' %} - {{ gateway.getPlanSettingsHtml({'plan': plan, 'gateway': gateway})|raw }} - {% endnamespace %} -
- {% endfor %} -
- {% else %} -

{{ 'You must set up at least one gateway that supports subscriptions first.'|t('commerce', {'link': url('commerce/settings/gateways')})|raw }}

- {% endif %} -{% endblock %} - -{% js %} - {% if plan is null or not plan.handle %}new Craft.HandleGenerator('#name', '#handle');{% endif %} - - $('#gatewayId').on('change', function (ev) { - $('.gateway-settings').addClass('hidden'); - $('#gateway-settings-' + ev.currentTarget.value).removeClass('hidden'); - }); -{% endjs %} diff --git a/src/templates/subscriptions/plans/index.twig b/src/templates/subscriptions/plans/index.twig deleted file mode 100644 index 90dbae0076..0000000000 --- a/src/templates/subscriptions/plans/index.twig +++ /dev/null @@ -1,59 +0,0 @@ -{% do view.registerAssetBundle('craft\\web\\assets\\admintable\\AdminTableAsset') -%} -{% do view.registerTranslations('commerce', [ - 'Active subscriptions', - 'Are you sure you want to archive the “{name}” subscription plan? It WILL NOT cancel the existing subscriptions.', - 'Couldn’t reorder plans.', - 'Enabled?', - 'Gateway', - 'Handle', - 'Information linked?', - 'Name', - 'No subscription plans exist yet.', - 'No', - 'Plans reordered.', - 'Yes', -]) %} - -{% block content %} -
-{% endblock %} - -{% set tableData = [] %} -{% for plan in plans %} - {% set tableData = tableData|merge([{ - id: plan.id, - title: plan.name|t('site')|e, - url: plan.getCpEditUrl(), - status: plan.enabled ? true : false, - handle: plan.handle|e, - gateway: plan.gateway.name|t('site')|e, - subscriptions: plan.subscriptionCount, - information: plan.planInformationId ? 'Yes'|t('commerce')|e : 'No'|t('commerce')|e, - }]) %} -{% endfor %} - - -{% js %} -var columns = [ - { name: '__slot:title', title: Craft.t('commerce', 'Name') }, - { name: '__slot:handle', title: Craft.t('commerce', 'Handle') }, - { name: 'gateway', title: Craft.t('commerce', 'Gateway') }, - { name: 'subscriptions', title: Craft.t('commerce', 'Active subscriptions') }, - { name: 'information', title: Craft.t('commerce', 'Information linked?'), - callback: function(value) { - return ''+Craft.escapeHtml(value)+''; - } - } -]; - -new Craft.VueAdminTable({ - columns: columns, - container: '#plans-vue-admin-table', - deleteAction: 'commerce/plans/archive-plan', - deleteConfirmationMessage: Craft.t('commerce', 'Are you sure you want to archive the “{name}” subscription plan? It WILL NOT cancel the existing subscriptions.'), - reorderAction: 'commerce/plans/reorder', - reorderSuccessMessage: Craft.t('commerce', 'Plans reordered.'), - reorderFailMessage: Craft.t('commerce', 'Couldn’t reorder plans.'), - tableData: {{ tableData|json_encode|raw }} - }); -{% endjs %} diff --git a/src/test/fixtures/elements/ProductFixture.php b/src/test/fixtures/elements/ProductFixture.php deleted file mode 100644 index aa36f5c348..0000000000 --- a/src/test/fixtures/elements/ProductFixture.php +++ /dev/null @@ -1,150 +0,0 @@ - - * @author Robuust digital | Bob Olde Hampsink - * @author Global Network Group | Giel Tettelaar - * @since 2.1 - */ -class ProductFixture extends BaseElementFixture -{ - /** - * @var array - */ - protected array $productTypeIds = []; - - private ?VariantCollection $_variants = null; - - /** - * {@inheritdoc} - */ - public function init(): void - { - parent::init(); - - // Ensure loaded - $commerce = Plugin::getInstance(); - if (!$commerce) { - throw new InvalidArgumentException('Commerce plugin needs to be loaded before using the ProductFixture'); - } - - // Get all product type id's - $this->productTypeIds = $this->_getProductTypeIds(); - } - - /** - * @inheritdoc - */ - public function afterLoad(): void - { - $this->productTypeIds = $this->_getProductTypeIds(); - - // Generate catalog pricing - Plugin::getInstance()->getCatalogPricing()->generateCatalogPrices(); - } - - protected function createElement(): ElementInterface - { - return new Product(); - } - - /** - * Get array of product type IDs indexed by handle. - * This uses a raw query to avoid service level caching/memoization. - * - * @todo Review whether this raw-query workaround for service-level memoization is still needed in Commerce 6.0 #COM-54 - */ - private function _getProductTypeIds(): array - { - return (new Query()) - ->select([ - 'productTypes.id', - 'productTypes.handle', - ]) - ->from([Table::PRODUCTTYPES . ' productTypes']) - ->indexBy('handle') - ->column(); - } - - /** - * @inheritdoc - * @param Product $element - */ - protected function populateElement(ElementInterface $element, array $attributes): void - { - foreach ($attributes as $name => $value) { - if ($name !== '_variants') { - $element->$name = $value; - } else { - $this->_variants = VariantCollection::make($value); - $element->setVariants($value); - } - } - } - - /** - * @inheritdoc - */ - protected function saveElement(ElementInterface $element): bool - { - $return = parent::saveElement($element); - - // Save the variants - $this->_variants->each(function(Variant $v) use ($element) { - if ((new Query()) - ->from(Table::VARIANTS . ' v') - ->leftJoin(Table::PURCHASABLES . ' p', '[[p.id]] = [[v.id]]') - ->where(['primaryOwnerId' => $element->id]) - ->andWhere(['p.sku' => $v->getSku()]) - ->exists() - ) { - return; - } - - $v->setPrimaryOwnerId($element->id); - $v->setOwnerId($element->id); - \Craft::$app->getElements()->saveElement($v,false); - }); - - $this->_variants = null; - - return $return; - } - - /** - * @inheritdoc - */ - protected function deleteElement(ElementInterface $element): bool - { - /** @var Product $element */ - $variants = $element->getVariants(true); - - foreach ($variants as $variant) { - Craft::$app->getElements()->deleteElement($variant, true); - } - - return parent::deleteElement($element); - } -} diff --git a/src/test/mockclasses/Purchasable.php b/src/test/mockclasses/Purchasable.php deleted file mode 100644 index 606d1d333a..0000000000 --- a/src/test/mockclasses/Purchasable.php +++ /dev/null @@ -1,40 +0,0 @@ - - * @author Global Network Group | Giel Tettelaar - * @since 2.1 - */ -class Purchasable extends BasePurchasable -{ - public bool $isPromotable = true; - - public float $price = 25.10; - - public function getIsPromotable(): bool - { - return $this->isPromotable; - } - - public function getPrice(string|Store|null $store = null): ?float - { - return 25.10; - } - - public function getSku(): string - { - return 'commerce_testing_unique_sku'; - } -} diff --git a/src/views/debug/commerce/detail.php b/src/views/debug/commerce/detail.php deleted file mode 100644 index 4815f8b3e4..0000000000 --- a/src/views/debug/commerce/detail.php +++ /dev/null @@ -1,35 +0,0 @@ - -

Commerce Info

- -
- data['content'] as $k => $item) { - echo Html::tag('div', $item, [ - 'class' => $k === 0 ? 'tab-pane fade active show' : 'tab-pane fade', - 'id' => 'comdebug-tab-' . $k, - ]); - } - ?> -
\ No newline at end of file diff --git a/src/views/debug/commerce/model.php b/src/views/debug/commerce/model.php deleted file mode 100644 index 75be0eae29..0000000000 --- a/src/views/debug/commerce/model.php +++ /dev/null @@ -1,22 +0,0 @@ - -

-
- - - toArray($fields ?? array_keys($model->fields()), $extraFields ?? $model->extraFields()) as $attr => $value): ?> - 0): ?> - $val): ?> - - - - - - - -
-
- diff --git a/src/views/debug/commerce/summary.php b/src/views/debug/commerce/summary.php deleted file mode 100644 index e8ecb2e0a9..0000000000 --- a/src/views/debug/commerce/summary.php +++ /dev/null @@ -1,8 +0,0 @@ - - diff --git a/src/web/assets/commercecp/src/js/CommerceSubscriptionIndex.js b/src/web/assets/commercecp/src/js/CommerceSubscriptionIndex.js deleted file mode 100644 index 9685d64729..0000000000 --- a/src/web/assets/commercecp/src/js/CommerceSubscriptionIndex.js +++ /dev/null @@ -1,14 +0,0 @@ -if (typeof Craft.Commerce === typeof undefined) { - Craft.Commerce = {}; -} - -/** - * Class Craft.Commerce.SubscriptionIndex - */ -Craft.Commerce.SubscriptionsIndex = Craft.BaseElementIndex.extend({}); - -// Register the Commerce order index class -Craft.registerElementIndexClass( - 'craft\\commerce\\elements\\Subscription', - Craft.Commerce.SubscriptionsIndex -); diff --git a/src/web/assets/commercecp/src/scss/subscriptions.scss b/src/web/assets/commercecp/src/scss/subscriptions.scss deleted file mode 100644 index 0eace9a2dd..0000000000 --- a/src/web/assets/commercecp/src/scss/subscriptions.scss +++ /dev/null @@ -1,7 +0,0 @@ -.payment-status-unpaid { - color: #d0021b; -} - -.payment-status-paid { - color: #27ae60; -} diff --git a/src/widgets/AverageOrderTotal.php b/src/widgets/AverageOrderTotal.php deleted file mode 100644 index 271438042b..0000000000 --- a/src/widgets/AverageOrderTotal.php +++ /dev/null @@ -1,136 +0,0 @@ - - * @since 3.0 - */ -class AverageOrderTotal extends Widget -{ - use StatWidgetTrait; - - /** - * @var null|AverageOrderTotalStat - */ - private ?AverageOrderTotalStat $_stat = null; - - /** - * @inheritDoc - */ - public function init(): void - { - parent::init(); - - if (!(isset($this->storeId)) || !$this->storeId) { - /** @var Site|StoreBehavior $site */ - $site = Cp::requestedSite(); - $this->storeId = $site->getStore()->id; - } - - $this->_stat = new AverageOrderTotalStat( - $this->dateRange, - DateTimeHelper::toDateTime($this->startDate, true), - DateTimeHelper::toDateTime($this->endDate, true), - $this->storeId - ); - - if (!empty($this->orderStatuses)) { - $this->_stat->setOrderStatuses($this->orderStatuses); - } - } - - /** - * @inheritdoc - */ - public static function isSelectable(): bool - { - return Craft::$app->getUser()->checkPermission('commerce-manageOrders'); - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Average Order Total'); - } - - /** - * @inheritdoc - */ - public static function icon(): ?string - { - return Craft::getAlias('@craft/commerce/icon-mask.svg'); - } - - /** - * @inheritdoc - */ - public function getTitle(): ?string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getBodyHtml(): ?string - { - $number = $this->_stat->get(); - $timeFrame = $this->_stat->getDateRangeWording(); - - $view = Craft::$app->getView(); - $view->registerAssetBundle(StatWidgetsAsset::class); - - return $view->renderTemplate('commerce/_components/widgets/orders/average/body', compact('number', 'timeFrame')); - } - - /** - * @inheritDoc - */ - public static function maxColspan(): ?int - { - return 1; - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - $id = 'average-order-total' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); - - return Craft::$app->getView()->renderTemplate('commerce/_components/widgets/orders/average/settings', [ - 'id' => $id, - 'namespaceId' => $namespaceId, - 'orderStatuses' => $this->getOrderStatusOptions(), - 'widget' => $this, - ]); - } -} diff --git a/src/widgets/NewCustomers.php b/src/widgets/NewCustomers.php deleted file mode 100644 index 76225e08a9..0000000000 --- a/src/widgets/NewCustomers.php +++ /dev/null @@ -1,138 +0,0 @@ - - * @since 3.0 - */ -class NewCustomers extends Widget -{ - use StatWidgetTrait; - - /** - * @var null|NewCustomersStat - */ - private ?NewCustomersStat $_stat = null; - - /** - * @inheritDoc - * @throws Exception - */ - public function init(): void - { - parent::init(); - - if (!(isset($this->storeId)) || !$this->storeId) { - /** @var Site|StoreBehavior $site */ - $site = Cp::requestedSite(); - $this->storeId = $site->getStore()->id; - } - - $this->_stat = new NewCustomersStat( - $this->dateRange, - DateTimeHelper::toDateTime($this->startDate, true), - DateTimeHelper::toDateTime($this->endDate, true), - $this->storeId - ); - - if (!empty($this->orderStatuses)) { - $this->_stat->setOrderStatuses($this->orderStatuses); - } - } - - /** - * @inheritdoc - */ - public static function isSelectable(): bool - { - return Craft::$app->getUser()->checkPermission('commerce-manageCustomers'); - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'New Customers'); - } - - /** - * @inheritdoc - */ - public static function icon(): ?string - { - return Craft::getAlias('@craft/commerce/icon-mask.svg'); - } - - /** - * @inheritdoc - */ - public function getTitle(): ?string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getBodyHtml(): ?string - { - $number = $this->_stat->get(); - $timeFrame = $this->_stat->getDateRangeWording(); - - $view = Craft::$app->getView(); - $view->registerAssetBundle(StatWidgetsAsset::class); - - return $view->renderTemplate('commerce/_components/widgets/customers/new/body', compact('number', 'timeFrame')); - } - - /** - * @inheritDoc - */ - public static function maxColspan(): ?int - { - return 1; - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - $id = 'new-customers' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); - - return Craft::$app->getView()->renderTemplate('commerce/_components/widgets/customers/new/settings', [ - 'id' => $id, - 'namespaceId' => $namespaceId, - 'orderStatuses' => $this->getOrderStatusOptions(), - 'widget' => $this, - ]); - } -} diff --git a/src/widgets/Orders.php b/src/widgets/Orders.php deleted file mode 100644 index baf0ee6ad4..0000000000 --- a/src/widgets/Orders.php +++ /dev/null @@ -1,155 +0,0 @@ - - * @since 2.0 - */ -class Orders extends Widget -{ - use StatWidgetTrait; - - /** - * @var int - */ - public int $limit = 10; - - public function init(): void - { - parent::init(); - - if (!(isset($this->storeId)) || !$this->storeId) { - /** @var Site|StoreBehavior $site */ - $site = Cp::requestedSite(); - $this->storeId = $site->getStore()->id; - } - } - - /** - * @inheritdoc - */ - public static function isSelectable(): bool - { - return Craft::$app->getUser()->checkPermission('commerce-manageOrders'); - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Recent Orders'); - } - - /** - * @inheritdoc - */ - public static function icon(): ?string - { - return Craft::getAlias('@craft/commerce/icon-mask.svg'); - } - - /** - * @inheritdoc - */ - public function getTitle(): ?string - { - if (!empty($this->orderStatuses) && count($this->orderStatuses) === 1) { - $orderStatus = Plugin::getInstance()->getOrderStatuses()->getOrderStatusByUid(ArrayHelper::firstValue($this->orderStatuses), $this->storeId); - - if ($orderStatus) { - return Craft::t('commerce', 'Recent Orders') . ' – ' . Craft::t('commerce', $orderStatus->name); - } - } - - return parent::getTitle(); - } - - /** - * @inheritdoc - */ - public function getBodyHtml(): ?string - { - $orders = $this->_getOrders(); - - $id = 'recent-orders-settings-' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - - return Craft::$app->getView()->renderTemplate('commerce/_components/widgets/orders/recent/body', [ - 'orders' => $orders, - 'showStatuses' => !empty($this->orderStatuses) && count($this->orderStatuses) > 1, - 'id' => $id, - 'namespaceId' => $namespaceId, - ]); - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - Craft::$app->getView()->registerAssetBundle(OrdersWidgetAsset::class); - Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); - - $id = 'recent-orders-settings-' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - return Craft::$app->getView()->renderTemplate('commerce/_components/widgets/orders/recent/settings', [ - 'id' => $id, - 'widget' => $this, - 'orderStatuses' => $this->getOrderStatusOptions(), - 'namespaceId' => $namespaceId, - ]); - } - - - /** - * Returns the recent entries, based on the widget settings and user permissions. - * - * @return Order[] - */ - private function _getOrders(): array - { - $limit = $this->limit; - - $query = Order::find(); - $query->isCompleted(true); - $query->dateOrdered(':notempty:'); - $query->limit($limit); - $query->storeId($this->storeId); - $query->orderBy('dateOrdered DESC'); - - if (!empty($this->orderStatuses)) { - $orderStatusIds = Plugin::getInstance()->getOrderStatuses()->getAllOrderStatuses($this->storeId) - ->filter(fn($orderStatus) => in_array($orderStatus->uid, $this->orderStatuses))->map(fn($os) => $os->id)->all(); - $query->orderStatusId($orderStatusIds); - } - - return $query->all(); - } -} diff --git a/src/widgets/RepeatCustomers.php b/src/widgets/RepeatCustomers.php deleted file mode 100644 index 153b310edb..0000000000 --- a/src/widgets/RepeatCustomers.php +++ /dev/null @@ -1,138 +0,0 @@ - - * @since 3.0 - */ -class RepeatCustomers extends Widget -{ - use StatWidgetTrait; - - /** - * @var null|RepeatingCustomersStat - */ - private ?RepeatingCustomersStat $_stat = null; - - /** - * @inheritDoc - */ - public function init(): void - { - parent::init(); - - if (!(isset($this->storeId)) || !$this->storeId) { - /** @var Site|StoreBehavior $site */ - $site = Cp::requestedSite(); - $this->storeId = $site->getStore()->id; - } - - $this->dateRange = !isset($this->dateRange) || !$this->dateRange ? RepeatingCustomersStat::DATE_RANGE_TODAY : $this->dateRange; - - $this->_stat = new RepeatingCustomersStat( - $this->dateRange, - DateTimeHelper::toDateTime($this->startDate, true), - DateTimeHelper::toDateTime($this->endDate, true), - $this->storeId - ); - - if (!empty($this->orderStatuses)) { - $this->_stat->setOrderStatuses($this->orderStatuses); - } - } - - /** - * @inheritdoc - */ - public static function isSelectable(): bool - { - return Craft::$app->getUser()->checkPermission('commerce-manageCustomers'); - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Repeat Customers'); - } - - /** - * @inheritdoc - */ - public static function icon(): ?string - { - return Craft::getAlias('@craft/commerce/icon-mask.svg'); - } - - /** - * @inheritdoc - */ - public function getTitle(): ?string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getBodyHtml(): ?string - { - $numbers = $this->_stat->get(); - $timeFrame = $this->_stat->getDateRangeWording(); - - $view = Craft::$app->getView(); - $view->registerAssetBundle(StatWidgetsAsset::class); - - return $view->renderTemplate('commerce/_components/widgets/customers/repeat/body', compact('numbers', 'timeFrame')); - } - - /** - * @inheritDoc - */ - public static function maxColspan(): ?int - { - return 1; - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - $id = 'repeat' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); - - return Craft::$app->getView()->renderTemplate('commerce/_components/widgets/customers/repeat/settings', [ - 'id' => $id, - 'namespaceId' => $namespaceId, - 'orderStatuses' => $this->getOrderStatusOptions(), - 'widget' => $this, - ]); - } -} diff --git a/src/widgets/TopCustomers.php b/src/widgets/TopCustomers.php deleted file mode 100644 index 3202a06d3f..0000000000 --- a/src/widgets/TopCustomers.php +++ /dev/null @@ -1,176 +0,0 @@ - - * @since 3.0 - */ -class TopCustomers extends Widget -{ - use StatWidgetTrait; - - /** - * @var string|null Options 'total', 'average'. - */ - public ?string $type = null; - - /** - * @var TopCustomersStat - */ - private TopCustomersStat $_stat; - - /** - * @var string - */ - private string $_title; - - /** - * @var array - */ - private array $_typeOptions; - - /** - * @inheritDoc - */ - public function init(): void - { - if (!(isset($this->storeId)) || !$this->storeId) { - /** @var Site|StoreBehavior $site */ - $site = Cp::requestedSite(); - $this->storeId = $site->getStore()->id; - } - - $this->_typeOptions = [ - 'total' => Craft::t('commerce', 'Total'), - 'average' => Craft::t('commerce', 'Average'), - ]; - - $this->_title = match ($this->type) { - 'average' => Craft::t('commerce', 'Top Customers by Average Order'), - 'total' => Craft::t('commerce', 'Top Customers by Total Revenue'), - default => Craft::t('commerce', 'Top Customers'), - }; - $this->dateRange = !isset($this->dateRange) || !$this->dateRange ? TopCustomersStat::DATE_RANGE_TODAY : $this->dateRange; - - $this->_stat = new TopCustomersStat( - $this->dateRange, - $this->type, - DateTimeHelper::toDateTime($this->startDate, true), - DateTimeHelper::toDateTime($this->endDate, true), - $this->storeId - ); - - if (!empty($this->orderStatuses)) { - $this->_stat->setOrderStatuses($this->orderStatuses); - } - - parent::init(); - } - - /** - * @inheritdoc - */ - public static function isSelectable(): bool - { - return Craft::$app->getUser()->checkPermission('commerce-manageOrders') && Craft::$app->getUser()->checkPermission('commerce-manageCustomers'); - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Top Customers'); - } - - /** - * @inheritdoc - */ - public static function icon(): ?string - { - return Craft::getAlias('@craft/commerce/icon-mask.svg'); - } - - /** - * @inheritdoc - */ - public function getTitle(): ?string - { - return $this->_title; - } - - /** - * @inheritDoc - */ - public function getSubtitle(): ?string - { - return $this->_stat->getDateRangeWording(); - } - - /** - * @inheritdoc - */ - public function getBodyHtml(): ?string - { - $stats = $this->_stat->get(); - - if (empty($stats)) { - return Html::tag('p', Craft::t('commerce', 'No stats available.'), ['class' => 'zilch']); - } - - $view = Craft::$app->getView(); - $view->registerAssetBundle(StatWidgetsAsset::class); - $view->registerAssetBundle(AdminTableAsset::class); - - return $view->renderTemplate('commerce/_components/widgets/customers/top/body', [ - 'stats' => $stats, - 'type' => $this->type, - 'typeLabel' => $this->_typeOptions[$this->type] ?? '', - 'id' => 'top-products' . StringHelper::randomString(), - ]); - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - $id = 'top-products' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); - - return Craft::$app->getView()->renderTemplate('commerce/_components/widgets/customers/top/settings', [ - 'id' => $id, - 'namespaceId' => $namespaceId, - 'orderStatuses' => $this->getOrderStatusOptions(), - 'widget' => $this, - 'typeOptions' => $this->_typeOptions, - ]); - } -} diff --git a/src/widgets/TopProductTypes.php b/src/widgets/TopProductTypes.php deleted file mode 100644 index a497e7ef79..0000000000 --- a/src/widgets/TopProductTypes.php +++ /dev/null @@ -1,177 +0,0 @@ - - * @since 3.0 - */ -class TopProductTypes extends Widget -{ - use StatWidgetTrait; - - /** - * @var string|null Options 'revenue', 'qty'. - */ - public ?string $type = null; - - /** - * @var TopProductTypesStat - */ - private TopProductTypesStat $_stat; - - /** - * @var string - */ - private string $_title; - - /** - * @var array - */ - private array $_typeOptions; - - /** - * @inheritDoc - */ - public function init(): void - { - if (!(isset($this->storeId)) || !$this->storeId) { - /** @var Site|StoreBehavior $site */ - $site = Cp::requestedSite(); - $this->storeId = $site->getStore()->id; - } - - $this->_typeOptions = [ - 'qty' => Craft::t('commerce', 'Qty'), - 'revenue' => Craft::t('commerce', 'Revenue'), - ]; - - $this->_title = match ($this->type) { - 'revenue' => Craft::t('commerce', 'Top Product Types by Revenue'), - 'qty' => Craft::t('commerce', 'Top Product Types by Qty Sold'), - default => Craft::t('commerce', 'Top Product Types'), - }; - - $this->dateRange = !isset($this->dateRange) || !$this->dateRange ? TopProductTypesStat::DATE_RANGE_TODAY : $this->dateRange; - - $this->_stat = new TopProductTypesStat( - $this->dateRange, - $this->type, - DateTimeHelper::toDateTime($this->startDate, true), - DateTimeHelper::toDateTime($this->endDate, true), - $this->storeId - ); - - if (!empty($this->orderStatuses)) { - $this->_stat->setOrderStatuses($this->orderStatuses); - } - - parent::init(); - } - - /** - * @inheritdoc - */ - public static function isSelectable(): bool - { - return Craft::$app->getUser()->checkPermission('commerce-manageOrders'); - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Top Product Types'); - } - - /** - * @inheritdoc - */ - public static function icon(): ?string - { - return Craft::getAlias('@craft/commerce/icon-mask.svg'); - } - - /** - * @inheritdoc - */ - public function getTitle(): ?string - { - return $this->_title; - } - - /** - * @inheritDoc - */ - public function getSubtitle(): ?string - { - return $this->_stat->getDateRangeWording(); - } - - /** - * @inheritdoc - */ - public function getBodyHtml(): ?string - { - $stats = $this->_stat->get(); - - if (empty($stats)) { - return Html::tag('p', Craft::t('commerce', 'No stats available.'), ['class' => 'zilch']); - } - - $view = Craft::$app->getView(); - $view->registerAssetBundle(StatWidgetsAsset::class); - $view->registerAssetBundle(AdminTableAsset::class); - - return $view->renderTemplate('commerce/_components/widgets/producttypes/top/body', [ - 'stats' => $stats, - 'type' => $this->type, - 'typeLabel' => $this->_typeOptions[$this->type] ?? '', - 'id' => 'top-products' . StringHelper::randomString(), - ]); - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - $id = 'top-products' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); - - return Craft::$app->getView()->renderTemplate('commerce/_components/widgets/producttypes/top/settings', [ - 'id' => $id, - 'namespaceId' => $namespaceId, - 'orderStatuses' => $this->getOrderStatusOptions(), - 'widget' => $this, - 'typeOptions' => $this->_typeOptions, - ]); - } -} diff --git a/src/widgets/TopProducts.php b/src/widgets/TopProducts.php deleted file mode 100644 index bc4705e52b..0000000000 --- a/src/widgets/TopProducts.php +++ /dev/null @@ -1,234 +0,0 @@ - - * @since 3.0 - */ -class TopProducts extends Widget -{ - use StatWidgetTrait; - - /** - * @var string|null Options 'revenue', 'qty'. - */ - public ?string $type = null; - - /** - * @var array|null - */ - public ?array $revenueOptions = [ - TopProductsStat::REVENUE_OPTION_DISCOUNT, - TopProductsStat::REVENUE_OPTION_TAX_INCLUDED, - TopProductsStat::REVENUE_OPTION_TAX, - TopProductsStat::REVENUE_OPTION_SHIPPING, - ]; - - /** - * @var TopProductsStat - */ - private TopProductsStat $_stat; - - /** - * @var string - */ - private string $_title; - - /** - * @var array - */ - private array $_typeOptions; - - /** - * @var array - */ - private array $_revenueCheckboxOptions; - - /** - * @inheritDoc - */ - public function init(): void - { - parent::init(); - - if (!(isset($this->storeId)) || !$this->storeId) { - /** @var Site|StoreBehavior $site */ - $site = Cp::requestedSite(); - $this->storeId = $site->getStore()->id; - } - - $this->_typeOptions = [ - TopProductsStat::TYPE_QTY => Craft::t('commerce', 'Qty'), - TopProductsStat::TYPE_REVENUE => Craft::t('commerce', 'Revenue'), - ]; - - $this->_revenueCheckboxOptions = [ - [ - 'value' => TopProductsStat::REVENUE_OPTION_DISCOUNT, - 'label' => Craft::t('commerce', 'Discount'), - 'checked' => in_array(TopProductsStat::REVENUE_OPTION_DISCOUNT, $this->revenueOptions, true), - 'instructions' => Craft::t('commerce', 'Include line item discounts.'), - ], - [ - 'value' => TopProductsStat::REVENUE_OPTION_TAX_INCLUDED, - 'label' => Craft::t('commerce', 'Tax (inc)'), - 'checked' => in_array(TopProductsStat::REVENUE_OPTION_TAX_INCLUDED, $this->revenueOptions, true), - 'instructions' => Craft::t('commerce', 'Include built-in line item tax.'), - ], - [ - 'value' => TopProductsStat::REVENUE_OPTION_TAX, - 'label' => Craft::t('commerce', 'Tax'), - 'checked' => in_array(TopProductsStat::REVENUE_OPTION_TAX, $this->revenueOptions, true), - 'instructions' => Craft::t('commerce', 'Include separate line item tax.'), - ], - [ - 'value' => TopProductsStat::REVENUE_OPTION_SHIPPING, - 'label' => Craft::t('commerce', 'Shipping'), - 'checked' => in_array(TopProductsStat::REVENUE_OPTION_SHIPPING, $this->revenueOptions, true), - 'instructions' => Craft::t('commerce', 'Include line item shipping costs.'), - ], - ]; - - $this->_title = match ($this->type) { - 'revenue' => Craft::t('commerce', 'Top Products by Revenue'), - 'qty' => Craft::t('commerce', 'Top Products by Qty Sold'), - default => Craft::t('commerce', 'Top Products'), - }; - - $this->dateRange = !isset($this->dateRange) || !$this->dateRange ? TopProductsStat::DATE_RANGE_TODAY : $this->dateRange; - - $this->_stat = new TopProductsStat( - $this->dateRange, - $this->type, - DateTimeHelper::toDateTime($this->startDate, true), - DateTimeHelper::toDateTime($this->endDate, true), - $this->revenueOptions, - $this->storeId - ); - - if (!empty($this->orderStatuses)) { - $this->_stat->setOrderStatuses($this->orderStatuses); - } - } - - /** - * @inheritdoc - */ - public static function isSelectable(): bool - { - return Craft::$app->getUser()->checkPermission('commerce-manageOrders'); - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Top Products'); - } - - /** - * @inheritdoc - */ - public static function icon(): ?string - { - return Craft::getAlias('@craft/commerce/icon-mask.svg'); - } - - /** - * @inheritdoc - */ - public function getTitle(): ?string - { - return $this->_title; - } - - /** - * @inheritDoc - */ - public function getSubtitle(): ?string - { - return $this->_stat->getDateRangeWording(); - } - - /** - * @inheritdoc - */ - public function getBodyHtml(): ?string - { - $stats = $this->_stat->get(); - - if (empty($stats)) { - return Html::tag('p', Craft::t('commerce', 'No stats available.'), ['class' => 'zilch']); - } - - $view = Craft::$app->getView(); - $view->registerAssetBundle(StatWidgetsAsset::class); - $view->registerAssetBundle(AdminTableAsset::class); - - $revenueOptions = [ - TopProductsStat::REVENUE_OPTION_DISCOUNT, - TopProductsStat::REVENUE_OPTION_TAX_INCLUDED, - TopProductsStat::REVENUE_OPTION_TAX, - TopProductsStat::REVENUE_OPTION_SHIPPING, - ]; - $revenueColumnHandle = 'revenue'; - if ($this->type === TopProductsStat::TYPE_REVENUE && count(array_intersect($revenueOptions, $this->revenueOptions)) !== count($revenueOptions)) { - $revenueColumnHandle = 'revenue_custom'; - } - - return $view->renderTemplate('commerce/_components/widgets/products/top/body', [ - 'stats' => $stats, - 'revenueColumnHandle' => $revenueColumnHandle, - 'type' => $this->type, - 'typeLabel' => $this->_typeOptions[$this->type] ?? '', - 'id' => 'top-products' . StringHelper::randomString(), - ]); - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - $id = 'top-products' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); - - return Craft::$app->getView()->renderTemplate('commerce/_components/widgets/products/top/settings', [ - 'id' => $id, - 'namespaceId' => $namespaceId, - 'widget' => $this, - 'typeOptions' => $this->_typeOptions, - 'revenueOptions' => $this->_revenueCheckboxOptions, - 'orderStatuses' => $this->getOrderStatusOptions(), - 'isRevenueOptionsEnabled' => $this->type === TopProductsStat::TYPE_REVENUE, - ]); - } -} diff --git a/src/widgets/TopPurchasables.php b/src/widgets/TopPurchasables.php deleted file mode 100644 index 953ab96a24..0000000000 --- a/src/widgets/TopPurchasables.php +++ /dev/null @@ -1,197 +0,0 @@ - - * @since 3.0 - */ -class TopPurchasables extends Widget -{ - use StatWidgetTrait; - - /** - * @var string|null Options 'revenue', 'qty'. - */ - public ?string $type = null; - - /** - * @var string options 'description', 'sku'. - */ - public string $nameField; - - /** - * @var TopPurchasablesStat - */ - private TopPurchasablesStat $_stat; - - /** - * @var string - */ - private string $_title; - - /** - * @var array - */ - private array $_typeOptions; - - /** - * @var array - */ - private array $_nameFieldOptions; - - /** - * @inheritDoc - */ - public function init(): void - { - if (!(isset($this->storeId)) || !$this->storeId) { - /** @var Site|StoreBehavior $site */ - $site = Cp::requestedSite(); - $this->storeId = $site->getStore()->id; - } - - $this->nameField = isset($this->nameField) ?: 'description'; - - $this->_nameFieldOptions = [ - 'description' => Craft::t('commerce', 'Description'), - 'sku' => Craft::t('commerce', 'SKU'), - ]; - - $this->_typeOptions = [ - 'qty' => Craft::t('commerce', 'Qty'), - 'revenue' => Craft::t('commerce', 'Revenue'), - ]; - - $this->_title = match ($this->type) { - 'revenue' => Craft::t('commerce', 'Top Purchasables by Revenue'), - 'qty' => Craft::t('commerce', 'Top Purchasables by Qty Sold'), - default => Craft::t('commerce', 'Top Purchasables'), - }; - - $this->dateRange = !isset($this->dateRange) || !$this->dateRange ? TopPurchasablesStat::DATE_RANGE_TODAY : $this->dateRange; - - $this->_stat = new TopPurchasablesStat( - $this->dateRange, - $this->type, - DateTimeHelper::toDateTime($this->startDate, true), - DateTimeHelper::toDateTime($this->endDate, true), - $this->storeId - ); - - if (!empty($this->orderStatuses)) { - $this->_stat->setOrderStatuses($this->orderStatuses); - } - - parent::init(); - } - - /** - * @inheritdoc - */ - public static function isSelectable(): bool - { - return Craft::$app->getUser()->checkPermission('commerce-manageOrders'); - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Top Purchasables'); - } - - /** - * @inheritdoc - */ - public static function icon(): ?string - { - return Craft::getAlias('@craft/commerce/icon-mask.svg'); - } - - /** - * @inheritdoc - */ - public function getTitle(): ?string - { - return $this->_title; - } - - /** - * @inheritDoc - */ - public function getSubtitle(): ?string - { - return $this->_stat->getDateRangeWording(); - } - - /** - * @inheritdoc - */ - public function getBodyHtml(): ?string - { - $stats = $this->_stat->get(); - - if (empty($stats)) { - return Html::tag('p', Craft::t('commerce', 'No stats available.'), ['class' => 'zilch']); - } - - $view = Craft::$app->getView(); - $view->registerAssetBundle(StatWidgetsAsset::class); - $view->registerAssetBundle(AdminTableAsset::class); - - return $view->renderTemplate('commerce/_components/widgets/purchasables/top/body', [ - 'stats' => $stats, - 'type' => $this->type, - 'nameField' => $this->nameField, - 'nameFieldLabel' => $this->_nameFieldOptions[$this->nameField] ?? '', - 'typeLabel' => $this->_typeOptions[$this->type] ?? '', - 'id' => 'top-purchasables' . StringHelper::randomString(), - ]); - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - $id = 'top-purchasables' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); - - return Craft::$app->getView()->renderTemplate('commerce/_components/widgets/purchasables/top/settings', [ - 'id' => $id, - 'namespaceId' => $namespaceId, - 'widget' => $this, - 'orderStatuses' => $this->getOrderStatusOptions(), - 'typeOptions' => $this->_typeOptions, - 'nameFieldOptions' => $this->_nameFieldOptions, - ]); - } -} diff --git a/src/widgets/TotalOrders.php b/src/widgets/TotalOrders.php deleted file mode 100644 index b60d591b3d..0000000000 --- a/src/widgets/TotalOrders.php +++ /dev/null @@ -1,182 +0,0 @@ - - * @since 3.0 - */ -class TotalOrders extends Widget -{ - use StatWidgetTrait; - - /** - * @var int|bool - */ - public mixed $showChart = null; - - /** - * @var null|TotalOrdersStat - */ - private ?TotalOrdersStat $_stat = null; - - public function init(): void - { - parent::init(); - - if (!(isset($this->storeId)) || !$this->storeId) { - /** @var Site|StoreBehavior $site */ - $site = Cp::requestedSite(); - $this->storeId = $site->getStore()->id; - } - - $this->dateRange = !isset($this->dateRange) || !$this->dateRange ? TotalOrdersStat::DATE_RANGE_TODAY : $this->dateRange; - - $this->_stat = new TotalOrdersStat( - $this->dateRange, - DateTimeHelper::toDateTime($this->startDate, true), - DateTimeHelper::toDateTime($this->endDate, true), - $this->storeId - ); - - if (!empty($this->orderStatuses)) { - $this->_stat->setOrderStatuses($this->orderStatuses); - } - } - - /** - * @inheritdoc - */ - public static function isSelectable(): bool - { - return Craft::$app->getUser()->checkPermission('commerce-manageOrders'); - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Total Orders'); - } - - /** - * @inheritdoc - */ - public static function icon(): ?string - { - return Craft::getAlias('@craft/commerce/icon-mask.svg'); - } - - /** - * @inheritdoc - */ - public function getTitle(): ?string - { - if (!$this->showChart) { - return ''; - } - - $stats = $this->_stat->get(); - $total = $stats['total'] ?? 0; - $total = Craft::$app->getFormatter()->asInteger($total); - - return Craft::t('commerce', '{total} orders', ['total' => $total]); - } - - public function getSubtitle(): ?string - { - if (!$this->showChart) { - return ''; - } - - return $this->_stat->getDateRangeWording(); - } - - /** - * @inheritdoc - */ - public function getBodyHtml(): ?string - { - $showChart = $this->showChart; - $stats = $this->_stat->get(); - - if (empty($stats)) { - return Html::tag('p', Craft::t('commerce', 'No stats available.'), ['class' => 'zilch']); - } - - $number = $stats['total'] ?? 0; - $chart = $stats['chart'] ?? []; - - $labels = ArrayHelper::getColumn($chart, 'datekey', false); - $data = ArrayHelper::getColumn($chart, 'total', false); - - $timeFrame = $this->_stat->getDateRangeWording(); - $number = Craft::$app->getFormatter()->asInteger($number); - - $id = 'total-orders' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - $view = Craft::$app->getView(); - $view->registerAssetBundle(StatWidgetsAsset::class); - - return $view->renderTemplate('commerce/_components/widgets/orders/total/body', compact( - 'namespaceId', - 'number', - 'timeFrame', - 'labels', - 'data', - 'showChart' - )); - } - - /** - * @inheritDoc - */ - public static function maxColspan(): ?int - { - return 1; - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - $id = 'total-orders' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); - - return Craft::$app->getView()->renderTemplate('commerce/_components/widgets/orders/total/settings', [ - 'id' => $id, - 'namespaceId' => $namespaceId, - 'orderStatuses' => $this->getOrderStatusOptions(), - 'widget' => $this, - ]); - } -} diff --git a/src/widgets/TotalOrdersByCountry.php b/src/widgets/TotalOrdersByCountry.php deleted file mode 100644 index 5b864e7b22..0000000000 --- a/src/widgets/TotalOrdersByCountry.php +++ /dev/null @@ -1,186 +0,0 @@ - - * @since 3.0 - */ -class TotalOrdersByCountry extends Widget -{ - use StatWidgetTrait; - - /** - * @var string Options 'billing', 'shipping'. - */ - public string $type; - - /** - * @var TotalOrdersByCountryStat - */ - private TotalOrdersByCountryStat $_stat; - - /** - * @var string - */ - private string $_title; - - /** - * @var array - */ - private array $_typeOptions; - - /** - * @inheritDoc - */ - public function init(): void - { - parent::init(); - - if (!(isset($this->storeId)) || !$this->storeId) { - /** @var Site|StoreBehavior $site */ - $site = Cp::requestedSite(); - $this->storeId = $site->getStore()->id; - } - - $this->_typeOptions = [ - 'billing' => Craft::t('commerce', 'Billing'), - 'shipping' => Craft::t('commerce', 'Shipping'), - ]; - - if (isset($this->type) && $this->type == 'billing') { - $this->_title = Craft::t('commerce', 'Total Orders by Billing Country'); - } else { - $this->_title = Craft::t('commerce', 'Total Orders by Shipping Country'); - $this->type = 'shipping'; - } - - $this->dateRange = !isset($this->dateRange) || !$this->dateRange ? TotalOrdersByCountryStat::DATE_RANGE_TODAY : $this->dateRange; - - $this->_stat = new TotalOrdersByCountryStat( - $this->dateRange, - $this->type, - DateTimeHelper::toDateTime($this->startDate, true), - DateTimeHelper::toDateTime($this->endDate, true), - $this->storeId - ); - - if (!empty($this->orderStatuses)) { - $this->_stat->setOrderStatuses($this->orderStatuses); - } - } - - /** - * @inheritDoc - */ - public function getTitle(): ?string - { - return $this->_title; - } - - /** - * @inheritDoc - */ - public function getSubtitle(): ?string - { - return $this->_stat->getDateRangeWording(); - } - - /** - * @inheritdoc - */ - public static function isSelectable(): bool - { - return Craft::$app->getUser()->checkPermission('commerce-manageOrders'); - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Total Orders by Country'); - } - - /** - * @inheritdoc - */ - public static function icon(): ?string - { - return Craft::getAlias('@craft/commerce/icon-mask.svg'); - } - - /** - * @inheritdoc - */ - public function getBodyHtml(): ?string - { - $stats = $this->_stat->get(); - - if (empty($stats)) { - return Html::tag('p', Craft::t('commerce', 'No stats available.'), ['class' => 'zilch']); - } - - $view = Craft::$app->getView(); - $view->registerAssetBundle(StatWidgetsAsset::class); - - $id = 'total-revenue' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - $labels = ArrayHelper::getColumn($stats, 'name', false); - $totalOrders = ArrayHelper::getColumn($stats, 'total', false); - - return $view->renderTemplate('commerce/_components/widgets/orders/country/body', - compact( - 'stats', - 'namespaceId', - 'labels', - 'totalOrders' - ) - ); - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - $id = 'total-orders' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); - - return Craft::$app->getView()->renderTemplate('commerce/_components/widgets/orders/country/settings', [ - 'id' => $id, - 'namespaceId' => $namespaceId, - 'orderStatuses' => $this->getOrderStatusOptions(), - 'widget' => $this, - 'typeOptions' => $this->_typeOptions, - ]); - } -} diff --git a/src/widgets/TotalRevenue.php b/src/widgets/TotalRevenue.php deleted file mode 100644 index 34f0190dd6..0000000000 --- a/src/widgets/TotalRevenue.php +++ /dev/null @@ -1,214 +0,0 @@ - - * @since 3.0 - */ -class TotalRevenue extends Widget -{ - use StatWidgetTrait; - - /** - * @var string - * @since 4.1.0 - */ - public string $type = TotalRevenueStat::TYPE_TOTAL; - - /** - * @var bool - */ - public bool $showOrderCount = false; - - /** - * @var TotalRevenueStat - */ - private TotalRevenueStat $_stat; - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['type'], 'in', 'range' => [TotalRevenueStat::TYPE_TOTAL, TotalRevenueStat::TYPE_TOTAL_PAID]]; - - return $rules; - } - - /** - * @inheritDoc - */ - public function init(): void - { - parent::init(); - - if (!(isset($this->storeId)) || !$this->storeId) { - /** @var Site|StoreBehavior $site */ - $site = Cp::requestedSite(); - $this->storeId = $site->getStore()->id; - } - - $this->dateRange = !isset($this->dateRange) || !$this->dateRange ? TotalRevenueStat::DATE_RANGE_TODAY : $this->dateRange; - - $this->_stat = new TotalRevenueStat( - $this->dateRange, - DateTimeHelper::toDateTime($this->startDate, true), - DateTimeHelper::toDateTime($this->endDate, true), - $this->storeId - ); - - if (!empty($this->orderStatuses)) { - $this->_stat->setOrderStatuses($this->orderStatuses); - } - - $this->_stat->type = $this->type; - } - - /** - * @inheritdoc - */ - public static function isSelectable(): bool - { - return Craft::$app->getUser()->checkPermission('commerce-manageOrders'); - } - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('commerce', 'Total Revenue'); - } - - /** - * @inheritdoc - */ - public function getTitle(): ?string - { - $stats = $this->_stat->get(); - $revenue = ArrayHelper::getColumn($stats, 'revenue', false); - $total = round(array_sum($revenue), 0, PHP_ROUND_HALF_DOWN); - - $formattedTotal = Currency::formatAsCurrency($total, $this->getStore()->getCurrency()->getCode(), false, true, true); - - return Craft::t('commerce', '{total} in total revenue', ['total' => $formattedTotal]); - } - - /** - * @inheritDoc - */ - public function getSubtitle(): ?string - { - return $this->_stat->getDateRangeWording(); - } - - /** - * @inheritdoc - */ - public static function icon(): ?string - { - return Craft::getAlias('@craft/commerce/icon-mask.svg'); - } - - /** - * @inheritdoc - */ - public function getBodyHtml(): ?string - { - $stats = $this->_stat->get(); - $timeFrame = $this->_stat->getDateRangeWording(); - $chartInterval = $this->_stat->getDateRangeInterval(); - - $view = Craft::$app->getView(); - $view->registerAssetBundle(StatWidgetsAsset::class); - - $id = 'total-revenue' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - if (empty($stats)) { - return Html::tag('p', Craft::t('commerce', 'No stats available.'), ['class' => 'zilch']); - } - - $labels = ArrayHelper::getColumn($stats, 'datekey', false); - if ($this->_stat->getDateRangeInterval() == 'month') { - $labels = array_map(static function($label) { - [$year, $month] = explode('-', $label); - $month = $month < 10 ? '0' . $month : $month; - return implode('-', [$year, $month, '01']); - }, $labels); - } elseif ($this->_stat->getDateRangeInterval() == 'week') { - $labels = array_map(static function($label) { - $year = substr($label, 0, 4); - $week = substr($label, -2); - return $year . 'W' . $week; - }, $labels); - } - - $revenue = ArrayHelper::getColumn($stats, 'revenue', false); - $orderCount = ArrayHelper::getColumn($stats, 'count', false); - $widget = $this; - - return $view->renderTemplate('commerce/_components/widgets/orders/revenue/body', - compact( - 'widget', - 'stats', - 'timeFrame', - 'namespaceId', - 'labels', - 'revenue', - 'orderCount', - 'chartInterval' - ) - ); - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - $id = 'total-revenue' . StringHelper::randomString(); - $namespaceId = Craft::$app->getView()->namespaceInputId($id); - - Craft::$app->getView()->registerAssetBundle(CommerceWidgetsAsset::class); - - return Craft::$app->getView()->renderTemplate('commerce/_components/widgets/orders/revenue/settings', [ - 'id' => $id, - 'namespaceId' => $namespaceId, - 'widget' => $this, - 'orderStatuses' => $this->getOrderStatusOptions(), - 'types' => [ - TotalRevenueStat::TYPE_TOTAL => Craft::t('commerce', 'Total'), - TotalRevenueStat::TYPE_TOTAL_PAID => Craft::t('commerce', 'Total Paid'), - ], - ]); - } -} diff --git a/testbench.yaml b/testbench.yaml new file mode 100644 index 0000000000..c337fd2038 --- /dev/null +++ b/testbench.yaml @@ -0,0 +1,5 @@ +providers: + - CraftCms\Aliases\AliasesServiceProvider + - CraftCms\DependencyAwareCache\CacheServiceProvider + - CraftCms\Cms\Providers\CraftServiceProvider + - CraftCms\Yii2Adapter\Yii2ServiceProvider diff --git a/tests/fixtures/BaseModelFixture.php b/tests-yii2/fixtures/BaseModelFixture.php similarity index 100% rename from tests/fixtures/BaseModelFixture.php rename to tests-yii2/fixtures/BaseModelFixture.php diff --git a/tests/fixtures/CategoriesFixture.php b/tests-yii2/fixtures/CategoriesFixture.php similarity index 100% rename from tests/fixtures/CategoriesFixture.php rename to tests-yii2/fixtures/CategoriesFixture.php diff --git a/tests/fixtures/CustomerAddressFixture.php b/tests-yii2/fixtures/CustomerAddressFixture.php similarity index 100% rename from tests/fixtures/CustomerAddressFixture.php rename to tests-yii2/fixtures/CustomerAddressFixture.php diff --git a/tests/fixtures/CustomerFixture.php b/tests-yii2/fixtures/CustomerFixture.php similarity index 100% rename from tests/fixtures/CustomerFixture.php rename to tests-yii2/fixtures/CustomerFixture.php diff --git a/tests/fixtures/DiscountsFixture.php b/tests-yii2/fixtures/DiscountsFixture.php similarity index 93% rename from tests/fixtures/DiscountsFixture.php rename to tests-yii2/fixtures/DiscountsFixture.php index 0844395810..96ce3590a7 100644 --- a/tests/fixtures/DiscountsFixture.php +++ b/tests-yii2/fixtures/DiscountsFixture.php @@ -10,7 +10,7 @@ use craft\commerce\models\Coupon; use craft\commerce\models\Discount; use craft\commerce\Plugin; -use craft\commerce\records\Coupon as CouponRecord; +use CraftCms\Commerce\Promotion\Records\Coupon as CouponRecord; /** * Class DiscountsFixture. @@ -85,7 +85,7 @@ public function unload(): void // @TODO Investigate why the FK cascade delete on coupons does not fire during fixture unload, then remove this manual cleanup if (isset($this->data) && !empty($this->data)) { foreach ($this->data as $discount) { - $coupons = CouponRecord::find()->where(['discountId' => $discount['id']])->all(); + $coupons = CouponRecord::where('discountId', $discount['id'])->get(); if (empty($coupons)) { continue; diff --git a/tests/fixtures/EmailsFixture.php b/tests-yii2/fixtures/EmailsFixture.php similarity index 100% rename from tests/fixtures/EmailsFixture.php rename to tests-yii2/fixtures/EmailsFixture.php diff --git a/tests/fixtures/FieldLayoutFixture.php b/tests-yii2/fixtures/FieldLayoutFixture.php similarity index 100% rename from tests/fixtures/FieldLayoutFixture.php rename to tests-yii2/fixtures/FieldLayoutFixture.php diff --git a/tests/fixtures/GqlSchemasFixture.php b/tests-yii2/fixtures/GqlSchemasFixture.php similarity index 100% rename from tests/fixtures/GqlSchemasFixture.php rename to tests-yii2/fixtures/GqlSchemasFixture.php diff --git a/tests/fixtures/OrderStatusesFixture.php b/tests-yii2/fixtures/OrderStatusesFixture.php similarity index 100% rename from tests/fixtures/OrderStatusesFixture.php rename to tests-yii2/fixtures/OrderStatusesFixture.php diff --git a/tests/fixtures/OrdersFixture.php b/tests-yii2/fixtures/OrdersFixture.php similarity index 100% rename from tests/fixtures/OrdersFixture.php rename to tests-yii2/fixtures/OrdersFixture.php diff --git a/tests/fixtures/PaymentCurrenciesFixture.php b/tests-yii2/fixtures/PaymentCurrenciesFixture.php similarity index 100% rename from tests/fixtures/PaymentCurrenciesFixture.php rename to tests-yii2/fixtures/PaymentCurrenciesFixture.php diff --git a/tests-yii2/fixtures/ProductFixture.php b/tests-yii2/fixtures/ProductFixture.php new file mode 100644 index 0000000000..fb3e23de34 --- /dev/null +++ b/tests-yii2/fixtures/ProductFixture.php @@ -0,0 +1,31 @@ + + * @since 3.1.4 + * @method Product getElement(string $key) + */ +class ProductFixture extends BaseProductFixture +{ + /** + * @inheritdoc + */ + public $dataFile = __DIR__ . '/data/products.php'; + + /** + * @inheritdoc + */ + public $depends = [ProductTypeFixture::class]; +} diff --git a/tests/fixtures/ProductTypeFixture.php b/tests-yii2/fixtures/ProductTypeFixture.php similarity index 100% rename from tests/fixtures/ProductTypeFixture.php rename to tests-yii2/fixtures/ProductTypeFixture.php diff --git a/tests/fixtures/ProductTypeSitesFixture.php b/tests-yii2/fixtures/ProductTypeSitesFixture.php similarity index 100% rename from tests/fixtures/ProductTypeSitesFixture.php rename to tests-yii2/fixtures/ProductTypeSitesFixture.php diff --git a/tests-yii2/fixtures/ProductTypesShippingCategoriesFixture.php b/tests-yii2/fixtures/ProductTypesShippingCategoriesFixture.php new file mode 100644 index 0000000000..ec40c922bd --- /dev/null +++ b/tests-yii2/fixtures/ProductTypesShippingCategoriesFixture.php @@ -0,0 +1,44 @@ +data = $this->loadData($this->dataFile, false); + + foreach ($this->data as $row) { + DB::table(Table::PRODUCTTYPES_SHIPPINGCATEGORIES)->insert($row); + } + } + + #[\Override] + public function unload(): void + { + foreach ($this->data as $row) { + DB::table(Table::PRODUCTTYPES_SHIPPINGCATEGORIES)->where('id', $row['id'])->delete(); + } + + $this->data = []; + } +} diff --git a/tests-yii2/fixtures/ProductTypesTaxCategoriesFixture.php b/tests-yii2/fixtures/ProductTypesTaxCategoriesFixture.php new file mode 100644 index 0000000000..1e48b5dc0a --- /dev/null +++ b/tests-yii2/fixtures/ProductTypesTaxCategoriesFixture.php @@ -0,0 +1,44 @@ +data = $this->loadData($this->dataFile, false); + + foreach ($this->data as $row) { + DB::table(Table::PRODUCTTYPES_TAXCATEGORIES)->insert($row); + } + } + + #[\Override] + public function unload(): void + { + foreach ($this->data as $row) { + DB::table(Table::PRODUCTTYPES_TAXCATEGORIES)->where('id', $row['id'])->delete(); + } + + $this->data = []; + } +} diff --git a/tests/fixtures/SalesFixture.php b/tests-yii2/fixtures/SalesFixture.php similarity index 100% rename from tests/fixtures/SalesFixture.php rename to tests-yii2/fixtures/SalesFixture.php diff --git a/tests/fixtures/ShippingCategoryFixture.php b/tests-yii2/fixtures/ShippingCategoryFixture.php similarity index 100% rename from tests/fixtures/ShippingCategoryFixture.php rename to tests-yii2/fixtures/ShippingCategoryFixture.php diff --git a/tests/fixtures/ShippingFixture.php b/tests-yii2/fixtures/ShippingFixture.php similarity index 100% rename from tests/fixtures/ShippingFixture.php rename to tests-yii2/fixtures/ShippingFixture.php diff --git a/tests/fixtures/ShippingMethodsFixture.php b/tests-yii2/fixtures/ShippingMethodsFixture.php similarity index 100% rename from tests/fixtures/ShippingMethodsFixture.php rename to tests-yii2/fixtures/ShippingMethodsFixture.php diff --git a/tests/fixtures/ShippingZonesFixture.php b/tests-yii2/fixtures/ShippingZonesFixture.php similarity index 100% rename from tests/fixtures/ShippingZonesFixture.php rename to tests-yii2/fixtures/ShippingZonesFixture.php diff --git a/tests/fixtures/SitesFixture.php b/tests-yii2/fixtures/SitesFixture.php similarity index 100% rename from tests/fixtures/SitesFixture.php rename to tests-yii2/fixtures/SitesFixture.php diff --git a/tests/fixtures/StoreFixture.php b/tests-yii2/fixtures/StoreFixture.php similarity index 100% rename from tests/fixtures/StoreFixture.php rename to tests-yii2/fixtures/StoreFixture.php diff --git a/tests-yii2/fixtures/TaxCategoryFixture.php b/tests-yii2/fixtures/TaxCategoryFixture.php new file mode 100644 index 0000000000..11f467b5e1 --- /dev/null +++ b/tests-yii2/fixtures/TaxCategoryFixture.php @@ -0,0 +1,50 @@ +service = Plugin::getInstance()->get($this->service); + + parent::init(); + } +} diff --git a/tests/fixtures/UserGroupsFixture.php b/tests-yii2/fixtures/UserGroupsFixture.php similarity index 100% rename from tests/fixtures/UserGroupsFixture.php rename to tests-yii2/fixtures/UserGroupsFixture.php diff --git a/tests/fixtures/data/categories.php b/tests-yii2/fixtures/data/categories.php similarity index 100% rename from tests/fixtures/data/categories.php rename to tests-yii2/fixtures/data/categories.php diff --git a/tests/fixtures/data/customer-addresses.php b/tests-yii2/fixtures/data/customer-addresses.php similarity index 100% rename from tests/fixtures/data/customer-addresses.php rename to tests-yii2/fixtures/data/customer-addresses.php diff --git a/tests/fixtures/data/customers.php b/tests-yii2/fixtures/data/customers.php similarity index 100% rename from tests/fixtures/data/customers.php rename to tests-yii2/fixtures/data/customers.php diff --git a/tests/fixtures/data/discounts.php b/tests-yii2/fixtures/data/discounts.php similarity index 100% rename from tests/fixtures/data/discounts.php rename to tests-yii2/fixtures/data/discounts.php diff --git a/tests/fixtures/data/emails.php b/tests-yii2/fixtures/data/emails.php similarity index 100% rename from tests/fixtures/data/emails.php rename to tests-yii2/fixtures/data/emails.php diff --git a/tests/fixtures/data/field-layout.php b/tests-yii2/fixtures/data/field-layout.php similarity index 100% rename from tests/fixtures/data/field-layout.php rename to tests-yii2/fixtures/data/field-layout.php diff --git a/tests/fixtures/data/gql-schemas.php b/tests-yii2/fixtures/data/gql-schemas.php similarity index 100% rename from tests/fixtures/data/gql-schemas.php rename to tests-yii2/fixtures/data/gql-schemas.php diff --git a/tests/fixtures/data/inventory-items.php b/tests-yii2/fixtures/data/inventory-items.php similarity index 100% rename from tests/fixtures/data/inventory-items.php rename to tests-yii2/fixtures/data/inventory-items.php diff --git a/tests/fixtures/data/order-statuses.php b/tests-yii2/fixtures/data/order-statuses.php similarity index 90% rename from tests/fixtures/data/order-statuses.php rename to tests-yii2/fixtures/data/order-statuses.php index b91977b054..df08477f61 100644 --- a/tests/fixtures/data/order-statuses.php +++ b/tests-yii2/fixtures/data/order-statuses.php @@ -11,7 +11,7 @@ 'sortOrder' => 1, 'default' => 1, // Because this is already in the DB, retrieve the `uid` - 'uid' => \craft\commerce\records\OrderStatus::find()->where(['id' => '1'])->one()->uid, + 'uid' => \CraftCms\Commerce\Order\Records\OrderStatus::where('id', '1')->first()->uid, ], [ 'storeId' => 1, // Primary diff --git a/tests/fixtures/data/orders.php b/tests-yii2/fixtures/data/orders.php similarity index 96% rename from tests/fixtures/data/orders.php rename to tests-yii2/fixtures/data/orders.php index 40594aadb1..a6ce4b3059 100644 --- a/tests/fixtures/data/orders.php +++ b/tests-yii2/fixtures/data/orders.php @@ -7,7 +7,7 @@ use craft\commerce\elements\Variant; use craft\commerce\Plugin; -use craft\commerce\records\OrderStatus; +use CraftCms\Commerce\Order\Records\OrderStatus; $variants = Variant::find()->indexBy('sku')->all(); @@ -25,7 +25,7 @@ 'note' => '', 'taxCategoryId' => 1, ]; -$orderStatuses = OrderStatus::find()->select(['id', 'handle'])->indexBy('handle')->column(); +$orderStatuses = OrderStatus::pluck('id', 'handle'); $yesterday = new DateTime(); $yesterday->setTimezone(new DateTimeZone('America/Los_Angeles')); diff --git a/tests/fixtures/data/payment-currencies.php b/tests-yii2/fixtures/data/payment-currencies.php similarity index 100% rename from tests/fixtures/data/payment-currencies.php rename to tests-yii2/fixtures/data/payment-currencies.php diff --git a/tests/fixtures/data/product-types-shipping-categories.php b/tests-yii2/fixtures/data/product-types-shipping-categories.php similarity index 100% rename from tests/fixtures/data/product-types-shipping-categories.php rename to tests-yii2/fixtures/data/product-types-shipping-categories.php diff --git a/tests/fixtures/data/product-types-sites.php b/tests-yii2/fixtures/data/product-types-sites.php similarity index 100% rename from tests/fixtures/data/product-types-sites.php rename to tests-yii2/fixtures/data/product-types-sites.php diff --git a/tests/fixtures/data/product-types-tax-categories.php b/tests-yii2/fixtures/data/product-types-tax-categories.php similarity index 100% rename from tests/fixtures/data/product-types-tax-categories.php rename to tests-yii2/fixtures/data/product-types-tax-categories.php diff --git a/tests/fixtures/data/product-types.php b/tests-yii2/fixtures/data/product-types.php similarity index 100% rename from tests/fixtures/data/product-types.php rename to tests-yii2/fixtures/data/product-types.php diff --git a/tests/fixtures/data/products.php b/tests-yii2/fixtures/data/products.php similarity index 100% rename from tests/fixtures/data/products.php rename to tests-yii2/fixtures/data/products.php diff --git a/tests/fixtures/data/sales.php b/tests-yii2/fixtures/data/sales.php similarity index 100% rename from tests/fixtures/data/sales.php rename to tests-yii2/fixtures/data/sales.php diff --git a/tests/fixtures/data/shipping-category.php b/tests-yii2/fixtures/data/shipping-category.php similarity index 100% rename from tests/fixtures/data/shipping-category.php rename to tests-yii2/fixtures/data/shipping-category.php diff --git a/tests/fixtures/data/shipping-methods.php b/tests-yii2/fixtures/data/shipping-methods.php similarity index 100% rename from tests/fixtures/data/shipping-methods.php rename to tests-yii2/fixtures/data/shipping-methods.php diff --git a/tests/fixtures/data/shipping-rules.php b/tests-yii2/fixtures/data/shipping-rules.php similarity index 100% rename from tests/fixtures/data/shipping-rules.php rename to tests-yii2/fixtures/data/shipping-rules.php diff --git a/tests/fixtures/data/shipping-zones.php b/tests-yii2/fixtures/data/shipping-zones.php similarity index 100% rename from tests/fixtures/data/shipping-zones.php rename to tests-yii2/fixtures/data/shipping-zones.php diff --git a/tests/fixtures/data/sites.php b/tests-yii2/fixtures/data/sites.php similarity index 100% rename from tests/fixtures/data/sites.php rename to tests-yii2/fixtures/data/sites.php diff --git a/tests/fixtures/data/stores.php b/tests-yii2/fixtures/data/stores.php similarity index 100% rename from tests/fixtures/data/stores.php rename to tests-yii2/fixtures/data/stores.php diff --git a/tests/fixtures/data/tax-category.php b/tests-yii2/fixtures/data/tax-category.php similarity index 100% rename from tests/fixtures/data/tax-category.php rename to tests-yii2/fixtures/data/tax-category.php diff --git a/tests/fixtures/data/user-addresses.php b/tests-yii2/fixtures/data/user-addresses.php similarity index 100% rename from tests/fixtures/data/user-addresses.php rename to tests-yii2/fixtures/data/user-addresses.php diff --git a/tests/fixtures/data/user-groups.php b/tests-yii2/fixtures/data/user-groups.php similarity index 100% rename from tests/fixtures/data/user-groups.php rename to tests-yii2/fixtures/data/user-groups.php diff --git a/tests-yii2/fixtures/elements/ProductFixture.php b/tests-yii2/fixtures/elements/ProductFixture.php new file mode 100644 index 0000000000..9eddb3a0de --- /dev/null +++ b/tests-yii2/fixtures/elements/ProductFixture.php @@ -0,0 +1,141 @@ + + * @author Robuust digital | Bob Olde Hampsink + * @author Global Network Group | Giel Tettelaar + * @since 2.1 + */ +class ProductFixture extends BaseElementFixture +{ + /** + * @var array + */ + protected array $productTypeIds = []; + + private ?VariantCollection $_variants = null; + + /** + * {@inheritdoc} + */ + public function init(): void + { + parent::init(); + + // Ensure loaded + $commerce = Plugin::getInstance(); + if (!$commerce) { + throw new InvalidArgumentException('Commerce plugin needs to be loaded before using the ProductFixture'); + } + + // Get all product type id's + $this->productTypeIds = $this->_getProductTypeIds(); + } + + public function afterLoad(): void + { + $this->productTypeIds = $this->_getProductTypeIds(); + + // Generate catalog pricing + Plugin::getInstance()->getCatalogPricing()->generateCatalogPrices(); + } + + protected function createElement(): ElementInterface + { + return new Product(); + } + + /** + * Get array of product type IDs indexed by handle. + * This uses a raw query to avoid service level caching/memoization. + * + * @todo Review whether this raw-query workaround for service-level memoization is still needed in Commerce 6.0 #COM-54 + */ + private function _getProductTypeIds(): array + { + return new Query() + ->select([ + 'productTypes.id', + 'productTypes.handle', + ]) + ->from([Table::PRODUCTTYPES . ' productTypes']) + ->indexBy('handle') + ->column(); + } + + /** + * @inheritdoc + * @param Product $element + */ + protected function populateElement(ElementInterface $element, array $attributes): void + { + foreach ($attributes as $name => $value) { + if ($name !== '_variants') { + $element->$name = $value; + } else { + $this->_variants = VariantCollection::make($value); + $element->setVariants($value); + } + } + } + + protected function saveElement(ElementInterface $element): bool + { + $return = parent::saveElement($element); + + // Save the variants + $this->_variants->each(function(Variant $v) use ($element) { + if (new Query() + ->from(Table::VARIANTS . ' v') + ->leftJoin(Table::PURCHASABLES . ' p', '[[p.id]] = [[v.id]]') + ->where(['primaryOwnerId' => $element->id]) + ->andWhere(['p.sku' => $v->getSku()]) + ->exists() + ) { + return; + } + + $v->setPrimaryOwnerId($element->id); + $v->setOwnerId($element->id); + \Craft::$app->getElements()->saveElement($v,false); + }); + + $this->_variants = null; + + return $return; + } + + protected function deleteElement(ElementInterface $element): bool + { + /** @var Product $element */ + $variants = $element->getVariants(true); + + foreach ($variants as $variant) { + Craft::$app->getElements()->deleteElement($variant, true); + } + + return parent::deleteElement($element); + } +} diff --git a/tests/gql/GqlCest.php b/tests-yii2/gql/GqlCest.php similarity index 100% rename from tests/gql/GqlCest.php rename to tests-yii2/gql/GqlCest.php diff --git a/tests/gql/data/gql.txt b/tests-yii2/gql/data/gql.txt similarity index 100% rename from tests/gql/data/gql.txt rename to tests-yii2/gql/data/gql.txt diff --git a/tests-yii2/mockclasses/Purchasable.php b/tests-yii2/mockclasses/Purchasable.php new file mode 100644 index 0000000000..975df68bd9 --- /dev/null +++ b/tests-yii2/mockclasses/Purchasable.php @@ -0,0 +1,40 @@ + + * @author Global Network Group | Giel Tettelaar + * @since 2.1 + */ +class Purchasable extends BasePurchasable +{ + public bool $isPromotable = true; + + public float $price = 25.10; + + public function getIsPromotable(): bool + { + return $this->isPromotable; + } + + public function getPrice(string|Store|null $store = null): ?float + { + return 25.10; + } + + public function getSku(): string + { + return 'commerce_testing_unique_sku'; + } +} diff --git a/tests/unit/adjusters/DiscountTest.php b/tests-yii2/unit/adjusters/DiscountTest.php similarity index 98% rename from tests/unit/adjusters/DiscountTest.php rename to tests-yii2/unit/adjusters/DiscountTest.php index 67fd51782e..ea6d8e5107 100644 --- a/tests/unit/adjusters/DiscountTest.php +++ b/tests-yii2/unit/adjusters/DiscountTest.php @@ -8,7 +8,6 @@ namespace craftcommercetests\unit\adjusters; use Codeception\Test\Unit; -use craft\commerce\adjusters\Discount; use craft\commerce\base\Purchasable; use craft\commerce\elements\Order; use craft\commerce\models\Discount as DiscountModel; @@ -17,6 +16,7 @@ use craft\commerce\Plugin; use craft\commerce\services\Discounts; use craft\helpers\ArrayHelper; +use CraftCms\Commerce\Order\Adjuster\Discount; /** * DiscountTest @@ -36,9 +36,7 @@ class DiscountTest extends Unit */ public ?string $originalEdition = null; - /** - * @inheritdoc - */ + #[\Override] protected function _before(): void { parent::_before(); @@ -46,9 +44,7 @@ protected function _before(): void $this->pluginInstance = Plugin::getInstance(); } - /** - * @inheritdoc - */ + #[\Override] protected function _after(): void { parent::_after(); diff --git a/tests/unit/adjusters/ShippingTest.php b/tests-yii2/unit/adjusters/ShippingTest.php similarity index 100% rename from tests/unit/adjusters/ShippingTest.php rename to tests-yii2/unit/adjusters/ShippingTest.php diff --git a/tests/unit/adjusters/TaxTest.php b/tests-yii2/unit/adjusters/TaxTest.php similarity index 99% rename from tests/unit/adjusters/TaxTest.php rename to tests-yii2/unit/adjusters/TaxTest.php index 56175e435c..9a67c41d51 100644 --- a/tests/unit/adjusters/TaxTest.php +++ b/tests-yii2/unit/adjusters/TaxTest.php @@ -9,7 +9,6 @@ use Codeception\Test\Unit; use Craft; -use craft\commerce\adjusters\Tax; use craft\commerce\elements\conditions\addresses\ZoneAddressCondition; use craft\commerce\elements\Order; use craft\commerce\models\LineItem; @@ -21,6 +20,7 @@ use craft\elements\conditions\addresses\CountryConditionRule; use craft\helpers\Json; use craft\helpers\StringHelper; +use CraftCms\Commerce\Order\Adjuster\Tax; /** * CartTest @@ -35,9 +35,7 @@ class TaxTest extends Unit */ public ?Plugin $pluginInstance = null; - /** - * @inheritdoc - */ + #[\Override] protected function _before(): void { parent::_before(); @@ -47,9 +45,7 @@ protected function _before(): void $this->pluginInstance = Plugin::getInstance(); } - /** - * @inheritdoc - */ + #[\Override] protected function _after(): void { parent::_after(); diff --git a/tests/unit/controllers/CartControllerRateLimitTest.php b/tests-yii2/unit/controllers/CartControllerRateLimitTest.php similarity index 99% rename from tests/unit/controllers/CartControllerRateLimitTest.php rename to tests-yii2/unit/controllers/CartControllerRateLimitTest.php index 89abd21f6b..a80927861e 100644 --- a/tests/unit/controllers/CartControllerRateLimitTest.php +++ b/tests-yii2/unit/controllers/CartControllerRateLimitTest.php @@ -34,6 +34,7 @@ class CartControllerRateLimitTest extends TestCase /** * @return array */ + #[\Override] public function _fixtures(): array { return [ @@ -43,6 +44,7 @@ public function _fixtures(): array ]; } + #[\Override] protected function setUp(): void { parent::setUp(); diff --git a/tests/unit/controllers/CartTest.php b/tests-yii2/unit/controllers/CartTest.php similarity index 97% rename from tests/unit/controllers/CartTest.php rename to tests-yii2/unit/controllers/CartTest.php index 736d4d1097..c5cfabb68e 100644 --- a/tests/unit/controllers/CartTest.php +++ b/tests-yii2/unit/controllers/CartTest.php @@ -83,6 +83,7 @@ public function _fixtures(): array /** * @inheritDoc */ + #[\Override] protected function _before(): void { parent::_before(); @@ -544,13 +545,7 @@ public function testSetAddressesOnCart(string $whichAddress = 'shipping', bool $ self::assertTrue($cart->hasErrors()); // loop through the error keys to make sure there is one starting with `shippingAddress` $errorKeys = array_keys($cart->getErrors()); - $found = false; - foreach ($errorKeys as $errorKey) { - if (str_starts_with($errorKey, 'shippingAddress')) { - $found = true; - break; - } - } + $found = array_any($errorKeys, fn($errorKey) => str_starts_with((string) $errorKey, 'shippingAddress')); self::assertTrue($found); } else { @@ -564,13 +559,7 @@ public function testSetAddressesOnCart(string $whichAddress = 'shipping', bool $ self::assertTrue($cart->hasErrors()); // loop through the error keys to make sure there is one starting with `billingAddress` $errorKeys = array_keys($cart->getErrors()); - $found = false; - foreach ($errorKeys as $errorKey) { - if (str_starts_with($errorKey, 'billingAddress')) { - $found = true; - break; - } - } + $found = array_any($errorKeys, fn($errorKey) => str_starts_with((string) $errorKey, 'billingAddress')); self::assertTrue($found); } else { diff --git a/tests/unit/controllers/EmailPreviewControllerTest.php b/tests-yii2/unit/controllers/EmailPreviewControllerTest.php similarity index 95% rename from tests/unit/controllers/EmailPreviewControllerTest.php rename to tests-yii2/unit/controllers/EmailPreviewControllerTest.php index 67f5e1b2cf..ff5c5ea64c 100644 --- a/tests/unit/controllers/EmailPreviewControllerTest.php +++ b/tests-yii2/unit/controllers/EmailPreviewControllerTest.php @@ -59,6 +59,7 @@ public function _fixtures(): array /** * @inheritDoc */ + #[\Override] protected function _before(): void { parent::_before(); @@ -80,7 +81,7 @@ public function testRenderRandomOrder(): void Craft::$app->getRequest()->setQueryParams(['email' => $email['id'] . ':' . $email['storeId']]); $response = $this->controller->runAction('render'); - (new TemplateResponseFormatter())->format($response); + new TemplateResponseFormatter()->format($response); self::assertInstanceOf(Response::class, $response); self::assertIsString($response->content); @@ -100,7 +101,7 @@ public function testRenderSpecificOrder(): void ]); $response = $this->controller->runAction('render'); - (new TemplateResponseFormatter())->format($response); + new TemplateResponseFormatter()->format($response); self::assertInstanceOf(Response::class, $response); self::assertIsString($response->content); diff --git a/tests/unit/controllers/OrdersControllerTest.php b/tests-yii2/unit/controllers/OrdersControllerTest.php similarity index 99% rename from tests/unit/controllers/OrdersControllerTest.php rename to tests-yii2/unit/controllers/OrdersControllerTest.php index 017a01952e..a3584c9877 100644 --- a/tests/unit/controllers/OrdersControllerTest.php +++ b/tests-yii2/unit/controllers/OrdersControllerTest.php @@ -62,6 +62,7 @@ public function _fixtures(): array /** * @inheritDoc */ + #[\Override] protected function _before(): void { parent::_before(); diff --git a/tests/unit/controllers/ShippingRulesControllerTest.php b/tests-yii2/unit/controllers/ShippingRulesControllerTest.php similarity index 97% rename from tests/unit/controllers/ShippingRulesControllerTest.php rename to tests-yii2/unit/controllers/ShippingRulesControllerTest.php index 2e3f850c37..5b50c7f842 100644 --- a/tests/unit/controllers/ShippingRulesControllerTest.php +++ b/tests-yii2/unit/controllers/ShippingRulesControllerTest.php @@ -62,6 +62,7 @@ public function _fixtures(): array /** * @inheritDoc */ + #[\Override] protected function _before(): void { parent::_before(); @@ -97,7 +98,7 @@ public function testReorder(): void self::assertEmpty($response->data); // Check rules have been reordered - $results = (new Query()) + $results = new Query() ->from(Table::SHIPPINGRULES) ->select(['id']) ->where(['id' => $ids]) @@ -140,7 +141,7 @@ public function testSave(): void $this->controller->runAction('save'); // Check rules have been reordered - $result = (new Query()) + $result = new Query() ->from(Table::SHIPPINGRULES) ->select(['name']) ->where(['id' => $rule['id']]) @@ -185,7 +186,7 @@ public function testDelete(): void $this->controller->runAction('delete'); - self::assertFalse(false, (new Query()) + self::assertFalse(false, new Query() ->from(Table::SHIPPINGRULES) ->select(['name']) ->where(['id' => $shippingFixture->data['us-only-2']['id']]) @@ -226,7 +227,7 @@ public function testDuplicate(): void $this->controller->runAction('duplicate'); // Check rules have been reordered - $result = (new Query()) + $result = new Query() ->from(Table::SHIPPINGRULES) ->select(['id']) ->where(['name' => $rule['name']]) diff --git a/tests/unit/elements/address/CustomerAddressBehaviorTest.php b/tests-yii2/unit/elements/address/CustomerAddressBehaviorTest.php similarity index 100% rename from tests/unit/elements/address/CustomerAddressBehaviorTest.php rename to tests-yii2/unit/elements/address/CustomerAddressBehaviorTest.php diff --git a/tests/unit/elements/donation/DonationQueryTest.php b/tests-yii2/unit/elements/donation/DonationQueryTest.php similarity index 95% rename from tests/unit/elements/donation/DonationQueryTest.php rename to tests-yii2/unit/elements/donation/DonationQueryTest.php index ee43f5a871..dbe2969dca 100644 --- a/tests/unit/elements/donation/DonationQueryTest.php +++ b/tests-yii2/unit/elements/donation/DonationQueryTest.php @@ -11,10 +11,10 @@ use Craft; use craft\commerce\db\Table; use craft\commerce\elements\db\DonationQuery; -use craft\commerce\elements\db\PurchasableQuery; use craft\commerce\elements\Donation; use craft\commerce\Plugin; use craft\db\Query; +use CraftCms\Commerce\Purchasable\Queries\PurchasableQuery; use craftcommercetests\fixtures\StoreFixture; use UnitTester; @@ -52,7 +52,7 @@ public function testQuery(): void public function testAvailableForPurchase(bool $availableForPurchase): void { // Make sure donation is installed - if ((int)(new Query())->from(Table::DONATIONS)->count() === 0) { + if ((int)new Query()->from(Table::DONATIONS)->count() === 0) { $primaryStore = Plugin::getInstance()->getStores()->getPrimaryStore(); $primarySite = Craft::$app->getSites()->getPrimarySite(); $donation = new Donation(); diff --git a/tests/unit/elements/order/OrderAddressesTest.php b/tests-yii2/unit/elements/order/OrderAddressesTest.php similarity index 98% rename from tests/unit/elements/order/OrderAddressesTest.php rename to tests-yii2/unit/elements/order/OrderAddressesTest.php index 7681673f32..49f6c9d4e9 100644 --- a/tests/unit/elements/order/OrderAddressesTest.php +++ b/tests-yii2/unit/elements/order/OrderAddressesTest.php @@ -176,9 +176,7 @@ public function hasMatchingAddressesDataProvider(): array ]; } - /** - * @inheritdoc - */ + #[\Override] protected function _before(): void { parent::_before(); @@ -188,9 +186,7 @@ protected function _before(): void $this->order = new Order(); } - /** - * @inheritdoc - */ + #[\Override] protected function _after(): void { parent::_after(); diff --git a/tests/unit/elements/order/OrderCustomerTest.php b/tests-yii2/unit/elements/order/OrderCustomerTest.php similarity index 100% rename from tests/unit/elements/order/OrderCustomerTest.php rename to tests-yii2/unit/elements/order/OrderCustomerTest.php diff --git a/tests/unit/elements/order/OrderMarkAsCompleteTest.php b/tests-yii2/unit/elements/order/OrderMarkAsCompleteTest.php similarity index 96% rename from tests/unit/elements/order/OrderMarkAsCompleteTest.php rename to tests-yii2/unit/elements/order/OrderMarkAsCompleteTest.php index 02c3455600..c3c34591c8 100644 --- a/tests/unit/elements/order/OrderMarkAsCompleteTest.php +++ b/tests-yii2/unit/elements/order/OrderMarkAsCompleteTest.php @@ -10,7 +10,7 @@ use Codeception\Test\Unit; use craft\commerce\elements\Order; use craft\commerce\Plugin; -use craft\commerce\records\Transaction as TransactionRecord; +use CraftCms\Commerce\Payment\Records\Transaction as TransactionRecord; use craftcommercetests\fixtures\OrdersFixture; use UnitTester; @@ -89,7 +89,7 @@ public function testUpdatedProperties(): void * @throws \Throwable * @throws \craft\commerce\errors\CurrencyException * @throws \craft\commerce\errors\OrderStatusException - * @throws \craft\commerce\errors\TransactionException + * @throws \CraftCms\Commerce\Payment\Exceptions\TransactionException * @throws \craft\errors\ElementNotFoundException * @throws \yii\base\Exception * @throws \yii\base\InvalidConfigException @@ -147,9 +147,7 @@ public function testPaidDatesUpdated(): void $this->pluginInstance->getTransactions()->deleteTransactionById($transaction3->id); } - /** - * @inheritdoc - */ + #[\Override] protected function _before(): void { parent::_before(); @@ -157,9 +155,7 @@ protected function _before(): void $this->pluginInstance = Plugin::getInstance(); } - /** - * @inheritdoc - */ + #[\Override] protected function _after(): void { parent::_after(); diff --git a/tests/unit/elements/order/OrderNoticesTest.php b/tests-yii2/unit/elements/order/OrderNoticesTest.php similarity index 99% rename from tests/unit/elements/order/OrderNoticesTest.php rename to tests-yii2/unit/elements/order/OrderNoticesTest.php index d853d35e6a..68dfe79938 100644 --- a/tests/unit/elements/order/OrderNoticesTest.php +++ b/tests-yii2/unit/elements/order/OrderNoticesTest.php @@ -279,6 +279,7 @@ public function testClearNoticesWithFlagClearsAll(): void /** * */ + #[\Override] protected function _before(): void { parent::_before(); @@ -290,6 +291,7 @@ protected function _before(): void /** * */ + #[\Override] protected function _after(): void { parent::_after(); diff --git a/tests/unit/elements/order/OrderPaymentAmountTest.php b/tests-yii2/unit/elements/order/OrderPaymentAmountTest.php similarity index 97% rename from tests/unit/elements/order/OrderPaymentAmountTest.php rename to tests-yii2/unit/elements/order/OrderPaymentAmountTest.php index 3794a3bd64..8f30399346 100644 --- a/tests/unit/elements/order/OrderPaymentAmountTest.php +++ b/tests-yii2/unit/elements/order/OrderPaymentAmountTest.php @@ -13,7 +13,7 @@ use craft\commerce\models\LineItem; use craft\commerce\models\Transaction; use craft\commerce\Plugin; -use craft\commerce\records\Transaction as TransactionRecord; +use CraftCms\Commerce\Payment\Records\Transaction as TransactionRecord; use craftcommercetests\fixtures\PaymentCurrenciesFixture; use UnitTester; @@ -166,6 +166,7 @@ public function isPaymentAmountPartialDataProvider() /** * */ + #[\Override] protected function _before(): void { parent::_before(); @@ -178,6 +179,7 @@ protected function _before(): void /** * */ + #[\Override] protected function _after(): void { parent::_after(); diff --git a/tests/unit/elements/order/OrderQueryTest.php b/tests-yii2/unit/elements/order/OrderQueryTest.php similarity index 97% rename from tests/unit/elements/order/OrderQueryTest.php rename to tests-yii2/unit/elements/order/OrderQueryTest.php index d31d21bf4d..8d9d795ca8 100644 --- a/tests/unit/elements/order/OrderQueryTest.php +++ b/tests-yii2/unit/elements/order/OrderQueryTest.php @@ -12,11 +12,11 @@ use craft\commerce\elements\Order; use craft\commerce\errors\CurrencyException; use craft\commerce\errors\OrderStatusException; -use craft\commerce\errors\TransactionException; use craft\commerce\Plugin; -use craft\commerce\records\Transaction as TransactionRecord; use craft\errors\ElementNotFoundException; use craft\helpers\DateTimeHelper; +use CraftCms\Commerce\Payment\Exceptions\TransactionException; +use CraftCms\Commerce\Payment\Records\Transaction as TransactionRecord; use craftcommercetests\fixtures\OrdersFixture; use UnitTester; use yii\base\Exception; diff --git a/tests/unit/elements/order/OrderRecalculationTest.php b/tests-yii2/unit/elements/order/OrderRecalculationTest.php similarity index 100% rename from tests/unit/elements/order/OrderRecalculationTest.php rename to tests-yii2/unit/elements/order/OrderRecalculationTest.php diff --git a/tests/unit/elements/order/OrderTotalsTest.php b/tests-yii2/unit/elements/order/OrderTotalsTest.php similarity index 96% rename from tests/unit/elements/order/OrderTotalsTest.php rename to tests-yii2/unit/elements/order/OrderTotalsTest.php index 45126f0674..b63a173342 100644 --- a/tests/unit/elements/order/OrderTotalsTest.php +++ b/tests-yii2/unit/elements/order/OrderTotalsTest.php @@ -8,11 +8,11 @@ namespace craftcommercetests\unit\elements\order; use Codeception\Test\Unit; -use craft\commerce\adjusters\Discount; use craft\commerce\elements\Order; use craft\commerce\models\LineItem; use craft\commerce\models\OrderAdjustment; use craft\commerce\Plugin; +use CraftCms\Commerce\Order\Adjuster\Discount; use UnitTester; /** @@ -98,9 +98,7 @@ public function testOrderSumTotalPrice(): void self::assertEquals(65, $this->order->getTotalPrice()); } - /** - * @inheritdoc - */ + #[\Override] protected function _before(): void { parent::_before(); @@ -110,9 +108,7 @@ protected function _before(): void $this->order = new Order(); } - /** - * @inheritdoc - */ + #[\Override] protected function _after(): void { parent::_after(); diff --git a/tests/unit/elements/order/OrderValidationTest.php b/tests-yii2/unit/elements/order/OrderValidationTest.php similarity index 97% rename from tests/unit/elements/order/OrderValidationTest.php rename to tests-yii2/unit/elements/order/OrderValidationTest.php index c861882d42..7da2303739 100644 --- a/tests/unit/elements/order/OrderValidationTest.php +++ b/tests-yii2/unit/elements/order/OrderValidationTest.php @@ -74,9 +74,7 @@ public function testAddressValidation(): void self::assertEmpty($this->order->getErrors()); } - /** - * @inheritdoc - */ + #[\Override] protected function _before(): void { parent::_before(); @@ -86,9 +84,7 @@ protected function _before(): void $this->order = new Order(); } - /** - * @inheritdoc - */ + #[\Override] protected function _after(): void { parent::_after(); diff --git a/tests/unit/elements/order/conditions/CouponCodeConditionRuleTest.php b/tests-yii2/unit/elements/order/conditions/CouponCodeConditionRuleTest.php similarity index 100% rename from tests/unit/elements/order/conditions/CouponCodeConditionRuleTest.php rename to tests-yii2/unit/elements/order/conditions/CouponCodeConditionRuleTest.php diff --git a/tests/unit/elements/order/conditions/CustomerConditionRuleTest.php b/tests-yii2/unit/elements/order/conditions/CustomerConditionRuleTest.php similarity index 100% rename from tests/unit/elements/order/conditions/CustomerConditionRuleTest.php rename to tests-yii2/unit/elements/order/conditions/CustomerConditionRuleTest.php diff --git a/tests/unit/elements/order/conditions/OrderConditionTest.php b/tests-yii2/unit/elements/order/conditions/OrderConditionTest.php similarity index 100% rename from tests/unit/elements/order/conditions/OrderConditionTest.php rename to tests-yii2/unit/elements/order/conditions/OrderConditionTest.php diff --git a/tests/unit/elements/product/ProductGetVariantsTest.php b/tests-yii2/unit/elements/product/ProductGetVariantsTest.php similarity index 97% rename from tests/unit/elements/product/ProductGetVariantsTest.php rename to tests-yii2/unit/elements/product/ProductGetVariantsTest.php index 047b5bf956..0344fd8513 100644 --- a/tests/unit/elements/product/ProductGetVariantsTest.php +++ b/tests-yii2/unit/elements/product/ProductGetVariantsTest.php @@ -70,7 +70,6 @@ public function testGetVariantsDoesNotMemoizeEmptyCollections(): void // Access private _variants property to check if it was memoized $reflection = new ReflectionClass($product); $variantsProperty = $reflection->getProperty('_variants'); - $variantsProperty->setAccessible(true); // Should be null, not an empty collection self::assertNull($variantsProperty->getValue($product)); @@ -101,7 +100,6 @@ public function testGetVariantsMemoizesNonEmptyCollections(): void // Access private _variants property $reflection = new ReflectionClass($product); $variantsProperty = $reflection->getProperty('_variants'); - $variantsProperty->setAccessible(true); // Should be memoized $memoizedVariants = $variantsProperty->getValue($product); @@ -134,7 +132,6 @@ public function testCreateVariantQueryDoesNotUseDuplicateOfId(): void // Use reflection to access private createVariantQuery method $reflection = new ReflectionClass(Product::class); $method = $reflection->getMethod('createVariantQuery'); - $method->setAccessible(true); /** @var VariantQuery $query */ $query = $method->invoke(null, $duplicateProduct); @@ -142,9 +139,7 @@ public function testCreateVariantQueryDoesNotUseDuplicateOfId(): void // Use reflection to check the query's ownerId and siteId $queryReflection = new ReflectionClass($query); $ownerIdProperty = $queryReflection->getProperty('ownerId'); - $ownerIdProperty->setAccessible(true); $siteIdProperty = $queryReflection->getProperty('siteId'); - $siteIdProperty->setAccessible(true); // Should use the original product's ID and siteId self::assertNotEquals($originalProduct->id, $ownerIdProperty->getValue($query)); @@ -230,7 +225,6 @@ public function testGetVariantsNullableIncludeDisabled(?bool $includeDisabled, b // regardless of which parameter was passed, all set variants must be retained. $reflection = new ReflectionClass($product); $variantsProperty = $reflection->getProperty('_variants'); - $variantsProperty->setAccessible(true); /** @var VariantCollection $internalVariants */ $internalVariants = $variantsProperty->getValue($product); diff --git a/tests/unit/elements/product/ProductPricingCatalogTest.php b/tests-yii2/unit/elements/product/ProductPricingCatalogTest.php similarity index 100% rename from tests/unit/elements/product/ProductPricingCatalogTest.php rename to tests-yii2/unit/elements/product/ProductPricingCatalogTest.php diff --git a/tests/unit/elements/product/ProductQueryTest.php b/tests-yii2/unit/elements/product/ProductQueryTest.php similarity index 100% rename from tests/unit/elements/product/ProductQueryTest.php rename to tests-yii2/unit/elements/product/ProductQueryTest.php diff --git a/tests/unit/elements/product/ProductTest.php b/tests-yii2/unit/elements/product/ProductTest.php similarity index 99% rename from tests/unit/elements/product/ProductTest.php rename to tests-yii2/unit/elements/product/ProductTest.php index 91f9580946..850644fc56 100644 --- a/tests/unit/elements/product/ProductTest.php +++ b/tests-yii2/unit/elements/product/ProductTest.php @@ -286,7 +286,7 @@ public function testSaveProductAndVariants(): void \Craft::$app->getElements()->saveElement($product, false); // Check default data when the variant is saved as part of the product save - $productData = (new Query()) + $productData = new Query() ->select([ 'defaultVariantId', 'defaultSku', @@ -300,7 +300,7 @@ public function testSaveProductAndVariants(): void ->where(['id' => $product->id]) ->one(); - $defaultVariantData = (new Query()) + $defaultVariantData = new Query() ->select([ 'v.id', ]) @@ -335,7 +335,7 @@ public function testSaveProductAndVariants(): void \Craft::$app->getElements()->saveElement($variant, false); - $newProductData = (new Query()) + $newProductData = new Query() ->select([ 'defaultVariantId', 'defaultSku', @@ -457,7 +457,6 @@ private function setProductTypeSkuFormat(int $typeId, ?string $skuFormat): void $reflection = new ReflectionClass($productTypesService); $prop = $reflection->getProperty('_allProductTypes'); - $prop->setAccessible(true); foreach ($prop->getValue($productTypesService) as $type) { if ($type->id === $typeId) { diff --git a/tests/unit/elements/product/conditions/ProductConditionTest.php b/tests-yii2/unit/elements/product/conditions/ProductConditionTest.php similarity index 100% rename from tests/unit/elements/product/conditions/ProductConditionTest.php rename to tests-yii2/unit/elements/product/conditions/ProductConditionTest.php diff --git a/tests/unit/elements/product/conditions/ProductTypeConditionRuleTest.php b/tests-yii2/unit/elements/product/conditions/ProductTypeConditionRuleTest.php similarity index 100% rename from tests/unit/elements/product/conditions/ProductTypeConditionRuleTest.php rename to tests-yii2/unit/elements/product/conditions/ProductTypeConditionRuleTest.php diff --git a/tests/unit/elements/product/conditions/ProductVariantHasUnlimitedStockConditionRuleTest.php b/tests-yii2/unit/elements/product/conditions/ProductVariantHasUnlimitedStockConditionRuleTest.php similarity index 99% rename from tests/unit/elements/product/conditions/ProductVariantHasUnlimitedStockConditionRuleTest.php rename to tests-yii2/unit/elements/product/conditions/ProductVariantHasUnlimitedStockConditionRuleTest.php index 4751a037a3..d1232bc402 100644 --- a/tests/unit/elements/product/conditions/ProductVariantHasUnlimitedStockConditionRuleTest.php +++ b/tests-yii2/unit/elements/product/conditions/ProductVariantHasUnlimitedStockConditionRuleTest.php @@ -119,7 +119,7 @@ public function testModifyQueryMatch(bool $hasUnlimitedStock): void $product = $productsFixture->getElement('rad-hoodie'); if (!$hasUnlimitedStock) { - $originalValues = (new Query()) + $originalValues = new Query() ->from(Table::PURCHASABLES_STORES) ->select(['purchasableId', 'stock', 'inventoryTracked']) ->indexBy('purchasableId') diff --git a/tests/unit/elements/product/conditions/ProductVariantPriceConditionRuleTest.php b/tests-yii2/unit/elements/product/conditions/ProductVariantPriceConditionRuleTest.php similarity index 100% rename from tests/unit/elements/product/conditions/ProductVariantPriceConditionRuleTest.php rename to tests-yii2/unit/elements/product/conditions/ProductVariantPriceConditionRuleTest.php diff --git a/tests/unit/elements/product/conditions/ProductVariantSkuConditionRuleTest.php b/tests-yii2/unit/elements/product/conditions/ProductVariantSkuConditionRuleTest.php similarity index 100% rename from tests/unit/elements/product/conditions/ProductVariantSkuConditionRuleTest.php rename to tests-yii2/unit/elements/product/conditions/ProductVariantSkuConditionRuleTest.php diff --git a/tests/unit/elements/product/conditions/ProductVariantStockConditionRuleTest.php b/tests-yii2/unit/elements/product/conditions/ProductVariantStockConditionRuleTest.php similarity index 99% rename from tests/unit/elements/product/conditions/ProductVariantStockConditionRuleTest.php rename to tests-yii2/unit/elements/product/conditions/ProductVariantStockConditionRuleTest.php index 1252afd4ff..a068b566fd 100644 --- a/tests/unit/elements/product/conditions/ProductVariantStockConditionRuleTest.php +++ b/tests-yii2/unit/elements/product/conditions/ProductVariantStockConditionRuleTest.php @@ -99,7 +99,7 @@ public function testModifyQueryMatch(): void /** @var Product $product */ $product = $productsFixture->getElement('rad-hoodie'); - $originalValues = (new Query()) + $originalValues = new Query() ->from(Table::PURCHASABLES_STORES) ->select(['purchasableId', 'stock', 'inventoryTracked']) ->indexBy('purchasableId') diff --git a/tests/unit/elements/user/CustomerBehaviorTest.php b/tests-yii2/unit/elements/user/CustomerBehaviorTest.php similarity index 100% rename from tests/unit/elements/user/CustomerBehaviorTest.php rename to tests-yii2/unit/elements/user/CustomerBehaviorTest.php diff --git a/tests/unit/elements/user/UserEmailTest.php b/tests-yii2/unit/elements/user/UserEmailTest.php similarity index 97% rename from tests/unit/elements/user/UserEmailTest.php rename to tests-yii2/unit/elements/user/UserEmailTest.php index 8743c54e87..5b320d1683 100644 --- a/tests/unit/elements/user/UserEmailTest.php +++ b/tests-yii2/unit/elements/user/UserEmailTest.php @@ -55,9 +55,7 @@ public function _fixtures(): array ]; } - /** - * @inheritdoc - */ + #[\Override] protected function _before(): void { parent::_before(); @@ -103,7 +101,7 @@ public function testUpdatedEmail(): void $this->_user->email = $newEmail; \Craft::$app->getElements()->saveElement($this->_user, false, false ,false); - $emails = (new Query()) + $emails = new Query() ->from(\craft\commerce\db\Table::ORDERS) ->select(['email']) ->where(['id' => [$order->id, $cart->id]]) @@ -115,9 +113,7 @@ public function testUpdatedEmail(): void } } - /** - * @inheritdoc - */ + #[\Override] protected function _after(): void { parent::_after(); diff --git a/tests/unit/elements/variant/PricingCatalogTest.php b/tests-yii2/unit/elements/variant/PricingCatalogTest.php similarity index 100% rename from tests/unit/elements/variant/PricingCatalogTest.php rename to tests-yii2/unit/elements/variant/PricingCatalogTest.php diff --git a/tests/unit/elements/variant/PricingSalesTest.php b/tests-yii2/unit/elements/variant/PricingSalesTest.php similarity index 100% rename from tests/unit/elements/variant/PricingSalesTest.php rename to tests-yii2/unit/elements/variant/PricingSalesTest.php diff --git a/tests/unit/elements/variant/VariantCollectionTest.php b/tests-yii2/unit/elements/variant/VariantCollectionTest.php similarity index 100% rename from tests/unit/elements/variant/VariantCollectionTest.php rename to tests-yii2/unit/elements/variant/VariantCollectionTest.php diff --git a/tests/unit/elements/variant/VariantEagerLoadingTest.php b/tests-yii2/unit/elements/variant/VariantEagerLoadingTest.php similarity index 100% rename from tests/unit/elements/variant/VariantEagerLoadingTest.php rename to tests-yii2/unit/elements/variant/VariantEagerLoadingTest.php diff --git a/tests/unit/elements/variant/VariantOwnerTest.php b/tests-yii2/unit/elements/variant/VariantOwnerTest.php similarity index 99% rename from tests/unit/elements/variant/VariantOwnerTest.php rename to tests-yii2/unit/elements/variant/VariantOwnerTest.php index ac22ab6762..73d05f9d2d 100644 --- a/tests/unit/elements/variant/VariantOwnerTest.php +++ b/tests-yii2/unit/elements/variant/VariantOwnerTest.php @@ -48,7 +48,6 @@ public function testOwnerTypeIsSetInInit(): void // Access protected ownerType property $reflection = new ReflectionClass($variant); $ownerTypeProperty = $reflection->getProperty('ownerType'); - $ownerTypeProperty->setAccessible(true); self::assertEquals(Product::class, $ownerTypeProperty->getValue($variant)); } diff --git a/tests/unit/elements/variant/VariantQueryTest.php b/tests-yii2/unit/elements/variant/VariantQueryTest.php similarity index 97% rename from tests/unit/elements/variant/VariantQueryTest.php rename to tests-yii2/unit/elements/variant/VariantQueryTest.php index 35818f2ac8..7aee9142f0 100644 --- a/tests/unit/elements/variant/VariantQueryTest.php +++ b/tests-yii2/unit/elements/variant/VariantQueryTest.php @@ -21,6 +21,7 @@ use craft\commerce\Plugin; use craft\db\Query; use craft\elements\User; +use CraftCms\Commerce\CatalogPricing\Records\CatalogPricingRule as CatalogPricingRuleRecord; use craftcommercetests\fixtures\ProductFixture; use craftcommercetests\fixtures\ShippingCategoryFixture; use UnitTester; @@ -268,9 +269,9 @@ public function testPriceQueryForCatalogPricingRule(): void // Create on the fly catalog pricing rule $primaryStore = Plugin::getInstance()->getStores()->getPrimaryStore(); $catalogPricingRule = new CatalogPricingRule(); - $catalogPricingRule->apply = \craft\commerce\records\CatalogPricingRule::APPLY_BY_PERCENT; + $catalogPricingRule->apply = CatalogPricingRuleRecord::APPLY_BY_PERCENT; $catalogPricingRule->applyAmount = 50 / -100; - $catalogPricingRule->applyPriceType = \craft\commerce\records\CatalogPricingRule::APPLY_PRICE_TYPE_PRICE; + $catalogPricingRule->applyPriceType = CatalogPricingRuleRecord::APPLY_PRICE_TYPE_PRICE; $catalogPricingRule->dateFrom = null; $catalogPricingRule->dateTo = null; $catalogPricingRule->description = ''; @@ -319,9 +320,9 @@ public function testPromotionalPriceQueryForCatalogPricingRule(): void // Create on the fly catalog pricing rule $primaryStore = Plugin::getInstance()->getStores()->getPrimaryStore(); $catalogPricingRule = new CatalogPricingRule(); - $catalogPricingRule->apply = \craft\commerce\records\CatalogPricingRule::APPLY_BY_PERCENT; + $catalogPricingRule->apply = CatalogPricingRuleRecord::APPLY_BY_PERCENT; $catalogPricingRule->applyAmount = 50 / -100; - $catalogPricingRule->applyPriceType = \craft\commerce\records\CatalogPricingRule::APPLY_PRICE_TYPE_PRICE; + $catalogPricingRule->applyPriceType = CatalogPricingRuleRecord::APPLY_PRICE_TYPE_PRICE; $catalogPricingRule->dateFrom = null; $catalogPricingRule->dateTo = null; $catalogPricingRule->description = ''; diff --git a/tests/unit/gql/ArgumentHandlerTest.php b/tests-yii2/unit/gql/ArgumentHandlerTest.php similarity index 100% rename from tests/unit/gql/ArgumentHandlerTest.php rename to tests-yii2/unit/gql/ArgumentHandlerTest.php diff --git a/tests/unit/gql/ProductResolverTest.php b/tests-yii2/unit/gql/ProductResolverTest.php similarity index 100% rename from tests/unit/gql/ProductResolverTest.php rename to tests-yii2/unit/gql/ProductResolverTest.php diff --git a/tests/unit/models/DiscountTest.php b/tests-yii2/unit/models/DiscountTest.php similarity index 100% rename from tests/unit/models/DiscountTest.php rename to tests-yii2/unit/models/DiscountTest.php diff --git a/tests/unit/models/LineItemTest.php b/tests-yii2/unit/models/LineItemTest.php similarity index 99% rename from tests/unit/models/LineItemTest.php rename to tests-yii2/unit/models/LineItemTest.php index 825a2a28aa..4916edb427 100644 --- a/tests/unit/models/LineItemTest.php +++ b/tests-yii2/unit/models/LineItemTest.php @@ -14,7 +14,7 @@ use craft\commerce\enums\LineItemType; use craft\commerce\models\LineItem; use craft\commerce\Plugin; -use craft\commerce\test\mockclasses\Purchasable; +use craftcommercetests\mockclasses\Purchasable; use craft\errors\SiteNotFoundException; use craft\helpers\Json; use craftcommercetests\fixtures\ProductFixture; diff --git a/tests/unit/models/SaleTest.php b/tests-yii2/unit/models/SaleTest.php similarity index 100% rename from tests/unit/models/SaleTest.php rename to tests-yii2/unit/models/SaleTest.php diff --git a/tests/unit/models/StoreTest.php b/tests-yii2/unit/models/StoreTest.php similarity index 100% rename from tests/unit/models/StoreTest.php rename to tests-yii2/unit/models/StoreTest.php diff --git a/tests/unit/models/TaxRateTest.php b/tests-yii2/unit/models/TaxRateTest.php similarity index 100% rename from tests/unit/models/TaxRateTest.php rename to tests-yii2/unit/models/TaxRateTest.php diff --git a/tests/unit/services/CartsTest.php b/tests-yii2/unit/services/CartsTest.php similarity index 99% rename from tests/unit/services/CartsTest.php rename to tests-yii2/unit/services/CartsTest.php index 907a7d532c..ef4de8e407 100644 --- a/tests/unit/services/CartsTest.php +++ b/tests-yii2/unit/services/CartsTest.php @@ -164,7 +164,6 @@ public function testForgetCartWithRestoredCartNumberReturnsSameNumber(): void // with whatever value was in the request cookie. $reflection = new \ReflectionClass($carts); $cartNumberProp = $reflection->getProperty('_cartNumber'); - $cartNumberProp->setAccessible(true); $cartNumberProp->setValue($carts, null); $requestCookies = new \yii\web\CookieCollection(); diff --git a/tests/unit/services/CatalogPricingQueueTest.php b/tests-yii2/unit/services/CatalogPricingQueueTest.php similarity index 83% rename from tests/unit/services/CatalogPricingQueueTest.php rename to tests-yii2/unit/services/CatalogPricingQueueTest.php index 01aff995c2..0c311909d5 100644 --- a/tests/unit/services/CatalogPricingQueueTest.php +++ b/tests-yii2/unit/services/CatalogPricingQueueTest.php @@ -10,7 +10,7 @@ use Codeception\Test\Unit; use craft\commerce\db\Table; use craft\commerce\Plugin; -use craft\commerce\records\CatalogPricingQueue as CatalogPricingQueueRecord; +use CraftCms\Commerce\CatalogPricing\Records\CatalogPricingQueue as CatalogPricingQueueRecord; use craftcommercetests\fixtures\StoreFixture; use UnitTester; @@ -45,7 +45,7 @@ protected function _before(): void { parent::_before(); // Clear the catalog pricing queue table before each test - CatalogPricingQueueRecord::deleteAll(); + CatalogPricingQueueRecord::query()->delete(); $this->_storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; } @@ -53,7 +53,7 @@ protected function _after(): void { parent::_after(); // Clean up the queue table after each test - CatalogPricingQueueRecord::deleteAll(); + CatalogPricingQueueRecord::query()->delete(); } /** @@ -68,14 +68,14 @@ public function testCreateQueueRowForSinglePurchasableId(): void 'storeId' => $this->_storeId, ]); - $rows = CatalogPricingQueueRecord::find()->all(); + $rows = CatalogPricingQueueRecord::all(); self::assertCount(1, $rows); /** @var CatalogPricingQueueRecord $row */ $row = $rows[0]; self::assertEquals(CatalogPricingQueueRecord::TYPE_PURCHASABLE, $row->type); self::assertEquals($this->_storeId, $row->storeId); - self::assertEquals([1], $row->getIds()); + self::assertEquals([1], $row->ids); self::assertFalse((bool)$row->reserved); } @@ -89,14 +89,14 @@ public function testCreateQueueRowForSingleRuleId(): void 'storeId' => $this->_storeId, ]); - $rows = CatalogPricingQueueRecord::find()->all(); + $rows = CatalogPricingQueueRecord::all(); self::assertCount(1, $rows); /** @var CatalogPricingQueueRecord $row */ $row = $rows[0]; self::assertEquals(CatalogPricingQueueRecord::TYPE_RULE, $row->type); self::assertEquals($this->_storeId, $row->storeId); - self::assertEquals([5], $row->getIds()); + self::assertEquals([5], $row->ids); self::assertFalse((bool)$row->reserved); } @@ -112,18 +112,18 @@ public function testPurchasableAndRuleTypesAreSeparated(): void ]); /** @var CatalogPricingQueueRecord[] $rows */ - $rows = CatalogPricingQueueRecord::find()->orderBy(['type' => SORT_ASC])->all(); + $rows = CatalogPricingQueueRecord::orderBy('type')->get(); self::assertCount(2, $rows); // First row should be purchasable type $purchasableRow = $rows[0]; self::assertEquals(CatalogPricingQueueRecord::TYPE_PURCHASABLE, $purchasableRow->type); - self::assertEquals([1, 2], $purchasableRow->getIds()); + self::assertEquals([1, 2], $purchasableRow->ids); // Second row should be rule type $ruleRow = $rows[1]; self::assertEquals(CatalogPricingQueueRecord::TYPE_RULE, $ruleRow->type); - self::assertEquals([5, 6], $ruleRow->getIds()); + self::assertEquals([5, 6], $ruleRow->ids); } /** @@ -145,7 +145,7 @@ public function testDifferentStoresCreateSeparateRows(): void ]); /** @var CatalogPricingQueueRecord[] $rows */ - $rows = CatalogPricingQueueRecord::find()->orderBy(['storeId' => SORT_ASC])->all(); + $rows = CatalogPricingQueueRecord::orderBy('storeId')->get(); self::assertCount(2, $rows); self::assertEquals($primaryStore->id, $rows[0]->storeId); @@ -167,11 +167,11 @@ public function testMultipleQueuesForSameStoreAndTypeMerge(): void 'storeId' => $this->_storeId, ]); - $rows = CatalogPricingQueueRecord::find()->all(); + $rows = CatalogPricingQueueRecord::all(); self::assertCount(1, $rows, 'Multiple queue calls should merge into a single row'); $row = $rows[0]; - self::assertEquals([1, 2, 3, 4], $row->getIds(), 'IDs should be merged and sorted'); + self::assertEquals([1, 2, 3, 4], $row->ids, 'IDs should be merged and sorted'); } /** @@ -189,8 +189,8 @@ public function testDuplicateIdsAreDeduplicated(): void 'storeId' => $this->_storeId, ]); - $row = CatalogPricingQueueRecord::findOne(['storeId' => $this->_storeId, 'type' => CatalogPricingQueueRecord::TYPE_PURCHASABLE]); - self::assertEquals([1, 2, 3, 4], $row->getIds(), 'Duplicate IDs should be removed and sorted'); + $row = CatalogPricingQueueRecord::where(['storeId' => $this->_storeId, 'type' => CatalogPricingQueueRecord::TYPE_PURCHASABLE])->first(); + self::assertEquals([1, 2, 3, 4], $row->ids, 'Duplicate IDs should be removed and sorted'); } /** @@ -203,9 +203,9 @@ public function testNullStoreIdRepresentsAllStores(): void 'storeId' => null, ]); - $row = CatalogPricingQueueRecord::findOne(['type' => CatalogPricingQueueRecord::TYPE_PURCHASABLE]); + $row = CatalogPricingQueueRecord::where(['type' => CatalogPricingQueueRecord::TYPE_PURCHASABLE])->first(); self::assertNull($row->storeId, 'storeId should be null to represent all stores'); - self::assertEquals([1], $row->getIds()); + self::assertEquals([1], $row->ids); } /** @@ -225,8 +225,8 @@ public function testMergingWithNullIdsExpandsScope(): void 'storeId' => $this->_storeId, ]); - $row = CatalogPricingQueueRecord::findOne(['storeId' => $this->_storeId, 'type' => CatalogPricingQueueRecord::TYPE_PURCHASABLE]); - self::assertNull($row->getIds(), 'IDs should be null (broader scope) when merging specific IDs with null'); + $row = CatalogPricingQueueRecord::where(['storeId' => $this->_storeId, 'type' => CatalogPricingQueueRecord::TYPE_PURCHASABLE])->first(); + self::assertNull($row->ids, 'IDs should be null (broader scope) when merging specific IDs with null'); } /** @@ -238,9 +238,9 @@ public function testReservedRowsAreNotMergedInto(): void $record = new CatalogPricingQueueRecord(); $record->storeId = $this->_storeId; $record->type = CatalogPricingQueueRecord::TYPE_PURCHASABLE; - $record->setIds([1]); + $record->ids = [1]; $record->reserved = true; - $record->save(false); + $record->save(); // Try to queue more IDs for the same store/type Plugin::getInstance()->getCatalogPricing()->createCatalogPricingJob([ @@ -249,22 +249,21 @@ public function testReservedRowsAreNotMergedInto(): void ]); /** @var CatalogPricingQueueRecord[] $rows */ - $rows = CatalogPricingQueueRecord::find() - ->where(['storeId' => $this->_storeId, 'type' => CatalogPricingQueueRecord::TYPE_PURCHASABLE]) - ->orderBy(['reserved' => SORT_DESC]) - ->all(); + $rows = CatalogPricingQueueRecord::where(['storeId' => $this->_storeId, 'type' => CatalogPricingQueueRecord::TYPE_PURCHASABLE]) + ->orderBy('reserved', 'desc') + ->get(); self::assertCount(2, $rows, 'A new row should be created instead of merging into the reserved row'); // One should be reserved with ID 1 $reservedRow = $rows[0]; self::assertNotNull($reservedRow); - self::assertEquals([1], $reservedRow->getIds()); + self::assertEquals([1], $reservedRow->ids); // One should be unreserved with ID 2 $unreservedRow = $rows[1]; self::assertNotNull($unreservedRow); - self::assertEquals([2], $unreservedRow->getIds()); + self::assertEquals([2], $unreservedRow->ids); } /** @@ -277,8 +276,8 @@ public function testIdsAreSortedNumerically(): void 'storeId' => $this->_storeId, ]); - $row = CatalogPricingQueueRecord::findOne(['storeId' => $this->_storeId]); - self::assertEquals([1, 5, 50, 100], $row->getIds(), 'IDs should be sorted numerically'); + $row = CatalogPricingQueueRecord::where(['storeId' => $this->_storeId])->first(); + self::assertEquals([1, 5, 50, 100], $row->ids, 'IDs should be sorted numerically'); } /** @@ -291,8 +290,8 @@ public function testZeroAndNegativeIdsAreFiltered(): void 'storeId' => $this->_storeId, ]); - $row = CatalogPricingQueueRecord::findOne(['storeId' => $this->_storeId]); - self::assertEquals([1, 2], $row->getIds(), 'Zero and negative IDs should be filtered out'); + $row = CatalogPricingQueueRecord::where(['storeId' => $this->_storeId])->first(); + self::assertEquals([1, 2], $row->ids, 'Zero and negative IDs should be filtered out'); } /** @@ -321,16 +320,16 @@ public function testReserveCatalogPricingQueueRowMarksAsReserved(): void ]); // All rows should be unreserved initially - self::assertCount(0, CatalogPricingQueueRecord::find()->where(['reserved' => true])->all()); + self::assertCount(0, CatalogPricingQueueRecord::where(['reserved' => true])->get()); $reserved = Plugin::getInstance()->getCatalogPricing()->reserveCatalogPricingQueueRow(); self::assertNotNull($reserved, 'Should return a reserved row'); self::assertTrue((bool)$reserved->reserved); - self::assertEquals([1], $reserved->getIds()); + self::assertEquals([1], $reserved->ids); // Verify in database - $dbRow = CatalogPricingQueueRecord::findOne($reserved->id); + $dbRow = CatalogPricingQueueRecord::find($reserved->id); self::assertTrue((bool)$dbRow->reserved); } @@ -351,11 +350,11 @@ public function testMultiplePendingRowsCanBeReservedInOrder(): void $first = Plugin::getInstance()->getCatalogPricing()->reserveCatalogPricingQueueRow(); self::assertNotNull($first); - self::assertEquals([1], $first->getIds()); + self::assertEquals([1], $first->ids); $second = Plugin::getInstance()->getCatalogPricing()->reserveCatalogPricingQueueRow(); self::assertNotNull($second); - self::assertEquals([2], $second->getIds()); + self::assertEquals([2], $second->ids); $third = Plugin::getInstance()->getCatalogPricing()->reserveCatalogPricingQueueRow(); self::assertNull($third, 'Should return null when no pending rows remain'); @@ -376,7 +375,7 @@ public function testReleaseCatalogPricingQueueByIdMarksAsUnreserved(): void Plugin::getInstance()->getCatalogPricing()->releaseCatalogPricingQueueRowById($reserved->id); - $released = CatalogPricingQueueRecord::findOne($reserved->id); + $released = CatalogPricingQueueRecord::find($reserved->id); self::assertFalse((bool)$released->reserved); } @@ -390,12 +389,12 @@ public function testDeleteCatalogPricingQueueByIdRemovesRow(): void 'storeId' => $this->_storeId, ]); - $row = CatalogPricingQueueRecord::findOne(['storeId' => $this->_storeId]); + $row = CatalogPricingQueueRecord::where(['storeId' => $this->_storeId])->first(); self::assertNotNull($row); Plugin::getInstance()->getCatalogPricing()->deleteCatalogPricingQueueRowById($row->id); - $deleted = CatalogPricingQueueRecord::findOne($row->id); + $deleted = CatalogPricingQueueRecord::find($row->id); self::assertNull($deleted); } @@ -415,7 +414,7 @@ public function testComplexQueueScenario(array $queueCalls, array $expectedRows) // Verify the state of all rows /** @var CatalogPricingQueueRecord[] $allRows */ - $allRows = CatalogPricingQueueRecord::find()->all(); + $allRows = CatalogPricingQueueRecord::all(); self::assertCount(count($expectedRows), $allRows, 'Should have expected number of rows'); foreach ($expectedRows as $index => $expected) { @@ -424,7 +423,7 @@ public function testComplexQueueScenario(array $queueCalls, array $expectedRows) self::assertNotNull($row, "Row at index $index should exist"); self::assertEquals($expected['storeId'] ?? null, $row->storeId, "Row $index storeId mismatch"); self::assertEquals($expected['type'], $row->type, "Row $index type mismatch"); - self::assertEquals($expected['ids'], $row->getIds(), "Row $index IDs mismatch"); + self::assertEquals($expected['ids'], $row->ids, "Row $index IDs mismatch"); } } diff --git a/tests/unit/services/CatalogPricingTest.php b/tests-yii2/unit/services/CatalogPricingTest.php similarity index 94% rename from tests/unit/services/CatalogPricingTest.php rename to tests-yii2/unit/services/CatalogPricingTest.php index 321112fee6..e6197f5461 100644 --- a/tests/unit/services/CatalogPricingTest.php +++ b/tests-yii2/unit/services/CatalogPricingTest.php @@ -16,8 +16,8 @@ use craft\commerce\elements\Variant; use craft\commerce\models\CatalogPricingRule; use craft\commerce\Plugin; -use craft\commerce\records\CatalogPricingRule as CatalogPricingRuleRecord; use craft\db\Query; +use CraftCms\Commerce\CatalogPricing\Records\CatalogPricingRule as CatalogPricingRuleRecord; use craftcommercetests\fixtures\ProductFixture; use UnitTester; @@ -54,6 +54,7 @@ public function _fixtures(): array ]; } + #[\Override] protected function _after() { parent::_after(); @@ -76,12 +77,12 @@ public function testGeneratePricesNoRules(): void // From the product fixture 3 variants exists in all stores, 1 variant only exists in the `ukStore` // (3 x 3) + 1 = 10 - self::assertCount(10, (new Query())->select('id')->from(Table::CATALOG_PRICING)->all()); + self::assertCount(10, new Query()->select('id')->from(Table::CATALOG_PRICING)->all()); $checkVariantPrices = function(Product $product) { $storeId = $product->getStore()->id; $product->getVariants()->each(function(Variant $variant) use ($storeId) { - $price = (new Query()) + $price = new Query() ->select('price') ->from(Table::CATALOG_PRICING) ->where(['purchasableId' => $variant->id]) @@ -220,7 +221,7 @@ public function generatePricesWithRulesDataProvider(): array [ 'class' => ProductTypeConditionRule::class, 'operator' => 'in', - 'values' => fn(): array => [(new Query())->select('uid')->from(Table::PRODUCTTYPES)->where(['handle' => 'tShirts'])->scalar()], + 'values' => fn(): array => [new Query()->select('uid')->from(Table::PRODUCTTYPES)->where(['handle' => 'tShirts'])->scalar()], ], ], ], @@ -243,7 +244,7 @@ public function generatePricesWithRulesDataProvider(): array [ 'class' => ProductTypeConditionRule::class, 'operator' => 'in', - 'values' => fn(): array => [(new Query())->select('uid')->from(Table::PRODUCTTYPES)->where(['handle' => 'ukOnly'])->scalar()], + 'values' => fn(): array => [new Query()->select('uid')->from(Table::PRODUCTTYPES)->where(['handle' => 'ukOnly'])->scalar()], ], ], ], diff --git a/tests/unit/services/CouponsTest.php b/tests-yii2/unit/services/CouponsTest.php similarity index 98% rename from tests/unit/services/CouponsTest.php rename to tests-yii2/unit/services/CouponsTest.php index a7ab88bcb5..4eb873cf15 100644 --- a/tests/unit/services/CouponsTest.php +++ b/tests-yii2/unit/services/CouponsTest.php @@ -13,6 +13,7 @@ use craft\commerce\Plugin; use craft\commerce\services\Coupons; use craft\helpers\ArrayHelper; +use CraftCms\Commerce\Promotion\Records\Coupon as CouponRecord; use craftcommercetests\fixtures\DiscountsFixture; use UnitTester; @@ -203,7 +204,7 @@ public function saveCouponDataProvider(): array */ public function testDeleteCouponById(): void { - $couponRecord = new \craft\commerce\records\Coupon(); + $couponRecord = new CouponRecord(); $couponRecord->code = 'commerce_test_code'; $couponRecord->discountId = $this->tester->grabFixture('discounts')['discount_with_coupon']['id']; $couponRecord->uses = 0; @@ -241,6 +242,7 @@ public function testSaveDiscountCoupons(): void /** * */ + #[\Override] protected function _before(): void { parent::_before(); diff --git a/tests/unit/services/CustomersTest.php b/tests-yii2/unit/services/CustomersTest.php similarity index 99% rename from tests/unit/services/CustomersTest.php rename to tests-yii2/unit/services/CustomersTest.php index dfb8d467f8..1b659ba0ff 100644 --- a/tests/unit/services/CustomersTest.php +++ b/tests-yii2/unit/services/CustomersTest.php @@ -59,6 +59,7 @@ public function _fixtures(): array ]; } + #[\Override] protected function _before(): void { parent::_before(); @@ -480,9 +481,7 @@ public function saveAddressesOnOrderCompleteDataProvider(): array ]; } - /** - * @inheritdoc - */ + #[\Override] protected function _after(): void { parent::_after(); diff --git a/tests/unit/services/DiscountsTest.php b/tests-yii2/unit/services/DiscountsTest.php similarity index 98% rename from tests/unit/services/DiscountsTest.php rename to tests-yii2/unit/services/DiscountsTest.php index 300e9d85f8..af10ec3a7f 100644 --- a/tests/unit/services/DiscountsTest.php +++ b/tests-yii2/unit/services/DiscountsTest.php @@ -19,10 +19,11 @@ use craft\commerce\models\OrderAdjustment; use craft\commerce\Plugin; use craft\commerce\services\Discounts; -use craft\commerce\test\mockclasses\Purchasable; +use craftcommercetests\mockclasses\Purchasable; use craft\db\Query; use craft\elements\Category; use craft\elements\User; +use CraftCms\Commerce\Promotion\Records\Discount as DiscountRecord; use craftcommercetests\fixtures\CategoriesFixture; use craftcommercetests\fixtures\CustomerFixture; use craftcommercetests\fixtures\DiscountsFixture; @@ -319,7 +320,7 @@ public function testOrderCompleteHandler(): void $this->discounts->orderCompleteHandler($order); // Get thew new Total uses. - $totalUses = (int)(new Query()) + $totalUses = (int)new Query() ->select('totalDiscountUses') ->from('{{%commerce_discounts}}') ->where(['id' => $discountId]) @@ -328,7 +329,7 @@ public function testOrderCompleteHandler(): void self::assertSame(1, $totalUses); // Get the Customer Discount Uses - $customerUses = (new Query()) + $customerUses = new Query() ->select('*') ->from('{{%commerce_customer_discountuses}}') ->where(['customerId' => $this->_user->id, 'discountId' => $discountId, 'uses' => '1']) @@ -338,7 +339,7 @@ public function testOrderCompleteHandler(): void // Get the Email Discount Uses $customerEmail = $order->getCustomer()->email; - $customerUses = (new Query()) + $customerUses = new Query() ->select('*') ->from('{{%commerce_email_discountuses}}') ->where(['email' => $customerEmail, 'discountId' => $discountId, 'uses' => '1']) @@ -347,7 +348,7 @@ public function testOrderCompleteHandler(): void self::assertNotNull($customerUses); // Coupon uses - $couponUses = (new Query()) + $couponUses = new Query() ->select('uses') ->from(Table::COUPONS) ->where(['code' => 'discount_1']) @@ -389,7 +390,7 @@ public function testEnsureSortOrder(): void $storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; // Create dummy discount records for ($i = 1; $i <= 5; $i++) { - $discount = new \craft\commerce\records\Discount(); + $discount = new DiscountRecord(); $discount->name = 'Dummy Discount ' . $i; // randomise the sort order $discount->sortOrder = $i + random_int(1, 15); @@ -405,7 +406,7 @@ public function testEnsureSortOrder(): void $this->discounts->ensureSortOrder($storeId); // Check table directly - $discountRows = (new Query()) + $discountRows = new Query() ->select(['id', 'sortOrder']) ->from(Table::DISCOUNTS) ->orderBy(['sortOrder' => SORT_ASC]) @@ -515,8 +516,8 @@ public function testGetAllActiveDiscounts(array|false $attributes, int $count, a */ public function gatAllActiveDiscountsDataProvider(): array { - $yesterday = (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(12, 0)->modify('-1 day'); - $tomorrow = (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(12, 0)->modify('+1 day'); + $yesterday = new DateTime('now', new DateTimeZone('America/Los_Angeles'))->setTime(12, 0)->modify('-1 day'); + $tomorrow = new DateTime('now', new DateTimeZone('America/Los_Angeles'))->setTime(12, 0)->modify('+1 day'); function _createDiscounts($discounts) { @@ -1007,6 +1008,7 @@ protected function orderCouponAvailableTest(array $orderConfig, bool $desiredRes /** * */ + #[\Override] protected function _before() { parent::_before(); @@ -1016,6 +1018,7 @@ protected function _before() Craft::$app->getUser()->setIdentity($this->_user); } + #[\Override] protected function _after() { Craft::$app->getUser()->setIdentity(null); diff --git a/tests/unit/services/GatewaysTest.php b/tests-yii2/unit/services/GatewaysTest.php similarity index 90% rename from tests/unit/services/GatewaysTest.php rename to tests-yii2/unit/services/GatewaysTest.php index 5a1b8c4386..8a7530b661 100644 --- a/tests/unit/services/GatewaysTest.php +++ b/tests-yii2/unit/services/GatewaysTest.php @@ -43,7 +43,7 @@ public function testGetAllCustomerEnabledGateways(array $gateways, int $count, a $attributes['name'] = $name; if (isset($attributes['isFrontendEnabled']) && is_array($attributes['isFrontendEnabled'])) { - putenv(substr($attributes['isFrontendEnabled']['var'], 1) . '=' . $attributes['isFrontendEnabled']['value']); + putenv(substr((string) $attributes['isFrontendEnabled']['var'], 1) . '=' . $attributes['isFrontendEnabled']['value']); $attributes['isFrontendEnabled'] = $attributes['isFrontendEnabled']['var']; } $gateway = Craft::createObject($class, ['config' => ['attributes' => $attributes]]); @@ -60,6 +60,14 @@ public function testGetAllCustomerEnabledGateways(array $gateways, int $count, a self::assertEquals($enabledKeys, ArrayHelper::getColumn($enabledGateways, 'name', false)); } + public function testGetAllGatewayTypes(): void + { + self::assertEqualsCanonicalizing( + [Dummy::class, Manual::class], + Plugin::getInstance()->getGateways()->getAllGatewayTypes(), + ); + } + public function getAllCustomerEnabledGatewaysDataProvider(): array { return [ diff --git a/tests/unit/services/InventoryMovementTest.php b/tests-yii2/unit/services/InventoryMovementTest.php similarity index 100% rename from tests/unit/services/InventoryMovementTest.php rename to tests-yii2/unit/services/InventoryMovementTest.php diff --git a/tests/unit/services/InventoryTest.php b/tests-yii2/unit/services/InventoryTest.php similarity index 100% rename from tests/unit/services/InventoryTest.php rename to tests-yii2/unit/services/InventoryTest.php diff --git a/tests/unit/services/LineItemsTest.php b/tests-yii2/unit/services/LineItemsTest.php similarity index 99% rename from tests/unit/services/LineItemsTest.php rename to tests-yii2/unit/services/LineItemsTest.php index 013fb370fd..e5a552f46d 100644 --- a/tests/unit/services/LineItemsTest.php +++ b/tests-yii2/unit/services/LineItemsTest.php @@ -51,6 +51,7 @@ public function _fixtures(): array ]; } + #[\Override] protected function _before(): void { parent::_before(); diff --git a/tests/unit/services/OrdersTest.php b/tests-yii2/unit/services/OrdersTest.php similarity index 99% rename from tests/unit/services/OrdersTest.php rename to tests-yii2/unit/services/OrdersTest.php index 5b3e9a08f8..e7880843b7 100644 --- a/tests/unit/services/OrdersTest.php +++ b/tests-yii2/unit/services/OrdersTest.php @@ -54,6 +54,7 @@ public function _fixtures(): array ]; } + #[\Override] protected function _before(): void { parent::_before(); diff --git a/tests/unit/services/PaymentCurrenciesTest.php b/tests-yii2/unit/services/PaymentCurrenciesTest.php similarity index 98% rename from tests/unit/services/PaymentCurrenciesTest.php rename to tests-yii2/unit/services/PaymentCurrenciesTest.php index 7988b7385b..16d9c669f1 100644 --- a/tests/unit/services/PaymentCurrenciesTest.php +++ b/tests-yii2/unit/services/PaymentCurrenciesTest.php @@ -11,8 +11,8 @@ use craft\commerce\errors\CurrencyException; use craft\commerce\events\PaymentCurrencyRateEvent; use craft\commerce\Plugin; -use craft\commerce\records\PaymentCurrency as PaymentCurrencyRecord; use craft\commerce\services\PaymentCurrencies; +use CraftCms\Commerce\Payment\Records\PaymentCurrency as PaymentCurrencyRecord; use craftcommercetests\fixtures\PaymentCurrenciesFixture; use Money\Currency; use Money\Money; @@ -58,6 +58,7 @@ public function _fixtures(): array /** * */ + #[\Override] protected function _before(): void { parent::_before(); @@ -254,7 +255,7 @@ public function testSavePaymentCurrencyIgnoresEventRate(): void self::assertTrue($this->pc->savePaymentCurrency($eur)); - $record = PaymentCurrencyRecord::findOne(['id' => $eur->id]); + $record = PaymentCurrencyRecord::find($eur->id); self::assertNotNull($record); self::assertEquals(0.75, $record->rate, 'Raw admin-entered rate is persisted, not the event rate.'); diff --git a/tests/unit/services/ProductPermissionTest.php b/tests-yii2/unit/services/ProductPermissionTest.php similarity index 100% rename from tests/unit/services/ProductPermissionTest.php rename to tests-yii2/unit/services/ProductPermissionTest.php diff --git a/tests/unit/services/SalesTest.php b/tests-yii2/unit/services/SalesTest.php similarity index 95% rename from tests/unit/services/SalesTest.php rename to tests-yii2/unit/services/SalesTest.php index 88123798f3..37d68e24ca 100644 --- a/tests/unit/services/SalesTest.php +++ b/tests-yii2/unit/services/SalesTest.php @@ -70,6 +70,7 @@ public function _fixtures(): array /* * */ + #[\Override] protected function _before(): void { parent::_before(); @@ -83,6 +84,7 @@ protected function _before(): void /** * */ + #[\Override] protected function _after() { parent::_after(); @@ -105,7 +107,7 @@ public function testGetAllSales(): void self::assertSame($this->salesData->data['percentageSale']['name'], $firstSale->name); $variant = Variant::find()->sku('rad-hood')->one(); - self::assertSame([(int)$variant->id], array_map('intval', $firstSale->getPurchasableIds())); + self::assertSame([(int)$variant->id], array_map(intval(...), $firstSale->getPurchasableIds())); self::assertSame([], $firstSale->getUserGroupIds()); self::assertSame([], $firstSale->getCategoryIds()); } @@ -175,7 +177,7 @@ public function testSaveSale(): void { $sale = $this->sales->getSaleById($this->salesData['allRelationships']['id']); $originalName = $sale->name; - $originalDateUpdated = (new Query()) + $originalDateUpdated = new Query() ->select('dateUpdated') ->from(Table::SALES) ->where(['id' => $sale->id]) @@ -185,7 +187,7 @@ public function testSaveSale(): void // Absolutely make sure enough time has passed sleep(1); $saveResult = $this->sales->saveSale($sale); - $newDateUpdated = (new Query()) + $newDateUpdated = new Query() ->select('dateUpdated') ->from(Table::SALES) ->where(['id' => $sale->id]) @@ -211,7 +213,7 @@ public function testReorderSales(): void self::assertTrue($reorderResult, 'Reorder sales completed'); - $dbOrder = (new Query()) + $dbOrder = new Query() ->select(['id']) ->from(Table::SALES) ->orderBy('sortOrder asc') @@ -240,6 +242,6 @@ public function testDeleteSaleById(): void self::assertTrue($deleteResult); self::assertNull($this->sales->getSaleById($id)); - self::assertFalse(array_key_exists($id, $this->sales->getAllSales())); + self::assertFalse(array_key_exists((string) $id, $this->sales->getAllSales())); } } diff --git a/tests/unit/services/ShippingCategoryTest.php b/tests-yii2/unit/services/ShippingCategoryTest.php similarity index 83% rename from tests/unit/services/ShippingCategoryTest.php rename to tests-yii2/unit/services/ShippingCategoryTest.php index 21124d663e..7d2db83850 100644 --- a/tests/unit/services/ShippingCategoryTest.php +++ b/tests-yii2/unit/services/ShippingCategoryTest.php @@ -10,9 +10,9 @@ use Codeception\Test\Unit; use craft\commerce\db\Table; use craft\commerce\Plugin; -use craft\commerce\records\ShippingCategory; use craft\commerce\services\ShippingCategories; use craft\helpers\Db; +use CraftCms\Commerce\Shipping\Records\ShippingCategory; use craftcommercetests\fixtures\ProductFixture; use UnitTester; @@ -40,6 +40,7 @@ public function _fixtures(): array ]; } + #[\Override] public function _before() { parent::_before(); @@ -50,9 +51,7 @@ public function _before() public function testDeleteShippingCategory() { // Get the non-default shipping category from fixtures (anotherShippingCategory) - $shippingCategory = ShippingCategory::find() - ->where(['handle' => 'anotherShippingCategory']) - ->one(); + $shippingCategory = ShippingCategory::where('handle', 'anotherShippingCategory')->first(); $this->assertNotNull($shippingCategory, 'anotherShippingCategory fixture should exist'); $this->assertFalse((bool)$shippingCategory->default, 'Test shipping category should not be default'); @@ -63,11 +62,11 @@ public function testDeleteShippingCategory() $this->assertTrue($result); - $shippingCategory = ShippingCategory::findOne($shippingCategoryId); + $shippingCategory = ShippingCategory::find($shippingCategoryId); $this->assertNull($shippingCategory); - $shippingCategory = ShippingCategory::findTrashed()->where(['id' => $shippingCategoryId])->one(); + $shippingCategory = ShippingCategory::onlyTrashed()->where('id', $shippingCategoryId)->first(); $this->assertInstanceOf(ShippingCategory::class, $shippingCategory); diff --git a/tests/unit/services/ShippingMethodsTest.php b/tests-yii2/unit/services/ShippingMethodsTest.php similarity index 99% rename from tests/unit/services/ShippingMethodsTest.php rename to tests-yii2/unit/services/ShippingMethodsTest.php index f139213c1c..eb36111052 100644 --- a/tests/unit/services/ShippingMethodsTest.php +++ b/tests-yii2/unit/services/ShippingMethodsTest.php @@ -33,6 +33,7 @@ class ShippingMethodsTest extends Unit protected $shippingMethods; + #[\Override] public function _before() { parent::_before(); diff --git a/tests/unit/services/ShippingRulesTest.php b/tests-yii2/unit/services/ShippingRulesTest.php similarity index 99% rename from tests/unit/services/ShippingRulesTest.php rename to tests-yii2/unit/services/ShippingRulesTest.php index 197f3ba5ac..dbddeb0cdc 100644 --- a/tests/unit/services/ShippingRulesTest.php +++ b/tests-yii2/unit/services/ShippingRulesTest.php @@ -48,6 +48,7 @@ public function _fixtures(): array ]; } + #[\Override] public function _before(): void { parent::_before(); diff --git a/tests/unit/services/StoreTest.php b/tests-yii2/unit/services/StoreTest.php similarity index 98% rename from tests/unit/services/StoreTest.php rename to tests-yii2/unit/services/StoreTest.php index e4479a4d27..bb9214d83b 100644 --- a/tests/unit/services/StoreTest.php +++ b/tests-yii2/unit/services/StoreTest.php @@ -55,6 +55,7 @@ public function testGetAllEnabledCountriesAsList(): void /** * */ + #[\Override] public function _before(): void { parent::_before(); diff --git a/tests/unit/services/StoresTest.php b/tests-yii2/unit/services/StoresTest.php similarity index 99% rename from tests/unit/services/StoresTest.php rename to tests-yii2/unit/services/StoresTest.php index c0a69fe24d..d7ac245ac7 100644 --- a/tests/unit/services/StoresTest.php +++ b/tests-yii2/unit/services/StoresTest.php @@ -90,6 +90,7 @@ public function getStoreBySiteIdDataProvider(): array /** * */ + #[\Override] public function _before(): void { parent::_before(); diff --git a/tests/unit/services/TaxCategoryTest.php b/tests-yii2/unit/services/TaxCategoryTest.php similarity index 88% rename from tests/unit/services/TaxCategoryTest.php rename to tests-yii2/unit/services/TaxCategoryTest.php index 126bcf06b2..3d9cdd7c4d 100644 --- a/tests/unit/services/TaxCategoryTest.php +++ b/tests-yii2/unit/services/TaxCategoryTest.php @@ -11,9 +11,9 @@ use craft\commerce\db\Table; use craft\commerce\elements\Product; use craft\commerce\Plugin; -use craft\commerce\records\TaxCategory; use craft\commerce\services\TaxCategories; use craft\helpers\Db; +use CraftCms\Commerce\Tax\Records\TaxCategory; use craftcommercetests\fixtures\ProductFixture; use UnitTester; @@ -41,6 +41,7 @@ public function _fixtures(): array ]; } + #[\Override] public function _before() { parent::_before(); @@ -59,11 +60,11 @@ public function testDeleteTaxCategory() $this->assertTrue($result); - $taxCategory = TaxCategory::findOne($taxCategoryId); + $taxCategory = TaxCategory::find($taxCategoryId); $this->assertNull($taxCategory); - $taxCategory = TaxCategory::findTrashed()->where(['id' => $taxCategoryId])->one(); + $taxCategory = TaxCategory::onlyTrashed()->where('id', $taxCategoryId)->first(); $this->assertInstanceOf(TaxCategory::class, $taxCategory); diff --git a/tests/unit/services/UserGroupConditionDiscountTest.php b/tests-yii2/unit/services/UserGroupConditionDiscountTest.php similarity index 98% rename from tests/unit/services/UserGroupConditionDiscountTest.php rename to tests-yii2/unit/services/UserGroupConditionDiscountTest.php index c1219afc81..ff382cae4a 100644 --- a/tests/unit/services/UserGroupConditionDiscountTest.php +++ b/tests-yii2/unit/services/UserGroupConditionDiscountTest.php @@ -10,7 +10,6 @@ use Codeception\Test\Unit; use craft\commerce\models\Discount; use craft\commerce\Plugin; -use craft\commerce\records\Discount as DiscountRecord; use craft\commerce\services\Discounts; use craft\elements\User; use craft\services\Users; @@ -35,6 +34,7 @@ class UserGroupConditionDiscountTest extends Unit /** * */ + #[\Override] protected function _before(): void { parent::_before(); diff --git a/tests/.env.example.mysql b/tests/.env.example.mysql deleted file mode 100644 index 2be345a8a1..0000000000 --- a/tests/.env.example.mysql +++ /dev/null @@ -1,15 +0,0 @@ -APP_ID=CraftCMS -SECURITY_KEY=UPzGqJJMCTM4n07jkqaFNaVoof6j_Xgo - -DB_DRIVER=mysql -DB_SERVER=127.0.0.1 -DB_PORT=3306 -DB_DATABASE=craft_test -DB_USER=root -DB_PASSWORD= -DB_SCHEMA="public" - -# Set this to the `entryUrl` param in the `codeception.yml` file. -DEFAULT_SITE_URL="https://test.craftcms.test/index.php" -FROM_EMAIL_NAME="Craft CMS" -FROM_EMAIL_ADDRESS="info@craftcms.com" \ No newline at end of file diff --git a/tests/.env.example.pgsql b/tests/.env.example.pgsql deleted file mode 100644 index aa67c08cc4..0000000000 --- a/tests/.env.example.pgsql +++ /dev/null @@ -1,15 +0,0 @@ -APP_ID=CraftCMS -SECURITY_KEY=UPzGqJJMCTM4n07jkqaFNaVoof6j_Xgo - -DB_DRIVER=pgsql -DB_SERVER=127.0.0.1 -DB_PORT=5432 -DB_DATABASE=craft_test -DB_USER=root -DB_PASSWORD= -DB_SCHEMA="public" - -# Set this to the `entryUrl` param in the `codeception.yml` file. -DEFAULT_SITE_URL="https://test.craftcms.test/index.php" -FROM_EMAIL_NAME="Craft CMS" -FROM_EMAIL_ADDRESS="info@craftcms.com" \ No newline at end of file diff --git a/tests/.gitignore b/tests/.gitignore deleted file mode 100644 index 4c49bd78f1..0000000000 --- a/tests/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.env diff --git a/tests/_data/.gitkeep b/tests/Arch/.gitkeep similarity index 100% rename from tests/_data/.gitkeep rename to tests/Arch/.gitkeep diff --git a/tests/Arch/ArchTest.php b/tests/Arch/ArchTest.php new file mode 100644 index 0000000000..8995fbc8bb --- /dev/null +++ b/tests/Arch/ArchTest.php @@ -0,0 +1,12 @@ +expect('src') + ->not->toUse(['die', 'dd', 'dump', 'env']); + +arch('src/ should not reference legacy Craft core classes (craft\commerce is allowed during the migration)') + ->expect('src') + ->not->toUse('craft') + ->ignoring('craft\commerce'); diff --git a/tests/Feature/Helpers/CurrencyTest.php b/tests/Feature/Helpers/CurrencyTest.php new file mode 100644 index 0000000000..c10817080d --- /dev/null +++ b/tests/Feature/Helpers/CurrencyTest.php @@ -0,0 +1,59 @@ +toBe($expected); +})->with([ + 'USD-US' => ['USD', 'en-US', '$1,234.56'], + 'USD-GB' => ['USD', 'en-GB', 'US$1,234.56'], + 'USD-FR' => ['USD', 'fr-FR', "1\u{202F}234,56\u{A0}\$US"], + 'EUR-US' => ['EUR', 'en-US', '€1,234.56'], + 'EUR-GB' => ['EUR', 'en-GB', '€1,234.56'], + 'EUR-FR' => ['EUR', 'fr-FR', "1\u{202F}234,56\u{A0}€"], +]); + +test('formatAsCurrency strips trailing zeros when requested', function(string $currency, string $language, float $amount, bool $stripZeros, string $expected) { + Locale::switchAppLanguage($language); + + expect(Currency::formatAsCurrency($amount, $currency, stripZeros: $stripZeros))->toBe($expected); +})->with([ + 'USD-US' => ['USD', 'en-US', 1234.56, true, '$1,234.56'], + 'USD-US-strip' => ['USD', 'en-US', 1234.00, true, '$1,234'], + 'USD-US-no-strip' => ['USD', 'en-US', 1234.00, false, '$1,234.00'], + 'USD-GB' => ['USD', 'en-GB', 1234.56, true, 'US$1,234.56'], + 'USD-GB-strip' => ['USD', 'en-GB', 1234.0, true, 'US$1,234'], + 'USD-GB-no-strip' => ['USD', 'en-GB', 1234.0, false, 'US$1,234.00'], + 'USD-FR' => ['USD', 'fr-FR', 1234.56, true, "1\u{202F}234,56\u{A0}\$US"], + 'USD-FR-strip' => ['USD', 'fr-FR', 1234.00, true, "1\u{202F}234\u{A0}\$US"], + 'USD-FR-no-strip' => ['USD', 'fr-FR', 1234.00, false, "1\u{202F}234,00\u{A0}\$US"], + 'EUR-US' => ['EUR', 'en-US', 1234.56, true, '€1,234.56'], + 'EUR-US-strip' => ['EUR', 'en-US', 1234.00, true, '€1,234'], + 'EUR-US-no-strip' => ['EUR', 'en-US', 1234.00, false, '€1,234.00'], + 'EUR-FR' => ['EUR', 'fr-FR', 1234.56, true, "1\u{202F}234,56\u{A0}€"], + 'EUR-FR-strip' => ['EUR', 'fr-FR', 1234.00, true, "1\u{202F}234\u{A0}€"], + 'EUR-FR-no-strip' => ['EUR', 'fr-FR', 1234.00, false, "1\u{202F}234,00\u{A0}€"], +]); + +test('formatAsCurrency formats negative amounts', function(string $currency, string $language, string $expected) { + Locale::switchAppLanguage($language); + + expect(Currency::formatAsCurrency(-1234.56, $currency))->toBe($expected); +})->with([ + 'USD-US' => ['USD', 'en-US', '-$1,234.56'], + 'USD-GB' => ['USD', 'en-GB', '-US$1,234.56'], + 'USD-FR' => ['USD', 'fr-FR', "-1\u{202F}234,56\u{A0}\$US"], + 'EUR-US' => ['EUR', 'en-US', '-€1,234.56'], + 'EUR-GB' => ['EUR', 'en-GB', '-€1,234.56'], + 'EUR-FR' => ['EUR', 'fr-FR', "-1\u{202F}234,56\u{A0}€"], + 'CHF-DE-CH' => ['CHF', 'de-CH', "CHF-1\u{2019}234.56"], +]); diff --git a/tests/Feature/Helpers/LocaleTest.php b/tests/Feature/Helpers/LocaleTest.php new file mode 100644 index 0000000000..22d06d315d --- /dev/null +++ b/tests/Feature/Helpers/LocaleTest.php @@ -0,0 +1,49 @@ +orderLanguage = 'nl'; + + $pdf = new Pdf(); + $pdf->language = PdfRecord::LOCALE_ORDER_LANGUAGE; + + expect($pdf->getRenderLanguage($order))->toBe('nl'); +}); + +test('Pdf::getRenderLanguage() returns its own language when not order-language', function() { + $order = new Order(); + $order->orderLanguage = 'nl'; + + $pdf = new Pdf(); + $pdf->language = 'ph'; + + expect($pdf->getRenderLanguage($order))->toBe('ph'); +}); + +test('Email::getRenderLanguage() resolves order-language from the given order', function() { + $order = new Order(); + $order->orderLanguage = 'nl'; + + $email = new Email(); + $email->language = EmailRecord::LOCALE_ORDER_LANGUAGE; + + expect($email->getRenderLanguage($order))->toBe('nl'); +}); + +test('Email::getRenderLanguage() returns its own language when not order-language', function() { + $order = new Order(); + $order->orderLanguage = 'nl'; + + $email = new Email(); + $email->language = 'ph'; + + expect($email->getRenderLanguage($order))->toBe('ph'); +}); diff --git a/tests/Feature/PluginBootTest.php b/tests/Feature/PluginBootTest.php new file mode 100644 index 0000000000..8888622f05 --- /dev/null +++ b/tests/Feature/PluginBootTest.php @@ -0,0 +1,26 @@ +types(); + + expect($types->contains(Product::class))->toBeTrue(); +}); + +test('Site::getStore() macro resolves via method call', function() { + $site = Sites::getCurrentSite(); + + expect($site->getStore())->toBeInstanceOf(Store::class); +}); + +test('Site::getStore() macro resolves via magic property access', function() { + $site = Sites::getCurrentSite(); + + expect($site->store)->toBeInstanceOf(Store::class); +}); diff --git a/tests/Feature/Stats/AverageOrderTotalTest.php b/tests/Feature/Stats/AverageOrderTotalTest.php new file mode 100644 index 0000000000..5987ea6781 --- /dev/null +++ b/tests/Feature/Stats/AverageOrderTotalTest.php @@ -0,0 +1,36 @@ +fixture = OrdersFixture::seed(); +}); + +test('getData', function(string $dateRange, DateTime $startDate, DateTime $endDate, ?float $average) { + $stat = new AverageOrderTotal($dateRange, $startDate, $endDate, $this->fixture->storeId); + $data = $stat->get(); + + if ($average === null) { + expect($data)->toBeNull(); + } else { + expect((float) $data)->toBe($average); + } +})->with(function() { + return [ + [ + AverageOrderTotal::DATE_RANGE_TODAY, + new DateTime('now')->setTime(0, 0), + new DateTime('now')->setTime(0, 0), + 63.97, + ], + [ + AverageOrderTotal::DATE_RANGE_CUSTOM, + new DateTime('7 days ago')->setTime(0, 0), + new DateTime('5 days ago')->setTime(0, 0), + null, + ], + ]; +}); diff --git a/tests/Feature/Stats/NewCustomersTest.php b/tests/Feature/Stats/NewCustomersTest.php new file mode 100644 index 0000000000..7df9d5d597 --- /dev/null +++ b/tests/Feature/Stats/NewCustomersTest.php @@ -0,0 +1,37 @@ +fixture = OrdersFixture::seed(); +}); + +test('getData', function(string $dateRange, DateTime $startDate, DateTime $endDate, ?float $count) { + $stat = new NewCustomers($dateRange, $startDate, $endDate, $this->fixture->storeId); + $data = $stat->get(); + + expect($data)->toBeNumeric(); + expect((float) $data)->toBe($count); +})->with([ + [ + NewCustomers::DATE_RANGE_CUSTOM, + new DateTime('2 days ago')->setTime(0, 0), + new DateTime('0 days ago')->setTime(0, 0), + 1.0, + ], + [ + NewCustomers::DATE_RANGE_TODAY, + new DateTime('now')->setTime(0, 0), + new DateTime('now')->setTime(0, 0), + 0.0, + ], + [ + NewCustomers::DATE_RANGE_CUSTOM, + new DateTime('7 days ago')->setTime(0, 0), + new DateTime('5 days ago')->setTime(0, 0), + 0.0, + ], +]); diff --git a/tests/Feature/Stats/RepeatCustomersTest.php b/tests/Feature/Stats/RepeatCustomersTest.php new file mode 100644 index 0000000000..c8bc233d81 --- /dev/null +++ b/tests/Feature/Stats/RepeatCustomersTest.php @@ -0,0 +1,37 @@ +fixture = OrdersFixture::seed(); +}); + +test('getData', function(string $dateRange, DateTime $startDate, DateTime $endDate, int $total, int $repeat, int $percentage) { + $stat = new RepeatCustomers($dateRange, $startDate, $endDate, $this->fixture->storeId); + $data = $stat->get(); + + expect($data)->toBeArray(); + expect($data['total'])->toBe($total); + expect($data['repeat'])->toBe($repeat); + expect((int) $data['percentage'])->toBe($percentage); +})->with([ + [ + RepeatCustomers::DATE_RANGE_TODAY, + new DateTime('now')->setTime(0, 0), + new DateTime('now')->setTime(0, 0), + 1, + 1, + 100, + ], + [ + RepeatCustomers::DATE_RANGE_CUSTOM, + new DateTime('7 days ago')->setTime(0, 0), + new DateTime('5 days ago')->setTime(0, 0), + 0, + 0, + 0, + ], +]); diff --git a/tests/Feature/Stats/StatTest.php b/tests/Feature/Stats/StatTest.php new file mode 100644 index 0000000000..3616483a34 --- /dev/null +++ b/tests/Feature/Stats/StatTest.php @@ -0,0 +1,120 @@ +createChartQuery(); + } + }; +} + +test('instantiating with a date range populates the chart with both endpoints', function(string $dateRange, DateTime $startDate, DateTime $endDate) { + $storeId = app(Stores::class)->getPrimaryStore()->id; + $stat = createStatClass($dateRange, $startDate, $endDate, $storeId); + + $data = $stat->get(); + + expect($data)->toHaveKey($startDate->format('Y-m-d')); + expect($data)->toHaveKey($endDate->format('Y-m-d')); + expect($data)->toHaveCount(2); +})->with('instantiateDatesDataProvider'); + +test('predefined date ranges produce a chart bucket for every day/month in range', function(string $dateRange, DateTime $startDate, DateTime $endDate, int $keysCount, bool $keyedByDays = true) { + $format = $keyedByDays ? 'Y-m-d' : 'Y-n'; + $storeId = app(Stores::class)->getPrimaryStore()->id; + $stat = createStatClass($dateRange, $startDate, $endDate, $storeId); + + $data = $stat->get(); + + while ($startDate <= $endDate) { + expect($data)->toHaveKey($startDate->format($format)); + + if ($keyedByDays) { + $startDate->add(new DateInterval('P1D')); + } else { + $startDate->add(new DateInterval('P1M')); + } + } + + expect($data)->toHaveCount($keysCount); +})->with('predefinedDateRangesDataProvider'); + +dataset('instantiateDatesDataProvider', function() { + $tz = new DateTimeZone('America/Los_Angeles'); + + return [ + [ + StatInterface::DATE_RANGE_CUSTOM, + new DateTime('yesterday', $tz)->setTime(0, 0), + new DateTime('now', $tz)->setTime(0, 0), + ], + ]; +}); + +dataset('predefinedDateRangesDataProvider', function() { + $tz = new DateTimeZone('America/Los_Angeles'); + $today = new DateTime('now', $tz)->setTime(0, 0); + + return [ + StatInterface::DATE_RANGE_TODAY => [ + StatInterface::DATE_RANGE_TODAY, + clone $today, + clone $today, + 1, + ], + StatInterface::DATE_RANGE_PAST7DAYS => [ + StatInterface::DATE_RANGE_PAST7DAYS, + new DateTime('6 days ago', $tz)->setTime(0, 0), + clone $today, + 7, + ], + StatInterface::DATE_RANGE_PAST30DAYS => [ + StatInterface::DATE_RANGE_PAST30DAYS, + new DateTime('29 days ago', $tz)->setTime(0, 0), + clone $today, + 30, + ], + StatInterface::DATE_RANGE_PAST90DAYS => [ + StatInterface::DATE_RANGE_PAST90DAYS, + new DateTime('89 days ago', $tz)->setTime(0, 0), + clone $today, + 90, + ], + StatInterface::DATE_RANGE_PASTYEAR => [ + StatInterface::DATE_RANGE_PASTYEAR, + new DateTime('11 months ago', $tz)->setTime(0, 0), + clone $today, + 12, + false, + ], + StatInterface::DATE_RANGE_THISMONTH => [ + StatInterface::DATE_RANGE_THISMONTH, + new DateTime('now', $tz)->setDate((int) $today->format('Y'), (int) $today->format('n'), 1)->setTime(0, 0), + clone $today, + (int) $today->format('t'), + ], + StatInterface::DATE_RANGE_THISWEEK => [ + StatInterface::DATE_RANGE_THISWEEK, + new DateTime('Monday this week', $tz)->setTime(0, 0), + clone $today, + 7, + ], + StatInterface::DATE_RANGE_THISYEAR => [ + StatInterface::DATE_RANGE_THISYEAR, + new DateTime('first day of January ' . $today->format('Y'), $tz)->setTime(0, 0), + clone $today, + (int) ($today->diff(new DateTime('first day of January ' . $today->format('Y'), $tz)->setTime(0, 0))->format('%m')) + 1, + false, + ], + ]; +}); diff --git a/tests/Feature/Stats/TopCustomersTest.php b/tests/Feature/Stats/TopCustomersTest.php new file mode 100644 index 0000000000..8acef15ba8 --- /dev/null +++ b/tests/Feature/Stats/TopCustomersTest.php @@ -0,0 +1,54 @@ +fixture = OrdersFixture::seed(); +}); + +test('getData', function(string $dateRange, string $type, DateTime $startDate, DateTime $endDate, int $count, ?Closure $customerData) { + $stat = new TopCustomers($dateRange, $type, $startDate, $endDate, $this->fixture->storeId); + $data = $stat->get(); + + expect($data)->toBeArray(); + expect($data)->toHaveCount($count); + + if ($count !== 0) { + $topCustomer = array_shift($data); + $expected = $customerData($this->fixture); + + foreach (['total', 'average', 'customerId', 'email', 'count'] as $key) { + expect($topCustomer)->toHaveKey($key); + expect($topCustomer[$key])->toBe($expected[$key]); + } + + expect($topCustomer['customer'])->toBeInstanceOf(User::class); + } +})->with([ + [ + TopCustomers::DATE_RANGE_TODAY, + 'total', + new DateTime('now')->setTime(0, 0), + new DateTime('now')->setTime(0, 0), + 1, + fn(OrdersFixture $fixture) => [ + 'total' => 127.94, + 'average' => 63.97, + 'customerId' => $fixture->customer->id, + 'email' => $fixture->customer->email, + 'count' => 2, + ], + ], + [ + TopCustomers::DATE_RANGE_CUSTOM, + 'total', + new DateTime('7 days ago')->setTime(0, 0), + new DateTime('5 days ago')->setTime(0, 0), + 0, + null, + ], +]); diff --git a/tests/Feature/Stats/TopProductTypesTest.php b/tests/Feature/Stats/TopProductTypesTest.php new file mode 100644 index 0000000000..605c6b3111 --- /dev/null +++ b/tests/Feature/Stats/TopProductTypesTest.php @@ -0,0 +1,64 @@ +fixture = OrdersFixture::seed(); + + $admin = User::find()->admin(true)->one(); + $this->actingAs($admin, 'craft'); + // `actingAs()` doesn't retroactively update the already-bound `request()` singleton's user + // resolver in this Testbench setup, and `getViewableProductTypeIds()` reads the current user + // via `request()->craftUser()` rather than the `Auth` facade. + request()->setUserResolver(fn() => $admin); +}); + +test('getData', function(string $dateRange, string $type, DateTime $startDate, DateTime $endDate, int $count, ?array $productTypeData) { + $stat = new TopProductTypes($dateRange, $type, $startDate, $endDate, $this->fixture->storeId); + $data = $stat->get(); + + expect($data)->toBeArray(); + expect($data)->toHaveCount($count); + + if ($count !== 0) { + $topProductType = array_shift($data); + + expect($topProductType['id'])->toBe($this->fixture->product->typeId); + + foreach (['name', 'qty', 'revenue'] as $key) { + expect($topProductType)->toHaveKey($key); + expect($topProductType[$key])->toBe($productTypeData[$key]); + } + + expect($topProductType['productType'])->toBeInstanceOf(ProductType::class); + } +})->with(function() { + return [ + [ + TopProducts::DATE_RANGE_TODAY, + 'revenue', + new DateTime('now')->setTime(0, 0), + new DateTime('now')->setTime(0, 0), + 1, + [ + 'name' => 'T-Shirts', + 'qty' => 6, + 'revenue' => 127.94, + ], + ], + [ + TopProducts::DATE_RANGE_CUSTOM, + 'revenue', + new DateTime('7 days ago')->setTime(0, 0), + new DateTime('5 days ago')->setTime(0, 0), + 0, + null, + ], + ]; +}); diff --git a/tests/Feature/Stats/TopProductsTest.php b/tests/Feature/Stats/TopProductsTest.php new file mode 100644 index 0000000000..1f6a46b230 --- /dev/null +++ b/tests/Feature/Stats/TopProductsTest.php @@ -0,0 +1,53 @@ +fixture = OrdersFixture::seed(); +}); + +test('getData', function(string $dateRange, string $type, DateTime $startDate, DateTime $endDate, int $count, ?Closure $productData) { + $stat = new TopProducts($dateRange, $type, $startDate, $endDate, storeId: $this->fixture->storeId); + $data = $stat->get(); + + expect($data)->toBeArray(); + expect($data)->toHaveCount($count); + + if ($count !== 0) { + $topProduct = array_shift($data); + $expected = $productData($this->fixture); + + foreach (['id', 'title', 'qty', 'revenue'] as $key) { + expect($topProduct)->toHaveKey($key); + expect($topProduct[$key])->toBe($expected[$key]); + } + + expect($topProduct['product'])->toBeInstanceOf(Product::class); + } +})->with([ + [ + TopProducts::DATE_RANGE_TODAY, + 'revenue', + new DateTime('now')->setTime(0, 0), + new DateTime('now')->setTime(0, 0), + 1, + fn(OrdersFixture $fixture) => [ + 'id' => $fixture->product->id, + 'title' => 'Hypercolor T-Shirt', + 'qty' => 6, + 'revenue' => 127.94, + ], + ], + [ + TopProducts::DATE_RANGE_CUSTOM, + 'revenue', + new DateTime('7 days ago')->setTime(0, 0), + new DateTime('5 days ago')->setTime(0, 0), + 0, + null, + ], +]); diff --git a/tests/Feature/Stats/TopPurchasablesTest.php b/tests/Feature/Stats/TopPurchasablesTest.php new file mode 100644 index 0000000000..18d9124d96 --- /dev/null +++ b/tests/Feature/Stats/TopPurchasablesTest.php @@ -0,0 +1,59 @@ +fixture = OrdersFixture::seed(); + + $admin = User::find()->admin(true)->one(); + $this->actingAs($admin, 'craft'); + // `actingAs()` doesn't retroactively update the already-bound `request()` singleton's user + // resolver in this Testbench setup, and `getViewableProductTypeIds()` reads the current user + // via `request()->craftUser()` rather than the `Auth` facade. + request()->setUserResolver(fn() => $admin); +}); + +test('getData', function(string $dateRange, string $type, DateTime $startDate, DateTime $endDate, int $count, ?Closure $purchasableData) { + $stat = new TopPurchasables($dateRange, $type, $startDate, $endDate, $this->fixture->storeId); + $data = $stat->get(); + + expect($data)->toBeArray(); + expect($data)->toHaveCount($count); + + if ($count !== 0) { + $topPurchasable = array_shift($data); + $expected = $purchasableData($this->fixture); + + foreach (['purchasableId', 'description', 'sku', 'qty', 'revenue'] as $key) { + expect($topPurchasable)->toHaveKey($key); + expect($topPurchasable[$key])->toBe($expected[$key]); + } + } +})->with([ + 'date-today' => [ + TopPurchasables::DATE_RANGE_TODAY, + 'revenue', + new DateTime('now')->setTime(0, 0), + new DateTime('now')->setTime(0, 0), + 2, + fn(OrdersFixture $fixture) => [ + 'purchasableId' => $fixture->blue->id, + 'description' => $fixture->blue->getDescription(), + 'sku' => 'hct-blue', + 'qty' => 4, + 'revenue' => 87.96, + ], + ], + 'date-custom' => [ + TopPurchasables::DATE_RANGE_CUSTOM, + 'qty', + new DateTime('7 days ago')->setTime(0, 0), + new DateTime('5 days ago')->setTime(0, 0), + 0, + null, + ], +]); diff --git a/tests/Feature/Stats/TotalOrdersByCountryTest.php b/tests/Feature/Stats/TotalOrdersByCountryTest.php new file mode 100644 index 0000000000..d6e79701ec --- /dev/null +++ b/tests/Feature/Stats/TotalOrdersByCountryTest.php @@ -0,0 +1,48 @@ +fixture = OrdersFixture::seed(); +}); + +test('getData', function(string $dateRange, string $type, DateTime $startDate, DateTime $endDate, int $count, array $countryData) { + $stat = new TotalOrdersByCountry($dateRange, $type, $startDate, $endDate, $this->fixture->storeId); + $data = $stat->get(); + + expect($data)->toBeArray(); + expect($data)->toHaveCount($count); + + if ($count !== 0) { + $firstItem = array_shift($data); + + foreach ($countryData as $key => $value) { + expect($firstItem)->toHaveKey($key); + expect($firstItem[$key])->toBe($value); + } + } +})->with([ + [ + TotalOrdersByCountry::DATE_RANGE_TODAY, + 'shipping', + new DateTime('now')->setTime(0, 0), + new DateTime('now')->setTime(0, 0), + 1, + [ + 'total' => 2, + 'name' => 'United States', + 'countryCode' => 'US', + ], + ], + [ + TotalOrdersByCountry::DATE_RANGE_CUSTOM, + 'shipping', + new DateTime('7 days ago')->setTime(0, 0), + new DateTime('5 days ago')->setTime(0, 0), + 0, + [], + ], +]); diff --git a/tests/Feature/Stats/TotalOrdersTest.php b/tests/Feature/Stats/TotalOrdersTest.php new file mode 100644 index 0000000000..8b50a9fdba --- /dev/null +++ b/tests/Feature/Stats/TotalOrdersTest.php @@ -0,0 +1,54 @@ +fixture = OrdersFixture::seed(); +}); + +test('getData', function(string $case) { + // Computed here, after the app has booted and pinned its timezone (see TestCase::setUp()), + // rather than in the ->with() dataset — datasets are resolved before beforeEach()/app boot, + // so a `new DateTime('now')` captured there can land on a different calendar date than one + // computed here (and than the one TotalOrders computes internally), depending on how far the + // pre-boot default PHP timezone is from the app's pinned one. + $now = new DateTime(); + + [$dateRange, $startDate, $endDate, $total, $daysDiff] = match ($case) { + 'today' => [ + TotalOrders::DATE_RANGE_TODAY, + (clone $now)->setTime(0, 0), + (clone $now)->setTime(0, 0), + 2, + 1, + ], + 'custom' => [ + TotalOrders::DATE_RANGE_CUSTOM, + (clone $now)->modify('-7 days')->setTime(0, 0), + (clone $now)->modify('-5 days')->setTime(0, 0), + 0, + 3, + ], + }; + + $stat = new TotalOrders($dateRange, $startDate, $endDate, $this->fixture->storeId); + $data = $stat->get(); + + expect($data)->toBeArray(); + expect($data)->toHaveKey('total'); + expect($data['total'])->toBe($total); + expect($data)->toHaveKey('chart'); + expect($data['chart'])->toBeArray(); + expect($data['chart'])->toHaveKey($startDate->format('Y-m-d')); + expect($data['chart'])->toHaveKey($endDate->format('Y-m-d')); + expect($data['chart'])->toHaveCount($daysDiff); + + $firstItem = array_shift($data['chart']); + expect($firstItem)->toHaveKey('total'); + expect($firstItem)->toHaveKey('datekey'); + expect($firstItem['datekey'])->toBe($startDate->format('Y-m-d')); + expect($firstItem['total'])->toBe($total); +})->with(['today', 'custom']); diff --git a/tests/Feature/Stats/TotalRevenueTest.php b/tests/Feature/Stats/TotalRevenueTest.php new file mode 100644 index 0000000000..eb9c3e94ea --- /dev/null +++ b/tests/Feature/Stats/TotalRevenueTest.php @@ -0,0 +1,42 @@ +fixture = OrdersFixture::seed(); +}); + +test('getData', function(string $dateRange, DateTime $startDate, DateTime $endDate, int $count, float $revenue, string $type) { + $stat = new TotalRevenue($dateRange, $startDate, $endDate, $this->fixture->storeId); + $stat->type = $type; + $data = $stat->get(); + + expect($data)->toBeArray(); + + $todaysStats = array_pop($data); + expect($todaysStats)->toHaveKey('count'); + expect($todaysStats)->toHaveKey('revenue'); + expect($todaysStats)->toHaveKey('datekey'); + expect($todaysStats['count'])->toBe($count); + expect((float) $todaysStats['revenue'])->toBe($revenue); +})->with([ + [ + TotalRevenue::DATE_RANGE_TODAY, + new DateTime('now')->setTime(0, 0), + new DateTime('now')->setTime(0, 0), + 2, + 127.94, + TotalRevenue::TYPE_TOTAL, + ], + [ + TotalRevenue::DATE_RANGE_TODAY, + new DateTime('now')->setTime(0, 0), + new DateTime('now')->setTime(0, 0), + 2, + 0.0, + TotalRevenue::TYPE_TOTAL_PAID, + ], +]); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000000..3902bd0397 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,9 @@ +in('Feature'); +uses(UnitTestCase::class)->in('Unit'); diff --git a/tests/Support/DatabaseLock.php b/tests/Support/DatabaseLock.php new file mode 100644 index 0000000000..ae5c2f6fb4 --- /dev/null +++ b/tests/Support/DatabaseLock.php @@ -0,0 +1,59 @@ + self::release()); + } + + private static function release(): void + { + if (self::$lockHandle === null) { + return; + } + + flock(self::$lockHandle, LOCK_UN); + fclose(self::$lockHandle); + + self::$lockHandle = null; + } + + private static function lockFile(): string + { + $workspaceRoot = dirname(__DIR__, 2); + $workspaceHash = md5($workspaceRoot); + $temporaryDirectory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR); + + return "$temporaryDirectory/commerce-database-$workspaceHash.lock"; + } +} diff --git a/tests/Support/OrdersFixture.php b/tests/Support/OrdersFixture.php new file mode 100644 index 0000000000..18f28ea3dd --- /dev/null +++ b/tests/Support/OrdersFixture.php @@ -0,0 +1,252 @@ + */ + public array $orders = []; + + public static function seed(): self + { + $fixture = new self(); + $fixture->build(); + + return $fixture; + } + + private function build(): void + { + $site = Sites::getCurrentSite(); + $this->storeId = app(Stores::class)->getPrimaryStore()->id; + + $customer = new User(); + $customer->username = 'customer1'; + $customer->email = 'customer1@crafttest.com'; + $customer->active = true; + if (!Elements::saveElement($customer)) { + throw new RuntimeException('Could not save customer: ' . json_encode($customer->errors()->all())); + } + $this->customer = $customer; + + $shippingMethod = new ShippingMethod(); + $shippingMethod->name = 'US Shipping'; + $shippingMethod->handle = 'usShipping'; + $shippingMethod->storeId = $this->storeId; + $shippingMethod->enabled = true; + if (!app(ShippingMethods::class)->saveShippingMethod($shippingMethod)) { + throw new RuntimeException('Could not save shipping method: ' . json_encode($shippingMethod->errors()->all())); + } + + // No zone on the rule means it matches every address; no rates set means $0 shipping. + $shippingRule = new ShippingRule(); + $shippingRule->name = 'US Shipping'; + $shippingRule->methodId = $shippingMethod->id; + $shippingRule->enabled = true; + $shippingRule->priority = 0; + if (!app(ShippingRules::class)->saveShippingRule($shippingRule)) { + throw new RuntimeException('Could not save shipping rule: ' . json_encode($shippingRule->errors()->all())); + } + + $shippedStatus = new OrderStatus(); + $shippedStatus->name = 'Shipped'; + $shippedStatus->handle = 'shipped'; + $shippedStatus->color = 'purple'; + $shippedStatus->storeId = $this->storeId; + if (!app(OrderStatuses::class)->saveOrderStatus($shippedStatus)) { + throw new RuntimeException('Could not save order status: ' . json_encode($shippedStatus->errors()->all())); + } + $this->shippedOrderStatusId = $shippedStatus->id; + + $productType = new ProductType(); + $productType->name = 'T-Shirts'; + $productType->handle = 'tShirts'; + $productType->hasVariantTitleField = false; + $productType->variantTitleFormat = '{product.title}'; + + $siteSettings = new ProductTypeSite(); + $siteSettings->siteId = $site->id; + $siteSettings->hasUrls = false; + $siteSettings->enabledByDefault = true; + $productType->setSiteSettings([$site->id => $siteSettings]); + + if (!app(ProductTypes::class)->saveProductType($productType)) { + throw new RuntimeException('Could not save product type: ' . json_encode($productType->errors()->all())); + } + + $product = new Product(); + $product->typeId = $productType->id; + $product->title = 'Hypercolor T-Shirt'; + $product->enabled = true; + $product->siteId = $site->id; + if (!Elements::saveElement($product)) { + throw new RuntimeException('Could not save product: ' . json_encode($product->errors()->all())); + } + $this->product = $product; + + $white = new Variant(); + $white->title = 'White'; + $white->setPrimaryOwner($product); + $white->setSku('hct-white'); + $white->setBasePrice(19.99); + $white->isDefault = true; + $white->siteId = $site->id; + if (!Elements::saveElement($white)) { + throw new RuntimeException('Could not save white variant: ' . json_encode($white->errors()->all())); + } + + $blue = new Variant(); + $blue->title = 'Blue'; + $blue->setPrimaryOwner($product); + $blue->setSku('hct-blue'); + $blue->setBasePrice(21.99); + $blue->siteId = $site->id; + if (!Elements::saveElement($blue)) { + throw new RuntimeException('Could not save blue variant: ' . json_encode($blue->errors()->all())); + } + + // Re-query fresh (rather than reuse the in-memory instances) so each variant's owner + // lookup round-trips through the database, same as it would for a real request. + $this->white = Variant::find()->id($white->id)->one(); + $this->blue = Variant::find()->id($blue->id)->one(); + + // Match `Stat`'s own date-range resolution (`new DateTime()`, no explicit timezone) so + // "today"/"yesterday" here line up with what the stat queries consider "today", whatever + // the environment's default timezone is. + $yesterday = new DateTime('now'); + $yesterday->modify('-1 day'); + $yesterday->setTime(23, 59, 59); + + $apple = [ + 'firstName' => 'Tim', 'lastName' => 'Cook', + 'addressLine1' => 'One Apple Park Way', 'locality' => 'Cupertino', + 'postalCode' => '95014', 'countryCode' => 'US', 'administrativeArea' => 'CA', + ]; + $bttf = [ + 'firstName' => 'Emmett', 'lastName' => 'Brown', + 'addressLine1' => '1640 Riverside Drive', 'locality' => 'Hill Valley', + 'postalCode' => '88', 'countryCode' => 'US', 'administrativeArea' => 'CA', + ]; + $bob = [ + 'firstName' => 'Bob', 'lastName' => 'Belcher', + 'addressLine1' => '101 Ocean Avenue', 'locality' => 'Long Island', + 'postalCode' => '12345', 'countryCode' => 'US', 'administrativeArea' => 'NY', + ]; + + $this->orders['completed-new'] = $this->createOrder( + lineItems: [[$this->white->id, 1], [$this->blue->id, 4]], + billingAddress: $apple, + shippingAddress: $apple, + shippingMethodHandle: 'usShipping', + ); + + $this->orders['completed-new-past'] = $this->createOrder( + lineItems: [[$this->white->id, 1], [$this->blue->id, 4]], + billingAddress: $bttf, + shippingAddress: $bttf, + dateOrdered: $yesterday, + ); + + $this->orders['completed-shipped'] = $this->createOrder( + lineItems: [[$this->white->id, 1]], + billingAddress: $bttf, + shippingAddress: $bob, + orderStatusId: $this->shippedOrderStatusId, + ); + } + + /** @param array $lineItems */ + private function createOrder( + array $lineItems, + array $billingAddress, + array $shippingAddress, + ?string $shippingMethodHandle = null, + ?DateTime $dateOrdered = null, + ?int $orderStatusId = null, + ): Order { + $order = new Order(); + $order->number = bin2hex(random_bytes(16)); + $order->storeId = $this->storeId; + $order->setCustomerId($this->customer->id); + + if ($shippingMethodHandle) { + $order->shippingMethodHandle = $shippingMethodHandle; + } + + if ($orderStatusId) { + $order->orderStatusId = $orderStatusId; + } + + if (!Elements::saveElement($order, false)) { + throw new RuntimeException('Could not save order: ' . json_encode($order->errors()->all())); + } + + $items = []; + foreach ($lineItems as [$purchasableId, $qty]) { + $items[] = app(LineItems::class)->create($order, [ + 'purchasableId' => $purchasableId, + 'qty' => $qty, + ]); + } + $order->setLineItems($items); + $order->setBillingAddress($billingAddress); + $order->setShippingAddress($shippingAddress); + + if (!Elements::saveElement($order, false)) { + throw new RuntimeException('Could not re-save order: ' . json_encode($order->errors()->all())); + } + + if (!$order->markAsComplete()) { + throw new RuntimeException('Could not complete order: ' . json_encode($order->errors()->all())); + } + + if ($dateOrdered) { + $order->dateOrdered = $dateOrdered; + if (!Elements::saveElement($order, false)) { + throw new RuntimeException('Could not update dateOrdered: ' . json_encode($order->errors()->all())); + } + } + + return Order::find()->id($order->id)->one(); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000000..a040bce16e --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,169 @@ +basePath()`) + // is already created by the time that attribute's `beforeEach()` fires. + // Create the symlink once, persistently, before the app exists at all. + $skeletonVendorPath = default_skeleton_path() . '/vendor'; + if (!is_link($skeletonVendorPath) && !is_dir($skeletonVendorPath)) { + symlink(package_path('vendor'), $skeletonVendorPath); + } + + // Craft defaults to the Solo edition (max 1 user) when CRAFT_EDITION/`system.edition` + // aren't set, which silently blocks `Elements::saveElement()` for any second user — + // needed by tests that create their own customer/author fixtures. Must be set before + // `Edition::get()`'s first call, since it caches its result for the rest of the request. + putenv('CRAFT_EDITION=pro'); + + parent::setUp(); + + config()->set('app.debug', true); + + app()->setLocale('en-US'); + app()->maintenanceMode()->deactivate(); + + File::cleanDirectory(config_path('craft/project')); + File::cleanDirectory(storage_path('runtime/compiled_classes')); + } + + protected function connectionsToTransact(): array + { + if (config('database.default') === 'sqlite') { + return [config('database.default')]; + } + + return [config('database.default'), 'db2']; + } + + #[Override] + protected function tearDown(): void + { + parent::tearDown(); + } + + protected function refreshTestDatabase(): void + { + if (!RefreshDatabaseState::$migrated) { + Context::forgetHidden('craft.info'); + Context::forgetHidden('craft.isInstalled'); + + $this->artisan('db:wipe'); + + $site = new Site([ + 'name' => 'Craft test site', + 'handle' => 'defaultSite', + 'language' => 'en-US', + 'baseUrl' => 'https://localhost/', + 'primary' => true, + 'hasUrls' => true, + ]); + + $craftMigration = new Install( + username: 'craftcms', + password: 'craftcms2018!!', + email: 'support@craftcms.com', + site: $site, + )->silent(); + + Cache::lock(\CraftCms\Cms\ProjectConfig\ProjectConfig::MUTEX_NAME)->forceRelease(); + + $migrator = app(Migrator::class)->track('craft'); + $migrator->runMigration($craftMigration, 'up'); + $migrator->getRepository()->log('Install', 1); + + foreach ($migrator->getPendingMigrations() as $file) { + $migrator->getRepository()->log($migrator->getMigrationName($file), 1); + } + + // Install Commerce via its Yii2 plugin system + Craft::$app->plugins->installPlugin('commerce'); + + // `Craft::$app->plugins->installPlugin()` is a thin proxy to `CraftCms\Cms\Plugin\Plugins` + // (the actual, shared plugin manager — there's only one). Its own `installPlugin()` calls + // `loadPlugins()` as its first line, which runs *before* Commerce has a row in the `plugins` + // table yet, so it finds nothing to register and sets its internal `pluginsLoaded` flag to + // `true`. Since `Plugins` is a container singleton, that flag then permanently short-circuits + // every later `loadPlugins()` call for the rest of the test run, so Commerce's Laravel + // `register()`/`boot()` (GQL argument handlers, widgets, permissions, CP nav, macros, event + // listeners, etc.) never fire. Forgetting the singleton forces the next resolution to + // re-scan the `plugins` table, which now has Commerce's row, and register it correctly. + app()->forgetInstance(\CraftCms\Cms\Plugin\Plugins::class); + app(\CraftCms\Cms\Plugin\Plugins::class)->loadPlugins(); + + RefreshDatabaseState::$migrated = true; + } + + $this->beginDatabaseTransaction(); + } + + #[Override] + protected function defineEnvironment($app): void + { + File::cleanDirectory(config_path('craft/project')); + File::cleanDirectory(storage_path('runtime/compiled_classes')); + + $app->useEnvironmentPath(__DIR__); + $app->bootstrapWith([LoadEnvironmentVariables::class]); + + tap($app->make(ConfigRepository::class), function(ConfigRepository $config) { + $config->set('auth.defaults.guard', 'craft'); + $config->set('auth.guards.craft', ['driver' => 'session', 'provider' => 'users']); + + $connection = env('DB_CONNECTION', 'testing'); + $driver = $config->get("database.connections.{$connection}.driver"); + + $config->set('database.default', $connection); + $config->set("database.connections.{$connection}.database", env('DB_DATABASE', ':memory:')); + $config->set("database.connections.{$connection}.host", env('DB_HOST', '127.0.0.1')); + $config->set("database.connections.{$connection}.username", env('DB_USERNAME', 'root')); + $config->set("database.connections.{$connection}.password", env('DB_PASSWORD', '')); + $config->set("database.connections.{$connection}.charset", env('DB_CHARSET', in_array($driver, ['mysql', 'mariadb']) ? 'utf8mb4' : 'utf8')); + $config->set("database.connections.{$connection}.collation", env('DB_COLLATION', in_array($driver, ['mysql', 'mariadb']) ? 'utf8mb4_unicode_ci' : 'utf8')); + $config->set("database.connections.{$connection}.prefix", env('DB_PREFIX')); + + DB::setDefaultConnection($connection); + }); + } +} diff --git a/tests/Unit/Customer/Conditions/DiscountGroupConditionRuleTest.php b/tests/Unit/Customer/Conditions/DiscountGroupConditionRuleTest.php new file mode 100644 index 0000000000..99daf706d1 --- /dev/null +++ b/tests/Unit/Customer/Conditions/DiscountGroupConditionRuleTest.php @@ -0,0 +1,50 @@ + new ReflectionMethod($rule, 'matchValue')->invoke($rule, $value); + +test('no configured groups always matches', function() use ($match) { + $rule = new DiscountGroupConditionRule(); + $rule->operator = 'in'; + + expect($match($rule, ['group-a']))->toBeTrue(); + expect($match($rule, null))->toBeTrue(); +}); + +test('in-all operator requires every configured group to be present', function() use ($match) { + $rule = new DiscountGroupConditionRule(); + $rule->operator = 'inAll'; + $rule->setValues(['group-a', 'group-b']); + + expect($match($rule, ['group-a', 'group-b']))->toBeTrue(); + expect($match($rule, ['group-a', 'group-b', 'group-c']))->toBeTrue(); + expect($match($rule, ['group-a']))->toBeFalse(); + expect($match($rule, []))->toBeFalse(); +}); + +test('in operator matches when any configured group is present', function() use ($match) { + $rule = new DiscountGroupConditionRule(); + $rule->operator = 'in'; + $rule->setValues(['group-a', 'group-b']); + + expect($match($rule, ['group-a']))->toBeTrue(); + expect($match($rule, ['group-c']))->toBeFalse(); +}); + +test('not-in operator matches when no configured group is present', function() use ($match) { + $rule = new DiscountGroupConditionRule(); + $rule->operator = 'ni'; + $rule->setValues(['group-a', 'group-b']); + + expect($match($rule, ['group-c']))->toBeTrue(); + expect($match($rule, ['group-a']))->toBeFalse(); +}); diff --git a/tests/Unit/Helpers/LocaleTest.php b/tests/Unit/Helpers/LocaleTest.php new file mode 100644 index 0000000000..a7ab756fc8 --- /dev/null +++ b/tests/Unit/Helpers/LocaleTest.php @@ -0,0 +1,29 @@ +language)->toBe('nl'); +}); + +test('Pdf::getRenderLanguage() throws without an order when language is order-language', function() { + $pdf = new Pdf(); + $pdf->language = PdfRecord::LOCALE_ORDER_LANGUAGE; + + expect(fn() => $pdf->getRenderLanguage())->toThrow(InvalidArgumentException::class); +}); + +test('Email::getRenderLanguage() throws without an order when language is order-language', function() { + $email = new Email(); + $email->language = EmailRecord::LOCALE_ORDER_LANGUAGE; + + expect(fn() => $email->getRenderLanguage())->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Unit/Helpers/LocalizationTest.php b/tests/Unit/Helpers/LocalizationTest.php new file mode 100644 index 0000000000..af19c1fcd3 --- /dev/null +++ b/tests/Unit/Helpers/LocalizationTest.php @@ -0,0 +1,24 @@ +toBe($expected); +})->with([ + 'null' => [null, 0.0], + 'empty string' => ['', 0.0], + 'percent symbol alone' => ['%', 0.0], + 'padded percent symbol' => [' % ', 0.0], + 'int zero' => [0, 0.0], + 'float' => [0.5, 0.5], + 'int' => [50, 50.0], + 'one' => [1, 1.0], + 'string zero' => ['0', 0.0], + 'string one' => ['1', 0.01], + 'string fifty' => ['50', 0.5], + 'padded string zero' => [' 0.0 ', 0.0], + 'fraction with trailing percent' => [' .5 % ', 0.005], + 'fraction with leading percent' => [' % 0.5 ', 0.005], +]); diff --git a/tests/Unit/Order/Conditions/CouponCodeConditionRuleTest.php b/tests/Unit/Order/Conditions/CouponCodeConditionRuleTest.php new file mode 100644 index 0000000000..5a027c96ad --- /dev/null +++ b/tests/Unit/Order/Conditions/CouponCodeConditionRuleTest.php @@ -0,0 +1,53 @@ + new ReflectionMethod($rule, 'matchValue')->invoke($rule, $value); + +test('equals operator is case-insensitive', function() use ($match) { + $rule = new CouponCodeConditionRule(); + $rule->operator = '='; + $rule->value = 'SUMMER10'; + + expect($match($rule, 'summer10'))->toBeTrue(); + expect($match($rule, 'Summer10'))->toBeTrue(); + expect($match($rule, 'winter10'))->toBeFalse(); +}); + +test('does-not-equal operator is case-insensitive', function() use ($match) { + $rule = new CouponCodeConditionRule(); + $rule->operator = '!='; + $rule->value = 'SUMMER10'; + + expect($match($rule, 'summer10'))->toBeFalse(); + expect($match($rule, 'winter10'))->toBeTrue(); +}); + +test('empty operator value always matches', function() use ($match) { + $rule = new CouponCodeConditionRule(); + $rule->operator = '='; + $rule->value = ''; + + expect($match($rule, 'anything'))->toBeTrue(); +}); + +test('empty and not-empty operators check for a coupon code presence', function() use ($match) { + $emptyRule = new CouponCodeConditionRule(); + $emptyRule->operator = 'empty'; + + expect($match($emptyRule, ''))->toBeTrue(); + expect($match($emptyRule, 'summer10'))->toBeFalse(); + + $notEmptyRule = new CouponCodeConditionRule(); + $notEmptyRule->operator = 'notempty'; + + expect($match($notEmptyRule, 'summer10'))->toBeTrue(); + expect($match($notEmptyRule, ''))->toBeFalse(); +}); diff --git a/tests/Unit/Order/Conditions/PaymentGatewayConditionRuleTest.php b/tests/Unit/Order/Conditions/PaymentGatewayConditionRuleTest.php new file mode 100644 index 0000000000..bf50720931 --- /dev/null +++ b/tests/Unit/Order/Conditions/PaymentGatewayConditionRuleTest.php @@ -0,0 +1,49 @@ +setAttributes(['value' => 'gateway-uid-1']); + + expect($rule->getValues())->toBe(['gateway-uid-1']); +}); + +test('setAttributes() prefers values over a legacy value when both are present', function() { + $rule = new PaymentGatewayConditionRule(); + $rule->setAttributes(['value' => 'gateway-uid-1', 'values' => ['gateway-uid-2']]); + + expect($rule->getValues())->toBe(['gateway-uid-2']); +}); + +test('getConfig() never emits the legacy value key', function() { + $rule = new PaymentGatewayConditionRule(); + $rule->setValues(['gateway-uid-1', 'gateway-uid-2']); + + $config = $rule->getConfig(); + + expect($config)->not->toHaveKey('value'); + expect($config['values'])->toBe(['gateway-uid-1', 'gateway-uid-2']); +}); + +test('getValue() returns the first of multiple selected values', function() { + $rule = new PaymentGatewayConditionRule(); + $rule->setValues(['gateway-uid-1', 'gateway-uid-2']); + + expect($rule->getValue())->toBe('gateway-uid-1'); +}); + +test('setValue() replaces the values array with a single-item array', function() { + $rule = new PaymentGatewayConditionRule(); + $rule->setValues(['gateway-uid-1', 'gateway-uid-2']); + $rule->setValue('gateway-uid-3'); + + expect($rule->getValues())->toBe(['gateway-uid-3']); +}); diff --git a/tests/Unit/Order/Conditions/TotalDiscountConditionRuleTest.php b/tests/Unit/Order/Conditions/TotalDiscountConditionRuleTest.php new file mode 100644 index 0000000000..b4e6e70d53 --- /dev/null +++ b/tests/Unit/Order/Conditions/TotalDiscountConditionRuleTest.php @@ -0,0 +1,42 @@ + new ReflectionMethod($rule, 'matchValue')->invoke($rule, $value); + +test('empty configured value always matches', function() use ($match) { + $rule = new TotalDiscountConditionRule(); + $rule->operator = '='; + $rule->value = ''; + + expect($match($rule, -50.0))->toBeTrue(); +}); + +test('greater-than operator compares against the negated value', function() use ($match) { + $rule = new TotalDiscountConditionRule(); + $rule->operator = '>'; + $rule->value = '5'; + + // A $10 discount (-10) is a *bigger* discount than $5 (-5), so -10 > -5 is false — + // matching the swapped "is less than" label this operator displays for this rule. + expect($match($rule, -10.0))->toBeFalse(); + // A $2 discount (-2) is smaller than $5, so -2 > -5 is true. + expect($match($rule, -2.0))->toBeTrue(); +}); + +test('equals operator matches the exact negated amount', function() use ($match) { + $rule = new TotalDiscountConditionRule(); + $rule->operator = '='; + $rule->value = '10'; + + expect($match($rule, -10.0))->toBeTrue(); + expect($match($rule, -5.0))->toBeFalse(); +}); diff --git a/tests/Unit/Transfer/Elements/TransferTest.php b/tests/Unit/Transfer/Elements/TransferTest.php new file mode 100644 index 0000000000..93070c5694 --- /dev/null +++ b/tests/Unit/Transfer/Elements/TransferTest.php @@ -0,0 +1,130 @@ +setDetails($details); + + return $transfer; +} + +test('sumDetailsQuanity sums quantity across all details', function() { + $transfer = transferWithDetails([ + ['inventoryItemId' => 1, 'quantity' => 3], + ['inventoryItemId' => 2, 'quantity' => 5], + ]); + + expect($transfer->sumDetailsQuanity())->toBe(8); +}); + +test('getTotalAccepted, getTotalRejected and getTotalReceived sum across details', function() { + $transfer = transferWithDetails([ + ['inventoryItemId' => 1, 'quantity' => 5, 'quantityAccepted' => 2, 'quantityRejected' => 1], + ['inventoryItemId' => 2, 'quantity' => 4, 'quantityAccepted' => 3, 'quantityRejected' => 0], + ]); + + expect($transfer->getTotalAccepted())->toBe(5); + expect($transfer->getTotalRejected())->toBe(1); + expect($transfer->getTotalReceived())->toBe(6); +}); + +test('isAllReceived is true only when every detail is fully received', function() { + $notAllReceived = transferWithDetails([ + ['inventoryItemId' => 1, 'quantity' => 5, 'quantityAccepted' => 2, 'quantityRejected' => 0], + ]); + expect($notAllReceived->isAllReceived())->toBeFalse(); + + $allReceived = transferWithDetails([ + ['inventoryItemId' => 1, 'quantity' => 5, 'quantityAccepted' => 3, 'quantityRejected' => 2], + ]); + expect($allReceived->isAllReceived())->toBeTrue(); +}); + +test('updateTransferStatus does nothing while still a draft', function() { + $transfer = transferWithDetails([ + ['inventoryItemId' => 1, 'quantity' => 5, 'quantityAccepted' => 5], + ]); + $transfer->setTransferStatus(TransferStatusType::DRAFT); + + $transfer->updateTransferStatus(); + + expect($transfer->getTransferStatus())->toBe(TransferStatusType::DRAFT); +}); + +test('updateTransferStatus moves to received once everything has been received', function() { + $transfer = transferWithDetails([ + ['inventoryItemId' => 1, 'quantity' => 5, 'quantityAccepted' => 5], + ]); + $transfer->setTransferStatus(TransferStatusType::PENDING); + + $transfer->updateTransferStatus(); + + expect($transfer->getTransferStatus())->toBe(TransferStatusType::RECEIVED); +}); + +test('updateTransferStatus moves to partial once some but not all has been received', function() { + $transfer = transferWithDetails([ + ['inventoryItemId' => 1, 'quantity' => 5, 'quantityAccepted' => 2], + ]); + $transfer->setTransferStatus(TransferStatusType::PENDING); + + $transfer->updateTransferStatus(); + + expect($transfer->getTransferStatus())->toBe(TransferStatusType::PARTIAL); +}); + +test('updateTransferStatus stays pending when nothing has been received yet', function() { + $transfer = transferWithDetails([ + ['inventoryItemId' => 1, 'quantity' => 5], + ]); + $transfer->setTransferStatus(TransferStatusType::DRAFT); + + // simulate the draft -> pending move made by afterSave() before updateTransferStatus() runs + $transfer->setTransferStatus(TransferStatusType::PENDING); + $transfer->updateTransferStatus(); + + expect($transfer->getTransferStatus())->toBe(TransferStatusType::PENDING); +}); + +test('validateLocations adds an error when origin and destination match', function() { + $transfer = new Transfer(); + $transfer->originLocationId = 1; + $transfer->destinationLocationId = 1; + + $transfer->validateLocations(); + + expect($transfer->errors()->has('originLocationId'))->toBeTrue(); +}); + +test('validateLocations adds no error when origin and destination differ', function() { + $transfer = new Transfer(); + $transfer->originLocationId = 1; + $transfer->destinationLocationId = 2; + + $transfer->validateLocations(); + + expect($transfer->errors()->has('originLocationId'))->toBeFalse(); +}); + +test('isTransferDraft, isTransferPending, isTransferPartial and isTransferReceived reflect the status', function() { + $transfer = new Transfer(); + + $transfer->setTransferStatus(TransferStatusType::DRAFT); + expect($transfer->isTransferDraft())->toBeTrue(); + expect($transfer->isTransferPending())->toBeFalse(); + + $transfer->setTransferStatus(TransferStatusType::PENDING); + expect($transfer->isTransferPending())->toBeTrue(); + expect($transfer->isTransferDraft())->toBeFalse(); + + $transfer->setTransferStatus(TransferStatusType::PARTIAL); + expect($transfer->isTransferPartial())->toBeTrue(); + + $transfer->setTransferStatus(TransferStatusType::RECEIVED); + expect($transfer->isTransferReceived())->toBeTrue(); +}); diff --git a/tests/UnitTestCase.php b/tests/UnitTestCase.php new file mode 100644 index 0000000000..ff01490cef --- /dev/null +++ b/tests/UnitTestCase.php @@ -0,0 +1,37 @@ +set('database.default', 'sqlite'); + $config->set('database.connections.sqlite', array_merge( + $config->get('database.connections.sqlite', []), + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + )); + }); + + DB::purge('sqlite'); + DB::setDefaultConnection('sqlite'); + } +} diff --git a/tests/_bootstrap.php b/tests/_bootstrap.php deleted file mode 100644 index eae6cbaab0..0000000000 --- a/tests/_bootstrap.php +++ /dev/null @@ -1,29 +0,0 @@ - App::env('DB_DSN') ?: null, - 'driver' => App::env('DB_DRIVER'), - 'server' => App::env('DB_SERVER'), - 'port' => App::env('DB_PORT'), - 'database' => App::env('DB_DATABASE'), - 'user' => App::env('DB_USER'), - 'password' => App::env('DB_PASSWORD'), - 'schema' => App::env('DB_SCHEMA'), - 'tablePrefix' => App::env('DB_TABLE_PREFIX'), -]; diff --git a/tests/_craft/config/test.php b/tests/_craft/config/test.php deleted file mode 100644 index a5b52b4abd..0000000000 --- a/tests/_craft/config/test.php +++ /dev/null @@ -1,5 +0,0 @@ - - - Order Confirmation - - -

Order Confirmation {{ order.shortNumber }}

- -

Thank you for placing an order.

- - \ No newline at end of file diff --git a/tests/_envs/installed.yml b/tests/_envs/installed.yml deleted file mode 100644 index 2e4e462f2e..0000000000 --- a/tests/_envs/installed.yml +++ /dev/null @@ -1,8 +0,0 @@ -# `installed` environment config -# This environment can be used after a full test has been run once. -# It expects that the database has been setup and everything installed. -# Cleanup and setup will be skipped -modules: - config: - \craft\test\Craft: - dbSetup: {clean: false, setupCraft: false} diff --git a/tests/_output/.gitignore b/tests/_output/.gitignore deleted file mode 100644 index c96a04f008..0000000000 --- a/tests/_output/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore \ No newline at end of file diff --git a/tests/_support/AcceptanceTester.php b/tests/_support/AcceptanceTester.php deleted file mode 100644 index 2fd5315c1a..0000000000 --- a/tests/_support/AcceptanceTester.php +++ /dev/null @@ -1,27 +0,0 @@ - - * @author Global Network Group | Giel Tettelaar - * @since 5.0.7 - */ -class Gql extends Module -{ -} diff --git a/tests/_support/Helper/Unit.php b/tests/_support/Helper/Unit.php deleted file mode 100644 index 03e4543c94..0000000000 --- a/tests/_support/Helper/Unit.php +++ /dev/null @@ -1,12 +0,0 @@ - - * @since 3.1.4 - * @method Product getElement(string $key) - */ -class ProductFixture extends BaseProductFixture -{ - /** - * @inheritdoc - */ - public $dataFile = __DIR__ . '/data/products.php'; - - /** - * @inheritdoc - */ - public $depends = [ProductTypeFixture::class]; -} diff --git a/tests/fixtures/ProductTypesShippingCategoriesFixture.php b/tests/fixtures/ProductTypesShippingCategoriesFixture.php deleted file mode 100644 index 0f98badd3a..0000000000 --- a/tests/fixtures/ProductTypesShippingCategoriesFixture.php +++ /dev/null @@ -1,28 +0,0 @@ - -* @since 4.0.5 -*/ -class SubscriptionPlansFixture extends ActiveFixture -{ - /** - * @inheritdoc - */ - public $dataFile = __DIR__ . '/data/subscription-plans.php'; - - /** - * @inheritdoc - */ - public $modelClass = Plan::class; -} diff --git a/tests/fixtures/SubscriptionsFixture.php b/tests/fixtures/SubscriptionsFixture.php deleted file mode 100644 index e9d3606620..0000000000 --- a/tests/fixtures/SubscriptionsFixture.php +++ /dev/null @@ -1,56 +0,0 @@ - -* @since 4.0.4 -*/ -class SubscriptionsFixture extends BaseElementFixture -{ - /** - * @inheritdoc - */ - public $dataFile = __DIR__ . '/data/subscriptions.php'; - - public $depends = [SubscriptionPlansFixture::class]; - - /** - * @inheritdoc - */ - protected function createElement(): ElementInterface - { - return new Subscription(); - } - - /** - * @inheritdoc - */ - protected function populateElement(ElementInterface $element, array $attributes): void - { - /** @var Subscription $element */ - foreach ($attributes as $name => $val) { - if ($name === '_plan') { - if ($plan = Plugin::getInstance()->getPlans()->getPlanByHandle($val)) { - $element->planId = $plan->id; - } - - unset($attributes['_plan']); - } - } - - parent::populateElement($element, $attributes); - } -} diff --git a/tests/fixtures/TaxCategoryFixture.php b/tests/fixtures/TaxCategoryFixture.php deleted file mode 100644 index 58df087a55..0000000000 --- a/tests/fixtures/TaxCategoryFixture.php +++ /dev/null @@ -1,28 +0,0 @@ - [ - 'gatewayId' => 1, - 'name' => 'Monthly Subscription', - 'handle' => 'monthlySubscription', - 'reference' => 'monthly_sub', - 'enabled' => true, - 'planData' => 'dummy.plan', - 'sortOrder' => 1, - ], - 'weekly-disabled' => [ - 'gatewayId' => 1, - 'name' => 'Weekly Subscription', - 'handle' => 'weeklySubscription', - 'reference' => 'weekly_sub', - 'enabled' => false, - 'planData' => 'dummy.plan', - 'sortOrder' => 2, - ], -]; diff --git a/tests/fixtures/data/subscriptions.php b/tests/fixtures/data/subscriptions.php deleted file mode 100644 index 2907eacf4d..0000000000 --- a/tests/fixtures/data/subscriptions.php +++ /dev/null @@ -1,13 +0,0 @@ - [ - '_plan' => 'monthlySubscription', - 'userId' => 1, - 'gatewayId' => 1, - 'reference' => 'sub_000000000000XXXXXXXXXXXX', - 'trialDays' => 0, - 'hasStarted' => true, - 'subscriptionData' => ['test' => 'Sub Data'], - ], -]; diff --git a/tests/functional.suite.yml b/tests/functional.suite.yml deleted file mode 100644 index 0f69c911cf..0000000000 --- a/tests/functional.suite.yml +++ /dev/null @@ -1,12 +0,0 @@ -# Codeception Test Suite Configuration -# -# Suite for functional tests -# Emulate web requests and make application process them -# Include one of framework modules (Symfony2, Yii2, Laravel5) to use it - -actor: FunctionalTester -modules: - enabled: - - Asserts - - \craft\test\Craft - - \Helper\Functional diff --git a/tests/functional/.gitkeep b/tests/functional/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/gql.suite.yml b/tests/gql.suite.yml deleted file mode 100644 index 0046eead8d..0000000000 --- a/tests/gql.suite.yml +++ /dev/null @@ -1,17 +0,0 @@ -# Codeception Test Suite Configuration -# -# Suite for GraphQL tests -# Emulate web requests and make application process them -# Include one of framework modules (Symfony2, Yii2, Laravel5) to use it - -actor: GqlTester -modules: - enabled: - - Asserts - - \craft\test\Craft: - edition: 1 - - \Helper\Gql - - REST: - url: 'http://testing.craft.local/' - depends: PhpBrowser - part: Json diff --git a/tests/gql/_bootstrap.php b/tests/gql/_bootstrap.php deleted file mode 100644 index 9be98403e8..0000000000 --- a/tests/gql/_bootstrap.php +++ /dev/null @@ -1,6 +0,0 @@ - - * @since 4.0.4 - */ -class SubscriptionTest extends Unit -{ - /** - * @var UnitTester - */ - protected UnitTester $tester; - - /** - * @return array - */ - public function _fixtures(): array - { - return [ - 'orders' => [ - 'class' => OrdersFixture::class, - ], - 'subscriptions' => [ - 'class' => SubscriptionsFixture::class, - ], - ]; - } - - /** - * @param array $attributes - * @param Order|null $order - * @return void - * @throws InvalidConfigException - * @dataProvider getOrderDataProvider - */ - public function testGetOrder(?string $orderFixtureHandle): void - { - $subscription = Craft::createObject(Subscription::class); - - if ($orderFixtureHandle) { - $orderFixture = $this->tester->grabFixture('orders')->getElement($orderFixtureHandle); - $subscription->orderId = $orderFixture->id; - $order = Plugin::getInstance()->getOrders()->getOrderById($orderFixture->id); - - self::assertEquals($order->toArray(), $subscription->getOrder()->toArray()); - } else { - self::assertEquals(null, $subscription->getOrder()); - } - } - - /** - * @return array - */ - public function getOrderDataProvider(): array - { - return [ - 'no-order' => [ - null, - ], - 'order' => [ - 'completed-new', - ], - ]; - } - - /** - * @param array $attributes - * @param string $expected - * @return void - * @throws InvalidConfigException - * @dataProvider getGatewayDataProvider - */ - public function testGetGateway(array $attributes, ?string $expected): void - { - $subscription = Craft::createObject(Subscription::class, [ - 'config' => [ - 'attributes' => $attributes, - ], - ]); - - if ($expected === null) { - self::assertNull($subscription->getGateway()); - } else { - self::assertEquals($expected, $subscription->getGateway()->handle); - } - } - - /** - * @return array[] - */ - public function getGatewayDataProvider(): array - { - return [ - 'no-gateway' => [ - [], - null, - ], - 'gateway' => [ - ['gatewayId' => 1], - 'dummy', - ], - ]; - } - - /** - * @param array $attributes - * @param array|null $expected - * @return void - * @throws InvalidConfigException - * @dataProvider getAlternativePlansDataProvider - */ - public function testGetAlternativePlans(array $attributes, ?array $expected): void - { - $subscription = Craft::createObject(Subscription::class, [ - 'config' => [ - 'attributes' => $attributes, - ], - ]); - - if ($expected === null) { - self::assertNull($subscription->getAlternativePlans()); - } else { - self::assertEquals($expected, $subscription->getAlternativePlans()); - } - } - - /** - * @return array[] - */ - public function getAlternativePlansDataProvider(): array - { - return [ - 'no-gateway' => [ - [], - [], - ], - 'gateway' => [ - ['gatewayId' => 1], - [], - ], - ]; - } - - /** - * @param array $attributes - * @param bool $expected - * @return void - * @throws InvalidConfigException - * @dataProvider getIsOnTrialDataProvider - */ - public function testGetIsOnTrial(array $attributes, bool $expected): void - { - $subscription = Craft::createObject(Subscription::class, [ - 'config' => [ - 'attributes' => $attributes, - ], - ]); - - self::assertEquals($subscription->getIsOnTrial(), $expected); - } - - /** - * @return array[] - * @throws \Exception - */ - public function getIsOnTrialDataProvider(): array - { - return [ - 'no-attributes' => [ - [], - false, - ], - 'on-trial' => [ - ['trialDays' => 10], - false, - ], - 'expired' => [ - [ - 'isExpired' => true, - 'dataExpired' => (new DateTime('yesterday', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 'trialDays' => 10, - ], - false, - ], - ]; - } -} diff --git a/tests/unit/elements/user/UserSubscriptionDeletionTest.php b/tests/unit/elements/user/UserSubscriptionDeletionTest.php deleted file mode 100644 index 7aa6bf6285..0000000000 --- a/tests/unit/elements/user/UserSubscriptionDeletionTest.php +++ /dev/null @@ -1,91 +0,0 @@ - - * @since 5.7.0 - */ -class UserSubscriptionDeletionTest extends Unit -{ - protected UnitTester $tester; - - private ?User $_user = null; - private ?int $_subscriptionId = null; - - public function _fixtures(): array - { - return [ - 'plans' => ['class' => SubscriptionPlansFixture::class], - ]; - } - - protected function _before(): void - { - parent::_before(); - - $user = new User(); - $user->username = 'subscription-cascade-test-' . uniqid(); - $user->email = 'subscription-cascade-' . uniqid() . '@crafttest.com'; - Craft::$app->getElements()->saveElement($user, false); - $this->_user = $user; - - $plan = $this->tester->grabFixture('plans')->getModel('monthly'); - - $subscription = new Subscription(); - $subscription->userId = $user->id; - $subscription->planId = $plan->id; - $subscription->gatewayId = $plan->gatewayId; - $subscription->reference = 'test-cascade-' . uniqid(); - $subscription->trialDays = 0; - $subscription->hasStarted = true; - $subscription->subscriptionData = ['test' => 'cascade-delete']; - Craft::$app->getElements()->saveElement($subscription, false); - $this->_subscriptionId = $subscription->id; - } - - public function testSubscriptionIsDeletedWhenUserIsHardDeleted(): void - { - self::assertNotNull( - Subscription::find()->id($this->_subscriptionId)->status(null)->one(), - 'Subscription should exist before user deletion.' - ); - - // Soft-delete then hard-delete the user, mimicking the trash → permanently delete flow - Craft::$app->getElements()->deleteElement($this->_user); - Craft::$app->getElements()->deleteElement($this->_user, true); - $this->_user = null; - - self::assertNull( - Subscription::find()->id($this->_subscriptionId)->status(null)->one(), - 'Subscription should be deleted when its user is hard-deleted.' - ); - } - - protected function _after(): void - { - parent::_after(); - - // Clean up if the test failed before the user was deleted - if ($this->_user?->id) { - Craft::$app->getElements()->deleteElementById($this->_user->id, User::class, null, true); - } - - $this->_user = null; - $this->_subscriptionId = null; - } -} diff --git a/tests/unit/helpers/CurrencyHelperTest.php b/tests/unit/helpers/CurrencyHelperTest.php deleted file mode 100644 index ac066c7dfe..0000000000 --- a/tests/unit/helpers/CurrencyHelperTest.php +++ /dev/null @@ -1,278 +0,0 @@ - - * @since 4.5.4 - */ -class CurrencyHelperTest extends Unit -{ - /** - * @var UnitTester - */ - protected $tester; - - /** - * @param string $currency - * @param string $language - * @param string $expected - * @return void - * @throws InvalidConfigException - * @throws CurrencyException - * @dataProvider formatAsCurrencyDataProvider - */ - public function testFormatAsCurrency(string $currency, string $language, string $expected): void - { - $originalLocale = \Craft::$app->getLocale(); - Locale::switchAppLanguage($language); - $amount = 1234.56; - $formattedValue = Currency::formatAsCurrency($amount, $currency); - - self::assertEquals($expected, $formattedValue); - Locale::switchAppLanguage($originalLocale->getLanguageID()); - } - - public function formatAsCurrencyDataProvider(): array - { - return [ - 'USD-US' => [ - 'USD', - 'en-US', - '$1,234.56', - ], - 'USD-GB' => [ - 'USD', - 'en-GB', - 'US$1,234.56', - ], - 'USD-FR' => [ - 'USD', - 'fr-FR', - '1 234,56 $US', - ], - 'EUR-US' => [ - 'EUR', - 'en-US', - '€1,234.56', - ], - 'EUR-GB' => [ - 'EUR', - 'en-GB', - '€1,234.56', - ], - 'EUR-FR' => [ - 'EUR', - 'fr-FR', - '1 234,56 €', - ], - ]; - } - - /** - * @param string $currency - * @param string $language - * @param string $expected - * @return void - * @dataProvider formatAsCurrencyStripZerosDataProvider - * @since 5.1.4 - */ - public function testFormatAsCurrencyStripZeros(string $currency, string $language, float $amount, bool $zeros, string $expected): void - { - $originalLocale = \Craft::$app->getLocale(); - Locale::switchAppLanguage($language); - $formattedValue = Currency::formatAsCurrency($amount, $currency, stripZeros: $zeros); - - self::assertEquals($expected, $formattedValue); - Locale::switchAppLanguage($originalLocale->getLanguageID()); - } - - /** - * @return array[] - */ - public function formatAsCurrencyStripZerosDataProvider(): array - { - return [ - 'USD-US' => [ - 'USD', - 'en-US', - 1234.56, - true, - '$1,234.56', - ], - 'USD-US-strip' => [ - 'USD', - 'en-US', - 1234.00, - true, - '$1,234', - ], - 'USD-US-no-strip' => [ - 'USD', - 'en-US', - 1234.00, - false, - '$1,234.00', - ], - 'USD-GB' => [ - 'USD', - 'en-GB', - 1234.56, - true, - 'US$1,234.56', - ], - 'USD-GB-strip' => [ - 'USD', - 'en-GB', - 1234.0, - true, - 'US$1,234', - ], - 'USD-GB-no-strip' => [ - 'USD', - 'en-GB', - 1234.0, - false, - 'US$1,234.00', - ], - 'USD-FR' => [ - 'USD', - 'fr-FR', - 1234.56, - true, - '1 234,56 $US', - ], - 'USD-FR-strip' => [ - 'USD', - 'fr-FR', - 1234.00, - true, - '1 234 $US', - ], - 'USD-FR-no-strip' => [ - 'USD', - 'fr-FR', - 1234.00, - false, - '1 234,00 $US', - ], - 'EUR-US' => [ - 'EUR', - 'en-US', - 1234.56, - true, - '€1,234.56', - ], - 'EUR-US-strip' => [ - 'EUR', - 'en-US', - 1234.00, - true, - '€1,234', - ], - 'EUR-US-no-strip' => [ - 'EUR', - 'en-US', - 1234.00, - false, - '€1,234.00', - ], - 'EUR-FR' => [ - 'EUR', - 'fr-FR', - 1234.56, - true, - '1 234,56 €', - ], - 'EUR-FR-strip' => [ - 'EUR', - 'fr-FR', - 1234.00, - true, - '1 234 €', - ], - 'EUR-FR-no-strip' => [ - 'EUR', - 'fr-FR', - 1234.00, - false, - '1 234,00 €', - ], - ]; - } - - /** - * @param string $currency - * @param string $language - * @param string $expected - * @return void - * @throws CurrencyException - * @throws InvalidConfigException - * @dataProvider formatAsCurrencyNegativeDataProvider - */ - public function testFormatAsCurrencyNegative(string $currency, string $language, string $expected): void - { - $originalLocale = \Craft::$app->getLocale(); - Locale::switchAppLanguage($language); - $amount = -1234.56; - $formattedValue = Currency::formatAsCurrency($amount, $currency); - - self::assertEquals($expected, $formattedValue); - Locale::switchAppLanguage($originalLocale->getLanguageID()); - } - - public function formatAsCurrencyNegativeDataProvider(): array - { - return [ - 'USD-US' => [ - 'USD', - 'en-US', - '-$1,234.56', - ], - 'USD-GB' => [ - 'USD', - 'en-GB', - '-US$1,234.56', - ], - 'USD-FR' => [ - 'USD', - 'fr-FR', - '-1 234,56 $US', - ], - 'EUR-US' => [ - 'EUR', - 'en-US', - '-€1,234.56', - ], - 'EUR-GB' => [ - 'EUR', - 'en-GB', - '-€1,234.56', - ], - 'EUR-FR' => [ - 'EUR', - 'fr-FR', - '-1 234,56 €', - ], - 'CHF-DE-CH' => [ - 'CHF', - 'de-CH', - 'CHF-1’234.56', - ], - ]; - } -} diff --git a/tests/unit/helpers/DebugPanelHelperTest.php b/tests/unit/helpers/DebugPanelHelperTest.php deleted file mode 100644 index ad839a7d34..0000000000 --- a/tests/unit/helpers/DebugPanelHelperTest.php +++ /dev/null @@ -1,175 +0,0 @@ - - * @since 4.0 - */ -class DebugPanelHelperTest extends Unit -{ - /** - * @var UnitTester - */ - protected $tester; - - /** - * @param array $models - * @param array|null $names - * @param array|null $prepend - * @param array $expected - * @throws \yii\base\InvalidConfigException - * @dataProvider prependOrAppendModelTabDataProvider - */ - public function testPrependOrAppendModelTab(array $models, ?array $names, ?array $prepend, array $expected): void - { - Craft::$app->getConfig()->getGeneral()->devMode = true; - Craft::$app->getUser()->setIdentity( - Craft::$app->getUsers()->getUserById('1') - ); - - $usersServices = $this->make(Users::class, [ - 'getUserPreferences' => fn($userId) => [ - 'enableDebugToolbarForSite' => true, - 'enableDebugToolbarForCp' => true, - ], - ]); - Craft::$app->set('users', $usersServices); - - foreach ($models as $key => $model) { - DebugPanel::prependOrAppendModelTab($model, $names[$key], $prepend[$key]); - } - - $event = new CommerceDebugPanelDataEvent(['nav' => [], 'content' => []]); - $commercePanel = new CommercePanel(); - $commercePanel->trigger(CommercePanel::EVENT_AFTER_DATA_PREPARE, $event); - - foreach ($models as $key => $model) { - self::assertIsArray($event->nav); - self::assertIsArray($event->content); - self::assertContains($expected[$key]['name'], $event->nav); - self::assertStringContainsString($expected[$key]['content'], $event->content[$expected[$key]['position']]); - } - } - - /** - * @return array - * @throws \yii\base\InvalidConfigException - */ - public function prependOrAppendModelTabDataProvider(): array - { - $discount = new Discount(); - $discount->id = 1; - - $sale = new Sale(); - $sale->id = 123; - return [ - [ - [ - $discount, - ], - [ - null, - ], - [ - true, - ], - [ - [ - 'name' => 'Discount (ID: 1)', - 'content' => 'id1', - 'position' => 0, - ], - ], - ], - [ - [ - $sale, - $discount, - ], - [ - 'Test Custom Name', - null, - ], - [ - false, - true, - ], - [ - [ - 'name' => 'Test Custom Name', - 'content' => 'id123', - 'position' => 1, - ], - [ - 'name' => 'Discount (ID: 1)', - 'content' => 'id1', - 'position' => 0, - ], - ], - ], - ]; - } - - /** - * @param string $attr - * @param string|null $label - * @param string $expected - * @return void - * @dataProvider renderModelAttributeRowDataProvider - */ - public function testRenderModelAttributeRow(string $attr, mixed $value, ?string $label = null, string $expected = ''): void - { - self::assertEquals($expected, DebugPanel::renderModelAttributeRow($attr, $value, $label)); - } - - public function renderModelAttributeRowDataProvider(): array - { - $discountVarDump = VarDumper::dumpAsString(new Discount()); - return [ - [ - 'stringAttr', - 'Test string', - null, - 'stringAttrTest string', - ], - [ - 'stringAttr', - 'Custom label', - 'Customize the label', - 'Customize the labelCustom label', - ], - [ - 'modelAttr', - $discountVarDump, - null, - 'modelAttr' . $discountVarDump . '', - ], - [ - 'attrHtml', - 'Extra & useful HTML', - null, - 'attrHtml' . Html::encode('Extra & useful HTML') . '', - ], - ]; - } -} diff --git a/tests/unit/helpers/LocaleHelperTest.php b/tests/unit/helpers/LocaleHelperTest.php deleted file mode 100644 index 9d1c4554ad..0000000000 --- a/tests/unit/helpers/LocaleHelperTest.php +++ /dev/null @@ -1,98 +0,0 @@ - - * @since 3.2.14 - */ -class LocaleHelperTest extends Unit -{ - /** - * @var UnitTester - */ - protected UnitTester $tester; - - public function testPdfGetRenderLanguageException(): void - { - $this->tester->expectThrowable(InvalidArgumentException::class, function() { - $pdf = new Pdf(); - $pdf->language = PdfRecord::LOCALE_ORDER_LANGUAGE; - $pdf->getRenderLanguage(); - }); - } - - public function testPdfGetOrderLanguage(): void - { - $order = new Order(); - $order->orderLanguage = 'nl'; - - $pdf = new Pdf(); - $pdf->language = PdfRecord::LOCALE_ORDER_LANGUAGE; - - $language = $pdf->getRenderLanguage($order); - - self::assertEquals('nl', $language); - - $pdf = new Pdf(); - $pdf->language = 'ph'; - - $language = $pdf->getRenderLanguage($order); - - self::assertEquals('ph', $language); - } - - public function testEmailGetRenderLanguageException(): void - { - $this->tester->expectThrowable(InvalidArgumentException::class, function() { - $email = new Email(); - $email->language = EmailRecord::LOCALE_ORDER_LANGUAGE; - $email->getRenderLanguage(); - }); - } - - public function testEmailGetOrderLanguage(): void - { - $order = new Order(); - $order->orderLanguage = 'nl'; - - $email = new Email(); - $email->language = EmailRecord::LOCALE_ORDER_LANGUAGE; - - $language = $email->getRenderLanguage($order); - - self::assertEquals('nl', $language); - - $pdf = new Email(); - $email->language = 'ph'; - - $language = $email->getRenderLanguage($order); - - self::assertEquals('ph', $language); - } - - public function testSwitchLanguage(): void - { - Locale::switchAppLanguage('nl'); - - self::assertEquals('nl', Craft::$app->language); - } -} diff --git a/tests/unit/helpers/LocalizationHelperTest.php b/tests/unit/helpers/LocalizationHelperTest.php deleted file mode 100644 index 8f12d678af..0000000000 --- a/tests/unit/helpers/LocalizationHelperTest.php +++ /dev/null @@ -1,62 +0,0 @@ - - * @since 3.2.14 - */ -class LocalizationHelperTest extends Unit -{ - /** - * @var UnitTester - */ - protected $tester; - - /** - * @param float $expected - * @param int|float|string|null $number - * @dataProvider normalizePercentageDataProvider - */ - public function testNormalizePercentage(float $expected, $number): void - { - self::assertEquals($expected, Localization::normalizePercentage($number)); - } - - /** - * @return array - */ - public function normalizePercentageDataProvider(): array - { - $pct = Craft::$app->getLocale()->getNumberSymbol(Locale::SYMBOL_PERCENT); - return [ - [0.0, null], - [0.0, ''], - [0.0, $pct], - [0.0, " $pct "], - [0.0, 0], - [0.5, 0.5], - [50.0, 50], - [1.0, 1], - [0.0, '0'], - [0.01, '1'], - [0.5, '50'], - [0.0, ' 0.0 '], - [0.005, " .5 $pct "], - [0.005, " $pct 0.5 "], - ]; - } -} diff --git a/tests/unit/services/PlansTest.php b/tests/unit/services/PlansTest.php deleted file mode 100644 index 2f4986e850..0000000000 --- a/tests/unit/services/PlansTest.php +++ /dev/null @@ -1,232 +0,0 @@ - - * @since 4.0.5 - */ -class PlansTest extends Unit -{ - /** - * @var UnitTester|UnitTesterActions - */ - protected UnitTester $tester; - - /** - * @var Plans - */ - protected Plans $service; - - /** - * @return array - */ - public function _fixtures(): array - { - return [ - 'plans' => [ - 'class' => SubscriptionPlansFixture::class, - ], - ]; - } - - /** - * @return void - */ - public function testGetAllPlans(): void - { - $plans = $this->service->getAllPlans(); - - self::assertCount(2, $plans); - self::assertEquals(['monthlySubscription', 'weeklySubscription'], ArrayHelper::getColumn($plans, 'handle', false)); - } - - /** - * @return void - */ - public function testGetAllEnabledPlans(): void - { - $plans = $this->service->getAllEnabledPlans(); - - self::assertCount(1, $plans); - self::assertEquals(['monthlySubscription'], ArrayHelper::getColumn($plans, 'handle', false)); - } - - /** - * @param int $gatewayId - * @param int $count - * @return void - * @dataProvider getPlansByGatewayIdDataProvider - */ - public function testGetPlansByGatewayId(int $gatewayId, int $count): void - { - $plans = $this->service->getPlansByGatewayId($gatewayId); - - self::assertCount($count, $plans); - } - - /** - * @return \int[][] - */ - public function getPlansByGatewayIdDataProvider(): array - { - return [ - 'dummy-gateway' => [1, 2], - 'non-existent-gateway' => [99, 0], - ]; - } - - /** - * @return void - */ - public function testGetPlanById(): void - { - /** @var Plan $monthlyPlan */ - $monthlyPlan = $this->tester->grabFixture('plans', 'monthly'); - $plan = $this->service->getPlanById($monthlyPlan->id); - - self::assertInstanceOf(Plan::class, $plan); - self::assertEquals($monthlyPlan->name, $plan->name); - } - - /** - * @return void - */ - public function testGetPlanByUid(): void - { - /** @var Plan $monthlyPlan */ - $monthlyPlan = $this->tester->grabFixture('plans', 'monthly'); - $plan = $this->service->getPlanByUid($monthlyPlan->uid); - - self::assertInstanceOf(Plan::class, $plan); - self::assertEquals($monthlyPlan->name, $plan->name); - } - - /** - * @return void - */ - public function testGetPlanByHandle(): void - { - $plan = $this->service->getPlanByHandle('monthlySubscription'); - self::assertEquals('Monthly Subscription', $plan->name); - - $plan = $this->service->getPlanByHandle('weeklySubscription'); - self::assertEquals('Weekly Subscription', $plan->name); - } - - /** - * @return void - */ - public function testGetPlanByReference(): void - { - $plan = $this->service->getPlanByReference('monthly_sub'); - self::assertEquals('Monthly Subscription', $plan->name); - - $plan = $this->service->getPlanByReference('weekly_sub'); - self::assertEquals('Weekly Subscription', $plan->name); - } - - /** - * @return void - * @throws InvalidConfigException - */ - public function testSavePlan(): void - { - $plan = $this->service->getPlanByHandle('monthlySubscription'); - - $plan->name .= ' foo'; - - $result = $this->service->savePlan($plan, false); - - self::assertTrue($result); - self::assertEquals('Monthly Subscription foo', $plan->name); - $dbRow = (new Query()) - ->from(Table::PLANS) - ->select(['id', 'name']) - ->where(['name' => 'Monthly Subscription foo']) - ->one(); - self::assertEquals('Monthly Subscription foo', $dbRow['name']); - self::assertEquals($plan->id, $dbRow['id']); - } - - - /** - * @return void - * @throws InvalidConfigException - */ - public function testArchivePlanById(): void - { - /** @var Plan $monthlyPlan */ - $monthlyPlan = $this->tester->grabFixture('plans', 'monthly'); - $result = $this->service->archivePlanById($monthlyPlan->id); - - self::assertTrue($result); - $dbRow = (new Query()) - ->from(Table::PLANS) - ->select(['id', 'name', 'isArchived']) - ->where(['isArchived' => true]) - ->andWhere(['id' => $monthlyPlan->id]) - ->one(); - self::assertEquals('Monthly Subscription', $dbRow['name']); - self::assertEquals($monthlyPlan->id, $dbRow['id']); - self::assertEquals(true, $dbRow['isArchived']); - - $allPlans = $this->service->getAllPlans(); - $allEnabledPlans = $this->service->getAllEnabledPlans(); - self::assertNull(ArrayHelper::firstWhere($allPlans, 'name', $monthlyPlan->name)); - self::assertNull(ArrayHelper::firstWhere($allEnabledPlans, 'name', $monthlyPlan->name)); - } - - /** - * @return void - * @throws Exception - */ - public function testReorderPlans(): void - { - $plans = ArrayHelper::getColumn($this->service->getAllPlans(), 'id', false); - - $result = $this->service->reorderPlans(array_reverse($plans)); - self::assertTrue($result); - $dbRows = (new Query()) - ->from(Table::PLANS) - ->select(['id', 'sortOrder']) - ->orderBy(['sortOrder' => SORT_ASC]) - ->all(); - $previousSortOrder = -1; - foreach (array_reverse($plans) as $key => $id) { - self::assertEquals($id, $dbRows[$key]['id']); - self::assertGreaterThan($previousSortOrder, $dbRows[$key]['sortOrder']); - $previousSortOrder = $dbRows[$key]['sortOrder']; - } - } - - /** - * - */ - public function _before(): void - { - parent::_before(); - - $this->service = Plugin::getInstance()->getPlans(); - } -} diff --git a/tests/unit/stats/AverageOrderTotalTest.php b/tests/unit/stats/AverageOrderTotalTest.php deleted file mode 100644 index 4366330d73..0000000000 --- a/tests/unit/stats/AverageOrderTotalTest.php +++ /dev/null @@ -1,84 +0,0 @@ - - * @since 3.3.2 - */ -class AverageOrderTotalTest extends Unit -{ - /** - * @var UnitTester - */ - protected UnitTester $tester; - - /** - * @return array - */ - public function _fixtures(): array - { - return [ - 'orders' => [ - 'class' => OrdersFixture::class, - ], - ]; - } - - /** - * @dataProvider getDataDataProvider - * - * @param string $dateRange - * @param DateTime $startDate - * @param DateTime $endDate - * @param float|null $average - */ - public function testGetData(string $dateRange, DateTime $startDate, DateTime $endDate, $average): void - { - $storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; - $stat = new AverageOrderTotal($dateRange, $startDate, $endDate, $storeId); - $data = $stat->get(); - - if ($average === null) { - self::assertEquals($average, $data); - } else { - self::assertIsNumeric($data); - } - self::assertEquals($average, $data); - } - - /** - * @return array[] - */ - public function getDataDataProvider(): array - { - return [ - [ - AverageOrderTotal::DATE_RANGE_TODAY, - (new DateTime('now', new \DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('now', new \DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 63.97, - ], - [ - AverageOrderTotal::DATE_RANGE_CUSTOM, - (new DateTime('7 days ago', new \DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('5 days ago', new \DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - null, - ], - ]; - } -} diff --git a/tests/unit/stats/NewCustomersTest.php b/tests/unit/stats/NewCustomersTest.php deleted file mode 100644 index 82b7ea34ee..0000000000 --- a/tests/unit/stats/NewCustomersTest.php +++ /dev/null @@ -1,90 +0,0 @@ - - * @since 3.3.2 - */ -class NewCustomersTest extends Unit -{ - /** - * @var UnitTester - */ - protected UnitTester $tester; - - /** - * @return array - */ - public function _fixtures(): array - { - return [ - 'orders' => [ - 'class' => OrdersFixture::class, - ], - ]; - } - - /** - * @dataProvider getDataDataProvider - * - * @param string $dateRange - * @param DateTime $startDate - * @param DateTime $endDate - * @param float|null $count - * @throws \yii\base\Exception - */ - public function testGetData(string $dateRange, DateTime $startDate, DateTime $endDate, ?float $count): void - { - $storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; - $stat = new NewCustomers($dateRange, $startDate, $endDate, $storeId); - $data = $stat->get(); - - self::assertIsNumeric($data); - self::assertEquals($count, $data); - } - - /** - * @return array[] - * @throws Exception - */ - public function getDataDataProvider(): array - { - return [ - [ - NewCustomers::DATE_RANGE_CUSTOM, - (new DateTime('2 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('0 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 1, - ], - [ - NewCustomers::DATE_RANGE_TODAY, - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 0, - ], - [ - NewCustomers::DATE_RANGE_CUSTOM, - (new DateTime('7 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('5 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 0, - ], - ]; - } -} diff --git a/tests/unit/stats/RepeatCustomersTest.php b/tests/unit/stats/RepeatCustomersTest.php deleted file mode 100644 index 1843cf8c6d..0000000000 --- a/tests/unit/stats/RepeatCustomersTest.php +++ /dev/null @@ -1,92 +0,0 @@ - - * @since 3.3.2 - */ -class RepeatCustomersTest extends Unit -{ - /** - * @var UnitTester - */ - protected UnitTester $tester; - - /** - * @return array - */ - public function _fixtures(): array - { - return [ - 'orders' => [ - 'class' => OrdersFixture::class, - ], - ]; - } - - /** - * @dataProvider getDataDataProvider - * - * @param string $dateRange - * @param DateTime $startDate - * @param DateTime $endDate - * @param int $total - * @param int $repeat - * @param int $percentage - * @throws \yii\base\Exception - */ - public function testGetData(string $dateRange, DateTime $startDate, DateTime $endDate, int $total, int $repeat, int $percentage): void - { - $storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; - $stat = new RepeatCustomers($dateRange, $startDate, $endDate, $storeId); - $data = $stat->get(); - - self::assertIsArray($data); - self::assertEquals($total, $data['total']); - self::assertEquals($repeat, $data['repeat']); - self::assertEquals($percentage, $data['percentage']); - } - - /** - * @return array[] - * @throws Exception - */ - public function getDataDataProvider(): array - { - return [ - [ - RepeatCustomers::DATE_RANGE_TODAY, - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 1, - 1, - 100, - ], - [ - RepeatCustomers::DATE_RANGE_CUSTOM, - (new DateTime('7 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('5 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 0, - 0, - 0, - ], - ]; - } -} diff --git a/tests/unit/stats/StatTest.php b/tests/unit/stats/StatTest.php deleted file mode 100644 index a7a8bc6074..0000000000 --- a/tests/unit/stats/StatTest.php +++ /dev/null @@ -1,201 +0,0 @@ - - * @since 3.3.2 - */ -class StatTest extends Unit -{ - /** - * @var UnitTester - */ - protected UnitTester $tester; - - /** - * @var DateTime - */ - protected DateTime $today; - - /** - * @var DateTime - */ - protected DateTime $yesterday; - - /** - * @dataProvider instantiateDatesDataProvider - * - * @param string $dateRange - * @param DateTime $startDate - * @param DateTime $endDate - * @throws Exception - */ - public function testInstantiateDates(string $dateRange, DateTime $startDate, DateTime $endDate): void - { - $storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; - $stat = $this->_createStatClass($dateRange, $startDate, $endDate, $storeId); - - $data = $stat->get(); - - self::assertArrayHasKey($startDate->format('Y-m-d'), $data); - self::assertArrayHasKey($endDate->format('Y-m-d'), $data); - self::assertCount(2, $data); - } - - /** - * @dataProvider predefinedDateRangesDataProvider - * - * @param string $dateRange - * @param DateTime $startDate - * @param DateTime $endDate - * @param int $keysCount - * @param bool $keyedByDays - * @throws Exception - */ - public function testPredefinedDateRanges(string $dateRange, DateTime $startDate, DateTime $endDate, int $keysCount, bool $keyedByDays = true): void - { - $format = $keyedByDays ? 'Y-m-d' : 'Y-n'; - $storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; - $stat = $this->_createStatClass($dateRange, $startDate, $endDate, $storeId); - - $data = $stat->get(); - - while ($startDate <= $endDate) { - self::assertArrayHasKey($startDate->format($format), $data); - - if ($keyedByDays) { - $startDate->add(new DateInterval('P1D')); - } else { - $startDate->add(new DateInterval('P1M')); - } - } - - self::assertCount($keysCount, $data); - } - - /** - * Create an anonymous stat class for testing generic features - * @param $range - * @param $start - * @param $end - * @return Stat - */ - private function _createStatClass($range, $start, $end, $storeId): Stat - { - return new class($range, $start, $end, $storeId) extends Stat { - // Prevent caching - public bool $cache = false; - - // Implement getData method - public function getData(): mixed - { - return $this->_createChartQuery(); - } - }; - } - - /** - * @before createDates - * - * @return array - * @throws \Exception - */ - public function instantiateDatesDataProvider(): array - { - // @TODO Source the timezone from the test Craft app instead of hardcoding it; data provider runs before the app is instantiated #COM-54 - $tz = new DateTimeZone('America/Los_Angeles'); - - return [ - [ - Stat::DATE_RANGE_CUSTOM, - (new DateTime('yesterday', $tz))->setTime(0, 0), - (new DateTime('now', $tz))->setTime(0, 0), - ], - ]; - } - - /** - * @before createDates - * - * @return array - * @throws \Exception - */ - public function predefinedDateRangesDataProvider(): array - { - - // @TODO Source the timezone from the test Craft app instead of hardcoding it; data provider runs before the app is instantiated. Consider storing `tz` in a class property set in a @before hook #COM-54 - - $tz = new DateTimeZone('America/Los_Angeles'); - $today = (new DateTime('now', $tz))->setTime(0, 0); - - return [ - Stat::DATE_RANGE_TODAY => [ - Stat::DATE_RANGE_TODAY, - clone $today, - clone $today, - 1, - ], - Stat::DATE_RANGE_PAST7DAYS => [ - Stat::DATE_RANGE_PAST7DAYS, - (new DateTime('6 days ago', $tz))->setTime(0, 0), - clone $today, - 7, - ], - Stat::DATE_RANGE_PAST30DAYS => [ - Stat::DATE_RANGE_PAST30DAYS, - (new DateTime('29 days ago', $tz))->setTime(0, 0), - clone $today, - 30, - ], - Stat::DATE_RANGE_PAST90DAYS => [ - Stat::DATE_RANGE_PAST90DAYS, - (new DateTime('89 days ago', $tz))->setTime(0, 0), - clone $today, - 90, - ], - Stat::DATE_RANGE_PASTYEAR => [ - Stat::DATE_RANGE_PASTYEAR, - (new DateTime('11 months ago', $tz))->setTime(0, 0), - clone $today, - 12, - false, - ], - Stat::DATE_RANGE_THISMONTH => [ - Stat::DATE_RANGE_THISMONTH, - (new DateTime('now', $tz))->setDate($today->format('Y'), $today->format('n'), 1)->setTime(0, 0), - clone $today, - (int)$today->format('t'), - ], - Stat::DATE_RANGE_THISWEEK => [ - Stat::DATE_RANGE_THISWEEK, - (new DateTime('Monday this week', $tz))->setTime(0, 0), - clone $today, - 7, - ], - Stat::DATE_RANGE_THISYEAR => [ - Stat::DATE_RANGE_THISYEAR, - (new DateTime('first day of January ' . $today->format('Y'), $tz))->setTime(0, 0), - clone $today, - (int)($today->diff((new DateTime('first day of January ' . $today->format('Y'), $tz))->setTime(0, 0))->format('%m')) + 1, - false, - ], - ]; - } -} diff --git a/tests/unit/stats/TopCustomersTest.php b/tests/unit/stats/TopCustomersTest.php deleted file mode 100644 index fecc866e8b..0000000000 --- a/tests/unit/stats/TopCustomersTest.php +++ /dev/null @@ -1,115 +0,0 @@ - - * @since 3.3.2 - */ -class TopCustomersTest extends Unit -{ - /** - * @var UnitTester - */ - protected UnitTester $tester; - - /** - * @return array - */ - public function _fixtures(): array - { - return [ - 'orders' => [ - 'class' => OrdersFixture::class, - ], - ]; - } - - /** - * @dataProvider getDataDataProvider - * - * @param string $dateRange - * @param string $type - * @param DateTime $startDate - * @param DateTime $endDate - * @param $customerData - * @throws \yii\base\Exception - */ - public function testGetData(string $dateRange, string $type, DateTime $startDate, DateTime $endDate, mixed $count, $customerData): void - { - $storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; - $stat = new TopCustomers($dateRange, $type, $startDate, $endDate, $storeId); - $data = $stat->get(); - - self::assertIsArray($data); - self::assertCount($count, $data); - - if ($count !== 0) { - $topCustomer = array_shift($data); - - $testKeys = ['total', 'average', 'customerId', 'email', 'count', 'customer']; - foreach ($testKeys as $testKey) { - self::assertArrayHasKey($testKey, $topCustomer); - - if ($testKey === 'customer') { - self::assertInstanceOf(User::class, $topCustomer[$testKey]); - } else { - self::assertEquals($customerData()[$testKey], $topCustomer[$testKey]); - } - } - } - } - - /** - * @return array[] - * @throws Exception - */ - public function getDataDataProvider(): array - { - return [ - [ - TopCustomers::DATE_RANGE_TODAY, - 'total', - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 1, - function() { - $user = Craft::$app->getUsers()->getUserByUsernameOrEmail('customer1@crafttest.com'); - return [ - 'total' => 127.94, - 'average' => 63.97, - 'customerId' => $user->id, - 'email' => $user->email, - 'count' => 2, - ]; - }, - ], - [ - TopCustomers::DATE_RANGE_CUSTOM, - 'total', - (new DateTime('7 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('5 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 0, - [], - ], - ]; - } -} diff --git a/tests/unit/stats/TopProductTypesTest.php b/tests/unit/stats/TopProductTypesTest.php deleted file mode 100644 index c8c95e68f3..0000000000 --- a/tests/unit/stats/TopProductTypesTest.php +++ /dev/null @@ -1,127 +0,0 @@ - - * @since 3.3.2 - */ -class TopProductTypesTest extends Unit -{ - /** - * @var UnitTester - */ - protected UnitTester $tester; - - /** - * @return array - */ - public function _fixtures(): array - { - return [ - 'orders' => [ - 'class' => OrdersFixture::class, - ], - ]; - } - - /** - * @dataProvider getDataDataProvider - * - * @param string $dateRange - * @param string $type - * @param DateTime $startDate - * @param DateTime $endDate - * @param int $count - * @param array $productTypeData - * @throws \yii\base\Exception - */ - public function testGetData(string $dateRange, string $type, DateTime $startDate, DateTime $endDate, int $count, array $productTypeData): void - { - $storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; - $this->_mockUser(); - $stat = new TopProductTypes($dateRange, $type, $startDate, $endDate, $storeId); - $data = $stat->get(); - - self::assertIsArray($data); - self::assertCount($count, $data); - - if ($count !== 0) { - $topProductType = array_shift($data); - - $testKeys = ['id', 'name', 'qty', 'revenue', 'productType']; - foreach ($testKeys as $testKey) { - self::assertArrayHasKey($testKey, $topProductType); - - if ($testKey === 'productType') { - self::assertInstanceOf(ProductType::class, $topProductType[$testKey]); - } else { - self::assertEquals($productTypeData[$testKey], $topProductType[$testKey]); - } - } - } - } - - /** - * @return array[] - * @throws Exception - */ - public function getDataDataProvider(): array - { - return [ - [ - TopProducts::DATE_RANGE_TODAY, - 'revenue', - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 1, - [ - 'id' => 2001, - 'name' => 'T-Shirts', - 'qty' => 6, - 'revenue' => 127.94, - ], - ], - [ - TopProducts::DATE_RANGE_CUSTOM, - 'revenue', - (new DateTime('7 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('5 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 0, - [], - ], - ]; - } - - public function _mockUser(): void - { - $user = new User(); - $user->id = 1; - $user->admin = true; - - $mockUser = $this->make(\craft\web\User::class, [ - 'getIdentity' => $user, - ]); - - \Craft::$app->set('user', $mockUser); - } -} diff --git a/tests/unit/stats/TopProductsTest.php b/tests/unit/stats/TopProductsTest.php deleted file mode 100644 index 5eec7798e6..0000000000 --- a/tests/unit/stats/TopProductsTest.php +++ /dev/null @@ -1,116 +0,0 @@ - - * @since 3.3.2 - */ -class TopProductsTest extends Unit -{ - /** - * @var UnitTester - */ - protected UnitTester $tester; - - /** - * @return array - */ - public function _fixtures(): array - { - return [ - 'orders' => [ - 'class' => OrdersFixture::class, - ], - ]; - } - - /** - * @dataProvider getDataDataProvider - * - * @param string $dateRange - * @param string $type - * @param DateTime $startDate - * @param DateTime $endDate - * @param int $count - * @param $productDataFunction - * @throws \yii\base\Exception - */ - public function testGetData(string $dateRange, string $type, DateTime $startDate, DateTime $endDate, int $count, $productDataFunction): void - { - $storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; - $stat = new TopProducts($dateRange, $type, $startDate, $endDate, storeId: $storeId); - $data = $stat->get(); - - self::assertIsArray($data); - self::assertCount($count, $data); - - if ($count !== 0) { - $topProduct = array_shift($data); - $productData = $productDataFunction(); - - $testKeys = ['id', 'title', 'qty', 'revenue', 'product']; - foreach ($testKeys as $testKey) { - self::assertArrayHasKey($testKey, $topProduct); - - if ($testKey === 'product') { - self::assertInstanceOf(Product::class, $topProduct[$testKey]); - } else { - self::assertEquals($productData[$testKey], $topProduct[$testKey]); - } - } - } - } - - /** - * @return array[] - * @throws Exception - */ - public function getDataDataProvider(): array - { - return [ - [ - TopProducts::DATE_RANGE_TODAY, - 'revenue', - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 1, - function() { - $product = Product::find()->title('Hypercolor T-shirt')->one(); - - return [ - 'id' => $product->id, - 'title' => 'Hypercolor T-Shirt', - 'qty' => 6, - 'revenue' => 127.94, - ]; - }, - ], - [ - TopProducts::DATE_RANGE_CUSTOM, - 'revenue', - (new DateTime('7 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('5 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 0, - fn() => [], - ], - ]; - } -} diff --git a/tests/unit/stats/TopPurchasablesTest.php b/tests/unit/stats/TopPurchasablesTest.php deleted file mode 100644 index 2a4fc59a0b..0000000000 --- a/tests/unit/stats/TopPurchasablesTest.php +++ /dev/null @@ -1,120 +0,0 @@ - - * @since 3.3.2 - */ -class TopPurchasablesTest extends Unit -{ - /** - * @var UnitTester - */ - protected UnitTester $tester; - - /** - * @return array - */ - public function _fixtures(): array - { - return [ - 'orders' => [ - 'class' => OrdersFixture::class, - ], - ]; - } - - /** - * @dataProvider getDataDataProvider - * - * @param string $dateRange - * @param string $type - * @param DateTime $startDate - * @param DateTime $endDate - * @param int $count - * @param $getVariantData - * @throws \yii\base\Exception - */ - public function testGetData(string $dateRange, string $type, DateTime $startDate, DateTime $endDate, int $count, $getVariantData): void - { - $storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; - Craft::$app->getUser()->setIdentity( - Craft::$app->getUsers()->getUserById('1') - ); - $stat = new TopPurchasables($dateRange, $type, $startDate, $endDate, $storeId); - $data = $stat->get(); - - self::assertIsArray($data); - self::assertCount($count, $data); - - if ($count !== 0) { - $topPurchasable = array_shift($data); - - $testKeys = ['purchasableId', 'description', 'sku', 'qty', 'revenue']; - $purchasableData = $getVariantData(Variant::find()); - foreach ($testKeys as $testKey) { - self::assertArrayHasKey($testKey, $topPurchasable); - - self::assertEquals($purchasableData[$testKey], $topPurchasable[$testKey], 'Assert ' . $testKey); - } - } - } - - /** - * @return array[] - * @throws Exception - */ - public function getDataDataProvider(): array - { - return [ - 'date-today' => [ - TopPurchasables::DATE_RANGE_TODAY, - 'revenue', - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 2, - function(VariantQuery $query) { - /** @var Purchasable $purchasable */ - $variant = $query->sku('hct-blue')->one(); - - return [ - 'purchasableId' => $variant->id ?? null, - 'description' => $variant ? $variant->getDescription() : null, - 'sku' => 'hct-blue', - 'qty' => 4, - 'revenue' => 87.96, - ]; - }, - ], - 'date-custom' => [ - TopPurchasables::DATE_RANGE_CUSTOM, - 'qty', - (new DateTime('7 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('5 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 0, - null, - ], - ]; - } -} diff --git a/tests/unit/stats/TotalOrdersByCountryTest.php b/tests/unit/stats/TotalOrdersByCountryTest.php deleted file mode 100644 index f58c6da695..0000000000 --- a/tests/unit/stats/TotalOrdersByCountryTest.php +++ /dev/null @@ -1,103 +0,0 @@ - - * @since 3.3.2 - */ -class TotalOrdersByCountryTest extends Unit -{ - /** - * @var UnitTester - */ - protected UnitTester $tester; - - /** - * @return array - */ - public function _fixtures(): array - { - return [ - 'orders' => [ - 'class' => OrdersFixture::class, - ], - ]; - } - - /** - * @dataProvider getDataDataProvider - * - * @param string $dateRange - * @param string $type - * @param DateTime $startDate - * @param DateTime $endDate - * @param int $count - * @param array $countryData - * @throws \yii\base\Exception - */ - public function testGetData(string $dateRange, string $type, DateTime $startDate, DateTime $endDate, int $count, array $countryData): void - { - $storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; - $stat = new TotalOrdersByCountry($dateRange, $type, $startDate, $endDate, $storeId); - $data = $stat->get(); - - self::assertIsArray($data); - self::assertCount($count, $data); - - if ($count !== 0) { - $firstItem = array_shift($data); - - foreach ($countryData as $key => $countryDatum) { - self::assertArrayHasKey($key, $firstItem); - self::assertEquals($countryDatum, $firstItem[$key]); - } - } - } - - /** - * @return array[] - * @throws Exception - */ - public function getDataDataProvider(): array - { - return [ - [ - TotalOrdersByCountry::DATE_RANGE_TODAY, - 'shipping', - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 1, - [ - 'total' => 2, - 'name' => 'United States', - 'countryCode' => 'US', - ], - ], - [ - TotalOrdersByCountry::DATE_RANGE_CUSTOM, - 'shipping', - (new DateTime('7 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('5 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 0, - [], - ], - ]; - } -} diff --git a/tests/unit/stats/TotalOrdersTest.php b/tests/unit/stats/TotalOrdersTest.php deleted file mode 100644 index 820335d4a9..0000000000 --- a/tests/unit/stats/TotalOrdersTest.php +++ /dev/null @@ -1,105 +0,0 @@ - - * @since 3.3.2 - */ -class TotalOrdersTest extends Unit -{ - /** - * @var UnitTester - */ - protected UnitTester $tester; - - /** - * @return array - */ - public function _fixtures(): array - { - return [ - 'orders' => [ - 'class' => OrdersFixture::class, - ], - ]; - } - - /** - * @dataProvider getDataDataProvider - * - * @param string $dateRange - * @param DateTime $startDate - * @param DateTime $endDate - * @param int $total - * @param int $daysDiff - * @throws \yii\base\Exception - */ - public function testGetData(string $dateRange, DateTime $startDate, DateTime $endDate, int $total, int $daysDiff): void - { - $storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; - $stat = new TotalOrders($dateRange, $startDate, $endDate, $storeId); - $data = $stat->get(); - - self::assertIsArray($data); - self::assertArrayHasKey('total', $data); - self::assertEquals($total, $data['total']); - self::assertArrayHasKey('chart', $data); - self::assertIsArray($data['chart']); - self::assertArrayHasKey($startDate->format('Y-m-d'), $data['chart']); - self::assertArrayHasKey($endDate->format('Y-m-d'), $data['chart']); - self::assertCount($daysDiff, $data['chart']); - - $firstItem = array_shift($data['chart']); - self::assertArrayHasKey('total', $firstItem); - self::assertArrayHasKey('datekey', $firstItem); - self::assertEquals($startDate->format('Y-m-d'), $firstItem['datekey']); - self::assertEquals($total, $firstItem['total']); - } - - protected function _before(): void - { - Craft::$app->setTimeZone('America/Los_Angeles'); - } - - /** - * @return array[] - * @throws Exception - */ - public function getDataDataProvider(): array - { - return [ - 'today' => [ - TotalOrders::DATE_RANGE_TODAY, - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 2, - 1, - ], - 'custom' => [ - TotalOrders::DATE_RANGE_CUSTOM, - (new DateTime('7 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('5 days ago', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 0, - 3, - ], - ]; - } -} diff --git a/tests/unit/stats/TotalRevenueTest.php b/tests/unit/stats/TotalRevenueTest.php deleted file mode 100644 index f4f8af7be3..0000000000 --- a/tests/unit/stats/TotalRevenueTest.php +++ /dev/null @@ -1,97 +0,0 @@ - - * @since 3.3.2 - */ -class TotalRevenueTest extends Unit -{ - /** - * @var UnitTester - */ - protected UnitTester $tester; - - /** - * @return array - */ - public function _fixtures(): array - { - return [ - 'orders' => [ - 'class' => OrdersFixture::class, - ], - ]; - } - - /** - * @dataProvider getDataDataProvider - * - * @param string $dateRange - * @param DateTime $startDate - * @param DateTime $endDate - * @param int $count - * @param $revenue - * @param string $type - * @throws \yii\base\Exception - */ - public function testGetData(string $dateRange, DateTime $startDate, DateTime $endDate, int $count, $revenue, string $type): void - { - $storeId = Plugin::getInstance()->getStores()->getPrimaryStore()->id; - $stat = new TotalRevenue($dateRange, $startDate, $endDate, $storeId); - $stat->type = $type; - $data = $stat->get(); - - self::assertIsArray($data); - - $todaysStats = array_pop($data); - self::assertArrayHasKey('count', $todaysStats); - self::assertArrayHasKey('revenue', $todaysStats); - self::assertArrayHasKey('datekey', $todaysStats); - self::assertEquals($count, $todaysStats['count']); - self::assertEquals($revenue, $todaysStats['revenue']); - } - - /** - * @return array[] - * @throws Exception - */ - public function getDataDataProvider(): array - { - return [ - [ - TotalRevenue::DATE_RANGE_TODAY, - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 2, - 127.94, - TotalRevenue::TYPE_TOTAL, - ], - [ - TotalRevenue::DATE_RANGE_TODAY, - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - (new DateTime('now', new DateTimeZone('America/Los_Angeles')))->setTime(0, 0), - 2, - 0, - TotalRevenue::TYPE_TOTAL_PAID, - ], - ]; - } -}