diff --git a/TODO.md b/TODO.md index b095cff894..d8cf10320e 100644 --- a/TODO.md +++ b/TODO.md @@ -28,6 +28,7 @@ Items tagged _(judgement)_ are genuine line-calls worth revisiting. - Cache toolkit follow-ons — outbound webhooks, change feed / sync cursor, internal derived caches (ride spec 0043 events) - Cache-invalidation deferrals — media as a tracked satellite; a `CartLinesUpdated` event (from spec 0043) +- Stock events — `StockMovementRecorded`, a variant stock-changed event and the Scout re-index on `stock_available` change (spec 0038's unshipped events section; split out of spec 0065) - Location-scoped availability & stock routing — per-location stock selection + sell-time routing/splitting (follow-on to 0038) - Checkout stock reservations — hold stock via the `ReservesStock` seam at checkout start (follow-on to 0038) - Selling-policy rework — declarative model: deny-oversell, sell-against-incoming, continue-selling boolean (follow-on to 0038) @@ -83,3 +84,4 @@ Items tagged _(judgement)_ are genuine line-calls worth revisiting. - Per-attribute validation rules across both panels (spec 0062) - Standalone attributes surface in the Filament admin (spec 0063) - Long-lived worker safety — scoped lifetimes for per-visitor service state (spec 0064) +- Port the v1.5.0 fixes into v2 (spec 0065) diff --git a/packages/admin/resources/lang/ar/global.php b/packages/admin/resources/lang/ar/global.php index a0febf3705..84b292a1a2 100644 --- a/packages/admin/resources/lang/ar/global.php +++ b/packages/admin/resources/lang/ar/global.php @@ -7,4 +7,8 @@ 'reports' => 'التقارير', 'settings' => 'الإعدادات', ], + + 'yes' => 'نعم', + 'no' => 'لا', + ]; diff --git a/packages/admin/resources/lang/bg/global.php b/packages/admin/resources/lang/bg/global.php index 241a10bfb5..1efad9b09a 100644 --- a/packages/admin/resources/lang/bg/global.php +++ b/packages/admin/resources/lang/bg/global.php @@ -7,4 +7,8 @@ 'reports' => 'Отчети', 'settings' => 'Настройки', ], + + 'yes' => 'Да', + 'no' => 'Не', + ]; diff --git a/packages/admin/resources/lang/de/global.php b/packages/admin/resources/lang/de/global.php index b3507c6537..885ac42e35 100644 --- a/packages/admin/resources/lang/de/global.php +++ b/packages/admin/resources/lang/de/global.php @@ -7,4 +7,8 @@ 'reports' => 'Berichte', 'settings' => 'Einstellungen', ], + + 'yes' => 'Ja', + 'no' => 'Nein', + ]; diff --git a/packages/admin/resources/lang/en/global.php b/packages/admin/resources/lang/en/global.php index 3b06a0c094..db5a41b34a 100644 --- a/packages/admin/resources/lang/en/global.php +++ b/packages/admin/resources/lang/en/global.php @@ -9,4 +9,7 @@ 'settings' => 'Settings', ], + 'yes' => 'Yes', + 'no' => 'No', + ]; diff --git a/packages/admin/resources/lang/es/global.php b/packages/admin/resources/lang/es/global.php index 4fa2a64d35..3530affbe5 100644 --- a/packages/admin/resources/lang/es/global.php +++ b/packages/admin/resources/lang/es/global.php @@ -7,4 +7,8 @@ 'reports' => 'Informes', 'settings' => 'Configuraciones', ], + + 'yes' => 'Sí', + 'no' => 'No', + ]; diff --git a/packages/admin/resources/lang/fa/global.php b/packages/admin/resources/lang/fa/global.php index 86c67d065e..1942f79322 100644 --- a/packages/admin/resources/lang/fa/global.php +++ b/packages/admin/resources/lang/fa/global.php @@ -7,4 +7,8 @@ 'reports' => 'Reports', 'settings' => 'Settings', ], + + 'yes' => 'بله', + 'no' => 'خیر', + ]; diff --git a/packages/admin/resources/lang/fr/global.php b/packages/admin/resources/lang/fr/global.php index ba730a9966..b5b9ff406f 100644 --- a/packages/admin/resources/lang/fr/global.php +++ b/packages/admin/resources/lang/fr/global.php @@ -7,4 +7,8 @@ 'reports' => 'Rapports', 'settings' => 'Paramètres', ], + + 'yes' => 'Oui', + 'no' => 'Non', + ]; diff --git a/packages/admin/resources/lang/hr/global.php b/packages/admin/resources/lang/hr/global.php index 616ccdebb2..1746e7b8f2 100644 --- a/packages/admin/resources/lang/hr/global.php +++ b/packages/admin/resources/lang/hr/global.php @@ -7,4 +7,8 @@ 'reports' => 'Izvještaji', 'settings' => 'Postavke', ], + + 'yes' => 'Da', + 'no' => 'Ne', + ]; diff --git a/packages/admin/resources/lang/hu/global.php b/packages/admin/resources/lang/hu/global.php index e4864827a9..bd399d144d 100644 --- a/packages/admin/resources/lang/hu/global.php +++ b/packages/admin/resources/lang/hu/global.php @@ -7,4 +7,8 @@ 'reports' => 'Jelentések', 'settings' => 'Beállítások', ], + + 'yes' => 'Igen', + 'no' => 'Nem', + ]; diff --git a/packages/admin/resources/lang/mn/global.php b/packages/admin/resources/lang/mn/global.php index b8643ea28b..892879f69f 100644 --- a/packages/admin/resources/lang/mn/global.php +++ b/packages/admin/resources/lang/mn/global.php @@ -7,4 +7,8 @@ 'reports' => 'Тайлан', 'settings' => 'Тохиргоо', ], + + 'yes' => 'Тийм', + 'no' => 'Үгүй', + ]; diff --git a/packages/admin/resources/lang/nl/global.php b/packages/admin/resources/lang/nl/global.php index 9a32bfda0e..70a63213c3 100644 --- a/packages/admin/resources/lang/nl/global.php +++ b/packages/admin/resources/lang/nl/global.php @@ -7,4 +7,8 @@ 'reports' => 'Rapporten', 'settings' => 'Instellingen', ], + + 'yes' => 'Ja', + 'no' => 'Nee', + ]; diff --git a/packages/admin/resources/lang/pl/global.php b/packages/admin/resources/lang/pl/global.php index ea9a649922..822d2d8e5f 100644 --- a/packages/admin/resources/lang/pl/global.php +++ b/packages/admin/resources/lang/pl/global.php @@ -7,4 +7,8 @@ 'reports' => 'Raporty', 'settings' => 'Ustawienia', ], + + 'yes' => 'Tak', + 'no' => 'Nie', + ]; diff --git a/packages/admin/resources/lang/pt_BR/global.php b/packages/admin/resources/lang/pt_BR/global.php index 83357873c1..1231bb7e2c 100644 --- a/packages/admin/resources/lang/pt_BR/global.php +++ b/packages/admin/resources/lang/pt_BR/global.php @@ -7,4 +7,8 @@ 'reports' => 'Relatórios', 'settings' => 'Configurações', ], + + 'yes' => 'Sim', + 'no' => 'Não', + ]; diff --git a/packages/admin/resources/lang/ro/global.php b/packages/admin/resources/lang/ro/global.php index 348827570a..7b156a0974 100644 --- a/packages/admin/resources/lang/ro/global.php +++ b/packages/admin/resources/lang/ro/global.php @@ -7,4 +7,8 @@ 'reports' => 'Rapoarte', 'settings' => 'Setări', ], + + 'yes' => 'Da', + 'no' => 'Nu', + ]; diff --git a/packages/admin/resources/lang/tr/global.php b/packages/admin/resources/lang/tr/global.php index 5f5af46c49..bf66b79430 100644 --- a/packages/admin/resources/lang/tr/global.php +++ b/packages/admin/resources/lang/tr/global.php @@ -7,4 +7,8 @@ 'reports' => 'Raporlar', 'settings' => 'Ayarlar', ], + + 'yes' => 'Evet', + 'no' => 'Hayır', + ]; diff --git a/packages/admin/resources/lang/vi/global.php b/packages/admin/resources/lang/vi/global.php index 73a76cc600..03c7441c4e 100644 --- a/packages/admin/resources/lang/vi/global.php +++ b/packages/admin/resources/lang/vi/global.php @@ -7,4 +7,8 @@ 'reports' => 'Báo cáo', 'settings' => 'Cài đặt', ], + + 'yes' => 'Có', + 'no' => 'Không', + ]; diff --git a/packages/admin/resources/views/pdf/order.blade.php b/packages/admin/resources/views/pdf/order.blade.php index 7cda19b783..68f2327c91 100644 --- a/packages/admin/resources/views/pdf/order.blade.php +++ b/packages/admin/resources/views/pdf/order.blade.php @@ -176,7 +176,7 @@ - @foreach($record->fulfillableLines as $line) + @foreach($record->productLines as $line) {{ $line->description }}
diff --git a/packages/admin/resources/views/resources/product-resource/widgets/product-options.blade.php b/packages/admin/resources/views/resources/product-resource/widgets/product-options.blade.php deleted file mode 100644 index 09855ca916..0000000000 --- a/packages/admin/resources/views/resources/product-resource/widgets/product-options.blade.php +++ /dev/null @@ -1,247 +0,0 @@ - - - @if(!$this->configuringOptions) -
-
-
-
-

- {{ __('lunarpanel::productoption.widgets.product-options.options-table.title') }} -

-
-
- - {{ __('lunarpanel::productoption.widgets.product-options.options-table.configure-options.label') }} - -
-
-
- @if(count($this->configuredOptions)) - - - - - - - - - @foreach($this->configuredOptions as $option) - - - - - @endforeach - -
- - {{ __('lunarpanel::productoption.widgets.product-options.options-table.table.option.label') }} - - - - {{ __('lunarpanel::productoption.widgets.product-options.options-table.table.values.label') }} - -
-
- - {{ $option['value'] }} - -
-
-
- - {{ collect($option['option_values']) - ->filter( - fn ($value) => $value['enabled'] - )->map( - fn ($value) => $value['value'] - )->join(', ') }} - -
-
- @else -
-
-
- {{ \Filament\Support\generate_icon_html('lucide-shapes', size: \Filament\Support\Enums\IconSize::Large) }} -
-

No Product Options Configured

-
-
- @endif -
-
- -
-
-
-

- {{ __('lunarpanel::productoption.widgets.product-options.variants-table.title') }} -

-
-
-
- @if(count($this->variants)) - - - - @if($this->hasNewVariants) - - @endif - - - - - - - - - - @foreach($this->variants as $permutationIndex => $permutation) - - @if($this->hasNewVariants) - - @endif - - - - - - - - @endforeach - -
- - - {{ __('lunarpanel::productoption.widgets.product-options.variants-table.table.option.label') }} - - - - {{ __('lunarpanel::productoption.widgets.product-options.variants-table.table.sku.label') }} - - - - {{ __('lunarpanel::productoption.widgets.product-options.variants-table.table.price.label') }} - - - - {{ __('lunarpanel::productoption.widgets.product-options.variants-table.table.stock.label') }} - - -
-
- @if(!$permutation['variant_id']) - - {{ __('lunarpanel::productoption.widgets.product-options.variants-table.table.new.label') }} - - @endif -
-
-
- - @foreach($permutation['values'] as $option => $value) - {{ $option }}: {{ $value }} - @endforeach - -
-
-
- - - -
-
-
- - - -
-
-
- - - -
-
-
- @if($permutation['variant_id']) - - {{ __('lunarpanel::productoption.widgets.product-options.variants-table.actions.edit.label') }} - - @endif - -
-
- @else -
-
-
- {{ \Filament\Support\generate_icon_html('lucide-shapes', size: \Filament\Support\Enums\IconSize::Large) }} -
-

{{ __('lunarpanel::productoption.widgets.product-options.variants-table.empty.heading') }}

-
-
- @endif -
-
-
- -
- {{ $this->saveVariantsAction }} -
- - @else -
-
-
- - {{ __('lunarpanel::productoption.widgets.product-options.actions.add-restricted-option.label') }} - - {{ $this->addSharedOptionAction }} -
-
- @if(!count($this->configuredOptions)) -
-
-
-
- {{ \Filament\Support\generate_icon_html('lucide-shapes', size: \Filament\Support\Enums\IconSize::Large) }} -
-

{{ __('lunarpanel::productoption.widgets.product-options.options-list.empty.heading') }}

-

{{ __('lunarpanel::productoption.widgets.product-options.options-list.empty.description') }}

-
-
-
- @else -
- -
- @endif - -
- - {{ __('lunarpanel::productoption.widgets.product-options.actions.save-options.label') }} - - - {{ __('lunarpanel::productoption.widgets.product-options.actions.cancel.label') }} - -
-
- - @endif -
diff --git a/packages/admin/src/Filament/Resources/OrderResource/Pages/ManageOrder.php b/packages/admin/src/Filament/Resources/OrderResource/Pages/ManageOrder.php index 5e6f4d9e05..2a05c5f09b 100644 --- a/packages/admin/src/Filament/Resources/OrderResource/Pages/ManageOrder.php +++ b/packages/admin/src/Filament/Resources/OrderResource/Pages/ManageOrder.php @@ -201,7 +201,9 @@ public static function getDefaultAdditionalInfoSection(): Section } return TextEntry::make('meta_'.$key) - ->getStateUsing(fn () => $value) + ->getStateUsing(fn () => is_bool($value) + ? __($value ? 'lunarpanel::global.yes' : 'lunarpanel::global.no') + : $value) ->label($key) ->copyable() ->limit(50)->tooltip(function (TextEntry $component): ?string { diff --git a/packages/admin/src/LunarPanelManager.php b/packages/admin/src/LunarPanelManager.php index 89cdb843e4..60480e49f0 100644 --- a/packages/admin/src/LunarPanelManager.php +++ b/packages/admin/src/LunarPanelManager.php @@ -5,6 +5,9 @@ use Closure; use Filament\Auth\MultiFactor\App\AppAuthentication; use Filament\Facades\Filament; +use Filament\Forms\Components\TagsInput; +use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; @@ -201,6 +204,11 @@ public function register(): self Grid::configureUsing(fn (Grid $grid) => $grid->columnSpanFull()); Fieldset::configureUsing(fn (Fieldset $fieldset) => $fieldset->columnSpanFull()); + // Livewire skips the TrimStrings middleware, so trim at the form layer instead. + TextInput::configureUsing(fn (TextInput $input) => $input->trim()); + Textarea::configureUsing(fn (Textarea $textarea) => $textarea->trim()); + TagsInput::configureUsing(fn (TagsInput $tagsInput) => $tagsInput->trim()); + return $this; } diff --git a/packages/admin/src/Support/RelationManagers/MediaRelationManager.php b/packages/admin/src/Support/RelationManagers/MediaRelationManager.php index 917064df0e..a4afac8a14 100644 --- a/packages/admin/src/Support/RelationManagers/MediaRelationManager.php +++ b/packages/admin/src/Support/RelationManagers/MediaRelationManager.php @@ -98,16 +98,24 @@ public function getDefaultTable(Table $table): Table ->usingFileName( $data['media']->getClientOriginalName() ) - ->withCustomProperties([ - 'name' => $data['custom_properties']['name'], - 'primary' => $data['custom_properties']['primary'], - ]) + ->withCustomProperties($data['custom_properties'] ?? []) ->preservingOriginal() ->toMediaCollection($this->mediaCollection); }), ]) ->recordActions([ - EditAction::make(), + EditAction::make() + ->mutateDataUsing(function (array $data, Media $record): array { + // The column is array-cast, so writing the submitted set + // wholesale drops any key this form did not render — an + // extension's own properties, for instance. + $data['custom_properties'] = array_merge( + $record->custom_properties ?? [], + $data['custom_properties'] ?? [], + ); + + return $data; + }), DeleteAction::make(), Action::make('view_open') ->label(__('lunarpanel::relationmanagers.medias.actions.view.label')) diff --git a/packages/core/resources/lang/ar/auth.php b/packages/core/resources/lang/ar/auth.php index 060410244e..7dafa3d5bd 100644 --- a/packages/core/resources/lang/ar/auth.php +++ b/packages/core/resources/lang/ar/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'السماح للموظف بإدارة العملاء', 'permissions.sales:manage-discounts.label' => 'إدارة الخصومات', 'permissions.sales:manage-discounts.description' => 'السماح للموظف بإدارة الخصومات', + 'permissions.shipping:manage.label' => 'إدارة الشحن', + 'permissions.shipping:manage.description' => 'السماح للموظف بإدارة الشحن', ]; diff --git a/packages/core/resources/lang/bg/auth.php b/packages/core/resources/lang/bg/auth.php index 109a64a8b7..71d4a7beb4 100644 --- a/packages/core/resources/lang/bg/auth.php +++ b/packages/core/resources/lang/bg/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Позволява на член на персонала да управлява клиенти', 'permissions.sales:manage-discounts.label' => 'Управление на отстъпки', 'permissions.sales:manage-discounts.description' => 'Позволява на член на персонала да управлява отстъпки', + 'permissions.shipping:manage.label' => 'Управление на доставки', + 'permissions.shipping:manage.description' => 'Позволява на член на персонала да управлява доставките', ]; diff --git a/packages/core/resources/lang/de/auth.php b/packages/core/resources/lang/de/auth.php index 38a036b29a..675e1e9f68 100644 --- a/packages/core/resources/lang/de/auth.php +++ b/packages/core/resources/lang/de/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Erlaubt dem Mitarbeiter, Kunden zu verwalten', 'permissions.sales:manage-discounts.label' => 'Rabatte verwalten', 'permissions.sales:manage-discounts.description' => 'Erlaubt dem Mitarbeiter, Rabatte zu verwalten', + 'permissions.shipping:manage.label' => 'Versand verwalten', + 'permissions.shipping:manage.description' => 'Erlaubt dem Mitarbeiter, den Versand zu verwalten', ]; diff --git a/packages/core/resources/lang/en/auth.php b/packages/core/resources/lang/en/auth.php index 2271ab451a..98341de58c 100644 --- a/packages/core/resources/lang/en/auth.php +++ b/packages/core/resources/lang/en/auth.php @@ -29,4 +29,6 @@ 'permissions.sales:manage-customers.description' => 'Allow the staff member to manage customers', 'permissions.sales:manage-discounts.label' => 'Manage Discounts', 'permissions.sales:manage-discounts.description' => 'Allow the staff member to manage discounts', + 'permissions.shipping:manage.label' => 'Manage Shipping', + 'permissions.shipping:manage.description' => 'Allow the staff member to manage shipping', ]; diff --git a/packages/core/resources/lang/es/auth.php b/packages/core/resources/lang/es/auth.php index 6b20a447b6..3c487b2a7d 100644 --- a/packages/core/resources/lang/es/auth.php +++ b/packages/core/resources/lang/es/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Permite al miembro del personal gestionar clientes', 'permissions.sales:manage-discounts.label' => 'Gestionar Descuentos', 'permissions.sales:manage-discounts.description' => 'Permite al miembro del personal gestionar descuentos', + 'permissions.shipping:manage.label' => 'Gestionar Envíos', + 'permissions.shipping:manage.description' => 'Permite al miembro del personal gestionar los envíos', ]; diff --git a/packages/core/resources/lang/fa/auth.php b/packages/core/resources/lang/fa/auth.php index 14a21c579f..27a6ada57b 100644 --- a/packages/core/resources/lang/fa/auth.php +++ b/packages/core/resources/lang/fa/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Allow the staff member to manage customers', 'permissions.sales:manage-discounts.label' => 'Manage Discounts', 'permissions.sales:manage-discounts.description' => 'Allow the staff member to manage discounts', + 'permissions.shipping:manage.label' => 'مدیریت حمل و نقل', + 'permissions.shipping:manage.description' => 'به کارمند اجازه می‌دهد حمل و نقل را مدیریت کند', ]; diff --git a/packages/core/resources/lang/fr/auth.php b/packages/core/resources/lang/fr/auth.php index 1c578b496f..a46b587f5e 100644 --- a/packages/core/resources/lang/fr/auth.php +++ b/packages/core/resources/lang/fr/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Permet au membre du personnel de gérer les clients', 'permissions.sales:manage-discounts.label' => 'Gérer les remises', 'permissions.sales:manage-discounts.description' => 'Permet au membre du personnel de gérer les remises', + 'permissions.shipping:manage.label' => 'Gérer l\'expédition', + 'permissions.shipping:manage.description' => 'Permet au membre du personnel de gérer l\'expédition', ]; diff --git a/packages/core/resources/lang/hr/auth.php b/packages/core/resources/lang/hr/auth.php index c8aeda7444..558d2a5f48 100644 --- a/packages/core/resources/lang/hr/auth.php +++ b/packages/core/resources/lang/hr/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Omogućuje osoblju upravljanje kupcima', 'permissions.sales:manage-discounts.label' => 'Upravljanje popustima', 'permissions.sales:manage-discounts.description' => 'Omogućuje osoblju upravljanje popustima', + 'permissions.shipping:manage.label' => 'Upravljanje dostavom', + 'permissions.shipping:manage.description' => 'Omogućuje osoblju upravljanje dostavom', ]; diff --git a/packages/core/resources/lang/hu/auth.php b/packages/core/resources/lang/hu/auth.php index e4cb5dc8c6..094b96b49b 100644 --- a/packages/core/resources/lang/hu/auth.php +++ b/packages/core/resources/lang/hu/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Engedélyezi a vásárlók kezelését', 'permissions.sales:manage-discounts.label' => 'Kedvezmények kezelése', 'permissions.sales:manage-discounts.description' => 'Engedélyezi a kedvezmények kezelését', + 'permissions.shipping:manage.label' => 'Szállítás kezelése', + 'permissions.shipping:manage.description' => 'Engedélyezi a szállítás kezelését', ]; diff --git a/packages/core/resources/lang/mn/auth.php b/packages/core/resources/lang/mn/auth.php index 7f9cc2c413..2dc577b66d 100644 --- a/packages/core/resources/lang/mn/auth.php +++ b/packages/core/resources/lang/mn/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Харилцагчдыг удирдах эрх олгоно', 'permissions.sales:manage-discounts.label' => 'Хөнгөлөлтүүдийн удирдлага', 'permissions.sales:manage-discounts.description' => 'Хөнгөлөлтүүдийг удирдах эрх олгоно', + 'permissions.shipping:manage.label' => 'Хүргэлтийн удирдлага', + 'permissions.shipping:manage.description' => 'Хүргэлтийг удирдах эрх олгоно', ]; diff --git a/packages/core/resources/lang/nl/auth.php b/packages/core/resources/lang/nl/auth.php index c46704ba64..48ccbe38a7 100644 --- a/packages/core/resources/lang/nl/auth.php +++ b/packages/core/resources/lang/nl/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Sta het personeelslid toe om klanten te beheren', 'permissions.sales:manage-discounts.label' => 'Beheer Kortingen', 'permissions.sales:manage-discounts.description' => 'Sta het personeelslid toe om kortingen te beheren', + 'permissions.shipping:manage.label' => 'Beheer Verzending', + 'permissions.shipping:manage.description' => 'Sta het personeelslid toe om verzending te beheren', ]; diff --git a/packages/core/resources/lang/pl/auth.php b/packages/core/resources/lang/pl/auth.php index 42c93958d5..1660cef359 100644 --- a/packages/core/resources/lang/pl/auth.php +++ b/packages/core/resources/lang/pl/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Pozwala na zarządzanie klientami', 'permissions.sales:manage-discounts.label' => 'Zarządzanie rabatami', 'permissions.sales:manage-discounts.description' => 'Pozwala na zarządzanie rabatami', + 'permissions.shipping:manage.label' => 'Zarządzanie dostawą', + 'permissions.shipping:manage.description' => 'Pozwala na zarządzanie dostawą', ]; diff --git a/packages/core/resources/lang/pt_BR/auth.php b/packages/core/resources/lang/pt_BR/auth.php index d56b710c1b..f370908259 100644 --- a/packages/core/resources/lang/pt_BR/auth.php +++ b/packages/core/resources/lang/pt_BR/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Permite que o membro da equipe gerencie clientes', 'permissions.sales:manage-discounts.label' => 'Gerenciar descontos', 'permissions.sales:manage-discounts.description' => 'Permite que o membro da equipe gerencie descontos', + 'permissions.shipping:manage.label' => 'Gerenciar envio', + 'permissions.shipping:manage.description' => 'Permite que o membro da equipe gerencie o envio', ]; diff --git a/packages/core/resources/lang/ro/auth.php b/packages/core/resources/lang/ro/auth.php index 58566805db..4c9b1c2036 100644 --- a/packages/core/resources/lang/ro/auth.php +++ b/packages/core/resources/lang/ro/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Permite personalului să gestioneze clienții', 'permissions.sales:manage-discounts.label' => 'Gestionare reduceri', 'permissions.sales:manage-discounts.description' => 'Permite personalului să gestioneze reducerile', + 'permissions.shipping:manage.label' => 'Gestionare livrare', + 'permissions.shipping:manage.description' => 'Permite personalului să gestioneze livrarea', ]; diff --git a/packages/core/resources/lang/tr/auth.php b/packages/core/resources/lang/tr/auth.php index 594e5cf409..483b71c721 100644 --- a/packages/core/resources/lang/tr/auth.php +++ b/packages/core/resources/lang/tr/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Personelin müşterileri yönetmesine izin verir', 'permissions.sales:manage-discounts.label' => 'İndirim Yönetimi', 'permissions.sales:manage-discounts.description' => 'Personelin indirimleri yönetmesine izin verir', + 'permissions.shipping:manage.label' => 'Kargo Yönetimi', + 'permissions.shipping:manage.description' => 'Personelin kargo işlemlerini yönetmesine izin verir', ]; diff --git a/packages/core/resources/lang/vi/auth.php b/packages/core/resources/lang/vi/auth.php index 506db755bb..1e0edc0055 100644 --- a/packages/core/resources/lang/vi/auth.php +++ b/packages/core/resources/lang/vi/auth.php @@ -23,4 +23,6 @@ 'permissions.sales:manage-customers.description' => 'Cho phép nhân viên quản lý khách hàng', 'permissions.sales:manage-discounts.label' => 'Quản lý giảm giá', 'permissions.sales:manage-discounts.description' => 'Cho phép nhân viên quản lý giảm giá', + 'permissions.shipping:manage.label' => 'Quản lý vận chuyển', + 'permissions.shipping:manage.description' => 'Cho phép nhân viên quản lý vận chuyển', ]; diff --git a/packages/core/src/Actions/Carts/CreateOrder.php b/packages/core/src/Actions/Carts/CreateOrder.php index 39df4221d6..de211eea5f 100644 --- a/packages/core/src/Actions/Carts/CreateOrder.php +++ b/packages/core/src/Actions/Carts/CreateOrder.php @@ -26,6 +26,11 @@ public function execute( /** @var Cart $cart */ $order = $cart->draftOrder($orderIdToUpdate)->first() ?: App::make(Order::class); + // Read before the creation pipeline runs: MapDiscountBreakdown + // rewrites the order's breakdown, so afterwards every discount would + // look as though it had already been consumed. + $alreadyConsumed = $cart->consumedDiscountIds(); + if ($cart->hasCompletedOrders() && ! $allowMultipleOrders) { throw new DisallowMultipleCartOrdersException; } @@ -43,10 +48,20 @@ public function execute( return $order; }); - $cart->discounts?->each(function ($discount) use ($cart) { + // Creating the order again for the same cart — a declined card and a + // retry — must not consume a second use of the same discount. + $cart->discounts?->each(function ($discount) use ($cart, $alreadyConsumed) { + if ($alreadyConsumed->contains($discount->discount->id)) { + return; + } + $discount->markAsUsed($cart)->discount->save(); }); + // The breakdown has been rewritten, so anything still holding this + // cart must not read a set memoised before the order existed. + $cart->forgetConsumedDiscountIds(); + $cart->save(); MarkAsNewCustomer::dispatch($order->id); diff --git a/packages/core/src/Actions/Products/MapVariantsToProductOptions.php b/packages/core/src/Actions/Products/MapVariantsToProductOptions.php index 085ade9cdf..ba88ba6de3 100644 --- a/packages/core/src/Actions/Products/MapVariantsToProductOptions.php +++ b/packages/core/src/Actions/Products/MapVariantsToProductOptions.php @@ -45,7 +45,7 @@ public function execute(array $options, array $variants, bool $fillMissing = tru $valueDifference = array_diff_assoc($permutation, $variant['values']); if (! count($valueDifference)) { - return $variant; + return true; } $amountMatched = count($permutation) - count($valueDifference); @@ -53,13 +53,23 @@ public function execute(array $options, array $variants, bool $fillMissing = tru return $amountMatched === count($variant['values']); }); - $variant = $variants[$variantIndex] ?? null; + // search() returns false when nothing matches; PHP coerces false to index 0. + $variant = $variantIndex === false ? null : ($variants[$variantIndex] ?? null); $variantId = $variant['id'] ?? null; $sku = $variant['sku'] ?? null; $copiedFrom = null; $shouldFill = true; + if (! $variant && ! $fillMissing) { + $shouldFill = false; + } + + // New option combinations still need tax_class_id on insert; copy the oldest sibling. + if (! $variant && $fillMissing) { + $copiedFrom = collect($variants)->min('id'); + } + if ($variant) { $existing = collect($variantPermutations) ->first(fn ($p) => $p['variant_id'] === $variant['id']); diff --git a/packages/core/src/Actions/Taxes/GetTaxZone.php b/packages/core/src/Actions/Taxes/GetTaxZone.php index 4e8fa70ae7..caba7ac12b 100644 --- a/packages/core/src/Actions/Taxes/GetTaxZone.php +++ b/packages/core/src/Actions/Taxes/GetTaxZone.php @@ -24,7 +24,7 @@ public function execute(?Addressable $address = null): ?TaxZone } if ($address && $address->state) { - $stateZone = $this->getTaxZoneState->execute($address->state); + $stateZone = $this->getTaxZoneState->execute($address->state, $address->country_id); if ($stateZone) { return $stateZone->taxZone; } diff --git a/packages/core/src/Actions/Taxes/GetTaxZoneState.php b/packages/core/src/Actions/Taxes/GetTaxZoneState.php index d930693ff6..1e6b0f914b 100644 --- a/packages/core/src/Actions/Taxes/GetTaxZoneState.php +++ b/packages/core/src/Actions/Taxes/GetTaxZoneState.php @@ -12,9 +12,9 @@ class GetTaxZoneState * @param string $state * @return null|TaxZoneState */ - public function execute($state) + public function execute($state, ?int $countryId = null) { - $stateZone = $this->getZoneMatches($state); + $stateZone = $this->getZoneMatches($state, $countryId); if ($stateZone instanceof TaxZoneState) { return $stateZone; @@ -26,17 +26,33 @@ public function execute($state) /** * Return the zone or zones which match the given state name/code. * + * A state name or code is only unique within a country — WA is both + * Washington and Western Australia — so a country narrows the match. States + * with no country stay matchable by any address. + * * @param string $state * @return null|TaxZoneState */ - protected function getZoneMatches($state) + protected function getZoneMatches($state, ?int $countryId = null) { $state = (string) $state; - $stateZone = TaxZoneState::whereHas('state', function ($query) use ($state) { - return $query - ->where('name', $state) - ->orWhere('code', $state); + $stateZone = TaxZoneState::whereHas('state', function ($query) use ($state, $countryId) { + $query->where(function ($query) use ($state) { + return $query + ->where('name', $state) + ->orWhere('code', $state); + }); + + if ($countryId) { + $query->where(function ($query) use ($countryId) { + return $query + ->whereNull('country_id') + ->orWhere('country_id', $countryId); + }); + } + + return $query; })->whereHas('taxZone', function ($query) { return $query->where('active', true); })->first(); diff --git a/packages/core/src/DiscountTypes/AbstractDiscountType.php b/packages/core/src/DiscountTypes/AbstractDiscountType.php index d3cd683b26..f0973c0393 100644 --- a/packages/core/src/DiscountTypes/AbstractDiscountType.php +++ b/packages/core/src/DiscountTypes/AbstractDiscountType.php @@ -89,9 +89,17 @@ protected function checkDiscountConditions(Cart $cart): bool $lines = $this->getEligibleLines($cart); $validMinSpend = $minSpend ? $minSpend < $lines->sum('subTotal.value') : true; - $validMaxUses = $this->discount->max_uses ? $this->discount->uses < $this->discount->max_uses : true; + // A cart that already consumed this discount when its draft order was + // created must not then be blocked by its own use. Otherwise creating + // that order again — a declined card and a retry — re-prices it without + // the discount the shopper was quoted. + $alreadyConsumed = $cart->consumedDiscountIds()->contains($this->discount->id); - if ($validMaxUses && $this->discount->max_uses_per_user) { + $validMaxUses = $this->discount->max_uses + ? ($alreadyConsumed || $this->discount->uses < $this->discount->max_uses) + : true; + + if (! $alreadyConsumed && $validMaxUses && $this->discount->max_uses_per_user) { $validMaxUses = $cart->user && ($this->usesByUser($cart->user) < $this->discount->max_uses_per_user); } diff --git a/packages/core/src/DiscountTypes/BuyXGetY.php b/packages/core/src/DiscountTypes/BuyXGetY.php index b4d39bdbf6..7438c3c420 100644 --- a/packages/core/src/DiscountTypes/BuyXGetY.php +++ b/packages/core/src/DiscountTypes/BuyXGetY.php @@ -4,6 +4,7 @@ use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Collection; +use Lunar\Core\Contracts\Purchasable; use Lunar\Core\DataObjects\PriceValue; use Lunar\Core\Models\Cart; use Lunar\Core\Models\CartLine; @@ -50,6 +51,10 @@ public function getRewardQuantity($linesQuantity, $minQty, $rewardQty, $maxRewar */ public function apply(Cart $cart): Cart { + if (! $this->checkDiscountConditions($cart)) { + return $cart; + } + $data = $this->discount->data; $minQty = $data['min_qty'] ?? null; @@ -224,23 +229,57 @@ public function apply(Cart $cart): Cart private function processAutomaticRewards(Cart $cart, int $remainingRewardQty, Collection $affectedLines, int $discountTotal) { + // Reward lines this run has added, keyed by purchasable. The check below + // reads $cart->lines, which never receives a line made here, so without + // this a reward quantity of three opens three lines of one rather than + // one line of three. + $addedRewardLines = []; + // we have lines to add if ($remainingRewardQty > 0) { - while ($remainingRewardQty > 0) { - $selectedRewardItem = $this->discount->discountableRewards->random()->discountable; + // Fulfillable products per collection reward, hydrated once here rather + // than re-queried on every iteration of the allocation loop below. + $fulfillableCollectionProducts = []; - if (! $selectedRewardItem) { - $remainingRewardQty--; + $fulfillableRewards = $this->discount->discountableRewards->filter(function ($discountableReward) use (&$fulfillableCollectionProducts) { + $rewardItem = $discountableReward->discountable; - continue; + if (! $rewardItem) { + return false; + } + + if ($rewardItem instanceof LunarCollection) { + $fulfillableCollectionProducts[$rewardItem->id] = $rewardItem->products() + ->with('variants') + ->get() + ->filter(fn ($p) => $p->variants->first()?->canBeFulfilledAtQuantity(1)) + ->values(); + + return $fulfillableCollectionProducts[$rewardItem->id]->isNotEmpty(); + } + + if ($rewardItem instanceof Purchasable) { + return $rewardItem->canBeFulfilledAtQuantity(1); } + return (bool) $rewardItem->variants()->first()?->canBeFulfilledAtQuantity(1); + }); + + if ($fulfillableRewards->isEmpty()) { + return [$affectedLines, $discountTotal]; + } + + while ($remainingRewardQty > 0) { + $selectedRewardItem = $fulfillableRewards->random()->discountable; + if ($selectedRewardItem instanceof LunarCollection) { - $product = $selectedRewardItem->products()->inRandomOrder()->first(); - $purchasable = $product?->variants()->first(); + $product = $fulfillableCollectionProducts[$selectedRewardItem->id]->random(); + $purchasable = $product->variants->first(); $selectedRewardItem = $product; + } elseif ($selectedRewardItem instanceof Purchasable) { + $purchasable = $selectedRewardItem; } else { - $purchasable = $selectedRewardItem->variants->first(); + $purchasable = $selectedRewardItem->variants()->first(); } if (! $purchasable) { @@ -249,14 +288,39 @@ private function processAutomaticRewards(Cart $cart, int $remainingRewardQty, Co continue; } + $rewardKey = $purchasable->getMorphClass().':'.$purchasable->id; + + // How many units of this reward this run has already allocated, + // since canBeFulfilledAtQuantity below must check against that + // running total rather than a fixed quantity of 1 each time. + $allocated = $addedRewardLines[$rewardKey]->quantity ?? 0; + + if (! $purchasable->canBeFulfilledAtQuantity($allocated + 1)) { + $remainingRewardQty--; + + continue; + } + // is it already in cart? - $rewardLine = $cart->lines->first(function ($line) use ($purchasable) { + $rewardLine = $addedRewardLines[$rewardKey] ?? $cart->lines->first(function ($line) use ($purchasable) { return $line->purchasable->id == $purchasable->id; }); + if ($rewardLine && isset($addedRewardLines[$rewardKey])) { + // Another unit of a reward this run already added: raise the + // quantity on that line. A line the shopper put in the cart + // themselves is left at the quantity they chose, as before. + $rewardLine->quantity++; + + $lineTotal = $rewardLine->unitPrice->multiply($rewardLine->quantity); + + $rewardLine->subTotal = $lineTotal; + $rewardLine->total = $lineTotal; + } + if (! $rewardLine) { $rewardLine = $cart->lines()->make([ - 'purchasable_type' => get_class($purchasable), + 'purchasable_type' => $purchasable->getMorphClass(), 'purchasable_id' => $purchasable->id, 'quantity' => 1, ]); @@ -279,11 +343,11 @@ private function processAutomaticRewards(Cart $cart, int $remainingRewardQty, Co return $cartLine; }); - $unitQuantity = $purchasable->getUnitQuantity(); - $rewardLine->subTotal = new PriceValue($rewardLine->unitPrice->value, $cart->currency); $rewardLine->taxAmount = new PriceValue(0, $cart->currency); $rewardLine->total = new PriceValue($rewardLine->unitPrice->value, $cart->currency); + + $addedRewardLines[$rewardKey] = $rewardLine; } $meta = $rewardLine->meta ?? json_decode('{}'); diff --git a/packages/core/src/Drivers/SystemTaxDriver.php b/packages/core/src/Drivers/SystemTaxDriver.php index 27e8061a49..659c36ed5c 100644 --- a/packages/core/src/Drivers/SystemTaxDriver.php +++ b/packages/core/src/Drivers/SystemTaxDriver.php @@ -172,6 +172,6 @@ public function getBreakdown($subTotal): TaxBreakdown protected function defaultTaxZone() { - return TaxZone::where('default', '=', 1)->first(); + return TaxZone::getDefault(); } } diff --git a/packages/core/src/LunarServiceProvider.php b/packages/core/src/LunarServiceProvider.php index 4267a10d5d..564d86fdf3 100644 --- a/packages/core/src/LunarServiceProvider.php +++ b/packages/core/src/LunarServiceProvider.php @@ -405,7 +405,9 @@ protected function registerServices(): void return new ShippingModifiers; }); - $this->app->singleton(ShippingManifest::class, function ($app) { + // Holds the resolved options for the cart in hand plus a re-entrancy + // flag, so it is per-request state, not a boot-time registry. + $this->app->scoped(ShippingManifest::class, function ($app) { return $app->make(ShippingManifestImpl::class); }); diff --git a/packages/core/src/Managers/CartSessionManager.php b/packages/core/src/Managers/CartSessionManager.php index 5198f69319..c1c2e84573 100644 --- a/packages/core/src/Managers/CartSessionManager.php +++ b/packages/core/src/Managers/CartSessionManager.php @@ -220,6 +220,13 @@ public function setCurrency(Currency $currency): void $this->cart->update([ 'currency_id' => $currency->id, ]); + + // Prices are resolved from the cart's loaded relations, and the + // lines carry their own copy of the cart (see lunar.cart.eager_load). + // Without dropping these the next calculate() prices in the old + // currency. + $this->cart->unsetRelation('currency'); + $this->cart->unsetRelation('lines'); } } diff --git a/packages/core/src/Managers/DiscountManager.php b/packages/core/src/Managers/DiscountManager.php index 633398ec16..82db4953f6 100644 --- a/packages/core/src/Managers/DiscountManager.php +++ b/packages/core/src/Managers/DiscountManager.php @@ -35,6 +35,11 @@ class DiscountManager implements DiscountManagerContract */ protected ?Collection $discounts = null; + /** + * Identifies the cart state $discounts was built from. + */ + protected ?string $discountsKey = null; + /** * The available discount types * @@ -132,7 +137,7 @@ public function getDiscounts(?Cart $cart = null): Collection } return Discount::active() - ->usable() + ->usable($cart?->consumedDiscountIds() ?? []) ->channel($this->channels) ->customerGroup($this->customerGroups) ->with([ @@ -217,8 +222,11 @@ public function getApplied(): Collection public function apply(Cart $cart): Cart { - if (! $this->discounts || $this->discounts?->isEmpty()) { + $key = $this->getDiscountsCacheKey($cart); + + if ($this->discounts === null || $this->discountsKey !== $key) { $this->discounts = $this->getDiscounts($cart); + $this->discountsKey = $key; } foreach ($this->discounts as $discount) { @@ -236,9 +244,26 @@ public function apply(Cart $cart): Cart return $cart; } + /** + * Build the cache key for the memoised discounts. + * + * getDiscounts() filters on the cart's coupon code and on the purchasables + * in its lines, so a set built for one cart state is not valid for another. + */ + protected function getDiscountsCacheKey(Cart $cart): string + { + return implode('|', [ + $cart->id, + $cart->coupon_code ?? '', + $cart->customer_id ?? '', + $cart->lines->map(fn ($line) => $line->purchasable_type.':'.$line->purchasable_id)->sort()->implode(','), + ]); + } + public function resetDiscounts(): self { $this->discounts = null; + $this->discountsKey = null; return $this; } diff --git a/packages/core/src/Managers/StorefrontSessionManager.php b/packages/core/src/Managers/StorefrontSessionManager.php index afd5a665e7..bc37bb918c 100644 --- a/packages/core/src/Managers/StorefrontSessionManager.php +++ b/packages/core/src/Managers/StorefrontSessionManager.php @@ -7,6 +7,7 @@ use Illuminate\Session\SessionManager; use Illuminate\Support\Collection; use Lunar\Core\Contracts\Actions\Storefront\ResolvesStorefrontContext; +use Lunar\Core\Contracts\CartSession; use Lunar\Core\Contracts\StorefrontSession; use Lunar\Core\DataObjects\StorefrontContext; use Lunar\Core\Exceptions\CustomerNotBelongsToUserException; @@ -32,6 +33,7 @@ public function __construct( protected SessionManager $sessionManager, protected AuthManager $authManager, protected ResolvesStorefrontContext $resolveStorefrontContext, + protected CartSession $cartSession, ) { $this->customerGroups = new Collection; @@ -142,6 +144,25 @@ public function getCurrency(): Currency } public function setCurrency(Currency $currency): static + { + $this->putCurrency($currency); + + // The cart carries its own currency_id and is priced from it, so a + // storefront switch has to reach the cart or the shopper keeps seeing + // the old currency. CartSession owns that write and the relation resets + // it needs; it is a no-op when the visitor has no cart. + $this->cartSession->setCurrency($currency); + + return $this; + } + + /** + * Store the currency without reaching for the cart. + * + * The boot cascade resolves a currency on every request, and must not + * calculate — or create — a cart to do it. + */ + protected function putCurrency(Currency $currency): void { $this->sessionManager->put( $this->getSessionKey().'_currency', @@ -149,8 +170,6 @@ public function setCurrency(Currency $currency): static ); $this->currency = $currency; - - return $this; } public function getCustomer(): ?Customer @@ -259,12 +278,12 @@ public function initCurrency(): void if ($sessionCurrency) { $currency = Currency::query()->where('code', $sessionCurrency)->firstOrFail(); - $this->setCurrency($currency); + $this->putCurrency($currency); return; } - $this->setCurrency($this->region?->currency ?? Currency::getDefault()); + $this->putCurrency($this->region?->currency ?? Currency::getDefault()); } public function initCustomer(): void diff --git a/packages/core/src/Manifests/ShippingManifest.php b/packages/core/src/Manifests/ShippingManifest.php index b7f96945fc..10b1176a7a 100644 --- a/packages/core/src/Manifests/ShippingManifest.php +++ b/packages/core/src/Manifests/ShippingManifest.php @@ -19,6 +19,11 @@ class ShippingManifest implements ShippingManifestContract public ?Closure $getOptionUsing = null; + /** + * Whether the shipping modifiers are currently being run. + */ + protected bool $resolving = false; + /** * Initiate the class. */ @@ -79,11 +84,25 @@ public function getOptionUsing(Closure $closure): self */ public function getOptions(Cart $cart): Collection { - app(Pipeline::class) - ->send($cart) - ->through( - app(ShippingModifiers::class)->getModifiers()->toArray() - )->thenReturn(); + // A modifier is free to calculate the cart, which runs ApplyShipping + // and lands back here. Hand back what has been resolved so far rather + // than running the modifiers again. The manifest is a singleton, so the + // flag must clear even when a modifier throws. + if ($this->resolving) { + return $this->options; + } + + $this->resolving = true; + + try { + app(Pipeline::class) + ->send($cart) + ->through( + app(ShippingModifiers::class)->getModifiers()->toArray() + )->thenReturn(); + } finally { + $this->resolving = false; + } return $this->options; } diff --git a/packages/core/src/Models/Cart.php b/packages/core/src/Models/Cart.php index 73d3bd1845..c2fe81828a 100644 --- a/packages/core/src/Models/Cart.php +++ b/packages/core/src/Models/Cart.php @@ -218,7 +218,7 @@ protected static function newFactory() public function lines(): HasMany { - return $this->hasMany(CartLine::class, 'cart_id', 'id'); + return $this->hasMany(CartLine::class, 'cart_id', 'id')->orderBy('id'); } public function currency(): BelongsTo @@ -300,6 +300,11 @@ public function scopeActive(Builder $query): Builder }); } + /** + * Memoised result of {@see self::consumedDiscountIds()}. + */ + protected ?Collection $consumedDiscountIds = null; + /** * Return the draft order relationship. */ @@ -311,6 +316,53 @@ public function draftOrder(?int $draftOrderId = null): HasOne })->whereNull('placed_at'); } + /** + * The ids of any discounts this cart has already consumed. + * + * Order creation records a use as soon as the draft order exists, so a + * checkout that runs it a second time — a declined card and a retry — would + * otherwise find its own coupon exhausted and re-price that same order + * without it. A cart's own consumption must not count against it. + * + * Memoised per instance, because this is read once when the discount set is + * rebuilt and once per discount while conditions are checked — a fresh query + * each time costs a single-row lookup per discount on every calculate, + * including for carts that never reach a checkout. Order creation is the + * only thing that changes the answer, so it forgets the memo. + * + * @see self::forgetConsumedDiscountIds() + */ + public function consumedDiscountIds(): Collection + { + if ($this->consumedDiscountIds !== null) { + return $this->consumedDiscountIds; + } + + // Read the raw column: the cast hydrates an OrderLine per breakdown + // line, which is a lot of work to reach an id. + $breakdown = $this->draftOrder()->first()?->getRawOriginal('discount_breakdown'); + + return $this->consumedDiscountIds = collect(json_decode($breakdown ?: '[]', true) ?: []) + ->pluck('discount_id') + ->filter() + ->unique() + ->values(); + } + + /** + * Forget the memoised consumed discount ids. + * + * Order creation writes the breakdown consumedDiscountIds() reads, so a set + * memoised before it ran is stale afterwards: on a same-request retry the + * cart's own coupon would look unconsumed to CreateOrder and be consumed + * twice, and exhausted to the discount conditions, re-pricing the order + * without it. + */ + public function forgetConsumedDiscountIds(): void + { + $this->consumedDiscountIds = null; + } + public function currentDraftOrder(?int $draftOrderId = null) { return $this->calculate() diff --git a/packages/core/src/Models/Concerns/HasDefaultRecord.php b/packages/core/src/Models/Concerns/HasDefaultRecord.php index 94f1c1d110..0906b69da3 100644 --- a/packages/core/src/Models/Concerns/HasDefaultRecord.php +++ b/packages/core/src/Models/Concerns/HasDefaultRecord.php @@ -22,7 +22,10 @@ public function scopeDefault($query, $default = true) /** * Get the default record. * - * @return self + * Null when nothing is marked as default — an install that has not been + * seeded, or a record that was unset. + * + * @return null|self */ public static function getDefault() { diff --git a/packages/core/src/Models/Discount.php b/packages/core/src/Models/Discount.php index 7257f8ba7b..1f01771e0a 100644 --- a/packages/core/src/Models/Discount.php +++ b/packages/core/src/Models/Discount.php @@ -270,11 +270,22 @@ public function scopeProductVariants(Builder $query, iterable $variantIds = [], ); } - public function scopeUsable(Builder $query): Builder + /** + * @param iterable $exempt Discount ids that stay usable whatever their + * use count — a cart that already consumed a + * discount must still be able to re-price with it. + */ + public function scopeUsable(Builder $query, iterable $exempt = []): Builder { - return $query->where(function ($subQuery) { + $exempt = collect($exempt)->filter()->values(); + + return $query->where(function ($subQuery) use ($exempt) { $subQuery->whereRaw('uses < max_uses') - ->orWhereNull('max_uses'); + ->orWhereNull('max_uses') + ->when( + $exempt->isNotEmpty(), + fn ($subQuery) => $subQuery->orWhereIn('id', $exempt) + ); }); } } diff --git a/packages/core/src/Models/Order.php b/packages/core/src/Models/Order.php index a2eca591b9..023c8bf39f 100644 --- a/packages/core/src/Models/Order.php +++ b/packages/core/src/Models/Order.php @@ -208,7 +208,7 @@ public function cart(): BelongsTo public function lines(): HasMany { - return $this->hasMany(OrderLine::class); + return $this->hasMany(OrderLine::class)->orderBy('id'); } public function fulfilments(): HasMany diff --git a/packages/core/src/Models/ProductVariant.php b/packages/core/src/Models/ProductVariant.php index d4e53e6d02..1cb9692f14 100644 --- a/packages/core/src/Models/ProductVariant.php +++ b/packages/core/src/Models/ProductVariant.php @@ -144,7 +144,9 @@ public function values(): BelongsToMany "{$prefix}product_option_value_product_variant", 'variant_id', 'value_id' - )->withTimestamps(); + )->withTimestamps() + ->orderBy('position') + ->orderByPivot('id'); } public function getPrices(): Collection diff --git a/packages/core/src/Pipelines/Order/Creation/CreateOrderAddresses.php b/packages/core/src/Pipelines/Order/Creation/CreateOrderAddresses.php index c8745352ca..33a552ac26 100644 --- a/packages/core/src/Pipelines/Order/Creation/CreateOrderAddresses.php +++ b/packages/core/src/Pipelines/Order/Creation/CreateOrderAddresses.php @@ -20,8 +20,7 @@ public function handle(Order $order, Closure $next): mixed foreach ($order->cart->addresses as $address) { /** @var OrderAddress $addressModel */ $addressModel = $orderAddresses->first(function ($orderAddress) use ($address) { - return $orderAddress->type == $address->type && - $orderAddress->postcode == $address->postcode; + return $orderAddress->type == $address->type; }) ?: App::make(OrderAddress::class); $addressModel->fill( diff --git a/packages/core/src/Pricing/DefaultPriceFormatter.php b/packages/core/src/Pricing/DefaultPriceFormatter.php index 277598fb6c..dc6f7b687d 100644 --- a/packages/core/src/Pricing/DefaultPriceFormatter.php +++ b/packages/core/src/Pricing/DefaultPriceFormatter.php @@ -13,6 +13,12 @@ public function __construct( public ?Currency $currency = null, public int $unitQty = 1 ) { + // unitDecimal() divides by this, so a zero or negative unit quantity + // would be a division by zero or a negative unit price. + if ($this->unitQty < 1) { + $this->unitQty = 1; + } + if (! $this->currency) { $this->currency = Currency::getDefault(); } @@ -32,17 +38,17 @@ public function unitDecimal(bool $rounding = true): float return $rounding ? round($convertedValue, $this->currency->decimal_places) : $convertedValue; } - public function formatted(?string $locale = null, string $formatterStyle = NumberFormatter::CURRENCY, ?int $decimalPlaces = null, bool $trimTrailingZeros = true): mixed + public function formatted(?string $locale = null, int $formatterStyle = NumberFormatter::CURRENCY, ?int $decimalPlaces = null, bool $trimTrailingZeros = true): mixed { return $this->formatValue($this->decimal(false), $locale, $formatterStyle, $decimalPlaces, $trimTrailingZeros); } - public function unitFormatted(?string $locale = null, string $formatterStyle = NumberFormatter::CURRENCY, ?int $decimalPlaces = null, bool $trimTrailingZeros = true): mixed + public function unitFormatted(?string $locale = null, int $formatterStyle = NumberFormatter::CURRENCY, ?int $decimalPlaces = null, bool $trimTrailingZeros = true): mixed { return $this->formatValue($this->unitDecimal(false), $locale, $formatterStyle, $decimalPlaces, $trimTrailingZeros); } - protected function formatValue(int|float $value, ?string $locale = null, string $formatterStyle = NumberFormatter::CURRENCY, ?int $decimalPlaces = null, bool $trimTrailingZeros = true): mixed + protected function formatValue(int|float $value, ?string $locale = null, int $formatterStyle = NumberFormatter::CURRENCY, ?int $decimalPlaces = null, bool $trimTrailingZeros = true): mixed { if (! $locale) { $locale = App::currentLocale(); diff --git a/packages/core/src/Pricing/PriceFormatterInterface.php b/packages/core/src/Pricing/PriceFormatterInterface.php index ff1a203c6f..55893da255 100644 --- a/packages/core/src/Pricing/PriceFormatterInterface.php +++ b/packages/core/src/Pricing/PriceFormatterInterface.php @@ -10,7 +10,7 @@ public function decimal(bool $rounding = true): float; public function unitDecimal(bool $rounding = true): float; - public function formatted(?string $locale = null, string $formatterStyle = NumberFormatter::CURRENCY, ?int $decimalPlaces = null, bool $trimTrailingZeros = true): mixed; + public function formatted(?string $locale = null, int $formatterStyle = NumberFormatter::CURRENCY, ?int $decimalPlaces = null, bool $trimTrailingZeros = true): mixed; - public function unitFormatted(?string $locale = null, string $formatterStyle = NumberFormatter::CURRENCY, ?int $decimalPlaces = null, bool $trimTrailingZeros = true): mixed; + public function unitFormatted(?string $locale = null, int $formatterStyle = NumberFormatter::CURRENCY, ?int $decimalPlaces = null, bool $trimTrailingZeros = true): mixed; } diff --git a/packages/filament/resources/lang/ar/productoption.php b/packages/filament/resources/lang/ar/productoption.php index 6dc09a56eb..1d03fd92de 100644 --- a/packages/filament/resources/lang/ar/productoption.php +++ b/packages/filament/resources/lang/ar/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'حفظ الخيارات', ], + 'save-variants' => [ + 'label' => 'حفظ المتغيرات', + ], 'add-shared-option' => [ 'label' => 'إضافة خيار مشترك', 'form' => [ diff --git a/packages/filament/resources/lang/bg/productoption.php b/packages/filament/resources/lang/bg/productoption.php index 34d781e6aa..c6ef14d7f8 100644 --- a/packages/filament/resources/lang/bg/productoption.php +++ b/packages/filament/resources/lang/bg/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Запази опциите', ], + 'save-variants' => [ + 'label' => 'Запази вариантите', + ], 'add-shared-option' => [ 'label' => 'Добави споделена опция', 'form' => [ diff --git a/packages/filament/resources/lang/de/productoption.php b/packages/filament/resources/lang/de/productoption.php index be3001252c..7580f299a4 100644 --- a/packages/filament/resources/lang/de/productoption.php +++ b/packages/filament/resources/lang/de/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Optionen speichern', ], + 'save-variants' => [ + 'label' => 'Varianten speichern', + ], 'add-shared-option' => [ 'label' => 'Geteilte Option hinzufügen', 'form' => [ diff --git a/packages/filament/resources/lang/en/productoption.php b/packages/filament/resources/lang/en/productoption.php index a101853486..4956fd8fbd 100644 --- a/packages/filament/resources/lang/en/productoption.php +++ b/packages/filament/resources/lang/en/productoption.php @@ -49,6 +49,9 @@ 'save-options' => [ 'label' => 'Save Options', ], + 'save-variants' => [ + 'label' => 'Save Variants', + ], 'add-shared-option' => [ 'label' => 'Add Shared Option', 'form' => [ diff --git a/packages/filament/resources/lang/es/productoption.php b/packages/filament/resources/lang/es/productoption.php index 6c75dd3631..35d42c3349 100644 --- a/packages/filament/resources/lang/es/productoption.php +++ b/packages/filament/resources/lang/es/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Guardar Opciones', ], + 'save-variants' => [ + 'label' => 'Guardar Variantes', + ], 'add-shared-option' => [ 'label' => 'Agregar Opción Compartida', 'form' => [ diff --git a/packages/filament/resources/lang/fa/productoption.php b/packages/filament/resources/lang/fa/productoption.php index 98ac4eeb15..a323582446 100644 --- a/packages/filament/resources/lang/fa/productoption.php +++ b/packages/filament/resources/lang/fa/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Save Options', ], + 'save-variants' => [ + 'label' => 'ذخیره گونه‌ها', + ], 'add-shared-option' => [ 'label' => 'Add Shared Option', 'form' => [ diff --git a/packages/filament/resources/lang/fr/productoption.php b/packages/filament/resources/lang/fr/productoption.php index 66d523e4ec..ebb07b941e 100644 --- a/packages/filament/resources/lang/fr/productoption.php +++ b/packages/filament/resources/lang/fr/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Enregistrer les options', ], + 'save-variants' => [ + 'label' => 'Enregistrer les variantes', + ], 'add-shared-option' => [ 'label' => 'Ajouter une option partagée', 'form' => [ diff --git a/packages/filament/resources/lang/hr/productoption.php b/packages/filament/resources/lang/hr/productoption.php index 1c1ace85de..42048f3de4 100644 --- a/packages/filament/resources/lang/hr/productoption.php +++ b/packages/filament/resources/lang/hr/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Spremi opcije', ], + 'save-variants' => [ + 'label' => 'Spremi varijante', + ], 'add-shared-option' => [ 'label' => 'Dodaj dijeljenu opciju', 'form' => [ diff --git a/packages/filament/resources/lang/hu/productoption.php b/packages/filament/resources/lang/hu/productoption.php index 19274b2d74..aea5df7d15 100644 --- a/packages/filament/resources/lang/hu/productoption.php +++ b/packages/filament/resources/lang/hu/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Opciók mentése', ], + 'save-variants' => [ + 'label' => 'Termékváltozatok mentése', + ], 'add-shared-option' => [ 'label' => 'Megosztott opció hozzáadása', 'form' => [ diff --git a/packages/filament/resources/lang/mn/productoption.php b/packages/filament/resources/lang/mn/productoption.php index 70f24611d8..5ff9733c6c 100644 --- a/packages/filament/resources/lang/mn/productoption.php +++ b/packages/filament/resources/lang/mn/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Сонголтууд хадгалах', ], + 'save-variants' => [ + 'label' => 'Вариантууд хадгалах', + ], 'add-shared-option' => [ 'label' => 'Хуваалцсан сонголт нэмэх', 'form' => [ diff --git a/packages/filament/resources/lang/nl/productoption.php b/packages/filament/resources/lang/nl/productoption.php index ca38cdcb99..57688f545a 100644 --- a/packages/filament/resources/lang/nl/productoption.php +++ b/packages/filament/resources/lang/nl/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Opties opslaan', ], + 'save-variants' => [ + 'label' => 'Varianten opslaan', + ], 'add-shared-option' => [ 'label' => 'Gedeelde optie toevoegen', 'form' => [ diff --git a/packages/filament/resources/lang/pl/productoption.php b/packages/filament/resources/lang/pl/productoption.php index 311a5ac7b5..f93c63b347 100644 --- a/packages/filament/resources/lang/pl/productoption.php +++ b/packages/filament/resources/lang/pl/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Zapisz opcje', ], + 'save-variants' => [ + 'label' => 'Zapisz warianty', + ], 'add-shared-option' => [ 'label' => 'Dodaj opcję współdzieloną', 'form' => [ diff --git a/packages/filament/resources/lang/pt_BR/productoption.php b/packages/filament/resources/lang/pt_BR/productoption.php index e2fd1ba5b2..083a132e4e 100644 --- a/packages/filament/resources/lang/pt_BR/productoption.php +++ b/packages/filament/resources/lang/pt_BR/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Salvar opções', ], + 'save-variants' => [ + 'label' => 'Salvar variações', + ], 'add-shared-option' => [ 'label' => 'Adicionar opção compartilhada', 'form' => [ diff --git a/packages/filament/resources/lang/ro/productoption.php b/packages/filament/resources/lang/ro/productoption.php index ad916c513a..5c2528c520 100644 --- a/packages/filament/resources/lang/ro/productoption.php +++ b/packages/filament/resources/lang/ro/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Salvează opțiunile', ], + 'save-variants' => [ + 'label' => 'Salvează variantele', + ], 'add-shared-option' => [ 'label' => 'Adaugă opțiune partajată', 'form' => [ diff --git a/packages/filament/resources/lang/tr/productoption.php b/packages/filament/resources/lang/tr/productoption.php index db5c43ae1b..186f51373a 100644 --- a/packages/filament/resources/lang/tr/productoption.php +++ b/packages/filament/resources/lang/tr/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Seçenekleri Kaydet', ], + 'save-variants' => [ + 'label' => 'Varyantları Kaydet', + ], 'add-shared-option' => [ 'label' => 'Paylaşılan Seçenek Ekle', 'form' => [ diff --git a/packages/filament/resources/lang/vi/productoption.php b/packages/filament/resources/lang/vi/productoption.php index 253855c916..961d2f18e2 100644 --- a/packages/filament/resources/lang/vi/productoption.php +++ b/packages/filament/resources/lang/vi/productoption.php @@ -44,6 +44,9 @@ 'save-options' => [ 'label' => 'Lưu tùy chọn', ], + 'save-variants' => [ + 'label' => 'Lưu biến thể', + ], 'add-shared-option' => [ 'label' => 'Thêm tùy chọn dùng chung', 'form' => [ diff --git a/packages/filament/resources/views/widgets/products/product-options.blade.php b/packages/filament/resources/views/widgets/products/product-options.blade.php index 3189a28387..f5beb07c22 100644 --- a/packages/filament/resources/views/widgets/products/product-options.blade.php +++ b/packages/filament/resources/views/widgets/products/product-options.blade.php @@ -64,7 +64,7 @@
{{ \Filament\Support\generate_icon_html('lucide-shapes', size: \Filament\Support\Enums\IconSize::Large) }}
-

No Product Options Configured

+

{{ __('lunar-filament::productoption.widgets.product-options.options-list.empty.heading') }}

@endif diff --git a/packages/filament/src/Forms/Components/CollectionSelect.php b/packages/filament/src/Forms/Components/CollectionSelect.php index 9f562cf9d5..eea66b587c 100644 --- a/packages/filament/src/Forms/Components/CollectionSelect.php +++ b/packages/filament/src/Forms/Components/CollectionSelect.php @@ -27,11 +27,11 @@ protected function setUp(): void $this->label(__('lunar-filament::forms/selectors.collection.label')); $this->placeholder(__('lunar-filament::forms/selectors.collection.placeholder')); $this->searchable(); - $this->modifyOptionsQueryUsing(fn ($query) => method_exists($query, 'with') ? $query->with('ancestors') : $query); + $this->modifyOptionsQueryUsing(fn ($query) => method_exists($query, 'with') ? $query->with(['group', 'ancestors']) : $query); $this->getSearchResultsUsing(fn (string $search): array => $this->searchLunarRecords($search)); $this->getOptionLabelUsing(function ($value): ?string { $model = $this->lunarModel(); - $record = $model::with('ancestors')->find($value); + $record = $model::with(['group', 'ancestors'])->find($value); return $record ? $this->optionLabel($record) : null; }); @@ -84,11 +84,20 @@ public function withinGroup(CollectionGroup $group): static return $this; } + public function optionLabelRelations(): array + { + return ['group', 'ancestors']; + } + public function optionLabel(Model $record): string { $breadcrumb = $record->breadcrumb ?? collect(); - $trail = $breadcrumb->push($record->translate('name')) + // Group first: two collections can share a name and an ancestry in + // different groups, and the trail alone leaves them indistinguishable. + $trail = collect([$record->group?->name]) + ->concat($breadcrumb) + ->push($record->translate('name')) ->filter() ->implode(' > '); diff --git a/packages/filament/src/Forms/Components/Concerns/AppliesToExistingSelect.php b/packages/filament/src/Forms/Components/Concerns/AppliesToExistingSelect.php index a333cb20ac..fde80a59a5 100644 --- a/packages/filament/src/Forms/Components/Concerns/AppliesToExistingSelect.php +++ b/packages/filament/src/Forms/Components/Concerns/AppliesToExistingSelect.php @@ -20,6 +20,16 @@ */ trait AppliesToExistingSelect { + /** + * Relations optionLabel() reads, eager loaded before it is called. + * + * @return array + */ + public function optionLabelRelations(): array + { + return []; + } + public static function applyTo(Select $select): Select { $proxy = static::make($select->getName() ?: '__lunar_selector_proxy'); @@ -32,7 +42,7 @@ public static function applyTo(Select $select): Select $select->getSearchResultsUsing(static function (string $search) use ($proxy, $modelClass): array { $query = RecordSearch::for($modelClass, $search); - return $query->take(50)->get() + return $query->with($proxy->optionLabelRelations())->take(50)->get() ->mapWithKeys(static fn (Model $record): array => [ $record->getKey() => $proxy->optionLabel($record), ]) @@ -40,7 +50,9 @@ public static function applyTo(Select $select): Select }); $select->getOptionLabelUsing(static function ($value) use ($proxy, $modelClass): ?string { - $record = $modelClass::find($value); + // optionLabel() may reach for relations (a collection's group and + // ancestors), which lazy loading would refuse under strict mode. + $record = $modelClass::with($proxy->optionLabelRelations())->find($value); return $record ? $proxy->optionLabel($record) : null; }); diff --git a/packages/filament/src/RelationManagers/Discount/CollectionConditionRelationManager.php b/packages/filament/src/RelationManagers/Discount/CollectionConditionRelationManager.php index b8ac027cd5..78071c8b79 100644 --- a/packages/filament/src/RelationManagers/Discount/CollectionConditionRelationManager.php +++ b/packages/filament/src/RelationManagers/Discount/CollectionConditionRelationManager.php @@ -43,6 +43,9 @@ public function getDefaultTable(Table $table): Table fn ($query) => $query->where('type', 'condition') ->where('discountable_type', Collection::morphName()) ->whereHas('discountable') + ->with(['discountable' => fn ($morphTo) => $morphTo->morphWith([ + Collection::class => ['group', 'ancestors'], + ])]) ) ->headerActions([ CreateAction::make()->schema([ @@ -63,6 +66,12 @@ public function getDefaultTable(Table $table): Table ->label( __('lunar-filament::discount.relationmanagers.collection_conditions.table.name.label') ) + ->description( + fn (Model $record): string => collect([$record->discountable?->group?->name]) + ->concat($record->discountable?->breadcrumb ?? []) + ->filter() + ->implode(' > ') + ) ->formatStateUsing( fn (Model $record) => $record->discountable?->translate('name') ), diff --git a/packages/filament/src/RelationManagers/Discount/CollectionLimitationRelationManager.php b/packages/filament/src/RelationManagers/Discount/CollectionLimitationRelationManager.php index c770855bba..7f18285442 100644 --- a/packages/filament/src/RelationManagers/Discount/CollectionLimitationRelationManager.php +++ b/packages/filament/src/RelationManagers/Discount/CollectionLimitationRelationManager.php @@ -40,7 +40,7 @@ public function getDefaultTable(Table $table): Table ->modifyQueryUsing( fn ($query) => $query ->whereIn($prefix.'collection_discount.type', ['limitation', 'exclusion']) - ->with('ancestors') + ->with(['group', 'ancestors']) ) ->paginated(false) ->headerActions([ @@ -66,7 +66,10 @@ public function getDefaultTable(Table $table): Table ->label( __('lunar-filament::discount.relationmanagers.collections.table.name.label') ) - ->description(fn (Collection $record): string => $record->breadcrumb->implode(' > ')) + ->description(fn (Collection $record): string => collect([$record->group?->name]) + ->concat($record->breadcrumb) + ->filter() + ->implode(' > ')) ->formatStateUsing( fn (Model $record) => $record->translate('name') ), diff --git a/packages/filament/src/Schemas/ProductVariant/ProductVariantForm.php b/packages/filament/src/Schemas/ProductVariant/ProductVariantForm.php index bfbc3598e2..ce415512bc 100644 --- a/packages/filament/src/Schemas/ProductVariant/ProductVariantForm.php +++ b/packages/filament/src/Schemas/ProductVariant/ProductVariantForm.php @@ -95,7 +95,8 @@ public static function getUnitQtyComponent(): TextInput ->label(__('lunar-filament::productvariant.form.unit_quantity.label')) ->hintIcon('heroicon-m-question-mark-circle') ->hintIconTooltip(__('lunar-filament::productvariant.form.unit_quantity.tooltip')) - ->numeric(); + ->numeric() + ->minValue(1); } public static function getQuantityIncrementComponent(): TextInput diff --git a/packages/filament/src/Schemas/TaxZone/TaxZoneForm.php b/packages/filament/src/Schemas/TaxZone/TaxZoneForm.php index 3f1fc4f8b0..a7dd3880a4 100644 --- a/packages/filament/src/Schemas/TaxZone/TaxZoneForm.php +++ b/packages/filament/src/Schemas/TaxZone/TaxZoneForm.php @@ -90,11 +90,9 @@ public static function getZoneTypeCountriesComponent(): Component ->map(static fn ($key): string => strval($key)) ->toArray(), ); - })->getOptionLabelsUsing(static function (Model $record): array { - $record->loadMissing('countries.country'); - - return $record->countries - ->pluck('country.name', 'country.iso3') + })->getOptionLabelsUsing(static function (array $values): array { + return Country::whereIn('iso3', $values) + ->pluck('name', 'iso3') ->toArray(); }) ->saveRelationshipsUsing(static function (Model $record, $state) { @@ -151,11 +149,10 @@ public static function getZoneTypeStatesComponent(): Component ->map(static fn ($key): string => strval($key)) ->toArray(), ); - })->getOptionLabelsUsing(static function (Model $record): array { - $record->loadMissing('states.state'); - - return $record->states - ->pluck('state.name', 'state.code') + })->getOptionLabelsUsing(static function (array $values, $get): array { + return State::where('country_id', $get('zone_country')) + ->whereIn('code', $values) + ->pluck('name', 'code') ->toArray(); }) ->saveRelationshipsUsing(static function (Model $record, $state, $get) { diff --git a/packages/filament/src/Widgets/Products/ProductOptionsWidget.php b/packages/filament/src/Widgets/Products/ProductOptionsWidget.php index 01a18c2d13..409d4c86fc 100644 --- a/packages/filament/src/Widgets/Products/ProductOptionsWidget.php +++ b/packages/filament/src/Widgets/Products/ProductOptionsWidget.php @@ -58,6 +58,9 @@ public function addSharedOptionAction(): Action ->get(); return Action::make('addSharedOption') + ->label( + __('lunar-filament::productoption.widgets.product-options.actions.add-shared-option.label') + ) ->schema([ Callout::make() ->status('info') @@ -354,6 +357,9 @@ protected function mapOptionValuesToIds(array $values): array public function saveVariantsAction(): Action { return Action::make('saveVariants') + ->label( + __('lunar-filament::productoption.widgets.product-options.actions.save-variants.label') + ) ->action(function () { try { $this->saveVariants(); @@ -414,6 +420,13 @@ protected function saveVariants(): void $basePrice = $variant->basePrices->first(); } + // A permutation the mapper could not match has no variant to update and + // nothing to copy tax_class_id or a base price from, so fall back to + // the oldest sibling. + if (empty($variantData['variant_id']) && empty($variantData['copied_id'])) { + $variantData['copied_id'] = $this->record->variants()->orderBy('id')->value('id'); + } + if (! empty($variantData['copied_id'])) { $copiedVariant = ProductVariant::find( $variantData['copied_id'] diff --git a/packages/stripe/resources/responses/charge.json b/packages/stripe/resources/responses/charge.json index 86291f26a7..3cd206be76 100644 --- a/packages/stripe/resources/responses/charge.json +++ b/packages/stripe/resources/responses/charge.json @@ -45,7 +45,7 @@ "type": "authorized" }, "paid": true, - "payment_intent": null, + "payment_intent": "{payment_intent}", "payment_method": "card_1MmlLrLkdIwHu7ixIJwEWSNR", "payment_method_details": { "card": { diff --git a/packages/stripe/resources/responses/refund.json b/packages/stripe/resources/responses/refund.json new file mode 100644 index 0000000000..e4e8471fb7 --- /dev/null +++ b/packages/stripe/resources/responses/refund.json @@ -0,0 +1,9 @@ +{ + "id": "re_TEST", + "object": "refund", + "amount": {refund_amount}, + "currency": "usd", + "payment_intent": "{payment_intent}", + "reason": null, + "status": "succeeded" +} diff --git a/packages/stripe/src/Actions/StoreCharges.php b/packages/stripe/src/Actions/StoreCharges.php index 65a64361e4..d66fd39f8b 100644 --- a/packages/stripe/src/Actions/StoreCharges.php +++ b/packages/stripe/src/Actions/StoreCharges.php @@ -5,6 +5,7 @@ use Illuminate\Support\Collection; use Lunar\Core\Models\Order; use Lunar\Core\Models\Transaction; +use Lunar\Stripe\Managers\StripeManager; class StoreCharges { @@ -64,7 +65,7 @@ public function store(Order $order, Collection $charges) 'success' => (bool) ! $charge->failure_code, 'type' => $charge->refunded ? 'refund' : $type, 'driver' => 'stripe', - 'amount' => $charge->amount, + 'amount' => StripeManager::fromStripeAmount($charge->amount, $order->currency), 'reference' => $charge->id, 'status' => $charge->status, 'notes' => $charge->failure_message ?: $charge->description, diff --git a/packages/stripe/src/Managers/StripeManager.php b/packages/stripe/src/Managers/StripeManager.php index cc837fc301..db57f8d163 100644 --- a/packages/stripe/src/Managers/StripeManager.php +++ b/packages/stripe/src/Managers/StripeManager.php @@ -216,26 +216,99 @@ public function getCharge(string $chargeId): Charge return $this->getClient()->charges->retrieve($chargeId); } + /** + * Zero-decimal currencies, per Stripe. The amount sent to Stripe is the + * major unit amount as-is. + * + * @see https://docs.stripe.com/currencies#zero-decimal + */ + protected const ZERO_DECIMAL_CURRENCIES = [ + 'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', + 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf', + ]; + + /** + * Three-decimal currencies, per Stripe. The amount sent to Stripe is the + * major unit amount multiplied by 1000. + * + * @see https://docs.stripe.com/currencies#three-decimal + */ + protected const THREE_DECIMAL_CURRENCIES = ['bhd', 'jod', 'kwd', 'omr', 'tnd']; + + /** + * HUF, TWD and UGX are ISO zero-decimal currencies, but Stripe still + * requires amounts to be sent as if they had two decimal places. + * + * @see https://docs.stripe.com/currencies#special-cases + */ + protected const SPECIAL_ZERO_DECIMAL_CURRENCIES = ['huf', 'twd', 'ugx']; + /** * Convert a Lunar price value to the amount expected by Stripe. * - * For most currencies Stripe expects the amount in the same sub-unit - * Lunar already stores it in (controlled by `Currency::decimal_places`). - * HUF, TWD and UGX are the exception: although they are ISO zero-decimal - * currencies, Stripe requires amounts to be sent as if they had two - * decimal places. + * Lunar stores prices as integers scaled by `Currency::decimal_places`, + * which merchants can set independently of what Stripe expects for a + * given currency. This converts back to the major unit amount first, + * then re-scales it to whatever sub-unit Stripe requires for the + * currency, so the result is correct regardless of how the merchant has + * configured `Currency::decimal_places`. * * @see https://docs.stripe.com/currencies */ public static function toStripeAmount(int $value, Currency $currency): int { - if (! in_array(strtolower($currency->code), ['huf', 'twd', 'ugx'], true)) { - return $value; + return self::rescale($value, max($currency->decimal_places, 0), self::stripeDecimalPlaces($currency)); + } + + /** + * Convert an amount received from Stripe back to a Lunar price value, + * scaled by `Currency::decimal_places`. Inverse of `toStripeAmount()`. + */ + public static function fromStripeAmount(int $amount, Currency $currency): int + { + return self::rescale($amount, self::stripeDecimalPlaces($currency), max($currency->decimal_places, 0)); + } + + /** + * The number of decimal places Stripe expects amounts in for a currency. + */ + protected static function stripeDecimalPlaces(Currency $currency): int + { + $code = strtolower($currency->code); + + // UGX is also in the zero-decimal list; the special case takes precedence. + if (in_array($code, self::SPECIAL_ZERO_DECIMAL_CURRENCIES, true)) { + return 2; + } + + if (in_array($code, self::ZERO_DECIMAL_CURRENCIES, true)) { + return 0; + } + + if (in_array($code, self::THREE_DECIMAL_CURRENCIES, true)) { + return 3; + } + + return 2; + } + + /** + * Rescale an integer amount between decimal-place precisions using integer + * arithmetic only — float division misrounds at half-unit boundaries + * (145 at 3dp: 0.145 stores as 0.1449…, so round() gives 14, not 15). + * Rounds half away from zero, matching round(). + */ + protected static function rescale(int $value, int $fromDecimalPlaces, int $toDecimalPlaces): int + { + $exponent = $toDecimalPlaces - $fromDecimalPlaces; + + if ($exponent >= 0) { + return $value * (10 ** $exponent); } - $majorAmount = $value / (10 ** $currency->decimal_places); + $divisor = 10 ** (-$exponent); - return (int) round($majorAmount * 100); + return intdiv(abs($value) + intdiv($divisor, 2), $divisor) * ($value < 0 ? -1 : 1); } /** diff --git a/packages/stripe/src/MockClient.php b/packages/stripe/src/MockClient.php index 1c29719cbf..4113258e86 100644 --- a/packages/stripe/src/MockClient.php +++ b/packages/stripe/src/MockClient.php @@ -19,6 +19,13 @@ class MockClient implements ClientInterface public string $url; + /** + * Every request made against the mock, for asserting outgoing payloads. + * + * @var array + */ + public array $requests = []; + private bool $failThenCaptureCalled = false; public function __construct() @@ -35,6 +42,8 @@ public function next(array $data): self public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1') { + $this->requests[] = ['method' => $method, 'url' => $absUrl, 'params' => $params]; + $id = array_slice(explode('/', $absUrl), -1)[0]; $policy = config('lunar.stripe.policy'); @@ -48,6 +57,16 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode return [$this->rBody, $this->rcode, $this->rheaders]; } + if ($method == 'get' && str_contains($absUrl, 'charges/')) { + $this->rBody = $this->getResponse('charge', [ + 'id' => $id, + 'payment_intent' => 'PI_CAPTURE', + ...$this->nextData, + ]); + + return [$this->rBody, $this->rcode, $this->rheaders]; + } + if ($method == 'get' && str_contains($absUrl, 'charges')) { $status = 'succeeded'; @@ -160,6 +179,15 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode } } + if ($method == 'post' && str_contains($absUrl, 'refunds')) { + $this->rBody = $this->getResponse('refund', [ + 'refund_amount' => $params['amount'] ?? 0, + 'payment_intent' => $params['payment_intent'] ?? 'PI_CAPTURE', + ]); + + return [$this->rBody, $this->rcode, $this->rheaders]; + } + if ($method == 'post' && str_contains($absUrl, 'payment_intents')) { $this->rBody = $this->getResponse('payment_intent_created'); diff --git a/packages/stripe/src/StripePaymentType.php b/packages/stripe/src/StripePaymentType.php index 8ecf3ce1d8..2de0e641d7 100644 --- a/packages/stripe/src/StripePaymentType.php +++ b/packages/stripe/src/StripePaymentType.php @@ -15,6 +15,7 @@ use Lunar\Stripe\Actions\UpdateOrderFromIntent; use Lunar\Stripe\Events\OrphanedPaymentIntentDetected; use Lunar\Stripe\Facades\Stripe; +use Lunar\Stripe\Managers\StripeManager; use Lunar\Stripe\Models\StripePaymentIntent; use Stripe\Exception\InvalidRequestException; use Stripe\PaymentIntent; @@ -196,13 +197,16 @@ protected function assertIntentMatchesTotal(): ?PaymentAuthorize if ($this->order) { $expectedAmount = $this->order->total; $expectedCurrency = $this->order->currency_code; + $currency = $this->order->currency; } else { $calculated = $this->cart->calculate(); $expectedAmount = $calculated->total->value; $expectedCurrency = $calculated->currency->code; + $currency = $calculated->currency; } - $amountMatches = $expectedAmount === (int) $this->paymentIntent->amount; + // The intent amount is in Stripe's sub-unit scale, not Lunar's. + $amountMatches = StripeManager::toStripeAmount($expectedAmount, $currency) === (int) $this->paymentIntent->amount; $currencyMatches = strtolower((string) $expectedCurrency) === strtolower((string) $this->paymentIntent->currency); if ($amountMatches && $currencyMatches) { @@ -232,7 +236,7 @@ public function capture(Transaction $transaction, $amount = 0): PaymentCapture $payload = []; if ($amount > 0) { - $payload['amount_to_capture'] = $amount; + $payload['amount_to_capture'] = StripeManager::toStripeAmount($amount, $transaction->order->currency); } $charge = Stripe::getCharge($transaction->reference); @@ -268,7 +272,7 @@ public function refund(Transaction $transaction, int $amount = 0, $notes = null) try { $refund = $this->stripe->refunds->create( - ['payment_intent' => $charge->payment_intent, 'amount' => $amount] + ['payment_intent' => $charge->payment_intent, 'amount' => StripeManager::toStripeAmount($amount, $transaction->order->currency)] ); } catch (InvalidRequestException $e) { return new PaymentRefund( @@ -281,7 +285,7 @@ public function refund(Transaction $transaction, int $amount = 0, $notes = null) 'success' => $refund->status != 'failed', 'type' => 'refund', 'driver' => 'stripe', - 'amount' => $refund->amount, + 'amount' => StripeManager::fromStripeAmount($refund->amount, $transaction->order->currency), 'reference' => $refund->payment_intent, 'status' => $refund->status, 'notes' => $notes, diff --git a/packages/table-rate-shipping/resources/lang/ar/relationmanagers.php b/packages/table-rate-shipping/resources/lang/ar/relationmanagers.php index 256ce25143..6358388299 100644 --- a/packages/table-rate-shipping/resources/lang/ar/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/ar/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'الحد الأدنى للوزن', - 'helper_text' => 'أدخل الوزن بالكيلوغرام', + 'helper_text' => 'أدخل الوزن بوحدة :unit', ], 'price' => [ 'label' => 'السعر', diff --git a/packages/table-rate-shipping/resources/lang/bg/relationmanagers.php b/packages/table-rate-shipping/resources/lang/bg/relationmanagers.php index 4b3c6d6981..4ccfa41d32 100644 --- a/packages/table-rate-shipping/resources/lang/bg/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/bg/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'Мин. тегло', - 'helper_text' => 'Въведете теглото в килограми', + 'helper_text' => 'Въведете теглото в :unit', ], 'price' => [ 'label' => 'Цена', diff --git a/packages/table-rate-shipping/resources/lang/en/relationmanagers.php b/packages/table-rate-shipping/resources/lang/en/relationmanagers.php index f0790ab92a..26f975c442 100644 --- a/packages/table-rate-shipping/resources/lang/en/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/en/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'Min. Weight', - 'helper_text' => 'Enter weight in kilograms', + 'helper_text' => 'Enter weight in :unit', ], 'price' => [ 'label' => 'Price', diff --git a/packages/table-rate-shipping/resources/lang/es/relationmanagers.php b/packages/table-rate-shipping/resources/lang/es/relationmanagers.php index 1cd276e8d1..c675104917 100644 --- a/packages/table-rate-shipping/resources/lang/es/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/es/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'Peso Mín.', - 'helper_text' => 'Introduce el peso en kilogramos', + 'helper_text' => 'Introduce el peso en :unit', ], 'price' => [ 'label' => 'Precio', diff --git a/packages/table-rate-shipping/resources/lang/fa/relationmanagers.php b/packages/table-rate-shipping/resources/lang/fa/relationmanagers.php index 725ac597d3..f23e0b9d46 100644 --- a/packages/table-rate-shipping/resources/lang/fa/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/fa/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'حداقل وزن', - 'helper_text' => 'وزن را به کیلوگرم وارد کنید', + 'helper_text' => 'وزن را به :unit وارد کنید', ], 'price' => [ 'label' => 'قیمت', diff --git a/packages/table-rate-shipping/resources/lang/fr/relationmanagers.php b/packages/table-rate-shipping/resources/lang/fr/relationmanagers.php index 3199e8ea0c..572e002c2d 100644 --- a/packages/table-rate-shipping/resources/lang/fr/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/fr/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'Poids Min.', - 'helper_text' => 'Saisir le poids en kilogrammes', + 'helper_text' => 'Saisir le poids en :unit', ], 'price' => [ 'label' => 'Prix', diff --git a/packages/table-rate-shipping/resources/lang/hr/relationmanagers.php b/packages/table-rate-shipping/resources/lang/hr/relationmanagers.php index ac73534201..7757dacc3f 100644 --- a/packages/table-rate-shipping/resources/lang/hr/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/hr/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'Min. težina', - 'helper_text' => 'Unesite težinu u kilogramima', + 'helper_text' => 'Unesite težinu u :unit', ], 'price' => [ 'label' => 'Cijena', diff --git a/packages/table-rate-shipping/resources/lang/hu/relationmanagers.php b/packages/table-rate-shipping/resources/lang/hu/relationmanagers.php index 05314ffd37..8955480f2a 100644 --- a/packages/table-rate-shipping/resources/lang/hu/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/hu/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'Min. súly', - 'helper_text' => 'Adja meg a súlyt kilogrammban', + 'helper_text' => 'Adja meg a súlyt :unit egységben', ], 'price' => [ 'label' => 'Ár', diff --git a/packages/table-rate-shipping/resources/lang/mn/relationmanagers.php b/packages/table-rate-shipping/resources/lang/mn/relationmanagers.php index cf2a9b58df..5420e7dc45 100644 --- a/packages/table-rate-shipping/resources/lang/mn/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/mn/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'Хамгийн бага жин', - 'helper_text' => 'Жинг килограммаар оруулна уу', + 'helper_text' => 'Жинг :unit-ээр оруулна уу', ], 'price' => [ 'label' => 'Үнэ', diff --git a/packages/table-rate-shipping/resources/lang/pl/relationmanagers.php b/packages/table-rate-shipping/resources/lang/pl/relationmanagers.php index 475f684949..ae3ebf30cd 100644 --- a/packages/table-rate-shipping/resources/lang/pl/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/pl/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'Min. Waga', - 'helper_text' => 'Podaj wagę w kilogramach', + 'helper_text' => 'Podaj wagę w :unit', ], 'price' => [ 'label' => 'Cena', diff --git a/packages/table-rate-shipping/resources/lang/pt_BR/relationmanagers.php b/packages/table-rate-shipping/resources/lang/pt_BR/relationmanagers.php index 6c0a3bd5a0..bcc7d1a72f 100644 --- a/packages/table-rate-shipping/resources/lang/pt_BR/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/pt_BR/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'Peso mín.', - 'helper_text' => 'Insira o peso em quilogramas', + 'helper_text' => 'Insira o peso em :unit', ], 'price' => [ 'label' => 'Preço', diff --git a/packages/table-rate-shipping/resources/lang/ro/relationmanagers.php b/packages/table-rate-shipping/resources/lang/ro/relationmanagers.php index ab9b971ba2..41f7c038e8 100644 --- a/packages/table-rate-shipping/resources/lang/ro/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/ro/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'Greutate min.', - 'helper_text' => 'Introduceți greutatea în kilograme', + 'helper_text' => 'Introduceți greutatea în :unit', ], 'price' => [ 'label' => 'Preț', diff --git a/packages/table-rate-shipping/resources/lang/tr/relationmanagers.php b/packages/table-rate-shipping/resources/lang/tr/relationmanagers.php index 0491f3be96..2bab315bf1 100644 --- a/packages/table-rate-shipping/resources/lang/tr/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/tr/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'Min. Ağırlık', - 'helper_text' => 'Ağırlığı kilogram olarak girin', + 'helper_text' => 'Ağırlığı :unit olarak girin', ], 'price' => [ 'label' => 'Fiyat', diff --git a/packages/table-rate-shipping/resources/lang/vi/relationmanagers.php b/packages/table-rate-shipping/resources/lang/vi/relationmanagers.php index 8fdb3b11de..9909f70367 100644 --- a/packages/table-rate-shipping/resources/lang/vi/relationmanagers.php +++ b/packages/table-rate-shipping/resources/lang/vi/relationmanagers.php @@ -39,7 +39,7 @@ ], 'min_weight' => [ 'label' => 'K.lượng T.thiểu', - 'helper_text' => 'Nhập trọng lượng theo kilôgam', + 'helper_text' => 'Nhập trọng lượng theo :unit', ], 'price' => [ 'label' => 'Giá', diff --git a/packages/table-rate-shipping/src/Drivers/ShippingMethods/ShipBy.php b/packages/table-rate-shipping/src/Drivers/ShippingMethods/ShipBy.php index 20b1ade7bf..545adbe292 100644 --- a/packages/table-rate-shipping/src/Drivers/ShippingMethods/ShipBy.php +++ b/packages/table-rate-shipping/src/Drivers/ShippingMethods/ShipBy.php @@ -72,8 +72,12 @@ public function resolve(ShippingOptionRequest $shippingOptionRequest): ?Shipping $tier = $subTotal; if ($chargeBy == 'weight') { + // Tiers are stored raw in the method's weight unit; each line's + // weight converts from its own purchasable unit. + $weightUnit = $shippingMethod->weight_unit ?: 'kg'; + $tier = $cart->lines->sum( - fn ($line) => $line->purchasable->weight->to('weight.kg')->convert()->getValue() * $line->quantity + fn ($line) => $line->purchasable->weight->to("weight.{$weightUnit}")->convert()->getValue() * $line->quantity ); } diff --git a/packages/table-rate-shipping/src/Filament/Resources/ShippingZoneResource/Pages/ManageShippingRates.php b/packages/table-rate-shipping/src/Filament/Resources/ShippingZoneResource/Pages/ManageShippingRates.php index 37ca4f26f7..8494d5db25 100644 --- a/packages/table-rate-shipping/src/Filament/Resources/ShippingZoneResource/Pages/ManageShippingRates.php +++ b/packages/table-rate-shipping/src/Filament/Resources/ShippingZoneResource/Pages/ManageShippingRates.php @@ -122,12 +122,20 @@ function () { : __('lunarpanel.shipping::relationmanagers.shipping_rates.form.prices.repeater.min_spend.label') ) ->helperText(fn (Get $get) => static::isWeightCharge($get) - ? __('lunarpanel.shipping::relationmanagers.shipping_rates.form.prices.repeater.min_weight.helper_text') + ? __('lunarpanel.shipping::relationmanagers.shipping_rates.form.prices.repeater.min_weight.helper_text', [ + 'unit' => static::getShippingWeightUnit($get('../../shipping_method_id')), + ]) : null ) // Unit symbol — intentionally not translated. - ->suffix(fn (Get $get) => static::isWeightCharge($get) ? 'kg' : null) + ->suffix(fn (Get $get) => static::isWeightCharge($get) + ? static::getShippingWeightUnit($get('../../shipping_method_id')) + : null + ) ->numeric() + // Weight tiers are stored as raw integers in the method's + // weight unit — reject decimals instead of truncating them. + ->rules(fn (Get $get) => static::isWeightCharge($get) ? ['integer'] : []) ->required(), ])->afterStateHydrated( static function (Repeater $component, ?Model $record = null): void { @@ -228,6 +236,19 @@ private static function isWeightCharge(Get $get): bool return static::getShippingChargeBy($get('../../shipping_method_id')) === 'weight'; } + private static function getShippingWeightUnit(ShippingMethod|int|null $method): string + { + if (blank($method)) { + return 'kg'; + } + + if (! $method instanceof ShippingMethod) { + $method = ShippingMethod::find($method); + } + + return $method?->weight_unit ?: 'kg'; + } + protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void { $shippingRate->basePrices()->delete(); diff --git a/packages/table-rate-shipping/src/Filament/Resources/ShippingZoneResource/Schemas/ShippingZoneForm.php b/packages/table-rate-shipping/src/Filament/Resources/ShippingZoneResource/Schemas/ShippingZoneForm.php index 85a40b056c..41755e9cdc 100644 --- a/packages/table-rate-shipping/src/Filament/Resources/ShippingZoneResource/Schemas/ShippingZoneForm.php +++ b/packages/table-rate-shipping/src/Filament/Resources/ShippingZoneResource/Schemas/ShippingZoneForm.php @@ -83,11 +83,9 @@ public static function getCountryComponent(): Component $country = $record->countries->first(); $component->state($country?->id); - })->getOptionLabelsUsing(static function (Model $record): array { - $record->loadMissing('countries.country'); - - return $record->countries - ->pluck('country.name', 'country.id') + })->getOptionLabelsUsing(static function (array $values): array { + return Country::whereIn('id', $values) + ->pluck('name', 'id') ->toArray(); }) ->saveRelationshipsUsing(static function (Model $record, $state) { @@ -116,10 +114,8 @@ public static function getCountriesComponent(): Component ->map(static fn ($key): string => strval($key)) ->toArray(), ); - })->getOptionLabelsUsing(static function (Model $record): array { - $record->loadMissing('countries'); - - return $record->countries + })->getOptionLabelsUsing(static function (array $values): array { + return Country::whereIn('id', $values) ->pluck('name', 'id') ->toArray(); }) @@ -148,10 +144,8 @@ public static function getStatesComponent(): Component ->map(static fn ($key): string => strval($key)) ->toArray(), ); - })->getOptionLabelsUsing(static function (Model $record): array { - $record->loadMissing('states'); - - return $record->states + })->getOptionLabelsUsing(static function (array $values): array { + return State::whereIn('id', $values) ->pluck('name', 'id') ->toArray(); }) diff --git a/packages/upgrade/src/Rector/LunarSetList.php b/packages/upgrade/src/Rector/LunarSetList.php index 308dec4216..35bf2d35a0 100644 --- a/packages/upgrade/src/Rector/LunarSetList.php +++ b/packages/upgrade/src/Rector/LunarSetList.php @@ -5,6 +5,7 @@ namespace Lunar\Upgrade\Rector; use Lunar\Upgrade\Rector\Models\RewriteModelClassCallRector; +use Lunar\Upgrade\Rector\Pricing\RetypeFormatterStyleParamRector; /** * Catalog of Rector renames contributed by v2 breaking specs. @@ -528,6 +529,7 @@ final class LunarSetList */ public const V1_TO_V2 = [ RewriteModelClassCallRector::class, + RetypeFormatterStyleParamRector::class, ]; /** diff --git a/packages/upgrade/src/Rector/Pricing/RetypeFormatterStyleParamRector.php b/packages/upgrade/src/Rector/Pricing/RetypeFormatterStyleParamRector.php new file mode 100644 index 0000000000..e5a52fe300 --- /dev/null +++ b/packages/upgrade/src/Rector/Pricing/RetypeFormatterStyleParamRector.php @@ -0,0 +1,86 @@ +> + */ + public function getNodeTypes(): array + { + return [ClassMethod::class]; + } + + /** + * @param ClassMethod $node + */ + public function refactor(Node $node): ?Node + { + if (! $node->name instanceof Identifier) { + return null; + } + + if (! in_array($node->name->toString(), self::METHODS, true)) { + return null; + } + + $changed = false; + + foreach ($node->params as $param) { + if (! $param->var instanceof Node\Expr\Variable) { + continue; + } + + if ($param->var->name !== self::PARAM_NAME) { + continue; + } + + if (! $param->type instanceof Identifier || $param->type->toString() !== 'string') { + continue; + } + + $param->type = new Identifier('int'); + $changed = true; + } + + return $changed ? $node : null; + } +} diff --git a/specs/completed/0065-port-v1.5-fixes.md b/specs/completed/0065-port-v1.5-fixes.md new file mode 100644 index 0000000000..16a106e83b --- /dev/null +++ b/specs/completed/0065-port-v1.5-fixes.md @@ -0,0 +1,114 @@ +# 0065 — Port the v1.5.0 fixes into v2 + +- Status: implemented +- Author: Glenn +- Created: 2026-08-26 +- TODO item: Port the v1.5.0 fixes into v2 + +## Problem + +Lunar 1.5.0 shipped 36 changes on the 1.x line (PRs #2409–#2648, tagged `1.4.0..1.5.0`). The 2.x branch forked at #2382, so none of them are on 2.x by cherry-pick — a code-level comparison of each 1.x commit against the current 2.x tree shows: + +- **4 already landed independently**: the order-line de-morph (#2643 is the backport of spec [[0045-optional-purchasables-and-shipping-de-morph]]), `DateTimeImmutable` support (#2648 backports 2.x #2594), the nestedset fork swap (#2510, on 2.x as `2f38c2a2`), and the Route facade imports (#2636 ≙ 2.x #2620). +- **2 are N/A by design**: attribute `default_value` (#2505) — the column was deliberately dropped by spec 0019; re-adding it is a new spec, not a port. The Boost skill (#2491) is v1-pinned throughout (namespaces, doc paths, contract advice) and is covered by the existing "Add Boost guidelines to packages" TODO item as a rewrite. +- **The remaining fixes are all still live bugs on 2.x.** Every one was verified against the 2.x working tree: the vulnerable code is present, usually byte-identical to the pre-fix 1.x shape, sometimes with v2-specific amplifiers (e.g. real inventory makes an unfulfillable BuyXGetY reward line an uncalculable cart; morph-alias matching makes the FQCN reward-line bug self-perpetuating). + +Straight cherry-picks don't apply: namespaces moved to `Lunar\Core\…`, `Base/` was reorganised, actions are constructor-injected with typed signatures, money is `PriceValue`, admin code split into `admin`/`filament`, and the Inertia panel is a second UI surface. Each fix needs re-implementing in its v2 location. + +## Proposal + +Re-implement the outstanding 1.5.0 fixes on 2.x, grouped below. Each item lists the 1.x source PR and the v2 target. Behavioural intent is preserved; the mechanics adapt to v2. + +### A. BuyXGetY — conditions and automatic rewards (`core/src/DiscountTypes/BuyXGetY.php`) + +The four `processAutomaticRewards()` fixes are one textual stack and must land in order; the conditions guard is independent. + +1. **Honour discount conditions** (#2624) — add the `checkDiscountConditions($cart)` guard at the top of `apply()`, matching `AmountOff` and `ShippingDiscount`. Without it, min spend, customer restrictions and `max_uses_per_user` are skipped for BuyXGetY. +2. **Variant rewards** (#2631) — add the `instanceof Purchasable` branch in `processAutomaticRewards()`; today a `ProductVariant` reward fatals on `->variants->first()`. v2's admin already offers variants as rewards, so the crash is reachable. +3. **Morph-map reward lines** (#2632) — `get_class($purchasable)` → `$purchasable->getMorphClass()`. Worse in v2 than 1.x: `apply()` matches lines on morph aliases, so an FQCN-typed reward line is never re-matched on the next calculate. +4. **Multi-quantity rewards as one line** (#2639) — port the `$addedRewardLines` map and the quantity-increment branch. Adapt the money maths to `PriceValue` (`$rewardLine->unitPrice->multiply($rewardLine->quantity)`); drop the now-dead `$unitQuantity` local. +5. **Only fulfillable rewards** (#2509) — filter `discountableRewards` through `canBeFulfilledAtQuantity()` and cap allocation against stock. Higher stakes in v2: `CartLineStock` validation rejects unfulfillable lines, so an out-of-stock automatic reward currently wedges the cart. Check whether the spec-0038 inventory surface offers a more idiomatic availability check before hand-rolling. + +### B. Discount memoisation correctness (`core/src/Managers/DiscountManager.php`, `Models/Cart.php`, `Actions/Carts/CreateOrder.php`) + +6. **Scope the memoised discount set to the cart it was built for** (#2623) — port the `$discountsKey` cache key (cart id + coupon + customer + line purchasables) into `DiscountManager::apply()`/`resetDiscounts()`. Spec [[0064-scoped-service-lifetimes]] fixed cross-request leakage only; this intra-request staleness bug (coupon accepted, total unchanged until reload) is fully present. Note v2 has no `resetDiscounts()` callers, so the key is the only invalidation a storefront gets. +7. **A cart's own consumption doesn't count against it** (#2637 + follow-up #2638) — port as one unit: `Cart::consumedDiscountIds()` (memoised, with `forgetConsumedDiscountIds()` invalidated from `CreateOrder`), the `$exempt` parameter on `Discount::scopeUsable()`, the consumed-aware `checkDiscountConditions()`, and the `markAsUsed()` skip in `CreateOrder`. Port #2638's final shape (invalidate on order creation), not its intermediate `fresh:` parameter. Prerequisites (`discount_breakdown` raw JSON with `discount_id`) exist in v2 unchanged. + +### C. Cart, tax and shipping (core + table-rate-shipping) + +8. **Reprice the cart when the session currency changes** (#2625) — `unsetRelation('currency')`/`unsetRelation('lines')` in `CartSessionManager::setCurrency()`. v2 also has a second currency-switching path: `StorefrontSessionManager::setCurrency()` (spec 0040) never touches the cart at all, so a region switch left it priced in the old currency. It now delegates to `CartSession::setCurrency()`, while the boot cascade writes through a non-propagating `putCurrency()` so resolving a currency per request never calculates or creates a cart. +9. **Scope state tax-zone lookups to the address country** (#2626) — thread `?int $countryId` from `GetTaxZone` into `GetTaxZoneState` with the `whereNull('country_id') OR country_id = ?` constraint. `GetTaxZoneState` has no contract of its own, so no interface change. Region (spec 0039) left tax-zone resolution intact. +10. **Shipping-modifier re-entrancy guard** (#2630) — the `$resolving` flag + `finally` in `ShippingManifest::getOptions()`. The manifest also moves from `singleton` to `scoped` and joins `ServiceLifetimesTest` — it holds the options resolved for the cart in hand plus the new flag, so it is per-request state, and the `finally` keeps a throwing modifier from wedging the flag within that request. +11. **Weight tiers in the method's configured `weight_unit`** (#2645) — both halves: `ShipBy::resolve()` sums in `$shippingMethod->weight_unit ?: 'kg'` (the constraint path in `ShippingRateResolver` is already unit-aware; the tier path is not — the exact split #2645 fixed), and `ManageShippingRates` gets the unit-aware suffix/helper-text plus the `integer` rule. 14 table-rate-shipping locales gain the `:unit` placeholder. Keep the three existing #2382 regression tests green (they use methods with no `weight_unit`, so the `?: 'kg'` default preserves them). +12. **`SystemTaxDriver` uses `TaxZone::getDefault()`** (#2511) — one line; bigger payoff in v2 because `defaultTaxZone()` runs per purchasable inside `getBreakdown()`. +13. **Annotate `getDefault()` as `null|self`** (#2634) — docblock on `Models/Concerns/HasDefaultRecord`. v2's typed `CartSessionManager::getCurrency(): Currency` / `getChannel(): Channel` make the missing-default case a hard `TypeError`; whether that deserves a domain exception is an open question, not part of this port. + +### D. Core models and orders + +14. **Deterministic ordering for PostgreSQL** (#2518) — `Cart::lines()->orderBy('id')`, `Order::lines()->orderBy('id')`, `ProductVariant::values()->orderBy('position')->orderByPivot('id')`. Fingerprints and the snapshotted variant option label depend on iteration order; heap order is unstable on Postgres. Derived relations (`productLines()`, `shippingLines()`, …) inherit the fix. +15. **One order address per type** (#2507) — drop the `postcode` clause from the match in `CreateOrderAddresses`; a postcode change on recalculation currently duplicates the address row (no unique index to catch it). +16. **Stock events** (#2606 / #2226) — **out of scope, deferred to its own spec.** The 1.x shape (an admin-namespaced event dispatched from a Filament page) is the wrong layer for v2 and would miss the panel controller, `AdjustStock`, and every automatic order/fulfilment movement. What v2 actually needs is spec 0038's documented-but-unshipped events section — `StockMovementRecorded` from `RecordStockMovement`, a variant-level stock-changed event from `RecomputeStockRollup`, and the Scout re-index on `stock_available` change. That is new surface rather than a port, so it belongs in a spec of its own against 0038 (tracked in `TODO.md`). + +### E. Filament admin + +17. **Trim whitespace from form input** (#2642) — `TextInput`/`Textarea`/`TagsInput` `configureUsing(... ->trim())` in `LunarPanelManager::register()` (Livewire skips the global `TrimStrings` middleware). The Inertia panel needs nothing — its requests go through the normal HTTP stack. +18. **Product option mapping + new-variant tax class** (#2640) — fix the `search() === false` coercion and add the `copied_id`-from-oldest-sibling behaviour in the now-core `Actions/Products/MapVariantsToProductOptions.php`, plus the widget-side fallback in `ProductOptionsWidget`. The panel's `GenerateProductVariants` path is already structurally correct. +19. **Stop dropping media custom properties** (#2598) — `MediaRelationManager`: pass `custom_properties` wholesale on create, `array_merge` on edit. The panel's `AddMedia`/`UpdateMedia` actions already merge per key. +20. **Country/state selects reject fresh selections** (#2612) — retype the `getOptionLabelsUsing()` closures to `array $values` with direct `whereIn` lookups in `TaxZoneForm` (bridge) and `ShippingZoneForm` (table-rate-shipping). Fix the latent v2-only bug in the same pass: `ShippingZoneForm::getCountryComponent()` plucks `country.name` off a relation that doesn't exist, so its labels are always empty. +21. **Localize boolean meta values** (#2545) — the `is_bool` ternary in `ManageOrder::getDefaultAdditionalInfoSection()` plus `yes`/`no` keys in `global.php` across all 16 `packages/admin` locales (this file still resolves through `lunarpanel::` — don't move namespaces as part of the port). Keep the stringification in `getStateUsing` so the `strlen()` tooltip closure downstream stays safe. +22. **Unit quantity minimum of 1** (#2547) — `->minValue(1)` on `ProductVariantForm::getUnitQtyComponent()` and the `max(1, $unitQty)` clamp in `DefaultPriceFormatter` (division-by-zero guard). The panel already enforces `min:1` in `VariantFields`. Consider the sibling `min_quantity`/`quantity_increment` components while there. +23. **Translate product-options widget labels** (#2555) — `->label()` on the `addSharedOption` and `saveVariants` actions, the empty-state heading in the widget blade (delete the stale duplicate blade under `packages/admin` rather than fixing both), and a new `save-variants.label` key across all 16 `packages/filament` locales. +24. **Shipping permission labels** (#2484) — the 1.x `mergeTranslationsForPanel()` merge hack is **not** ported. v2 centralises label resolution in the `Permission` DTO on the flat key `lunar::auth.permissions.{handle}.*`, consumed by both the Filament staff form and the panel role/staff controllers, so one fix covers both surfaces: the two `shipping:manage` keys go into `packages/core` `auth.php` across 16 locales. `shipping:manage` is a first-party permission registered by a first-party migration, so core is its home; no runtime merging, and no new machinery. A third-party add-on that wants its own permission labelled uses `Lang::addLines()` into the `lunar` namespace — the native Laravel API, which needs nothing from us because the DTO reads a flat dotted key. Renaming the v1-era `lunarpanel.shipping` translation namespace is deliberately **left alone**: it would break every `__('lunarpanel.shipping::…')` call site and any published overrides, so it needs its own spec and Rector rule. +25. **Collection breadcrumb gaps in discount selection** (#2557) — mostly covered by `CollectionSelect` already; close the deltas: prepend the collection group name to `optionLabel()` (+ eager-load `group`), give `CollectionConditionRelationManager`'s table column the breadcrumb `description()` + `morphWith` eager load for parity with the limitation manager, and eager-load `ancestors` in `AppliesToExistingSelect::applyTo()` (an N+1 that trips `preventLazyLoading()`). +26. **Digital lines on the PDF invoice** (#2599) — the bug survived the v2 refactor in a new form: `pdf/order.blade.php` iterates `fulfillableLines`, and non-shippable variants have `requires_fulfilment = false`, so they vanish from the invoice body. Switch to `productLines`. Audit other `fulfillableLines` call sites to confirm the invoice is the only mis-application. + +### F. Payments and pricing + +27. **Stripe amounts by currency decimal places** (#2601) — port the full conversion layer (`toStripeAmount()` rewrite, new `fromStripeAmount()`, zero/three-decimal and special-case currency tables, integer-only rounding) and apply it at capture, refund, intent-total assertion and `StoreCharges`. v2 adaptations: keep the concrete `Currency` type (v2's stripe package doesn't use model contracts); preserve v2's plain-int `$order->total` in `assertIntentMatchesTotal()`; port the supporting test infra (`MockClient` request capture + refund fixture, `CartBuilder` `currencyParams`, `StripeFake` scaling). Bundle the #2644 currency pin for `StoreChargesTest` — without it, this port introduces the random CI flake 1.x hit. +28. **`formatterStyle` is an `int`** (#2520) — `string` → `int` on the three `DefaultPriceFormatter` signatures **and** on `PriceFormatterInterface` (v2 widened the interface to declare the full parameter list, so both files must change together). + +## Alternatives considered + +- **Cherry-pick from 1.x**: rejected — the fork predates all 36 changes and v2 moved or reshaped every target (namespaces, `Base/` reorganisation, DI-typed actions, `PriceValue`, the admin/filament split, the panel). Each pick would conflict into a rewrite anyway. +- **Port everything verbatim, including shape**: rejected for the three items where 1.x fixed the symptom at the wrong layer for v2 — stock events (#2606) belong on the core write seam per spec 0038, permission labels (#2484) belong on the `Permission` DTO key convention, and the Stripe test pin (#2644) is meaningless without #2601. +- **Do nothing / wait for bug reports**: rejected — several of these are worse in v2 than in 1.x (unfulfillable reward lines wedge carts under real inventory; FQCN reward lines defeat v2's alias-based matching; the manifest re-entrancy guard protects a process-lifetime singleton). + +## Migration impact + +- **Database**: none. Every schema-adjacent 1.x change (nullable purchasable morph, weight-unit column) is already in the v2 baseline. +- **Public contract surface**: additive only — `Discount::scopeUsable()` gains an optional `$exempt` parameter, `Cart` gains `consumedDiscountIds()`/`forgetConsumedDiscountIds()`, `GetTaxZoneState::execute()` gains an optional `$countryId`, new stock events in `Lunar\Core\Events\Products`. `PriceFormatterInterface`'s `formatterStyle` retype is a signature change on a published interface; downstream implementations that copied the buggy `string` type would become a signature mismatch, so `RetypeFormatterStyleParamRector` (registered in `LunarSetList::V1_TO_V2`) retypes them. +- **Upgrade path**: no new v1 → v2 data transformations. +- **Translations**: four locale batches — `:unit` placeholder in table-rate-shipping `relationmanagers.php` (14 locales), `yes`/`no` in admin `global.php` (16), `save-variants.label` in filament `productoption.php` (16), `shipping:manage` permission label/description (16, location per item 24). Translate values per locale; no English placeholders. +- **Filament / admin**: items 17–26 above; no new screens, all fixes to existing surfaces. Panel is unaffected except where noted (already correct or section not yet built — the orders/discounts panel sections should pick up items 21 and 25 as acceptance criteria when they land). + +## Open questions + +Resolved during implementation: + +- **Currency switch propagation** (item 8): `StorefrontSessionManager::setCurrency()` propagates to `CartSession::setCurrency()`, which owns the cart row write and the relation resets. The boot cascade (`initCurrency()`, and `initRegion()` which deliberately bypasses `setRegion()`) writes through a non-propagating `putCurrency()`, so resolving a currency on every request never calculates — or creates — a cart. +- **`ShippingManifest` lifetime**: moved to `scoped` and added to `ServiceLifetimesTest`. It holds the resolved options for the cart in hand plus the new `$resolving` flag, so it is per-request state rather than a boot-time registry. +- **Stock events** (item 16): deferred to its own spec against 0038. + +Still open: + +- **Missing default records** (item 13): the annotation lands as a docblock-only change (PHPStan level 0 is unaffected). Separately, the typed `CartSessionManager::getCurrency()/getChannel()` still fail with a `TypeError` when no default exists — whether that should be a domain exception is its own question. Owner: Glenn. +- **Attribute `default_value`**: wanted back in v2? If so it follows the spec-0062 validation-rules template (core field-type concept + both panels) as its own spec. Owner: Glenn. + +## References + +- 1.5.0 release: https://github.com/lunarphp/lunar/releases/tag/1.5.0 (range `1.4.0..1.5.0`, 36 first-parent merges) +- Already on 2.x: #2594 (≙ #2648), #2620 (≙ #2636), `2f38c2a2` (≙ #2510), spec [[0045-optional-purchasables-and-shipping-de-morph]] (≙ #2643) +- Related specs: [[0038-inventory-fundamentals]] (item 16 implements its events section), [[0064-scoped-service-lifetimes]] (orthogonal to item 6), 0019/0062 (attribute `default_value` history) + +## Implementation plan + +- [x] Slice 1 — BuyXGetY: conditions guard + automatic-rewards stack (items 1–5, in order) +- [x] Slice 2 — Discount memoisation: cache key + consumed-discount exemption (items 6–7) +- [x] Slice 3 — Cart/tax/shipping core: currency reprice, state tax zones, manifest re-entrancy, `getDefault()` (items 8–10, 12–13) +- [x] Slice 4 — Weight-unit tiers incl. 14 locales (item 11) +- [x] Slice 5 — Core model fixes: deterministic ordering, one address per type (items 14–15) +- [x] Slice 6 — Filament admin batch 1: trim, option mapping, media properties, country/state selects (items 17–20) +- [x] Slice 7 — Filament admin batch 2: boolean meta, unit qty, widget labels, breadcrumb gaps, PDF invoice, incl. locales (items 21–23, 25–26) +- [x] Slice 8 — Shipping permission label seam incl. 16 locales (item 24) +- [x] Slice 9 — Stripe amount conversion + test pin (item 27) +- [x] Slice 10 — Pricing formatter: `formatterStyle` retype + unit-qty clamp (item 28 + the core half of item 22) diff --git a/tests/admin/Feature/Filament/Resources/OrderResource/Pages/ManageOrderTest.php b/tests/admin/Feature/Filament/Resources/OrderResource/Pages/ManageOrderTest.php index 0aad2102dd..689b925e6a 100644 --- a/tests/admin/Feature/Filament/Resources/OrderResource/Pages/ManageOrderTest.php +++ b/tests/admin/Feature/Filament/Resources/OrderResource/Pages/ManageOrderTest.php @@ -247,3 +247,37 @@ public function via(): array NotificationFacade::assertNothingSent(); }); + +it('displays boolean order meta values as localized yes/no', function () { + $this->order->update([ + 'meta' => [ + 'express_shipping' => true, + 'gift_message' => false, + ], + ]); + + Livewire::test(ManageOrder::class, [ + 'record' => $this->order->getRouteKey(), + ]) + ->assertSuccessful() + ->assertSchemaComponentStateSet('meta.meta_express_shipping', __('lunarpanel::global.yes')) + ->assertSchemaComponentStateSet('meta.meta_gift_message', __('lunarpanel::global.no')); +}); + +it('does not display numeric order meta values as yes/no', function () { + $this->order->update([ + 'meta' => [ + 'parcel_count' => 1, + 'failed_attempts' => 0, + 'floor' => '1', + ], + ]); + + Livewire::test(ManageOrder::class, [ + 'record' => $this->order->getRouteKey(), + ]) + ->assertSuccessful() + ->assertSchemaComponentStateSet('meta.meta_parcel_count', 1) + ->assertSchemaComponentStateSet('meta.meta_failed_attempts', 0) + ->assertSchemaComponentStateSet('meta.meta_floor', '1'); +}); diff --git a/tests/admin/Feature/Support/RelationManagers/MediaRelationManagerTest.php b/tests/admin/Feature/Support/RelationManagers/MediaRelationManagerTest.php index 20a29f18f0..89294e0f1e 100644 --- a/tests/admin/Feature/Support/RelationManagers/MediaRelationManagerTest.php +++ b/tests/admin/Feature/Support/RelationManagers/MediaRelationManagerTest.php @@ -1,8 +1,15 @@ components([ + ...$schema->getComponents(true), + TextInput::make('custom_properties.credits'), + ]); + + return $schema; + } + }; + + LunarPanel::extensions([ + MediaRelationManager::class => $class::class, + ]); + + $this->asStaff(); + + Language::factory()->create([ + 'default' => true, + ]); + + $brand = Brand::factory()->create(); + + Livewire::test(MediaRelationManager::class, [ + 'ownerRecord' => $brand, + 'pageClass' => ManageBrandMedia::class, + ])->callTableAction(CreateAction::class, data: [ + 'custom_properties.name' => 'Test image', + 'custom_properties.credits' => 'Jane Doe', + 'media' => UploadedFile::fake()->image('foobar.jpg'), + ])->assertHasNoTableActionErrors(); + + $media = $brand->fresh()->getFirstMedia('default'); + + expect($media)->not->toBeNull() + ->and($media->getCustomProperty('name'))->toBe('Test image') + ->and($media->getCustomProperty('credits'))->toBe('Jane Doe'); +}); + +it('preserves existing custom properties not present on the edit form', function () { + $this->asStaff(); + + Language::factory()->create([ + 'default' => true, + ]); + + $brand = Brand::factory()->create(); + + $media = $brand + ->addMedia(UploadedFile::fake()->image('foobar.jpg')) + ->preservingOriginal() + ->withCustomProperties([ + 'name' => 'Original name', + 'sha1' => 'abc123', + ]) + ->toMediaCollection('default'); + + Livewire::test(MediaRelationManager::class, [ + 'ownerRecord' => $brand, + 'pageClass' => ManageBrandMedia::class, + ])->mountTableAction(EditAction::class, $media) + ->setTableActionData([ + 'custom_properties' => [ + 'name' => 'Updated name', + 'primary' => false, + ], + ])->callMountedTableAction() + ->assertHasNoTableActionErrors(); + + $media->refresh(); + + expect($media->getCustomProperty('name'))->toBe('Updated name') + ->and($media->getCustomProperty('sha1'))->toBe('abc123'); +}); diff --git a/tests/admin/Unit/Actions/Products/MapVariantsToProductOptionsTest.php b/tests/admin/Unit/Actions/Products/MapVariantsToProductOptionsTest.php index df14d0a29d..e0f4004972 100644 --- a/tests/admin/Unit/Actions/Products/MapVariantsToProductOptionsTest.php +++ b/tests/admin/Unit/Actions/Products/MapVariantsToProductOptionsTest.php @@ -78,3 +78,118 @@ expect($result)->toHaveCount(4); }); + +it('does not assign the first variant sku to unmatched sparse permutations', function () { + $optionValues = [ + 'Size' => [ + 'XS', + 'L', + ], + 'Colour' => [ + 'Navy', + 'White', + ], + 'Length' => [ + 'Long', + 'Short', + ], + 'Fit' => [ + 'Slim', + ], + ]; + + $variants = [ + [ + 'id' => 1, + 'sku' => 'TEE-L-NAVY-SHORT', + 'price' => 0, + 'stock' => 0, + 'values' => [ + 'Size' => 'L', + 'Colour' => 'Navy', + 'Length' => 'Short', + 'Fit' => 'Slim', + ], + ], + [ + 'id' => 2, + 'sku' => 'TEE-XS-WHITE-LONG', + 'price' => 0, + 'stock' => 0, + 'values' => [ + 'Size' => 'XS', + 'Colour' => 'White', + 'Length' => 'Long', + 'Fit' => 'Slim', + ], + ], + ]; + + $result = MapVariantsToProductOptions::map($optionValues, $variants, fillMissing: false); + + expect($result)->toHaveCount(2); + + $mappedBySku = collect($result)->keyBy('sku'); + + expect($mappedBySku['TEE-L-NAVY-SHORT']['values'])->toBe([ + 'Size' => 'L', + 'Colour' => 'Navy', + 'Length' => 'Short', + 'Fit' => 'Slim', + ])->and($mappedBySku['TEE-XS-WHITE-LONG']['values'])->toBe([ + 'Size' => 'XS', + 'Colour' => 'White', + 'Length' => 'Long', + 'Fit' => 'Slim', + ]); + + // First cartesian permutation is XS/Navy/Long — must not steal the first variant SKU. + expect( + collect($result)->contains(fn (array $row) => $row['sku'] === 'TEE-L-NAVY-SHORT' + && $row['values']['Size'] === 'XS') + )->toBeFalse(); +}); + +it('keeps unmatched permutations without binding the first variant when fillMissing is enabled', function () { + $optionValues = [ + 'Size' => [ + 'Small', + 'Large', + ], + 'Colour' => [ + 'Red', + 'Blue', + ], + ]; + + $variants = [ + [ + 'id' => 10, + 'sku' => 'SMALL-RED', + 'price' => 1, + 'stock' => 5, + 'values' => [ + 'Size' => 'Small', + 'Colour' => 'Red', + ], + ], + ]; + + $result = MapVariantsToProductOptions::map($optionValues, $variants, fillMissing: true); + + expect($result)->toHaveCount(4); + + $exact = collect($result)->first( + fn (array $row) => $row['values'] === ['Size' => 'Small', 'Colour' => 'Red'] + ); + + expect($exact['sku'])->toBe('SMALL-RED') + ->and($exact['variant_id'])->toBe(10) + ->and($exact['copied_id'])->toBeNull(); + + $unmatched = collect($result) + ->reject(fn (array $row) => $row['variant_id'] === 10); + + expect($unmatched->pluck('sku')->all())->each->toBeNull(); + expect($unmatched->pluck('copied_id')->unique()->all())->toBe([10]); +}); diff --git a/tests/core/Stubs/TestRecursiveShippingModifier.php b/tests/core/Stubs/TestRecursiveShippingModifier.php new file mode 100644 index 0000000000..d5f2ab5599 --- /dev/null +++ b/tests/core/Stubs/TestRecursiveShippingModifier.php @@ -0,0 +1,32 @@ +calculate(); + } + + return $next($cart); + } +} diff --git a/tests/core/Unit/Actions/Carts/CreateOrderTest.php b/tests/core/Unit/Actions/Carts/CreateOrderTest.php index 8bd62866a4..65358478fd 100644 --- a/tests/core/Unit/Actions/Carts/CreateOrderTest.php +++ b/tests/core/Unit/Actions/Carts/CreateOrderTest.php @@ -4,14 +4,18 @@ use Lunar\Core\Actions\Carts\CreateOrder; use Lunar\Core\DataObjects\PriceValue as PriceDataType; use Lunar\Core\DataTypes\ShippingOption; +use Lunar\Core\DiscountTypes\AmountOff; use Lunar\Core\Exceptions\DisallowMultipleCartOrdersException; +use Lunar\Core\Facades\Discounts; use Lunar\Core\Facades\ShippingManifest; use Lunar\Core\Models\Cart; use Lunar\Core\Models\CartAddress; +use Lunar\Core\Models\Channel; use Lunar\Core\Models\Country; use Lunar\Core\Models\Currency; use Lunar\Core\Models\Customer; use Lunar\Core\Models\CustomerGroup; +use Lunar\Core\Models\Discount; use Lunar\Core\Models\Order; use Lunar\Core\Models\OrderAddress; use Lunar\Core\Models\OrderLine; @@ -347,3 +351,421 @@ function can_update_draft_order() $this->assertDatabaseHas((new Order)->getTable(), $datacheck); }); + +test('can keep the discount when the draft order is created again', function () { + TaxClass::factory()->create([ + 'default' => true, + ]); + + $customerGroup = CustomerGroup::factory()->create([ + 'default' => true, + ]); + + $channel = Channel::factory()->create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'decimal_places' => 2, + ]); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + 'channel_id' => $channel->id, + 'coupon_code' => 'SAVE10', + ]); + + $purchasable = ProductVariant::factory()->create(); + + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + + $cart->lines()->create([ + 'purchasable_type' => $purchasable->getMorphClass(), + 'purchasable_id' => $purchasable->id, + 'quantity' => 2, + ]); + + // A single-use coupon, which is the ordinary shape of a promotional code. + $discount = Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Ten off', + 'coupon' => 'SAVE10', + 'uses' => 0, + 'max_uses' => 1, + 'data' => [ + 'fixed_value' => true, + 'fixed_values' => [ + $currency->code => 500, + ], + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $cart->calculate(); + + $orderA = (new CreateOrder)->execute($cart)->refresh(); + + expect($orderA->discount_total)->toEqual(500); + expect($discount->refresh()->uses)->toEqual(1); + + // The card is declined and the shopper tries another one. That is a fresh + // request, so nothing is memoised from the first attempt. + Discounts::resetDiscounts(); + + $cart = Cart::find($cart->id); + $cart->calculate(); + + $orderB = (new CreateOrder)->execute($cart)->refresh(); + + expect($orderB->id)->toEqual($orderA->id); + expect($orderB->discount_total)->toEqual(500); + + // The retry must not consume a second use of a single-use coupon. + expect($discount->refresh()->uses)->toEqual(1); +}); + +test('can not reuse a discount another cart has exhausted', function () { + TaxClass::factory()->create([ + 'default' => true, + ]); + + $customerGroup = CustomerGroup::factory()->create([ + 'default' => true, + ]); + + $channel = Channel::factory()->create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'decimal_places' => 2, + ]); + + $purchasable = ProductVariant::factory()->create(); + + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + + $discount = Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Ten off', + 'coupon' => 'SAVE10', + 'uses' => 0, + 'max_uses' => 1, + 'data' => [ + 'fixed_value' => true, + 'fixed_values' => [ + $currency->code => 500, + ], + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $makeCart = function () use ($currency, $channel, $purchasable) { + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + 'channel_id' => $channel->id, + 'coupon_code' => 'SAVE10', + ]); + + $cart->lines()->create([ + 'purchasable_type' => $purchasable->getMorphClass(), + 'purchasable_id' => $purchasable->id, + 'quantity' => 2, + ]); + + return $cart; + }; + + $cartA = $makeCart(); + $cartA->calculate(); + $orderA = (new CreateOrder)->execute($cartA)->refresh(); + + expect($orderA->discount_total)->toEqual(500); + expect($discount->refresh()->uses)->toEqual(1); + + // A different shopper, with the last use already spent. + Discounts::resetDiscounts(); + + $cartB = $makeCart(); + $cartB->calculate(); + $orderB = (new CreateOrder)->execute($cartB)->refresh(); + + expect($orderB->id)->not->toEqual($orderA->id); + expect($orderB->discount_total)->toEqual(0); + expect($discount->refresh()->uses)->toEqual(1); +}); + +test('can still enforce other conditions on a discount the cart consumed', function () { + TaxClass::factory()->create([ + 'default' => true, + ]); + + $customerGroup = CustomerGroup::factory()->create([ + 'default' => true, + ]); + + $channel = Channel::factory()->create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'decimal_places' => 2, + ]); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + 'channel_id' => $channel->id, + 'coupon_code' => 'SAVE10', + ]); + + $purchasable = ProductVariant::factory()->create(); + + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + + $line = $cart->lines()->create([ + 'purchasable_type' => $purchasable->getMorphClass(), + 'purchasable_id' => $purchasable->id, + 'quantity' => 2, + ]); + + // Spend at least 15.00 to qualify. Two units is 20.00, one is 10.00. + $discount = Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Ten off', + 'coupon' => 'SAVE10', + 'uses' => 0, + 'max_uses' => 1, + 'data' => [ + 'fixed_value' => true, + 'fixed_values' => [ + $currency->code => 500, + ], + 'min_prices' => [ + $currency->code => 1500, + ], + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $cart->calculate(); + + $orderA = (new CreateOrder)->execute($cart)->refresh(); + + expect($orderA->discount_total)->toEqual(500); + + // The shopper drops a unit, taking the cart under the minimum spend. Being + // the cart that consumed the discount must not exempt it from that. + Discounts::resetDiscounts(); + + $cart = Cart::find($cart->id); + $cart->updateLine($line->id, 1); + $cart->calculate(); + + $orderB = (new CreateOrder)->execute($cart)->refresh(); + + expect($orderB->id)->toEqual($orderA->id); + expect($orderB->sub_total)->toEqual(1000); + expect($orderB->discount_total)->toEqual(0); +}); + +test('can not consume a discount twice on one cart instance', function () { + TaxClass::factory()->create([ + 'default' => true, + ]); + + $customerGroup = CustomerGroup::factory()->create([ + 'default' => true, + ]); + + $channel = Channel::factory()->create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'decimal_places' => 2, + ]); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + 'channel_id' => $channel->id, + 'coupon_code' => 'SAVE10', + ]); + + $purchasable = ProductVariant::factory()->create(); + + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + + $cart->lines()->create([ + 'purchasable_type' => $purchasable->getMorphClass(), + 'purchasable_id' => $purchasable->id, + 'quantity' => 2, + ]); + + $discount = Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Ten off', + 'coupon' => 'SAVE10', + 'uses' => 0, + 'max_uses' => 1, + 'data' => [ + 'fixed_value' => true, + 'fixed_values' => [ + $currency->code => 500, + ], + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + // The same instance throughout: no reload between the two attempts. This is + // what a checkout that retries in one request looks like, and it is the case + // any memoisation of consumedDiscountIds() has to survive - a set cached + // before the first order exists would still be empty for the second. + $cart->calculate(); + + $orderA = (new CreateOrder)->execute($cart)->refresh(); + + expect($orderA->discount_total)->toEqual(500); + expect($discount->refresh()->uses)->toEqual(1); + + $cart->calculate(); + + $orderB = (new CreateOrder)->execute($cart)->refresh(); + + expect($orderB->id)->toEqual($orderA->id); + expect($orderB->discount_total)->toEqual(500); + expect($discount->refresh()->uses)->toEqual(1); +}); + +test('keeps its own discount when a cart is priced again after order creation', function () { + TaxClass::factory()->create([ + 'default' => true, + ]); + + $customerGroup = CustomerGroup::factory()->create([ + 'default' => true, + ]); + + $channel = Channel::factory()->create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'decimal_places' => 2, + ]); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + 'channel_id' => $channel->id, + 'coupon_code' => 'SAVE10', + ]); + + $purchasable = ProductVariant::factory()->create(); + + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + + $cart->lines()->create([ + 'purchasable_type' => $purchasable->getMorphClass(), + 'purchasable_id' => $purchasable->id, + 'quantity' => 2, + ]); + + $discount = Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Ten off', + 'coupon' => 'SAVE10', + 'uses' => 0, + 'max_uses' => 1, + 'data' => [ + 'fixed_value' => true, + 'fixed_values' => [ + $currency->code => 500, + ], + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $cart->calculate(); + + $orderA = (new CreateOrder)->execute($cart)->refresh(); + + expect($orderA->discount_total)->toEqual(500); + expect($discount->refresh()->uses)->toEqual(1); + + // Priced again on the same instance, with the discount set rebuilt: the + // cart's own use must not read as an exhausted coupon, or the retry is + // re-priced without the discount the shopper was quoted. + Discounts::resetDiscounts(); + + $cart->recalculate(); + + $orderB = (new CreateOrder)->execute($cart)->refresh(); + + expect($orderB->id)->toEqual($orderA->id); + expect($orderB->discount_total)->toEqual(500); + expect($discount->refresh()->uses)->toEqual(1); +}); diff --git a/tests/core/Unit/Actions/Taxes/GetTaxZoneStateTest.php b/tests/core/Unit/Actions/Taxes/GetTaxZoneStateTest.php index e749a37838..fa0f4c3d4e 100644 --- a/tests/core/Unit/Actions/Taxes/GetTaxZoneStateTest.php +++ b/tests/core/Unit/Actions/Taxes/GetTaxZoneStateTest.php @@ -2,6 +2,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Lunar\Core\Actions\Taxes\GetTaxZoneState; +use Lunar\Core\Models\Country; use Lunar\Core\Models\State; use Lunar\Core\Models\TaxZoneState; use Lunar\Tests\Core\TestCase; @@ -85,3 +86,82 @@ $this->assertNotEquals($al->id, $zone?->id); }); + +test('can match a state in the given country', function () { + $australia = Country::factory()->create([ + 'name' => 'Australia', + ]); + + $unitedStates = Country::factory()->create([ + 'name' => 'United States', + ]); + + $westernAustralia = State::factory()->create([ + 'country_id' => $australia->id, + 'code' => 'WA', + 'name' => 'Western Australia', + ]); + + $washington = State::factory()->create([ + 'country_id' => $unitedStates->id, + 'code' => 'WA', + 'name' => 'Washington', + ]); + + // Created first, so an unscoped lookup returns this one. + TaxZoneState::factory()->create([ + 'state_id' => $washington->id, + ]); + + $auZone = TaxZoneState::factory()->create([ + 'state_id' => $westernAustralia->id, + ]); + + $zone = app(GetTaxZoneState::class)->execute('WA', $australia->id); + + expect($zone?->id)->toEqual($auZone->id); +}); + +test('can mismatch a state in another country', function () { + $australia = Country::factory()->create([ + 'name' => 'Australia', + ]); + + $unitedStates = Country::factory()->create([ + 'name' => 'United States', + ]); + + $washington = State::factory()->create([ + 'country_id' => $unitedStates->id, + 'code' => 'WA', + 'name' => 'Washington', + ]); + + TaxZoneState::factory()->create([ + 'state_id' => $washington->id, + ]); + + $zone = app(GetTaxZoneState::class)->execute('WA', $australia->id); + + expect($zone)->toBeNull(); +}); + +test('can match a state which is not assigned to a country', function () { + $australia = Country::factory()->create([ + 'name' => 'Australia', + ]); + + $alabama = State::factory()->create([ + 'country_id' => null, + 'code' => 'AL', + 'name' => 'Alabama', + ]); + + $alZone = TaxZoneState::factory()->create([ + 'state_id' => $alabama->id, + ]); + + $zone = app(GetTaxZoneState::class)->execute('AL', $australia->id); + + expect($zone?->id)->toEqual($alZone->id); +}); diff --git a/tests/core/Unit/Actions/Taxes/GetTaxZoneTest.php b/tests/core/Unit/Actions/Taxes/GetTaxZoneTest.php index 7a57cfaac5..d1049887ff 100644 --- a/tests/core/Unit/Actions/Taxes/GetTaxZoneTest.php +++ b/tests/core/Unit/Actions/Taxes/GetTaxZoneTest.php @@ -89,3 +89,43 @@ expect($zone3->id)->toEqual($defaultTaxZone->id); }); + +test('can ignore a state tax zone from another country', function () { + $australia = Country::factory()->create([ + 'name' => 'Australia', + ]); + + $unitedStates = Country::factory()->create([ + 'name' => 'United States', + ]); + + $washington = State::factory()->create([ + 'country_id' => $unitedStates->id, + 'code' => 'WA', + 'name' => 'Washington', + ]); + + TaxZoneState::factory()->create([ + 'tax_zone_id' => TaxZone::factory(['default' => false]), + 'state_id' => $washington->id, + ]); + + $auZone = TaxZoneCountry::factory()->create([ + 'tax_zone_id' => TaxZone::factory(['default' => false]), + 'country_id' => $australia->id, + ]); + + TaxZone::factory(['default' => true])->create(); + + // Western Australia is also "WA", but has no tax zone of its own, so the + // Australian country zone should apply. + $address = Address::factory()->create([ + 'postcode' => '6000', + 'state' => 'WA', + 'country_id' => $australia->id, + ]); + + $zone = app(GetTaxZone::class)->execute($address); + + expect($zone->id)->toEqual($auZone->tax_zone_id); +}); diff --git a/tests/core/Unit/Base/ShippingManifestTest.php b/tests/core/Unit/Base/ShippingManifestTest.php index 29dcb66438..60ea450834 100644 --- a/tests/core/Unit/Base/ShippingManifestTest.php +++ b/tests/core/Unit/Base/ShippingManifestTest.php @@ -12,6 +12,8 @@ use Lunar\Core\Models\ProductVariant; use Lunar\Core\Models\TaxClass; use Lunar\Core\Models\TaxRateAmount; +use Lunar\Core\Modifiers\ShippingModifiers; +use Lunar\Tests\Core\Stubs\TestRecursiveShippingModifier; use Lunar\Tests\Core\TestCase; uses(TestCase::class); @@ -253,3 +255,33 @@ expect(ShippingManifest::getShippingOption($this->cart))->toBe($option); }); + +test('can not re-enter shipping modifiers while resolving options', function () { + TestRecursiveShippingModifier::$calls = 0; + + $taxClass = TaxClass::factory()->create(); + + CartAddress::factory()->create([ + 'cart_id' => $this->cart->id, + 'type' => 'shipping', + 'shipping_option' => 'BASDEL', + ]); + + ShippingManifest::addOption( + new ShippingOption( + name: 'Basic Delivery', + description: 'Basic Delivery', + identifier: 'BASDEL', + price: new PriceValue(500, $this->cart->currency), + taxClass: $taxClass + ) + ); + + app(ShippingModifiers::class)->add(TestRecursiveShippingModifier::class); + + $this->cart->calculate(); + + // ApplyShipping and CalculateTax each resolve the shipping option once, so + // a single calculate runs the modifiers twice. Neither run may re-enter. + expect(TestRecursiveShippingModifier::$calls)->toEqual(2); +}); diff --git a/tests/core/Unit/Base/Traits/HasDefaultRecordTest.php b/tests/core/Unit/Base/Traits/HasDefaultRecordTest.php index 10929f18d3..a86cb84b09 100644 --- a/tests/core/Unit/Base/Traits/HasDefaultRecordTest.php +++ b/tests/core/Unit/Base/Traits/HasDefaultRecordTest.php @@ -31,3 +31,11 @@ expect(Channel::getDefault()->id)->toEqual($defaultChannel->id); }); + +test('can return null when no record is marked as default', function () { + Channel::factory(3)->create([ + 'default' => false, + ]); + + expect(Channel::getDefault())->toBeNull(); +}); diff --git a/tests/core/Unit/DiscountTypes/BuyXGetYConditionsTest.php b/tests/core/Unit/DiscountTypes/BuyXGetYConditionsTest.php new file mode 100644 index 0000000000..7dc43b32f3 --- /dev/null +++ b/tests/core/Unit/DiscountTypes/BuyXGetYConditionsTest.php @@ -0,0 +1,133 @@ +create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + $currency = Currency::factory()->create(['code' => 'GBP', 'default' => true]); + + $conditionProduct = Product::factory()->create(); + $rewardProduct = Product::factory()->create(); + + $condition = ProductVariant::factory()->create(['product_id' => $conditionProduct->id]); + $reward = ProductVariant::factory()->create(['product_id' => $rewardProduct->id]); + + foreach ([$condition, $reward] as $variant) { + Price::factory()->create([ + 'price' => 1000, // £10 + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $variant->getMorphClass(), + 'priceable_id' => $variant->id, + ]); + } + + $discount = Discount::factory()->create(array_merge([ + 'type' => BuyXGetY::class, + 'name' => 'Buy one get one', + 'starts_at' => now()->subDay(), + 'data' => array_merge([ + 'min_qty' => 1, + 'reward_qty' => 1, + 'automatically_add_rewards' => false, + ], $discountData), + ], $discountAttributes)); + + $discount->channels()->attach([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subDay()], + ]); + + $discount->customerGroups()->attach([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()->subDay()], + ]); + + $discount->discountables()->create([ + 'discountable_type' => $conditionProduct->getMorphClass(), + 'discountable_id' => $conditionProduct->id, + 'type' => 'condition', + ]); + + $discount->discountables()->create([ + 'discountable_type' => $rewardProduct->getMorphClass(), + 'discountable_id' => $rewardProduct->id, + 'type' => 'reward', + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + 'coupon_code' => null, + ]); + + // £10 of the condition product and £10 of the reward product: £20 total. + $cart->lines()->create([ + 'purchasable_type' => $condition->getMorphClass(), + 'purchasable_id' => $condition->id, + 'quantity' => 1, + ]); + + $cart->lines()->create([ + 'purchasable_type' => $reward->getMorphClass(), + 'purchasable_id' => $reward->id, + 'quantity' => 1, + ]); + + return $cart->refresh(); +} + +test('is not applied when the cart is below the minimum spend', function () { + // £50 minimum against a £20 cart. + $cart = buyXGetYConditionCart([ + 'min_prices' => ['GBP' => 5000], + ]); + + $cart->calculate(); + + expect($cart->discountTotal->value)->toEqual(0); +}); + +test('is applied when the cart meets the minimum spend', function () { + // £10 minimum against a £20 cart. + $cart = buyXGetYConditionCart([ + 'min_prices' => ['GBP' => 1000], + ]); + + $cart->calculate(); + + expect($cart->discountTotal->value)->toBeGreaterThan(0); +}); + +test('is not applied when the cart customer is not on the discount', function () { + $cart = buyXGetYConditionCart([]); + + // Restrict the discount to a customer this cart does not belong to. + $discount = Discount::first(); + $discount->customers()->attach(Customer::factory()->create()->id); + + $cart->refresh()->calculate(); + + expect($cart->discountTotal->value)->toEqual(0); +}); diff --git a/tests/core/Unit/DiscountTypes/BuyXGetYTest.php b/tests/core/Unit/DiscountTypes/BuyXGetYTest.php index a2c7ec1b6c..dafe759d87 100644 --- a/tests/core/Unit/DiscountTypes/BuyXGetYTest.php +++ b/tests/core/Unit/DiscountTypes/BuyXGetYTest.php @@ -3,7 +3,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Lunar\Core\DiscountTypes\AmountOff; use Lunar\Core\DiscountTypes\BuyXGetY; +use Lunar\Core\Enums\SellingPolicy; use Lunar\Core\Models\Cart; +use Lunar\Core\Models\CartLine; use Lunar\Core\Models\Channel; use Lunar\Core\Models\Collection; use Lunar\Core\Models\Currency; @@ -1548,3 +1550,656 @@ // productC (not in collection) should not be discounted expect($lineC->discountTotal->value)->toEqual(0); }); + +test('can store an automatically added reward line using the morph map', function () { + $customerGroup = CustomerGroup::factory()->create([ + 'default' => true, + ]); + + $channel = Channel::factory()->create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'code' => 'GBP', + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + $productA = Product::factory()->create(); + $productB = Product::factory()->create(); + + $purchasableA = ProductVariant::factory()->create([ + 'product_id' => $productA->id, + ]); + $purchasableB = ProductVariant::factory()->create([ + 'product_id' => $productB->id, + ]); + + Price::factory()->create([ + 'price' => 1000, // £10 + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasableA->getMorphClass(), + 'priceable_id' => $purchasableA->id, + ]); + + Price::factory()->create([ + 'price' => 1000, // £10 + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasableB->getMorphClass(), + 'priceable_id' => $purchasableB->id, + ]); + + $cart->lines()->create([ + 'purchasable_type' => $purchasableA->getMorphClass(), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Product Discount', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 1, + 'automatically_add_rewards' => true, + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => [ + 'enabled' => true, + 'starts_at' => now(), + ], + ]); + + $discount->channels()->sync([ + $channel->id => [ + 'enabled' => true, + 'starts_at' => now()->subHour(), + ], + ]); + + $discount->discountableConditions()->create([ + 'discountable_type' => $productA->getMorphClass(), + 'discountable_id' => $productA->id, + ]); + + $discount->discountableRewards()->create([ + 'discountable_type' => $productB->getMorphClass(), + 'discountable_id' => $productB->id, + 'type' => 'reward', + ]); + + $cart->calculate(); + + $rewardLine = CartLine::where('cart_id', $cart->id) + ->where('purchasable_id', $purchasableB->id) + ->first(); + + expect($rewardLine->purchasable_type)->toEqual($purchasableB->getMorphClass()); +}); + +test('can add a multi quantity reward as a single line', function () { + $customerGroup = CustomerGroup::factory()->create([ + 'default' => true, + ]); + + $channel = Channel::factory()->create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'code' => 'GBP', + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + $productA = Product::factory()->create(); + $productB = Product::factory()->create(); + + $purchasableA = ProductVariant::factory()->create([ + 'product_id' => $productA->id, + ]); + $purchasableB = ProductVariant::factory()->create([ + 'product_id' => $productB->id, + ]); + + foreach ([$purchasableA, $purchasableB] as $purchasable) { + Price::factory()->create([ + 'price' => 1000, // £10 + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + } + + $cart->lines()->create([ + 'purchasable_type' => $purchasableA->getMorphClass(), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Product Discount', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 3, + 'automatically_add_rewards' => true, + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => [ + 'enabled' => true, + 'starts_at' => now(), + ], + ]); + + $discount->channels()->sync([ + $channel->id => [ + 'enabled' => true, + 'starts_at' => now()->subHour(), + ], + ]); + + $discount->discountableConditions()->create([ + 'discountable_type' => $productA->getMorphClass(), + 'discountable_id' => $productA->id, + ]); + + $discount->discountableRewards()->create([ + 'discountable_type' => $productB->getMorphClass(), + 'discountable_id' => $productB->id, + 'type' => 'reward', + ]); + + $cart->calculate(); + + $rewardLines = CartLine::where('cart_id', $cart->id) + ->where('purchasable_id', $purchasableB->id) + ->get(); + + // Three of one thing is one line of three, not three lines of one. + expect($rewardLines)->toHaveCount(1); + expect($rewardLines->first()->quantity)->toEqual(3); +}); + +test('can leave a reward line the shopper added at their own quantity', function () { + $customerGroup = CustomerGroup::factory()->create([ + 'default' => true, + ]); + + $channel = Channel::factory()->create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'code' => 'GBP', + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + $productA = Product::factory()->create(); + $productB = Product::factory()->create(); + + $purchasableA = ProductVariant::factory()->create([ + 'product_id' => $productA->id, + ]); + $purchasableB = ProductVariant::factory()->create([ + 'product_id' => $productB->id, + ]); + + foreach ([$purchasableA, $purchasableB] as $purchasable) { + Price::factory()->create([ + 'price' => 1000, // £10 + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + } + + $cart->lines()->create([ + 'purchasable_type' => $purchasableA->getMorphClass(), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + // The shopper already put the reward product in the cart themselves. + $cart->lines()->create([ + 'purchasable_type' => $purchasableB->getMorphClass(), + 'purchasable_id' => $purchasableB->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Product Discount', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 3, + 'automatically_add_rewards' => true, + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => [ + 'enabled' => true, + 'starts_at' => now(), + ], + ]); + + $discount->channels()->sync([ + $channel->id => [ + 'enabled' => true, + 'starts_at' => now()->subHour(), + ], + ]); + + $discount->discountableConditions()->create([ + 'discountable_type' => $productA->getMorphClass(), + 'discountable_id' => $productA->id, + ]); + + $discount->discountableRewards()->create([ + 'discountable_type' => $productB->getMorphClass(), + 'discountable_id' => $productB->id, + 'type' => 'reward', + ]); + + $cart->calculate(); + + $rewardLines = CartLine::where('cart_id', $cart->id) + ->where('purchasable_id', $purchasableB->id) + ->get(); + + expect($rewardLines)->toHaveCount(1); + expect($rewardLines->first()->quantity)->toEqual(1); +}); +test('does not automatically add reward when product variant has no stock', function () { + $customerGroup = CustomerGroup::factory()->create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + $currency = Currency::factory()->create(['code' => 'GBP']); + + $productA = Product::factory()->create(); + $productB = Product::factory()->create(); + + $purchasableA = ProductVariant::factory()->create(['product_id' => $productA->id]); + $purchasableB = ProductVariant::factory()->create([ + 'product_id' => $productB->id, + 'selling_policy' => SellingPolicy::InStock, + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + foreach ([$purchasableA, $purchasableB] as $purchasable) { + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + } + + $cart->lines()->create([ + 'purchasable_type' => $purchasableA->getMorphClass(), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Automatic Reward Out Of Stock', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 1, + 'automatically_add_rewards' => true, + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->discountableConditions()->create([ + 'discountable_type' => $productA->getMorphClass(), + 'discountable_id' => $productA->id, + ]); + + $discount->discountableRewards()->create([ + 'discountable_type' => $productB->getMorphClass(), + 'discountable_id' => $productB->id, + 'type' => 'reward', + ]); + + $cart = $cart->calculate(); + + expect($cart->freeItems)->toBeNull(); +}); + +test('does not automatically add collection reward when all products are out of stock', function () { + $customerGroup = CustomerGroup::factory()->create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + $currency = Currency::factory()->create(['code' => 'GBP']); + + $collection = Collection::factory()->create(); + + $productA = Product::factory()->create(); + $productB = Product::factory()->create(); + + $productB->collections()->sync($collection); + + $purchasableA = ProductVariant::factory()->create(['product_id' => $productA->id]); + $purchasableB = ProductVariant::factory()->create([ + 'product_id' => $productB->id, + 'selling_policy' => SellingPolicy::InStock, + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + foreach ([$purchasableA, $purchasableB] as $purchasable) { + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + } + + $cart->lines()->create([ + 'purchasable_type' => $purchasableA->getMorphClass(), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Automatic Collection Reward Out Of Stock', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 1, + 'automatically_add_rewards' => true, + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->discountableConditions()->create([ + 'discountable_type' => $productA->getMorphClass(), + 'discountable_id' => $productA->id, + ]); + + $discount->discountableRewards()->create([ + 'discountable_type' => $collection->getMorphClass(), + 'discountable_id' => $collection->id, + 'type' => 'reward', + ]); + + $cart = $cart->calculate(); + + expect($cart->freeItems)->toBeNull(); +}); + +test('automatically adds collection reward selecting in-stock product when others are out of stock', function () { + $customerGroup = CustomerGroup::factory()->create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + $currency = Currency::factory()->create(['code' => 'GBP']); + + $collection = Collection::factory()->create(); + + $productA = Product::factory()->create(); + $productB = Product::factory()->create(); // out of stock + $productC = Product::factory()->create(); // in stock + + $productB->collections()->sync($collection); + $productC->collections()->sync($collection); + + $purchasableA = ProductVariant::factory()->create(['product_id' => $productA->id]); + $purchasableB = ProductVariant::factory()->create([ + 'product_id' => $productB->id, + 'selling_policy' => SellingPolicy::InStock, + ]); + $purchasableC = ProductVariant::factory()->inStock(5)->create([ + 'product_id' => $productC->id, + 'selling_policy' => SellingPolicy::InStock, + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + foreach ([$purchasableA, $purchasableB, $purchasableC] as $purchasable) { + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + } + + $cart->lines()->create([ + 'purchasable_type' => $purchasableA->getMorphClass(), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Automatic Collection Reward Mixed Stock', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 1, + 'automatically_add_rewards' => true, + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->discountableConditions()->create([ + 'discountable_type' => $productA->getMorphClass(), + 'discountable_id' => $productA->id, + ]); + + $discount->discountableRewards()->create([ + 'discountable_type' => $collection->getMorphClass(), + 'discountable_id' => $collection->id, + 'type' => 'reward', + ]); + + $cart = $cart->calculate(); + + // Only the in-stock product (C) should be added as a free item + expect($cart->freeItems)->toHaveCount(1); + expect($cart->freeItems->first()->id)->toEqual($productC->id); +}); + +test('automatically adds in-stock product reward when another reward product is out of stock', function () { + $customerGroup = CustomerGroup::factory()->create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + $currency = Currency::factory()->create(['code' => 'GBP']); + + $productA = Product::factory()->create(); // condition + $productB = Product::factory()->create(); // reward — out of stock + $productC = Product::factory()->create(); // reward — in stock + + $purchasableA = ProductVariant::factory()->create(['product_id' => $productA->id]); + $purchasableB = ProductVariant::factory()->create([ + 'product_id' => $productB->id, + 'selling_policy' => SellingPolicy::InStock, + ]); + $purchasableC = ProductVariant::factory()->inStock(5)->create([ + 'product_id' => $productC->id, + 'selling_policy' => SellingPolicy::InStock, + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + foreach ([$purchasableA, $purchasableB, $purchasableC] as $purchasable) { + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + } + + $cart->lines()->create([ + 'purchasable_type' => $purchasableA->getMorphClass(), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Automatic Reward Mixed Stock', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 1, + 'automatically_add_rewards' => true, + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->discountableConditions()->create([ + 'discountable_type' => $productA->getMorphClass(), + 'discountable_id' => $productA->id, + ]); + + // Both B (no stock) and C (in stock) are rewards + $discount->discountableRewards()->createMany([ + ['discountable_type' => $productB->getMorphClass(), 'discountable_id' => $productB->id, 'type' => 'reward'], + ['discountable_type' => $productC->getMorphClass(), 'discountable_id' => $productC->id, 'type' => 'reward'], + ]); + + $cart = $cart->calculate(); + + // Product C (in stock) must be added; product B (out of stock) must never be added + expect($cart->freeItems)->toHaveCount(1); + expect($cart->freeItems->first()->id)->toEqual($productC->id); +}); + +test('does not allocate a multi quantity reward beyond stock', function () { + $customerGroup = CustomerGroup::factory()->create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + $currency = Currency::factory()->create(['code' => 'GBP']); + + $productA = Product::factory()->create(); + $productB = Product::factory()->create(); + + $purchasableA = ProductVariant::factory()->create(['product_id' => $productA->id]); + $purchasableB = ProductVariant::factory()->inStock(1)->create([ + 'product_id' => $productB->id, + 'selling_policy' => SellingPolicy::InStock, + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + foreach ([$purchasableA, $purchasableB] as $purchasable) { + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + } + + $cart->lines()->create([ + 'purchasable_type' => $purchasableA->getMorphClass(), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Automatic Reward Limited Stock', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 3, + 'automatically_add_rewards' => true, + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->discountableConditions()->create([ + 'discountable_type' => $productA->getMorphClass(), + 'discountable_id' => $productA->id, + ]); + + $discount->discountableRewards()->create([ + 'discountable_type' => $productB->getMorphClass(), + 'discountable_id' => $productB->id, + 'type' => 'reward', + ]); + + $cart->calculate(); + + $rewardLines = CartLine::where('cart_id', $cart->id) + ->where('purchasable_id', $purchasableB->id) + ->get(); + + // Only one unit is in stock, so only one is allocated despite reward_qty 3. + expect($rewardLines)->toHaveCount(1); + expect($rewardLines->first()->quantity)->toEqual(1); +}); diff --git a/tests/core/Unit/Managers/CartSessionSetCurrencyTest.php b/tests/core/Unit/Managers/CartSessionSetCurrencyTest.php new file mode 100644 index 0000000000..1cb1627c6d --- /dev/null +++ b/tests/core/Unit/Managers/CartSessionSetCurrencyTest.php @@ -0,0 +1,115 @@ +create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + + $gbp = Currency::factory()->create(['code' => 'GBP', 'default' => true, 'exchange_rate' => 1]); + $usd = Currency::factory()->create(['code' => 'USD', 'default' => false, 'exchange_rate' => 1]); + + $variant = ProductVariant::factory()->create(); + + Price::factory()->create([ + 'price' => 1000, // £10 + 'min_quantity' => 1, + 'currency_id' => $gbp->id, + 'priceable_type' => $variant->getMorphClass(), + 'priceable_id' => $variant->id, + ]); + + Price::factory()->create([ + 'price' => 2500, // $25 — deliberately unrelated to the GBP price + 'min_quantity' => 1, + 'currency_id' => $usd->id, + 'priceable_type' => $variant->getMorphClass(), + 'priceable_id' => $variant->id, + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $gbp->id, + ]); + + $cart->lines()->create([ + 'purchasable_type' => $variant->getMorphClass(), + 'purchasable_id' => $variant->id, + 'quantity' => 1, + ]); + + CartSession::use($cart->refresh()); + + return [$cart, $gbp, $usd]; +} + +test('switching the session currency reprices the cart', function () { + [$cart, $gbp, $usd] = currencySwitchCart(); + + $cart->calculate(); + expect($cart->subTotal->value)->toEqual(1000); + + CartSession::setCurrency($usd); + + $cart->calculate(); + + expect($cart->subTotal->value)->toEqual(2500); +}); + +test('switching the session currency reprices a cart read back from the session', function () { + [$cart, $gbp, $usd] = currencySwitchCart(); + + $cart->calculate(); + + CartSession::setCurrency($usd); + + expect(CartSession::current()->subTotal->value)->toEqual(2500); +}); + +test('switching the storefront currency reprices the cart', function () { + [$cart, $gbp, $usd] = currencySwitchCart(); + + $cart->calculate(); + expect($cart->subTotal->value)->toEqual(1000); + + // The storefront session owns the visitor's currency; the cart carries its + // own currency_id, so the switch has to reach it. + StorefrontSession::setCurrency($usd); + + expect(CartSession::current()->subTotal->value)->toEqual(2500); +}); + +test('switching the storefront region reprices the cart', function () { + [$cart, $gbp, $usd] = currencySwitchCart(); + + $cart->calculate(); + + $region = Region::factory()->create([ + 'currency_id' => $usd->id, + ]); + + StorefrontSession::setRegion($region); + + expect(CartSession::current()->subTotal->value)->toEqual(2500); +}); diff --git a/tests/core/Unit/Managers/DiscountManagerMemoisationTest.php b/tests/core/Unit/Managers/DiscountManagerMemoisationTest.php new file mode 100644 index 0000000000..864944a331 --- /dev/null +++ b/tests/core/Unit/Managers/DiscountManagerMemoisationTest.php @@ -0,0 +1,124 @@ +create([ + 'code' => 'GBP', + 'default' => true, + ]); + + $customerGroup = CustomerGroup::factory()->create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + + $variant = ProductVariant::factory()->create(); + + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $variant->getMorphClass(), + 'priceable_id' => $variant->id, + ]); + + $attach = function (Discount $discount) use ($channel, $customerGroup) { + $discount->channels()->attach([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subDay()], + ]); + + $discount->customerGroups()->attach([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()->subDay()], + ]); + }; + + // An always-on discount is required to reproduce this: apply() re-queries + // whenever the memoised set is empty, so the staleness only shows once the + // first calculation already found something. + $attach(Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Always on', + 'coupon' => null, + 'starts_at' => now()->subDay(), + 'data' => ['fixed_value' => true, 'fixed_values' => ['GBP' => 1]], + ])); + + $attach(Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Coupon discount', + 'coupon' => 'SAVE', + 'starts_at' => now()->subDay(), + 'data' => ['fixed_value' => true, 'fixed_values' => ['GBP' => 5]], + ])); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + 'channel_id' => $channel->id, + 'coupon_code' => null, + ]); + + $cart->lines()->create([ + 'purchasable_type' => $variant->getMorphClass(), + 'purchasable_id' => $variant->id, + 'quantity' => 2, + ]); + + return $cart->refresh(); +} + +test('a coupon applied after the cart has been calculated is applied', function () { + $cart = discountMemoFixture(); + + // The storefront renders the cart, which calculates it. The cart has no + // coupon yet, so the memoised set cannot contain the coupon discount. + $cart->recalculate(); + + $withoutCoupon = $cart->total->value; + + $cart->coupon_code = 'SAVE'; + $cart->save(); + $cart->recalculate(); + + expect($cart->coupon_code)->toBe('SAVE') + ->and($cart->total->value)->toBeLessThan($withoutCoupon); +}); + +test('resetting the discount manager applies the same coupon', function () { + $cart = discountMemoFixture(); + + $cart->recalculate(); + $withoutCoupon = $cart->total->value; + + $cart->coupon_code = 'SAVE'; + $cart->save(); + + // Same cart, same coupon, only the memoised set dropped. + Discounts::resetDiscounts(); + $cart->recalculate(); + + expect($cart->total->value)->toBeLessThan($withoutCoupon); +}); diff --git a/tests/core/Unit/Models/CartTest.php b/tests/core/Unit/Models/CartTest.php index e289d9b172..e1241720a6 100644 --- a/tests/core/Unit/Models/CartTest.php +++ b/tests/core/Unit/Models/CartTest.php @@ -1342,3 +1342,33 @@ ->and($context->customer->id)->toBe($customer->id) ->and($context->customerGroups->pluck('id')->all())->toBe([$trade->id]); }); + +test('orders cart lines by id', function () { + $cart = Cart::factory()->create(); + + // Cart::lines() must carry an explicit ordering contract. Without an + // ORDER BY, row order is undefined by the SQL standard: MySQL/InnoDB + // returns clustered primary-key order by coincidence, but PostgreSQL + // returns heap order, which changes after an UPDATE. Lunar relies on a + // stable line sequence (e.g. GenerateFingerprint reduces $cart->lines in + // iteration order), so it must be deterministic across engines. + expect($cart->lines()->toBase()->orders) + ->toBe([['column' => 'id', 'direction' => 'asc']]); +}); + +test('can retrieve cart lines in ascending id order', function () { + $currency = Currency::factory()->create(); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + ]); + + $lines = CartLine::factory() + ->count(5) + ->create(['cart_id' => $cart->id]); + + $expectedOrder = $lines->pluck('id')->sort()->values()->all(); + + expect($cart->load('lines')->lines->pluck('id')->all()) + ->toBe($expectedOrder); +}); diff --git a/tests/core/Unit/Models/OrderTest.php b/tests/core/Unit/Models/OrderTest.php index b131fb12d6..2d71d5e080 100644 --- a/tests/core/Unit/Models/OrderTest.php +++ b/tests/core/Unit/Models/OrderTest.php @@ -422,3 +422,72 @@ public function orderContactType(): string expect($order->routeNotificationForMail(new SelfRoutingOrderNotification(null))) ->toBe('shipping@example.com'); }); + +test('orders order lines by id', function () { + $order = Order::factory()->create(); + + // Order::lines() must carry an explicit ordering contract so line order is + // deterministic across database engines. PostgreSQL does not return rows in + // insertion order without an ORDER BY, which shifts invoice/history display. + expect($order->lines()->toBase()->orders) + ->toBe([['column' => 'id', 'direction' => 'asc']]); +}); + +test('can retrieve order lines in ascending id order', function () { + $order = Order::factory()->create(); + + $lines = OrderLine::factory() + ->count(5) + ->create(['order_id' => $order->id]); + + $expectedOrder = $lines->pluck('id')->sort()->values()->all(); + + expect($order->load('lines')->lines->pluck('id')->all()) + ->toBe($expectedOrder); +}); + +test('product lines include both physical and digital lines', function () { + Currency::factory()->create([ + 'default' => true, + ]); + + $order = Order::factory()->create([ + 'user_id' => null, + ]); + + $shippableVariant = ProductVariant::factory()->create([ + 'shippable' => true, + ]); + + $nonShippableVariant = ProductVariant::factory()->create([ + 'shippable' => false, + ]); + + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'purchasable_type' => $shippableVariant->getMorphClass(), + 'purchasable_id' => $shippableVariant->id, + 'type' => 'physical', + ]); + + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'purchasable_type' => $nonShippableVariant->getMorphClass(), + 'purchasable_id' => $nonShippableVariant->id, + 'type' => 'digital', + ]); + + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'type' => 'shipping', + ]); + + $order = $order->refresh(); + + // The PDF invoice iterates productLines: a non-shippable variant has + // requires_fulfilment false, so fulfillableLines dropped it from the body. + expect($order->physicalLines)->toHaveCount(1); + expect($order->digitalLines)->toHaveCount(1); + expect($order->productLines)->toHaveCount(2); + expect($order->fulfillableLines)->toHaveCount(1); +}); diff --git a/tests/core/Unit/Models/ProductVariantTest.php b/tests/core/Unit/Models/ProductVariantTest.php index 8b565f46a6..a6794d2292 100644 --- a/tests/core/Unit/Models/ProductVariantTest.php +++ b/tests/core/Unit/Models/ProductVariantTest.php @@ -8,6 +8,8 @@ use Lunar\Core\Models\CustomerGroup; use Lunar\Core\Models\Price; use Lunar\Core\Models\Product; +use Lunar\Core\Models\ProductOption; +use Lunar\Core\Models\ProductOptionValue; use Lunar\Core\Models\ProductVariant; use Lunar\Core\Models\TaxClass; use Lunar\Core\Models\TaxRate; @@ -285,3 +287,22 @@ expect($product->variants()->enabled()->pluck('id')->all())->toBe([$enabled->id]); }); + +test('returns variant option values ordered by position', function () { + $variant = ProductVariant::factory()->create(); + $option = ProductOption::factory()->create(); + + // Attached deliberately out of position order. Without an explicit ORDER BY + // the pivot rows would come back in attachment order ([3, 1, 2]); the + // relationship must order by the option value's position so getOption() + // (snapshotted onto order lines) is deterministic across database engines. + $values = collect([3, 1, 2])->map(fn ($position) => ProductOptionValue::factory()->create([ + 'product_option_id' => $option->id, + 'position' => $position, + ])); + + $variant->values()->attach($values->pluck('id')->all()); + + expect($variant->load('values')->values->pluck('position')->all()) + ->toBe([1, 2, 3]); +}); diff --git a/tests/core/Unit/Pipelines/Order/Creation/CreateOrderAddressesTest.php b/tests/core/Unit/Pipelines/Order/Creation/CreateOrderAddressesTest.php index 9555944a60..3a55b12760 100644 --- a/tests/core/Unit/Pipelines/Order/Creation/CreateOrderAddressesTest.php +++ b/tests/core/Unit/Pipelines/Order/Creation/CreateOrderAddressesTest.php @@ -82,3 +82,47 @@ expect($order->addresses)->toHaveCount($cart->addresses->count()); }); + +test('does not duplicate addresses when the cart address postcode changes', function () { + $currency = Currency::factory()->create(); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + ]); + + CartAddress::factory()->create([ + 'type' => 'billing', + 'cart_id' => $cart->id, + 'postcode' => 'N1 1TW', + ]); + + CartAddress::factory()->create([ + 'type' => 'shipping', + 'cart_id' => $cart->id, + 'postcode' => 'N2 2TW', + ]); + + $order = Order::factory()->create([ + 'cart_id' => $cart->id, + ]); + + $billing = OrderAddress::factory()->create([ + 'type' => 'billing', + 'order_id' => $order->id, + 'postcode' => 'SW1 1AA', + ]); + + $shipping = OrderAddress::factory()->create([ + 'type' => 'shipping', + 'order_id' => $order->id, + 'postcode' => 'SW2 2AA', + ]); + + app(CreateOrderAddresses::class)->handle($order, fn ($order) => $order); + + expect($order->refresh()->addresses)->toHaveCount(2) + ->and($order->billingAddress->id)->toBe($billing->id) + ->and($order->billingAddress->postcode)->toBe('N1 1TW') + ->and($order->shippingAddress->id)->toBe($shipping->id) + ->and($order->shippingAddress->postcode)->toBe('N2 2TW'); +}); diff --git a/tests/core/Unit/Pricing/DefaultPriceFormatterTest.php b/tests/core/Unit/Pricing/DefaultPriceFormatterTest.php new file mode 100644 index 0000000000..3fdcf7746b --- /dev/null +++ b/tests/core/Unit/Pricing/DefaultPriceFormatterTest.php @@ -0,0 +1,52 @@ +create([ + 'code' => 'GBP', + 'decimal_places' => 2, + ]); + + // unitDecimal() divides by the unit quantity, so zero would throw and a + // negative would invert the price. + $formatter = new DefaultPriceFormatter(1500, $currency, 0); + + expect($formatter->unitQty)->toBe(1) + ->and($formatter->unitDecimal())->toEqual(15.00) + ->and($formatter->unitFormatted())->toEqual('£15.00'); +}); + +test('divides by a unit quantity of more than one', function () { + $currency = Currency::factory()->create([ + 'code' => 'GBP', + 'decimal_places' => 2, + ]); + + $formatter = new DefaultPriceFormatter(1500, $currency, 3); + + expect($formatter->unitDecimal())->toEqual(5.00); +}); + +test('accepts a NumberFormatter style constant', function () { + $currency = Currency::factory()->create([ + 'code' => 'GBP', + 'decimal_places' => 2, + ]); + + // NumberFormatter::CURRENCY and friends are int constants; the parameter + // used to be typed string, so passing one tripped a coercion deprecation. + $formatter = new DefaultPriceFormatter(1500, $currency); + + expect($formatter->formatted(locale: 'en_GB', formatterStyle: NumberFormatter::DECIMAL)) + ->toEqual('15.00') + ->and($formatter->formatted(locale: 'en_GB', formatterStyle: NumberFormatter::CURRENCY)) + ->toEqual('£15.00'); +}); diff --git a/tests/core/Unit/ServiceLifetimesTest.php b/tests/core/Unit/ServiceLifetimesTest.php index eadf6b6511..71a026825f 100644 --- a/tests/core/Unit/ServiceLifetimesTest.php +++ b/tests/core/Unit/ServiceLifetimesTest.php @@ -7,6 +7,7 @@ use Lunar\Core\Contracts\CacheInvalidator; use Lunar\Core\Contracts\CartSession; use Lunar\Core\Contracts\DiscountManager; +use Lunar\Core\Contracts\ShippingManifest; use Lunar\Core\Contracts\StorefrontSession; use Lunar\Core\Models\Channel; use Lunar\Core\Models\Currency; @@ -42,6 +43,7 @@ 'discount manager' => [DiscountManager::class], 'cache invalidator' => [CacheInvalidator::class], 'attribute cache' => [AttributeCache::class], + 'shipping manifest' => [ShippingManifest::class], 'access control manifest' => ['lunar-access-control'], ]); diff --git a/tests/filament/Unit/Schemas/ProductVariant/ProductVariantFormTest.php b/tests/filament/Unit/Schemas/ProductVariant/ProductVariantFormTest.php new file mode 100644 index 0000000000..87f9fadf53 --- /dev/null +++ b/tests/filament/Unit/Schemas/ProductVariant/ProductVariantFormTest.php @@ -0,0 +1,12 @@ +getValidationRules()) + ->toContain('min:1'); +}); diff --git a/tests/filament/Unit/Schemas/TaxZoneFormTest.php b/tests/filament/Unit/Schemas/TaxZoneFormTest.php new file mode 100644 index 0000000000..229d7739a2 --- /dev/null +++ b/tests/filament/Unit/Schemas/TaxZoneFormTest.php @@ -0,0 +1,67 @@ +statePath('data') + ->components([$component]); + + $schema->fill([...$siblingState, $component->getName() => $state]); + + return $schema->getComponents(withHidden: true)[0]; +} + +it('resolves country labels from the selected values, not the saved relationship', function () { + $selected = Country::factory()->count(2)->create(); + Country::factory()->create(); + + // Nothing is attached to a tax zone — these are fresh selections. + $component = mountedSelect( + TaxZoneForm::getZoneTypeCountriesComponent(), + $selected->pluck('iso3')->all(), + ); + + expect($component->getOptionLabels()) + ->toEqual($selected->pluck('name', 'iso3')->all()); +}); + +it('resolves state labels scoped to the chosen country', function () { + $country = Country::factory()->create(); + $other = Country::factory()->create(); + + $selected = State::factory()->count(2)->create(['country_id' => $country->id]); + $elsewhere = State::factory()->create(['country_id' => $other->id]); + + $component = mountedSelect( + TaxZoneForm::getZoneTypeStatesComponent(), + $selected->pluck('code')->push($elsewhere->code)->all(), + ['zone_country' => $country->id], + ); + + // Scoped to zone_country, so a state of the same code in another country is + // not labelled. + expect($component->getOptionLabels()) + ->toEqual($selected->pluck('name', 'code')->all()); +}); diff --git a/tests/filament/Unit/Widgets/ProductOptionsWidgetLabelsTest.php b/tests/filament/Unit/Widgets/ProductOptionsWidgetLabelsTest.php new file mode 100644 index 0000000000..828d614ae3 --- /dev/null +++ b/tests/filament/Unit/Widgets/ProductOptionsWidgetLabelsTest.php @@ -0,0 +1,20 @@ +{$method}()->getLabel()) + ->toBe(__("lunar-filament::productoption.widgets.product-options.actions.{$key}.label")); +})->with([ + 'save variants' => ['saveVariantsAction', 'save-variants'], + 'add shared option' => ['addSharedOptionAction', 'add-shared-option'], +]); diff --git a/tests/shipping/Unit/Drivers/ShippingMethods/ShipByTest.php b/tests/shipping/Unit/Drivers/ShippingMethods/ShipByTest.php index eee17d6524..5c2acf2066 100644 --- a/tests/shipping/Unit/Drivers/ShippingMethods/ShipByTest.php +++ b/tests/shipping/Unit/Drivers/ShippingMethods/ShipByTest.php @@ -577,3 +577,79 @@ expect($option)->toBeInstanceOf(ShippingOption::class) ->and($option->price->value)->toEqual(400); }); + +test('weight tiers are evaluated in the shipping method configured weight_unit', function () { + $currency = Currency::factory()->create(['default' => true]); + TaxClass::factory()->create(['default' => true]); + + $shippingZone = ShippingZone::factory()->create(['type' => 'countries']); + + // Grams give merchants sub-kilogram tier precision with raw integer storage. + $shippingMethod = ShippingMethod::factory()->create([ + 'driver' => 'ship-by', + 'data' => ['charge_by' => 'weight'], + 'weight_unit' => 'g', + ]); + + $shippingRate = ShippingRate::factory()->create([ + 'shipping_method_id' => $shippingMethod->id, + 'shipping_zone_id' => $shippingZone->id, + ]); + + $shippingRate->prices()->createMany([ + ['price' => 1000, 'min_quantity' => 1, 'currency_id' => $currency->id], + ['price' => 600, 'min_quantity' => 500, 'currency_id' => $currency->id], + ['price' => 200, 'min_quantity' => 1000, 'currency_id' => $currency->id], + ]); + + $makeCart = function (array $lines) use ($currency): Cart { + $cart = Cart::factory()->create(['currency_id' => $currency->id]); + + foreach ($lines as [$weightValue, $weightUnit]) { + $variant = ProductVariant::factory()->create([ + 'weight_value' => $weightValue, + 'weight_unit' => $weightUnit, + ]); + + Price::factory()->create([ + 'price' => 500, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $variant->getMorphClass(), + 'priceable_id' => $variant->id, + ]); + + $cart->lines()->create([ + 'purchasable_type' => $variant->getMorphClass(), + 'purchasable_id' => $variant->id, + 'quantity' => 1, + ]); + } + + return $cart->calculate(); + }; + + // 300g cart sits below the 500g tier. + $option = (new ShipBy)->resolve(new ShippingOptionRequest( + shippingRate: $shippingRate, + cart: $makeCart([[0.3, 'kg']]), + )); + expect($option)->toBeInstanceOf(ShippingOption::class) + ->and($option->price->value)->toEqual(1000); + + // A 0.5kg product converts to 500g and matches the 500g tier exactly. + $option = (new ShipBy)->resolve(new ShippingOptionRequest( + shippingRate: $shippingRate, + cart: $makeCart([[0.5, 'kg']]), + )); + expect($option)->toBeInstanceOf(ShippingOption::class) + ->and($option->price->value)->toEqual(600); + + // Mixed units: 0.4kg + 600g = 1000g hits the top tier. + $option = (new ShipBy)->resolve(new ShippingOptionRequest( + shippingRate: $shippingRate, + cart: $makeCart([[0.4, 'kg'], [600.0, 'g']]), + )); + expect($option)->toBeInstanceOf(ShippingOption::class) + ->and($option->price->value)->toEqual(200); +}); diff --git a/tests/shipping/Unit/ShippingPermissionLabelTest.php b/tests/shipping/Unit/ShippingPermissionLabelTest.php new file mode 100644 index 0000000000..d0f1af55f5 --- /dev/null +++ b/tests/shipping/Unit/ShippingPermissionLabelTest.php @@ -0,0 +1,33 @@ +group('shipping'); + +uses(RefreshDatabase::class); + +/** + * The permission is registered by the shipping package's own migration, but + * label resolution is centralised on `lunar::auth.permissions.{handle}.*` and + * falls back to the raw handle — so an unlabelled permission rendered as + * "shipping:manage" in the staff and roles UI of both panels. + */ +it('labels the shipping permission rather than falling back to the handle', function () { + $permission = Permission::make('shipping:manage', firstParty: true); + + expect($permission->transLabel())->toBe('Manage Shipping') + ->and($permission->transDescription())->not->toBe('shipping:manage'); +}); + +it('translates the shipping permission label', function (string $locale, string $label) { + app()->setLocale($locale); + + expect(Permission::make('shipping:manage', firstParty: true)->transLabel()) + ->toBe($label); +})->with([ + 'de' => ['de', 'Versand verwalten'], + 'fr' => ['fr', "Gérer l'expédition"], + 'nl' => ['nl', 'Beheer Verzending'], +]); diff --git a/tests/stripe/Unit/Actions/StoreChargesTest.php b/tests/stripe/Unit/Actions/StoreChargesTest.php index e19e9f96f2..9f76fc715c 100644 --- a/tests/stripe/Unit/Actions/StoreChargesTest.php +++ b/tests/stripe/Unit/Actions/StoreChargesTest.php @@ -8,7 +8,13 @@ uses(TestCase::class); it('can store successful charge', function () { - $cart = CartBuilder::build(); + // Pin a two-decimal currency: the factory's random faker code can land on + // a currency Stripe scales differently (JPY, HUF, ...), which makes the + // stored amount diverge from the raw charge fixture amount. + $cart = CartBuilder::build(currencyParams: [ + 'code' => 'USD', + 'decimal_places' => 2, + ]); $order = $cart->createOrder(); @@ -31,7 +37,10 @@ })->group('lunar.stripe.actions'); it('updates existing transactions', function () { - $cart = CartBuilder::build(); + $cart = CartBuilder::build(currencyParams: [ + 'code' => 'USD', + 'decimal_places' => 2, + ]); $order = $cart->createOrder(); diff --git a/tests/stripe/Unit/Managers/StripeManagerTest.php b/tests/stripe/Unit/Managers/StripeManagerTest.php index 4bcc74dc73..280080cf48 100644 --- a/tests/stripe/Unit/Managers/StripeManagerTest.php +++ b/tests/stripe/Unit/Managers/StripeManagerTest.php @@ -99,3 +99,86 @@ expect(StripeManager::toStripeAmount(500, $currency))->toBe(50000); }); + +it('normalises amounts for currencies configured with more than 2 decimal places', function () { + // A $60.0000 cart stored with 4 decimal places is 600000, not 6000. + $currency = Currency::factory()->make([ + 'code' => 'USD', + 'decimal_places' => 4, + ]); + + expect(StripeManager::toStripeAmount(600000, $currency))->toBe(6000); +}); + +it('normalises amounts for zero-decimal currencies misconfigured with extra decimal places', function () { + $currency = Currency::factory()->make([ + 'code' => 'JPY', + 'decimal_places' => 2, + ]); + + expect(StripeManager::toStripeAmount(100000, $currency))->toBe(1000); +}); + +it('rounds half-unit boundaries exactly when rescaling', function () { + // 145 at 3dp is 0.145 — binary float division would misround this to 14. + $currency = Currency::factory()->make([ + 'code' => 'USD', + 'decimal_places' => 3, + ]); + + expect(StripeManager::toStripeAmount(145, $currency))->toBe(15); + + $currency = Currency::factory()->make([ + 'code' => 'USD', + 'decimal_places' => 4, + ]); + + expect(StripeManager::toStripeAmount(1450, $currency))->toBe(15); +}); + +it('sends three-decimal currency amounts in thousandths', function () { + $currency = Currency::factory()->make([ + 'code' => 'BHD', + 'decimal_places' => 3, + ]); + + expect(StripeManager::toStripeAmount(60123, $currency))->toBe(60123); + + $currency = Currency::factory()->make([ + 'code' => 'BHD', + 'decimal_places' => 2, + ]); + + expect(StripeManager::toStripeAmount(6012, $currency))->toBe(60120); +}); + +it('converts Stripe amounts back to the stored scale', function () { + $currency = Currency::factory()->make([ + 'code' => 'USD', + 'decimal_places' => 4, + ]); + + expect(StripeManager::fromStripeAmount(6000, $currency))->toBe(600000); + + $currency = Currency::factory()->make([ + 'code' => 'JPY', + 'decimal_places' => 2, + ]); + + expect(StripeManager::fromStripeAmount(1000, $currency))->toBe(100000); + + $currency = Currency::factory()->make([ + 'code' => 'HUF', + 'decimal_places' => 0, + ]); + + expect(StripeManager::fromStripeAmount(50000, $currency))->toBe(500); + + // Identity for a correctly-configured two-decimal currency. + $currency = Currency::factory()->make([ + 'code' => 'USD', + 'decimal_places' => 2, + ]); + + expect(StripeManager::fromStripeAmount(1999, $currency))->toBe(1999); +}); diff --git a/tests/stripe/Unit/StripePaymentTypeTest.php b/tests/stripe/Unit/StripePaymentTypeTest.php index b846f9eae6..bb44e65972 100644 --- a/tests/stripe/Unit/StripePaymentTypeTest.php +++ b/tests/stripe/Unit/StripePaymentTypeTest.php @@ -6,6 +6,7 @@ use Lunar\Core\Models\Transaction; use Lunar\Stripe\Events\OrphanedPaymentIntentDetected; use Lunar\Stripe\Facades\Stripe; +use Lunar\Stripe\Managers\StripeManager; use Lunar\Stripe\Models\StripePaymentIntent; use Lunar\Stripe\StripePaymentType; use Lunar\Tests\Stripe\Unit\TestCase; @@ -421,3 +422,92 @@ ->not ->toBe(true); }); + +it('authorizes a cart whose currency needs rescaling for stripe', function () { + $cart = CartBuilder::build(currencyParams: [ + 'code' => 'HUF', + 'decimal_places' => 0, + ]); + $payment = new StripePaymentType; + + $cart->calculate(); + + // Stripe holds HUF amounts multiplied by 100, as createIntent sends them. + StripeFake::forCart($cart, [ + 'amount' => StripeManager::toStripeAmount($cart->total->value, $cart->currency), + ]); + + $response = $payment->cart($cart)->withData([ + 'payment_intent' => 'PI_CAPTURE', + ])->authorize(); + + expect($response->success)->toBeTrue() + ->and($cart->refresh()->completedOrder->placed_at)->not()->toBeNull(); + + // The charges fixture reports 1099 Stripe sub-units; stored back as 11 HUF. + $transaction = $cart->completedOrder->transactions()->where('type', 'capture')->first(); + + expect($transaction->amount)->toBe(11); +}); + +it('rejects an intent holding the raw lunar value for a rescaled currency', function () { + $cart = CartBuilder::build(currencyParams: [ + 'code' => 'HUF', + 'decimal_places' => 0, + ]); + $payment = new StripePaymentType; + + $cart->calculate(); + + // An intent holding the raw stored value — 100x off Stripe's HUF scale. + StripeFake::forCart($cart, ['amount' => $cart->total->value]); + + $response = $payment->cart($cart)->withData([ + 'payment_intent' => 'PI_CAPTURE', + ])->authorize(); + + expect($response->success)->toBeFalse() + ->and($response->message)->toEqual('Payment intent amount does not match order total'); +}); + +it('converts capture and refund amounts to stripe scale and stores refunds in lunar scale', function () { + $cart = CartBuilder::build(currencyParams: [ + 'code' => 'HUF', + 'decimal_places' => 0, + ]); + $payment = new StripePaymentType; + + $cart->calculate(); + + $mock = StripeFake::forCart($cart, [ + 'amount' => StripeManager::toStripeAmount($cart->total->value, $cart->currency), + ]); + + $payment->cart($cart)->withData([ + 'payment_intent' => 'PI_CAPTURE', + ])->authorize(); + + $order = $cart->refresh()->completedOrder; + $transaction = $order->transactions()->where('type', 'capture')->first(); + + $payment->capture($transaction, 500); + + $captureRequest = collect($mock->requests)->last( + fn ($r) => $r['method'] == 'post' && str_contains($r['url'], 'capture') + ); + + expect($captureRequest['params']['amount_to_capture'])->toBe(50000); + + $payment->refund($transaction, 500, 'test refund'); + + $refundRequest = collect($mock->requests)->last( + fn ($r) => $r['method'] == 'post' && str_contains($r['url'], 'refunds') + ); + + expect($refundRequest['params']['amount'])->toBe(50000); + + // The refund transaction is stored back in Lunar's scale. + $refund = $order->transactions()->where('type', 'refund')->first(); + + expect($refund->amount)->toBe(500); +}); diff --git a/tests/stripe/Utils/CartBuilder.php b/tests/stripe/Utils/CartBuilder.php index 4832ccacc6..2e78eb982d 100644 --- a/tests/stripe/Utils/CartBuilder.php +++ b/tests/stripe/Utils/CartBuilder.php @@ -15,15 +15,15 @@ class CartBuilder { - public static function build(array $cartParams = []) + public static function build(array $cartParams = [], array $currencyParams = []) { Language::factory()->create([ 'default' => true, ]); - $currency = Currency::factory()->create([ + $currency = Currency::factory()->create(array_merge([ 'default' => true, - ]); + ], $currencyParams)); $taxClass = TaxClass::factory()->create(); diff --git a/tests/stripe/Utils/StripeFake.php b/tests/stripe/Utils/StripeFake.php index 12b2dd1b7c..d1df2e01ad 100644 --- a/tests/stripe/Utils/StripeFake.php +++ b/tests/stripe/Utils/StripeFake.php @@ -5,6 +5,7 @@ use Lunar\Core\Models\Cart; use Lunar\Core\Models\Order; use Lunar\Stripe\Facades\Stripe; +use Lunar\Stripe\Managers\StripeManager; use Lunar\Stripe\MockClient; class StripeFake @@ -18,7 +19,8 @@ public static function forCart(Cart $cart, array $extra = []): MockClient $cart->calculate(); return Stripe::fake([ - 'amount' => $cart->total->value, + // Mirror createIntent: Stripe holds amounts in its own sub-unit scale. + 'amount' => StripeManager::toStripeAmount($cart->total->value, $cart->currency), 'currency' => strtolower($cart->currency->code), ...$extra, ]); @@ -31,7 +33,7 @@ public static function forCart(Cart $cart, array $extra = []): MockClient public static function forOrder(Order $order, array $extra = []): MockClient { return Stripe::fake([ - 'amount' => $order->total, + 'amount' => StripeManager::toStripeAmount($order->total, $order->currency), 'currency' => strtolower($order->currency_code), ...$extra, ]); diff --git a/tests/upgrade/Unit/Rector/Pricing/Fixture/RetypeFormatterStyleParamRector/formatter_style_param.php.inc b/tests/upgrade/Unit/Rector/Pricing/Fixture/RetypeFormatterStyleParamRector/formatter_style_param.php.inc new file mode 100644 index 0000000000..169b799147 --- /dev/null +++ b/tests/upgrade/Unit/Rector/Pricing/Fixture/RetypeFormatterStyleParamRector/formatter_style_param.php.inc @@ -0,0 +1,41 @@ + +----- + diff --git a/tests/upgrade/Unit/Rector/Pricing/Fixture/RetypeFormatterStyleParamRector/leaves_unrelated_alone.php.inc b/tests/upgrade/Unit/Rector/Pricing/Fixture/RetypeFormatterStyleParamRector/leaves_unrelated_alone.php.inc new file mode 100644 index 0000000000..43bc196495 --- /dev/null +++ b/tests/upgrade/Unit/Rector/Pricing/Fixture/RetypeFormatterStyleParamRector/leaves_unrelated_alone.php.inc @@ -0,0 +1,24 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__.'/Fixture/RetypeFormatterStyleParamRector'); + } + + public function provideConfigFilePath(): string + { + return __DIR__.'/config/formatter_style.php'; + } +} diff --git a/tests/upgrade/Unit/Rector/Pricing/config/formatter_style.php b/tests/upgrade/Unit/Rector/Pricing/config/formatter_style.php new file mode 100644 index 0000000000..21859dbbdc --- /dev/null +++ b/tests/upgrade/Unit/Rector/Pricing/config/formatter_style.php @@ -0,0 +1,9 @@ +withRules([RetypeFormatterStyleParamRector::class]);