Compare commits

...
92 changed files with 2302 additions and 588 deletions
+57
View File
@@ -4,6 +4,63 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [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 ## [0.18.0] - 2026-09-16
### Added ### Added
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.18.0", "version": "0.18.1",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
+13 -5
View File
@@ -35,11 +35,19 @@ return [
'privacy' => [ 'privacy' => [
'providers' => [ 'providers' => [
\Modules\Core\Privacy\Providers\CustomerDataProvider::class, // ActivityLogDataProvider MUST run before AddressDataProvider —
\Modules\Core\Privacy\Providers\AddressDataProvider::class, // it resolves which activity_log rows belong to this customer
\Modules\Core\Privacy\Providers\OrderDataProvider::class, // (including ones keyed by an Address id) before
\Modules\Core\Privacy\Providers\CartDataProvider::class, // AddressDataProvider hard-deletes those Address rows. See that
\Modules\Core\Privacy\Providers\ReviewDataProvider::class, // 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, 'grace_period_days' => 30,
+14
View File
@@ -13,12 +13,25 @@
| |
| Set these via environment variables — never commit real values. | 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_BASE_URL Root REST endpoint for delivery-requests/parcels.
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations | BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
| lookups (Box Now recommends this over the main | lookups (Box Now recommends this over the main
| base URL for those two calls specifically). | base URL for those two calls specifically).
| BOXNOW_CLIENT_ID OAuth2 client id. | BOXNOW_CLIENT_ID OAuth2 client id.
| BOXNOW_CLIENT_SECRET OAuth2 client secret. | 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 | BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
| the pickup origin on every delivery request. | the pickup origin on every delivery request.
| BOXNOW_SENDER_* Static sender contact details reused on every | BOXNOW_SENDER_* Static sender contact details reused on every
@@ -33,6 +46,7 @@ return [
'client_id' => env('BOXNOW_CLIENT_ID'), 'client_id' => env('BOXNOW_CLIENT_ID'),
'client_secret' => env('BOXNOW_CLIENT_SECRET'), 'client_secret' => env('BOXNOW_CLIENT_SECRET'),
'partner_id' => env('BOXNOW_PARTNER_ID'),
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'), 'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
+10 -2
View File
@@ -49,7 +49,8 @@ boboko-test/
app/ app/
Models/ Models/
Customer.php ← app-level model, extends Modules\Core\Customer\Models\Customer 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 Staff.php ← app-level model, extends Modules\Core\Auth\Models\Staff
Lunar/ Lunar/
Extensions/ ← app's own Filament resource extensions (source of truth, wired in PanelServiceProvider) 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, '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 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. answer "which order/cart does gateway reference X belong to?" between the two calls.
**Read directly from `lunarphp/stripe`'s own source** (`StripePaymentType::authorize()`, The precedent for this originally came from reading `lunarphp/stripe`'s own source
`ProcessStripeWebhook`, `WebhookController`) to see how Lunar itself solves this — confirmed (`StripePaymentType::authorize()`, `ProcessStripeWebhook`, `WebhookController`) — that package
it does **not** stash a generic opaque blob. It writes the correlating ids as real, typed solved this the same way, writing the correlating ids as real, typed columns on its own
columns on `Lunar\Stripe\Models\StripePaymentIntent` (`cart_id`, `order_id`) at the moment the `StripePaymentIntent` model rather than a generic opaque blob. **`lunarphp/stripe` has since
intent is created/first seen, then reads them back the same way when the webhook arrives: 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 **`StripePaymentDriver` follows this pattern**: it reads `cart_id`/`order_id` out of `$context`
// ProcessStripeWebhook::handle() — falls back through two real lookups, at `pay()`/`authorize()` time and writes them onto its own `StripePaymentIntent` row (`src/
// neither of them a generic context blob: Payment/Models/StripePaymentIntent.php`, table `stripe_payment_intents`), then reads them back
$cart = StripePaymentIntent::where('intent_id', $this->paymentIntentId)->first()?->cart the same way in `handleCallback()`. No generic `context` json column beyond what that table
?: Cart::where('meta->payment_intent', '=', $this->paymentIntentId)->first(); already carries (`context`, added for a different purpose — see that migration's own
``` docblock), no new table.
**`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.
### This pattern is per-driver, not a shared 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 ## Explicitly out of scope for this pass
- **`Checkout`/`Order` wiring** — how `Checkout` calls into `Payment`, how `Order`/`Checkout` - **`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 payload for export (e.g. `AddressDataProvider::eraseForUser()`, since addresses belong to a
Customer, not an individual). 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 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: 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 // config/core.php
'privacy' => [ 'privacy' => [
'providers' => [ 'providers' => [
\Modules\Core\Privacy\Providers\CustomerDataProvider::class, \Modules\Core\Customer\Privacy\CustomerDataProvider::class,
\Modules\Core\Privacy\Providers\AddressDataProvider::class, \Modules\Core\Customer\Privacy\AddressDataProvider::class,
\Modules\Core\Privacy\Providers\OrderDataProvider::class, \Modules\Core\Order\Privacy\OrderDataProvider::class,
\Modules\Core\Privacy\Providers\CartDataProvider::class, \Modules\Core\Cart\Privacy\CartDataProvider::class,
\Modules\Core\Privacy\Providers\ReviewDataProvider::class, \Modules\Core\Review\Privacy\ReviewDataProvider::class,
// A future module just adds its own provider here. // 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 ## Providers shipped in core
| Provider | `name()` | Covers | Customer-scope | User-scope | | Provider | `name()` | Lives in | 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 | | `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 |
| `AddressDataProvider` | `addresses` | `lunar_addresses` | Erased (deleted outright) | Skipped — belongs to a Customer, not an individual | | `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 |
| `OrderDataProvider` | `orders` | `lunar_orders`, `lunar_order_addresses` | **Pseudonymized, not erased** — see below | Skipped — belongs to a Customer, not an individual | | `AddressDataProvider` | `addresses` | `Modules\Core\Customer\Privacy` | `lunar_addresses` | Erased (deleted outright) | Skipped — belongs to a Customer, not an individual |
| `CartDataProvider` | `carts` | `lunar_cart_addresses` | Erased | 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 |
| `ReviewDataProvider` | `reviews` | `product_reviews` | Skipped — authored by an individual, not a business account | Pseudonymized by matching `reviewer_email`; rating/title/body text kept | | `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 `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. 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 **`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 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 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). `Modules\Core\Auth\Services\UserOtpService` — nothing else changes).
```php ```php
use Modules\Core\Privacy\PrivacyService; use Modules\Core\Privacy\Services\PrivacyService;
$service = app(PrivacyService::class); $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\Mail;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\UserAuthenticated; use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Auth\Events\UserCreated;
use Modules\Core\Auth\Exceptions\OtpThrottledException; use Modules\Core\Auth\Exceptions\OtpThrottledException;
use Modules\Core\Auth\Mail\UserOtpMail; use Modules\Core\Auth\Mail\UserOtpMail;
@@ -75,6 +76,18 @@ class UserOtpService
$model = config('auth.providers.users.model'); $model = config('auth.providers.users.model');
$user = $model::firstOrCreate(['email' => $email]); $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); $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
$user->otp_code = $code; $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; namespace Modules\Core\Catalog\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Lunar\Models\Product; use Lunar\Models\Product;
use Modules\Core\Catalog\Events\ProductDeleted; use Modules\Core\Catalog\Events\ProductDeleted;
use Modules\Core\Catalog\Events\ProductSaved; 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 * Keeps every product's embedded `recommendations` field (see
* ProductIndexer) in sync when a product they recommend changes or is * ProductIndexer) in sync when a product they recommend changes or is
* removed. Unlike Modules\Core\Catalog\Observers\ProductOptionReindexObserver's * 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 * SCOUT_QUEUE is configured) reindex job per matched product — this
* listener itself does no synchronous Meilisearch writing. * listener itself does no synchronous Meilisearch writing.
*/ */
class ReindexProductsRecommendingProduct class ReindexProductsRecommendingProduct implements ShouldQueue
{ {
public function handleSaved(ProductSaved $event): void 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\DataTypes\ShippingOption;
use Lunar\Facades\ShippingManifest; use Lunar\Facades\ShippingManifest;
use Lunar\Models\Cart; use Lunar\Models\Cart;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Cart\Services\CartService; use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Events\BillingAddressSet; use Modules\Core\Checkout\Events\BillingAddressSet;
use Modules\Core\Checkout\Events\PaymentMethodSelected; 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\ShippingAddressSet;
use Modules\Core\Checkout\Events\ShippingOptionSelected; use Modules\Core\Checkout\Events\ShippingOptionSelected;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\NoShippingAddressException;
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException; use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException; use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry; use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache; use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Shipping\Support\FulfillmentType;
/** /**
* Storefront-facing checkout operations, mirroring * Storefront-facing checkout operations, mirroring
@@ -49,9 +53,35 @@ class CheckoutService
private readonly PaymentMethodCache $paymentMethods, 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 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)); Event::dispatch(new ShippingAddressSet($cart, $address));
@@ -141,15 +171,71 @@ class CheckoutService
$cart = $cartBefore->setShippingOption($option); $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)); Event::dispatch(new ShippingOptionSelected($cart, $option));
return $cart; 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 * Every payment method currently offered to the storefront, ordered by
* Modules\Core\Payment\Models\PaymentMethod::position — a row is * 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 * different to an admin diagnosing why a method isn't showing up (see
* docs/payments.md): * docs/payments.md):
* 1. `enabled` — an admin turned it on. * 1. `enabled` — an admin turned it on.
@@ -160,17 +246,61 @@ class CheckoutService
* vanished driver can never silently look "available"). * vanished driver can never silently look "available").
* 3. the resolved driver reports Configurable::isConfigured() — its * 3. the resolved driver reports Configurable::isConfigured() — its
* own runtime requirements (e.g. an API key) are met. * 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> * @return Collection<int, PaymentMethod>
*/ */
public function getPaymentMethods(): Collection public function getPaymentMethods(): Collection
{ {
$fulfillmentType = $this->currentFulfillmentType();
return $this->paymentMethods->all() return $this->paymentMethods->all()
->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null) ->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(); ->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 * Records which payment type the shopper picked (Cart::meta
* ['payment_method']) — read by Modules\Core\Payment\Pipelines\ * ['payment_method']) — read by Modules\Core\Payment\Pipelines\
@@ -3,7 +3,7 @@
namespace Modules\Core\Command; namespace Modules\Core\Command;
use Illuminate\Console\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\Jobs\EraseDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest; use Modules\Core\Privacy\Models\DataErasureRequest;
+23 -13
View File
@@ -102,9 +102,21 @@ class CorePlugin implements Plugin
CustomerResource::class => CustomerErasureRelationsExtension::class, CustomerResource::class => CustomerErasureRelationsExtension::class,
]); ]);
Product::macro('reviews', function (): HasMany { // resolveRelationUsing(), not macro() — Illuminate\Database\Eloquent\
/** @var Product $this */ // Model does not use the Macroable trait in this Laravel version, so
return $this->hasMany(ProductReview::class); // 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 // 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"), // 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 // so this is a MorphMany built by hand rather than a bare Eloquent
// convention lookup. // convention lookup.
Customer::macro('erasureRequests', function (): MorphMany { Customer::resolveRelationUsing('erasureRequests', function (Customer $customer): MorphMany {
/** @var Customer $this */ return $customer->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
return $this->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
}); });
Customer::macro('exportRequests', function (): MorphMany { Customer::resolveRelationUsing('exportRequests', function (Customer $customer): MorphMany {
/** @var Customer $this */ return $customer->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id');
return $this->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id');
}); });
$userModel = config('auth.providers.users.model'); $userModel = config('auth.providers.users.model');
$userModel::macro('erasureRequests', function (): MorphMany { $userModel::resolveRelationUsing('erasureRequests', function ($user): MorphMany {
return $this->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id'); return $user->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id');
}); });
$userModel::macro('exportRequests', function (): MorphMany { $userModel::resolveRelationUsing('exportRequests', function ($user): MorphMany {
return $this->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id'); return $user->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id');
}); });
LunarStaff::addActivitylogExcept([ LunarStaff::addActivitylogExcept([
@@ -6,6 +6,19 @@ use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Customer as CustomerContract; use Lunar\Models\Contracts\Customer as CustomerContract;
use Modules\Core\Auth\Events\UserCreated; 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 class CreateCustomerForUser
{ {
public function handle(UserCreated $event): void public function handle(UserCreated $event): void
@@ -2,6 +2,7 @@
namespace Modules\Core\Customer\Listeners; namespace Modules\Core\Customer\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Lunar\Models\Address; use Lunar\Models\Address;
use Modules\Core\Customer\Events\CustomerAddressCreated; use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted; 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 * passed through explicitly on every call, since these events are
* `web`-guard-caused, not `staff`-guard — see ActivityLogService's own * `web`-guard-caused, not `staff`-guard — see ActivityLogService's own
* docblock for why that parameter exists. * 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( public function __construct(
private readonly ActivityLogService $activityLog, private readonly ActivityLogService $activityLog,
@@ -1,14 +1,14 @@
<?php <?php
namespace Modules\Core\Privacy\Providers; namespace Modules\Core\Customer\Privacy;
use Lunar\Models\Address; use Lunar\Models\Address;
use Modules\Core\Privacy\Contracts\PersonalDataProvider; use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject; use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome; use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult; use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult; use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\UserSubject; use Modules\Core\Privacy\DTOs\UserSubject;
/** /**
* A customer's saved addresses (lunar_addresses) — belong to the Customer * A customer's saved addresses (lunar_addresses) — belong to the Customer
@@ -1,14 +1,14 @@
<?php <?php
namespace Modules\Core\Privacy\Providers; namespace Modules\Core\Customer\Privacy;
use Lunar\Models\Customer; use Lunar\Models\Customer;
use Modules\Core\Privacy\Contracts\PersonalDataProvider; use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject; use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome; use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult; use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult; use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\UserSubject; use Modules\Core\Privacy\DTOs\UserSubject;
/** /**
* The Customer record itself (lunar_customers) and, on the User side, the User's * The Customer record itself (lunar_customers) and, on the User side, the User's
@@ -108,6 +108,13 @@ class CustomerDataProvider implements PersonalDataProvider
$user->update([ $user->update([
'name' => null, 'name' => null,
'email' => "erased-user-{$user->id}@example.invalid", '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); return new ProviderErasureResult('customer', ErasureOutcome::Erased);
+4 -2
View File
@@ -2,6 +2,8 @@
namespace Modules\Core\Export; namespace Modules\Core\Export;
use Closure;
/** /**
* One column in a CsvWriter schema: a header label plus a closure that pulls this * 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 * 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 final class CsvColumn
{ {
/** /**
* @param \Closure(mixed): (string|int|float|null) $value * @param Closure(mixed):((string|int|float|null)) $value
*/ */
public function __construct( public function __construct(
public readonly string $header, public readonly string $header,
public readonly \Closure $value, public readonly Closure $value,
) {} ) {}
} }
@@ -2,12 +2,21 @@
namespace Modules\Core\Localization\Listeners; namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Localization\Events\LanguageCreated; use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted; use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated; use Modules\Core\Localization\Events\LanguageUpdated;
use Modules\Core\Localization\Services\LanguageCache; 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) {} public function __construct(private readonly LanguageCache $languages) {}
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Listeners; namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Modules\Core\Localization\Events\TranslationCreated; use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted; 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). * `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 * This listener flushes every group+locale combination touched by either the
* old or new state so nothing can remain stale. * 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 public function handle(TranslationCreated|TranslationUpdated|TranslationDeleted $event): void
{ {
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Listeners; namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Modules\Core\Localization\Events\TranslationCreated; use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted; use Modules\Core\Localization\Events\TranslationDeleted;
@@ -9,7 +10,12 @@ use Modules\Core\Localization\Events\TranslationUpdated;
use Modules\Core\Logging\ActivityLogService; use Modules\Core\Logging\ActivityLogService;
use Spatie\TranslationLoader\LanguageLine; 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( public function __construct(
private readonly ActivityLogService $activityLog, private readonly ActivityLogService $activityLog,
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Listeners; namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Modules\Core\Localization\Events\LanguageUpdated; use Modules\Core\Localization\Events\LanguageUpdated;
use Spatie\TranslationLoader\LanguageLine; use Spatie\TranslationLoader\LanguageLine;
@@ -12,8 +13,15 @@ use Spatie\TranslationLoader\LanguageLine;
* getTranslationsForGroup($newCode, ...) would silently return nothing for * getTranslationsForGroup($newCode, ...) would silently return nothing for
* that locale even though the translated content still exists. Move the * that locale even though the translated content still exists. Move the
* text.{oldCode} key to text.{newCode} on every affected row instead. * 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 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; namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderDispatched; use Modules\Core\Order\Events\OrderDispatched;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter; use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; 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 * The automatic half of "Dispatched" — the manual fallback is the staff
* "Update Status" action (Modules\Core\Shipping\Extensions\ * "Update Status" action (Modules\Core\Shipping\Extensions\
* OrderViewExtension). Listens to ShipmentStatusUpdatedByCarrier directly, * OrderViewExtension). Listens to ShipmentStatusUpdatedByCarrier directly,
@@ -19,14 +26,17 @@ use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
* carrier that skips straight there without a distinct collection * carrier that skips straight there without a distinct collection
* checkpoint. * checkpoint.
* *
* Guarded to only fire from 'ready_for_dispatch' — a late/duplicate * Guarded by OrderStatusFlow::isValidTransition() rather than a hardcoded
* checkpoint, or an order the manual action already advanced, is a * "only fire from 'ready_for_dispatch'" comparison — the single source of
* silent no-op. * 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( public function __construct(
private readonly OrderStatusWriter $writer, private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {} ) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void public function handle(ShipmentStatusUpdatedByCarrier $event): void
@@ -38,7 +48,7 @@ class AdvanceFulfillmentOnCarrierCheckpoint
$order = $event->shipmentInfo->shipment->order; $order = $event->shipmentInfo->shipment->order;
if (! $order || $order->status !== 'ready_for_dispatch') { if (! $order || ! $this->flow->isValidTransition($order, 'dispatched')) {
return; return;
} }
@@ -2,10 +2,18 @@
namespace Modules\Core\Order\Listeners; namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderDelivered; use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter; 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 * Writes `status` to 'delivered' once a carrier confirms delivery, rather
* than jumping straight to 'completed'. Carrier orders get a return * than jumping straight to 'completed'. Carrier orders get a return
* window between delivery and completion (see Modules\Core\Order\ * 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 * OrderDelivered — deriving "was this delivered" and acting on it by
* writing `status` are deliberately two different listeners. * 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 * 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( public function __construct(
private readonly OrderStatusWriter $writer, private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {} ) {}
public function handle(OrderDelivered $event): void public function handle(OrderDelivered $event): void
{ {
$order = $event->order; $order = $event->order;
if ($order->status !== 'dispatched') { if (! $this->flow->isValidTransition($order, 'delivered')) {
return; return;
} }
@@ -2,13 +2,8 @@
namespace Modules\Core\Order\Listeners; namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Order\Services\OrderPaymentResolutionService;
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\Payment\Events\PaymentAuthorized; use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured; use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded; use Modules\Core\Payment\Events\PaymentRefunded;
@@ -17,38 +12,24 @@ use Modules\Core\Payment\Events\PaymentRefunded;
* Registered against PaymentCaptured, PaymentAuthorized, AND * Registered against PaymentCaptured, PaymentAuthorized, AND
* PaymentRefunded (see OrderServiceProvider). * PaymentRefunded (see OrderServiceProvider).
* *
* PaymentCaptured writes both Order::paid/paid_at (via * A thin reactor — resolves which Order this outcome belongs to (Payment
* OrderStatusWriter::markPaid()) AND advances `status` out of * has no concept of an Order, so this reads $event->context['order_id'])
* 'awaiting_payment' to the next step in the order's flow (see * and hands off to Modules\Core\Order\Services\
* OrderStatusFlow::nextOptions()) — re-confirmed with the user: a * OrderPaymentResolutionService for the actual decisions: whether to mark
* captured payment, manual or via Stripe's webhook, should never leave an * the order paid, whether/how far to advance `status`, and what a refund
* order sitting at 'awaiting_payment'. Only fires when status is still * does to it. See that service's own docblock, and its methods' own
* exactly 'awaiting_payment', so a duplicate/delayed capture event never * docblocks, for the full business reasoning (re-confirmed with the
* regresses an order staff already advanced further. PaymentAuthorized * user): a captured payment, manual or via Stripe's webhook, should
* only marks paid — an authorization is not yet captured funds, so * never leave an order sitting at 'awaiting_payment'; an authorization
* status stays put until the actual capture. * 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.
* 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.
* *
* Deliberately does NOT react to PaymentVoided. * Deliberately does NOT react to PaymentVoided.
*/ */
class ApplyResolvedPaymentStatus class ApplyResolvedPaymentStatus
{ {
public function __construct( public function __construct(
private readonly OrderStatusWriter $writer, private readonly OrderPaymentResolutionService $resolution,
private readonly OrderStatusFlow $flow,
) {} ) {}
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
@@ -62,58 +43,11 @@ class ApplyResolvedPaymentStatus
$order = Order::findOrFail($orderId); $order = Order::findOrFail($orderId);
if ($event instanceof PaymentRefunded) { if ($event instanceof PaymentRefunded) {
$this->applyRefund($order, $event); $this->resolution->resolveRefund($order, $event::class);
return; return;
} }
$wasPlaced = ! blank($order->placed_at); $this->resolution->resolveCaptureOrAuthorization($order, $event::class, isCapture: $event instanceof PaymentCaptured);
$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);
}
} }
} }
@@ -4,9 +4,19 @@ namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderCompleted; use Modules\Core\Order\Events\OrderCompleted;
use Modules\Core\Order\Events\OrderPickedUp; use Modules\Core\Order\Events\OrderPickedUp;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter; 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 * The store-pickup mirror of AdvanceFulfillmentOnDelivered — reacts to
* OrderPickedUp (dispatched by Modules\Core\Order\Services\ * OrderPickedUp (dispatched by Modules\Core\Order\Services\
* OrderFulfillmentService::markPickedUp() the moment staff confirm the * 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' * business design — unlike the carrier branch, there is no 'delivered'
* intermediate value on this path. * intermediate value on this path.
* *
* Guarded to only fire from 'picked_up' — a duplicate dispatch (e.g. a * Guarded by OrderStatusFlow::isValidTransition() rather than a hardcoded
* stale page re-submitting the action) is a silent no-op. * "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 class CompleteOrderOnPickedUp
{ {
public function __construct( public function __construct(
private readonly OrderStatusWriter $writer, private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {} ) {}
public function handle(OrderPickedUp $event): void public function handle(OrderPickedUp $event): void
{ {
$order = $event->order; $order = $event->order;
if ($order->status !== 'picked_up') { if (! $this->flow->isValidTransition($order, 'completed')) {
return; return;
} }
@@ -2,63 +2,38 @@
namespace Modules\Core\Order\Listeners; namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\DB; use Modules\Core\Catalog\Services\StockService;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Checkout\Events\OrderPlaced;
/** /**
* The only place ProductVariant::stock is written as a result of an order — * Deliberately NOT queued — unlike this codebase's other queued side
* fires once per order regardless of capture_mode/driver, same reasoning as * effects (cache flushes, audit logs, search reindexes), a stalled queue
* Modules\Core\Order\Notifications\OrderPlacedNotification: OrderPlaced is * here isn't just cosmetic staleness: it widens the window in which
* dispatched exactly once, from the one place an order's placed_at * another order can be accepted against stock this order already
* actually gets set (Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus), * committed (Lunar has no stock-reservation step at checkout time to
* so this can't double-decrement across a capture/authorize/refund sequence * begin with — see StockService's own "Never lets stock go negative"
* the way listening to PaymentCaptured directly could. * 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 * The actual decrement logic lives in Modules\Core\Catalog\Services\
* 'backorder' variants are deliberately allowed to sell past (or without * StockService — stock (the column, its invariants) is a Catalog concern,
* regard to) their stock count already (see ProductVariant:: * not an Order one; this listener is just the "an order was placed"
* canBeFulfilledAtQuantity()), so decrementing their stock would just make * trigger. Fires once per order regardless of capture_mode/driver, same
* that column an inaccurate, decreasingly-negative number with no purchasing * reasoning as Modules\Core\Order\Notifications\OrderPlacedNotification:
* consequence. Only `OrderLine::type === 'physical'` lines are considered — * OrderPlaced is dispatched exactly once, from the one place an order's
* a digital line has no stock to decrement (ProductVariant::getType()). * placed_at actually gets set (Modules\Core\Order\Listeners\
* * ApplyResolvedPaymentStatus), so this can't double-decrement across a
* A single UPDATE per variant (`DB::table(...)->decrement()`), not a * capture/authorize/refund sequence the way listening to PaymentCaptured
* read-then-write on the Eloquent model — avoids a lost-update race between * directly could.
* 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 DecrementStockOnOrderPlaced class DecrementStockOnOrderPlaced
{ {
public function handle(OrderPlaced $event): void public function handle(OrderPlaced $event): void
{ {
$lines = $event->order->lines() app(StockService::class)->decrementForOrder($event->order);
->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();
} }
} }
@@ -2,17 +2,23 @@
namespace Modules\Core\Order\Listeners; namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderDelivered; use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; 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 * Translates a carrier tracking checkpoint into OrderDelivered — the event
* OrderDeliveredNotification (via NotificationRegistry) actually listens * OrderDeliveredNotification (via NotificationRegistry) actually listens
* to. Kept separate from the notification itself so the "is this checkpoint * to. Kept separate from the notification itself so the "is this checkpoint
* a delivery" filtering doesn't leak into notification code. * a delivery" filtering doesn't leak into notification code.
*/ */
class DeriveOrderDeliveredFromShipment class DeriveOrderDeliveredFromShipment implements ShouldQueue
{ {
public function handle(ShipmentStatusUpdatedByCarrier $event): void public function handle(ShipmentStatusUpdatedByCarrier $event): void
{ {
@@ -2,20 +2,28 @@
namespace Modules\Core\Order\Listeners; 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\Order\Services\OrderStatusWriter;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; 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 * Wires TrackingStatus::Failed to the 'delivery_failed' status for the
* first time — previously an unused enum case. Guarded to only fire from * first time — previously an unused enum case. Guarded by
* 'dispatched': a stale/duplicate checkpoint, or an order a manual action * OrderStatusFlow::isValidTransition() rather than a hardcoded "only fire
* already moved past, is a silent no-op. * 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( public function __construct(
private readonly OrderStatusWriter $writer, private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {} ) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void public function handle(ShipmentStatusUpdatedByCarrier $event): void
@@ -26,7 +34,7 @@ class MarkDeliveryFailedOnCarrierCheckpoint
$order = $event->shipmentInfo->shipment->order; $order = $event->shipmentInfo->shipment->order;
if (! $order || $order->status !== 'dispatched') { if (! $order || ! $this->flow->isValidTransition($order, 'delivery_failed')) {
return; 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; 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 * Writes the Transaction row for a successful payment outcome — the
* "record what happened" half of reacting to Payment's events, separate * "record what happened" half of reacting to Payment's events, separate
* from Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus's "update * from Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus's "update
@@ -2,11 +2,16 @@
namespace Modules\Core\Order\Listeners; namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderPaidChanged; use Modules\Core\Order\Events\OrderPaidChanged;
use Modules\Core\Order\Events\OrderStatusChanged; use Modules\Core\Order\Events\OrderStatusChanged;
use Modules\Core\Order\Services\OrderStatusTransitionRecorder; 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 — * The one place order_status_transitions rows actually get written —
* listens to OrderStatusChanged (every write of the single `status` * listens to OrderStatusChanged (every write of the single `status`
* column, via Modules\Core\Order\Services\OrderStatusWriter::write()) and * 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) * one consistent audit trail entry ('paid', with a null from_status)
* rather than a second, separate table. * rather than a second, separate table.
*/ */
class RecordStatusTransition class RecordStatusTransition implements ShouldQueue
{ {
public function __construct( public function __construct(
private readonly OrderStatusTransitionRecorder $recorder, 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\OrderPickedUp;
use Modules\Core\Order\Events\OrderReadyForDispatch; use Modules\Core\Order\Events\OrderReadyForDispatch;
use Modules\Core\Order\Events\OrderReadyForPickup; 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\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DTOs\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Throwable; use Throwable;
@@ -31,6 +33,7 @@ class OrderFulfillmentService
public function __construct( public function __construct(
private readonly OrderStatusWriter $writer, private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow, private readonly OrderStatusFlow $flow,
private readonly TransactionRecorder $transactions,
) {} ) {}
public function markReady(Order $order): OrderFulfillmentResult public function markReady(Order $order): OrderFulfillmentResult
@@ -121,6 +124,39 @@ class OrderFulfillmentService
return OrderFulfillmentResult::failure('This order cannot be marked paid right now.'); 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'); $this->writer->markPaid($order, self::class.'::markPaid');
return OrderFulfillmentResult::success('Order marked as paid.'); 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); 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 private function label(string $status): string
{ {
return (string) str($status)->replace('_', ' ')->title(); 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 Illuminate\Support\Str;
use Lunar\DataTypes\Price; use Lunar\DataTypes\Price;
use Modules\Core\Payment\Contracts\Configurable; use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\Contracts\SupportsPay; use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus; 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 * 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\ * marking it received (Modules\Core\Order\Services\
* OrderFulfillmentService::markPaid()), offered by the single "Update * OrderFulfillmentService::markPaid()), offered by the single "Update
* Status" action at any time, independent of status. * 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 public function isConfigured(): bool
{ {
return true; 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 public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{ {
return new PaymentResult( $result = new PaymentResult(
status: PaymentResultStatus::Pending, status: PaymentResultStatus::Pending,
reference: 'cod-'.Str::uuid(), reference: 'cod-'.Str::uuid(),
amount: $amount, 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 Illuminate\Support\Str;
use Lunar\DataTypes\Price; use Lunar\DataTypes\Price;
use Modules\Core\Payment\Contracts\Configurable; use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\Contracts\SupportsPay; use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus; 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, * none) purely so PaymentCaptured, and anything downstream keying on it,
* have something to identify this attempt by. * 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. * Always true — no external dependency to be missing.
@@ -35,6 +36,16 @@ class OfflinePaymentDriver implements Configurable, SupportsPay
return true; 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 public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{ {
$reference = 'offline-'.Str::uuid(); $reference = 'offline-'.Str::uuid();
@@ -115,6 +115,25 @@ class StripePaymentDriver implements
$params['payment_method'] = $data['payment_method']; $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 { try {
$paymentIntent = $this->stripe->getClient()->paymentIntents->create($params); $paymentIntent = $this->stripe->getClient()->paymentIntents->create($params);
} catch (ApiErrorException $e) { } 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; namespace Modules\Core\Payment\Filament\Resources;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Component; use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Utilities\Get;
use InvalidArgumentException;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Columns\ToggleColumn;
@@ -14,6 +17,7 @@ use Filament\Tables\Table;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Lunar\Admin\Support\Forms\Components\TranslatedText; use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Modules\Core\Payment\Contracts\Configurable; use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\SupportsAuthorization;
use Modules\Core\Payment\Events\PaymentMethodsReordered; use Modules\Core\Payment\Events\PaymentMethodsReordered;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
@@ -60,9 +64,9 @@ class PaymentMethodResource extends Resource
{ {
protected static ?string $model = PaymentMethod::class; 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'; protected static ?string $modelLabel = 'Payment Method';
@@ -144,7 +148,31 @@ class PaymentMethodResource extends Resource
]) ])
->default('pay') ->default('pay')
->live() ->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') return Select::make('driver')
->label('Driver') ->label('Driver')
->options(fn () => app(PaymentDriverRegistry::class)->labels()) ->options(fn () => app(PaymentDriverRegistry::class)->labels())
->live()
->required(); ->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 public static function getPages(): array
{ {
return [ return [
@@ -180,7 +222,7 @@ class PaymentMethodResource extends Resource
->icon('heroicon-o-pencil-square') ->icon('heroicon-o-pencil-square')
->schema(static::getFormComponents()) ->schema(static::getFormComponents())
->fillForm(fn (PaymentMethod $record) => $record->only([ ->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)); ->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data));
} }
@@ -2,8 +2,10 @@
namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages; namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Lunar\Models\Language;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentMethodService; use Modules\Core\Payment\Services\PaymentMethodService;
@@ -15,12 +17,21 @@ class ListPaymentMethods extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() CreateAction::make()
->schema(PaymentMethodResource::getFormComponents()) ->schema(PaymentMethodResource::getFormComponents())
->fillForm(fn () => [ ->fillForm(fn () => [
'position' => (PaymentMethod::max('position') ?? 0) + 1, 'position' => (PaymentMethod::max('position') ?? 0) + 1,
'enabled' => false, 'enabled' => false,
'data' => [], '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 // Every PaymentMethod write goes through PaymentMethodService
// — see PaymentMethodResource's own docblock — so this // — see PaymentMethodResource's own docblock — so this
@@ -2,6 +2,7 @@
namespace Modules\Core\Payment\Listeners; namespace Modules\Core\Payment\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Logging\ActivityLogService; use Modules\Core\Logging\ActivityLogService;
use Modules\Core\Payment\Events\PaymentMethodCreated; use Modules\Core\Payment\Events\PaymentMethodCreated;
use Modules\Core\Payment\Events\PaymentMethodDeleted; use Modules\Core\Payment\Events\PaymentMethodDeleted;
@@ -9,6 +10,8 @@ use Modules\Core\Payment\Events\PaymentMethodUpdated;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
/** /**
* Queued — a pure audit-log write with no same-request reader.
*
* Same pattern as Localization\Listeners\LogTranslationActivity — routes * Same pattern as Localization\Listeners\LogTranslationActivity — routes
* PaymentMethodService's own events through the existing * PaymentMethodService's own events through the existing
* Logging\ActivityLogService instead of PaymentMethod separately opting * 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 * forcing into a one-subject shape or adding a new method to the shared
* service for. * service for.
*/ */
class LogPaymentMethodActivity class LogPaymentMethodActivity implements ShouldQueue
{ {
public function __construct( public function __construct(
private readonly ActivityLogService $activityLog, 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; namespace Modules\Core\Privacy\Contracts;
use Modules\Core\Privacy\CustomerSubject; use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\ProviderErasureResult; use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult; use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\UserSubject; use Modules\Core\Privacy\DTOs\UserSubject;
/** /**
* Implemented by any module that holds personal data and wants it included in * Implemented by any module that holds personal data and wants it included in
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Privacy; namespace Modules\Core\Privacy\DTOs;
use Lunar\Models\Customer; use Lunar\Models\Customer;
@@ -1,6 +1,8 @@
<?php <?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 * Every registered provider's outcome, assembled into one right-of-erasure response
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Privacy; namespace Modules\Core\Privacy\DTOs;
/** /**
* Every registered provider's export, assembled into one right-of-access response. * Every registered provider's export, assembled into one right-of-access response.
@@ -25,7 +25,13 @@ class ExportReport
$data = []; $data = [];
foreach ($this->results as $result) { 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; return $data;
@@ -1,6 +1,8 @@
<?php <?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 * One provider's outcome on an erasure request. `reason` is required whenever
@@ -1,11 +1,17 @@
<?php <?php
namespace Modules\Core\Privacy; namespace Modules\Core\Privacy\DTOs;
/** /**
* One provider's contribution to a right-of-access export. `provider` is a short, * 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 * 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. * 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 class ProviderExportResult
{ {
@@ -15,5 +21,6 @@ class ProviderExportResult
public function __construct( public function __construct(
public readonly string $provider, public readonly string $provider,
public readonly array $data, public readonly array $data,
public readonly ?string $error = null,
) {} ) {}
} }
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Privacy; namespace Modules\Core\Privacy\DTOs;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Base\LunarUser; 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 <?php
namespace Modules\Core\Privacy; namespace Modules\Core\Privacy\Enums;
enum ErasureRequestStatus: string enum ErasureRequestStatus: string
{ {
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Privacy; namespace Modules\Core\Privacy\Enums;
enum ExportRequestStatus: string 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; namespace Modules\Core\Privacy\Events;
use Modules\Core\Privacy\ExportReport; use Modules\Core\Privacy\DTOs\ExportReport;
use Modules\Core\Privacy\Models\DataExportRequest; use Modules\Core\Privacy\Models\DataExportRequest;
/** /**
@@ -8,7 +8,7 @@ use Filament\Notifications\Notification;
use Lunar\Admin\Support\Extending\BaseExtension; use Lunar\Admin\Support\Extending\BaseExtension;
use Lunar\Models\Customer; use Lunar\Models\Customer;
use Modules\Core\Auth\Models\Staff; 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 * Adds "Request erasure" / "Request export" header actions to the Customer
@@ -33,7 +33,7 @@ class CustomerErasureActionsExtension extends BaseExtension
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->modalDescription('Opens a cancellable grace-period erasure request for this Customer account. No linked User\'s login is affected.') ->modalDescription('Opens a cancellable grace-period erasure request for this Customer account. No linked User\'s login is affected.')
->form([ ->schema([
Checkbox::make('immediate') Checkbox::make('immediate')
->label('Erase immediately (skip the 30-day grace period)') ->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.') ->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; namespace Modules\Core\Privacy\Filament\Resources;
use Filament\Forms\Components\KeyValue; use Filament\Schemas\Schema;
use Filament\Forms\Components\Placeholder; use Filament\Actions\ViewAction;
use Filament\Forms\Components\TextInput; use Filament\Actions\Action;
use Filament\Forms\Form; 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\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Lunar\Models\Customer; 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\Filament\Resources\DataErasureRequestResource\Pages;
use Modules\Core\Privacy\Models\DataErasureRequest; 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 * 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 $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 $modelLabel = 'Erasure Request';
protected static ?string $pluralModelLabel = 'Erasure Requests'; 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([ return $schema->components([
Placeholder::make('subject') Section::make('Request')
->label('Subject') ->icon('heroicon-o-shield-exclamation')
->content(fn (DataErasureRequest $record) => sprintf( ->columns(4)
'%s (%s)', ->components([
DataErasureRequest::displayNameFor($record->subject), TextEntry::make('subject')
$record->isForCustomer() ? 'Customer account' : 'Individual user' ->label('Subject')
)), ->state(fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->subject))
Placeholder::make('requested_by') ->weight('bold')
->label('Requested by') ->size('lg'),
->content(fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->requestedBy)), TextEntry::make('subject_type')
TextInput::make('email') ->label('Scope')
->label('Email (snapshot at request time)') ->formatStateUsing(fn (DataErasureRequest $record) => $record->isForCustomer() ? 'Customer account' : 'Individual user')
->disabled(), ->badge()
Placeholder::make('status') ->icon(fn (DataErasureRequest $record) => $record->isForCustomer() ? 'heroicon-o-building-office' : 'heroicon-o-user')
->content(fn (DataErasureRequest $record) => $record->status->value), ->color(fn (DataErasureRequest $record) => $record->isForCustomer() ? 'info' : 'warning'),
Placeholder::make('scheduled_for') TextEntry::make('email')
->label('Scheduled for') ->label('Email (snapshot at request time)')
->content(fn (DataErasureRequest $record) => $record->scheduled_for->toDayDateTimeString()), ->icon('heroicon-o-envelope')
Placeholder::make('cancelled_at') ->copyable(),
->label('Cancelled at') TextEntry::make('requested_by')
->content(fn (DataErasureRequest $record) => $record->cancelled_at?->toDayDateTimeString() ?? '—'), ->label('Requested by')
Placeholder::make('completed_at') ->state(fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->requestedBy))
->label('Completed at') ->icon('heroicon-o-user-circle'),
->content(fn (DataErasureRequest $record) => $record->completed_at?->toDayDateTimeString() ?? '—'), TextEntry::make('status')
Placeholder::make('caused_by') ->badge()
->label('Caused by (cascade)') ->formatStateUsing(fn (ErasureRequestStatus $state) => ucfirst($state->value))
->content(fn (DataErasureRequest $record) => $record->causedBy ->color(fn (ErasureRequestStatus $state) => match ($state) {
? "Request #{$record->causedBy->id} (".DataErasureRequest::displayNameFor($record->causedBy->subject).')' ErasureRequestStatus::Pending => 'warning',
: 'Not a cascade — directly requested') ErasureRequestStatus::Cancelled => 'gray',
->visible(fn (DataErasureRequest $record) => $record->caused_by_request_id !== null), ErasureRequestStatus::Completed => 'success',
KeyValue::make('report') }),
->label('Per-provider outcome') TextEntry::make('created_at')
->disabled() ->label('Requested at')
->dateTime()
->icon('heroicon-o-calendar'),
TextEntry::make('scheduled_for')
->label('Scheduled for')
->dateTime()
->icon('heroicon-o-calendar-days'),
TextEntry::make('completed_at')
->label('Completed at')
->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) ->visible(fn (DataErasureRequest $record) => $record->report !== null)
->helperText('Each provider\'s outcome once the erasure completed — see docs/privacy.md.'), ->components([
])->columns(2); 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 public static function table(Table $table): Table
@@ -139,7 +204,7 @@ class DataErasureRequestResource extends Resource
]; ];
}), }),
]) ])
->actions([ ->recordActions([
ViewAction::make(), ViewAction::make(),
Action::make('cancel') Action::make('cancel')
->label('Cancel') ->label('Cancel')
@@ -154,8 +219,8 @@ class DataErasureRequestResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListDataErasureRequests::route('/'), 'index' => ListDataErasureRequests::route('/'),
'view' => Pages\ViewDataErasureRequest::route('/{record}'), 'view' => ViewDataErasureRequest::route('/{record}'),
]; ];
} }
@@ -2,16 +2,18 @@
namespace Modules\Core\Privacy\Filament\Resources; namespace Modules\Core\Privacy\Filament\Resources;
use Filament\Forms\Components\Placeholder; use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput; use Filament\Actions\ViewAction;
use Filament\Forms\Form; 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\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; 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\Filament\Resources\DataExportRequestResource\Pages;
use Modules\Core\Privacy\Models\DataErasureRequest; use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest; use Modules\Core\Privacy\Models\DataExportRequest;
@@ -25,36 +27,85 @@ class DataExportRequestResource extends Resource
{ {
protected static ?string $model = DataExportRequest::class; 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 $modelLabel = 'Export Request';
protected static ?string $pluralModelLabel = 'Export Requests'; 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([ return $schema->components([
Placeholder::make('subject') Section::make('Request')
->label('Subject') ->icon('heroicon-o-arrow-down-tray')
->content(fn (DataExportRequest $record) => sprintf( ->columns(4)
'%s (%s)', ->components([
DataErasureRequest::displayNameFor($record->subject), TextEntry::make('subject')
$record->isForCustomer() ? 'Customer account' : 'Individual user' ->label('Subject')
)), ->state(fn (DataExportRequest $record) => DataErasureRequest::displayNameFor($record->subject))
TextInput::make('email') ->weight('bold')
->label('Email (snapshot at request time)') ->size('lg'),
->disabled(), TextEntry::make('subject_type')
Placeholder::make('status') ->label('Scope')
->content(fn (DataExportRequest $record) => $record->status->value), ->formatStateUsing(fn (DataExportRequest $record) => $record->isForCustomer() ? 'Customer account' : 'Individual user')
Placeholder::make('completed_at') ->badge()
->label('Completed at') ->icon(fn (DataExportRequest $record) => $record->isForCustomer() ? 'heroicon-o-building-office' : 'heroicon-o-user')
->content(fn (DataExportRequest $record) => $record->completed_at?->toDayDateTimeString() ?? '—'), ->color(fn (DataExportRequest $record) => $record->isForCustomer() ? 'info' : 'warning'),
Placeholder::make('file_path') TextEntry::make('email')
->label('Export file') ->label('Email (snapshot at request time)')
->content(fn (DataExportRequest $record) => $record->file_path ?? 'Not generated yet'), ->icon('heroicon-o-envelope')
])->columns(2); ->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')
->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 public static function table(Table $table): Table
@@ -100,21 +151,17 @@ class DataExportRequestResource extends Resource
ExportRequestStatus::Failed->value => 'Failed', ExportRequestStatus::Failed->value => 'Failed',
]), ]),
]) ])
->actions([ ->recordActions([
ViewAction::make(), ViewAction::make(),
Action::make('download') self::downloadAction(),
->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 getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListDataExportRequests::route('/'), 'index' => ListDataExportRequests::route('/'),
'view' => Pages\ViewDataExportRequest::route('/{record}'), 'view' => ViewDataExportRequest::route('/{record}'),
]; ];
} }
@@ -8,4 +8,11 @@ use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource;
class ViewDataExportRequest extends ViewRecord class ViewDataExportRequest extends ViewRecord
{ {
protected static string $resource = DataExportRequestResource::class; 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\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Modules\Core\Privacy\Models\DataErasureRequest; 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 * Runs PrivacyService::completeErasure() for one due DataErasureRequest, dispatched
+44 -9
View File
@@ -2,19 +2,23 @@
namespace Modules\Core\Privacy\Jobs; namespace Modules\Core\Privacy\Jobs;
use Throwable;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Event; 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\Events\PersonalDataGathered;
use Modules\Core\Privacy\ExportReport; use Modules\Core\Privacy\DTOs\ExportReport;
use Modules\Core\Privacy\ExportRequestStatus; use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
use Modules\Core\Privacy\Models\DataExportRequest; use Modules\Core\Privacy\Models\DataExportRequest;
use Modules\Core\Privacy\PrivacyManager; use Modules\Core\Privacy\Services\PrivacyManager;
use Modules\Core\Privacy\UserSubject; use Modules\Core\Privacy\DTOs\UserSubject;
/** /**
* Gathers every registered PersonalDataProvider's export data for one request, all * 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. * generated PDF), that's the point to reconsider — not before.
* *
* Calls each provider's *ForCustomer() or *ForUser() method depending on the * 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". * docs/privacy.md "User-scope vs Customer-scope".
* *
* Writing the gathered data to a file is intentionally NOT done here — see * 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()) { if ($this->request->isForCustomer()) {
$subject = new CustomerSubject(customerId: $this->request->subject_id); $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 { } else {
$subject = new UserSubject(userId: $this->request->subject_id, email: $this->request->email); $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( 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]); $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 Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Auth\Events\UserAuthenticated; 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\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 * 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\Base\LunarUser;
use Lunar\Models\Customer; use Lunar\Models\Customer;
use Modules\Core\Privacy\Events\UserErasureRequested; 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, * 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\Export\CsvWriter;
use Modules\Core\Privacy\Events\PersonalDataExportFileWritten; use Modules\Core\Privacy\Events\PersonalDataExportFileWritten;
use Modules\Core\Privacy\Events\PersonalDataGathered; use Modules\Core\Privacy\Events\PersonalDataGathered;
use Modules\Core\Privacy\ExportRequestStatus; use Modules\Core\Privacy\Enums\ExportRequestStatus;
use ZipArchive; use ZipArchive;
/** /**
@@ -19,9 +19,11 @@ use ZipArchive;
* listener) without touching how the data is gathered. * listener) without touching how the data is gathered.
* *
* Column schema: every provider's data is either a list of associative arrays * 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 * (rows directly) or a single associative array (one row) — see the providers
* Modules\Core\Privacy\Providers, all of which return exactly one of those two * registered in config('core.privacy.providers'), each living in its own owning
* shapes. Any nested array value within a row (e.g. an order's `addresses`) is * 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 — * JSON-encoded into that one cell rather than exploded into further columns —
* CsvWriter's generic stringify() behavior, not special-cased here. * 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\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Lunar\Models\Customer; 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 * A pending, cancelled, or completed right-of-erasure request — the grace-period
* record between "subject/staff asked for this" and "providers actually erased * 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). * these).
* *
* `subject` is polymorphic — either a Lunar Customer (business account) or a User * `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\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Lunar\Models\Customer; 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 * 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; namespace Modules\Core\Privacy\RelationManagers;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; 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\Filament\Resources\DataErasureRequestResource;
use Modules\Core\Privacy\Models\DataErasureRequest; 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 * Lists erasure requests where the record being viewed (Customer or User) is the
@@ -65,7 +65,7 @@ class ErasureRequestsRelationManager extends RelationManager
]), ]),
]) ])
->headerActions([]) ->headerActions([])
->actions([ ->recordActions([
ViewAction::make() ViewAction::make()
->url(fn (DataErasureRequest $record) => DataErasureRequestResource::getUrl('view', ['record' => $record])), ->url(fn (DataErasureRequest $record) => DataErasureRequestResource::getUrl('view', ['record' => $record])),
Action::make('cancel') Action::make('cancel')
@@ -2,13 +2,13 @@
namespace Modules\Core\Privacy\RelationManagers; namespace Modules\Core\Privacy\RelationManagers;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; 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\Filament\Resources\DataExportRequestResource;
use Modules\Core\Privacy\Models\DataExportRequest; use Modules\Core\Privacy\Models\DataExportRequest;
@@ -56,7 +56,7 @@ class ExportRequestsRelationManager extends RelationManager
]), ]),
]) ])
->headerActions([]) ->headerActions([])
->actions([ ->recordActions([
ViewAction::make() ViewAction::make()
->url(fn (DataExportRequest $record) => DataExportRequestResource::getUrl('view', ['record' => $record])), ->url(fn (DataExportRequest $record) => DataExportRequestResource::getUrl('view', ['record' => $record])),
Action::make('download') Action::make('download')
@@ -2,16 +2,16 @@
namespace Modules\Core\Privacy\RelationManagers; namespace Modules\Core\Privacy\RelationManagers;
use Filament\Actions\Action;
use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\Checkbox;
use Filament\Infolists\Components\RepeatableEntry; use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Modules\Core\Customer\RelationManagers\UserRelationManager as CoreUserRelationManager; use Modules\Core\Customer\RelationManagers\UserRelationManager as CoreUserRelationManager;
use Modules\Core\Privacy\Models\DataErasureRequest; 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: * Extends core's own Customer -> User relation manager to add:
@@ -31,7 +31,7 @@ class UserRelationManager extends CoreUserRelationManager
{ {
$table = parent::getDefaultTable($table); $table = parent::getDefaultTable($table);
return $table->actions([ return $table->recordActions([
...$table->getActions(), ...$table->getActions(),
Action::make('privacyRequests') Action::make('privacyRequests')
->label('Privacy Requests') ->label('Privacy Requests')
@@ -39,14 +39,14 @@ class UserRelationManager extends CoreUserRelationManager
->modalHeading(fn (Model $record) => "Privacy requests for {$record->name}") ->modalHeading(fn (Model $record) => "Privacy requests for {$record->name}")
->modalSubmitAction(false) ->modalSubmitAction(false)
->modalCancelActionLabel('Close') ->modalCancelActionLabel('Close')
->infolist(fn (Model $record) => $this->requestsInfolist($record)), ->schema(fn (Model $record) => $this->requestsInfolist($record)),
Action::make('requestErasure') Action::make('requestErasure')
->label('Request Erasure') ->label('Request Erasure')
->icon('heroicon-o-shield-exclamation') ->icon('heroicon-o-shield-exclamation')
->color('danger') ->color('danger')
->requiresConfirmation() ->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.') ->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') Checkbox::make('immediate')
->label('Erase immediately (skip the 30-day grace period)') ->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.') ->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 <?php
namespace Modules\Core\Privacy; namespace Modules\Core\Privacy\Services;
use LogicException;
use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Container\Container;
use Modules\Core\Privacy\Contracts\PersonalDataProvider; use Modules\Core\Privacy\Contracts\PersonalDataProvider;
@@ -41,7 +42,7 @@ class PrivacyManager
$duplicates = array_diff_assoc($names, array_unique($names)); $duplicates = array_diff_assoc($names, array_unique($names));
if ($duplicates !== []) { if ($duplicates !== []) {
throw new \LogicException( throw new LogicException(
'Duplicate Modules\Core\Privacy provider name(s): '.implode(', ', array_unique($duplicates)) 'Duplicate Modules\Core\Privacy provider name(s): '.implode(', ', array_unique($duplicates))
.'. Each provider registered in config(\'core.privacy.providers\') must return a unique name().' .'. Each provider registered in config(\'core.privacy.providers\') must return a unique name().'
); );
@@ -1,17 +1,27 @@
<?php <?php
namespace Modules\Core\Privacy; namespace Modules\Core\Privacy\Services;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Lunar\Base\LunarUser; use Lunar\Base\LunarUser;
use Lunar\Models\Customer; use Lunar\Models\Customer;
use Modules\Core\Auth\Models\Staff; 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\Events\UserErasureRequested;
use Modules\Core\Privacy\Jobs\ExportDataSubjectJob; use Modules\Core\Privacy\Jobs\ExportDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest; use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest; use Modules\Core\Privacy\Models\DataExportRequest;
use Throwable;
/** /**
* Entry point for right-of-access and right-of-erasure requests, split into two * 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 * called directly for a request that hasn't passed its grace period, since
* that defeats the point of the window; ProcessErasureRequestsCommand * that defeats the point of the window; ProcessErasureRequestsCommand
* enforces isDue() before calling this. * 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 public function completeErasure(DataErasureRequest $request): ErasureReport
{ {
if ($request->isForCustomer()) { if ($request->isForCustomer()) {
$subject = new CustomerSubject(customerId: $request->subject_id); $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 { } else {
$subject = new UserSubject(userId: $request->subject_id, email: $request->email); $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); $report = new ErasureReport($subject, $results);
@@ -276,4 +301,21 @@ class PrivacyService
'deactivated_at' => $deactivated ? now() : null, '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\DecrementStockOnOrderPlaced;
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment; use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
use Modules\Core\Order\Listeners\MarkDeliveryFailedOnCarrierCheckpoint; use Modules\Core\Order\Listeners\MarkDeliveryFailedOnCarrierCheckpoint;
use Modules\Core\Order\Listeners\MarkOrderPlacedOnDeferredPayment;
use Modules\Core\Order\Listeners\RecordPaymentTransaction; use Modules\Core\Order\Listeners\RecordPaymentTransaction;
use Modules\Core\Order\Listeners\RecordStatusTransition; use Modules\Core\Order\Listeners\RecordStatusTransition;
use Modules\Core\Order\Models\OrderStatusTransition; 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\Order\Support\OrderStatus;
use Modules\Core\Payment\Events\PaymentAuthorized; use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured; use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentDeferred;
use Modules\Core\Payment\Events\PaymentRefunded; use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Events\PaymentVoided; use Modules\Core\Payment\Events\PaymentVoided;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
@@ -74,6 +76,8 @@ class OrderServiceProvider extends ServiceProvider
Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class); Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class);
Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class); Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentDeferred::class, MarkOrderPlacedOnDeferredPayment::class);
Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class); Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class);
Event::listen(OrderStatusChanged::class, [RecordStatusTransition::class, 'handleStatusChanged']); 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\Forms\Components\TextInput;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Carbon;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
use Lunar\Admin\Filament\Resources\ProductResource; use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Support\Pages\BaseManageRelatedRecords; use Lunar\Admin\Support\Pages\BaseManageRelatedRecords;
use Modules\Core\Review\Models\ProductReview; use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Review\Services\ReviewService;
class ManageProductReviews extends BaseManageRelatedRecords class ManageProductReviews extends BaseManageRelatedRecords
{ {
@@ -139,10 +139,7 @@ class ManageProductReviews extends BaseManageRelatedRecords
]) ])
->fillForm(fn (ProductReview $record) => ['reply' => $record->reply]) ->fillForm(fn (ProductReview $record) => ['reply' => $record->reply])
->action(function (ProductReview $record, array $data) { ->action(function (ProductReview $record, array $data) {
$record->update([ app(ReviewService::class)->reply($record, $data['reply'], auth('staff')->user());
'reply' => $data['reply'],
'replied_at' => Carbon::now(),
]);
}), }),
DeleteAction::make(), DeleteAction::make(),
]) ])
@@ -1,14 +1,14 @@
<?php <?php
namespace Modules\Core\Privacy\Providers; namespace Modules\Core\Review\Privacy;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Privacy\Contracts\PersonalDataProvider; use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject; use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome; use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult; use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult; use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\UserSubject; use Modules\Core\Privacy\DTOs\UserSubject;
use Modules\Core\Review\Models\ProductReview; 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() ?? []; 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. * 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'), 'locationId' => config('boxnow.origin_location_id'),
], ],
'destination' => [ 'destination' => [
'contactNumber' => $address->contact_phone, 'contactNumber' => $this->internationalPhone($address->contact_phone),
'contactEmail' => $address->contact_email, 'contactEmail' => $address->contact_email,
'contactName' => trim("{$address->first_name} {$address->last_name}"), 'contactName' => trim("{$address->first_name} {$address->last_name}"),
'locationId' => $destinationLocationId, 'locationId' => $destinationLocationId,
@@ -105,6 +105,37 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
return $shipments->first(); 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 public function printLabel(Shipment $shipment): string
{ {
$bytes = $this->client->requestRaw("/parcels/{$shipment->tracking_reference}/label.pdf"); $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\Actions\Action;
use Filament\Infolists\Components\RepeatableEntry; use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Illuminate\Support\Collection;
use Modules\Core\Shipping\Models\ShipmentInfo;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
@@ -112,10 +114,34 @@ class OrderShipmentsExtension extends ViewPageExtension
->action(fn (Shipment $record) => $this->cancel($record)) ->action(fn (Shipment $record) => $this->cancel($record))
->visible(fn (Shipment $record) => ! $record->cancelled_at), ->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 private function carrierLabel(Shipment $record): string
{ {
return match ($record->carrier) { 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 * each with its own S/M/L size) instead — see
* Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService for how * Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService for how
* multiple boxes become multiple Shipment rows from one delivery request. * 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 * Box Now's locker field defaults from the shopper's own checkout
* selection ($order->shippingAddress->meta['box_now_locker']) is present * selection ($order->shippingAddress->meta['box_now_locker']) when present,
* — staff can only fill it in manually for the (current, checkout-UI-less) * but stays editable — staff can override to a different locker (e.g. the
* case where nothing set it yet. * 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 * "Mark Paid" is a third, separate header action — Order::paid is
* independent of `status` (see OrderStatusFlow's own docblock), so it * independent of `status` (see OrderStatusFlow's own docblock), so it
@@ -124,16 +125,9 @@ class OrderViewExtension extends ViewPageExtension
TextInput::make('destination_location_id') TextInput::make('destination_location_id')
->label('Box Now locker ID') ->label('Box Now locker ID')
->default($lockerId) ->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() ->required()
->helperText($lockerId ->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.'), : 'No locker was selected at checkout — enter it manually.'),
Repeater::make('boxes') Repeater::make('boxes')
->label('Boxes') ->label('Boxes')
@@ -152,12 +146,29 @@ class OrderViewExtension extends ViewPageExtension
]; ];
}) })
->action(function (Order $record, array $data, Action $action) { ->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( $result = $this->service()->createShipmentAndDispatch(
$record, $record,
new ShipmentRequest( new ShipmentRequest(
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
packageCount: (int) ($data['package_count'] ?? 1), packageCount: (int) ($data['package_count'] ?? 1),
destinationLocationId: $data['destination_location_id'] ?? null, destinationLocationId: $data['destination_location_id'] ?? null,
paymentMode: $isCod ? 'cod' : 'prepaid',
amountToCollect: $isCod ? $record->total->decimal : null,
boxes: collect($data['boxes'] ?? [])->pluck('size')->all(), boxes: collect($data['boxes'] ?? [])->pluck('size')->all(),
), ),
); );
@@ -12,11 +12,15 @@ use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
/** /**
* ListShippingMethod::getDefaultHeaderActions() builds its CreateAction's * ListShippingMethod::getDefaultHeaderActions() builds its CreateAction's
* form inline (calling ShippingMethodResource::getDriverFormComponent() * form inline (calling ShippingMethodResource::getDriverFormComponent()/
* directly, a hardcoded 2-option Select) rather than through the resource's * getNameFormComponent() directly, hardcoded vendor components) rather
* own extendForm() pipeline, so ShippingMethodResourceExtension's driver * than through the resource's own extendForm() pipeline, so neither
* fix never reaches it. Re-declares the same create-action form with a * ShippingMethodResourceExtension's driver Select nor its translated
* dynamic driver Select instead. * `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 class ShippingMethodListExtension extends BaseExtension
{ {
@@ -25,7 +29,7 @@ class ShippingMethodListExtension extends BaseExtension
foreach ($actions as $action) { foreach ($actions as $action) {
if ($action instanceof CreateAction) { if ($action instanceof CreateAction) {
$action->schema([ $action->schema([
ShippingMethodResource::getNameFormComponent(), ShippingMethodResourceExtension::translatedNameField(),
Group::make([ Group::make([
ShippingMethodResource::getCodeFormComponent(), ShippingMethodResource::getCodeFormComponent(),
$this->driverSelect(), $this->driverSelect(),
@@ -43,11 +43,11 @@ class ShippingMethodResourceExtension extends ResourceExtension
{ {
return array_map(function (Component $component) { return array_map(function (Component $component) {
if (method_exists($component, 'getName') && $component->getName() === 'name') { if (method_exists($component, 'getName') && $component->getName() === 'name') {
return $this->translatedNameField(); return self::translatedNameField();
} }
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema($this->replaceNameField($component->getChildComponents())); $component->schema($this->replaceNameField($component->getDefaultChildComponents()));
} }
return $component; return $component;
@@ -68,14 +68,28 @@ class ShippingMethodResourceExtension extends ResourceExtension
* the model attribute is a string on the way in and out, only ever * the model attribute is a string on the way in and out, only ever
* an array while Filament's schema state holds it. * 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') $field = TranslatedText::make('name')
->label('Name') ->label('Name')
->required() ->required()
->afterStateHydrated(function (TranslatedText $component, $state) { ->afterStateHydrated(function (TranslatedText $component, $state) {
$decoded = json_decode((string) $state, true); // On create there is no record yet, so Filament hydrates
$component->state(is_array($decoded) ? $decoded : []); // 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 : [])); ->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)) { if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema( $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)) { if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema( $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\Schema;
use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Get;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; 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\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates;
use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Support\ShippingMethodName;
/** /**
* Bound in place of the vendor ManageShippingRates page via the container * 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 * 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 * for a live rate that has never had a fallback price configured. Same
* logic, just null-safe. * 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 class ManageShippingRates extends BaseManageShippingRates
{ {
@@ -41,10 +53,30 @@ class ManageShippingRates extends BaseManageShippingRates
$schema = parent::form($schema); $schema = parent::form($schema);
return $schema->components( 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 private function labelPriceFieldsAsFallbackWhenLive(array $components): array
{ {
$isLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) === 'live'; $isLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) === 'live';
@@ -86,6 +118,14 @@ class ManageShippingRates extends BaseManageShippingRates
return $table->columns( return $table->columns(
array_map(function ($column) { 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') { if (method_exists($column, 'getName') && $column->getName() === 'basePrices.0') {
return TextColumn::make('basePrices.0') return TextColumn::make('basePrices.0')
->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label')) ->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label'))
+12 -1
View File
@@ -14,12 +14,19 @@ use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo; use Modules\Core\Shipping\Models\ShipmentInfo;
use Throwable;
/** /**
* Carrier-agnostic: polls every Shipment not yet in a terminal state, * Carrier-agnostic: polls every Shipment not yet in a terminal state,
* skipping carriers whose fulfillment service doesn't implement * skipping carriers whose fulfillment service doesn't implement
* SupportsTracking. New checkpoints are recorded in shipment_info and * SupportsTracking. New checkpoints are recorded in shipment_info and
* dispatch ShipmentStatusUpdatedByCarrier — one event per new checkpoint. * 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 class PollShipmentTrackingJob implements ShouldQueue
{ {
@@ -68,7 +75,11 @@ class PollShipmentTrackingJob implements ShouldQueue
} }
foreach ($shipments as $shipment) { foreach ($shipments as $shipment) {
$this->recordNewCheckpoints($shipment, $service->trackShipment($shipment)); try {
$this->recordNewCheckpoints($shipment, $service->trackShipment($shipment));
} catch (Throwable $e) {
report($e);
}
} }
} }