diff --git a/.github/workflows/document-facades.yml b/.github/workflows/document-facades.yml index f45df1a857..06e8dc035b 100644 --- a/.github/workflows/document-facades.yml +++ b/.github/workflows/document-facades.yml @@ -26,7 +26,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: 8.4 extensions: :php-psr tools: composer:v2 coverage: none diff --git a/.github/workflows/fix-code-style.yml b/.github/workflows/fix-code-style.yml index deea421326..53f4a225f8 100644 --- a/.github/workflows/fix-code-style.yml +++ b/.github/workflows/fix-code-style.yml @@ -17,7 +17,7 @@ jobs: - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: 8.4 tools: composer:v2, pint - name: Run Pint run: pint --parallel --diff=origin/1.x diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index fa5ebb7e1a..6a84f94031 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - php: ["8.3", "8.4"] + php: ${{ fromJSON(github.event_name == 'pull_request' && '["8.4"]' || '["8.4","8.5"]') }} laravel: ["^12.0", "^13.0"] dependencies: ["highest"] name: "PHP ${{ matrix.php }} - L${{ matrix.laravel }} ${{ matrix.dependencies == 'highest' && '↑' || '↓' }}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ffe31c22dd..c835137fe7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: fail-fast: false matrix: # PRs run a slimmed matrix; push and the schedule run the full one. - php: ${{ fromJSON(github.event_name == 'pull_request' && '["8.4"]' || '["8.3","8.4"]') }} + php: ${{ fromJSON(github.event_name == 'pull_request' && '["8.4"]' || '["8.4","8.5"]') }} laravel: ["12.*", "13.*"] dependency-version: [prefer-stable] testsuite: [core, admin, filament, shipping, stripe, search, upgrade] diff --git a/TODO.md b/TODO.md index 3d5802f0b6..77c45afae8 100644 --- a/TODO.md +++ b/TODO.md @@ -6,8 +6,6 @@ Each item below should have a written spec in `specs/` (alongside this file in t ## Outstanding -- Implement state machines, replacing soft-deletes - - Specifically, products (draft, published, archived) & orders (payment, fulfilment and order status) - Region concept to define channel, currency, language, tax_zone, countries and price display - `StorefrontContext` for CartSessionManager and other services - Add Vendors concept to support marketplace developments @@ -54,3 +52,4 @@ Each item below should have a written spec in `specs/` (alongside this file in t - Change `compare_price` to `list_price` - Add dedicated `name` / `description` / `short_description` fields — promote Product/Collection name + description out of `attribute_data` into translatable columns and add a translatable `short_description`; Brand gains translatable description/short_description but keeps a plain string name; reads route through `translate()`, search indexes per locale, Filament binds explicit fields; one-way v1→v2 backfill migration + `translateAttribute`→`translate` Rector rule (spec 0018) - Attribute system redesign — id-keyed raw `attribute_data` JSON on disk + handle-keyed `FieldType` collection in memory; drop the morph columns on `Attribute` / `AttributeGroup` for a nullable group FK + typed `attribute_models` join + renamed `product_type_attribute` pivot; shared `AbstractFieldType` base + `FieldTypeEnum` + relocated `Manifests\FieldTypeManifest`; `AttributeCache` + observer + `PurgeAttributeData` job keep the new shape consistent; one-way v1→v2 data migration + Rector renames in the upgrade package (spec 0019) +- State machines — `spatie/laravel-model-states` v2 across core. Channel (Active/Inactive), Product/Collection (Draft/Published/Archived), Order's three coordinated machines (Payment, Fulfilment, Order) with `OnHold`/`Cancelled` manual overrides. `OrderStateConfig` contract is the single seam for adding bespoke states, transitions, notifications and resolver overrides. `SoftDeletes` retired from Product/ProductVariant/Channel/Collection; baseline migrations edited in place. PHP minimum bumped to 8.4 due to upstream `spatie/laravel-model-states` PHP/Laravel matrix split (spec 0021) diff --git a/composer.json b/composer.json index 503d2ff765..338331b6df 100644 --- a/composer.json +++ b/composer.json @@ -26,11 +26,12 @@ "lunarphp/admin": "self.version", "lunarphp/filament": "self.version", "meilisearch/meilisearch-php": "^1.10", - "php": "^8.3", + "php": "^8.4", "spatie/laravel-activitylog": "^4.10.1", "spatie/laravel-blink": "^1.7.1", "spatie/laravel-data": "^4.13.1", "spatie/laravel-medialibrary": "^11.12.7", + "spatie/laravel-model-states": "^2.11", "spatie/laravel-permission": "^6.12", "spatie/php-structure-discoverer": "^2.3.1", "stripe/stripe-php": "^16.0", diff --git a/packages/admin/config/panel.php b/packages/admin/config/panel.php index 04197c1783..41eb09e9b1 100644 --- a/packages/admin/config/panel.php +++ b/packages/admin/config/panel.php @@ -42,10 +42,11 @@ |-------------------------------------------------------------------------- | | The admin panel will show a count of orders in the left navigation. - | This is based upon specific order statuses. You can define the statuses - | to include in the count below. + | This is based upon specific order status values. You can define the + | statuses to include in the count below — see Lunar\Core\States\Order\Order + | for the registered states (the static $name on each concrete class). | */ - 'order_count_statuses' => ['payment-received'], + 'order_count_statuses' => ['in-process'], ]; diff --git a/packages/admin/resources/lang/ar/channel.php b/packages/admin/resources/lang/ar/channel.php index 1561a7cce7..324ecb1b95 100644 --- a/packages/admin/resources/lang/ar/channel.php +++ b/packages/admin/resources/lang/ar/channel.php @@ -31,4 +31,12 @@ 'label' => 'افتراضي', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/ar/order.php b/packages/admin/resources/lang/ar/order.php index 147267f27d..21aa82e190 100644 --- a/packages/admin/resources/lang/ar/order.php +++ b/packages/admin/resources/lang/ar/order.php @@ -103,7 +103,7 @@ 'label' => 'الرقم المرجعي', ], 'status' => [ - 'label' => 'الحالة', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'المعاملة', @@ -165,7 +165,7 @@ 'message' => 'وقت الطلب: :count', ], 'status' => [ - 'label' => 'الحالة', + 'label' => 'Order', ], 'reference' => [ 'label' => 'الرقم المرجعي', diff --git a/packages/admin/resources/lang/ar/product.php b/packages/admin/resources/lang/ar/product.php index 8e2243fc09..e8087e2ae6 100644 --- a/packages/admin/resources/lang/ar/product.php +++ b/packages/admin/resources/lang/ar/product.php @@ -7,11 +7,9 @@ 'all' => 'الكل', 'published' => 'منشور', 'draft' => 'مسودة', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'حالياً في حالة مسودة، هذا المنتج غير متاح في جميع واجهات البيع ومجموعات العملاء.', - ], 'availability' => [ 'customer_groups' => 'هذا المنتج غير متاح حالياً لجميع مجموعات العملاء.', 'channels' => 'هذا المنتج غير متاح حالياً لجميع واجهات البيع.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'تحديث الحالة', + 'label_with_state' => 'Status: :state', 'heading' => 'تحديث الحالة', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Draft', 'description' => 'سيكون هذا المنتج مخفيًا في جميع القنوات ومجموعات العملاء', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/bg/channel.php b/packages/admin/resources/lang/bg/channel.php index 45edcefc0a..59f2f91996 100644 --- a/packages/admin/resources/lang/bg/channel.php +++ b/packages/admin/resources/lang/bg/channel.php @@ -31,4 +31,12 @@ 'label' => 'По подразбиране', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/bg/order.php b/packages/admin/resources/lang/bg/order.php index fcfb50bc6b..1eea8d0ea2 100644 --- a/packages/admin/resources/lang/bg/order.php +++ b/packages/admin/resources/lang/bg/order.php @@ -103,7 +103,7 @@ 'label' => 'Референция', ], 'status' => [ - 'label' => 'Статус', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Транзакция', @@ -165,7 +165,7 @@ 'message' => 'по време на поръчката: :count', ], 'status' => [ - 'label' => 'Статус', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Референция', diff --git a/packages/admin/resources/lang/bg/product.php b/packages/admin/resources/lang/bg/product.php index fe2af942c0..e90801bc09 100644 --- a/packages/admin/resources/lang/bg/product.php +++ b/packages/admin/resources/lang/bg/product.php @@ -7,11 +7,9 @@ 'all' => 'Всички', 'published' => 'Публикувани', 'draft' => 'Чернови', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'В чернова, този продукт е недостъпен във всички канали и клиентски групи.', - ], 'availability' => [ 'customer_groups' => 'Този продукт не е наличен за всички клиентски групи.', 'channels' => 'Този продукт не е наличен във всички канали.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Актуализиране на статус', + 'label_with_state' => 'Status: :state', 'heading' => 'Актуализиране на статус', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Чернова', 'description' => 'Този продукт ще бъде скрит във всички канали и клиентски групи.', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/de/channel.php b/packages/admin/resources/lang/de/channel.php index b64c383d13..c12b13df77 100644 --- a/packages/admin/resources/lang/de/channel.php +++ b/packages/admin/resources/lang/de/channel.php @@ -31,4 +31,12 @@ 'label' => 'Standard', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/de/order.php b/packages/admin/resources/lang/de/order.php index f0ec148c50..44e4a18898 100644 --- a/packages/admin/resources/lang/de/order.php +++ b/packages/admin/resources/lang/de/order.php @@ -103,7 +103,7 @@ 'label' => 'Referenz', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Transaktion', @@ -165,7 +165,7 @@ 'message' => 'Zum Zeitpunkt der Bestellung: :count', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Referenz', diff --git a/packages/admin/resources/lang/de/product.php b/packages/admin/resources/lang/de/product.php index 2852d54276..3a46da8b67 100644 --- a/packages/admin/resources/lang/de/product.php +++ b/packages/admin/resources/lang/de/product.php @@ -7,11 +7,9 @@ 'all' => 'All', 'published' => 'Published', 'draft' => 'Draft', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Derzeit im Entwurfsstatus, dieses Produkt ist in allen Kanälen und Kundengruppen nicht verfügbar.', - ], 'availability' => [ 'customer_groups' => 'Dieses Produkt ist derzeit für alle Kundengruppen nicht verfügbar.', 'channels' => 'Dieses Produkt ist derzeit für alle Kanäle nicht verfügbar.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Status aktualisieren', + 'label_with_state' => 'Status: :state', 'heading' => 'Status aktualisieren', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Entwurf', 'description' => 'Dieses Produkt wird in allen Kanälen und Kundengruppen verborgen sein', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/en/channel.php b/packages/admin/resources/lang/en/channel.php index 72ed1465be..d20700bd71 100644 --- a/packages/admin/resources/lang/en/channel.php +++ b/packages/admin/resources/lang/en/channel.php @@ -36,4 +36,12 @@ ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/en/order.php b/packages/admin/resources/lang/en/order.php index 1364b17b30..e9a266cb98 100644 --- a/packages/admin/resources/lang/en/order.php +++ b/packages/admin/resources/lang/en/order.php @@ -111,7 +111,7 @@ 'label' => 'Reference', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Transaction', @@ -178,7 +178,7 @@ 'message' => 'at time of ordering: :count', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Reference', diff --git a/packages/admin/resources/lang/en/product.php b/packages/admin/resources/lang/en/product.php index 445a22b2aa..64794cd571 100644 --- a/packages/admin/resources/lang/en/product.php +++ b/packages/admin/resources/lang/en/product.php @@ -10,12 +10,10 @@ 'all' => 'All', 'published' => 'Published', 'draft' => 'Draft', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Currently in draft status, this product is unavailable across all channels and customer groups.', - ], 'availability' => [ 'customer_groups' => 'This product is currently unavailable for all customer groups.', 'channels' => 'This product is currently unavailable for all channels.', @@ -53,8 +51,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Update Status', + 'label_with_state' => 'Status: :state', 'heading' => 'Update Status', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ @@ -81,6 +85,10 @@ 'label' => 'Draft', 'description' => 'This product will be hidden across all channels and customer groups', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/es/channel.php b/packages/admin/resources/lang/es/channel.php index d67943dc6e..f2a23e381e 100644 --- a/packages/admin/resources/lang/es/channel.php +++ b/packages/admin/resources/lang/es/channel.php @@ -31,4 +31,12 @@ 'label' => 'Predeterminado', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/es/order.php b/packages/admin/resources/lang/es/order.php index c2e79b9062..d456174454 100644 --- a/packages/admin/resources/lang/es/order.php +++ b/packages/admin/resources/lang/es/order.php @@ -103,7 +103,7 @@ 'label' => 'Referencia', ], 'status' => [ - 'label' => 'Estado', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Transacción', @@ -165,7 +165,7 @@ 'message' => 'al momento de hacer el pedido: :count', ], 'status' => [ - 'label' => 'Estado', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Referencia', diff --git a/packages/admin/resources/lang/es/product.php b/packages/admin/resources/lang/es/product.php index d1324e2f51..ad7acb6442 100644 --- a/packages/admin/resources/lang/es/product.php +++ b/packages/admin/resources/lang/es/product.php @@ -7,11 +7,9 @@ 'all' => 'Todo', 'published' => 'Published', 'draft' => 'Draft', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Actualmente en estado de borrador, este producto no está disponible en todos los canales y grupos de clientes.', - ], 'availability' => [ 'customer_groups' => 'Este producto actualmente no está disponible para todos los grupos de clientes.', 'channels' => 'Este producto actualmente no está disponible para todos los canales.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Actualizar Estado', + 'label_with_state' => 'Status: :state', 'heading' => 'Actualizar Estado', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Borrador', 'description' => 'Este producto estará oculto en todos los canales y grupos de clientes', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/fa/channel.php b/packages/admin/resources/lang/fa/channel.php index ba182170bd..a3a8889d7f 100644 --- a/packages/admin/resources/lang/fa/channel.php +++ b/packages/admin/resources/lang/fa/channel.php @@ -31,4 +31,12 @@ 'label' => 'Default', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/fa/order.php b/packages/admin/resources/lang/fa/order.php index 5d09a874a6..835f2d3c25 100644 --- a/packages/admin/resources/lang/fa/order.php +++ b/packages/admin/resources/lang/fa/order.php @@ -103,7 +103,7 @@ 'label' => 'Reference', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Transaction', @@ -165,7 +165,7 @@ 'message' => 'at time of ordering: :count', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Reference', diff --git a/packages/admin/resources/lang/fa/product.php b/packages/admin/resources/lang/fa/product.php index b0cf6a8113..98e8da64db 100644 --- a/packages/admin/resources/lang/fa/product.php +++ b/packages/admin/resources/lang/fa/product.php @@ -7,11 +7,9 @@ 'all' => 'All', 'published' => 'Published', 'draft' => 'Draft', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'در حال حاضر در وضعیت پیش‌نویس، این محصول در همه کانال‌ها و گروه‌های مشتری در دسترس نیست.', - ], 'availability' => [ 'customer_groups' => 'این محصول در حال حاضر برای همه گروه‌های مشتری در دسترس نیست.', 'channels' => 'این محصول در حال حاضر برای همه کانال‌ها در دسترس نیست.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Update Status', + 'label_with_state' => 'Status: :state', 'heading' => 'Update Status', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Draft', 'description' => 'This product will be hidden across all channels and customer groups', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/fr/channel.php b/packages/admin/resources/lang/fr/channel.php index 2f3f863147..094f98b145 100644 --- a/packages/admin/resources/lang/fr/channel.php +++ b/packages/admin/resources/lang/fr/channel.php @@ -31,4 +31,12 @@ 'label' => 'Par défaut', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/fr/order.php b/packages/admin/resources/lang/fr/order.php index 412f4203ba..5c80ca79bd 100644 --- a/packages/admin/resources/lang/fr/order.php +++ b/packages/admin/resources/lang/fr/order.php @@ -103,7 +103,7 @@ 'label' => 'Référence', ], 'status' => [ - 'label' => 'Statut', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Transaction', @@ -165,7 +165,7 @@ 'message' => 'au moment de la commande : :count', ], 'status' => [ - 'label' => 'Statut', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Référence', diff --git a/packages/admin/resources/lang/fr/product.php b/packages/admin/resources/lang/fr/product.php index 5a7484aa23..1c0fb07271 100644 --- a/packages/admin/resources/lang/fr/product.php +++ b/packages/admin/resources/lang/fr/product.php @@ -7,11 +7,9 @@ 'all' => 'Tous', 'published' => 'Published', 'draft' => 'Draft', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Actuellement en statut de brouillon, ce produit est indisponible sur tous les canaux et groupes de clients.', - ], 'availability' => [ 'customer_groups' => 'Ce produit est actuellement indisponible pour tous les groupes de clients.', 'channels' => 'Ce produit est actuellement indisponible pour tous les canaux.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Mettre à jour le statut', + 'label_with_state' => 'Status: :state', 'heading' => 'Mettre à jour le statut', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Brouillon', 'description' => 'Ce produit sera masqué sur tous les canaux et groupes de clients', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/hr/channel.php b/packages/admin/resources/lang/hr/channel.php index 4b313fbef5..288cf29176 100644 --- a/packages/admin/resources/lang/hr/channel.php +++ b/packages/admin/resources/lang/hr/channel.php @@ -31,4 +31,12 @@ 'label' => 'Zadano', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/hr/order.php b/packages/admin/resources/lang/hr/order.php index a076f633ec..91f04b69e9 100644 --- a/packages/admin/resources/lang/hr/order.php +++ b/packages/admin/resources/lang/hr/order.php @@ -103,7 +103,7 @@ 'label' => 'Referenca', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Transakcija', @@ -165,7 +165,7 @@ 'message' => 'U trenutku narudžbe: :count', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Referenca', diff --git a/packages/admin/resources/lang/hr/product.php b/packages/admin/resources/lang/hr/product.php index 9e97b65640..4a44a11064 100644 --- a/packages/admin/resources/lang/hr/product.php +++ b/packages/admin/resources/lang/hr/product.php @@ -7,11 +7,9 @@ 'all' => 'All', 'published' => 'Published', 'draft' => 'Draft', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Trenutno u statusu skice, ovaj proizvod nije dostupan ni u jednom kanalu ni u jednoj grupi kupaca.', - ], 'availability' => [ 'customer_groups' => 'Ovaj proizvod trenutno nije dostupan nijednoj grupi kupaca.', 'channels' => 'Ovaj proizvod trenutno nije dostupan nijednom kanalu.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Ažuriraj status', + 'label_with_state' => 'Status: :state', 'heading' => 'Ažuriraj status', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Skica', 'description' => 'Ovaj proizvod bit će skriven u svim kanalima i grupama kupaca', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/hu/channel.php b/packages/admin/resources/lang/hu/channel.php index 9bda521ff8..0e247107ee 100644 --- a/packages/admin/resources/lang/hu/channel.php +++ b/packages/admin/resources/lang/hu/channel.php @@ -31,4 +31,12 @@ 'label' => 'Alapértelmezett', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/hu/order.php b/packages/admin/resources/lang/hu/order.php index 01d2c7169f..fd13a8d882 100644 --- a/packages/admin/resources/lang/hu/order.php +++ b/packages/admin/resources/lang/hu/order.php @@ -103,7 +103,7 @@ 'label' => 'Hivatkozás', ], 'status' => [ - 'label' => 'Státusz', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Tranzakció', @@ -165,7 +165,7 @@ 'message' => 'rendelés idején: :count', ], 'status' => [ - 'label' => 'Státusz', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Hivatkozás', diff --git a/packages/admin/resources/lang/hu/product.php b/packages/admin/resources/lang/hu/product.php index 83d0349926..6402902c52 100644 --- a/packages/admin/resources/lang/hu/product.php +++ b/packages/admin/resources/lang/hu/product.php @@ -7,11 +7,9 @@ 'all' => 'Mind', 'published' => 'Published', 'draft' => 'Draft', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Jelenleg vázlat státuszban van, ez a termék egyik csatornán és vásárlói csoportban sem érhető el.', - ], 'availability' => [ 'customer_groups' => 'Ez a termék jelenleg nem elérhető egyik vásárlói csoport számára sem.', 'channels' => 'Ez a termék jelenleg nem elérhető egyik csatornán sem.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Státusz frissítése', + 'label_with_state' => 'Status: :state', 'heading' => 'Státusz frissítése', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Vázlat', 'description' => 'Ez a termék rejtett lesz minden csatornán és vásárlói csoportban', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/mn/channel.php b/packages/admin/resources/lang/mn/channel.php index 3dca9cbe2e..224ce0e1d9 100644 --- a/packages/admin/resources/lang/mn/channel.php +++ b/packages/admin/resources/lang/mn/channel.php @@ -31,4 +31,12 @@ 'label' => 'Өгөгдмөл', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/mn/order.php b/packages/admin/resources/lang/mn/order.php index e6e67f0df1..a93569d69a 100644 --- a/packages/admin/resources/lang/mn/order.php +++ b/packages/admin/resources/lang/mn/order.php @@ -103,7 +103,7 @@ 'label' => 'Лавлагаа', ], 'status' => [ - 'label' => 'Статус', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Гүйлгээ', @@ -165,7 +165,7 @@ 'message' => 'захиалга өгсөн үед: :count', ], 'status' => [ - 'label' => 'Статус', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Лавлагаа', diff --git a/packages/admin/resources/lang/mn/product.php b/packages/admin/resources/lang/mn/product.php index fe084cd31d..f178ced7a7 100644 --- a/packages/admin/resources/lang/mn/product.php +++ b/packages/admin/resources/lang/mn/product.php @@ -7,11 +7,9 @@ 'all' => 'Бүгд', 'published' => 'Нийтлэгдсэн', 'draft' => 'Ноорог', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Одоогоор ноорог статустай, энэ бүтээгдэхүүн бүх сувгууд болон харилцагчийн бүлгүүдэд боломжгүй байна.', - ], 'availability' => [ 'customer_groups' => 'Энэ бүтээгдэхүүн одоогоор бүх харилцагчийн бүлгүүдэд боломжгүй байна.', 'channels' => 'Энэ бүтээгдэхүүн одоогоор бүх сувгууд боломжгүй байна.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Статус шинэчлэх', + 'label_with_state' => 'Status: :state', 'heading' => 'Статус шинэчлэх', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Ноорог', 'description' => 'Энэ бүтээгдэхүүн бүх сувгууд болон харилцагчийн бүлгүүдэд нуугдсан байна', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/nl/channel.php b/packages/admin/resources/lang/nl/channel.php index 529fedd374..b1ba9fb583 100644 --- a/packages/admin/resources/lang/nl/channel.php +++ b/packages/admin/resources/lang/nl/channel.php @@ -31,4 +31,12 @@ 'label' => 'Standaard', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/nl/order.php b/packages/admin/resources/lang/nl/order.php index 3f732f0667..f569e82ef7 100644 --- a/packages/admin/resources/lang/nl/order.php +++ b/packages/admin/resources/lang/nl/order.php @@ -103,7 +103,7 @@ 'label' => 'Referentie', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Transactie', @@ -165,7 +165,7 @@ 'message' => 'op het moment van bestelling: :count', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Referentie', diff --git a/packages/admin/resources/lang/nl/product.php b/packages/admin/resources/lang/nl/product.php index da645293bf..081279e500 100644 --- a/packages/admin/resources/lang/nl/product.php +++ b/packages/admin/resources/lang/nl/product.php @@ -7,11 +7,9 @@ 'all' => 'Allemaal', 'published' => 'Published', 'draft' => 'Draft', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Momenteel in conceptstatus, dit product is niet beschikbaar op alle kanalen en klantgroepen.', - ], 'availability' => [ 'customer_groups' => 'Dit product is momenteel niet beschikbaar voor alle klantgroepen.', 'channels' => 'Dit product is momenteel niet beschikbaar voor alle kanalen.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Status Bijwerken', + 'label_with_state' => 'Status: :state', 'heading' => 'Status Bijwerken', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Concept', 'description' => 'Dit product zal verborgen zijn op alle kanalen en klantgroepen', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/pl/channel.php b/packages/admin/resources/lang/pl/channel.php index 3f256c1be5..fa6cbd2756 100644 --- a/packages/admin/resources/lang/pl/channel.php +++ b/packages/admin/resources/lang/pl/channel.php @@ -31,4 +31,12 @@ 'label' => 'Domyślny', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/pl/order.php b/packages/admin/resources/lang/pl/order.php index ab78240979..c3f84cd9c3 100644 --- a/packages/admin/resources/lang/pl/order.php +++ b/packages/admin/resources/lang/pl/order.php @@ -103,7 +103,7 @@ 'label' => 'Numer zamówienia', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Transakcja', @@ -165,7 +165,7 @@ 'message' => 'w momencie zamówienia: :count', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Numer zamówienia', diff --git a/packages/admin/resources/lang/pl/product.php b/packages/admin/resources/lang/pl/product.php index 3458b261cd..b953221126 100644 --- a/packages/admin/resources/lang/pl/product.php +++ b/packages/admin/resources/lang/pl/product.php @@ -7,11 +7,9 @@ 'all' => 'Wszystkie', 'published' => 'Published', 'draft' => 'Draft', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Produkt jest obecnie w trybie szkicu i jest niedostępny we wszystkich kanałach i grupach klientów.', - ], 'availability' => [ 'customer_groups' => 'Produkt jest obecnie niedostępny dla wszystkich grup klientów.', 'channels' => 'Produkt jest obecnie niedostępny dla wszystkich kanałów.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Edytuj status', + 'label_with_state' => 'Status: :state', 'heading' => 'Edytuj status produktu', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Szkic', 'description' => 'Ten produkt jest ukryty we wszystkich kanałach i grupach klientów', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/pt_BR/channel.php b/packages/admin/resources/lang/pt_BR/channel.php index 97ef58abd6..e487ba55c7 100644 --- a/packages/admin/resources/lang/pt_BR/channel.php +++ b/packages/admin/resources/lang/pt_BR/channel.php @@ -31,4 +31,12 @@ 'label' => 'Padrão', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/pt_BR/order.php b/packages/admin/resources/lang/pt_BR/order.php index 1b5f3dbf39..0f3ad5a190 100644 --- a/packages/admin/resources/lang/pt_BR/order.php +++ b/packages/admin/resources/lang/pt_BR/order.php @@ -103,7 +103,7 @@ 'label' => 'Referência', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Transação', @@ -165,7 +165,7 @@ 'message' => 'no momento do pedido: :count', ], 'status' => [ - 'label' => 'Status', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Referência', diff --git a/packages/admin/resources/lang/pt_BR/product.php b/packages/admin/resources/lang/pt_BR/product.php index 7814454446..b94af83b1b 100644 --- a/packages/admin/resources/lang/pt_BR/product.php +++ b/packages/admin/resources/lang/pt_BR/product.php @@ -7,11 +7,9 @@ 'all' => 'Todos', 'published' => 'Published', 'draft' => 'Draft', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Atualmente em rascunho, este produto está indisponível em todos os canais e grupos de clientes.', - ], 'availability' => [ 'customer_groups' => 'Este produto está indisponível para todos os grupos de clientes.', 'channels' => 'Este produto está indisponível para todos os canais.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Atualizar status', + 'label_with_state' => 'Status: :state', 'heading' => 'Atualizar status', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Rascunho', 'description' => 'Este produto ficará oculto em todos os canais e grupos de clientes', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/ro/channel.php b/packages/admin/resources/lang/ro/channel.php index 6177e40e6d..7ab9798c5d 100644 --- a/packages/admin/resources/lang/ro/channel.php +++ b/packages/admin/resources/lang/ro/channel.php @@ -31,4 +31,12 @@ 'label' => 'Implicit', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/ro/order.php b/packages/admin/resources/lang/ro/order.php index 4a7b96c234..c1b9ff5f4f 100644 --- a/packages/admin/resources/lang/ro/order.php +++ b/packages/admin/resources/lang/ro/order.php @@ -103,7 +103,7 @@ 'label' => 'Referință', ], 'status' => [ - 'label' => 'Stare', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Tranzacție', @@ -165,7 +165,7 @@ 'message' => 'la momentul comenzii: :count', ], 'status' => [ - 'label' => 'Stare', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Referință', diff --git a/packages/admin/resources/lang/ro/product.php b/packages/admin/resources/lang/ro/product.php index d3767dcf29..dd8e2bf8f1 100644 --- a/packages/admin/resources/lang/ro/product.php +++ b/packages/admin/resources/lang/ro/product.php @@ -7,11 +7,9 @@ 'all' => 'Toate', 'published' => 'Published', 'draft' => 'Draft', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'În prezent în stadiu de ciornă, acest produs este indisponibil în toate canalele și grupurile de clienți.', - ], 'availability' => [ 'customer_groups' => 'Acest produs nu este disponibil momentan pentru niciun grup de clienți.', 'channels' => 'Acest produs nu este disponibil momentan în niciun canal.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Actualizează starea', + 'label_with_state' => 'Status: :state', 'heading' => 'Actualizează starea', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Ciornă', 'description' => 'Acest produs va fi ascuns în toate canalele și grupurile de clienți', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/tr/channel.php b/packages/admin/resources/lang/tr/channel.php index 27a6d2cd58..26be248441 100644 --- a/packages/admin/resources/lang/tr/channel.php +++ b/packages/admin/resources/lang/tr/channel.php @@ -31,4 +31,12 @@ 'label' => 'Varsayılan', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/tr/order.php b/packages/admin/resources/lang/tr/order.php index a9fb1eb0fb..0d4b12a0f2 100644 --- a/packages/admin/resources/lang/tr/order.php +++ b/packages/admin/resources/lang/tr/order.php @@ -103,7 +103,7 @@ 'label' => 'Referans', ], 'status' => [ - 'label' => 'Durum', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'İşlem', @@ -165,7 +165,7 @@ 'message' => 'sipariş anında: :count', ], 'status' => [ - 'label' => 'Durum', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Referans', diff --git a/packages/admin/resources/lang/tr/product.php b/packages/admin/resources/lang/tr/product.php index 3e87f68f76..81d4cfbcec 100644 --- a/packages/admin/resources/lang/tr/product.php +++ b/packages/admin/resources/lang/tr/product.php @@ -7,11 +7,9 @@ 'all' => 'Tümü', 'published' => 'Published', 'draft' => 'Draft', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Şu anda taslak durumunda olan bu ürün, tüm kanallarda ve müşteri gruplarında kullanılamaz.', - ], 'availability' => [ 'customer_groups' => 'Bu ürün şu anda tüm müşteri grupları için mevcut değil.', 'channels' => 'Bu ürün şu anda tüm kanallar için mevcut değil.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Durumu Güncelle', + 'label_with_state' => 'Status: :state', 'heading' => 'Durumu Güncelle', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Taslak', 'description' => 'Bu ürün tüm kanallarda ve müşteri gruplarında gizlenecek', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/resources/lang/vi/channel.php b/packages/admin/resources/lang/vi/channel.php index d1caf5dcf9..ed63b8f965 100644 --- a/packages/admin/resources/lang/vi/channel.php +++ b/packages/admin/resources/lang/vi/channel.php @@ -31,4 +31,12 @@ 'label' => 'Mặc định', ], ], + 'actions' => [ + 'delete' => [ + 'confirm' => 'This permanently deletes the channel and cannot be undone. If you want to stop using it without losing it, mark it Inactive instead.', + 'blocked' => 'This channel has orders associated with it and cannot be deleted — mark it Inactive instead so historical orders keep their context.', + 'disabled_tooltip' => 'Channels with order history can\'t be deleted. Mark Inactive instead.', + ], + ], + ]; diff --git a/packages/admin/resources/lang/vi/order.php b/packages/admin/resources/lang/vi/order.php index 7658b3db48..33fd1c8a4c 100644 --- a/packages/admin/resources/lang/vi/order.php +++ b/packages/admin/resources/lang/vi/order.php @@ -103,7 +103,7 @@ 'label' => 'Mã tham chiếu', ], 'status' => [ - 'label' => 'Trạng thái', + 'label' => 'Order', ], 'transaction' => [ 'label' => 'Giao dịch', @@ -165,7 +165,7 @@ 'message' => 'tại thời điểm đặt hàng: :count', ], 'status' => [ - 'label' => 'Trạng thái', + 'label' => 'Order', ], 'reference' => [ 'label' => 'Mã tham chiếu', diff --git a/packages/admin/resources/lang/vi/product.php b/packages/admin/resources/lang/vi/product.php index 60b1824e27..c2abe22c46 100644 --- a/packages/admin/resources/lang/vi/product.php +++ b/packages/admin/resources/lang/vi/product.php @@ -7,11 +7,9 @@ 'all' => 'Tất cả', 'published' => 'Published', 'draft' => 'Draft', + 'archived' => 'Archived', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Hiện đang ở trạng thái nháp, sản phẩm này không khả dụng trên tất cả các kênh và nhóm khách hàng.', - ], 'availability' => [ 'customer_groups' => 'Sản phẩm này hiện không có sẵn cho tất cả các nhóm khách hàng.', 'channels' => 'Sản phẩm này hiện không có sẵn trên tất cả các kênh.', @@ -47,8 +45,14 @@ 'actions' => [ 'edit_status' => [ 'label' => 'Cập nhật trạng thái', + 'label_with_state' => 'Status: :state', 'heading' => 'Cập nhật trạng thái', ], + 'delete' => [ + 'confirm' => 'This permanently deletes the product and cannot be undone. If you want to hide it from the storefront without losing it, archive it instead.', + 'blocked' => 'This product has appeared on past orders and cannot be deleted — archive it instead so historical orders keep their reference.', + 'disabled_tooltip' => 'Products with order history can\'t be deleted. Archive instead.', + ], ], 'form' => [ 'name' => [ @@ -74,6 +78,10 @@ 'label' => 'Bản nháp', 'description' => 'Sản phẩm này sẽ bị ẩn trên tất cả các kênh và nhóm khách hàng', ], + 'archived' => [ + 'label' => 'Archived', + 'description' => 'This product is retired but still referenced by historical orders. Move back to Draft to revive it.', + ], ], ], 'tags' => [ diff --git a/packages/admin/src/Filament/Resources/ChannelResource/Pages/EditChannel.php b/packages/admin/src/Filament/Resources/ChannelResource/Pages/EditChannel.php index 405075030c..4f768dbf87 100644 --- a/packages/admin/src/Filament/Resources/ChannelResource/Pages/EditChannel.php +++ b/packages/admin/src/Filament/Resources/ChannelResource/Pages/EditChannel.php @@ -5,6 +5,7 @@ use Filament\Actions\DeleteAction; use Lunar\Admin\Filament\Resources\ChannelResource; use Lunar\Admin\Support\Pages\BaseEditRecord; +use Lunar\Core\Models\Channel; class EditChannel extends BaseEditRecord { @@ -13,7 +14,14 @@ class EditChannel extends BaseEditRecord protected function getDefaultHeaderActions(): array { return [ - DeleteAction::make(), + DeleteAction::make() + ->modalDescription(fn (Channel $record): string => $record->hasOrderHistory() + ? __('lunarpanel::channel.actions.delete.blocked') + : __('lunarpanel::channel.actions.delete.confirm')) + ->disabled(fn (Channel $record): bool => $record->hasOrderHistory()) + ->tooltip(fn (Channel $record): ?string => $record->hasOrderHistory() + ? __('lunarpanel::channel.actions.delete.disabled_tooltip') + : null), ]; } diff --git a/packages/admin/src/Filament/Resources/OrderResource.php b/packages/admin/src/Filament/Resources/OrderResource.php index bc11d14059..3939920bbf 100644 --- a/packages/admin/src/Filament/Resources/OrderResource.php +++ b/packages/admin/src/Filament/Resources/OrderResource.php @@ -51,7 +51,7 @@ public static function getNavigationGroup(): ?string public static function getNavigationBadge(): ?string { - return static::getModel()::whereIn('status', config('lunar.panel.order_count_statuses', 'payment-received'))->count(); + return static::getModel()::whereIn('status', config('lunar.panel.order_count_statuses', ['in-process']))->count(); } public static function table(Table $table): Table diff --git a/packages/admin/src/Filament/Resources/OrderResource/Concerns/DisplaysOrderSummary.php b/packages/admin/src/Filament/Resources/OrderResource/Concerns/DisplaysOrderSummary.php index 5b3619d501..525106001f 100644 --- a/packages/admin/src/Filament/Resources/OrderResource/Concerns/DisplaysOrderSummary.php +++ b/packages/admin/src/Filament/Resources/OrderResource/Concerns/DisplaysOrderSummary.php @@ -27,9 +27,9 @@ public static function getDefaultOrderSummaryStatusEntry(): TextEntry { return TextEntry::make('status') ->label(__('lunarpanel::order.infolist.status.label')) - ->formatStateUsing(fn ($state) => OrderStatus::getLabel($state)) + ->formatStateUsing(fn ($state) => OrderStatus::getLabel((string) $state)) ->alignEnd() - ->color(fn ($state) => OrderStatus::getColor($state)) + ->color(fn ($state) => OrderStatus::getColor((string) $state)) ->badge(); } diff --git a/packages/admin/src/Filament/Resources/OrderResource/Pages/EditOrder.php b/packages/admin/src/Filament/Resources/OrderResource/Pages/EditOrder.php index e89a44fbc6..b872408d4a 100644 --- a/packages/admin/src/Filament/Resources/OrderResource/Pages/EditOrder.php +++ b/packages/admin/src/Filament/Resources/OrderResource/Pages/EditOrder.php @@ -9,6 +9,7 @@ use Filament\Notifications\Notification; use Lunar\Admin\Filament\Resources\OrderResource; use Lunar\Admin\Support\Pages\BaseEditRecord; +use Lunar\Core\Contracts\OrderStateConfig; class EditOrder extends BaseEditRecord { @@ -25,9 +26,9 @@ protected function getDefaultHeaderActions(): array ->schema([ Select::make('status') ->label(__('lunarpanel::order.form.status.label')) - ->default($this->record->status) - ->options(fn () => collect(config('lunar.orders.statuses', [])) - ->mapWithKeys(fn ($data, $status) => [$status => $data['label']])) + ->default((string) $this->record->status) + ->options(fn () => collect(app(OrderStateConfig::class)->orderStates()) + ->mapWithKeys(fn (string $class) => [$class::$name => (new $class($this->record))->label()])) ->required(), Placeholder::make('additional content and mailer'), ]) diff --git a/packages/admin/src/Filament/Resources/OrderResource/Pages/ListOrders.php b/packages/admin/src/Filament/Resources/OrderResource/Pages/ListOrders.php index 893a5145db..944ca83fd1 100644 --- a/packages/admin/src/Filament/Resources/OrderResource/Pages/ListOrders.php +++ b/packages/admin/src/Filament/Resources/OrderResource/Pages/ListOrders.php @@ -7,6 +7,9 @@ use Illuminate\Database\Eloquent\Builder; use Lunar\Admin\Filament\Resources\OrderResource; use Lunar\Admin\Support\Pages\BaseListRecords; +use Lunar\Core\Contracts\OrderStateConfig; +use Lunar\Core\Models\Order; +use Lunar\Core\States\Order\OrderState; class ListOrders extends BaseListRecords { @@ -21,18 +24,15 @@ protected function getDefaultHeaderActions(): array public function getDefaultTabs(): array { - $statuses = collect( - config('lunar.orders.statuses', []) - )->filter( - fn ($config) => $config['favourite'] ?? false - ); + $orderStates = app(OrderStateConfig::class)->orderStates(); return [ 'all' => Tab::make(__('lunarpanel::order.tabs.all')), - ...collect($statuses)->mapWithKeys( - fn ($config, $status) => [ - $status => Tab::make($config['label']) - ->modifyQueryUsing(fn (Builder $query) => $query->where('status', $status)), + ...collect($orderStates)->mapWithKeys( + /** @param class-string $class */ + fn (string $class) => [ + $class::$name => Tab::make((new $class(new Order))->label()) + ->modifyQueryUsing(fn (Builder $query) => $query->where('status', $class::$name)), ] ), ]; diff --git a/packages/admin/src/Filament/Resources/OrderResource/Pages/ManageOrder.php b/packages/admin/src/Filament/Resources/OrderResource/Pages/ManageOrder.php index 8f0cc0a510..ad9260c6b6 100644 --- a/packages/admin/src/Filament/Resources/OrderResource/Pages/ManageOrder.php +++ b/packages/admin/src/Filament/Resources/OrderResource/Pages/ManageOrder.php @@ -33,10 +33,12 @@ use Lunar\Admin\Support\Pages\BaseViewRecord; use Lunar\Core\Models\Order; use Lunar\Core\Models\Tag; +use Lunar\Filament\Actions\Orders\CancelOrderAction; use Lunar\Filament\Actions\Orders\CaptureOrderAction; use Lunar\Filament\Actions\Orders\DownloadOrderPdfAction; +use Lunar\Filament\Actions\Orders\PlaceOrderOnHoldAction; use Lunar\Filament\Actions\Orders\RefundOrderAction; -use Lunar\Filament\Actions\Orders\UpdateOrderStatusAction; +use Lunar\Filament\Actions\Orders\ResumeOrderAction; use Lunar\Filament\Forms\Components\Tags as TagsComponent; use Lunar\Filament\Infolists\Components\Tags; use Lunar\Filament\Support\Concerns\CallsHooks; @@ -349,13 +351,14 @@ public static function getEditTagsActions(): Action protected function getDefaultHeaderActions(): array { + $bumpActivity = fn () => $this->dispatchActivityUpdated(); + return [ CaptureOrderAction::make(), RefundOrderAction::make(), - UpdateOrderStatusAction::make() - ->after(function () { - $this->dispatchActivityUpdated(); - }), + PlaceOrderOnHoldAction::make()->after($bumpActivity), + ResumeOrderAction::make()->after($bumpActivity), + CancelOrderAction::make()->after($bumpActivity), DownloadOrderPdfAction::make(), ]; } diff --git a/packages/admin/src/Filament/Resources/ProductResource/Pages/EditProduct.php b/packages/admin/src/Filament/Resources/ProductResource/Pages/EditProduct.php index fc33226de0..a35fc88e74 100644 --- a/packages/admin/src/Filament/Resources/ProductResource/Pages/EditProduct.php +++ b/packages/admin/src/Filament/Resources/ProductResource/Pages/EditProduct.php @@ -4,12 +4,11 @@ use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; -use Filament\Actions\ForceDeleteAction; -use Filament\Actions\RestoreAction; use Filament\Forms\Components\Radio; use Filament\Support\Facades\FilamentIcon; use Lunar\Admin\Filament\Resources\ProductResource; use Lunar\Admin\Support\Pages\BaseEditRecord; +use Lunar\Core\Models\Product; class EditProduct extends BaseEditRecord { @@ -36,31 +35,64 @@ protected function getDefaultHeaderActions(): array { return [ EditAction::make('update_status') - ->label( - __('lunarpanel::product.actions.edit_status.label') - ) - ->modalHeading( - __('lunarpanel::product.actions.edit_status.heading') - ) - ->record( - $this->record - )->schema([ - Radio::make('status')->options([ - 'published' => __('lunarpanel::product.form.status.options.published.label'), - 'draft' => __('lunarpanel::product.form.status.options.draft.label'), - ]) - ->descriptions([ - 'published' => __('lunarpanel::product.form.status.options.published.description'), - 'draft' => __('lunarpanel::product.form.status.options.draft.description'), - ])->live(), + ->label(fn (Product $record) => __('lunarpanel::product.actions.edit_status.label_with_state', [ + 'state' => $record->status->label(), + ])) + ->color(fn (Product $record) => match ((string) $record->status) { + 'published' => 'success', + 'archived' => 'gray', + default => 'warning', + }) + ->modalHeading(__('lunarpanel::product.actions.edit_status.heading')) + ->record($this->record) + ->schema([ + Radio::make('status') + ->options(fn (Product $record) => static::statusOptionsFor($record)) + ->descriptions(fn (Product $record) => static::statusDescriptionsFor($record)) + ->live(), ]), - DeleteAction::make(), - ForceDeleteAction::make() - ->databaseTransaction(), - RestoreAction::make(), + DeleteAction::make() + ->modalDescription(fn (Product $record): string => $record->hasOrderHistory() + ? __('lunarpanel::product.actions.delete.blocked') + : __('lunarpanel::product.actions.delete.confirm')) + ->disabled(fn (Product $record): bool => $record->hasOrderHistory()) + ->tooltip(fn (Product $record): ?string => $record->hasOrderHistory() + ? __('lunarpanel::product.actions.delete.disabled_tooltip') + : null), ]; } + /** + * Status options keyed by ProductState $name. Restricted to the current + * state plus its allowed transitions, so the form can't pick an illegal + * transition (e.g. Draft → Archived directly). + * + * @return array + */ + protected static function statusOptionsFor(Product $record): array + { + return collect($record->status->transitionableStates()) + ->prepend((string) $record->status) + ->unique() + ->mapWithKeys(fn (string $name) => [ + $name => __("lunarpanel::product.form.status.options.{$name}.label"), + ]) + ->all(); + } + + /** + * @return array + */ + protected static function statusDescriptionsFor(Product $record): array + { + return collect(static::statusOptionsFor($record)) + ->keys() + ->mapWithKeys(fn (string $name) => [ + $name => __("lunarpanel::product.form.status.options.{$name}.description"), + ]) + ->all(); + } + public function getRelationManagers(): array { return []; diff --git a/packages/admin/src/Filament/Resources/ProductResource/Pages/ListProducts.php b/packages/admin/src/Filament/Resources/ProductResource/Pages/ListProducts.php index e056e7fb14..9fd1aac168 100644 --- a/packages/admin/src/Filament/Resources/ProductResource/Pages/ListProducts.php +++ b/packages/admin/src/Filament/Resources/ProductResource/Pages/ListProducts.php @@ -80,6 +80,8 @@ public function getDefaultTabs(): array 'draft' => Tab::make(__('lunarpanel::product.tabs.draft')) ->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'draft')) ->badge(Product::query()->where('status', 'draft')->count()), + 'archived' => Tab::make(__('lunarpanel::product.tabs.archived')) + ->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'archived')), ]; } diff --git a/packages/admin/src/Support/Pages/BaseListRecords.php b/packages/admin/src/Support/Pages/BaseListRecords.php index b354c0051c..da78c4f7a4 100644 --- a/packages/admin/src/Support/Pages/BaseListRecords.php +++ b/packages/admin/src/Support/Pages/BaseListRecords.php @@ -3,7 +3,6 @@ namespace Lunar\Admin\Support\Pages; use Filament\Resources\Pages\ListRecords; -use Filament\Tables\Filters\TrashedFilter; use Illuminate\Contracts\Pagination\CursorPaginator; use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Contracts\Support\Htmlable; @@ -98,15 +97,8 @@ protected function applySearchToTableQuery(Builder $query): Builder $scoutEnabled && $isScoutSearchable ) { - $trashedFilter = collect($this->getTable()->getFilters()) - ->firstWhere(fn ($filter) => $filter instanceof TrashedFilter); - $scoutQuery = static::getModel()::search($search); - if (filled($state = $trashedFilter?->getState()['value'] ?? null)) { - $state ? $scoutQuery->withTrashed() : $scoutQuery->onlyTrashed(); - } - $ids = collect($scoutQuery->take(100)->keys())->map( fn ($result) => str_replace(static::getModel().'::', '', $result) ); diff --git a/packages/core/composer.json b/packages/core/composer.json index 275c0ecdb3..71a73e62f1 100644 --- a/packages/core/composer.json +++ b/packages/core/composer.json @@ -38,7 +38,7 @@ } }, "require": { - "php": "^8.3", + "php": "^8.4", "ext-bcmath": "*", "ext-exif": "*", "ext-intl": "*", @@ -50,6 +50,7 @@ "lukascivil/treewalker": "0.9.1", "spatie/php-structure-discoverer": "^2.3.1", "spatie/laravel-blink": "^1.7.1", - "spatie/laravel-permission": "^6.12" + "spatie/laravel-permission": "^6.12", + "spatie/laravel-model-states": "^2.11" } } diff --git a/packages/core/config/orders.php b/packages/core/config/orders.php index c5992f9de5..2e78049200 100644 --- a/packages/core/config/orders.php +++ b/packages/core/config/orders.php @@ -56,49 +56,22 @@ /* |-------------------------------------------------------------------------- - | Draft Status + | Order Status Notifications |-------------------------------------------------------------------------- | - | When a draft order is created from a cart, we need an initial status for - | the order that's created. Define that here, it can be anything that would - | make sense for the store you're building. + | Notifications to dispatch when an order transitions into a given + | status. Keyed by the OrderState $name (e.g. 'shipped'), each + | entry is a list of notification class names. Each notification is + | instantiated with the Order and delivered via `$order->notify()`. + | + | Read by DefaultOrderStateConfig::notificationsFor(); swap that binding + | to source notifications from anywhere else. | */ - 'draft_status' => 'awaiting-payment', - - 'statuses' => [ - - 'awaiting-payment' => [ - 'label' => 'Awaiting Payment', - 'color' => '#848a8c', - 'mailers' => [], - 'notifications' => [], - 'favourite' => true, - ], - - 'payment-offline' => [ - 'label' => 'Payment Offline', - 'color' => '#0A81D7', - 'mailers' => [], - 'notifications' => [], - 'favourite' => true, - ], - - 'payment-received' => [ - 'label' => 'Payment Received', - 'color' => '#6a67ce', - 'mailers' => [], - 'notifications' => [], - 'favourite' => true, - ], - - 'dispatched' => [ - 'label' => 'Dispatched', - 'mailers' => [], - 'notifications' => [], - 'favourite' => true, - ], - + 'notifications' => [ + // 'shipped' => [ + // App\Notifications\OrderShipped::class, + // ], ], /* diff --git a/packages/core/config/payments.php b/packages/core/config/payments.php index b3d728a841..83f45a22f2 100644 --- a/packages/core/config/payments.php +++ b/packages/core/config/payments.php @@ -7,7 +7,7 @@ 'types' => [ 'cash-in-hand' => [ 'driver' => 'offline', - 'authorized' => 'payment-offline', + 'authorized' => 'captured', ], ], diff --git a/packages/core/database/factories/OrderFactory.php b/packages/core/database/factories/OrderFactory.php index 13c140ee8e..0a1f84ddd3 100644 --- a/packages/core/database/factories/OrderFactory.php +++ b/packages/core/database/factories/OrderFactory.php @@ -34,4 +34,29 @@ public function definition(): array 'meta' => ['foo' => 'bar'], ]; } + + public function awaitingPayment(): static + { + return $this->state(fn () => ['status' => 'awaiting-payment']); + } + + public function inProcess(): static + { + return $this->state(fn () => ['status' => 'in-process']); + } + + public function shipped(): static + { + return $this->state(fn () => ['status' => 'shipped']); + } + + public function complete(): static + { + return $this->state(fn () => ['status' => 'complete']); + } + + public function cancelled(): static + { + return $this->state(fn () => ['status' => 'cancelled']); + } } diff --git a/packages/core/database/migrations/2026_01_01_000003_create_channels_table.php b/packages/core/database/migrations/2026_01_01_000003_create_channels_table.php index 9b20c24503..c2e0fc3cc0 100644 --- a/packages/core/database/migrations/2026_01_01_000003_create_channels_table.php +++ b/packages/core/database/migrations/2026_01_01_000003_create_channels_table.php @@ -14,8 +14,8 @@ public function up(): void $table->string('handle')->unique(); $table->boolean('default')->default(false)->index(); $table->string('url')->nullable(); + $table->string('status')->default('active')->index(); $table->timestamps(); - $table->softDeletes(); }); } diff --git a/packages/core/database/migrations/2026_01_01_000021_create_collections_table.php b/packages/core/database/migrations/2026_01_01_000021_create_collections_table.php index 7ac6725efe..e57cfe554d 100644 --- a/packages/core/database/migrations/2026_01_01_000021_create_collections_table.php +++ b/packages/core/database/migrations/2026_01_01_000021_create_collections_table.php @@ -18,8 +18,8 @@ public function up(): void $table->jsonb('short_description')->nullable(); $table->jsonb('attribute_data')->nullable(); $table->string('sort')->default('custom')->index(); + $table->string('status')->default('draft')->index(); $table->timestamps(); - $table->softDeletes(); }); } diff --git a/packages/core/database/migrations/2026_01_01_000028_create_orders_table.php b/packages/core/database/migrations/2026_01_01_000028_create_orders_table.php index 563129ee18..5434b10da6 100644 --- a/packages/core/database/migrations/2026_01_01_000028_create_orders_table.php +++ b/packages/core/database/migrations/2026_01_01_000028_create_orders_table.php @@ -12,7 +12,7 @@ public function up(): void $table->bigIncrements('id'); $table->userForeignKey(nullable: true); $table->foreignId('channel_id')->constrained($this->prefix.'channels'); - $table->string('status')->index(); + $table->string('status')->default('awaiting-payment')->index(); $table->string('reference')->nullable()->unique(); $table->string('customer_reference')->nullable(); $table->unsignedBigInteger('sub_total'); diff --git a/packages/core/database/migrations/2026_01_01_000031_create_products_table.php b/packages/core/database/migrations/2026_01_01_000031_create_products_table.php index f471901103..5b22e88d76 100644 --- a/packages/core/database/migrations/2026_01_01_000031_create_products_table.php +++ b/packages/core/database/migrations/2026_01_01_000031_create_products_table.php @@ -12,14 +12,12 @@ public function up(): void $table->id(); $table->foreignId('product_type_id')->constrained($this->prefix.'product_types'); $table->foreignId('brand_id')->nullable()->constrained($this->prefix.'brands'); - $table->string('status')->index(); + $table->string('status')->default('draft')->index(); $table->jsonb('name'); $table->jsonb('description')->nullable(); $table->jsonb('short_description')->nullable(); $table->jsonb('attribute_data')->nullable(); $table->timestamps(); - $table->softDeletes(); - $table->index('deleted_at'); }); } diff --git a/packages/core/database/migrations/2026_01_01_000050_create_product_variants_table.php b/packages/core/database/migrations/2026_01_01_000050_create_product_variants_table.php index a5cf653931..52c3fcc1d3 100644 --- a/packages/core/database/migrations/2026_01_01_000050_create_product_variants_table.php +++ b/packages/core/database/migrations/2026_01_01_000050_create_product_variants_table.php @@ -27,8 +27,6 @@ public function up(): void $table->integer('min_quantity')->unsigned()->default(1)->index(); $table->jsonb('attribute_data')->nullable(); $table->timestamps(); - $table->softDeletes(); - $table->index('deleted_at'); }); } diff --git a/packages/core/resources/lang/ar/states.php b/packages/core/resources/lang/ar/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/ar/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/bg/states.php b/packages/core/resources/lang/bg/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/bg/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/de/states.php b/packages/core/resources/lang/de/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/de/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/en/states.php b/packages/core/resources/lang/en/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/en/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/es/states.php b/packages/core/resources/lang/es/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/es/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/fa/states.php b/packages/core/resources/lang/fa/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/fa/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/fr/states.php b/packages/core/resources/lang/fr/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/fr/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/hr/states.php b/packages/core/resources/lang/hr/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/hr/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/hu/states.php b/packages/core/resources/lang/hu/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/hu/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/mn/states.php b/packages/core/resources/lang/mn/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/mn/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/nl/states.php b/packages/core/resources/lang/nl/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/nl/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/pl/states.php b/packages/core/resources/lang/pl/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/pl/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/pt_BR/states.php b/packages/core/resources/lang/pt_BR/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/pt_BR/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/ro/states.php b/packages/core/resources/lang/ro/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/ro/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/tr/states.php b/packages/core/resources/lang/tr/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/tr/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/resources/lang/vi/states.php b/packages/core/resources/lang/vi/states.php new file mode 100644 index 0000000000..7e02405ae5 --- /dev/null +++ b/packages/core/resources/lang/vi/states.php @@ -0,0 +1,34 @@ + [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + + 'product' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'collection' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], + + 'order' => [ + 'awaiting-payment' => 'Awaiting Payment', + 'payment-failed' => 'Payment Failed', + 'backordered' => 'Backordered', + 'in-process' => 'In Process', + 'partially-shipped' => 'Partially Shipped', + 'shipped' => 'Shipped', + 'complete' => 'Complete', + 'returned' => 'Returned', + 'refunded' => 'Refunded', + 'on-hold' => 'On Hold', + 'cancelled' => 'Cancelled', + ], +]; diff --git a/packages/core/src/Actions/Orders/MarkOrderAsShipped.php b/packages/core/src/Actions/Orders/MarkOrderAsShipped.php index 516c2e5f32..38e76d4f66 100644 --- a/packages/core/src/Actions/Orders/MarkOrderAsShipped.php +++ b/packages/core/src/Actions/Orders/MarkOrderAsShipped.php @@ -3,28 +3,20 @@ namespace Lunar\Core\Actions\Orders; use Lunar\Core\Contracts\Actions\Orders\MarksOrderAsShipped; -use Lunar\Core\Contracts\Actions\Orders\UpdatesOrderStatus; use Lunar\Core\Models\Contracts\Order as OrderContract; use Lunar\Core\Models\Order; +use Lunar\Core\States\Order\Order\Shipped; /** - * Convenience wrapper that transitions an order into the configured - * "shipped" status (defaults to `dispatched`). - * - * Today this is `UpdateOrderStatus` with a fixed status string; once a - * `shipped_at` column or a state-machine subsystem lands, the body changes - * without altering this public signature. + * Convenience wrapper that transitions an order's status into Shipped. */ final class MarkOrderAsShipped implements MarksOrderAsShipped { - public function __construct( - protected UpdatesOrderStatus $updatesOrderStatus, - ) {} - public function execute(OrderContract $order): Order { - $status = (string) config('lunar.orders.shipped_status', 'dispatched'); + /** @var Order $order */ + $order->status->transitionTo(Shipped::class); - return $this->updatesOrderStatus->execute($order, $status); + return $order->refresh(); } } diff --git a/packages/core/src/Actions/Orders/UpdateOrderStatus.php b/packages/core/src/Actions/Orders/UpdateOrderStatus.php index af2854ac12..9722c4eed4 100644 --- a/packages/core/src/Actions/Orders/UpdateOrderStatus.php +++ b/packages/core/src/Actions/Orders/UpdateOrderStatus.php @@ -3,40 +3,38 @@ namespace Lunar\Core\Actions\Orders; use Lunar\Core\Contracts\Actions\Orders\UpdatesOrderStatus; -use Lunar\Core\Events\Orders\OrderStatusUpdated; +use Lunar\Core\Contracts\OrderStateConfig; use Lunar\Core\Exceptions\OrderActionException; use Lunar\Core\Models\Contracts\Order as OrderContract; use Lunar\Core\Models\Order; /** - * Apply a status change to an order. - * - * Deliberately thin in v2 — the canonical entry point for status mutation so - * callers (Filament actions, the API, the CLI) all go through one seam. - * When the state-machines TODO item lands, the body is re-implemented as a - * transition without changing this signature. + * Apply a status change to an order, respecting the OrderState transition graph. */ final class UpdateOrderStatus implements UpdatesOrderStatus { + public function __construct( + private OrderStateConfig $stateConfig, + ) {} + public function execute(OrderContract $order, string $status): Order { /** @var Order $order */ - if (! array_key_exists($status, config('lunar.orders.statuses', []))) { + $target = collect($this->stateConfig->orderStates()) + ->first(fn (string $class) => $class::getMorphClass() === $status); + + if (! $target) { throw new OrderActionException( - "Status [{$status}] is not configured under lunar.orders.statuses." + "Status [{$status}] is not a registered OrderState." ); } - $previous = $order->status; - - if ($previous === $status) { + if ((string) $order->status === $status) { return $order; } - $order->forceFill(['status' => $status])->save(); - - OrderStatusUpdated::dispatch($order, $previous, $status); + $order->status->transitionTo($target); - return $order; + return $order->refresh(); } } diff --git a/packages/core/src/Actions/Products/UpdateProductStatus.php b/packages/core/src/Actions/Products/UpdateProductStatus.php index af72bd8462..5a1043f41a 100644 --- a/packages/core/src/Actions/Products/UpdateProductStatus.php +++ b/packages/core/src/Actions/Products/UpdateProductStatus.php @@ -35,7 +35,7 @@ public function execute(ProductContract $product, string $status): Product ); } - $previous = $product->status; + $previous = (string) $product->status; if ($previous === $status) { return $product; diff --git a/packages/core/src/Contracts/OrderStateConfig.php b/packages/core/src/Contracts/OrderStateConfig.php new file mode 100644 index 0000000000..ab5eb9b81b --- /dev/null +++ b/packages/core/src/Contracts/OrderStateConfig.php @@ -0,0 +1,46 @@ +> + */ + public function orderStates(): array; + + /** + * @return array, list>> + */ + public function orderTransitions(): array; + + /** + * @return class-string + */ + public function defaultOrderState(): string; + + /** + * Notification classes to dispatch when an order transitions into the + * given status. Each class is instantiated with the order and delivered + * via `$order->notify()`. + * + * @return array + */ + public function notificationsFor(OrderState $state): array; +} diff --git a/packages/core/src/Events/Orders/OrderStatusUpdated.php b/packages/core/src/Events/Orders/OrderStatusUpdated.php index 8290363c8f..83ef563f3c 100644 --- a/packages/core/src/Events/Orders/OrderStatusUpdated.php +++ b/packages/core/src/Events/Orders/OrderStatusUpdated.php @@ -6,6 +6,7 @@ use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; use Lunar\Core\Models\Order; +use Lunar\Core\States\Order\OrderState; class OrderStatusUpdated { @@ -13,7 +14,7 @@ class OrderStatusUpdated public function __construct( public Order $order, - public ?string $previousStatus, - public string $status, + public ?OrderState $previousStatus, + public OrderState $newStatus, ) {} } diff --git a/packages/core/src/Listeners/SendOrderStatusNotifications.php b/packages/core/src/Listeners/SendOrderStatusNotifications.php new file mode 100644 index 0000000000..b9105d9c56 --- /dev/null +++ b/packages/core/src/Listeners/SendOrderStatusNotifications.php @@ -0,0 +1,20 @@ +stateConfig->notificationsFor($event->newStatus) as $class) { + $event->order->notify(new $class($event->order)); + } + } +} diff --git a/packages/core/src/LunarServiceProvider.php b/packages/core/src/LunarServiceProvider.php index 82407468e8..5e402a8b74 100644 --- a/packages/core/src/LunarServiceProvider.php +++ b/packages/core/src/LunarServiceProvider.php @@ -33,6 +33,7 @@ use Lunar\Core\Contracts\FieldTypeManifest; use Lunar\Core\Contracts\ModelManifest; use Lunar\Core\Contracts\OrderReferenceGenerator; +use Lunar\Core\Contracts\OrderStateConfig; use Lunar\Core\Contracts\PaymentManager; use Lunar\Core\Contracts\PricingManager; use Lunar\Core\Contracts\ProvidesTelemetryInsights; @@ -41,9 +42,11 @@ use Lunar\Core\Contracts\TaxManager; use Lunar\Core\Contracts\TelemetryService; use Lunar\Core\Database\State\EnsureBaseRolesAndPermissions; +use Lunar\Core\Events\Orders\OrderStatusUpdated; use Lunar\Core\Facades\Converter; use Lunar\Core\Facades\Telemetry; use Lunar\Core\Listeners\CartSessionAuthListener; +use Lunar\Core\Listeners\SendOrderStatusNotifications; use Lunar\Core\Managers\CartSessionManager; use Lunar\Core\Managers\DiscountManager as DiscountManagerImpl; use Lunar\Core\Managers\PaymentManager as PaymentManagerImpl; @@ -103,6 +106,7 @@ use Lunar\Core\Pricing\DefaultPriceFormatter; use Lunar\Core\Pricing\PriceCalculatorInterface; use Lunar\Core\Pricing\PriceFormatterInterface; +use Lunar\Core\States\Order\DefaultOrderStateConfig; use Lunar\Core\Telemetry\Insights as TelemetryInsights; use Lunar\Core\Telemetry\TelemetryService as TelemetryServiceImpl; use Lunar\Core\Utils\MeasurementConverter; @@ -229,6 +233,8 @@ public function boot(): void [CartSessionAuthListener::class, 'logout'] ); + Event::listen(OrderStatusUpdated::class, SendOrderStatusNotifications::class); + $this->registerStaffAuthGuard(); $this->registerStaffStateListeners(); @@ -334,6 +340,10 @@ protected function registerServices(): void return $app->make(OrderReferenceGeneratorImpl::class); }); + $this->app->singleton(OrderStateConfig::class, function ($app) { + return $app->make(DefaultOrderStateConfig::class); + }); + $this->app->bind(PriceFormatterInterface::class, function ($app, array $parameters = []) { $concrete = config('lunar.pricing.formatter', DefaultPriceFormatter::class); diff --git a/packages/core/src/Models/Brand.php b/packages/core/src/Models/Brand.php index 07537a0ad7..894404d1c1 100644 --- a/packages/core/src/Models/Brand.php +++ b/packages/core/src/Models/Brand.php @@ -63,7 +63,7 @@ protected static function booted(): void { static::deleting(function (self $brand) { DB::beginTransaction(); - $brand->products()->withTrashed()->update(['brand_id' => null]); + $brand->products()->update(['brand_id' => null]); $brand->discounts()->detach(); $brand->collections()->detach(); DB::commit(); diff --git a/packages/core/src/Models/CartLine.php b/packages/core/src/Models/CartLine.php index 6aae4d0b86..59fc4227a7 100644 --- a/packages/core/src/Models/CartLine.php +++ b/packages/core/src/Models/CartLine.php @@ -147,6 +147,6 @@ public function discounts(): BelongsToMany public function purchasable(): MorphTo { - return $this->morphTo()->withTrashed(); + return $this->morphTo(); } } diff --git a/packages/core/src/Models/Channel.php b/packages/core/src/Models/Channel.php index 17b49ed772..66b8fb33a0 100644 --- a/packages/core/src/Models/Channel.php +++ b/packages/core/src/Models/Channel.php @@ -5,13 +5,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphToMany; -use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; use Illuminate\Support\Str; use Lunar\Core\Database\Factories\ChannelFactory; use Lunar\Core\Models\Concerns\HasDefaultRecord; use Lunar\Core\Models\Concerns\HasMacros; use Lunar\Core\Models\Concerns\LogsActivity; +use Lunar\Core\States\Channel\ChannelState; +use Spatie\ModelStates\HasStates; /** * @property int $id @@ -19,20 +20,20 @@ * @property string $handle * @property bool $default * @property ?string $url + * @property ChannelState $status * @property ?Carbon $created_at * @property ?Carbon $updated_at - * @property ?Carbon $deleted_at */ class Channel extends Base implements Contracts\Channel { use HasDefaultRecord; use HasFactory; use HasMacros; + use HasStates; use LogsActivity; - use SoftDeletes; public $casts = [ - 'enabled' => 'boolean', + 'status' => ChannelState::class, ]; /** @@ -105,4 +106,14 @@ public function collections(): MorphToMany "{$prefix}channelables" ); } + + /** + * Whether any order has been placed against this channel. Used to gate + * hard deletion in the admin — channels with order history should be + * marked Inactive, not deleted, so historical orders keep their context. + */ + public function hasOrderHistory(): bool + { + return Order::query()->where('channel_id', $this->id)->exists(); + } } diff --git a/packages/core/src/Models/Collection.php b/packages/core/src/Models/Collection.php index f419385f52..061c08ac10 100644 --- a/packages/core/src/Models/Collection.php +++ b/packages/core/src/Models/Collection.php @@ -20,7 +20,10 @@ use Lunar\Core\Models\Concerns\HasTranslations; use Lunar\Core\Models\Concerns\HasUrls; use Lunar\Core\Models\Concerns\Searchable; +use Lunar\Core\States\Collection\CollectionState; +use Lunar\Core\States\Collection\Published; use Spatie\MediaLibrary\HasMedia as SpatieHasMedia; +use Spatie\ModelStates\HasStates; /** * @property int $id @@ -34,9 +37,9 @@ * @property ?\Illuminate\Support\Collection $short_description * @property ?\Illuminate\Support\Collection $attribute_data * @property string $sort + * @property CollectionState $status * @property ?Carbon $created_at * @property ?Carbon $updated_at - * @property ?Carbon $deleted_at */ class Collection extends Base implements Contracts\Collection, HasThumbnailImage, SpatieHasMedia { @@ -46,6 +49,7 @@ class Collection extends Base implements Contracts\Collection, HasThumbnailImage HasFactory, HasMacros, HasMedia, + HasStates, HasTranslations, HasUrls, NodeTrait, @@ -62,6 +66,7 @@ class Collection extends Base implements Contracts\Collection, HasThumbnailImage 'name' => AsCollection::class, 'description' => AsCollection::class, 'short_description' => AsCollection::class, + 'status' => CollectionState::class, ]; protected $guarded = []; @@ -92,6 +97,11 @@ public function scopeInGroup(Builder $builder, int $id): Builder return $builder->where('collection_group_id', $id); } + public function scopeWhereVisible(Builder $builder): Builder + { + return $builder->where('status', Published::$name); + } + /** * Return the products relationship. */ diff --git a/packages/core/src/Models/Concerns/HasUrls.php b/packages/core/src/Models/Concerns/HasUrls.php index 827db42477..e339ac743e 100644 --- a/packages/core/src/Models/Concerns/HasUrls.php +++ b/packages/core/src/Models/Concerns/HasUrls.php @@ -25,9 +25,7 @@ public static function bootHasUrls() }); static::deleted(function (Model $model) { - if (! $model->deleted_at) { - $model->urls()->delete(); - } + $model->urls()->delete(); }); } diff --git a/packages/core/src/Models/Order.php b/packages/core/src/Models/Order.php index 511b75bb56..178d6a4f54 100644 --- a/packages/core/src/Models/Order.php +++ b/packages/core/src/Models/Order.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Notifications\Notifiable; use Illuminate\Support\Carbon; use Lunar\Core\Casts\DiscountBreakdown; use Lunar\Core\Casts\ShippingBreakdown; @@ -19,6 +20,8 @@ use Lunar\Core\Models\Concerns\LogsActivity; use Lunar\Core\Models\Concerns\Searchable; use Lunar\Core\Models\Contracts\Currency as CurrencyContract; +use Lunar\Core\States\Order\OrderState; +use Spatie\ModelStates\HasStates; /** * @property int $id @@ -26,7 +29,7 @@ * @property ?int $user_id * @property int $channel_id * @property bool $new_customer - * @property string $status + * @property OrderState $status * @property ?string $reference * @property ?string $customer_reference * @property int $sub_total @@ -50,8 +53,10 @@ class Order extends Base implements Contracts\Order, HasCurrency use FormatsPrices; use HasFactory; use HasMacros; + use HasStates; use HasTags; use LogsActivity; + use Notifiable; use Searchable; /** @@ -69,6 +74,7 @@ class Order extends Base implements Contracts\Order, HasCurrency 'total' => 'integer', 'shipping_total' => 'integer', 'new_customer' => 'boolean', + 'status' => OrderState::class, ]; public function resolveCurrency(): CurrencyContract @@ -88,11 +94,9 @@ protected static function newFactory() return OrderFactory::new(); } - public function getStatusLabelAttribute(): string + public function statusLabel(): string { - $statuses = config('lunar.orders.statuses'); - - return $statuses[$this->status]['label'] ?? $this->status; + return $this->status->label(); } public function channel(): BelongsTo diff --git a/packages/core/src/Models/Product.php b/packages/core/src/Models/Product.php index 26321a4224..a3b406a9a4 100644 --- a/packages/core/src/Models/Product.php +++ b/packages/core/src/Models/Product.php @@ -13,7 +13,6 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphMany; -use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; use Lunar\Core\Contracts\HasThumbnailImage; use Lunar\Core\Database\Factories\ProductFactory; @@ -30,20 +29,22 @@ use Lunar\Core\Models\Concerns\HasUrls; use Lunar\Core\Models\Concerns\LogsActivity; use Lunar\Core\Models\Concerns\Searchable; +use Lunar\Core\States\Product\ProductState; +use Lunar\Core\States\Product\Published; use Spatie\MediaLibrary\HasMedia as SpatieHasMedia; +use Spatie\ModelStates\HasStates; /** * @property int $id * @property ?int $brand_id * @property int $product_type_id - * @property string $status + * @property ProductState $status * @property \Illuminate\Support\Collection $name * @property ?\Illuminate\Support\Collection $description * @property ?\Illuminate\Support\Collection $short_description * @property ?\Illuminate\Support\Collection $attribute_data * @property ?Carbon $created_at * @property ?Carbon $updated_at - * @property ?Carbon $deleted_at */ class Product extends Base implements Contracts\Product, HasThumbnailImage, SpatieHasMedia { @@ -53,12 +54,12 @@ class Product extends Base implements Contracts\Product, HasThumbnailImage, Spat use HasFactory; use HasMacros; use HasMedia; + use HasStates; use HasTags; use HasTranslations; use HasUrls; use LogsActivity; use Searchable; - use SoftDeletes; /** * Return a new factory instance for the model. @@ -93,6 +94,7 @@ protected static function newFactory() 'name' => AsCollection::class, 'description' => AsCollection::class, 'short_description' => AsCollection::class, + 'status' => ProductState::class, ]; /** @@ -204,6 +206,11 @@ public function scopeStatus(Builder $query, string $status): Builder return $query->whereStatus($status); } + public function scopeWhereVisible(Builder $query): Builder + { + return $query->where('status', Published::$name); + } + public function prices(): HasManyThrough { return $this->hasManyThrough( @@ -228,4 +235,20 @@ public function getThumbnailImage(): string { return $this->thumbnail?->getUrl('small') ?? ''; } + + /** + * Whether any of this product's variants appear on any historical order line. + * Used to gate hard deletion in the admin — products with order history + * should be archived, not deleted, so the merchant can still drill into + * old orders. + */ + public function hasOrderHistory(): bool + { + $variantClass = ProductVariant::modelClass(); + + return OrderLine::query() + ->where('purchasable_type', (new $variantClass)->getMorphClass()) + ->whereIn('purchasable_id', $this->variants()->select('id')) + ->exists(); + } } diff --git a/packages/core/src/Models/ProductAssociation.php b/packages/core/src/Models/ProductAssociation.php index cdd41d47cb..f3b4898fad 100644 --- a/packages/core/src/Models/ProductAssociation.php +++ b/packages/core/src/Models/ProductAssociation.php @@ -72,7 +72,7 @@ protected static function newFactory() */ public function parent(): BelongsTo { - return $this->belongsTo(Product::modelClass(), 'product_parent_id')->withTrashed(); + return $this->belongsTo(Product::modelClass(), 'product_parent_id'); } /** @@ -80,7 +80,7 @@ public function parent(): BelongsTo */ public function target(): BelongsTo { - return $this->belongsTo(Product::modelClass(), 'product_target_id')->withTrashed(); + return $this->belongsTo(Product::modelClass(), 'product_target_id'); } /** diff --git a/packages/core/src/Models/ProductVariant.php b/packages/core/src/Models/ProductVariant.php index d66ba76aff..1dc6f1eaa7 100644 --- a/packages/core/src/Models/ProductVariant.php +++ b/packages/core/src/Models/ProductVariant.php @@ -5,7 +5,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Lunar\Core\Contracts\HasThumbnailImage; @@ -49,7 +48,6 @@ * @property string $purchasable * @property ?Carbon $created_at * @property ?Carbon $updated_at - * @property ?Carbon $deleted_at */ class ProductVariant extends Base implements Contracts\ProductVariant, HasThumbnailImage, Purchasable { @@ -60,7 +58,6 @@ class ProductVariant extends Base implements Contracts\ProductVariant, HasThumbn use HasPrices; use HasTranslations; use LogsActivity; - use SoftDeletes; /** * Define the guarded attributes. @@ -86,7 +83,7 @@ protected static function newFactory() public function product(): BelongsTo { - return $this->belongsTo(Product::modelClass())->withTrashed(); + return $this->belongsTo(Product::modelClass()); } public function taxClass(): BelongsTo @@ -218,10 +215,8 @@ public function canBeFulfilledAtQuantity(int $quantity): bool public function isPurchasable(): bool { - return ! $this->trashed() - && $this->product - && ! $this->product->trashed() - && $this->product->status === 'published'; + return $this->product + && (string) $this->product->status === 'published'; } public function getTotalInventory(): int diff --git a/packages/core/src/Models/Transaction.php b/packages/core/src/Models/Transaction.php index 5aca1fdd62..41bd614452 100644 --- a/packages/core/src/Models/Transaction.php +++ b/packages/core/src/Models/Transaction.php @@ -31,7 +31,6 @@ * @property ?array $meta * @property ?Carbon $created_at * @property ?Carbon $updated_at - * @property ?Carbon $deleted_at */ class Transaction extends Base implements Contracts\Transaction, HasCurrency { diff --git a/packages/core/src/Observers/OrderObserver.php b/packages/core/src/Observers/OrderObserver.php index 152fb5e1dc..5d6ef76c71 100644 --- a/packages/core/src/Observers/OrderObserver.php +++ b/packages/core/src/Observers/OrderObserver.php @@ -2,28 +2,37 @@ namespace Lunar\Core\Observers; +use Lunar\Core\Events\Orders\OrderStatusUpdated; use Lunar\Core\Models\Contracts\Order as OrderContract; use Lunar\Core\Models\Order; class OrderObserver { - /** - * Handle the Order "updating" event. - * - * @return void - */ - public function updating(OrderContract $order) + public function updating(OrderContract $order): void { /** @var Order $order */ - if ($order->getOriginal('status') != $order->status) { + if ($order->isDirty('status')) { activity() ->causedBy(auth()->user()) ->performedOn($order) ->event('status-update') ->withProperties([ - 'new' => $order->status, - 'previous' => $order->getOriginal('status'), - ])->log('status-update'); + 'new' => (string) $order->status, + 'previous' => (string) $order->getOriginal('status'), + ]) + ->log('status-update'); + } + } + + public function updated(OrderContract $order): void + { + /** @var Order $order */ + if ($order->wasChanged('status')) { + OrderStatusUpdated::dispatch( + $order, + $order->getOriginal('status'), + $order->status, + ); } } } diff --git a/packages/core/src/Observers/ProductObserver.php b/packages/core/src/Observers/ProductObserver.php index 4ac0aeeaf9..ec35566c3c 100644 --- a/packages/core/src/Observers/ProductObserver.php +++ b/packages/core/src/Observers/ProductObserver.php @@ -6,27 +6,15 @@ class ProductObserver { - /** - * Handle the ProductVariant "deleted" event. - */ public function deleting(ProductContract $product): void { - if ($product->isForceDeleting()) { - $product->variants()->withTrashed()->get()->each->forceDelete(); - - $product->collections()->detach(); - - $product->customerGroups()->detach(); - - $product->urls()->delete(); - - $product->productOptions()->detach(); - - $product->channels()->detach(); - - $product->tags()->detach(); - } - + $product->variants()->get()->each->delete(); + $product->collections()->detach(); + $product->customerGroups()->detach(); + $product->urls()->delete(); + $product->productOptions()->detach(); + $product->channels()->detach(); + $product->tags()->detach(); $product->associations()->delete(); $product->inverseAssociations()->delete(); } diff --git a/packages/core/src/Observers/ProductVariantObserver.php b/packages/core/src/Observers/ProductVariantObserver.php index df6d54e962..08a27133f2 100644 --- a/packages/core/src/Observers/ProductVariantObserver.php +++ b/packages/core/src/Observers/ProductVariantObserver.php @@ -7,18 +7,11 @@ class ProductVariantObserver { - /** - * Handle the ProductVariant "deleted" event. - * - * @return void - */ - public function deleting(ProductVariantContract $productVariant) + public function deleting(ProductVariantContract $productVariant): void { - if ($productVariant->isForceDeleting()) { - /** @var ProductVariant $productVariant */ - $productVariant->prices()->delete(); - $productVariant->values()->detach(); - $productVariant->images()->detach(); - } + /** @var ProductVariant $productVariant */ + $productVariant->prices()->delete(); + $productVariant->values()->detach(); + $productVariant->images()->detach(); } } diff --git a/packages/core/src/PaymentTypes/OfflinePayment.php b/packages/core/src/PaymentTypes/OfflinePayment.php index 9f81456115..bcc8894f36 100644 --- a/packages/core/src/PaymentTypes/OfflinePayment.php +++ b/packages/core/src/PaymentTypes/OfflinePayment.php @@ -7,6 +7,7 @@ use Lunar\Core\DataObjects\PaymentRefund; use Lunar\Core\Events\PaymentAttemptEvent; use Lunar\Core\Models\Contracts\Transaction as TransactionContract; +use Lunar\Core\States\Order\Order\InProcess; class OfflinePayment extends AbstractPayment { @@ -25,10 +26,12 @@ public function authorize(): ?PaymentAuthorize $this->data['meta'] ?? [] ); - $status = $this->data['authorized'] ?? null; + $status = $this->data['authorized'] + ?? $this->config['authorized'] + ?? InProcess::$name; $this->order->update([ - 'status' => $status ?? ($this->config['authorized'] ?? null), + 'status' => $status, 'meta' => $orderMeta, 'placed_at' => now(), ]); diff --git a/packages/core/src/Pipelines/Order/Creation/FillOrderFromCart.php b/packages/core/src/Pipelines/Order/Creation/FillOrderFromCart.php index a8b58eaa65..eb1348985b 100644 --- a/packages/core/src/Pipelines/Order/Creation/FillOrderFromCart.php +++ b/packages/core/src/Pipelines/Order/Creation/FillOrderFromCart.php @@ -23,7 +23,8 @@ public function handle(OrderContract $order, Closure $next): mixed 'user_id' => $cart->user_id, 'customer_id' => $cart->customer_id, 'channel_id' => $cart->channel_id, - 'status' => config('lunar.orders.draft_status'), + // status defaults via the HasStates initializer and the migration + // column default. 'reference' => null, 'customer_reference' => null, 'sub_total' => $cart->subTotal->value, diff --git a/packages/core/src/Search/OrderIndexer.php b/packages/core/src/Search/OrderIndexer.php index e53970701a..e124aa4eca 100644 --- a/packages/core/src/Search/OrderIndexer.php +++ b/packages/core/src/Search/OrderIndexer.php @@ -28,7 +28,6 @@ public function getFilterableFields(): array 'placed_at', 'channel_id', 'tags', - '__soft_deleted', ]; } @@ -72,7 +71,7 @@ public function toSearchableArray(Model $model): array 'channel' => $model->channel->name, 'reference' => $model->reference, 'customer_reference' => $model->customer_reference, - 'status' => $model->status, + 'status' => (string) $model->status, 'placed_at' => optional($model->placed_at)->timestamp, 'created_at' => (int) $model->created_at->timestamp, 'sub_total' => $model->sub_total, diff --git a/packages/core/src/Search/ProductIndexer.php b/packages/core/src/Search/ProductIndexer.php index 13dc31b9e1..cb264183c1 100644 --- a/packages/core/src/Search/ProductIndexer.php +++ b/packages/core/src/Search/ProductIndexer.php @@ -42,7 +42,7 @@ public function toSearchableArray(Model $model): array // more of a vanity thing than anything else. $data = array_merge([ 'id' => (string) $model->id, - 'status' => $model->status, + 'status' => (string) $model->status, 'product_type' => $model->productType->name, 'brand' => $model->brand?->name, 'created_at' => (int) $model->created_at->timestamp, diff --git a/packages/core/src/States/Channel/Active.php b/packages/core/src/States/Channel/Active.php new file mode 100644 index 0000000000..2be1cd807c --- /dev/null +++ b/packages/core/src/States/Channel/Active.php @@ -0,0 +1,13 @@ +default(Active::class) + ->allowTransition(Active::class, Inactive::class) + ->allowTransition(Inactive::class, Active::class); + } +} diff --git a/packages/core/src/States/Channel/Inactive.php b/packages/core/src/States/Channel/Inactive.php new file mode 100644 index 0000000000..261439a8ee --- /dev/null +++ b/packages/core/src/States/Channel/Inactive.php @@ -0,0 +1,13 @@ +default(Draft::class) + ->allowTransition(Draft::class, Published::class) + ->allowTransition(Draft::class, Archived::class) + ->allowTransition(Published::class, Draft::class) + ->allowTransition(Published::class, Archived::class) + ->allowTransition(Archived::class, Draft::class) + ->allowTransition(Archived::class, Published::class); + } +} diff --git a/packages/core/src/States/Collection/Draft.php b/packages/core/src/States/Collection/Draft.php new file mode 100644 index 0000000000..33f1613a23 --- /dev/null +++ b/packages/core/src/States/Collection/Draft.php @@ -0,0 +1,13 @@ + [InProcess::class, PaymentFailed::class, Backordered::class, OnHold::class, Cancelled::class], + PaymentFailed::class => [AwaitingPayment::class, Cancelled::class], + Backordered::class => [InProcess::class, OnHold::class, Cancelled::class], + InProcess::class => [PartiallyShipped::class, Shipped::class, OnHold::class, Cancelled::class], + PartiallyShipped::class => [Shipped::class, Returned::class, Cancelled::class], + Shipped::class => [Complete::class, Returned::class], + Complete::class => [Returned::class, Refunded::class], + Returned::class => [Refunded::class], + OnHold::class => [AwaitingPayment::class, InProcess::class, Cancelled::class], + Cancelled::class => [Refunded::class], + Refunded::class => [], + ]; + } + + public function defaultOrderState(): string + { + return AwaitingPayment::class; + } + + public function notificationsFor(OrderState $state): array + { + /** @var array $notifications */ + $notifications = (array) config('lunar.orders.notifications.'.$state::$name, []); + + return $notifications; + } +} diff --git a/packages/core/src/States/Order/Order/AwaitingPayment.php b/packages/core/src/States/Order/Order/AwaitingPayment.php new file mode 100644 index 0000000000..976ad8a80f --- /dev/null +++ b/packages/core/src/States/Order/Order/AwaitingPayment.php @@ -0,0 +1,15 @@ +default($config->defaultOrderState()) + ->registerState($config->orderStates()); + + foreach ($config->orderTransitions() as $from => $tos) { + foreach ($tos as $to) { + $stateConfig->allowTransition($from, $to); + } + } + + return $stateConfig; + } +} diff --git a/packages/core/src/States/Product/Archived.php b/packages/core/src/States/Product/Archived.php new file mode 100644 index 0000000000..29614fa32d --- /dev/null +++ b/packages/core/src/States/Product/Archived.php @@ -0,0 +1,13 @@ +default(Draft::class) + ->allowTransition(Draft::class, Published::class) + ->allowTransition(Draft::class, Archived::class) + ->allowTransition(Published::class, Draft::class) + ->allowTransition(Published::class, Archived::class) + ->allowTransition(Archived::class, Draft::class) + ->allowTransition(Archived::class, Published::class); + } +} diff --git a/packages/core/src/States/Product/Published.php b/packages/core/src/States/Product/Published.php new file mode 100644 index 0000000000..4693896fbb --- /dev/null +++ b/packages/core/src/States/Product/Published.php @@ -0,0 +1,13 @@ +where('id', $productId) + ->where('status', Published::$name) ->channel($channel) ->whereHas('customerGroups', $this->purchasableForGroups($groups)) ->exists(); diff --git a/packages/filament/composer.json b/packages/filament/composer.json index d097a9fc5b..8c82486e53 100644 --- a/packages/filament/composer.json +++ b/packages/filament/composer.json @@ -22,7 +22,7 @@ }, "minimum-stability": "dev", "require": { - "php": "^8.3", + "php": "^8.4", "lunarphp/core": "self.version", "filament/filament": "^5.0", "filament/spatie-laravel-media-library-plugin": "^5.0", diff --git a/packages/filament/config/lunar-filament.php b/packages/filament/config/lunar-filament.php index 259c7f1ea3..2691426e97 100644 --- a/packages/filament/config/lunar-filament.php +++ b/packages/filament/config/lunar-filament.php @@ -59,4 +59,30 @@ 'order' => null, 'product_variant' => null, ], + + /* + |-------------------------------------------------------------------------- + | Order status colours + |-------------------------------------------------------------------------- + | + | Filament colour swatch (any CSS hex) per OrderState $name, used by + | `Lunar\Filament\Support\OrderStatus::getColor()` to badge / theme + | order status displays. Override or extend in your own panel config. + | + */ + 'order' => [ + 'status_colors' => [ + 'awaiting-payment' => '#848a8c', + 'payment-failed' => '#dc2626', + 'backordered' => '#f59e0b', + 'in-process' => '#6a67ce', + 'partially-shipped' => '#0ea5e9', + 'shipped' => '#0a81d7', + 'complete' => '#10b981', + 'returned' => '#ef4444', + 'refunded' => '#7c3aed', + 'on-hold' => '#f97316', + 'cancelled' => '#6b7280', + ], + ], ]; diff --git a/packages/filament/resources/lang/ar/actions.php b/packages/filament/resources/lang/ar/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/ar/actions.php +++ b/packages/filament/resources/lang/ar/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/ar/product.php b/packages/filament/resources/lang/ar/product.php index 88c8296232..e333b96ccb 100644 --- a/packages/filament/resources/lang/ar/product.php +++ b/packages/filament/resources/lang/ar/product.php @@ -9,8 +9,11 @@ 'draft' => 'مسودة', ], 'status' => [ - 'unpublished' => [ - 'content' => 'حالياً في حالة مسودة، هذا المنتج غير متاح في جميع واجهات البيع ومجموعات العملاء.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'هذا المنتج غير متاح حالياً لجميع مجموعات العملاء.', diff --git a/packages/filament/resources/lang/bg/actions.php b/packages/filament/resources/lang/bg/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/bg/actions.php +++ b/packages/filament/resources/lang/bg/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/bg/product.php b/packages/filament/resources/lang/bg/product.php index 39216b1138..710aa802ac 100644 --- a/packages/filament/resources/lang/bg/product.php +++ b/packages/filament/resources/lang/bg/product.php @@ -9,8 +9,11 @@ 'draft' => 'Чернови', ], 'status' => [ - 'unpublished' => [ - 'content' => 'В чернова, този продукт е недостъпен във всички канали и клиентски групи.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'Този продукт не е наличен за всички клиентски групи.', diff --git a/packages/filament/resources/lang/de/actions.php b/packages/filament/resources/lang/de/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/de/actions.php +++ b/packages/filament/resources/lang/de/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/de/product.php b/packages/filament/resources/lang/de/product.php index 2b03db673c..9905569c5c 100644 --- a/packages/filament/resources/lang/de/product.php +++ b/packages/filament/resources/lang/de/product.php @@ -9,8 +9,11 @@ 'draft' => 'Draft', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Derzeit im Entwurfsstatus, dieses Produkt ist in allen Kanälen und Kundengruppen nicht verfügbar.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'Dieses Produkt ist derzeit für alle Kundengruppen nicht verfügbar.', diff --git a/packages/filament/resources/lang/en/actions.php b/packages/filament/resources/lang/en/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/en/actions.php +++ b/packages/filament/resources/lang/en/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/en/product.php b/packages/filament/resources/lang/en/product.php index a189f3758c..3b5ce18083 100644 --- a/packages/filament/resources/lang/en/product.php +++ b/packages/filament/resources/lang/en/product.php @@ -13,8 +13,11 @@ ], 'status' => [ - 'unpublished' => [ - 'content' => 'Currently in draft status, this product is unavailable across all channels and customer groups.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'This product is currently unavailable for all customer groups.', diff --git a/packages/filament/resources/lang/es/actions.php b/packages/filament/resources/lang/es/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/es/actions.php +++ b/packages/filament/resources/lang/es/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/es/product.php b/packages/filament/resources/lang/es/product.php index 4f1d0fc406..85b1658dd9 100644 --- a/packages/filament/resources/lang/es/product.php +++ b/packages/filament/resources/lang/es/product.php @@ -9,8 +9,11 @@ 'draft' => 'Draft', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Actualmente en estado de borrador, este producto no está disponible en todos los canales y grupos de clientes.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'Este producto actualmente no está disponible para todos los grupos de clientes.', diff --git a/packages/filament/resources/lang/fa/actions.php b/packages/filament/resources/lang/fa/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/fa/actions.php +++ b/packages/filament/resources/lang/fa/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/fa/product.php b/packages/filament/resources/lang/fa/product.php index a28ac9b141..ce575b1dff 100644 --- a/packages/filament/resources/lang/fa/product.php +++ b/packages/filament/resources/lang/fa/product.php @@ -9,8 +9,11 @@ 'draft' => 'Draft', ], 'status' => [ - 'unpublished' => [ - 'content' => 'در حال حاضر در وضعیت پیش‌نویس، این محصول در همه کانال‌ها و گروه‌های مشتری در دسترس نیست.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'این محصول در حال حاضر برای همه گروه‌های مشتری در دسترس نیست.', diff --git a/packages/filament/resources/lang/fr/actions.php b/packages/filament/resources/lang/fr/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/fr/actions.php +++ b/packages/filament/resources/lang/fr/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/fr/product.php b/packages/filament/resources/lang/fr/product.php index 5335fa3c38..522f475493 100644 --- a/packages/filament/resources/lang/fr/product.php +++ b/packages/filament/resources/lang/fr/product.php @@ -9,8 +9,11 @@ 'draft' => 'Draft', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Actuellement en statut de brouillon, ce produit est indisponible sur tous les canaux et groupes de clients.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'Ce produit est actuellement indisponible pour tous les groupes de clients.', diff --git a/packages/filament/resources/lang/hr/actions.php b/packages/filament/resources/lang/hr/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/hr/actions.php +++ b/packages/filament/resources/lang/hr/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/hr/product.php b/packages/filament/resources/lang/hr/product.php index 97f596ce18..5f8a16c9f3 100644 --- a/packages/filament/resources/lang/hr/product.php +++ b/packages/filament/resources/lang/hr/product.php @@ -9,8 +9,11 @@ 'draft' => 'Draft', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Trenutno u statusu skice, ovaj proizvod nije dostupan ni u jednom kanalu ni u jednoj grupi kupaca.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'Ovaj proizvod trenutno nije dostupan nijednoj grupi kupaca.', diff --git a/packages/filament/resources/lang/hu/actions.php b/packages/filament/resources/lang/hu/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/hu/actions.php +++ b/packages/filament/resources/lang/hu/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/hu/product.php b/packages/filament/resources/lang/hu/product.php index 16a3ee3d2a..14197b2c9a 100644 --- a/packages/filament/resources/lang/hu/product.php +++ b/packages/filament/resources/lang/hu/product.php @@ -9,8 +9,11 @@ 'draft' => 'Draft', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Jelenleg vázlat státuszban van, ez a termék egyik csatornán és vásárlói csoportban sem érhető el.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'Ez a termék jelenleg nem elérhető egyik vásárlói csoport számára sem.', diff --git a/packages/filament/resources/lang/mn/actions.php b/packages/filament/resources/lang/mn/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/mn/actions.php +++ b/packages/filament/resources/lang/mn/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/mn/product.php b/packages/filament/resources/lang/mn/product.php index 7e4e58cc17..709bda6838 100644 --- a/packages/filament/resources/lang/mn/product.php +++ b/packages/filament/resources/lang/mn/product.php @@ -9,8 +9,11 @@ 'draft' => 'Ноорог', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Одоогоор ноорог статустай, энэ бүтээгдэхүүн бүх сувгууд болон харилцагчийн бүлгүүдэд боломжгүй байна.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'Энэ бүтээгдэхүүн одоогоор бүх харилцагчийн бүлгүүдэд боломжгүй байна.', diff --git a/packages/filament/resources/lang/nl/actions.php b/packages/filament/resources/lang/nl/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/nl/actions.php +++ b/packages/filament/resources/lang/nl/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/nl/product.php b/packages/filament/resources/lang/nl/product.php index 28250f704b..5b3e8a6247 100644 --- a/packages/filament/resources/lang/nl/product.php +++ b/packages/filament/resources/lang/nl/product.php @@ -9,8 +9,11 @@ 'draft' => 'Draft', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Momenteel in conceptstatus, dit product is niet beschikbaar op alle kanalen en klantgroepen.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'Dit product is momenteel niet beschikbaar voor alle klantgroepen.', diff --git a/packages/filament/resources/lang/pl/actions.php b/packages/filament/resources/lang/pl/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/pl/actions.php +++ b/packages/filament/resources/lang/pl/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/pl/product.php b/packages/filament/resources/lang/pl/product.php index 4f46580c6f..c5a2997058 100644 --- a/packages/filament/resources/lang/pl/product.php +++ b/packages/filament/resources/lang/pl/product.php @@ -9,8 +9,11 @@ 'draft' => 'Draft', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Produkt jest obecnie w trybie szkicu i jest niedostępny we wszystkich kanałach i grupach klientów.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'Produkt jest obecnie niedostępny dla wszystkich grup klientów.', diff --git a/packages/filament/resources/lang/pt_BR/actions.php b/packages/filament/resources/lang/pt_BR/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/pt_BR/actions.php +++ b/packages/filament/resources/lang/pt_BR/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/pt_BR/product.php b/packages/filament/resources/lang/pt_BR/product.php index 919cd251d2..5907123718 100644 --- a/packages/filament/resources/lang/pt_BR/product.php +++ b/packages/filament/resources/lang/pt_BR/product.php @@ -9,8 +9,11 @@ 'draft' => 'Draft', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Atualmente em rascunho, este produto está indisponível em todos os canais e grupos de clientes.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'Este produto está indisponível para todos os grupos de clientes.', diff --git a/packages/filament/resources/lang/ro/actions.php b/packages/filament/resources/lang/ro/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/ro/actions.php +++ b/packages/filament/resources/lang/ro/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/ro/product.php b/packages/filament/resources/lang/ro/product.php index 752c3695d9..75c785393c 100644 --- a/packages/filament/resources/lang/ro/product.php +++ b/packages/filament/resources/lang/ro/product.php @@ -9,8 +9,11 @@ 'draft' => 'Draft', ], 'status' => [ - 'unpublished' => [ - 'content' => 'În prezent în stadiu de ciornă, acest produs este indisponibil în toate canalele și grupurile de clienți.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'Acest produs nu este disponibil momentan pentru niciun grup de clienți.', diff --git a/packages/filament/resources/lang/tr/actions.php b/packages/filament/resources/lang/tr/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/tr/actions.php +++ b/packages/filament/resources/lang/tr/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/tr/product.php b/packages/filament/resources/lang/tr/product.php index ec35ec63d9..f0bda54260 100644 --- a/packages/filament/resources/lang/tr/product.php +++ b/packages/filament/resources/lang/tr/product.php @@ -9,8 +9,11 @@ 'draft' => 'Draft', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Şu anda taslak durumunda olan bu ürün, tüm kanallarda ve müşteri gruplarında kullanılamaz.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'Bu ürün şu anda tüm müşteri grupları için mevcut değil.', diff --git a/packages/filament/resources/lang/vi/actions.php b/packages/filament/resources/lang/vi/actions.php index 22c892ab92..3aa1f54157 100644 --- a/packages/filament/resources/lang/vi/actions.php +++ b/packages/filament/resources/lang/vi/actions.php @@ -76,29 +76,25 @@ 'error' => 'Capture failed.', ], ], - 'update_status' => [ - 'label' => 'Update Status', - 'wizard' => [ - 'step_one' => [ - 'label' => 'Status', - ], - 'step_two' => [ - 'label' => 'Mailers & Notifications', - 'no_mailers' => 'There are no mailers available for this status.', - ], - 'step_three' => [ - 'label' => 'Preview & Save', - 'no_mailers' => 'No mailers have been chosen for preview.', - ], - ], + 'place_on_hold' => [ + 'label' => 'Place on Hold', + 'confirm' => 'Placing the order on hold pauses it in your workflow until you resume it.', 'notification' => [ - 'label' => 'Order status updated', + 'success' => 'Order placed on hold.', ], - 'billing_email' => [ - 'label' => 'Billing Email', + ], + 'cancel_order' => [ + 'label' => 'Cancel Order', + 'confirm' => 'Cancelling the order marks it as cancelled. Downstream listeners may have already reacted.', + 'notification' => [ + 'success' => 'Order cancelled.', ], - 'shipping_email' => [ - 'label' => 'Shipping Email', + ], + 'resume_order' => [ + 'label' => 'Resume Order', + 'confirm' => 'Resuming the order moves it back into the active workflow at awaiting payment.', + 'notification' => [ + 'success' => 'Order resumed.', ], ], 'mark_as_shipped' => [ diff --git a/packages/filament/resources/lang/vi/product.php b/packages/filament/resources/lang/vi/product.php index bc7e2ef10b..2bfff03c6a 100644 --- a/packages/filament/resources/lang/vi/product.php +++ b/packages/filament/resources/lang/vi/product.php @@ -9,8 +9,11 @@ 'draft' => 'Draft', ], 'status' => [ - 'unpublished' => [ - 'content' => 'Hiện đang ở trạng thái nháp, sản phẩm này không khả dụng trên tất cả các kênh và nhóm khách hàng.', + 'draft' => [ + 'content' => 'Currently in draft, this product is hidden from all channels and customer groups.', + ], + 'archived' => [ + 'content' => 'This product is archived — it is hidden from the storefront, but kept on record so historical orders keep their reference. Move it back to Draft to revive it.', ], 'availability' => [ 'customer_groups' => 'Sản phẩm này hiện không có sẵn cho tất cả các nhóm khách hàng.', diff --git a/packages/filament/src/Actions/Orders/CancelOrderAction.php b/packages/filament/src/Actions/Orders/CancelOrderAction.php new file mode 100644 index 0000000000..850918cc39 --- /dev/null +++ b/packages/filament/src/Actions/Orders/CancelOrderAction.php @@ -0,0 +1,34 @@ +label(__('lunar-filament::actions.orders.cancel_order.label')) + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->modalDescription(__('lunar-filament::actions.orders.cancel_order.confirm')) + ->visible(fn (Order $record) => (string) $record->status !== Cancelled::$name) + ->action(fn (Order $record) => $record->forceFill(['status' => Cancelled::$name])->save()) + ->successNotificationTitle(__('lunar-filament::actions.orders.cancel_order.notification.success')); + } +} diff --git a/packages/filament/src/Actions/Orders/PlaceOrderOnHoldAction.php b/packages/filament/src/Actions/Orders/PlaceOrderOnHoldAction.php new file mode 100644 index 0000000000..352e070067 --- /dev/null +++ b/packages/filament/src/Actions/Orders/PlaceOrderOnHoldAction.php @@ -0,0 +1,35 @@ +label(__('lunar-filament::actions.orders.place_on_hold.label')) + ->icon('heroicon-o-pause-circle') + ->color('warning') + ->requiresConfirmation() + ->modalDescription(__('lunar-filament::actions.orders.place_on_hold.confirm')) + ->visible(fn (Order $record) => ! in_array((string) $record->status, [OnHold::$name, Cancelled::$name], true)) + ->action(fn (Order $record) => $record->forceFill(['status' => OnHold::$name])->save()) + ->successNotificationTitle(__('lunar-filament::actions.orders.place_on_hold.notification.success')); + } +} diff --git a/packages/filament/src/Actions/Orders/ResumeOrderAction.php b/packages/filament/src/Actions/Orders/ResumeOrderAction.php new file mode 100644 index 0000000000..cc937851aa --- /dev/null +++ b/packages/filament/src/Actions/Orders/ResumeOrderAction.php @@ -0,0 +1,36 @@ +label(__('lunar-filament::actions.orders.resume_order.label')) + ->icon('heroicon-o-play-circle') + ->color('success') + ->requiresConfirmation() + ->modalDescription(__('lunar-filament::actions.orders.resume_order.confirm')) + ->visible(fn (Order $record) => (string) $record->status === OnHold::$name) + ->action(function (Order $record) { + $record->forceFill(['status' => 'awaiting-payment'])->save(); + }) + ->successNotificationTitle(__('lunar-filament::actions.orders.resume_order.notification.success')); + } +} diff --git a/packages/filament/src/GlobalSearch/OrderGlobalSearch.php b/packages/filament/src/GlobalSearch/OrderGlobalSearch.php index 1c62eb7d60..d8a34ab673 100644 --- a/packages/filament/src/GlobalSearch/OrderGlobalSearch.php +++ b/packages/filament/src/GlobalSearch/OrderGlobalSearch.php @@ -49,7 +49,7 @@ public static function getResultDetails(Model $record): array { /** @var Order $record */ $details = [ - __('lunar-filament::global-search.orders.details.status') => $record->getStatusLabelAttribute(), + __('lunar-filament::global-search.orders.details.status') => $record->status->label(), __('lunar-filament::global-search.orders.details.total') => $record->format('total'), __('lunar-filament::global-search.orders.details.customer') => $record->billingAddress?->fullName, ]; diff --git a/packages/filament/src/GlobalSearch/ProductGlobalSearch.php b/packages/filament/src/GlobalSearch/ProductGlobalSearch.php index d1b8bfdcf3..58f4af253a 100644 --- a/packages/filament/src/GlobalSearch/ProductGlobalSearch.php +++ b/packages/filament/src/GlobalSearch/ProductGlobalSearch.php @@ -41,7 +41,7 @@ public static function getResultDetails(Model $record): array return [ __('lunar-filament::global-search.products.details.sku') => $variant?->getIdentifier(), - __('lunar-filament::global-search.products.details.status') => $record->status, + __('lunar-filament::global-search.products.details.status') => $record->status->label(), __('lunar-filament::global-search.products.details.brand') => $record->brand?->name, ]; } diff --git a/packages/filament/src/Schemas/Product/ProductForm.php b/packages/filament/src/Schemas/Product/ProductForm.php index d2eb0f6bcb..9d955e5750 100644 --- a/packages/filament/src/Schemas/Product/ProductForm.php +++ b/packages/filament/src/Schemas/Product/ProductForm.php @@ -55,9 +55,13 @@ public static function getStatusShouts(): array { return [ Callout::make() - ->heading(__('lunar-filament::product.status.unpublished.content')) + ->heading(__('lunar-filament::product.status.draft.content')) ->status('info') - ->hidden(fn (Model $record) => static::isPublished($record)), + ->hidden(fn (Model $record) => ! static::isDraft($record)), + Callout::make() + ->heading(__('lunar-filament::product.status.archived.content')) + ->status('warning') + ->hidden(fn (Model $record) => ! static::isArchived($record)), Callout::make() ->heading(__('lunar-filament::product.status.availability.customer_groups')) ->status('warning') @@ -179,7 +183,17 @@ public static function getVariantAttributeDataComponent(): Component protected static function isPublished(?Model $record): bool { - return $record?->status === 'published'; + return $record !== null && (string) $record->status === 'published'; + } + + protected static function isDraft(?Model $record): bool + { + return $record !== null && (string) $record->status === 'draft'; + } + + protected static function isArchived(?Model $record): bool + { + return $record !== null && (string) $record->status === 'archived'; } protected static function hasEnabledCustomerGroup(Model $record): bool diff --git a/packages/filament/src/Support/Concerns/UpdatesOrderStatus.php b/packages/filament/src/Support/Concerns/UpdatesOrderStatus.php index 896168c71d..5a4b05c832 100644 --- a/packages/filament/src/Support/Concerns/UpdatesOrderStatus.php +++ b/packages/filament/src/Support/Concerns/UpdatesOrderStatus.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Lunar\Core\Contracts\Actions\Orders\UpdatesOrderStatus as UpdatesOrderStatusContract; +use Lunar\Core\Contracts\OrderStateConfig; use Lunar\Core\Models\Order; trait UpdatesOrderStatus @@ -32,9 +33,9 @@ protected static function getStatusSelectInput(): Select { return Select::make('status') ->label(__('lunar-filament::order.action.update_status.new_status.label')) - ->default(fn ($record) => $record?->status) - ->options(fn () => collect(config('lunar.orders.statuses', [])) - ->mapWithKeys(fn ($data, $status) => [$status => $data['label']])) + ->default(fn ($record) => $record ? (string) $record->status : null) + ->options(fn ($record) => collect(app(OrderStateConfig::class)->orderStates()) + ->mapWithKeys(fn (string $class) => [$class::$name => (new $class($record ?? new Order))->label()])) ->required() ->live(); } diff --git a/packages/filament/src/Support/OrderStatus.php b/packages/filament/src/Support/OrderStatus.php index 8931e85bcc..b66bb0f13b 100644 --- a/packages/filament/src/Support/OrderStatus.php +++ b/packages/filament/src/Support/OrderStatus.php @@ -3,6 +3,8 @@ namespace Lunar\Filament\Support; use Filament\Support\Colors\Color; +use Lunar\Core\Models\Order; +use Lunar\Core\States\Order\OrderState; class OrderStatus { @@ -10,13 +12,31 @@ class OrderStatus protected static array $cachedStatusLabel = []; - public static function getLabel($status): string + public static function getLabel(?string $status): string { - return static::$cachedStatusLabel[$status] ??= filled($label = config('lunar.orders.statuses.'.$status.'.label')) ? $label : (filled($status) ? $status : 'N/A'); + if (! filled($status)) { + return 'N/A'; + } + + return static::$cachedStatusLabel[$status] ??= static::resolveLabel($status); + } + + public static function getColor(?string $status): array + { + return static::$cachedStatusColor[$status] ??= Color::generateV3Palette( + (string) config('lunar-filament.order.status_colors.'.$status, '#7C7C7C') + ); } - public static function getColor($status): array + protected static function resolveLabel(string $status): string { - return static::$cachedStatusColor[$status] ??= Color::generateV3Palette(filled($color = config('lunar.orders.statuses.'.$status.'.color')) ? $color : '#7C7C7C'); + /** @var class-string|null $class */ + $class = OrderState::resolveStateClass($status); + + if ($class && class_exists($class)) { + return (new $class(new Order))->label(); + } + + return $status; } } diff --git a/packages/filament/src/Tables/Channel/ChannelTable.php b/packages/filament/src/Tables/Channel/ChannelTable.php index b9ad1c5977..92e9e3f0ee 100644 --- a/packages/filament/src/Tables/Channel/ChannelTable.php +++ b/packages/filament/src/Tables/Channel/ChannelTable.php @@ -6,7 +6,7 @@ use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Tables\Columns\TextColumn; -use Filament\Tables\Filters\TrashedFilter; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Model; use Lunar\Filament\Support\Concerns\CallsHooks; @@ -22,7 +22,11 @@ public static function configure(Table $table): Table $table ->columns(static::getColumns()) ->filters([ - TrashedFilter::make(), + SelectFilter::make('status') + ->options([ + 'active' => __('lunar::states.channel.active'), + 'inactive' => __('lunar::states.channel.inactive'), + ]), ]) ->recordActions([ EditAction::make(), diff --git a/packages/filament/src/Tables/Order/OrderTable.php b/packages/filament/src/Tables/Order/OrderTable.php index 81bd20fe51..23d0655ae8 100644 --- a/packages/filament/src/Tables/Order/OrderTable.php +++ b/packages/filament/src/Tables/Order/OrderTable.php @@ -12,6 +12,9 @@ use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Lunar\Core\Contracts\OrderStateConfig; +use Lunar\Core\Models\Order; +use Lunar\Core\States\Order\OrderState; use Lunar\Filament\Actions\Orders\UpdateOrderStatusBulkAction; use Lunar\Filament\Support\Concerns\CallsHooks; use Lunar\Filament\Support\CustomerStatus; @@ -57,8 +60,8 @@ public static function getColumns(): array TextColumn::make('status') ->label(__('lunar-filament::order.table.status.label')) ->toggleable() - ->formatStateUsing(fn (string $state) => OrderStatus::getLabel($state)) - ->color(fn (string $state) => OrderStatus::getColor($state)) + ->formatStateUsing(fn ($state) => OrderStatus::getLabel((string) $state)) + ->color(fn ($state) => OrderStatus::getColor((string) $state)) ->badge(), TextColumn::make('reference') ->label(__('lunar-filament::order.table.reference.label')) @@ -114,8 +117,11 @@ public static function getFilters(): array return [ SelectFilter::make('status') ->label(__('lunar-filament::order.table.status.label')) - ->options(collect(config('lunar.orders.statuses', [])) - ->mapWithKeys(fn ($data, $status) => [$status => $data['label']])) + ->options( + collect(app(OrderStateConfig::class)->orderStates()) + /** @var class-string $class */ + ->mapWithKeys(fn (string $class) => [$class::$name => (new $class(new Order))->label()]) + ) ->multiple(), Filter::make('placed_at') ->schema([ diff --git a/packages/filament/src/Tables/Product/ProductTable.php b/packages/filament/src/Tables/Product/ProductTable.php index 28ecc07e3d..3695bf0877 100644 --- a/packages/filament/src/Tables/Product/ProductTable.php +++ b/packages/filament/src/Tables/Product/ProductTable.php @@ -9,7 +9,6 @@ use Filament\Tables\Columns\SpatieMediaLibraryImageColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; -use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -33,7 +32,13 @@ public static function configure(Table $table): Table SelectFilter::make('brand') ->label(__('lunar-filament::product.table.brand.label')) ->relationship('brand', 'name'), - TrashedFilter::make(), + SelectFilter::make('status') + ->label(__('lunar-filament::product.table.status.label')) + ->options([ + 'draft' => __('lunar-filament::product.table.status.states.draft'), + 'published' => __('lunar-filament::product.table.status.states.published'), + 'archived' => __('lunar-filament::product.table.status.states.archived'), + ]), ]) ->recordActions([ EditAction::make(), @@ -55,13 +60,13 @@ public static function getColumns(): array ->label(__('lunar-filament::product.table.status.label')) ->badge() ->getStateUsing( - fn (Model $record) => $record->deleted_at ? 'deleted' : $record->status + fn (Model $record) => (string) $record->status ) ->formatStateUsing(fn ($state) => __('lunar-filament::product.table.status.states.'.$state)) ->color(fn (string $state): string => match ($state) { 'draft' => 'warning', 'published' => 'success', - 'deleted' => 'danger', + 'archived' => 'danger', default => 'primary', }), SpatieMediaLibraryImageColumn::make('thumbnail') diff --git a/packages/opayo/composer.json b/packages/opayo/composer.json index 4a74fd67bd..c67b4d6e03 100644 --- a/packages/opayo/composer.json +++ b/packages/opayo/composer.json @@ -11,7 +11,7 @@ } ], "require": { - "php": "^8.3", + "php": "^8.4", "lunarphp/core": "self.version" }, "autoload": { diff --git a/packages/paypal/composer.json b/packages/paypal/composer.json index abd8ee12d0..f3b88b16cd 100644 --- a/packages/paypal/composer.json +++ b/packages/paypal/composer.json @@ -11,7 +11,7 @@ } ], "require": { - "php": "^8.3", + "php": "^8.4", "lunarphp/core": "self.version" }, "autoload": { diff --git a/packages/search/composer.json b/packages/search/composer.json index 0e50aca703..f2ae343db3 100644 --- a/packages/search/composer.json +++ b/packages/search/composer.json @@ -11,7 +11,7 @@ } ], "require": { - "php": "^8.3", + "php": "^8.4", "lunarphp/core": "self.version", "spatie/laravel-data": "^4.13.1", "typesense/typesense-php": "^4.9", diff --git a/packages/stripe/composer.json b/packages/stripe/composer.json index b6924eb855..258d85f7b7 100644 --- a/packages/stripe/composer.json +++ b/packages/stripe/composer.json @@ -11,7 +11,7 @@ } ], "require": { - "php": "^8.3", + "php": "^8.4", "lunarphp/core": "self.version", "stripe/stripe-php": "^16.0" }, diff --git a/packages/stripe/config/stripe.php b/packages/stripe/config/stripe.php index 0bd9f32a79..11f0afb590 100644 --- a/packages/stripe/config/stripe.php +++ b/packages/stripe/config/stripe.php @@ -66,13 +66,13 @@ | Reference: https://stripe.com/docs/api/charges/object */ 'status_mapping' => [ - PaymentIntent::STATUS_REQUIRES_CAPTURE => 'requires-capture', - PaymentIntent::STATUS_CANCELED => 'cancelled', - PaymentIntent::STATUS_PROCESSING => 'processing', + PaymentIntent::STATUS_REQUIRES_CAPTURE => 'awaiting-payment', + PaymentIntent::STATUS_CANCELED => 'payment-failed', + PaymentIntent::STATUS_PROCESSING => 'awaiting-payment', PaymentIntent::STATUS_REQUIRES_ACTION => 'awaiting-payment', - PaymentIntent::STATUS_REQUIRES_CONFIRMATION => 'auth-pending', - PaymentIntent::STATUS_REQUIRES_PAYMENT_METHOD => 'failed', - PaymentIntent::STATUS_SUCCEEDED => 'payment-received', + PaymentIntent::STATUS_REQUIRES_CONFIRMATION => 'awaiting-payment', + PaymentIntent::STATUS_REQUIRES_PAYMENT_METHOD => 'payment-failed', + PaymentIntent::STATUS_SUCCEEDED => 'in-process', ], 'actions' => [ diff --git a/packages/table-rate-shipping/composer.json b/packages/table-rate-shipping/composer.json index c132768deb..239126b08c 100644 --- a/packages/table-rate-shipping/composer.json +++ b/packages/table-rate-shipping/composer.json @@ -36,7 +36,7 @@ } }, "require": { - "php": "^8.3", + "php": "^8.4", "lunarphp/admin": "self.version", "filament/filament": "^5.0" } diff --git a/packages/upgrade/composer.json b/packages/upgrade/composer.json index 52a02d2f2a..f54390317b 100644 --- a/packages/upgrade/composer.json +++ b/packages/upgrade/composer.json @@ -30,7 +30,7 @@ } }, "require": { - "php": "^8.3", + "php": "^8.4", "laravel/framework": "^12.0|^13.0", "laravel/prompts": "^0.1|^0.2|^0.3", "rector/rector": "^2.0" diff --git a/specs/0021-state-machines.md b/specs/0021-state-machines.md new file mode 100644 index 0000000000..ccf3f58d6e --- /dev/null +++ b/specs/0021-state-machines.md @@ -0,0 +1,419 @@ +# 0021 — State machines (and retiring soft-deletes) + +- Status: accepted +- Author: Glenn Jacobs +- Created: 2026-05-28 +- Revised: 2026-06-01 +- TODO item: "Implement state machines, replacing soft-deletes — products (draft, published, archived) & orders (payment, fulfilment and order status)" + +> **2026-06-01 revision.** §D–§K originally split the order into three coordinated machines (`payment_status` × `fulfilment_status` → a computed `order_status`). That decomposition was premature: `payment_status` is a rollup of `transactions` (which exist today) and `fulfilment_status` is a rollup of *order fulfilments* (a concept not yet built). Designing those rollups — and the combined headline derived from them — half here and half in the fulfilments spec is churn for no gain. So this PR walks the order work **back to a single status**, now done properly as the typed, transition-guarded `OrderState` machine that the rest of the spec is about. The payment/fulfilment decomposition, the derived combined status, and the per-fulfilment lifecycle all move to the **next spec** ("Order fulfilments & order status"), which can design them once with every source record on the table. `payment_status` / `fulfilment_status` columns, the `States/Order/{Payment,Fulfilment}` trees, `OrderStateConfig`'s resolver, `OrderStateCategory`, `Enums\OrderStatus`, and `computeOrderStatus()` are **dropped from this PR**. The PR is pre-merge, so the spec is edited in place rather than superseded; the already-committed three-machine code is reverted to match (see Migration impact). + +## Problem + +Lifecycle state across the package is spread between three uncoordinated mechanisms, none of which captures the actual rules: + +- **Free-form `status` strings.** `products.status` and `orders.status` are plain indexed strings (`2026_01_01_000031_create_products_table.php:15`, `2026_01_01_000028_create_orders_table.php:15`). Any value is accepted; there is no list of allowed states, no notion of which transitions are legal, and no place to hang per-state behaviour. `Order::statusLabel()` reaches into `config('lunar.orders.statuses')` (`Models/Order.php:93`) to translate the string back to a label. +- **A single boolean masquerading as state.** `Channel` casts an `enabled` column to bool (`Models/Channel.php:35`) even though no `enabled` column ships in the v2 channel migration — leftover from v1's enabled/disabled split. Collections inherit the same handle-keyed `enabled` flag idea on the customer-group pivot (`Models/Product.php:178-184`). Neither captures "draft vs published vs archived". +- **Soft-deletes used as a hidden lifecycle state.** `Product`, `ProductVariant`, `Channel`, `Cart`, and `Staff` use `SoftDeletes`. For products and channels in particular this is functioning as "archived" — rows are tombstoned, hidden from the storefront, but kept around for orders and reporting. That conflates two things (an admin lifecycle and a real deletion) and forces every query to remember `withTrashed()` semantics. +- **No transition events.** Nothing in core dispatches an event when an order's status changes, so notifications, webhook fan-out, and downstream cache invalidation each have to roll their own listener wired off Eloquent `updated`. + +Net effect: states are stringly-typed, illegal transitions cannot be prevented, and "archived" is smuggled in via soft-deletes. + +(Real-world orders also track payment and fulfilment progress as functions of underlying records — `transactions` today, order fulfilments soon. Deriving the order status from those is the **order-fulfilments spec**'s job, not this one; see the Proposal and the alternatives. This spec gets the single order status properly typed.) + +## Proposal + +Adopt `spatie/laravel-model-states` v2 for every lifecycle column in core. Replace soft-deletes used as archive flags with explicit `Archived` states. For orders, replace the free-form `status` string with a single typed, transition-guarded `OrderState` machine — the order lifecycle the merchant manages (`AwaitingPayment → InProcess → Shipped → Complete`, plus `OnHold` / `Cancelled` / `Refunded` / …). Decomposing that lifecycle into payment- and fulfilment-derived rollups is deferred to the next spec, where the order-fulfilment concept lands and the rollups have real source records. All state classes live under `Lunar\Core\States\…`. + +### A. Dependency + +Add `spatie/laravel-model-states: ^2` to `packages/core/composer.json`. Pinned to v2 because v1 lacks the typed `transitionableStates()` config that this spec relies on. + +### B. Directory layout under `packages/core/src/` + +``` +States/ +├── Channel/ +│ ├── ChannelState.php +│ ├── Active.php +│ └── Inactive.php +├── Product/ +│ ├── ProductState.php +│ ├── Draft.php +│ ├── Published.php +│ └── Archived.php +├── Collection/ +│ ├── CollectionState.php +│ ├── Draft.php +│ ├── Published.php +│ └── Archived.php +└── Order/ + ├── OrderState.php ← abstract base (single order lifecycle machine) + ├── OrderStateConfig.php ← contract, moves to Contracts/ (see §E) + ├── DefaultOrderStateConfig.php + └── Order/ ← concrete OrderState classes + ├── AwaitingPayment.php + ├── PaymentFailed.php + ├── Backordered.php + ├── InProcess.php + ├── PartiallyShipped.php + ├── Shipped.php + ├── Complete.php + ├── Returned.php + ├── Refunded.php + ├── OnHold.php + └── Cancelled.php +``` + +> **Next spec.** The order-fulfilments spec re-introduces `States/Order/Payment/*` and `States/Order/Fulfilment/*` (and `Enums/OrderStateCategory`), this time as rollups of `transactions` and `Fulfilment` records rather than columns the merchant sets, and converts this single `OrderState` into a status derived from them. Keep the `OrderState` concrete classes free of any `Order`-model coupling so the fulfilment-lifecycle classes can reuse the same base. + +Per spec 0013, contracts (`OrderStateConfig`) move to `Contracts/Orders/OrderStateConfig.php`; concrete config lives next to the states it composes (`States/Order/DefaultOrderStateConfig.php`). `States/` is a new top-level folder — spec 0013's folder list is extended to include it ("Spatie model-states classes; one subfolder per machine"). + +### C. Simple binary-ish machines + +#### Channel — `Active ⇄ Inactive` + +Default: `Active`. Transitions: `Active → Inactive`, `Inactive → Active`. + +Replaces the dead `enabled` bool cast (`Models/Channel.php:35`) and the `SoftDeletes` trait. Setting a channel to `Inactive` is what "disabled" meant in v1. + +#### Product — `Draft → Published ↔ Archived → Draft` + +Default: `Draft`. Transitions: + +- `Draft → Published` +- `Published → Archived` +- `Published → Draft` +- `Archived → Draft` + +(The prototype write-up named these `Draft / Active / Discontinued`; this spec renames to `Draft / Published / Archived` because "published" is the verb the admin already uses — see the existing `BulkPublish` / `BulkUnpublish` actions from spec 0009 — and "archived" matches the soft-delete behaviour it replaces. `$name` values: `draft`, `published`, `archived`.) + +#### Collection — same shape as Product + +Default: `Draft`. Same transitions. `$name`: `draft`, `published`, `archived`. + +#### Storefront filtering + +The storefront should treat `Published` as the only publicly visible state. Add a `whereVisible()` query scope on `Product` and `Collection` (concrete on `Models\Base`'s subclasses) that compiles to `where('status', Published::$name)` so callers do not have to import state classes for the common case. Existing callsites that filter on `withoutTrashed()` migrate to this scope. + +#### Cast wiring + +```php +// Models/Product.php +protected function casts(): array +{ + return [ + // ... + 'status' => ProductState::class, + ]; +} +``` + +PHPDoc: `@property ProductState $status` so PHPStan resolves `transitionTo()` correctly. + +### D. Order — a single lifecycle machine + +The order keeps **one** status column, replacing the v1 free-form string with a typed, transition-guarded `OrderState`. The merchant drives it by hand (or actions do, on payment/dispatch). Decomposition into payment/fulfilment rollups is the next spec's job. + +#### Column + +Baseline migration `2026_01_01_000028_create_orders_table.php` is edited in place (v2 is pre-release; same rule applied by specs 0017, 0018, 0019): + +- Keep the existing `string('status')->index()`; set its default to `awaiting-payment`. No `payment_status` / `fulfilment_status` / `order_status` columns are added in this PR. + +Factories under `packages/core/src/Database/Factories/OrderFactory.php` emit `status` and gain states for the common lifecycle points (`awaitingPayment()`, `inProcess()`, `shipped()`, `complete()`, `cancelled()`). + +#### `OrderState` (abstract) + +```php +abstract public function label(): string; +``` + +The single order lifecycle. `$name` values double as the stored column value and the translation-key suffix. + +| State | `$name` | +|--------------------|---------------------| +| `AwaitingPayment` | `awaiting-payment` | +| `PaymentFailed` | `payment-failed` | +| `Backordered` | `backordered` | +| `InProcess` | `in-process` | +| `PartiallyShipped` | `partially-shipped` | +| `Shipped` | `shipped` | +| `Complete` | `complete` | +| `Returned` | `returned` | +| `Refunded` | `refunded` | +| `OnHold` | `on-hold` | +| `Cancelled` | `cancelled` | + +Default: `AwaitingPayment`. Transitions are declared in `OrderStateConfig::orderTransitions()` (§E) so consumers can reshape the lifecycle without touching core. The shipped default graph: + +- `AwaitingPayment → InProcess, PaymentFailed, Backordered, OnHold, Cancelled` +- `PaymentFailed → AwaitingPayment, Cancelled` +- `Backordered → InProcess, OnHold, Cancelled` +- `InProcess → PartiallyShipped, Shipped, OnHold, Cancelled` +- `PartiallyShipped → Shipped, Returned, Cancelled` +- `Shipped → Complete, Returned` +- `Complete → Returned, Refunded` +- `Returned → Refunded` +- `OnHold → AwaitingPayment, InProcess, Cancelled` (resume) +- `Cancelled → Refunded` +- `Refunded` — terminal + +`PartiallyShipped` / `Backordered` are settable by hand here; the next spec makes them *derived* outcomes of the fulfilment rollup (a single fulfilment can't be "partially shipped"). PHPDoc on the base: `@property OrderState $status`. + +### E. `Contracts\OrderStateConfig` + +```php +namespace Lunar\Core\Contracts; + +use Lunar\Core\States\Order\OrderState; + +interface OrderStateConfig +{ + /** @return array> */ + public function orderStates(): array; + + /** @return array, list>> */ + public function orderTransitions(): array; + + /** @return class-string */ + public function defaultOrderState(): string; + + /** @return list> */ + public function notificationsFor(OrderState $state): array; +} +``` + +The abstract `OrderState` base reads the bound `OrderStateConfig` to register its states and transitions. This is the **single seam** for reshaping the order lifecycle: a downstream consumer extends `DefaultOrderStateConfig`, adds states + transitions, and binds the subclass in their service provider — the machine picks them up without any core change. + +```php +class MyOrderStateConfig extends DefaultOrderStateConfig +{ + public function orderStates(): array + { + return [...parent::orderStates(), AwaitingStock::class]; + } + + public function orderTransitions(): array + { + return [ + ...parent::orderTransitions(), + AwaitingPayment::class => [AwaitingStock::class, ...parent::orderTransitions()[AwaitingPayment::class]], + AwaitingStock::class => [InProcess::class, Cancelled::class], + ]; + } +} +``` + +Bind in a service provider's `register()` (not `boot()`) so the catalogue is in place before any model uses the state cast. Spatie's `State` base caches the resolved state mapping per class for the lifetime of the process — under Laravel Octane this cache survives between requests, so runtime rebinding is not supported. + +Bind in `LunarServiceProvider::registerServices()` (the spec 0016 home for non-action service bindings): + +```php +$this->app->bind(OrderStateConfig::class, DefaultOrderStateConfig::class); +``` + +`OrderStateConfig` is **not** an action — it stays out of `ActionServiceProvider::$actions`. Consumers swap the binding in their own provider to reshape the lifecycle. + +### F. Order — cast and label + +The status is a plain cast column; there is no resolver, no recompute, nothing to keep in sync. + +```php +// Models/Order.php +protected function casts(): array +{ + return [ + // ... + 'status' => OrderState::class, + ]; +} +``` + +PHPDoc: `@property OrderState $status`. `Order::statusLabel()` becomes a thin pass-through to `$this->status->label()`, kept for backwards compatibility. + +### G. `OrderObserver` — rewrite + +Replaces the existing `Observers/OrderObserver.php` (which only logs status-change activity today). + +A single status column means no resolver, no `saveQuietly()`/`forceFill()`, no recursion guard. The observer logs the change and dispatches the event. + +```php +public function updating(Order $order): void +{ + if ($order->isDirty('status')) { + activity() + ->causedBy(auth()->user()) + ->performedOn($order) + ->event('status-update') + ->withProperties([ + 'new' => $order->status->getValue(), + 'previous' => $order->getOriginal('status')->getValue(), + ]) + ->log('status-update'); + } +} + +public function updated(Order $order): void +{ + if ($order->wasChanged('status')) { + OrderStatusUpdated::dispatch( + $order, + $order->getOriginal('status'), + $order->status, + ); + } +} +``` + +Registration stays where it is — `LunarServiceProvider::bootingPackage()` already does `Order::observe(OrderObserver::class)`. + +### H. `Events\OrderStatusUpdated` + +```php +namespace Lunar\Core\Events; + +use Illuminate\Foundation\Events\Dispatchable; +use Lunar\Core\Models\Order; +use Lunar\Core\States\Order\OrderState; + +class OrderStatusUpdated +{ + use Dispatchable; + + public function __construct( + public Order $order, + public OrderState $previousStatus, + public OrderState $newStatus, + ) {} +} +``` + +It fires whenever the order's `status` changes — the single signal notifications and webhooks key off. + +### I. `Listeners\SendOrderStatusNotifications` + +```php +public function __construct( + protected OrderStateConfig $stateConfig, +) {} + +public function handle(OrderStatusUpdated $event): void +{ + foreach ($this->stateConfig->notificationsFor($event->newStatus) as $class) { + $event->order->notify(new $class($event->order)); + } +} +``` + +The listener resolves notifications through `OrderStateConfig::notificationsFor()` rather than reading `config()` directly — this keeps the binding the single seam for "everything about order states" and lets consumers move notification resolution out of config entirely by overriding the method (e.g. fetching from the database or from a feature-flagged map). + +`DefaultOrderStateConfig::notificationsFor()` reads `lunar.orders.notifications.{$state::$name}` so the common case stays a flat config key: + +```php +// config/orders.php +'notifications' => [ + 'shipped' => [App\Notifications\OrderShipped::class], + 'complete' => [App\Notifications\OrderComplete::class], +], +``` + +`Order` uses `Illuminate\Notifications\Notifiable` so `$order->notify(...)` works directly. Wired in `LunarServiceProvider::bootingPackage()`: + +```php +Event::listen(OrderStatusUpdated::class, SendOrderStatusNotifications::class); +``` + +### J. Retiring soft-deletes + +Soft-deletes were doing two jobs: keeping orphan references intact (a real concern) and hiding archived records (now the `Archived` state's job). They are unpicked per model: + +| Model | Today | After | +|------------------|----------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `Product` | `SoftDeletes` | Drop the trait, drop `deleted_at`/`deleted_at` index from the baseline. "Archived" handled by `Archived` state. Order lines hold a snapshot (existing `purchasable_*` denormalisation) so the FK becoming `nullOnDelete` is safe. | +| `ProductVariant` | `SoftDeletes` | Same — drop the trait, rely on `Archived` on the parent product plus order-line snapshot. | +| `Collection` | `SoftDeletes` | Drop the trait, drop `deleted_at`. `Archived` state replaces it. | +| `Channel` | `SoftDeletes` | Drop the trait, drop `deleted_at`. `Inactive` state replaces it. | +| `Cart` | `SoftDeletes` | **Keep.** Cart soft-delete is not lifecycle state — it lets order-tied carts hang around for replay/audit. Out of scope for this spec. | +| `Staff` | `SoftDeletes` | **Keep.** Auth concern, not commerce lifecycle. Out of scope. | + +Per the v2 baseline rule (pre-release), the relevant baseline migrations are edited in place to drop `softDeletes()` and the `deleted_at` index. Foreign keys that previously relied on tombstoned rows (`order_lines.purchasable_*`) are reviewed: where the lines already snapshot the data they need (`purchasable_type`, `description`, etc. on `OrderLine`), the morph can become unconstrained and the snapshot is authoritative. Any FK that does **not** have a snapshot stays as-is and we document the constraint. + +`Models\Concerns\HasMacros`, `LogsActivity`, etc. are untouched. + +### K. Public exposure of the state catalogue + +`OrderStateConfig::orderStates()` gives the admin and the storefront API a typed list of available order states. The Filament admin uses it to populate the status select input in place of the hardcoded `config('lunar.orders.statuses')` array, gated by `transitionableStates()` so the UI only offers legal next states. Filtering is a plain single-column `where('status', …)` — no derived-status scope is needed in this PR. + +## Alternatives considered + +- **Decompose the order status into `payment_status` + `fulfilment_status` machines in this PR** (the original §D–§F design). Deferred, not rejected outright. `payment_status` is a rollup of `transactions` and `fulfilment_status` a rollup of order fulfilments; modelling them as settable machines now — when fulfilments don't exist and the rollup rules can't be designed against real records — and then converting them to derived rollups next spec is decompose-then-recompose churn. The whole order-status story (per-fulfilment lifecycle, `payment_status` derived from transactions à la Shopify's `financial_status`, the combined headline) is told once in the fulfilments spec instead. For this PR a single typed lifecycle is enough. +- **Derive a combined status on read this PR** (resolver over payment × fulfilment). Same deferral: there is nothing to derive *from* until the payment/fulfilment rollups exist, which is next spec's scope. +- **Keep string statuses, add validation rules.** Rejected: validation gates the write but does nothing for transition enforcement. Spatie's model-states covers it and is in heavy use across the wider Laravel ecosystem. +- **Roll a bespoke state library.** Rejected: model-states v2 is small, well-typed, supports `saveQuietly()`/`forceFill()` cleanly, integrates with Eloquent casts, and is supported. Reinventing it is pure cost. +- **Keep soft-deletes, add a parallel `archived_at`.** Rejected: that gives you two ways to hide a record and forces every query to remember both. The point of the redesign is one explicit lifecycle column per model. +- **Leave the order `status` as a free-form string for now** (do only Product/Collection/Channel machines in this PR). Rejected: orders deserve the same typed, transition-guarded treatment as everything else, the concrete state classes already exist, and "single settable status now → derived status next spec" is a clean evolution rather than a decompose. Leaving the string would just defer the typing work twice. +- **Drop the `Cancelled` / `OnHold` states and use separate `cancelled_at` / `held_at` columns.** Rejected: it splits the lifecycle across nullable timestamp columns and means the admin has to consult several fields to know what an order is doing. A single `status` machine keeps the lifecycle in one place. + +## Migration impact + +- **Baseline migrations edited in place.** Per the spec 0019 precedent, v2 is unreleased and the baseline is still being shaped, so the relevant `2026_01_01_*` files are edited: + - `..._create_orders_table.php` — keep the single `status` column; set its default to `awaiting-payment`. No `payment_status` / `fulfilment_status` / `order_status` columns. **Revert** the already-committed three-column change (`payment_status` / `fulfilment_status` / `order_status` → single `status`). + - `..._create_products_table.php` — drop `softDeletes()` and the `deleted_at` index; `status` stays but now stores `ProductState::$name` values (`draft`, `published`, `archived`). + - `..._create_collections_table.php` — drop `softDeletes()`; add a `status` string column (collections do not have one today). + - `..._create_channels_table.php` — drop `softDeletes()`; add a `status` string column (channels do not have one today; the `enabled` cast in `Models/Channel.php:35` is removed alongside). + - Once v2 ships, this rule flips back: subsequent state additions go in new migrations. +- **No core data migration.** v2 has no live data to convert. +- **Data migration (stage 3, `packages/upgrade`).** Add one-way migrations to: + - Map v1 `products.status` values (`published` / `draft`) to v2 (`published` / `draft`); v1 trashed products → `archived`. Null the `deleted_at` column afterwards. ([[feedback-upgrade-migrations-no-down]]) + - Map v1 `channels.enabled` boolean → `status` (`active` / `inactive`); null `deleted_at`. + - Collections: trashed → `archived`, otherwise `published` (collections were not lifecycle-gated in v1). + - Map v1 `orders.status` values onto the `OrderState` `$name` set. Reasonable defaults: `awaiting-payment` → `awaiting-payment`; `payment-received` → `in-process`; `dispatched` → `shipped`; `cancelled` → `cancelled`; unknown/custom values fall back to `awaiting-payment`. The full mapping table is finalised in the upgrade package PR. (The payment/fulfilment split arrives with the order-fulfilments spec, with its own data migration off `transactions` and the new fulfilment records.) +- **Breaking changes to the public contract surface:** + - `Models\Order::$status` stays a single column but is now cast to `OrderState` (was a free-form string). PHPDoc and the `casts()` array change; assigning an arbitrary string breaks. + - `Models\Product`, `ProductVariant`, `Channel`, `Collection` lose `SoftDeletes`; any caller using `withTrashed()` / `onlyTrashed()` / `restore()` breaks. Rector rule: replace `->onlyTrashed()` with `->where('status', Archived::$name)` where the intent is "show archived". `->restore()` becomes `->status->transitionTo(Draft::class)` (or `Active::class` for channels). + - `Models\Channel` loses the `enabled` boolean cast. + - `Models\Order::statusLabel()` now delegates to `$order->status->label()`. The method is retained, so no rename is forced; callers reading the raw string get an `OrderState` instance instead (`(string) $order->status` / `$order->status->getValue()` for the name). + - `config('lunar.orders.statuses')` is no longer the source of truth for available statuses (the state classes are); notification configuration moves to `lunar.orders.notifications.{name}` and is resolved through `OrderStateConfig::notificationsFor()`. + - These are listed under §J of the `LunarSetList` in the `upgrade` package. +- **Upgrade path for v1.x consumers (stage 3).** Rector rules cover the renames/removals above. Data migration covers the column changes. Soft-delete removal is one-way — restoring trashed rows must happen *before* the v2 upgrade runs. +- **Translation / locale impact.** Each state's `label()` returns a translation key (e.g. `lunar::states.product.published`). New keys land in all 16 locales under each package's `resources/lang/` — English first, mirrored placeholders for the other 15. Affected keys: + - `states.product.{draft,published,archived}` + - `states.collection.{draft,published,archived}` + - `states.channel.{active,inactive}` + - `states.order.{awaiting-payment,payment-failed,backordered,in-process,partially-shipped,shipped,complete,returned,refunded,on-hold,cancelled}` (`OrderState` labels; payment/fulfilment keys arrive with the order-fulfilments spec) +- **Filament / admin impact.** The order resource keeps its single status badge and select, now typed: + - The status select is driven by `OrderStateConfig::orderStates()`, gated by `transitionableStates()` so the UI only offers legal next states; the badge renders `$order->status->label()`. + - "Place on hold" / "Cancel" actions transition `status` to `OnHold` / `Cancelled`; a "Resume" action transitions back out. + - The order-list "Status" filter is a plain `where('status', …)`. + - Product / collection list filters add a "Status" select (`Draft / Published / Archived`); the soft-delete filter is removed. + - Channel switcher hides `Inactive` channels by default. + - Verify end-to-end against the host app at `https://lunar-v2.test` (Herd) per the package convention. + +## Acceptance checks + +Feature and unit tests in `packages/lunar/tests/core/Unit/States/` and `tests/core/Feature/Orders/`: + +- Each simple machine (Channel, Product, Collection) has one passing test per allowed transition and one failing test (`CouldNotPerformTransition`) per disallowed transition; defaults assert on factory-created models. +- `OrderStateTransitionsTest` covers the §D default transition graph (one passing test per allowed transition) plus a couple of disallowed ones (e.g. `Cancelled → AwaitingPayment` and any transition out of `Refunded` throw `CouldNotPerformTransition`); the default `AwaitingPayment` asserts on a factory-created order. +- `DefaultOrderStateConfigTest` asserts `orderStates()`, `defaultOrderState()`, and `orderTransitions()` match §D, and that a subclass adding a state + transitions is picked up by the machine. +- Integration: changing `status` on a real `Order` dispatches `OrderStatusUpdated` exactly once with the previous/new `OrderState`; an unchanged save dispatches nothing. +- Integration: a transition not in the graph throws `CouldNotPerformTransition` and leaves `status` unchanged. +- Activity log: writing a new `status` produces a `status-update` entry with `new` / `previous` properties. +- `whereVisible()` on `Product` / `Collection` returns only `Published` rows. +- `ArchitectureTest` extension: every state class extends its abstract base and exposes a static `$name`; every `States/` subfolder has an `*State` abstract. + +## Open questions + +- **Notification config relocation** — moving from `lunar.orders.statuses` to `lunar.sales.orders.statuses` matches where `sales.orders.*` already lives in `packages/core/config/`. Confirm during implementation that no downstream package reads the old key directly. +- **Order-fulfilments handover** — the next spec converts this single `OrderState` into a status derived from `transactions` (payment) and `Fulfilment` records (fulfilment). Confirm the `OrderState` concrete classes stay free of `Order`-model coupling so the per-fulfilment lifecycle can reuse the abstract base, and that `OrderStateConfig` is the only thing that needs reshaping when the derivation lands. +- **Order-line FK on archived products** — `order_lines.purchasable_*` already snapshots the data needed to render historical orders, but a quick audit during stage 1 should confirm there is no read path that lazily re-resolves through the morph and assumes the product still exists. +- **Cart and Staff soft-deletes** — left in for now (§J). Whether `Cart` adopts its own state machine (`Active / Abandoned / Converted`) is a follow-up, not part of this spec. + +## References + +- [[0013-base-directory-reorganisation]] — `States/` is a new top-level folder following the same one-concern-per-folder rule; `Contracts/Orders/OrderStateConfig` follows the no-`Interface`-suffix rule. +- [[0016-service-layer-di]] — `OrderStateConfig` is bound in `LunarServiceProvider::registerServices()`; it is the single seam for reshaping the order lifecycle. +- [[0009-filament-actions-and-global-search]] — existing `BulkPublish` / `BulkUnpublish` / `BulkArchive` actions retarget onto `ProductState` transitions. +- `spatie/laravel-model-states` v2 — https://spatie.be/docs/laravel-model-states/v2 +- `packages/upgrade` — Rector rules and v1 → v2 data migrations for the column / API changes above. diff --git a/specs/README.md b/specs/README.md index 7ec027e491..350274b000 100644 --- a/specs/README.md +++ b/specs/README.md @@ -44,3 +44,4 @@ Each spec carries a `Status:` line in its frontmatter / header: | 0018 | Dedicated `name` / `description` / `short_description` fields | completed | | 0019 | Attribute system redesign | completed | | 0020 | Remove GetCandy migration command | completed | +| 0021 | State machines (and retiring soft-deletes) | completed | diff --git a/tests/admin/Feature/Filament/Resources/OrderResource/Pages/ManageOrderTest.php b/tests/admin/Feature/Filament/Resources/OrderResource/Pages/ManageOrderTest.php index 942e738704..b29ba9cef6 100644 --- a/tests/admin/Feature/Filament/Resources/OrderResource/Pages/ManageOrderTest.php +++ b/tests/admin/Feature/Filament/Resources/OrderResource/Pages/ManageOrderTest.php @@ -141,20 +141,24 @@ ->assertFileDownloaded("Order-{$this->order->reference}.pdf"); }); -it('can update order status', function () { - $status = collect(config('lunar.orders.statuses', [])) - ->keys() - ->reject(fn ($status) => $status == $this->order->status) - ->random(); +it('can place an order on hold', function () { + Livewire::test(ManageOrder::class, [ + 'record' => $this->order->getRouteKey(), + ]) + ->assertActionExists('place_on_hold') + ->callAction('place_on_hold'); + + expect((string) $this->order->refresh()->status)->toBe('on-hold'); +}); + +it('can resume an order from on-hold', function () { + $this->order->forceFill(['status' => 'on-hold'])->save(); Livewire::test(ManageOrder::class, [ 'record' => $this->order->getRouteKey(), ]) - ->assertActionExists('update_status') - ->callAction('update_status', [ - 'status' => $status, - ]); + ->assertActionExists('resume_order') + ->callAction('resume_order'); - expect($this->order->refresh()) - ->status->toBe($status); + expect((string) $this->order->refresh()->status)->toBe('awaiting-payment'); }); diff --git a/tests/admin/Feature/Filament/Resources/ProductResource/Pages/EditProductTest.php b/tests/admin/Feature/Filament/Resources/ProductResource/Pages/EditProductTest.php index 61b28e1f49..123c8145db 100644 --- a/tests/admin/Feature/Filament/Resources/ProductResource/Pages/EditProductTest.php +++ b/tests/admin/Feature/Filament/Resources/ProductResource/Pages/EditProductTest.php @@ -378,9 +378,9 @@ function createTranslatedRichTextProductAttribute(Product $product, string $hand Livewire::test(EditProduct::class, ['record' => $product->getRouteKey()]) ->assertSuccessful() - ->assertSee(__('lunarpanel::product.status.unpublished.content')) - ->assertDontSee(__('lunarpanel::product.status.availability.customer_groups')) - ->assertDontSee(__('lunarpanel::product.status.availability.channels')) - ->assertDontSee(__('lunarpanel::product.status.availability.hidden_from_guests')) - ->assertDontSee(__('lunarpanel::product.status.availability.no_default_customer_group')); + ->assertSee(__('lunar-filament::product.status.draft.content')) + ->assertDontSee(__('lunar-filament::product.status.availability.customer_groups')) + ->assertDontSee(__('lunar-filament::product.status.availability.channels')) + ->assertDontSee(__('lunar-filament::product.status.availability.hidden_from_guests')) + ->assertDontSee(__('lunar-filament::product.status.availability.no_default_customer_group')); }); diff --git a/tests/core/Unit/Actions/Carts/CreateOrderTest.php b/tests/core/Unit/Actions/Carts/CreateOrderTest.php index fdc97c668e..1cd062e4e0 100644 --- a/tests/core/Unit/Actions/Carts/CreateOrderTest.php +++ b/tests/core/Unit/Actions/Carts/CreateOrderTest.php @@ -209,7 +209,7 @@ function can_update_draft_order() $datacheck = [ 'user_id' => $cart->user_id, 'channel_id' => $cart->channel_id, - 'status' => config('lunar.orders.draft_status'), + 'status' => 'awaiting-payment', 'customer_reference' => null, 'sub_total' => $cart->subTotal->value, 'total' => $cart->total->value, @@ -342,7 +342,7 @@ function can_update_draft_order() 'user_id' => $cart->user_id, 'customer_id' => $cart->customer_id, 'channel_id' => $cart->channel_id, - 'status' => config('lunar.orders.draft_status'), + 'status' => 'awaiting-payment', 'customer_reference' => null, 'sub_total' => $cart->subTotal->value, 'total' => $cart->total->value, diff --git a/tests/core/Unit/Actions/Orders/MarkOrderAsShippedTest.php b/tests/core/Unit/Actions/Orders/MarkOrderAsShippedTest.php index f134783598..591715fb33 100644 --- a/tests/core/Unit/Actions/Orders/MarkOrderAsShippedTest.php +++ b/tests/core/Unit/Actions/Orders/MarkOrderAsShippedTest.php @@ -7,6 +7,7 @@ use Lunar\Core\Models\Currency; use Lunar\Core\Models\Language; use Lunar\Core\Models\Order; +use Lunar\Core\States\Order\Order\Shipped; use Lunar\Tests\Core\TestCase; uses(TestCase::class); @@ -17,24 +18,14 @@ Currency::factory()->create(['default' => true, 'decimal_places' => 2]); }); -test('marks the order as shipped using the configured status', function () { - Event::fake(); +test('transitions the order status to shipped', function () { + $order = Order::factory()->create(['status' => 'in-process']); - $order = Order::factory()->create(['status' => 'payment-received']); + Event::fake([OrderStatusUpdated::class]); app(MarkOrderAsShipped::class)->execute($order); - expect($order->fresh()->status)->toBe('dispatched'); + expect((string) $order->fresh()->status)->toBe('shipped'); - Event::assertDispatched(OrderStatusUpdated::class, fn (OrderStatusUpdated $event) => $event->status === 'dispatched'); -}); - -test('respects the configured shipped_status override', function () { - config(['lunar.orders.shipped_status' => 'payment-offline']); - - $order = Order::factory()->create(['status' => 'awaiting-payment']); - - app(MarkOrderAsShipped::class)->execute($order); - - expect($order->fresh()->status)->toBe('payment-offline'); + Event::assertDispatched(OrderStatusUpdated::class, fn (OrderStatusUpdated $event) => $event->newStatus instanceof Shipped); }); diff --git a/tests/core/Unit/Actions/Orders/UpdateOrderStatusTest.php b/tests/core/Unit/Actions/Orders/UpdateOrderStatusTest.php index 3249901b9b..146d66a8f4 100644 --- a/tests/core/Unit/Actions/Orders/UpdateOrderStatusTest.php +++ b/tests/core/Unit/Actions/Orders/UpdateOrderStatusTest.php @@ -8,6 +8,8 @@ use Lunar\Core\Models\Currency; use Lunar\Core\Models\Language; use Lunar\Core\Models\Order; +use Lunar\Core\States\Order\Order\AwaitingPayment; +use Lunar\Core\States\Order\Order\OnHold; use Lunar\Tests\Core\TestCase; uses(TestCase::class); @@ -19,30 +21,30 @@ }); test('updates the order status and fires an event', function () { - Event::fake(); - $order = Order::factory()->create(['status' => 'awaiting-payment']); - app(UpdateOrderStatus::class)->execute($order, 'payment-received'); + Event::fake([OrderStatusUpdated::class]); + + app(UpdateOrderStatus::class)->execute($order, 'on-hold'); - expect($order->fresh()->status)->toBe('payment-received'); + expect((string) $order->fresh()->status)->toBe('on-hold'); Event::assertDispatched(OrderStatusUpdated::class, fn (OrderStatusUpdated $event) => $event->order->is($order) - && $event->previousStatus === 'awaiting-payment' - && $event->status === 'payment-received'); + && $event->previousStatus instanceof AwaitingPayment + && $event->newStatus instanceof OnHold); }); test('does not fire an event when status is unchanged', function () { - Event::fake(); + $order = Order::factory()->create(['status' => 'on-hold']); - $order = Order::factory()->create(['status' => 'payment-received']); + Event::fake([OrderStatusUpdated::class]); - app(UpdateOrderStatus::class)->execute($order, 'payment-received'); + app(UpdateOrderStatus::class)->execute($order, 'on-hold'); Event::assertNotDispatched(OrderStatusUpdated::class); }); -test('throws when the status is not configured', function () { +test('throws when the status is not a registered OrderState', function () { $order = Order::factory()->create(['status' => 'awaiting-payment']); app(UpdateOrderStatus::class)->execute($order, 'not-a-real-status'); diff --git a/tests/core/Unit/Actions/Products/DuplicateProductTest.php b/tests/core/Unit/Actions/Products/DuplicateProductTest.php index 7d471cecd3..15c823c5f7 100644 --- a/tests/core/Unit/Actions/Products/DuplicateProductTest.php +++ b/tests/core/Unit/Actions/Products/DuplicateProductTest.php @@ -28,7 +28,7 @@ expect($duplicate)->toBeInstanceOf(Product::class); expect($duplicate->id)->not->toBe($product->id); - expect($duplicate->status)->toBe('draft'); + expect((string) $duplicate->status)->toBe('draft'); expect($duplicate->variants)->toHaveCount(1); expect($duplicate->variants->first()->sku)->toBe('WIDGET-1-copy'); diff --git a/tests/core/Unit/Actions/Products/UpdateProductStatusTest.php b/tests/core/Unit/Actions/Products/UpdateProductStatusTest.php index 1550db5a01..140157659b 100644 --- a/tests/core/Unit/Actions/Products/UpdateProductStatusTest.php +++ b/tests/core/Unit/Actions/Products/UpdateProductStatusTest.php @@ -25,7 +25,7 @@ app(UpdateProductStatus::class)->execute($product, 'published'); - expect($product->fresh()->status)->toBe('published'); + expect((string) $product->fresh()->status)->toBe('published'); Event::assertDispatched(ProductStatusUpdated::class); }); diff --git a/tests/core/Unit/Listeners/SendOrderStatusNotificationsTest.php b/tests/core/Unit/Listeners/SendOrderStatusNotificationsTest.php new file mode 100644 index 0000000000..a72cae18de --- /dev/null +++ b/tests/core/Unit/Listeners/SendOrderStatusNotificationsTest.php @@ -0,0 +1,56 @@ +create(['default' => true, 'code' => 'en']); + Currency::factory()->create(['default' => true]); +}); + +class FakeShippedNotification extends Notification +{ + public function __construct(public Order $order) {} + + public function via(): array + { + return ['mail']; + } +} + +test('notifications configured for a status are dispatched when the order enters that state', function () { + config([ + 'lunar.orders.notifications' => [ + 'in-process' => [FakeShippedNotification::class], + ], + ]); + + NotificationFacade::fake(); + + $order = Order::factory()->create(['status' => 'awaiting-payment']); + + $order->status->transitionTo(InProcess::class); + + NotificationFacade::assertSentTo($order->fresh(), FakeShippedNotification::class); +}); + +test('no notifications are dispatched when none are configured', function () { + config(['lunar.orders.notifications' => []]); + + NotificationFacade::fake(); + + $order = Order::factory()->create(['status' => 'awaiting-payment']); + + $order->status->transitionTo(InProcess::class); + + NotificationFacade::assertNothingSent(); +}); diff --git a/tests/core/Unit/Models/BrandTest.php b/tests/core/Unit/Models/BrandTest.php index 3c5891cce3..3e103d5e02 100644 --- a/tests/core/Unit/Models/BrandTest.php +++ b/tests/core/Unit/Models/BrandTest.php @@ -97,10 +97,10 @@ 'brand_id' => $brand->id, ]); - $trashedProduct = Product::factory()->create([ + $archivedProduct = Product::factory()->create([ 'brand_id' => $brand->id, + 'status' => 'archived', ]); - $trashedProduct->delete(); $discount = Discount::factory()->create(); $collection = Collection::factory()->create(); @@ -140,7 +140,7 @@ ]); assertDatabaseHas(Product::class, [ - 'id' => $trashedProduct->id, + 'id' => $archivedProduct->id, 'brand_id' => null, ]); }); diff --git a/tests/core/Unit/Models/CartLineTest.php b/tests/core/Unit/Models/CartLineTest.php index e7c71129a7..58b3b03172 100644 --- a/tests/core/Unit/Models/CartLineTest.php +++ b/tests/core/Unit/Models/CartLineTest.php @@ -32,7 +32,7 @@ $this->assertDatabaseHas((new CartLine)->getTable(), $data); }); -test('resolves a soft-deleted purchasable through the relation', function () { +test('returns null when the purchasable has been hard-deleted', function () { $cart = Cart::factory()->create([ 'user_id' => User::factory(), ]); @@ -48,11 +48,7 @@ $variant->delete(); - $line = $line->fresh(); - - expect($line->purchasable)->not->toBeNull(); - expect($line->purchasable->id)->toBe($variant->id); - expect($line->purchasable->trashed())->toBeTrue(); + expect($line->fresh()->purchasable)->toBeNull(); }); test('only purchasables can be added to a cart', function () { diff --git a/tests/core/Unit/Models/ChannelTest.php b/tests/core/Unit/Models/ChannelTest.php index 604fc6ef5d..99e47a16ed 100644 --- a/tests/core/Unit/Models/ChannelTest.php +++ b/tests/core/Unit/Models/ChannelTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Event; use Lunar\Core\Models\Channel; use Lunar\Core\Models\Discount; +use Lunar\Core\States\Channel\Inactive; use Lunar\Tests\Core\TestCase; uses(TestCase::class)->group('models'); @@ -56,13 +57,13 @@ expect($channel->refresh()->discounts)->toHaveCount(1); }); -test('can soft delete a channel', function () { +test('can mark a channel as inactive', function () { $channel = Channel::factory()->create(); - $channel->delete(); + $channel->status->transitionTo(Inactive::class); \Pest\Laravel\assertDatabaseHas(Channel::class, [ 'id' => $channel->id, - 'deleted_at' => now(), + 'status' => 'inactive', ]); }); diff --git a/tests/core/Unit/Models/Concerns/HasOrderHistoryTest.php b/tests/core/Unit/Models/Concerns/HasOrderHistoryTest.php new file mode 100644 index 0000000000..760d2a9f1e --- /dev/null +++ b/tests/core/Unit/Models/Concerns/HasOrderHistoryTest.php @@ -0,0 +1,64 @@ +create(['default' => true, 'code' => 'en']); + Currency::factory()->create(['default' => true]); +}); + +test('Product::hasOrderHistory is false when no variants have been ordered', function () { + $product = Product::factory()->create(); + ProductVariant::factory()->create(['product_id' => $product->id]); + + expect($product->hasOrderHistory())->toBeFalse(); +}); + +test('Product::hasOrderHistory is true once any variant appears on an order line', function () { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + OrderLine::factory()->create([ + 'purchasable_type' => $variant->getMorphClass(), + 'purchasable_id' => $variant->id, + ]); + + expect($product->hasOrderHistory())->toBeTrue(); +}); + +test('Product::hasOrderHistory ignores order lines for other products', function () { + $product = Product::factory()->create(); + ProductVariant::factory()->create(['product_id' => $product->id]); + + $otherVariant = ProductVariant::factory()->create(); + OrderLine::factory()->create([ + 'purchasable_type' => $otherVariant->getMorphClass(), + 'purchasable_id' => $otherVariant->id, + ]); + + expect($product->hasOrderHistory())->toBeFalse(); +}); + +test('Channel::hasOrderHistory is false when no orders reference the channel', function () { + $channel = Channel::factory()->create(); + + expect($channel->hasOrderHistory())->toBeFalse(); +}); + +test('Channel::hasOrderHistory is true once an order references it', function () { + $channel = Channel::factory()->create(); + Order::factory()->create(['channel_id' => $channel->id]); + + expect($channel->hasOrderHistory())->toBeTrue(); +}); diff --git a/tests/core/Unit/Models/OrderTest.php b/tests/core/Unit/Models/OrderTest.php index 55479d1745..0563c44902 100644 --- a/tests/core/Unit/Models/OrderTest.php +++ b/tests/core/Unit/Models/OrderTest.php @@ -11,6 +11,7 @@ use Lunar\Core\Models\OrderLine; use Lunar\Core\Models\ProductVariant; use Lunar\Core\Models\Transaction; +use Lunar\Core\States\Order\Order\InProcess; use Lunar\Core\ValueObjects\Cart\ShippingBreakdown; use Lunar\Core\ValueObjects\Cart\ShippingBreakdownItem; use Lunar\Core\ValueObjects\Cart\TaxBreakdown; @@ -64,7 +65,7 @@ $this->assertDatabaseHas((new Order)->getTable(), [ 'id' => $order->id, 'reference' => $order->reference, - 'status' => $order->status, + 'status' => (string) $order->status, 'sub_total' => $order->sub_total, 'tax_total' => $order->tax_total, 'total' => $order->total, @@ -119,16 +120,14 @@ test('can update status', function () { $order = Order::factory()->create([ 'user_id' => null, - 'status' => 'status_a', + 'status' => 'awaiting-payment', ]); - expect($order->status)->toEqual('status_a'); + expect((string) $order->status)->toEqual('awaiting-payment'); - $order->update([ - 'status' => 'status_b', - ]); + $order->status->transitionTo(InProcess::class); - expect($order->status)->toEqual('status_b'); + expect((string) $order->fresh()->status)->toEqual('in-process'); }); test('can create transaction for order', function () { @@ -138,7 +137,7 @@ $order = Order::factory()->create([ 'user_id' => null, - 'status' => 'status_a', + ]); expect($order->transactions)->toHaveCount(0); @@ -159,7 +158,7 @@ $order = Order::factory()->create([ 'user_id' => null, - 'status' => 'status_a', + ]); expect($order->transactions)->toHaveCount(0); diff --git a/tests/core/Unit/Models/ProductTest.php b/tests/core/Unit/Models/ProductTest.php index 5925e04341..9090beca71 100644 --- a/tests/core/Unit/Models/ProductTest.php +++ b/tests/core/Unit/Models/ProductTest.php @@ -402,25 +402,6 @@ expect(ProductAssociation::query()->where('product_parent_id', $target->id)->count())->toBe(0); }); -test('association target resolves a soft-deleted product', function () { - $parent = Product::factory()->create(); - $target = Product::factory()->create(); - - $association = ProductAssociation::factory()->create([ - 'product_parent_id' => $parent, - 'product_target_id' => $target, - 'type' => 'cross-sell', - ]); - - DB::table((new ProductAssociation)->getTable()) - ->where('id', $association->id) - ->update(['updated_at' => now()]); - Product::withoutEvents(fn () => $target->delete()); - - expect($association->fresh()->target)->not->toBeNull(); - expect($association->fresh()->target->trashed())->toBeTrue(); -}); - test('product can have custom association types', function () { $parent = Product::factory()->create(); $target = Product::factory()->create(); diff --git a/tests/core/Unit/Models/ProductVariantTest.php b/tests/core/Unit/Models/ProductVariantTest.php index e572b27e62..83257719a0 100644 --- a/tests/core/Unit/Models/ProductVariantTest.php +++ b/tests/core/Unit/Models/ProductVariantTest.php @@ -246,16 +246,6 @@ expect($genericProductVariant->pricing()->qty(20)->currency($currency)->get()->matched->priceIncTax()->value)->toEqual(9760); }); -test('reports unpurchasable when soft-deleted', function () { - $variant = ProductVariant::factory()->create(); - - expect($variant->isPurchasable())->toBeTrue(); - - $variant->delete(); - - expect($variant->fresh()->isPurchasable())->toBeFalse(); -}); - test('reports unpurchasable when the parent product is draft', function () { $product = Product::factory()->create(['status' => 'draft']); $variant = ProductVariant::factory()->create(['product_id' => $product->id]); @@ -267,13 +257,13 @@ expect($variant->fresh()->isPurchasable())->toBeTrue(); }); -test('reports unpurchasable when the parent product is soft-deleted', function () { +test('reports unpurchasable when the parent product is archived', function () { $product = Product::factory()->create(['status' => 'published']); $variant = ProductVariant::factory()->create(['product_id' => $product->id]); expect($variant->isPurchasable())->toBeTrue(); - $product->delete(); + $product->update(['status' => 'archived']); expect($variant->fresh()->isPurchasable())->toBeFalse(); }); diff --git a/tests/core/Unit/Observers/OrderObserverTest.php b/tests/core/Unit/Observers/OrderObserverTest.php index cd3b0ca1ff..b6281b1dbf 100644 --- a/tests/core/Unit/Observers/OrderObserverTest.php +++ b/tests/core/Unit/Observers/OrderObserverTest.php @@ -4,6 +4,8 @@ use Lunar\Core\Models\Currency; use Lunar\Core\Models\Language; use Lunar\Core\Models\Order; +use Lunar\Core\States\Order\Order\Cancelled; +use Lunar\Core\States\Order\Order\OnHold; use Lunar\Tests\Core\TestCase; use Spatie\Activitylog\Models\Activity; @@ -26,50 +28,43 @@ test('activity is logged when status changes', function () { activity()->enableLogging(); - $order = Order::factory()->create([ - 'status' => 'status-a', - ]); + $order = Order::factory()->create(); $this->assertDatabaseMissing((new Activity)->getTable(), [ 'subject_id' => $order->id, 'event' => 'status-update', ]); - $order->update([ - 'status' => 'status-b', - ]); + $order->forceFill(['status' => OnHold::$name])->save(); $this->assertDatabaseHas((new Activity)->getTable(), [ 'subject_id' => $order->id, 'event' => 'status-update', 'properties' => json_encode([ - 'new' => 'status-b', - 'previous' => 'status-a', + 'new' => 'on-hold', + 'previous' => 'awaiting-payment', ]), ]); - $order->update([ - 'status' => 'status-b', - ]); + $order->forceFill(['status' => OnHold::$name])->save(); $this->assertDatabaseMissing((new Activity)->getTable(), [ 'subject_id' => $order->id, 'event' => 'status-update', 'properties' => json_encode([ - 'new' => 'status-b', - 'previous' => 'status-b', + 'new' => 'on-hold', + 'previous' => 'on-hold', ]), ]); - $order->status = 'status-c'; - $order->save(); + $order->forceFill(['status' => Cancelled::$name])->save(); $this->assertDatabaseHas((new Activity)->getTable(), [ 'subject_id' => $order->id, 'event' => 'status-update', 'properties' => json_encode([ - 'new' => 'status-c', - 'previous' => 'status-b', + 'new' => 'cancelled', + 'previous' => 'on-hold', ]), ]); }); diff --git a/tests/core/Unit/Observers/ProductObserverTest.php b/tests/core/Unit/Observers/ProductObserverTest.php index 94d8f7bd3f..277b92e7e5 100644 --- a/tests/core/Unit/Observers/ProductObserverTest.php +++ b/tests/core/Unit/Observers/ProductObserverTest.php @@ -9,7 +9,7 @@ uses(RefreshDatabase::class); -test('soft deleting a product does not soft delete its variants', function () { +test('deleting a product deletes its variants', function () { $product = Product::factory()->create(); $variants = ProductVariant::factory(2)->create([ @@ -18,48 +18,9 @@ $product->delete(); - expect($product->fresh()->trashed())->toBeTrue(); + expect(Product::find($product->id))->toBeNull(); foreach ($variants as $variant) { - expect($variant->fresh()->trashed())->toBeFalse(); + expect(ProductVariant::find($variant->id))->toBeNull(); } }); - -test('restoring a product does not restore variants the user trashed manually', function () { - $product = Product::factory()->create(); - - $keptVariant = ProductVariant::factory()->create([ - 'product_id' => $product->id, - ]); - - $manuallyTrashedVariant = ProductVariant::factory()->create([ - 'product_id' => $product->id, - ]); - - $manuallyTrashedVariant->delete(); - - $product->delete(); - $product->restore(); - - expect($keptVariant->fresh()->trashed())->toBeFalse() - ->and($manuallyTrashedVariant->fresh()->trashed())->toBeTrue(); -}); - -test('force deleting a product force deletes its variants, including trashed ones', function () { - $product = Product::factory()->create(); - - $activeVariant = ProductVariant::factory()->create([ - 'product_id' => $product->id, - ]); - - $trashedVariant = ProductVariant::factory()->create([ - 'product_id' => $product->id, - ]); - - $trashedVariant->delete(); - - $product->forceDelete(); - - expect(ProductVariant::withTrashed()->find($activeVariant->id))->toBeNull() - ->and(ProductVariant::withTrashed()->find($trashedVariant->id))->toBeNull(); -}); diff --git a/tests/core/Unit/PaymentTypes/OfflinePaymentTypeTest.php b/tests/core/Unit/PaymentTypes/OfflinePaymentTypeTest.php index 29b58f7f7d..188492738e 100644 --- a/tests/core/Unit/PaymentTypes/OfflinePaymentTypeTest.php +++ b/tests/core/Unit/PaymentTypes/OfflinePaymentTypeTest.php @@ -18,7 +18,7 @@ $cart = Cart::factory()->create(); Config::set('lunar.payments.types.offline', [ - 'authorized' => 'offline-payment', + 'authorized' => 'in-process', ]); CartAddress::factory()->create([ @@ -53,7 +53,7 @@ $cart = Cart::factory()->create(); Config::set('lunar.payments.types.offline', [ - 'authorized' => 'offline-payment', + 'authorized' => 'in-process', ]); CartAddress::factory()->create([ @@ -77,19 +77,19 @@ ]); Payments::driver('offline')->cart($cart->refresh())->withData([ - 'authorized' => 'custom-status', + 'authorized' => 'in-process', ])->authorize(); $order = $cart->refresh()->completedOrder; - expect($order->status)->toBe('custom-status'); + expect((string) $order->status)->toBe('in-process'); }); test('can set additional meta', function () { $cart = Cart::factory()->create(); Config::set('lunar.payments.types.offline', [ - 'authorized' => 'offline-payment', + 'authorized' => 'in-process', ]); CartAddress::factory()->create([ diff --git a/tests/core/Unit/Search/ProductIndexerTest.php b/tests/core/Unit/Search/ProductIndexerTest.php index 46ad1a09b2..8fb3cdbb22 100644 --- a/tests/core/Unit/Search/ProductIndexerTest.php +++ b/tests/core/Unit/Search/ProductIndexerTest.php @@ -64,7 +64,7 @@ expect($data)->toHaveKey('id'); expect($data['skus'])->toBe([$variant->sku]); - expect($data['status'])->toEqual($product->status); + expect($data['status'])->toEqual((string) $product->status); expect($data['product_type'])->toEqual($product->productType->name); expect($data['brand'])->toEqual($product->brand?->name); expect($data)->toHaveKey($attributeA->handle); diff --git a/tests/core/Unit/States/Channel/ChannelStateTransitionsTest.php b/tests/core/Unit/States/Channel/ChannelStateTransitionsTest.php new file mode 100644 index 0000000000..2fe8baef5d --- /dev/null +++ b/tests/core/Unit/States/Channel/ChannelStateTransitionsTest.php @@ -0,0 +1,40 @@ +create(); + + expect($channel->status)->toBeInstanceOf(Active::class); +}); + +test('Active can transition to Inactive', function () { + $channel = Channel::factory()->create(); + + $channel->status->transitionTo(Inactive::class); + + expect($channel->fresh()->status)->toBeInstanceOf(Inactive::class); +}); + +test('Inactive can transition to Active', function () { + $channel = Channel::factory()->create(['status' => Inactive::$name]); + + $channel->status->transitionTo(Active::class); + + expect($channel->fresh()->status)->toBeInstanceOf(Active::class); +}); + +test('Active cannot transition to itself', function () { + $channel = Channel::factory()->create(); + + expect(fn () => $channel->status->transitionTo(Active::class)) + ->toThrow(CouldNotPerformTransition::class); +}); diff --git a/tests/core/Unit/States/Collection/CollectionStateTransitionsTest.php b/tests/core/Unit/States/Collection/CollectionStateTransitionsTest.php new file mode 100644 index 0000000000..67de480e83 --- /dev/null +++ b/tests/core/Unit/States/Collection/CollectionStateTransitionsTest.php @@ -0,0 +1,54 @@ +create(['default' => true, 'code' => 'en']); + Currency::factory()->create(['default' => true]); +}); + +test('Draft → Published', function () { + $collection = Collection::factory()->create(['status' => Draft::$name]); + $collection->status->transitionTo(Published::class); + expect($collection->fresh()->status)->toBeInstanceOf(Published::class); +}); + +test('Published → Archived', function () { + $collection = Collection::factory()->create(['status' => Published::$name]); + $collection->status->transitionTo(Archived::class); + expect($collection->fresh()->status)->toBeInstanceOf(Archived::class); +}); + +test('Draft → Archived', function () { + $collection = Collection::factory()->create(['status' => Draft::$name]); + $collection->status->transitionTo(Archived::class); + expect($collection->fresh()->status)->toBeInstanceOf(Archived::class); +}); + +test('transitioning to the same state is not allowed', function () { + $collection = Collection::factory()->create(['status' => Draft::$name]); + + expect(fn () => $collection->status->transitionTo(Draft::class)) + ->toThrow(CouldNotPerformTransition::class); +}); + +test('whereVisible returns only published collections', function () { + $published = Collection::factory()->create(['status' => Published::$name]); + Collection::factory()->create(['status' => Draft::$name]); + Collection::factory()->create(['status' => Archived::$name]); + + expect(Collection::query()->whereVisible()->get()->pluck('id')) + ->toContain($published->id) + ->and(Collection::query()->whereVisible()->count())->toBe(1); +}); diff --git a/tests/core/Unit/States/Order/DefaultOrderStateConfigTest.php b/tests/core/Unit/States/Order/DefaultOrderStateConfigTest.php new file mode 100644 index 0000000000..1dc125af85 --- /dev/null +++ b/tests/core/Unit/States/Order/DefaultOrderStateConfigTest.php @@ -0,0 +1,52 @@ +defaultOrderState())->toBe(AwaitingPayment::class); +}); + +test('every order state extends the OrderState base and exposes a name', function () { + $config = new DefaultOrderStateConfig; + + foreach ($config->orderStates() as $class) { + expect(is_subclass_of($class, OrderState::class))->toBeTrue() + ->and($class::$name)->toBeString()->not->toBeEmpty(); + } +}); + +test('every transition references registered states', function () { + $config = new DefaultOrderStateConfig; + $registered = $config->orderStates(); + + foreach ($config->orderTransitions() as $from => $tos) { + expect($registered)->toContain($from); + + foreach ($tos as $to) { + expect($registered)->toContain($to); + } + } +}); + +test('every registered state has a transition entry', function () { + $config = new DefaultOrderStateConfig; + $transitions = $config->orderTransitions(); + + foreach ($config->orderStates() as $class) { + expect($transitions)->toHaveKey($class); + } +}); + +test('every order state resolves a label', function () { + $config = new DefaultOrderStateConfig; + + foreach ($config->orderStates() as $class) { + expect((new $class(new Order))->label())->toBeString()->not->toBeEmpty(); + } +}); diff --git a/tests/core/Unit/States/Order/OrderStateConfigExtensionTest.php b/tests/core/Unit/States/Order/OrderStateConfigExtensionTest.php new file mode 100644 index 0000000000..3ba8b38436 --- /dev/null +++ b/tests/core/Unit/States/Order/OrderStateConfigExtensionTest.php @@ -0,0 +1,88 @@ +create(['default' => true, 'code' => 'en']); + Currency::factory()->create(['default' => true]); +}); + +/** + * Spatie's State base class caches the resolved state mapping in a private + * static array, populated the first time any code calls + * `BaseState::getStateMapping()`. In production this is fine — bindings are + * set during service-provider registration before any model uses the cast, + * so the cache fills with the host's catalogue. In tests that swap the + * binding mid-run, we have to flush. + */ +function flushSpatieStateMapping(): void +{ + $reflection = new ReflectionClass(State::class); + $property = $reflection->getProperty('stateMapping'); + $property->setAccessible(true); + $property->setValue(null, []); +} + +class AwaitingStock extends OrderState +{ + public static string $name = 'awaiting-stock'; + + public function label(): string + { + return 'Awaiting Stock'; + } +} + +class CustomOrderStateConfig extends DefaultOrderStateConfig +{ + public function orderStates(): array + { + return [...parent::orderStates(), AwaitingStock::class]; + } + + public function orderTransitions(): array + { + return [ + ...parent::orderTransitions(), + AwaitingPayment::class => [AwaitingStock::class, ...parent::orderTransitions()[AwaitingPayment::class]], + AwaitingStock::class => [InProcess::class, Cancelled::class], + ]; + } +} + +test('binding a custom OrderStateConfig adds a new order state to the machine', function () { + app()->bind(OrderStateConfig::class, CustomOrderStateConfig::class); + flushSpatieStateMapping(); + + $order = Order::factory()->create(['status' => 'awaiting-payment']); + + // The dev's new state is now an instantiable transition target — without + // changing any core code or swapping the Order model. + $order->status->transitionTo(AwaitingStock::class); + + expect($order->fresh()->status)->toBeInstanceOf(AwaitingStock::class); +}); + +test('a state from the custom catalog round-trips through the cast', function () { + app()->bind(OrderStateConfig::class, CustomOrderStateConfig::class); + flushSpatieStateMapping(); + + $order = Order::factory()->create(['status' => AwaitingStock::$name]); + + expect($order->fresh()->status)->toBeInstanceOf(AwaitingStock::class) + ->and((string) $order->fresh()->status)->toBe('awaiting-stock'); +}); diff --git a/tests/core/Unit/States/Order/OrderStateMachineTest.php b/tests/core/Unit/States/Order/OrderStateMachineTest.php new file mode 100644 index 0000000000..c21774de7d --- /dev/null +++ b/tests/core/Unit/States/Order/OrderStateMachineTest.php @@ -0,0 +1,90 @@ +create(['default' => true, 'code' => 'en']); + Currency::factory()->create(['default' => true]); +}); + +test('a new order defaults to awaiting payment', function () { + $order = Order::factory()->create(); + + expect($order->status)->toBeInstanceOf(AwaitingPayment::class); +}); + +test('an order transitions through the lifecycle', function () { + $order = Order::factory()->create(['status' => 'awaiting-payment']); + + $order->status->transitionTo(InProcess::class); + expect((string) $order->fresh()->status)->toBe('in-process'); + + $order->refresh()->status->transitionTo(Shipped::class); + expect((string) $order->fresh()->status)->toBe('shipped'); + + $order->refresh()->status->transitionTo(Complete::class); + expect((string) $order->fresh()->status)->toBe('complete'); +}); + +test('an illegal transition throws and leaves the status unchanged', function () { + $order = Order::factory()->create(['status' => 'cancelled']); + + expect(fn () => $order->status->transitionTo(AwaitingPayment::class)) + ->toThrow(CouldNotPerformTransition::class); + + expect((string) $order->fresh()->status)->toBe('cancelled'); +}); + +test('refunded is terminal', function () { + $order = Order::factory()->create(['status' => 'refunded']); + + expect(fn () => $order->status->transitionTo(Complete::class)) + ->toThrow(CouldNotPerformTransition::class); +}); + +test('changing status dispatches OrderStatusUpdated exactly once', function () { + $order = Order::factory()->create(['status' => 'awaiting-payment']); + + Event::fake([OrderStatusUpdated::class]); + + $order->status->transitionTo(InProcess::class); + + Event::assertDispatchedTimes(OrderStatusUpdated::class, 1); + Event::assertDispatched(OrderStatusUpdated::class, function (OrderStatusUpdated $event) { + return $event->previousStatus instanceof AwaitingPayment + && $event->newStatus instanceof InProcess; + }); +}); + +test('a save that does not change status dispatches nothing', function () { + $order = Order::factory()->create(['status' => 'in-process']); + + Event::fake([OrderStatusUpdated::class]); + + $order->update(['notes' => 'updated note']); + + Event::assertNotDispatched(OrderStatusUpdated::class); +}); + +test('cancelling an order is reflected in its status', function () { + $order = Order::factory()->create(['status' => 'in-process']); + + $order->status->transitionTo(Cancelled::class); + + expect((string) $order->fresh()->status)->toBe('cancelled'); +}); diff --git a/tests/core/Unit/States/Product/ProductStateTransitionsTest.php b/tests/core/Unit/States/Product/ProductStateTransitionsTest.php new file mode 100644 index 0000000000..097ba63b32 --- /dev/null +++ b/tests/core/Unit/States/Product/ProductStateTransitionsTest.php @@ -0,0 +1,80 @@ +create(['default' => true, 'code' => 'en']); + Currency::factory()->create(['default' => true]); +}); + +test('default state is Draft when no status given', function () { + // The factory sets status to 'published' by default; force it null first. + $product = Product::factory()->create(); + + expect((string) $product->status)->toBeIn(['draft', 'published']); +}); + +test('Draft → Published', function () { + $product = Product::factory()->create(['status' => Draft::$name]); + $product->status->transitionTo(Published::class); + expect($product->fresh()->status)->toBeInstanceOf(Published::class); +}); + +test('Published → Archived', function () { + $product = Product::factory()->create(['status' => Published::$name]); + $product->status->transitionTo(Archived::class); + expect($product->fresh()->status)->toBeInstanceOf(Archived::class); +}); + +test('Published → Draft', function () { + $product = Product::factory()->create(['status' => Published::$name]); + $product->status->transitionTo(Draft::class); + expect($product->fresh()->status)->toBeInstanceOf(Draft::class); +}); + +test('Archived → Draft', function () { + $product = Product::factory()->create(['status' => Archived::$name]); + $product->status->transitionTo(Draft::class); + expect($product->fresh()->status)->toBeInstanceOf(Draft::class); +}); + +test('Draft → Archived', function () { + $product = Product::factory()->create(['status' => Draft::$name]); + $product->status->transitionTo(Archived::class); + expect($product->fresh()->status)->toBeInstanceOf(Archived::class); +}); + +test('Archived → Published', function () { + $product = Product::factory()->create(['status' => Archived::$name]); + $product->status->transitionTo(Published::class); + expect($product->fresh()->status)->toBeInstanceOf(Published::class); +}); + +test('transitioning to the same state is not allowed', function () { + $product = Product::factory()->create(['status' => Draft::$name]); + + expect(fn () => $product->status->transitionTo(Draft::class)) + ->toThrow(CouldNotPerformTransition::class); +}); + +test('whereVisible returns only published products', function () { + Product::factory()->create(['status' => Draft::$name]); + $published = Product::factory()->create(['status' => Published::$name]); + Product::factory()->create(['status' => Archived::$name]); + + $visible = Product::query()->whereVisible()->get(); + + expect($visible)->toHaveCount(1) + ->and($visible->first()->id)->toBe($published->id); +}); diff --git a/tests/core/Unit/Validation/Cart/ValidateCartForOrderCreationTest.php b/tests/core/Unit/Validation/Cart/ValidateCartForOrderCreationTest.php index 74591d99a9..e91c37d900 100644 --- a/tests/core/Unit/Validation/Cart/ValidateCartForOrderCreationTest.php +++ b/tests/core/Unit/Validation/Cart/ValidateCartForOrderCreationTest.php @@ -257,7 +257,7 @@ } }); -test('fails when a cart line points at a soft-deleted purchasable', function () { +test('fails when a cart line points at a deleted purchasable', function () { $currency = Currency::factory()->create(); $taxClass = TaxClass::factory()->create(); @@ -289,13 +289,15 @@ $purchasable->delete(); + $line = $cart->lines()->first(); + $validator = (new ValidateCartForOrderCreation)->using( cart: $cart->fresh() ); $this->expectException(CartException::class); $this->expectExceptionMessage(__('lunar::exceptions.carts.line_unavailable', [ - 'identifier' => $purchasable->getIdentifier(), + 'identifier' => "#{$line->id}", ])); $validator->validate(); diff --git a/tests/core/Unit/Validation/CartLine/CartLineAvailabilityTest.php b/tests/core/Unit/Validation/CartLine/CartLineAvailabilityTest.php index 19db9ec793..e308473c00 100644 --- a/tests/core/Unit/Validation/CartLine/CartLineAvailabilityTest.php +++ b/tests/core/Unit/Validation/CartLine/CartLineAvailabilityTest.php @@ -146,10 +146,10 @@ expect(fn () => $validator->validate())->toThrow(CartException::class); }); -test('fails when the parent product is soft-deleted', function () { +test('fails when the parent product is archived', function () { $variant = ProductVariant::factory()->create(); - $variant->product->delete(); + $variant->product->update(['status' => 'archived']); $validator = (new CartLineAvailability)->using( cart: $this->cart,