Compare commits

...
39 Commits
Author SHA1 Message Date
arvanitakis f1a0322d3f Feat: Upload Controller and Prune Commands Extraction from 3dealer 2026-09-25 13:48:42 +03:00
arvanitakis 6025ea4304 Feat: Updating FIle Services, Updating Order Views to list product extra options 2026-09-25 10:08:57 +03:00
arvanitakis 2b8fe5764c Feat: Creating Migration Models And Adapters for file Service 2026-09-25 09:10:56 +03:00
arvanitakis 69fdd0b4b8 Bump version to 0.20.2 2026-09-25 08:55:10 +03:00
arvanitakis 5347e01f0e Chore: Updating ProductDocumentLocalizer to translate Custom Fields 2026-09-25 08:39:15 +03:00
arvanitakis 78b46e5594 Chore: Adding Locales to Product Custom Fields 2026-09-24 23:42:43 +03:00
arvanitakis 621381beaa Bump Version to 0.20.1 2026-09-24 22:53:03 +03:00
arvanitakis 8f4156cfe8 Feat: Restructuring MigrateImport, Dispatching a per product job for import 2026-09-24 22:50:41 +03:00
arvanitakis a9b993182b Fix: Product Localizer now checks if a value is filled, or, not to show the fallback 2026-09-24 22:00:28 +03:00
arvanitakis 5a7fcd9f51 Fix: Removing Cart Lines along Products, so that the frontend loads 2026-09-24 21:57:31 +03:00
arvanitakis b4e9b8a4a9 Fix: Updating MIgrateImportCommand to accept language for import 2026-09-24 21:41:11 +03:00
arvanitakis c7035d6782 Bump version to 0.20.0 2026-09-23 09:47:28 +03:00
arvanitakis 4c0974bf84 Feat: Updating Product Indexer, and Product Sort 2026-09-23 09:41:21 +03:00
arvanitakis 4e15d8ef8c Fix: Updating Wipe Catalog Command to force delete products instead of the soft delete 2026-09-22 21:17:35 +03:00
arvanitakis 1c7efc6e4d Feature: Adding Custom Fields to Products 2026-09-22 21:17:01 +03:00
arvanitakis 59c57b37fc Feat: Updating ShopifyExportImporter and WipeCatalogCommand to handle images 2026-09-22 15:29:03 +03:00
arvanitakis 37b49963f6 Feat: Adding Backfill Skus to the Migrate Import Job 2026-09-22 14:55:18 +03:00
arvanitakis 050204f063 Feature: Adding Wipe Catalog Command for all products 2026-09-22 14:36:57 +03:00
arvanitakis c0ae9d8996 Feat: Adding Purchasable to 'in_stock' when importing a new product 2026-09-22 13:48:08 +03:00
arvanitakis cc1cf6ea7f Feat: Displaying Draft Products, only when AppDebug = true 2026-09-22 13:46:26 +03:00
arvanitakis 0437057e5d Merge branch 'Quality-Updates' 2026-09-18 01:30:37 +03:00
arvanitakis 0c169daf55 Bump version to 0.19.0 2026-09-18 01:29:54 +03:00
arvanitakis 609a63c2f4 Feat: Tying Specific Methods with Carrier Drivers 2026-09-18 01:23:50 +03:00
arvanitakis 12aaa43f10 Fix: Updates to OrderFullfilmentServices and box now clients, order views and checkout services 2026-09-18 00:54:43 +03:00
arvanitakis dcdc998eee Feat: Updating Shipping Method with new variables, for correct box env vars 2026-09-18 00:52:38 +03:00
arvanitakis a55697ce82 Feature: Updating Listreners, Separating Logic from listeners, Queuing Policies 2026-09-16 23:24:02 +03:00
arvanitakis a411e6bbc1 Bump version to 0.18.1 2026-09-16 18:55:42 +03:00
arvanitakis 910fa94395 Feat: Updating the Privacy Providers, moving them into the appropriate Modules, Updating Privacy views 2026-09-16 18:51:20 +03:00
arvanitakis fdd1899c34 Feat: Rearranging Providers 2026-09-16 13:44:14 +03:00
arvanitakis 3ad3a1b4d6 Fix: Adding cart id and order id to stripe payload 2026-09-16 01:39:23 +03:00
arvanitakis 68233f43ef Feat: Privacy Concern redesign to match project structure 2026-09-16 01:21:22 +03:00
arvanitakis 027f7e8982 Feat: Updating Data Erasure and Data Export Views 2026-09-16 00:46:13 +03:00
arvanitakis c084eb47cb Feat: Bringin Privacy to Filament v4, the managers and resources were built with filament v3 2026-09-16 00:24:18 +03:00
arvanitakis 58d165acc3 Fix: FIxing Bug on resolving relation on Products, Orders, and Users 2026-09-16 00:23:29 +03:00
arvanitakis 44ad943eec Merge branch 'master' into Privacy 2026-09-16 00:13:41 +03:00
arvanitakis e7784364fd Feature: Data Access and Data Export Admin Service
This commit introduces the data retention and data export Admin Services, accessed by the Boboko admin UI
2026-08-25 09:33:03 +03:00
arvanitakis 59303cf25f Feature: Updating Readme to reflect changes on Privacy 2026-08-24 21:44:10 +03:00
arvanitakis af380a7fa0 Feature: Handling Cases for User to Customer Relationships
This commit handles a case where customer data are "dead-data" menaing there is no way of erasure for them, which makes the app non-compliant
2026-08-24 21:41:23 +03:00
arvanitakis 9f540cbaa4 Feature: Creating Privacy Basics 2026-08-24 21:06:11 +03:00
156 changed files with 7198 additions and 409 deletions
+307
View File
@@ -4,6 +4,313 @@ 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.2] - 2026-09-25
### Changed
- Product custom fields (`Product::$custom_fields`) moved off the main product edit form onto
their own "Custom Fields" sub-page (`Modules\Core\Catalog\Filament\Pages\
ManageProductCustomFields`), alongside "Reviews" — the same admin pattern, registered from the
same `Review\Filament\Extensions\ProductResourceExtension` (CorePlugin only allows one
extension class per Lunar resource, and Review's already owns this one).
- Each custom field's `label` and new `help_text` are now translatable per storefront language
(`{locale: string}`, e.g. `{en: "...", el: "..."}`) instead of a single plain string — entered
as a plain `TextInput` per configured language rather than Lunar's `TranslatedText` form
component, which turned out to only resolve its state path correctly as a top-level form
field, not nested inside a `Repeater` item (every value silently failed to save under that
combination). `help_text` is optional and, unlike `label`, shown only on the product page, not
the cart or checkout.
- `Modules\Core\Catalog\Support\ProductDocumentLocalizer::withLocalizedFields()` now also
resolves each `custom_fields` item's `label`/`help_text` to a single string for the current
locale (falling back to the store's default language), the same `filled()`-over-`??` way as
every other translated field — the storefront and cart still only ever see one resolved
string per field, unaware the admin-side value became translatable. A product's custom fields
saved before this change (plain string `label`, no `help_text`) still resolve correctly.
## [0.20.1] - 2026-09-24
### Fixed
- `Modules\Core\Catalog\Support\ProductDocumentLocalizer::withLocalizedFields()` — a translated
attribute (name, description, ...) saved blank for the current locale kept the empty string
instead of falling back to the store's default language, since `??` only falls back on a
missing/null key, not an empty one. A product with no English copy yet showed a blank
title/description on `/en/` instead of its Greek content.
- `Modules\Core\Command\WipeCatalogCommand` — now also deletes every `CartLine` referencing a
`product_variant` purchasable as part of the wipe (line items only, `Cart` records themselves
are left alone). Previously, any cart still holding a line for a wiped variant crashed the
entire storefront on every page load (`PricingManager::for()` throws when the variant a line
points at no longer exists) until those dangling lines were removed by hand.
- `Modules\Core\Command\MigrateImportCommand` now asks which language a Shopify export file's
own text is written in before importing, instead of silently assuming it matches the store's
default language — the two are independent facts, and a mismatch used to save every imported
product's name/description under the wrong language. Backing class renamed `DefaultLocale` →
`Modules\Core\MigrateImport\Services\ImportLocale` to stop implying that assumption.
### Changed
- `Modules\Core\MigrateImport` reorganized to match every other module's layout
(`Contracts/`, `DTOs/`, `Jobs/`, `Models/`, `Services/`) instead of loose files at each
namespace root — no behavior change, but every `use` of `Importer`, `ImportSpec`,
`ImporterFactory`, `ImportLocale`, `RunMigrateImportJob`, `ShopifyExportImporter`,
`ShopifyCsvReader`, `ProductGroup`, `JudgeMeExportImporter`, and `JudgeMeCsvReader` moved to
its new namespace.
- `Modules\Core\MigrateImport\Shopify\Services\ShopifyExportImporter::import()` now dispatches
one `Jobs\ImportShopifyProductJob` per product (via `Bus::batch()`) instead of importing every
product inline in a single queued job. A large export's variants, resolvers, and media
downloads accumulating in one long-lived process routinely exceeded `queue:work`'s
`--memory` limit; the worker died mid-run, the container restarted, and the entire import
started over from the first row every time, never actually finishing. Splitting into one job
per product resets memory between products, and a restart now only repeats whichever single
product was in flight. `Modules\Core\Catalog\Services\SkuBackfillService::backfill()` moved
from running right after the import loop to the batch's `then()` callback, since it must wait
for every product job to finish rather than firing the moment jobs are merely queued.
## [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
+117 -21
View File
@@ -1,6 +1,9 @@
# Core Module
A Laravel module providing authentication, notifications, activity logging, CLI tooling, and functional types on top of the [Lunar](https://lunarphp.io) admin panel. Designed to be consumed as a standalone Composer package.
A Laravel module providing authentication, localization, product search/catalog, privacy/GDPR
tooling, notifications, activity logging, CLI tooling, and functional types on top of the
[Lunar](https://lunarphp.io) e-commerce package. Designed to be consumed as a standalone Composer
package by any Lunar-based e-shop.
---
@@ -8,13 +11,83 @@ A Laravel module providing authentication, notifications, activity logging, CLI
### OTP Authentication
Passwordless login for both staff (Lunar panel) and customers via 6-digit codes delivered by email. Codes expire after 10 minutes. The Lunar panel login page is a two-step flow: email → OTP. Rate-limited to 5 attempts.
Passwordless login for both staff (Lunar panel) and customers via 6-digit codes delivered by
email. Codes expire after 10 minutes, rate-limited to 5 attempts. The Lunar panel login page is a
two-step flow (email → OTP) with a back button to return from the code step to the email step.
See [`docs/otp-auth.md`](docs/otp-auth.md).
### Localization
Locale-prefixed routing (`Modules\Core\Localization\LocaleMiddleware`) — a `locale` route
middleware, opt-in per shop, that resolves and redirects to the correct language segment
(`/el/...`, `/en/...`) based on Lunar's own language list, with caching and rename-safe
translation migration. Also brings in storefront UI label translations
(`spatie/laravel-translation-loader`) with an admin-editable `LanguageLine` resource.
See [`docs/localization.md`](docs/localization.md).
### Product Search & Catalog
Two complementary services on top of Meilisearch:
- **`Modules\Core\Search\ProductSearchService`** — locale-aware full-text product search.
- **`Modules\Core\Catalog\ProductService`** — listing/filtering (by collection, brand, price
range) and single-product lookup by id or slug, reading directly from the Meilisearch index
rather than the database.
Both are backed by `Modules\Core\Search\ProductIndexer`, which extends Lunar's own indexer with
collections, price, variants, media, tags, and reviews — everything needed for both a listing
page and a full product detail page from one index.
See [`docs/product-search.md`](docs/product-search.md) and
[`docs/product-listing.md`](docs/product-listing.md).
### Product Reviews
`Modules\Core\Review\ProductReview` — ratings/reviews with staff replies, a Filament sub-navigation
page on the product edit screen, and automatic re-indexing (via `ReviewServiceProvider`) whenever
a review is created, updated, or deleted, so a product's Meilisearch document never goes stale.
### Privacy / GDPR Data-Subject Requests
Right of access (export) and right of erasure, built as an extensible contract
(`Modules\Core\Privacy\Contracts\PersonalDataProvider`) rather than a fixed table list — any
module can register its own data without core knowing it exists.
- **Two independent scopes**: erasing/exporting a Lunar `Customer` (business account) is never
the same operation as erasing/exporting a `User` (individual login) — a `Customer` erasure
never touches any linked `User`'s login, and a `User` erasure never touches a `Customer`
account's own data. See `docs/privacy.md` "User-scope vs Customer-scope".
- **Cancellable grace period** (default 30 days, configurable) before anything is actually
erased — logging back in during the window automatically reverts the request, mirroring
Shopify's own account-deletion flow. Immediate erasure exists but is staff-only by type, never
reachable from a self-service flow.
- **Sole-owner cascade**: erasing the last remaining `User` on a `Customer` also opens a (grace
period) erasure request for that now-orphaned `Customer`, so its PII doesn't sit unreachable
forever — traced back to the triggering request so login-reactivation can revert exactly that
cascade.
- **Queued export**: gathering data and writing a CSV-per-provider zip (via the generic,
reusable `Modules\Core\Export\CsvWriter`) runs as a background job; a consuming app hooks its
own notification onto the completion event via the Notification Registry (below).
See [`docs/privacy.md`](docs/privacy.md).
### Shopify Migration
`Modules\Core\MigrateImport\Shopify\ShopifyExportImporter` — imports a Shopify CSV product export
(products, variants, images, collections, tags, prices) into Lunar, idempotently re-runnable via
an `import_mappings` table. Part of a source-agnostic import framework
(`boboko:migrate:import`) designed to support additional sources later.
See [`docs/shopify-import.md`](docs/shopify-import.md).
### Notification Registry
An event-driven notification system. Each notification class declares which event it listens to and who to notify — the registry wires up the listener automatically. All notifications extend `BaseNotification` which implements `ShouldQueue`, so delivery is async. Supports optional delays.
An event-driven notification system. Each notification class declares which event it listens to
and who to notify — the registry wires up the listener automatically. All notifications extend
`BaseNotification`, which implements `ShouldQueue`, so delivery is async. Supports optional
delays.
**Creating a notification:**
@@ -33,9 +106,13 @@ class MyNotification extends BaseNotification
NotificationRegistry::get()->register([MyNotification::class]);
```
See [`docs/notifications.md`](docs/notifications.md).
### Activity Logging
Thin wrapper around [Spatie Laravel Activity Log](https://github.com/spatie/laravel-activitylog). Four standardized methods: `created()`, `updated()`, `failed()`, `deleted()`. Logs to the `lunar` channel and auto-resolves the actor from the staff session.
Thin wrapper around [Spatie Laravel Activity Log](https://github.com/spatie/laravel-activitylog).
Four standardized methods: `created()`, `updated()`, `failed()`, `deleted()`. Logs to the `lunar`
channel and auto-resolves the actor from the staff session.
See [`docs/activity-log.md`](docs/activity-log.md).
@@ -43,8 +120,11 @@ See [`docs/activity-log.md`](docs/activity-log.md).
- Custom OTP login page replacing the default Lunar panel login
- `StaffResourceExtension` — removes password field from Lunar's staff resource
- `CustomerResourceExtension` — replaces default address relation manager with a custom implementation
- `CorePlugin` — configures panel path, branding, logos, navigation items, and activity log field exclusions for staff
- `CustomerResourceExtension` — replaces default address relation manager with a custom
implementation
- Table-rate shipping (`ShippingPlugin`) registered by default
- `CorePlugin` — configures panel path, branding, logos, navigation items, and activity log
field exclusions for staff
Register the plugin in your Lunar panel provider:
@@ -52,30 +132,36 @@ Register the plugin in your Lunar panel provider:
->plugin(\Modules\Core\CorePlugin::make())
```
See [`docs/lunar.md`](docs/lunar.md) for the full Lunar reference and non-obvious gotchas hit
while building against it.
### CLI Commands
| Command | Description |
|---|---|
| `core:create-admin` | Create a Lunar admin user |
| `core:anonymize` | GDPR anonymization of users and customers (local only) |
| `core:export` | Dump database + storage files to a timestamped zip |
| `core:import` | Restore from a zip export (runs anonymize automatically, local only) |
| `core:export-cleanup` | Delete old export zips, keep N most recent |
| `boboko:anonymize` | Dummy-scrub personal data in `users`/`lunar_customers` for local dev safety (local environment only — **not** the GDPR erasure tool; see Privacy above for that) |
| `boboko:export` | Dump database + storage files to a timestamped zip |
| `boboko:import` | Restore from a `boboko:export` zip archive |
| `boboko:export:cleanup` | Delete old export zips, keep N most recent |
| `boboko:migrate:import` | Import a vendor product catalog (Shopify, etc.) into Lunar |
| `boboko:privacy:process-erasure-requests` | Dispatch an erasure job for every due GDPR erasure request (wire into your own scheduler) |
| `lunar:create-admin` | Create a Lunar admin user (overrides Lunar's own command) |
| `lunar:install` | Seed default Lunar store data — countries, channel, currency, tax zone, attributes, product type (overrides Lunar's own command) |
### Functional Types
Result and Option monads for explicit error handling without exceptions.
Result and Option types for explicit error handling without exceptions.
```php
// Result<T, E>
$result = Success::of($value);
$result = Error::of('something went wrong');
$result = Success::create($value);
$result = Error::create('something went wrong');
$result->map(fn($v) => ...)->flatMap(fn($v) => ...);
// Option<T>
$option = Option::fromValue($nullableValue);
$option->getOrElse('default');
$option->map(fn($v) => ...)->filter(fn($v) => $v > 0);
$option = Some::create($value);
$option = None::create();
$option->map(fn($v) => ...);
```
---
@@ -102,17 +188,22 @@ Then run:
```bash
composer require boboko/core
php artisan vendor:publish --tag=core-config
php artisan vendor:publish --tag=core-assets
php artisan migrate
```
For local core development alongside a consuming app (path-repo symlink + Docker mount), see
[`docs/modules.md`](docs/modules.md) "Docker Compose: the local-core mount".
---
## Requirements
- PHP 8.2+
- Laravel 11+
- Lunar (lunarphp/lunar + lunarphp/admin)
- PHP 8.5+
- Laravel 12+
- Lunar 1.3 (`lunarphp/lunar`)
- Meilisearch (for product search/listing/catalog)
- Spatie Laravel Activity Log
---
@@ -120,7 +211,12 @@ php artisan migrate
## Documentation
- [`docs/otp-auth.md`](docs/otp-auth.md) — OTP authentication flow
- [`docs/localization.md`](docs/localization.md) — Locale-prefixed routing and storefront translations
- [`docs/product-search.md`](docs/product-search.md) — Full-text product search
- [`docs/product-listing.md`](docs/product-listing.md) — Product listing/filtering/detail catalog service
- [`docs/privacy.md`](docs/privacy.md) — GDPR right of access/erasure, User-scope vs Customer-scope
- [`docs/shopify-import.md`](docs/shopify-import.md) — Shopify CSV → Lunar field mapping and import design
- [`docs/activity-log.md`](docs/activity-log.md) — Activity logging
- [`docs/lunar.md`](docs/lunar.md) — Lunar framework reference
- [`docs/notifications.md`](docs/notifications.md) — Notification registry
- [`docs/lunar.md`](docs/lunar.md) — Lunar framework reference and gotchas
- [`docs/modules.md`](docs/modules.md) — Module architecture, Customer/User pairing, provider registration pitfalls
+4 -2
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.2",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -43,8 +43,10 @@
"Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\CartServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider",
"Modules\\Core\\Providers\\FileServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider"
"Modules\\Core\\Providers\\OrderServiceProvider",
"Modules\\Core\\Providers\\PrivacyServiceProvider"
]
}
},
+37
View File
@@ -16,6 +16,43 @@ return [
'auto_create_customer_for_user' => true,
/*
|--------------------------------------------------------------------------
| Privacy / GDPR data-subject requests
|--------------------------------------------------------------------------
|
| 'providers' lists every Modules\Core\Privacy\Contracts\PersonalDataProvider
| that should be consulted for right-of-access/right-of-erasure requests. A
| module never needs to be known to core in advance — it just adds its own
| provider class here, the same way config('lunar.search.indexers') maps a
| model to its indexer. See docs/privacy.md.
|
| 'grace_period_days' is how long an erasure request stays cancellable
| (account deactivated, not yet erased) before it's actually processed by
| the privacy:process-erasure-requests scheduled command.
|
*/
'privacy' => [
'providers' => [
// ActivityLogDataProvider MUST run before AddressDataProvider —
// it resolves which activity_log rows belong to this customer
// (including ones keyed by an Address id) before
// AddressDataProvider hard-deletes those Address rows. See that
// provider's own class docblock.
\Modules\Core\Logging\Privacy\ActivityLogDataProvider::class,
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
\Modules\Core\Order\Privacy\OrderDataProvider::class,
\Modules\Core\Cart\Privacy\CartDataProvider::class,
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
\Modules\Core\Payment\Privacy\PaymentDataProvider::class,
\Modules\Core\Auth\Privacy\UserSessionDataProvider::class,
],
'grace_period_days' => 30,
],
/*
|--------------------------------------------------------------------------
| Cart Abandonment Threshold
+14
View File
@@ -13,12 +13,25 @@
|
| Set these via environment variables — never commit real values.
|
| Box Now has two environments (see their Partner API manual, section 2):
| Stage/Sandbox for testing, Production once live. Each has its own
| client_id/client_secret pair and its own base_url/location_api_url —
| there is no shared "switch an env var" flag, since stage credentials
| don't work against the production host or vice versa.
|
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
| lookups (Box Now recommends this over the main
| base URL for those two calls specifically).
| BOXNOW_CLIENT_ID OAuth2 client id.
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
| BOXNOW_PARTNER_ID Numeric partnerId Box Now issues alongside your
| credentials. NOT used for REST API authentication
| (BoxNowClient authenticates with client_id/
| client_secret alone) — this is only consumed by
| the client-side Destination Map widget config
| (_bn_map_widget_config.partnerId), confirmed
| against Box Now's own WooCommerce plugin source.
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
| the pickup origin on every delivery request.
| BOXNOW_SENDER_* Static sender contact details reused on every
@@ -33,6 +46,7 @@ return [
'client_id' => env('BOXNOW_CLIENT_ID'),
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
'partner_id' => env('BOXNOW_PARTNER_ID'),
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('deactivated_at')->nullable()->after('otp_expires_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('deactivated_at');
});
}
};
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('data_erasure_requests', function (Blueprint $table) {
$table->id();
// Polymorphic, not a fixed customer_id — a request targets either a
// Lunar Customer (business account) or a User (individual), never
// both at once. See docs/privacy.md "User-scope vs Customer-scope".
$table->string('subject_type');
$table->unsignedBigInteger('subject_id');
// Snapshot, not a live-looked-up value — the subject's email may
// change or the record may be gone by the time this is read.
$table->string('email')->nullable();
// Who asked for this: the subject themselves (self-service deletion)
// or a staff member acting on their behalf. Plain nullable type+id
// columns rather than morphs() — only ever one of two concrete actor
// types, not an open-ended polymorphic set.
$table->string('requested_by_type');
$table->unsignedBigInteger('requested_by_id');
$table->string('status')->default('pending');
// Set only on a Customer-scoped request that was auto-created because
// erasing a User left them as the sole remaining user on that Customer
// (see Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener).
// Null for every normal, directly-requested erasure. Lets login-
// reactivation find and revert exactly the Customer request THIS
// User's cancellation caused, without touching an unrelated,
// independently-requested Customer erasure the User happens to be
// linked to.
$table->foreignId('caused_by_request_id')->nullable()->constrained('data_erasure_requests')->nullOnDelete();
// now() + config('core.privacy.grace_period_days') at creation time —
// when privacy:process-erasure-requests will actually run this.
$table->timestamp('scheduled_for');
$table->timestamp('cancelled_at')->nullable();
$table->timestamp('completed_at')->nullable();
// Every provider's outcome, written once the request completes —
// see Modules\Core\Privacy\ErasureReport. Null until then.
$table->json('report')->nullable();
$table->timestamps();
$table->index(['status', 'scheduled_for']);
$table->index(['subject_type', 'subject_id']);
});
}
public function down(): void
{
Schema::dropIfExists('data_erasure_requests');
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('data_export_requests', function (Blueprint $table) {
$table->id();
// Polymorphic, not a fixed customer_id — see data_erasure_requests
// for the same shape and reasoning.
$table->string('subject_type');
$table->unsignedBigInteger('subject_id');
// Snapshot, not a live lookup — same reasoning as
// data_erasure_requests.email (see that migration).
$table->string('email')->nullable();
$table->string('status')->default('pending');
// Storage path of the assembled export .zip, set once the queued job
// finishes. Null while pending.
$table->string('file_path')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->index('status');
$table->index(['subject_type', 'subject_id']);
});
}
public function down(): void
{
Schema::dropIfExists('data_export_requests');
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Per-product, customer-authored input fields — a personalized-statue
* product needing a reference photo upload and an optional engraving
* textarea, for example. Deliberately NOT modeled as a Lunar ProductOption
* (see Modules\Core\Catalog\Contracts\ProductOptionTypeInterface's own
* docblock): an option's values are a fixed, admin-authored list that
* define variants (Red/Green/Blue) — a photo upload has no such list, it's
* unique per order, and creates no variant at all. This is a genuinely
* different concept that happens to configure on the same product page.
*
* Array of {key, type: 'text'|'textarea'|'file', label, required} — `key`
* is what a submitted answer is keyed by in CartLine/OrderLine.meta (both
* already have a `meta` json column — see Modules\Core\Cart\Services\
* CartService::addLine()'s own $meta parameter), not a new table, since
* this is small, rarely-queried per-product config, the same reasoning
* ShippingMethod.data/PaymentMethod.data already follow for their own
* per-row settings.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
$table->json('custom_fields')->nullable()->after('attribute_data');
});
}
public function down(): void
{
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
$table->dropColumn('custom_fields');
});
}
};
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A generic, storage-backend-agnostic file registry — Modules\Core\File\
* Services\FileService's own backing table. `disk`/`path` are whatever
* Laravel's Storage facade already understands (local, s3, ...); this
* table adds what Flysystem itself has no concept of: who a file
* belongs to, why it was uploaded, and whether anything still needs it.
*
* `owner_type`/`owner_id` are nullable — a file can (and, for a product
* custom-field photo, always does) exist before anything owns it yet: a
* shopper picks a photo on the product page and it's uploaded immediately
* (see 3dealer's CustomFieldUploadController), well before add-to-cart
* gives it a CartLine to belong to. FileService::attachOwner() re-points
* these columns once an owner exists, rather than creating a second row
* for the same physical file.
*
* `purpose` (e.g. 'custom-field-upload') lets one table serve unrelated
* future features without collision — FileService itself has no
* knowledge of what a purpose means, callers scope their own queries by
* it.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('files', function (Blueprint $table) {
$table->id();
$table->string('disk');
$table->string('path');
$table->string('original_name')->nullable();
$table->string('mime')->nullable();
$table->unsignedBigInteger('size')->nullable();
$table->string('purpose');
$table->nullableMorphs('owner');
$table->timestamps();
$table->index(['purpose', 'owner_type', 'owner_id']);
});
}
public function down(): void
{
Schema::dropIfExists('files');
}
};
+10 -2
View File
@@ -49,7 +49,8 @@ boboko-test/
app/
Models/
Customer.php ← app-level model, extends Modules\Core\Customer\Models\Customer
User.php ← app-level model, dispatches Modules\Core\Auth\Events\UserCreated
User.php ← app-level model, no $dispatchesEvents needed — core dispatches
UserCreated itself (Modules\Core\Auth\Services\UserOtpService)
Staff.php ← app-level model, extends Modules\Core\Auth\Models\Staff
Lunar/
Extensions/ ← app's own Filament resource extensions (source of truth, wired in PanelServiceProvider)
@@ -264,7 +265,14 @@ php artisan vendor:publish --tag=core-config
'auto_create_customer_for_user' => false,
```
Both listeners guard against the other direction re-triggering: they call `User::withoutEvents(...)` around `firstOrCreate`/save, so pairing a `Customer` never spuriously fires `UserCreated` (and vice versa) even if both directions are somehow active at once.
A guard against the other direction re-triggering is only needed where a real risk exists:
`App\Listeners\CreateUserForCustomerListener` (`boboko-test`, app-level) wraps its
`firstOrCreate` in `User::withoutEvents(...)`, since finding-or-creating a `User` there could
itself fire `UserCreated` and loop back into `CreateCustomerForUser`. `Modules\Core\Customer\
Listeners\CreateCustomerForUser` (core) needs no such guard — it calls a plain
`$model::create([])` on `Customer`, which has no `$dispatchesEvents`/model hooks of its own in
core that could re-trigger anything; the guard belongs only on the side that actually creates a
`User`.
---
+55 -16
View File
@@ -145,23 +145,20 @@ produced had it resolved synchronously.
with no memory of the request that started the payment. Something has to persist enough to
answer "which order/cart does gateway reference X belong to?" between the two calls.
**Read directly from `lunarphp/stripe`'s own source** (`StripePaymentType::authorize()`,
`ProcessStripeWebhook`, `WebhookController`) to see how Lunar itself solves this — confirmed
it does **not** stash a generic opaque blob. It writes the correlating ids as real, typed
columns on `Lunar\Stripe\Models\StripePaymentIntent` (`cart_id`, `order_id`) at the moment the
intent is created/first seen, then reads them back the same way when the webhook arrives:
The precedent for this originally came from reading `lunarphp/stripe`'s own source
(`StripePaymentType::authorize()`, `ProcessStripeWebhook`, `WebhookController`) — that package
solved this the same way, writing the correlating ids as real, typed columns on its own
`StripePaymentIntent` model rather than a generic opaque blob. **`lunarphp/stripe` has since
been removed from this project** in favour of depending on `stripe/stripe-php` directly (see
CHANGELOG.md) — `Modules\Core\Payment\Models\StripePaymentIntent` is now a first-party model
over the same table shape, kept for exactly the same reason.
```php
// ProcessStripeWebhook::handle() — falls back through two real lookups,
// neither of them a generic context blob:
$cart = StripePaymentIntent::where('intent_id', $this->paymentIntentId)->first()?->cart
?: Cart::where('meta->payment_intent', '=', $this->paymentIntentId)->first();
```
**`StripePaymentDriver` follows this exact precedent**: it reads `cart_id`/`order_id` out of
`$context` at `pay()`/`authorize()` time and writes them onto its own `StripePaymentIntent`
row (a table already owned by `lunarphp/stripe`, already shaped for exactly this), then reads
them back the same way in `handleCallback()`. No generic `context` json column, no new table.
**`StripePaymentDriver` follows this pattern**: it reads `cart_id`/`order_id` out of `$context`
at `pay()`/`authorize()` time and writes them onto its own `StripePaymentIntent` row (`src/
Payment/Models/StripePaymentIntent.php`, table `stripe_payment_intents`), then reads them back
the same way in `handleCallback()`. No generic `context` json column beyond what that table
already carries (`context`, added for a different purpose — see that migration's own
docblock), no new table.
### This pattern is per-driver, not a shared table
@@ -176,6 +173,48 @@ a shared generic one.
---
## Reconciliation — a charge that succeeds on Stripe but is never written locally
This app never creates or reuses a Stripe **Customer** object — every PaymentIntent is a
one-off (`StripePaymentDriver::createAndConfirm()`'s own `$params` never includes a `customer`
key), and nothing calls Stripe's Customer API anywhere in this codebase. That's a deliberate
choice, not an oversight: a Customer object only earns its keep if something actually needs it
(saved/reusable payment methods, subscriptions, Stripe-side lifetime-value grouping across
orders) — none of which exist in this checkout flow today. Creating one anyway would just be
more PII sitting on a third party's servers for no functional benefit, and it would become
another cross-reference a future Payment privacy provider has to account for (detaching/
deleting the Customer on erasure, not just the local PaymentIntent row). If a real feature
needs it later (e.g. "save my card"), add it then, scoped to that feature.
The gap this creates: with no Customer object and no other identifying field previously sent
to Stripe, a PaymentIntent that succeeds on Stripe's side but is never written to our own DB
(e.g. a database outage at exactly the wrong moment, between Stripe confirming the charge and
`rememberIntent()`'s insert) would be **untraceable** back to a cart or order — nothing to
search Stripe's dashboard by except amount, timestamp, and card last-4.
**Fix**: `createAndConfirm()` now sets `metadata: ['cart_id' => ..., 'order_id' => ...]`
(`array_filter()`-ed, since `order_id` isn't known yet at initial `pay()`/`authorize()` time —
same null-coalesce `rememberIntent()` already does) on every PaymentIntent. This is metadata
only, visible on Stripe's own dashboard/API for manual reconciliation — it does not create a
Customer object and does not change anything about how `handleCallback()`/webhook correlation
works (that still goes through `stripe_payment_intents`, per "Async resolution" above). It's
purely a recovery aid for the case where our own write never happened at all.
---
## GDPR erasure/export
`Modules\Core\Payment\Privacy\PaymentDataProvider` covers `lunar_transactions`
(`card_type`/`last_four`) and `stripe_payment_intents` — see `docs/privacy.md` for the full
right-of-erasure/right-of-access design. Pseudonymizes card metadata on erasure (same
tax/accounting retention reasoning `Order`'s own provider uses) and deletes the Stripe
correlation rows outright, since their only purpose — resolving an async webhook callback, see
"Async resolution" above — has already been served by the time an erasure request runs. No
Stripe Customer object exists anywhere in this app (see "Reconciliation" above) for this
provider to also request deletion of.
---
## Explicitly out of scope for this pass
- **`Checkout`/`Order` wiring** — how `Checkout` calls into `Payment`, how `Order`/`Checkout`
+417
View File
@@ -0,0 +1,417 @@
# Privacy / GDPR Data-Subject Requests
`Modules\Core\Privacy` implements the right of access (export) and right of erasure for
customers, as an extensible contract rather than a fixed list of tables — any module (core,
or a future ERP/banking/etc. module) can register its own data without core knowing it exists.
---
## User-scope vs Customer-scope — two genuinely different operations
A Lunar `Customer` (business account: orders, addresses, buyer record) and a `User` (individual
login identity) are linked many-to-many via the `customer_user` pivot (see `docs/modules.md`
"Customer/User Pairing") — **one User can belong to many Customer accounts, and one Customer
account can have many linked Users.** This is the real shape of B2B multi-seat access: a person
can have login access to several separate business accounts, and a business account can have
several employees each with their own login.
That means "delete my personal data" and "delete this business account" are not the same request,
and conflating them is actively wrong:
- **Erasing a Customer must never touch any linked User's login or identity.** Erasing "Acme
Corp" must not deactivate or destroy access for the employees who work there — and must not
touch any *other* Customer account, even one sharing some of the same Users.
- **Erasing a User must never touch any Customer account's own data.** John asking to delete
*his* account must clear his name/email/login wherever it appears — and correctly end his
membership on every Customer he's linked to (detach the pivot) — but must not erase Acme Corp's
orders or addresses, and must not affect any other employee still linked to Acme Corp.
Every part of this module is split along that line — a `PersonalDataProvider`, a `PrivacyService`
method, a request record — is always explicitly **for a Customer** or **for a User**, never both
at once, and never one with an implicit cascade into the other.
---
## Why an extensible contract, not a hardcoded script
A GDPR erasure/export request has to touch every module that holds personal data, but core can't
know in advance what future modules will exist or what data they'll hold — and different data
needs fundamentally different handling (freely erasable PII vs. financial records that must be
pseudonymized-not-deleted for legal retention vs. data that must be retained outright). There's
deliberately no central taxonomy for this in the contract — each module owns its own retention
judgment, since only the module that owns a table actually knows its legal requirements.
`Modules\Core\Privacy\Contracts\PersonalDataProvider` is the whole contract:
```php
interface PersonalDataProvider
{
public function name(): string;
public function exportForUser(UserSubject $subject): ProviderExportResult;
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult;
public function eraseForUser(UserSubject $subject): ProviderErasureResult;
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult;
}
```
Every provider implements all four methods. A provider with nothing relevant to one scope
implements that method as a no-op — `ErasureOutcome::Skipped` with a reason for erase, an empty
payload for export (e.g. `AddressDataProvider::eraseForUser()`, since addresses belong to a
Customer, not an individual).
A provider implementation lives inside the module that owns the data it erases/exports, under
that module's own `Privacy/` subdirectory (e.g. `Modules\Core\Order\Privacy\OrderDataProvider`,
`Modules\Core\Customer\Privacy\CustomerDataProvider`) — never inside `Modules\Core\Privacy`
itself, which only owns the shared contract (`Contracts\PersonalDataProvider`), the request
lifecycle (`Services\PrivacyManager`/`PrivacyService`), and the DTOs/enums every provider
returns. This mirrors how this codebase already handles other cross-cutting-but-domain-specific
code (e.g. a resource's own `Filament/Extensions/` subdirectory) — and matters concretely if a
module is ever extracted into its own composer package (see `docs/modules.md`): the provider
that knows how to erase that module's data must travel with it, not get stranded in `Privacy`
depending on a package that no longer ships in this repo.
A module registers by adding its provider class to `config('core.privacy.providers')` — the
same shape as Lunar's own `config('lunar.search.indexers')` model→indexer map:
```php
// config/core.php
'privacy' => [
'providers' => [
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
\Modules\Core\Order\Privacy\OrderDataProvider::class,
\Modules\Core\Cart\Privacy\CartDataProvider::class,
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
// A future module just adds its own provider here.
],
],
```
`PrivacyManager` resolves each class via the container and asserts every `name()` is unique —
two providers registering the same name throws, so a naming collision fails loudly at
resolution time rather than silently overwriting one provider's data in an export/report.
---
## `UserSubject` and `CustomerSubject` — identifying "the person" vs "the account"
Two separate value objects, not one — each deliberately carries only what its own scope needs, so
a provider can't accidentally reach across the boundary:
```php
class CustomerSubject
{
public readonly int $customerId;
// No userIds, no email — Customer-scope has no business knowing about logins.
}
class UserSubject
{
public readonly int $userId;
public readonly ?string $email;
// No customerId — one User can be linked to many Customers; a provider that
// needs to know which ones looks that up itself (e.g. to detach the pivot),
// rather than this value object assuming or privileging any single one.
}
```
`CustomerSubject::forCustomer(Customer $customer)` and `UserSubject::forUser($user)` build one
from the record staff (or the person themselves) look up.
---
## Providers shipped in core
| Provider | `name()` | Lives in | Covers | Customer-scope | User-scope |
|---|---|---|---|---|---|
| `ActivityLogDataProvider` | `activity_log` | `Modules\Core\Logging\Privacy` | `activity_log` (Spatie) for subject types `Customer`/`Address`/`CartAddress`/`OrderAddress`/`Transaction` | **Pseudonymized** — `properties` redacted, who/what/when metadata kept | Skipped — `causer_id` is an actor reference, not PII content; see below |
| `CustomerDataProvider` | `customer` | `Modules\Core\Customer\Privacy` | `lunar_customers`, and separately the `User`'s own name/email/OTP fields | Erases the account's own fields only | Erases that User's name/email/OTP fields only, and detaches them from every linked Customer |
| `AddressDataProvider` | `addresses` | `Modules\Core\Customer\Privacy` | `lunar_addresses` | Erased (deleted outright) | Skipped — belongs to a Customer, not an individual |
| `OrderDataProvider` | `orders` | `Modules\Core\Order\Privacy` | `lunar_orders`, `lunar_order_addresses`, and their `meta` (`terms_accepted*`, `payment_method`, `box_now_locker`) | **Pseudonymized, not erased** — see below | Skipped — belongs to a Customer, not an individual |
| `CartDataProvider` | `carts` | `Modules\Core\Cart\Privacy` | `lunar_cart_addresses`, and `lunar_carts.meta` (`recovery_consent*`, `payment_method`, `checkout_fingerprint`) | Erased | Skipped — belongs to a Customer, not an individual |
| `ReviewDataProvider` | `reviews` | `Modules\Core\Review\Privacy` | `product_reviews` | Skipped — authored by an individual, not a business account | Pseudonymized by matching `reviewer_email`; rating/title/body text kept |
| `PaymentDataProvider` | `payments` | `Modules\Core\Payment\Privacy` | `lunar_transactions` (`card_type`/`last_four`), `stripe_payment_intents` | **Pseudonymized** — card metadata cleared, correlation rows deleted, amounts/statuses kept | Skipped — belongs to Customer-owned orders, not individual users |
| `UserSessionDataProvider` | `sessions` | `Modules\Core\Auth\Privacy` | `user_sessions` (`ip_address`, `user_agent`) | Skipped — belongs to an individual User, not a business account | Erased (deleted outright) |
`CustomerDataProvider` is the one provider that implements both scopes meaningfully, and keeps
them from touching each other — see the class docblock for the full reasoning.
### `activity_log` is redacted by subject, never by causer
`Modules\Core\Logging\ActivityLogService` (plus several Lunar models' own native `use
LogsActivity` — `Customer`, `CartAddress`, `OrderAddress`, `Transaction`) durably retains a full
snapshot of whatever it logged in `properties`, completely independent of the real row it
describes — erasing/pseudonymizing a `Customer`/`Address`/`Order`/etc. elsewhere does nothing to
this table on its own. `ActivityLogDataProvider::eraseForCustomer()` redacts `properties` on
every row whose **subject** (not causer) resolves back to that customer, across all five
PII-bearing subject types.
It deliberately never touches `causer_id` — the causer is "who performed this action," not PII
content, and erasing it would defeat the audit trail's own purpose. `eraseForUser()` is
therefore a no-op: a `User` appears in this table only as a causer, never as subject content, so
there's nothing to redact from the User side alone.
**Ordering dependency**: `ActivityLogDataProvider` must run *before* `AddressDataProvider` in
`config('core.privacy.providers')` — it resolves which `activity_log` rows are keyed by an
`Address` id while those Address rows still exist; `AddressDataProvider` then hard-deletes them.
Reversing the order would make matching those rows impossible once the addresses are gone.
**`ReviewDataProvider` needs review.** It moved from Customer-scope to User-scope on the
reasoning that authorship is a personal attribute, not a business-account attribute — but this
hasn't been fully validated against how reviews are actually attributed in this codebase. The
class carries a `NEEDS REVIEW` note; revisit before relying on it for a real request.
### Orders are pseudonymized, not deleted
GDPR Art. 17(3)(b) explicitly allows retaining data an erasure request would otherwise cover,
when a legal obligation requires it — tax/accounting law generally requires invoices be kept for
several years. `OrderDataProvider::eraseForCustomer()` clears the free-text PII fields on `Order`/
`OrderAddress` (`customer_reference`, `notes`, name/address/contact fields) but leaves the order
row, totals, line items, and tax data fully intact. Its `ProviderErasureResult` reports
`ErasureOutcome::Pseudonymized`, not `Erased` — a compliance report or admin UI can see exactly
why an order wasn't deleted without reading `OrderDataProvider`'s source.
### Reviews are matched by email — a real, documented limitation
`ProductReview` has no FK to Customer/User at all (see `docs/product-listing.md` "Reviews") —
it's deliberately anonymous, just free-text `reviewer_name`/`reviewer_email`. `ReviewDataProvider`
matches by `reviewer_email` against `UserSubject::$email`; a review submitted under a different
email than the one on file simply won't be found. There's no stronger signal available without
changing `ProductReview`'s schema.
### Staff/employee data is out of scope
`Staff` (admin/panel employees) is never a `UserSubject`/`CustomerSubject` at all — this feature
is scoped to customer-initiated and staff-initiated-on-a-customer's-behalf requests. An employee's
own data (a different HR/access-management concern) isn't reachable through this flow.
---
## Erasure isn't immediate — a cancellable grace period
`PrivacyService` has parallel methods for each scope: `requestErasureForCustomer()` /
`requestErasureForUser()`. Neither erases anything immediately. Each opens a `DataErasureRequest`
(`pending`, `scheduled_for` = now + `config('core.privacy.grace_period_days')`, default 30). This
mirrors Shopify's own account-deletion flow: a window where the subject can change their mind
before anything is actually erased.
**Only the User-scoped request deactivates a login.** `requestErasureForCustomer()` deactivates
no one — a business-account erasure must never block anyone's access.
`requestErasureForUser()` deactivates that one User's login (blocks it — see
`Modules\Core\Auth\Services\UserOtpService` — nothing else changes).
```php
use Modules\Core\Privacy\Services\PrivacyService;
$service = app(PrivacyService::class);
// Customer-scoped: either the Customer itself (self-service) or a Staff member.
$request = $service->requestErasureForCustomer($customer, $requestedBy);
// User-scoped: either the User itself (self-service) or a Staff member.
$request = $service->requestErasureForUser($user, $requestedBy);
// Cancel before scheduled_for — for a User-scoped request, reactivates the
// account. A Customer-scoped request never deactivated anything, so there's
// nothing to reactivate for it.
$service->cancelErasure($request);
```
### Logging back in during the grace period cancels the request automatically
Authentication is never blocked by deactivation — `UserOtpService::validate()` still requires
the correct OTP code. Once validated, it dispatches `Modules\Core\Auth\Events\UserAuthenticated`;
`Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener` (registered in
`PrivacyServiceProvider`, **queued** — see below) looks for a pending request keyed on *that
User's own id* — never a Customer-scoped one, since Customer-scope never deactivates a login in
the first place — and calls `cancelErasure()` on it, then reverts every Customer erasure request
it caused (see "The sole-owner cascade" below). Logging back in **is** the "I changed my mind"
action — no separate UI/flow needed for reactivation.
This listener is queued rather than synchronous, so login returns to the browser without waiting
on the bookkeeping. Nothing else in this codebase currently reads `deactivated_at` besides this
listener and `PrivacyService` itself — `UserOtpService::validate()` never gates the login on it —
so the brief window between the login response and the job actually running has no other consumer
to observe it as stale.
### The sole-owner cascade — erasing the last User on a Customer also erases the Customer
If a User is erased and they were the **only** User linked to a given Customer, that Customer's
data (orders, addresses, buyer record) becomes permanently unreachable through any login the
moment the User's identity is gone — nobody could ever again log in to exercise a data-subject
right over it. GDPR's data minimization principle (Art. 5(1)(c)) means it shouldn't just sit
there indefinitely with no legitimate purpose.
`requestErasureForUser()` and `requestImmediateErasureForUser()` both fire
`Modules\Core\Privacy\Events\UserErasureRequested` right after the request is created (and, for
the immediate path, before `completeErasure()` runs — see below).
`Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener` (**queued**, registered in
`PrivacyServiceProvider`) handles it: for every Customer the User is linked to, if that User is
currently the *sole* linked User (count is 1, and that one User is this one — not just count ===
1, to be explicit rather than relying on an assumption), it opens a second, independent
grace-period request via `requestErasureForCustomer($customer, $user, causedByRequestId: ...)`.
Both requests then run through their own separate 30-day windows.
```
User erasure requested
│
▼
UserErasureRequested event ──▶ CascadeCustomerErasureListener (queued)
│
▼
for each linked Customer: sole owner?
│ yes
▼
requestErasureForCustomer(..., causedByRequestId: <user request id>)
```
**Tracing the cascade — `caused_by_request_id`.** A cascade-created Customer request's
`caused_by_request_id` points back at the User request that triggered it. This is what lets
`CancelErasureOnLoginListener` revert *exactly* the cascade a User's own cancellation should
undo (via `DataErasureRequest::caused()`) without ever touching an unrelated, independently
staff-requested Customer erasure the User happens to still be linked to.
**Why this is queued, not synchronous.** `CascadeCustomerErasureListener` runs as an independent,
separately-retryable job rather than inline inside `requestErasureForUser()` — a failure in the
cascade check never rolls back or blocks the User's own request, and there's no
`DB::transaction()` wrapping needed, since the two writes (the User's request, and any cascaded
Customer request) aren't required to be atomic with each other.
**A known, accepted race on the immediate-erasure path only.** Because the listener is queued,
Eloquent re-fetches its models fresh when the job actually runs (see
`Illuminate\Queue\SerializesModels`) — so `$event->request->subject->customers` reflects the
*real* state at execution time, not a stale snapshot from dispatch time. For
`requestImmediateErasureForUser()`, that job may run before or after `completeErasure()` detaches
the User's memberships in the same call. If the detach happens first, the User is simply no
longer linked to anything by the time the cascade job runs, and nothing cascades — an accepted
race for that rare, staff-only path (see "Immediate erasure" below), not a concern for the
everyday `requestErasureForUser()` grace-period path, where nothing detaches until its own later,
separate `completeErasure()` run — well after the cascade job has had time to fire.
### Processing due requests — one job per request
`php artisan boboko:privacy:process-erasure-requests` finds every `pending` request whose
`scheduled_for` has passed and dispatches one `Modules\Core\Privacy\Jobs\EraseDataSubjectJob` per
request — it does not run `completeErasure()` inline itself. Each job independently calls
`PrivacyService::completeErasure()`, which checks the request's polymorphic `subject` and calls
either every registered provider's `eraseForCustomer()` or `eraseForUser()`, writing the full
per-provider outcome onto the request's `report` column and marking it `completed`. One job per
request means one request's failure (a provider throwing, a DB error) doesn't block or crash
processing of the others, and Laravel's normal per-job retry/failure handling applies to each
request independently. This package doesn't register a schedule itself; each consuming app wires
the command into its own scheduler (daily is reasonable), the same way it owns any other
scheduled task.
### Immediate erasure — staff-only, not self-service
`requestImmediateErasureForCustomer(Customer $customer, Staff $requestedBy): ErasureReport` and
`requestImmediateErasureForUser($user, Staff $requestedBy): ErasureReport` bypass the grace
period entirely and erase right away. Both are `Staff`-only **by type**, not just by convention —
their signatures take `Staff $requestedBy` specifically (not the union type the grace-period
methods accept), so a self-service/customer-facing code path can't reach either one even by
accident; calling with a `Customer`/`User` actor is a compile-time type error, not a runtime
check to remember.
This exists for a formal legal request or regulator inquiry that genuinely requires immediate
action, not as a convenience for an impatient customer. GDPR Art. 17 requires erasure "without
undue delay," but doesn't set a maximum number of days for a grace period, and a short, disclosed,
cancellable hold before executing a self-service request is a widely-used, generally accepted
pattern (the same one Shopify and most major platforms use) — it is **not** offered as a
same-click alternative on the self-service deletion flow, since doing so would mostly defeat the
grace period's purpose (protecting an impulsive requester from themselves). If a subject
explicitly insists on immediate deletion, that's a staff/support decision to make on the record
via one of these methods, not a checkbox exposed to every customer.
```php
$report = $service->requestImmediateErasureForCustomer($customer, $staffMember);
$report = $service->requestImmediateErasureForUser($user, $staffMember);
// Both run synchronously — no queueing, no grace period. $report is the same
// ErasureReport completeErasure() would produce.
```
---
## Export — queued, not synchronous
Export gathers real data across every registered provider — potentially slow, and there's no
reason to block whatever request triggered it (a customer clicking "export my data," an API
call). `requestExportForCustomer()`/`requestExportForUser()` are fast synchronous calls that only
create a `DataExportRequest` row and dispatch the actual work:
```php
$request = $service->requestExportForCustomer($customer);
$request = $service->requestExportForUser($user);
// $request->status is 'pending'; nothing has been gathered yet.
```
### The event chain
1. **`ExportDataSubjectJob`** (queued) checks the request's polymorphic `subject` and calls every
registered provider's `exportForCustomer()` or `exportForUser()` — all sequentially, in this
one job, not fanned out into one job per provider. Per-subject export work is small (a handful
of indexed queries per provider), so there's no real parallelism win, and one job means
"finished" is just "`handle()` returned," with no `Bus::batch()`/completion-counting needed. If
a future provider ever does something genuinely slow (an external API call, a generated PDF),
that's the point to reconsider a per-provider batch — not before.
2. Once every provider's data is gathered, the job fires **`PersonalDataGathered`**
(carries the request and the assembled `ExportReport`) — no file exists yet.
3. **`Modules\Core\Privacy\Listeners\WriteExportToCsvListener`** (registered in
`PrivacyServiceProvider`) handles that event: turns each provider's data into its own CSV (via
the generic `Modules\Core\Export\CsvWriter` — see below), zips them together, writes the zip to
`storage/app/exports/privacy/`, and updates the request (`status: completed`, `file_path`).
This is its own listener — not inline in the job — so the export *format* is swappable (an app
could unregister this and register a JSON-only listener instead) without touching how data is
gathered.
4. Once the file exists, that listener fires **`PersonalDataExportFileWritten`**.
5. Core has no opinion on how the subject is told. A consuming app registers its own notification
against `PersonalDataExportFileWritten` via `Modules\Core\Notification\NotificationRegistry` —
the same pattern as `App\Notifications\QuestionnaireResultsSentNotification` listening on
`App\Events\QuestionnaireResultsSent` (see `boboko-test` for a working example). Core
deliberately does not send an email itself.
### CSV shape
Every provider's `data` is either a list of associative arrays (addresses, orders, reviews — each
item becomes a row) or a single associative array (customer — becomes one row). Any nested array
value within a row (e.g. an order's `addresses` sub-array) is JSON-encoded into that one cell
rather than exploded into further columns — a generic, provider-agnostic rule in
`WriteExportToCsvListener`, not something each provider has to think about.
### `Modules\Core\Export\CsvWriter` — a generic, reusable piece
`CsvWriter::write(array $columns, iterable $rows, string $path)` has no knowledge of GDPR,
customers, or Lunar at all — a caller supplies a schema (`CsvColumn[]`, each just a header plus a
closure that pulls that column's value out of one record) and any iterable data source. It's used
here by `WriteExportToCsvListener`, but is equally usable for an unrelated future need — an admin
bulk catalog export, an accounting handoff — by supplying a different schema and row source;
nothing about it is GDPR-specific.
---
## Audit trail
`DataErasureRequest` (`data_erasure_requests`) and `DataExportRequest` (`data_export_requests`)
are the audit records for erasure and export respectively. Both have a polymorphic `subject`
(`subject_type`/`subject_id`, pointing at either a Lunar `Customer` or a `User` — never both) —
`subject_type`/`subject_id`/`email` are stored as a **snapshot**, not looked up live, since the
whole point is for these tables to remain readable after the record they're about has been
erased. `DataErasureRequest::isForCustomer()` tells you which scope a given request is.
`DataErasureRequest.requested_by_type`/`requested_by_id` capture who asked for it (the subject
themselves, self-service; `Staff` acting on their behalf; or, for a cascade-created Customer
request, the User whose erasure caused it — see "The sole-owner cascade") at request time.
`DataErasureRequest.caused_by_request_id` is set only on a cascade-created Customer request,
pointing back at the User request that triggered it; null on every normal, directly-requested
erasure — see `DataErasureRequest::causedBy()`/`::caused()`.
`DataErasureRequest.report` holds the full per-provider outcome once `completeErasure()` runs;
`DataExportRequest.file_path` points at the generated zip once `WriteExportToCsvListener`
finishes.
**Not yet built**: a standalone "leave/remove from a Customer account" action — unlinking a User
from a Customer without any erasure involved (e.g. a teammate leaving a project, or an account
admin removing someone) — is a related but separate, smaller feature, deliberately out of scope
for this module so far. It shares the same pivot-detach primitive `CustomerDataProvider::
eraseForUser()` already uses as part of a full erasure, but as a standalone action it doesn't
exist yet.
+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>
-18
View File
@@ -1,18 +0,0 @@
<?php
namespace Modules\Core\Auth\Events;
use Illuminate\Contracts\Auth\Authenticatable;
/**
* Dispatched by Modules\Core\Auth\Services\UserOtpService::validate() on a
* successful OTP login — distinct from UserCreated (which only fires for
* a genuinely first-time email); this fires on every successful login,
* new user or returning one.
*/
class CustomerLoggedIn
{
public function __construct(
public readonly Authenticatable $user,
) {}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Auth\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Base\LunarUser;
/**
* Dispatched by UserOtpService::validate() on every successful OTP login, not just
* a first-time one. Modules\Core\Privacy listens on this to auto-cancel a pending
* DataErasureRequest — logging back in during the grace period is the "I changed
* my mind" action (see Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener),
* which needs $user->customers to resolve any pending request. Typed as
* Authenticatable&LunarUser rather than plain Authenticatable (unlike the sibling
* UserCreated event) specifically because that listener depends on it — every real
* User in this codebase implements LunarUser (see docs/lunar.md "LunarUser trait"),
* and User is the only Authenticatable entity in this project (Customer is not —
* see docs/modules.md "Customer/User Pairing").
*/
class UserAuthenticated
{
public function __construct(
public readonly Authenticatable&LunarUser $user,
) {}
}
+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;
}
+15 -2
View File
@@ -9,7 +9,8 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\CustomerLoggedIn;
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;
@@ -144,7 +157,7 @@ class UserOtpService
$this->sessions->record($result, $request);
Event::dispatch(new CustomerLoggedIn($result));
Event::dispatch(new UserAuthenticated($result));
return $result;
}
@@ -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']);
return $cart->calculate();
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',
};
}
}
@@ -0,0 +1,162 @@
<?php
namespace Modules\Core\Catalog\Filament\Pages;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Support\Pages\BaseEditRecord;
use Lunar\Models\Language;
/**
* Own sub-page for Product::$custom_fields (see that column's own docblock
* on Modules\Core\Catalog\Models\Product) — used to be a collapsible
* Section inline on the main product edit form (Review\Filament\
* Extensions\ProductResourceExtension::extendForm()), moved out to match
* how Reviews already gets its own sub-page (ManageProductReviews) rather
* than crowding the main form with a second unrelated concern.
*
* Deliberately no ->statePath('') override, no custom mount()/
* handleRecordUpdate() — EditRecord::mount() already fills the form from
* $record->attributesToArray() (which includes custom_fields, a real cast
* + fillable column) onto the default 'data' statePath, and save() reads
* it straight back off via $this->form->getState(). An earlier version of
* this page used ->statePath('') to bind the repeater directly to the
* record's attributes (copying ManageProductPricing) — that repointed the
* Repeater at $this->data['custom_fields'] AS THE ROOT state path itself,
* so every "add item" click re-filled the whole form from the record's
* still-unsaved value and immediately discarded the new row before it
* ever reached the page. Reverting to the plain default form/statePath is
* both simpler and is what actually works — same as the original inline
* repeater on the main product form did before this became its own page.
*
* Registered from Review\Filament\Extensions\ProductResourceExtension, not
* here — CorePlugin only allows one extension class per Lunar resource,
* and Review's already owns ProductResource's extension slot (see that
* class's own docblock).
*
* `label`/`help_text` are each stored as {locale: string} (e.g. {en: "...",
* el: "..."}) — see translatedField()'s own docblock for why that's a
* hand-rolled TextInput per language rather than Lunar's TranslatedText
* component. A product saved before this change still has a plain string
* `label` and no `help_text` at all; itemLabel() below tolerates both
* shapes, and the storefront/cart resolve either shape the same way (see
* product-custom-fields.blade.php and CartController::
* customFieldsMeta()). `key`/`type`/`required` stay plain, single values —
* only shopper-facing copy needs a translation, not the field's own
* machine-facing configuration.
*/
class ManageProductCustomFields extends BaseEditRecord
{
protected static string $resource = ProductResource::class;
public static function getNavigationIcon(): ?string
{
return 'heroicon-o-adjustments-horizontal';
}
public function getTitle(): string
{
return 'Custom Fields';
}
public static function getNavigationLabel(): string
{
return 'Custom Fields';
}
/**
* Without this, Filament's EditRecord defaults to every relation
* manager the WHOLE ProductResource defines (see HasRelationManagers::
* getAllRelationManagers(), which reads ProductResource::getRelations()
* regardless of which sub-page is rendering) — Channels, Customer
* Groups, Media, Pricing tabs all bleeding onto this page alongside the
* repeater below. This page has no relations of its own.
*/
public function getRelationManagers(): array
{
return [];
}
/**
* A plain TextInput per configured language, named "{$field}.{locale}"
* so it resolves to a normal nested array under the repeater item
* (custom_fields.{item}.label.en, .label.el, ...) — NOT Lunar's
* TranslatedText component. That component's per-locale sub-fields
* set their own statePath to just the locale code itself
* (TranslatedText::prepareTranslateLocaleComponent()), which only
* resolves correctly when TranslatedText is used as a single
* top-level named field directly on a form's root state (exactly how
* every existing usage in this codebase uses it — Lunar's own
* product name/description). Nested inside a Repeater item here, that
* same statePath resolution silently failed to nest under the item's
* own label/help_text key at all, and every typed value was lost on
* save. Hand-rolling the per-locale inputs sidesteps that assumption
* entirely.
*/
private function translatedField(string $field, string $label, string $helperText, bool $required): Group
{
$languages = Language::orderBy('default', 'desc')->get(['code', 'name', 'default']);
return Group::make(
$languages->map(fn (Language $language, int $index) => TextInput::make("{$field}.{$language->code}")
->label($index === 0 ? $label : null)
->hiddenLabel($index !== 0)
->helperText($index === 0 ? $helperText : null)
->prefix(Str::upper($language->code))
->required($required && $language->default))->values()->all(),
)
->columnSpanFull();
}
public function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('Custom Fields')
->description('Extra input the shopper fills in on this product\'s page before adding it to their cart — a reference photo, personalization text, etc.')
->schema([
Repeater::make('custom_fields')
->hiddenLabel()
->schema([
$this->translatedField('label', 'Label', 'Shown to the shopper above the field. Only the current storefront locale is shown on the cart and checkout.', required: true),
$this->translatedField('help_text', 'Help text', 'Optional — shown under the label on the product page only, not on the cart or checkout.', required: false),
Select::make('type')
->label('Field type')
->options([
'text' => 'Short text',
'textarea' => 'Long text',
'file' => 'File upload',
])
->default('text')
->native(false)
->live()
->required(),
TextInput::make('key')
->label('Key')
->helperText('Machine-facing identifier — stored on the order/cart line, used to look up this answer elsewhere. Cannot be changed once orders reference it.')
->required()
->alphaDash()
->maxLength(64),
Toggle::make('required')
->label('Required')
->helperText('Shopper cannot add this product to their cart without answering.')
->default(false),
])
->columns(2)
->addActionLabel('Add a custom field')
->reorderable()
->collapsible()
->itemLabel(fn (array $state): ?string => is_array($state['label'] ?? null)
? collect($state['label'])->first(fn ($value) => filled($value))
: ($state['label'] ?? null)),
]),
]);
}
}
@@ -2,11 +2,17 @@
namespace Modules\Core\Catalog\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Lunar\Models\Product;
use Modules\Core\Catalog\Events\ProductDeleted;
use Modules\Core\Catalog\Events\ProductSaved;
/**
* Queued — a Meilisearch filter query plus N reindex calls with no
* same-request reader; a few seconds of stale `recommendations` on a
* referencing product's storefront page is a cosmetic, not correctness,
* concern (see the class's own docblock below).
*
* Keeps every product's embedded `recommendations` field (see
* ProductIndexer) in sync when a product they recommend changes or is
* removed. Unlike Modules\Core\Catalog\Observers\ProductOptionReindexObserver's
@@ -27,7 +33,7 @@ use Modules\Core\Catalog\Events\ProductSaved;
* SCOUT_QUEUE is configured) reindex job per matched product — this
* listener itself does no synchronous Meilisearch writing.
*/
class ReindexProductsRecommendingProduct
class ReindexProductsRecommendingProduct implements ShouldQueue
{
public function handleSaved(ProductSaved $event): void
{
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\Catalog\Models;
/**
* Registered via Lunar\Facades\ModelManifest::replace(Lunar\Models\
* Product::class, self::class) — see Providers\CatalogServiceProvider —
* purely to add a cast AND fillable entry for `custom_fields` (see the
* migration adding that column: database/migrations/
* ..._add_custom_fields_to_products_table.php). Without the fillable
* entry, Lunar\Models\Product's own $fillable allowlist (attribute_data,
* product_type_id, status, brand_id — custom_fields isn't in it) silently
* drops the field on every mass-assignment save (Filament's own
* $record->update($data)) — no error, no exception, the admin form shows
* the repeater's rows as saved right up until the next page load, when
* they're simply gone. Caught in practice.
*
* ModelManifest::replace() only changes what code resolving Product
* through the CONTRACT (app(Contracts\Product::class), Filament's own
* ProductResource — its $model is ProductContract::class, not the
* concrete class) or the morph map receives — it does NOT retroactively
* change what a hardcoded `Lunar\Models\Product::query()`/`::find()`
* elsewhere in this codebase (or Lunar's own internals, e.g. the
* scheduled Meilisearch reindex command — see CatalogServiceProvider,
* which references this subclass by name specifically so that path picks
* it up too) resolves to. Most of this codebase's existing Product
* references are plain type-hints (they accept whichever instance is
* handed to them, subclass included) or don't touch `custom_fields` at
* all, so they're unaffected either way.
*/
class Product extends \Lunar\Models\Product
{
// NOT `protected $casts = [...]` — that property assignment REPLACES
// the parent's own $casts array wholesale rather than merging with
// it (PHP class property redeclaration has no merge semantics), which
// would silently drop every cast Lunar\Models\Product already
// defines (attribute_data, status, etc.). mergeCasts() is Eloquent's
// own documented mechanism for a subclass adding to, not replacing,
// its parent's casts.
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->mergeCasts([
'custom_fields' => 'array',
]);
$this->mergeFillable([
'custom_fields',
]);
}
}
+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,57 @@
<?php
namespace Modules\Core\Catalog\Services;
use Lunar\Models\ProductVariant;
/**
* Generates a SKU for every ProductVariant missing one — extracted out of
* Command\BackfillMissingSkusCommand (which becomes a thin CLI wrapper
* around this, keeping --dry-run/progress-bar concerns out of the
* reusable logic) so MigrateImport\Shopify\Services\ShopifyExportImporter can
* also call it directly, once every product job in its import batch has
* finished (see that class's own import()), with no CLI concerns at all.
*
* Format is "SKU-P{product_id}-V{variant_id}": deterministic and
* guaranteed unique without a uniqueness check, since product_id/
* variant_id already are. Only variants with a null `sku` are touched —
* not an importer bug when one shows up after a Shopify import, the
* source CSV rows genuinely had no `Variant SKU` value (see
* MigrateImport\Shopify\Services\ShopifyExportImporter).
*/
class SkuBackfillService
{
/**
* @param ?callable(ProductVariant, string): void $onEach invoked
* once per variant with the sku about to be written (or, when
* $dryRun is true, that WOULD be written) — the command's own
* --dry-run listing and progress bar hook in here without this
* service knowing anything about console output.
* @return int the number of variants processed
*/
public function backfill(bool $dryRun = false, ?callable $onEach = null): int
{
$query = ProductVariant::query()->whereNull('sku');
$total = $query->count();
if ($total === 0) {
return 0;
}
$query->chunkById(500, function ($variants) use ($dryRun, $onEach) {
foreach ($variants as $variant) {
$sku = "SKU-P{$variant->product_id}-V{$variant->id}";
if (! $dryRun) {
$variant->update(['sku' => $sku]);
}
if ($onEach !== null) {
$onEach($variant, $sku);
}
}
});
return $total;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Order;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
/**
* The one place ProductVariant::stock is written as a result of an order —
* previously this lived entirely inside Modules\Core\Order\Listeners\
* DecrementStockOnOrderPlaced, a listener with no Service behind it at
* all, even though stock (the column, its invariants — "never negative",
* "only in_stock variants") is fundamentally a Catalog concern, not an
* Order one. That listener is now a thin caller of this class, matching
* how every other module's event reaction delegates its actual write to
* a Service (e.g. Modules\Core\Order\Listeners\RecordPaymentTransaction
* -> Modules\Core\Order\Services\TransactionRecorder).
*
* Only decrements for `purchasable === 'in_stock'` variants — 'always' and
* 'backorder' variants are deliberately allowed to sell past (or without
* regard to) their stock count already (see ProductVariant::
* canBeFulfilledAtQuantity()), so decrementing their stock would just make
* that column an inaccurate, decreasingly-negative number with no purchasing
* consequence. Only `OrderLine::type === 'physical'` lines are considered —
* a digital line has no stock to decrement (ProductVariant::getType()).
*
* A single UPDATE per variant (`DB::table(...)->update()` with a raw
* expression), not a read-then-write on the Eloquent model — avoids a
* lost-update race between two orders decrementing the same variant
* concurrently, and skips Modules\Core\Catalog\Services\ProductIndexer::
* stock's staleness gap for the DB value itself even though the search
* index still only refreshes on the next reindex event/nightly job (see
* that class's own docblock).
*
* Never lets stock go negative (`GREATEST(stock - qty, 0)` via a raw
* expression) — an order can still be placed against a variant whose stock
* was already fully consumed by another concurrent order (Lunar has no
* stock-reservation step at cart/checkout time), so this is a best-effort
* count, not a hard inventory guarantee.
*/
class StockService
{
public function decrementForOrder(Order $order): void
{
$lines = $order->lines()
->where('type', 'physical')
->where('purchasable_type', ProductVariant::morphName())
->get(['purchasable_id', 'quantity']);
if ($lines->isEmpty()) {
return;
}
foreach ($lines as $line) {
DB::table((new ProductVariant())->getTable())
->where('id', $line->purchasable_id)
->where('purchasable', 'in_stock')
->update([
'stock' => DB::raw('GREATEST(stock - '.(int) $line->quantity.', 0)'),
]);
}
$productIds = ProductVariant::whereIn('id', $lines->pluck('purchasable_id'))
->pluck('product_id')
->unique();
Product::whereIn('id', $productIds)->get()->each->searchable();
}
}
@@ -51,16 +51,62 @@ class ProductDocumentLocalizer
$availableLocales = $this->languages->availableLocales();
foreach ($this->translatedAttributeHandles() as $handle) {
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null;
// filled(), not ?? - a translated attribute saved blank for
// the current locale still has that {handle}_{locale} key in
// the document, just set to '' rather than absent. ?? only
// falls back on a missing/null key, so it kept the empty
// string instead of falling through to a locale that actually
// has content.
$product[$handle] = filled($product[$handle.'_'.$locale] ?? null)
? $product[$handle.'_'.$locale]
: ($product[$handle.'_'.$fallbackLocale] ?? null);
foreach ($availableLocales as $availableLocale) {
unset($product[$handle.'_'.$availableLocale]);
}
}
if (! empty($product['custom_fields'])) {
$product['custom_fields'] = $this->localizeCustomFields($product['custom_fields'], $locale, $fallbackLocale);
}
return $product;
}
/**
* Product::$custom_fields isn't an AttributeManifest attribute (it's a
* plain JSON column, see Catalog\Models\Product's own docblock), so it
* never goes through the {handle}_{locale} explosion above — the
* indexer copies it straight through (see ProductIndexer), meaning
* each item's `label`/`help_text` still arrives here as a raw
* {locale: string} object (or, for a product saved before those
* became translatable, a plain string). Resolved the same filled()-
* over-?? way as every other translated field above, to the same
* single current-locale string the storefront/cart already expect
* (see product-custom-fields.blade.php and CartController::
* customFieldsMeta()) — a repeater item has no other reason to reach
* the storefront untouched.
*
* @param array<int, array<string, mixed>> $fields
* @return array<int, array<string, mixed>>
*/
private function localizeCustomFields(array $fields, string $locale, ?string $fallbackLocale): array
{
return array_map(function (array $field) use ($locale, $fallbackLocale) {
foreach (['label', 'help_text'] as $key) {
if (! is_array($field[$key] ?? null)) {
continue;
}
$field[$key] = filled($field[$key][$locale] ?? null)
? $field[$key][$locale]
: ($field[$key][$fallbackLocale] ?? null);
}
return $field;
}, $fields);
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
+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\
+13 -22
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\Jobs\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}";
if ($dryRun) {
$this->newLine();
$this->line("Variant {$variant->id}: sku => {$sku}");
} else {
$variant->update(['sku' => $sku]);
}
$bar->advance();
$backfill->backfill($dryRun, function (ProductVariant $variant, string $sku) use ($dryRun, $bar) {
if ($dryRun) {
$this->newLine();
$this->line("Variant {$variant->id}: sku => {$sku}");
}
$bar->advance();
});
$bar->finish();
+41 -2
View File
@@ -3,8 +3,9 @@
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\MigrateImport\ImportSpec;
use Modules\Core\MigrateImport\RunMigrateImportJob;
use Lunar\Models\Language;
use Modules\Core\MigrateImport\DTOs\ImportSpec;
use Modules\Core\MigrateImport\Jobs\RunMigrateImportJob;
class MigrateImportCommand extends Command
{
@@ -62,11 +63,29 @@ class MigrateImportCommand extends Command
$credentials = null;
}
// Shopify's own product export is a flat CSV — one Title/Body
// (HTML)/etc. column per row, no per-locale columns at all — so
// its text is necessarily written in exactly one language, and
// there is no reliable way to detect which one from the file
// itself. Modules\Core\MigrateImport\Services\ImportLocale::code() used to
// (as its former name, DefaultLocale, admits) assume it always
// matched this store's own Lunar\Models\
// Language::getDefault(), which is often wrong (a store's default
// admin/storefront language and the language a given export
// happens to be written in are two independent facts) — every
// imported product's name/description then saved silently under
// the wrong language, invisible unless that language happened to
// also be selected when viewing/editing the product afterward.
$locale = $source === 'shopify' && $type === 'export'
? $this->askImportLocale()
: null;
$spec = new ImportSpec(
source: $source,
type: $type,
filePath: $filePath,
credentials: $credentials,
locale: $locale,
);
RunMigrateImportJob::dispatch($spec);
@@ -74,6 +93,26 @@ class MigrateImportCommand extends Command
$this->info('Import queued.');
}
/**
* Choices come from Language::all() — the same list an admin manages
* from the Filament panel (Settings > Languages) — not a hardcoded
* set, so a language this store doesn't have yet simply isn't
* offered here; the hint below says where to add it instead of this
* command silently accepting an arbitrary code Lunar has no row for.
*/
private function askImportLocale(): string
{
$languages = Language::orderBy('default', 'desc')->get(['code', 'name']);
return $this->choice(
"Which language is the export file's own text (product titles, descriptions, etc.) written in?\n".
' (Not necessarily this store\'s default language — the two are independent. '.
"If the language you need isn't listed, add it first from the admin panel under Languages.)",
$languages->mapWithKeys(fn (Language $language) => [$language->code => "{$language->name} ({$language->code})"])->all(),
$languages->first()?->code,
);
}
// Answers are relative to storage/app/private/imports (e.g. "shopify" or
// "shopify/products_export.csv"); absolute paths are used as-is. A
// directory answer picks the first CSV file found inside it.
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
use Modules\Core\Privacy\Jobs\EraseDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest;
/**
* Finds every erasure request whose grace period (config('core.privacy.
* grace_period_days')) has passed and dispatches one EraseDataSubjectJob per
* request — see docs/privacy.md. This command itself just finds due requests and
* dispatches; the actual erasure work happens in the queue, one job per request,
* so one failing request doesn't block the others. Meant to run daily via the
* scheduler; each consuming app wires that in its own Console\Kernel (or
* bootstrap/app.php schedule closure on Laravel 11+), the same way it owns any
* other scheduled task — this package doesn't register schedules itself.
*/
class ProcessErasureRequestsCommand extends Command
{
protected $signature = 'boboko:privacy:process-erasure-requests';
protected $description = 'Dispatch an erasure job for every pending data-erasure request whose grace period has passed';
public function handle(): void
{
$due = DataErasureRequest::where('status', ErasureRequestStatus::Pending)
->where('scheduled_for', '<=', now())
->get();
if ($due->isEmpty()) {
$this->info('No due erasure requests.');
return;
}
foreach ($due as $request) {
EraseDataSubjectJob::dispatch($request);
$scope = $request->isForCustomer() ? 'customer' : 'user';
$this->info("Dispatched erasure job for {$scope} #{$request->subject_id} (request #{$request->id})");
}
$this->info('Dispatched '.$due->count().' erasure job(s).');
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Lunar\Models\CartLine;
use Lunar\Models\Product;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Auth\Services\OtpService;
use Modules\Core\MigrateImport\Models\ImportMapping;
use function Laravel\Prompts\password;
use function Laravel\Prompts\text;
/**
* Irreversibly deletes every Product and everything that only exists
* because of a product — variants, variant prices, product-option value
* assignments, product images/media, product associations, the
* ImportMapping rows tying them back to an external source, product-
* variant CartLine rows (line items only — Cart records themselves are
* left alone), and the Meilisearch product index. Deliberately does NOT
* touch catalog STRUCTURE other products could still reference: ProductOption/
* ProductOptionValue definitions ("Size", "Color" as reusable option
* types), Brands, Collections, Tags, Customer Groups — none of those are
* products, they're config a merchant would otherwise have to rebuild
* from scratch.
*
* Two gates a destructive, whole-catalog, irreversible operation
* warrants — deliberately NOT restricted to non-production on top of
* these; a real, legitimate use case is wiping a client's demo/seed
* catalog on a production database right before real launch, and the OTP
* below already proves the operator has real staff access, not just
* shell access to wherever `php artisan` happens to be runnable:
* 1. An OTP emailed to a real Staff account (reusing Auth\Services\
* OtpService — the exact mechanism admin login already uses).
* 2. Typing the literal product count back, not just "yes" — a plain
* confirm() is too easy to reflexively accept; forcing the operator
* to read and retype the actual number they're about to delete is a
* last check against running this against the wrong environment/
* database by mistake.
*
* Deletes via Eloquent model instances, not DB::table()->delete() —
* Product/ProductVariant use Spatie's InteractsWithMedia (see Lunar\Base\
* Traits\HasMedia), which only cleans up media files/rows on a real model
* `deleted` event, never on a raw query-builder delete.
*/
class WipeCatalogCommand extends Command
{
protected $signature = 'boboko:wipe-catalog {--email= : Staff email to send the confirmation code to}';
protected $description = 'Irreversibly delete every product, variant, and related catalog data';
public function handle(OtpService $otp): int
{
// withTrashed() — a prior soft-delete-only bug in this command
// (fixed in wipe() below) could leave ghost rows a plain count()
// would never see, silently reporting "nothing to do" while they
// sit there breaking other things (e.g. the admin's own global
// search, which assumes every returned product has variants).
$productCount = Product::withTrashed()->count();
if ($productCount === 0) {
$this->info('No products exist — nothing to do.');
return self::SUCCESS;
}
if (! $this->authorize($otp)) {
return self::FAILURE;
}
$this->warn("This will PERMANENTLY delete {$productCount} product(s) and everything that only exists because of them (variants, prices, images, product-option assignments, associations). This cannot be undone.");
$typed = text(label: "Type the product count ({$productCount}) to confirm");
if ($typed !== (string) $productCount) {
$this->error('Count did not match — aborted, nothing was deleted.');
return self::FAILURE;
}
$this->wipe();
$this->info("Deleted {$productCount} product(s) and all related data.");
return self::SUCCESS;
}
private function authorize(OtpService $otp): bool
{
$email = $this->option('email') ?? text(
label: 'Staff email to send a confirmation code to',
validate: fn (string $value) => Staff::where('email', $value)->exists()
? null
: 'No staff account with that email exists.',
);
if (! $otp->generateAndSend($email, purpose: 'wipe-catalog')) {
$this->error('Could not send a confirmation code to that email.');
return false;
}
$this->info("A confirmation code was sent to {$email}.");
$code = password(label: 'Enter the confirmation code');
if ($otp->validate($email, $code) === null) {
$this->error('Invalid or expired code — aborted, nothing was deleted.');
return false;
}
return true;
}
/**
* Every step below goes through a real Eloquent relation, never a raw
* table name — Lunar's own table prefix is configurable
* (config('lunar.database.table_prefix'), applied in BaseModel's
* constructor), so a hardcoded 'lunar_...' string would silently
* no-op on an install using a different one.
*
* Order matters: product_associations and the product/product_option
* pivot have a real FK to `products` but no ON DELETE CASCADE (both
* RESTRICT, Laravel's own default), so they're detached before the
* product/variant rows they reference — deleting a product that
* still has either would throw. ProductVariant's own `prices` (a
* plain morph, HasPrices trait — no FK constraint at all) would
* otherwise silently orphan rather than throw, so it's cleared the
* same way regardless. media_variant and product_option_value_
* product_variant DO cascade at the DB level (see their own
* migrations), so deleting the variant itself is enough for those two.
*
* Deliberately NOT chunkById() — that re-queries "id > lastSeenId"
* every iteration, but deleting rows inside the loop shrinks the
* table out from under it: any product whose id fell in a range
* chunkById() had already stepped past could be silently skipped and
* never actually deleted at all. Caught in practice — the first real
* run of this command left orphaned Media rows (Spatie's own
* deleteAllMedia(), fired from Product's `deleting` event, never ran
* for the skipped products) whose 'image' ImportMapping rows then
* caused a LATER Shopify re-import to silently reuse those now-
* orphaned Media objects instead of importing fresh ones — see
* MigrateImport\Shopify\Services\ShopifyExportImporter::resolveOrImportImage()'s
* own docblock for that half of the same incident. Always re-querying
* the first N remaining rows (never advancing an id cursor) guarantees
* every product is actually visited exactly once, however many are
* deleted out from under the query as it goes.
*/
private function wipe(): void
{
ImportMapping::whereIn('source_type', ['product', 'variant', 'image'])->delete();
// Only the line items — not the parent Cart rows. This command is
// meant for early-stage/setup use where no real customer carts
// matter yet, but a customer's Cart record also anchors their
// session/coupon/address state; deleting it outright is more than
// "the catalog is gone" calls for. Leaving every variant a cart
// line could reference about to be force-deleted below would
// otherwise reproduce the exact storefront crash this step exists
// to prevent: CartLine::purchasable() resolves to null,
// PricingManager::for() throws a TypeError on every page load that
// renders the cart drawer.
CartLine::where('purchasable_type', 'product_variant')->delete();
while (true) {
// withTrashed(): Product/ProductVariant both use SoftDeletes
// — a plain query would stop seeing a product the moment
// forceDelete() below actually removes it, which is fine, but
// WITHOUT withTrashed() here this loop would never even
// fetch a row that a previous, buggy run of this command
// (or any other code) had already soft-deleted without
// force-deleting it. Ghost rows like that are exactly what
// this command exists to remove.
$products = Product::withTrashed()
->with(['variants' => fn ($query) => $query->withTrashed(), 'associations', 'inverseAssociations'])
->limit(100)
->get();
if ($products->isEmpty()) {
break;
}
foreach ($products as $product) {
$product->associations()->delete();
$product->inverseAssociations()->delete();
$product->productOptions()->detach();
foreach ($product->variants as $variant) {
$variant->prices()->delete();
// NOT delete() — Product/ProductVariant both use
// SoftDeletes, and a plain delete() only sets
// deleted_at, leaving the row (and, for Product, its
// media) sitting in the table. This command's whole
// purpose is an irreversible wipe; a soft-deleted
// ghost row is the opposite of that. Caught in
// practice — a prior run's plain delete() left 185
// ghost Product rows with zero real variants, which
// then crashed the admin's own global search
// (Lunar\Admin\Filament\Resources\ProductResource::
// getGlobalSearchResultDetails() assumes
// $record->variants->first() is never null).
$variant->forceDelete();
}
$product->forceDelete();
}
}
Product::removeAllFromSearch();
}
}
+69 -3
View File
@@ -7,7 +7,11 @@ use Lunar\Admin\Filament\Resources\OrderResource\Pages\Components\OrderItemsTabl
use Filament\Contracts\Plugin;
use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Mail;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Admin\Filament\Resources\CustomerResource\Pages\EditCustomer;
use Lunar\Admin\Filament\Resources\CustomerResource\Pages\ViewCustomer;
use Lunar\Admin\Filament\Resources\ProductOptionResource;
use Lunar\Admin\Filament\Resources\ProductOptionResource\RelationManagers\ValuesRelationManager;
use Lunar\Admin\Filament\Resources\OrderResource;
@@ -15,6 +19,7 @@ use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff;
use Lunar\Admin\Support\Facades\LunarPanel;
use Lunar\Models\Customer;
use Lunar\Models\Product;
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
use Lunar\Shipping\Filament\Resources\ShippingMethodResource\Pages\ListShippingMethod;
@@ -31,6 +36,12 @@ use Modules\Core\Order\Filament\Extensions\OrderPaymentMethodSummaryExtension;
use Modules\Core\Order\Filament\Extensions\OrderActionsExtension;
use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Privacy\Filament\Extensions\CustomerErasureActionsExtension;
use Modules\Core\Privacy\Filament\Extensions\CustomerErasureRelationsExtension;
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource;
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Shipping\Extensions\OrderShipmentsExtension;
@@ -56,6 +67,8 @@ class CorePlugin implements Plugin
->login(Login::class)
->resources([
LanguageLineResource::class,
DataErasureRequestResource::class,
DataExportRequestResource::class,
CartResource::class,
PaymentMethodResource::class,
ShipmentResource::class,
@@ -72,11 +85,64 @@ class CorePlugin implements Plugin
ListShippingMethod::class => ShippingMethodListExtension::class,
ManageOrder::class => [OrderViewExtension::class, OrderActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class],
OrderItemsTable::class => OrderItemsTableExtension::class,
// headerActions() is resolved per PAGE class, not per resource class —
// unlike extendForm()/extendTable(), which really are resource-keyed
// (called statically from the Resource class itself). Registering this
// under CustomerResource::class would silently never fire; it has to be
// keyed by each concrete page it should appear on. Layered with
// whatever extension the consuming app registers for the same page —
// LunarPanel::extensions() merges per key, and this one only touches
// headerActions(), so it never conflicts with an app's own extension
// (see docs/modules.md "Layering Module and App Configuration").
EditCustomer::class => CustomerErasureActionsExtension::class,
ViewCustomer::class => CustomerErasureActionsExtension::class,
// getRelations(), unlike headerActions(), genuinely is resolved
// statically from the Resource class itself — CustomerResource::class
// is the correct key here.
CustomerResource::class => CustomerErasureRelationsExtension::class,
]);
Product::macro('reviews', function (): HasMany {
/** @var Product $this */
return $this->hasMany(ProductReview::class);
// resolveRelationUsing(), not macro() — Illuminate\Database\Eloquent\
// Model does not use the Macroable trait in this Laravel version, so
// Product::macro(...)/Customer::macro(...)/$userModel::macro(...)
// silently fall through to Model::__callStatic(), which instantiates
// the model and tries to call the method as a real one, hitting
// newQuery()->getConnection() — this crashes every console command
// and every request, since CorePlugin::register() runs during
// provider registration, before the DB connection is configured
// ("Call to a member function connection() on null"). This bit us
// once already; resolveRelationUsing() is Eloquent's real, intended,
// connection-free extension point for exactly this (Order::
// resolveRelationUsing('shipments', ...) in ShippingServiceProvider
// already uses it correctly).
Product::resolveRelationUsing('reviews', function (Product $product): HasMany {
return $product->hasMany(ProductReview::class);
});
// Customer::erasureRequests()/exportRequests() and the User-model
// equivalents below let a relation manager scope
// DataErasureRequest/DataExportRequest to one specific subject — both
// tables use a plain subject_type/subject_id pair rather than Laravel's
// usual morphs() convention, since one column pair identifies either a
// Customer or a User (see docs/privacy.md "User-scope vs Customer-scope"),
// so this is a MorphMany built by hand rather than a bare Eloquent
// convention lookup.
Customer::resolveRelationUsing('erasureRequests', function (Customer $customer): MorphMany {
return $customer->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
});
Customer::resolveRelationUsing('exportRequests', function (Customer $customer): MorphMany {
return $customer->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id');
});
$userModel = config('auth.providers.users.model');
$userModel::resolveRelationUsing('erasureRequests', function ($user): MorphMany {
return $user->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
});
$userModel::resolveRelationUsing('exportRequests', function ($user): MorphMany {
return $user->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id');
});
LunarStaff::addActivitylogExcept([
@@ -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,
@@ -0,0 +1,63 @@
<?php
namespace Modules\Core\Customer\Privacy;
use Lunar\Models\Address;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\DTOs\UserSubject;
/**
* A customer's saved addresses (lunar_addresses) — belong to the Customer
* (business account) via customer_id, not to an individual User, so this is
* Customer-scope only. No legal retention requirement of their own (unlike
* OrderAddress, handled by OrderDataProvider), so they're freely deleted outright
* rather than pseudonymized in place.
*/
class AddressDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'addresses';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$addresses = Address::where('customer_id', $subject->customerId)->get();
return new ProviderExportResult('addresses', $addresses->map(fn (Address $address) => [
'id' => $address->id,
'first_name' => $address->first_name,
'last_name' => $address->last_name,
'company_name' => $address->company_name,
'line_one' => $address->line_one,
'line_two' => $address->line_two,
'line_three' => $address->line_three,
'city' => $address->city,
'state' => $address->state,
'postcode' => $address->postcode,
'contact_email' => $address->contact_email,
'contact_phone' => $address->contact_phone,
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('addresses', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
Address::where('customer_id', $subject->customerId)->delete();
return new ProviderErasureResult('addresses', ErasureOutcome::Erased);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('addresses', ErasureOutcome::Skipped, 'Addresses belong to Customer accounts, not individual users.');
}
}
@@ -0,0 +1,122 @@
<?php
namespace Modules\Core\Customer\Privacy;
use Lunar\Models\Customer;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\DTOs\UserSubject;
/**
* The Customer record itself (lunar_customers) and, on the User side, the User's
* own name/email. This is the one provider that implements both scopes
* meaningfully, and they are deliberately kept from touching each other's data:
*
* - eraseForCustomer() clears the account's own fields (name, company, tax id)
* only — it never touches any linked User's login or identity, even though
* $customer->users exists. Erasing a business account must not destroy the
* login access of every person who works there.
* - eraseForUser() clears that one person's name/email only — it never touches
* the Customer record's own fields, and it also detaches the User from every
* Customer they're linked to (the customer_user pivot — see docs/modules.md
* "Customer/User Pairing"), since erasing a person's identity should end
* their membership everywhere, without erasing the business accounts
* themselves or any other User still linked to them.
*
* No legal retention requirement applies to this table on its own, so both
* directions are freely erased — Order/OrderAddress, which DO have a retention
* requirement, are handled separately by OrderDataProvider.
*/
class CustomerDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'customer';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$customer = Customer::find($subject->customerId);
return new ProviderExportResult('customer', $customer ? [
'id' => $customer->id,
'title' => $customer->title,
'first_name' => $customer->first_name,
'last_name' => $customer->last_name,
'company_name' => $customer->company_name,
'tax_identifier' => $customer->tax_identifier,
'meta' => $customer->meta,
'users' => $customer->users->map(fn ($user) => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
])->all(),
] : []);
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
$model = config('auth.providers.users.model');
$user = $model::find($subject->userId);
return new ProviderExportResult('customer', $user ? [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'customers' => $user->customers->map(fn (Customer $customer) => [
'id' => $customer->id,
'company_name' => $customer->company_name,
])->all(),
] : []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$customer = Customer::find($subject->customerId);
if (! $customer) {
return new ProviderErasureResult('customer', ErasureOutcome::Skipped, 'Customer record not found.');
}
$customer->update([
'title' => null,
'first_name' => 'Erased',
'last_name' => "Customer #{$customer->id}",
'company_name' => null,
'tax_identifier' => null,
'account_ref' => null,
'meta' => null,
]);
return new ProviderErasureResult('customer', ErasureOutcome::Erased);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
$model = config('auth.providers.users.model');
$user = $model::find($subject->userId);
if (! $user) {
return new ProviderErasureResult('customer', ErasureOutcome::Skipped, 'User record not found.');
}
$user->customers()->detach();
$user->update([
'name' => null,
'email' => "erased-user-{$user->id}@example.invalid",
// A live OTP code left on an otherwise-erased row is a residual
// secret tied to an identity that no longer exists here — clear
// it alongside name/email rather than leaving it to expire on
// its own 10-minute window.
'otp_code' => null,
'otp_expires_at' => null,
'otp_attempts' => 0,
]);
return new ProviderErasureResult('customer', ErasureOutcome::Erased);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Export;
use Closure;
/**
* One column in a CsvWriter schema: a header label plus a closure that pulls this
* column's value out of one record. The closure doesn't care what shape a record
* is — an array, an Eloquent model, a DTO — so the same CsvWriter serves any
* domain (GDPR export, an admin catalog export, an accounting export) by simply
* being handed a different column schema and a different row source.
*/
final class CsvColumn
{
/**
* @param Closure(mixed):((string|int|float|null)) $value
*/
public function __construct(
public readonly string $header,
public readonly Closure $value,
) {}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Modules\Core\Export;
/**
* A generic columns + rows -> CSV file writer. No knowledge of any domain (GDPR,
* catalog, accounting, ...) — a caller supplies the schema (CsvColumn[]) and the
* data source (any iterable of records), and this writes one CSV. Reusable for
* any future bulk-export need without modification.
*/
class CsvWriter
{
/**
* @param array<int, CsvColumn> $columns
* @param iterable<mixed> $rows
*/
public function write(array $columns, iterable $rows, string $path): void
{
$handle = fopen($path, 'w');
fputcsv($handle, array_map(fn (CsvColumn $column) => $column->header, $columns));
foreach ($rows as $row) {
fputcsv($handle, array_map(
fn (CsvColumn $column) => $this->stringify(($column->value)($row)),
$columns
));
}
fclose($handle);
}
private function stringify(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_array($value)) {
return json_encode($value);
}
return (string) $value;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace Modules\Core\File\Adapters;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Modules\Core\File\Contracts\FileAdapterInterface;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Wraps Laravel's own 'local' Storage disk — see FileAdapterInterface's
* own docblock for why this exists as a named adapter rather than every
* caller reaching for Storage::disk('local') directly: swapping to a
* different backend later (S3FileAdapter, say) means adding one class and
* one contextual-binding entry, touching nothing that already uses
* FileService.
*/
class LocalFileAdapter implements FileAdapterInterface
{
public function __construct(
private readonly Filesystem $disk,
) {}
public function store(UploadedFile $file, string $directory): string
{
return $this->disk->putFile($directory, $file);
}
public function exists(string $path): bool
{
return $this->disk->exists($path);
}
public function delete(string $path): void
{
$this->disk->delete($path);
}
public function retrieve(string $path, ?string $name = null): StreamedResponse
{
return $this->disk->response($path, $name);
}
public function download(string $path, ?string $name = null): StreamedResponse
{
return $this->disk->download($path, $name);
}
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\File\Commands;
use Illuminate\Console\Command;
use Modules\Core\File\Services\FileService;
/**
* Generic wrapper around FileService::pruneUnowned() — see that method's
* own docblock for what "unowned" means and why the grace period exists.
* Any caller (3dealer's product custom-field photo uploads today, some
* other future upload feature tomorrow, in this app or another consuming
* app) schedules this once per purpose string it stores files under; this
* command itself has no opinion about what any given purpose means.
*/
class PruneUnownedFilesCommand extends Command
{
protected $signature = 'boboko:file:prune-unowned {purpose} {--hours=24 : Only delete unowned files older than this}';
protected $description = 'Delete unowned files of a given purpose past their grace period';
public function handle(FileService $files): int
{
$purpose = $this->argument('purpose');
$deleted = $files->pruneUnowned($purpose, now()->subHours((int) $this->option('hours')));
$this->info("Deleted {$deleted} unowned file(s) of purpose \"{$purpose}\".");
return self::SUCCESS;
}
}
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\File\Contracts;
use Illuminate\Http\UploadedFile;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* One storage backend's actual byte-level operations — a disk name (see
* Modules\Core\File\Models\File::$disk) resolves to exactly one
* implementation of this via Modules\Core\File\Services\FileService's own
* contextual binding (see Providers\FileServiceProvider), the same
* pattern Shipping\Contracts\CarrierFulfillmentInterface uses to pick an
* AcsFulfillmentService/BoxNowFulfillmentService per carrier. FileService
* itself never touches a disk directly — every backend-specific detail
* (a local path, an S3 bucket/region, ...) lives entirely inside one
* adapter, so adding a new backend never touches FileService or any of
* its callers.
*/
interface FileAdapterInterface
{
/**
* Stores the file under $directory, returning the path to record on
* the File row (Models\File::$path) — backend-specific (a relative
* local path, an S3 object key, ...), meaningful only to this same
* adapter.
*/
public function store(UploadedFile $file, string $directory): string;
public function exists(string $path): bool;
public function delete(string $path): void;
/**
* Streams the file at $path straight to the browser, inline (the
* browser renders/previews it directly rather than prompting to save).
*/
public function retrieve(string $path, ?string $name = null): StreamedResponse;
/**
* Same bytes as retrieve(), but as a forced attachment — the browser
* always prompts to save, even for a type it could otherwise preview
* (an image inline in a new tab).
*/
public function download(string $path, ?string $name = null): StreamedResponse;
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\Core\File\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Core\File\Models\File;
use Modules\Core\File\Services\FileService;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Streams a File's bytes straight to the browser — inline by default (a
* browser-previewable type like an image opens/displays directly), or as
* a forced download with ?download=1 (e.g. an explicit "Download" button
* distinct from a thumbnail/preview link pointing at the same file). Only
* reachable via a short-lived signed URL — same auth model as
* Modules\Core\Shipping\Http\Controllers\DownloadShipmentLabelController
* (a valid signature IS the auth check, no separate staff/customer
* session check here) — so any caller that can mint a signed URL to this
* route (the storefront's own custom-field upload flow, or the admin
* order-line display) can hand a viewer a working link without this
* controller knowing anything about who they are or why they're allowed
* to see this particular file.
*
* Looks the File up manually from a plain {file} id rather than relying
* on implicit route-model-binding — registered via loadRoutesFrom() with
* no middleware group (see Providers\FileServiceProvider::boot()), so
* SubstituteBindings never runs and a type-hinted File parameter would
* silently resolve to an empty, non-existent model instead of 404ing.
*/
class DownloadFileController extends Controller
{
public function __invoke(Request $request, int $file, FileService $files): StreamedResponse
{
if (! $request->hasValidSignature()) {
abort(401);
}
$file = File::findOrFail($file);
abort_unless($files->exists($file), 404);
return $request->boolean('download') ? $files->download($file) : $files->retrieve($file);
}
}
@@ -0,0 +1,76 @@
<?php
namespace Modules\Core\File\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Validator;
use Modules\Core\File\Services\FileService;
/**
* The generic "accept an upload, validate it, store it via FileService,
* return its id" flow — what varies per use case (which extensions/sizes
* are acceptable) is deliberately NOT configurable here, and NEVER trusts
* anything the request itself claims about its own limits: a client could
* simply lie about them. purpose()/validationRules() are protected hooks
* a concrete subclass overrides instead — an ordinary PHP method a caller
* writes once per upload policy, not request input, so the actual limit
* enforced is always whatever server-side code says it is. Different
* products can even need different limits (a 3D-print reference photo
* vs. a video upload, say) — that's still a subclass's own store()
* override deciding which rule set applies to a given request, not
* something this base class or a shared config file could express.
*/
abstract class UploadFileController extends Controller
{
/**
* The File row's `purpose` tag (see Models\File) — also the storage
* directory it lands under (FileService::store()'s single $purpose
* param doubles as both).
*/
abstract protected function purpose(): string;
/**
* Laravel validation rules for the incoming request, keyed exactly as
* $request->all() would be. Must include a 'file' rule accepting an
* uploaded file — this class always reads the file from that key.
*
* @return array<string, array<int, mixed>>
*/
abstract protected function validationRules(Request $request): array;
public function store(Request $request, FileService $files): JsonResponse
{
$validator = Validator::make(
$request->all(),
$this->validationRules($request),
$this->validationMessages($request),
$this->validationAttributes($request),
);
if ($validator->fails()) {
return response()->json(['error' => $validator->errors()->first('file')], 422);
}
$file = $files->store($request->file('file'), $this->purpose());
return response()->json(['file_id' => $file->id]);
}
/**
* @return array<string, string>
*/
protected function validationMessages(Request $request): array
{
return [];
}
/**
* @return array<string, string>
*/
protected function validationAttributes(Request $request): array
{
return [];
}
}
@@ -0,0 +1,44 @@
<?php
namespace Modules\Core\File\Listeners;
use Modules\Core\Cart\Events\CartLineAdded;
use Modules\Core\File\Models\File;
use Modules\Core\File\Services\FileService;
/**
* A custom-field photo is uploaded (and gets its own File row, unowned)
* the moment a shopper picks it on the product page — before add-to-cart
* even runs (see 3dealer's CustomFieldUploadController). The storefront's
* add-to-cart request only carries that File's `id` in its custom_fields
* answer (see CartController::customFieldsMeta()); this is what actually
* gives the File an owner, once the real CartLine it belongs to exists.
*
* Listens for Cart\Events\CartLineAdded rather than reaching back into
* the cart after CartService::addLine() returns — that event already
* carries the exact CartLine Lunar resolved/created, with no need to
* re-match it by meta (ambiguous whenever two lines share a purchasable +
* similar meta).
*/
class AttachCustomFieldFileToCartLine
{
public function __construct(
private readonly FileService $files,
) {}
public function handle(CartLineAdded $event): void
{
$fileIds = collect($event->line->meta['custom_fields'] ?? [])
->pluck('file_id')
->filter()
->all();
if ($fileIds === []) {
return;
}
File::query()
->whereIn('id', $fileIds)
->each(fn (File $file) => $this->files->attachOwner($file, $event->line));
}
}
@@ -0,0 +1,62 @@
<?php
namespace Modules\Core\File\Listeners;
use Lunar\Models\OrderLine;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\File\Models\File;
use Modules\Core\File\Services\FileService;
/**
* Lunar\Pipelines\Order\Creation\CreateOrderLines copies a CartLine's
* meta (custom_fields included) onto its new OrderLine verbatim — so an
* order's custom-field file answer keeps the exact same `file_id` its
* originating cart line's meta already had (see 3dealer's CartController::
* customFieldsMeta(), which stores only that id — File is the single
* source of truth for disk/path/name/mime, never duplicated into meta).
* That id is enough to find the File row directly, with no need to match
* an OrderLine back to "the" CartLine it came from.
*
* Re-points ownership (not a copy — the same File row) from whatever
* CartLine owned it to this OrderLine, so a customer's placed order keeps
* its file even after the cart it came from is later cleared (see
* Modules\Core\Cart\Services\CartService, or a checkout-complete cart
* reset) — FileService::pruneUnowned() only ever removes UNOWNED files,
* but a File left pointing at a since-deleted CartLine would be just as
* orphaned in practice; this listener is what keeps that from ever
* happening for a real, placed order.
*
* Listens for Checkout\Events\OrderPlaced, not an OrderLine model event —
* that's the one place in this codebase an order is reliably known to be
* placed exactly once (see that event's own docblock), and it hands over
* the whole Order with every line already loaded.
*/
class TransferCustomFieldFileOwnership
{
public function __construct(
private readonly FileService $files,
) {}
public function handle(OrderPlaced $event): void
{
foreach ($event->order->lines as $line) {
$this->transferLine($line);
}
}
private function transferLine(OrderLine $line): void
{
$fileIds = collect($line->meta['custom_fields'] ?? [])
->pluck('file_id')
->filter()
->all();
if ($fileIds === []) {
return;
}
File::query()
->whereIn('id', $fileIds)
->each(fn (File $file) => $this->files->attachOwner($file, $line));
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\File\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* A row per physical file Modules\Core\File\Services\FileService has
* stored — see that migration's own docblock for why `owner_type`/
* `owner_id` are nullable and what `purpose` is for.
*/
class File extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'size' => 'integer',
];
}
public function owner(): MorphTo
{
return $this->morphTo();
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
namespace Modules\Core\File\Services;
use Illuminate\Contracts\Container\Container;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Modules\Core\File\Contracts\FileAdapterInterface;
use Modules\Core\File\Models\File;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Orchestrates the `files` table (ownership, purpose, cleanup — see that
* migration's own docblock) on top of whichever FileAdapterInterface a
* disk resolves to (see Providers\FileServiceProvider's contextual
* binding) — never touches a disk or a raw path itself. Knows nothing
* about custom fields, carts, or orders specifically; every caller
* (3dealer's custom-field upload flow today, some other future
* file-upload need tomorrow) supplies its own `purpose` string and owner
* model, and scopes its own queries by them.
*
* `purpose` also doubles as the storage directory a file lands under
* (store() passes it straight through as the adapter's own $directory) —
* one string to name both, rather than every caller supplying two
* near-identical values for what's really the same distinction ("which
* kind of upload is this").
*/
class FileService
{
public function __construct(
private readonly Container $container,
) {}
public function store(UploadedFile $file, string $purpose, string $disk = 'local'): File
{
$path = $this->adapter($disk)->store($file, $purpose);
return File::create([
'disk' => $disk,
'path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime' => $file->getMimeType(),
'size' => $file->getSize(),
'purpose' => $purpose,
]);
}
/**
* Re-points an existing File at its owner — called once an owner
* actually exists (e.g. a shopper's picked-but-not-yet-added photo
* gets a CartLine the moment it's added to the cart). Never creates a
* second row for the same physical file.
*/
public function attachOwner(File $file, Model $owner): File
{
$file->update([
'owner_type' => $owner->getMorphClass(),
'owner_id' => $owner->getKey(),
]);
return $file;
}
public function retrieve(File $file): StreamedResponse
{
return $this->adapter($file->disk)->retrieve($file->path, $file->original_name);
}
/**
* Same file as retrieve(), forced as a download (Content-Disposition:
* attachment) rather than served inline — for a button distinct from
* a preview link/thumbnail pointing at the same File.
*/
public function download(File $file): StreamedResponse
{
return $this->adapter($file->disk)->download($file->path, $file->original_name);
}
public function exists(File $file): bool
{
return $this->adapter($file->disk)->exists($file->path);
}
public function delete(File $file): void
{
$this->adapter($file->disk)->delete($file->path);
$file->delete();
}
/**
* @return Collection<int, File>
*/
public function list(string $purpose, ?Model $owner = null): Collection
{
return File::query()
->where('purpose', $purpose)
->when($owner, fn ($query) => $query
->where('owner_type', $owner->getMorphClass())
->where('owner_id', $owner->getKey()))
->get();
}
/**
* Deletes every File of the given purpose that has no owner yet and
* is older than $olderThan — the grace period covers a shopper still
* on the page with a picked-but-not-yet-added file. An owned File
* (whatever the owner type) is never touched here; callers that want
* owned files gone too should delete() them explicitly wherever that
* ownership itself ends (e.g. a CartLine being removed).
*
* @return int number of files deleted
*/
public function pruneUnowned(string $purpose, Carbon $olderThan): int
{
$files = File::query()
->where('purpose', $purpose)
->whereNull('owner_type')
->where('created_at', '<', $olderThan)
->get();
foreach ($files as $file) {
$this->delete($file);
}
return $files->count();
}
private function adapter(string $disk): FileAdapterInterface
{
return $this->container->make(FileAdapterInterface::class, ['disk' => $disk]);
}
}
+7
View File
@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Core\File\Http\Controllers\DownloadFileController;
Route::get('files/{file}/download', DownloadFileController::class)
->name('files.download');
@@ -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);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Modules\Core\MigrateImport\Contracts;
use Modules\Core\MigrateImport\DTOs\ImportSpec;
interface Importer
{
public function import(ImportSpec $spec): void;
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\MigrateImport\DTOs;
class ImportSpec
{
/**
* @param ?string $locale The language the export file's own text
* (product titles, descriptions, option names, ...) is actually
* written in — asked of the operator at import time (see
* Command\MigrateImportCommand), since an export has no reliable
* way to declare its own language and it does not necessarily match
* this store's Lunar\Models\Language::getDefault(). Null for a
* source/type this doesn't apply to (e.g. an API-based import with
* no free-text file to attribute a single language to).
*/
public function __construct(
public readonly string $source,
public readonly string $type,
public readonly ?string $filePath = null,
public readonly ?array $credentials = null,
public readonly ?string $locale = null,
) {
}
}
-13
View File
@@ -1,13 +0,0 @@
<?php
namespace Modules\Core\MigrateImport;
use Lunar\Models\Language;
class DefaultLocale
{
public static function code(): string
{
return Language::getDefault()->code;
}
}
-14
View File
@@ -1,14 +0,0 @@
<?php
namespace Modules\Core\MigrateImport;
class ImportSpec
{
public function __construct(
public readonly string $source,
public readonly string $type,
public readonly ?string $filePath = null,
public readonly ?array $credentials = null,
) {
}
}
-8
View File
@@ -1,8 +0,0 @@
<?php
namespace Modules\Core\MigrateImport;
interface Importer
{
public function import(ImportSpec $spec): void;
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\Core\MigrateImport\Jobs;
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\Log;
use Modules\Core\MigrateImport\DTOs\ImportSpec;
use Modules\Core\MigrateImport\Services\ImporterFactory;
class RunMigrateImportJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public readonly ImportSpec $spec,
) {
}
/**
* Just hands off to the right Importer and returns — for Shopify,
* that importer dispatches a job batch instead of importing inline
* (see Shopify\ShopifyExportImporter::import()) and this job's own
* work is done the moment that batch is queued, well before the
* batch's jobs actually run. The SKU backfill that used to happen
* right here, after import() returned, now happens in that batch's
* own then() callback instead — running it here would fire before a
* single product had actually been imported.
*/
public function handle(): void
{
Log::info('Import started', ['source' => $this->spec->source, 'type' => $this->spec->type, 'file' => $this->spec->filePath]);
$importer = ImporterFactory::make($this->spec);
$importer->import($this->spec);
Log::info('Import job complete', ['source' => $this->spec->source]);
}
}
@@ -3,7 +3,6 @@
namespace Modules\Core\MigrateImport\JudgeMe\Resolvers;
use Lunar\Models\Product;
use Lunar\Models\Url;
class ProductResolver
{
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\MigrateImport\JudgeMe;
namespace Modules\Core\MigrateImport\JudgeMe\Services;
use RuntimeException;
@@ -1,12 +1,12 @@
<?php
namespace Modules\Core\MigrateImport\JudgeMe;
namespace Modules\Core\MigrateImport\JudgeMe\Services;
use Throwable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Modules\Core\MigrateImport\Importer;
use Modules\Core\MigrateImport\ImportSpec;
use Modules\Core\MigrateImport\Contracts\Importer;
use Modules\Core\MigrateImport\DTOs\ImportSpec;
use Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver;
use Modules\Core\MigrateImport\Models\ImportMapping;
use Modules\Core\Review\Models\ProductReview;
@@ -22,9 +22,22 @@ class JudgeMeExportImporter implements Importer
public function import(ImportSpec $spec): void
{
foreach ($this->csvReader->read($spec->filePath) as $row) {
$rows = $this->csvReader->read($spec->filePath);
$total = count($rows);
$imported = 0;
Log::info('JudgeMe import: starting', ['total' => $total]);
foreach ($rows as $row) {
$this->importReview($row);
$imported++;
if ($imported % 100 === 0) {
Log::info('JudgeMe import: progress', ['imported' => $imported, 'total' => $total]);
}
}
Log::info('JudgeMe import: finished', ['imported' => $imported, 'total' => $total]);
}
private function importReview(array $row): void
-28
View File
@@ -1,28 +0,0 @@
<?php
namespace Modules\Core\MigrateImport;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RunMigrateImportJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public readonly ImportSpec $spec,
) {
}
public function handle(): void
{
$importer = ImporterFactory::make($this->spec);
$importer->import($this->spec);
}
}
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\MigrateImport\Services;
use Lunar\Models\Language;
/**
* The language every resolver in an import run writes product-facing text
* under (name, description, option names, ...) — NOT necessarily this
* store's own Lunar\Models\Language::getDefault(). An export file has no
* reliable way to declare its own language, and a store's default admin/
* storefront language is an independent fact from whatever language a
* given export happens to be written in — conflating the two (this
* class's own former name, DefaultLocale, said as much) used to silently
* save every imported product's text under the wrong language, invisible
* unless that language was also the one selected while viewing/editing
* the product afterward.
*
* Set once per import run from the operator's own answer (see
* Command\MigrateImportCommand::askImportLocale(), threaded through
* ImportSpec::$locale) at the top of Importer::import() — every resolver
* downstream (ProductAttributeResolver, ProductOptionResolver,
* CollectionResolver, ImportAttributeResolver) calls code() exactly as
* before, unaware anything changed. Falls back to Language::getDefault()
* only when nothing was ever set (e.g. an import path with no locale
* concept of its own — JudgeMe's reviews-only export never calls set()).
*/
class ImportLocale
{
private static ?string $code = null;
public static function set(string $code): void
{
self::$code = $code;
}
public static function code(): string
{
return self::$code ?? Language::getDefault()->code;
}
/**
* A static property outlives a single request only inside a
* long-running worker process, where a queued job for one import
* must not leak its locale into the next — called at the end of
* every Importer::import() run, success or failure.
*/
public static function reset(): void
{
self::$code = null;
}
}
@@ -1,10 +1,12 @@
<?php
namespace Modules\Core\MigrateImport;
namespace Modules\Core\MigrateImport\Services;
use InvalidArgumentException;
use Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter;
use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter;
use Modules\Core\MigrateImport\DTOs\ImportSpec;
use Modules\Core\MigrateImport\Contracts\Importer;
use Modules\Core\MigrateImport\JudgeMe\Services\JudgeMeExportImporter;
use Modules\Core\MigrateImport\Shopify\Services\ShopifyExportImporter;
class ImporterFactory
{
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\MigrateImport\Shopify;
namespace Modules\Core\MigrateImport\Shopify\DTOs;
class ProductGroup
{
@@ -0,0 +1,65 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Jobs;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Lunar\Models\CollectionGroup;
use Lunar\Models\Currency;
use Modules\Core\MigrateImport\Services\ImportLocale;
use Modules\Core\MigrateImport\Shopify\DTOs\ProductGroup;
use Modules\Core\MigrateImport\Shopify\Services\ShopifyExportImporter;
/**
* One product per job — ShopifyExportImporter::import() used to loop over
* every ProductGroup inline, inside RunMigrateImportJob's own single
* process. A large export (hundreds of products, each with variants,
* media downloads and conversions) grew that one process's memory past
* queue:work's --memory limit (see docker-compose.yml), which kills the
* worker mid-job; the container then restarts and the WHOLE import
* re-runs from row one, never actually finishing. Splitting into one
* job per product means memory resets between jobs (a fresh worker
* process picks up each one), and a restart only repeats whichever
* single product was in flight — ImportMapping's own per-handle
* resolve()/record() already makes re-importing the same product cheap
* and idempotent.
*/
class ImportShopifyProductJob implements ShouldQueue
{
use Batchable;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public function __construct(
private readonly ProductGroup $group,
private readonly string $imagesPath,
private readonly CollectionGroup $collectionGroup,
private readonly Currency $currency,
private readonly ?string $locale,
) {
}
public function handle(ShopifyExportImporter $importer): void
{
if ($this->locale !== null) {
ImportLocale::set($this->locale);
}
try {
$importer->importProduct($this->group, $this->imagesPath, $this->collectionGroup, $this->currency);
} finally {
// A queue worker process outlives a single job — this must
// not leak into whichever product the same worker picks up
// next.
ImportLocale::reset();
}
}
}
@@ -7,7 +7,7 @@ use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup;
use Lunar\Models\Product;
use Modules\Core\MigrateImport\DefaultLocale;
use Modules\Core\MigrateImport\Services\ImportLocale;
class CollectionResolver
{
@@ -45,7 +45,7 @@ class CollectionResolver
'collection_group_id' => $group->id,
'attribute_data' => [
'name' => new TranslatedText(collect([
DefaultLocale::code() => new Text($name),
ImportLocale::code() => new Text($name),
])),
],
]);
@@ -8,7 +8,7 @@ use Lunar\Models\Attribute;
use Lunar\Models\AttributeGroup;
use Lunar\Models\Product;
use Lunar\Models\ProductType;
use Modules\Core\MigrateImport\DefaultLocale;
use Modules\Core\MigrateImport\Services\ImportLocale;
class ImportAttributeResolver
{
@@ -40,7 +40,7 @@ class ImportAttributeResolver
[
'attribute_group_id' => $group->id,
'position' => $nextPosition++,
'name' => [DefaultLocale::code() => $definition['label']],
'name' => [ImportLocale::code() => $definition['label']],
'section' => 'main',
'type' => $definition['type'],
'required' => false,
@@ -49,7 +49,7 @@ class ImportAttributeResolver
? ['richtext' => false]
: [],
'system' => false,
'description' => [DefaultLocale::code() => ''],
'description' => [ImportLocale::code() => ''],
],
);
@@ -62,7 +62,7 @@ class ImportAttributeResolver
return AttributeGroup::firstOrCreate(
['attributable_type' => Product::morphName(), 'handle' => 'import'],
[
'name' => [DefaultLocale::code() => 'Additional Details'],
'name' => [ImportLocale::code() => 'Additional Details'],
'position' => 100,
],
);
@@ -6,7 +6,7 @@ use Lunar\FieldTypes\Number;
use Lunar\FieldTypes\Text;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\ProductType;
use Modules\Core\MigrateImport\DefaultLocale;
use Modules\Core\MigrateImport\Services\ImportLocale;
class ProductAttributeResolver
{
@@ -43,7 +43,7 @@ class ProductAttributeResolver
}
return new TranslatedText(collect([
DefaultLocale::code() => new Text($value),
ImportLocale::code() => new Text($value),
]));
}
}
@@ -5,7 +5,7 @@ namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Illuminate\Support\Str;
use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue;
use Modules\Core\MigrateImport\DefaultLocale;
use Modules\Core\MigrateImport\Services\ImportLocale;
class ProductOptionResolver
{
@@ -24,8 +24,8 @@ class ProductOptionResolver
return ProductOption::query()->firstOrCreate(
['handle' => $handle],
[
'name' => [DefaultLocale::code() => $name],
'label' => [DefaultLocale::code() => $name],
'name' => [ImportLocale::code() => $name],
'label' => [ImportLocale::code() => $name],
'shared' => true,
],
);
@@ -46,7 +46,7 @@ class ProductOptionResolver
return $existing ?? ProductOptionValue::create([
'product_option_id' => $option->id,
'name' => [DefaultLocale::code() => $value],
'name' => [ImportLocale::code() => $value],
]);
}
}
@@ -1,8 +1,9 @@
<?php
namespace Modules\Core\MigrateImport\Shopify;
namespace Modules\Core\MigrateImport\Shopify\Services;
use RuntimeException;
use Modules\Core\MigrateImport\Shopify\DTOs\ProductGroup;
class ShopifyCsvReader
{
@@ -1,9 +1,10 @@
<?php
namespace Modules\Core\MigrateImport\Shopify;
namespace Modules\Core\MigrateImport\Shopify\Services;
use Lunar\Models\TaxClass;
use Lunar\Models\ProductOption;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup;
@@ -12,9 +13,13 @@ use Lunar\Models\Language;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
use Lunar\Models\Url;
use Modules\Core\MigrateImport\ImportSpec;
use Modules\Core\MigrateImport\Importer;
use Modules\Core\Catalog\Services\SkuBackfillService;
use Modules\Core\MigrateImport\Services\ImportLocale;
use Modules\Core\MigrateImport\DTOs\ImportSpec;
use Modules\Core\MigrateImport\Shopify\DTOs\ProductGroup;
use Modules\Core\MigrateImport\Contracts\Importer;
use Modules\Core\MigrateImport\Models\ImportMapping;
use Modules\Core\MigrateImport\Shopify\Jobs\ImportShopifyProductJob;
use Modules\Core\MigrateImport\Shopify\Resolvers\AssetResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\BrandResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\CollectionResolver;
@@ -46,9 +51,21 @@ class ShopifyExportImporter implements Importer
) {
}
/**
* Dispatches one ImportShopifyProductJob per ProductGroup instead of
* importing them inline — see that job's own docblock for why (a
* single process holding every group in memory for the whole run
* kept exceeding queue:work's --memory limit on a large export,
* which kills the worker mid-run and restarts the entire import from
* scratch). Bus::batch()'s then() is what SkuBackfillService used to
* run right after this loop — now deferred until every product job
* in the batch has actually finished, since dispatching a batch
* itself returns immediately.
*/
public function import(ImportSpec $spec): void
{
$groups = $this->csvReader->read($spec->filePath);
$total = count($groups);
$imagesPath = dirname($spec->filePath).'/files';
$collectionGroup = CollectionGroup::firstOrCreate(
['handle' => 'shopify'],
@@ -56,12 +73,30 @@ class ShopifyExportImporter implements Importer
);
$currency = Currency::getDefault();
foreach ($groups as $group) {
$this->importProduct($group, $imagesPath, $collectionGroup, $currency);
}
Log::info('Shopify import: dispatching product jobs', ['total' => $total]);
$jobs = collect($groups)->map(fn (ProductGroup $group) => new ImportShopifyProductJob(
$group,
$imagesPath,
$collectionGroup,
$currency,
$spec->locale,
))->all();
Bus::batch($jobs)
->name("Shopify import: {$spec->filePath}")
->then(function () use ($total) {
Log::info('Shopify import: all product jobs finished, backfilling missing SKUs', ['total' => $total]);
app(SkuBackfillService::class)->backfill();
Log::info('Shopify import: finished', ['total' => $total]);
})
->catch(function ($batch, $e) {
Log::error('Shopify import: batch failed', ['error' => $e->getMessage()]);
})
->dispatch();
}
private function importProduct(
public function importProduct(
ProductGroup $group,
string $imagesPath,
CollectionGroup $collectionGroup,
@@ -100,7 +135,12 @@ class ShopifyExportImporter implements Importer
[
'element_type' => $product->getMorphClass(),
'element_id' => $product->id,
'language_id' => Language::getDefault()->id,
// ImportLocale, not Language::getDefault() — same
// reasoning as ProductAttributeResolver et al.: this
// product's handle/slug came from the export, written in
// whatever language the operator said the file is in,
// not necessarily this store's own default language.
'language_id' => Language::where('code', ImportLocale::code())->value('id'),
],
[
'slug' => $group->handle,
@@ -163,6 +203,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 +285,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;
}
@@ -3,22 +3,60 @@
namespace Modules\Core\Order\Filament\Extensions;
use Filament\Actions\BulkAction;
use Filament\Support\Colors\Color;
use Filament\Support\Exceptions\Halt;
use Filament\Tables\Columns\Layout\Panel;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\HtmlString;
use Lunar\Admin\Support\Extending\BaseExtension;
use Lunar\Models\OrderLine;
use Modules\Core\File\Models\File;
/**
* Same fix as OrderActionsExtension, applied to the order lines
* table's "bulk_refund" toolbar action (Lunar\Admin\...\OrderItemsTable::
* getBulkRefundAction()) — see that class's docblock for the underlying
* Filament bug (failureNotification()+failure()+halt() never actually
* sends the notification, because halt()'s Halt exception is caught before
* Filament reaches the code that would send it).
* extendTable() has two unrelated jobs: the "bulk_refund" toolbar-action
* fix (see fixFailureNotification()'s own docblock — a genuine Filament
* bug), and adding a "Custom Fields" entry to each order line's own
* collapsible details dropdown (Lunar\Admin\...\OrderItemsTable::
* getOrderLinesTableColumns()'s Panel — the same one already showing
* stock level, notes, and the price_breakdowns table) — the shopper's
* answers to Product::$custom_fields (a reference photo, personalization
* text, ...), stored on OrderLine.meta by 3dealer's CartController::
* customFieldsMeta() and, until now, never shown anywhere in the admin.
*
* Finds that Panel via $table->getCollapsibleColumnsLayout() — NOT
* $table->getColumns(), which two earlier attempts at this both reached
* for. HasColumns::pushColumns() flattens every Panel/Split into leaf
* columns at table-build time and stores THAT flat list as
* $this->columns (what getColumns() returns); the original nested
* Panel/Stack objects actually used for rendering are kept separately —
* in $this->columnsLayout for a non-collapsible layout component, or
* $this->collapsibleColumnsLayout for one that IS collapsible (this
* order-lines Panel is, via ->collapsible()). So `$column instanceof
* Panel` over getColumns() can never match anything — Panel/Split
* instances simply never appear in that array at all — and a fix built
* on that check silently mutated nothing. A first attempt building a
* brand new Panel and re-calling $table->columns() on top of the
* existing setup fixed nothing either and instead rendered as a stray
* empty extra column outside the dropdown (caught by actually opening
* the order page). Mutates the found Panel's Stack in place via
* Stack::schema(), the one part of both earlier attempts that actually
* worked once the right object was found.
*
* Its own TextColumn rather than reusing the Panel's existing KeyValue:
* KeyValue's own Blade view HTML-escapes every value ({{ $value }}),
* which can't render a clickable link for a file answer.
*/
class OrderItemsTableExtension extends BaseExtension
{
public function extendTable(Table $table): Table
{
if ($table->getCollapsibleColumnsLayout() instanceof Panel) {
$this->addCustomFieldsColumn($table->getCollapsibleColumnsLayout());
}
return $table->toolbarActions(
array_map(
fn ($action) => $action instanceof BulkAction && $action->getName() === 'bulk_refund'
@@ -29,6 +67,88 @@ class OrderItemsTableExtension extends BaseExtension
);
}
private function addCustomFieldsColumn(Panel $panel): void
{
$stack = $panel->getComponents()[0] ?? null;
if ($stack === null) {
return;
}
$stack->schema([
...$stack->getComponents(),
TextColumn::make('custom_fields')
->label('Custom Fields')
->visible(fn (OrderLine $record) => filled($record->meta['custom_fields'] ?? null))
->getStateUsing(fn (OrderLine $record) => $this->renderCustomFields($record))
->html(),
]);
}
/**
* Same table markup/classes as this Panel's own existing KeyValue
* component (Lunar\Admin's price_breakdowns, right above this in the
* dropdown — see lunarpanel::tables.components.key-value) for visual
* consistency, rebuilt here rather than reused: KeyValue's Blade view
* HTML-escapes every value ({{ $value }}), which can't render a
* thumbnail/download link for a file answer.
*/
private function renderCustomFields(OrderLine $record): HtmlString
{
$rows = collect($record->meta['custom_fields'] ?? [])
->map(fn (array $field) => sprintf(
'<tr class="divide-x divide-gray-950/10 dark:divide-white/10"><td class="p-2 font-medium whitespace-nowrap">%s</td><td class="p-2">%s</td></tr>',
e($field['label']),
$field['type'] === 'file' ? $this->fileCell($field) : e($field['value'] ?? ''),
))
->implode('');
return new HtmlString(
'<div class="w-full mt-2 overflow-hidden overflow-x-auto ring-1 ring-inset ring-gray-950/10 dark:ring-white/10 rounded bg-white/70 dark:bg-white/5">'
.'<table class="min-w-full text-xs divide-y divide-gray-950/10 dark:divide-white/10"><tbody class="divide-y divide-gray-950/10 dark:divide-white/10">'
.$rows
.'</tbody></table></div>',
);
}
/**
* A thumbnail (previewable image types only — an inline-signed URL to
* the same File; see FileService::retrieve()) alongside an icon-only
* download link forcing Content-Disposition: attachment (FileService::
* download()) — two separate signed URLs, not one reused with a query
* string appended after signing, since a signature covers the exact
* query parameters present when it was minted.
*/
private function fileCell(array $field): string
{
$file = File::find($field['file_id'] ?? null);
if ($file === null) {
return __('lunarpanel::global.na');
}
$previewUrl = URL::temporarySignedRoute('files.download', now()->addHours(2), ['file' => $file->id]);
$downloadUrl = URL::temporarySignedRoute('files.download', now()->addHours(2), ['file' => $file->id, 'download' => 1]);
$previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
$thumbnail = in_array($file->mime, $previewable, true)
? sprintf(
'<a href="%s" target="_blank" rel="noopener"><img src="%s" alt="" style="width:2.5rem;height:2.5rem;object-fit:cover;border-radius:0.375rem;vertical-align:middle"></a>',
$previewUrl,
$previewUrl,
)
: '';
return sprintf(
'<div style="display:flex;align-items:center;gap:0.5rem">%s<span>%s</span><a href="%s" title="Download" style="color:rgb(%s);display:inline-flex">%s</a></div>',
$thumbnail,
e($file->original_name),
$downloadUrl,
Color::Blue[600],
Blade::render('<x-filament::icon icon="heroicon-o-arrow-down-tray" style="width:1rem;height:1rem"/>'),
);
}
private function fixFailureNotification(BulkAction $action): BulkAction
{
$originalAction = $action->getActionFunction();
@@ -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 = [],
) {}
}

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