Compare commits

...
14 Commits
Author SHA1 Message Date
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
92 changed files with 2405 additions and 588 deletions
+160
View File
@@ -4,6 +4,166 @@ 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.19.0] - 2026-09-18
### Added
- `Modules\Core\Payment\Contracts\RequiresFulfillmentType` — a payment driver can now declare
it only makes sense for one fulfillment type (carrier delivery vs. store pickup), the
payment-side mirror of `Shipping\Contracts\DeclaresFulfillmentType`. `CheckoutService::
getPaymentMethods()` excludes a method whose driver disagrees with the cart's currently
selected shipping method — `OfflinePaymentDriver` ("pay in store") now requires
`store_pickup`, `CashOnDeliveryPaymentDriver` requires `carrier`. Previously every enabled,
configured payment method was offered regardless of shipping choice, so a shopper picking a
courier delivery could still see "Pay in store" (no staff member present to take cash), and a
store-pickup shopper could see cash-on-delivery (meaningless — there is no delivery to collect
payment on). No constraint is imposed before a shipping option is selected.
- `Modules\Core\Payment\Events\PaymentDeferred` — dispatched by any payment driver whose
`Pending` result will never resolve via a later gateway callback (currently only
`CashOnDeliveryPaymentDriver`), distinct from a Stripe-style `Pending` that a webhook will
still resolve. Handled by the new `Modules\Core\Order\Listeners\
MarkOrderPlacedOnDeferredPayment`, which sets `Order::placed_at`, dispatches `OrderPlaced`,
and advances `status` past `awaiting_payment` — without ever touching `Order::paid`, which
still only flips via staff explicitly marking a COD order received.
- `Modules\Core\Order\Services\OrderPaymentResolutionService::resolveDeferredPayment()` — the
status-advance half of the above, reusing the same "advance past `awaiting_payment`" logic a
captured payment already uses.
- `Modules\Core\Checkout\Exceptions\NoShippingAddressException`.
- `Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient::destinations()` — lists available Box
Now lockers (`GET /destinations`), backing a plain, self-hosted locker picker on checkout;
Box Now's own Destination Map JS widget only talks to their Production environment, making it
unusable while developing against Stage credentials.
- `config/shippingCarriers/boxnow.php`: `BOXNOW_PARTNER_ID` — issued alongside Box Now
credentials, consumed only by their client-side map widget, never by `BoxNowClient`'s own
REST authentication.
- A "Tracking history" list under each shipment on the order page (`Shipping\Extensions\
OrderShipmentsExtension`) — every recorded carrier checkpoint, oldest first, not just the
latest status.
- `Modules\Core\Review\Services\ReviewService` and `ReviewEvents\ReviewReplied` — extracted
from `ManageProductReviews`'s inline `$record->update()`, following the write-then-dispatch
pattern used everywhere else.
- `Modules\Core\Catalog\Services\StockService::decrementForOrder()` — extracted from
`DecrementStockOnOrderPlaced`, isolating the atomic stock-decrement SQL and Meilisearch
reindex from the listener itself.
- `Modules\Core\Order\Services\OrderStatusFlow::isValidTransition()` — the single source of
truth for "is this a legal next status," replacing several listeners' own hardcoded "only
fire from status X" comparisons.
### Fixed
- Cash-on-delivery orders were placed but never left `awaiting_payment`, were invisible in
customer order history, never decremented stock, and the storefront's own post-checkout
confirmation could never find them — `CashOnDeliveryPaymentDriver::pay()` returns `Pending`,
which dispatched no event at all, so nothing ever set `Order::placed_at` or advanced
`status`. Fixed by `PaymentDeferred`/`MarkOrderPlacedOnDeferredPayment` above.
- Staff marking a COD order "paid" (`OrderFulfillmentService::markPaid()`) flipped
`Order::paid`/`paid_at` but never recorded a `Transaction` row — no audit trail, and anything
reading `$order->transactions` (paid-amount displays included) saw nothing. Now records a
`capture` transaction via `TransactionRecorder`, the exact call site its own docblock had
already anticipated ("a future admin action ... can write a row the same way").
- A confirmed cash-on-delivery shipment dispatched via ACS or Box Now never actually told the
carrier to collect payment — `ShipmentRequest::$paymentMode`/`$amountToCollect` were defined
on the DTO but no caller ever populated them, permanently dead-coding both carriers' COD
branches (`AcsFulfillmentService`'s `Cod_Ammount`/`Cod_Payment_Way`, Box Now's
`amountToBeCollected`). `OrderViewExtension`'s "Create Shipment" action now derives both from
`OrderStatusFlow::isCod($order)` at dispatch time — never left to staff to remember.
- `Modules\Core\Shipping\Jobs\PollShipmentTrackingJob`: one shipment's tracking lookup failing
(a carrier 500, a malformed parcel response) aborted the rest of that carrier's shipments in
the same batch — now individually caught and reported per shipment.
- Every Box Now delivery request 400'd (`P405`, invalid phone number) for any customer whose
phone was stored in local Greek format rather than full international — `contactNumber` is
now normalized to `+30...` before every request.
- Creating a Box Now shipment 400'd (`P401`/`P402`) whenever `BOXNOW_ORIGIN_LOCATION_ID` or the
sender contact fields were unset — documented and confirmed against a live sandbox account.
- Selecting a Box Now locker at checkout, then making any unrelated address-form edit
afterward (even a delivery-instructions keystroke), silently discarded the locker choice —
`Lunar\Actions\Carts\AddAddress` deletes and recreates the cart's shipping address row on
every save, wiping whatever `meta` a prior save had written onto it.
`CheckoutService::setShippingAddress()` now carries the locker forward across that
recreation; `selectShippingOption()` clears it when switching away from Box Now, so a stale
locker never resurfaces if the shopper switches back later.
`Shipping\Extensions\OrderViewExtension`'s "Box Now locker ID" field is no longer locked
read-only once a customer choice exists — staff can override it.
- Creating a Box Now shipping method 500'd (`Array to string conversion` / invalid JSON insert)
— the vendor `ListShippingMethod` page's `CreateAction` builds its form inline, bypassing
`ShippingMethodResourceExtension`'s translated-name field entirely; `Filament\Pages\
ManageShippingRates`'s method picker and "Shipping Method" table column also queried/sorted
the now-JSON `name` column directly in SQL (`could not identify an ordering operator for type
json`), both resolved app-side instead.
- Creating or editing a Payment Method: `capture_mode` ("Charge immediately" / "Hold now,
charge later") was offered even for a driver with no `authorize()` method at all
(`CashOnDeliveryPaymentDriver`), which would have fatally errored at checkout had "authorize"
ever been selected — now hidden/non-required unless the driver implements
`SupportsAuthorization`. A spurious `validation.required` on the translated Name field, and
every new Payment Method silently saving at `position` 0 regardless of the intended
"last in the list" default — both traced to the same cause: an `Action::schema()` modal only
dehydrates fields backed by a real form component, so `fillForm()`'s defaults for `name`/
`position` were computed but never actually reached the saved record.
- `Modules\Core\Auth\Services\UserOtpService::generateAndSend()` now dispatches `UserCreated`
when a new `User` row is created — this event was previously never dispatched anywhere in
this package at all, despite listeners existing for it.
- Applied a deliberate queueing policy across every Order/Localization/Customer/Payment/
Catalog listener, judged case-by-case on "if the queue stalls for minutes/hours, does this
cause a real functional break, not just cosmetic staleness" — `RecordPaymentTransaction`,
`CompleteOrderOnPickedUp`, `CreateCustomerForUser`, and `DecrementStockOnOrderPlaced` stay
synchronous (a stalled queue would mean a real ordering violation or oversell risk); cache
flushes, activity logging, and carrier-checkpoint-driven fulfillment listeners are now queued.
## [0.18.1] - 2026-09-16
### Added
- `Modules\Core\Payment\Privacy\PaymentDataProvider` — `lunar_transactions` (`card_type`/
`last_four`) and `stripe_payment_intents` were previously uncovered by any Privacy provider.
Pseudonymizes card metadata on erasure (same tax/accounting retention reasoning as
`OrderDataProvider`); deletes the Stripe correlation rows outright, since their only purpose
(resolving an async webhook callback) has already been served by the time an erasure request
runs. No Stripe Customer object exists anywhere in this app to also request deletion of — see
`docs/payments.md` "Reconciliation".
- `Modules\Core\Auth\Privacy\UserSessionDataProvider` — `user_sessions` (`ip_address`,
`user_agent`) was previously uncovered. User-scope only; deleted outright on erasure, no legal
retention argument applies to login-session metadata.
- `Modules\Core\Logging\Privacy\ActivityLogDataProvider` — Spatie's `activity_log` table
(`Modules\Core\Logging\ActivityLogService`, plus several Lunar models' native `LogsActivity`)
durably retained full PII snapshots in `properties` even after the real row was erased
elsewhere. Redacts `properties` by subject (`Customer`/`Address`/`CartAddress`/`OrderAddress`/
`Transaction`) on erasure; deliberately never touches `causer_id`, which is an actor reference,
not PII content. Must run before `AddressDataProvider` in `config('core.privacy.providers')` —
see the class's own docblock.
- `ErasureOutcome::Failed` — a provider throwing an exception is now a genuine, distinct outcome
from `Skipped` (a deliberate no-op), surfaced in the erasure report rather than silently
aborting the request.
### Fixed
- `PrivacyService::completeErasure()` and `ExportDataSubjectJob::handle()` ran every registered
provider through a plain `array_map()` with no per-provider error handling — one provider
throwing aborted the entire request, discarding every other provider's already-computed
result and leaving the request stuck `Pending`/`Failed` with no report at all. Both now catch
per-provider (`PrivacyService::safeErase()`, `ExportDataSubjectJob::safeExport()`), logging the
exception and recording `ErasureOutcome::Failed`/`ProviderExportResult::$error` for that one
provider while every other provider's result is still recorded normally. Verified live:
simulating a throwing provider mid-erasure now correctly completes the request with a mixed
`erased`/`failed`/`erased` report instead of leaving it `Pending` forever.
- `CartDataProvider`/`OrderDataProvider` never covered PII-adjacent keys living in `Cart.meta`/
`Order.meta`/`OrderAddress.meta` — `recovery_consent*`, `payment_method`, `checkout_fingerprint`
(Cart), `terms_accepted*` (Order), and `box_now_locker` (OrderAddress) all survived an erasure
request untouched. Both providers now clear these keys alongside their existing address/
free-text field erasure.
- `CustomerDataProvider::eraseForUser()` left `otp_code`/`otp_expires_at`/`otp_attempts` on an
otherwise-erased `User` row. Now cleared alongside name/email.
- `Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource`'s "Outcome" section
referenced `docs/privacy.md` directly in staff-facing UI text (meaningless to a user with no
repo access) and rendered the per-provider report as raw JSON strings via a `KeyValueEntry`
(the wrong component for a list of structured rows). Replaced with a plain-language
description and a proper `RepeatableEntry` table (Data category / Outcome badge / Reason).
### Changed
- The 5 existing Privacy providers (`CustomerDataProvider`, `AddressDataProvider`,
`OrderDataProvider`, `CartDataProvider`, `ReviewDataProvider`) moved out of
`Modules\Core\Privacy\Providers` into their owning domain module's own `Privacy/` subdirectory
(e.g. `Modules\Core\Order\Privacy\OrderDataProvider`) — `Modules\Core\Privacy` now owns only
the shared contract, request lifecycle, and DTOs/enums. Matters concretely if a module is ever
extracted into its own composer package: the provider that knows how to erase that module's
data now travels with it, rather than being stranded in `Privacy` depending on a package that
no longer ships in this repo. See `docs/privacy.md` for the full reasoning.
## [0.18.0] - 2026-09-16
### Added
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.18.0",
"version": "0.19.0",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
+13 -5
View File
@@ -35,11 +35,19 @@ return [
'privacy' => [
'providers' => [
\Modules\Core\Privacy\Providers\CustomerDataProvider::class,
\Modules\Core\Privacy\Providers\AddressDataProvider::class,
\Modules\Core\Privacy\Providers\OrderDataProvider::class,
\Modules\Core\Privacy\Providers\CartDataProvider::class,
\Modules\Core\Privacy\Providers\ReviewDataProvider::class,
// ActivityLogDataProvider MUST run before AddressDataProvider —
// it resolves which activity_log rows belong to this customer
// (including ones keyed by an Address id) before
// AddressDataProvider hard-deletes those Address rows. See that
// provider's own class docblock.
\Modules\Core\Logging\Privacy\ActivityLogDataProvider::class,
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
\Modules\Core\Order\Privacy\OrderDataProvider::class,
\Modules\Core\Cart\Privacy\CartDataProvider::class,
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
\Modules\Core\Payment\Privacy\PaymentDataProvider::class,
\Modules\Core\Auth\Privacy\UserSessionDataProvider::class,
],
'grace_period_days' => 30,
+14
View File
@@ -13,12 +13,25 @@
|
| Set these via environment variables — never commit real values.
|
| Box Now has two environments (see their Partner API manual, section 2):
| Stage/Sandbox for testing, Production once live. Each has its own
| client_id/client_secret pair and its own base_url/location_api_url —
| there is no shared "switch an env var" flag, since stage credentials
| don't work against the production host or vice versa.
|
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
| lookups (Box Now recommends this over the main
| base URL for those two calls specifically).
| BOXNOW_CLIENT_ID OAuth2 client id.
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
| BOXNOW_PARTNER_ID Numeric partnerId Box Now issues alongside your
| credentials. NOT used for REST API authentication
| (BoxNowClient authenticates with client_id/
| client_secret alone) — this is only consumed by
| the client-side Destination Map widget config
| (_bn_map_widget_config.partnerId), confirmed
| against Box Now's own WooCommerce plugin source.
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
| the pickup origin on every delivery request.
| BOXNOW_SENDER_* Static sender contact details reused on every
@@ -33,6 +46,7 @@ return [
'client_id' => env('BOXNOW_CLIENT_ID'),
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
'partner_id' => env('BOXNOW_PARTNER_ID'),
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
+10 -2
View File
@@ -49,7 +49,8 @@ boboko-test/
app/
Models/
Customer.php ← app-level model, extends Modules\Core\Customer\Models\Customer
User.php ← app-level model, dispatches Modules\Core\Auth\Events\UserCreated
User.php ← app-level model, no $dispatchesEvents needed — core dispatches
UserCreated itself (Modules\Core\Auth\Services\UserOtpService)
Staff.php ← app-level model, extends Modules\Core\Auth\Models\Staff
Lunar/
Extensions/ ← app's own Filament resource extensions (source of truth, wired in PanelServiceProvider)
@@ -264,7 +265,14 @@ php artisan vendor:publish --tag=core-config
'auto_create_customer_for_user' => false,
```
Both listeners guard against the other direction re-triggering: they call `User::withoutEvents(...)` around `firstOrCreate`/save, so pairing a `Customer` never spuriously fires `UserCreated` (and vice versa) even if both directions are somehow active at once.
A guard against the other direction re-triggering is only needed where a real risk exists:
`App\Listeners\CreateUserForCustomerListener` (`boboko-test`, app-level) wraps its
`firstOrCreate` in `User::withoutEvents(...)`, since finding-or-creating a `User` there could
itself fire `UserCreated` and loop back into `CreateCustomerForUser`. `Modules\Core\Customer\
Listeners\CreateCustomerForUser` (core) needs no such guard — it calls a plain
`$model::create([])` on `Customer`, which has no `$dispatchesEvents`/model hooks of its own in
core that could re-trigger anything; the guard belongs only on the side that actually creates a
`User`.
---
+55 -16
View File
@@ -145,23 +145,20 @@ produced had it resolved synchronously.
with no memory of the request that started the payment. Something has to persist enough to
answer "which order/cart does gateway reference X belong to?" between the two calls.
**Read directly from `lunarphp/stripe`'s own source** (`StripePaymentType::authorize()`,
`ProcessStripeWebhook`, `WebhookController`) to see how Lunar itself solves this — confirmed
it does **not** stash a generic opaque blob. It writes the correlating ids as real, typed
columns on `Lunar\Stripe\Models\StripePaymentIntent` (`cart_id`, `order_id`) at the moment the
intent is created/first seen, then reads them back the same way when the webhook arrives:
The precedent for this originally came from reading `lunarphp/stripe`'s own source
(`StripePaymentType::authorize()`, `ProcessStripeWebhook`, `WebhookController`) — that package
solved this the same way, writing the correlating ids as real, typed columns on its own
`StripePaymentIntent` model rather than a generic opaque blob. **`lunarphp/stripe` has since
been removed from this project** in favour of depending on `stripe/stripe-php` directly (see
CHANGELOG.md) — `Modules\Core\Payment\Models\StripePaymentIntent` is now a first-party model
over the same table shape, kept for exactly the same reason.
```php
// ProcessStripeWebhook::handle() — falls back through two real lookups,
// neither of them a generic context blob:
$cart = StripePaymentIntent::where('intent_id', $this->paymentIntentId)->first()?->cart
?: Cart::where('meta->payment_intent', '=', $this->paymentIntentId)->first();
```
**`StripePaymentDriver` follows this exact precedent**: it reads `cart_id`/`order_id` out of
`$context` at `pay()`/`authorize()` time and writes them onto its own `StripePaymentIntent`
row (a table already owned by `lunarphp/stripe`, already shaped for exactly this), then reads
them back the same way in `handleCallback()`. No generic `context` json column, no new table.
**`StripePaymentDriver` follows this pattern**: it reads `cart_id`/`order_id` out of `$context`
at `pay()`/`authorize()` time and writes them onto its own `StripePaymentIntent` row (`src/
Payment/Models/StripePaymentIntent.php`, table `stripe_payment_intents`), then reads them back
the same way in `handleCallback()`. No generic `context` json column beyond what that table
already carries (`context`, added for a different purpose — see that migration's own
docblock), no new table.
### This pattern is per-driver, not a shared table
@@ -176,6 +173,48 @@ a shared generic one.
---
## Reconciliation — a charge that succeeds on Stripe but is never written locally
This app never creates or reuses a Stripe **Customer** object — every PaymentIntent is a
one-off (`StripePaymentDriver::createAndConfirm()`'s own `$params` never includes a `customer`
key), and nothing calls Stripe's Customer API anywhere in this codebase. That's a deliberate
choice, not an oversight: a Customer object only earns its keep if something actually needs it
(saved/reusable payment methods, subscriptions, Stripe-side lifetime-value grouping across
orders) — none of which exist in this checkout flow today. Creating one anyway would just be
more PII sitting on a third party's servers for no functional benefit, and it would become
another cross-reference a future Payment privacy provider has to account for (detaching/
deleting the Customer on erasure, not just the local PaymentIntent row). If a real feature
needs it later (e.g. "save my card"), add it then, scoped to that feature.
The gap this creates: with no Customer object and no other identifying field previously sent
to Stripe, a PaymentIntent that succeeds on Stripe's side but is never written to our own DB
(e.g. a database outage at exactly the wrong moment, between Stripe confirming the charge and
`rememberIntent()`'s insert) would be **untraceable** back to a cart or order — nothing to
search Stripe's dashboard by except amount, timestamp, and card last-4.
**Fix**: `createAndConfirm()` now sets `metadata: ['cart_id' => ..., 'order_id' => ...]`
(`array_filter()`-ed, since `order_id` isn't known yet at initial `pay()`/`authorize()` time —
same null-coalesce `rememberIntent()` already does) on every PaymentIntent. This is metadata
only, visible on Stripe's own dashboard/API for manual reconciliation — it does not create a
Customer object and does not change anything about how `handleCallback()`/webhook correlation
works (that still goes through `stripe_payment_intents`, per "Async resolution" above). It's
purely a recovery aid for the case where our own write never happened at all.
---
## GDPR erasure/export
`Modules\Core\Payment\Privacy\PaymentDataProvider` covers `lunar_transactions`
(`card_type`/`last_four`) and `stripe_payment_intents` — see `docs/privacy.md` for the full
right-of-erasure/right-of-access design. Pseudonymizes card metadata on erasure (same
tax/accounting retention reasoning `Order`'s own provider uses) and deletes the Stripe
correlation rows outright, since their only purpose — resolving an async webhook callback, see
"Async resolution" above — has already been served by the time an erasure request runs. No
Stripe Customer object exists anywhere in this app (see "Reconciliation" above) for this
provider to also request deletion of.
---
## Explicitly out of scope for this pass
- **`Checkout`/`Order` wiring** — how `Checkout` calls into `Payment`, how `Order`/`Checkout`
+47 -13
View File
@@ -61,6 +61,17 @@ implements that method as a no-op — `ErasureOutcome::Skipped` with a reason fo
payload for export (e.g. `AddressDataProvider::eraseForUser()`, since addresses belong to a
Customer, not an individual).
A provider implementation lives inside the module that owns the data it erases/exports, under
that module's own `Privacy/` subdirectory (e.g. `Modules\Core\Order\Privacy\OrderDataProvider`,
`Modules\Core\Customer\Privacy\CustomerDataProvider`) — never inside `Modules\Core\Privacy`
itself, which only owns the shared contract (`Contracts\PersonalDataProvider`), the request
lifecycle (`Services\PrivacyManager`/`PrivacyService`), and the DTOs/enums every provider
returns. This mirrors how this codebase already handles other cross-cutting-but-domain-specific
code (e.g. a resource's own `Filament/Extensions/` subdirectory) — and matters concretely if a
module is ever extracted into its own composer package (see `docs/modules.md`): the provider
that knows how to erase that module's data must travel with it, not get stranded in `Privacy`
depending on a package that no longer ships in this repo.
A module registers by adding its provider class to `config('core.privacy.providers')` — the
same shape as Lunar's own `config('lunar.search.indexers')` model→indexer map:
@@ -68,11 +79,11 @@ same shape as Lunar's own `config('lunar.search.indexers')` model→indexer map:
// config/core.php
'privacy' => [
'providers' => [
\Modules\Core\Privacy\Providers\CustomerDataProvider::class,
\Modules\Core\Privacy\Providers\AddressDataProvider::class,
\Modules\Core\Privacy\Providers\OrderDataProvider::class,
\Modules\Core\Privacy\Providers\CartDataProvider::class,
\Modules\Core\Privacy\Providers\ReviewDataProvider::class,
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
\Modules\Core\Order\Privacy\OrderDataProvider::class,
\Modules\Core\Cart\Privacy\CartDataProvider::class,
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
// A future module just adds its own provider here.
],
],
@@ -113,17 +124,40 @@ from the record staff (or the person themselves) look up.
## Providers shipped in core
| Provider | `name()` | Covers | Customer-scope | User-scope |
|---|---|---|---|---|
| `CustomerDataProvider` | `customer` | `lunar_customers`, and separately the `User`'s own name/email | Erases the account's own fields only | Erases that User's name/email only, and detaches them from every linked Customer |
| `AddressDataProvider` | `addresses` | `lunar_addresses` | Erased (deleted outright) | Skipped — belongs to a Customer, not an individual |
| `OrderDataProvider` | `orders` | `lunar_orders`, `lunar_order_addresses` | **Pseudonymized, not erased** — see below | Skipped — belongs to a Customer, not an individual |
| `CartDataProvider` | `carts` | `lunar_cart_addresses` | Erased | Skipped — belongs to a Customer, not an individual |
| `ReviewDataProvider` | `reviews` | `product_reviews` | Skipped — authored by an individual, not a business account | Pseudonymized by matching `reviewer_email`; rating/title/body text kept |
| Provider | `name()` | Lives in | Covers | Customer-scope | User-scope |
|---|---|---|---|---|---|
| `ActivityLogDataProvider` | `activity_log` | `Modules\Core\Logging\Privacy` | `activity_log` (Spatie) for subject types `Customer`/`Address`/`CartAddress`/`OrderAddress`/`Transaction` | **Pseudonymized** — `properties` redacted, who/what/when metadata kept | Skipped — `causer_id` is an actor reference, not PII content; see below |
| `CustomerDataProvider` | `customer` | `Modules\Core\Customer\Privacy` | `lunar_customers`, and separately the `User`'s own name/email/OTP fields | Erases the account's own fields only | Erases that User's name/email/OTP fields only, and detaches them from every linked Customer |
| `AddressDataProvider` | `addresses` | `Modules\Core\Customer\Privacy` | `lunar_addresses` | Erased (deleted outright) | Skipped — belongs to a Customer, not an individual |
| `OrderDataProvider` | `orders` | `Modules\Core\Order\Privacy` | `lunar_orders`, `lunar_order_addresses`, and their `meta` (`terms_accepted*`, `payment_method`, `box_now_locker`) | **Pseudonymized, not erased** — see below | Skipped — belongs to a Customer, not an individual |
| `CartDataProvider` | `carts` | `Modules\Core\Cart\Privacy` | `lunar_cart_addresses`, and `lunar_carts.meta` (`recovery_consent*`, `payment_method`, `checkout_fingerprint`) | Erased | Skipped — belongs to a Customer, not an individual |
| `ReviewDataProvider` | `reviews` | `Modules\Core\Review\Privacy` | `product_reviews` | Skipped — authored by an individual, not a business account | Pseudonymized by matching `reviewer_email`; rating/title/body text kept |
| `PaymentDataProvider` | `payments` | `Modules\Core\Payment\Privacy` | `lunar_transactions` (`card_type`/`last_four`), `stripe_payment_intents` | **Pseudonymized** — card metadata cleared, correlation rows deleted, amounts/statuses kept | Skipped — belongs to Customer-owned orders, not individual users |
| `UserSessionDataProvider` | `sessions` | `Modules\Core\Auth\Privacy` | `user_sessions` (`ip_address`, `user_agent`) | Skipped — belongs to an individual User, not a business account | Erased (deleted outright) |
`CustomerDataProvider` is the one provider that implements both scopes meaningfully, and keeps
them from touching each other — see the class docblock for the full reasoning.
### `activity_log` is redacted by subject, never by causer
`Modules\Core\Logging\ActivityLogService` (plus several Lunar models' own native `use
LogsActivity` — `Customer`, `CartAddress`, `OrderAddress`, `Transaction`) durably retains a full
snapshot of whatever it logged in `properties`, completely independent of the real row it
describes — erasing/pseudonymizing a `Customer`/`Address`/`Order`/etc. elsewhere does nothing to
this table on its own. `ActivityLogDataProvider::eraseForCustomer()` redacts `properties` on
every row whose **subject** (not causer) resolves back to that customer, across all five
PII-bearing subject types.
It deliberately never touches `causer_id` — the causer is "who performed this action," not PII
content, and erasing it would defeat the audit trail's own purpose. `eraseForUser()` is
therefore a no-op: a `User` appears in this table only as a causer, never as subject content, so
there's nothing to redact from the User side alone.
**Ordering dependency**: `ActivityLogDataProvider` must run *before* `AddressDataProvider` in
`config('core.privacy.providers')` — it resolves which `activity_log` rows are keyed by an
`Address` id while those Address rows still exist; `AddressDataProvider` then hard-deletes them.
Reversing the order would make matching those rows impossible once the addresses are gone.
**`ReviewDataProvider` needs review.** It moved from Customer-scope to User-scope on the
reasoning that authorship is a personal attribute, not a business-account attribute — but this
hasn't been fully validated against how reviews are actually attributed in this codebase. The
@@ -169,7 +203,7 @@ no one — a business-account erasure must never block anyone's access.
`Modules\Core\Auth\Services\UserOtpService` — nothing else changes).
```php
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
$service = app(PrivacyService::class);
@@ -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);
}
}
+13
View File
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Auth\Events\UserCreated;
use Modules\Core\Auth\Exceptions\OtpThrottledException;
use Modules\Core\Auth\Mail\UserOtpMail;
@@ -75,6 +76,18 @@ class UserOtpService
$model = config('auth.providers.users.model');
$user = $model::firstOrCreate(['email' => $email]);
// wasRecentlyCreated is Eloquent's own "did firstOrCreate() just
// INSERT, or did it find an existing row" flag — the only reliable
// way to tell them apart from firstOrCreate()'s return value alone.
// Without this check, a genuinely new signup never fired
// UserCreated at all (this class's own docblock claimed the
// Customer/User pairing cascade "already triggers" here, which was
// false as written — see Modules\Core\Customer\Listeners\
// CreateCustomerForUser, which depends entirely on this event).
if ($user->wasRecentlyCreated) {
Event::dispatch(new UserCreated($user));
}
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
$user->otp_code = $code;
+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));
}
}
@@ -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
{
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Order;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
/**
* The one place ProductVariant::stock is written as a result of an order —
* previously this lived entirely inside Modules\Core\Order\Listeners\
* DecrementStockOnOrderPlaced, a listener with no Service behind it at
* all, even though stock (the column, its invariants — "never negative",
* "only in_stock variants") is fundamentally a Catalog concern, not an
* Order one. That listener is now a thin caller of this class, matching
* how every other module's event reaction delegates its actual write to
* a Service (e.g. Modules\Core\Order\Listeners\RecordPaymentTransaction
* -> Modules\Core\Order\Services\TransactionRecorder).
*
* Only decrements for `purchasable === 'in_stock'` variants — 'always' and
* 'backorder' variants are deliberately allowed to sell past (or without
* regard to) their stock count already (see ProductVariant::
* canBeFulfilledAtQuantity()), so decrementing their stock would just make
* that column an inaccurate, decreasingly-negative number with no purchasing
* consequence. Only `OrderLine::type === 'physical'` lines are considered —
* a digital line has no stock to decrement (ProductVariant::getType()).
*
* A single UPDATE per variant (`DB::table(...)->update()` with a raw
* expression), not a read-then-write on the Eloquent model — avoids a
* lost-update race between two orders decrementing the same variant
* concurrently, and skips Modules\Core\Catalog\Services\ProductIndexer::
* stock's staleness gap for the DB value itself even though the search
* index still only refreshes on the next reindex event/nightly job (see
* that class's own docblock).
*
* Never lets stock go negative (`GREATEST(stock - qty, 0)` via a raw
* expression) — an order can still be placed against a variant whose stock
* was already fully consumed by another concurrent order (Lunar has no
* stock-reservation step at cart/checkout time), so this is a best-effort
* count, not a hard inventory guarantee.
*/
class StockService
{
public function decrementForOrder(Order $order): void
{
$lines = $order->lines()
->where('type', 'physical')
->where('purchasable_type', ProductVariant::morphName())
->get(['purchasable_id', 'quantity']);
if ($lines->isEmpty()) {
return;
}
foreach ($lines as $line) {
DB::table((new ProductVariant())->getTable())
->where('id', $line->purchasable_id)
->where('purchasable', 'in_stock')
->update([
'stock' => DB::raw('GREATEST(stock - '.(int) $line->quantity.', 0)'),
]);
}
$productIds = ProductVariant::whereIn('id', $lines->pluck('purchasable_id'))
->pluck('product_id')
->unique();
Product::whereIn('id', $productIds)->get()->each->searchable();
}
}
@@ -0,0 +1,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\
@@ -3,7 +3,7 @@
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
use Modules\Core\Privacy\Jobs\EraseDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest;
+23 -13
View File
@@ -102,9 +102,21 @@ class CorePlugin implements Plugin
CustomerResource::class => CustomerErasureRelationsExtension::class,
]);
Product::macro('reviews', function (): HasMany {
/** @var Product $this */
return $this->hasMany(ProductReview::class);
// resolveRelationUsing(), not macro() — Illuminate\Database\Eloquent\
// Model does not use the Macroable trait in this Laravel version, so
// Product::macro(...)/Customer::macro(...)/$userModel::macro(...)
// silently fall through to Model::__callStatic(), which instantiates
// the model and tries to call the method as a real one, hitting
// newQuery()->getConnection() — this crashes every console command
// and every request, since CorePlugin::register() runs during
// provider registration, before the DB connection is configured
// ("Call to a member function connection() on null"). This bit us
// once already; resolveRelationUsing() is Eloquent's real, intended,
// connection-free extension point for exactly this (Order::
// resolveRelationUsing('shipments', ...) in ShippingServiceProvider
// already uses it correctly).
Product::resolveRelationUsing('reviews', function (Product $product): HasMany {
return $product->hasMany(ProductReview::class);
});
// Customer::erasureRequests()/exportRequests() and the User-model
@@ -115,24 +127,22 @@ class CorePlugin implements Plugin
// Customer or a User (see docs/privacy.md "User-scope vs Customer-scope"),
// so this is a MorphMany built by hand rather than a bare Eloquent
// convention lookup.
Customer::macro('erasureRequests', function (): MorphMany {
/** @var Customer $this */
return $this->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
Customer::resolveRelationUsing('erasureRequests', function (Customer $customer): MorphMany {
return $customer->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
});
Customer::macro('exportRequests', function (): MorphMany {
/** @var Customer $this */
return $this->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id');
Customer::resolveRelationUsing('exportRequests', function (Customer $customer): MorphMany {
return $customer->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id');
});
$userModel = config('auth.providers.users.model');
$userModel::macro('erasureRequests', function (): MorphMany {
return $this->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
$userModel::resolveRelationUsing('erasureRequests', function ($user): MorphMany {
return $user->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
});
$userModel::macro('exportRequests', function (): MorphMany {
return $this->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id');
$userModel::resolveRelationUsing('exportRequests', function ($user): MorphMany {
return $user->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id');
});
LunarStaff::addActivitylogExcept([
@@ -6,6 +6,19 @@ use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Customer as CustomerContract;
use Modules\Core\Auth\Events\UserCreated;
/**
* Deliberately NOT queued, even though UserCreated (requesting an OTP
* code) and the login that follows it (submitting the code) are normally
* separate requests with a real time gap between them — that gap is not
* a guarantee this code controls. A busy/backed-up queue (a deploy in
* progress, a crashed worker, a traffic spike) could make this job run
* AFTER the shopper has already logged in and something has read
* $user->latestCustomer() (Modules\Core\Customer\Services\
* CustomerAccountService), silently returning null for a legitimately
* paired user with no retry anywhere to catch it. Kept synchronous so the
* Customer always exists by the time UserCreated's dispatch call returns,
* regardless of queue health.
*/
class CreateCustomerForUser
{
public function handle(UserCreated $event): void
@@ -2,6 +2,7 @@
namespace Modules\Core\Customer\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Lunar\Models\Address;
use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted;
@@ -18,8 +19,11 @@ use Modules\Core\Logging\ActivityLogService;
* passed through explicitly on every call, since these events are
* `web`-guard-caused, not `staff`-guard — see ActivityLogService's own
* docblock for why that parameter exists.
*
* Queued — a pure audit-log write with no same-request reader; the
* shopper's own request doesn't need this to complete before responding.
*/
class LogCustomerAccountActivity
class LogCustomerAccountActivity implements ShouldQueue
{
public function __construct(
private readonly ActivityLogService $activityLog,
@@ -1,14 +1,14 @@
<?php
namespace Modules\Core\Privacy\Providers;
namespace Modules\Core\Customer\Privacy;
use Lunar\Models\Address;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\DTOs\UserSubject;
/**
* A customer's saved addresses (lunar_addresses) — belong to the Customer
@@ -1,14 +1,14 @@
<?php
namespace Modules\Core\Privacy\Providers;
namespace Modules\Core\Customer\Privacy;
use Lunar\Models\Customer;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\DTOs\UserSubject;
/**
* The Customer record itself (lunar_customers) and, on the User side, the User's
@@ -108,6 +108,13 @@ class CustomerDataProvider implements PersonalDataProvider
$user->update([
'name' => null,
'email' => "erased-user-{$user->id}@example.invalid",
// A live OTP code left on an otherwise-erased row is a residual
// secret tied to an identity that no longer exists here — clear
// it alongside name/email rather than leaving it to expire on
// its own 10-minute window.
'otp_code' => null,
'otp_expires_at' => null,
'otp_attempts' => 0,
]);
return new ProviderErasureResult('customer', ErasureOutcome::Erased);
+4 -2
View File
@@ -2,6 +2,8 @@
namespace Modules\Core\Export;
use Closure;
/**
* One column in a CsvWriter schema: a header label plus a closure that pulls this
* column's value out of one record. The closure doesn't care what shape a record
@@ -12,10 +14,10 @@ namespace Modules\Core\Export;
final class CsvColumn
{
/**
* @param \Closure(mixed): (string|int|float|null) $value
* @param Closure(mixed):((string|int|float|null)) $value
*/
public function __construct(
public readonly string $header,
public readonly \Closure $value,
public readonly Closure $value,
) {}
}
@@ -2,12 +2,21 @@
namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated;
use Modules\Core\Localization\Services\LanguageCache;
class FlushLanguageCache
/**
* Queued — the only reader of this cache is Modules\Core\Localization\
* Middleware\LocaleMiddleware on a LATER storefront request, never the
* same admin request that edited/created/deleted the Language row (that
* request redirects to a fresh page read straight from the DB, not this
* cache). A few seconds of eventual consistency before the queue worker
* picks this up is an acceptable trade for not blocking the admin save.
*/
class FlushLanguageCache implements ShouldQueue
{
public function __construct(private readonly LanguageCache $languages) {}
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted;
@@ -15,8 +16,13 @@ use Spatie\TranslationLoader\LanguageLine;
* `group`/`key` (the old group's cached array never gets told a row left it).
* This listener flushes every group+locale combination touched by either the
* old or new state so nothing can remain stale.
*
* Queued — this cache backs `__('storefront.*')` lookups on a LATER
* storefront request, never the same admin request that just edited the
* translation (Filament redirects to a fresh index read straight from the
* DB, not this cache). Safe to let a queue worker pick up.
*/
class FlushTranslationCache
class FlushTranslationCache implements ShouldQueue
{
public function handle(TranslationCreated|TranslationUpdated|TranslationDeleted $event): void
{
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Arr;
use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted;
@@ -9,7 +10,12 @@ use Modules\Core\Localization\Events\TranslationUpdated;
use Modules\Core\Logging\ActivityLogService;
use Spatie\TranslationLoader\LanguageLine;
class LogTranslationActivity
/**
* Queued — a pure audit-log write with no same-request reader (Filament
* redirects to a fresh index page after save, which doesn't read the
* activity log at all).
*/
class LogTranslationActivity implements ShouldQueue
{
public function __construct(
private readonly ActivityLogService $activityLog,
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Localization\Events\LanguageUpdated;
use Spatie\TranslationLoader\LanguageLine;
@@ -12,8 +13,15 @@ use Spatie\TranslationLoader\LanguageLine;
* getTranslationsForGroup($newCode, ...) would silently return nothing for
* that locale even though the translated content still exists. Move the
* text.{oldCode} key to text.{newCode} on every affected row instead.
*
* Queued — this walks every LanguageLine row containing the old locale key
* with no upper bound, and nothing in the same request needs the migration
* to have completed before responding (a rename is a rare admin action;
* the affected storefront locale is briefly unavailable until the queue
* worker finishes, the same window that already exists before this
* listener runs at all).
*/
class MigrateTranslationsForRenamedLanguage
class MigrateTranslationsForRenamedLanguage implements ShouldQueue
{
public function handle(LanguageUpdated $event): void
{
@@ -0,0 +1,148 @@
<?php
namespace Modules\Core\Logging\Privacy;
use Lunar\Models\Address;
use Lunar\Models\Cart;
use Lunar\Models\CartAddress;
use Lunar\Models\Customer;
use Lunar\Models\Order;
use Lunar\Models\OrderAddress;
use Lunar\Models\Transaction;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\DTOs\UserSubject;
use Spatie\Activitylog\Models\Activity;
/**
* Spatie's own activity_log table (Modules\Core\Logging\ActivityLogService,
* plus several Lunar models' native `use LogsActivity` — Customer,
* CartAddress, OrderAddress, Transaction) durably retains a full snapshot
* of whatever it logged in `properties` (created/updated/deleted
* attributes, including a before/after diff on update), completely
* independent of the real row it describes. Erasing/pseudonymizing
* Customer/Address/CartAddress/OrderAddress/Transaction elsewhere (see
* Customer\Privacy\CustomerDataProvider, Customer\Privacy\
* AddressDataProvider, Cart\Privacy\CartDataProvider, Order\Privacy\
* OrderDataProvider, Payment\Privacy\PaymentDataProvider) does nothing to
* this table — a full copy of the old PII survives here regardless.
*
* Redacts by SUBJECT only, never by `causer_id` — the causer is "who did
* this," not PII content, and erasing it would erode the audit trail's own
* purpose (see this provider's own eraseForUser(), which is a deliberate
* no-op). Genuinely Customer-scope only: every subject type here
* (Customer, Address, CartAddress, OrderAddress, Transaction) resolves to
* a business account via its own chain (Address/Customer directly;
* CartAddress via cart_id -&gt; Cart.customer_id; OrderAddress/Transaction
* via order_id -&gt; Order.customer_id) — none of it is a User's own data on
* its own.
*
* MUST run before Customer\Privacy\AddressDataProvider in
* config('core.privacy.providers') — that provider hard-deletes Address
* rows, and once gone there is no way to re-derive which activity_log
* rows (subject_type = Address) belonged to this customer. This provider
* resolves that address id list itself, before anything deletes it.
*/
class ActivityLogDataProvider implements PersonalDataProvider
{
private const REDACTED = '[redacted]';
public function name(): string
{
return 'activity_log';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$activities = Activity::query()
->where(fn ($query) => $this->scopeToCustomer($query, $subject->customerId))
->get();
return new ProviderExportResult('activity_log', $activities->map(fn (Activity $activity) => [
'id' => $activity->id,
'log_name' => $activity->log_name,
'description' => $activity->description,
'subject_type' => $activity->subject_type,
'subject_id' => $activity->subject_id,
'event' => $activity->event,
'properties' => $activity->properties?->toArray(),
'created_at' => $activity->created_at?->toIso8601String(),
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('activity_log', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$affected = Activity::query()
->where(fn ($query) => $this->scopeToCustomer($query, $subject->customerId))
->get();
if ($affected->isEmpty()) {
return new ProviderErasureResult('activity_log', ErasureOutcome::Skipped, 'No activity log entries for this customer.');
}
foreach ($affected as $activity) {
$activity->update(['properties' => $this->redact($activity->properties?->toArray() ?? [])]);
}
return new ProviderErasureResult(
'activity_log',
ErasureOutcome::Pseudonymized,
'PII-bearing properties redacted on matching audit log entries; who/what/when metadata (log_name, subject, event, timestamp, causer) retained for audit integrity.'
);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult(
'activity_log',
ErasureOutcome::Skipped,
'A User only ever appears here as causer_id (who performed an action), not as the PII content of a log entry — redacting that would erode the audit trail\'s own record of who acted.'
);
}
private function scopeToCustomer($query, int $customerId): void
{
$customerMorph = (new Customer)->getMorphClass();
$addressMorph = (new Address)->getMorphClass();
$cartAddressMorph = (new CartAddress)->getMorphClass();
$orderAddressMorph = (new OrderAddress)->getMorphClass();
$transactionMorph = (new Transaction)->getMorphClass();
$addressIds = Address::where('customer_id', $customerId)->pluck('id');
$cartIds = Cart::where('customer_id', $customerId)->pluck('id');
$cartAddressIds = CartAddress::whereIn('cart_id', $cartIds)->pluck('id');
$orderIds = Order::where('customer_id', $customerId)->pluck('id');
$orderAddressIds = OrderAddress::whereIn('order_id', $orderIds)->pluck('id');
$transactionIds = Transaction::whereIn('order_id', $orderIds)->pluck('id');
$query
->where(fn ($q) => $q->where('subject_type', $customerMorph)->where('subject_id', $customerId))
->orWhere(fn ($q) => $q->where('subject_type', $addressMorph)->whereIn('subject_id', $addressIds))
->orWhere(fn ($q) => $q->where('subject_type', $cartAddressMorph)->whereIn('subject_id', $cartAddressIds))
->orWhere(fn ($q) => $q->where('subject_type', $orderAddressMorph)->whereIn('subject_id', $orderAddressIds))
->orWhere(fn ($q) => $q->where('subject_type', $transactionMorph)->whereIn('subject_id', $transactionIds));
}
/**
* @param array<string, mixed> $properties
* @return array<string, mixed>
*/
private function redact(array $properties): array
{
return array_map(function ($value) {
if (is_array($value)) {
return array_map(fn () => self::REDACTED, $value);
}
return self::REDACTED;
}, $properties);
}
}
@@ -2,12 +2,19 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderDispatched;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/**
* Queued — see Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment's
* own docblock: ShipmentStatusUpdatedByCarrier comes from a scheduled
* polling job, not a webhook, so nothing needs this to complete before a
* request returns.
*
* The automatic half of "Dispatched" — the manual fallback is the staff
* "Update Status" action (Modules\Core\Shipping\Extensions\
* OrderViewExtension). Listens to ShipmentStatusUpdatedByCarrier directly,
@@ -19,14 +26,17 @@ use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
* carrier that skips straight there without a distinct collection
* checkpoint.
*
* Guarded to only fire from 'ready_for_dispatch' — a late/duplicate
* checkpoint, or an order the manual action already advanced, is a
* silent no-op.
* Guarded by OrderStatusFlow::isValidTransition() rather than a hardcoded
* "only fire from 'ready_for_dispatch'" comparison — the single source of
* truth for the status graph lives there, not duplicated here. A
* late/duplicate checkpoint, or an order the manual action already
* advanced, is a silent no-op either way.
*/
class AdvanceFulfillmentOnCarrierCheckpoint
class AdvanceFulfillmentOnCarrierCheckpoint implements ShouldQueue
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void
@@ -38,7 +48,7 @@ class AdvanceFulfillmentOnCarrierCheckpoint
$order = $event->shipmentInfo->shipment->order;
if (! $order || $order->status !== 'ready_for_dispatch') {
if (! $order || ! $this->flow->isValidTransition($order, 'dispatched')) {
return;
}
@@ -2,10 +2,18 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter;
/**
* Queued — OrderDelivered is only ever dispatched from Modules\Core\Order\
* Listeners\DeriveOrderDeliveredFromShipment, itself queued (see that
* class's own docblock: the triggering ShipmentStatusUpdatedByCarrier
* comes from a scheduled polling job, not a request with a page waiting
* on the result).
*
* Writes `status` to 'delivered' once a carrier confirms delivery, rather
* than jumping straight to 'completed'. Carrier orders get a return
* window between delivery and completion (see Modules\Core\Order\
@@ -20,21 +28,23 @@ use Modules\Core\Order\Services\OrderStatusWriter;
* OrderDelivered — deriving "was this delivered" and acting on it by
* writing `status` are deliberately two different listeners.
*
* Guarded to only fire from 'dispatched' — a duplicate/late Delivered
* Guarded by OrderStatusFlow::isValidTransition() rather than a hardcoded
* "only fire from 'dispatched'" comparison. A duplicate/late Delivered
* checkpoint, or an order a manual action already moved past, is a
* silent no-op.
* silent no-op either way.
*/
class AdvanceFulfillmentOnDelivered
class AdvanceFulfillmentOnDelivered implements ShouldQueue
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
public function handle(OrderDelivered $event): void
{
$order = $event->order;
if ($order->status !== 'dispatched') {
if (! $this->flow->isValidTransition($order, 'delivered')) {
return;
}
@@ -2,13 +2,8 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Order\Support\OrderStatus;
use Modules\Core\Order\Services\OrderPaymentResolutionService;
use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded;
@@ -17,38 +12,24 @@ use Modules\Core\Payment\Events\PaymentRefunded;
* Registered against PaymentCaptured, PaymentAuthorized, AND
* PaymentRefunded (see OrderServiceProvider).
*
* PaymentCaptured writes both Order::paid/paid_at (via
* OrderStatusWriter::markPaid()) AND advances `status` out of
* 'awaiting_payment' to the next step in the order's flow (see
* OrderStatusFlow::nextOptions()) — re-confirmed with the user: a
* captured payment, manual or via Stripe's webhook, should never leave an
* order sitting at 'awaiting_payment'. Only fires when status is still
* exactly 'awaiting_payment', so a duplicate/delayed capture event never
* regresses an order staff already advanced further. PaymentAuthorized
* only marks paid — an authorization is not yet captured funds, so
* status stays put until the actual capture.
*
* A refund still moves `status` (returned -> refunded/partially_refunded)
* — refunds are a normal step in Modules\Core\Order\Services\
* OrderStatusFlow's own sequence, unlike captures. Derives
* Refunded/PartialRefund from Modules\Core\Order\Support\OrderStatus::
* payment() — the existing, unchanged derived-enum logic, reused rather
* than reimplemented.
*
* Reads $event->context['order_id'] to find which Order this outcome
* belongs to — Payment has no concept of an Order.
*
* Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set.
* Never fires from the PaymentRefunded path — a refund can only ever
* happen after an order was already placed.
* A thin reactor — resolves which Order this outcome belongs to (Payment
* has no concept of an Order, so this reads $event->context['order_id'])
* and hands off to Modules\Core\Order\Services\
* OrderPaymentResolutionService for the actual decisions: whether to mark
* the order paid, whether/how far to advance `status`, and what a refund
* does to it. See that service's own docblock, and its methods' own
* docblocks, for the full business reasoning (re-confirmed with the
* user): a captured payment, manual or via Stripe's webhook, should
* never leave an order sitting at 'awaiting_payment'; an authorization
* only marks paid, since it isn't yet captured funds; a refund is a
* normal step in the order's own status sequence, unlike a capture.
*
* Deliberately does NOT react to PaymentVoided.
*/
class ApplyResolvedPaymentStatus
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
private readonly OrderPaymentResolutionService $resolution,
) {}
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
@@ -62,58 +43,11 @@ class ApplyResolvedPaymentStatus
$order = Order::findOrFail($orderId);
if ($event instanceof PaymentRefunded) {
$this->applyRefund($order, $event);
$this->resolution->resolveRefund($order, $event::class);
return;
}
$wasPlaced = ! blank($order->placed_at);
$this->writer->markPaid($order, $event::class);
if ($event instanceof PaymentCaptured) {
$this->advancePastAwaitingPayment($order, $event);
}
if (! $wasPlaced) {
$order->update(['placed_at' => $order->placed_at ?? now()]);
Event::dispatch(new OrderPlaced($order));
}
}
private function advancePastAwaitingPayment(Order $order, PaymentCaptured $event): void
{
if ($order->status !== 'awaiting_payment') {
return;
}
$next = $this->flow->nextOptions($order);
$target = array_key_first($next);
if ($target !== null) {
$this->writer->write($order, $target, $event::class);
}
}
/**
* Requires the refund Transaction row to already exist (Modules\Core\
* Order\Listeners\RecordPaymentTransaction must run first — see
* OrderServiceProvider's listener registration order for
* PaymentRefunded), so the relation is refreshed here rather than
* trusted from a possibly-stale $order instance.
*/
private function applyRefund(Order $order, PaymentRefunded $event): void
{
$order->load('transactions');
$target = match (OrderStatus::payment($order)) {
PaymentStatus::Refunded => 'refunded',
PaymentStatus::PartialRefund => 'partially_refunded',
default => null,
};
if ($target !== null && $order->status !== $target) {
$this->writer->write($order, $target, $event::class);
}
$this->resolution->resolveCaptureOrAuthorization($order, $event::class, isCapture: $event instanceof PaymentCaptured);
}
}
@@ -4,9 +4,19 @@ namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderCompleted;
use Modules\Core\Order\Events\OrderPickedUp;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter;
/**
* Deliberately NOT queued — OrderPickedUp is dispatched from a staff
* Filament action (see OrderFulfillmentService::markPickedUp()), and the
* page staff are looking at needs to show `status` as 'completed'
* immediately after they click, not still 'picked_up' until a queue
* worker catches up. Unlike ShipmentStatusUpdatedByCarrier's listeners
* (queued — dispatched from a scheduled polling job with no page waiting
* on the result), this one has a real same-request/same-page-load
* dependency.
*
* The store-pickup mirror of AdvanceFulfillmentOnDelivered — reacts to
* OrderPickedUp (dispatched by Modules\Core\Order\Services\
* OrderFulfillmentService::markPickedUp() the moment staff confirm the
@@ -15,20 +25,22 @@ use Modules\Core\Order\Services\OrderStatusWriter;
* business design — unlike the carrier branch, there is no 'delivered'
* intermediate value on this path.
*
* Guarded to only fire from 'picked_up' — a duplicate dispatch (e.g. a
* stale page re-submitting the action) is a silent no-op.
* Guarded by OrderStatusFlow::isValidTransition() rather than a hardcoded
* "only fire from 'picked_up'" comparison. A duplicate dispatch (e.g. a
* stale page re-submitting the action) is a silent no-op either way.
*/
class CompleteOrderOnPickedUp
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
public function handle(OrderPickedUp $event): void
{
$order = $event->order;
if ($order->status !== 'picked_up') {
if (! $this->flow->isValidTransition($order, 'completed')) {
return;
}
@@ -2,63 +2,38 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
use Modules\Core\Catalog\Services\StockService;
use Modules\Core\Checkout\Events\OrderPlaced;
/**
* The only place ProductVariant::stock is written as a result of an order —
* fires once per order regardless of capture_mode/driver, same reasoning as
* Modules\Core\Order\Notifications\OrderPlacedNotification: OrderPlaced is
* dispatched exactly once, from the one place an order's placed_at
* actually gets set (Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus),
* so this can't double-decrement across a capture/authorize/refund sequence
* the way listening to PaymentCaptured directly could.
* Deliberately NOT queued — unlike this codebase's other queued side
* effects (cache flushes, audit logs, search reindexes), a stalled queue
* here isn't just cosmetic staleness: it widens the window in which
* another order can be accepted against stock this order already
* committed (Lunar has no stock-reservation step at checkout time to
* begin with — see StockService's own "Never lets stock go negative"
* note — so some oversell race already exists, but a queue stall of
* minutes/hours extends that window far past the sub-millisecond one a
* synchronous write leaves open). StockService's atomic `GREATEST(stock -
* qty, 0)` SQL still protects against a LOST update between two orders
* decrementing the same variant concurrently; running it synchronously
* keeps the exposure window as small as possible on top of that.
*
* Only decrements for `purchasable === 'in_stock'` variants — 'always' and
* 'backorder' variants are deliberately allowed to sell past (or without
* regard to) their stock count already (see ProductVariant::
* canBeFulfilledAtQuantity()), so decrementing their stock would just make
* that column an inaccurate, decreasingly-negative number with no purchasing
* consequence. Only `OrderLine::type === 'physical'` lines are considered —
* a digital line has no stock to decrement (ProductVariant::getType()).
*
* A single UPDATE per variant (`DB::table(...)->decrement()`), not a
* read-then-write on the Eloquent model — avoids a lost-update race between
* two orders decrementing the same variant concurrently, and skips
* Modules\Core\Catalog\Services\ProductIndexer::stock's staleness gap for
* the DB value itself even though the search index still only refreshes on
* the next reindex event/nightly job (see that class's own docblock).
*
* Never lets stock go negative (`GREATEST(stock - qty, 0)` via a raw
* expression) — an order can still be placed against a variant whose stock
* was already fully consumed by another concurrent order (Lunar has no
* stock-reservation step at cart/checkout time), so this is a best-effort
* count, not a hard inventory guarantee.
* The actual decrement logic lives in Modules\Core\Catalog\Services\
* StockService — stock (the column, its invariants) is a Catalog concern,
* not an Order one; this listener is just the "an order was placed"
* trigger. Fires once per order regardless of capture_mode/driver, same
* reasoning as Modules\Core\Order\Notifications\OrderPlacedNotification:
* OrderPlaced is dispatched exactly once, from the one place an order's
* placed_at actually gets set (Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus), so this can't double-decrement across a
* capture/authorize/refund sequence the way listening to PaymentCaptured
* directly could.
*/
class DecrementStockOnOrderPlaced
{
public function handle(OrderPlaced $event): void
{
$lines = $event->order->lines()
->where('type', 'physical')
->where('purchasable_type', ProductVariant::morphName())
->get(['purchasable_id', 'quantity']);
foreach ($lines as $line) {
DB::table((new ProductVariant())->getTable())
->where('id', $line->purchasable_id)
->where('purchasable', 'in_stock')
->update([
'stock' => DB::raw('GREATEST(stock - '.(int) $line->quantity.', 0)'),
]);
}
$productIds = ProductVariant::whereIn('id', $lines->pluck('purchasable_id'))
->pluck('product_id')
->unique();
Product::whereIn('id', $productIds)->get()->each->searchable();
app(StockService::class)->decrementForOrder($event->order);
}
}
@@ -2,17 +2,23 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/**
* Queued — ShipmentStatusUpdatedByCarrier is dispatched from
* Modules\Core\Shipping\Jobs\PollShipmentTrackingJob, a scheduled job with
* no HTTP request waiting on a response, so there is no same-request
* timing pressure for any of this event's listeners (unlike a webhook).
*
* Translates a carrier tracking checkpoint into OrderDelivered — the event
* OrderDeliveredNotification (via NotificationRegistry) actually listens
* to. Kept separate from the notification itself so the "is this checkpoint
* a delivery" filtering doesn't leak into notification code.
*/
class DeriveOrderDeliveredFromShipment
class DeriveOrderDeliveredFromShipment implements ShouldQueue
{
public function handle(ShipmentStatusUpdatedByCarrier $event): void
{
@@ -2,20 +2,28 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/**
* Queued — see Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment's
* own docblock: ShipmentStatusUpdatedByCarrier comes from a scheduled
* polling job, not a webhook.
*
* Wires TrackingStatus::Failed to the 'delivery_failed' status for the
* first time — previously an unused enum case. Guarded to only fire from
* 'dispatched': a stale/duplicate checkpoint, or an order a manual action
* already moved past, is a silent no-op.
* first time — previously an unused enum case. Guarded by
* OrderStatusFlow::isValidTransition() rather than a hardcoded "only fire
* from 'dispatched'" comparison. A stale/duplicate checkpoint, or an
* order a manual action already moved past, is a silent no-op either way.
*/
class MarkDeliveryFailedOnCarrierCheckpoint
class MarkDeliveryFailedOnCarrierCheckpoint implements ShouldQueue
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void
@@ -26,7 +34,7 @@ class MarkDeliveryFailedOnCarrierCheckpoint
$order = $event->shipmentInfo->shipment->order;
if (! $order || $order->status !== 'dispatched') {
if (! $order || ! $this->flow->isValidTransition($order, 'delivery_failed')) {
return;
}
@@ -0,0 +1,66 @@
<?php
namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Services\OrderPaymentResolutionService;
use Modules\Core\Payment\Events\PaymentDeferred;
/**
* Deliberately NOT queued — the storefront's own post-checkout
* confirmation page (Modules\Core\Checkout\Http\Controllers\
* CheckoutController::orderStatus()/confirmation(), per docs/checkout.md)
* looks up the order by placed_at being set immediately after
* initiatePayment() returns; a stalled queue would show the shopper a
* blank/failed confirmation for an order that, in the database, already
* exists and was genuinely placed. Same reasoning as
* DecrementStockOnOrderPlaced staying synchronous — this is the listener
* that makes DecrementStockOnOrderPlaced fire at all for a COD order (see
* OrderServiceProvider: OrderPlaced => DecrementStockOnOrderPlaced),
* so queueing this one would just move the same stock-oversell risk one
* hop earlier.
*
* A thin reactor, same shape as ApplyResolvedPaymentStatus — the actual
* decisions ("this order counts as placed the moment a deferred-payment
* driver resolves, independent of Order::paid" and "such an order also
* has nothing to sit at awaiting_payment for") live in PaymentDeferred's
* and OrderPaymentResolutionService::resolveDeferredPayment()'s own
* docblocks, re-confirmed with the user; this only extracts the order id
* and applies both, guarded against a duplicate/replayed event the same
* way OrderPaymentResolutionService::resolveCaptureOrAuthorization() is.
*
* Without the status advance below, a COD order was left sitting at
* 'awaiting_payment' forever — placed_at/OrderPlaced alone fixed order
* visibility and stock decrement, but nothing ever moved `status` off its
* initial value, since resolveCaptureOrAuthorization() only does that for
* an actual capture. Caught and fixed after the fact.
*/
class MarkOrderPlacedOnDeferredPayment
{
public function __construct(
private readonly OrderPaymentResolutionService $resolution,
) {}
public function handle(PaymentDeferred $event): void
{
$orderId = $event->context['order_id'] ?? null;
if ($orderId === null) {
return;
}
$order = Order::findOrFail($orderId);
$this->resolution->resolveDeferredPayment($order, self::class);
if (! blank($order->placed_at)) {
return;
}
$order->update(['placed_at' => now()]);
Event::dispatch(new OrderPlaced($order));
}
}
@@ -10,6 +10,17 @@ use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Events\PaymentVoided;
/**
* Deliberately NOT queued, despite looking like a pure audit-trail write
* with no same-request reader — Modules\Core\Providers\
* OrderServiceProvider registers this to run BEFORE
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus for
* PaymentRefunded specifically, because that listener's refund-status
* resolution reads the Transaction row this listener just wrote. Queueing
* this would run it asynchronously while ApplyResolvedPaymentStatus (sync)
* proceeds immediately, almost certainly executing before the queued job
* and silently breaking that read. See OrderServiceProvider's own
* registration-order comment.
*
* Writes the Transaction row for a successful payment outcome — the
* "record what happened" half of reacting to Payment's events, separate
* from Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus's "update
@@ -2,11 +2,16 @@
namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderPaidChanged;
use Modules\Core\Order\Events\OrderStatusChanged;
use Modules\Core\Order\Services\OrderStatusTransitionRecorder;
/**
* Queued — a pure history-log write with no same-request reader anywhere
* in the codebase (no Filament page renders order_status_transitions
* immediately after a status change; it's browsed later, if at all).
*
* The one place order_status_transitions rows actually get written —
* listens to OrderStatusChanged (every write of the single `status`
* column, via Modules\Core\Order\Services\OrderStatusWriter::write()) and
@@ -15,7 +20,7 @@ use Modules\Core\Order\Services\OrderStatusTransitionRecorder;
* one consistent audit trail entry ('paid', with a null from_status)
* rather than a second, separate table.
*/
class RecordStatusTransition
class RecordStatusTransition implements ShouldQueue
{
public function __construct(
private readonly OrderStatusTransitionRecorder $recorder,
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace Modules\Core\Order\Privacy;
use Lunar\Models\Order;
use Lunar\Models\OrderAddress;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\DTOs\UserSubject;
/**
* Orders and order addresses (lunar_orders, lunar_order_addresses) belong to the
* Customer (business account) via customer_id, not to an individual User, so this
* is Customer-scope only. They're also subject to legal retention (tax/accounting
* law generally requires invoices be kept for several years — GDPR Art. 17(3)(b)
* explicitly allows this to override an erasure request). eraseForCustomer()
* therefore pseudonymizes the PII-bearing free-text fields in place rather than
* deleting the order: totals, line items, tax data, and the order itself all
* remain intact and auditable.
*
* Also covers PII-adjacent keys living in Order.meta and OrderAddress.meta —
* Modules\Core\Checkout\Services\CheckoutService::initiatePayment() writes
* terms_accepted/terms_accepted_at/terms_accepted_policy_version/payment_method
* onto Order.meta, and Modules\Core\Shipping\Carriers\BoxNow\
* BoxNowFulfillmentService writes the shopper's chosen box_now_locker onto
* OrderAddress.meta — neither of which the free-text column erase above ever
* touched. Kept Customer-scope, consistent with Order/OrderAddress themselves.
*/
class OrderDataProvider implements PersonalDataProvider
{
private const ORDER_META_KEYS = [
'terms_accepted',
'terms_accepted_at',
'terms_accepted_policy_version',
'payment_method',
];
private const ADDRESS_META_KEYS = [
'box_now_locker',
];
public function name(): string
{
return 'orders';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$orders = Order::where('customer_id', $subject->customerId)->with('addresses')->get();
return new ProviderExportResult('orders', $orders->map(fn (Order $order) => [
'id' => $order->id,
'reference' => $order->reference,
'status' => $order->status,
'total' => $order->total?->decimal(),
'placed_at' => $order->placed_at?->toIso8601String(),
'meta' => $this->onlyKeys((array) $order->meta, self::ORDER_META_KEYS),
'addresses' => $order->addresses->map(fn (OrderAddress $address) => [
'type' => $address->type,
'first_name' => $address->first_name,
'last_name' => $address->last_name,
'line_one' => $address->line_one,
'city' => $address->city,
'postcode' => $address->postcode,
'contact_email' => $address->contact_email,
'contact_phone' => $address->contact_phone,
'meta' => $this->onlyKeys((array) $address->meta, self::ADDRESS_META_KEYS),
])->all(),
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('orders', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$orders = Order::where('customer_id', $subject->customerId)->with('addresses')->get();
if ($orders->isEmpty()) {
return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'No orders for this customer.');
}
foreach ($orders as $order) {
$order->update([
'customer_reference' => null,
'notes' => null,
'meta' => $this->withoutKeys((array) $order->meta, self::ORDER_META_KEYS),
]);
foreach ($order->addresses as $address) {
$address->update([
'title' => null,
'first_name' => 'Erased',
'last_name' => 'Customer',
'company_name' => null,
'tax_identifier' => null,
'line_one' => null,
'line_two' => null,
'line_three' => null,
'delivery_instructions' => null,
'contact_email' => null,
'contact_phone' => null,
'meta' => $this->withoutKeys((array) $address->meta, self::ADDRESS_META_KEYS),
]);
}
}
return new ProviderErasureResult(
'orders',
ErasureOutcome::Pseudonymized,
'Order and address free-text fields and PII-bearing meta keys cleared; order records, totals, and line items retained for legal/tax record-keeping.'
);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'Orders belong to Customer accounts, not individual users.');
}
/**
* @param array<string, mixed> $meta
* @param array<int, string> $keys
* @return array<string, mixed>
*/
private function onlyKeys(array $meta, array $keys): array
{
return array_intersect_key($meta, array_flip($keys));
}
/**
* @param array<string, mixed> $meta
* @param array<int, string> $keys
* @return array<string, mixed>
*/
private function withoutKeys(array $meta, array $keys): array
{
foreach ($keys as $key) {
unset($meta[$key]);
}
return $meta;
}
}
@@ -8,6 +8,8 @@ use Modules\Core\Order\DTOs\OrderFulfillmentResult;
use Modules\Core\Order\Events\OrderPickedUp;
use Modules\Core\Order\Events\OrderReadyForDispatch;
use Modules\Core\Order\Events\OrderReadyForPickup;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Throwable;
@@ -31,6 +33,7 @@ class OrderFulfillmentService
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
private readonly TransactionRecorder $transactions,
) {}
public function markReady(Order $order): OrderFulfillmentResult
@@ -121,6 +124,39 @@ class OrderFulfillmentService
return OrderFulfillmentResult::failure('This order cannot be marked paid right now.');
}
// canMarkPaid() only ever returns true for an order whose payment
// method resolves to the cash-on-delivery DRIVER (see
// OrderStatusFlow::isCod(), which checks PaymentMethod::driver,
// never the merchant-chosen `type` slug directly — a store could
// name that method "cod", "pay-on-delivery", anything). Such an
// order never runs through Payment's pay()/authorize() flow at
// checkout, so nothing else records a Transaction for it. Money
// changes hands right here, at this click, so this is the one
// place that write can happen; there is no earlier Payment event
// to hang it off of the way Modules\Core\Order\Listeners\
// RecordPaymentTransaction does for a gateway driver. See
// TransactionRecorder's own docblock — it already anticipated
// exactly this "manually-triggered ... from Filament" call site.
//
// $driver below is the payment method's own `type` slug (whatever
// the merchant named it, e.g. 'cash-on-delivery' or 'cod') —
// Transaction.driver's established meaning everywhere else in this
// codebase (see RecordPaymentTransaction/TransactionRecorder's own
// docblocks) is that type key, never the underlying driver CLASS.
// No fallback guess here: CheckoutService::initiatePayment() always
// writes Order.meta['payment_method'] before charging, and
// canMarkPaid() already guarantees this order got that far.
$this->transactions->record(
$order,
type: 'capture',
driver: (string) $order->meta['payment_method'],
result: new PaymentResult(
status: PaymentResultStatus::Succeeded,
reference: 'cod-manual-'.$order->id,
amount: $order->total,
),
);
$this->writer->markPaid($order, self::class.'::markPaid');
return OrderFulfillmentResult::success('Order marked as paid.');
@@ -0,0 +1,108 @@
<?php
namespace Modules\Core\Order\Services;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* The actual business decisions behind reacting to a payment outcome —
* previously these lived entirely inside Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus, a listener with no Service behind it, even
* though "should this order be marked paid," "should its status advance,
* and to what," and "what does a refund do to status" are all genuine
* decisions about Order state, not side effects of Payment's own events.
* That listener is now a thin reactor: extract the order id from
* $event->context, load the Order, call this service, done.
*
* See ApplyResolvedPaymentStatus's own docblock for the full business
* reasoning (re-confirmed with the user) behind each rule enforced here —
* this class only re-documents what's specific to the decision logic
* itself, not the "why" already recorded there.
*/
class OrderPaymentResolutionService
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
/**
* A captured or authorized payment: marks the order paid (capture
* only — an authorization is not yet captured funds), advances status
* out of 'awaiting_payment' (capture only), and marks the order
* placed if this is the first payment outcome it's seen.
*/
public function resolveCaptureOrAuthorization(Order $order, string $causeClass, bool $isCapture): void
{
$wasPlaced = ! blank($order->placed_at);
$this->writer->markPaid($order, $causeClass);
if ($isCapture) {
$this->advancePastAwaitingPayment($order, $causeClass);
}
if (! $wasPlaced) {
$order->update(['placed_at' => $order->placed_at ?? now()]);
Event::dispatch(new OrderPlaced($order));
}
}
/**
* A deferred-capture payment (currently only cash-on-delivery — see
* Payment\Events\PaymentDeferred's own docblock): no money has moved,
* so unlike resolveCaptureOrAuthorization() this never calls
* $writer->markPaid() — Order::paid stays false until staff explicitly
* mark it received. But per OrderStatusFlow's own docblock, payment
* method never affects the status SEQUENCE at all — a COD order has
* nothing to "await" at checkout (no payment attempt happens), so
* 'awaiting_payment' is simply the wrong first status for it. Reuses
* the exact same advancePastAwaitingPayment() a capture uses, since
* the status-sequence logic itself doesn't differ by payment method,
* only whether `paid` also flips alongside it.
*/
public function resolveDeferredPayment(Order $order, string $causeClass): void
{
$this->advancePastAwaitingPayment($order, $causeClass);
}
/**
* Requires the refund Transaction row to already exist (Modules\Core\
* Order\Listeners\RecordPaymentTransaction must run first — see
* OrderServiceProvider's listener registration order for
* PaymentRefunded), so the relation is refreshed here rather than
* trusted from a possibly-stale $order instance.
*/
public function resolveRefund(Order $order, string $causeClass): void
{
$order->load('transactions');
$target = match (OrderStatus::payment($order)) {
PaymentStatus::Refunded => 'refunded',
PaymentStatus::PartialRefund => 'partially_refunded',
default => null,
};
if ($target !== null && $order->status !== $target) {
$this->writer->write($order, $target, $causeClass);
}
}
private function advancePastAwaitingPayment(Order $order, string $causeClass): void
{
if ($order->status !== 'awaiting_payment') {
return;
}
$next = $this->flow->nextOptions($order);
$target = array_key_first($next);
if ($target !== null) {
$this->writer->write($order, $target, $causeClass);
}
}
}
+16
View File
@@ -133,6 +133,22 @@ class OrderStatusFlow
return ! $order->paid && $this->isCod($order);
}
/**
* Whether moving $order to $to is a valid transition from its CURRENT
* status — the single source of truth for "is this a legal next step,"
* so a caller reacting to an external event (a carrier tracking
* checkpoint, a staff action) doesn't need to hardcode its own "only
* fire from status X" guard duplicating what nextOptions() already
* knows. See e.g. Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnCarrierCheckpoint, which used to compare
* $order->status to a literal 'ready_for_dispatch' inline instead of
* asking this class.
*/
public function isValidTransition(Order $order, string $to): bool
{
return array_key_exists($to, $this->nextOptions($order));
}
private function label(string $status): string
{
return (string) str($status)->replace('_', ' ')->title();
@@ -0,0 +1,38 @@
<?php
namespace Modules\Core\Payment\Contracts;
/**
* Optional contract a payment driver implements to declare it only makes
* sense for one fulfillment type — the payment-side mirror of
* Modules\Core\Shipping\Contracts\DeclaresFulfillmentType. Two concrete
* cases exist today, both hardcoded facts about the driver rather than a
* merchant configuration choice:
* - OfflinePaymentDriver ("pay in store," cash-in-hand) only makes
* sense when the shopper collects in person — meaningless for a
* carrier delivery, where no staff member is present to take the
* cash.
* - CashOnDeliveryPaymentDriver only makes sense when a carrier
* physically hands over the parcel and collects payment at that
* moment — meaningless for store pickup, which already has
* OfflinePaymentDriver for exactly that in-person moment.
*
* A driver that doesn't implement this (Stripe, bank transfer) has no
* fulfillment-type constraint — offered regardless of the cart's
* currently selected shipping method's fulfillment type.
*
* Read by Modules\Core\Checkout\Services\CheckoutService::
* getPaymentMethods(), which excludes a method whose driver implements
* this and disagrees with the cart's current fulfillment type (via
* Modules\Core\Shipping\Support\FulfillmentType::resolve() on the
* currently selected ShippingMethod). A cart with no shipping option
* selected yet imposes no constraint — every method is offered until a
* fulfillment type is actually known.
*/
interface RequiresFulfillmentType
{
/**
* @return 'carrier'|'store_pickup'
*/
public function requiredFulfillmentType(): string;
}
@@ -5,9 +5,11 @@ namespace Modules\Core\Payment\Drivers;
use Illuminate\Support\Str;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentDeferred;
/**
* Cash-on-delivery/cash-on-pickup — the shopper pays staff in person, at
@@ -31,20 +33,46 @@ use Modules\Core\Payment\Enums\PaymentResultStatus;
* marking it received (Modules\Core\Order\Services\
* OrderFulfillmentService::markPaid()), offered by the single "Update
* Status" action at any time, independent of status.
*
* Despite returning Pending, this order IS fully placed the moment pay()
* returns — unlike a Stripe 3-D Secure Pending, nothing will ever resolve
* this into a later PaymentCaptured/PaymentAuthorized (COD has no gateway
* callback at all). Without PaymentDeferred, no listener ever set
* Order::placed_at for a COD order: invisible in customer order history,
* no stock decrement (Modules\Core\Order\Listeners\
* DecrementStockOnOrderPlaced only reacts to Checkout\Events\OrderPlaced),
* and the storefront's own post-checkout confirmation could never find it
* — a real bug, not a hypothetical, caught and fixed after the fact. See
* PaymentDeferred's own docblock for the full reasoning.
*/
class CashOnDeliveryPaymentDriver implements Configurable, SupportsPay
class CashOnDeliveryPaymentDriver implements Configurable, SupportsPay, RequiresFulfillmentType
{
public function isConfigured(): bool
{
return true;
}
/**
* "On delivery" is the operative word — a carrier physically hands
* over the parcel and collects payment at that moment. Meaningless
* for store pickup, which already has OfflinePaymentDriver for the
* equivalent in-person moment.
*/
public function requiredFulfillmentType(): string
{
return 'carrier';
}
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{
return new PaymentResult(
$result = new PaymentResult(
status: PaymentResultStatus::Pending,
reference: 'cod-'.Str::uuid(),
amount: $amount,
);
PaymentDeferred::dispatch($type, $result, $context);
return $result;
}
}
+12 -1
View File
@@ -5,6 +5,7 @@ namespace Modules\Core\Payment\Drivers;
use Illuminate\Support\Str;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
@@ -25,7 +26,7 @@ use Modules\Core\Payment\Events\PaymentCaptured;
* none) purely so PaymentCaptured, and anything downstream keying on it,
* have something to identify this attempt by.
*/
class OfflinePaymentDriver implements Configurable, SupportsPay
class OfflinePaymentDriver implements Configurable, SupportsPay, RequiresFulfillmentType
{
/**
* Always true — no external dependency to be missing.
@@ -35,6 +36,16 @@ class OfflinePaymentDriver implements Configurable, SupportsPay
return true;
}
/**
* Cash-in-hand requires a staff member physically present to take the
* payment — meaningless for a carrier delivery, where no such person
* exists at handoff.
*/
public function requiredFulfillmentType(): string
{
return 'store_pickup';
}
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{
$reference = 'offline-'.Str::uuid();
@@ -115,6 +115,25 @@ class StripePaymentDriver implements
$params['payment_method'] = $data['payment_method'];
}
// Reconciliation safety net: this app never creates a Stripe Customer
// object and attaches no other identifying info to the PaymentIntent
// (see docs/payments.md "Reconciliation" for the full reasoning), so
// without this, a charge that succeeds on Stripe's side but is never
// written to our own DB (e.g. a DB outage at exactly the wrong
// moment) would be untraceable back to a cart/order — nothing to
// search Stripe's dashboard by except amount/time/card last-4.
// array_filter() drops order_id when it's not yet known (still null
// in $context at initial pay()/authorize() time — see
// rememberIntent()'s own null-coalesce for the same case).
$metadata = array_filter([
'cart_id' => $context['cart_id'] ?? null,
'order_id' => $context['order_id'] ?? null,
]);
if ($metadata !== []) {
$params['metadata'] = $metadata;
}
try {
$paymentIntent = $this->stripe->getClient()->paymentIntents->create($params);
} catch (ApiErrorException $e) {
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\Payment\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* "This order has no money to collect yet, and never will via a gateway
* callback — nothing further will ever resolve this PaymentResult's
* Pending status into Captured/Authorized." Distinct from a Stripe-style
* Pending (3-D Secure, still resolving asynchronously via a later webhook
* or client-side confirmation) — that case correctly dispatches nothing
* yet, since PaymentCaptured/PaymentAuthorized WILL still follow once
* resolved.
*
* Dispatched by Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver::
* pay() the moment it returns Pending — a COD order is fully placed at
* that instant, with reconciliation (Order::paid) happening independently,
* anywhere from same-day to months later, entirely outside any gateway's
* knowledge. Any other current or future "deferred capture, no gateway
* callback" driver dispatches this the same way, rather than each
* reinventing its own "mark placed" event.
*
* Handled by Modules\Core\Order\Listeners\MarkOrderPlacedOnDeferredPayment
* — sets ONLY Order::placed_at and fires Checkout\Events\OrderPlaced.
* Deliberately does not touch Order::paid/paid_at (see
* OrderStatusWriter::markPaid(), the only path that ever does) or create a
* Transaction row (RecordPaymentTransaction listens to PaymentCaptured/
* PaymentAuthorized/PaymentVoided/PaymentRefunded only — correctly not
* this event, since no money has moved and there is nothing to record
* yet).
*/
class PaymentDeferred
{
use Dispatchable;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public readonly string $type,
public readonly PaymentResult $result,
public readonly array $context = [],
) {}
}
@@ -3,10 +3,13 @@
namespace Modules\Core\Payment\Filament\Resources;
use Filament\Actions\Action;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Utilities\Get;
use InvalidArgumentException;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
@@ -14,6 +17,7 @@ use Filament\Tables\Table;
use Illuminate\Support\Facades\Event;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\SupportsAuthorization;
use Modules\Core\Payment\Events\PaymentMethodsReordered;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
use Modules\Core\Payment\Models\PaymentMethod;
@@ -60,9 +64,9 @@ class PaymentMethodResource extends Resource
{
protected static ?string $model = PaymentMethod::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-credit-card';
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
protected static string | \UnitEnum | null $navigationGroup = 'Settings';
protected static ?string $modelLabel = 'Payment Method';
@@ -144,7 +148,31 @@ class PaymentMethodResource extends Resource
])
->default('pay')
->live()
->required(),
// Only meaningful for a driver that actually implements
// SupportsAuthorization — CheckoutService::initiatePayment()
// calls $driver->authorize() when capture_mode is
// "authorize", which fatals on a driver missing that method
// entirely (e.g. CashOnDeliveryPaymentDriver, which only
// ever implements SupportsPay: the shopper pays staff in
// person, at an unknown future moment — there is no
// "hold now, settle later" operation to offer for that at
// all). Hidden rather than merely disabled, since a
// hidden field is also excluded from validation/dehydration
// — required() below would otherwise still block saving.
->visible(fn (Get $get) => static::driverSupportsAuthorization($get('driver')))
->required(fn (Get $get) => static::driverSupportsAuthorization($get('driver'))),
// Every OTHER fillForm() value not backed by a real component
// here is silently dropped — an Action::schema() modal only
// dehydrates fields present in its own schema, unlike a
// resource's form(); ListPaymentMethods::getHeaderActions()'s
// CreateAction::fillForm() used to set 'position' this same
// way and it never reached PaymentMethodService::create(),
// so every new method saved with the column's raw DB default
// (0) regardless of what fillForm() computed. Hidden here
// purely so it actually dehydrates; the table's own
// reorderable('position') drag-and-drop remains the real
// staff-facing way to change it afterward.
Hidden::make('position'),
];
}
@@ -153,9 +181,23 @@ class PaymentMethodResource extends Resource
return Select::make('driver')
->label('Driver')
->options(fn () => app(PaymentDriverRegistry::class)->labels())
->live()
->required();
}
private static function driverSupportsAuthorization(?string $driver): bool
{
if (! $driver) {
return true;
}
try {
return app(PaymentDriverRegistry::class)->resolve($driver) instanceof SupportsAuthorization;
} catch (InvalidArgumentException) {
return true;
}
}
public static function getPages(): array
{
return [
@@ -180,7 +222,7 @@ class PaymentMethodResource extends Resource
->icon('heroicon-o-pencil-square')
->schema(static::getFormComponents())
->fillForm(fn (PaymentMethod $record) => $record->only([
'name', 'type', 'driver', 'capture_mode',
'name', 'type', 'driver', 'capture_mode', 'position',
]))
->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data));
}
@@ -2,8 +2,10 @@
namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Lunar\Models\Language;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentMethodService;
@@ -15,12 +17,21 @@ class ListPaymentMethods extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
CreateAction::make()
->schema(PaymentMethodResource::getFormComponents())
->fillForm(fn () => [
'position' => (PaymentMethod::max('position') ?? 0) + 1,
'enabled' => false,
'data' => [],
// TranslatedText's own default() (getLanguageDefaults())
// never reaches this mounted action's initial state —
// unlike a resource's own form(), an Action::schema()
// modal starts from exactly what fillForm() returns, so
// `name` was landing as null rather than the expected
// per-locale array, and every locale's sub-input
// silently failed to bind to it (required() on the
// default locale then correctly rejected the null).
'name' => Language::pluck('code')->mapWithKeys(fn (string $code) => [$code => ''])->all(),
])
// Every PaymentMethod write goes through PaymentMethodService
// — see PaymentMethodResource's own docblock — so this
@@ -2,6 +2,7 @@
namespace Modules\Core\Payment\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Logging\ActivityLogService;
use Modules\Core\Payment\Events\PaymentMethodCreated;
use Modules\Core\Payment\Events\PaymentMethodDeleted;
@@ -9,6 +10,8 @@ use Modules\Core\Payment\Events\PaymentMethodUpdated;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* Queued — a pure audit-log write with no same-request reader.
*
* Same pattern as Localization\Listeners\LogTranslationActivity — routes
* PaymentMethodService's own events through the existing
* Logging\ActivityLogService instead of PaymentMethod separately opting
@@ -29,7 +32,7 @@ use Modules\Core\Payment\Models\PaymentMethod;
* forcing into a one-subject shape or adding a new method to the shared
* service for.
*/
class LogPaymentMethodActivity
class LogPaymentMethodActivity implements ShouldQueue
{
public function __construct(
private readonly ActivityLogService $activityLog,
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace Modules\Core\Payment\Privacy;
use Lunar\Models\Order;
use Lunar\Models\Transaction;
use Modules\Core\Payment\Models\StripePaymentIntent;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\DTOs\UserSubject;
/**
* Payment records (lunar_transactions, stripe_payment_intents) belong to the
* Customer (business account) via the Order they're attached to, not to an
* individual User, so this is Customer-scope only — same chain
* OrderDataProvider already uses (Order.customer_id).
*
* Like Order itself, payment/transaction records are subject to the same
* tax/accounting legal retention argument (GDPR Art. 17(3)(b)) — a payment
* record is part of the same financial audit trail as the order it settled,
* so this pseudonymizes the card-identifying fields in place rather than
* deleting the transaction: amount, status, and the transaction/order link
* all remain intact and auditable.
*
* No Stripe Customer object exists anywhere in this app (see docs/
* payments.md "Reconciliation") — there is nothing to request deletion of
* on Stripe's side. The only local, erasable PII is the card brand/last-4
* on Transaction and the cart_id/order_id/context correlation row on
* stripe_payment_intents, which is deleted outright once its Order is
* settled (its only purpose was resolving an async webhook callback — see
* docs/payments.md "Async resolution" — which has already happened by the
* time an erasure request would run).
*/
class PaymentDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'payments';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$orderIds = Order::where('customer_id', $subject->customerId)->pluck('id');
$transactions = Transaction::whereIn('order_id', $orderIds)->get();
$intents = StripePaymentIntent::whereIn('order_id', $orderIds)->get();
return new ProviderExportResult('payments', [
'transactions' => $transactions->map(fn (Transaction $transaction) => [
'id' => $transaction->id,
'order_id' => $transaction->order_id,
'type' => $transaction->type,
'status' => $transaction->status,
'amount' => $transaction->amount,
'card_type' => $transaction->card_type,
'last_four' => $transaction->last_four,
'reference' => $transaction->reference,
])->all(),
'stripe_payment_intents' => $intents->map(fn (StripePaymentIntent $intent) => [
'id' => $intent->id,
'order_id' => $intent->order_id,
'intent_id' => $intent->intent_id,
'status' => $intent->status,
])->all(),
]);
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('payments', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$orderIds = Order::where('customer_id', $subject->customerId)->pluck('id');
if ($orderIds->isEmpty()) {
return new ProviderErasureResult('payments', ErasureOutcome::Skipped, 'No orders, and therefore no payment records, for this customer.');
}
Transaction::whereIn('order_id', $orderIds)->update([
'card_type' => null,
'last_four' => null,
]);
// stripe_payment_intents only ever existed to correlate a webhook
// callback back to a cart/order (see docs/payments.md "Async
// resolution") — that correlation has already served its purpose by
// the time an erasure request runs, so these rows are deleted
// outright rather than pseudonymized, unlike Transaction, which is
// the actual audit-trail record.
StripePaymentIntent::whereIn('order_id', $orderIds)->delete();
return new ProviderErasureResult(
'payments',
ErasureOutcome::Pseudonymized,
'Card brand/last-four cleared from transaction records; amounts, statuses, and references retained for legal/tax record-keeping. Stripe correlation rows (no longer needed post-settlement) deleted.'
);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('payments', ErasureOutcome::Skipped, 'Payments belong to Customer-owned orders, not individual users.');
}
}
@@ -2,10 +2,10 @@
namespace Modules\Core\Privacy\Contracts;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\DTOs\UserSubject;
/**
* Implemented by any module that holds personal data and wants it included in
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\DTOs;
use Lunar\Models\Customer;
@@ -1,6 +1,8 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\DTOs;
use Modules\Core\Privacy\Enums\ErasureOutcome;
/**
* Every registered provider's outcome, assembled into one right-of-erasure response
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\DTOs;
/**
* Every registered provider's export, assembled into one right-of-access response.
@@ -25,7 +25,13 @@ class ExportReport
$data = [];
foreach ($this->results as $result) {
$data[$result->provider] = $result->data;
// A provider that threw (ProviderExportResult::$error set — see
// Modules\Core\Privacy\Jobs\ExportDataSubjectJob::safeExport())
// surfaces as an explicit error marker rather than an empty
// array indistinguishable from "genuinely nothing to export."
$data[$result->provider] = $result->error !== null
? ['error' => $result->error]
: $result->data;
}
return $data;
@@ -1,6 +1,8 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\DTOs;
use Modules\Core\Privacy\Enums\ErasureOutcome;
/**
* One provider's outcome on an erasure request. `reason` is required whenever
@@ -1,11 +1,17 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\DTOs;
/**
* One provider's contribution to a right-of-access export. `provider` is a short,
* stable machine name (e.g. 'customer', 'orders', 'reviews') used as the top-level
* key when PrivacyService assembles every provider's data into one export payload.
*
* `error` is set only when the provider threw an exception instead of returning
* normally — see Modules\Core\Privacy\Jobs\ExportDataSubjectJob, which catches
* per-provider so one provider throwing doesn't discard every other provider's
* already-gathered data for the same request. `data` is empty whenever `error` is
* set, never a partial/best-effort payload.
*/
class ProviderExportResult
{
@@ -15,5 +21,6 @@ class ProviderExportResult
public function __construct(
public readonly string $provider,
public readonly array $data,
public readonly ?string $error = null,
) {}
}
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\DTOs;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Base\LunarUser;
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Privacy\Enums;
/**
* What actually happened to a provider's data on an erasure request. Erased/
* Pseudonymized/Retained/Skipped are never failures — Retained is a valid, often
* legally-required outcome (e.g. an Order kept intact for tax retention), distinct
* from a provider erroring out. Failed is the one genuine failure case: a provider
* threw an exception instead of returning normally — see Modules\Core\Privacy\
* Services\PrivacyService::completeErasure(), which catches per-provider so one
* provider throwing doesn't discard every other provider's already-computed
* result for the same request.
*/
enum ErasureOutcome: string
{
case Erased = 'erased';
case Pseudonymized = 'pseudonymized';
case Retained = 'retained';
case Skipped = 'skipped';
case Failed = 'failed';
}
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\Enums;
enum ErasureRequestStatus: string
{
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\Enums;
enum ExportRequestStatus: string
{
-16
View File
@@ -1,16 +0,0 @@
<?php
namespace Modules\Core\Privacy;
/**
* What actually happened to a provider's data on an erasure request. None of these
* are failures — Retained is a valid, often legally-required outcome (e.g. an Order
* kept intact for tax retention), distinct from a provider erroring out.
*/
enum ErasureOutcome: string
{
case Erased = 'erased';
case Pseudonymized = 'pseudonymized';
case Retained = 'retained';
case Skipped = 'skipped';
}
+1 -1
View File
@@ -2,7 +2,7 @@
namespace Modules\Core\Privacy\Events;
use Modules\Core\Privacy\ExportReport;
use Modules\Core\Privacy\DTOs\ExportReport;
use Modules\Core\Privacy\Models\DataExportRequest;
/**
@@ -8,7 +8,7 @@ use Filament\Notifications\Notification;
use Lunar\Admin\Support\Extending\BaseExtension;
use Lunar\Models\Customer;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* Adds "Request erasure" / "Request export" header actions to the Customer
@@ -33,7 +33,7 @@ class CustomerErasureActionsExtension extends BaseExtension
->color('danger')
->requiresConfirmation()
->modalDescription('Opens a cancellable grace-period erasure request for this Customer account. No linked User\'s login is affected.')
->form([
->schema([
Checkbox::make('immediate')
->label('Erase immediately (skip the 30-day grace period)')
->helperText('Staff-only, for a formal legal request or regulator inquiry that genuinely requires urgency — not a routine deletion. Runs synchronously, cannot be cancelled once submitted.')
@@ -2,21 +2,25 @@
namespace Modules\Core\Privacy\Filament\Resources;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\RepeatableEntry\TableColumn;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource\Pages\ListDataErasureRequests;
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource\Pages\ViewDataErasureRequest;
use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Lunar\Models\Customer;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource\Pages;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* Read-mostly audit view over data_erasure_requests — staff can see every request
@@ -30,53 +34,114 @@ class DataErasureRequestResource extends Resource
{
protected static ?string $model = DataErasureRequest::class;
protected static ?string $navigationIcon = 'heroicon-o-shield-exclamation';
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-shield-exclamation';
protected static ?string $navigationGroup = 'Privacy';
protected static string | \UnitEnum | null $navigationGroup = 'Privacy';
protected static ?string $modelLabel = 'Erasure Request';
protected static ?string $pluralModelLabel = 'Erasure Requests';
public static function form(Form $form): Form
/**
* A real infolist, not form()'s disabled inputs/Placeholders — ViewRecord
* falls back to rendering form() in read-only mode when a resource has no
* infolist() at all (Filament\Resources\Pages\ViewRecord::hasInfolist()),
* which is what this resource did before: every field rendered as a
* plain, unstyled label/value pair with no grouping, badges, or icons.
*/
public static function infolist(Schema $schema): Schema
{
return $form->schema([
Placeholder::make('subject')
return $schema->components([
Section::make('Request')
->icon('heroicon-o-shield-exclamation')
->columns(4)
->components([
TextEntry::make('subject')
->label('Subject')
->content(fn (DataErasureRequest $record) => sprintf(
'%s (%s)',
DataErasureRequest::displayNameFor($record->subject),
$record->isForCustomer() ? 'Customer account' : 'Individual user'
)),
Placeholder::make('requested_by')
->label('Requested by')
->content(fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->requestedBy)),
TextInput::make('email')
->state(fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->subject))
->weight('bold')
->size('lg'),
TextEntry::make('subject_type')
->label('Scope')
->formatStateUsing(fn (DataErasureRequest $record) => $record->isForCustomer() ? 'Customer account' : 'Individual user')
->badge()
->icon(fn (DataErasureRequest $record) => $record->isForCustomer() ? 'heroicon-o-building-office' : 'heroicon-o-user')
->color(fn (DataErasureRequest $record) => $record->isForCustomer() ? 'info' : 'warning'),
TextEntry::make('email')
->label('Email (snapshot at request time)')
->disabled(),
Placeholder::make('status')
->content(fn (DataErasureRequest $record) => $record->status->value),
Placeholder::make('scheduled_for')
->icon('heroicon-o-envelope')
->copyable(),
TextEntry::make('requested_by')
->label('Requested by')
->state(fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->requestedBy))
->icon('heroicon-o-user-circle'),
TextEntry::make('status')
->badge()
->formatStateUsing(fn (ErasureRequestStatus $state) => ucfirst($state->value))
->color(fn (ErasureRequestStatus $state) => match ($state) {
ErasureRequestStatus::Pending => 'warning',
ErasureRequestStatus::Cancelled => 'gray',
ErasureRequestStatus::Completed => 'success',
}),
TextEntry::make('created_at')
->label('Requested at')
->dateTime()
->icon('heroicon-o-calendar'),
TextEntry::make('scheduled_for')
->label('Scheduled for')
->content(fn (DataErasureRequest $record) => $record->scheduled_for->toDayDateTimeString()),
Placeholder::make('cancelled_at')
->label('Cancelled at')
->content(fn (DataErasureRequest $record) => $record->cancelled_at?->toDayDateTimeString() ?? '—'),
Placeholder::make('completed_at')
->dateTime()
->icon('heroicon-o-calendar-days'),
TextEntry::make('completed_at')
->label('Completed at')
->content(fn (DataErasureRequest $record) => $record->completed_at?->toDayDateTimeString() ?? '—'),
Placeholder::make('caused_by')
->label('Caused by (cascade)')
->content(fn (DataErasureRequest $record) => $record->causedBy
? "Request #{$record->causedBy->id} (".DataErasureRequest::displayNameFor($record->causedBy->subject).')'
: 'Not a cascade — directly requested')
->visible(fn (DataErasureRequest $record) => $record->caused_by_request_id !== null),
KeyValue::make('report')
->label('Per-provider outcome')
->disabled()
->dateTime()
->placeholder('—')
->icon('heroicon-o-check-circle')
->color(fn (DataErasureRequest $record) => $record->completed_at ? 'success' : 'gray'),
TextEntry::make('cancelled_at')
->label('Cancelled at')
->dateTime()
->placeholder('—')
->icon('heroicon-o-x-circle')
->color(fn (DataErasureRequest $record) => $record->cancelled_at ? 'danger' : 'gray')
->visible(fn (DataErasureRequest $record) => $record->cancelled_at !== null),
TextEntry::make('caused_by')
->label('Cascade')
->icon('heroicon-o-arrow-turn-down-right')
->state(fn (DataErasureRequest $record) => $record->causedBy
? "From request #{$record->causedBy->id} (".DataErasureRequest::displayNameFor($record->causedBy->subject).')'
: 'Directly requested')
->color(fn (DataErasureRequest $record) => $record->caused_by_request_id !== null ? 'info' : 'gray'),
]),
Section::make('Outcome')
->description('What happened to each data category once the erasure ran. "Retained"/"Pseudonymized" usually means the data is kept in an anonymized form for legal or accounting reasons.')
->icon('heroicon-o-document-check')
->visible(fn (DataErasureRequest $record) => $record->report !== null)
->helperText('Each provider\'s outcome once the erasure completed — see docs/privacy.md.'),
])->columns(2);
->components([
RepeatableEntry::make('report')
->hiddenLabel()
->table([
TableColumn::make('Data category'),
TableColumn::make('Outcome'),
TableColumn::make('Reason'),
])
->components([
TextEntry::make('provider'),
TextEntry::make('outcome')
->badge()
->formatStateUsing(fn (string $state) => ucfirst($state))
->color(fn (string $state) => match ($state) {
ErasureOutcome::Erased->value => 'success',
ErasureOutcome::Pseudonymized->value, ErasureOutcome::Retained->value => 'info',
ErasureOutcome::Skipped->value => 'gray',
ErasureOutcome::Failed->value => 'danger',
default => 'gray',
}),
TextEntry::make('reason')
->placeholder('—'),
]),
]),
]);
}
public static function table(Table $table): Table
@@ -139,7 +204,7 @@ class DataErasureRequestResource extends Resource
];
}),
])
->actions([
->recordActions([
ViewAction::make(),
Action::make('cancel')
->label('Cancel')
@@ -154,8 +219,8 @@ class DataErasureRequestResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListDataErasureRequests::route('/'),
'view' => Pages\ViewDataErasureRequest::route('/{record}'),
'index' => ListDataErasureRequests::route('/'),
'view' => ViewDataErasureRequest::route('/{record}'),
];
}
@@ -2,16 +2,18 @@
namespace Modules\Core\Privacy\Filament\Resources;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource\Pages\ListDataExportRequests;
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource\Pages\ViewDataExportRequest;
use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Modules\Core\Privacy\ExportRequestStatus;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource\Pages;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest;
@@ -25,36 +27,85 @@ class DataExportRequestResource extends Resource
{
protected static ?string $model = DataExportRequest::class;
protected static ?string $navigationIcon = 'heroicon-o-arrow-down-tray';
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-arrow-down-tray';
protected static ?string $navigationGroup = 'Privacy';
protected static string | \UnitEnum | null $navigationGroup = 'Privacy';
protected static ?string $modelLabel = 'Export Request';
protected static ?string $pluralModelLabel = 'Export Requests';
public static function form(Form $form): Form
/**
* A real infolist, not form()'s disabled inputs/Placeholders — see
* DataErasureRequestResource::infolist()'s own docblock for why.
*/
public static function infolist(Schema $schema): Schema
{
return $form->schema([
Placeholder::make('subject')
return $schema->components([
Section::make('Request')
->icon('heroicon-o-arrow-down-tray')
->columns(4)
->components([
TextEntry::make('subject')
->label('Subject')
->content(fn (DataExportRequest $record) => sprintf(
'%s (%s)',
DataErasureRequest::displayNameFor($record->subject),
$record->isForCustomer() ? 'Customer account' : 'Individual user'
)),
TextInput::make('email')
->state(fn (DataExportRequest $record) => DataErasureRequest::displayNameFor($record->subject))
->weight('bold')
->size('lg'),
TextEntry::make('subject_type')
->label('Scope')
->formatStateUsing(fn (DataExportRequest $record) => $record->isForCustomer() ? 'Customer account' : 'Individual user')
->badge()
->icon(fn (DataExportRequest $record) => $record->isForCustomer() ? 'heroicon-o-building-office' : 'heroicon-o-user')
->color(fn (DataExportRequest $record) => $record->isForCustomer() ? 'info' : 'warning'),
TextEntry::make('email')
->label('Email (snapshot at request time)')
->disabled(),
Placeholder::make('status')
->content(fn (DataExportRequest $record) => $record->status->value),
Placeholder::make('completed_at')
->icon('heroicon-o-envelope')
->copyable(),
TextEntry::make('status')
->badge()
->formatStateUsing(fn (ExportRequestStatus $state) => ucfirst($state->value))
->color(fn (ExportRequestStatus $state) => match ($state) {
ExportRequestStatus::Pending => 'warning',
ExportRequestStatus::Failed => 'danger',
ExportRequestStatus::Completed => 'success',
}),
TextEntry::make('created_at')
->label('Requested at')
->dateTime()
->icon('heroicon-o-calendar'),
TextEntry::make('completed_at')
->label('Completed at')
->content(fn (DataExportRequest $record) => $record->completed_at?->toDayDateTimeString() ?? '—'),
Placeholder::make('file_path')
->label('Export file')
->content(fn (DataExportRequest $record) => $record->file_path ?? 'Not generated yet'),
])->columns(2);
->dateTime()
->placeholder('Not generated yet')
->icon('heroicon-o-check-circle')
->color(fn (DataExportRequest $record) => $record->completed_at ? 'success' : 'gray'),
TextEntry::make('file_path')
->label('File')
// Just the filename, not the full server path — a raw
// filesystem path (/var/www/.../export_2_....zip) isn't
// actionable for staff and previously rendered as if it
// were a clickable link. The actual download is the
// "Download" header action below (self::downloadAction()),
// shared with the table's row action.
->state(fn (DataExportRequest $record) => $record->file_path ? basename($record->file_path) : 'Not generated yet')
->icon('heroicon-o-document')
->color(fn (DataExportRequest $record) => $record->file_path ? 'success' : 'gray'),
]),
]);
}
/**
* Shared by the table's row action and the view page's header action
* (ViewDataExportRequest::getHeaderActions()) so "is this downloadable"
* and the download itself are defined in exactly one place.
*/
public static function downloadAction(): Action
{
return Action::make('download')
->label('Download')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (DataExportRequest $record) => $record->status === ExportRequestStatus::Completed && $record->file_path && file_exists($record->file_path))
->action(fn (DataExportRequest $record) => response()->download($record->file_path));
}
public static function table(Table $table): Table
@@ -100,21 +151,17 @@ class DataExportRequestResource extends Resource
ExportRequestStatus::Failed->value => 'Failed',
]),
])
->actions([
->recordActions([
ViewAction::make(),
Action::make('download')
->label('Download')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (DataExportRequest $record) => $record->status === ExportRequestStatus::Completed && $record->file_path && file_exists($record->file_path))
->action(fn (DataExportRequest $record) => response()->download($record->file_path)),
self::downloadAction(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListDataExportRequests::route('/'),
'view' => Pages\ViewDataExportRequest::route('/{record}'),
'index' => ListDataExportRequests::route('/'),
'view' => ViewDataExportRequest::route('/{record}'),
];
}
@@ -8,4 +8,11 @@ use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource;
class ViewDataExportRequest extends ViewRecord
{
protected static string $resource = DataExportRequestResource::class;
protected function getHeaderActions(): array
{
return [
DataExportRequestResource::downloadAction(),
];
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* Runs PrivacyService::completeErasure() for one due DataErasureRequest, dispatched
+44 -9
View File
@@ -2,19 +2,23 @@
namespace Modules\Core\Privacy\Jobs;
use Throwable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Event;
use Modules\Core\Privacy\CustomerSubject;
use Illuminate\Support\Facades\Log;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Events\PersonalDataGathered;
use Modules\Core\Privacy\ExportReport;
use Modules\Core\Privacy\ExportRequestStatus;
use Modules\Core\Privacy\DTOs\ExportReport;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
use Modules\Core\Privacy\Models\DataExportRequest;
use Modules\Core\Privacy\PrivacyManager;
use Modules\Core\Privacy\UserSubject;
use Modules\Core\Privacy\Services\PrivacyManager;
use Modules\Core\Privacy\DTOs\UserSubject;
/**
* Gathers every registered PersonalDataProvider's export data for one request, all
@@ -26,7 +30,7 @@ use Modules\Core\Privacy\UserSubject;
* generated PDF), that's the point to reconsider — not before.
*
* Calls each provider's *ForCustomer() or *ForUser() method depending on the
* request's polymorphic subject — see Modules\Core\Privacy\PrivacyService and
* request's polymorphic subject — see Modules\Core\Privacy\Services\PrivacyService and
* docs/privacy.md "User-scope vs Customer-scope".
*
* Writing the gathered data to a file is intentionally NOT done here — see
@@ -48,10 +52,16 @@ class ExportDataSubjectJob implements ShouldQueue
{
if ($this->request->isForCustomer()) {
$subject = new CustomerSubject(customerId: $this->request->subject_id);
$results = array_map(fn ($provider) => $provider->exportForCustomer($subject), $manager->providers());
$results = array_map(
fn (PersonalDataProvider $provider) => $this->safeExport($provider, 'exportForCustomer', $subject),
$manager->providers()
);
} else {
$subject = new UserSubject(userId: $this->request->subject_id, email: $this->request->email);
$results = array_map(fn ($provider) => $provider->exportForUser($subject), $manager->providers());
$results = array_map(
fn (PersonalDataProvider $provider) => $this->safeExport($provider, 'exportForUser', $subject),
$manager->providers()
);
}
Event::dispatch(new PersonalDataGathered(
@@ -60,8 +70,33 @@ class ExportDataSubjectJob implements ShouldQueue
));
}
public function failed(\Throwable $exception): void
public function failed(Throwable $exception): void
{
$this->request->update(['status' => ExportRequestStatus::Failed]);
}
/**
* Catches per-provider so one provider throwing doesn't discard every
* other provider's already-gathered export data for this same request —
* without this, the whole array_map aborts, handle() never reaches
* Event::dispatch(), and failed() marks the ENTIRE request Failed even
* though most providers may have already gathered their data
* successfully. Logged via Log::error() so a thrown provider is still
* visible to staff, not just an empty/missing section in the export.
*
* @param 'exportForCustomer'|'exportForUser' $method
*/
private function safeExport(PersonalDataProvider $provider, string $method, CustomerSubject|UserSubject $subject): ProviderExportResult
{
try {
return $provider->{$method}($subject);
} catch (Throwable $e) {
Log::error("Privacy provider {$provider->name()}::{$method}() threw during export", [
'provider' => $provider->name(),
'exception' => $e,
]);
return new ProviderExportResult($provider->name(), [], $e->getMessage());
}
}
}
@@ -4,9 +4,9 @@ namespace Modules\Core\Privacy\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* Logging back in during a pending erasure request's grace period IS the "I
@@ -7,7 +7,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Lunar\Base\LunarUser;
use Lunar\Models\Customer;
use Modules\Core\Privacy\Events\UserErasureRequested;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* When a User's erasure leaves a Customer account with no remaining User at all,
@@ -8,7 +8,7 @@ use Modules\Core\Export\CsvColumn;
use Modules\Core\Export\CsvWriter;
use Modules\Core\Privacy\Events\PersonalDataExportFileWritten;
use Modules\Core\Privacy\Events\PersonalDataGathered;
use Modules\Core\Privacy\ExportRequestStatus;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
use ZipArchive;
/**
@@ -19,9 +19,11 @@ use ZipArchive;
* listener) without touching how the data is gathered.
*
* Column schema: every provider's data is either a list of associative arrays
* (rows directly) or a single associative array (one row) — see the providers in
* Modules\Core\Privacy\Providers, all of which return exactly one of those two
* shapes. Any nested array value within a row (e.g. an order's `addresses`) is
* (rows directly) or a single associative array (one row) — see the providers
* registered in config('core.privacy.providers'), each living in its own owning
* module's Privacy/ subdirectory (e.g. Modules\Core\Order\Privacy\
* OrderDataProvider), all of which return exactly one of those two shapes. Any
* nested array value within a row (e.g. an order's `addresses`) is
* JSON-encoded into that one cell rather than exploded into further columns —
* CsvWriter's generic stringify() behavior, not special-cased here.
*/
+2 -2
View File
@@ -7,12 +7,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Lunar\Models\Customer;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
/**
* A pending, cancelled, or completed right-of-erasure request — the grace-period
* record between "subject/staff asked for this" and "providers actually erased
* their data" (see Modules\Core\Privacy\PrivacyService, which creates/processes
* their data" (see Modules\Core\Privacy\Services\PrivacyService, which creates/processes
* these).
*
* `subject` is polymorphic — either a Lunar Customer (business account) or a User
+1 -1
View File
@@ -5,7 +5,7 @@ namespace Modules\Core\Privacy\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Lunar\Models\Customer;
use Modules\Core\Privacy\ExportRequestStatus;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
/**
* A right-of-access export request. Created synchronously (fast — one insert), then
@@ -1,62 +0,0 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Lunar\Models\Cart;
use Lunar\Models\CartAddress;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
/**
* Carts and cart addresses (lunar_carts, lunar_cart_addresses) belong to the
* Customer (business account) via customer_id, not to an individual User, so this
* is Customer-scope only. Unlike Order/OrderAddress, an abandoned cart has no
* legal retention requirement, so its addresses are freely deleted. The Cart row
* itself is left alone (any completed order it produced is handled separately by
* OrderDataProvider, which is what retention law actually cares about) — only its
* address PII is removed.
*/
class CartDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'carts';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$addresses = CartAddress::whereIn('cart_id', Cart::where('customer_id', $subject->customerId)->pluck('id'))->get();
return new ProviderExportResult('carts', $addresses->map(fn (CartAddress $address) => [
'type' => $address->type,
'first_name' => $address->first_name,
'last_name' => $address->last_name,
'line_one' => $address->line_one,
'city' => $address->city,
'postcode' => $address->postcode,
'contact_email' => $address->contact_email,
'contact_phone' => $address->contact_phone,
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('carts', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
CartAddress::whereIn('cart_id', Cart::where('customer_id', $subject->customerId)->pluck('id'))->delete();
return new ProviderErasureResult('carts', ErasureOutcome::Erased);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('carts', ErasureOutcome::Skipped, 'Carts belong to Customer accounts, not individual users.');
}
}
@@ -1,97 +0,0 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Lunar\Models\Order;
use Lunar\Models\OrderAddress;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
/**
* Orders and order addresses (lunar_orders, lunar_order_addresses) belong to the
* Customer (business account) via customer_id, not to an individual User, so this
* is Customer-scope only. They're also subject to legal retention (tax/accounting
* law generally requires invoices be kept for several years — GDPR Art. 17(3)(b)
* explicitly allows this to override an erasure request). eraseForCustomer()
* therefore pseudonymizes the PII-bearing free-text fields in place rather than
* deleting the order: totals, line items, tax data, and the order itself all
* remain intact and auditable.
*/
class OrderDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'orders';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$orders = Order::where('customer_id', $subject->customerId)->with('addresses')->get();
return new ProviderExportResult('orders', $orders->map(fn (Order $order) => [
'id' => $order->id,
'reference' => $order->reference,
'status' => $order->status,
'total' => $order->total?->decimal(),
'placed_at' => $order->placed_at?->toIso8601String(),
'addresses' => $order->addresses->map(fn (OrderAddress $address) => [
'type' => $address->type,
'first_name' => $address->first_name,
'last_name' => $address->last_name,
'line_one' => $address->line_one,
'city' => $address->city,
'postcode' => $address->postcode,
'contact_email' => $address->contact_email,
'contact_phone' => $address->contact_phone,
])->all(),
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('orders', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$orderIds = Order::where('customer_id', $subject->customerId)->pluck('id');
if ($orderIds->isEmpty()) {
return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'No orders for this customer.');
}
Order::whereIn('id', $orderIds)->update([
'customer_reference' => null,
'notes' => null,
]);
OrderAddress::whereIn('order_id', $orderIds)->update([
'title' => null,
'first_name' => 'Erased',
'last_name' => 'Customer',
'company_name' => null,
'tax_identifier' => null,
'line_one' => null,
'line_two' => null,
'line_three' => null,
'delivery_instructions' => null,
'contact_email' => null,
'contact_phone' => null,
]);
return new ProviderErasureResult(
'orders',
ErasureOutcome::Pseudonymized,
'Order and address free-text fields cleared; order records, totals, and line items retained for legal/tax record-keeping.'
);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'Orders belong to Customer accounts, not individual users.');
}
}
@@ -2,16 +2,16 @@
namespace Modules\Core\Privacy\RelationManagers;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* Lists erasure requests where the record being viewed (Customer or User) is the
@@ -65,7 +65,7 @@ class ErasureRequestsRelationManager extends RelationManager
]),
])
->headerActions([])
->actions([
->recordActions([
ViewAction::make()
->url(fn (DataErasureRequest $record) => DataErasureRequestResource::getUrl('view', ['record' => $record])),
Action::make('cancel')
@@ -2,13 +2,13 @@
namespace Modules\Core\Privacy\RelationManagers;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Modules\Core\Privacy\ExportRequestStatus;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource;
use Modules\Core\Privacy\Models\DataExportRequest;
@@ -56,7 +56,7 @@ class ExportRequestsRelationManager extends RelationManager
]),
])
->headerActions([])
->actions([
->recordActions([
ViewAction::make()
->url(fn (DataExportRequest $record) => DataExportRequestResource::getUrl('view', ['record' => $record])),
Action::make('download')
@@ -2,16 +2,16 @@
namespace Modules\Core\Privacy\RelationManagers;
use Filament\Actions\Action;
use Filament\Forms\Components\Checkbox;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Customer\RelationManagers\UserRelationManager as CoreUserRelationManager;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* Extends core's own Customer -> User relation manager to add:
@@ -31,7 +31,7 @@ class UserRelationManager extends CoreUserRelationManager
{
$table = parent::getDefaultTable($table);
return $table->actions([
return $table->recordActions([
...$table->getActions(),
Action::make('privacyRequests')
->label('Privacy Requests')
@@ -39,14 +39,14 @@ class UserRelationManager extends CoreUserRelationManager
->modalHeading(fn (Model $record) => "Privacy requests for {$record->name}")
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->infolist(fn (Model $record) => $this->requestsInfolist($record)),
->schema(fn (Model $record) => $this->requestsInfolist($record)),
Action::make('requestErasure')
->label('Request Erasure')
->icon('heroicon-o-shield-exclamation')
->color('danger')
->requiresConfirmation()
->modalDescription('Opens a cancellable grace-period erasure request for this individual — deactivates their login and detaches them from every linked Customer account once it completes. No Customer account\'s own data is affected.')
->form([
->schema([
Checkbox::make('immediate')
->label('Erase immediately (skip the 30-day grace period)')
->helperText('Staff-only, for a formal legal request or regulator inquiry that genuinely requires urgency — not a routine deletion. Runs synchronously, cannot be cancelled once submitted.')
@@ -1,7 +1,8 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\Services;
use LogicException;
use Illuminate\Contracts\Container\Container;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
@@ -41,7 +42,7 @@ class PrivacyManager
$duplicates = array_diff_assoc($names, array_unique($names));
if ($duplicates !== []) {
throw new \LogicException(
throw new LogicException(
'Duplicate Modules\Core\Privacy provider name(s): '.implode(', ', array_unique($duplicates))
.'. Each provider registered in config(\'core.privacy.providers\') must return a unique name().'
);
@@ -1,17 +1,27 @@
<?php
namespace Modules\Core\Privacy;
namespace Modules\Core\Privacy\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Lunar\Base\LunarUser;
use Lunar\Models\Customer;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\DTOs\ErasureReport;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\UserSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
use Modules\Core\Privacy\Events\UserErasureRequested;
use Modules\Core\Privacy\Jobs\ExportDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest;
use Throwable;
/**
* Entry point for right-of-access and right-of-erasure requests, split into two
@@ -238,15 +248,30 @@ class PrivacyService
* called directly for a request that hasn't passed its grace period, since
* that defeats the point of the window; ProcessErasureRequestsCommand
* enforces isDue() before calling this.
*
* Each provider call is caught individually — a provider throwing (a bug,
* an unexpected DB state) converts to ErasureOutcome::Failed rather than
* aborting the whole array_map, so one broken provider never discards
* every OTHER provider's already-completed erasure for this same request.
* Without this, the $request->update() below would never run at all on a
* throw, silently leaving providers that already succeeded unrecorded and
* the request stuck Pending forever. Logged via Log::error() so a thrown
* provider is still visible to staff, not just swallowed into "Failed."
*/
public function completeErasure(DataErasureRequest $request): ErasureReport
{
if ($request->isForCustomer()) {
$subject = new CustomerSubject(customerId: $request->subject_id);
$results = array_map(fn ($provider) => $provider->eraseForCustomer($subject), $this->manager->providers());
$results = array_map(
fn (PersonalDataProvider $provider) => $this->safeErase($provider, 'eraseForCustomer', $subject),
$this->manager->providers()
);
} else {
$subject = new UserSubject(userId: $request->subject_id, email: $request->email);
$results = array_map(fn ($provider) => $provider->eraseForUser($subject), $this->manager->providers());
$results = array_map(
fn (PersonalDataProvider $provider) => $this->safeErase($provider, 'eraseForUser', $subject),
$this->manager->providers()
);
}
$report = new ErasureReport($subject, $results);
@@ -276,4 +301,21 @@ class PrivacyService
'deactivated_at' => $deactivated ? now() : null,
]);
}
/**
* @param 'eraseForCustomer'|'eraseForUser' $method
*/
private function safeErase(PersonalDataProvider $provider, string $method, CustomerSubject|UserSubject $subject): ProviderErasureResult
{
try {
return $provider->{$method}($subject);
} catch (Throwable $e) {
Log::error("Privacy provider {$provider->name()}::{$method}() threw during erasure", [
'provider' => $provider->name(),
'exception' => $e,
]);
return new ProviderErasureResult($provider->name(), ErasureOutcome::Failed, $e->getMessage());
}
}
}
+4
View File
@@ -21,6 +21,7 @@ use Modules\Core\Order\Listeners\CompleteOrderOnPickedUp;
use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced;
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
use Modules\Core\Order\Listeners\MarkDeliveryFailedOnCarrierCheckpoint;
use Modules\Core\Order\Listeners\MarkOrderPlacedOnDeferredPayment;
use Modules\Core\Order\Listeners\RecordPaymentTransaction;
use Modules\Core\Order\Listeners\RecordStatusTransition;
use Modules\Core\Order\Models\OrderStatusTransition;
@@ -37,6 +38,7 @@ use Modules\Core\Order\Observers\TransactionObserver;
use Modules\Core\Order\Support\OrderStatus;
use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentDeferred;
use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Events\PaymentVoided;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
@@ -74,6 +76,8 @@ class OrderServiceProvider extends ServiceProvider
Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class);
Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentDeferred::class, MarkOrderPlacedOnDeferredPayment::class);
Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class);
Event::listen(OrderStatusChanged::class, [RecordStatusTransition::class, 'handleStatusChanged']);
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Review\Events;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Review\Models\ProductReview;
/**
* Dispatched whenever staff post or edit a reply to a review (see
* Modules\Core\Review\Services\ReviewService::reply()) — previously this
* happened with no event at all, so nothing could react to it (no audit
* trail, no "notify the reviewer their review got a reply" hook).
*
* $wasReply distinguishes a brand-new reply from an edit to an existing
* one — a listener building an audit trail or notification may care which
* happened (e.g. only notify the reviewer the first time, not on every
* subsequent edit).
*/
class ReviewReplied
{
public function __construct(
public readonly ProductReview $review,
public readonly Staff $repliedBy,
public readonly bool $wasReply,
) {}
}
@@ -13,11 +13,11 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Carbon;
use Illuminate\Support\HtmlString;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Support\Pages\BaseManageRelatedRecords;
use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Review\Services\ReviewService;
class ManageProductReviews extends BaseManageRelatedRecords
{
@@ -139,10 +139,7 @@ class ManageProductReviews extends BaseManageRelatedRecords
])
->fillForm(fn (ProductReview $record) => ['reply' => $record->reply])
->action(function (ProductReview $record, array $data) {
$record->update([
'reply' => $data['reply'],
'replied_at' => Carbon::now(),
]);
app(ReviewService::class)->reply($record, $data['reply'], auth('staff')->user());
}),
DeleteAction::make(),
])
@@ -1,14 +1,14 @@
<?php
namespace Modules\Core\Privacy\Providers;
namespace Modules\Core\Review\Privacy;
use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\DTOs\UserSubject;
use Modules\Core\Review\Models\ProductReview;
/**
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Review\Services;
use Illuminate\Support\Facades\Event;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Review\Events\ReviewReplied;
use Modules\Core\Review\Models\ProductReview;
/**
* The one place a reply is written onto a ProductReview — previously
* Modules\Core\Review\Filament\Pages\ManageProductReviews wrote directly
* to the record inside its own Action closure, with no Service and no
* event dispatched for a real state change (a customer's review getting a
* staff reply). Matches the write-then-dispatch shape every other
* module's Service already uses (e.g. Modules\Core\Payment\Services\
* PaymentMethodService, Modules\Core\Customer\Services\
* CustomerAccountService).
*/
class ReviewService
{
public function reply(ProductReview $review, string $reply, Staff $repliedBy): ProductReview
{
$wasReply = $review->replied_at === null;
$review->update([
'reply' => $reply,
'replied_at' => now(),
]);
Event::dispatch(new ReviewReplied($review, $repliedBy, $wasReply));
return $review;
}
}
@@ -61,6 +61,20 @@ class BoxNowClient
return $response->json() ?? [];
}
/**
* List available APM (locker) destinations — the data behind Box Now's
* own Destination Map widget, which only works against their
* Production environment (not Stage/sandbox). Used to build a plain
* locker picker on the storefront checkout instead, working against
* whichever environment is configured.
*
* @return array<int, array<string, mixed>>
*/
public function destinations(array $query = []): array
{
return $this->locationRequest('/destinations', $query)['data'] ?? [];
}
/**
* Fetch raw bytes (e.g. a PDF label) rather than JSON.
*/
@@ -67,7 +67,7 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
'locationId' => config('boxnow.origin_location_id'),
],
'destination' => [
'contactNumber' => $address->contact_phone,
'contactNumber' => $this->internationalPhone($address->contact_phone),
'contactEmail' => $address->contact_email,
'contactName' => trim("{$address->first_name} {$address->last_name}"),
'locationId' => $destinationLocationId,
@@ -105,6 +105,37 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
return $shipments->first();
}
/**
* Box Now rejects any contactNumber not in full international format
* (error P405 — confirmed in practice: a plain Greek mobile like
* "6955994563" 400s with {"code":"P405"}). Checkout collects phone
* numbers in local format, with no international-format enforcement of
* its own — this store is Greece-only (see CheckoutController::
* STORE_COUNTRY_ISO3), so a bare local number is assumed Greek and
* prefixed accordingly, same convention ACS's own sender config already
* uses (config('boxnow.sender.phone') is documented as "+30..." there
* too). A number already carrying a country code (leading "+" or "00")
* is passed through unchanged.
*/
private function internationalPhone(?string $phone): ?string
{
if ($phone === null) {
return null;
}
$digitsOnly = preg_replace('/[^\d+]/', '', $phone);
if (str_starts_with($digitsOnly, '+')) {
return $digitsOnly;
}
if (str_starts_with($digitsOnly, '00')) {
return '+'.substr($digitsOnly, 2);
}
return '+30'.ltrim($digitsOnly, '0');
}
public function printLabel(Shipment $shipment): string
{
$bytes = $this->client->requestRaw("/parcels/{$shipment->tracking_reference}/label.pdf");
@@ -5,6 +5,8 @@ namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Illuminate\Support\Collection;
use Modules\Core\Shipping\Models\ShipmentInfo;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Illuminate\Support\Facades\URL;
@@ -112,10 +114,34 @@ class OrderShipmentsExtension extends ViewPageExtension
->action(fn (Shipment $record) => $this->cancel($record))
->visible(fn (Shipment $record) => ! $record->cancelled_at),
]),
RepeatableEntry::make('shipmentInfo')
->label('Tracking history')
->state(fn (Shipment $record) => $this->orderedCheckpoints($record))
->hidden(fn (Shipment $record) => $record->shipmentInfo->isEmpty())
->schema([
TextEntry::make('status')
->label(fn (ShipmentInfo $record) => $record->occurred_at->format('Y-m-d H:i'))
->inlineLabel()
->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title())
->helperText(fn (ShipmentInfo $record) => $record->location),
]),
]),
]);
}
/**
* Oldest first — a delivery journey (Collected → In Transit →
* Delivered) reads naturally top-to-bottom in that order, unlike
* statusLabel()/statusColor() above which only ever need the single
* latest checkpoint and so use latestShipmentInfo() directly instead.
*
* @return Collection<int, ShipmentInfo>
*/
private function orderedCheckpoints(Shipment $record): Collection
{
return $record->shipmentInfo->sortBy('occurred_at')->values();
}
private function carrierLabel(Shipment $record): string
{
return match ($record->carrier) {
+23 -12
View File
@@ -56,10 +56,11 @@ use Modules\Core\Shipping\Support\WeightCalculator;
* each with its own S/M/L size) instead — see
* Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService for how
* multiple boxes become multiple Shipment rows from one delivery request.
* Box Now's locker is locked to read-only once the shopper's own checkout
* selection ($order->shippingAddress->meta['box_now_locker']) is present
* — staff can only fill it in manually for the (current, checkout-UI-less)
* case where nothing set it yet.
* Box Now's locker field defaults from the shopper's own checkout
* selection ($order->shippingAddress->meta['box_now_locker']) when present,
* but stays editable — staff can override to a different locker (e.g. the
* customer's choice turns out to be unavailable) or fill it in manually for
* an order placed before the checkout locker picker existed.
*
* "Mark Paid" is a third, separate header action — Order::paid is
* independent of `status` (see OrderStatusFlow's own docblock), so it
@@ -124,16 +125,9 @@ class OrderViewExtension extends ViewPageExtension
TextInput::make('destination_location_id')
->label('Box Now locker ID')
->default($lockerId)
// Locked once the shopper's own checkout selection is
// known — staff should not be able to redirect a
// parcel to a different locker than the one the
// customer picked. Only editable for the (current,
// checkout-UI-less) case where nothing set it yet.
->disabled(filled($lockerId))
->dehydrated()
->required()
->helperText($lockerId
? 'Set by the customer at checkout.'
? 'Set by the customer at checkout — override if the parcel needs to go to a different locker.'
: 'No locker was selected at checkout — enter it manually.'),
Repeater::make('boxes')
->label('Boxes')
@@ -152,12 +146,29 @@ class OrderViewExtension extends ViewPageExtension
];
})
->action(function (Order $record, array $data, Action $action) {
// Derived from the order itself, never from staff input —
// whether a shipment collects cash on delivery is a fact
// about the order (which PaymentMethod it was placed
// against), not a choice to make again at dispatch time.
// Previously this was never set at all (defaulted to
// ShipmentRequest::$paymentMode's own null), which silently
// made AcsFulfillmentService::createShipment()'s
// `$request->paymentMode === 'cod'` branch (sends
// Cod_Ammount/Cod_Payment_Way to ACS) permanently
// unreachable, and BoxNowFulfillmentService::createShipment()
// always ship 'prepaid' with amountToBeCollected '0.00' —
// a real COD order would arrive with the carrier believing
// full payment was already settled, and never collect it.
$isCod = app(OrderStatusFlow::class)->isCod($record);
$result = $this->service()->createShipmentAndDispatch(
$record,
new ShipmentRequest(
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
packageCount: (int) ($data['package_count'] ?? 1),
destinationLocationId: $data['destination_location_id'] ?? null,
paymentMode: $isCod ? 'cod' : 'prepaid',
amountToCollect: $isCod ? $record->total->decimal : null,
boxes: collect($data['boxes'] ?? [])->pluck('size')->all(),
),
);
@@ -12,11 +12,15 @@ use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
/**
* ListShippingMethod::getDefaultHeaderActions() builds its CreateAction's
* form inline (calling ShippingMethodResource::getDriverFormComponent()
* directly, a hardcoded 2-option Select) rather than through the resource's
* own extendForm() pipeline, so ShippingMethodResourceExtension's driver
* fix never reaches it. Re-declares the same create-action form with a
* dynamic driver Select instead.
* form inline (calling ShippingMethodResource::getDriverFormComponent()/
* getNameFormComponent() directly, hardcoded vendor components) rather
* than through the resource's own extendForm() pipeline, so neither
* ShippingMethodResourceExtension's driver Select nor its translated
* `name` field ever reached this action — `name`'s plain-string TextInput
* in particular used to insert a raw string into the now-JSON `name`
* column, crashing with a Postgres "invalid input syntax for type json"
* error on every create. Re-declares the same create-action form with
* both fixes reapplied instead.
*/
class ShippingMethodListExtension extends BaseExtension
{
@@ -25,7 +29,7 @@ class ShippingMethodListExtension extends BaseExtension
foreach ($actions as $action) {
if ($action instanceof CreateAction) {
$action->schema([
ShippingMethodResource::getNameFormComponent(),
ShippingMethodResourceExtension::translatedNameField(),
Group::make([
ShippingMethodResource::getCodeFormComponent(),
$this->driverSelect(),
@@ -43,11 +43,11 @@ class ShippingMethodResourceExtension extends ResourceExtension
{
return array_map(function (Component $component) {
if (method_exists($component, 'getName') && $component->getName() === 'name') {
return $this->translatedNameField();
return self::translatedNameField();
}
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema($this->replaceNameField($component->getChildComponents()));
$component->schema($this->replaceNameField($component->getDefaultChildComponents()));
}
return $component;
@@ -68,14 +68,28 @@ class ShippingMethodResourceExtension extends ResourceExtension
* the model attribute is a string on the way in and out, only ever
* an array while Filament's schema state holds it.
*/
private function translatedNameField(): TranslatedText
/**
* Also called directly by Modules\Core\Shipping\Extensions\
* ShippingMethodListExtension — the create action's form is built
* inline by the vendor's ListShippingMethod page rather than through
* this class's own extendForm() pipeline, so it needs the same
* translated field wired in separately.
*/
public static function translatedNameField(): TranslatedText
{
$field = TranslatedText::make('name')
->label('Name')
->required()
->afterStateHydrated(function (TranslatedText $component, $state) {
$decoded = json_decode((string) $state, true);
$component->state(is_array($decoded) ? $decoded : []);
// On create there is no record yet, so Filament hydrates
// this from the field's own default/current state — already
// an array (or null), never the raw JSON string edit gets
// from the model attribute. Only decode when it's a string.
if (is_string($state)) {
$state = json_decode($state, true);
}
$component->state(is_array($state) ? $state : []);
})
->dehydrateStateUsing(fn ($state) => json_encode(is_array($state) ? $state : []));
@@ -158,7 +172,7 @@ class ShippingMethodResourceExtension extends ResourceExtension
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema(
$this->replaceChargeByField($component->getChildComponents())
$this->replaceChargeByField($component->getDefaultChildComponents())
);
}
@@ -269,7 +283,7 @@ class ShippingMethodResourceExtension extends ResourceExtension
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema(
$this->replaceDriverField($component->getChildComponents())
$this->replaceDriverField($component->getDefaultChildComponents())
);
}
@@ -4,6 +4,7 @@ namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -11,6 +12,7 @@ use Illuminate\Database\Eloquent\Model;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Support\ShippingMethodName;
/**
* Bound in place of the vendor ManageShippingRates page via the container
@@ -33,6 +35,16 @@ use Lunar\Shipping\Models\ShippingRate;
* null-guard, which crashes on any rate with no basePrices row — routine
* for a live rate that has never had a fallback price configured. Same
* logic, just null-safe.
*
* Also replaces the vendor's `shipping_method_id` Select, which uses
* ->relationship(titleAttribute: 'name') — Filament builds that option
* list with `orderBy('name')`/`pluck('name', ...)` against the DB, but
* ShippingMethod.name is now a locale-keyed JSON column (see database/
* migrations/..._make_shipping_methods_name_translatable.php) that
* Postgres has no default ordering operator for, crashing with
* "could not identify an ordering operator for type json" the moment
* this page loads. Resolved app-side instead via ShippingMethodName,
* same as every other read site for this column.
*/
class ManageShippingRates extends BaseManageShippingRates
{
@@ -41,10 +53,30 @@ class ManageShippingRates extends BaseManageShippingRates
$schema = parent::form($schema);
return $schema->components(
$this->labelPriceFieldsAsFallbackWhenLive($schema->getComponents())
$this->labelPriceFieldsAsFallbackWhenLive(
$this->replaceShippingMethodField($schema->getComponents())
)
);
}
private function replaceShippingMethodField(array $components): array
{
return array_map(function ($component) {
if (method_exists($component, 'getName') && $component->getName() === 'shipping_method_id') {
return Select::make('shipping_method_id')
->label($component->getLabel())
->required()
->live()
->options(fn () => ShippingMethod::all()
->mapWithKeys(fn (ShippingMethod $method) => [$method->id => ShippingMethodName::resolve($method)]))
->searchable()
->columnSpan(2);
}
return $component;
}, $components);
}
private function labelPriceFieldsAsFallbackWhenLive(array $components): array
{
$isLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) === 'live';
@@ -86,6 +118,14 @@ class ManageShippingRates extends BaseManageShippingRates
return $table->columns(
array_map(function ($column) {
if (method_exists($column, 'getName') && $column->getName() === 'shippingMethod.name') {
return TextColumn::make('shippingMethod.name')
->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.shipping_method.label'))
->state(fn (ShippingRate $record) => $record->shippingMethod
? ShippingMethodName::resolve($record->shippingMethod)
: null);
}
if (method_exists($column, 'getName') && $column->getName() === 'basePrices.0') {
return TextColumn::make('basePrices.0')
->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label'))
@@ -14,12 +14,19 @@ use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo;
use Throwable;
/**
* Carrier-agnostic: polls every Shipment not yet in a terminal state,
* skipping carriers whose fulfillment service doesn't implement
* SupportsTracking. New checkpoints are recorded in shipment_info and
* dispatch ShipmentStatusUpdatedByCarrier — one event per new checkpoint.
*
* Each shipment's trackShipment() call is individually try/caught in
* pollCarrierShipments() — one shipment's tracking lookup failing (a
* carrier 500, a malformed parcel response) must not stop the rest of that
* carrier's shipments in the same batch from being polled. The failure is
* reported and the loop continues.
*/
class PollShipmentTrackingJob implements ShouldQueue
{
@@ -68,7 +75,11 @@ class PollShipmentTrackingJob implements ShouldQueue
}
foreach ($shipments as $shipment) {
try {
$this->recordNewCheckpoints($shipment, $service->trackShipment($shipment));
} catch (Throwable $e) {
report($e);
}
}
}