Compare commits

...
79 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 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
278 changed files with 15012 additions and 1020 deletions
+1041 -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
+6 -3
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.13.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,13 +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\\FileServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider"
"Modules\\Core\\Providers\\OrderServiceProvider",
"Modules\\Core\\Providers\\PrivacyServiceProvider"
]
}
},
+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 -33
View File
@@ -1,48 +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 —
| the driver instance Modules\Core\Payment\Services\PaymentDriverResolver
| resolves via the container. 'capture_mode' ('pay' or 'authorize') is
| also boboko-owned — which contract method
| CheckoutService::initiatePayment() calls for this type. Kept on the
| same row as 'driver' rather than a second, separately-keyed map, so a
| type's full definition lives in one place.
|
*/
'types' => [
'cash-on-delivery' => [
'driver' => 'offline',
'payment_driver' => OfflinePaymentDriver::class,
'capture_mode' => 'pay',
'captured_status' => 'payment-offline',
'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,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,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`.
---
+55 -16
View File
@@ -145,23 +145,20 @@ produced had it resolved synchronously.
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.
**Read directly from `lunarphp/stripe`'s own source** (`StripePaymentType::authorize()`,
`ProcessStripeWebhook`, `WebhookController`) to see how Lunar itself solves this — confirmed
it does **not** stash a generic opaque blob. It writes the correlating ids as real, typed
columns on `Lunar\Stripe\Models\StripePaymentIntent` (`cart_id`, `order_id`) at the moment the
intent is created/first seen, then reads them back the same way when the webhook arrives:
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.
```php
// ProcessStripeWebhook::handle() — falls back through two real lookups,
// neither of them a generic context blob:
$cart = StripePaymentIntent::where('intent_id', $this->paymentIntentId)->first()?->cart
?: Cart::where('meta->payment_intent', '=', $this->paymentIntentId)->first();
```
**`StripePaymentDriver` follows this exact precedent**: it reads `cart_id`/`order_id` out of
`$context` at `pay()`/`authorize()` time and writes them onto its own `StripePaymentIntent`
row (a table already owned by `lunarphp/stripe`, already shaped for exactly this), then reads
them back the same way in `handleCallback()`. No generic `context` json column, no new table.
**`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
@@ -176,6 +173,48 @@ 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`
+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.
+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>Your order <strong>{{ $reference }}</strong> is complete. Thanks for shopping with us!</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>
@@ -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'));
}
}
+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',
};
}
}
@@ -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)),
]),
]);
}
}
@@ -2,11 +2,17 @@
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
@@ -27,7 +33,7 @@ use Modules\Core\Catalog\Events\ProductSaved;
* SCOUT_QUEUE is configured) reindex job per matched product — this
* listener itself does no synchronous Meilisearch writing.
*/
class ReindexProductsRecommendingProduct
class ReindexProductsRecommendingProduct implements ShouldQueue
{
public function handleSaved(ProductSaved $event): void
{
+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',
]);
}
}
+32 -2
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;
@@ -47,8 +48,12 @@ 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"
@@ -99,6 +104,7 @@ class ProductIndexer extends BaseProductIndexer
return [
...parent::getSortableFields(),
'price',
'order_count',
];
}
@@ -135,6 +141,12 @@ class ProductIndexer extends BaseProductIndexer
->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();
@@ -151,6 +163,24 @@ 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'])
+43 -7
View File
@@ -2,12 +2,14 @@
namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Collection;
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;
/**
@@ -21,20 +23,37 @@ class ProductSearchService
{
public function __construct(
private readonly ProductFilterBuilder $filterBuilder,
private readonly ProductDocumentLocalizer $localizer,
private readonly ProductService $products,
) {}
/**
* 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.
*
* @return Collection<int, Product>
*/
public function search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null): Collection
{
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),
@@ -44,9 +63,26 @@ class ProductSearchService
$options['sort'] = [$sort->toMeilisearchSort()];
}
return Product::search($query)
$paginator = Product::search($query)
->options($options)
->get();
->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);
}
/**
+21 -76
View File
@@ -2,17 +2,13 @@
namespace Modules\Core\Catalog\Services;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Pagination\LengthAwarePaginator;
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;
/**
@@ -27,8 +23,7 @@ use Modules\Core\Catalog\Support\ProductFilterBuilder;
class ProductService
{
public function __construct(
private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes,
private readonly ProductDocumentLocalizer $localizer,
private readonly ProductFilterBuilder $filterBuilder,
) {}
@@ -65,8 +60,8 @@ 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();
$products = new LengthAwarePaginator(
@@ -91,12 +86,20 @@ class ProductService
* 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>
*/
private function availableTags(?ProductFilters $filters): array
public function availableTags(?ProductFilters $filters, string $query = ''): array
{
$filter = $this->filterBuilder->build($filters, exclude: ['tag']);
$tags = $this->rawFacets('tags', $filter)['facetDistribution']['tags'] ?? [];
$tags = $this->rawFacets('tags', $filter, $query)['facetDistribution']['tags'] ?? [];
return collect($tags)->keys()->sort()->values()->all();
}
@@ -251,7 +254,10 @@ class ProductService
public function random(int $limit): array
{
$raw = Product::search('')
->options(['attributesToRetrieve' => ['id']])
->options([
'attributesToRetrieve' => ['id'],
'filter' => $this->filterBuilder->withVisibility(),
])
->raw();
$ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values();
@@ -284,72 +290,11 @@ class ProductService
private function findAllWhere(string $filter, int $limit = 1000): array
{
$paginator = Product::search('')
->options(['filter' => $filter])
->options(['filter' => $this->filterBuilder->withVisibility($filter)])
->paginateRaw(perPage: $limit, page: 1);
return collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
return collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
}
/**
* 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.
*/
private function withLocalizedFields(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')
->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.
*/
private function hitsFrom(LengthAwarePaginatorContract $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
}
@@ -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();
}
}
+31 -3
View File
@@ -10,6 +10,13 @@ use Modules\Core\Catalog\DTOs\ProductFilters;
* 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
{
@@ -19,10 +26,10 @@ class ProductFilterBuilder
* 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
public function build(?ProductFilters $filters, array $exclude = []): string
{
if ($filters === null) {
return null;
return $this->withVisibility();
}
$clauses = Collection::make([
@@ -36,6 +43,27 @@ class ProductFilterBuilder
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
])->except($exclude)->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
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 ');
}
}
@@ -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.');
}
}
+257 -53
View File
@@ -10,16 +10,23 @@ use Lunar\Base\Addressable;
use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\ShippingManifest;
use Lunar\Models\Cart;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Events\BillingAddressSet;
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\PaymentDriverResolver;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Shipping\Support\FulfillmentType;
/**
* Storefront-facing checkout operations, mirroring
@@ -42,12 +49,39 @@ class CheckoutService
{
public function __construct(
private readonly CartService $cart,
private readonly PaymentDriverResolver $paymentDrivers,
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));
@@ -63,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
@@ -94,60 +171,162 @@ 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;
}
/**
* 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 driver reports itself usable right now
* (Configurable::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().
* 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'].
*
* @return array<string>
* 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.
*
* @throws NoShippingAddressException if the cart has no shipping
* address yet
*/
public function getPaymentMethods(): array
public function selectBoxNowLocker(array $locker): Cart
{
return PaymentMethod::where('enabled', true)
->pluck('type')
->filter(fn (string $type) => $this->paymentDrivers->resolve($type)?->isConfigured() ?? false)
->values()
->all();
$cart = $this->cart->currentOrCreate();
$address = $cart->shippingAddress;
if (! $address) {
throw new NoShippingAddressException();
}
$address->meta = [
...($address->meta?->toArray() ?? []),
'box_now_locker' => $locker,
];
$address->save();
return $cart;
}
/**
* 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 Collection<int, PaymentMethod>
*/
public function getPaymentMethods(): Collection
{
$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 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.
* 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 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);
}
@@ -155,7 +334,15 @@ class CheckoutService
$cart->meta = [...($cart->meta?->toArray() ?? []), 'payment_method' => $type];
$cart->save();
$cart = $cart->calculate();
// 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();
@@ -170,11 +357,9 @@ class CheckoutService
* 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 type selected by selectPaymentMethod() and
* calls pay() or authorize() on its driver, per that type's
* config('lunar.payments.types.{type}.capture_mode') — boboko-core's
* own types (config/payment.php) are merged into that same Lunar
* config key by PaymentServiceProvider::boot().
* resolves the payment method selected by selectPaymentMethod() and
* calls pay() or authorize() on its driver, per that method's own
* `capture_mode` column.
*
* Returns the driver's own PaymentResult UNCHANGED — this method does
* not wait for or resolve anything past what pay()/authorize() itself
@@ -183,14 +368,6 @@ class CheckoutService
* outcome, not an error — the caller (a storefront controller) is
* responsible for whatever the gateway needs next.
*
* KNOWN GAP, explicitly out of scope for now: PaymentResult alone does
* not carry gateway-specific continuation data (e.g. Stripe's
* PaymentIntent client_secret for a Pending result needing frontend
* confirmation) — that concept existed on the deleted PaymentInitiation
* DTO and was intentionally removed from Payment's abstraction layer.
* Nothing here re-introduces it; only OfflinePaymentDriver's
* always-Immediate-Succeeded path is fully wired end-to-end today.
*
* 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
@@ -204,36 +381,63 @@ class CheckoutService
* 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 type could
* be disabled in between
* — 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 initiatePayment(string $fingerprint, array $data = []): PaymentResult
public function initiatePayment(string $fingerprint, bool $termsAccepted, string $policyVersion, array $data = []): PaymentResult
{
if (! $termsAccepted) {
throw new TermsNotAcceptedException;
}
$cart = $this->cart->currentOrCreate();
$cart->checkFingerprint($fingerprint);
$type = $cart->meta['payment_method'] ?? null;
$method = $type !== null ? $this->getPaymentMethods()->firstWhere('type', $type) : null;
if ($type === null || ! in_array($type, $this->getPaymentMethods(), true)) {
if ($method === null) {
throw new UnknownPaymentTypeException((string) $type);
}
$order = $cart->createOrder();
$driver = $this->paymentDrivers->resolve($type);
$captureMode = config("lunar.payments.types.{$type}.capture_mode", 'pay');
$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 $captureMode === 'authorize'
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.');
}
}
+41 -25
View File
@@ -66,6 +66,16 @@ class InstallLunarCommand extends Command
]);
}
if (! Language::where('code', 'el')->exists()) {
$this->components->info('Adding Greek language');
Language::create([
'code' => 'el',
'name' => 'Greek',
'default' => false,
]);
}
if (! Currency::whereDefault(true)->exists()) {
$this->components->info('Adding a default currency (USD)');
@@ -284,35 +294,41 @@ class InstallLunarCommand extends Command
}
/**
* Per-type skip-if-exists, same idempotent convention as
* seedStorefrontLabels() — a type already present (including one an
* admin has since edited via the Filament Payment Methods resource) is
* left untouched. Safe to re-run after a new payment type is added to
* config('lunar.payments.types') (e.g. installing a Stripe/Nexi
* package), which is the whole reason this isn't a one-time-only seed.
* A single, deliberately opinionated starter row on fresh install —
* `PaymentMethod` is now fully admin-creatable/deletable (see
* docs/payments.md), so this is no longer "seed every config-defined
* type," it's "give a fresh store one reasonable payment method to
* start from instead of zero." Every value here is a plain literal in
* THIS command, not sourced from config or PaymentDriverRegistry — a
* driver has no business carrying opinions about what its captured
* order status should be called; that's a merchant decision.
*
* Seeded disabled — a newly-seeded row (whether from this store's
* initial install, or a payment provider package installed later)
* shouldn't go live for shoppers before staff have actually reviewed
* it (real credentials configured, a fee set, etc.) and turned it on
* via the Payment Methods resource. See CheckoutService::
* getPaymentMethods(), which only offers a type once both 'enabled'
* here and its driver's own isConfigured() check pass.
* Skip-if-exists on `type`, same idempotent convention as
* seedStorefrontLabels() — an admin who has since edited or deleted
* this row (via the Filament Payment Methods resource) is left alone;
* re-running lunar:install never recreates a deleted starter row.
*
* Seeded disabled — shouldn't go live for shoppers before staff have
* actually reviewed it and turned it on via the Payment Methods
* resource. See CheckoutService::getPaymentMethods().
*/
private function seedPaymentMethods(): void
{
$existingTypes = PaymentMethod::pluck('type');
foreach (array_keys(config('lunar.payments.types', [])) as $type) {
if ($existingTypes->contains($type)) {
continue;
}
PaymentMethod::create([
'type' => $type,
'enabled' => false,
'data' => [],
]);
if (PaymentMethod::where('type', 'cash-on-delivery')->exists()) {
return;
}
PaymentMethod::create([
'type' => 'cash-on-delivery',
'name' => [
'en' => 'Cash on Delivery',
'el' => 'Αντικαταβολή',
],
'driver' => 'cash-on-delivery',
'capture_mode' => 'pay',
'position' => 0,
'enabled' => false,
'data' => [],
]);
}
}
+41 -2
View File
@@ -3,8 +3,9 @@
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\MigrateImport\ImportSpec;
use Modules\Core\MigrateImport\RunMigrateImportJob;
use Lunar\Models\Language;
use Modules\Core\MigrateImport\DTOs\ImportSpec;
use Modules\Core\MigrateImport\Jobs\RunMigrateImportJob;
class MigrateImportCommand extends Command
{
@@ -62,11 +63,29 @@ class MigrateImportCommand extends Command
$credentials = null;
}
// Shopify's own product export is a flat CSV — one Title/Body
// (HTML)/etc. column per row, no per-locale columns at all — so
// its text is necessarily written in exactly one language, and
// there is no reliable way to detect which one from the file
// itself. Modules\Core\MigrateImport\Services\ImportLocale::code() used to
// (as its former name, DefaultLocale, admits) assume it always
// matched this store's own Lunar\Models\
// Language::getDefault(), which is often wrong (a store's default
// admin/storefront language and the language a given export
// happens to be written in are two independent facts) — every
// imported product's name/description then saved silently under
// the wrong language, invisible unless that language happened to
// also be selected when viewing/editing the product afterward.
$locale = $source === 'shopify' && $type === 'export'
? $this->askImportLocale()
: null;
$spec = new ImportSpec(
source: $source,
type: $type,
filePath: $filePath,
credentials: $credentials,
locale: $locale,
);
RunMigrateImportJob::dispatch($spec);
@@ -74,6 +93,26 @@ class MigrateImportCommand extends Command
$this->info('Import queued.');
}
/**
* Choices come from Language::all() — the same list an admin manages
* from the Filament panel (Settings > Languages) — not a hardcoded
* set, so a language this store doesn't have yet simply isn't
* offered here; the hint below says where to add it instead of this
* command silently accepting an arbitrary code Lunar has no row for.
*/
private function askImportLocale(): string
{
$languages = Language::orderBy('default', 'desc')->get(['code', 'name']);
return $this->choice(
"Which language is the export file's own text (product titles, descriptions, etc.) written in?\n".
' (Not necessarily this store\'s default language — the two are independent. '.
"If the language you need isn't listed, add it first from the admin panel under Languages.)",
$languages->mapWithKeys(fn (Language $language) => [$language->code => "{$language->name} ({$language->code})"])->all(),
$languages->first()?->code,
);
}
// Answers are relative to storage/app/private/imports (e.g. "shopify" or
// "shopify/products_export.csv"); absolute paths are used as-is. A
// directory answer picks the first CSV file found inside it.
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
use Modules\Core\Privacy\Jobs\EraseDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest;
/**
* Finds every erasure request whose grace period (config('core.privacy.
* grace_period_days')) has passed and dispatches one EraseDataSubjectJob per
* request — see docs/privacy.md. This command itself just finds due requests and
* dispatches; the actual erasure work happens in the queue, one job per request,
* so one failing request doesn't block the others. Meant to run daily via the
* scheduler; each consuming app wires that in its own Console\Kernel (or
* bootstrap/app.php schedule closure on Laravel 11+), the same way it owns any
* other scheduled task — this package doesn't register schedules itself.
*/
class ProcessErasureRequestsCommand extends Command
{
protected $signature = 'boboko:privacy:process-erasure-requests';
protected $description = 'Dispatch an erasure job for every pending data-erasure request whose grace period has passed';
public function handle(): void
{
$due = DataErasureRequest::where('status', ErasureRequestStatus::Pending)
->where('scheduled_for', '<=', now())
->get();
if ($due->isEmpty()) {
$this->info('No due erasure requests.');
return;
}
foreach ($due as $request) {
EraseDataSubjectJob::dispatch($request);
$scope = $request->isForCustomer() ? 'customer' : 'user';
$this->info("Dispatched erasure job for {$scope} #{$request->subject_id} (request #{$request->id})");
}
$this->info('Dispatched '.$due->count().' erasure job(s).');
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
/**
* Reconciles every Modules\Core\Payment\Models\PaymentMethod row's `driver`
* column against PaymentDriverRegistry — the registry only knows "which
* driver classes exist THIS deploy," and only at the moment something
* calls resolve(); nothing else notices a driver disappearing (a package
* removed, a custom Registry::register() call deleted) on its own. Meant
* to run unconditionally on every container start/deploy (alongside
* `migrate`), not on a schedule — "did the set of registered drivers
* change" is a deploy-time event, cheap enough to check every single time
* regardless of whether anything actually changed. See docs/payments.md.
*
* Sets/clears `driver_missing_at` — deliberately NOT the `enabled` column,
* so an admin's own manual toggle is never confused with "the driver
* vanished," and a driver that comes back in a later deploy auto-clears
* this with no admin action needed.
*/
class SyncPaymentDriversCommand extends Command
{
protected $signature = 'boboko:payment:sync-drivers';
protected $description = 'Flag PaymentMethod rows whose driver no longer resolves via the registry, and clear the flag for ones that do again';
public function handle(PaymentDriverRegistry $registry): int
{
$missing = 0;
$restored = 0;
PaymentMethod::query()->each(function (PaymentMethod $method) use ($registry, &$missing, &$restored) {
$resolves = $method->driver !== null && $registry->resolve($method->driver) !== null;
if (! $resolves && $method->driver_missing_at === null) {
$method->update(['driver_missing_at' => now()]);
$missing++;
} elseif ($resolves && $method->driver_missing_at !== null) {
$method->update(['driver_missing_at' => null]);
$restored++;
}
});
$this->components->info("Payment driver sync complete: {$missing} newly flagged, {$restored} restored.");
return self::SUCCESS;
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Lunar\Models\CartLine;
use Lunar\Models\Product;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Auth\Services\OtpService;
use Modules\Core\MigrateImport\Models\ImportMapping;
use function Laravel\Prompts\password;
use function Laravel\Prompts\text;
/**
* Irreversibly deletes every Product and everything that only exists
* because of a product — variants, variant prices, product-option value
* assignments, product images/media, product associations, the
* ImportMapping rows tying them back to an external source, product-
* variant CartLine rows (line items only — Cart records themselves are
* left alone), and the Meilisearch product index. Deliberately does NOT
* touch catalog STRUCTURE other products could still reference: ProductOption/
* ProductOptionValue definitions ("Size", "Color" as reusable option
* types), Brands, Collections, Tags, Customer Groups — none of those are
* products, they're config a merchant would otherwise have to rebuild
* from scratch.
*
* Two gates a destructive, whole-catalog, irreversible operation
* warrants — deliberately NOT restricted to non-production on top of
* these; a real, legitimate use case is wiping a client's demo/seed
* catalog on a production database right before real launch, and the OTP
* below already proves the operator has real staff access, not just
* shell access to wherever `php artisan` happens to be runnable:
* 1. An OTP emailed to a real Staff account (reusing Auth\Services\
* OtpService — the exact mechanism admin login already uses).
* 2. Typing the literal product count back, not just "yes" — a plain
* confirm() is too easy to reflexively accept; forcing the operator
* to read and retype the actual number they're about to delete is a
* last check against running this against the wrong environment/
* database by mistake.
*
* Deletes via Eloquent model instances, not DB::table()->delete() —
* Product/ProductVariant use Spatie's InteractsWithMedia (see Lunar\Base\
* Traits\HasMedia), which only cleans up media files/rows on a real model
* `deleted` event, never on a raw query-builder delete.
*/
class WipeCatalogCommand extends Command
{
protected $signature = 'boboko:wipe-catalog {--email= : Staff email to send the confirmation code to}';
protected $description = 'Irreversibly delete every product, variant, and related catalog data';
public function handle(OtpService $otp): int
{
// withTrashed() — a prior soft-delete-only bug in this command
// (fixed in wipe() below) could leave ghost rows a plain count()
// would never see, silently reporting "nothing to do" while they
// sit there breaking other things (e.g. the admin's own global
// search, which assumes every returned product has variants).
$productCount = Product::withTrashed()->count();
if ($productCount === 0) {
$this->info('No products exist — nothing to do.');
return self::SUCCESS;
}
if (! $this->authorize($otp)) {
return self::FAILURE;
}
$this->warn("This will PERMANENTLY delete {$productCount} product(s) and everything that only exists because of them (variants, prices, images, product-option assignments, associations). This cannot be undone.");
$typed = text(label: "Type the product count ({$productCount}) to confirm");
if ($typed !== (string) $productCount) {
$this->error('Count did not match — aborted, nothing was deleted.');
return self::FAILURE;
}
$this->wipe();
$this->info("Deleted {$productCount} product(s) and all related data.");
return self::SUCCESS;
}
private function authorize(OtpService $otp): bool
{
$email = $this->option('email') ?? text(
label: 'Staff email to send a confirmation code to',
validate: fn (string $value) => Staff::where('email', $value)->exists()
? null
: 'No staff account with that email exists.',
);
if (! $otp->generateAndSend($email, purpose: 'wipe-catalog')) {
$this->error('Could not send a confirmation code to that email.');
return false;
}
$this->info("A confirmation code was sent to {$email}.");
$code = password(label: 'Enter the confirmation code');
if ($otp->validate($email, $code) === null) {
$this->error('Invalid or expired code — aborted, nothing was deleted.');
return false;
}
return true;
}
/**
* Every step below goes through a real Eloquent relation, never a raw
* table name — Lunar's own table prefix is configurable
* (config('lunar.database.table_prefix'), applied in BaseModel's
* constructor), so a hardcoded 'lunar_...' string would silently
* no-op on an install using a different one.
*
* Order matters: product_associations and the product/product_option
* pivot have a real FK to `products` but no ON DELETE CASCADE (both
* RESTRICT, Laravel's own default), so they're detached before the
* product/variant rows they reference — deleting a product that
* still has either would throw. ProductVariant's own `prices` (a
* plain morph, HasPrices trait — no FK constraint at all) would
* otherwise silently orphan rather than throw, so it's cleared the
* same way regardless. media_variant and product_option_value_
* product_variant DO cascade at the DB level (see their own
* migrations), so deleting the variant itself is enough for those two.
*
* Deliberately NOT chunkById() — that re-queries "id > lastSeenId"
* every iteration, but deleting rows inside the loop shrinks the
* table out from under it: any product whose id fell in a range
* chunkById() had already stepped past could be silently skipped and
* never actually deleted at all. Caught in practice — the first real
* run of this command left orphaned Media rows (Spatie's own
* deleteAllMedia(), fired from Product's `deleting` event, never ran
* for the skipped products) whose 'image' ImportMapping rows then
* caused a LATER Shopify re-import to silently reuse those now-
* orphaned Media objects instead of importing fresh ones — see
* MigrateImport\Shopify\Services\ShopifyExportImporter::resolveOrImportImage()'s
* own docblock for that half of the same incident. Always re-querying
* the first N remaining rows (never advancing an id cursor) guarantees
* every product is actually visited exactly once, however many are
* deleted out from under the query as it goes.
*/
private function wipe(): void
{
ImportMapping::whereIn('source_type', ['product', 'variant', 'image'])->delete();
// Only the line items — not the parent Cart rows. This command is
// meant for early-stage/setup use where no real customer carts
// matter yet, but a customer's Cart record also anchors their
// session/coupon/address state; deleting it outright is more than
// "the catalog is gone" calls for. Leaving every variant a cart
// line could reference about to be force-deleted below would
// otherwise reproduce the exact storefront crash this step exists
// to prevent: CartLine::purchasable() resolves to null,
// PricingManager::for() throws a TypeError on every page load that
// renders the cart drawer.
CartLine::where('purchasable_type', 'product_variant')->delete();
while (true) {
// withTrashed(): Product/ProductVariant both use SoftDeletes
// — a plain query would stop seeing a product the moment
// forceDelete() below actually removes it, which is fine, but
// WITHOUT withTrashed() here this loop would never even
// fetch a row that a previous, buggy run of this command
// (or any other code) had already soft-deleted without
// force-deleting it. Ghost rows like that are exactly what
// this command exists to remove.
$products = Product::withTrashed()
->with(['variants' => fn ($query) => $query->withTrashed(), 'associations', 'inverseAssociations'])
->limit(100)
->get();
if ($products->isEmpty()) {
break;
}
foreach ($products as $product) {
$product->associations()->delete();
$product->inverseAssociations()->delete();
$product->productOptions()->detach();
foreach ($product->variants as $variant) {
$variant->prices()->delete();
// NOT delete() — Product/ProductVariant both use
// SoftDeletes, and a plain delete() only sets
// deleted_at, leaving the row (and, for Product, its
// media) sitting in the table. This command's whole
// purpose is an irreversible wipe; a soft-deleted
// ghost row is the opposite of that. Caught in
// practice — a prior run's plain delete() left 185
// ghost Product rows with zero real variants, which
// then crashed the admin's own global search
// (Lunar\Admin\Filament\Resources\ProductResource::
// getGlobalSearchResultDetails() assumes
// $record->variants->first() is never null).
$variant->forceDelete();
}
$product->forceDelete();
}
}
Product::removeAllFromSearch();
}
}
+82 -7
View File
@@ -3,10 +3,15 @@
namespace Modules\Core;
use Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder;
use Lunar\Admin\Filament\Resources\OrderResource\Pages\Components\OrderItemsTable;
use Filament\Contracts\Plugin;
use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Mail;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Admin\Filament\Resources\CustomerResource\Pages\EditCustomer;
use Lunar\Admin\Filament\Resources\CustomerResource\Pages\ViewCustomer;
use Lunar\Admin\Filament\Resources\ProductOptionResource;
use Lunar\Admin\Filament\Resources\ProductOptionResource\RelationManagers\ValuesRelationManager;
use Lunar\Admin\Filament\Resources\OrderResource;
@@ -14,6 +19,7 @@ use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff;
use Lunar\Admin\Support\Facades\LunarPanel;
use Lunar\Models\Customer;
use Lunar\Models\Product;
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
use Lunar\Shipping\Filament\Resources\ShippingMethodResource\Pages\ListShippingMethod;
@@ -25,13 +31,25 @@ use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension;
use Modules\Core\Order\Filament\Extensions\OrderPaymentMethodSummaryExtension;
use Modules\Core\Order\Filament\Extensions\OrderActionsExtension;
use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Privacy\Filament\Extensions\CustomerErasureActionsExtension;
use Modules\Core\Privacy\Filament\Extensions\CustomerErasureRelationsExtension;
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource;
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Shipping\Extensions\OrderShipmentsExtension;
use Modules\Core\Shipping\Extensions\OrderViewExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
use Modules\Core\Shipping\Filament\Pages\ManagePickupManifests;
use Modules\Core\Shipping\Filament\Resources\ManifestResource;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
class CorePlugin implements Plugin
{
@@ -49,11 +67,14 @@ class CorePlugin implements Plugin
->login(Login::class)
->resources([
LanguageLineResource::class,
DataErasureRequestResource::class,
DataExportRequestResource::class,
CartResource::class,
PaymentMethodResource::class,
ShipmentResource::class,
ManifestResource::class,
])
->plugin(ShippingPlugin::make())
->pages([ManagePickupManifests::class]);
->plugin(ShippingPlugin::make());
LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class,
@@ -62,12 +83,66 @@ class CorePlugin implements Plugin
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
ListShippingMethod::class => ShippingMethodListExtension::class,
ManageOrder::class => OrderViewExtension::class,
ManageOrder::class => [OrderViewExtension::class, OrderActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class],
OrderItemsTable::class => OrderItemsTableExtension::class,
// headerActions() is resolved per PAGE class, not per resource class —
// unlike extendForm()/extendTable(), which really are resource-keyed
// (called statically from the Resource class itself). Registering this
// under CustomerResource::class would silently never fire; it has to be
// keyed by each concrete page it should appear on. Layered with
// whatever extension the consuming app registers for the same page —
// LunarPanel::extensions() merges per key, and this one only touches
// headerActions(), so it never conflicts with an app's own extension
// (see docs/modules.md "Layering Module and App Configuration").
EditCustomer::class => CustomerErasureActionsExtension::class,
ViewCustomer::class => CustomerErasureActionsExtension::class,
// getRelations(), unlike headerActions(), genuinely is resolved
// statically from the Resource class itself — CustomerResource::class
// is the correct key here.
CustomerResource::class => CustomerErasureRelationsExtension::class,
]);
Product::macro('reviews', function (): HasMany {
/** @var Product $this */
return $this->hasMany(ProductReview::class);
// resolveRelationUsing(), not macro() — Illuminate\Database\Eloquent\
// Model does not use the Macroable trait in this Laravel version, so
// Product::macro(...)/Customer::macro(...)/$userModel::macro(...)
// silently fall through to Model::__callStatic(), which instantiates
// the model and tries to call the method as a real one, hitting
// newQuery()->getConnection() — this crashes every console command
// and every request, since CorePlugin::register() runs during
// provider registration, before the DB connection is configured
// ("Call to a member function connection() on null"). This bit us
// once already; resolveRelationUsing() is Eloquent's real, intended,
// connection-free extension point for exactly this (Order::
// resolveRelationUsing('shipments', ...) in ShippingServiceProvider
// already uses it correctly).
Product::resolveRelationUsing('reviews', function (Product $product): HasMany {
return $product->hasMany(ProductReview::class);
});
// Customer::erasureRequests()/exportRequests() and the User-model
// equivalents below let a relation manager scope
// DataErasureRequest/DataExportRequest to one specific subject — both
// tables use a plain subject_type/subject_id pair rather than Laravel's
// usual morphs() convention, since one column pair identifies either a
// Customer or a User (see docs/privacy.md "User-scope vs Customer-scope"),
// so this is a MorphMany built by hand rather than a bare Eloquent
// convention lookup.
Customer::resolveRelationUsing('erasureRequests', function (Customer $customer): MorphMany {
return $customer->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
});
Customer::resolveRelationUsing('exportRequests', function (Customer $customer): MorphMany {
return $customer->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id');
});
$userModel = config('auth.providers.users.model');
$userModel::resolveRelationUsing('erasureRequests', function ($user): MorphMany {
return $user->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
});
$userModel::resolveRelationUsing('exportRequests', function ($user): MorphMany {
return $user->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id');
});
LunarStaff::addActivitylogExcept([
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Models\Address;
/**
* Dispatched by Modules\Core\Customer\Services\CustomerAccountService::
* createAddress(). $causer is carried explicitly (unlike e.g.
* Modules\Core\Payment\Events\PaymentMethodCreated, which is always
* staff-caused implicitly) because this write happens on the `web`
* guard, not `staff` — a listener logging this needs to know who to
* attribute it to without guessing a guard.
*/
class CustomerAddressCreated
{
public function __construct(
public readonly Address $address,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
class CustomerAddressDeleted
{
/**
* @param array<string, mixed> $address Snapshot of the deleted
* row — already gone from the database by dispatch time.
*/
public function __construct(
public readonly array $address,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Models\Address;
class CustomerAddressUpdated
{
/**
* @param array<string, mixed> $old Snapshot of the changed
* attributes before the update.
*/
public function __construct(
public readonly Address $address,
public readonly array $old,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Modules\Core\Customer\Models\Customer;
class CustomerProfileUpdated
{
/**
* @param array<string, mixed> $old Snapshot of the changed
* attributes before the update.
*/
public function __construct(
public readonly Customer $customer,
public readonly array $old,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Customer\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Customer\Services\CustomerAccountService when an
* address id doesn't belong to the customer making the request — never
* a plain 404/ModelNotFoundException, so a storefront can't probe for
* another customer's address ids by trying sequential ones and reading
* the response shape.
*/
class AddressNotFoundException extends RuntimeException
{
public function __construct()
{
parent::__construct('Address not found.');
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Customer\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Customer\Services\CustomerAccountService when an
* order id doesn't belong to the customer making the request (or isn't
* placed yet) — never a plain 404/ModelNotFoundException, so a
* storefront can't probe for another customer's order ids.
*/
class OrderNotFoundException extends RuntimeException
{
public function __construct()
{
parent::__construct('Order not found.');
}
}
@@ -6,6 +6,19 @@ use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Customer as CustomerContract;
use Modules\Core\Auth\Events\UserCreated;
/**
* Deliberately NOT queued, even though UserCreated (requesting an OTP
* code) and the login that follows it (submitting the code) are normally
* separate requests with a real time gap between them — that gap is not
* a guarantee this code controls. A busy/backed-up queue (a deploy in
* progress, a crashed worker, a traffic spike) could make this job run
* AFTER the shopper has already logged in and something has read
* $user->latestCustomer() (Modules\Core\Customer\Services\
* CustomerAccountService), silently returning null for a legitimately
* paired user with no retry anywhere to catch it. Kept synchronous so the
* Customer always exists by the time UserCreated's dispatch call returns,
* regardless of queue health.
*/
class CreateCustomerForUser
{
public function handle(UserCreated $event): void
@@ -0,0 +1,65 @@
<?php
namespace Modules\Core\Customer\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Lunar\Models\Address;
use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted;
use Modules\Core\Customer\Events\CustomerAddressUpdated;
use Modules\Core\Customer\Events\CustomerProfileUpdated;
use Modules\Core\Logging\ActivityLogService;
/**
* Same pattern as Payment\Listeners\LogPaymentMethodActivity — routes
* Modules\Core\Customer\Services\CustomerAccountService's own events
* through the shared Logging\ActivityLogService, giving every
* shopper-initiated address/profile change an audit trail (previously
* none existed at all for account self-service writes). $causer is
* passed through explicitly on every call, since these events are
* `web`-guard-caused, not `staff`-guard — see ActivityLogService's own
* docblock for why that parameter exists.
*
* Queued — a pure audit-log write with no same-request reader; the
* shopper's own request doesn't need this to complete before responding.
*/
class LogCustomerAccountActivity implements ShouldQueue
{
public function __construct(
private readonly ActivityLogService $activityLog,
) {}
public function handleAddressCreated(CustomerAddressCreated $event): void
{
$this->activityLog->created($event->address, $event->address->getAttributes(), $event->causer);
}
public function handleAddressUpdated(CustomerAddressUpdated $event): void
{
$this->activityLog->updated(
$event->address,
$event->old,
$event->address->only(array_keys($event->old)),
$event->causer,
);
}
public function handleAddressDeleted(CustomerAddressDeleted $event): void
{
$subject = (new Address)->forceFill($event->address);
$subject->exists = true;
$subject->id = $event->address['id'];
$this->activityLog->deleted($subject, $event->address, $event->causer);
}
public function handleProfileUpdated(CustomerProfileUpdated $event): void
{
$this->activityLog->updated(
$event->customer,
$event->old,
$event->customer->only(array_keys($event->old)),
$event->causer,
);
}
}
@@ -0,0 +1,63 @@
<?php
namespace Modules\Core\Customer\Privacy;
use Lunar\Models\Address;
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;
/**
* A customer's saved addresses (lunar_addresses) — belong to the Customer
* (business account) via customer_id, not to an individual User, so this is
* Customer-scope only. No legal retention requirement of their own (unlike
* OrderAddress, handled by OrderDataProvider), so they're freely deleted outright
* rather than pseudonymized in place.
*/
class AddressDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'addresses';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$addresses = Address::where('customer_id', $subject->customerId)->get();
return new ProviderExportResult('addresses', $addresses->map(fn (Address $address) => [
'id' => $address->id,
'first_name' => $address->first_name,
'last_name' => $address->last_name,
'company_name' => $address->company_name,
'line_one' => $address->line_one,
'line_two' => $address->line_two,
'line_three' => $address->line_three,
'city' => $address->city,
'state' => $address->state,
'postcode' => $address->postcode,
'contact_email' => $address->contact_email,
'contact_phone' => $address->contact_phone,
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('addresses', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
Address::where('customer_id', $subject->customerId)->delete();
return new ProviderErasureResult('addresses', ErasureOutcome::Erased);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('addresses', ErasureOutcome::Skipped, 'Addresses belong to Customer accounts, not individual users.');
}
}
@@ -0,0 +1,122 @@
<?php
namespace Modules\Core\Customer\Privacy;
use Lunar\Models\Customer;
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;
/**
* The Customer record itself (lunar_customers) and, on the User side, the User's
* own name/email. This is the one provider that implements both scopes
* meaningfully, and they are deliberately kept from touching each other's data:
*
* - eraseForCustomer() clears the account's own fields (name, company, tax id)
* only — it never touches any linked User's login or identity, even though
* $customer->users exists. Erasing a business account must not destroy the
* login access of every person who works there.
* - eraseForUser() clears that one person's name/email only — it never touches
* the Customer record's own fields, and it also detaches the User from every
* Customer they're linked to (the customer_user pivot — see docs/modules.md
* "Customer/User Pairing"), since erasing a person's identity should end
* their membership everywhere, without erasing the business accounts
* themselves or any other User still linked to them.
*
* No legal retention requirement applies to this table on its own, so both
* directions are freely erased — Order/OrderAddress, which DO have a retention
* requirement, are handled separately by OrderDataProvider.
*/
class CustomerDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'customer';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$customer = Customer::find($subject->customerId);
return new ProviderExportResult('customer', $customer ? [
'id' => $customer->id,
'title' => $customer->title,
'first_name' => $customer->first_name,
'last_name' => $customer->last_name,
'company_name' => $customer->company_name,
'tax_identifier' => $customer->tax_identifier,
'meta' => $customer->meta,
'users' => $customer->users->map(fn ($user) => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
])->all(),
] : []);
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
$model = config('auth.providers.users.model');
$user = $model::find($subject->userId);
return new ProviderExportResult('customer', $user ? [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'customers' => $user->customers->map(fn (Customer $customer) => [
'id' => $customer->id,
'company_name' => $customer->company_name,
])->all(),
] : []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$customer = Customer::find($subject->customerId);
if (! $customer) {
return new ProviderErasureResult('customer', ErasureOutcome::Skipped, 'Customer record not found.');
}
$customer->update([
'title' => null,
'first_name' => 'Erased',
'last_name' => "Customer #{$customer->id}",
'company_name' => null,
'tax_identifier' => null,
'account_ref' => null,
'meta' => null,
]);
return new ProviderErasureResult('customer', ErasureOutcome::Erased);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
$model = config('auth.providers.users.model');
$user = $model::find($subject->userId);
if (! $user) {
return new ProviderErasureResult('customer', ErasureOutcome::Skipped, 'User record not found.');
}
$user->customers()->detach();
$user->update([
'name' => null,
'email' => "erased-user-{$user->id}@example.invalid",
// A live OTP code left on an otherwise-erased row is a residual
// secret tied to an identity that no longer exists here — clear
// it alongside name/email rather than leaving it to expire on
// its own 10-minute window.
'otp_code' => null,
'otp_expires_at' => null,
'otp_attempts' => 0,
]);
return new ProviderErasureResult('customer', ErasureOutcome::Erased);
}
}
@@ -0,0 +1,255 @@
<?php
namespace Modules\Core\Customer\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Address;
use Lunar\Models\Order;
use LogicException;
use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted;
use Modules\Core\Customer\Events\CustomerAddressUpdated;
use Modules\Core\Customer\Events\CustomerProfileUpdated;
use Modules\Core\Customer\Exceptions\AddressNotFoundException;
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
use Modules\Core\Customer\Models\Customer;
/**
* The storefront-facing "My Account" API — mirrors Modules\Core\Cart\
* Services\CartService's shape, one boboko-owned service a storefront
* calls, so Lunar's own Customer/Order/Address models stay an
* implementation detail. Every method is scoped to the given
* Authenticatable's own Customer::latestCustomer() (see docs/modules.md
* "Customer/User Pairing") — there is no method here that accepts a bare
* order/address id without also requiring the owning user, precisely so
* a controller built on top of this can't accidentally leak one
* customer's data to another by trusting a client-supplied id alone.
*
* $user->latestCustomer() can be null for a User that has no paired
* Customer yet (shouldn't happen via the normal OTP-login cascade — see
* Modules\Core\Auth\Events\UserCreated — but is defended against anyway,
* since nothing stops a User row existing without one, e.g. seeded data)
* — every method returns an empty/null result rather than throwing in
* that case, since "no customer paired yet" isn't a not-found error, it's
* a legitimately empty account.
*
* Address/profile writes go through an explicit column allowlist
* (WRITABLE_ADDRESS_FIELDS/WRITABLE_PROFILE_FIELDS) rather than trusting
* Lunar\Models\Address/Customer's own $guarded = [] — that flag makes
* every column mass-assignable at the model layer, including
* customer_id on addresses, so a caller passing through an unfiltered
* request array (a real risk for a storefront controller built directly
* against this service) could otherwise reassign an address to a
* different customer entirely, or overwrite created_at/id. Arr::only()
* silently drops anything not on the allowlist rather than erroring —
* this is a safety boundary, not form validation (a storefront still
* validates its own request shape before calling this).
*
* Authorization here IS the ownership scoping itself, not a separate
* layer bolted on top — there is deliberately no Laravel Policy/Gate
* class for Order/Address, since a policy is meaningless without a
* controller calling authorize() against it, and this branch is scoped
* to backend services only (no routes/controllers — see the branch's own
* commit history). Every public method below takes Authenticatable $user
* as a required first argument and resolves everything else (Order,
* Address, Customer) strictly through that user's own
* latestCustomer() — there is no method that looks anything up by a bare
* id alone. A future storefront controller cannot "forget" the
* authorization check the way it could with a separate policy class,
* because the check IS how every lookup happens; skipping it isn't an
* option the method signatures allow.
*/
class CustomerAccountService
{
private const WRITABLE_ADDRESS_FIELDS = [
'title', 'first_name', 'last_name', 'company_name',
'line_one', 'line_two', 'line_three', 'city', 'state', 'postcode',
'delivery_instructions', 'contact_email', 'contact_phone',
'country_id', 'shipping_default', 'billing_default',
];
private const WRITABLE_PROFILE_FIELDS = [
'title', 'first_name', 'last_name', 'company_name', 'vat_no',
];
public function customer(Authenticatable $user): ?Customer
{
/** @var Customer|null */
return $user->latestCustomer();
}
/**
* Placed orders only (placed_at IS NOT NULL) — a draft/abandoned
* order with no placed_at is checkout-in-progress state, not
* something that belongs in order history.
*/
public function orders(Authenticatable $user, int $perPage = 15): LengthAwarePaginator
{
$customer = $this->customer($user);
if (! $customer) {
return new LengthAwarePaginator([], 0, $perPage);
}
return $customer->orders()
->whereNotNull('placed_at')
->latest('placed_at')
->paginate($perPage);
}
/**
* @throws OrderNotFoundException if $orderId doesn't belong to this
* customer, or belongs to a draft (never placed) order
*/
public function order(Authenticatable $user, int $orderId): Order
{
$customer = $this->customer($user);
$order = $customer
?->orders()
->whereNotNull('placed_at')
->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments'])
->find($orderId);
if (! $order) {
throw new OrderNotFoundException;
}
return $order;
}
public function addresses(Authenticatable $user): iterable
{
$customer = $this->customer($user);
return $customer?->addresses ?? collect();
}
/**
* @param array<string, mixed> $data Any key not in
* WRITABLE_ADDRESS_FIELDS is silently dropped — see this class's
* own docblock.
*/
public function createAddress(Authenticatable $user, array $data): Address
{
$customer = $this->customerOrFail($user);
$address = $customer->addresses()->create(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS));
$this->enforceSingleDefault($customer, $address);
$address->refresh();
Event::dispatch(new CustomerAddressCreated($address, $user));
return $address;
}
/**
* @throws AddressNotFoundException if $addressId doesn't belong to
* this customer
*/
public function updateAddress(Authenticatable $user, int $addressId, array $data): Address
{
$address = $this->ownedAddress($user, $addressId);
$old = $address->only(array_keys(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS)));
$address->update(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS));
$this->enforceSingleDefault($address->customer, $address);
$address->refresh();
Event::dispatch(new CustomerAddressUpdated($address, $old, $user));
return $address;
}
/**
* @throws AddressNotFoundException if $addressId doesn't belong to
* this customer
*/
public function deleteAddress(Authenticatable $user, int $addressId): void
{
$address = $this->ownedAddress($user, $addressId);
$snapshot = $address->getAttributes();
$address->delete();
Event::dispatch(new CustomerAddressDeleted($snapshot, $user));
}
/**
* Lunar has no built-in action enforcing "at most one shipping
* default / one billing default per customer" — a raw update() could
* otherwise leave two addresses both flagged shipping_default. Runs
* after every create/update, unconditionally (cheap — at most two
* single-row UPDATEs, only fired when the just-written address
* itself is a default), clearing the flag on every OTHER address of
* the same customer.
*/
private function enforceSingleDefault(Customer $customer, Address $address): void
{
if ($address->shipping_default) {
$customer->addresses()->where('id', '!=', $address->id)->update(['shipping_default' => false]);
}
if ($address->billing_default) {
$customer->addresses()->where('id', '!=', $address->id)->update(['billing_default' => false]);
}
}
/**
* @throws AddressNotFoundException if $addressId doesn't belong to
* this customer
*/
private function ownedAddress(Authenticatable $user, int $addressId): Address
{
$customer = $this->customer($user);
$address = $customer?->addresses()->find($addressId);
if (! $address) {
throw new AddressNotFoundException;
}
return $address;
}
/**
* @param array<string, mixed> $data Any key not in
* WRITABLE_PROFILE_FIELDS is silently dropped — see this class's
* own docblock.
*/
public function updateProfile(Authenticatable $user, array $data): Customer
{
$customer = $this->customerOrFail($user);
$old = $customer->only(array_keys(Arr::only($data, self::WRITABLE_PROFILE_FIELDS)));
$customer->update(Arr::only($data, self::WRITABLE_PROFILE_FIELDS));
$customer->refresh();
Event::dispatch(new CustomerProfileUpdated($customer, $old, $user));
return $customer;
}
/**
* @throws LogicException if $user has no paired Customer at all —
* distinct from AddressNotFoundException/OrderNotFoundException
* (which mean "this id isn't yours"), this means the account
* itself is in an invariant-violating state the normal OTP-login
* cascade should never produce.
*/
private function customerOrFail(Authenticatable $user): Customer
{
$customer = $this->customer($user);
if (! $customer) {
throw new LogicException('This user has no paired Customer record.');
}
return $customer;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Export;
use Closure;
/**
* One column in a CsvWriter schema: a header label plus a closure that pulls this
* column's value out of one record. The closure doesn't care what shape a record
* is — an array, an Eloquent model, a DTO — so the same CsvWriter serves any
* domain (GDPR export, an admin catalog export, an accounting export) by simply
* being handed a different column schema and a different row source.
*/
final class CsvColumn
{
/**
* @param Closure(mixed):((string|int|float|null)) $value
*/
public function __construct(
public readonly string $header,
public readonly Closure $value,
) {}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Modules\Core\Export;
/**
* A generic columns + rows -> CSV file writer. No knowledge of any domain (GDPR,
* catalog, accounting, ...) — a caller supplies the schema (CsvColumn[]) and the
* data source (any iterable of records), and this writes one CSV. Reusable for
* any future bulk-export need without modification.
*/
class CsvWriter
{
/**
* @param array<int, CsvColumn> $columns
* @param iterable<mixed> $rows
*/
public function write(array $columns, iterable $rows, string $path): void
{
$handle = fopen($path, 'w');
fputcsv($handle, array_map(fn (CsvColumn $column) => $column->header, $columns));
foreach ($rows as $row) {
fputcsv($handle, array_map(
fn (CsvColumn $column) => $this->stringify(($column->value)($row)),
$columns
));
}
fclose($handle);
}
private function stringify(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_array($value)) {
return json_encode($value);
}
return (string) $value;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace Modules\Core\File\Adapters;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Modules\Core\File\Contracts\FileAdapterInterface;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Wraps Laravel's own 'local' Storage disk — see FileAdapterInterface's
* own docblock for why this exists as a named adapter rather than every
* caller reaching for Storage::disk('local') directly: swapping to a
* different backend later (S3FileAdapter, say) means adding one class and
* one contextual-binding entry, touching nothing that already uses
* FileService.
*/
class LocalFileAdapter implements FileAdapterInterface
{
public function __construct(
private readonly Filesystem $disk,
) {}
public function store(UploadedFile $file, string $directory): string
{
return $this->disk->putFile($directory, $file);
}
public function exists(string $path): bool
{
return $this->disk->exists($path);
}
public function delete(string $path): void
{
$this->disk->delete($path);
}
public function retrieve(string $path, ?string $name = null): StreamedResponse
{
return $this->disk->response($path, $name);
}
public function download(string $path, ?string $name = null): StreamedResponse
{
return $this->disk->download($path, $name);
}
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\File\Commands;
use Illuminate\Console\Command;
use Modules\Core\File\Services\FileService;
/**
* Generic wrapper around FileService::pruneUnowned() — see that method's
* own docblock for what "unowned" means and why the grace period exists.
* Any caller (3dealer's product custom-field photo uploads today, some
* other future upload feature tomorrow, in this app or another consuming
* app) schedules this once per purpose string it stores files under; this
* command itself has no opinion about what any given purpose means.
*/
class PruneUnownedFilesCommand extends Command
{
protected $signature = 'boboko:file:prune-unowned {purpose} {--hours=24 : Only delete unowned files older than this}';
protected $description = 'Delete unowned files of a given purpose past their grace period';
public function handle(FileService $files): int
{
$purpose = $this->argument('purpose');
$deleted = $files->pruneUnowned($purpose, now()->subHours((int) $this->option('hours')));
$this->info("Deleted {$deleted} unowned file(s) of purpose \"{$purpose}\".");
return self::SUCCESS;
}
}
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\File\Contracts;
use Illuminate\Http\UploadedFile;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* One storage backend's actual byte-level operations — a disk name (see
* Modules\Core\File\Models\File::$disk) resolves to exactly one
* implementation of this via Modules\Core\File\Services\FileService's own
* contextual binding (see Providers\FileServiceProvider), the same
* pattern Shipping\Contracts\CarrierFulfillmentInterface uses to pick an
* AcsFulfillmentService/BoxNowFulfillmentService per carrier. FileService
* itself never touches a disk directly — every backend-specific detail
* (a local path, an S3 bucket/region, ...) lives entirely inside one
* adapter, so adding a new backend never touches FileService or any of
* its callers.
*/
interface FileAdapterInterface
{
/**
* Stores the file under $directory, returning the path to record on
* the File row (Models\File::$path) — backend-specific (a relative
* local path, an S3 object key, ...), meaningful only to this same
* adapter.
*/
public function store(UploadedFile $file, string $directory): string;
public function exists(string $path): bool;
public function delete(string $path): void;
/**
* Streams the file at $path straight to the browser, inline (the
* browser renders/previews it directly rather than prompting to save).
*/
public function retrieve(string $path, ?string $name = null): StreamedResponse;
/**
* Same bytes as retrieve(), but as a forced attachment — the browser
* always prompts to save, even for a type it could otherwise preview
* (an image inline in a new tab).
*/
public function download(string $path, ?string $name = null): StreamedResponse;
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\Core\File\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Core\File\Models\File;
use Modules\Core\File\Services\FileService;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Streams a File's bytes straight to the browser — inline by default (a
* browser-previewable type like an image opens/displays directly), or as
* a forced download with ?download=1 (e.g. an explicit "Download" button
* distinct from a thumbnail/preview link pointing at the same file). Only
* reachable via a short-lived signed URL — same auth model as
* Modules\Core\Shipping\Http\Controllers\DownloadShipmentLabelController
* (a valid signature IS the auth check, no separate staff/customer
* session check here) — so any caller that can mint a signed URL to this
* route (the storefront's own custom-field upload flow, or the admin
* order-line display) can hand a viewer a working link without this
* controller knowing anything about who they are or why they're allowed
* to see this particular file.
*
* Looks the File up manually from a plain {file} id rather than relying
* on implicit route-model-binding — registered via loadRoutesFrom() with
* no middleware group (see Providers\FileServiceProvider::boot()), so
* SubstituteBindings never runs and a type-hinted File parameter would
* silently resolve to an empty, non-existent model instead of 404ing.
*/
class DownloadFileController extends Controller
{
public function __invoke(Request $request, int $file, FileService $files): StreamedResponse
{
if (! $request->hasValidSignature()) {
abort(401);
}
$file = File::findOrFail($file);
abort_unless($files->exists($file), 404);
return $request->boolean('download') ? $files->download($file) : $files->retrieve($file);
}
}
@@ -0,0 +1,76 @@
<?php
namespace Modules\Core\File\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Validator;
use Modules\Core\File\Services\FileService;
/**
* The generic "accept an upload, validate it, store it via FileService,
* return its id" flow — what varies per use case (which extensions/sizes
* are acceptable) is deliberately NOT configurable here, and NEVER trusts
* anything the request itself claims about its own limits: a client could
* simply lie about them. purpose()/validationRules() are protected hooks
* a concrete subclass overrides instead — an ordinary PHP method a caller
* writes once per upload policy, not request input, so the actual limit
* enforced is always whatever server-side code says it is. Different
* products can even need different limits (a 3D-print reference photo
* vs. a video upload, say) — that's still a subclass's own store()
* override deciding which rule set applies to a given request, not
* something this base class or a shared config file could express.
*/
abstract class UploadFileController extends Controller
{
/**
* The File row's `purpose` tag (see Models\File) — also the storage
* directory it lands under (FileService::store()'s single $purpose
* param doubles as both).
*/
abstract protected function purpose(): string;
/**
* Laravel validation rules for the incoming request, keyed exactly as
* $request->all() would be. Must include a 'file' rule accepting an
* uploaded file — this class always reads the file from that key.
*
* @return array<string, array<int, mixed>>
*/
abstract protected function validationRules(Request $request): array;
public function store(Request $request, FileService $files): JsonResponse
{
$validator = Validator::make(
$request->all(),
$this->validationRules($request),
$this->validationMessages($request),
$this->validationAttributes($request),
);
if ($validator->fails()) {
return response()->json(['error' => $validator->errors()->first('file')], 422);
}
$file = $files->store($request->file('file'), $this->purpose());
return response()->json(['file_id' => $file->id]);
}
/**
* @return array<string, string>
*/
protected function validationMessages(Request $request): array
{
return [];
}
/**
* @return array<string, string>
*/
protected function validationAttributes(Request $request): array
{
return [];
}
}

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