Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9fb3bbde6 | ||
|
|
26b4c5bfd7 |
+9
-30
@@ -4,37 +4,16 @@ 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/).
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.17.1] - 2026-09-15
|
||||||
|
|
||||||
### Added
|
### Fixed
|
||||||
- Customer-portal backend groundwork — no routes/controllers/views yet (a storefront-facing UI is
|
- `Modules\Core\Payment\Drivers\StripePaymentDriver::createAndConfirm()` only set
|
||||||
3dealer's job once a frontend designer picks it up), but the boboko-owned services it needs to
|
`automatic_payment_methods` when no `payment_method` was given — the actual checkout flow always
|
||||||
call now exist:
|
sends one, so it was omitted, and Stripe fell back to whatever payment methods are enabled in the
|
||||||
- `Modules\Core\Auth\Services\UserOtpService::validate()` now actually logs the shopper in
|
Dashboard and demanded a `return_url` on confirm. Fixed by setting `automatic_payment_methods`
|
||||||
(`Auth::login()`, `web` guard) — previously it only returned the `User` model with no session
|
unconditionally with `allow_redirects: never` — the storefront's Payment Element already restricts
|
||||||
established and no route/controller anywhere ever called it (the checkout page's "Login" tab
|
itself to `paymentMethodTypes: ['card']`, so this just tells Stripe the same thing server-side,
|
||||||
was a disabled placeholder). `Auth::login()` alone is enough to merge/associate any active
|
which drops the `return_url` requirement.
|
||||||
guest cart too — it fires `Illuminate\Auth\Events\Login`, which Lunar's own
|
|
||||||
`Lunar\Listeners\CartSessionAuthListener` (registered unconditionally in core, no opt-in
|
|
||||||
needed) already reacts to, honoring `config('lunar.cart.auth_policy')` (`'merge'` by default).
|
|
||||||
An earlier draft of this also called `Cart::associate()` directly from this service — removed
|
|
||||||
as redundant and actually wrong: it ran a second, separate association with a hardcoded
|
|
||||||
`'merge'` policy that ignored whatever a consumer had actually set `auth_policy` to. Also fixed
|
|
||||||
an unbounded brute-force window: a 6-digit code (1M combinations, was guessable for its full
|
|
||||||
10-minute expiry with no attempt cap) now invalidates itself after 5 wrong guesses
|
|
||||||
(`users.otp_attempts`, new column), forcing a fresh code request rather than leaving a live one
|
|
||||||
guessable indefinitely.
|
|
||||||
- `Modules\Core\Auth\Events\CustomerLoggedIn` — dispatched on every successful OTP login (new
|
|
||||||
user or returning), for a storefront to hook into (e.g. post-login redirect, analytics).
|
|
||||||
- `Modules\Core\Customer\Services\CustomerAccountService` — the storefront-facing "My Account"
|
|
||||||
API (mirrors `CartService`/`CheckoutService`'s shape): `orders()` (paginated, placed orders
|
|
||||||
only), `order()`, `addresses()`, `createAddress()`/`updateAddress()`/`deleteAddress()`,
|
|
||||||
`updateProfile()`. Every method is scoped to the given user's own
|
|
||||||
`latestCustomer()` — there is no method that accepts a bare order/address id without also
|
|
||||||
requiring the owning user, so a controller built on top of this can't leak one customer's data
|
|
||||||
to another by trusting a client-supplied id alone (verified live: a second customer attempting
|
|
||||||
to read/edit the first's address or order gets `AddressNotFoundException`/
|
|
||||||
`OrderNotFoundException`, not the record).
|
|
||||||
|
|
||||||
## [0.17.0] - 2026-09-14
|
## [0.17.0] - 2026-09-14
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.17.0",
|
"version": "0.17.1",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Modules\\Core\\": "src/"
|
"Modules\\Core\\": "src/"
|
||||||
|
|||||||
@@ -65,29 +65,4 @@ return [
|
|||||||
'return_window_days' => 14,
|
'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,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Caps brute-forcing a 6-digit OTP code (1M combinations, 10-minute
|
|
||||||
* window, previously uncapped) — see Modules\Core\Auth\Services\
|
|
||||||
* UserOtpService::validate(), which now invalidates the code entirely
|
|
||||||
* (forcing a fresh generateAndSend()) once otp_attempts reaches its max,
|
|
||||||
* rather than leaving a live code guessable indefinitely within its
|
|
||||||
* expiry window.
|
|
||||||
*/
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', function (Blueprint $table) {
|
|
||||||
$table->unsignedTinyInteger('otp_attempts')->default(0)->after('otp_expires_at');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', function (Blueprint $table) {
|
|
||||||
$table->dropColumn('otp_attempts');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A per-login session registry, independent of the actual session store
|
|
||||||
* driver (SESSION_DRIVER=redis in this app — no "sessions" table to
|
|
||||||
* purge by user_id the way the database driver would allow). Each
|
|
||||||
* successful OTP login (Modules\Core\Auth\Services\UserOtpService::
|
|
||||||
* validate()) records one row here and stamps the token into the
|
|
||||||
* Laravel session payload; Modules\Core\Auth\Http\Middleware\
|
|
||||||
* EnsureSessionNotRevoked checks it on every request. "Logout
|
|
||||||
* everywhere" (Modules\Core\Auth\Services\UserSessionService::
|
|
||||||
* revokeOtherSessions()) is then just marking every OTHER row
|
|
||||||
* revoked_at, no session-store-specific logic anywhere.
|
|
||||||
*/
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('user_sessions', function (Blueprint $table) {
|
|
||||||
$table->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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Auth\Events;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispatched by Modules\Core\Auth\Services\UserOtpService::validate() on a
|
|
||||||
* successful OTP login — distinct from UserCreated (which only fires for
|
|
||||||
* a genuinely first-time email); this fires on every successful login,
|
|
||||||
* new user or returning one.
|
|
||||||
*/
|
|
||||||
class CustomerLoggedIn
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly Authenticatable $user,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Auth\Exceptions;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown by Modules\Core\Auth\Services\UserOtpService::generateAndSend()
|
|
||||||
* when an email has requested too many codes too quickly — caps both
|
|
||||||
* mail-bombing one inbox and the "just request a fresh code to reset my
|
|
||||||
* guess count" loophole a per-code attempt cap alone doesn't close.
|
|
||||||
*/
|
|
||||||
class OtpThrottledException extends RuntimeException
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly int $availableInSeconds,
|
|
||||||
) {
|
|
||||||
parent::__construct("Too many code requests. Try again in {$availableInSeconds} second(s).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Auth\Http\Middleware;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Modules\Core\Auth\Services\UserSessionService;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The enforcement half of the session registry — see
|
|
||||||
* Modules\Core\Auth\Services\UserSessionService's own docblock. Not
|
|
||||||
* auto-registered anywhere (no routes/kernel wiring exist in this
|
|
||||||
* package — see Modules\Core\Customer\Services\CustomerAccountService's
|
|
||||||
* own docblock for why this branch stops at services); a consuming app
|
|
||||||
* adds this to its `web` middleware group (after `auth`) to actually get
|
|
||||||
* "logout everywhere" enforcement.
|
|
||||||
*
|
|
||||||
* A request with no recorded UserSession at all (see
|
|
||||||
* UserSessionService::currentSession()'s own docblock) is let through —
|
|
||||||
* only an EXPLICITLY revoked session is rejected.
|
|
||||||
*/
|
|
||||||
class EnsureSessionNotRevoked
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly UserSessionService $sessions,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
if (! Auth::check()) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
$session = $this->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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Auth\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row per login (see Modules\Core\Auth\Services\UserOtpService::
|
|
||||||
* validate()) — see that table's own migration docblock for why this
|
|
||||||
* exists independent of the actual session-store driver.
|
|
||||||
*/
|
|
||||||
class UserSession extends Model
|
|
||||||
{
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'last_used_at' => '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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,75 +2,16 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Auth\Services;
|
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\Mail;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
|
||||||
use Modules\Core\Auth\Events\CustomerLoggedIn;
|
|
||||||
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
|
||||||
use Modules\Core\Auth\Mail\UserOtpMail;
|
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
|
class UserOtpService
|
||||||
{
|
{
|
||||||
private const EXPIRY_MINUTES = 10;
|
private const EXPIRY_MINUTES = 10;
|
||||||
private const CODE_LENGTH = 6;
|
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
|
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');
|
$model = config('auth.providers.users.model');
|
||||||
$user = $model::firstOrCreate(['email' => $email]);
|
$user = $model::firstOrCreate(['email' => $email]);
|
||||||
|
|
||||||
@@ -78,7 +19,6 @@ class UserOtpService
|
|||||||
|
|
||||||
$user->otp_code = $code;
|
$user->otp_code = $code;
|
||||||
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
|
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
|
||||||
$user->otp_attempts = 0;
|
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code));
|
Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code));
|
||||||
@@ -86,55 +26,23 @@ class UserOtpService
|
|||||||
return true;
|
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');
|
$model = config('auth.providers.users.model');
|
||||||
$user = $model::where('email', $email)->first();
|
$user = $model::where('email', $email)->first();
|
||||||
|
|
||||||
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
|
if (! $user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->otp_code != $code) {
|
if (! $user->otp_expires_at || $user->otp_code != $code || now()->isAfter($user->otp_expires_at)) {
|
||||||
$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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->otp_code = null;
|
$user->otp_code = null;
|
||||||
$user->otp_expires_at = null;
|
$user->otp_expires_at = null;
|
||||||
$user->otp_attempts = 0;
|
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
RateLimiter::clear($this->generationLimiterKey($email));
|
|
||||||
|
|
||||||
Auth::login($user);
|
|
||||||
|
|
||||||
$this->sessions->record($user, $request);
|
|
||||||
|
|
||||||
Event::dispatch(new CustomerLoggedIn($user));
|
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generationLimiterKey(string $email): string
|
|
||||||
{
|
|
||||||
return 'otp-generate:'.strtolower($email);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Auth\Services;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Modules\Core\Auth\Models\UserSession;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The record/revoke half of the session registry — see
|
|
||||||
* database/migrations/2026_09_15_000001_create_user_sessions_table.php's
|
|
||||||
* own docblock for why this exists (SESSION_DRIVER=redis in this app has
|
|
||||||
* no "sessions" table to purge by user_id). The enforcement half is
|
|
||||||
* Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked, which reads
|
|
||||||
* the token this class stamps into the session payload.
|
|
||||||
*/
|
|
||||||
class UserSessionService
|
|
||||||
{
|
|
||||||
private const SESSION_TOKEN_KEY = 'user_session_token';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called once, right after Auth::login() succeeds (see
|
|
||||||
* UserOtpService::validate()) — generates a fresh token, records it,
|
|
||||||
* and stamps it into the CURRENT session payload so
|
|
||||||
* EnsureSessionNotRevoked can look it up on later requests.
|
|
||||||
*/
|
|
||||||
public function record(Authenticatable $user, ?Request $request = null): UserSession
|
|
||||||
{
|
|
||||||
$token = Str::random(64);
|
|
||||||
|
|
||||||
$session = UserSession::create([
|
|
||||||
'user_id' => $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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Customer\Events;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
|
||||||
use Lunar\Models\Address;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispatched by Modules\Core\Customer\Services\CustomerAccountService::
|
|
||||||
* createAddress(). $causer is carried explicitly (unlike e.g.
|
|
||||||
* Modules\Core\Payment\Events\PaymentMethodCreated, which is always
|
|
||||||
* staff-caused implicitly) because this write happens on the `web`
|
|
||||||
* guard, not `staff` — a listener logging this needs to know who to
|
|
||||||
* attribute it to without guessing a guard.
|
|
||||||
*/
|
|
||||||
class CustomerAddressCreated
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly Address $address,
|
|
||||||
public readonly Authenticatable $causer,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Customer\Events;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
|
||||||
|
|
||||||
class CustomerAddressDeleted
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $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,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Customer\Events;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
|
||||||
use Lunar\Models\Address;
|
|
||||||
|
|
||||||
class CustomerAddressUpdated
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $old Snapshot of the changed
|
|
||||||
* attributes before the update.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public readonly Address $address,
|
|
||||||
public readonly array $old,
|
|
||||||
public readonly Authenticatable $causer,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Customer\Events;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
|
||||||
use Modules\Core\Customer\Models\Customer;
|
|
||||||
|
|
||||||
class CustomerProfileUpdated
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $old Snapshot of the changed
|
|
||||||
* attributes before the update.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public readonly Customer $customer,
|
|
||||||
public readonly array $old,
|
|
||||||
public readonly Authenticatable $causer,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Customer\Exceptions;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown by Modules\Core\Customer\Services\CustomerAccountService when an
|
|
||||||
* address id doesn't belong to the customer making the request — never
|
|
||||||
* a plain 404/ModelNotFoundException, so a storefront can't probe for
|
|
||||||
* another customer's address ids by trying sequential ones and reading
|
|
||||||
* the response shape.
|
|
||||||
*/
|
|
||||||
class AddressNotFoundException extends RuntimeException
|
|
||||||
{
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct('Address not found.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Customer\Exceptions;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown by Modules\Core\Customer\Services\CustomerAccountService when an
|
|
||||||
* order id doesn't belong to the customer making the request (or isn't
|
|
||||||
* placed yet) — never a plain 404/ModelNotFoundException, so a
|
|
||||||
* storefront can't probe for another customer's order ids.
|
|
||||||
*/
|
|
||||||
class OrderNotFoundException extends RuntimeException
|
|
||||||
{
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct('Order not found.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Customer\Listeners;
|
|
||||||
|
|
||||||
use Lunar\Models\Address;
|
|
||||||
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\Logging\ActivityLogService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Same pattern as Payment\Listeners\LogPaymentMethodActivity — routes
|
|
||||||
* Modules\Core\Customer\Services\CustomerAccountService's own events
|
|
||||||
* through the shared Logging\ActivityLogService, giving every
|
|
||||||
* shopper-initiated address/profile change an audit trail (previously
|
|
||||||
* none existed at all for account self-service writes). $causer is
|
|
||||||
* passed through explicitly on every call, since these events are
|
|
||||||
* `web`-guard-caused, not `staff`-guard — see ActivityLogService's own
|
|
||||||
* docblock for why that parameter exists.
|
|
||||||
*/
|
|
||||||
class LogCustomerAccountActivity
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly ActivityLogService $activityLog,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handleAddressCreated(CustomerAddressCreated $event): void
|
|
||||||
{
|
|
||||||
$this->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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Customer\Services;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use Lunar\Models\Address;
|
|
||||||
use Lunar\Models\Order;
|
|
||||||
use LogicException;
|
|
||||||
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\Exceptions\AddressNotFoundException;
|
|
||||||
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
|
|
||||||
use Modules\Core\Customer\Models\Customer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The storefront-facing "My Account" API — mirrors Modules\Core\Cart\
|
|
||||||
* Services\CartService's shape, one boboko-owned service a storefront
|
|
||||||
* calls, so Lunar's own Customer/Order/Address models stay an
|
|
||||||
* implementation detail. Every method is scoped to the given
|
|
||||||
* Authenticatable's own Customer::latestCustomer() (see docs/modules.md
|
|
||||||
* "Customer/User Pairing") — there is no method here that accepts a bare
|
|
||||||
* order/address id without also requiring the owning user, precisely so
|
|
||||||
* a controller built on top of this can't accidentally leak one
|
|
||||||
* customer's data to another by trusting a client-supplied id alone.
|
|
||||||
*
|
|
||||||
* $user->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<string, mixed> $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<string, mixed> $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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,31 +2,25 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Logging;
|
namespace Modules\Core\Logging;
|
||||||
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin wrapper around Spatie Activity Log that standardises the log channel,
|
* Thin wrapper around Spatie Activity Log that standardises the log channel,
|
||||||
* actor, and property shape for all domain events.
|
* actor (authenticated staff member), and property shape for all domain events.
|
||||||
*
|
*
|
||||||
* All logs are written to the 'lunar' channel. The subject is always an
|
* All logs are written to the 'lunar' channel. The subject is always an
|
||||||
* Eloquent model. $causer defaults to the 'staff' guard's current user —
|
* Eloquent model, and the actor is resolved from the 'staff' guard at call time.
|
||||||
* 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
|
class ActivityLogService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Log a creation event. $attributes describes the initial state.
|
* Log a creation event. $attributes describes the initial state.
|
||||||
*/
|
*/
|
||||||
public function created(Model $subject, array $attributes, ?Authenticatable $causer = null): void
|
public function created(Model $subject, array $attributes): void
|
||||||
{
|
{
|
||||||
activity('lunar')
|
activity('lunar')
|
||||||
->performedOn($subject)
|
->performedOn($subject)
|
||||||
->causedBy($causer ?? auth('staff')->user())
|
->causedBy(auth('staff')->user())
|
||||||
->withProperties(['attributes' => $attributes])
|
->withProperties(['attributes' => $attributes])
|
||||||
->log('created');
|
->log('created');
|
||||||
}
|
}
|
||||||
@@ -34,11 +28,11 @@ class ActivityLogService
|
|||||||
/**
|
/**
|
||||||
* Log an update event. $old holds the previous values, $attributes the new ones.
|
* Log an update event. $old holds the previous values, $attributes the new ones.
|
||||||
*/
|
*/
|
||||||
public function updated(Model $subject, array $old, array $attributes, ?Authenticatable $causer = null): void
|
public function updated(Model $subject, array $old, array $attributes): void
|
||||||
{
|
{
|
||||||
activity('lunar')
|
activity('lunar')
|
||||||
->performedOn($subject)
|
->performedOn($subject)
|
||||||
->causedBy($causer ?? auth('staff')->user())
|
->causedBy(auth('staff')->user())
|
||||||
->withProperties(['old' => $old, 'attributes' => $attributes])
|
->withProperties(['old' => $old, 'attributes' => $attributes])
|
||||||
->log('updated');
|
->log('updated');
|
||||||
}
|
}
|
||||||
@@ -46,11 +40,11 @@ class ActivityLogService
|
|||||||
/**
|
/**
|
||||||
* Log a failed operation. $attributes provides context (e.g. error message, service).
|
* Log a failed operation. $attributes provides context (e.g. error message, service).
|
||||||
*/
|
*/
|
||||||
public function failed(Model $subject, array $attributes, ?Authenticatable $causer = null): void
|
public function failed(Model $subject, array $attributes): void
|
||||||
{
|
{
|
||||||
activity('lunar')
|
activity('lunar')
|
||||||
->performedOn($subject)
|
->performedOn($subject)
|
||||||
->causedBy($causer ?? auth('staff')->user())
|
->causedBy(auth('staff')->user())
|
||||||
->withProperties(['attributes' => $attributes])
|
->withProperties(['attributes' => $attributes])
|
||||||
->log('failed');
|
->log('failed');
|
||||||
}
|
}
|
||||||
@@ -58,11 +52,11 @@ class ActivityLogService
|
|||||||
/**
|
/**
|
||||||
* Log a deletion event. $attributes provides context (e.g. reason, name).
|
* Log a deletion event. $attributes provides context (e.g. reason, name).
|
||||||
*/
|
*/
|
||||||
public function deleted(Model $subject, array $attributes, ?Authenticatable $causer = null): void
|
public function deleted(Model $subject, array $attributes): void
|
||||||
{
|
{
|
||||||
activity('lunar')
|
activity('lunar')
|
||||||
->performedOn($subject)
|
->performedOn($subject)
|
||||||
->causedBy($causer ?? auth('staff')->user())
|
->causedBy(auth('staff')->user())
|
||||||
->withProperties(['attributes' => $attributes])
|
->withProperties(['attributes' => $attributes])
|
||||||
->log('deleted');
|
->log('deleted');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,12 +100,18 @@ class StripePaymentDriver implements
|
|||||||
'currency' => $amount->currency->code,
|
'currency' => $amount->currency->code,
|
||||||
'capture_method' => $captureMethod,
|
'capture_method' => $captureMethod,
|
||||||
'confirm' => true,
|
'confirm' => true,
|
||||||
|
// 'never' rather than the client-side paymentMethodTypes: ['card']
|
||||||
|
// restriction alone — the storefront's Payment Element already
|
||||||
|
// excludes every redirect-based method, but without this Stripe
|
||||||
|
// still falls back to whatever's enabled in the Dashboard and
|
||||||
|
// demands a return_url on confirm. Setting this unconditionally
|
||||||
|
// (not only when no payment_method is given) matches the actual
|
||||||
|
// flow: a payment_method is always supplied here.
|
||||||
|
'automatic_payment_methods' => ['enabled' => true, 'allow_redirects' => 'never'],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isset($data['payment_method'])) {
|
if (isset($data['payment_method'])) {
|
||||||
$params['payment_method'] = $data['payment_method'];
|
$params['payment_method'] = $data['payment_method'];
|
||||||
} else {
|
|
||||||
$params['automatic_payment_methods'] = ['enabled' => true];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,12 +7,7 @@ use Illuminate\Support\ServiceProvider;
|
|||||||
use Lunar\Facades\ModelManifest;
|
use Lunar\Facades\ModelManifest;
|
||||||
use Lunar\Models\Contracts\Customer as LunarCustomer;
|
use Lunar\Models\Contracts\Customer as LunarCustomer;
|
||||||
use Modules\Core\Auth\Events\UserCreated;
|
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\CreateCustomerForUser;
|
||||||
use Modules\Core\Customer\Listeners\LogCustomerAccountActivity;
|
|
||||||
use Modules\Core\Customer\Models\Customer;
|
use Modules\Core\Customer\Models\Customer;
|
||||||
|
|
||||||
class CustomerServiceProvider extends ServiceProvider
|
class CustomerServiceProvider extends ServiceProvider
|
||||||
@@ -22,10 +17,5 @@ class CustomerServiceProvider extends ServiceProvider
|
|||||||
ModelManifest::replace(LunarCustomer::class, Customer::class);
|
ModelManifest::replace(LunarCustomer::class, Customer::class);
|
||||||
|
|
||||||
Event::listen(UserCreated::class, CreateCustomerForUser::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']);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user