Compare commits

...
103 Commits
Author SHA1 Message Date
arvanitakis f1a0322d3f Feat: Upload Controller and Prune Commands Extraction from 3dealer 2026-09-25 13:48:42 +03:00
arvanitakis 6025ea4304 Feat: Updating FIle Services, Updating Order Views to list product extra options 2026-09-25 10:08:57 +03:00
arvanitakis 2b8fe5764c Feat: Creating Migration Models And Adapters for file Service 2026-09-25 09:10:56 +03:00
arvanitakis 69fdd0b4b8 Bump version to 0.20.2 2026-09-25 08:55:10 +03:00
arvanitakis 5347e01f0e Chore: Updating ProductDocumentLocalizer to translate Custom Fields 2026-09-25 08:39:15 +03:00
arvanitakis 78b46e5594 Chore: Adding Locales to Product Custom Fields 2026-09-24 23:42:43 +03:00
arvanitakis 621381beaa Bump Version to 0.20.1 2026-09-24 22:53:03 +03:00
arvanitakis 8f4156cfe8 Feat: Restructuring MigrateImport, Dispatching a per product job for import 2026-09-24 22:50:41 +03:00
arvanitakis a9b993182b Fix: Product Localizer now checks if a value is filled, or, not to show the fallback 2026-09-24 22:00:28 +03:00
arvanitakis 5a7fcd9f51 Fix: Removing Cart Lines along Products, so that the frontend loads 2026-09-24 21:57:31 +03:00
arvanitakis b4e9b8a4a9 Fix: Updating MIgrateImportCommand to accept language for import 2026-09-24 21:41:11 +03:00
arvanitakis c7035d6782 Bump version to 0.20.0 2026-09-23 09:47:28 +03:00
arvanitakis 4c0974bf84 Feat: Updating Product Indexer, and Product Sort 2026-09-23 09:41:21 +03:00
arvanitakis 4e15d8ef8c Fix: Updating Wipe Catalog Command to force delete products instead of the soft delete 2026-09-22 21:17:35 +03:00
arvanitakis 1c7efc6e4d Feature: Adding Custom Fields to Products 2026-09-22 21:17:01 +03:00
arvanitakis 59c57b37fc Feat: Updating ShopifyExportImporter and WipeCatalogCommand to handle images 2026-09-22 15:29:03 +03:00
arvanitakis 37b49963f6 Feat: Adding Backfill Skus to the Migrate Import Job 2026-09-22 14:55:18 +03:00
arvanitakis 050204f063 Feature: Adding Wipe Catalog Command for all products 2026-09-22 14:36:57 +03:00
arvanitakis c0ae9d8996 Feat: Adding Purchasable to 'in_stock' when importing a new product 2026-09-22 13:48:08 +03:00
arvanitakis cc1cf6ea7f Feat: Displaying Draft Products, only when AppDebug = true 2026-09-22 13:46:26 +03:00
arvanitakis 0437057e5d Merge branch 'Quality-Updates' 2026-09-18 01:30:37 +03:00
arvanitakis 0c169daf55 Bump version to 0.19.0 2026-09-18 01:29:54 +03:00
arvanitakis 609a63c2f4 Feat: Tying Specific Methods with Carrier Drivers 2026-09-18 01:23:50 +03:00
arvanitakis 12aaa43f10 Fix: Updates to OrderFullfilmentServices and box now clients, order views and checkout services 2026-09-18 00:54:43 +03:00
arvanitakis dcdc998eee Feat: Updating Shipping Method with new variables, for correct box env vars 2026-09-18 00:52:38 +03:00
arvanitakis a55697ce82 Feature: Updating Listreners, Separating Logic from listeners, Queuing Policies 2026-09-16 23:24:02 +03:00
arvanitakis a411e6bbc1 Bump version to 0.18.1 2026-09-16 18:55:42 +03:00
arvanitakis 910fa94395 Feat: Updating the Privacy Providers, moving them into the appropriate Modules, Updating Privacy views 2026-09-16 18:51:20 +03:00
arvanitakis fdd1899c34 Feat: Rearranging Providers 2026-09-16 13:44:14 +03:00
arvanitakis 3ad3a1b4d6 Fix: Adding cart id and order id to stripe payload 2026-09-16 01:39:23 +03:00
arvanitakis 68233f43ef Feat: Privacy Concern redesign to match project structure 2026-09-16 01:21:22 +03:00
arvanitakis 027f7e8982 Feat: Updating Data Erasure and Data Export Views 2026-09-16 00:46:13 +03:00
arvanitakis c084eb47cb Feat: Bringin Privacy to Filament v4, the managers and resources were built with filament v3 2026-09-16 00:24:18 +03:00
arvanitakis 58d165acc3 Fix: FIxing Bug on resolving relation on Products, Orders, and Users 2026-09-16 00:23:29 +03:00
arvanitakis 44ad943eec Merge branch 'master' into Privacy 2026-09-16 00:13:41 +03:00
arvanitakis 2cc6f5e5f0 Bump Version to 0.18.0 2026-09-16 00:06:04 +03:00
arvanitakis 0babc6a96d Fix: Locking User on Login to manage otp attempts 2026-09-16 00:05:53 +03:00
arvanitakis 89a3d4bbad Merge branch 'master' into customer 2026-09-15 23:57:07 +03:00
arvanitakis ccb2666495 Bump Version to 0.17.5 2026-09-15 23:52:48 +03:00
arvanitakis 97004234f0 Feat: Adding Translations to Payment and Shipping Methods, removing unecessary shipping method fulfillment type 2026-09-15 23:51:10 +03:00
arvanitakis ea73cc3562 Feat: Translating States and Countries For Greece 2026-09-15 22:30:57 +03:00
arvanitakis 02816fb9e7 Bump Version to 0.17.4 2026-09-15 22:13:31 +03:00
arvanitakis 910ce0205d Feat: Adding command for backfilling all product skus 2026-09-15 22:13:18 +03:00
arvanitakis e532c32cab Bump version to 0.17.3 2026-09-15 21:49:03 +03:00
arvanitakis 6a51b672c8 Fix: Adding a check for hasTable 2026-09-15 21:40:35 +03:00
arvanitakis 956e9e88a6 Fix: Stripping Lunar's Stripe Driver with Boboko's Stripe Payment Driver 2026-09-15 21:38:16 +03:00
arvanitakis 4489475840 Bump version to 0.17.2 2026-09-15 21:25:25 +03:00
arvanitakis e4e008167a Fix: Correct Display of last 4 digits of credit card 2026-09-15 21:22:45 +03:00
arvanitakis a5f3008ce2 Fix: Update Order status to Processing when payment has been recieved 2026-09-15 21:17:56 +03:00
arvanitakis d9fb3bbde6 Bump version to 0.17.1 2026-09-15 16:12:03 +03:00
arvanitakis 26b4c5bfd7 Fix: Move Stripe Payment Intent to always allow redirect 2026-09-15 16:11:31 +03:00
arvanitakis 409e8204f6 Updating Changelog 2026-09-15 16:02:20 +03:00
arvanitakis 8472649905 Feature: Customer Account Services 2026-09-15 16:01:28 +03:00
arvanitakis 57fc28ca06 Bump version to 0.17.0 2026-09-14 20:18:26 +03:00
arvanitakis 9d3e54e5df Changelog 2026-09-14 00:04:20 +03:00
arvanitakis 44c6b7defd Feature: Order Updates, Events, Order Flows, Shipment And COD support 2026-09-14 00:03:06 +03:00
arvanitakis 78bbd8390a Feature: Minor Updates to Order Shipping And Order Statuses 2026-09-10 22:50:40 +03:00
arvanitakis 99e55902ac Feat: Updating OrderPlaced Listeners to Decrement Stock, Creating Notifications 2026-09-10 01:34:30 +03:00
arvanitakis 864c8b19aa Feat: Updating Cart Lifecycle Service, and Capping Abandoned Cart Days. Also Updating Cart Views 2026-09-10 01:13:15 +03:00
arvanitakis 8f4c1a22ea Bump version to 0.16.3 2026-09-10 00:14:57 +03:00
arvanitakis 13d5833d18 Fix: Fixing Stripe Payment Driver, Applying Payment Mehtod (COD) fee correctly 2026-09-10 00:14:42 +03:00
arvanitakis 3e45b84636 Bump version to 0.16.2 2026-09-09 23:46:29 +03:00
arvanitakis 437cbf2460 Fix: Updating Shipping Listeners to clear the ShippingManifest options 2026-09-09 23:45:21 +03:00
arvanitakis 5425a0396f Feat: Adding missing nav translations 2026-09-09 23:43:39 +03:00
arvanitakis 4d0e326cb9 Bump version to 0.16.1 2026-09-09 23:25:22 +03:00
arvanitakis d4f9766940 Fix: Correcting spaceing on login form 2026-09-09 23:22:13 +03:00
arvanitakis 359e1e262e Bump Version to 0.16.0 2026-09-09 01:19:31 +03:00
arvanitakis fb684dc97b Feat: Adding Concent Updates 2026-09-09 01:12:21 +03:00
arvanitakis 9c95c0bccb Bump Version to 0.15.0 2026-09-09 00:48:52 +03:00
arvanitakis 73bfc748b4 Feature: Moving Payment Methods to DB, adding fees, Transaction Updates, Refund Updates, General Updates to Payments 2026-09-09 00:48:09 +03:00
arvanitakis 4ff9bdacc3 Bump version to 0.14.0 2026-09-04 13:06:26 +03:00
arvanitakis 55832d9549 Feat: Adding search results for translation 2026-09-04 13:06:07 +03:00
arvanitakis 7b46a83e5e Feat: Product Search Service Restructure 2026-09-04 13:03:38 +03:00
arvanitakis e9aa08a338 Bump version to 0.13.1 2026-09-03 18:28:22 +03:00
arvanitakis 0676a1f5c7 Feat: Recording Payment Transactions 2026-09-03 18:26:48 +03:00
arvanitakis 8e8ec17d09 Bump version to 0.13.0 2026-09-03 17:44:22 +03:00
arvanitakis e6f1ca179a Fix: Fixing various bugs occured on payment lifecycle 2026-09-03 17:42:30 +03:00
arvanitakis 79e525d53f Fix: Stripe Intents Table is a Lunar Table, so needs a prefix. This is covered by the Lunar\Base\Migration 2026-09-03 17:31:39 +03:00
arvanitakis 456943dc74 Feature: Payment resolver, Payment Provider, Completing Stripe Webhooks, Wiring Payments to checkout service 2026-09-03 17:27:34 +03:00
arvanitakis 35c3334690 Feat: Payment Restructuring to be fully event-driven 2026-09-03 17:00:24 +03:00
arvanitakis a987a2d57c Feature: Abstraction on Payments based on their operations 2026-09-03 16:01:33 +03:00
arvanitakis 0fa2188146 Feat: Refactoring Shipping DataTransferObjects to DTOs namespace 2026-09-03 15:58:51 +03:00
arvanitakis a1301d4b46 Merge branch 'master' into Payments 2026-09-03 13:34:32 +03:00
arvanitakis 215f43f3ef Feat: Adding tags to product filters 2026-09-03 13:34:04 +03:00
arvanitakis c439299144 Bump version to 0.12.1 2026-09-03 13:23:50 +03:00
arvanitakis 6963515971 Fix: Small update to label parsing 2026-09-03 13:22:09 +03:00
arvanitakis f43f72f633 Feat: Updates to Product Indexer, Shopify Importer, Adding cascade delete on reviews 2026-09-03 12:26:00 +03:00
arvanitakis 448da869a9 Bump version to 0.12.0 2026-09-03 11:44:36 +03:00
arvanitakis 1287b513cd Feat: Updating Product Service with new Methods, DTOs for ListingResult And Slider Bounds 2026-09-03 11:44:23 +03:00
arvanitakis b2919f1f4b Feature: Product Search Service Updates 2026-09-03 11:04:19 +03:00
arvanitakis 8cb54e065e Feat: Updates to Payments, Checkout Services, Payment Events 2026-09-02 16:14:52 +03:00
arvanitakis 3497553b41 Feature: Order Service Provider, Order Events Observers 2026-09-01 14:04:12 +03:00
arvanitakis 29e77a973b Feat: Orders Feature Survey 2026-09-01 13:34:37 +03:00
arvanitakis 026ab5bc2d BUmp version to 0.11.1 2026-09-01 13:04:54 +03:00
arvanitakis 483c0ce00f Fix: Recommendations are an eloquent collection, not an Generic Collection 2026-09-01 13:03:39 +03:00
arvanitakis ce405d20e6 Bump Version to 0.11.0 2026-09-01 12:08:47 +03:00
arvanitakis 0f07751559 Feature: Recommendation Service
This commit introduces a RecommendationService that is used when indexing products.

The service is utilizing a recommendation rules interface, so many rules can be created and applied during indexing the products
2026-09-01 12:06:29 +03:00
arvanitakis 53d8a5aefe Bump Version to 0.10.1 2026-09-01 12:04:06 +03:00
arvanitakis 380e860386 Feat: Adding Translations to the Seeder 2026-09-01 10:52:56 +03:00
arvanitakis e7784364fd Feature: Data Access and Data Export Admin Service
This commit introduces the data retention and data export Admin Services, accessed by the Boboko admin UI
2026-08-25 09:33:03 +03:00
arvanitakis 59303cf25f Feature: Updating Readme to reflect changes on Privacy 2026-08-24 21:44:10 +03:00
arvanitakis af380a7fa0 Feature: Handling Cases for User to Customer Relationships
This commit handles a case where customer data are "dead-data" menaing there is no way of erasure for them, which makes the app non-compliant
2026-08-24 21:41:23 +03:00
arvanitakis 9f540cbaa4 Feature: Creating Privacy Basics 2026-08-24 21:06:11 +03:00
337 changed files with 18626 additions and 1151 deletions
+1110 -4
View File
File diff suppressed because it is too large Load Diff
+117 -21
View File
@@ -1,6 +1,9 @@
# Core Module
A Laravel module providing authentication, notifications, activity logging, CLI tooling, and functional types on top of the [Lunar](https://lunarphp.io) admin panel. Designed to be consumed as a standalone Composer package.
A Laravel module providing authentication, localization, product search/catalog, privacy/GDPR
tooling, notifications, activity logging, CLI tooling, and functional types on top of the
[Lunar](https://lunarphp.io) e-commerce package. Designed to be consumed as a standalone Composer
package by any Lunar-based e-shop.
---
@@ -8,13 +11,83 @@ A Laravel module providing authentication, notifications, activity logging, CLI
### OTP Authentication
Passwordless login for both staff (Lunar panel) and customers via 6-digit codes delivered by email. Codes expire after 10 minutes. The Lunar panel login page is a two-step flow: email → OTP. Rate-limited to 5 attempts.
Passwordless login for both staff (Lunar panel) and customers via 6-digit codes delivered by
email. Codes expire after 10 minutes, rate-limited to 5 attempts. The Lunar panel login page is a
two-step flow (email → OTP) with a back button to return from the code step to the email step.
See [`docs/otp-auth.md`](docs/otp-auth.md).
### Localization
Locale-prefixed routing (`Modules\Core\Localization\LocaleMiddleware`) — a `locale` route
middleware, opt-in per shop, that resolves and redirects to the correct language segment
(`/el/...`, `/en/...`) based on Lunar's own language list, with caching and rename-safe
translation migration. Also brings in storefront UI label translations
(`spatie/laravel-translation-loader`) with an admin-editable `LanguageLine` resource.
See [`docs/localization.md`](docs/localization.md).
### Product Search & Catalog
Two complementary services on top of Meilisearch:
- **`Modules\Core\Search\ProductSearchService`** — locale-aware full-text product search.
- **`Modules\Core\Catalog\ProductService`** — listing/filtering (by collection, brand, price
range) and single-product lookup by id or slug, reading directly from the Meilisearch index
rather than the database.
Both are backed by `Modules\Core\Search\ProductIndexer`, which extends Lunar's own indexer with
collections, price, variants, media, tags, and reviews — everything needed for both a listing
page and a full product detail page from one index.
See [`docs/product-search.md`](docs/product-search.md) and
[`docs/product-listing.md`](docs/product-listing.md).
### Product Reviews
`Modules\Core\Review\ProductReview` — ratings/reviews with staff replies, a Filament sub-navigation
page on the product edit screen, and automatic re-indexing (via `ReviewServiceProvider`) whenever
a review is created, updated, or deleted, so a product's Meilisearch document never goes stale.
### Privacy / GDPR Data-Subject Requests
Right of access (export) and right of erasure, built as an extensible contract
(`Modules\Core\Privacy\Contracts\PersonalDataProvider`) rather than a fixed table list — any
module can register its own data without core knowing it exists.
- **Two independent scopes**: erasing/exporting a Lunar `Customer` (business account) is never
the same operation as erasing/exporting a `User` (individual login) — a `Customer` erasure
never touches any linked `User`'s login, and a `User` erasure never touches a `Customer`
account's own data. See `docs/privacy.md` "User-scope vs Customer-scope".
- **Cancellable grace period** (default 30 days, configurable) before anything is actually
erased — logging back in during the window automatically reverts the request, mirroring
Shopify's own account-deletion flow. Immediate erasure exists but is staff-only by type, never
reachable from a self-service flow.
- **Sole-owner cascade**: erasing the last remaining `User` on a `Customer` also opens a (grace
period) erasure request for that now-orphaned `Customer`, so its PII doesn't sit unreachable
forever — traced back to the triggering request so login-reactivation can revert exactly that
cascade.
- **Queued export**: gathering data and writing a CSV-per-provider zip (via the generic,
reusable `Modules\Core\Export\CsvWriter`) runs as a background job; a consuming app hooks its
own notification onto the completion event via the Notification Registry (below).
See [`docs/privacy.md`](docs/privacy.md).
### Shopify Migration
`Modules\Core\MigrateImport\Shopify\ShopifyExportImporter` — imports a Shopify CSV product export
(products, variants, images, collections, tags, prices) into Lunar, idempotently re-runnable via
an `import_mappings` table. Part of a source-agnostic import framework
(`boboko:migrate:import`) designed to support additional sources later.
See [`docs/shopify-import.md`](docs/shopify-import.md).
### Notification Registry
An event-driven notification system. Each notification class declares which event it listens to and who to notify — the registry wires up the listener automatically. All notifications extend `BaseNotification` which implements `ShouldQueue`, so delivery is async. Supports optional delays.
An event-driven notification system. Each notification class declares which event it listens to
and who to notify — the registry wires up the listener automatically. All notifications extend
`BaseNotification`, which implements `ShouldQueue`, so delivery is async. Supports optional
delays.
**Creating a notification:**
@@ -33,9 +106,13 @@ class MyNotification extends BaseNotification
NotificationRegistry::get()->register([MyNotification::class]);
```
See [`docs/notifications.md`](docs/notifications.md).
### Activity Logging
Thin wrapper around [Spatie Laravel Activity Log](https://github.com/spatie/laravel-activitylog). Four standardized methods: `created()`, `updated()`, `failed()`, `deleted()`. Logs to the `lunar` channel and auto-resolves the actor from the staff session.
Thin wrapper around [Spatie Laravel Activity Log](https://github.com/spatie/laravel-activitylog).
Four standardized methods: `created()`, `updated()`, `failed()`, `deleted()`. Logs to the `lunar`
channel and auto-resolves the actor from the staff session.
See [`docs/activity-log.md`](docs/activity-log.md).
@@ -43,8 +120,11 @@ See [`docs/activity-log.md`](docs/activity-log.md).
- Custom OTP login page replacing the default Lunar panel login
- `StaffResourceExtension` — removes password field from Lunar's staff resource
- `CustomerResourceExtension` — replaces default address relation manager with a custom implementation
- `CorePlugin` — configures panel path, branding, logos, navigation items, and activity log field exclusions for staff
- `CustomerResourceExtension` — replaces default address relation manager with a custom
implementation
- Table-rate shipping (`ShippingPlugin`) registered by default
- `CorePlugin` — configures panel path, branding, logos, navigation items, and activity log
field exclusions for staff
Register the plugin in your Lunar panel provider:
@@ -52,30 +132,36 @@ Register the plugin in your Lunar panel provider:
->plugin(\Modules\Core\CorePlugin::make())
```
See [`docs/lunar.md`](docs/lunar.md) for the full Lunar reference and non-obvious gotchas hit
while building against it.
### CLI Commands
| Command | Description |
|---|---|
| `core:create-admin` | Create a Lunar admin user |
| `core:anonymize` | GDPR anonymization of users and customers (local only) |
| `core:export` | Dump database + storage files to a timestamped zip |
| `core:import` | Restore from a zip export (runs anonymize automatically, local only) |
| `core:export-cleanup` | Delete old export zips, keep N most recent |
| `boboko:anonymize` | Dummy-scrub personal data in `users`/`lunar_customers` for local dev safety (local environment only — **not** the GDPR erasure tool; see Privacy above for that) |
| `boboko:export` | Dump database + storage files to a timestamped zip |
| `boboko:import` | Restore from a `boboko:export` zip archive |
| `boboko:export:cleanup` | Delete old export zips, keep N most recent |
| `boboko:migrate:import` | Import a vendor product catalog (Shopify, etc.) into Lunar |
| `boboko:privacy:process-erasure-requests` | Dispatch an erasure job for every due GDPR erasure request (wire into your own scheduler) |
| `lunar:create-admin` | Create a Lunar admin user (overrides Lunar's own command) |
| `lunar:install` | Seed default Lunar store data — countries, channel, currency, tax zone, attributes, product type (overrides Lunar's own command) |
### Functional Types
Result and Option monads for explicit error handling without exceptions.
Result and Option types for explicit error handling without exceptions.
```php
// Result<T, E>
$result = Success::of($value);
$result = Error::of('something went wrong');
$result = Success::create($value);
$result = Error::create('something went wrong');
$result->map(fn($v) => ...)->flatMap(fn($v) => ...);
// Option<T>
$option = Option::fromValue($nullableValue);
$option->getOrElse('default');
$option->map(fn($v) => ...)->filter(fn($v) => $v > 0);
$option = Some::create($value);
$option = None::create();
$option->map(fn($v) => ...);
```
---
@@ -102,17 +188,22 @@ Then run:
```bash
composer require boboko/core
php artisan vendor:publish --tag=core-config
php artisan vendor:publish --tag=core-assets
php artisan migrate
```
For local core development alongside a consuming app (path-repo symlink + Docker mount), see
[`docs/modules.md`](docs/modules.md) "Docker Compose: the local-core mount".
---
## Requirements
- PHP 8.2+
- Laravel 11+
- Lunar (lunarphp/lunar + lunarphp/admin)
- PHP 8.5+
- Laravel 12+
- Lunar 1.3 (`lunarphp/lunar`)
- Meilisearch (for product search/listing/catalog)
- Spatie Laravel Activity Log
---
@@ -120,7 +211,12 @@ php artisan migrate
## Documentation
- [`docs/otp-auth.md`](docs/otp-auth.md) — OTP authentication flow
- [`docs/localization.md`](docs/localization.md) — Locale-prefixed routing and storefront translations
- [`docs/product-search.md`](docs/product-search.md) — Full-text product search
- [`docs/product-listing.md`](docs/product-listing.md) — Product listing/filtering/detail catalog service
- [`docs/privacy.md`](docs/privacy.md) — GDPR right of access/erasure, User-scope vs Customer-scope
- [`docs/shopify-import.md`](docs/shopify-import.md) — Shopify CSV → Lunar field mapping and import design
- [`docs/activity-log.md`](docs/activity-log.md) — Activity logging
- [`docs/lunar.md`](docs/lunar.md) — Lunar framework reference
- [`docs/notifications.md`](docs/notifications.md) — Notification registry
- [`docs/lunar.md`](docs/lunar.md) — Lunar framework reference and gotchas
- [`docs/modules.md`](docs/modules.md) — Module architecture, Customer/User pairing, provider registration pitfalls
+7 -3
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.10.0",
"version": "0.20.2",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -18,7 +18,7 @@
"lunarphp/search": "*",
"lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8",
"lunarphp/stripe": "^1.5"
"stripe/stripe-php": "^16.6"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -37,12 +37,16 @@
"Modules\\Core\\Providers\\CoreServiceProvider",
"Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\CheckoutServiceProvider",
"Modules\\Core\\Providers\\PaymentServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\CartServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider"
"Modules\\Core\\Providers\\FileServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider",
"Modules\\Core\\Providers\\PrivacyServiceProvider"
]
}
},
+25
View File
@@ -0,0 +1,25 @@
<?php
use Modules\Core\Catalog\Recommendations\RandomRule;
use Modules\Core\Catalog\Recommendations\SameCategoryRule;
return [
/*
|--------------------------------------------------------------------------
| Product recommendation rules
|--------------------------------------------------------------------------
|
| Tried in order by Modules\Core\Catalog\Services\RecommendationService —
| the first rule that returns at least one product wins. The order here IS
| the fallback chain: SameCategoryRule first, then RandomRule as a
| last-resort so a product page is never left with zero recommendations
| (as long as the store has more than one product). A consuming app can
| reorder, add, or remove rules freely — nothing about the chain shape is
| hardcoded in the service itself.
|
*/
'recommendation_rules' => [
SameCategoryRule::class,
RandomRule::class,
],
];
+95
View File
@@ -16,6 +16,43 @@ return [
'auto_create_customer_for_user' => true,
/*
|--------------------------------------------------------------------------
| Privacy / GDPR data-subject requests
|--------------------------------------------------------------------------
|
| 'providers' lists every Modules\Core\Privacy\Contracts\PersonalDataProvider
| that should be consulted for right-of-access/right-of-erasure requests. A
| module never needs to be known to core in advance — it just adds its own
| provider class here, the same way config('lunar.search.indexers') maps a
| model to its indexer. See docs/privacy.md.
|
| 'grace_period_days' is how long an erasure request stays cancellable
| (account deactivated, not yet erased) before it's actually processed by
| the privacy:process-erasure-requests scheduled command.
|
*/
'privacy' => [
'providers' => [
// ActivityLogDataProvider MUST run before AddressDataProvider —
// it resolves which activity_log rows belong to this customer
// (including ones keyed by an Address id) before
// AddressDataProvider hard-deletes those Address rows. See that
// provider's own class docblock.
\Modules\Core\Logging\Privacy\ActivityLogDataProvider::class,
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
\Modules\Core\Order\Privacy\OrderDataProvider::class,
\Modules\Core\Cart\Privacy\CartDataProvider::class,
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
\Modules\Core\Payment\Privacy\PaymentDataProvider::class,
\Modules\Core\Auth\Privacy\UserSessionDataProvider::class,
],
'grace_period_days' => 30,
],
/*
|--------------------------------------------------------------------------
| Cart Abandonment Threshold
@@ -30,6 +67,64 @@ return [
'cart' => [
'abandoned_after' => '1 hour',
/*
|----------------------------------------------------------------------
| Unrecoverable Cap
|----------------------------------------------------------------------
|
| Beyond this age, a stale cart stops being treated as an active
| "Abandoned Cart"/"Abandoned Checkout" (Modules\Core\Cart\Services\
| CartLifecycleService) — too old to be a realistic recovery target
| (pricing/stock/tax likely stale by then). This is about the
| abandoned-cart pipeline only, not data retention — no rows are
| deleted or pruned based on this value.
|
*/
'unrecoverable_after' => '90 days',
],
/*
|--------------------------------------------------------------------------
| Order Return Window
|--------------------------------------------------------------------------
|
| How many days after a carrier order is delivered (Order::fulfillment_status
| becomes 'return_window_open') before Modules\Core\Order\Commands\
| CloseExpiredReturnWindows auto-completes it, if no return was requested.
| Store-pickup orders have no return-window step and are unaffected by
| this value (see Modules\Core\Order\Listeners\CompleteOrderOnPickedUp).
|
*/
'order' => [
'return_window_days' => 14,
],
/*
|--------------------------------------------------------------------------
| Storefront OTP Login
|--------------------------------------------------------------------------
|
| Modules\Core\Auth\Services\UserOtpService's passwordless login.
| max_attempts caps how many wrong codes a shopper can guess against ONE
| generated code before it's invalidated outright. generation_limit/
| generation_decay_minutes cap how often a NEW code can be requested for
| the same email — independent of max_attempts, since generating a fresh
| code also resets the guess count, so an attempt cap alone doesn't stop
| an attacker from just requesting a new code every few tries. This same
| limit is also what stands between a malicious/careless caller and
| mail-bombing one inbox.
|
*/
'auth' => [
'otp' => [
'max_attempts' => 5,
'generation_limit' => 3,
'generation_decay_minutes' => 10,
],
],
];
+21
View File
@@ -0,0 +1,21 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Policy versions
|--------------------------------------------------------------------------
|
| Plain version strings, bumped by whoever edits the corresponding legal
| page — recorded alongside every consent/acceptance so a later dispute
| ("what did the shopper actually agree to?") can be answered from the
| order/cart itself rather than a live lookup against whatever the pages
| say TODAY. Not tied to any CMS/database row on purpose — this stays a
| plain config value the same way payment.php's cart_pipeline is a plain
| cross-cutting setting, not a per-instance one.
|
*/
'privacy_policy_version' => env('LEGAL_PRIVACY_POLICY_VERSION', '2026-01-01'),
'terms_version' => env('LEGAL_TERMS_VERSION', '2026-01-01'),
];
+13 -31
View File
@@ -1,46 +1,28 @@
<?php
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee;
use Modules\Core\Payment\Pipelines\Cart\ApplyPaymentMethodFee;
return [
/*
|--------------------------------------------------------------------------
| Lunar payment types merged in by Boboko Core
|--------------------------------------------------------------------------
|
| These are merged into config('lunar.payments.types') so every app using
| boboko-core gets cash-on-delivery out of the box, without publishing
| Lunar's own config.
|
| 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key —
| it's the Modules\Core\Checkout\Contracts\PaymentDriver class
| CheckoutService::confirmPayment() resolves via the container and calls
| confirm() on. Kept on the same row as 'driver' rather than a second,
| separately-keyed map, so a type's full definition — Lunar's driver,
| its config, and its PaymentDriver — lives in one place.
|
*/
'types' => [
'cash-on-delivery' => [
'driver' => 'offline',
'payment_driver' => OfflinePaymentDriver::class,
'authorized' => 'awaiting-payment',
'fee' => 0,
],
],
/*
|--------------------------------------------------------------------------
| Lunar cart pipeline additions
|--------------------------------------------------------------------------
|
| Appended to config('lunar.cart.pipelines.cart') after ApplyShipping so
| the cash-on-delivery fee is added to the shipping total before the
| final Calculate step sums everything up.
| the selected payment method's own fee (if any) is added to the
| shipping total before the final Calculate step sums everything up.
|
| This is the one thing left in this file — everything about WHICH
| payment methods exist (driver mapping, capture_mode, statuses) moved
| onto Modules\Core\Payment\Models\PaymentMethod's own row (see
| docs/payments.md): that's a per-instance, merchant decision, not a
| store-wide-singular setting, so it never belonged in config at all.
| This pipeline registration IS genuinely cross-cutting — every store
| using this driver gets the same cart-pipeline wiring, regardless of
| how many payment methods it configures.
|
*/
'cart_pipeline' => [
ApplyCashOnDeliveryFee::class,
ApplyPaymentMethodFee::class,
],
];
+14
View File
@@ -13,12 +13,25 @@
|
| Set these via environment variables — never commit real values.
|
| Box Now has two environments (see their Partner API manual, section 2):
| Stage/Sandbox for testing, Production once live. Each has its own
| client_id/client_secret pair and its own base_url/location_api_url —
| there is no shared "switch an env var" flag, since stage credentials
| don't work against the production host or vice versa.
|
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
| lookups (Box Now recommends this over the main
| base URL for those two calls specifically).
| BOXNOW_CLIENT_ID OAuth2 client id.
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
| BOXNOW_PARTNER_ID Numeric partnerId Box Now issues alongside your
| credentials. NOT used for REST API authentication
| (BoxNowClient authenticates with client_id/
| client_secret alone) — this is only consumed by
| the client-side Destination Map widget config
| (_bn_map_widget_config.partnerId), confirmed
| against Box Now's own WooCommerce plugin source.
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
| the pickup origin on every delivery request.
| BOXNOW_SENDER_* Static sender contact details reused on every
@@ -33,6 +46,7 @@ return [
'client_id' => env('BOXNOW_CLIENT_ID'),
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
'partner_id' => env('BOXNOW_PARTNER_ID'),
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('deactivated_at')->nullable()->after('otp_expires_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('deactivated_at');
});
}
};
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('data_erasure_requests', function (Blueprint $table) {
$table->id();
// Polymorphic, not a fixed customer_id — a request targets either a
// Lunar Customer (business account) or a User (individual), never
// both at once. See docs/privacy.md "User-scope vs Customer-scope".
$table->string('subject_type');
$table->unsignedBigInteger('subject_id');
// Snapshot, not a live-looked-up value — the subject's email may
// change or the record may be gone by the time this is read.
$table->string('email')->nullable();
// Who asked for this: the subject themselves (self-service deletion)
// or a staff member acting on their behalf. Plain nullable type+id
// columns rather than morphs() — only ever one of two concrete actor
// types, not an open-ended polymorphic set.
$table->string('requested_by_type');
$table->unsignedBigInteger('requested_by_id');
$table->string('status')->default('pending');
// Set only on a Customer-scoped request that was auto-created because
// erasing a User left them as the sole remaining user on that Customer
// (see Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener).
// Null for every normal, directly-requested erasure. Lets login-
// reactivation find and revert exactly the Customer request THIS
// User's cancellation caused, without touching an unrelated,
// independently-requested Customer erasure the User happens to be
// linked to.
$table->foreignId('caused_by_request_id')->nullable()->constrained('data_erasure_requests')->nullOnDelete();
// now() + config('core.privacy.grace_period_days') at creation time —
// when privacy:process-erasure-requests will actually run this.
$table->timestamp('scheduled_for');
$table->timestamp('cancelled_at')->nullable();
$table->timestamp('completed_at')->nullable();
// Every provider's outcome, written once the request completes —
// see Modules\Core\Privacy\ErasureReport. Null until then.
$table->json('report')->nullable();
$table->timestamps();
$table->index(['status', 'scheduled_for']);
$table->index(['subject_type', 'subject_id']);
});
}
public function down(): void
{
Schema::dropIfExists('data_erasure_requests');
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('data_export_requests', function (Blueprint $table) {
$table->id();
// Polymorphic, not a fixed customer_id — see data_erasure_requests
// for the same shape and reasoning.
$table->string('subject_type');
$table->unsignedBigInteger('subject_id');
// Snapshot, not a live lookup — same reasoning as
// data_erasure_requests.email (see that migration).
$table->string('email')->nullable();
$table->string('status')->default('pending');
// Storage path of the assembled export .zip, set once the queued job
// finishes. Null while pending.
$table->string('file_path')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->index('status');
$table->index(['subject_type', 'subject_id']);
});
}
public function down(): void
{
Schema::dropIfExists('data_export_requests');
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* product_reviews.product_id's original foreign key (2026_07_10_000001) had
* no ON DELETE clause, so deleting a Product with reviews throws a
* constraint violation instead of the review rows going with it — unlike
* every other Product-dependent table (variants, media, etc.), which does
* cascade. A review is dependent, disposable data, not something worth
* blocking a product deletion over.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('product_reviews', function (Blueprint $table) {
$table->dropForeign(['product_id']);
});
Schema::table('product_reviews', function (Blueprint $table) {
$table->foreign('product_id')
->references('id')
->on(config('lunar.database.table_prefix').'products')
->cascadeOnDelete();
});
}
public function down(): void
{
Schema::table('product_reviews', function (Blueprint $table) {
$table->dropForeign(['product_id']);
});
Schema::table('product_reviews', function (Blueprint $table) {
$table->foreign('product_id')
->references('id')
->on(config('lunar.database.table_prefix').'products');
});
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Lunar\Base\Migration;
/**
* First-party copy of lunarphp/stripe's own create_stripe_payment_intents_table
* migration (package removed in favour of depending on stripe/stripe-php
* directly — see Modules\Core\Payment\Support\StripeManager and
* Modules\Core\Payment\Models\StripePaymentIntent, which replace the
* package's own classes over this same table). Timestamped to run just
* before this app's own add_context_to_stripe_payment_intents migration,
* which already alters this table.
*
* Guarded with hasTable(): on any environment that already ran
* lunarphp/stripe's own copy of this migration before the package was
* removed, the table already exists — this migration is only the one that
* actually creates it on a fresh install/database from now on.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable($this->prefix.'stripe_payment_intents')) {
return;
}
Schema::create($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
$table->id();
$table->foreignId('cart_id')->constrained($this->prefix.'carts');
$table->foreignId('order_id')->nullable()->constrained($this->prefix.'orders');
$table->string('intent_id')->index();
$table->string('status')->nullable();
$table->string('event_id')->index()->nullable();
$table->timestamp('processing_at')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists($this->prefix.'stripe_payment_intents');
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Lunar\Base\Migration;
/**
* lunarphp/stripe's own stripe_payment_intents table already correlates a
* Stripe intent back to a cart/order via cart_id/order_id — exactly what
* Modules\Core\Payment\Drivers\StripePaymentDriver needs to recover
* $context in handleCallback(), a separate request (a webhook) from the
* pay()/authorize() call that originated it. Two columns this driver
* needs that the vendor table doesn't have:
* - context: the full opaque $context bag pay()/authorize() received,
* stored so handleCallback() can dispatch the SAME context the
* original call would have, without Payment inventing its own
* correlation table — see docs/payments.md "Async resolution".
* - payment_type: the payment type key (e.g. 'stripe') pay()/authorize()
* were called with — needed to dispatch Payment events with the
* correct $type in handleCallback(), which otherwise has no way to
* know it (a webhook payload doesn't carry it).
*
* Extends Lunar\Base\Migration (not the plain base Migration) so $this->prefix
* resolves the SAME table-prefix config every Lunar-owned table uses
* (config('lunar.database.table_prefix')) — the vendor migration that
* creates this table (lunarphp/stripe's create_stripe_payment_intents_table)
* already does this, so a store running with a non-default prefix (this
* one runs with 'lunar_') would otherwise have this migration fail against
* a table name that doesn't exist.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
$table->json('context')->nullable()->after('status');
$table->string('payment_type')->nullable()->after('context');
});
}
public function down(): void
{
Schema::table($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
$table->dropColumn(['context', 'payment_type']);
});
}
};
@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Moves the driver mapping and per-type behavior that used to live in
* config('lunar.payments.types.{type}.*') onto the PaymentMethod row
* itself — same DB-instance-vs-config split Modules\Core\Shipping's own
* shipping_methods table already has (code/driver/name/enabled columns,
* no driver mapping in any config file). See docs/payments.md.
*
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
* (NOT the same as `type` — two rows can share one driver).
* - name: admin-facing label. Nothing played this role before; `type`
* was always the machine slug.
* - capture_mode / captured_status / authorized_status: per-instance
* behavior — fails the "would a store ever want two different answers
* to this" cross-cutting-config test, so these move off config.
* - position: admin-controlled display/checkout order.
* - driver_missing_at: set by the payment:sync-drivers command when
* `driver` no longer resolves via the registry — deliberately
* separate from `enabled`, so a driver vanishing (a deploy removed
* it) is never confused with an admin's own manual toggle, and a
* driver that comes back later auto-clears this with no admin action.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('name')->nullable()->after('type');
$table->string('driver')->nullable()->after('name');
$table->string('capture_mode')->nullable()->after('driver');
$table->string('captured_status')->nullable()->after('capture_mode');
$table->string('authorized_status')->nullable()->after('captured_status');
$table->unsignedInteger('position')->default(0)->after('authorized_status');
$table->timestamp('driver_missing_at')->nullable()->after('position');
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn([
'name', 'driver', 'capture_mode', 'captured_status',
'authorized_status', 'position', 'driver_missing_at',
]);
});
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* captured_status/authorized_status (added in 2026_09_05_000001) cover a
* payment being taken, but nothing wrote Order.status on a REFUND —
* Order::paymentStatus() (Order\Support\OrderStatus::payment(), derived
* live from transactions) already reflects a refund correctly, but the
* stored status column — the one admin filtering, customer emails, etc.
* actually key off — never moved. Same reasoning as captured_status/
* authorized_status: a store could plausibly want a different resulting
* status per payment method (e.g. a "Refunded" vs. a "Refund Pending"
* variant), so this is a PaymentMethod column, not cross-cutting config.
*
* Deliberately no separate void_status — void never moved money (it
* releases an authorization hold before any capture), so it doesn't carry
* the same "the customer needs to see this changed" weight a refund does;
* add one later if a real need for it shows up.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('refunded_status')->nullable()->after('authorized_status');
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn('refunded_status');
});
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Splits Lunar's single flat `status` column into three independently
* tracked axes — payment, fulfillment, return — so a payment refund and a
* fulfillment dispatch stop racing to write the same field, and each axis
* can be filtered/queried directly instead of overloading one string for
* three unrelated concerns. See Modules\Core\Order\Enums\OrderPaymentStatus/
* OrderFulfillmentStatus/OrderReturnStatus for the value vocabularies, and
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus and friends for
* where these columns actually get written. `status` itself is left in
* place, unchanged — Lunar core still reads/writes it in places this
* package doesn't own — but nothing in this package's business logic keys
* off it anymore after this migration's consumers land.
*
* lunar_customers already has a direct precedent for a boboko-core
* migration altering a Lunar-owned table (see
* 2026_07_02_000002_drop_otp_from_lunar_customers_table.php) — this is not
* a new pattern for this codebase, just the first time it's applied to
* lunar_orders.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->string('payment_status')->default('awaiting_payment')->after('status')->index();
$table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index();
$table->string('return_status')->default('none')->after('fulfillment_status')->index();
});
}
public function down(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->dropColumn(['payment_status', 'fulfillment_status', 'return_status']);
});
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Append-only audit trail for Order's three status axes (see
* 2026_09_11_000001_add_status_axes_to_orders_table.php) — the thing
* `Lunar\Models\Order::getDefaultLogExcept()` explicitly denies (`status`
* is excluded from Lunar's own Spatie activity log), so this is a
* from-scratch mechanism, not a gap in an existing one.
*
* No `updated_at` — a row is never edited after it's written, only ever
* inserted. `event_class` is the FQCN of whatever business event/action
* caused the write (e.g. Modules\Core\Order\Events\OrderDispatched, or a
* plain string like 'Modules\Core\Shipping\Extensions\OrderViewExtension::
* markDispatchedAction' for a manual Filament action that has no backing
* event class of its own) — see Modules\Core\Order\Services\
* OrderStatusTransitionRecorder.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('order_status_transitions', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained('lunar_orders')->cascadeOnDelete();
$table->string('axis');
$table->string('from_status')->nullable();
$table->string('to_status');
$table->string('event_class');
$table->timestamp('created_at')->useCurrent();
$table->index(['order_id', 'axis']);
});
}
public function down(): void
{
Schema::dropIfExists('order_status_transitions');
}
};
@@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Order;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* Maps every existing order's flat `status` (as it stood before
* 2026_09_11_000001_add_status_axes_to_orders_table.php) onto the new
* payment_status/fulfillment_status/return_status columns. A separate
* migration from the schema change so the schema migration stays simply
* reversible via down(), and this data pass can be independently re-run.
*
* The flat status never captured refunds at all (no 'refunded' value was
* ever added to config('lunar.orders.statuses')), so the table-driven
* mapping below is corrected per-order by re-deriving
* Modules\Core\Order\Support\OrderStatus::payment() — the existing,
* unchanged derived-enum logic — and overriding payment_status to
* refunded/partially_refunded wherever it disagrees with the flat-status
* mapping. This is the one place the "keep the old derived enums" design
* decision earns its keep: refund-fraction math isn't reimplemented here,
* just reused.
*/
return new class extends Migration
{
private const MAP = [
'awaiting-payment' => ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'],
'payment-offline' => ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'],
'payment-received' => ['payment_status' => 'paid', 'fulfillment_status' => 'unfulfilled'],
'ready-for-dispatch' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'],
'ready-for-pickup' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'],
'dispatched' => ['payment_status' => 'paid', 'fulfillment_status' => 'in_transit'],
'completed' => ['payment_status' => 'paid', 'fulfillment_status' => 'completed'],
];
public function up(): void
{
Order::query()->with('transactions')->chunkById(200, function ($orders) {
foreach ($orders as $order) {
$mapped = self::MAP[$order->status] ?? null;
if ($mapped === null) {
Log::warning('Order status axis backfill: unmapped status, leaving column defaults', [
'order_id' => $order->id,
'status' => $order->status,
]);
continue;
}
$paymentStatus = $mapped['payment_status'];
$derived = OrderStatus::payment($order);
if ($derived === PaymentStatus::Refunded) {
$paymentStatus = 'refunded';
} elseif ($derived === PaymentStatus::PartialRefund) {
$paymentStatus = 'partially_refunded';
}
DB::table('lunar_orders')->where('id', $order->id)->update([
'payment_status' => $paymentStatus,
'fulfillment_status' => $mapped['fulfillment_status'],
'return_status' => 'none',
]);
}
});
}
public function down(): void
{
// Column defaults (set in the schema migration) are the correct
// "undo" — no need to reverse-map back to the flat status, since
// `status` itself was never touched by this migration.
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* captured_status/authorized_status/refunded_status let a merchant pick
* which per-method Order::status label a payment outcome resulted in — a
* mechanism that only made sense while Order.status was the single field
* carrying that meaning. Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus now writes a fixed 3-value payment_status
* column instead (see 2026_09_11_000001_add_status_axes_to_orders_table.php);
* there is no longer any per-method flexibility to preserve — "paid" is
* "paid" regardless of which method captured it. Dropped rather than left
* vestigial: keeping them visible in the admin would let a merchant
* configure something that silently does nothing.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->dropColumn(['captured_status', 'authorized_status', 'refunded_status']);
});
}
public function down(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->string('captured_status')->nullable();
$table->string('authorized_status')->nullable();
$table->string('refunded_status')->nullable();
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Order::paid/paid_at — entirely independent of the `status` column (see
* Modules\Core\Order\Services\OrderStatusFlow's own docblock for why
* payment timing, especially for cash-on-delivery, cannot be modeled as a
* status-sequence step). `paid` is the fast-filter boolean; `paid_at` is
* when it actually happened.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->boolean('paid')->default(false)->after('status')->index();
$table->timestamp('paid_at')->nullable()->after('paid');
});
}
public function down(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->dropColumn(['paid', 'paid_at']);
});
}
};
@@ -0,0 +1,116 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Order;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* Collapses the 3-axis (payment_status/fulfillment_status/return_status)
* model this session briefly built — abandoned before shipping — back
* onto a single `status` column plus the new independent `paid`/`paid_at`
* fields. Must run after 2026_09_12_000001 (adds paid/paid_at) and before
* 2026_09_12_000003 (drops the axis columns this migration still reads).
*
* Priority rule: axis data where it's genuinely non-default (this order
* was really moved through the axis system during this session's manual
* testing); the legacy `status` column (which may still hold pre-session
* hyphenated values) as fallback everywhere else.
*/
return new class extends Migration
{
private const LEGACY_MAP = [
'awaiting-payment' => 'awaiting_payment',
'payment-offline' => 'awaiting_payment',
'payment-received' => 'processing',
'ready-for-dispatch' => 'ready_for_dispatch',
'ready-for-pickup' => 'ready_for_pickup',
'dispatched' => 'dispatched',
'completed' => 'completed',
];
/**
* Axis fulfillment_status -> new single status, given branch. Axis
* 'delivered' folds into 'return_window_open' (same combined-value
* decision the going-forward design makes). Axis payment_status is
* used only to decide whether a fully-unfulfilled order should read
* as 'awaiting_payment' or 'processing'.
*/
private function mapFromAxes(string $payment, string $fulfillment, string $return, bool $isPickup): ?string
{
if ($return === 'returned') {
return 'returned';
}
if ($return === 'requested') {
return 'return_requested';
}
return match ($fulfillment) {
'unfulfilled' => $payment === 'paid' ? 'processing' : 'awaiting_payment',
'processing' => 'processing',
'ready' => $isPickup ? 'ready_for_pickup' : 'ready_for_dispatch',
'in_transit' => 'dispatched',
'delivered', 'return_window_open' => 'return_window_open',
'picked_up' => 'picked_up',
'completed' => 'completed',
default => null,
};
}
public function up(): void
{
Order::query()->with('transactions')->chunkById(200, function ($orders) {
foreach ($orders as $order) {
$isPickup = $order->isStorePickupOrder();
$axisIsDefault = $order->payment_status === 'awaiting_payment'
&& $order->fulfillment_status === 'unfulfilled'
&& $order->return_status === 'none';
$status = $axisIsDefault
? (self::LEGACY_MAP[$order->status] ?? null)
: $this->mapFromAxes($order->payment_status, $order->fulfillment_status, $order->return_status, $isPickup);
if ($status === null) {
Log::warning('Single-status backfill: unmapped order, defaulting to awaiting_payment', [
'order_id' => $order->id,
'status' => $order->status,
'payment_status' => $order->payment_status,
'fulfillment_status' => $order->fulfillment_status,
'return_status' => $order->return_status,
]);
$status = 'awaiting_payment';
}
$derived = OrderStatus::payment($order);
$paid = $order->payment_status === 'paid'
|| in_array($derived, [PaymentStatus::Captured, PaymentStatus::Refunded, PaymentStatus::PartialRefund], true);
// A refund implies the order concluded via a return —
// even one backfilled to an early status (e.g. an order
// refunded before fulfillment ever started) is corrected
// to refunded/partially_refunded here, not left stuck
// pre-fulfillment with no sign a refund ever happened.
if ($derived === PaymentStatus::Refunded) {
$status = 'refunded';
} elseif ($derived === PaymentStatus::PartialRefund) {
$status = 'partially_refunded';
}
DB::table('lunar_orders')->where('id', $order->id)->update([
'status' => $status,
'paid' => $paid,
'paid_at' => $paid ? ($order->placed_at ?? now()) : null,
]);
}
});
}
public function down(): void
{
// No reverse mapping — column defaults (post-rollback of the
// schema migrations) are the correct "undo".
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Reverses 2026_09_11_000001_add_status_axes_to_orders_table.php — the
* 3-axis model was abandoned before shipping in favor of a single
* `status` column plus independent `paid`/`paid_at` (see
* 2026_09_12_000001/000002). Must run after 2026_09_12_000002, which
* still reads these columns for the backfill.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->dropColumn(['payment_status', 'fulfillment_status', 'return_status']);
});
}
public function down(): void
{
// Mirrors 2026_09_11_000001's own down() — restores columns
// empty/defaulted, does not attempt to resurrect real per-order
// values.
Schema::table('lunar_orders', function (Blueprint $table) {
$table->string('payment_status')->default('awaiting_payment')->after('paid_at')->index();
$table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index();
$table->string('return_status')->default('none')->after('fulfillment_status')->index();
});
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* There is only one status column left to audit (plus the synthetic
* 'paid' entry — see Modules\Core\Order\Listeners\RecordStatusTransition),
* so the `axis` column this table was created with
* (2026_09_11_000002_create_order_status_transitions_table.php) no longer
* means anything.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('order_status_transitions', function (Blueprint $table) {
$table->dropIndex(['order_id', 'axis']);
$table->dropColumn('axis');
$table->index('order_id');
});
}
public function down(): void
{
Schema::table('order_status_transitions', function (Blueprint $table) {
$table->dropIndex(['order_id']);
$table->string('axis')->default('status')->after('order_id');
$table->index(['order_id', 'axis']);
});
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* The seeded 'cash-on-delivery' PaymentMethod row
* (Modules\Core\Command\InstallLunarCommand::seedPaymentMethods()) was
* wired to driver => 'offline' — the same immediate-capture driver as
* cash-in-hand. That's the bug that made COD "pay immediately" instead of
* waiting for staff to confirm cash was actually received. Repoints
* already-seeded environments to the new dedicated
* Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver; the seeder
* itself is fixed separately for fresh installs.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'cash-on-delivery']);
}
public function down(): void
{
DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'offline']);
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* 'return_window_open' is renamed to 'delivered' — same status value,
* same meaning (the parcel arrived AND the return window is now open,
* still one combined moment — see Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnDelivered), just a name a merchant expects to read
* on the order page rather than an internal mechanic. Also renames it in
* order_status_transitions' audit rows so the history stays consistent
* with `status` going forward.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('lunar_orders')->where('status', 'return_window_open')->update(['status' => 'delivered']);
DB::table('order_status_transitions')->where('from_status', 'return_window_open')->update(['from_status' => 'delivered']);
DB::table('order_status_transitions')->where('to_status', 'return_window_open')->update(['to_status' => 'delivered']);
}
public function down(): void
{
DB::table('lunar_orders')->where('status', 'delivered')->update(['status' => 'return_window_open']);
DB::table('order_status_transitions')->where('from_status', 'delivered')->update(['from_status' => 'return_window_open']);
DB::table('order_status_transitions')->where('to_status', 'delivered')->update(['to_status' => 'return_window_open']);
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A real record of "a manifest was issued", not just a loose
* manifest_reference string stamped onto each Shipment row — ACS's own
* ACS_Issue_Pickup_List call returns nothing beyond a PickupList_No (see
* Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService::issueManifest()),
* so this table is entirely our own bookkeeping: when the manifest was
* issued and how many shipments it included, not something re-derivable
* from the carrier later. `shipment_count` is denormalized (also
* countable via shipments()->count()) purely so the manifests list can
* render without an extra query per row.
*
* carrier-agnostic by design — see Modules\Core\Shipping\Contracts\
* SupportsManifestBatching, the same contract any future carrier
* (Speedex, etc.) implements to get manifest batching at all; this table
* has no ACS-specific columns.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('manifests', function (Blueprint $table) {
$table->id();
$table->string('carrier');
$table->string('reference');
$table->unsignedInteger('shipment_count')->default(0);
$table->timestamp('issued_at');
$table->timestamps();
$table->unique(['carrier', 'reference']);
});
}
public function down(): void
{
Schema::dropIfExists('manifests');
}
};
@@ -0,0 +1,82 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Replaces the loose manifest_reference string with a real manifests
* relation — see 2026_09_13_000002_create_manifests_table.php. Backfills
* one Manifest row per distinct (carrier, manifest_reference) pair
* already present in shipments, using the earliest label_printed_at (or
* updated_at as a fallback) among that group as a best-effort issued_at,
* since the exact original issue time was never recorded anywhere.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->foreignId('manifest_id')->nullable()->after('manifest_reference')->constrained()->nullOnDelete();
});
$groups = DB::table('shipments')
->select('carrier', 'manifest_reference')
->whereNotNull('manifest_reference')
->distinct()
->get();
foreach ($groups as $group) {
$shipments = DB::table('shipments')
->where('carrier', $group->carrier)
->where('manifest_reference', $group->manifest_reference)
->get();
$issuedAt = $shipments->pluck('label_printed_at')->filter()->min()
?? $shipments->pluck('updated_at')->min();
$manifestId = DB::table('manifests')->insertGetId([
'carrier' => $group->carrier,
'reference' => $group->manifest_reference,
'shipment_count' => $shipments->count(),
'issued_at' => $issuedAt,
'created_at' => $issuedAt,
'updated_at' => $issuedAt,
]);
DB::table('shipments')
->where('carrier', $group->carrier)
->where('manifest_reference', $group->manifest_reference)
->update(['manifest_id' => $manifestId]);
}
Schema::table('shipments', function (Blueprint $table) {
$table->dropColumn('manifest_reference');
});
}
public function down(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->string('manifest_reference')->nullable()->after('parent_reference');
});
DB::table('shipments')
->whereNotNull('manifest_id')
->orderBy('id')
->each(function ($shipment) {
$manifest = DB::table('manifests')->find($shipment->manifest_id);
if ($manifest) {
DB::table('shipments')->where('id', $shipment->id)->update([
'manifest_reference' => $manifest->reference,
]);
}
});
Schema::table('shipments', function (Blueprint $table) {
$table->dropConstrainedForeignId('manifest_id');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Caps brute-forcing a 6-digit OTP code (1M combinations, 10-minute
* window, previously uncapped) — see Modules\Core\Auth\Services\
* UserOtpService::validate(), which now invalidates the code entirely
* (forcing a fresh generateAndSend()) once otp_attempts reaches its max,
* rather than leaving a live code guessable indefinitely within its
* expiry window.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unsignedTinyInteger('otp_attempts')->default(0)->after('otp_expires_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('otp_attempts');
});
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A per-login session registry, independent of the actual session store
* driver (SESSION_DRIVER=redis in this app — no "sessions" table to
* purge by user_id the way the database driver would allow). Each
* successful OTP login (Modules\Core\Auth\Services\UserOtpService::
* validate()) records one row here and stamps the token into the
* Laravel session payload; Modules\Core\Auth\Http\Middleware\
* EnsureSessionNotRevoked checks it on every request. "Logout
* everywhere" (Modules\Core\Auth\Services\UserSessionService::
* revokeOtherSessions()) is then just marking every OTHER row
* revoked_at, no session-store-specific logic anywhere.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('user_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('token', 64)->unique();
$table->string('user_agent')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamp('last_used_at');
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'revoked_at']);
});
}
public function down(): void
{
Schema::dropIfExists('user_sessions');
}
};
@@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Language;
/**
* PaymentMethod.name becomes a locale-keyed JSON array (e.g.
* {"en": "Cash On Delivery", "el": "Αντικαταβολή"}), rendered in Filament
* via Lunar's own Lunar\Admin\Support\Forms\Components\TranslatedText —
* the same reusable component/data-shape Product/Collection names already
* use (Lunar\Base\Traits\HasTranslations), just applied directly to a
* plain column here rather than through attribute_data, since
* PaymentMethod is a merchant-configured settings row, not a translatable
* catalog attribute.
*
* Existing plain-string rows are preserved under the store's default
* Language code (falls back to 'en' if no Language row exists yet — this
* migration can run before lunar:install seeds one) rather than dropped,
* so an already-configured payment method's name isn't blanked out.
*
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
* (which requires doctrine/dbal — not installed in this project) —
* Postgres-specific (this project runs on `pgsql`, per its own docker
* setup), with an explicit USING clause since json isn't implicitly
* castable from varchar.
*/
return new class extends Migration
{
public function up(): void
{
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
$existing = DB::table('payment_methods')->pluck('name', 'id');
DB::statement('ALTER TABLE payment_methods ALTER COLUMN name DROP DEFAULT');
DB::statement("ALTER TABLE payment_methods ALTER COLUMN name TYPE json USING NULL");
foreach ($existing as $id => $name) {
if ($name === null) {
continue;
}
DB::table('payment_methods')
->where('id', $id)
->update(['name' => json_encode([$defaultLocale => $name])]);
}
}
public function down(): void
{
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
$existing = DB::table('payment_methods')->pluck('name', 'id');
DB::statement('ALTER TABLE payment_methods ALTER COLUMN name TYPE varchar(255) USING NULL');
foreach ($existing as $id => $name) {
$decoded = json_decode((string) $name, true);
$flat = is_array($decoded) ? ($decoded[$defaultLocale] ?? reset($decoded) ?: null) : $name;
DB::table('payment_methods')->where('id', $id)->update(['name' => $flat]);
}
}
};
@@ -0,0 +1,74 @@
<?php
use Illuminate\Support\Facades\DB;
use Lunar\Base\Migration;
use Lunar\Models\Language;
/**
* ShippingMethod.name becomes a locale-keyed JSON array (e.g.
* {"en": "Standard Delivery", "el": "Κανονική Παράδοση"}), rendered in
* Filament via Lunar's own Lunar\Admin\Support\Forms\Components\
* TranslatedText (Modules\Core\Shipping\Extensions\
* ShippingMethodResourceExtension::replaceNameField()) — same shape/
* resolution as PaymentMethod.name (see its own migration,
* 2026_09_15_000001_make_payment_methods_name_translatable.php) and
* Product/Collection names (Lunar\Base\Traits\HasTranslations).
*
* ShippingMethod is a vendor (lunarphp/table-rate-shipping) table, but
* converting a vendor column's type via a migration is no different from
* any other schema change this project already makes against a vendor
* table (see database/migrations/2026_08_31_000001_create_payment_methods_table.php's
* sibling migrations for the same pattern against PaymentMethod) — there
* was no good reason to route this through `data.name` instead, unlike
* `data.fulfillment_type` which is a genuinely NEW field the vendor table
* never had at all.
*
* Existing plain-string rows are preserved under the store's default
* Language code (falls back to 'en' if no Language row exists yet)
* rather than dropped.
*
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
* (requires doctrine/dbal — not installed in this project) — Postgres-
* specific (this project runs on `pgsql`), with an explicit USING clause
* since json isn't implicitly castable from varchar.
*/
return new class extends Migration
{
public function up(): void
{
$table = $this->prefix.'shipping_methods';
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
// The column is NOT NULL (vendor migration never marked it
// nullable) — converting via `USING NULL` first, then
// backfilling with a second UPDATE, violates that constraint
// before the backfill ever runs. json_build_object() converts
// each existing string in place, in the same statement, so the
// column is never transiently NULL. $defaultLocale is inlined
// (not bound) — parameter binding inside an ALTER TABLE ... USING
// expression isn't reliable across drivers; it's a Language::code
// value we control, not user input, so quote_literal-safe
// interpolation here is fine.
$quotedLocale = DB::getPdo()->quote($defaultLocale);
DB::statement("ALTER TABLE {$table} ALTER COLUMN name TYPE json USING json_build_object({$quotedLocale}, name)");
}
public function down(): void
{
$table = $this->prefix.'shipping_methods';
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
// Same NOT NULL constraint applies going back — ->>'{locale}'
// extracts the default locale's text value directly in the
// USING clause, falling back to the first key present via
// COALESCE for any row missing that locale (e.g. one only ever
// filled in via a non-default language).
$quotedLocale = DB::getPdo()->quote($defaultLocale);
DB::statement(
"ALTER TABLE {$table} ALTER COLUMN name TYPE varchar(255) ".
"USING COALESCE(name->>{$quotedLocale}, (SELECT value FROM json_each_text(name) LIMIT 1))"
);
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Per-product, customer-authored input fields — a personalized-statue
* product needing a reference photo upload and an optional engraving
* textarea, for example. Deliberately NOT modeled as a Lunar ProductOption
* (see Modules\Core\Catalog\Contracts\ProductOptionTypeInterface's own
* docblock): an option's values are a fixed, admin-authored list that
* define variants (Red/Green/Blue) — a photo upload has no such list, it's
* unique per order, and creates no variant at all. This is a genuinely
* different concept that happens to configure on the same product page.
*
* Array of {key, type: 'text'|'textarea'|'file', label, required} — `key`
* is what a submitted answer is keyed by in CartLine/OrderLine.meta (both
* already have a `meta` json column — see Modules\Core\Cart\Services\
* CartService::addLine()'s own $meta parameter), not a new table, since
* this is small, rarely-queried per-product config, the same reasoning
* ShippingMethod.data/PaymentMethod.data already follow for their own
* per-row settings.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
$table->json('custom_fields')->nullable()->after('attribute_data');
});
}
public function down(): void
{
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
$table->dropColumn('custom_fields');
});
}
};
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A generic, storage-backend-agnostic file registry — Modules\Core\File\
* Services\FileService's own backing table. `disk`/`path` are whatever
* Laravel's Storage facade already understands (local, s3, ...); this
* table adds what Flysystem itself has no concept of: who a file
* belongs to, why it was uploaded, and whether anything still needs it.
*
* `owner_type`/`owner_id` are nullable — a file can (and, for a product
* custom-field photo, always does) exist before anything owns it yet: a
* shopper picks a photo on the product page and it's uploaded immediately
* (see 3dealer's CustomFieldUploadController), well before add-to-cart
* gives it a CartLine to belong to. FileService::attachOwner() re-points
* these columns once an owner exists, rather than creating a second row
* for the same physical file.
*
* `purpose` (e.g. 'custom-field-upload') lets one table serve unrelated
* future features without collision — FileService itself has no
* knowledge of what a purpose means, callers scope their own queries by
* it.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('files', function (Blueprint $table) {
$table->id();
$table->string('disk');
$table->string('path');
$table->string('original_name')->nullable();
$table->string('mime')->nullable();
$table->unsignedBigInteger('size')->nullable();
$table->string('purpose');
$table->nullableMorphs('owner');
$table->timestamps();
$table->index(['purpose', 'owner_type', 'owner_id']);
});
}
public function down(): void
{
Schema::dropIfExists('files');
}
};
+48 -40
View File
@@ -1,28 +1,34 @@
# Cart Admin Visibility
`Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into
customer/user carts in the Filament admin panel. Lunar itself ships no cart admin view at
all — no Filament resource for `Cart`/`CartLine` exists anywhere in `lunarphp/lunar` or
`lunarphp/core` — this is a from-scratch addition, not an extension of something Lunar
half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the underlying Lunar cart
mechanics this resource reads from.
every cart in the Filament admin panel, guest carts included. Lunar itself ships no cart
admin view at all — no Filament resource for `Cart`/`CartLine` exists anywhere in
`lunarphp/lunar` or `lunarphp/core` — this is a from-scratch addition, not an extension of
something Lunar half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the
underlying Lunar cart mechanics this resource reads from.
---
## Scope: only carts with a known customer or user
## Scope: every cart, identified or not
`CartResource::getEloquentQuery()` filters to `Cart::whereNotNull('user_id')->orWhereNotNull('customer_id')`
— an anonymous guest's session cart is excluded entirely.
`CartResource` lists every cart the four lifecycle states (below) cover, with no
`user_id`/`customer_id` filter — an anonymous guest's session cart is included.
This was a deliberate call, not an oversight: an anonymous cart carries no identity a staff
member could act on — no name, no email, nothing to follow up with — so listing every guest
session cart would be noise, not a real admin capability. This does **not** mirror Shopify's
admin (Shopify has no "all carts" view at all — only "Abandoned checkouts," gated on a
shopper reaching checkout and entering contact info, a later/narrower stage than Lunar's
`Cart`). Lunar's own `Cart` model already gets `user_id`/`customer_id` set the moment a
shopper is authenticated (via `Lunar\Listeners\CartSessionAuthListener` on login), with no
checkout step required — so scoping to "identifiable" here is broader than Shopify's
equivalent, not a copy of it.
This was a reversal of an earlier, deliberate call to exclude guest carts entirely (on the
reasoning that an anonymous cart carries no identity a staff member could act on — no name, no
email, nothing to follow up with — so listing every guest session cart would be noise, not a
real admin capability). That reasoning holds for "can I click through to a Customer record,"
but not for the resource's other real use — seeing how many carts are ongoing/abandoned right
now regardless of who's shopping. Most real storefront traffic never reaches an identified
user/customer, so excluding it silently undercounts exactly the thing `ListCarts`'s tabs (and
`CartLifecycleService`, which they and `DetectAbandonedCarts` both build on) exist to report
on. The `Customer`/`User` columns on a guest row just render "—" (Filament's `placeholder()`)
instead of a link — nothing to click into, but the row and its contents are still visible via
`ViewCart`.
This does **not** mirror Shopify's admin (Shopify has no "all carts" view at all — only
"Abandoned checkouts," gated on a shopper reaching checkout and entering contact info, a
later/narrower stage than Lunar's `Cart`).
---
@@ -40,28 +46,29 @@ distinct states together: no order ever started, vs. a draft order exists
different purchase-intent signals (see "Abandoned Cart vs Abandoned Checkout" below) and
different reachability (checkout usually captures an email even for a guest), so
`ListCarts::getTabs()` splits them into four tabs instead of `scopeActive()`'s two-state
split:
split.
- **Ongoing** — `scopeActive()` and recent `updated_at` (within `abandonedCutoff()`). Default
active tab on page load.
- **Abandoned Cart** — `whereDoesntHave('orders')` and stale `updated_at`.
- **Abandoned Checkout** — has an order with `placed_at IS NULL`, and stale `updated_at`.
- **Completed** — has an order with `placed_at IS NOT NULL`.
`Modules\Core\Cart\Services\CartLifecycleService` is the single source of truth for these four
query shapes — both `ListCarts::getTabs()` (staff browsing) and `DetectAbandonedCarts`
(abandonment-event dispatch) build on it, rather than each reimplementing the same split
independently (which is what happened before this service existed, and is exactly the kind of
drift that lets the admin panel and the recovery-email pipeline quietly disagree about what
"abandoned" means):
```php
// Ongoing
$query->active()->where('updated_at', '>', CartResource::abandonedCutoff());
- **Ongoing** (`ongoing()`) — `scopeActive()` and recent `updated_at` (within
`abandonedCutoff()`). Default active tab on page load.
- **Abandoned Cart** (`abandonedCarts()`) — `whereDoesntHave('orders')` and stale
`updated_at`.
- **Abandoned Checkout** (`abandonedCheckouts()`) — has an order with `placed_at IS NULL`,
and stale `updated_at`.
- **Completed** (`completed()`) — has an order with `placed_at IS NOT NULL`.
// Abandoned Cart
$query->whereDoesntHave('orders')->where('updated_at', '<=', CartResource::abandonedCutoff());
// Abandoned Checkout
$query->whereHas('orders', fn ($q) => $q->whereNull('placed_at'))
->where('updated_at', '<=', CartResource::abandonedCutoff());
// Completed
$query->whereHas('orders', fn ($q) => $q->whereNotNull('placed_at'));
```
Each method takes a `Builder` and returns it further scoped, so callers compose it onto
whatever base query they already have (`CartResource::getEloquentQuery()` for the Filament
tabs, a bare `Cart::query()` for the command). Deliberately query-shape-only: consent
(`meta->recovery_consent`) and non-empty-lines filtering stay in `DetectAbandonedCarts`, not on
the service — those gate whether a recovery *event* should fire, not what "abandoned" means to
a staff member browsing the list.
There is deliberately **no "All" tab.** Every row shown is always scoped to one of the four
states above — the list never runs an unfiltered `Cart::query()->get()` over the whole
@@ -111,7 +118,7 @@ runs once per admin page load, not once per cart row.
```php
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->active()->count();
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
}
```
@@ -235,9 +242,10 @@ just upper-cases the code; `Lunar\Managers\DiscountManager::validateCoupon()` (v
via a normal Eloquent write, so there's no model-event hook to dispatch from directly.
`Modules\Core\Cart\Commands\DetectAbandonedCarts` (registered on an hourly schedule by
`Modules\Core\Providers\CartServiceProvider`) is the only place that moment gets detected: it
queries the same two branches `ListCarts::getTabs()` uses (no order at all vs. draft order
never placed) and dispatches `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned`
for anything currently stale.
builds on the same `CartLifecycleService::abandonedCarts()`/`abandonedCheckouts()` queries
`ListCarts::getTabs()` uses (no order at all vs. draft order never placed) and dispatches
`Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned` for anything currently stale
that also has `meta->recovery_consent = true`.
### Cart/Checkout have zero abandonment-related writes — by design
+10 -2
View File
@@ -49,7 +49,8 @@ boboko-test/
app/
Models/
Customer.php ← app-level model, extends Modules\Core\Customer\Models\Customer
User.php ← app-level model, dispatches Modules\Core\Auth\Events\UserCreated
User.php ← app-level model, no $dispatchesEvents needed — core dispatches
UserCreated itself (Modules\Core\Auth\Services\UserOtpService)
Staff.php ← app-level model, extends Modules\Core\Auth\Models\Staff
Lunar/
Extensions/ ← app's own Filament resource extensions (source of truth, wired in PanelServiceProvider)
@@ -264,7 +265,14 @@ php artisan vendor:publish --tag=core-config
'auto_create_customer_for_user' => false,
```
Both listeners guard against the other direction re-triggering: they call `User::withoutEvents(...)` around `firstOrCreate`/save, so pairing a `Customer` never spuriously fires `UserCreated` (and vice versa) even if both directions are somehow active at once.
A guard against the other direction re-triggering is only needed where a real risk exists:
`App\Listeners\CreateUserForCustomerListener` (`boboko-test`, app-level) wraps its
`firstOrCreate` in `User::withoutEvents(...)`, since finding-or-creating a `User` there could
itself fire `UserCreated` and loop back into `CreateCustomerForUser`. `Modules\Core\Customer\
Listeners\CreateCustomerForUser` (core) needs no such guard — it calls a plain
`$model::create([])` on `Customer`, which has no `$dispatchesEvents`/model hooks of its own in
core that could re-trigger anything; the guard belongs only on the side that actually creates a
`User`.
---
+228
View File
@@ -0,0 +1,228 @@
# Payment — Design Notes
**Status: abstraction layer built, drivers/wiring in progress.** `Payment` is designed as a
standalone module: it never calls into `Checkout` or `Order`, never touches their Eloquent
models, and communicates only via events. This document is the design spec for that
abstraction — contracts, DTOs, events — independent of how `Checkout`/`Order` end up consuming
it (that wiring is a separate, later pass).
---
## Operations, not gateways
The driver contracts model the actual operations a payment gateway can perform, not vendor
terminology. Every real gateway checked while designing this converges on the same small set
under different names:
| Operation | Mastercard | Stripe | Nexi |
|---|---|---|---|
| Atomic charge (authorize+capture in one call) | `Pay` | `capture_method: automatic` | `ActionType::PAY()` |
| Hold only, settle/release later | `Authorize` | `capture_method: manual` | `ActionType::PREAUTH()` |
| Settle a prior hold | `Capture` | `PaymentIntent::capture()` | `CaptureRequest`/`CaptureResponse` |
| Release a prior hold without settling | `Void`/`Cancel` | `PaymentIntent::cancel()` | `CancelRequest`/`CancelResponse` |
| Reverse settled funds | `Refund` | `Refund::create()` | (refund endpoint) |
A driver implements only the interfaces its gateway actually supports:
- An offline/cash type (`cash-on-delivery`, `cash-in-hand`) only ever settles atomically —
implements `SupportsPay` alone.
- A card gateway capable of either mode per-transaction (Stripe, most card processors)
implements `SupportsPay`, `SupportsAuthorization`, `SupportsCaptures`, `SupportsVoids`, and
`SupportsRefunds` all at once — which one gets *called* for a given attempt is the caller's
policy choice (e.g. `config('lunar.stripe.policy')`), not something baked into the driver's
shape.
- A redirect/wallet gateway with no separate hold step (Viva/Klarna in typical flows)
implements `SupportsPay` and `SupportsRefunds`, never `SupportsCaptures`/`SupportsVoids`.
### `pay()` and `authorize()` stay separate methods even when a gateway implements both as "the same call with a flag"
Stripe has no separate `authorize`/`pay` API endpoints — one `PaymentIntent`, confirmed with
either `capture_method: automatic` or `manual`. Mastercard and Nexi *do* have genuinely
separate operations. The contract abstracts over both shapes uniformly: every driver capable
of both exposes two distinct methods, `pay()` and `authorize()`. A Mastercard-style driver
calls two different endpoints under the hood; a Stripe-style driver calls the same endpoint
twice with a different flag each time. Neither difference is visible to a caller.
### `capture()`/`void()` are only ever valid against a prior `authorize()`
They are not standalone operations — `capture()` settles a specific hold identified by the
`reference` `authorize()` returned; `void()` releases that same hold instead. A driver that
never implements `SupportsAuthorization` never produces a reference either of these methods
could act on.
---
## `PaymentResult` — the one return shape, every operation, every driver
```php
enum PaymentResultStatus { case Succeeded; case Failed; case Pending; }
final class PaymentResult {
public function __construct(
public readonly PaymentResultStatus $status,
public readonly string $reference,
public readonly int $amount,
public readonly ?string $failureReason = null,
public readonly bool $retriable = false,
public readonly array $raw = [],
public readonly array $meta = [],
) {}
}
```
Real gateway responses vary wildly in richness — confirmed by reading three SDKs directly:
- **Stripe's `PaymentIntent`** is rich: `status`, `amount`, `amount_capturable`,
`amount_received`, `last_payment_error`, a full `getLastResponse()`.
- **Nexi's `CaptureResponse`/`CancelResponse`** are minimal: just `operationId` +
`operationTime` — no echoed amount or status at all. Success is inferred from getting a
response rather than an SDK exception.
- **Mastercard's** gateway sits in between, with `gatewayCode`/`acquirerCode`/
`merchantAdviceCode`.
`PaymentResult` only requires what every driver can always know: `status`, `reference`,
`amount` (the amount **we** requested — not necessarily echoed back by a sparse gateway like
Nexi's capture). Everything else is best-effort: `failureReason`/`retriable` are normalized
only when the gateway has something to normalize from; `raw` is the unconditional escape
hatch — the untouched gateway response body, always populated, for genuine audit fidelity
regardless of how sparse the normalized fields ended up.
### `retriable` — real on some gateways, absent on others
Stripe classifies declines as soft (`do_not_honor`, `insufficient_funds` — worth retrying,
after a delay) vs. hard (`stolen_card`, `expired_card` — never retry the same method).
Mastercard has the equivalent via `authorizationResponse.merchantAdviceCode` and card-scheme
soft-decline codes. **Nexi has no such signal at all** — `OperationResult` is just
`DECLINED`/`DENIED_BY_RISK`/`FAILED`/etc. with no retriability classification. `retriable`
therefore defaults to `false` (assume not safely retriable) rather than guessing when a
driver's gateway has nothing to base it on.
---
## Events — one terminal pair per operation, keyed to the business fact, not the call path
`Modules\Core\Payment\Events`:
| Event pair | Dispatched by |
|---|---|
| `PaymentAuthorized` / `PaymentAuthorizationFailed` | `SupportsAuthorization::authorize()`, or a later `HandlesPaymentCallback::handleCallback()` resolving it |
| `PaymentCaptured` / `PaymentCaptureFailed` | `SupportsPay::pay()` **or** `SupportsCaptures::capture()` |
| `PaymentVoided` / `PaymentVoidFailed` | `SupportsVoids::void()` |
| `PaymentRefunded` / `PaymentRefundFailed` | `SupportsRefunds::refund()` |
`PaymentCaptured` is deliberately the *same* event whether money was taken via `pay()` (one
gateway call) or `authorize()` → `capture()` (two calls) — "a payment has been captured" is
the same business fact either way, and a listener reacting to it never needs to know which
path produced it. There is no separate "payment succeeded" wrapper event distinct from
`PaymentCaptured`.
Every event carries `{type: string, result: PaymentResult, context: array}`. `Payment` has no
concept of a `Cart`, an `Order`, or a checkout fingerprint — `$context` is an opaque bag the
caller hands in on the way down (`pay($type, $data, $context)`) and gets back untouched on
whichever event that call (or a later `handleCallback()`) produces. Each listener interprets
`$context` on its own terms, or ignores the event if the keys it needs aren't present —
`Checkout` is only one possible consumer of these events, not the only one.
---
## Async resolution — `HandlesPaymentCallback`
Only implemented by a driver whose `pay()`/`authorize()` can return `PaymentResultStatus::Pending`
— a redirect the shopper completes elsewhere, a webhook that arrives later. A driver whose
gateway always resolves synchronously never implements this.
```php
public function handleCallback(string $reference, array $data, array $context = []): PaymentResult;
```
Resolves into the *same* event pair the original `pay()`/`authorize()` call would have
produced had it resolved synchronously.
### The correlation problem: `handleCallback()` runs in a different request
`$context` passed into the original `pay()`/`authorize()` call does not survive to
`handleCallback()` on its own — that call is typically a separate HTTP request (a webhook)
with no memory of the request that started the payment. Something has to persist enough to
answer "which order/cart does gateway reference X belong to?" between the two calls.
The precedent for this originally came from reading `lunarphp/stripe`'s own source
(`StripePaymentType::authorize()`, `ProcessStripeWebhook`, `WebhookController`) — that package
solved this the same way, writing the correlating ids as real, typed columns on its own
`StripePaymentIntent` model rather than a generic opaque blob. **`lunarphp/stripe` has since
been removed from this project** in favour of depending on `stripe/stripe-php` directly (see
CHANGELOG.md) — `Modules\Core\Payment\Models\StripePaymentIntent` is now a first-party model
over the same table shape, kept for exactly the same reason.
**`StripePaymentDriver` follows this pattern**: it reads `cart_id`/`order_id` out of `$context`
at `pay()`/`authorize()` time and writes them onto its own `StripePaymentIntent` row (`src/
Payment/Models/StripePaymentIntent.php`, table `stripe_payment_intents`), then reads them back
the same way in `handleCallback()`. No generic `context` json column beyond what that table
already carries (`context`, added for a different purpose — see that migration's own
docblock), no new table.
### This pattern is per-driver, not a shared table
`stripe_payment_intents` is Stripe-specific — keyed on `intent_id`, typed around
`Stripe\PaymentIntent`'s own status values. It cannot be reused as-is for a future non-Stripe
async driver (Nexi, Viva): that driver's own gateway reference has a different shape entirely,
and shoehorning it into Stripe-named columns would make the table misleading. The **pattern**
generalizes — *any* driver needing async callback resolution owns a small table keyed by its
own gateway's reference, storing whatever correlation data that driver specifically needs —
but each driver gets its own table, matching what it actually needs to correlate, rather than
a shared generic one.
---
## Reconciliation — a charge that succeeds on Stripe but is never written locally
This app never creates or reuses a Stripe **Customer** object — every PaymentIntent is a
one-off (`StripePaymentDriver::createAndConfirm()`'s own `$params` never includes a `customer`
key), and nothing calls Stripe's Customer API anywhere in this codebase. That's a deliberate
choice, not an oversight: a Customer object only earns its keep if something actually needs it
(saved/reusable payment methods, subscriptions, Stripe-side lifetime-value grouping across
orders) — none of which exist in this checkout flow today. Creating one anyway would just be
more PII sitting on a third party's servers for no functional benefit, and it would become
another cross-reference a future Payment privacy provider has to account for (detaching/
deleting the Customer on erasure, not just the local PaymentIntent row). If a real feature
needs it later (e.g. "save my card"), add it then, scoped to that feature.
The gap this creates: with no Customer object and no other identifying field previously sent
to Stripe, a PaymentIntent that succeeds on Stripe's side but is never written to our own DB
(e.g. a database outage at exactly the wrong moment, between Stripe confirming the charge and
`rememberIntent()`'s insert) would be **untraceable** back to a cart or order — nothing to
search Stripe's dashboard by except amount, timestamp, and card last-4.
**Fix**: `createAndConfirm()` now sets `metadata: ['cart_id' => ..., 'order_id' => ...]`
(`array_filter()`-ed, since `order_id` isn't known yet at initial `pay()`/`authorize()` time —
same null-coalesce `rememberIntent()` already does) on every PaymentIntent. This is metadata
only, visible on Stripe's own dashboard/API for manual reconciliation — it does not create a
Customer object and does not change anything about how `handleCallback()`/webhook correlation
works (that still goes through `stripe_payment_intents`, per "Async resolution" above). It's
purely a recovery aid for the case where our own write never happened at all.
---
## GDPR erasure/export
`Modules\Core\Payment\Privacy\PaymentDataProvider` covers `lunar_transactions`
(`card_type`/`last_four`) and `stripe_payment_intents` — see `docs/privacy.md` for the full
right-of-erasure/right-of-access design. Pseudonymizes card metadata on erasure (same
tax/accounting retention reasoning `Order`'s own provider uses) and deletes the Stripe
correlation rows outright, since their only purpose — resolving an async webhook callback, see
"Async resolution" above — has already been served by the time an erasure request runs. No
Stripe Customer object exists anywhere in this app (see "Reconciliation" above) for this
provider to also request deletion of.
---
## Explicitly out of scope for this pass
- **`Checkout`/`Order` wiring** — how `Checkout` calls into `Payment`, how `Order`/`Checkout`
react to `Payment`'s events, where a draft `Order` gets created relative to when `Payment` is
called. Deliberately designed and built separately, after `Payment` itself was complete —
`Payment` must stand on its own regardless of what ends up consuming it.
- **`Transaction` persistence** — Lunar's own `transactions` table (`type`: `intent`/`capture`/
`refund`, `parent_transaction_id` chaining) already models the audit trail these events
would feed, once a listener is built to write to it. `Payment` itself does not write
`Transaction` rows — see the events table above; that is a listener's job, in whichever
module ends up owning the write (likely `Order`, since `Transaction.order_id` is required).
+417
View File
@@ -0,0 +1,417 @@
# Privacy / GDPR Data-Subject Requests
`Modules\Core\Privacy` implements the right of access (export) and right of erasure for
customers, as an extensible contract rather than a fixed list of tables — any module (core,
or a future ERP/banking/etc. module) can register its own data without core knowing it exists.
---
## User-scope vs Customer-scope — two genuinely different operations
A Lunar `Customer` (business account: orders, addresses, buyer record) and a `User` (individual
login identity) are linked many-to-many via the `customer_user` pivot (see `docs/modules.md`
"Customer/User Pairing") — **one User can belong to many Customer accounts, and one Customer
account can have many linked Users.** This is the real shape of B2B multi-seat access: a person
can have login access to several separate business accounts, and a business account can have
several employees each with their own login.
That means "delete my personal data" and "delete this business account" are not the same request,
and conflating them is actively wrong:
- **Erasing a Customer must never touch any linked User's login or identity.** Erasing "Acme
Corp" must not deactivate or destroy access for the employees who work there — and must not
touch any *other* Customer account, even one sharing some of the same Users.
- **Erasing a User must never touch any Customer account's own data.** John asking to delete
*his* account must clear his name/email/login wherever it appears — and correctly end his
membership on every Customer he's linked to (detach the pivot) — but must not erase Acme Corp's
orders or addresses, and must not affect any other employee still linked to Acme Corp.
Every part of this module is split along that line — a `PersonalDataProvider`, a `PrivacyService`
method, a request record — is always explicitly **for a Customer** or **for a User**, never both
at once, and never one with an implicit cascade into the other.
---
## Why an extensible contract, not a hardcoded script
A GDPR erasure/export request has to touch every module that holds personal data, but core can't
know in advance what future modules will exist or what data they'll hold — and different data
needs fundamentally different handling (freely erasable PII vs. financial records that must be
pseudonymized-not-deleted for legal retention vs. data that must be retained outright). There's
deliberately no central taxonomy for this in the contract — each module owns its own retention
judgment, since only the module that owns a table actually knows its legal requirements.
`Modules\Core\Privacy\Contracts\PersonalDataProvider` is the whole contract:
```php
interface PersonalDataProvider
{
public function name(): string;
public function exportForUser(UserSubject $subject): ProviderExportResult;
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult;
public function eraseForUser(UserSubject $subject): ProviderErasureResult;
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult;
}
```
Every provider implements all four methods. A provider with nothing relevant to one scope
implements that method as a no-op — `ErasureOutcome::Skipped` with a reason for erase, an empty
payload for export (e.g. `AddressDataProvider::eraseForUser()`, since addresses belong to a
Customer, not an individual).
A provider implementation lives inside the module that owns the data it erases/exports, under
that module's own `Privacy/` subdirectory (e.g. `Modules\Core\Order\Privacy\OrderDataProvider`,
`Modules\Core\Customer\Privacy\CustomerDataProvider`) — never inside `Modules\Core\Privacy`
itself, which only owns the shared contract (`Contracts\PersonalDataProvider`), the request
lifecycle (`Services\PrivacyManager`/`PrivacyService`), and the DTOs/enums every provider
returns. This mirrors how this codebase already handles other cross-cutting-but-domain-specific
code (e.g. a resource's own `Filament/Extensions/` subdirectory) — and matters concretely if a
module is ever extracted into its own composer package (see `docs/modules.md`): the provider
that knows how to erase that module's data must travel with it, not get stranded in `Privacy`
depending on a package that no longer ships in this repo.
A module registers by adding its provider class to `config('core.privacy.providers')` — the
same shape as Lunar's own `config('lunar.search.indexers')` model→indexer map:
```php
// config/core.php
'privacy' => [
'providers' => [
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
\Modules\Core\Order\Privacy\OrderDataProvider::class,
\Modules\Core\Cart\Privacy\CartDataProvider::class,
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
// A future module just adds its own provider here.
],
],
```
`PrivacyManager` resolves each class via the container and asserts every `name()` is unique —
two providers registering the same name throws, so a naming collision fails loudly at
resolution time rather than silently overwriting one provider's data in an export/report.
---
## `UserSubject` and `CustomerSubject` — identifying "the person" vs "the account"
Two separate value objects, not one — each deliberately carries only what its own scope needs, so
a provider can't accidentally reach across the boundary:
```php
class CustomerSubject
{
public readonly int $customerId;
// No userIds, no email — Customer-scope has no business knowing about logins.
}
class UserSubject
{
public readonly int $userId;
public readonly ?string $email;
// No customerId — one User can be linked to many Customers; a provider that
// needs to know which ones looks that up itself (e.g. to detach the pivot),
// rather than this value object assuming or privileging any single one.
}
```
`CustomerSubject::forCustomer(Customer $customer)` and `UserSubject::forUser($user)` build one
from the record staff (or the person themselves) look up.
---
## Providers shipped in core
| Provider | `name()` | Lives in | Covers | Customer-scope | User-scope |
|---|---|---|---|---|---|
| `ActivityLogDataProvider` | `activity_log` | `Modules\Core\Logging\Privacy` | `activity_log` (Spatie) for subject types `Customer`/`Address`/`CartAddress`/`OrderAddress`/`Transaction` | **Pseudonymized** — `properties` redacted, who/what/when metadata kept | Skipped — `causer_id` is an actor reference, not PII content; see below |
| `CustomerDataProvider` | `customer` | `Modules\Core\Customer\Privacy` | `lunar_customers`, and separately the `User`'s own name/email/OTP fields | Erases the account's own fields only | Erases that User's name/email/OTP fields only, and detaches them from every linked Customer |
| `AddressDataProvider` | `addresses` | `Modules\Core\Customer\Privacy` | `lunar_addresses` | Erased (deleted outright) | Skipped — belongs to a Customer, not an individual |
| `OrderDataProvider` | `orders` | `Modules\Core\Order\Privacy` | `lunar_orders`, `lunar_order_addresses`, and their `meta` (`terms_accepted*`, `payment_method`, `box_now_locker`) | **Pseudonymized, not erased** — see below | Skipped — belongs to a Customer, not an individual |
| `CartDataProvider` | `carts` | `Modules\Core\Cart\Privacy` | `lunar_cart_addresses`, and `lunar_carts.meta` (`recovery_consent*`, `payment_method`, `checkout_fingerprint`) | Erased | Skipped — belongs to a Customer, not an individual |
| `ReviewDataProvider` | `reviews` | `Modules\Core\Review\Privacy` | `product_reviews` | Skipped — authored by an individual, not a business account | Pseudonymized by matching `reviewer_email`; rating/title/body text kept |
| `PaymentDataProvider` | `payments` | `Modules\Core\Payment\Privacy` | `lunar_transactions` (`card_type`/`last_four`), `stripe_payment_intents` | **Pseudonymized** — card metadata cleared, correlation rows deleted, amounts/statuses kept | Skipped — belongs to Customer-owned orders, not individual users |
| `UserSessionDataProvider` | `sessions` | `Modules\Core\Auth\Privacy` | `user_sessions` (`ip_address`, `user_agent`) | Skipped — belongs to an individual User, not a business account | Erased (deleted outright) |
`CustomerDataProvider` is the one provider that implements both scopes meaningfully, and keeps
them from touching each other — see the class docblock for the full reasoning.
### `activity_log` is redacted by subject, never by causer
`Modules\Core\Logging\ActivityLogService` (plus several Lunar models' own native `use
LogsActivity` — `Customer`, `CartAddress`, `OrderAddress`, `Transaction`) durably retains a full
snapshot of whatever it logged in `properties`, completely independent of the real row it
describes — erasing/pseudonymizing a `Customer`/`Address`/`Order`/etc. elsewhere does nothing to
this table on its own. `ActivityLogDataProvider::eraseForCustomer()` redacts `properties` on
every row whose **subject** (not causer) resolves back to that customer, across all five
PII-bearing subject types.
It deliberately never touches `causer_id` — the causer is "who performed this action," not PII
content, and erasing it would defeat the audit trail's own purpose. `eraseForUser()` is
therefore a no-op: a `User` appears in this table only as a causer, never as subject content, so
there's nothing to redact from the User side alone.
**Ordering dependency**: `ActivityLogDataProvider` must run *before* `AddressDataProvider` in
`config('core.privacy.providers')` — it resolves which `activity_log` rows are keyed by an
`Address` id while those Address rows still exist; `AddressDataProvider` then hard-deletes them.
Reversing the order would make matching those rows impossible once the addresses are gone.
**`ReviewDataProvider` needs review.** It moved from Customer-scope to User-scope on the
reasoning that authorship is a personal attribute, not a business-account attribute — but this
hasn't been fully validated against how reviews are actually attributed in this codebase. The
class carries a `NEEDS REVIEW` note; revisit before relying on it for a real request.
### Orders are pseudonymized, not deleted
GDPR Art. 17(3)(b) explicitly allows retaining data an erasure request would otherwise cover,
when a legal obligation requires it — tax/accounting law generally requires invoices be kept for
several years. `OrderDataProvider::eraseForCustomer()` clears the free-text PII fields on `Order`/
`OrderAddress` (`customer_reference`, `notes`, name/address/contact fields) but leaves the order
row, totals, line items, and tax data fully intact. Its `ProviderErasureResult` reports
`ErasureOutcome::Pseudonymized`, not `Erased` — a compliance report or admin UI can see exactly
why an order wasn't deleted without reading `OrderDataProvider`'s source.
### Reviews are matched by email — a real, documented limitation
`ProductReview` has no FK to Customer/User at all (see `docs/product-listing.md` "Reviews") —
it's deliberately anonymous, just free-text `reviewer_name`/`reviewer_email`. `ReviewDataProvider`
matches by `reviewer_email` against `UserSubject::$email`; a review submitted under a different
email than the one on file simply won't be found. There's no stronger signal available without
changing `ProductReview`'s schema.
### Staff/employee data is out of scope
`Staff` (admin/panel employees) is never a `UserSubject`/`CustomerSubject` at all — this feature
is scoped to customer-initiated and staff-initiated-on-a-customer's-behalf requests. An employee's
own data (a different HR/access-management concern) isn't reachable through this flow.
---
## Erasure isn't immediate — a cancellable grace period
`PrivacyService` has parallel methods for each scope: `requestErasureForCustomer()` /
`requestErasureForUser()`. Neither erases anything immediately. Each opens a `DataErasureRequest`
(`pending`, `scheduled_for` = now + `config('core.privacy.grace_period_days')`, default 30). This
mirrors Shopify's own account-deletion flow: a window where the subject can change their mind
before anything is actually erased.
**Only the User-scoped request deactivates a login.** `requestErasureForCustomer()` deactivates
no one — a business-account erasure must never block anyone's access.
`requestErasureForUser()` deactivates that one User's login (blocks it — see
`Modules\Core\Auth\Services\UserOtpService` — nothing else changes).
```php
use Modules\Core\Privacy\Services\PrivacyService;
$service = app(PrivacyService::class);
// Customer-scoped: either the Customer itself (self-service) or a Staff member.
$request = $service->requestErasureForCustomer($customer, $requestedBy);
// User-scoped: either the User itself (self-service) or a Staff member.
$request = $service->requestErasureForUser($user, $requestedBy);
// Cancel before scheduled_for — for a User-scoped request, reactivates the
// account. A Customer-scoped request never deactivated anything, so there's
// nothing to reactivate for it.
$service->cancelErasure($request);
```
### Logging back in during the grace period cancels the request automatically
Authentication is never blocked by deactivation — `UserOtpService::validate()` still requires
the correct OTP code. Once validated, it dispatches `Modules\Core\Auth\Events\UserAuthenticated`;
`Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener` (registered in
`PrivacyServiceProvider`, **queued** — see below) looks for a pending request keyed on *that
User's own id* — never a Customer-scoped one, since Customer-scope never deactivates a login in
the first place — and calls `cancelErasure()` on it, then reverts every Customer erasure request
it caused (see "The sole-owner cascade" below). Logging back in **is** the "I changed my mind"
action — no separate UI/flow needed for reactivation.
This listener is queued rather than synchronous, so login returns to the browser without waiting
on the bookkeeping. Nothing else in this codebase currently reads `deactivated_at` besides this
listener and `PrivacyService` itself — `UserOtpService::validate()` never gates the login on it —
so the brief window between the login response and the job actually running has no other consumer
to observe it as stale.
### The sole-owner cascade — erasing the last User on a Customer also erases the Customer
If a User is erased and they were the **only** User linked to a given Customer, that Customer's
data (orders, addresses, buyer record) becomes permanently unreachable through any login the
moment the User's identity is gone — nobody could ever again log in to exercise a data-subject
right over it. GDPR's data minimization principle (Art. 5(1)(c)) means it shouldn't just sit
there indefinitely with no legitimate purpose.
`requestErasureForUser()` and `requestImmediateErasureForUser()` both fire
`Modules\Core\Privacy\Events\UserErasureRequested` right after the request is created (and, for
the immediate path, before `completeErasure()` runs — see below).
`Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener` (**queued**, registered in
`PrivacyServiceProvider`) handles it: for every Customer the User is linked to, if that User is
currently the *sole* linked User (count is 1, and that one User is this one — not just count ===
1, to be explicit rather than relying on an assumption), it opens a second, independent
grace-period request via `requestErasureForCustomer($customer, $user, causedByRequestId: ...)`.
Both requests then run through their own separate 30-day windows.
```
User erasure requested
│
▼
UserErasureRequested event ──▶ CascadeCustomerErasureListener (queued)
│
▼
for each linked Customer: sole owner?
│ yes
▼
requestErasureForCustomer(..., causedByRequestId: <user request id>)
```
**Tracing the cascade — `caused_by_request_id`.** A cascade-created Customer request's
`caused_by_request_id` points back at the User request that triggered it. This is what lets
`CancelErasureOnLoginListener` revert *exactly* the cascade a User's own cancellation should
undo (via `DataErasureRequest::caused()`) without ever touching an unrelated, independently
staff-requested Customer erasure the User happens to still be linked to.
**Why this is queued, not synchronous.** `CascadeCustomerErasureListener` runs as an independent,
separately-retryable job rather than inline inside `requestErasureForUser()` — a failure in the
cascade check never rolls back or blocks the User's own request, and there's no
`DB::transaction()` wrapping needed, since the two writes (the User's request, and any cascaded
Customer request) aren't required to be atomic with each other.
**A known, accepted race on the immediate-erasure path only.** Because the listener is queued,
Eloquent re-fetches its models fresh when the job actually runs (see
`Illuminate\Queue\SerializesModels`) — so `$event->request->subject->customers` reflects the
*real* state at execution time, not a stale snapshot from dispatch time. For
`requestImmediateErasureForUser()`, that job may run before or after `completeErasure()` detaches
the User's memberships in the same call. If the detach happens first, the User is simply no
longer linked to anything by the time the cascade job runs, and nothing cascades — an accepted
race for that rare, staff-only path (see "Immediate erasure" below), not a concern for the
everyday `requestErasureForUser()` grace-period path, where nothing detaches until its own later,
separate `completeErasure()` run — well after the cascade job has had time to fire.
### Processing due requests — one job per request
`php artisan boboko:privacy:process-erasure-requests` finds every `pending` request whose
`scheduled_for` has passed and dispatches one `Modules\Core\Privacy\Jobs\EraseDataSubjectJob` per
request — it does not run `completeErasure()` inline itself. Each job independently calls
`PrivacyService::completeErasure()`, which checks the request's polymorphic `subject` and calls
either every registered provider's `eraseForCustomer()` or `eraseForUser()`, writing the full
per-provider outcome onto the request's `report` column and marking it `completed`. One job per
request means one request's failure (a provider throwing, a DB error) doesn't block or crash
processing of the others, and Laravel's normal per-job retry/failure handling applies to each
request independently. This package doesn't register a schedule itself; each consuming app wires
the command into its own scheduler (daily is reasonable), the same way it owns any other
scheduled task.
### Immediate erasure — staff-only, not self-service
`requestImmediateErasureForCustomer(Customer $customer, Staff $requestedBy): ErasureReport` and
`requestImmediateErasureForUser($user, Staff $requestedBy): ErasureReport` bypass the grace
period entirely and erase right away. Both are `Staff`-only **by type**, not just by convention —
their signatures take `Staff $requestedBy` specifically (not the union type the grace-period
methods accept), so a self-service/customer-facing code path can't reach either one even by
accident; calling with a `Customer`/`User` actor is a compile-time type error, not a runtime
check to remember.
This exists for a formal legal request or regulator inquiry that genuinely requires immediate
action, not as a convenience for an impatient customer. GDPR Art. 17 requires erasure "without
undue delay," but doesn't set a maximum number of days for a grace period, and a short, disclosed,
cancellable hold before executing a self-service request is a widely-used, generally accepted
pattern (the same one Shopify and most major platforms use) — it is **not** offered as a
same-click alternative on the self-service deletion flow, since doing so would mostly defeat the
grace period's purpose (protecting an impulsive requester from themselves). If a subject
explicitly insists on immediate deletion, that's a staff/support decision to make on the record
via one of these methods, not a checkbox exposed to every customer.
```php
$report = $service->requestImmediateErasureForCustomer($customer, $staffMember);
$report = $service->requestImmediateErasureForUser($user, $staffMember);
// Both run synchronously — no queueing, no grace period. $report is the same
// ErasureReport completeErasure() would produce.
```
---
## Export — queued, not synchronous
Export gathers real data across every registered provider — potentially slow, and there's no
reason to block whatever request triggered it (a customer clicking "export my data," an API
call). `requestExportForCustomer()`/`requestExportForUser()` are fast synchronous calls that only
create a `DataExportRequest` row and dispatch the actual work:
```php
$request = $service->requestExportForCustomer($customer);
$request = $service->requestExportForUser($user);
// $request->status is 'pending'; nothing has been gathered yet.
```
### The event chain
1. **`ExportDataSubjectJob`** (queued) checks the request's polymorphic `subject` and calls every
registered provider's `exportForCustomer()` or `exportForUser()` — all sequentially, in this
one job, not fanned out into one job per provider. Per-subject export work is small (a handful
of indexed queries per provider), so there's no real parallelism win, and one job means
"finished" is just "`handle()` returned," with no `Bus::batch()`/completion-counting needed. If
a future provider ever does something genuinely slow (an external API call, a generated PDF),
that's the point to reconsider a per-provider batch — not before.
2. Once every provider's data is gathered, the job fires **`PersonalDataGathered`**
(carries the request and the assembled `ExportReport`) — no file exists yet.
3. **`Modules\Core\Privacy\Listeners\WriteExportToCsvListener`** (registered in
`PrivacyServiceProvider`) handles that event: turns each provider's data into its own CSV (via
the generic `Modules\Core\Export\CsvWriter` — see below), zips them together, writes the zip to
`storage/app/exports/privacy/`, and updates the request (`status: completed`, `file_path`).
This is its own listener — not inline in the job — so the export *format* is swappable (an app
could unregister this and register a JSON-only listener instead) without touching how data is
gathered.
4. Once the file exists, that listener fires **`PersonalDataExportFileWritten`**.
5. Core has no opinion on how the subject is told. A consuming app registers its own notification
against `PersonalDataExportFileWritten` via `Modules\Core\Notification\NotificationRegistry` —
the same pattern as `App\Notifications\QuestionnaireResultsSentNotification` listening on
`App\Events\QuestionnaireResultsSent` (see `boboko-test` for a working example). Core
deliberately does not send an email itself.
### CSV shape
Every provider's `data` is either a list of associative arrays (addresses, orders, reviews — each
item becomes a row) or a single associative array (customer — becomes one row). Any nested array
value within a row (e.g. an order's `addresses` sub-array) is JSON-encoded into that one cell
rather than exploded into further columns — a generic, provider-agnostic rule in
`WriteExportToCsvListener`, not something each provider has to think about.
### `Modules\Core\Export\CsvWriter` — a generic, reusable piece
`CsvWriter::write(array $columns, iterable $rows, string $path)` has no knowledge of GDPR,
customers, or Lunar at all — a caller supplies a schema (`CsvColumn[]`, each just a header plus a
closure that pulls that column's value out of one record) and any iterable data source. It's used
here by `WriteExportToCsvListener`, but is equally usable for an unrelated future need — an admin
bulk catalog export, an accounting handoff — by supplying a different schema and row source;
nothing about it is GDPR-specific.
---
## Audit trail
`DataErasureRequest` (`data_erasure_requests`) and `DataExportRequest` (`data_export_requests`)
are the audit records for erasure and export respectively. Both have a polymorphic `subject`
(`subject_type`/`subject_id`, pointing at either a Lunar `Customer` or a `User` — never both) —
`subject_type`/`subject_id`/`email` are stored as a **snapshot**, not looked up live, since the
whole point is for these tables to remain readable after the record they're about has been
erased. `DataErasureRequest::isForCustomer()` tells you which scope a given request is.
`DataErasureRequest.requested_by_type`/`requested_by_id` capture who asked for it (the subject
themselves, self-service; `Staff` acting on their behalf; or, for a cascade-created Customer
request, the User whose erasure caused it — see "The sole-owner cascade") at request time.
`DataErasureRequest.caused_by_request_id` is set only on a cascade-created Customer request,
pointing back at the User request that triggered it; null on every normal, directly-requested
erasure — see `DataErasureRequest::causedBy()`/`::caused()`.
`DataErasureRequest.report` holds the full per-provider outcome once `completeErasure()` runs;
`DataExportRequest.file_path` points at the generated zip once `WriteExportToCsvListener`
finishes.
**Not yet built**: a standalone "leave/remove from a Customer account" action — unlinking a User
from a Customer without any erasure involved (e.g. a teammate leaving a project, or an account
admin removing someone) — is a related but separate, smaller feature, deliberately out of scope
for this module so far. It shares the same pivot-detach primitive `CustomerDataProvider::
eraseForUser()` already uses as part of a full erasure, but as a standalone action it doesn't
exist yet.
+46 -16
View File
@@ -1,8 +1,9 @@
# Product Listing
`Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product
lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the
Meilisearch index rather than the database. One data source for everything this service does.
lookup for a storefront — `list()`, `getById()`, `getBySlug()`, `random()`, `variantSummaries()` —
all reading directly from the Meilisearch index rather than the database. One data source for
everything this service does.
This is separate from `Modules\Core\Catalog\Services\ProductSearchService` (see `product-search.md`), which
handles free-text query search. `ProductService` is for browsing/lookup without a search term.
@@ -28,13 +29,14 @@ use Modules\Core\Catalog\Enums\ProductSort;
$service = app(ProductService::class);
// List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator,
// built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see
// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator.
$products = $service->list(perPage: 24, page: 1);
// One call for everything a listing page needs — products AND the price slider's
// bounds together, as a Modules\Core\Catalog\DTOs\ProductListingResult. A caller
// used to have to call list() and priceSliderBounds() (or the older priceRange())
// separately and glue the results together itself; that's now list()'s own job.
$listing = $service->list(perPage: 24, page: 1);
// Filter by collection, brand, price range, and/or stock
$products = $service->list(
$listing = $service->list(
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true),
perPage: 24,
page: 1,
@@ -42,8 +44,13 @@ $products = $service->list(
// Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default
// relevance ordering (irrelevant here since the query is always empty).
$products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc);
$listing = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc);
$products = $listing->products; // a real Illuminate\Pagination\LengthAwarePaginator,
// built from the localized Meilisearch hits (not Scout's
// own paginateRaw() result — see "Meilisearch driver
// quirk" below), so it behaves like any other Laravel
// paginator.
$products->items(); // array of Meilisearch documents (plain arrays, not models)
$products->total();
$products->perPage();
@@ -51,12 +58,32 @@ $products->currentPage();
$products->lastPage();
$products->links(); // in a Blade view — renders pagination links as usual
$bounds = $listing->priceBounds; // Modules\Core\Catalog\DTOs\PriceSliderBounds
$bounds->floor; // ?int — floor() of the matching range's minimum, in whole currency units
$bounds->ceil; // ?int — ceil() of the matching range's maximum
$bounds->filtered; // bool — whether the applied filters' minPrice/maxPrice actually
// narrow the slider below/above these bounds (drives whether a
// "clear filter" control should show)
// Single product, by primary key
$product = $service->getById(367); // array, or null if not found
// Single product, by URL slug (any locale — slugs are indexed across all languages)
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found
// $limit random products — still scoped to the index's own default channel/status
// visibility, unlike Eloquent's Product::inRandomOrder() (which has no notion of
// that filtering at all). Meilisearch has no ORDER BY RANDOM() equivalent, so this
// pulls every matching id only, shuffles in PHP, then fetches the full localized
// documents for just the ids picked — see random()'s own docblock.
$randomProducts = $service->random(13); // array of documents, same shape as list()'s items
// The id/price/image of every variant on a product document — the base price and
// thumbnail a variant picker/swatch list needs, without reaching into
// $product['variants'][n]['prices'][0]/['media'][0] yourself.
$variants = $service->variantSummaries($product);
// [['id' => 1204, 'price' => 19.99, 'image' => 'https://.../thumb.jpg'], ...]
// Facet counts for a sidebar — value => matching product count, scoped to whatever
// $filters is passed. Does NOT exclude the faceted field itself from $filters — see
// facets()'s docblock for why, and how to build a standard "every option's count,
@@ -64,11 +91,13 @@ $product = $service->getBySlug('erotika-mprelok'); // array, or null if not fo
$brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17));
// ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135]
// Min/max price across matching products, for sizing a price-range slider.
// minPrice/maxPrice are ALWAYS excluded from the filter driving this (unlike
// facets(), which doesn't auto-exclude) — the slider's own bounds shouldn't shrink
// to whatever range is currently selected on it. Other filters (collectionId,
// brand, inStockOnly) still apply normally.
// Min/max price across matching products — the raw, unrounded values list() itself
// uses to build priceBounds above. minPrice/maxPrice are ALWAYS excluded from the
// filter driving this (unlike facets(), which doesn't auto-exclude) — the slider's
// own bounds shouldn't shrink to whatever range is currently selected on it. Other
// filters (collectionId, brand, inStockOnly) still apply normally. Pass $query too
// to scope the range to a text search's own matches (see product-search.md) rather
// than the whole catalog.
$range = $service->priceRange(new ProductFilters(collectionId: 17));
// ['min' => 0.0, 'max' => 120.0]
```
@@ -77,8 +106,8 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei
`facets()` only makes sense on discrete-value filterable fields (`brand`, `in_stock`) — a numeric
field like `price` would return one "facet" per exact price, not a usable range bucket. Use
`priceRange()` for `price` instead, which reads Meilisearch's `facetStats` (min/max), a different
feature from `facetDistribution`.
`priceRange()` (or `list()`'s own `priceBounds`) for `price` instead, which reads Meilisearch's
`facetStats` (min/max), a different feature from `facetDistribution`.
---
@@ -106,11 +135,12 @@ needs, listing and detail alike:
| `collections` | `$product->collections` | Array of `{id, name}` — directly assigned collections only, `name` is the translated collection name. Not filterable — see `collection_ids`. |
| `collection_ids` | `$product->collections` + `->ancestors` | Filterable. Flat array of every directly-assigned collection's id, unioned with all of its ancestors' ids. `ProductFilters(collectionId: ...)` filters against this field, not `collections`, since products are typically attached only to leaf collections — a plain direct-match filter would never return anything for a parent/root category page. |
| `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. |
| `skus` | `$product->variants->pluck('sku')` | Filterable. Every variant's `sku`, deduplicated, empty ones dropped. Same "resolve from the index alone" reasoning as `slugs`, for a future SKU-based lookup/filter. |
| `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. |
| `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. |
| `tags` | `$product->tags->pluck('value')` | Display only. |
| `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. |
| `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). |
| `variants` | `$product->variants` | Per variant: `id`, `sku`, `gtin`, `mpn`, `ean`, `stock`, `backorder`, `unit_quantity`, `purchasable`, `shippable`, `tax_ref`, `dimensions` (`length`/`width`/`height`/`weight`/`volume`, each `{value, unit}`), `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (the variant's own images — `ProductVariant::images()`, a separate pivot from the product's own gallery above, populated by `ShopifyExportImporter` from Shopify's `Variant Image` CSV column). |
| `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. |
| `in_stock` | `$model->variants` | Filterable boolean. `true` if ANY variant currently passes `ProductVariant::canBeFulfilledAtQuantity(1)` — Lunar's own purchasability rule (`purchasable === 'always'` ignores stock entirely; `in_stock` checks `stock` alone; anything else checks `stock + backorder`). Only as fresh as the last reindex — see "Stock goes stale" below. |
+155
View File
@@ -0,0 +1,155 @@
# Product Recommendations
`Modules\Core\Catalog\Services\RecommendationService` computes "related products" for a given
product — a same-category pick today, with a random fallback, but built as a configurable chain of
strategies rather than one hardcoded rule. `Modules\Core\Catalog\Services\ProductIndexer` embeds
the result directly into each product's own Meilisearch document, so a product detail page renders
its recommendations with zero extra queries — same reasoning as `collections` (see
`docs/product-listing.md`).
---
## The rule chain
```php
use Modules\Core\Catalog\Services\RecommendationService;
$recommendations = app(RecommendationService::class)->recommend($product, limit: 4);
// Illuminate\Support\Collection<int, Lunar\Models\Product>
```
`recommend()` walks `config('catalog.recommendation_rules')` in order, **topping up** from each
successive rule until `$limit` distinct products are collected or every rule is exhausted — it does
not stop at the first rule that returns *something*. If a product's category only has 3 other
products, `SameCategoryRule` contributes those 3 and `RandomRule` fills the last slot. A rule is
handed the ids already collected (`$exclude`, always including the source product's own id) so it
never wastes its own `$limit` budget re-suggesting something already picked, and the same product
is never returned twice even if two rules would both suggest it.
Default chain (`config/catalog.php`):
```php
'recommendation_rules' => [
SameCategoryRule::class, // other products sharing $product's first collection
RandomRule::class, // universal fallback — always returns something as
// long as the store has more than one product
],
```
A consuming app publishes and edits this config to reorder, add, or remove rules — nothing about
the chain shape is hardcoded in `RecommendationService` itself. A new rule (same tag, best sellers,
"frequently bought together", ...) is a class implementing `Modules\Core\Catalog\Contracts\
RecommendationRule`, added to the array:
```php
interface RecommendationRule
{
/**
* @param array<int> $exclude ids to never return — the source product's own
* id, plus every id an earlier rule in the chain already picked
* @return Collection<int, Product> at most $limit products
*/
public function recommend(Product $product, int $limit, array $exclude): Collection;
}
```
Rules query Eloquent directly (`$product->collections->first()->products()`, `Product::query()`),
not `Modules\Core\Catalog\Services\ProductService` — see "Why not `ProductService`" below.
---
## Why not `ProductService`
Every other read path in `Modules\Core\Catalog` goes through `ProductService`, which reads
Meilisearch and resolves translated fields to whatever locale the *current request* is in (see
`docs/product-listing.md`, "Locale resolution"). Recommendation rules deliberately don't use it:
they run inside `ProductIndexer::toSearchableArray()`, at **index time** — there is no request, no
meaningful "current locale" to resolve against, and Meilisearch itself may be mid-write for the very
product being indexed. Rules return raw `Lunar\Models\Product` models instead; `ProductIndexer`
resolves what it embeds (`name` via `translateAttribute()`, `price` via the indexer's own
`cheapestPrice()`, `image` via its own `mapMedia()`) the same way it already does for the embedded
`collections` field — including that field's same accepted index-time-locale tradeoff (a
recommendation's embedded `name` reflects whatever locale was active when *that* product was last
indexed, not the viewer's current locale).
---
## What's embedded, and why not just an id
`ProductIndexer` embeds full card data per recommendation, not just an id:
```php
$data['recommendations'] = [
['id' => 42, 'name' => 'Espresso Cup', 'price' => 12.5, 'image' => 'https://.../thumb.jpg'],
// ...
];
```
This shape is deliberately exactly what `x-ui.product-card`/`x-product-grid` (3dealer's storefront
components) need — `name`, `price`, `image`, and an `id` the view resolves to a URL itself via
`route('product.show', ['id' => $rec['id']])`. A resolved `href` is **not** embedded: `product.show`
is locale-prefixed (`{locale}/products/{id}`), so a URL baked in at index time would be correct only
for whichever locale happened to be active during that index run — wrong for every other locale.
Building the URL is left to the view, which knows the current request's locale.
`recommendations.id` is marked **filterable** — not for the storefront, but for the reverse-lookup
reindexing below.
---
## Keeping it fresh: `ProductSaved` / `ProductDeleted`
A recommendation is computed once, at index time, and embedded — it does not update itself when the
recommended product later changes name, price, or image, or is deleted. Unlike `Modules\Core\Catalog\
Observers\ProductOptionReindexObserver`'s equivalent problem (which product option value is used by),
there is no Postgres relation for "which products currently recommend product X" — a recommendation
only exists inside Meilisearch. The fix is a reverse Meilisearch filter query, not a database join,
wired through a real event → listener pair (`Modules\Core\Providers\CatalogServiceProvider`):
- `Product::saved()` dispatches `Modules\Core\Catalog\Events\ProductSaved`.
- `Product::deleted()` dispatches `Modules\Core\Catalog\Events\ProductDeleted` — fires for both a
soft delete and a force delete (`Lunar\Models\Product` uses `SoftDeletes`), the same model event
Laravel Scout's own `ModelObserver` hooks to make a deleted product `unsearchable()`.
- `Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct` handles both: it searches the
product index for `recommendations.id = "{id}"`, finds every referencing product, and calls
`->searchable()` on each — which recomputes their `recommendations` field fresh, picking up the
changed name/price/image, or (for a delete) dropping the now-gone product and topping back up to
the configured limit via the rule chain, same as any other reindex.
`->searchable()` dispatches Scout's own reindex job, queued if `SCOUT_QUEUE` is configured — this
listener does no synchronous Meilisearch writing itself.
**Product creation is deliberately not hooked into this.** A brand-new product has no
`recommendations` of its own until Scout's existing create-triggered indexing runs (already correct
— nothing to add). What's *not* immediate is other products picking the new one up as a fresh
recommendation candidate — that happens on their own next natural reindex (a save, or the nightly
full reindex below), the same accepted staleness window `docs/product-listing.md` already documents
for `in_stock`/`price`. A full proactive "who could now recommend this new product" pass was
considered and rejected as unnecessary cost for a cosmetic delay.
---
## Nightly full reindex
`Modules\Core\Providers\CatalogServiceProvider` schedules `lunar:search:index "Lunar\Models\Product"
--refresh` daily at 03:00 — a safety net on top of the event-driven reindexing above, not a
replacement for it. Catches what event-driven reindexing deliberately doesn't cover: a newly-created
product not yet appearing as a recommendation elsewhere, and any other drift already accepted
between reindexes (see `docs/product-listing.md`, "Stock goes stale between orders"). `--refresh`
also re-syncs filterable/sortable index *settings*, not just documents, so a deploy that changed
`ProductIndexer`'s field list self-heals overnight even if `lunar:meilisearch:setup` wasn't run
manually right after that deploy.
---
## Re-syncing after this change
Same as any other `ProductIndexer` field change (see `docs/product-listing.md`):
```bash
php artisan lunar:meilisearch:setup
php artisan lunar:search:index "Lunar\Models\Product" --refresh
```
Restart the queue worker if `SCOUT_QUEUE=true` — see `docs/product-listing.md`'s "Re-syncing after
this change" for why a running worker won't otherwise pick up the new indexer code.
+39 -13
View File
@@ -24,36 +24,62 @@ merges `$builder->options` directly into the search request).
## Usage
```php
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Services\ProductSearchService;
$results = app(ProductSearchService::class)->search('running shoes');
// or an explicit locale, bypassing App::getLocale():
$results = app(ProductSearchService::class)->search('running shoes', 'el');
// Filters/sort apply the exact same semantics ProductService::list() uses for
// collection browsing (same ProductFilterBuilder, same ProductSort) — a shopper
// narrowing a text search by price/brand/stock gets identical filter behavior
// to narrowing a category listing.
$results = app(ProductSearchService::class)->search(
'running shoes',
filters: new ProductFilters(brand: 'Acme', minPrice: 20.0, inStockOnly: true),
sort: ProductSort::PriceAsc,
);
```
Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product` — Scout's
`->get()` hydrates real models from the database after the Meilisearch query, so relations
(`variants`, `brand`, `media`, etc.) are available on the results as normal.
`$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by
`Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers
don't need to pass it explicitly.
There is no `$locale` parameter — see "Field list is dynamic, not hardcoded" below for why
every configured store language is always searched, regardless of the current request locale.
---
## Missing-translation fallback
## Missing-translation fallback, in both directions
If a product was only ever given an English name, `name_el` doesn't exist on that document at
all (Lunar's indexer only writes a `{handle}_{locale}` field for locales actually present in the
attribute's stored data — see `ScoutIndexer::mapSearchableAttributes()`). Searching strictly
against `name_el` would make that product invisible to Greek-locale search, even though it's a
real catalog item.
against the current request's locale field would make that product invisible whenever a shopper's
locale doesn't match the language it happens to be translated into.
To avoid silently hiding incompletely-translated products, `ProductSearchService` targets **both**
the resolved locale's fields **and** the default language's fields
(`Lunar\Models\Language::getDefault()->code`) — e.g. searching in `el` targets `name_el`,
`name_en`, `description_el`, `description_en` together (assuming `en` is the default language).
A product missing an `el` translation still matches via its `en` fields.
`ProductSearchService` avoids this by targeting **every configured store language's fields**
(`Lunar\Models\Language::all()`) on every search, not just the current request locale plus the
store default — e.g. with `el`/`en` configured, every search targets `name_el`, `name_en`,
`description_el`, `description_en` together, regardless of which locale the shopper is browsing
in. This is deliberately not scoped to "current locale + default locale": if the current locale
already equals the default (a single-language store, or a shopper browsing in the default
language), that pairing collapses to one locale and stops catching anything else — always
searching every configured language avoids that gap in both directions, at the cost of a larger
`attributesToSearchOn` list as the store's language count grows.
---
## Variant option values are searched too
Alongside the locale-suffixed attribute fields, every search also targets
`variants.options.value` directly — e.g. a variant named "Κάπτεν Γαμέρικα" on a "Name" option
matches a search for that text, even though it never appears in the product's own name or
description. This isn't one of Lunar's own attributes (`AttributeManifest` has no entry for it),
so it can't be discovered the way `name`/`description` are — it's a structural field of
`Modules\Core\Catalog\Services\ProductIndexer`'s own document shape (see `ProductIndexer::mapVariant()`),
added here directly. Not locale-suffixed — each option value is stored as one already-resolved
string per variant.
---
+450
View File
@@ -0,0 +1,450 @@
<title>Order Feature Survey</title>
<style>
:root {
--paper: #FAFAF7;
--ink: #1C1C1A;
--muted: #6B6B63;
--accent: #2F5D50;
--accent-soft: #E4EDE9;
--good: #3F7A5C;
--good-soft: #E6F0EA;
--warn: #B8863B;
--warn-soft: #F5ECDC;
--miss: #A14B3B;
--miss-soft: #F5E5E0;
--hairline: #E4E2DB;
--card: #FFFFFF;
}
:root:not([data-theme="light"]) {
@media (prefers-color-scheme: dark) {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
}
:root[data-theme="dark"] {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
* { box-sizing: border-box; }
body {
background: var(--paper);
color: var(--ink);
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 15.5px;
line-height: 1.55;
margin: 0;
padding: 4.5rem 1.5rem 6rem;
}
.wrap {
max-width: 780px;
margin: 0 auto;
}
header.page {
margin-bottom: 3.25rem;
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.9rem;
}
h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 560;
font-size: clamp(2.1rem, 4.5vw, 2.65rem);
line-height: 1.08;
letter-spacing: -0.01em;
margin: 0 0 0.9rem;
text-wrap: balance;
}
.dek {
color: var(--muted);
max-width: 60ch;
font-size: 1.02rem;
}
.dek strong {
color: var(--ink);
font-weight: 600;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1.6rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.04em;
padding: 0.28rem 0.6rem;
border-radius: 3px;
}
.chip.have { background: var(--good-soft); color: var(--good); }
.chip.partial { background: var(--warn-soft); color: var(--warn); }
.chip.missing { background: var(--miss-soft); color: var(--miss); }
section.category {
margin-top: 3rem;
}
.cat-head {
display: flex;
align-items: baseline;
gap: 0.85rem;
border-bottom: 1px solid var(--hairline);
padding-bottom: 0.7rem;
margin-bottom: 1.1rem;
}
.cat-num {
font-family: "Fraunces", Georgia, serif;
font-size: 1.05rem;
color: var(--accent);
font-variant-numeric: tabular-nums;
min-width: 1.6rem;
}
.cat-head h2 {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 1.28rem;
margin: 0;
letter-spacing: -0.005em;
}
.cat-note {
color: var(--muted);
font-size: 0.86rem;
margin: 0 0 1.2rem;
max-width: 62ch;
}
.feature {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.3rem 1rem;
padding: 1.05rem 0;
border-bottom: 1px solid var(--hairline);
align-items: start;
}
.feature:last-child { border-bottom: none; }
.f-name {
font-weight: 600;
font-size: 0.98rem;
}
.f-status {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.22rem 0.55rem;
border-radius: 3px;
white-space: nowrap;
height: fit-content;
}
.f-status.have { background: var(--good-soft); color: var(--good); }
.f-status.partial { background: var(--warn-soft); color: var(--warn); }
.f-status.missing { background: var(--miss-soft); color: var(--miss); }
.f-note {
grid-column: 1 / -1;
color: var(--muted);
font-size: 0.87rem;
margin-top: 0.15rem;
max-width: 66ch;
}
.f-note code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.82em;
background: var(--accent-soft);
color: var(--accent);
padding: 0.08em 0.35em;
border-radius: 3px;
}
footer.page {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--hairline);
color: var(--muted);
font-size: 0.82rem;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
footer.page a { color: var(--accent); }
@media (max-width: 560px) {
body { padding: 3rem 1.1rem 4rem; }
.feature { grid-template-columns: 1fr; }
.f-status { justify-self: start; }
}
</style>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<div class="wrap">
<header class="page">
<div class="eyebrow">boboko / order · competitive survey</div>
<h1>What order management elsewhere can do that boboko can't yet</h1>
<p class="dek">
Where the Checkout survey stopped — the instant <code>Order</code> exists — this
one starts. A feature-by-feature pass across Shopify, WooCommerce, PrestaShop, and
(briefly) Magento's post-placement order layer — sourced, not recalled from memory —
checked against what <strong>Lunar's <code>Order</code> model and the
already-shipped Filament <code>ManageOrder</code> page</strong> actually support
today. For deciding what the new <code>Order</code> module needs to own, not a
build order.
</p>
<div class="legend">
<span class="chip have">● have</span>
<span class="chip partial">◐ partial</span>
<span class="chip missing">○ missing</span>
</div>
</header>
<section class="category">
<div class="cat-head">
<span class="cat-num">01</span>
<h2>Status model</h2>
</div>
<p class="cat-note">One field, or several axes — and who's allowed to move it.</p>
<div class="feature">
<div class="f-name">Payment status independent of a single overall status</div>
<span class="f-status partial">partial</span>
<div class="f-note">The data exists — <code>ManageOrder::paymentStatus()</code> derives a real value from <code>transactions()</code>/<code>captureTotal()</code>/<code>refundTotal()</code>/<code>intentTotal()</code> — but it's a computed display value on the admin page, not a stored column or something the rest of the system (mailers, automations) can key off. Shopify and Magento both make payment status a first-class, independently-queryable dimension; here it's derived on the fly, once, in one Filament page.</div>
</div>
<div class="feature">
<div class="f-name">Fulfillment status independent of overall status</div>
<span class="f-status missing">missing</span>
<div class="f-note">No equivalent of <code>paymentStatus()</code> exists for shipment/fulfillment state — <code>Order</code> has no <code>shipments()</code> relation of its own at all; it's added dynamically by <code>Modules\Core\Shipping\Providers\ShippingServiceProvider::resolveRelationUsing()</code>, outside Order's own boundary (see docs/checkout.md, "Where Order would likely absorb work"). Every platform researched (Shopify, Woo, PrestaShop, Magento) treats "has this shipped" as derivable from child records, not a manually-set field — Lunar has the child records (<code>Shipment</code>) but no derived status method reading them.</div>
</div>
<div class="feature">
<div class="f-name">Staff-editable order status with a picker/action</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code> ships a working <code>UpdateStatusAction</code> out of the box, backed by <code>config('lunar.orders.statuses')</code> — a flat, merchant-configured list, each entry carrying a <code>label</code>/<code>color</code>/<code>favourite</code> flag. Closer to WooCommerce's single linear field than Shopify's multi-axis split.</div>
</div>
<div class="feature">
<div class="f-name">Status rows carry behavior (auto-send email, generate invoice, restock)</div>
<span class="f-status partial">partial</span>
<div class="f-note">Each status entry in <code>config('lunar.orders.statuses')</code> already declares <code>mailers</code> and <code>notifications</code> arrays — the PrestaShop-style shape is there in config — but per the Checkout survey's finding, nothing in core actually reads and dispatches from those keys on a transition. The data model for "status carries behavior" exists; the behavior doesn't.</div>
</div>
<div class="feature">
<div class="f-name">Order-status-changed event other code can react to</div>
<span class="f-status missing">missing</span>
<div class="f-note">Same gap the Checkout survey flagged for order creation: no <code>OrderStatusUpdated</code>/equivalent exists anywhere in core. <code>UpdateStatusAction</code> just writes the column. Anything wanting to react to a status change — a confirmation email, a webhook, re-deriving payment/fulfillment status — has to hook the raw Eloquent <code>Order::updated()</code> event and diff <code>status</code> itself.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">02</span>
<h2>Fulfillment &amp; shipment tracking</h2>
</div>
<p class="cat-note">Turning a placed order into a package that moves.</p>
<div class="feature">
<div class="f-name">Shipment as its own record, separate from the order</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Modules\Core\Shipping\Models\Shipment</code> (carrier, tracking reference, label-printed timestamp, manifest reference) already exists and belongs to <code>Order</code>. Built this session, ahead of most gaps in this survey — the record shape is closer to Magento's per-shipment entity than Woo's "no shipment entity at all."</div>
</div>
<div class="feature">
<div class="f-name">Multiple shipments per order (partial/split fulfillment)</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>Shipment</code> has no <code>quantity</code>-per-line or <code>order_line_id</code> concept — it's one shipment record per carrier voucher, with a <code>parent_reference</code> for ACS's own multipart-voucher case (one physical order split into multiple packages by the carrier), not a per-line-item fulfillment split decided by staff. Closer to "multiple packages for one shipment" than Magento's true per-line partial-shipment model.</div>
</div>
<div class="feature">
<div class="f-name">Create-shipment action from the order admin screen</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Modules\Core\Shipping\Extensions\OrderViewExtension</code> adds a working "Create Shipment" header action to <code>ManageOrder</code>, resolving a <code>CarrierFulfillmentInterface</code> by the order's chosen shipping method and calling <code>createShipment()</code> — genuinely wired, not a stub. Currently lives under <code>Shipping</code>, flagged in docs/checkout.md as conceptually an <code>Order</code> concern.</div>
</div>
<div class="feature">
<div class="f-name">Tracking number + carrier surfaced on the order itself</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Shipment.tracking_reference</code>/<code>carrier</code> exist and are populated by <code>createShipment()</code>; <code>PollShipmentTrackingJob</code> (scheduled every 30 minutes) keeps <code>ShipmentInfo</code> checkpoints current via <code>CarrierFulfillmentInterface::trackShipment()</code>. Genuinely ahead of PrestaShop's thin <code>order_carrier.tracking_number</code> field — this has a real checkpoint history, not just one string.</div>
</div>
<div class="feature">
<div class="f-name">"Shipped"/"delivered" status auto-derived from tracking</div>
<span class="f-status missing">missing</span>
<div class="f-note">The tracking checkpoints exist (<code>ShipmentInfo</code>, <code>TrackingStatus</code> enum including <code>Delivered</code>) but nothing writes them back onto <code>Order.status</code> — a delivered shipment doesn't move the order out of whatever status it was already in. Every platform researched treats "delivered" as a status a customer/staff can see on the order, not something buried one relation away.</div>
</div>
<div class="feature">
<div class="f-name">Shipping/delivery notification emails (shipped, out-for-delivery, delivered)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Research: Shopify fires four separate templated notifications across this window alone (shipping confirmation, out-for-delivery, delivered, plus edited-order). None of the pieces exist here — no order-status-changed event (01) to trigger from, and no mailer wired to <code>PollShipmentTrackingJob</code>'s own status updates either.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">03</span>
<h2>Payments: capture, refund, cancellation</h2>
</div>
<p class="cat-note">Money moving back out, and orders that never should have been placed.</p>
<div class="feature">
<div class="f-name">Refund action from the order screen, amount-scoped</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code>'s <code>refund</code> action already exists — picks a transaction, an amount (validated against <code>availableToRefund()</code>), and notes, then calls the driver's own <code>Transaction::refund()</code>. This is genuinely native, matching Woo/Magento's line-item-adjacent (if not line-item-exact) refund UX.</div>
</div>
<div class="feature">
<div class="f-name">Capture action for auth-then-capture payment flows</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code>'s <code>capture</code> action + <code>requiresCapture()</code>/<code>canBeRefunded()</code> guard methods already exist, delegating to <code>Transaction::capture()</code> — this is the Stripe "authorize now, capture later" flow's admin-side half, already built ahead of most gaps here.</div>
</div>
<div class="feature">
<div class="f-name">Refund tied to specific line items (not just a dollar amount)</div>
<span class="f-status missing">missing</span>
<div class="f-note">The refund action takes a transaction + amount, with no line-item selection or restock decision — WooCommerce and Magento both make "which items, how many, restock or not" the primary refund UI; here it's one number against one transaction, closer to a manual adjustment than a structured partial return.</div>
</div>
<div class="feature">
<div class="f-name">Order cancellation as a distinct action (vs. just changing status)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No dedicated "cancel" action exists on <code>ManageOrder</code> — a cancellation today would just be picking a "cancelled"-labeled entry from the generic status dropdown (01), with no automatic refund trigger, no stock-release logic, and no distinction from any other manual status edit.</div>
</div>
<div class="feature">
<div class="f-name">Refund/capture reflected back into an order-level payment status</div>
<span class="f-status partial">partial</span>
<div class="f-note">Same gap as 01's payment-status finding — <code>paymentStatus()</code> recomputes correctly from transactions when the admin page loads, but a refund doesn't push the order into a <code>refunded</code>/<code>partially-refunded</code> overall status the way Shopify's <code>displayFinancialStatus</code> does automatically.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">04</span>
<h2>Returns (RMA)</h2>
</div>
<p class="cat-note">The one area every researched platform treats as optional, not core.</p>
<div class="feature">
<div class="f-name">Return-merchandise-authorization flow (customer requests, staff approves)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No <code>Return</code>/RMA model, status set, or request flow exists anywhere in this codebase. Consistent with the research: Shopify is the only platform of the four with this genuinely native; PrestaShop ships it off-by-default; Magento gates it behind the paid Adobe Commerce tier; WooCommerce lacks it entirely. Safe to treat as a real gap, not an urgent one.</div>
</div>
<div class="feature">
<div class="f-name">Return shipping label generation</div>
<span class="f-status missing">missing</span>
<div class="f-note">Depends entirely on the RMA flow above existing first — <code>CarrierFulfillmentInterface</code> already has the label-printing primitive (<code>printLabel()</code>) a return label would reuse, so the carrier-side plumbing isn't the blocker, the RMA request/approval model is.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">05</span>
<h2>Order editing</h2>
</div>
<p class="cat-note">Changing a placed order — and where every platform draws the line.</p>
<div class="feature">
<div class="f-name">Editing guardrails keyed to fulfillment state</div>
<span class="f-status missing">missing</span>
<div class="f-note">No line-item add/remove exists on a placed order at all today (unlike Shopify/Woo/PrestaShop, which all allow it up to some fulfillment-keyed cutoff, then force a return instead) — so there's no guardrail to speak of yet because there's no editing to guard. Whatever gets built here should key the cutoff to <code>Shipment</code> existing, per the pattern all four researched platforms converge on.</div>
</div>
<div class="feature">
<div class="f-name">Editable shipping/billing address after placement</div>
<span class="f-status missing">missing</span>
<div class="f-note"><code>OrderAddress</code> rows are snapshotted at creation (see Checkout survey, 02) and nothing in <code>ManageOrder</code> exposes editing them afterward — every platform researched treats address edits as lower-risk than line-item edits and allows them more freely; this codebase currently allows neither.</div>
</div>
<div class="feature">
<div class="f-name">Tag editing on a placed order</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code>'s <code>edit_tags</code> action already works — the one piece of native post-placement editing that exists today, via <code>HasTags</code> on the <code>Order</code> model.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">06</span>
<h2>Notes &amp; audit trail</h2>
</div>
<p class="cat-note">The one thing every researched platform treats as non-negotiable.</p>
<div class="feature">
<div class="f-name">Append-only change history (who changed what, when)</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Order</code> already uses Spatie's <code>LogsActivity</code> trait — every save is recorded with a diff, same underlying mechanism already relied on elsewhere in this codebase (staff activity log, translation history). Structurally equivalent to PrestaShop's <code>order_history</code> table, just via a different package.</div>
</div>
<div class="feature">
<div class="f-name">Internal staff notes, separate from system-generated log entries</div>
<span class="f-status missing">missing</span>
<div class="f-note">The activity log above captures field changes automatically, but there's no free-text "leave a note for the next person" field — every platform researched has this as a distinct feed from the automatic history (Woo's Order Notes, Shopify's Timeline comments, Magento's Comments History), usually with a private-vs-customer-visible toggle. Nothing here yet.</div>
</div>
<div class="feature">
<div class="f-name">Customer-visible note-to-customer, sent as a message</div>
<span class="f-status missing">missing</span>
<div class="f-note">Depends on both the internal-notes feature above and a working mailer (01/02) — genuinely blocked on more foundational gaps, not just unbuilt on its own.</div>
</div>
</section>
<footer class="page">
<span>Compiled 2026-09-01 — sources cited inline; <code>vendor/lunarphp/lunar</code> and this codebase's own <code>src/</code> reads are marked by file/class name, Shopify/WooCommerce/PrestaShop/Magento claims are marked "Research."</span>
<span>boboko-core / docs</span>
</footer>
</div>
+3
View File
@@ -2,6 +2,9 @@
Findings from comparing a real Shopify product export CSV against Lunar's schema (`vendor/lunarphp/core`), plus the resulting implementation plan for `MigrateImport\Shopify\ShopifyExportImporter`.
Need to discard everything and re-import from scratch (e.g. after a schema/indexer change that
only applies to newly-created rows)? See `docs/shopify-reimport.md`.
## Idempotency problem
Nothing in Lunar tracks "this record came from external system X, ID Y." Re-running an import with no external-ID tracking would duplicate every product on each run.
+149
View File
@@ -0,0 +1,149 @@
# Wiping products before a clean Shopify re-import
A runbook for discarding every imported product (and everything that hangs off one —
variants, prices, media, reviews, options/values, the Meilisearch documents) and re-running
`ShopifyExportImporter` from scratch. Useful after a schema/indexer change that only applies to
newly-created rows (see "Why a wipe, not an update" below), or when the export CSV itself changed
enough that stale products need to go, not just be updated in place.
Every command below is a `tinker --execute=` one-liner run inside the app container — adjust the
exec prefix (`./bin/dc-core.sh exec app ...`, `docker compose exec app ...`, etc.) for your setup.
---
## Why a wipe, not an update
`ShopifyExportImporter`'s resolvers are mostly `firstOrCreate` — re-running the importer against
an *existing* database updates matched rows but leaves already-created ones exactly as they were.
That's the right behavior for routine re-imports (an updated price, a new variant), but it means a
change to what gets set **at creation time only** — e.g. `ProductOptionResolver` now also setting
`label`, not just `name`, on a `ProductOption` — never reaches a `ProductOption` row that already
exists. A wipe forces every row to go through creation again, picking up such fixes.
---
## 1. Delete every product
Cascades to `ProductVariant`, prices, and Spatie media rows — verified live (see
`shopify-import.md`'s own history/commit log for context). Also removes each product's Meilisearch
document automatically, via Scout's own delete hook fired on `forceDelete()` — no separate
`scout:flush` needed.
```php
\Lunar\Models\Product::withTrashed()->get()->each->forceDelete();
```
**Let this run to completion.** Interrupting it mid-loop (e.g. Ctrl+C on the tinker session) stops
after whichever product it was on, leaving the rest undeleted — safe to just re-run the same
command again afterward, since already-deleted products are simply skipped.
Verify:
```php
\Lunar\Models\Product::withTrashed()->count(); // 0
```
### Requires: `product_reviews.product_id` cascades on delete
`product_reviews` (boboko-core's own table, not Lunar's) originally had no `ON DELETE` clause on
its `product_id` foreign key — deleting a reviewed product threw a constraint violation instead of
the review going with it. Fixed by
`database/migrations/2026_09_03_000001_add_cascade_delete_to_product_reviews_product_id.php`. Make
sure this migration has actually run (`php artisan migrate`) before step 1, or a product with
reviews will fail to delete.
---
## 2. Delete product options and values
Not touched by step 1 (`ProductOption`/`ProductOptionValue` aren't scoped to one product — they're
shared across the catalog, per `ProductOptionResolver::resolveOption()`'s `shared: true`). Safe to
delete in full once every product (and therefore every variant referencing an option value via the
`product_option_value_product_variant` pivot) is gone — deleting values while variants still
reference them throws the same kind of FK violation step 1 guards against.
```php
\Lunar\Models\ProductOptionValue::query()->delete();
\Lunar\Models\ProductOption::query()->delete();
```
Verify:
```php
\Lunar\Models\ProductOption::count(); // 0
\Lunar\Models\ProductOptionValue::count(); // 0
```
---
## 3. Clear the import mappings
Without this, the importer's `ImportMapping::resolve(...)` calls still find the (now-deleted)
mappings' rows absent, so this step is really about not leaving stale mapping rows pointing at
nothing — `ImportMapping` rows aren't foreign-keyed to the models they map (`morphTo`, no
constraint), so leaving them wouldn't break the re-import, but a stale mapping for a product that
no longer exists is dead weight.
```php
\Modules\Core\MigrateImport\Models\ImportMapping::where('source', 'shopify')->delete();
```
Verify:
```php
\Modules\Core\MigrateImport\Models\ImportMapping::where('source', 'shopify')->count(); // 0
```
---
## 4. Re-run the importer
`boboko:migrate:import` dispatches `RunMigrateImportJob` onto the queue — **not synchronous** —
so a queue worker must actually be running (`php artisan queue:work`, or your dev queue container)
or the job just sits queued.
```bash
php artisan boboko:migrate:import --source=shopify --type=export --file=<absolute path to the CSV>
```
The `--file` value must be an **absolute path** inside the container (e.g.
`/var/www/html/storage/app/private/imports/shopify/products_export.csv`) when running
non-interactively — a path relative to `storage/app/private/imports` only resolves correctly when
the command can fall back to its interactive prompt, which isn't available in a scripted/non-TTY
run.
Watch the queue worker's own log output for `FAIL` entries (see `docs/lunar.md` or your compose
setup for how logs are routed to `docker compose logs`) — a clean run shows every
`Laravel\Scout\Jobs\MakeSearchable` / `Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob`
line ending `DONE`, never `FAIL`.
---
## 5. Re-sync Meilisearch and reindex
```bash
php artisan lunar:meilisearch:setup
php artisan lunar:meilisearch:tune-product-search
php artisan lunar:search:index "Lunar\Models\Product" --refresh
```
`--refresh` re-syncs filterable/sortable index settings *and* reindexes every document — it does
not reset `typoTolerance`/`prefixSearch` (confirmed live: both survived a `--refresh` run
unchanged), so `tune-product-search` only needs re-running here for completeness/if it hadn't
already been applied, not because `--refresh` would have clobbered it.
---
## Verifying the result
```php
// Product count should match the CSV's actual unique `Handle` count, not
// whatever the database held before the wipe — those aren't the same number
// if stale/manually-added products existed alongside the CSV-sourced ones.
\Lunar\Models\Product::count();
// Spot-check that at least one variant picked up its own image (see
// shopify-import.md's "Images" section) — 0 is only correct if the CSV
// genuinely has no `Variant Image` values populated.
\Lunar\Models\ProductVariant::has('images')->count();
```
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* Greek translations for Lunar\Models\Country::name, keyed by the exact
* English spelling Lunar's own installer seeds (`lunar:import:address-data`
* fetches http://data.lunarphp.io/countries+states.json — see
* vendor/lunarphp/core/src/Console/Commands/Import/AddressData.php).
* `Country`/`State` have no i18n support of their own (plain string
* columns, no translatable trait) — this is a plain Laravel lang file, not
* Modules\Core\Localization's DB-backed TranslationService, since these
* names are fixed reference data seeded once, not editable UI copy (see
* docs/localization.md). A consuming app's storefront looks this up
* itself, e.g. __('core::countries.'.$country->name) — core has no
* storefront UI of its own to wire this into (see docs/lunar.md).
*
* Only Greece is covered — this store operates within Greece; add further
* countries here as needed.
*/
return [
'Greece' => 'Ελλάδα',
];
+52
View File
@@ -0,0 +1,52 @@
<?php
/**
* Greek translations for Lunar\Models\State::name, keyed by the exact
* English spelling Lunar's own installer seeds for Greece
* (`lunar:import:address-data` — see lang/el/countries.php's own docblock
* for the full explanation of why this is a plain lang file, not
* Modules\Core\Localization's TranslationService).
*
* Covers every Greek state/regional-unit row in Lunar's seed dataset —
* scoped to Greece only, matching this store's operating country.
*/
return [
'Achaea Regional Unit' => 'Περιφερειακή Ενότητα Αχαΐας',
'Aetolia-Acarnania Regional Unit' => 'Περιφερειακή Ενότητα Αιτωλοακαρνανίας',
'Arcadia Prefecture' => 'Νομός Αρκαδίας',
'Argolis Regional Unit' => 'Περιφερειακή Ενότητα Αργολίδας',
'Attica Region' => 'Περιφέρεια Αττικής',
'Boeotia Regional Unit' => 'Περιφερειακή Ενότητα Βοιωτίας',
'Central Greece Region' => 'Περιφέρεια Στερεάς Ελλάδας',
'Central Macedonia' => 'Κεντρική Μακεδονία',
'Chania Regional Unit' => 'Περιφερειακή Ενότητα Χανίων',
'Corfu Prefecture' => 'Νομός Κέρκυρας',
'Corinthia Regional Unit' => 'Περιφερειακή Ενότητα Κορινθίας',
'Crete Region' => 'Περιφέρεια Κρήτης',
'Drama Regional Unit' => 'Περιφερειακή Ενότητα Δράμας',
'East Attica Regional Unit' => 'Περιφερειακή Ενότητα Ανατολικής Αττικής',
'East Macedonia and Thrace' => 'Ανατολική Μακεδονία και Θράκη',
'Epirus Region' => 'Περιφέρεια Ηπείρου',
'Euboea' => 'Εύβοια',
'Grevena Prefecture' => 'Νομός Γρεβενών',
'Imathia Regional Unit' => 'Περιφερειακή Ενότητα Ημαθίας',
'Ioannina Regional Unit' => 'Περιφερειακή Ενότητα Ιωαννίνων',
'Ionian Islands Region' => 'Περιφέρεια Ιονίων Νήσων',
'Karditsa Regional Unit' => 'Περιφερειακή Ενότητα Καρδίτσας',
'Kastoria Regional Unit' => 'Περιφερειακή Ενότητα Καστοριάς',
'Kefalonia Prefecture' => 'Νομός Κεφαλληνίας',
'Kilkis Regional Unit' => 'Περιφερειακή Ενότητα Κιλκίς',
'Kozani Prefecture' => 'Νομός Κοζάνης',
'Laconia' => 'Λακωνία',
'Larissa Prefecture' => 'Νομός Λάρισας',
'Lefkada Regional Unit' => 'Περιφερειακή Ενότητα Λευκάδας',
'Pella Regional Unit' => 'Περιφερειακή Ενότητα Πέλλας',
'Peloponnese Region' => 'Περιφέρεια Πελοποννήσου',
'Phthiotis Prefecture' => 'Νομός Φθιώτιδας',
'Preveza Prefecture' => 'Νομός Πρέβεζας',
'Serres Prefecture' => 'Νομός Σερρών',
'South Aegean' => 'Νότιο Αιγαίο',
'Thessaloniki Regional Unit' => 'Περιφερειακή Ενότητα Θεσσαλονίκης',
'West Greece Region' => 'Περιφέρεια Δυτικής Ελλάδας',
'West Macedonia Region' => 'Περιφέρεια Δυτικής Μακεδονίας',
];
@@ -2,7 +2,7 @@
@if (! $otpSent)
<form wire:submit="requestOtp">
<div class="grid gap-y-4">
<div style="display: flex; flex-direction: column; row-gap: 1rem;">
<x-filament::input.wrapper>
<x-filament::input
type="email"
@@ -24,7 +24,7 @@
</form>
@else
<form wire:submit="authenticate">
<div class="grid gap-y-4">
<div style="display: flex; flex-direction: column; row-gap: 1rem;">
<p class="text-sm text-gray-500">
A login code was sent to <strong>{{ $email }}</strong>.
</p>
+1 -1
View File
@@ -1,5 +1,5 @@
<p>Hi {{ $name }},</p>
<p>Your login code is:</p>
<p>{{ $intro }}</p>
<p style="font-size: 2rem; font-weight: bold; letter-spacing: 0.25rem;">{{ $code }}</p>
@@ -0,0 +1,127 @@
@php
$transaction = $getRecord();
$notes = $transaction->notes ?: ($transaction->meta['notes'] ?? null);
@endphp
@once
@php
$renderPaymentIcons();
@endphp
@endonce
<div
@class([
'text-sm rounded-lg shadow-md border dark:bg-gray-900',
'text-gray-950 dark:text-white',
match($transaction->type){
'refund' => 'border-orange-300',
'intent' => 'border-sky-300',
'capture' => 'border-green-300',
default => 'border-gray-300',
},
'!border-red-500 bg-red-50' => !$transaction->success,
'bg-gray-50' => $transaction->success,
])
>
<div class="p-2 space-y-2">
<div class="px-4 py-2 rounded text-xs bg-white dark:bg-gray-800 shadow text-gray-600 dark:text-gray-400 ring-1 ring-gray-100 dark:ring-gray-700">
<span>{{ $transaction->driver }}</span> //
<span>{{ $transaction->reference }}</span>
</div>
<div class="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded shadow ring-1 ring-gray-100 dark:ring-gray-700">
<div class="flex items-center gap-6">
<div>
<strong class="text-xs">
{{ $transaction->status }}
</strong>
</div>
<div>
<svg viewBox="0 0 50 50" class="w-10">
<use xlink:href="#{{ strtolower($transaction->card_type) }}"></use>
</svg>
</div>
@if($transaction->last_four)
<p class="text-sm">
<span class="inline-block -translate-y-px">
&lowast;&lowast;&lowast;&lowast; &lowast;&lowast;&lowast;&lowast; &lowast;&lowast;&lowast;&lowast;
</span>
<span class="font-medium">
{{ (string) $transaction->last_four }}
</span>
</p>
@endif
</div>
<strong
@class([
"text-sm",
'text-red-500' => !$transaction->success,
match($transaction->type){
'refund' => "text-orange-500",
default => "text-gray-900 dark:text-gray-100",
},
])
>
@if($transaction->type == 'refund')-@endif{{ $transaction->amount->formatted }}
</strong>
</div>
<div class="px-4 py-2 bg-white dark:bg-gray-800 shadow rounded flex items-center justify-between text-gray-600 dark:text-gray-400 ring-1 ring-gray-100 dark:ring-gray-700">
<div class="text-xs flex items-center gap-2">
<div>
<x-filament::icon
icon="heroicon-o-clock"
class="w-4"
/>
</div>
<span>{{ $transaction->created_at->format('jS F Y h:ia') }}</span>
</div>
<div class="flex space-x-2">
@foreach($transaction->paymentChecks() as $check)
<x-filament::badge
:icon="$check->successful ? 'heroicon-m-check' : 'heroicon-m-x-mark'"
:color="$check->successful ? \Filament\Support\Colors\Color::Sky : 'gray'"
>
{{ $check->label }}: {{ $check->message }}
</x-filament::badge>
@endforeach
</div>
</div>
@if($notes)
<div class="px-4 py-2 bg-white dark:bg-gray-800 shadow flex items-center rounded gap-2 ring-1 ring-gray-100 dark:ring-gray-700">
<div>
<x-filament::icon
icon="heroicon-o-chat-bubble-oval-left-ellipsis"
class="w-4"
/>
</div>
<p class="text-sm">{{ $notes }}</p>
</div>
@endif
</div>
<div
@class([
"bottom-0 left-0 block w-full text-center rounded-b-lg border-t text-xs py-1",
"!bg-red-50 !dark:bg-red-400/10 !border-red-300 !text-red-600 !dark:text-red-400" => !$transaction->success,
match($transaction->type){
'refund' => "bg-orange-50 dark:bg-orange-400/10 border-orange-300 text-orange-600 dark:text-orange-400",
'intent' => "bg-sky-50 dark:bg-sky-400/10 border-sky-300 text-sky-600 dark:text-sky-400",
'capture' => "bg-green-50 dark:bg-green-400/10 border-green-300 text-green-600 dark:text-green-400",
default => "bg-gray-50 dark:bg-gray-400/10 border-gray-300 text-gray-600 dark:text-gray-400",
},
])
>
@if(!$transaction->success)
{{ __('lunarpanel::order.transactions.failed') }}
@else
{{ __('lunarpanel::order.transactions.'.$transaction->type) }}
@endif
</div>
</div>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Payment of <strong>{{ $amount }}</strong> for your order <strong>{{ $reference }}</strong> has been captured.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is complete. Thanks for shopping with us!</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Good news — your order <strong>{{ $reference }}</strong> has been delivered.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is on its way.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is ready for pickup in store.</p>
@@ -0,0 +1,11 @@
<p>Hi,</p>
<p>Thanks for your order! Your order <strong>{{ $reference }}</strong> is confirmed.</p>
<ul>
@foreach ($lines as $line)
<li>{{ $line->quantity }} &times; {{ $line->description }} — {{ $line->total?->formatted }}</li>
@endforeach
</ul>
<p>Total: <strong>{{ $total }}</strong></p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>A refund of <strong>{{ $amount }}</strong> has been issued for your order <strong>{{ $reference }}</strong>.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is now: <strong>{{ $statusLabel }}</strong></p>
@@ -1,3 +0,0 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Auth\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Base\LunarUser;
/**
* Dispatched by UserOtpService::validate() on every successful OTP login, not just
* a first-time one. Modules\Core\Privacy listens on this to auto-cancel a pending
* DataErasureRequest — logging back in during the grace period is the "I changed
* my mind" action (see Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener),
* which needs $user->customers to resolve any pending request. Typed as
* Authenticatable&LunarUser rather than plain Authenticatable (unlike the sibling
* UserCreated event) specifically because that listener depends on it — every real
* User in this codebase implements LunarUser (see docs/lunar.md "LunarUser trait"),
* and User is the only Authenticatable entity in this project (Customer is not —
* see docs/modules.md "Customer/User Pairing").
*/
class UserAuthenticated
{
public function __construct(
public readonly Authenticatable&LunarUser $user,
) {}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Auth\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Auth\Services\UserOtpService::generateAndSend()
* when an email has requested too many codes too quickly — caps both
* mail-bombing one inbox and the "just request a fresh code to reset my
* guess count" loophole a per-code attempt cap alone doesn't close.
*/
class OtpThrottledException extends RuntimeException
{
public function __construct(
public readonly int $availableInSeconds,
) {
parent::__construct("Too many code requests. Try again in {$availableInSeconds} second(s).");
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Auth\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Core\Auth\Services\UserSessionService;
use Symfony\Component\HttpFoundation\Response;
/**
* The enforcement half of the session registry — see
* Modules\Core\Auth\Services\UserSessionService's own docblock. Not
* auto-registered anywhere (no routes/kernel wiring exist in this
* package — see Modules\Core\Customer\Services\CustomerAccountService's
* own docblock for why this branch stops at services); a consuming app
* adds this to its `web` middleware group (after `auth`) to actually get
* "logout everywhere" enforcement.
*
* A request with no recorded UserSession at all (see
* UserSessionService::currentSession()'s own docblock) is let through —
* only an EXPLICITLY revoked session is rejected.
*/
class EnsureSessionNotRevoked
{
public function __construct(
private readonly UserSessionService $sessions,
) {}
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return $next($request);
}
$session = $this->sessions->currentSession();
if ($session && $session->isRevoked()) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
abort(401, 'Your session has been revoked. Please log in again.');
}
$session?->update(['last_used_at' => now()]);
return $next($request);
}
}
+29 -2
View File
@@ -6,20 +6,47 @@ use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* The one OTP email template for every use of Auth\Services\OtpService —
* not just admin login. A code confirming a destructive Artisan command
* (e.g. Command\WipeCatalogCommand) reuses the exact same generation/
* validation mechanism as login, but "Your login code" as the subject
* would be actively misleading for that — the recipient never initiated a
* login. $purpose is a small, fixed set of known keys (see
* COPY_BY_PURPOSE), not free text — a typo'd/unknown purpose falls back
* to 'login' rather than rendering a blank subject/intro.
*/
class OtpMail extends Mailable
{
private const COPY_BY_PURPOSE = [
'login' => [
'subject' => 'Your login code',
'intro' => 'Your login code is:',
],
'wipe-catalog' => [
'subject' => 'Confirm: Wipe Catalog',
'intro' => 'Someone requested to permanently delete every product in the catalog. If this was you, enter this code to confirm:',
],
];
public function __construct(
public readonly string $name,
public readonly string $code,
public readonly string $purpose = 'login',
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Your login code');
return new Envelope(subject: $this->copy()['subject']);
}
public function content(): Content
{
return new Content(view: 'core::auth.mail.otp');
return new Content(view: 'core::auth.mail.otp', with: ['intro' => $this->copy()['intro']]);
}
private function copy(): array
{
return self::COPY_BY_PURPOSE[$this->purpose] ?? self::COPY_BY_PURPOSE['login'];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Auth\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One row per login (see Modules\Core\Auth\Services\UserOtpService::
* validate()) — see that table's own migration docblock for why this
* exists independent of the actual session-store driver.
*/
class UserSession extends Model
{
protected $guarded = [];
protected $casts = [
'last_used_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function user(): BelongsTo
{
$model = config('auth.providers.users.model');
return $this->belongsTo($model);
}
public function isRevoked(): bool
{
return $this->revoked_at !== null;
}
}
@@ -0,0 +1,68 @@
<?php
namespace Modules\Core\Auth\Privacy;
use Modules\Core\Auth\Models\UserSession;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\DTOs\UserSubject;
/**
* Login-session device/location metadata (user_sessions) — ip_address and
* user_agent are device/location fingerprinting data tied 1:1 to a User via
* user_id, never to a Customer (business account), so this is User-scope
* only. No legal retention requirement applies to session metadata the way
* it does to Order (there's no tax/accounting reason to keep old login IPs
* around), so rows are deleted outright rather than pseudonymized.
*
* A hard delete here is safe regardless of whether the User row itself has
* already been erased — CustomerDataProvider::eraseForUser() nulls the
* User's own name/email but never touches user_sessions, and the table's
* own user_id FK is cascadeOnDelete() only if the User row itself were
* hard-deleted, which it never is (erasure here means "identity nulled,"
* not "row removed" — see docs/modules.md "Customer/User Pairing").
*/
class UserSessionDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'sessions';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
return new ProviderExportResult('sessions', []);
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
$sessions = UserSession::where('user_id', $subject->userId)->get();
return new ProviderExportResult('sessions', $sessions->map(fn (UserSession $session) => [
'id' => $session->id,
'ip_address' => $session->ip_address,
'user_agent' => $session->user_agent,
'last_used_at' => $session->last_used_at?->toIso8601String(),
'revoked_at' => $session->revoked_at?->toIso8601String(),
])->all());
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('sessions', ErasureOutcome::Skipped, 'Login sessions belong to individual Users, not Customer accounts.');
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
$deleted = UserSession::where('user_id', $subject->userId)->delete();
if ($deleted === 0) {
return new ProviderErasureResult('sessions', ErasureOutcome::Skipped, 'No login sessions for this user.');
}
return new ProviderErasureResult('sessions', ErasureOutcome::Erased);
}
}
+8 -2
View File
@@ -11,7 +11,13 @@ class OtpService
private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6;
public function generateAndSend(string $email): bool
/**
* $purpose is forwarded as-is to OtpMail, which only recognizes a
* fixed set of keys (see its own COPY_BY_PURPOSE) — an unrecognized
* value there just falls back to 'login' rather than failing here, so
* this method has nothing of its own to validate.
*/
public function generateAndSend(string $email, string $purpose = 'login'): bool
{
$staff = Staff::where('email', $email)->first();
@@ -25,7 +31,7 @@ class OtpService
$staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$staff->save();
Mail::to($staff->email)->send(new OtpMail($staff->first_name, $code));
Mail::to($staff->email)->send(new OtpMail($staff->first_name, $code, $purpose));
return true;
}
+131 -10
View File
@@ -2,23 +2,97 @@
namespace Modules\Core\Auth\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Auth\Events\UserCreated;
use Modules\Core\Auth\Exceptions\OtpThrottledException;
use Modules\Core\Auth\Mail\UserOtpMail;
/**
* The storefront's passwordless login — a shopper supplies only an email
* (Shopify-style), gets a 6-digit code, and validate() authenticates the
* `web` guard via Auth::login().
*
* That alone is enough to merge/associate any active guest cart into the
* now-known customer — Auth::login() fires Illuminate\Auth\Events\Login,
* which Lunar's own Lunar\Listeners\CartSessionAuthListener (registered
* unconditionally in LunarServiceProvider::boot(), no opt-in needed)
* already listens to, calling CartSession::associate() with
* config('lunar.cart.auth_policy') — 'merge' by default, 'override' if a
* consumer changes that config. Deliberately no cart-association call
* here: doing our own on top would run a SECOND merge attempt with a
* hardcoded policy that ignores whatever the consumer configured.
*
* generateAndSend()'s find-or-create already triggers the full
* Customer/User pairing cascade for a genuinely new email — see
* Modules\Core\Auth\Events\UserCreated's own docblock and
* Modules\Core\Customer\Listeners\CreateCustomerForUser.
*
* Two independent throttles, both configured under core.auth.otp — see
* config/core.php's own comment for why they're separate: max_attempts
* caps wrong guesses against ONE code; generation_limit caps how often a
* NEW code can be requested for the same email at all (closes both the
* "regenerate to reset my guess count" loophole and mail-bombing one
* inbox).
*
* validate() also records a UserSessionService entry for the new login —
* see that class's own docblock for the "logout everywhere" registry
* this feeds (Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked
* is the enforcement half; a consuming app must add it to its own
* middleware stack). $request is optional purely so this service stays
* callable from a context with no HTTP request at all (a console
* command, a test) — user-agent/ip are simply not recorded when omitted.
*/
class UserOtpService
{
private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6;
public function __construct(
private readonly UserSessionService $sessions,
) {}
/**
* @throws OtpThrottledException if this email has requested too many
* codes within core.auth.otp.generation_decay_minutes
*/
public function generateAndSend(string $email): bool
{
$limiterKey = $this->generationLimiterKey($email);
$maxGenerations = (int) config('core.auth.otp.generation_limit', 3);
if (RateLimiter::tooManyAttempts($limiterKey, $maxGenerations)) {
throw new OtpThrottledException(RateLimiter::availableIn($limiterKey));
}
RateLimiter::hit($limiterKey, (int) config('core.auth.otp.generation_decay_minutes', 10) * 60);
$model = config('auth.providers.users.model');
$user = $model::firstOrCreate(['email' => $email]);
// wasRecentlyCreated is Eloquent's own "did firstOrCreate() just
// INSERT, or did it find an existing row" flag — the only reliable
// way to tell them apart from firstOrCreate()'s return value alone.
// Without this check, a genuinely new signup never fired
// UserCreated at all (this class's own docblock claimed the
// Customer/User pairing cascade "already triggers" here, which was
// false as written — see Modules\Core\Customer\Listeners\
// CreateCustomerForUser, which depends entirely on this event).
if ($user->wasRecentlyCreated) {
Event::dispatch(new UserCreated($user));
}
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
$user->otp_code = $code;
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$user->otp_attempts = 0;
$user->save();
Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code));
@@ -26,23 +100,70 @@ class UserOtpService
return true;
}
public function validate(string $email, string $code)
/**
* A wrong code counts against core.auth.otp.max_attempts and, once
* reached, invalidates the code entirely — the shopper must request
* a fresh one via generateAndSend() (itself throttled independently
* — see this class's own docblock) rather than being able to keep
* guessing against a still-live code for the rest of its 10-minute
* expiry window.
*/
public function validate(string $email, string $code, ?Request $request = null): ?Authenticatable
{
$model = config('auth.providers.users.model');
$user = $model::where('email', $email)->first();
if (! $user) {
// lockForUpdate() + a transaction make the read-check-increment-save
// below atomic across concurrent requests for the same user — without
// it, two guesses fired in parallel can each read the same
// pre-increment otp_attempts value and both save past
// max_attempts, letting an attacker exceed the lockout by
// parallelizing requests instead of sending them serially.
$result = DB::transaction(function () use ($model, $email, $code) {
$user = $model::where('email', $email)->lockForUpdate()->first();
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
return null;
}
if (! hash_equals((string) $user->otp_code, $code)) {
$user->otp_attempts++;
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) {
$user->otp_code = null;
$user->otp_expires_at = null;
$user->otp_attempts = 0;
}
$user->save();
return null;
}
$user->otp_code = null;
$user->otp_expires_at = null;
$user->otp_attempts = 0;
$user->save();
return $user;
});
if (! $result) {
return null;
}
if (! $user->otp_expires_at || $user->otp_code != $code || now()->isAfter($user->otp_expires_at)) {
return null;
}
RateLimiter::clear($this->generationLimiterKey($email));
$user->otp_code = null;
$user->otp_expires_at = null;
$user->save();
Auth::login($result);
return $user;
$this->sessions->record($result, $request);
Event::dispatch(new UserAuthenticated($result));
return $result;
}
private function generationLimiterKey(string $email): string
{
return 'otp-generate:'.strtolower($email);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace Modules\Core\Auth\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Modules\Core\Auth\Models\UserSession;
/**
* The record/revoke half of the session registry — see
* database/migrations/2026_09_15_000001_create_user_sessions_table.php's
* own docblock for why this exists (SESSION_DRIVER=redis in this app has
* no "sessions" table to purge by user_id). The enforcement half is
* Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked, which reads
* the token this class stamps into the session payload.
*/
class UserSessionService
{
private const SESSION_TOKEN_KEY = 'user_session_token';
/**
* Called once, right after Auth::login() succeeds (see
* UserOtpService::validate()) — generates a fresh token, records it,
* and stamps it into the CURRENT session payload so
* EnsureSessionNotRevoked can look it up on later requests.
*/
public function record(Authenticatable $user, ?Request $request = null): UserSession
{
$token = Str::random(64);
$session = UserSession::create([
'user_id' => $user->getAuthIdentifier(),
'token' => $token,
'user_agent' => $request?->userAgent(),
'ip_address' => $request?->ip(),
'last_used_at' => now(),
]);
session([self::SESSION_TOKEN_KEY => $token]);
return $session;
}
/**
* Revokes every OTHER active session for $user — the current one
* (matched by the token in the CURRENT session payload) is left
* alone, matching Laravel's own logoutOtherDevices() semantics
* (there just isn't a password to re-verify against here — this is a
* passwordless account, so revocation is simply "every row that
* isn't the one making this request").
*
* Known, deliberately accepted gap: this requires only a currently
* valid session, not a freshly-completed login — so anyone holding
* an already-authenticated session (e.g. someone who sits down at an
* account left logged in on a shared/public PC) can use this to
* evict the real owner's OTHER sessions just as easily as the real
* owner could use it to evict an intruder's. A stricter version would
* require a fresh OTP re-verification (e.g. within the last few
* minutes) before allowing this call. Left as-is for now — revisit if
* this turns out to matter in practice, rather than building
* abuse-resistance against a threat model nobody's confirmed is real
* for this storefront.
*/
public function revokeOtherSessions(Authenticatable $user): int
{
$currentToken = session(self::SESSION_TOKEN_KEY);
return UserSession::query()
->where('user_id', $user->getAuthIdentifier())
->whereNull('revoked_at')
->when($currentToken, fn ($query) => $query->where('token', '!=', $currentToken))
->update(['revoked_at' => now()]);
}
/**
* Revokes EVERY session for $user, current one included — for a
* "this account may be compromised" response, not a routine logout.
*/
public function revokeAllSessions(Authenticatable $user): int
{
return UserSession::query()
->where('user_id', $user->getAuthIdentifier())
->whereNull('revoked_at')
->update(['revoked_at' => now()]);
}
/**
* @return UserSession|null null if the CURRENT session has no
* recorded token at all (e.g. a session predating this feature, or
* one Auth::login() established outside UserOtpService) — treated
* as valid by EnsureSessionNotRevoked rather than rejected, since
* there's nothing to have been revoked.
*/
public function currentSession(): ?UserSession
{
$token = session(self::SESSION_TOKEN_KEY);
if (! $token) {
return null;
}
return UserSession::where('token', $token)->first();
}
}
+20 -15
View File
@@ -5,7 +5,7 @@ namespace Modules\Core\Cart\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Cart\Services\CartLifecycleService;
use Modules\Core\Recovery\Events\CartAbandoned;
use Modules\Core\Recovery\Events\CheckoutAbandoned;
@@ -31,6 +31,20 @@ use Modules\Core\Recovery\Events\CheckoutAbandoned;
* state at all; every cart still matching the query below refires its event
* on every run until Recovery (not yet built — see
* docs/recovery-strategies.md) owns its own dedup/tracking table.
*
* Both queries require meta->recovery_consent = true — CartAbandoned/
* CheckoutAbandoned exist specifically to drive future recovery-email
* sends (Checkout\Services\CheckoutService::setRecoveryConsent() is where
* that consent is actually recorded), and a non-consenting cart's
* abandonment must never be dispatched at all, not merely filtered later
* at send time — see docs referenced above for the legal reasoning. This
* consent filter stays here rather than on Modules\Core\Cart\Services\
* CartLifecycleService, whose two "abandoned" queries this command builds
* on — dispatch eligibility is this command's own concern, not part of
* what "abandoned" means to a staff member browsing the admin panel. (The
* non-empty-lines requirement, by contrast, IS part of what "abandoned"
* means either way, so it lives on CartLifecycleService::abandonedCarts()
* itself, not here.)
*/
class DetectAbandonedCarts extends Command
{
@@ -38,32 +52,23 @@ class DetectAbandonedCarts extends Command
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
public function handle(): void
public function handle(CartLifecycleService $lifecycle): void
{
$cutoff = CartResource::abandonedCutoff();
$cartsAbandoned = 0;
$checkoutsAbandoned = 0;
Cart::query()
->whereDoesntHave('orders')
->where('updated_at', '<=', $cutoff)
->with('lines')
$lifecycle->abandonedCarts(Cart::query())
->where('meta->recovery_consent', true)
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
foreach ($carts as $cart) {
if ($cart->lines->isEmpty()) {
continue;
}
Event::dispatch(new CartAbandoned($cart));
$cartsAbandoned++;
}
});
Cart::query()
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', $cutoff)
$lifecycle->abandonedCheckouts(Cart::query())
->where('meta->recovery_consent', true)
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
foreach ($carts as $cart) {
+15 -14
View File
@@ -9,20 +9,22 @@ use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ViewCart;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Modules\Core\Cart\Services\CartLifecycleService;
/**
* Read-only — a cart is managed entirely through the storefront (add/update/remove
* line, checkout), never hand-edited by staff. Scoped to carts with a known
* `user_id`/`customer_id` only: an anonymous guest's session cart carries no
* identity a staff member could act on (no name, no email, nothing to follow up
* with), so listing every such row would be noise, not a real admin capability —
* see docs/cart.md for the reasoning (Lunar itself ships no cart admin view at all
* to follow a precedent from).
* line, checkout), never hand-edited by staff. Lists every cart, guest carts
* included — see docs/cart.md ("Scope: every cart, identified or not"). An
* anonymous cart's Customer/User columns just render "—" (see table() below)
* rather than the row being hidden outright: most real traffic never reaches
* an identified user/customer, and "how many carts are ongoing/abandoned
* right now" is a real reporting need regardless of identity — excluding
* anonymous carts would silently undercount it. Lunar itself ships no cart
* admin view at all to follow a precedent from.
*/
class CartResource extends Resource
{
@@ -36,12 +38,6 @@ class CartResource extends Resource
protected static ?string $pluralModelLabel = 'Carts';
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->where(fn (Builder $query) => $query->whereNotNull('user_id')->orWhereNotNull('customer_id'));
}
/**
* Count only, not a fetch — no rows are loaded. Combines BOTH abandoned
* states (`active()` already covers "no order at all" and "draft order,
@@ -56,6 +52,11 @@ class CartResource extends Resource
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
}
public static function lifecycle(): CartLifecycleService
{
return app(CartLifecycleService::class);
}
/**
* `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very
* different things together: a cart someone is actively shopping in right now,
@@ -67,7 +68,7 @@ class CartResource extends Resource
*/
public static function abandonedCutoff(): Carbon
{
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
return static::lifecycle()->abandonedCutoff();
}
public static function table(Table $table): Table
@@ -6,6 +6,7 @@ use Filament\Schemas\Components\Tabs\Tab;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Cart\Services\CartLifecycleService;
class ListCarts extends ListRecords
{
@@ -27,30 +28,24 @@ class ListCarts extends ListRecords
* bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned /
* Modules\Core\Recovery\Events\CheckoutAbandoned draw.
*
* "Ongoing" vs the two abandoned tabs all split on `updated_at` against
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness
* signal of its own, so recent activity is the only thing distinguishing a
* cart someone is shopping in right now from one genuinely left behind.
* The four query shapes below live on Modules\Core\Cart\Services\
* CartLifecycleService, shared with Modules\Core\Cart\Commands\
* DetectAbandonedCarts — see that service's docblock for why duplicating
* them independently in both places was worth centralizing.
*/
public function getTabs(): array
{
$lifecycle = app(CartLifecycleService::class);
return [
'abandoned_cart' => Tab::make('Abandoned Cart')
->modifyQueryUsing(fn(Builder $query) => $query
->whereDoesntHave('orders')
->where('updated_at', '<=', CartResource::abandonedCutoff())),
->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCarts($query)),
'abandoned_checkout' => Tab::make('Abandoned Checkout')
->modifyQueryUsing(fn(Builder $query) => $query
->whereHas('orders', fn(Builder $query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', CartResource::abandonedCutoff())),
->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCheckouts($query)),
'ongoing' => Tab::make('Ongoing')
->modifyQueryUsing(fn(Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())),
->modifyQueryUsing(fn (Builder $query) => $lifecycle->ongoing($query)),
'completed' => Tab::make('Completed')
->modifyQueryUsing(fn(Builder $query) => $query->whereHas(
'orders',
fn(Builder $query) => $query->whereNotNull('placed_at'),
)),
->modifyQueryUsing(fn (Builder $query) => $lifecycle->completed($query)),
];
}
}
@@ -5,12 +5,19 @@ namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Section;
use Filament\Actions\Action;
use Filament\Infolists\Components\ImageEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Colors\Color;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\Blade;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct;
use Lunar\Exceptions\MissingCurrencyPriceException;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
use Lunar\Models\ProductVariant;
use Modules\Core\Cart\Filament\Resources\CartResource;
class ViewCart extends ViewRecord
@@ -35,13 +42,39 @@ class ViewCart extends ViewRecord
* (a single view page load), not per-row in the list table, since running the
* full pipeline for every row of a paginated table would be expensive for no
* real benefit — see docs/lunar.md's Cart gotchas.
*
* Eager-loads what the Lines section (below) reads off each line's
* purchasable — name, thumbnail, options — the same relations Lunar's
* own OrderItemsTable loads for an order's line items (`with(['purchasable'])`,
* see vendor/lunarphp/lunar/.../OrderItemsTable::getDefaultTable()) — so
* rendering the product grid doesn't N+1 per line.
*
* calculate() throws Lunar\Exceptions\MissingCurrencyPriceException
* (vendor PricingManager) the moment ANY line's purchasable has no
* price row for the cart's currency — including a line whose
* purchasable no longer exists at all (a deleted ProductVariant still
* referenced by cart_lines.purchasable_id), which 500'd this whole
* page rather than just leaving that one line unpriced. The Lines
* section below already guards every purchasable-derived field with
* `instanceof ProductVariant` and renders fine with $cart left
* uncalculated — subTotal/total/etc. simply won't be populated, which
* reads as a stale/pending state rather than a broken page.
*/
protected function resolveRecord(int|string $key): Cart
{
/** @var Cart $cart */
$cart = parent::resolveRecord($key);
return $cart->calculate();
$cart->load('lines.purchasable', 'shippingAddress.country');
EloquentCollection::make($cart->lines->pluck('purchasable')->filter(fn ($p) => $p instanceof ProductVariant))
->loadMissing(['product.thumbnail', 'images', 'values']);
try {
return $cart->calculate();
} catch (MissingCurrencyPriceException) {
return $cart;
}
}
public function infolist(Schema $schema): Schema
@@ -77,6 +110,37 @@ class ViewCart extends ViewRecord
RepeatableEntry::make('lines')
->hiddenLabel()
->schema([
ImageEntry::make('image')
->hiddenLabel()
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? $record->purchasable->getThumbnail()?->getUrl('small')
: null)
->defaultImageUrl(fn () => 'data:image/svg+xml;base64,'.base64_encode(
Blade::render('<x-filament::icon icon="heroicon-o-photo" style="color:rgb('.Color::Gray[400].');"/>')
))
->imageSize(48),
TextEntry::make('description')
->label('Product')
// ProductVariant::getDescription()/getOption() are typed
// string but internally read translateAttribute()/
// translate(), which return null for a product/option
// with no attribute data set for the active locale —
// reading the underlying relations directly here avoids
// that TypeError rather than calling through them.
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? ($record->purchasable->product?->translateAttribute('name') ?? '—')
: '—')
->url(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? EditProduct::getUrl(['record' => $record->purchasable->product_id])
: null)
->weight('bold'),
TextEntry::make('options')
->label('Options')
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? ($record->purchasable->values->map(fn ($value) => $value->translate('name'))->filter()->join(', ') ?: null)
: null)
->placeholder('—')
->badge(),
TextEntry::make('purchasable.sku')
->label('SKU')
->placeholder('—'),
@@ -90,6 +154,53 @@ class ViewCart extends ViewRecord
])
->columns(4),
]),
Section::make('Shipping')
->columns(3)
->schema([
TextEntry::make('shippingAddress.shipping_option')
->label('Shipping method')
// The raw identifier (e.g. "acs") is all a
// CartAddress row stores — the human-readable
// name only exists on the resolved
// Lunar\DataTypes\ShippingOption, which is what
// shippingBreakdown's items are keyed/named
// from below, so fall back to that name rather
// than showing the bare identifier.
->formatStateUsing(fn (Cart $record, ?string $state) => $state
? ($record->shippingBreakdown?->items->get($state)?->name ?? $state)
: null)
->placeholder('Not selected'),
TextEntry::make('shippingAddress.country.name')
->label('Shipping to')
->placeholder('—'),
TextEntry::make('shippingTotal')
->label('Shipping total')
->formatStateUsing(fn (Cart $record) => $record->shippingTotal?->formatted() ?? '—')
->weight('bold'),
RepeatableEntry::make('shippingBreakdownItems')
->label('Breakdown')
->columnSpanFull()
// shippingBreakdown->items is a plain (non-Eloquent)
// Collection of Lunar\Base\ValueObjects\Cart\
// ShippingBreakdownItem — e.g. the carrier rate and,
// separately, Modules\Core\Payment\Pipelines\Cart\
// ApplyPaymentMethodFee's own line item when the
// selected payment method carries a fee (see
// CHANGELOG 0.16.3) — both show up here individually
// rather than only as the summed shippingTotal above.
->state(fn (Cart $record) => $record->shippingBreakdown?->items->values() ?? [])
->schema([
TextEntry::make('name')
->hiddenLabel(),
TextEntry::make('price')
->hiddenLabel()
->formatStateUsing(fn ($state) => $state?->formatted() ?? '—')
->alignEnd(),
])
->columns(2)
->visible(fn (Cart $record) => (bool) $record->shippingBreakdown?->items->isNotEmpty()),
])
->visible(fn (Cart $record) => $record->shippingAddress !== null),
Section::make('Totals')
->columns(3)
->schema([
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace Modules\Core\Cart\Privacy;
use Lunar\Models\Cart;
use Lunar\Models\CartAddress;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\DTOs\UserSubject;
/**
* Carts and cart addresses (lunar_carts, lunar_cart_addresses) belong to the
* Customer (business account) via customer_id, not to an individual User, so this
* is Customer-scope only. Unlike Order/OrderAddress, an abandoned cart has no
* legal retention requirement, so its addresses are freely deleted. The Cart row
* itself is left alone (any completed order it produced is handled separately by
* OrderDataProvider, which is what retention law actually cares about) — only its
* address PII is removed.
*
* Also covers Cart.meta's own PII-adjacent keys — Modules\Core\Checkout\Services\
* CheckoutService::setRecoveryConsent()/selectPaymentMethod() write
* recovery_consent/recovery_consent_at/recovery_consent_policy_version and
* payment_method/checkout_fingerprint directly onto this same Cart row, which the
* address-only erase above never touched. Kept Customer-scope, consistent with
* how Cart itself is already classified — see docs/privacy.md for the
* User-vs-Customer discussion this raised.
*/
class CartDataProvider implements PersonalDataProvider
{
private const META_KEYS = [
'recovery_consent',
'recovery_consent_at',
'recovery_consent_policy_version',
'payment_method',
'checkout_fingerprint',
];
public function name(): string
{
return 'carts';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$carts = Cart::where('customer_id', $subject->customerId)->get();
$addresses = CartAddress::whereIn('cart_id', $carts->pluck('id'))->get();
return new ProviderExportResult('carts', [
'addresses' => $addresses->map(fn (CartAddress $address) => [
'type' => $address->type,
'first_name' => $address->first_name,
'last_name' => $address->last_name,
'line_one' => $address->line_one,
'city' => $address->city,
'postcode' => $address->postcode,
'contact_email' => $address->contact_email,
'contact_phone' => $address->contact_phone,
])->all(),
'carts' => $carts->map(fn (Cart $cart) => [
'id' => $cart->id,
'meta' => $this->metaOnly($cart),
])->all(),
]);
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('carts', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$carts = Cart::where('customer_id', $subject->customerId)->get();
CartAddress::whereIn('cart_id', $carts->pluck('id'))->delete();
foreach ($carts as $cart) {
$meta = (array) $cart->meta;
foreach (self::META_KEYS as $key) {
unset($meta[$key]);
}
$cart->update(['meta' => $meta]);
}
return new ProviderErasureResult('carts', ErasureOutcome::Erased);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('carts', ErasureOutcome::Skipped, 'Carts belong to Customer accounts, not individual users.');
}
/**
* @return array<string, mixed>
*/
private function metaOnly(Cart $cart): array
{
$meta = (array) $cart->meta;
return array_intersect_key($meta, array_flip(self::META_KEYS));
}
}
@@ -0,0 +1,99 @@
<?php
namespace Modules\Core\Cart\Services;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Lunar\Models\Cart;
/**
* The single source of truth for the four cart lifecycle states documented in
* docs/cart.md ("Four states, not two — and not Cart::completed_at"). Both
* Modules\Core\Cart\Filament\Resources\CartResource/ListCarts (staff-facing
* browsing/tabs) and Modules\Core\Cart\Commands\DetectAbandonedCarts
* (abandonment-event dispatch) build on these same four query shapes — before
* this existed, each reimplemented them independently, which is exactly the
* kind of drift that lets the admin panel and the recovery-email pipeline
* quietly disagree about what "abandoned" means.
*
* `Cart::completed_at` is declared/cast on the model but never actually
* written anywhere in Lunar core — not a real signal, not used here.
* `Cart::scopeActive()` (Lunar's own "not yet converted to an order" scope)
* mixes two distinct states together (no order at all vs. a draft order that
* was never placed) — see docs/cart.md for why they're kept apart as
* different purchase-intent/reachability signals rather than folded into one
* "not converted" bucket.
*
* Query shape only: consent (`meta->recovery_consent`) and non-empty-lines
* filtering stay in DetectAbandonedCarts, not here — those are specific to
* whether a recovery event should fire, not to what "abandoned" means. Staff
* browsing the admin panel should see every abandoned cart, consenting or
* not.
*
* `unrecoverableCutoff()` is a second, older threshold
* (`core.cart.unrecoverable_after`, default 90 days) applied as a lower
* bound on both abandoned*() methods below: a cart past it is too old to be
* a realistic recovery target (pricing/stock/tax have likely moved on), so
* it drops out of "Abandoned Cart"/"Abandoned Checkout" entirely rather than
* staying flagged as an actionable abandonment forever. It does not appear
* in `ongoing()`/`completed()` either — this is about the abandoned-cart
* pipeline specifically, not a retention/deletion policy (no rows are
* touched here).
*/
class CartLifecycleService
{
public function abandonedCutoff(): Carbon
{
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
}
public function unrecoverableCutoff(): Carbon
{
return now()->sub(config('core.cart.unrecoverable_after', '90 days'));
}
/**
* Not yet converted to an order (scopeActive()), with recent activity —
* someone plausibly shopping right now, not (yet) left behind.
*/
public function ongoing(Builder $query): Builder
{
return $query->active()->where('updated_at', '>', $this->abandonedCutoff());
}
/**
* No order started at all, stale, not yet past the unrecoverable cap, and
* actually has something in it — the weaker of the two abandoned states
* (see docs/cart.md's "Abandoned Cart vs Abandoned Checkout"). An empty
* cart (created but nothing ever added — e.g. a bot, or a session that
* never shopped) was never really "abandoned"; there's nothing to
* recover, so it's excluded rather than counted as a false positive.
*/
public function abandonedCarts(Builder $query): Builder
{
return $query->whereDoesntHave('orders')
->whereHas('lines')
->where('updated_at', '<=', $this->abandonedCutoff())
->where('updated_at', '>', $this->unrecoverableCutoff());
}
/**
* A draft order exists (checkout was started) but was never placed,
* stale, and not yet past the unrecoverable cap — the stronger of the
* two abandoned states.
*/
public function abandonedCheckouts(Builder $query): Builder
{
return $query->whereHas('orders', fn (Builder $query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', $this->abandonedCutoff())
->where('updated_at', '>', $this->unrecoverableCutoff());
}
/**
* Has an order that was actually placed, not just drafted.
*/
public function completed(Builder $query): Builder
{
return $query->whereHas('orders', fn (Builder $query) => $query->whereNotNull('placed_at'));
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Catalog\Contracts;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
/**
* One strategy for producing recommended products for a given product —
* e.g. same category, same tag, best sellers, random. Modules\Core\Catalog\
* Services\RecommendationService runs rules registered in
* config('catalog.recommendation_rules') in order, topping up from each
* successive rule until $limit distinct products are collected or every
* rule is exhausted (e.g. 3 from SameCategoryRule + 1 from RandomRule) —
* nothing here decides that accumulation itself; a store composes its own
* chain by ordering rules in config (e.g. [SameCategoryRule::class,
* RandomRule::class]).
*
* Returns raw Product models, not ProductService::list()'s locale-resolved
* array output — this runs at index time (ProductIndexer::toSearchableArray()),
* where "the current locale" isn't a meaningful concept the way it is for a
* storefront request. ProductIndexer resolves translated fields itself via
* translateAttribute(), same as it already does for the embedded `collections`
* field — same known index-time-locale tradeoff, not a new one.
*/
interface RecommendationRule
{
/**
* $exclude carries $product's own id plus every id already picked by an
* earlier rule this call — RecommendationService never shows the same
* product twice even when two rules would both suggest it, and a rule
* shouldn't spend its $limit budget re-returning something already
* collected.
*
* @param array<int> $exclude
* @return Collection<int, Product> at most $limit products
*/
public function recommend(Product $product, int $limit, array $exclude): Collection;
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Catalog\DTOs;
/**
* A price range slider's bounds and whether it's currently narrowed —
* built by ProductService::priceSliderBounds(), which owns the floor/ceil
* rounding and "is this actually a meaningful filter" comparison, so a
* controller (CategoryController, SearchController, ...) doesn't have to
* reimplement that rule itself.
*/
class PriceSliderBounds
{
public function __construct(
public readonly ?int $floor,
public readonly ?int $ceil,
public readonly bool $filtered,
) {}
}
+2
View File
@@ -17,10 +17,12 @@ class ProductFilters
* not a direct-assignment-only match) — the right semantics for "products on
* this category page", since products are typically attached only to leaf
* collections.
* @param $tag exactly one tag — no multi-select yet.
*/
public function __construct(
public readonly ?int $collectionId = null,
public readonly ?string $brand = null,
public readonly ?string $tag = null,
public readonly ?float $minPrice = null,
public readonly ?float $maxPrice = null,
public readonly bool $inStockOnly = false,
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Catalog\DTOs;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Everything a listing page needs from one ProductService::list() call —
* the product page itself, the price slider's bounds, and the set of tags
* actually present on matching products (for a tag filter sidebar) — so a
* controller makes one service call instead of orchestrating list(),
* priceSliderBounds(), and facets('tags', ...) separately. list() still
* issues multiple Meilisearch requests internally (the product search, the
* price facet stats, the tag facet distribution — see priceSliderBounds()'s
* own docblock for why the price ones can't be merged into one without
* changing the slider's UX), but that's this DTO's job to hide, not the
* controller's to know about.
*/
class ProductListingResult
{
/**
* @param array<int, string> $availableTags every distinct tag value
* present on at least one product matching the listing's OTHER
* filters (collection/price/stock — never the tag filter itself, so
* selecting a tag doesn't collapse the list down to just that tag).
* Sorted alphabetically. Empty if no product in scope has any tag.
*/
public function __construct(
public readonly LengthAwarePaginator $products,
public readonly PriceSliderBounds $priceBounds,
public readonly array $availableTags = [],
) {}
}
+7
View File
@@ -14,6 +14,7 @@ enum ProductSort: string
case PriceAsc = 'price_asc';
case PriceDesc = 'price_desc';
case Newest = 'newest';
case Popularity = 'popularity';
public function toMeilisearchSort(): string
{
@@ -21,6 +22,12 @@ enum ProductSort: string
self::PriceAsc => 'price:asc',
self::PriceDesc => 'price:desc',
self::Newest => 'created_at:desc',
// order_count — see Modules\Core\Catalog\Services\
// ProductIndexer::toSearchableArray()'s own docblock: the same
// trailing-year, physical-order-line-count definition Lunar's
// own admin dashboard "Popular Products" widget already uses,
// aggregated per product rather than per variant.
self::Popularity => 'order_count:desc',
};
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Catalog\Events;
/**
* Dispatched whenever a Product is deleted (see CatalogServiceProvider,
* which wires this to the model's own deleted() hook — fires for both a
* soft delete and a force delete, same as Laravel Scout's own
* ModelObserver::deleted() that triggers unsearchable() for the product
* itself). Same purpose as Modules\Core\Catalog\Events\ProductSaved: lets
* Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct find
* and re-index every OTHER product that currently embeds this one in its
* `recommendations` field, so a deleted product doesn't linger as a dead
* reference elsewhere. Carries only the id, not the Product model — by the
* time this fires the model may already be gone (force delete), and the
* reverse lookup only ever needs the id to filter on.
*/
class ProductDeleted
{
public function __construct(
public readonly int $productId,
) {}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Catalog\Events;
use Lunar\Models\Product;
/**
* Dispatched whenever a Product is saved (see CatalogServiceProvider,
* which wires this to the model's own saved() hook) — exists specifically
* so Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct can
* find and re-index every OTHER product that currently embeds this one in
* its own `recommendations` field (see ProductIndexer). Those products
* have no direct database relationship to this one — a recommendation is
* computed and stored only inside Meilisearch (Modules\Core\Catalog\
* Services\RecommendationService) — so nothing about their own save
* lifecycle would otherwise pick up this product's changed name/price/
* image.
*/
class ProductSaved
{
public function __construct(
public readonly Product $product,
) {}
}
@@ -0,0 +1,162 @@
<?php
namespace Modules\Core\Catalog\Filament\Pages;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Support\Pages\BaseEditRecord;
use Lunar\Models\Language;
/**
* Own sub-page for Product::$custom_fields (see that column's own docblock
* on Modules\Core\Catalog\Models\Product) — used to be a collapsible
* Section inline on the main product edit form (Review\Filament\
* Extensions\ProductResourceExtension::extendForm()), moved out to match
* how Reviews already gets its own sub-page (ManageProductReviews) rather
* than crowding the main form with a second unrelated concern.
*
* Deliberately no ->statePath('') override, no custom mount()/
* handleRecordUpdate() — EditRecord::mount() already fills the form from
* $record->attributesToArray() (which includes custom_fields, a real cast
* + fillable column) onto the default 'data' statePath, and save() reads
* it straight back off via $this->form->getState(). An earlier version of
* this page used ->statePath('') to bind the repeater directly to the
* record's attributes (copying ManageProductPricing) — that repointed the
* Repeater at $this->data['custom_fields'] AS THE ROOT state path itself,
* so every "add item" click re-filled the whole form from the record's
* still-unsaved value and immediately discarded the new row before it
* ever reached the page. Reverting to the plain default form/statePath is
* both simpler and is what actually works — same as the original inline
* repeater on the main product form did before this became its own page.
*
* Registered from Review\Filament\Extensions\ProductResourceExtension, not
* here — CorePlugin only allows one extension class per Lunar resource,
* and Review's already owns ProductResource's extension slot (see that
* class's own docblock).
*
* `label`/`help_text` are each stored as {locale: string} (e.g. {en: "...",
* el: "..."}) — see translatedField()'s own docblock for why that's a
* hand-rolled TextInput per language rather than Lunar's TranslatedText
* component. A product saved before this change still has a plain string
* `label` and no `help_text` at all; itemLabel() below tolerates both
* shapes, and the storefront/cart resolve either shape the same way (see
* product-custom-fields.blade.php and CartController::
* customFieldsMeta()). `key`/`type`/`required` stay plain, single values —
* only shopper-facing copy needs a translation, not the field's own
* machine-facing configuration.
*/
class ManageProductCustomFields extends BaseEditRecord
{
protected static string $resource = ProductResource::class;
public static function getNavigationIcon(): ?string
{
return 'heroicon-o-adjustments-horizontal';
}
public function getTitle(): string
{
return 'Custom Fields';
}
public static function getNavigationLabel(): string
{
return 'Custom Fields';
}
/**
* Without this, Filament's EditRecord defaults to every relation
* manager the WHOLE ProductResource defines (see HasRelationManagers::
* getAllRelationManagers(), which reads ProductResource::getRelations()
* regardless of which sub-page is rendering) — Channels, Customer
* Groups, Media, Pricing tabs all bleeding onto this page alongside the
* repeater below. This page has no relations of its own.
*/
public function getRelationManagers(): array
{
return [];
}
/**
* A plain TextInput per configured language, named "{$field}.{locale}"
* so it resolves to a normal nested array under the repeater item
* (custom_fields.{item}.label.en, .label.el, ...) — NOT Lunar's
* TranslatedText component. That component's per-locale sub-fields
* set their own statePath to just the locale code itself
* (TranslatedText::prepareTranslateLocaleComponent()), which only
* resolves correctly when TranslatedText is used as a single
* top-level named field directly on a form's root state (exactly how
* every existing usage in this codebase uses it — Lunar's own
* product name/description). Nested inside a Repeater item here, that
* same statePath resolution silently failed to nest under the item's
* own label/help_text key at all, and every typed value was lost on
* save. Hand-rolling the per-locale inputs sidesteps that assumption
* entirely.
*/
private function translatedField(string $field, string $label, string $helperText, bool $required): Group
{
$languages = Language::orderBy('default', 'desc')->get(['code', 'name', 'default']);
return Group::make(
$languages->map(fn (Language $language, int $index) => TextInput::make("{$field}.{$language->code}")
->label($index === 0 ? $label : null)
->hiddenLabel($index !== 0)
->helperText($index === 0 ? $helperText : null)
->prefix(Str::upper($language->code))
->required($required && $language->default))->values()->all(),
)
->columnSpanFull();
}
public function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('Custom Fields')
->description('Extra input the shopper fills in on this product\'s page before adding it to their cart — a reference photo, personalization text, etc.')
->schema([
Repeater::make('custom_fields')
->hiddenLabel()
->schema([
$this->translatedField('label', 'Label', 'Shown to the shopper above the field. Only the current storefront locale is shown on the cart and checkout.', required: true),
$this->translatedField('help_text', 'Help text', 'Optional — shown under the label on the product page only, not on the cart or checkout.', required: false),
Select::make('type')
->label('Field type')
->options([
'text' => 'Short text',
'textarea' => 'Long text',
'file' => 'File upload',
])
->default('text')
->native(false)
->live()
->required(),
TextInput::make('key')
->label('Key')
->helperText('Machine-facing identifier — stored on the order/cart line, used to look up this answer elsewhere. Cannot be changed once orders reference it.')
->required()
->alphaDash()
->maxLength(64),
Toggle::make('required')
->label('Required')
->helperText('Shopper cannot add this product to their cart without answering.')
->default(false),
])
->columns(2)
->addActionLabel('Add a custom field')
->reorderable()
->collapsible()
->itemLabel(fn (array $state): ?string => is_array($state['label'] ?? null)
? collect($state['label'])->first(fn ($value) => filled($value))
: ($state['label'] ?? null)),
]),
]);
}
}
@@ -0,0 +1,70 @@
<?php
namespace Modules\Core\Catalog\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Lunar\Models\Product;
use Modules\Core\Catalog\Events\ProductDeleted;
use Modules\Core\Catalog\Events\ProductSaved;
/**
* Queued — a Meilisearch filter query plus N reindex calls with no
* same-request reader; a few seconds of stale `recommendations` on a
* referencing product's storefront page is a cosmetic, not correctness,
* concern (see the class's own docblock below).
*
* Keeps every product's embedded `recommendations` field (see
* ProductIndexer) in sync when a product they recommend changes or is
* removed. Unlike Modules\Core\Catalog\Observers\ProductOptionReindexObserver's
* equivalent ("who references this option value"), there is no Postgres
* table to query here — a recommendation only exists inside Meilisearch,
* computed by RecommendationService at index time — so the reverse lookup
* is a Meilisearch filter query against `recommendations.id`, not a
* database join.
*
* Handles both ProductSaved (name/price/image changed — referencing
* products' embedded copy is stale) and ProductDeleted (the recommended
* product no longer exists at all — referencing products need to drop it
* and, since RecommendationService tops up to its limit, naturally pick up
* a replacement on reindex). Same reverse lookup either way, just a
* different source for the id being searched for.
*
* Re-indexing via ->searchable() dispatches Scout's own (queued, if
* SCOUT_QUEUE is configured) reindex job per matched product — this
* listener itself does no synchronous Meilisearch writing.
*/
class ReindexProductsRecommendingProduct implements ShouldQueue
{
public function handleSaved(ProductSaved $event): void
{
$this->reindexReferencingProducts($event->product->id);
}
public function handleDeleted(ProductDeleted $event): void
{
$this->reindexReferencingProducts($event->productId);
}
private function reindexReferencingProducts(int $productId): void
{
$hits = Product::search('')
->options([
'filter' => "recommendations.id = \"{$productId}\"",
'attributesToRetrieve' => ['id'],
// Meilisearch's own hitsPerPage default (20) would silently
// drop referencing products past that count — this is a
// reverse lookup, not a paginated storefront result, so it
// needs every match, up to Meilisearch's hard limit.
'hitsPerPage' => 1000,
])
->raw()['hits'] ?? [];
$ids = collect($hits)->pluck('id')->unique()->values();
if ($ids->isEmpty()) {
return;
}
Product::whereIn('id', $ids)->get()->each->searchable();
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\Catalog\Models;
/**
* Registered via Lunar\Facades\ModelManifest::replace(Lunar\Models\
* Product::class, self::class) — see Providers\CatalogServiceProvider —
* purely to add a cast AND fillable entry for `custom_fields` (see the
* migration adding that column: database/migrations/
* ..._add_custom_fields_to_products_table.php). Without the fillable
* entry, Lunar\Models\Product's own $fillable allowlist (attribute_data,
* product_type_id, status, brand_id — custom_fields isn't in it) silently
* drops the field on every mass-assignment save (Filament's own
* $record->update($data)) — no error, no exception, the admin form shows
* the repeater's rows as saved right up until the next page load, when
* they're simply gone. Caught in practice.
*
* ModelManifest::replace() only changes what code resolving Product
* through the CONTRACT (app(Contracts\Product::class), Filament's own
* ProductResource — its $model is ProductContract::class, not the
* concrete class) or the morph map receives — it does NOT retroactively
* change what a hardcoded `Lunar\Models\Product::query()`/`::find()`
* elsewhere in this codebase (or Lunar's own internals, e.g. the
* scheduled Meilisearch reindex command — see CatalogServiceProvider,
* which references this subclass by name specifically so that path picks
* it up too) resolves to. Most of this codebase's existing Product
* references are plain type-hints (they accept whichever instance is
* handed to them, subclass included) or don't touch `custom_fields` at
* all, so they're unaffected either way.
*/
class Product extends \Lunar\Models\Product
{
// NOT `protected $casts = [...]` — that property assignment REPLACES
// the parent's own $casts array wholesale rather than merging with
// it (PHP class property redeclaration has no merge semantics), which
// would silently drop every cast Lunar\Models\Product already
// defines (attribute_data, status, etc.). mergeCasts() is Eloquent's
// own documented mechanism for a subclass adding to, not replacing,
// its parent's casts.
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->mergeCasts([
'custom_fields' => 'array',
]);
$this->mergeFillable([
'custom_fields',
]);
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Catalog\Recommendations;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
/**
* The universal fallback — always returns something as long as the store
* has more than one product, since it has no eligibility condition of its
* own to come up empty on. Meant to be placed last in
* config('catalog.recommendation_rules'), not first: every store using
* the default chain gets a real fallback, but one that only kicks in once
* more specific rules (same category, same tag, ...) have had a chance.
*/
class RandomRule implements RecommendationRule
{
public function recommend(Product $product, int $limit, array $exclude): Collection
{
return Product::query()
->whereKeyNot($exclude)
->inRandomOrder()
->limit($limit)
->get();
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Catalog\Recommendations;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
/**
* Recommends other products sharing at least one of $product's directly-
* assigned collections — takes $product's first collection (a product
* usually has one primary category; if it has several, the first is as
* good a choice as any without a "primary collection" concept to prefer).
* Returns nothing if $product has no collection at all, letting the next
* rule in the chain (see RecommendationRule's docblock) take over.
*
* Queries Eloquent directly rather than going through Modules\Core\Catalog\
* Services\ProductService — this runs at index time (see
* RecommendationRule's docblock), where Meilisearch may be mid-reindex for
* this very product and ProductService::list()'s locale-resolution has no
* meaningful "current locale" to resolve against anyway.
*/
class SameCategoryRule implements RecommendationRule
{
public function recommend(Product $product, int $limit, array $exclude): Collection
{
$collection = $product->collections->first();
if ($collection === null) {
return collect();
}
return $collection->products()
->whereKeyNot($exclude)
->inRandomOrder()
->limit($limit)
->get();
}
}
+82 -3
View File
@@ -5,6 +5,7 @@ namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Lunar\Models\Currency;
use Lunar\Models\OrderLine;
use Lunar\Models\Price;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
@@ -27,8 +28,14 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* - slugs (every locale's Url::slug for the product, filterable) — lets
* ProductService::getBySlug() resolve a product from the index directly, with
* no database read at all
* - skus (every variant's sku, deduplicated, filterable) — same "resolve from the
* index alone" reasoning as slugs, for a future SKU-based lookup/filter
* - price (cheapest variant, filterable) and full per-variant pricing
* - variants: sku, stock, purchasable, option values, prices, media
* - variants: sku, gtin, mpn, ean, stock, backorder, unit_quantity, purchasable,
* shippable, tax_ref, dimensions (length/width/height/weight/volume, each
* {value, unit}), option values, prices, media — the variant's own images
* (ProductVariant::images(), separate from the product's gallery below), not
* the product's own media repeated per variant
* - the full media gallery (not just the single thumbnail Lunar's base indexer sends)
* - tags
* - reviews: {items: [...], count, average_rating} — items are public-safe fields
@@ -41,8 +48,27 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* quantity 1, via ProductVariant::canBeFulfilledAtQuantity() (Lunar's own
* purchasability rule: `purchasable === 'always'` is always true regardless of
* stock, `in_stock` checks stock alone, anything else checks stock+backorder).
* Reflects stock as of the last reindex only — nothing currently reindexes a
* product when an order decrements its stock (see docs/product-listing.md).
* Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced reindexes a product
* the moment an order placed against it decrements its stock — see that
* class's own docblock for why only `purchasable === 'in_stock'`
* variants are ever touched. Any other stock edit (a manual admin
* change, a future inventory-sync integration) still only reflects here
* as of the next reindex (see docs/product-listing.md).
*
* - recommendations (recommendations.id filterable): [{id, name, price, image}, ...]
* up to 4 other products to show alongside this one (a "related products"
* section), sourced from Modules\Core\Catalog\Services\RecommendationService's
* configured rule chain (config('catalog.recommendation_rules')). Embedded
* card data, not just ids, same reasoning as `collections`: renders
* directly with zero extra Meilisearch calls. `name` is resolved via
* translateAttribute() at index time (not through ProductService's
* per-request locale resolution, since indexing has no "current locale"
* the way a storefront request does) — same known index-time-locale
* tradeoff `collections` already has. `recommendations.id` is filterable
* specifically so Modules\Core\Catalog\Listeners\
* ReindexProductsRecommendingProduct can find every product currently
* recommending a given one, when that one changes — there's no Postgres
* relation for this, a recommendation only exists inside the index.
*
* A review is created/edited independently of its product (Modules\Core\Providers\
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
@@ -67,6 +93,9 @@ class ProductIndexer extends BaseProductIndexer
'slugs',
'channel_ids',
'in_stock',
'recommendations.id',
'skus',
'tags',
];
}
@@ -75,6 +104,7 @@ class ProductIndexer extends BaseProductIndexer
return [
...parent::getSortableFields(),
'price',
'order_count',
];
}
@@ -110,6 +140,13 @@ class ProductIndexer extends BaseProductIndexer
->values()
->all();
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
$data['skus'] = $model->variants->pluck('sku')->filter()->unique()->values()->all();
// Only decoded correctly when $model is an instance of
// Modules\Core\Catalog\Models\Product (the custom_fields cast
// lives there, not on the base Lunar\Models\Product) — see
// CatalogServiceProvider's own comment on why the scheduled
// reindex command references that subclass by name specifically.
$data['custom_fields'] = $model->custom_fields ?? [];
$data['tags'] = $model->tags->pluck('value')->all();
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
@@ -126,6 +163,34 @@ class ProductIndexer extends BaseProductIndexer
$data['in_stock'] = $model->variants->contains(
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
);
// Same "popular" definition as Lunar's own admin dashboard widget
// (Lunar\Admin\Filament\Widgets\Dashboard\Orders\
// PopularProductsTable) — order-line COUNT, not summed quantity,
// over the trailing year, physical lines only — just aggregated
// per PRODUCT here (across all its variants) rather than per
// variant/identifier, since a storefront "sort by popularity"
// ranks products, not individual variant SKUs. Necessarily as
// stale as any other reindex-time field here (in_stock, price) —
// there's no live equivalent without a query per page load.
$data['order_count'] = OrderLine::query()
->whereIn('purchasable_id', $model->variants->pluck('id'))
->where('purchasable_type', 'product_variant')
->where('type', 'physical')
->whereHas('order', fn ($query) => $query->whereBetween('placed_at', [
now()->subYear()->startOfDay(),
now()->endOfDay(),
]))
->count();
$data['recommendations'] = app(RecommendationService::class)
->recommend($model)
->load(['media', 'variants.prices'])
->map(fn (Product $recommendation) => [
'id' => $recommendation->id,
'name' => $recommendation->translateAttribute('name'),
'price' => $this->cheapestPrice($recommendation, $currency),
'image' => $recommendation->media->first() ? $this->mapMedia($recommendation->media->first())['thumb'] : null,
])
->all();
return $data;
}
@@ -135,8 +200,22 @@ class ProductIndexer extends BaseProductIndexer
return [
'id' => $variant->id,
'sku' => $variant->sku,
'gtin' => $variant->gtin,
'mpn' => $variant->mpn,
'ean' => $variant->ean,
'stock' => $variant->stock,
'backorder' => $variant->backorder,
'unit_quantity' => $variant->unit_quantity,
'purchasable' => $variant->purchasable,
'shippable' => $variant->shippable,
'tax_ref' => $variant->tax_ref,
'dimensions' => [
'length' => ['value' => $variant->length_value, 'unit' => $variant->length_unit],
'width' => ['value' => $variant->width_value, 'unit' => $variant->width_unit],
'height' => ['value' => $variant->height_value, 'unit' => $variant->height_unit],
'weight' => ['value' => $variant->weight_value, 'unit' => $variant->weight_unit],
'volume' => ['value' => $variant->volume_value, 'unit' => $variant->volume_unit],
],
'options' => $variant->values->map(fn ($value) => [
'option' => $this->translatedName($value->option->name),
'handle' => $value->option->handle,
+90 -22
View File
@@ -2,11 +2,15 @@
namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Pagination\LengthAwarePaginator;
use Lunar\Facades\AttributeManifest;
use Lunar\Models\Language;
use Lunar\Models\Product;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\DTOs\ProductListingResult;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Support\ProductDocumentLocalizer;
use Modules\Core\Catalog\Support\ProductFilterBuilder;
/**
* Lunar's Meilisearch indexer flattens translated attributes into locale-suffixed
@@ -17,39 +21,103 @@ use Lunar\Models\Product;
*/
class ProductSearchService
{
/**
* @return Collection<int, Product>
*/
public function search(string $query, ?string $locale = null): Collection
{
$locale ??= App::getLocale();
$defaultLocale = Language::getDefault()->code;
public function __construct(
private readonly ProductFilterBuilder $filterBuilder,
private readonly ProductDocumentLocalizer $localizer,
private readonly ProductService $products,
) {}
return Product::search($query)
->options([
'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale),
])
->get();
/**
* Returns the exact same Modules\Core\Catalog\DTOs\ProductListingResult
* ProductService::list() does — a search results page and a category
* listing page consume identically shaped data, one call each. The
* paginator itself carries plain, localized indexed-document arrays
* (not hydrated Product models), same as list().
*
* priceBounds/availableTags are delegated to ProductService's own
* priceSliderBounds()/availableTags() rather than reimplemented here —
* both already accept a $query param for exactly this reason (a search
* page's slider/tag sidebar should reflect only the products search
* actually matched, not the whole catalog).
*
* $filters/$sort apply the exact same semantics ProductService::list()
* uses for collection browsing (same ProductFilterBuilder, same
* ProductSort::toMeilisearchSort()) — a shopper narrowing a text search
* by price/brand/stock gets identical filter behavior to narrowing a
* category listing, since both go through the same Meilisearch `filter`
* clause underneath.
*/
public function search(
string $query,
?ProductFilters $filters = null,
?ProductSort $sort = null,
int $perPage = 24,
int $page = 1,
): ProductListingResult {
$options = [
'attributesToSearchOn' => $this->searchableFields(),
'filter' => $this->filterBuilder->build($filters),
];
if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()];
}
$paginator = Product::search($query)
->options($options)
->paginateRaw(perPage: $perPage, page: $page);
$data = collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
$products = new LengthAwarePaginator(
items: $data,
total: $paginator->total(),
perPage: $paginator->perPage(),
currentPage: $paginator->currentPage(),
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
);
$priceBounds = $this->products->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice, $query);
$availableTags = $this->products->availableTags($filters, $query);
return new ProductListingResult($products, $priceBounds, $availableTags);
}
/**
* Target the resolved locale's fields plus the default locale's fields, so a
* product that's only ever been translated into the default language still
* surfaces when searched in another locale, instead of becoming invisible
* until every product is fully translated.
* Targets every configured store language's fields, not just the current
* request locale plus the store default — a shopper browsing in Greek
* typing an English word (or vice versa) should still match a product
* whose only translation for that text happens to be in a third
* language. There's no per-request "current locale" concept in this
* method any more: which fields exist to search on is a property of the
* store's configured languages, not of who's asking.
*
* Also targets variants.options.value directly — a variant's option
* value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is how ProductIndexer
* already indexes it (see mapVariant()), but it isn't one of Lunar's own
* attributes, so it can't come from AttributeManifest the way name/
* description do; it's a structural field of the document, added here
* directly instead. Not locale-suffixed like the attribute-manifest
* fields — option values are stored as one already-resolved string per
* variant (see ProductIndexer::translatedName()), not per-locale.
*
* @return array<int, string>
*/
private function searchableFields(string $locale, string $defaultLocale): array
private function searchableFields(): array
{
$handles = AttributeManifest::getSearchableAttributes(Product::morphName())
->pluck('handle');
$locales = array_unique([$locale, $defaultLocale]);
$locales = Language::all()->pluck('code');
return $handles
$attributeFields = $handles
->crossJoin($locales)
->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}")
->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}");
return $attributeFields
->push('variants.options.value')
->values()
->all();
}
+172 -103
View File
@@ -2,16 +2,14 @@
namespace Modules\Core\Catalog\Services;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache;
use Modules\Core\Catalog\DTOs\PriceSliderBounds;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\DTOs\ProductListingResult;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Support\ProductDocumentLocalizer;
use Modules\Core\Catalog\Support\ProductFilterBuilder;
/**
* Storefront product listing/filtering AND single-product lookup, all reading directly
@@ -25,19 +23,34 @@ use Modules\Core\Catalog\Enums\ProductSort;
class ProductService
{
public function __construct(
private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes,
private readonly ProductDocumentLocalizer $localizer,
private readonly ProductFilterBuilder $filterBuilder,
) {}
/**
* Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result -
* see "Meilisearch driver quirk" below) so a controller/view gets normal
* pagination behaviour ($products->links(), JSON serialization, etc.)
* without ever touching the raw Meilisearch response directly.
* One call for everything a listing page needs: the product page AND
* the price slider's bounds — a controller used to have to call this
* plus priceSliderBounds() separately and glue the results together
* itself; that orchestration now happens in here instead. Still issues
* two Meilisearch requests under the hood (the product search, and a
* separate price-facet-stats query — see priceSliderBounds()'s
* docblock for why they can't be merged into one without changing the
* slider's own UX), but the caller only ever makes one call.
*
* $filters->minPrice/$filters->maxPrice double as both the applied
* product filter AND the "is the slider actually narrowed" comparison
* in priceSliderBounds() — the same values, used two ways, so nothing
* new needs to be threaded through separately.
*
* The paginator itself is a real LengthAwarePaginator (not Scout's own
* paginateRaw() result - see "Meilisearch driver quirk" below) so a
* controller/view gets normal pagination behaviour ($products->links(),
* JSON serialization, etc.) without ever touching the raw Meilisearch
* response directly.
*/
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): ProductListingResult
{
$options = ['filter' => $this->buildFilter($filters)];
$options = ['filter' => $this->filterBuilder->build($filters)];
if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()];
@@ -47,17 +60,48 @@ class ProductService
->options($options)
->paginateRaw(perPage: $perPage, page: $page);
$data = collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
$data = collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
return new LengthAwarePaginator(
$products = new LengthAwarePaginator(
items: $data,
total: $paginator->total(),
perPage: $paginator->perPage(),
currentPage: $paginator->currentPage(),
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
);
$priceBounds = $this->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice);
$availableTags = $this->availableTags($filters);
return new ProductListingResult($products, $priceBounds, $availableTags);
}
/**
* Every distinct `tags` value present on a product matching $filters,
* excluding $filters->tag itself — same "scoped but not self-collapsing"
* reasoning as priceRange() excluding `price` — so selecting a tag
* doesn't shrink the sidebar down to just that one tag. Sorted
* alphabetically; Meilisearch's facetDistribution has no defined order
* of its own.
*
* $query defaults to '' (every product, same as list()'s own default
* text query) — same reasoning as priceRange()'s own $query: pass the
* shopper's search text here too so a search page's own tag sidebar
* reflects only the products search actually matched. Public (not
* private, unlike the rest of this listing-only orchestration) so
* ProductSearchService::search() can reuse it directly rather than
* reimplementing the same facet call a second time.
*
* @return array<int, string>
*/
public function availableTags(?ProductFilters $filters, string $query = ''): array
{
$filter = $this->filterBuilder->build($filters, exclude: ['tag']);
$tags = $this->rawFacets('tags', $filter, $query)['facetDistribution']['tags'] ?? [];
return collect($tags)->keys()->sort()->values()->all();
}
/**
@@ -71,15 +115,18 @@ class ProductService
* apply that field's own filter separately in the UI/query layer.
*
* `$field` must be one of ProductIndexer's filterable fields; only discrete-value
* fields make sense here (`brand`, `in_stock`) — a numeric field like `price`
* would return one "facet" per exact price, not a usable range bucket. Use
* `priceRange()` for `price` instead.
* fields make sense here (`brand`, `tags`, `in_stock`) — a numeric field like
* `price` would return one "facet" per exact price, not a usable range bucket.
* Use `priceRange()` for `price` instead. `facets('tags', $filters)` is how a
* category page gets "which tags actually appear on products in this category" —
* pass a $filters that omits `tag` (see `build()`'s $exclude) so the tag list
* itself doesn't collapse to whichever tag is already selected.
*
* @return array<string, int> facet value => matching product count
*/
public function facets(string $field, ?ProductFilters $filters = null): array
{
return $this->rawFacets($field, $this->buildFilter($filters))['facetDistribution'][$field] ?? [];
return $this->rawFacets($field, $this->filterBuilder->build($filters))['facetDistribution'][$field] ?? [];
}
/**
@@ -90,12 +137,17 @@ class ProductService
* not `facetDistribution` — the right feature for a numeric field's range,
* where `facets('price')` would otherwise return one entry per exact price.
*
* $query defaults to '' (every product, same as list()'s own default text
* query) — pass the shopper's search text here too so a search page's own
* price slider spans only the products that search actually matched,
* rather than the whole catalog's price range.
*
* @return array{min: ?float, max: ?float} null/null if no product matches
*/
public function priceRange(?ProductFilters $filters = null): array
public function priceRange(?ProductFilters $filters = null, string $query = ''): array
{
$filter = $this->buildFilter($filters, exclude: ['price']);
$stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null;
$filter = $this->filterBuilder->build($filters, exclude: ['price']);
$stats = $this->rawFacets('price', $filter, $query)['facetStats']['price'] ?? null;
return [
'min' => $stats['min'] ?? null,
@@ -103,9 +155,36 @@ class ProductService
];
}
private function rawFacets(string $field, ?string $filter): array
/**
* priceRange() rounded to whole euros (floor/ceil, so the slider's ends
* are never tighter than what's actually in range) plus whether
* $selectedMinPrice/$selectedMaxPrice actually narrow it — the same
* "floor/ceil + is this a real filter" rule CategoryController and
* SearchController each used to duplicate inline. $selectedMinPrice/
* $selectedMaxPrice are the currently-applied filter values (e.g.
* CategoryListing::$minPrice), not part of $filters itself, since
* $filters here must already exclude price the way priceRange() expects.
*/
public function priceSliderBounds(
?ProductFilters $filters,
?float $selectedMinPrice,
?float $selectedMaxPrice,
string $query = '',
): PriceSliderBounds {
$priceRange = $this->priceRange($filters, $query);
$floor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null;
$ceil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null;
$filtered = ($selectedMinPrice !== null && $selectedMinPrice > ($floor ?? PHP_INT_MIN))
|| ($selectedMaxPrice !== null && $selectedMaxPrice < ($ceil ?? PHP_INT_MAX));
return new PriceSliderBounds($floor, $ceil, $filtered);
}
private function rawFacets(string $field, ?string $filter, string $query = ''): array
{
return Product::search('')
return Product::search($query)
->options([
'filter' => $filter,
'facets' => [$field],
@@ -133,99 +212,89 @@ class ProductService
return $this->findOneWhere("id = \"{$id}\"");
}
private function findOneWhere(string $filter): ?array
{
$paginator = Product::search('')
->options(['filter' => $filter])
->paginateRaw(perPage: 1, page: 1);
$product = $this->hitsFrom($paginator)[0] ?? null;
return $product !== null ? $this->withLocalizedFields($product) : null;
}
/**
* Resolves every translated Product attribute's current-locale value from the
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`,
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's
* default language (LanguageCache::defaultLocale()) when the current locale
* has no translation - e.g. a product with no English copy yet still shows its
* Greek name on /en/ rather than rendering blank.
* The id/price/image of every variant on a product document (from
* getById()/getBySlug()'s own 'variants' array) — the base price and
* thumbnail a variant picker/swatch list needs, without a caller
* reaching into $product['variants'][n]['prices'][0]/['media'][0]
* itself. Domain shaping (which price/image represents a variant),
* not presentation — a card's href/layout stays a storefront concern
* (e.g. App\Catalog\ProductCard in 3dealer), but "the variant's price
* is its first price row" is a rule about the data, true regardless of
* which app renders it.
*
* Which handles are translated is read from AttributeManifest - the same
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
* picked up automatically with no change here. The raw per-locale keys are
* then stripped, since once resolved, callers only ever need the one that
* matched the current locale.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
* @param array $product A document from getById()/getBySlug().
* @return array<int, array{id: int, price: ?float, image: ?string}>
*/
private function withLocalizedFields(array $product): array
public function variantSummaries(array $product): array
{
$locale = App::getLocale();
$fallbackLocale = $this->languages->defaultLocale();
$availableLocales = $this->languages->availableLocales();
foreach ($this->translatedAttributeHandles() as $handle) {
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null;
foreach ($availableLocales as $availableLocale) {
unset($product[$handle.'_'.$availableLocale]);
}
}
return $product;
}
/**
* @return array<int, string>
*/
private function translatedAttributeHandles(): array
{
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
->pluck('handle')
return collect($product['variants'] ?? [])
->map(fn (array $variant) => [
'id' => $variant['id'],
'price' => $variant['prices'][0]['price'] ?? null,
'image' => $variant['media'][0]['url'] ?? null,
])
->values()
->all();
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
* actual documents are under the 'hits' key.
* $limit random products, still scoped to the index's own default
* visibility (channel/status), unlike Eloquent's Product::inRandomOrder()
* which has no notion of that filtering at all — a random pick can never
* surface a hidden/unpublished product this way. Meilisearch itself has
* no ORDER BY RANDOM() equivalent, so this pulls every matching id only
* (attributesToRetrieve: ['id'], the lightest possible request — no
* name/media/variants/etc. for documents that will mostly be discarded),
* shuffles in PHP, then fetches the full localized documents for just
* the $limit ids actually picked.
*
* @return array<int, array>
*/
private function hitsFrom(LengthAwarePaginatorContract $paginator): array
public function random(int $limit): array
{
$rawResponse = $paginator->items();
$raw = Product::search('')
->options([
'attributesToRetrieve' => ['id'],
'filter' => $this->filterBuilder->withVisibility(),
])
->raw();
return collect($rawResponse['hits'] ?? [])->values()->all();
$ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values();
if ($ids->isEmpty()) {
return [];
}
// Meilisearch's `id IN [...]` doesn't preserve the given order — it's
// an unordered set filter, not a list to iterate — so the shuffle
// above would otherwise be silently undone by whatever order the
// re-fetch comes back in. Re-sort the fetched documents back into
// $ids's already-shuffled order instead of trusting the response's.
$products = collect($this->findAllWhere('id IN ['.$ids->implode(', ').']'))
->keyBy('id');
return $ids->map(fn ($id) => $products->get($id))->filter()->values()->all();
}
private function findOneWhere(string $filter): ?array
{
$products = $this->findAllWhere($filter, limit: 1);
return $products[0] ?? null;
}
/**
* @param array<int, 'collectionId'|'brand'|'price'|'inStockOnly'> $exclude filter
* fields to leave out even if set on $filters — e.g. priceRange() excludes
* 'price' so a price slider's own bounds don't shrink to whatever range is
* already selected on it.
* @return array<int, array>
*/
private function buildFilter(?ProductFilters $filters, array $exclude = []): ?string
private function findAllWhere(string $filter, int $limit = 1000): array
{
if ($filters === null) {
return null;
}
$paginator = Product::search('')
->options(['filter' => $this->filterBuilder->withVisibility($filter)])
->paginateRaw(perPage: $limit, page: 1);
$clauses = Collection::make([
'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
'price' => Collection::make([
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
])->filter()->join(' AND ') ?: null,
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
])->except($exclude)->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
return collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
}
}
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
/**
* Runs each rule in config('catalog.recommendation_rules'), in order,
* topping up from each successive rule until $limit distinct products are
* collected or every rule is exhausted — e.g. 3 from SameCategoryRule
* (the product's category only has 3 other products) + 1 from RandomRule.
* No rule is special-cased as "the fallback" here; a store gets fallback
* behaviour purely by how it orders its own config (e.g. SameCategoryRule
* before RandomRule). Never returns the same product twice even if two
* rules would both suggest it (see RecommendationRule's $exclude), and
* never returns fewer than $limit unless the store genuinely doesn't have
* that many other products at all.
*/
class RecommendationService
{
/**
* @return Collection<int, Product>
*/
public function recommend(Product $product, int $limit = 4): Collection
{
$recommendations = new Collection();
foreach (config('catalog.recommendation_rules', []) as $ruleClass) {
if ($recommendations->count() >= $limit) {
break;
}
$exclude = [$product->id, ...$recommendations->pluck('id')];
$remaining = $limit - $recommendations->count();
/** @var RecommendationRule $rule */
$rule = app($ruleClass);
$recommendations = $recommendations->merge(
$rule->recommend($product, $remaining, $exclude)
);
}
return $recommendations->take($limit)->values();
}
}
@@ -0,0 +1,57 @@
<?php
namespace Modules\Core\Catalog\Services;
use Lunar\Models\ProductVariant;
/**
* Generates a SKU for every ProductVariant missing one — extracted out of
* Command\BackfillMissingSkusCommand (which becomes a thin CLI wrapper
* around this, keeping --dry-run/progress-bar concerns out of the
* reusable logic) so MigrateImport\Shopify\Services\ShopifyExportImporter can
* also call it directly, once every product job in its import batch has
* finished (see that class's own import()), with no CLI concerns at all.
*
* Format is "SKU-P{product_id}-V{variant_id}": deterministic and
* guaranteed unique without a uniqueness check, since product_id/
* variant_id already are. Only variants with a null `sku` are touched —
* not an importer bug when one shows up after a Shopify import, the
* source CSV rows genuinely had no `Variant SKU` value (see
* MigrateImport\Shopify\Services\ShopifyExportImporter).
*/
class SkuBackfillService
{
/**
* @param ?callable(ProductVariant, string): void $onEach invoked
* once per variant with the sku about to be written (or, when
* $dryRun is true, that WOULD be written) — the command's own
* --dry-run listing and progress bar hook in here without this
* service knowing anything about console output.
* @return int the number of variants processed
*/
public function backfill(bool $dryRun = false, ?callable $onEach = null): int
{
$query = ProductVariant::query()->whereNull('sku');
$total = $query->count();
if ($total === 0) {
return 0;
}
$query->chunkById(500, function ($variants) use ($dryRun, $onEach) {
foreach ($variants as $variant) {
$sku = "SKU-P{$variant->product_id}-V{$variant->id}";
if (! $dryRun) {
$variant->update(['sku' => $sku]);
}
if ($onEach !== null) {
$onEach($variant, $sku);
}
}
});
return $total;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Order;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
/**
* The one place ProductVariant::stock is written as a result of an order —
* previously this lived entirely inside Modules\Core\Order\Listeners\
* DecrementStockOnOrderPlaced, a listener with no Service behind it at
* all, even though stock (the column, its invariants — "never negative",
* "only in_stock variants") is fundamentally a Catalog concern, not an
* Order one. That listener is now a thin caller of this class, matching
* how every other module's event reaction delegates its actual write to
* a Service (e.g. Modules\Core\Order\Listeners\RecordPaymentTransaction
* -> Modules\Core\Order\Services\TransactionRecorder).
*
* Only decrements for `purchasable === 'in_stock'` variants — 'always' and
* 'backorder' variants are deliberately allowed to sell past (or without
* regard to) their stock count already (see ProductVariant::
* canBeFulfilledAtQuantity()), so decrementing their stock would just make
* that column an inaccurate, decreasingly-negative number with no purchasing
* consequence. Only `OrderLine::type === 'physical'` lines are considered —
* a digital line has no stock to decrement (ProductVariant::getType()).
*
* A single UPDATE per variant (`DB::table(...)->update()` with a raw
* expression), not a read-then-write on the Eloquent model — avoids a
* lost-update race between two orders decrementing the same variant
* concurrently, and skips Modules\Core\Catalog\Services\ProductIndexer::
* stock's staleness gap for the DB value itself even though the search
* index still only refreshes on the next reindex event/nightly job (see
* that class's own docblock).
*
* Never lets stock go negative (`GREATEST(stock - qty, 0)` via a raw
* expression) — an order can still be placed against a variant whose stock
* was already fully consumed by another concurrent order (Lunar has no
* stock-reservation step at cart/checkout time), so this is a best-effort
* count, not a hard inventory guarantee.
*/
class StockService
{
public function decrementForOrder(Order $order): void
{
$lines = $order->lines()
->where('type', 'physical')
->where('purchasable_type', ProductVariant::morphName())
->get(['purchasable_id', 'quantity']);
if ($lines->isEmpty()) {
return;
}
foreach ($lines as $line) {
DB::table((new ProductVariant())->getTable())
->where('id', $line->purchasable_id)
->where('purchasable', 'in_stock')
->update([
'stock' => DB::raw('GREATEST(stock - '.(int) $line->quantity.', 0)'),
]);
}
$productIds = ProductVariant::whereIn('id', $lines->pluck('purchasable_id'))
->pluck('product_id')
->unique();
Product::whereIn('id', $productIds)->get()->each->searchable();
}
}
@@ -0,0 +1,132 @@
<?php
namespace Modules\Core\Catalog\Support;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Support\Facades\App;
use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache;
/**
* Shared between Modules\Core\Catalog\Services\ProductService and
* ProductSearchService — both read the same kind of Meilisearch document
* (Modules\Core\Catalog\Services\ProductIndexer's shape) and need the
* exact same per-locale field resolution and raw-response unwrapping.
* Extracted rather than duplicated so a future fix to the localization-
* fallback logic only needs to be made once.
*/
class ProductDocumentLocalizer
{
public function __construct(
private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes,
) {}
/**
* Resolves every translated Product attribute's current-locale value from the
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`,
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's
* default language (LanguageCache::defaultLocale()) when the current locale
* has no translation - e.g. a product with no English copy yet still shows its
* Greek name on /en/ rather than rendering blank.
*
* Which handles are translated is read from AttributeManifest - the same
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
* picked up automatically with no change here. The raw per-locale keys are
* then stripped, since once resolved, callers only ever need the one that
* matched the current locale.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
*/
public function withLocalizedFields(array $product): array
{
$locale = App::getLocale();
$fallbackLocale = $this->languages->defaultLocale();
$availableLocales = $this->languages->availableLocales();
foreach ($this->translatedAttributeHandles() as $handle) {
// filled(), not ?? - a translated attribute saved blank for
// the current locale still has that {handle}_{locale} key in
// the document, just set to '' rather than absent. ?? only
// falls back on a missing/null key, so it kept the empty
// string instead of falling through to a locale that actually
// has content.
$product[$handle] = filled($product[$handle.'_'.$locale] ?? null)
? $product[$handle.'_'.$locale]
: ($product[$handle.'_'.$fallbackLocale] ?? null);
foreach ($availableLocales as $availableLocale) {
unset($product[$handle.'_'.$availableLocale]);
}
}
if (! empty($product['custom_fields'])) {
$product['custom_fields'] = $this->localizeCustomFields($product['custom_fields'], $locale, $fallbackLocale);
}
return $product;
}
/**
* Product::$custom_fields isn't an AttributeManifest attribute (it's a
* plain JSON column, see Catalog\Models\Product's own docblock), so it
* never goes through the {handle}_{locale} explosion above — the
* indexer copies it straight through (see ProductIndexer), meaning
* each item's `label`/`help_text` still arrives here as a raw
* {locale: string} object (or, for a product saved before those
* became translatable, a plain string). Resolved the same filled()-
* over-?? way as every other translated field above, to the same
* single current-locale string the storefront/cart already expect
* (see product-custom-fields.blade.php and CartController::
* customFieldsMeta()) — a repeater item has no other reason to reach
* the storefront untouched.
*
* @param array<int, array<string, mixed>> $fields
* @return array<int, array<string, mixed>>
*/
private function localizeCustomFields(array $fields, string $locale, ?string $fallbackLocale): array
{
return array_map(function (array $field) use ($locale, $fallbackLocale) {
foreach (['label', 'help_text'] as $key) {
if (! is_array($field[$key] ?? null)) {
continue;
}
$field[$key] = filled($field[$key][$locale] ?? null)
? $field[$key][$locale]
: ($field[$key][$fallbackLocale] ?? null);
}
return $field;
}, $fields);
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
* actual documents are under the 'hits' key.
*/
public function hitsFrom(LengthAwarePaginatorContract $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
/**
* @return array<int, string>
*/
private function translatedAttributeHandles(): array
{
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
->pluck('handle')
->all();
}
}
@@ -0,0 +1,69 @@
<?php
namespace Modules\Core\Catalog\Support;
use Illuminate\Support\Collection;
use Modules\Core\Catalog\DTOs\ProductFilters;
/**
* Builds a Meilisearch `filter` clause from a ProductFilters DTO — extracted
* out of ProductService (where it originated, scoped to browsing/filtering
* without a search term) so ProductSearchService can apply the exact same
* filter semantics to a text query too, rather than reimplementing it.
*
* Also the single place that composes the draft-visibility clause (see
* withVisibility()) — every Meilisearch `filter` string ProductService
* constructs, including the handful of ad-hoc ones that don't call build()
* at all (getById()/getBySlug()'s id lookup, random()'s id-only fetch),
* goes through this class so none of them can silently omit it the way a
* status filter was missing everywhere until now.
*/
class ProductFilterBuilder
{
/**
* @param array<int, 'collectionId'|'brand'|'tag'|'price'|'inStockOnly'> $exclude
* filter fields to leave out even if set on $filters — e.g.
* ProductService::priceRange() excludes 'price' so a price slider's own
* bounds don't shrink to whatever range is already selected on it.
*/
public function build(?ProductFilters $filters, array $exclude = []): string
{
if ($filters === null) {
return $this->withVisibility();
}
$clauses = Collection::make([
'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
'tag' => $filters->tag !== null ? 'tags = "'.addcslashes($filters->tag, '"\\').'"' : null,
'price' => Collection::make([
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
])->filter()->join(' AND ') ?: null,
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
])->except($exclude)->filter();
return $this->withVisibility($clauses->isEmpty() ? null : $clauses->join(' AND '));
}
/**
* A draft product (status = 'draft', see Lunar\Filament\Resources\
* ProductResource's own status Select) is only ever visible while
* APP_DEBUG is true — a merchant/developer previewing an unfinished
* product locally or on a staging box, never a real storefront
* visitor. Every ProductService method that builds a Meilisearch
* `filter` string, build() included, calls this rather than passing
* $rawClause straight to Product::search() — the one seam that
* guarantees none of them can omit the visibility rule.
*
* Always returns a non-empty string (never null) — a bare
* 'status = "published"' is itself a complete, valid Meilisearch
* filter on its own when $rawClause is null.
*/
public function withVisibility(?string $rawClause = null): string
{
$visibility = config('app.debug') ? null : 'status = "published"';
return Collection::make([$visibility, $rawClause])->filter()->join(' AND ');
}
}
-62
View File
@@ -1,62 +0,0 @@
<?php
namespace Modules\Core\Checkout\Contracts;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Models\Cart;
use Lunar\Models\Order;
/**
* A boboko-owned payment driver — wraps a payment gateway's own confirmation
* mechanics (Stripe's synchronous authorize() call, a redirect-based
* provider's async callback/webhook, anything else) behind one uniform
* moment: "payment is confirmed, place the order."
*
* confirm() is the only thing a driver is required to do: once it has,
* by whatever mechanism is native to that gateway, independently decided
* the payment succeeded, it calls Modules\Core\Checkout\Services\
* CheckoutService::placeOrder($fingerprint) itself — no driver ever calls
* Lunar\Models\Cart::createOrder() directly. This is what lets the
* storefront checkout sequence stay uniform regardless of which provider is
* active: set addresses, select shipping, hand off to whichever driver is
* configured, and the driver decides when (or whether) the order actually
* gets created. See docs/checkout.md / docs/payments.md.
*/
interface PaymentDriver
{
/**
* Whether this driver can actually be used right now — e.g. Stripe
* checking its own API key is present, an offline-style driver always
* returning true since it has no external dependency. Independent of
* Modules\Core\Payment\Models\PaymentMethod::enabled (the admin
* on/off toggle) — CheckoutService::getPaymentMethods() combines both:
* a type is only offered to the storefront if it's administratively
* enabled AND its driver reports itself configured.
*/
public function isConfigured(): bool;
/**
* $type is the payment type key being confirmed (e.g. 'cash-in-hand',
* 'cash-on-delivery', 'stripe') — passed through even though most
* drivers only ever serve one type, because a driver shared across
* several types (e.g. one "no real confirmation" offline driver behind
* both cash-in-hand and cash-on-delivery) needs it to look up that
* type's own config (e.g. its 'authorized' status) rather than another
* type's.
*
* $data carries whatever the gateway needs to confirm this specific
* payment (Stripe: ['payment_intent' => $id], a redirect-based
* provider: its callback payload) — passed explicitly by the caller
* (a controller, a webhook job) rather than a driver reaching into the
* global request(), so confirm() works the same whether it's called
* from a synchronous HTTP request or an async webhook/job with no
* active request at all.
*
* @param array<string, mixed> $data
*
* @throws FingerprintMismatchException
* @throws CartException
*/
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order;
}
+11 -7
View File
@@ -5,13 +5,17 @@ namespace Modules\Core\Checkout\Events;
use Lunar\Models\Order;
/**
* Dispatched by CheckoutService::placeOrder() the moment an Order exists —
* the handoff point between Checkout and Order (see docs/checkout.md's
* "Three-stage lifecycle"). Checkout has no opinion about what happens
* after this fires; Order's own listeners (not built yet — Order is a
* named-but-unscoped concern, same status Recovery had before it existed)
* would be what reacts to it — e.g. a confirmation email, initializing
* order status tracking.
* Dispatched once an Order's placed_at is set — the handoff point between
* Checkout/Payment and Order (see docs/checkout.md's "Three-stage
* lifecycle"). Fired by Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus once it resolves a PaymentCaptured/
* PaymentAuthorized event into an actual order status change, not by
* CheckoutService directly — a draft Order can exist (via
* CheckoutService::initiatePayment()) well before this fires, if payment
* resolves asynchronously (e.g. a redirect-based gateway). Checkout has no
* opinion about what happens after this fires; Order's own listeners are
* what react to it — e.g. a confirmation email, initializing order status
* tracking.
*/
class OrderPlaced
{
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\Models\Cart;
/**
* Dispatched by CheckoutService::setRecoveryConsent() every time the
* shopper's promotional/abandoned-cart-recovery opt-in changes — including
* an explicit opt-OUT (a later submit with the checkbox unticked), not
* just an opt-in. $consent is the new value, already written to
* Cart::meta by the time this fires.
*/
class RecoveryConsentSet
{
public function __construct(
public readonly Cart $cart,
public readonly bool $consent,
) {}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Checkout\Exceptions;
use RuntimeException;
/**
* Thrown by CheckoutService::selectBoxNowLocker() when the cart has no
* shipping address yet to attach the chosen locker's meta to — the
* storefront must call setShippingAddress() first.
*/
class NoShippingAddressException extends RuntimeException
{
public function __construct()
{
parent::__construct('Cannot select a Box Now locker before a shipping address is set.');
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Checkout\Exceptions;
use RuntimeException;
/**
* Thrown by CheckoutService::initiatePayment() when $termsAccepted is
* false — an Order is a consumer contract, and its acceptance must be
* refused rather than created-then-flagged. No Lunar exception type
* covers this, same reasoning as UnknownPaymentTypeException.
*/
class TermsNotAcceptedException extends RuntimeException
{
public function __construct()
{
parent::__construct('The order cannot be placed until the terms have been accepted.');
}
}
+296 -100
View File
@@ -10,17 +10,23 @@ use Lunar\Base\Addressable;
use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\ShippingManifest;
use Lunar\Models\Cart;
use Lunar\Models\Order;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Contracts\PaymentDriver;
use Modules\Core\Checkout\Events\BillingAddressSet;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Checkout\Events\PaymentMethodSelected;
use Modules\Core\Checkout\Events\RecoveryConsentSet;
use Modules\Core\Checkout\Events\ShippingAddressSet;
use Modules\Core\Checkout\Events\ShippingOptionSelected;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\NoShippingAddressException;
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Shipping\Support\FulfillmentType;
/**
* Storefront-facing checkout operations, mirroring
@@ -29,9 +35,11 @@ use Modules\Core\Payment\Models\PaymentMethod;
* implementation detail. See docs/checkout.md for the full design —
* Checkout is the middle of a three-stage lifecycle (Cart → Checkout →
* Order): it owns the placement moment itself (address, shipping selection,
* placeOrder()) and ends the instant an Order exists. What happens to that
* Order afterward (status transitions, fulfillment) is deliberately out of
* scope here — see OrderPlaced's docblock.
* ensuring a draft Order exists) and hands off to Payment the instant that
* draft exists — see initiatePayment(). What happens to that Order
* afterward (status transitions, fulfillment) is deliberately out of
* scope here — see docs/payments.md and Checkout\Events\OrderPlaced's
* docblock for where that now lives.
*
* Depends on CartService for cart access rather than reaching into
* Lunar\Facades\CartSession directly a second time, so Checkout stays
@@ -41,11 +49,39 @@ class CheckoutService
{
public function __construct(
private readonly CartService $cart,
private readonly PaymentDriverRegistry $paymentDrivers,
private readonly PaymentMethodCache $paymentMethods,
) {}
/**
* Lunar\Actions\Carts\AddAddress (behind Cart::setShippingAddress())
* always deletes the cart's existing shipping address row and inserts
* a brand new one — it has no notion of "update in place." Every field
* on the new row therefore starts blank, including `meta`, which is
* where selectBoxNowLocker() stores the shopper's chosen locker. Since
* the checkout page autosaves the address form on every field change
* (not just once), any edit made after picking a locker — even an
* unrelated one, like delivery instructions — silently wiped the
* locker choice by recreating the row out from under it.
*
* Carries the previous row's box_now_locker forward onto the new one
* so the two features don't stomp on each other, without needing
* Lunar's own AddAddress action to change. The old row's meta is read
* BEFORE Lunar deletes it, since afterward there's nothing left to
* read.
*/
public function setShippingAddress(array|Addressable $address): Cart
{
$cart = $this->cart->currentOrCreate()->setShippingAddress($address);
$cartBefore = $this->cart->currentOrCreate();
$boxNowLocker = $cartBefore->shippingAddress?->meta['box_now_locker'] ?? null;
$cart = $cartBefore->setShippingAddress($address);
if ($boxNowLocker !== null) {
$newAddress = $cart->shippingAddress;
$newAddress->meta = [...($newAddress->meta?->toArray() ?? []), 'box_now_locker' => $boxNowLocker];
$newAddress->save();
}
Event::dispatch(new ShippingAddressSet($cart, $address));
@@ -61,6 +97,49 @@ class CheckoutService
return $cart;
}
/**
* The shopper's promotional/abandoned-cart-recovery opt-in — a
* cart-level decision, deliberately independent of setShippingAddress()/
* setBillingAddress(): consent is given once, and must NOT be reset or
* re-asked just because the shopper later changes which address is on
* the cart (a different Addressable being set is not a withdrawal of
* consent). Only an explicit call to THIS method — the checkbox itself
* being submitted, checked or unchecked — ever changes it; calling it
* again with false is exactly how a later opt-out is recorded.
*
* Stored on Cart::meta (interim, per the legal design this implements —
* a real column/consent record is the eventual target) as
* recovery_consent (bool), recovery_consent_at (ISO 8601 timestamp,
* null when $consent is false), and recovery_consent_policy_version
* (config('legal.privacy_policy_version') at the moment of consent —
* so a later dispute is answered from what was actually agreed to,
* not whatever the policy says today). Separate from any future
* newsletter opt-in — recovery consent is its own scope, never merged
* with marketing-newsletter consent.
*
* Deliberately does not merge with the meta-writing pattern
* selectPaymentMethod() uses (read-merge-save in two separate
* statements) — this writes both meta keys in one save, since there's
* no dependency between recovery_consent and anything else needing to
* be persisted first.
*/
public function setRecoveryConsent(bool $consent): Cart
{
$cart = $this->cart->currentOrCreate();
$cart->meta = [
...($cart->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$cart->save();
Event::dispatch(new RecoveryConsentSet($cart, $consent));
return $cart;
}
/**
* Every shipping option currently available for the cart — already
* fully backed by the merged Shipping-Carriers work: this runs every
@@ -92,104 +171,179 @@ class CheckoutService
$cart = $cartBefore->setShippingOption($option);
// Switching away from Box Now leaves a stale box_now_locker on the
// address's meta (see setShippingAddress()'s own docblock for why
// it survives address-row recreation) — irrelevant while a
// different method is selected, but wrong if the shopper later
// switches BACK to Box Now and it resurfaces as if still chosen,
// possibly for a locker that no longer exists/fits. Cleared here,
// the one place that knows the method just changed.
if ($identifier !== 'box-now') {
$address = $cart->shippingAddress;
if ($address && isset($address->meta['box_now_locker'])) {
$meta = $address->meta->toArray();
unset($meta['box_now_locker']);
$address->meta = $meta;
$address->save();
}
}
Event::dispatch(new ShippingOptionSelected($cart, $option));
return $cart;
}
/**
* $fingerprint is mandatory, not optional — the caller must prove the
* cart total the shopper last saw (Cart::fingerprint()) still matches
* before an order is placed. Cart::checkFingerprint() throws Lunar's own
* FingerprintMismatchException on a mismatch (a line's price changed,
* stock adjusted the total, another tab modified the cart) rather than
* silently placing an order at a different total than what was shown.
* Records the shopper's chosen Box Now locker on the cart's shipping
* address (Cart\Addresses::shippingAddress()->meta['box_now_locker']),
* not on the cart itself — Lunar\Pipelines\Order\Creation\
* CreateOrderAddresses copies every cart address's full attributes
* (meta included) onto the new order address when the order is placed,
* so this is what Modules\Core\Shipping\Carriers\BoxNow\
* BoxNowFulfillmentService and Modules\Core\Shipping\Extensions\
* OrderViewExtension already expect to find at
* $order->shippingAddress->meta['box_now_locker']['locationId'].
*
* Not called directly by a storefront — see confirmPayment(), which is
* the only caller and supplies the fingerprint captured in
* selectPaymentMethod(), not one the storefront has to obtain itself.
* No validation against Box Now's own /destinations list here — this
* mirrors setShippingAddress()'s leniency (see its own docblock/the
* class-level note on required-field enforcement happening at the
* payment gate, not mid-checkout). An invalid/stale locationId still
* surfaces later, at BoxNowFulfillmentService::createShipment() time.
*
* No exception wrapping: Lunar\Validation\Cart\ValidateCartForOrderCreation
* (run inside Cart::createOrder()) already throws
* Lunar\Exceptions\Carts\CartException with a field-keyed MessageBag
* ($exception->errors()) for address/shipping-option validation and the
* duplicate-order guard — already the right shape for a storefront to
* render as form errors directly. FingerprintMismatchException
* propagates the same way, for the same reason.
*
* @throws FingerprintMismatchException
* @throws CartException
* @throws NoShippingAddressException if the cart has no shipping
* address yet
*/
public function placeOrder(string $fingerprint): Order
public function selectBoxNowLocker(array $locker): Cart
{
$cart = $this->cart->currentOrCreate();
$cart->checkFingerprint($fingerprint);
$address = $cart->shippingAddress;
$order = $cart->createOrder();
if (! $address) {
throw new NoShippingAddressException();
}
Event::dispatch(new OrderPlaced($order));
$address->meta = [
...($address->meta?->toArray() ?? []),
'box_now_locker' => $locker,
];
$address->save();
return $order;
return $cart;
}
/**
* Every payment type currently offered to the storefront — every key
* in config('lunar.payments.types') that is BOTH administratively
* enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) AND
* whose registered PaymentDriver reports itself usable right now
* (PaymentDriver::isConfigured() — e.g. Stripe with no API key set is
* never offered, regardless of the enabled toggle). A type with no
* PaymentMethod row at all (never seeded) is treated as not offered,
* same as disabled — nothing here creates one; see
* InstallLunarCommand::seedPaymentMethods().
* Every payment method currently offered to the storefront, ordered by
* Modules\Core\Payment\Models\PaymentMethod::position — a row is
* offered only when ALL four checks pass, each meaning something
* different to an admin diagnosing why a method isn't showing up (see
* docs/payments.md):
* 1. `enabled` — an admin turned it on.
* 2. its `driver` still resolves via PaymentDriverRegistry — the
* driver class hasn't been removed (see the `payment:sync-drivers`
* command, which sets `driver_missing_at` when this fails; a row
* with that set is excluded here regardless of `enabled`, so a
* vanished driver can never silently look "available").
* 3. the resolved driver reports Configurable::isConfigured() — its
* own runtime requirements (e.g. an API key) are met.
* 4. its driver's RequiresFulfillmentType (if it declares one)
* agrees with the cart's currently selected shipping method's own
* fulfillment type (Modules\Core\Shipping\Support\
* FulfillmentType::resolve()) — "Pay in store" offered alongside
* a courier delivery makes no sense (no staff member present at
* handoff to take cash), and cash-on-delivery alongside store
* pickup is equally meaningless (OfflinePaymentDriver already
* covers that in-person moment). A cart with no shipping option
* selected yet imposes no constraint here — every method is
* offered until a fulfillment type is actually known, the same
* leniency setShippingAddress()'s own docblock describes for
* required-field enforcement happening at the payment gate, not
* mid-checkout.
*
* @return array<string>
* @return Collection<int, PaymentMethod>
*/
public function getPaymentMethods(): array
public function getPaymentMethods(): Collection
{
return PaymentMethod::where('enabled', true)
->pluck('type')
->filter(fn (string $type) => $this->resolvePaymentDriver($type)?->isConfigured() ?? false)
->values()
->all();
$fulfillmentType = $this->currentFulfillmentType();
return $this->paymentMethods->all()
->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null)
->filter(function (PaymentMethod $method) use ($fulfillmentType) {
$driver = $this->paymentDrivers->resolve($method->driver);
if (! $driver?->isConfigured()) {
return false;
}
if ($fulfillmentType === null || ! $driver instanceof RequiresFulfillmentType) {
return true;
}
return $driver->requiredFulfillmentType() === $fulfillmentType;
})
->values();
}
/**
* @return 'carrier'|'store_pickup'|null null when the cart has no
* shipping option selected yet
*/
private function currentFulfillmentType(): ?string
{
$identifier = $this->cart->currentOrCreate()->shippingAddress?->shipping_option;
if ($identifier === null) {
return null;
}
$method = ShippingMethod::where('code', $identifier)->first();
return $method ? FulfillmentType::resolve($method) : null;
}
/**
* Records which payment type the shopper picked (Cart::meta
* ['payment_method']) — read by e.g. Modules\Core\Payment\Pipelines\
* Cart\ApplyCashOnDeliveryFee to add that type's own cart-total
* adjustments before recalculation.
* ['payment_method']) — read by Modules\Core\Payment\Pipelines\
* Cart\ApplyPaymentMethodFee to add that method's own `data.fee` (if
* any) before recalculation.
*
* Also snapshots Cart::fingerprint() into meta, *after* saving the
* chosen type — the fingerprint has to reflect the final total
* including any payment-type-specific adjustment (e.g. a COD
* surcharge), which only exists once payment_method is set and the
* cart recalculates. Captured here, server-side, rather than asked of
* the storefront: this is the last moment before confirmPayment() that
* the shopper's reviewed total is known, and confirmPayment() reads it
* back internally instead of taking a fingerprint parameter — a
* storefront should never need to know Cart::fingerprint() exists.
* including any payment-method-specific fee, which only exists once
* payment_method is set and the cart recalculates. Captured here,
* server-side, rather than asked of the storefront: this is the last
* moment before initiatePayment() that the shopper's reviewed total is
* known, and initiatePayment() reads it back internally instead of
* taking a fingerprint parameter — a storefront should never need to
* know Cart::fingerprint() exists.
*
* Does not itself call a PaymentDriver — selecting a method and
* confirming payment against it are deliberately separate steps, same
* Does not itself call a payment driver — selecting a method and
* initiating payment against it are deliberately separate steps, same
* as selecting a shipping option happens before placing the order.
*
* @throws UnknownPaymentTypeException if $type isn't currently offered
* — see getPaymentMethods() for what that means (registered,
* administratively enabled, and its driver reports itself usable)
* — see getPaymentMethods() for what that means
*/
public function selectPaymentMethod(string $type): Cart
{
if (! in_array($type, $this->getPaymentMethods(), true)) {
if (! $this->getPaymentMethods()->contains('type', $type)) {
throw new UnknownPaymentTypeException($type);
}
$cart = $this->cart->currentOrCreate();
$cart->meta = [...$cart->meta->toArray(), 'payment_method' => $type];
$cart->meta = [...($cart->meta?->toArray() ?? []), 'payment_method' => $type];
$cart->save();
$cart = $cart->calculate();
$cart->meta = [...$cart->meta->toArray(), 'checkout_fingerprint' => $cart->fingerprint()];
// Cart::calculate() no-ops if this cart instance was already
// calculated earlier in the request (Cart::isCalculated()) — which
// it will have been if the shopper switches payment method after
// the checkout page's first render already calculated it. Without
// recalculate() forcing a fresh run, the just-saved payment_method
// (and any fee tied to it, see ApplyPaymentMethodFee) would never
// be reflected — the summary would keep showing whichever method
// was calculated first.
$cart = $cart->recalculate();
$cart->meta = [...($cart->meta?->toArray() ?? []), 'checkout_fingerprint' => $cart->fingerprint()];
$cart->save();
Event::dispatch(new PaymentMethodSelected($cart, $type));
@@ -198,51 +352,93 @@ class CheckoutService
}
/**
* Resolves $type's registered PaymentDriver and calls confirm() —
* the driver decides whether/when the order actually gets placed (see
* Modules\Core\Checkout\Contracts\PaymentDriver's docblock). $data
* carries whatever that driver needs (Stripe's payment_intent id, a
* future redirect-based provider's callback payload).
* The one storefront-facing "place this order and pay for it" call —
* the point where Checkout hands off to Payment. Ensures a draft
* Order exists (Cart::createOrder() — confirmed idempotent against a
* cart's own pre-existing, not-yet-placed-at draft; see
* vendor/lunarphp/core/src/Actions/Carts/CreateOrder.php), then
* resolves the payment method selected by selectPaymentMethod() and
* calls pay() or authorize() on its driver, per that method's own
* `capture_mode` column.
*
* The fingerprint passed to the driver is the one captured by
* selectPaymentMethod(), not supplied by the caller — see that
* method's docblock. Throws the same FingerprintMismatchException a
* caller-supplied one would if the cart's total has since changed;
* missing entirely (selectPaymentMethod() was never called for this
* cart) is treated the same as a mismatch, not a different error.
* Returns the driver's own PaymentResult UNCHANGED — this method does
* not wait for or resolve anything past what pay()/authorize() itself
* returns synchronously. A Pending result (an async gateway like
* Stripe requiring 3-D Secure/a redirect) is a normal, expected
* outcome, not an error — the caller (a storefront controller) is
* responsible for whatever the gateway needs next.
*
* @param array<string, mixed> $data
* The draft order's own $order->total (not the Cart's) is what gets
* passed as $amount — Order::$total is Lunar's own Price-cast
* attribute, already resolving the correct Currency via the order's
* own currency_code, and is the authoritative total once the draft
* row exists.
*
* @throws UnknownPaymentTypeException if $type isn't currently offered
* (see getPaymentMethods()) — re-checked here, not just in
* selectPaymentMethod(), since a type could be disabled between
* selection and confirmation
* @throws \Lunar\Exceptions\FingerprintMismatchException
* @throws \Lunar\Exceptions\Carts\CartException
* $context passed to the driver is {cart_id, order_id} — the exact
* keys Modules\Core\Payment\Drivers\StripePaymentDriver::
* rememberIntent() already reads.
*
* Same fingerprint precondition the old placeOrder() had: mandatory,
* not optional, checked before the draft is created.
*
* $termsAccepted is likewise mandatory, not optional data a caller
* might omit — an Order is a consumer contract, and its acceptance
* must be refused (TermsNotAcceptedException, before createOrder() is
* ever called — the order is never created-then-flagged) rather than
* assumed. $policyVersion is recorded alongside it on the created
* Order's own meta (terms_accepted, terms_accepted_at,
* terms_accepted_policy_version) — the order-level equivalent of
* setRecoveryConsent()'s cart-level record, and the durable audit
* trail for a later "what did the shopper actually agree to"
* dispute. Written directly here (not via a separate event/listener)
* since the Order row this attaches to doesn't exist before
* createOrder() runs, and nothing else needs to react to this
* specific write independently of the order simply existing.
*
* @param array<string, mixed> $data passed through untouched to
* the driver's pay()/authorize() — e.g. Stripe's payment_method
* token.
*
* @throws UnknownPaymentTypeException if the cart's selected
* payment_method (from selectPaymentMethod()) is no longer offered
* — re-checked here, not just at selection time, since a method
* could be disabled (or its driver removed) in between
* @throws TermsNotAcceptedException if $termsAccepted is false
* @throws FingerprintMismatchException
* @throws CartException
*/
public function confirmPayment(string $type, array $data = []): Order
public function initiatePayment(string $fingerprint, bool $termsAccepted, string $policyVersion, array $data = []): PaymentResult
{
if (! in_array($type, $this->getPaymentMethods(), true)) {
throw new UnknownPaymentTypeException($type);
if (! $termsAccepted) {
throw new TermsNotAcceptedException;
}
$cart = $this->cart->currentOrCreate();
$fingerprint = $cart->meta['checkout_fingerprint'] ?? '';
$cart->checkFingerprint($fingerprint);
return $this->resolvePaymentDriver($type)->confirm($cart, $type, $fingerprint, $data);
}
$type = $cart->meta['payment_method'] ?? null;
$method = $type !== null ? $this->getPaymentMethods()->firstWhere('type', $type) : null;
/**
* Resolves $type's registered PaymentDriver, or null if $type has no
* 'payment_driver' registered in config('lunar.payments.types.<type>')
* at all — deliberately non-throwing so getPaymentMethods() can filter
* unresolvable types silently rather than treating "not registered"
* as an error condition when just checking availability.
*/
private function resolvePaymentDriver(string $type): ?PaymentDriver
{
$driverClass = config("lunar.payments.types.{$type}.payment_driver");
if ($method === null) {
throw new UnknownPaymentTypeException((string) $type);
}
return $driverClass ? app($driverClass) : null;
$order = $cart->createOrder();
$order->meta = [
...($order->meta?->toArray() ?? []),
'payment_method' => $type,
'terms_accepted' => true,
'terms_accepted_at' => now()->toIso8601String(),
'terms_accepted_policy_version' => $policyVersion,
];
$order->save();
$driver = $this->paymentDrivers->resolve($method->driver);
$context = ['cart_id' => $cart->id, 'order_id' => $order->id];
return $method->capture_mode === 'authorize'
? $driver->authorize($type, $order->total, $data, $context)
: $driver->pay($type, $order->total, $data, $context);
}
}
@@ -0,0 +1,51 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Lunar\Models\ProductVariant;
use Modules\Core\Catalog\Services\SkuBackfillService;
/**
* CLI wrapper (--dry-run, a progress bar) around Catalog\Services\
* SkuBackfillService — see that class's own docblock for the actual
* backfill logic, also called automatically after a Shopify import (see
* MigrateImport\Jobs\RunMigrateImportJob).
*/
class BackfillMissingSkusCommand extends Command
{
protected $signature = 'boboko:catalog:backfill-skus {--dry-run : List what would change without writing}';
protected $description = 'Generate a SKU for every product variant that is missing one';
public function handle(SkuBackfillService $backfill): void
{
$dryRun = (bool) $this->option('dry-run');
$total = ProductVariant::query()->whereNull('sku')->count();
if ($total === 0) {
$this->info('No variants are missing a SKU.');
return;
}
$this->info(($dryRun ? '[dry-run] ' : '') . "Backfilling SKUs for {$total} variant(s)...");
$bar = $this->output->createProgressBar($total);
$bar->start();
$backfill->backfill($dryRun, function (ProductVariant $variant, string $sku) use ($dryRun, $bar) {
if ($dryRun) {
$this->newLine();
$this->line("Variant {$variant->id}: sku => {$sku}");
}
$bar->advance();
});
$bar->finish();
$this->newLine();
$this->info($dryRun ? 'Dry run complete — no changes were written.' : 'Done.');
}
}

Some files were not shown because too many files have changed in this diff Show More