diff --git a/config/core.php b/config/core.php index 49d34f6..8fda506 100644 --- a/config/core.php +++ b/config/core.php @@ -65,4 +65,29 @@ return [ 'return_window_days' => 14, ], + /* + |-------------------------------------------------------------------------- + | Storefront OTP Login + |-------------------------------------------------------------------------- + | + | Modules\Core\Auth\Services\UserOtpService's passwordless login. + | max_attempts caps how many wrong codes a shopper can guess against ONE + | generated code before it's invalidated outright. generation_limit/ + | generation_decay_minutes cap how often a NEW code can be requested for + | the same email — independent of max_attempts, since generating a fresh + | code also resets the guess count, so an attempt cap alone doesn't stop + | an attacker from just requesting a new code every few tries. This same + | limit is also what stands between a malicious/careless caller and + | mail-bombing one inbox. + | + */ + + 'auth' => [ + 'otp' => [ + 'max_attempts' => 5, + 'generation_limit' => 3, + 'generation_decay_minutes' => 10, + ], + ], + ]; diff --git a/database/migrations/2026_09_14_000001_add_otp_attempts_to_users_table.php b/database/migrations/2026_09_14_000001_add_otp_attempts_to_users_table.php new file mode 100644 index 0000000..bf43b6c --- /dev/null +++ b/database/migrations/2026_09_14_000001_add_otp_attempts_to_users_table.php @@ -0,0 +1,30 @@ +unsignedTinyInteger('otp_attempts')->default(0)->after('otp_expires_at'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('otp_attempts'); + }); + } +}; diff --git a/database/migrations/2026_09_15_000001_create_user_sessions_table.php b/database/migrations/2026_09_15_000001_create_user_sessions_table.php new file mode 100644 index 0000000..dbfb6fa --- /dev/null +++ b/database/migrations/2026_09_15_000001_create_user_sessions_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('token', 64)->unique(); + $table->string('user_agent')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->timestamp('last_used_at'); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'revoked_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_sessions'); + } +}; diff --git a/src/Auth/Events/CustomerLoggedIn.php b/src/Auth/Events/CustomerLoggedIn.php new file mode 100644 index 0000000..5404c30 --- /dev/null +++ b/src/Auth/Events/CustomerLoggedIn.php @@ -0,0 +1,18 @@ +sessions->currentSession(); + + if ($session && $session->isRevoked()) { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + abort(401, 'Your session has been revoked. Please log in again.'); + } + + $session?->update(['last_used_at' => now()]); + + return $next($request); + } +} diff --git a/src/Auth/Models/UserSession.php b/src/Auth/Models/UserSession.php new file mode 100644 index 0000000..219b7da --- /dev/null +++ b/src/Auth/Models/UserSession.php @@ -0,0 +1,33 @@ + 'datetime', + 'revoked_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + $model = config('auth.providers.users.model'); + + return $this->belongsTo($model); + } + + public function isRevoked(): bool + { + return $this->revoked_at !== null; + } +} diff --git a/src/Auth/Services/UserOtpService.php b/src/Auth/Services/UserOtpService.php index b5a2b1d..7bc15da 100644 --- a/src/Auth/Services/UserOtpService.php +++ b/src/Auth/Services/UserOtpService.php @@ -2,16 +2,75 @@ namespace Modules\Core\Auth\Services; +use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\RateLimiter; +use Modules\Core\Auth\Events\CustomerLoggedIn; +use Modules\Core\Auth\Exceptions\OtpThrottledException; use Modules\Core\Auth\Mail\UserOtpMail; +/** + * The storefront's passwordless login — a shopper supplies only an email + * (Shopify-style), gets a 6-digit code, and validate() authenticates the + * `web` guard via Auth::login(). + * + * That alone is enough to merge/associate any active guest cart into the + * now-known customer — Auth::login() fires Illuminate\Auth\Events\Login, + * which Lunar's own Lunar\Listeners\CartSessionAuthListener (registered + * unconditionally in LunarServiceProvider::boot(), no opt-in needed) + * already listens to, calling CartSession::associate() with + * config('lunar.cart.auth_policy') — 'merge' by default, 'override' if a + * consumer changes that config. Deliberately no cart-association call + * here: doing our own on top would run a SECOND merge attempt with a + * hardcoded policy that ignores whatever the consumer configured. + * + * generateAndSend()'s find-or-create already triggers the full + * Customer/User pairing cascade for a genuinely new email — see + * Modules\Core\Auth\Events\UserCreated's own docblock and + * Modules\Core\Customer\Listeners\CreateCustomerForUser. + * + * Two independent throttles, both configured under core.auth.otp — see + * config/core.php's own comment for why they're separate: max_attempts + * caps wrong guesses against ONE code; generation_limit caps how often a + * NEW code can be requested for the same email at all (closes both the + * "regenerate to reset my guess count" loophole and mail-bombing one + * inbox). + * + * validate() also records a UserSessionService entry for the new login — + * see that class's own docblock for the "logout everywhere" registry + * this feeds (Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked + * is the enforcement half; a consuming app must add it to its own + * middleware stack). $request is optional purely so this service stays + * callable from a context with no HTTP request at all (a console + * command, a test) — user-agent/ip are simply not recorded when omitted. + */ class UserOtpService { private const EXPIRY_MINUTES = 10; private const CODE_LENGTH = 6; + public function __construct( + private readonly UserSessionService $sessions, + ) {} + + /** + * @throws OtpThrottledException if this email has requested too many + * codes within core.auth.otp.generation_decay_minutes + */ public function generateAndSend(string $email): bool { + $limiterKey = $this->generationLimiterKey($email); + $maxGenerations = (int) config('core.auth.otp.generation_limit', 3); + + if (RateLimiter::tooManyAttempts($limiterKey, $maxGenerations)) { + throw new OtpThrottledException(RateLimiter::availableIn($limiterKey)); + } + + RateLimiter::hit($limiterKey, (int) config('core.auth.otp.generation_decay_minutes', 10) * 60); + $model = config('auth.providers.users.model'); $user = $model::firstOrCreate(['email' => $email]); @@ -19,6 +78,7 @@ class UserOtpService $user->otp_code = $code; $user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); + $user->otp_attempts = 0; $user->save(); Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code)); @@ -26,23 +86,55 @@ class UserOtpService return true; } - public function validate(string $email, string $code) + /** + * A wrong code counts against core.auth.otp.max_attempts and, once + * reached, invalidates the code entirely — the shopper must request + * a fresh one via generateAndSend() (itself throttled independently + * — see this class's own docblock) rather than being able to keep + * guessing against a still-live code for the rest of its 10-minute + * expiry window. + */ + public function validate(string $email, string $code, ?Request $request = null): ?Authenticatable { $model = config('auth.providers.users.model'); $user = $model::where('email', $email)->first(); - if (! $user) { + if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) { return null; } - if (! $user->otp_expires_at || $user->otp_code != $code || now()->isAfter($user->otp_expires_at)) { + if ($user->otp_code != $code) { + $user->otp_attempts++; + + if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) { + $user->otp_code = null; + $user->otp_expires_at = null; + $user->otp_attempts = 0; + } + + $user->save(); + return null; } $user->otp_code = null; $user->otp_expires_at = null; + $user->otp_attempts = 0; $user->save(); + RateLimiter::clear($this->generationLimiterKey($email)); + + Auth::login($user); + + $this->sessions->record($user, $request); + + Event::dispatch(new CustomerLoggedIn($user)); + return $user; } + + private function generationLimiterKey(string $email): string + { + return 'otp-generate:'.strtolower($email); + } } diff --git a/src/Auth/Services/UserSessionService.php b/src/Auth/Services/UserSessionService.php new file mode 100644 index 0000000..d1f36b1 --- /dev/null +++ b/src/Auth/Services/UserSessionService.php @@ -0,0 +1,105 @@ + $user->getAuthIdentifier(), + 'token' => $token, + 'user_agent' => $request?->userAgent(), + 'ip_address' => $request?->ip(), + 'last_used_at' => now(), + ]); + + session([self::SESSION_TOKEN_KEY => $token]); + + return $session; + } + + /** + * Revokes every OTHER active session for $user — the current one + * (matched by the token in the CURRENT session payload) is left + * alone, matching Laravel's own logoutOtherDevices() semantics + * (there just isn't a password to re-verify against here — this is a + * passwordless account, so revocation is simply "every row that + * isn't the one making this request"). + * + * Known, deliberately accepted gap: this requires only a currently + * valid session, not a freshly-completed login — so anyone holding + * an already-authenticated session (e.g. someone who sits down at an + * account left logged in on a shared/public PC) can use this to + * evict the real owner's OTHER sessions just as easily as the real + * owner could use it to evict an intruder's. A stricter version would + * require a fresh OTP re-verification (e.g. within the last few + * minutes) before allowing this call. Left as-is for now — revisit if + * this turns out to matter in practice, rather than building + * abuse-resistance against a threat model nobody's confirmed is real + * for this storefront. + */ + public function revokeOtherSessions(Authenticatable $user): int + { + $currentToken = session(self::SESSION_TOKEN_KEY); + + return UserSession::query() + ->where('user_id', $user->getAuthIdentifier()) + ->whereNull('revoked_at') + ->when($currentToken, fn ($query) => $query->where('token', '!=', $currentToken)) + ->update(['revoked_at' => now()]); + } + + /** + * Revokes EVERY session for $user, current one included — for a + * "this account may be compromised" response, not a routine logout. + */ + public function revokeAllSessions(Authenticatable $user): int + { + return UserSession::query() + ->where('user_id', $user->getAuthIdentifier()) + ->whereNull('revoked_at') + ->update(['revoked_at' => now()]); + } + + /** + * @return UserSession|null null if the CURRENT session has no + * recorded token at all (e.g. a session predating this feature, or + * one Auth::login() established outside UserOtpService) — treated + * as valid by EnsureSessionNotRevoked rather than rejected, since + * there's nothing to have been revoked. + */ + public function currentSession(): ?UserSession + { + $token = session(self::SESSION_TOKEN_KEY); + + if (! $token) { + return null; + } + + return UserSession::where('token', $token)->first(); + } +} diff --git a/src/Customer/Events/CustomerAddressCreated.php b/src/Customer/Events/CustomerAddressCreated.php new file mode 100644 index 0000000..2afacf3 --- /dev/null +++ b/src/Customer/Events/CustomerAddressCreated.php @@ -0,0 +1,22 @@ + $address Snapshot of the deleted + * row — already gone from the database by dispatch time. + */ + public function __construct( + public readonly array $address, + public readonly Authenticatable $causer, + ) {} +} diff --git a/src/Customer/Events/CustomerAddressUpdated.php b/src/Customer/Events/CustomerAddressUpdated.php new file mode 100644 index 0000000..bdb4701 --- /dev/null +++ b/src/Customer/Events/CustomerAddressUpdated.php @@ -0,0 +1,19 @@ + $old Snapshot of the changed + * attributes before the update. + */ + public function __construct( + public readonly Address $address, + public readonly array $old, + public readonly Authenticatable $causer, + ) {} +} diff --git a/src/Customer/Events/CustomerProfileUpdated.php b/src/Customer/Events/CustomerProfileUpdated.php new file mode 100644 index 0000000..63f5bd7 --- /dev/null +++ b/src/Customer/Events/CustomerProfileUpdated.php @@ -0,0 +1,19 @@ + $old Snapshot of the changed + * attributes before the update. + */ + public function __construct( + public readonly Customer $customer, + public readonly array $old, + public readonly Authenticatable $causer, + ) {} +} diff --git a/src/Customer/Exceptions/AddressNotFoundException.php b/src/Customer/Exceptions/AddressNotFoundException.php new file mode 100644 index 0000000..c215141 --- /dev/null +++ b/src/Customer/Exceptions/AddressNotFoundException.php @@ -0,0 +1,20 @@ +activityLog->created($event->address, $event->address->getAttributes(), $event->causer); + } + + public function handleAddressUpdated(CustomerAddressUpdated $event): void + { + $this->activityLog->updated( + $event->address, + $event->old, + $event->address->only(array_keys($event->old)), + $event->causer, + ); + } + + public function handleAddressDeleted(CustomerAddressDeleted $event): void + { + $subject = (new Address)->forceFill($event->address); + $subject->exists = true; + $subject->id = $event->address['id']; + + $this->activityLog->deleted($subject, $event->address, $event->causer); + } + + public function handleProfileUpdated(CustomerProfileUpdated $event): void + { + $this->activityLog->updated( + $event->customer, + $event->old, + $event->customer->only(array_keys($event->old)), + $event->causer, + ); + } +} diff --git a/src/Customer/Services/CustomerAccountService.php b/src/Customer/Services/CustomerAccountService.php new file mode 100644 index 0000000..45d663c --- /dev/null +++ b/src/Customer/Services/CustomerAccountService.php @@ -0,0 +1,255 @@ +latestCustomer() can be null for a User that has no paired + * Customer yet (shouldn't happen via the normal OTP-login cascade — see + * Modules\Core\Auth\Events\UserCreated — but is defended against anyway, + * since nothing stops a User row existing without one, e.g. seeded data) + * — every method returns an empty/null result rather than throwing in + * that case, since "no customer paired yet" isn't a not-found error, it's + * a legitimately empty account. + * + * Address/profile writes go through an explicit column allowlist + * (WRITABLE_ADDRESS_FIELDS/WRITABLE_PROFILE_FIELDS) rather than trusting + * Lunar\Models\Address/Customer's own $guarded = [] — that flag makes + * every column mass-assignable at the model layer, including + * customer_id on addresses, so a caller passing through an unfiltered + * request array (a real risk for a storefront controller built directly + * against this service) could otherwise reassign an address to a + * different customer entirely, or overwrite created_at/id. Arr::only() + * silently drops anything not on the allowlist rather than erroring — + * this is a safety boundary, not form validation (a storefront still + * validates its own request shape before calling this). + * + * Authorization here IS the ownership scoping itself, not a separate + * layer bolted on top — there is deliberately no Laravel Policy/Gate + * class for Order/Address, since a policy is meaningless without a + * controller calling authorize() against it, and this branch is scoped + * to backend services only (no routes/controllers — see the branch's own + * commit history). Every public method below takes Authenticatable $user + * as a required first argument and resolves everything else (Order, + * Address, Customer) strictly through that user's own + * latestCustomer() — there is no method that looks anything up by a bare + * id alone. A future storefront controller cannot "forget" the + * authorization check the way it could with a separate policy class, + * because the check IS how every lookup happens; skipping it isn't an + * option the method signatures allow. + */ +class CustomerAccountService +{ + private const WRITABLE_ADDRESS_FIELDS = [ + 'title', 'first_name', 'last_name', 'company_name', + 'line_one', 'line_two', 'line_three', 'city', 'state', 'postcode', + 'delivery_instructions', 'contact_email', 'contact_phone', + 'country_id', 'shipping_default', 'billing_default', + ]; + + private const WRITABLE_PROFILE_FIELDS = [ + 'title', 'first_name', 'last_name', 'company_name', 'vat_no', + ]; + + public function customer(Authenticatable $user): ?Customer + { + /** @var Customer|null */ + return $user->latestCustomer(); + } + + /** + * Placed orders only (placed_at IS NOT NULL) — a draft/abandoned + * order with no placed_at is checkout-in-progress state, not + * something that belongs in order history. + */ + public function orders(Authenticatable $user, int $perPage = 15): LengthAwarePaginator + { + $customer = $this->customer($user); + + if (! $customer) { + return new LengthAwarePaginator([], 0, $perPage); + } + + return $customer->orders() + ->whereNotNull('placed_at') + ->latest('placed_at') + ->paginate($perPage); + } + + /** + * @throws OrderNotFoundException if $orderId doesn't belong to this + * customer, or belongs to a draft (never placed) order + */ + public function order(Authenticatable $user, int $orderId): Order + { + $customer = $this->customer($user); + + $order = $customer + ?->orders() + ->whereNotNull('placed_at') + ->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments']) + ->find($orderId); + + if (! $order) { + throw new OrderNotFoundException; + } + + return $order; + } + + public function addresses(Authenticatable $user): iterable + { + $customer = $this->customer($user); + + return $customer?->addresses ?? collect(); + } + + /** + * @param array $data Any key not in + * WRITABLE_ADDRESS_FIELDS is silently dropped — see this class's + * own docblock. + */ + public function createAddress(Authenticatable $user, array $data): Address + { + $customer = $this->customerOrFail($user); + + $address = $customer->addresses()->create(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS)); + + $this->enforceSingleDefault($customer, $address); + $address->refresh(); + + Event::dispatch(new CustomerAddressCreated($address, $user)); + + return $address; + } + + /** + * @throws AddressNotFoundException if $addressId doesn't belong to + * this customer + */ + public function updateAddress(Authenticatable $user, int $addressId, array $data): Address + { + $address = $this->ownedAddress($user, $addressId); + $old = $address->only(array_keys(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS))); + + $address->update(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS)); + + $this->enforceSingleDefault($address->customer, $address); + $address->refresh(); + + Event::dispatch(new CustomerAddressUpdated($address, $old, $user)); + + return $address; + } + + /** + * @throws AddressNotFoundException if $addressId doesn't belong to + * this customer + */ + public function deleteAddress(Authenticatable $user, int $addressId): void + { + $address = $this->ownedAddress($user, $addressId); + $snapshot = $address->getAttributes(); + + $address->delete(); + + Event::dispatch(new CustomerAddressDeleted($snapshot, $user)); + } + + /** + * Lunar has no built-in action enforcing "at most one shipping + * default / one billing default per customer" — a raw update() could + * otherwise leave two addresses both flagged shipping_default. Runs + * after every create/update, unconditionally (cheap — at most two + * single-row UPDATEs, only fired when the just-written address + * itself is a default), clearing the flag on every OTHER address of + * the same customer. + */ + private function enforceSingleDefault(Customer $customer, Address $address): void + { + if ($address->shipping_default) { + $customer->addresses()->where('id', '!=', $address->id)->update(['shipping_default' => false]); + } + + if ($address->billing_default) { + $customer->addresses()->where('id', '!=', $address->id)->update(['billing_default' => false]); + } + } + + /** + * @throws AddressNotFoundException if $addressId doesn't belong to + * this customer + */ + private function ownedAddress(Authenticatable $user, int $addressId): Address + { + $customer = $this->customer($user); + + $address = $customer?->addresses()->find($addressId); + + if (! $address) { + throw new AddressNotFoundException; + } + + return $address; + } + + /** + * @param array $data Any key not in + * WRITABLE_PROFILE_FIELDS is silently dropped — see this class's + * own docblock. + */ + public function updateProfile(Authenticatable $user, array $data): Customer + { + $customer = $this->customerOrFail($user); + $old = $customer->only(array_keys(Arr::only($data, self::WRITABLE_PROFILE_FIELDS))); + + $customer->update(Arr::only($data, self::WRITABLE_PROFILE_FIELDS)); + $customer->refresh(); + + Event::dispatch(new CustomerProfileUpdated($customer, $old, $user)); + + return $customer; + } + + /** + * @throws LogicException if $user has no paired Customer at all — + * distinct from AddressNotFoundException/OrderNotFoundException + * (which mean "this id isn't yours"), this means the account + * itself is in an invariant-violating state the normal OTP-login + * cascade should never produce. + */ + private function customerOrFail(Authenticatable $user): Customer + { + $customer = $this->customer($user); + + if (! $customer) { + throw new LogicException('This user has no paired Customer record.'); + } + + return $customer; + } +} diff --git a/src/Logging/ActivityLogService.php b/src/Logging/ActivityLogService.php index afc3f6e..30ce3d2 100644 --- a/src/Logging/ActivityLogService.php +++ b/src/Logging/ActivityLogService.php @@ -2,25 +2,31 @@ namespace Modules\Core\Logging; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Database\Eloquent\Model; /** * Thin wrapper around Spatie Activity Log that standardises the log channel, - * actor (authenticated staff member), and property shape for all domain events. + * actor, and property shape for all domain events. * * All logs are written to the 'lunar' channel. The subject is always an - * Eloquent model, and the actor is resolved from the 'staff' guard at call time. + * Eloquent model. $causer defaults to the 'staff' guard's current user — + * every existing caller of this class is admin-side — but can be passed + * explicitly for a non-staff actor (e.g. a customer editing their own + * address on the `web` guard — see Modules\Core\Customer\Services\ + * CustomerAccountService, which passes the acting User rather than + * relying on this default resolving to null for a web-guard session). */ class ActivityLogService { /** * Log a creation event. $attributes describes the initial state. */ - public function created(Model $subject, array $attributes): void + public function created(Model $subject, array $attributes, ?Authenticatable $causer = null): void { activity('lunar') ->performedOn($subject) - ->causedBy(auth('staff')->user()) + ->causedBy($causer ?? auth('staff')->user()) ->withProperties(['attributes' => $attributes]) ->log('created'); } @@ -28,11 +34,11 @@ class ActivityLogService /** * Log an update event. $old holds the previous values, $attributes the new ones. */ - public function updated(Model $subject, array $old, array $attributes): void + public function updated(Model $subject, array $old, array $attributes, ?Authenticatable $causer = null): void { activity('lunar') ->performedOn($subject) - ->causedBy(auth('staff')->user()) + ->causedBy($causer ?? auth('staff')->user()) ->withProperties(['old' => $old, 'attributes' => $attributes]) ->log('updated'); } @@ -40,11 +46,11 @@ class ActivityLogService /** * Log a failed operation. $attributes provides context (e.g. error message, service). */ - public function failed(Model $subject, array $attributes): void + public function failed(Model $subject, array $attributes, ?Authenticatable $causer = null): void { activity('lunar') ->performedOn($subject) - ->causedBy(auth('staff')->user()) + ->causedBy($causer ?? auth('staff')->user()) ->withProperties(['attributes' => $attributes]) ->log('failed'); } @@ -52,11 +58,11 @@ class ActivityLogService /** * Log a deletion event. $attributes provides context (e.g. reason, name). */ - public function deleted(Model $subject, array $attributes): void + public function deleted(Model $subject, array $attributes, ?Authenticatable $causer = null): void { activity('lunar') ->performedOn($subject) - ->causedBy(auth('staff')->user()) + ->causedBy($causer ?? auth('staff')->user()) ->withProperties(['attributes' => $attributes]) ->log('deleted'); } diff --git a/src/Providers/CustomerServiceProvider.php b/src/Providers/CustomerServiceProvider.php index 525006c..4b5f995 100644 --- a/src/Providers/CustomerServiceProvider.php +++ b/src/Providers/CustomerServiceProvider.php @@ -7,7 +7,12 @@ use Illuminate\Support\ServiceProvider; use Lunar\Facades\ModelManifest; use Lunar\Models\Contracts\Customer as LunarCustomer; use Modules\Core\Auth\Events\UserCreated; +use Modules\Core\Customer\Events\CustomerAddressCreated; +use Modules\Core\Customer\Events\CustomerAddressDeleted; +use Modules\Core\Customer\Events\CustomerAddressUpdated; +use Modules\Core\Customer\Events\CustomerProfileUpdated; use Modules\Core\Customer\Listeners\CreateCustomerForUser; +use Modules\Core\Customer\Listeners\LogCustomerAccountActivity; use Modules\Core\Customer\Models\Customer; class CustomerServiceProvider extends ServiceProvider @@ -17,5 +22,10 @@ class CustomerServiceProvider extends ServiceProvider ModelManifest::replace(LunarCustomer::class, Customer::class); Event::listen(UserCreated::class, CreateCustomerForUser::class); + + Event::listen(CustomerAddressCreated::class, [LogCustomerAccountActivity::class, 'handleAddressCreated']); + Event::listen(CustomerAddressUpdated::class, [LogCustomerAccountActivity::class, 'handleAddressUpdated']); + Event::listen(CustomerAddressDeleted::class, [LogCustomerAccountActivity::class, 'handleAddressDeleted']); + Event::listen(CustomerProfileUpdated::class, [LogCustomerAccountActivity::class, 'handleProfileUpdated']); } }