Compare commits

..
23 Commits
Author SHA1 Message Date
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
111 changed files with 3157 additions and 629 deletions
+248
View File
@@ -4,6 +4,254 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.20.0] - 2026-09-23
### Added
- `Modules\Core\Catalog\Support\ProductFilterBuilder::withVisibility()` — every storefront
product read (`ProductService::list()`/`getById()`/`getBySlug()`/`random()`/`facets()`/
`priceRange()`, and `ProductSearchService`) now excludes `status = "draft"` products unless
`APP_DEBUG` is true. Previously nothing filtered by status anywhere in this service — a draft
product was fully visible on the storefront in every environment, always.
- Per-product custom input fields (`Modules\Core\Catalog\Models\Product::$custom_fields`) — a
repeater on the product edit form lets a merchant define extra input a shopper fills in on
that product's page before adding it to cart (a reference photo upload, personalization
text, etc.), each field a `{key, type: text|textarea|file, label, required}` entry.
Deliberately not a Lunar `ProductOption`: an option's values are a fixed, admin-authored list
that define variants, which doesn't fit "the shopper uploads their own unique photo."
Required a new first-party `Product` model (registered via `ModelManifest::replace()`) purely
to add a cast and `$fillable` entry Lunar's own base model doesn't have for this column — see
that class's own docblock for two real Lunar-integration bugs this surfaced (below).
- `boboko:wipe-catalog` (`Modules\Core\Command\WipeCatalogCommand`) — irreversibly deletes every
Product and everything that only exists because of a product (variants, variant prices,
product-option assignments, images/media, associations, the `ImportMapping` rows tying them
back to an external source, the Meilisearch index), leaving catalog structure other products
could still reference untouched (ProductOption/ProductOptionValue definitions, Brands,
Collections, Tags, Customer Groups). Gated by an OTP emailed to a real Staff account (reusing
`Auth\Services\OtpService`, the same mechanism admin login already uses) plus typing the exact
product count back — not a plain yes/no confirm.
- `Modules\Core\Auth\Services\OtpService::generateAndSend()` gained an optional `$purpose`
parameter (default `'login'`, fully backward compatible) — `OtpMail` picks its subject/intro
copy from a small fixed set of known purposes, so a destructive-command confirmation code
reads as "Confirm: Wipe Catalog," not the login flow's "Your login code."
- `Modules\Core\Catalog\Services\SkuBackfillService` — the actual backfill logic behind
`boboko:catalog:backfill-skus`, extracted so `MigrateImport\RunMigrateImportJob` can also call
it automatically right after a Shopify import (gated on `$spec->source === 'shopify'`, the
only source that creates variants at all) — no separate manual step needed after a migration.
- `ProductSort::Popularity` — sorts by a new `order_count` field Meilisearch now indexes per
product (trailing-year, physical order lines only, aggregated across a product's variants) —
the same "popular" definition Lunar's own admin dashboard "Popular Products" widget already
uses. Not wired into the storefront's sort dropdown yet; callable directly via
`ProductService::list(sort: ProductSort::Popularity)`.
### Fixed
- `MigrateImport\Shopify\ShopifyExportImporter` created every variant with Lunar's own column
default `purchasable = 'always'` (purchasable regardless of stock) rather than respecting the
real `Variant Inventory Qty` the import itself provides — now explicitly set to `'in_stock'`.
Forward-only; does not retroactively touch variants from a prior import run.
- `WipeCatalogCommand::wipe()` used `chunkById()` while deleting rows inside the loop — a known
pitfall where `chunkById()` re-queries "id > lastSeenId" every iteration, so deleting rows
shrinks the table out from under it and can silently skip products that were never actually
deleted at all. Fixed by always re-querying the first N remaining rows instead of advancing
an id cursor, so every product is visited exactly once regardless of how many are deleted out
from under the query as it goes.
- `WipeCatalogCommand` called `delete()`, not `forceDelete()`, on `Product`/`ProductVariant` —
both use `SoftDeletes`, so an "irreversible" wipe only trashed rows, leaving them sitting in
the table. Combined with the `chunkById` bug above, this left ~185 zero-variant ghost
`Product` rows in practice, which then crashed the admin's own global search (Lunar's
`ProductResource::getGlobalSearchResultDetails()` assumes every returned product — trashed
ones deliberately included, by Lunar's own design — has at least one variant). Fixed to
`forceDelete()`; the existing ghost rows were removed directly (none had live order/cart
references). `wipe()` also now clears `'image'`-type `ImportMapping` rows, not just
`'product'`/`'variant'`.
- `ShopifyExportImporter::resolveOrImportImage()` trusted a cached `ImportMapping`'d `Media`
object unconditionally — now verifies the row still exists and is still attached to the
current product before reusing it, falling through to a fresh import/attach otherwise. Makes
a re-import robust to orphaned media regardless of what left them behind (e.g. a prior
`WipeCatalogCommand` run, before the fix above).
- Cart admin view (`Cart\Filament\Resources\CartResource\Pages\ViewCart`) 500'd
(`Lunar\Exceptions\MissingCurrencyPriceException`) for any cart still holding a line whose
purchasable no longer exists (e.g. after `boboko:wipe-catalog`) — `Cart::calculate()` now has
that exception caught, falling back to an uncalculated cart; every total field already
rendered `?->formatted() ?? '—'`, so the page degrades to showing "—" instead of a 500.
- Creating or editing a Payment Method offered "Capture mode" (Charge immediately / Hold now,
charge later) even for `cash-on-delivery`, whose driver has no `authorize()` method at
all — selecting "authorize" there would have fatally errored at checkout. Now hidden/
non-required unless the resolved driver implements `SupportsAuthorization`.
- `Lunar\Base\Traits\Searchable::indexer()` (and its sibling filterable/sortable-attribute
methods) resolve their configured indexer via `$config[self::class]` — but `self::class`
inside a trait method is a compile-time literal bound to whichever class first `use`s the
trait, so it always evaluates to `Lunar\Models\Product`, never a subclass, regardless of
which instance calls it. `config/lunar/search.php`'s `'indexers'` map must stay keyed by
`Lunar\Models\Product::class`, not the new `Product` subclass — keying it by the subclass
made the lookup miss entirely and silently fall back to a near-empty default indexer, wiping
every filterable/sortable attribute the index had. Caught live, reverted; documented in the
config file itself so it isn't repeated.
- `CustomerServiceProvider`/`CatalogServiceProvider` called `ModelManifest::replace()` directly
from `boot()` — `LunarServiceProvider` (lunarphp/core) calls `Facades\ModelManifest::
register()` from its OWN `boot()`, re-discovering every `Lunar\Models\*` class and silently
overwriting any `replace()` registered earlier in the provider boot order. Both now defer to
`$this->app->booted()`, which only runs once every provider's `boot()` has completed.
## [0.19.0] - 2026-09-18
### Added
- `Modules\Core\Payment\Contracts\RequiresFulfillmentType` — a payment driver can now declare
it only makes sense for one fulfillment type (carrier delivery vs. store pickup), the
payment-side mirror of `Shipping\Contracts\DeclaresFulfillmentType`. `CheckoutService::
getPaymentMethods()` excludes a method whose driver disagrees with the cart's currently
selected shipping method — `OfflinePaymentDriver` ("pay in store") now requires
`store_pickup`, `CashOnDeliveryPaymentDriver` requires `carrier`. Previously every enabled,
configured payment method was offered regardless of shipping choice, so a shopper picking a
courier delivery could still see "Pay in store" (no staff member present to take cash), and a
store-pickup shopper could see cash-on-delivery (meaningless — there is no delivery to collect
payment on). No constraint is imposed before a shipping option is selected.
- `Modules\Core\Payment\Events\PaymentDeferred` — dispatched by any payment driver whose
`Pending` result will never resolve via a later gateway callback (currently only
`CashOnDeliveryPaymentDriver`), distinct from a Stripe-style `Pending` that a webhook will
still resolve. Handled by the new `Modules\Core\Order\Listeners\
MarkOrderPlacedOnDeferredPayment`, which sets `Order::placed_at`, dispatches `OrderPlaced`,
and advances `status` past `awaiting_payment` — without ever touching `Order::paid`, which
still only flips via staff explicitly marking a COD order received.
- `Modules\Core\Order\Services\OrderPaymentResolutionService::resolveDeferredPayment()` — the
status-advance half of the above, reusing the same "advance past `awaiting_payment`" logic a
captured payment already uses.
- `Modules\Core\Checkout\Exceptions\NoShippingAddressException`.
- `Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient::destinations()` — lists available Box
Now lockers (`GET /destinations`), backing a plain, self-hosted locker picker on checkout;
Box Now's own Destination Map JS widget only talks to their Production environment, making it
unusable while developing against Stage credentials.
- `config/shippingCarriers/boxnow.php`: `BOXNOW_PARTNER_ID` — issued alongside Box Now
credentials, consumed only by their client-side map widget, never by `BoxNowClient`'s own
REST authentication.
- A "Tracking history" list under each shipment on the order page (`Shipping\Extensions\
OrderShipmentsExtension`) — every recorded carrier checkpoint, oldest first, not just the
latest status.
- `Modules\Core\Review\Services\ReviewService` and `ReviewEvents\ReviewReplied` — extracted
from `ManageProductReviews`'s inline `$record->update()`, following the write-then-dispatch
pattern used everywhere else.
- `Modules\Core\Catalog\Services\StockService::decrementForOrder()` — extracted from
`DecrementStockOnOrderPlaced`, isolating the atomic stock-decrement SQL and Meilisearch
reindex from the listener itself.
- `Modules\Core\Order\Services\OrderStatusFlow::isValidTransition()` — the single source of
truth for "is this a legal next status," replacing several listeners' own hardcoded "only
fire from status X" comparisons.
### Fixed
- Cash-on-delivery orders were placed but never left `awaiting_payment`, were invisible in
customer order history, never decremented stock, and the storefront's own post-checkout
confirmation could never find them — `CashOnDeliveryPaymentDriver::pay()` returns `Pending`,
which dispatched no event at all, so nothing ever set `Order::placed_at` or advanced
`status`. Fixed by `PaymentDeferred`/`MarkOrderPlacedOnDeferredPayment` above.
- Staff marking a COD order "paid" (`OrderFulfillmentService::markPaid()`) flipped
`Order::paid`/`paid_at` but never recorded a `Transaction` row — no audit trail, and anything
reading `$order->transactions` (paid-amount displays included) saw nothing. Now records a
`capture` transaction via `TransactionRecorder`, the exact call site its own docblock had
already anticipated ("a future admin action ... can write a row the same way").
- A confirmed cash-on-delivery shipment dispatched via ACS or Box Now never actually told the
carrier to collect payment — `ShipmentRequest::$paymentMode`/`$amountToCollect` were defined
on the DTO but no caller ever populated them, permanently dead-coding both carriers' COD
branches (`AcsFulfillmentService`'s `Cod_Ammount`/`Cod_Payment_Way`, Box Now's
`amountToBeCollected`). `OrderViewExtension`'s "Create Shipment" action now derives both from
`OrderStatusFlow::isCod($order)` at dispatch time — never left to staff to remember.
- `Modules\Core\Shipping\Jobs\PollShipmentTrackingJob`: one shipment's tracking lookup failing
(a carrier 500, a malformed parcel response) aborted the rest of that carrier's shipments in
the same batch — now individually caught and reported per shipment.
- Every Box Now delivery request 400'd (`P405`, invalid phone number) for any customer whose
phone was stored in local Greek format rather than full international — `contactNumber` is
now normalized to `+30...` before every request.
- Creating a Box Now shipment 400'd (`P401`/`P402`) whenever `BOXNOW_ORIGIN_LOCATION_ID` or the
sender contact fields were unset — documented and confirmed against a live sandbox account.
- Selecting a Box Now locker at checkout, then making any unrelated address-form edit
afterward (even a delivery-instructions keystroke), silently discarded the locker choice —
`Lunar\Actions\Carts\AddAddress` deletes and recreates the cart's shipping address row on
every save, wiping whatever `meta` a prior save had written onto it.
`CheckoutService::setShippingAddress()` now carries the locker forward across that
recreation; `selectShippingOption()` clears it when switching away from Box Now, so a stale
locker never resurfaces if the shopper switches back later.
`Shipping\Extensions\OrderViewExtension`'s "Box Now locker ID" field is no longer locked
read-only once a customer choice exists — staff can override it.
- Creating a Box Now shipping method 500'd (`Array to string conversion` / invalid JSON insert)
— the vendor `ListShippingMethod` page's `CreateAction` builds its form inline, bypassing
`ShippingMethodResourceExtension`'s translated-name field entirely; `Filament\Pages\
ManageShippingRates`'s method picker and "Shipping Method" table column also queried/sorted
the now-JSON `name` column directly in SQL (`could not identify an ordering operator for type
json`), both resolved app-side instead.
- Creating or editing a Payment Method: `capture_mode` ("Charge immediately" / "Hold now,
charge later") was offered even for a driver with no `authorize()` method at all
(`CashOnDeliveryPaymentDriver`), which would have fatally errored at checkout had "authorize"
ever been selected — now hidden/non-required unless the driver implements
`SupportsAuthorization`. A spurious `validation.required` on the translated Name field, and
every new Payment Method silently saving at `position` 0 regardless of the intended
"last in the list" default — both traced to the same cause: an `Action::schema()` modal only
dehydrates fields backed by a real form component, so `fillForm()`'s defaults for `name`/
`position` were computed but never actually reached the saved record.
- `Modules\Core\Auth\Services\UserOtpService::generateAndSend()` now dispatches `UserCreated`
when a new `User` row is created — this event was previously never dispatched anywhere in
this package at all, despite listeners existing for it.
- Applied a deliberate queueing policy across every Order/Localization/Customer/Payment/
Catalog listener, judged case-by-case on "if the queue stalls for minutes/hours, does this
cause a real functional break, not just cosmetic staleness" — `RecordPaymentTransaction`,
`CompleteOrderOnPickedUp`, `CreateCustomerForUser`, and `DecrementStockOnOrderPlaced` stay
synchronous (a stalled queue would mean a real ordering violation or oversell risk); cache
flushes, activity logging, and carrier-checkpoint-driven fulfillment listeners are now queued.
## [0.18.1] - 2026-09-16
### Added
- `Modules\Core\Payment\Privacy\PaymentDataProvider` — `lunar_transactions` (`card_type`/
`last_four`) and `stripe_payment_intents` were previously uncovered by any Privacy provider.
Pseudonymizes card metadata on erasure (same tax/accounting retention reasoning as
`OrderDataProvider`); deletes the Stripe correlation rows outright, since their only purpose
(resolving an async webhook callback) has already been served by the time an erasure request
runs. No Stripe Customer object exists anywhere in this app to also request deletion of — see
`docs/payments.md` "Reconciliation".
- `Modules\Core\Auth\Privacy\UserSessionDataProvider` — `user_sessions` (`ip_address`,
`user_agent`) was previously uncovered. User-scope only; deleted outright on erasure, no legal
retention argument applies to login-session metadata.
- `Modules\Core\Logging\Privacy\ActivityLogDataProvider` — Spatie's `activity_log` table
(`Modules\Core\Logging\ActivityLogService`, plus several Lunar models' native `LogsActivity`)
durably retained full PII snapshots in `properties` even after the real row was erased
elsewhere. Redacts `properties` by subject (`Customer`/`Address`/`CartAddress`/`OrderAddress`/
`Transaction`) on erasure; deliberately never touches `causer_id`, which is an actor reference,
not PII content. Must run before `AddressDataProvider` in `config('core.privacy.providers')` —
see the class's own docblock.
- `ErasureOutcome::Failed` — a provider throwing an exception is now a genuine, distinct outcome
from `Skipped` (a deliberate no-op), surfaced in the erasure report rather than silently
aborting the request.
### Fixed
- `PrivacyService::completeErasure()` and `ExportDataSubjectJob::handle()` ran every registered
provider through a plain `array_map()` with no per-provider error handling — one provider
throwing aborted the entire request, discarding every other provider's already-computed
result and leaving the request stuck `Pending`/`Failed` with no report at all. Both now catch
per-provider (`PrivacyService::safeErase()`, `ExportDataSubjectJob::safeExport()`), logging the
exception and recording `ErasureOutcome::Failed`/`ProviderExportResult::$error` for that one
provider while every other provider's result is still recorded normally. Verified live:
simulating a throwing provider mid-erasure now correctly completes the request with a mixed
`erased`/`failed`/`erased` report instead of leaving it `Pending` forever.
- `CartDataProvider`/`OrderDataProvider` never covered PII-adjacent keys living in `Cart.meta`/
`Order.meta`/`OrderAddress.meta` — `recovery_consent*`, `payment_method`, `checkout_fingerprint`
(Cart), `terms_accepted*` (Order), and `box_now_locker` (OrderAddress) all survived an erasure
request untouched. Both providers now clear these keys alongside their existing address/
free-text field erasure.
- `CustomerDataProvider::eraseForUser()` left `otp_code`/`otp_expires_at`/`otp_attempts` on an
otherwise-erased `User` row. Now cleared alongside name/email.
- `Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource`'s "Outcome" section
referenced `docs/privacy.md` directly in staff-facing UI text (meaningless to a user with no
repo access) and rendered the per-provider report as raw JSON strings via a `KeyValueEntry`
(the wrong component for a list of structured rows). Replaced with a plain-language
description and a proper `RepeatableEntry` table (Data category / Outcome badge / Reason).
### Changed
- The 5 existing Privacy providers (`CustomerDataProvider`, `AddressDataProvider`,
`OrderDataProvider`, `CartDataProvider`, `ReviewDataProvider`) moved out of
`Modules\Core\Privacy\Providers` into their owning domain module's own `Privacy/` subdirectory
(e.g. `Modules\Core\Order\Privacy\OrderDataProvider`) — `Modules\Core\Privacy` now owns only
the shared contract, request lifecycle, and DTOs/enums. Matters concretely if a module is ever
extracted into its own composer package: the provider that knows how to erase that module's
data now travels with it, rather than being stranded in `Privacy` depending on a package that
no longer ships in this repo. See `docs/privacy.md` for the full reasoning.
## [0.18.0] - 2026-09-16
### Added
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.18.0",
"version": "0.20.0",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
+13 -5
View File
@@ -35,11 +35,19 @@ return [
'privacy' => [
'providers' => [
\Modules\Core\Privacy\Providers\CustomerDataProvider::class,
\Modules\Core\Privacy\Providers\AddressDataProvider::class,
\Modules\Core\Privacy\Providers\OrderDataProvider::class,
\Modules\Core\Privacy\Providers\CartDataProvider::class,
\Modules\Core\Privacy\Providers\ReviewDataProvider::class,
// 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,
+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,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');
});
}
};
+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`
+47 -13
View File
@@ -61,6 +61,17 @@ implements that method as a no-op — `ErasureOutcome::Skipped` with a reason fo
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:
@@ -68,11 +79,11 @@ same shape as Lunar's own `config('lunar.search.indexers')` model→indexer map:
// config/core.php
'privacy' => [
'providers' => [
\Modules\Core\Privacy\Providers\CustomerDataProvider::class,
\Modules\Core\Privacy\Providers\AddressDataProvider::class,
\Modules\Core\Privacy\Providers\OrderDataProvider::class,
\Modules\Core\Privacy\Providers\CartDataProvider::class,
\Modules\Core\Privacy\Providers\ReviewDataProvider::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,
// A future module just adds its own provider here.
],
],
@@ -113,17 +124,40 @@ from the record staff (or the person themselves) look up.
## Providers shipped in core
| Provider | `name()` | Covers | Customer-scope | User-scope |
|---|---|---|---|---|
| `CustomerDataProvider` | `customer` | `lunar_customers`, and separately the `User`'s own name/email | Erases the account's own fields only | Erases that User's name/email only, and detaches them from every linked Customer |
| `AddressDataProvider` | `addresses` | `lunar_addresses` | Erased (deleted outright) | Skipped — belongs to a Customer, not an individual |
| `OrderDataProvider` | `orders` | `lunar_orders`, `lunar_order_addresses` | **Pseudonymized, not erased** — see below | Skipped — belongs to a Customer, not an individual |
| `CartDataProvider` | `carts` | `lunar_cart_addresses` | Erased | Skipped — belongs to a Customer, not an individual |
| `ReviewDataProvider` | `reviews` | `product_reviews` | Skipped — authored by an individual, not a business account | Pseudonymized by matching `reviewer_email`; rating/title/body text kept |
| 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
@@ -169,7 +203,7 @@ no one — a business-account erasure must never block anyone's access.
`Modules\Core\Auth\Services\UserOtpService` — nothing else changes).
```php
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
$service = app(PrivacyService::class);
+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>
+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'];
}
}
@@ -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;
}
+13
View File
@@ -10,6 +10,7 @@ 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;
@@ -75,6 +76,18 @@ class UserOtpService
$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;
@@ -14,6 +14,7 @@ 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;
@@ -47,6 +48,17 @@ class ViewCart extends ViewRecord
* 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
{
@@ -58,7 +70,11 @@ class ViewCart extends ViewRecord
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
+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));
}
}
+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',
};
}
}
@@ -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',
]);
}
}
+26
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;
@@ -103,6 +104,7 @@ class ProductIndexer extends BaseProductIndexer
return [
...parent::getSortableFields(),
'price',
'order_count',
];
}
@@ -139,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();
@@ -155,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'])
+5 -2
View File
@@ -254,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();
@@ -287,7 +290,7 @@ 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->localizer->hitsFrom($paginator))
@@ -0,0 +1,56 @@
<?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\RunMigrateImportJob can also call it
* directly, right after a Shopify 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\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();
}
}
+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,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.');
}
}
+133 -3
View File
@@ -10,6 +10,7 @@ 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;
@@ -17,12 +18,15 @@ use Modules\Core\Checkout\Events\RecoveryConsentSet;
use Modules\Core\Checkout\Events\ShippingAddressSet;
use Modules\Core\Checkout\Events\ShippingOptionSelected;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\NoShippingAddressException;
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Shipping\Support\FulfillmentType;
/**
* Storefront-facing checkout operations, mirroring
@@ -49,9 +53,35 @@ class CheckoutService
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));
@@ -141,15 +171,71 @@ 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;
}
/**
* 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'].
*
* 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 selectBoxNowLocker(array $locker): Cart
{
$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 three checks pass, each meaning something
* 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.
@@ -160,17 +246,61 @@ class CheckoutService
* 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(fn (PaymentMethod $method) => $this->paymentDrivers->resolve($method->driver)?->isConfigured() ?? false)
->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 Modules\Core\Payment\Pipelines\
+8 -17
View File
@@ -4,15 +4,13 @@ namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Lunar\Models\ProductVariant;
use Modules\Core\Catalog\Services\SkuBackfillService;
/**
* One-off backfill for variants the Shopify import left with a blank SKU —
* not an importer bug, the source CSV rows genuinely had no `Variant SKU`
* value (see Modules\MigrateImport\Shopify\ShopifyExportImporter) — so
* this synthesizes one instead of re-running the import. 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.
* 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\RunMigrateImportJob).
*/
class BackfillMissingSkusCommand extends Command
{
@@ -20,12 +18,11 @@ class BackfillMissingSkusCommand extends Command
protected $description = 'Generate a SKU for every product variant that is missing one';
public function handle(): void
public function handle(SkuBackfillService $backfill): void
{
$dryRun = (bool) $this->option('dry-run');
$query = ProductVariant::query()->whereNull('sku');
$total = $query->count();
$total = ProductVariant::query()->whereNull('sku')->count();
if ($total === 0) {
$this->info('No variants are missing a SKU.');
@@ -38,19 +35,13 @@ class BackfillMissingSkusCommand extends Command
$bar = $this->output->createProgressBar($total);
$bar->start();
$query->chunkById(500, function ($variants) use ($dryRun, $bar) {
foreach ($variants as $variant) {
$sku = "SKU-P{$variant->product_id}-V{$variant->id}";
$backfill->backfill($dryRun, function (ProductVariant $variant, string $sku) use ($dryRun, $bar) {
if ($dryRun) {
$this->newLine();
$this->line("Variant {$variant->id}: sku => {$sku}");
} else {
$variant->update(['sku' => $sku]);
}
$bar->advance();
}
});
$bar->finish();
@@ -3,7 +3,7 @@
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
use Modules\Core\Privacy\Jobs\EraseDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest;
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
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, 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\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();
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();
}
}
+23 -13
View File
@@ -102,9 +102,21 @@ class CorePlugin implements Plugin
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
@@ -115,24 +127,22 @@ class CorePlugin implements Plugin
// 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::macro('erasureRequests', function (): MorphMany {
/** @var Customer $this */
return $this->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
Customer::resolveRelationUsing('erasureRequests', function (Customer $customer): MorphMany {
return $customer->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
});
Customer::macro('exportRequests', function (): MorphMany {
/** @var Customer $this */
return $this->morphMany(DataExportRequest::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::macro('erasureRequests', function (): MorphMany {
return $this->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
$userModel::resolveRelationUsing('erasureRequests', function ($user): MorphMany {
return $user->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
});
$userModel::macro('exportRequests', function (): MorphMany {
return $this->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id');
$userModel::resolveRelationUsing('exportRequests', function ($user): MorphMany {
return $user->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id');
});
LunarStaff::addActivitylogExcept([
@@ -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
@@ -2,6 +2,7 @@
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;
@@ -18,8 +19,11 @@ use Modules\Core\Logging\ActivityLogService;
* 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
class LogCustomerAccountActivity implements ShouldQueue
{
public function __construct(
private readonly ActivityLogService $activityLog,
@@ -1,14 +1,14 @@
<?php
namespace Modules\Core\Privacy\Providers;
namespace Modules\Core\Customer\Privacy;
use Lunar\Models\Address;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
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
@@ -1,14 +1,14 @@
<?php
namespace Modules\Core\Privacy\Providers;
namespace Modules\Core\Customer\Privacy;
use Lunar\Models\Customer;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
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
@@ -108,6 +108,13 @@ class CustomerDataProvider implements PersonalDataProvider
$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);
+4 -2
View File
@@ -2,6 +2,8 @@
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
@@ -12,10 +14,10 @@ namespace Modules\Core\Export;
final class CsvColumn
{
/**
* @param \Closure(mixed): (string|int|float|null) $value
* @param Closure(mixed):((string|int|float|null)) $value
*/
public function __construct(
public readonly string $header,
public readonly \Closure $value,
public readonly Closure $value,
) {}
}
@@ -2,12 +2,21 @@
namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated;
use Modules\Core\Localization\Services\LanguageCache;
class FlushLanguageCache
/**
* Queued — the only reader of this cache is Modules\Core\Localization\
* Middleware\LocaleMiddleware on a LATER storefront request, never the
* same admin request that edited/created/deleted the Language row (that
* request redirects to a fresh page read straight from the DB, not this
* cache). A few seconds of eventual consistency before the queue worker
* picks this up is an acceptable trade for not blocking the admin save.
*/
class FlushLanguageCache implements ShouldQueue
{
public function __construct(private readonly LanguageCache $languages) {}
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted;
@@ -15,8 +16,13 @@ use Spatie\TranslationLoader\LanguageLine;
* `group`/`key` (the old group's cached array never gets told a row left it).
* This listener flushes every group+locale combination touched by either the
* old or new state so nothing can remain stale.
*
* Queued — this cache backs `__('storefront.*')` lookups on a LATER
* storefront request, never the same admin request that just edited the
* translation (Filament redirects to a fresh index read straight from the
* DB, not this cache). Safe to let a queue worker pick up.
*/
class FlushTranslationCache
class FlushTranslationCache implements ShouldQueue
{
public function handle(TranslationCreated|TranslationUpdated|TranslationDeleted $event): void
{
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Arr;
use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted;
@@ -9,7 +10,12 @@ use Modules\Core\Localization\Events\TranslationUpdated;
use Modules\Core\Logging\ActivityLogService;
use Spatie\TranslationLoader\LanguageLine;
class LogTranslationActivity
/**
* Queued — a pure audit-log write with no same-request reader (Filament
* redirects to a fresh index page after save, which doesn't read the
* activity log at all).
*/
class LogTranslationActivity implements ShouldQueue
{
public function __construct(
private readonly ActivityLogService $activityLog,
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Localization\Events\LanguageUpdated;
use Spatie\TranslationLoader\LanguageLine;
@@ -12,8 +13,15 @@ use Spatie\TranslationLoader\LanguageLine;
* getTranslationsForGroup($newCode, ...) would silently return nothing for
* that locale even though the translated content still exists. Move the
* text.{oldCode} key to text.{newCode} on every affected row instead.
*
* Queued — this walks every LanguageLine row containing the old locale key
* with no upper bound, and nothing in the same request needs the migration
* to have completed before responding (a rename is a rare admin action;
* the affected storefront locale is briefly unavailable until the queue
* worker finishes, the same window that already exists before this
* listener runs at all).
*/
class MigrateTranslationsForRenamedLanguage
class MigrateTranslationsForRenamedLanguage implements ShouldQueue
{
public function handle(LanguageUpdated $event): void
{
@@ -0,0 +1,148 @@
<?php
namespace Modules\Core\Logging\Privacy;
use Lunar\Models\Address;
use Lunar\Models\Cart;
use Lunar\Models\CartAddress;
use Lunar\Models\Customer;
use Lunar\Models\Order;
use Lunar\Models\OrderAddress;
use Lunar\Models\Transaction;
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;
use Spatie\Activitylog\Models\Activity;
/**
* Spatie's own activity_log table (Modules\Core\Logging\ActivityLogService,
* plus several Lunar models' native `use LogsActivity` — Customer,
* CartAddress, OrderAddress, Transaction) durably retains a full snapshot
* of whatever it logged in `properties` (created/updated/deleted
* attributes, including a before/after diff on update), completely
* independent of the real row it describes. Erasing/pseudonymizing
* Customer/Address/CartAddress/OrderAddress/Transaction elsewhere (see
* Customer\Privacy\CustomerDataProvider, Customer\Privacy\
* AddressDataProvider, Cart\Privacy\CartDataProvider, Order\Privacy\
* OrderDataProvider, Payment\Privacy\PaymentDataProvider) does nothing to
* this table — a full copy of the old PII survives here regardless.
*
* Redacts by SUBJECT only, never by `causer_id` — the causer is "who did
* this," not PII content, and erasing it would erode the audit trail's own
* purpose (see this provider's own eraseForUser(), which is a deliberate
* no-op). Genuinely Customer-scope only: every subject type here
* (Customer, Address, CartAddress, OrderAddress, Transaction) resolves to
* a business account via its own chain (Address/Customer directly;
* CartAddress via cart_id -&gt; Cart.customer_id; OrderAddress/Transaction
* via order_id -&gt; Order.customer_id) — none of it is a User's own data on
* its own.
*
* MUST run before Customer\Privacy\AddressDataProvider in
* config('core.privacy.providers') — that provider hard-deletes Address
* rows, and once gone there is no way to re-derive which activity_log
* rows (subject_type = Address) belonged to this customer. This provider
* resolves that address id list itself, before anything deletes it.
*/
class ActivityLogDataProvider implements PersonalDataProvider
{
private const REDACTED = '[redacted]';
public function name(): string
{
return 'activity_log';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$activities = Activity::query()
->where(fn ($query) => $this->scopeToCustomer($query, $subject->customerId))
->get();
return new ProviderExportResult('activity_log', $activities->map(fn (Activity $activity) => [
'id' => $activity->id,
'log_name' => $activity->log_name,
'description' => $activity->description,
'subject_type' => $activity->subject_type,
'subject_id' => $activity->subject_id,
'event' => $activity->event,
'properties' => $activity->properties?->toArray(),
'created_at' => $activity->created_at?->toIso8601String(),
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('activity_log', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$affected = Activity::query()
->where(fn ($query) => $this->scopeToCustomer($query, $subject->customerId))
->get();
if ($affected->isEmpty()) {
return new ProviderErasureResult('activity_log', ErasureOutcome::Skipped, 'No activity log entries for this customer.');
}
foreach ($affected as $activity) {
$activity->update(['properties' => $this->redact($activity->properties?->toArray() ?? [])]);
}
return new ProviderErasureResult(
'activity_log',
ErasureOutcome::Pseudonymized,
'PII-bearing properties redacted on matching audit log entries; who/what/when metadata (log_name, subject, event, timestamp, causer) retained for audit integrity.'
);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult(
'activity_log',
ErasureOutcome::Skipped,
'A User only ever appears here as causer_id (who performed an action), not as the PII content of a log entry — redacting that would erode the audit trail\'s own record of who acted.'
);
}
private function scopeToCustomer($query, int $customerId): void
{
$customerMorph = (new Customer)->getMorphClass();
$addressMorph = (new Address)->getMorphClass();
$cartAddressMorph = (new CartAddress)->getMorphClass();
$orderAddressMorph = (new OrderAddress)->getMorphClass();
$transactionMorph = (new Transaction)->getMorphClass();
$addressIds = Address::where('customer_id', $customerId)->pluck('id');
$cartIds = Cart::where('customer_id', $customerId)->pluck('id');
$cartAddressIds = CartAddress::whereIn('cart_id', $cartIds)->pluck('id');
$orderIds = Order::where('customer_id', $customerId)->pluck('id');
$orderAddressIds = OrderAddress::whereIn('order_id', $orderIds)->pluck('id');
$transactionIds = Transaction::whereIn('order_id', $orderIds)->pluck('id');
$query
->where(fn ($q) => $q->where('subject_type', $customerMorph)->where('subject_id', $customerId))
->orWhere(fn ($q) => $q->where('subject_type', $addressMorph)->whereIn('subject_id', $addressIds))
->orWhere(fn ($q) => $q->where('subject_type', $cartAddressMorph)->whereIn('subject_id', $cartAddressIds))
->orWhere(fn ($q) => $q->where('subject_type', $orderAddressMorph)->whereIn('subject_id', $orderAddressIds))
->orWhere(fn ($q) => $q->where('subject_type', $transactionMorph)->whereIn('subject_id', $transactionIds));
}
/**
* @param array<string, mixed> $properties
* @return array<string, mixed>
*/
private function redact(array $properties): array
{
return array_map(function ($value) {
if (is_array($value)) {
return array_map(fn () => self::REDACTED, $value);
}
return self::REDACTED;
}, $properties);
}
}
+12 -1
View File
@@ -7,6 +7,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Core\Catalog\Services\SkuBackfillService;
class RunMigrateImportJob implements ShouldQueue
{
@@ -20,9 +21,19 @@ class RunMigrateImportJob implements ShouldQueue
) {
}
public function handle(): void
public function handle(SkuBackfillService $skuBackfill): void
{
$importer = ImporterFactory::make($this->spec);
$importer->import($this->spec);
// Only Shopify's importer creates ProductVariant rows at all (see
// Shopify\ShopifyExportImporter) — JudgeMe never touches products,
// so running this for that source would just be a guaranteed
// no-op query every time. Source CSV rows genuinely can have no
// `Variant SKU` value; see SkuBackfillService's own docblock for
// why that's synthesized rather than treated as an importer bug.
if ($this->spec->source === 'shopify') {
$skuBackfill->backfill();
}
}
}
@@ -163,6 +163,12 @@ class ShopifyExportImporter implements Importer
$variant->sku = trim((string) ($row['Variant SKU'] ?? '')) ?: null;
$variant->stock = (int) ($row['Variant Inventory Qty'] ?? 0);
$variant->shippable = filter_var($row['Variant Requires Shipping'] ?? 'true', FILTER_VALIDATE_BOOLEAN);
// Lunar's own column default is 'always' (purchasable regardless of
// stock) — wrong for an imported catalogue, whose Variant Inventory
// Qty is real, meaningful stock data. 'in_stock' makes purchasability
// actually respect it (see Modules\Core\Catalog\Services\
// StockService's own docblock on the three purchasable values).
$variant->purchasable = 'in_stock';
$variant->save();
ImportMapping::record(self::SOURCE, 'variant', $externalId, $variant);
@@ -239,7 +245,23 @@ class ShopifyExportImporter implements Importer
$existing = ImportMapping::resolve(self::SOURCE, 'image', $externalId);
if ($existing instanceof Media) {
// ImportMapping is a durable record of "we already imported this,"
// but the Media row it points at can go stale — e.g. Command\
// WipeCatalogCommand deletes every Product (media included, via
// Spatie's own model-delete cleanup) without knowing this mapping
// exists, since the mapping ISN'T scoped to a single Product to
// clean up alongside it. Re-running an import afterward used to
// trust the cached Media object unconditionally — it still existed
// as a PHP object even though its underlying row (and file) were
// long gone, so every re-imported product silently got zero
// media, no error, no warning. Falls through to a fresh import
// whenever the mapping doesn't resolve to a real, still-attached
// Media row.
if ($existing instanceof Media
&& Media::whereKey($existing->getKey())->exists()
&& $existing->model_type === $product->getMorphClass()
&& (int) $existing->model_id === $product->id
) {
return $existing;
}
@@ -2,12 +2,19 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderDispatched;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/**
* Queued — see Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment's
* own docblock: ShipmentStatusUpdatedByCarrier comes from a scheduled
* polling job, not a webhook, so nothing needs this to complete before a
* request returns.
*
* The automatic half of "Dispatched" — the manual fallback is the staff
* "Update Status" action (Modules\Core\Shipping\Extensions\
* OrderViewExtension). Listens to ShipmentStatusUpdatedByCarrier directly,
@@ -19,14 +26,17 @@ use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
* carrier that skips straight there without a distinct collection
* checkpoint.
*
* Guarded to only fire from 'ready_for_dispatch' — a late/duplicate
* checkpoint, or an order the manual action already advanced, is a
* silent no-op.
* Guarded by OrderStatusFlow::isValidTransition() rather than a hardcoded
* "only fire from 'ready_for_dispatch'" comparison — the single source of
* truth for the status graph lives there, not duplicated here. A
* late/duplicate checkpoint, or an order the manual action already
* advanced, is a silent no-op either way.
*/
class AdvanceFulfillmentOnCarrierCheckpoint
class AdvanceFulfillmentOnCarrierCheckpoint implements ShouldQueue
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void
@@ -38,7 +48,7 @@ class AdvanceFulfillmentOnCarrierCheckpoint
$order = $event->shipmentInfo->shipment->order;
if (! $order || $order->status !== 'ready_for_dispatch') {
if (! $order || ! $this->flow->isValidTransition($order, 'dispatched')) {
return;
}
@@ -2,10 +2,18 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter;
/**
* Queued — OrderDelivered is only ever dispatched from Modules\Core\Order\
* Listeners\DeriveOrderDeliveredFromShipment, itself queued (see that
* class's own docblock: the triggering ShipmentStatusUpdatedByCarrier
* comes from a scheduled polling job, not a request with a page waiting
* on the result).
*
* Writes `status` to 'delivered' once a carrier confirms delivery, rather
* than jumping straight to 'completed'. Carrier orders get a return
* window between delivery and completion (see Modules\Core\Order\
@@ -20,21 +28,23 @@ use Modules\Core\Order\Services\OrderStatusWriter;
* OrderDelivered — deriving "was this delivered" and acting on it by
* writing `status` are deliberately two different listeners.
*
* Guarded to only fire from 'dispatched' — a duplicate/late Delivered
* Guarded by OrderStatusFlow::isValidTransition() rather than a hardcoded
* "only fire from 'dispatched'" comparison. A duplicate/late Delivered
* checkpoint, or an order a manual action already moved past, is a
* silent no-op.
* silent no-op either way.
*/
class AdvanceFulfillmentOnDelivered
class AdvanceFulfillmentOnDelivered implements ShouldQueue
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
public function handle(OrderDelivered $event): void
{
$order = $event->order;
if ($order->status !== 'dispatched') {
if (! $this->flow->isValidTransition($order, 'delivered')) {
return;
}
@@ -2,13 +2,8 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Order\Support\OrderStatus;
use Modules\Core\Order\Services\OrderPaymentResolutionService;
use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded;
@@ -17,38 +12,24 @@ use Modules\Core\Payment\Events\PaymentRefunded;
* Registered against PaymentCaptured, PaymentAuthorized, AND
* PaymentRefunded (see OrderServiceProvider).
*
* PaymentCaptured writes both Order::paid/paid_at (via
* OrderStatusWriter::markPaid()) AND advances `status` out of
* 'awaiting_payment' to the next step in the order's flow (see
* OrderStatusFlow::nextOptions()) — re-confirmed with the user: a
* captured payment, manual or via Stripe's webhook, should never leave an
* order sitting at 'awaiting_payment'. Only fires when status is still
* exactly 'awaiting_payment', so a duplicate/delayed capture event never
* regresses an order staff already advanced further. PaymentAuthorized
* only marks paid — an authorization is not yet captured funds, so
* status stays put until the actual capture.
*
* A refund still moves `status` (returned -> refunded/partially_refunded)
* — refunds are a normal step in Modules\Core\Order\Services\
* OrderStatusFlow's own sequence, unlike captures. Derives
* Refunded/PartialRefund from Modules\Core\Order\Support\OrderStatus::
* payment() — the existing, unchanged derived-enum logic, reused rather
* than reimplemented.
*
* Reads $event->context['order_id'] to find which Order this outcome
* belongs to — Payment has no concept of an Order.
*
* Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set.
* Never fires from the PaymentRefunded path — a refund can only ever
* happen after an order was already placed.
* A thin reactor — resolves which Order this outcome belongs to (Payment
* has no concept of an Order, so this reads $event->context['order_id'])
* and hands off to Modules\Core\Order\Services\
* OrderPaymentResolutionService for the actual decisions: whether to mark
* the order paid, whether/how far to advance `status`, and what a refund
* does to it. See that service's own docblock, and its methods' own
* docblocks, for the full business reasoning (re-confirmed with the
* user): a captured payment, manual or via Stripe's webhook, should
* never leave an order sitting at 'awaiting_payment'; an authorization
* only marks paid, since it isn't yet captured funds; a refund is a
* normal step in the order's own status sequence, unlike a capture.
*
* Deliberately does NOT react to PaymentVoided.
*/
class ApplyResolvedPaymentStatus
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
private readonly OrderPaymentResolutionService $resolution,
) {}
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
@@ -62,58 +43,11 @@ class ApplyResolvedPaymentStatus
$order = Order::findOrFail($orderId);
if ($event instanceof PaymentRefunded) {
$this->applyRefund($order, $event);
$this->resolution->resolveRefund($order, $event::class);
return;
}
$wasPlaced = ! blank($order->placed_at);
$this->writer->markPaid($order, $event::class);
if ($event instanceof PaymentCaptured) {
$this->advancePastAwaitingPayment($order, $event);
}
if (! $wasPlaced) {
$order->update(['placed_at' => $order->placed_at ?? now()]);
Event::dispatch(new OrderPlaced($order));
}
}
private function advancePastAwaitingPayment(Order $order, PaymentCaptured $event): void
{
if ($order->status !== 'awaiting_payment') {
return;
}
$next = $this->flow->nextOptions($order);
$target = array_key_first($next);
if ($target !== null) {
$this->writer->write($order, $target, $event::class);
}
}
/**
* Requires the refund Transaction row to already exist (Modules\Core\
* Order\Listeners\RecordPaymentTransaction must run first — see
* OrderServiceProvider's listener registration order for
* PaymentRefunded), so the relation is refreshed here rather than
* trusted from a possibly-stale $order instance.
*/
private function applyRefund(Order $order, PaymentRefunded $event): void
{
$order->load('transactions');
$target = match (OrderStatus::payment($order)) {
PaymentStatus::Refunded => 'refunded',
PaymentStatus::PartialRefund => 'partially_refunded',
default => null,
};
if ($target !== null && $order->status !== $target) {
$this->writer->write($order, $target, $event::class);
}
$this->resolution->resolveCaptureOrAuthorization($order, $event::class, isCapture: $event instanceof PaymentCaptured);
}
}
@@ -4,9 +4,19 @@ namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderCompleted;
use Modules\Core\Order\Events\OrderPickedUp;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter;
/**
* Deliberately NOT queued — OrderPickedUp is dispatched from a staff
* Filament action (see OrderFulfillmentService::markPickedUp()), and the
* page staff are looking at needs to show `status` as 'completed'
* immediately after they click, not still 'picked_up' until a queue
* worker catches up. Unlike ShipmentStatusUpdatedByCarrier's listeners
* (queued — dispatched from a scheduled polling job with no page waiting
* on the result), this one has a real same-request/same-page-load
* dependency.
*
* The store-pickup mirror of AdvanceFulfillmentOnDelivered — reacts to
* OrderPickedUp (dispatched by Modules\Core\Order\Services\
* OrderFulfillmentService::markPickedUp() the moment staff confirm the
@@ -15,20 +25,22 @@ use Modules\Core\Order\Services\OrderStatusWriter;
* business design — unlike the carrier branch, there is no 'delivered'
* intermediate value on this path.
*
* Guarded to only fire from 'picked_up' — a duplicate dispatch (e.g. a
* stale page re-submitting the action) is a silent no-op.
* Guarded by OrderStatusFlow::isValidTransition() rather than a hardcoded
* "only fire from 'picked_up'" comparison. A duplicate dispatch (e.g. a
* stale page re-submitting the action) is a silent no-op either way.
*/
class CompleteOrderOnPickedUp
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
public function handle(OrderPickedUp $event): void
{
$order = $event->order;
if ($order->status !== 'picked_up') {
if (! $this->flow->isValidTransition($order, 'completed')) {
return;
}
@@ -2,63 +2,38 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
use Modules\Core\Catalog\Services\StockService;
use Modules\Core\Checkout\Events\OrderPlaced;
/**
* The only place ProductVariant::stock is written as a result of an order —
* fires once per order regardless of capture_mode/driver, same reasoning as
* Modules\Core\Order\Notifications\OrderPlacedNotification: OrderPlaced is
* dispatched exactly once, from the one place an order's placed_at
* actually gets set (Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus),
* so this can't double-decrement across a capture/authorize/refund sequence
* the way listening to PaymentCaptured directly could.
* Deliberately NOT queued — unlike this codebase's other queued side
* effects (cache flushes, audit logs, search reindexes), a stalled queue
* here isn't just cosmetic staleness: it widens the window in which
* another order can be accepted against stock this order already
* committed (Lunar has no stock-reservation step at checkout time to
* begin with — see StockService's own "Never lets stock go negative"
* note — so some oversell race already exists, but a queue stall of
* minutes/hours extends that window far past the sub-millisecond one a
* synchronous write leaves open). StockService's atomic `GREATEST(stock -
* qty, 0)` SQL still protects against a LOST update between two orders
* decrementing the same variant concurrently; running it synchronously
* keeps the exposure window as small as possible on top of that.
*
* 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(...)->decrement()`), 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.
* The actual decrement logic lives in Modules\Core\Catalog\Services\
* StockService — stock (the column, its invariants) is a Catalog concern,
* not an Order one; this listener is just the "an order was placed"
* trigger. Fires once per order regardless of capture_mode/driver, same
* reasoning as Modules\Core\Order\Notifications\OrderPlacedNotification:
* OrderPlaced is dispatched exactly once, from the one place an order's
* placed_at actually gets set (Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus), so this can't double-decrement across a
* capture/authorize/refund sequence the way listening to PaymentCaptured
* directly could.
*/
class DecrementStockOnOrderPlaced
{
public function handle(OrderPlaced $event): void
{
$lines = $event->order->lines()
->where('type', 'physical')
->where('purchasable_type', ProductVariant::morphName())
->get(['purchasable_id', 'quantity']);
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();
app(StockService::class)->decrementForOrder($event->order);
}
}
@@ -2,17 +2,23 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/**
* Queued — ShipmentStatusUpdatedByCarrier is dispatched from
* Modules\Core\Shipping\Jobs\PollShipmentTrackingJob, a scheduled job with
* no HTTP request waiting on a response, so there is no same-request
* timing pressure for any of this event's listeners (unlike a webhook).
*
* Translates a carrier tracking checkpoint into OrderDelivered — the event
* OrderDeliveredNotification (via NotificationRegistry) actually listens
* to. Kept separate from the notification itself so the "is this checkpoint
* a delivery" filtering doesn't leak into notification code.
*/
class DeriveOrderDeliveredFromShipment
class DeriveOrderDeliveredFromShipment implements ShouldQueue
{
public function handle(ShipmentStatusUpdatedByCarrier $event): void
{
@@ -2,20 +2,28 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/**
* Queued — see Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment's
* own docblock: ShipmentStatusUpdatedByCarrier comes from a scheduled
* polling job, not a webhook.
*
* Wires TrackingStatus::Failed to the 'delivery_failed' status for the
* first time — previously an unused enum case. Guarded to only fire from
* 'dispatched': a stale/duplicate checkpoint, or an order a manual action
* already moved past, is a silent no-op.
* first time — previously an unused enum case. Guarded by
* OrderStatusFlow::isValidTransition() rather than a hardcoded "only fire
* from 'dispatched'" comparison. A stale/duplicate checkpoint, or an
* order a manual action already moved past, is a silent no-op either way.
*/
class MarkDeliveryFailedOnCarrierCheckpoint
class MarkDeliveryFailedOnCarrierCheckpoint implements ShouldQueue
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void
@@ -26,7 +34,7 @@ class MarkDeliveryFailedOnCarrierCheckpoint
$order = $event->shipmentInfo->shipment->order;
if (! $order || $order->status !== 'dispatched') {
if (! $order || ! $this->flow->isValidTransition($order, 'delivery_failed')) {
return;
}
@@ -0,0 +1,66 @@
<?php
namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Services\OrderPaymentResolutionService;
use Modules\Core\Payment\Events\PaymentDeferred;
/**
* Deliberately NOT queued — the storefront's own post-checkout
* confirmation page (Modules\Core\Checkout\Http\Controllers\
* CheckoutController::orderStatus()/confirmation(), per docs/checkout.md)
* looks up the order by placed_at being set immediately after
* initiatePayment() returns; a stalled queue would show the shopper a
* blank/failed confirmation for an order that, in the database, already
* exists and was genuinely placed. Same reasoning as
* DecrementStockOnOrderPlaced staying synchronous — this is the listener
* that makes DecrementStockOnOrderPlaced fire at all for a COD order (see
* OrderServiceProvider: OrderPlaced => DecrementStockOnOrderPlaced),
* so queueing this one would just move the same stock-oversell risk one
* hop earlier.
*
* A thin reactor, same shape as ApplyResolvedPaymentStatus — the actual
* decisions ("this order counts as placed the moment a deferred-payment
* driver resolves, independent of Order::paid" and "such an order also
* has nothing to sit at awaiting_payment for") live in PaymentDeferred's
* and OrderPaymentResolutionService::resolveDeferredPayment()'s own
* docblocks, re-confirmed with the user; this only extracts the order id
* and applies both, guarded against a duplicate/replayed event the same
* way OrderPaymentResolutionService::resolveCaptureOrAuthorization() is.
*
* Without the status advance below, a COD order was left sitting at
* 'awaiting_payment' forever — placed_at/OrderPlaced alone fixed order
* visibility and stock decrement, but nothing ever moved `status` off its
* initial value, since resolveCaptureOrAuthorization() only does that for
* an actual capture. Caught and fixed after the fact.
*/
class MarkOrderPlacedOnDeferredPayment
{
public function __construct(
private readonly OrderPaymentResolutionService $resolution,
) {}
public function handle(PaymentDeferred $event): void
{
$orderId = $event->context['order_id'] ?? null;
if ($orderId === null) {
return;
}
$order = Order::findOrFail($orderId);
$this->resolution->resolveDeferredPayment($order, self::class);
if (! blank($order->placed_at)) {
return;
}
$order->update(['placed_at' => now()]);
Event::dispatch(new OrderPlaced($order));
}
}
@@ -10,6 +10,17 @@ use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Events\PaymentVoided;
/**
* Deliberately NOT queued, despite looking like a pure audit-trail write
* with no same-request reader — Modules\Core\Providers\
* OrderServiceProvider registers this to run BEFORE
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus for
* PaymentRefunded specifically, because that listener's refund-status
* resolution reads the Transaction row this listener just wrote. Queueing
* this would run it asynchronously while ApplyResolvedPaymentStatus (sync)
* proceeds immediately, almost certainly executing before the queued job
* and silently breaking that read. See OrderServiceProvider's own
* registration-order comment.
*
* Writes the Transaction row for a successful payment outcome — the
* "record what happened" half of reacting to Payment's events, separate
* from Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus's "update
@@ -2,11 +2,16 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderPaidChanged;
use Modules\Core\Order\Events\OrderStatusChanged;
use Modules\Core\Order\Services\OrderStatusTransitionRecorder;
/**
* Queued — a pure history-log write with no same-request reader anywhere
* in the codebase (no Filament page renders order_status_transitions
* immediately after a status change; it's browsed later, if at all).
*
* The one place order_status_transitions rows actually get written —
* listens to OrderStatusChanged (every write of the single `status`
* column, via Modules\Core\Order\Services\OrderStatusWriter::write()) and
@@ -15,7 +20,7 @@ use Modules\Core\Order\Services\OrderStatusTransitionRecorder;
* one consistent audit trail entry ('paid', with a null from_status)
* rather than a second, separate table.
*/
class RecordStatusTransition
class RecordStatusTransition implements ShouldQueue
{
public function __construct(
private readonly OrderStatusTransitionRecorder $recorder,
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace Modules\Core\Order\Privacy;
use Lunar\Models\Order;
use Lunar\Models\OrderAddress;
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;
/**
* Orders and order addresses (lunar_orders, lunar_order_addresses) belong to the
* Customer (business account) via customer_id, not to an individual User, so this
* is Customer-scope only. They're also subject to legal retention (tax/accounting
* law generally requires invoices be kept for several years — GDPR Art. 17(3)(b)
* explicitly allows this to override an erasure request). eraseForCustomer()
* therefore pseudonymizes the PII-bearing free-text fields in place rather than
* deleting the order: totals, line items, tax data, and the order itself all
* remain intact and auditable.
*
* Also covers PII-adjacent keys living in Order.meta and OrderAddress.meta —
* Modules\Core\Checkout\Services\CheckoutService::initiatePayment() writes
* terms_accepted/terms_accepted_at/terms_accepted_policy_version/payment_method
* onto Order.meta, and Modules\Core\Shipping\Carriers\BoxNow\
* BoxNowFulfillmentService writes the shopper's chosen box_now_locker onto
* OrderAddress.meta — neither of which the free-text column erase above ever
* touched. Kept Customer-scope, consistent with Order/OrderAddress themselves.
*/
class OrderDataProvider implements PersonalDataProvider
{
private const ORDER_META_KEYS = [
'terms_accepted',
'terms_accepted_at',
'terms_accepted_policy_version',
'payment_method',
];
private const ADDRESS_META_KEYS = [
'box_now_locker',
];
public function name(): string
{
return 'orders';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$orders = Order::where('customer_id', $subject->customerId)->with('addresses')->get();
return new ProviderExportResult('orders', $orders->map(fn (Order $order) => [
'id' => $order->id,
'reference' => $order->reference,
'status' => $order->status,
'total' => $order->total?->decimal(),
'placed_at' => $order->placed_at?->toIso8601String(),
'meta' => $this->onlyKeys((array) $order->meta, self::ORDER_META_KEYS),
'addresses' => $order->addresses->map(fn (OrderAddress $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,
'meta' => $this->onlyKeys((array) $address->meta, self::ADDRESS_META_KEYS),
])->all(),
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('orders', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$orders = Order::where('customer_id', $subject->customerId)->with('addresses')->get();
if ($orders->isEmpty()) {
return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'No orders for this customer.');
}
foreach ($orders as $order) {
$order->update([
'customer_reference' => null,
'notes' => null,
'meta' => $this->withoutKeys((array) $order->meta, self::ORDER_META_KEYS),
]);
foreach ($order->addresses as $address) {
$address->update([
'title' => null,
'first_name' => 'Erased',
'last_name' => 'Customer',
'company_name' => null,
'tax_identifier' => null,
'line_one' => null,
'line_two' => null,
'line_three' => null,
'delivery_instructions' => null,
'contact_email' => null,
'contact_phone' => null,
'meta' => $this->withoutKeys((array) $address->meta, self::ADDRESS_META_KEYS),
]);
}
}
return new ProviderErasureResult(
'orders',
ErasureOutcome::Pseudonymized,
'Order and address free-text fields and PII-bearing meta keys cleared; order records, totals, and line items retained for legal/tax record-keeping.'
);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'Orders belong to Customer accounts, not individual users.');
}
/**
* @param array<string, mixed> $meta
* @param array<int, string> $keys
* @return array<string, mixed>
*/
private function onlyKeys(array $meta, array $keys): array
{
return array_intersect_key($meta, array_flip($keys));
}
/**
* @param array<string, mixed> $meta
* @param array<int, string> $keys
* @return array<string, mixed>
*/
private function withoutKeys(array $meta, array $keys): array
{
foreach ($keys as $key) {
unset($meta[$key]);
}
return $meta;
}
}
@@ -8,6 +8,8 @@ use Modules\Core\Order\DTOs\OrderFulfillmentResult;
use Modules\Core\Order\Events\OrderPickedUp;
use Modules\Core\Order\Events\OrderReadyForDispatch;
use Modules\Core\Order\Events\OrderReadyForPickup;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Throwable;
@@ -31,6 +33,7 @@ class OrderFulfillmentService
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
private readonly TransactionRecorder $transactions,
) {}
public function markReady(Order $order): OrderFulfillmentResult
@@ -121,6 +124,39 @@ class OrderFulfillmentService
return OrderFulfillmentResult::failure('This order cannot be marked paid right now.');
}
// canMarkPaid() only ever returns true for an order whose payment
// method resolves to the cash-on-delivery DRIVER (see
// OrderStatusFlow::isCod(), which checks PaymentMethod::driver,
// never the merchant-chosen `type` slug directly — a store could
// name that method "cod", "pay-on-delivery", anything). Such an
// order never runs through Payment's pay()/authorize() flow at
// checkout, so nothing else records a Transaction for it. Money
// changes hands right here, at this click, so this is the one
// place that write can happen; there is no earlier Payment event
// to hang it off of the way Modules\Core\Order\Listeners\
// RecordPaymentTransaction does for a gateway driver. See
// TransactionRecorder's own docblock — it already anticipated
// exactly this "manually-triggered ... from Filament" call site.
//
// $driver below is the payment method's own `type` slug (whatever
// the merchant named it, e.g. 'cash-on-delivery' or 'cod') —
// Transaction.driver's established meaning everywhere else in this
// codebase (see RecordPaymentTransaction/TransactionRecorder's own
// docblocks) is that type key, never the underlying driver CLASS.
// No fallback guess here: CheckoutService::initiatePayment() always
// writes Order.meta['payment_method'] before charging, and
// canMarkPaid() already guarantees this order got that far.
$this->transactions->record(
$order,
type: 'capture',
driver: (string) $order->meta['payment_method'],
result: new PaymentResult(
status: PaymentResultStatus::Succeeded,
reference: 'cod-manual-'.$order->id,
amount: $order->total,
),
);
$this->writer->markPaid($order, self::class.'::markPaid');
return OrderFulfillmentResult::success('Order marked as paid.');
@@ -0,0 +1,108 @@
<?php
namespace Modules\Core\Order\Services;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* The actual business decisions behind reacting to a payment outcome —
* previously these lived entirely inside Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus, a listener with no Service behind it, even
* though "should this order be marked paid," "should its status advance,
* and to what," and "what does a refund do to status" are all genuine
* decisions about Order state, not side effects of Payment's own events.
* That listener is now a thin reactor: extract the order id from
* $event->context, load the Order, call this service, done.
*
* See ApplyResolvedPaymentStatus's own docblock for the full business
* reasoning (re-confirmed with the user) behind each rule enforced here —
* this class only re-documents what's specific to the decision logic
* itself, not the "why" already recorded there.
*/
class OrderPaymentResolutionService
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
/**
* A captured or authorized payment: marks the order paid (capture
* only — an authorization is not yet captured funds), advances status
* out of 'awaiting_payment' (capture only), and marks the order
* placed if this is the first payment outcome it's seen.
*/
public function resolveCaptureOrAuthorization(Order $order, string $causeClass, bool $isCapture): void
{
$wasPlaced = ! blank($order->placed_at);
$this->writer->markPaid($order, $causeClass);
if ($isCapture) {
$this->advancePastAwaitingPayment($order, $causeClass);
}
if (! $wasPlaced) {
$order->update(['placed_at' => $order->placed_at ?? now()]);
Event::dispatch(new OrderPlaced($order));
}
}
/**
* A deferred-capture payment (currently only cash-on-delivery — see
* Payment\Events\PaymentDeferred's own docblock): no money has moved,
* so unlike resolveCaptureOrAuthorization() this never calls
* $writer->markPaid() — Order::paid stays false until staff explicitly
* mark it received. But per OrderStatusFlow's own docblock, payment
* method never affects the status SEQUENCE at all — a COD order has
* nothing to "await" at checkout (no payment attempt happens), so
* 'awaiting_payment' is simply the wrong first status for it. Reuses
* the exact same advancePastAwaitingPayment() a capture uses, since
* the status-sequence logic itself doesn't differ by payment method,
* only whether `paid` also flips alongside it.
*/
public function resolveDeferredPayment(Order $order, string $causeClass): void
{
$this->advancePastAwaitingPayment($order, $causeClass);
}
/**
* Requires the refund Transaction row to already exist (Modules\Core\
* Order\Listeners\RecordPaymentTransaction must run first — see
* OrderServiceProvider's listener registration order for
* PaymentRefunded), so the relation is refreshed here rather than
* trusted from a possibly-stale $order instance.
*/
public function resolveRefund(Order $order, string $causeClass): void
{
$order->load('transactions');
$target = match (OrderStatus::payment($order)) {
PaymentStatus::Refunded => 'refunded',
PaymentStatus::PartialRefund => 'partially_refunded',
default => null,
};
if ($target !== null && $order->status !== $target) {
$this->writer->write($order, $target, $causeClass);
}
}
private function advancePastAwaitingPayment(Order $order, string $causeClass): void
{
if ($order->status !== 'awaiting_payment') {
return;
}
$next = $this->flow->nextOptions($order);
$target = array_key_first($next);
if ($target !== null) {
$this->writer->write($order, $target, $causeClass);
}
}
}
+16
View File
@@ -133,6 +133,22 @@ class OrderStatusFlow
return ! $order->paid && $this->isCod($order);
}
/**
* Whether moving $order to $to is a valid transition from its CURRENT
* status — the single source of truth for "is this a legal next step,"
* so a caller reacting to an external event (a carrier tracking
* checkpoint, a staff action) doesn't need to hardcode its own "only
* fire from status X" guard duplicating what nextOptions() already
* knows. See e.g. Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnCarrierCheckpoint, which used to compare
* $order->status to a literal 'ready_for_dispatch' inline instead of
* asking this class.
*/
public function isValidTransition(Order $order, string $to): bool
{
return array_key_exists($to, $this->nextOptions($order));
}
private function label(string $status): string
{
return (string) str($status)->replace('_', ' ')->title();
@@ -0,0 +1,38 @@
<?php
namespace Modules\Core\Payment\Contracts;
/**
* Optional contract a payment driver implements to declare it only makes
* sense for one fulfillment type — the payment-side mirror of
* Modules\Core\Shipping\Contracts\DeclaresFulfillmentType. Two concrete
* cases exist today, both hardcoded facts about the driver rather than a
* merchant configuration choice:
* - OfflinePaymentDriver ("pay in store," cash-in-hand) only makes
* sense when the shopper collects in person — meaningless for a
* carrier delivery, where no staff member is present to take the
* cash.
* - CashOnDeliveryPaymentDriver only makes sense when a carrier
* physically hands over the parcel and collects payment at that
* moment — meaningless for store pickup, which already has
* OfflinePaymentDriver for exactly that in-person moment.
*
* A driver that doesn't implement this (Stripe, bank transfer) has no
* fulfillment-type constraint — offered regardless of the cart's
* currently selected shipping method's fulfillment type.
*
* Read by Modules\Core\Checkout\Services\CheckoutService::
* getPaymentMethods(), which excludes a method whose driver implements
* this and disagrees with the cart's current fulfillment type (via
* Modules\Core\Shipping\Support\FulfillmentType::resolve() on the
* currently selected ShippingMethod). A cart with no shipping option
* selected yet imposes no constraint — every method is offered until a
* fulfillment type is actually known.
*/
interface RequiresFulfillmentType
{
/**
* @return 'carrier'|'store_pickup'
*/
public function requiredFulfillmentType(): string;
}
@@ -5,9 +5,11 @@ namespace Modules\Core\Payment\Drivers;
use Illuminate\Support\Str;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentDeferred;
/**
* Cash-on-delivery/cash-on-pickup — the shopper pays staff in person, at
@@ -31,20 +33,46 @@ use Modules\Core\Payment\Enums\PaymentResultStatus;
* marking it received (Modules\Core\Order\Services\
* OrderFulfillmentService::markPaid()), offered by the single "Update
* Status" action at any time, independent of status.
*
* Despite returning Pending, this order IS fully placed the moment pay()
* returns — unlike a Stripe 3-D Secure Pending, nothing will ever resolve
* this into a later PaymentCaptured/PaymentAuthorized (COD has no gateway
* callback at all). Without PaymentDeferred, no listener ever set
* Order::placed_at for a COD order: invisible in customer order history,
* no stock decrement (Modules\Core\Order\Listeners\
* DecrementStockOnOrderPlaced only reacts to Checkout\Events\OrderPlaced),
* and the storefront's own post-checkout confirmation could never find it
* — a real bug, not a hypothetical, caught and fixed after the fact. See
* PaymentDeferred's own docblock for the full reasoning.
*/
class CashOnDeliveryPaymentDriver implements Configurable, SupportsPay
class CashOnDeliveryPaymentDriver implements Configurable, SupportsPay, RequiresFulfillmentType
{
public function isConfigured(): bool
{
return true;
}
/**
* "On delivery" is the operative word — a carrier physically hands
* over the parcel and collects payment at that moment. Meaningless
* for store pickup, which already has OfflinePaymentDriver for the
* equivalent in-person moment.
*/
public function requiredFulfillmentType(): string
{
return 'carrier';
}
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{
return new PaymentResult(
$result = new PaymentResult(
status: PaymentResultStatus::Pending,
reference: 'cod-'.Str::uuid(),
amount: $amount,
);
PaymentDeferred::dispatch($type, $result, $context);
return $result;
}
}
+12 -1
View File
@@ -5,6 +5,7 @@ namespace Modules\Core\Payment\Drivers;
use Illuminate\Support\Str;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
@@ -25,7 +26,7 @@ use Modules\Core\Payment\Events\PaymentCaptured;
* none) purely so PaymentCaptured, and anything downstream keying on it,
* have something to identify this attempt by.
*/
class OfflinePaymentDriver implements Configurable, SupportsPay
class OfflinePaymentDriver implements Configurable, SupportsPay, RequiresFulfillmentType
{
/**
* Always true — no external dependency to be missing.
@@ -35,6 +36,16 @@ class OfflinePaymentDriver implements Configurable, SupportsPay
return true;
}
/**
* Cash-in-hand requires a staff member physically present to take the
* payment — meaningless for a carrier delivery, where no such person
* exists at handoff.
*/
public function requiredFulfillmentType(): string
{
return 'store_pickup';
}
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{
$reference = 'offline-'.Str::uuid();
@@ -115,6 +115,25 @@ class StripePaymentDriver implements
$params['payment_method'] = $data['payment_method'];
}
// Reconciliation safety net: this app never creates a Stripe Customer
// object and attaches no other identifying info to the PaymentIntent
// (see docs/payments.md "Reconciliation" for the full reasoning), so
// without this, a charge that succeeds on Stripe's side but is never
// written to our own DB (e.g. a DB outage at exactly the wrong
// moment) would be untraceable back to a cart/order — nothing to
// search Stripe's dashboard by except amount/time/card last-4.
// array_filter() drops order_id when it's not yet known (still null
// in $context at initial pay()/authorize() time — see
// rememberIntent()'s own null-coalesce for the same case).
$metadata = array_filter([
'cart_id' => $context['cart_id'] ?? null,
'order_id' => $context['order_id'] ?? null,
]);
if ($metadata !== []) {
$params['metadata'] = $metadata;
}
try {
$paymentIntent = $this->stripe->getClient()->paymentIntents->create($params);
} catch (ApiErrorException $e) {
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\Payment\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* "This order has no money to collect yet, and never will via a gateway
* callback — nothing further will ever resolve this PaymentResult's
* Pending status into Captured/Authorized." Distinct from a Stripe-style
* Pending (3-D Secure, still resolving asynchronously via a later webhook
* or client-side confirmation) — that case correctly dispatches nothing
* yet, since PaymentCaptured/PaymentAuthorized WILL still follow once
* resolved.
*
* Dispatched by Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver::
* pay() the moment it returns Pending — a COD order is fully placed at
* that instant, with reconciliation (Order::paid) happening independently,
* anywhere from same-day to months later, entirely outside any gateway's
* knowledge. Any other current or future "deferred capture, no gateway
* callback" driver dispatches this the same way, rather than each
* reinventing its own "mark placed" event.
*
* Handled by Modules\Core\Order\Listeners\MarkOrderPlacedOnDeferredPayment
* — sets ONLY Order::placed_at and fires Checkout\Events\OrderPlaced.
* Deliberately does not touch Order::paid/paid_at (see
* OrderStatusWriter::markPaid(), the only path that ever does) or create a
* Transaction row (RecordPaymentTransaction listens to PaymentCaptured/
* PaymentAuthorized/PaymentVoided/PaymentRefunded only — correctly not
* this event, since no money has moved and there is nothing to record
* yet).
*/
class PaymentDeferred
{
use Dispatchable;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public readonly string $type,
public readonly PaymentResult $result,
public readonly array $context = [],
) {}
}
@@ -3,10 +3,13 @@
namespace Modules\Core\Payment\Filament\Resources;
use Filament\Actions\Action;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Utilities\Get;
use InvalidArgumentException;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
@@ -14,6 +17,7 @@ use Filament\Tables\Table;
use Illuminate\Support\Facades\Event;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\SupportsAuthorization;
use Modules\Core\Payment\Events\PaymentMethodsReordered;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
use Modules\Core\Payment\Models\PaymentMethod;
@@ -60,9 +64,9 @@ class PaymentMethodResource extends Resource
{
protected static ?string $model = PaymentMethod::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-credit-card';
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
protected static string | \UnitEnum | null $navigationGroup = 'Settings';
protected static ?string $modelLabel = 'Payment Method';
@@ -144,7 +148,31 @@ class PaymentMethodResource extends Resource
])
->default('pay')
->live()
->required(),
// Only meaningful for a driver that actually implements
// SupportsAuthorization — CheckoutService::initiatePayment()
// calls $driver->authorize() when capture_mode is
// "authorize", which fatals on a driver missing that method
// entirely (e.g. CashOnDeliveryPaymentDriver, which only
// ever implements SupportsPay: the shopper pays staff in
// person, at an unknown future moment — there is no
// "hold now, settle later" operation to offer for that at
// all). Hidden rather than merely disabled, since a
// hidden field is also excluded from validation/dehydration
// — required() below would otherwise still block saving.
->visible(fn (Get $get) => static::driverSupportsAuthorization($get('driver')))
->required(fn (Get $get) => static::driverSupportsAuthorization($get('driver'))),
// Every OTHER fillForm() value not backed by a real component
// here is silently dropped — an Action::schema() modal only
// dehydrates fields present in its own schema, unlike a
// resource's form(); ListPaymentMethods::getHeaderActions()'s
// CreateAction::fillForm() used to set 'position' this same
// way and it never reached PaymentMethodService::create(),
// so every new method saved with the column's raw DB default
// (0) regardless of what fillForm() computed. Hidden here
// purely so it actually dehydrates; the table's own
// reorderable('position') drag-and-drop remains the real
// staff-facing way to change it afterward.
Hidden::make('position'),
];
}
@@ -153,9 +181,23 @@ class PaymentMethodResource extends Resource
return Select::make('driver')
->label('Driver')
->options(fn () => app(PaymentDriverRegistry::class)->labels())
->live()
->required();
}
private static function driverSupportsAuthorization(?string $driver): bool
{
if (! $driver) {
return true;
}
try {
return app(PaymentDriverRegistry::class)->resolve($driver) instanceof SupportsAuthorization;
} catch (InvalidArgumentException) {
return true;
}
}
public static function getPages(): array
{
return [
@@ -180,7 +222,7 @@ class PaymentMethodResource extends Resource
->icon('heroicon-o-pencil-square')
->schema(static::getFormComponents())
->fillForm(fn (PaymentMethod $record) => $record->only([
'name', 'type', 'driver', 'capture_mode',
'name', 'type', 'driver', 'capture_mode', 'position',
]))
->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data));
}
@@ -2,8 +2,10 @@
namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Lunar\Models\Language;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentMethodService;
@@ -15,12 +17,21 @@ class ListPaymentMethods extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
CreateAction::make()
->schema(PaymentMethodResource::getFormComponents())
->fillForm(fn () => [
'position' => (PaymentMethod::max('position') ?? 0) + 1,
'enabled' => false,
'data' => [],
// TranslatedText's own default() (getLanguageDefaults())
// never reaches this mounted action's initial state —
// unlike a resource's own form(), an Action::schema()
// modal starts from exactly what fillForm() returns, so
// `name` was landing as null rather than the expected
// per-locale array, and every locale's sub-input
// silently failed to bind to it (required() on the
// default locale then correctly rejected the null).
'name' => Language::pluck('code')->mapWithKeys(fn (string $code) => [$code => ''])->all(),
])
// Every PaymentMethod write goes through PaymentMethodService
// — see PaymentMethodResource's own docblock — so this
@@ -2,6 +2,7 @@
namespace Modules\Core\Payment\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Logging\ActivityLogService;
use Modules\Core\Payment\Events\PaymentMethodCreated;
use Modules\Core\Payment\Events\PaymentMethodDeleted;
@@ -9,6 +10,8 @@ use Modules\Core\Payment\Events\PaymentMethodUpdated;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* Queued — a pure audit-log write with no same-request reader.
*
* Same pattern as Localization\Listeners\LogTranslationActivity — routes
* PaymentMethodService's own events through the existing
* Logging\ActivityLogService instead of PaymentMethod separately opting
@@ -29,7 +32,7 @@ use Modules\Core\Payment\Models\PaymentMethod;
* forcing into a one-subject shape or adding a new method to the shared
* service for.
*/
class LogPaymentMethodActivity
class LogPaymentMethodActivity implements ShouldQueue
{
public function __construct(
private readonly ActivityLogService $activityLog,
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace Modules\Core\Payment\Privacy;
use Lunar\Models\Order;
use Lunar\Models\Transaction;
use Modules\Core\Payment\Models\StripePaymentIntent;
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;
/**
* Payment records (lunar_transactions, stripe_payment_intents) belong to the
* Customer (business account) via the Order they're attached to, not to an
* individual User, so this is Customer-scope only — same chain
* OrderDataProvider already uses (Order.customer_id).
*
* Like Order itself, payment/transaction records are subject to the same
* tax/accounting legal retention argument (GDPR Art. 17(3)(b)) — a payment
* record is part of the same financial audit trail as the order it settled,
* so this pseudonymizes the card-identifying fields in place rather than
* deleting the transaction: amount, status, and the transaction/order link
* all remain intact and auditable.
*
* No Stripe Customer object exists anywhere in this app (see docs/
* payments.md "Reconciliation") — there is nothing to request deletion of
* on Stripe's side. The only local, erasable PII is the card brand/last-4
* on Transaction and the cart_id/order_id/context correlation row on
* stripe_payment_intents, which is deleted outright once its Order is
* settled (its only purpose was resolving an async webhook callback — see
* docs/payments.md "Async resolution" — which has already happened by the
* time an erasure request would run).
*/
class PaymentDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'payments';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$orderIds = Order::where('customer_id', $subject->customerId)->pluck('id');
$transactions = Transaction::whereIn('order_id', $orderIds)->get();
$intents = StripePaymentIntent::whereIn('order_id', $orderIds)->get();
return new ProviderExportResult('payments', [
'transactions' => $transactions->map(fn (Transaction $transaction) => [
'id' => $transaction->id,
'order_id' => $transaction->order_id,
'type' => $transaction->type,
'status' => $transaction->status,
'amount' => $transaction->amount,
'card_type' => $transaction->card_type,
'last_four' => $transaction->last_four,
'reference' => $transaction->reference,
])->all(),
'stripe_payment_intents' => $intents->map(fn (StripePaymentIntent $intent) => [
'id' => $intent->id,
'order_id' => $intent->order_id,
'intent_id' => $intent->intent_id,
'status' => $intent->status,
])->all(),
]);
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('payments', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$orderIds = Order::where('customer_id', $subject->customerId)->pluck('id');
if ($orderIds->isEmpty()) {
return new ProviderErasureResult('payments', ErasureOutcome::Skipped, 'No orders, and therefore no payment records, for this customer.');
}
Transaction::whereIn('order_id', $orderIds)->update([
'card_type' => null,
'last_four' => null,
]);
// stripe_payment_intents only ever existed to correlate a webhook
// callback back to a cart/order (see docs/payments.md "Async
// resolution") — that correlation has already served its purpose by
// the time an erasure request runs, so these rows are deleted
// outright rather than pseudonymized, unlike Transaction, which is
// the actual audit-trail record.
StripePaymentIntent::whereIn('order_id', $orderIds)->delete();
return new ProviderErasureResult(
'payments',
ErasureOutcome::Pseudonymized,
'Card brand/last-four cleared from transaction records; amounts, statuses, and references retained for legal/tax record-keeping. Stripe correlation rows (no longer needed post-settlement) deleted.'
);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('payments', ErasureOutcome::Skipped, 'Payments belong to Customer-owned orders, not individual users.');
}
}
@@ -2,10 +2,10 @@
namespace Modules\Core\Privacy\Contracts;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\DTOs\UserSubject;
/**
* Implemented by any module that holds personal data and wants it included in
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\DTOs;
use Lunar\Models\Customer;
@@ -1,6 +1,8 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\DTOs;
use Modules\Core\Privacy\Enums\ErasureOutcome;
/**
* Every registered provider's outcome, assembled into one right-of-erasure response
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\DTOs;
/**
* Every registered provider's export, assembled into one right-of-access response.
@@ -25,7 +25,13 @@ class ExportReport
$data = [];
foreach ($this->results as $result) {
$data[$result->provider] = $result->data;
// A provider that threw (ProviderExportResult::$error set — see
// Modules\Core\Privacy\Jobs\ExportDataSubjectJob::safeExport())
// surfaces as an explicit error marker rather than an empty
// array indistinguishable from "genuinely nothing to export."
$data[$result->provider] = $result->error !== null
? ['error' => $result->error]
: $result->data;
}
return $data;
@@ -1,6 +1,8 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\DTOs;
use Modules\Core\Privacy\Enums\ErasureOutcome;
/**
* One provider's outcome on an erasure request. `reason` is required whenever
@@ -1,11 +1,17 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\DTOs;
/**
* One provider's contribution to a right-of-access export. `provider` is a short,
* stable machine name (e.g. 'customer', 'orders', 'reviews') used as the top-level
* key when PrivacyService assembles every provider's data into one export payload.
*
* `error` is set only when the provider threw an exception instead of returning
* normally — see Modules\Core\Privacy\Jobs\ExportDataSubjectJob, which catches
* per-provider so one provider throwing doesn't discard every other provider's
* already-gathered data for the same request. `data` is empty whenever `error` is
* set, never a partial/best-effort payload.
*/
class ProviderExportResult
{
@@ -15,5 +21,6 @@ class ProviderExportResult
public function __construct(
public readonly string $provider,
public readonly array $data,
public readonly ?string $error = null,
) {}
}
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\DTOs;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Base\LunarUser;
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Privacy\Enums;
/**
* What actually happened to a provider's data on an erasure request. Erased/
* Pseudonymized/Retained/Skipped are never failures — Retained is a valid, often
* legally-required outcome (e.g. an Order kept intact for tax retention), distinct
* from a provider erroring out. Failed is the one genuine failure case: a provider
* threw an exception instead of returning normally — see Modules\Core\Privacy\
* Services\PrivacyService::completeErasure(), which catches per-provider so one
* provider throwing doesn't discard every other provider's already-computed
* result for the same request.
*/
enum ErasureOutcome: string
{
case Erased = 'erased';
case Pseudonymized = 'pseudonymized';
case Retained = 'retained';
case Skipped = 'skipped';
case Failed = 'failed';
}
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\Enums;
enum ErasureRequestStatus: string
{
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\Enums;
enum ExportRequestStatus: string
{
-16
View File
@@ -1,16 +0,0 @@
<?php
namespace Modules\Core\Privacy;
/**
* What actually happened to a provider's data on an erasure request. None of these
* are failures — Retained is a valid, often legally-required outcome (e.g. an Order
* kept intact for tax retention), distinct from a provider erroring out.
*/
enum ErasureOutcome: string
{
case Erased = 'erased';
case Pseudonymized = 'pseudonymized';
case Retained = 'retained';
case Skipped = 'skipped';
}
+1 -1
View File
@@ -2,7 +2,7 @@
namespace Modules\Core\Privacy\Events;
use Modules\Core\Privacy\ExportReport;
use Modules\Core\Privacy\DTOs\ExportReport;
use Modules\Core\Privacy\Models\DataExportRequest;
/**
@@ -8,7 +8,7 @@ use Filament\Notifications\Notification;
use Lunar\Admin\Support\Extending\BaseExtension;
use Lunar\Models\Customer;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* Adds "Request erasure" / "Request export" header actions to the Customer
@@ -33,7 +33,7 @@ class CustomerErasureActionsExtension extends BaseExtension
->color('danger')
->requiresConfirmation()
->modalDescription('Opens a cancellable grace-period erasure request for this Customer account. No linked User\'s login is affected.')
->form([
->schema([
Checkbox::make('immediate')
->label('Erase immediately (skip the 30-day grace period)')
->helperText('Staff-only, for a formal legal request or regulator inquiry that genuinely requires urgency — not a routine deletion. Runs synchronously, cannot be cancelled once submitted.')
@@ -2,21 +2,25 @@
namespace Modules\Core\Privacy\Filament\Resources;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\RepeatableEntry\TableColumn;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource\Pages\ListDataErasureRequests;
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource\Pages\ViewDataErasureRequest;
use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Lunar\Models\Customer;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource\Pages;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* Read-mostly audit view over data_erasure_requests — staff can see every request
@@ -30,53 +34,114 @@ class DataErasureRequestResource extends Resource
{
protected static ?string $model = DataErasureRequest::class;
protected static ?string $navigationIcon = 'heroicon-o-shield-exclamation';
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-shield-exclamation';
protected static ?string $navigationGroup = 'Privacy';
protected static string | \UnitEnum | null $navigationGroup = 'Privacy';
protected static ?string $modelLabel = 'Erasure Request';
protected static ?string $pluralModelLabel = 'Erasure Requests';
public static function form(Form $form): Form
/**
* A real infolist, not form()'s disabled inputs/Placeholders — ViewRecord
* falls back to rendering form() in read-only mode when a resource has no
* infolist() at all (Filament\Resources\Pages\ViewRecord::hasInfolist()),
* which is what this resource did before: every field rendered as a
* plain, unstyled label/value pair with no grouping, badges, or icons.
*/
public static function infolist(Schema $schema): Schema
{
return $form->schema([
Placeholder::make('subject')
return $schema->components([
Section::make('Request')
->icon('heroicon-o-shield-exclamation')
->columns(4)
->components([
TextEntry::make('subject')
->label('Subject')
->content(fn (DataErasureRequest $record) => sprintf(
'%s (%s)',
DataErasureRequest::displayNameFor($record->subject),
$record->isForCustomer() ? 'Customer account' : 'Individual user'
)),
Placeholder::make('requested_by')
->label('Requested by')
->content(fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->requestedBy)),
TextInput::make('email')
->state(fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->subject))
->weight('bold')
->size('lg'),
TextEntry::make('subject_type')
->label('Scope')
->formatStateUsing(fn (DataErasureRequest $record) => $record->isForCustomer() ? 'Customer account' : 'Individual user')
->badge()
->icon(fn (DataErasureRequest $record) => $record->isForCustomer() ? 'heroicon-o-building-office' : 'heroicon-o-user')
->color(fn (DataErasureRequest $record) => $record->isForCustomer() ? 'info' : 'warning'),
TextEntry::make('email')
->label('Email (snapshot at request time)')
->disabled(),
Placeholder::make('status')
->content(fn (DataErasureRequest $record) => $record->status->value),
Placeholder::make('scheduled_for')
->icon('heroicon-o-envelope')
->copyable(),
TextEntry::make('requested_by')
->label('Requested by')
->state(fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->requestedBy))
->icon('heroicon-o-user-circle'),
TextEntry::make('status')
->badge()
->formatStateUsing(fn (ErasureRequestStatus $state) => ucfirst($state->value))
->color(fn (ErasureRequestStatus $state) => match ($state) {
ErasureRequestStatus::Pending => 'warning',
ErasureRequestStatus::Cancelled => 'gray',
ErasureRequestStatus::Completed => 'success',
}),
TextEntry::make('created_at')
->label('Requested at')
->dateTime()
->icon('heroicon-o-calendar'),
TextEntry::make('scheduled_for')
->label('Scheduled for')
->content(fn (DataErasureRequest $record) => $record->scheduled_for->toDayDateTimeString()),
Placeholder::make('cancelled_at')
->label('Cancelled at')
->content(fn (DataErasureRequest $record) => $record->cancelled_at?->toDayDateTimeString() ?? '—'),
Placeholder::make('completed_at')
->dateTime()
->icon('heroicon-o-calendar-days'),
TextEntry::make('completed_at')
->label('Completed at')
->content(fn (DataErasureRequest $record) => $record->completed_at?->toDayDateTimeString() ?? '—'),
Placeholder::make('caused_by')
->label('Caused by (cascade)')
->content(fn (DataErasureRequest $record) => $record->causedBy
? "Request #{$record->causedBy->id} (".DataErasureRequest::displayNameFor($record->causedBy->subject).')'
: 'Not a cascade — directly requested')
->visible(fn (DataErasureRequest $record) => $record->caused_by_request_id !== null),
KeyValue::make('report')
->label('Per-provider outcome')
->disabled()
->dateTime()
->placeholder('—')
->icon('heroicon-o-check-circle')
->color(fn (DataErasureRequest $record) => $record->completed_at ? 'success' : 'gray'),
TextEntry::make('cancelled_at')
->label('Cancelled at')
->dateTime()
->placeholder('—')
->icon('heroicon-o-x-circle')
->color(fn (DataErasureRequest $record) => $record->cancelled_at ? 'danger' : 'gray')
->visible(fn (DataErasureRequest $record) => $record->cancelled_at !== null),
TextEntry::make('caused_by')
->label('Cascade')
->icon('heroicon-o-arrow-turn-down-right')
->state(fn (DataErasureRequest $record) => $record->causedBy
? "From request #{$record->causedBy->id} (".DataErasureRequest::displayNameFor($record->causedBy->subject).')'
: 'Directly requested')
->color(fn (DataErasureRequest $record) => $record->caused_by_request_id !== null ? 'info' : 'gray'),
]),
Section::make('Outcome')
->description('What happened to each data category once the erasure ran. "Retained"/"Pseudonymized" usually means the data is kept in an anonymized form for legal or accounting reasons.')
->icon('heroicon-o-document-check')
->visible(fn (DataErasureRequest $record) => $record->report !== null)
->helperText('Each provider\'s outcome once the erasure completed — see docs/privacy.md.'),
])->columns(2);
->components([
RepeatableEntry::make('report')
->hiddenLabel()
->table([
TableColumn::make('Data category'),
TableColumn::make('Outcome'),
TableColumn::make('Reason'),
])
->components([
TextEntry::make('provider'),
TextEntry::make('outcome')
->badge()
->formatStateUsing(fn (string $state) => ucfirst($state))
->color(fn (string $state) => match ($state) {
ErasureOutcome::Erased->value => 'success',
ErasureOutcome::Pseudonymized->value, ErasureOutcome::Retained->value => 'info',
ErasureOutcome::Skipped->value => 'gray',
ErasureOutcome::Failed->value => 'danger',
default => 'gray',
}),
TextEntry::make('reason')
->placeholder('—'),
]),
]),
]);
}
public static function table(Table $table): Table
@@ -139,7 +204,7 @@ class DataErasureRequestResource extends Resource
];
}),
])
->actions([
->recordActions([
ViewAction::make(),
Action::make('cancel')
->label('Cancel')
@@ -154,8 +219,8 @@ class DataErasureRequestResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListDataErasureRequests::route('/'),
'view' => Pages\ViewDataErasureRequest::route('/{record}'),
'index' => ListDataErasureRequests::route('/'),
'view' => ViewDataErasureRequest::route('/{record}'),
];
}
@@ -2,16 +2,18 @@
namespace Modules\Core\Privacy\Filament\Resources;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource\Pages\ListDataExportRequests;
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource\Pages\ViewDataExportRequest;
use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Modules\Core\Privacy\ExportRequestStatus;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource\Pages;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest;
@@ -25,36 +27,85 @@ class DataExportRequestResource extends Resource
{
protected static ?string $model = DataExportRequest::class;
protected static ?string $navigationIcon = 'heroicon-o-arrow-down-tray';
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-arrow-down-tray';
protected static ?string $navigationGroup = 'Privacy';
protected static string | \UnitEnum | null $navigationGroup = 'Privacy';
protected static ?string $modelLabel = 'Export Request';
protected static ?string $pluralModelLabel = 'Export Requests';
public static function form(Form $form): Form
/**
* A real infolist, not form()'s disabled inputs/Placeholders — see
* DataErasureRequestResource::infolist()'s own docblock for why.
*/
public static function infolist(Schema $schema): Schema
{
return $form->schema([
Placeholder::make('subject')
return $schema->components([
Section::make('Request')
->icon('heroicon-o-arrow-down-tray')
->columns(4)
->components([
TextEntry::make('subject')
->label('Subject')
->content(fn (DataExportRequest $record) => sprintf(
'%s (%s)',
DataErasureRequest::displayNameFor($record->subject),
$record->isForCustomer() ? 'Customer account' : 'Individual user'
)),
TextInput::make('email')
->state(fn (DataExportRequest $record) => DataErasureRequest::displayNameFor($record->subject))
->weight('bold')
->size('lg'),
TextEntry::make('subject_type')
->label('Scope')
->formatStateUsing(fn (DataExportRequest $record) => $record->isForCustomer() ? 'Customer account' : 'Individual user')
->badge()
->icon(fn (DataExportRequest $record) => $record->isForCustomer() ? 'heroicon-o-building-office' : 'heroicon-o-user')
->color(fn (DataExportRequest $record) => $record->isForCustomer() ? 'info' : 'warning'),
TextEntry::make('email')
->label('Email (snapshot at request time)')
->disabled(),
Placeholder::make('status')
->content(fn (DataExportRequest $record) => $record->status->value),
Placeholder::make('completed_at')
->icon('heroicon-o-envelope')
->copyable(),
TextEntry::make('status')
->badge()
->formatStateUsing(fn (ExportRequestStatus $state) => ucfirst($state->value))
->color(fn (ExportRequestStatus $state) => match ($state) {
ExportRequestStatus::Pending => 'warning',
ExportRequestStatus::Failed => 'danger',
ExportRequestStatus::Completed => 'success',
}),
TextEntry::make('created_at')
->label('Requested at')
->dateTime()
->icon('heroicon-o-calendar'),
TextEntry::make('completed_at')
->label('Completed at')
->content(fn (DataExportRequest $record) => $record->completed_at?->toDayDateTimeString() ?? '—'),
Placeholder::make('file_path')
->label('Export file')
->content(fn (DataExportRequest $record) => $record->file_path ?? 'Not generated yet'),
])->columns(2);
->dateTime()
->placeholder('Not generated yet')
->icon('heroicon-o-check-circle')
->color(fn (DataExportRequest $record) => $record->completed_at ? 'success' : 'gray'),
TextEntry::make('file_path')
->label('File')
// Just the filename, not the full server path — a raw
// filesystem path (/var/www/.../export_2_....zip) isn't
// actionable for staff and previously rendered as if it
// were a clickable link. The actual download is the
// "Download" header action below (self::downloadAction()),
// shared with the table's row action.
->state(fn (DataExportRequest $record) => $record->file_path ? basename($record->file_path) : 'Not generated yet')
->icon('heroicon-o-document')
->color(fn (DataExportRequest $record) => $record->file_path ? 'success' : 'gray'),
]),
]);
}
/**
* Shared by the table's row action and the view page's header action
* (ViewDataExportRequest::getHeaderActions()) so "is this downloadable"
* and the download itself are defined in exactly one place.
*/
public static function downloadAction(): Action
{
return Action::make('download')
->label('Download')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (DataExportRequest $record) => $record->status === ExportRequestStatus::Completed && $record->file_path && file_exists($record->file_path))
->action(fn (DataExportRequest $record) => response()->download($record->file_path));
}
public static function table(Table $table): Table
@@ -100,21 +151,17 @@ class DataExportRequestResource extends Resource
ExportRequestStatus::Failed->value => 'Failed',
]),
])
->actions([
->recordActions([
ViewAction::make(),
Action::make('download')
->label('Download')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (DataExportRequest $record) => $record->status === ExportRequestStatus::Completed && $record->file_path && file_exists($record->file_path))
->action(fn (DataExportRequest $record) => response()->download($record->file_path)),
self::downloadAction(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListDataExportRequests::route('/'),
'view' => Pages\ViewDataExportRequest::route('/{record}'),
'index' => ListDataExportRequests::route('/'),
'view' => ViewDataExportRequest::route('/{record}'),
];
}
@@ -8,4 +8,11 @@ use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource;
class ViewDataExportRequest extends ViewRecord
{
protected static string $resource = DataExportRequestResource::class;
protected function getHeaderActions(): array
{
return [
DataExportRequestResource::downloadAction(),
];
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* Runs PrivacyService::completeErasure() for one due DataErasureRequest, dispatched
+44 -9
View File
@@ -2,19 +2,23 @@
namespace Modules\Core\Privacy\Jobs;
use Throwable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Event;
use Modules\Core\Privacy\CustomerSubject;
use Illuminate\Support\Facades\Log;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Events\PersonalDataGathered;
use Modules\Core\Privacy\ExportReport;
use Modules\Core\Privacy\ExportRequestStatus;
use Modules\Core\Privacy\DTOs\ExportReport;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
use Modules\Core\Privacy\Models\DataExportRequest;
use Modules\Core\Privacy\PrivacyManager;
use Modules\Core\Privacy\UserSubject;
use Modules\Core\Privacy\Services\PrivacyManager;
use Modules\Core\Privacy\DTOs\UserSubject;
/**
* Gathers every registered PersonalDataProvider's export data for one request, all
@@ -26,7 +30,7 @@ use Modules\Core\Privacy\UserSubject;
* generated PDF), that's the point to reconsider — not before.
*
* Calls each provider's *ForCustomer() or *ForUser() method depending on the
* request's polymorphic subject — see Modules\Core\Privacy\PrivacyService and
* request's polymorphic subject — see Modules\Core\Privacy\Services\PrivacyService and
* docs/privacy.md "User-scope vs Customer-scope".
*
* Writing the gathered data to a file is intentionally NOT done here — see
@@ -48,10 +52,16 @@ class ExportDataSubjectJob implements ShouldQueue
{
if ($this->request->isForCustomer()) {
$subject = new CustomerSubject(customerId: $this->request->subject_id);
$results = array_map(fn ($provider) => $provider->exportForCustomer($subject), $manager->providers());
$results = array_map(
fn (PersonalDataProvider $provider) => $this->safeExport($provider, 'exportForCustomer', $subject),
$manager->providers()
);
} else {
$subject = new UserSubject(userId: $this->request->subject_id, email: $this->request->email);
$results = array_map(fn ($provider) => $provider->exportForUser($subject), $manager->providers());
$results = array_map(
fn (PersonalDataProvider $provider) => $this->safeExport($provider, 'exportForUser', $subject),
$manager->providers()
);
}
Event::dispatch(new PersonalDataGathered(
@@ -60,8 +70,33 @@ class ExportDataSubjectJob implements ShouldQueue
));
}
public function failed(\Throwable $exception): void
public function failed(Throwable $exception): void
{
$this->request->update(['status' => ExportRequestStatus::Failed]);
}
/**
* Catches per-provider so one provider throwing doesn't discard every
* other provider's already-gathered export data for this same request —
* without this, the whole array_map aborts, handle() never reaches
* Event::dispatch(), and failed() marks the ENTIRE request Failed even
* though most providers may have already gathered their data
* successfully. Logged via Log::error() so a thrown provider is still
* visible to staff, not just an empty/missing section in the export.
*
* @param 'exportForCustomer'|'exportForUser' $method
*/
private function safeExport(PersonalDataProvider $provider, string $method, CustomerSubject|UserSubject $subject): ProviderExportResult
{
try {
return $provider->{$method}($subject);
} catch (Throwable $e) {
Log::error("Privacy provider {$provider->name()}::{$method}() threw during export", [
'provider' => $provider->name(),
'exception' => $e,
]);
return new ProviderExportResult($provider->name(), [], $e->getMessage());
}
}
}
@@ -4,9 +4,9 @@ namespace Modules\Core\Privacy\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* Logging back in during a pending erasure request's grace period IS the "I
@@ -7,7 +7,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Lunar\Base\LunarUser;
use Lunar\Models\Customer;
use Modules\Core\Privacy\Events\UserErasureRequested;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* When a User's erasure leaves a Customer account with no remaining User at all,
@@ -8,7 +8,7 @@ use Modules\Core\Export\CsvColumn;
use Modules\Core\Export\CsvWriter;
use Modules\Core\Privacy\Events\PersonalDataExportFileWritten;
use Modules\Core\Privacy\Events\PersonalDataGathered;
use Modules\Core\Privacy\ExportRequestStatus;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
use ZipArchive;
/**
@@ -19,9 +19,11 @@ use ZipArchive;
* listener) without touching how the data is gathered.
*
* Column schema: every provider's data is either a list of associative arrays
* (rows directly) or a single associative array (one row) — see the providers in
* Modules\Core\Privacy\Providers, all of which return exactly one of those two
* shapes. Any nested array value within a row (e.g. an order's `addresses`) is
* (rows directly) or a single associative array (one row) — see the providers
* registered in config('core.privacy.providers'), each living in its own owning
* module's Privacy/ subdirectory (e.g. Modules\Core\Order\Privacy\
* OrderDataProvider), all of which return exactly one of those two shapes. Any
* nested array value within a row (e.g. an order's `addresses`) is
* JSON-encoded into that one cell rather than exploded into further columns —
* CsvWriter's generic stringify() behavior, not special-cased here.
*/
+2 -2
View File
@@ -7,12 +7,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Lunar\Models\Customer;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
/**
* A pending, cancelled, or completed right-of-erasure request — the grace-period
* record between "subject/staff asked for this" and "providers actually erased
* their data" (see Modules\Core\Privacy\PrivacyService, which creates/processes
* their data" (see Modules\Core\Privacy\Services\PrivacyService, which creates/processes
* these).
*
* `subject` is polymorphic — either a Lunar Customer (business account) or a User
+1 -1
View File
@@ -5,7 +5,7 @@ namespace Modules\Core\Privacy\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Lunar\Models\Customer;
use Modules\Core\Privacy\ExportRequestStatus;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
/**
* A right-of-access export request. Created synchronously (fast — one insert), then
@@ -1,62 +0,0 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Lunar\Models\Cart;
use Lunar\Models\CartAddress;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\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.
*/
class CartDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'carts';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$addresses = CartAddress::whereIn('cart_id', Cart::where('customer_id', $subject->customerId)->pluck('id'))->get();
return new ProviderExportResult('carts', $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());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('carts', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
CartAddress::whereIn('cart_id', Cart::where('customer_id', $subject->customerId)->pluck('id'))->delete();
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.');
}
}
@@ -1,97 +0,0 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Lunar\Models\Order;
use Lunar\Models\OrderAddress;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
/**
* Orders and order addresses (lunar_orders, lunar_order_addresses) belong to the
* Customer (business account) via customer_id, not to an individual User, so this
* is Customer-scope only. They're also subject to legal retention (tax/accounting
* law generally requires invoices be kept for several years — GDPR Art. 17(3)(b)
* explicitly allows this to override an erasure request). eraseForCustomer()
* therefore pseudonymizes the PII-bearing free-text fields in place rather than
* deleting the order: totals, line items, tax data, and the order itself all
* remain intact and auditable.
*/
class OrderDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'orders';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$orders = Order::where('customer_id', $subject->customerId)->with('addresses')->get();
return new ProviderExportResult('orders', $orders->map(fn (Order $order) => [
'id' => $order->id,
'reference' => $order->reference,
'status' => $order->status,
'total' => $order->total?->decimal(),
'placed_at' => $order->placed_at?->toIso8601String(),
'addresses' => $order->addresses->map(fn (OrderAddress $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(),
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('orders', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$orderIds = Order::where('customer_id', $subject->customerId)->pluck('id');
if ($orderIds->isEmpty()) {
return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'No orders for this customer.');
}
Order::whereIn('id', $orderIds)->update([
'customer_reference' => null,
'notes' => null,
]);
OrderAddress::whereIn('order_id', $orderIds)->update([
'title' => null,
'first_name' => 'Erased',
'last_name' => 'Customer',
'company_name' => null,
'tax_identifier' => null,
'line_one' => null,
'line_two' => null,
'line_three' => null,
'delivery_instructions' => null,
'contact_email' => null,
'contact_phone' => null,
]);
return new ProviderErasureResult(
'orders',
ErasureOutcome::Pseudonymized,
'Order and address free-text fields cleared; order records, totals, and line items retained for legal/tax record-keeping.'
);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'Orders belong to Customer accounts, not individual users.');
}
}
@@ -2,16 +2,16 @@
namespace Modules\Core\Privacy\RelationManagers;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* Lists erasure requests where the record being viewed (Customer or User) is the
@@ -65,7 +65,7 @@ class ErasureRequestsRelationManager extends RelationManager
]),
])
->headerActions([])
->actions([
->recordActions([
ViewAction::make()
->url(fn (DataErasureRequest $record) => DataErasureRequestResource::getUrl('view', ['record' => $record])),
Action::make('cancel')
@@ -2,13 +2,13 @@
namespace Modules\Core\Privacy\RelationManagers;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Modules\Core\Privacy\ExportRequestStatus;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource;
use Modules\Core\Privacy\Models\DataExportRequest;
@@ -56,7 +56,7 @@ class ExportRequestsRelationManager extends RelationManager
]),
])
->headerActions([])
->actions([
->recordActions([
ViewAction::make()
->url(fn (DataExportRequest $record) => DataExportRequestResource::getUrl('view', ['record' => $record])),
Action::make('download')
@@ -2,16 +2,16 @@
namespace Modules\Core\Privacy\RelationManagers;
use Filament\Actions\Action;
use Filament\Forms\Components\Checkbox;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Customer\RelationManagers\UserRelationManager as CoreUserRelationManager;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* Extends core's own Customer -> User relation manager to add:
@@ -31,7 +31,7 @@ class UserRelationManager extends CoreUserRelationManager
{
$table = parent::getDefaultTable($table);
return $table->actions([
return $table->recordActions([
...$table->getActions(),
Action::make('privacyRequests')
->label('Privacy Requests')
@@ -39,14 +39,14 @@ class UserRelationManager extends CoreUserRelationManager
->modalHeading(fn (Model $record) => "Privacy requests for {$record->name}")
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->infolist(fn (Model $record) => $this->requestsInfolist($record)),
->schema(fn (Model $record) => $this->requestsInfolist($record)),
Action::make('requestErasure')
->label('Request Erasure')
->icon('heroicon-o-shield-exclamation')
->color('danger')
->requiresConfirmation()
->modalDescription('Opens a cancellable grace-period erasure request for this individual — deactivates their login and detaches them from every linked Customer account once it completes. No Customer account\'s own data is affected.')
->form([
->schema([
Checkbox::make('immediate')
->label('Erase immediately (skip the 30-day grace period)')
->helperText('Staff-only, for a formal legal request or regulator inquiry that genuinely requires urgency — not a routine deletion. Runs synchronously, cannot be cancelled once submitted.')
@@ -1,7 +1,8 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\Services;
use LogicException;
use Illuminate\Contracts\Container\Container;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
@@ -41,7 +42,7 @@ class PrivacyManager
$duplicates = array_diff_assoc($names, array_unique($names));
if ($duplicates !== []) {
throw new \LogicException(
throw new LogicException(
'Duplicate Modules\Core\Privacy provider name(s): '.implode(', ', array_unique($duplicates))
.'. Each provider registered in config(\'core.privacy.providers\') must return a unique name().'
);
@@ -1,17 +1,27 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Lunar\Base\LunarUser;
use Lunar\Models\Customer;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\DTOs\ErasureReport;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\UserSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
use Modules\Core\Privacy\Events\UserErasureRequested;
use Modules\Core\Privacy\Jobs\ExportDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest;
use Throwable;
/**
* Entry point for right-of-access and right-of-erasure requests, split into two
@@ -238,15 +248,30 @@ class PrivacyService
* called directly for a request that hasn't passed its grace period, since
* that defeats the point of the window; ProcessErasureRequestsCommand
* enforces isDue() before calling this.
*
* Each provider call is caught individually — a provider throwing (a bug,
* an unexpected DB state) converts to ErasureOutcome::Failed rather than
* aborting the whole array_map, so one broken provider never discards
* every OTHER provider's already-completed erasure for this same request.
* Without this, the $request->update() below would never run at all on a
* throw, silently leaving providers that already succeeded unrecorded and
* the request stuck Pending forever. Logged via Log::error() so a thrown
* provider is still visible to staff, not just swallowed into "Failed."
*/
public function completeErasure(DataErasureRequest $request): ErasureReport
{
if ($request->isForCustomer()) {
$subject = new CustomerSubject(customerId: $request->subject_id);
$results = array_map(fn ($provider) => $provider->eraseForCustomer($subject), $this->manager->providers());
$results = array_map(
fn (PersonalDataProvider $provider) => $this->safeErase($provider, 'eraseForCustomer', $subject),
$this->manager->providers()
);
} else {
$subject = new UserSubject(userId: $request->subject_id, email: $request->email);
$results = array_map(fn ($provider) => $provider->eraseForUser($subject), $this->manager->providers());
$results = array_map(
fn (PersonalDataProvider $provider) => $this->safeErase($provider, 'eraseForUser', $subject),
$this->manager->providers()
);
}
$report = new ErasureReport($subject, $results);
@@ -276,4 +301,21 @@ class PrivacyService
'deactivated_at' => $deactivated ? now() : null,
]);
}
/**
* @param 'eraseForCustomer'|'eraseForUser' $method
*/
private function safeErase(PersonalDataProvider $provider, string $method, CustomerSubject|UserSubject $subject): ProviderErasureResult
{
try {
return $provider->{$method}($subject);
} catch (Throwable $e) {
Log::error("Privacy provider {$provider->name()}::{$method}() threw during erasure", [
'provider' => $provider->name(),
'exception' => $e,
]);
return new ProviderErasureResult($provider->name(), ErasureOutcome::Failed, $e->getMessage());
}
}
}
+55 -4
View File
@@ -5,12 +5,15 @@ namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lunar\Models\Product;
use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Product as ProductContract;
use Lunar\Models\Product as LunarProduct;
use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue;
use Modules\Core\Catalog\Events\ProductDeleted;
use Modules\Core\Catalog\Events\ProductSaved;
use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct;
use Modules\Core\Catalog\Models\Product;
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
use Modules\Core\Catalog\OptionTypes\ColorOptionType;
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
@@ -40,13 +43,55 @@ class CatalogServiceProvider extends ServiceProvider
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
Product::saved(fn (Product $product) => Event::dispatch(new ProductSaved($product)));
Product::deleted(fn (Product $product) => Event::dispatch(new ProductDeleted($product->id)));
// Registered on BOTH classes — Eloquent model events are keyed by
// the literal class ::saved()/::deleted() was called on
// (registerModelEvent() uses static::class at registration time),
// not by inheritance, so a listener registered only on one class
// never fires for an instance of the other. Code resolving Product
// through the contract (Filament's own ProductResource, anything
// using app(Contracts\Product::class)) gets the subclass once
// ModelManifest::replace() below takes effect; code that still
// hardcodes `Lunar\Models\Product` directly (e.g. MigrateImport\
// Shopify\ShopifyExportImporter — importing has no reason to need
// the subclass's own custom_fields cast) keeps creating base-class
// instances. Both must dispatch ProductSaved/ProductDeleted, since
// ReindexProductsRecommendingProduct listens to those regardless
// of which path created/updated the product.
$dispatchSaved = fn (LunarProduct $product) => Event::dispatch(new ProductSaved($product));
$dispatchDeleted = fn (LunarProduct $product) => Event::dispatch(new ProductDeleted($product->id));
LunarProduct::saved($dispatchSaved);
LunarProduct::deleted($dispatchDeleted);
Product::saved($dispatchSaved);
Product::deleted($dispatchDeleted);
Event::listen(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']);
Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']);
$this->app->booted(function () {
// Deferred to booted() to run after every provider (Lunar's
// own included) has finished its own boot() — matches
// 3dealer's own AppServiceProvider, which registers Customer
// the same way for the same reason.
//
// The first argument MUST be the CONTRACT
// (Lunar\Models\Contracts\Product), not the concrete
// Lunar\Models\Product — HasModelExtending::modelClass()
// (which every Lunar model's __callStatic()/newModelQuery()
// consults to decide "is there a registered replacement for
// me") looks itself up by
// ModelManifest::guessContractClass(static::class), which
// resolves to the CONTRACT interface, then does
// ModelManifest::get($thatContract) — so the manifest must be
// keyed by the contract, or the lookup simply misses and
// silently falls back to the base class. Caught in practice —
// passing the concrete LunarProduct::class here (mirroring
// Modules\Core\Customer\Providers\CustomerServiceProvider's
// own replace() call, which has this exact same bug) left
// Product::modelClass() resolving to Lunar\Models\Product no
// matter what, until this was corrected.
ModelManifest::replace(ProductContract::class, Product::class);
// A full nightly reindex, on top of the per-event reindexing
// above — catches everything event-driven reindexing
// deliberately doesn't cover: a newly-created product not yet
@@ -58,8 +103,14 @@ class CatalogServiceProvider extends ServiceProvider
// documents, so a deploy that changed ProductIndexer's field
// list self-heals here even if `lunar:meilisearch:setup`
// wasn't run manually after that deploy.
// References the subclass, not 'Lunar\Models\Product' — Scout's
// own reindex loop (Searchable::makeAllSearchable()) queries
// via whatever class name is passed here, so this determines
// which class's casts (custom_fields included) are actually
// applied to $model in ProductIndexer::toSearchableArray()
// during this nightly full reindex.
$this->app->make(Schedule::class)
->command('lunar:search:index', ['Lunar\\Models\\Product', '--refresh'])
->command('lunar:search:index', [Product::class, '--refresh'])
->dailyAt('03:00');
});
}
+2 -1
View File
@@ -13,6 +13,7 @@ use Modules\Core\Command\InstallLunarCommand;
use Modules\Core\Command\MigrateImportCommand;
use Modules\Core\Command\ProcessErasureRequestsCommand;
use Modules\Core\Command\TuneProductSearchCommand;
use Modules\Core\Command\WipeCatalogCommand;
class CoreServiceProvider extends ServiceProvider
{
@@ -39,7 +40,7 @@ class CoreServiceProvider extends ServiceProvider
], 'core-assets');
if ($this->app->runningInConsole()) {
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class, BackfillMissingSkusCommand::class, ProcessErasureRequestsCommand::class]);
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class, BackfillMissingSkusCommand::class, ProcessErasureRequestsCommand::class, WipeCatalogCommand::class]);
//Overriding lunar:install
$this->app->booted(fn() => $this->commands([InstallLunarCommand::class]));
+14 -1
View File
@@ -19,7 +19,20 @@ class CustomerServiceProvider extends ServiceProvider
{
public function boot(): void
{
ModelManifest::replace(LunarCustomer::class, Customer::class);
// Deferred to booted() — LunarServiceProvider (lunarphp/core)
// calls Facades\ModelManifest::register() from its OWN boot(),
// which re-discovers every Lunar\Models\* class and repopulates
// the whole manifest from scratch, silently undoing a replace()
// call made from a boot() that ran earlier in the provider list.
// booted() fires only once every provider's boot() has completed,
// guaranteeing this is the one that actually sticks — same fix,
// same reasoning, as Providers\CatalogServiceProvider's own
// Product replace() call. This one likely only "worked" before
// because 3dealer's own AppServiceProvider independently
// re-registers Customer correctly in its own booted() — a
// consuming app without that redundant registration would have
// silently gotten the base Lunar\Models\Customer back.
$this->app->booted(fn () => ModelManifest::replace(LunarCustomer::class, Customer::class));
Event::listen(UserCreated::class, CreateCustomerForUser::class);
+4
View File
@@ -21,6 +21,7 @@ use Modules\Core\Order\Listeners\CompleteOrderOnPickedUp;
use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced;
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
use Modules\Core\Order\Listeners\MarkDeliveryFailedOnCarrierCheckpoint;
use Modules\Core\Order\Listeners\MarkOrderPlacedOnDeferredPayment;
use Modules\Core\Order\Listeners\RecordPaymentTransaction;
use Modules\Core\Order\Listeners\RecordStatusTransition;
use Modules\Core\Order\Models\OrderStatusTransition;
@@ -37,6 +38,7 @@ use Modules\Core\Order\Observers\TransactionObserver;
use Modules\Core\Order\Support\OrderStatus;
use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentDeferred;
use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Events\PaymentVoided;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
@@ -74,6 +76,8 @@ class OrderServiceProvider extends ServiceProvider
Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class);
Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentDeferred::class, MarkOrderPlacedOnDeferredPayment::class);
Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class);
Event::listen(OrderStatusChanged::class, [RecordStatusTransition::class, 'handleStatusChanged']);
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Review\Events;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Review\Models\ProductReview;
/**
* Dispatched whenever staff post or edit a reply to a review (see
* Modules\Core\Review\Services\ReviewService::reply()) — previously this
* happened with no event at all, so nothing could react to it (no audit
* trail, no "notify the reviewer their review got a reply" hook).
*
* $wasReply distinguishes a brand-new reply from an edit to an existing
* one — a listener building an audit trail or notification may care which
* happened (e.g. only notify the reviewer the first time, not on every
* subsequent edit).
*/
class ReviewReplied
{
public function __construct(
public readonly ProductReview $review,
public readonly Staff $repliedBy,
public readonly bool $wasReply,
) {}
}
@@ -2,9 +2,31 @@
namespace Modules\Core\Review\Filament\Extensions;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Review\Filament\Pages\ManageProductReviews;
/**
* CorePlugin allows exactly one extension class per Lunar resource — this
* one already owned ProductResource (adding the Reviews sub-page) before
* Catalog needed its own product-form addition, so extendForm() lives
* here too rather than competing for the same resource slot.
*
* extendForm() adds a "Custom Fields" repeater authoring Product::
* $custom_fields (see the migration adding that column, and Modules\Core\
* Catalog\Models\Product's own docblock on why this needed a first-party
* Product subclass rather than being addable to the base Lunar model) —
* per-product, customer-authored input (a reference photo upload, an
* optional engraving textarea) rendered on the storefront product page,
* NOT a Lunar ProductOption: an option's values are a fixed, admin-
* authored list that define variants, which doesn't fit "the customer
* uploads their own unique photo."
*/
class ProductResourceExtension extends ResourceExtension
{
public function extendPages(array $pages): array
@@ -18,4 +40,56 @@ class ProductResourceExtension extends ResourceExtension
{
return [...$pages, ManageProductReviews::class];
}
public function extendForm(Schema $schema): Schema
{
return $schema->components([
...$schema->getComponents(),
$this->customFieldsSection(),
]);
}
private function customFieldsSection(): Section
{
return 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.')
->collapsible()
->collapsed(fn ($record) => blank($record?->custom_fields))
->schema([
Repeater::make('custom_fields')
->hiddenLabel()
->schema([
TextInput::make('label')
->label('Label')
->helperText('Shown to the shopper above the field.')
->required(),
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 => $state['label'] ?? null),
]);
}
}

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