Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02816fb9e7 | ||
|
|
910ce0205d | ||
|
|
e532c32cab | ||
|
|
6a51b672c8 | ||
|
|
956e9e88a6 | ||
|
|
4489475840 | ||
|
|
e4e008167a | ||
|
|
a5f3008ce2 | ||
|
|
d9fb3bbde6 | ||
|
|
26b4c5bfd7 |
+70
-29
@@ -4,37 +4,78 @@ 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/).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.17.4] - 2026-09-15
|
||||
|
||||
### Added
|
||||
- Customer-portal backend groundwork — no routes/controllers/views yet (a storefront-facing UI is
|
||||
3dealer's job once a frontend designer picks it up), but the boboko-owned services it needs to
|
||||
call now exist:
|
||||
- `Modules\Core\Auth\Services\UserOtpService::validate()` now actually logs the shopper in
|
||||
(`Auth::login()`, `web` guard) — previously it only returned the `User` model with no session
|
||||
established and no route/controller anywhere ever called it (the checkout page's "Login" tab
|
||||
was a disabled placeholder). `Auth::login()` alone is enough to merge/associate any active
|
||||
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).
|
||||
- `boboko:catalog:backfill-skus` — one-off Artisan command to generate a SKU
|
||||
(`SKU-P{product_id}-V{variant_id}`) for every `Lunar\Models\ProductVariant` left with a `null`
|
||||
SKU by the earlier Shopify import (the source export's `Variant SKU` column was genuinely blank
|
||||
for these rows, not an importer mapping bug — see `Modules\MigrateImport\Shopify\
|
||||
ShopifyExportImporter`). Only touches variants missing a SKU; `--dry-run` lists what would
|
||||
change without writing.
|
||||
|
||||
## [0.17.3] - 2026-09-15
|
||||
|
||||
### Changed
|
||||
- Removed the `lunarphp/stripe` dependency in favour of depending on `stripe/stripe-php` directly.
|
||||
`Modules\Core\Payment\Drivers\StripePaymentDriver` had already replaced every bit of Lunar's own
|
||||
Stripe payment flow (checkout, webhook processing) with its own — all that remained load-bearing
|
||||
from the package was raw API-client access, amount conversion, and a correlation table, none of
|
||||
which are Lunar-specific. Added first-party replacements: `Modules\Core\Payment\Support\
|
||||
StripeManager` (API client + `toStripeAmount()`/`fromStripeAmount()`), `Modules\Core\Payment\
|
||||
Models\StripePaymentIntent` (now with a proper `context` array cast, replacing manual
|
||||
`json_encode`/`json_decode`), and `Modules\Core\Payment\Http\Middleware\
|
||||
StripeWebhookMiddleware`. Added `database/migrations/..._create_stripe_payment_intents_table.php`,
|
||||
a first-party copy of the vendor migration (guarded with `Schema::hasTable()` so it's a no-op on
|
||||
any environment that already has the table from the vendor package's own earlier migration run,
|
||||
and only actually creates it on a genuinely fresh install). No behavior change for consuming
|
||||
apps — same table, same driver contract, same webhook endpoint.
|
||||
|
||||
## [0.17.2] - 2026-09-15
|
||||
|
||||
### Fixed
|
||||
- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` never advanced `Order::status` past
|
||||
`awaiting_payment` on a capture — only `paid`/`paid_at` were written, so a fully captured order
|
||||
could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update
|
||||
Status". Now, on `PaymentCaptured` (not `PaymentAuthorized` — an authorization isn't yet
|
||||
captured funds), `status` advances to the next step in the order's flow
|
||||
(`Modules\Core\Order\Services\OrderStatusFlow::nextOptions()`) — but only when it's still
|
||||
exactly `awaiting_payment`, so a duplicate/delayed capture event never regresses an order staff
|
||||
already moved further.
|
||||
- The backoffice "Capture" action on the order page (Filament) called vendor Lunar's
|
||||
`Lunar\Models\Transaction::capture()` directly, which resolves `Lunar\Facades\Payments` — an
|
||||
entirely separate, unused driver registry — and never dispatched `Modules\Core\Payment\Events\
|
||||
PaymentCaptured`. This meant a manual capture from the admin panel never ran this app's own
|
||||
payment pipeline at all (including the status-advance fix above). `Modules\Core\Order\Filament\
|
||||
Extensions\OrderActionsExtension` (renamed from `OrderRefundActionsExtension`, since it now
|
||||
fixes both the refund and capture header actions — see below) now routes capture through
|
||||
`Modules\Core\Payment\Support\TransactionDriverAdapter::capture()`, the same app-level path
|
||||
checkout-time captures use.
|
||||
- `Modules\Core\Payment\Drivers\StripePaymentDriver` never extracted a card's brand/last four
|
||||
digits from Stripe's response, so `Lunar\Models\Transaction::card_type`/`last_four` were always
|
||||
empty and the admin's "Payment of :amount on card ending :last_four" activity-log line rendered
|
||||
with no digits — reproduced on both checkout-time and manual captures. Added
|
||||
`cardMetaFromIntent()`, reading `payment_method_details` off the PaymentIntent's `latest_charge`
|
||||
(same source `lunarphp/stripe`'s own `StoreCharges` uses), populated into `PaymentResult::$meta`
|
||||
from `resultFromIntent()` and `capture()`. `Modules\Core\Order\Services\TransactionRecorder`
|
||||
now maps `meta['card_type']`/`meta['last_four']` onto the `Transaction` row. Only applies to
|
||||
transactions recorded after this change — existing rows are not backfilled.
|
||||
|
||||
### Changed
|
||||
- `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension` renamed to
|
||||
`OrderActionsExtension` — the class now fixes both the refund and capture header actions on the
|
||||
order page, not just refund, so the old name undersold its scope.
|
||||
|
||||
## [0.17.1] - 2026-09-15
|
||||
|
||||
### Fixed
|
||||
- `Modules\Core\Payment\Drivers\StripePaymentDriver::createAndConfirm()` only set
|
||||
`automatic_payment_methods` when no `payment_method` was given — the actual checkout flow always
|
||||
sends one, so it was omitted, and Stripe fell back to whatever payment methods are enabled in the
|
||||
Dashboard and demanded a `return_url` on confirm. Fixed by setting `automatic_payment_methods`
|
||||
unconditionally with `allow_redirects: never` — the storefront's Payment Element already restricts
|
||||
itself to `paymentMethodTypes: ['card']`, so this just tells Stripe the same thing server-side,
|
||||
which drops the `return_url` requirement.
|
||||
|
||||
## [0.17.0] - 2026-09-14
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"name": "boboko/core",
|
||||
"description": "Core module — authentication and shared panel behaviour",
|
||||
"type": "library",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.4",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Core\\": "src/"
|
||||
@@ -18,7 +18,7 @@
|
||||
"lunarphp/search": "*",
|
||||
"lunarphp/meilisearch": "*",
|
||||
"spatie/laravel-translation-loader": "^2.8",
|
||||
"lunarphp/stripe": "^1.5"
|
||||
"stripe/stripe-php": "^16.6"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
@@ -65,29 +65,4 @@ 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,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Lunar\Base\Migration;
|
||||
|
||||
/**
|
||||
* First-party copy of lunarphp/stripe's own create_stripe_payment_intents_table
|
||||
* migration (package removed in favour of depending on stripe/stripe-php
|
||||
* directly — see Modules\Core\Payment\Support\StripeManager and
|
||||
* Modules\Core\Payment\Models\StripePaymentIntent, which replace the
|
||||
* package's own classes over this same table). Timestamped to run just
|
||||
* before this app's own add_context_to_stripe_payment_intents migration,
|
||||
* which already alters this table.
|
||||
*
|
||||
* Guarded with hasTable(): on any environment that already ran
|
||||
* lunarphp/stripe's own copy of this migration before the package was
|
||||
* removed, the table already exists — this migration is only the one that
|
||||
* actually creates it on a fresh install/database from now on.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable($this->prefix.'stripe_payment_intents')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('cart_id')->constrained($this->prefix.'carts');
|
||||
$table->foreignId('order_id')->nullable()->constrained($this->prefix.'orders');
|
||||
$table->string('intent_id')->index();
|
||||
$table->string('status')->nullable();
|
||||
$table->string('event_id')->index()->nullable();
|
||||
$table->timestamp('processing_at')->nullable();
|
||||
$table->timestamp('processed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists($this->prefix.'stripe_payment_intents');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
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]);
|
||||
|
||||
@@ -78,7 +19,6 @@ 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));
|
||||
@@ -86,55 +26,23 @@ class UserOtpService
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
public function validate(string $email, string $code)
|
||||
{
|
||||
$model = config('auth.providers.users.model');
|
||||
$user = $model::where('email', $email)->first();
|
||||
|
||||
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
|
||||
if (! $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (! $user->otp_expires_at || $user->otp_code != $code || now()->isAfter($user->otp_expires_at)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Command;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Lunar\Models\ProductVariant;
|
||||
|
||||
/**
|
||||
* One-off backfill for variants the Shopify import left with a blank SKU —
|
||||
* not an importer bug, the source CSV rows genuinely had no `Variant SKU`
|
||||
* value (see Modules\MigrateImport\Shopify\ShopifyExportImporter) — so
|
||||
* this synthesizes one instead of re-running the import. Format is
|
||||
* "SKU-P{product_id}-V{variant_id}": deterministic and guaranteed unique
|
||||
* without a uniqueness check, since product_id/variant_id already are.
|
||||
* Only variants with a null `sku` are touched.
|
||||
*/
|
||||
class BackfillMissingSkusCommand extends Command
|
||||
{
|
||||
protected $signature = 'boboko:catalog:backfill-skus {--dry-run : List what would change without writing}';
|
||||
|
||||
protected $description = 'Generate a SKU for every product variant that is missing one';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$query = ProductVariant::query()->whereNull('sku');
|
||||
$total = $query->count();
|
||||
|
||||
if ($total === 0) {
|
||||
$this->info('No variants are missing a SKU.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info(($dryRun ? '[dry-run] ' : '') . "Backfilling SKUs for {$total} variant(s)...");
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
$query->chunkById(500, function ($variants) use ($dryRun, $bar) {
|
||||
foreach ($variants as $variant) {
|
||||
$sku = "SKU-P{$variant->product_id}-V{$variant->id}";
|
||||
|
||||
if ($dryRun) {
|
||||
$this->newLine();
|
||||
$this->line("Variant {$variant->id}: sku => {$sku}");
|
||||
} else {
|
||||
$variant->update(['sku' => $sku]);
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info($dryRun ? 'Dry run complete — no changes were written.' : 'Done.');
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -28,7 +28,7 @@ use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
|
||||
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||
use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension;
|
||||
use Modules\Core\Order\Filament\Extensions\OrderPaymentMethodSummaryExtension;
|
||||
use Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension;
|
||||
use Modules\Core\Order\Filament\Extensions\OrderActionsExtension;
|
||||
use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension;
|
||||
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
|
||||
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
|
||||
@@ -70,7 +70,7 @@ class CorePlugin implements Plugin
|
||||
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
|
||||
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
|
||||
ListShippingMethod::class => ShippingMethodListExtension::class,
|
||||
ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class],
|
||||
ManageOrder::class => [OrderViewExtension::class, OrderActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class],
|
||||
OrderItemsTable::class => OrderItemsTableExtension::class,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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).
|
||||
* Eloquent model, and the actor is resolved from the 'staff' guard at call time.
|
||||
*/
|
||||
class ActivityLogService
|
||||
{
|
||||
/**
|
||||
* 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')
|
||||
->performedOn($subject)
|
||||
->causedBy($causer ?? auth('staff')->user())
|
||||
->causedBy(auth('staff')->user())
|
||||
->withProperties(['attributes' => $attributes])
|
||||
->log('created');
|
||||
}
|
||||
@@ -34,11 +28,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, ?Authenticatable $causer = null): void
|
||||
public function updated(Model $subject, array $old, array $attributes): void
|
||||
{
|
||||
activity('lunar')
|
||||
->performedOn($subject)
|
||||
->causedBy($causer ?? auth('staff')->user())
|
||||
->causedBy(auth('staff')->user())
|
||||
->withProperties(['old' => $old, 'attributes' => $attributes])
|
||||
->log('updated');
|
||||
}
|
||||
@@ -46,11 +40,11 @@ class ActivityLogService
|
||||
/**
|
||||
* 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')
|
||||
->performedOn($subject)
|
||||
->causedBy($causer ?? auth('staff')->user())
|
||||
->causedBy(auth('staff')->user())
|
||||
->withProperties(['attributes' => $attributes])
|
||||
->log('failed');
|
||||
}
|
||||
@@ -58,11 +52,11 @@ class ActivityLogService
|
||||
/**
|
||||
* 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')
|
||||
->performedOn($subject)
|
||||
->causedBy($causer ?? auth('staff')->user())
|
||||
->causedBy(auth('staff')->user())
|
||||
->withProperties(['attributes' => $attributes])
|
||||
->log('deleted');
|
||||
}
|
||||
|
||||
+50
-39
@@ -32,10 +32,6 @@ use ReflectionProperty;
|
||||
* could return a real, honest failure — see Payment\Support\
|
||||
* TransactionDriverAdapter's own docblock for that history.
|
||||
*
|
||||
* Fix, for capture: wrap the action's own action() closure so that, on
|
||||
* Halt, we call $action->sendFailureNotification() ourselves before letting
|
||||
* the Halt continue propagating — everything else is untouched.
|
||||
*
|
||||
* Fix, for refund: same notification fix, but the action() closure is
|
||||
* replaced outright (not wrapped) rather than reused, because refund also
|
||||
* needs a "Refund via" driver Select added to the modal (see
|
||||
@@ -43,15 +39,28 @@ use ReflectionProperty;
|
||||
* Payment\Support\TransactionDriverAdapter::refundVia() instead of
|
||||
* Lunar\Models\Transaction::refund() — see fixRefundAction()'s own
|
||||
* docblock.
|
||||
*
|
||||
* Fix, for capture: same notification fix, but the action() closure is
|
||||
* also replaced outright — the actual call is routed through
|
||||
* Payment\Support\TransactionDriverAdapter::capture() instead of
|
||||
* Lunar\Models\Transaction::capture() (see fixCaptureAction()), so a
|
||||
* manual backoffice capture goes through the app's own payment driver
|
||||
* registry and dispatches Payment\Events\PaymentCaptured exactly like a
|
||||
* checkout-time capture does — the vendor path resolved
|
||||
* Lunar\Facades\Payments (an entirely separate, unused driver registry)
|
||||
* and never dispatched that event, which is why Order::status used to
|
||||
* stay stuck on 'awaiting_payment' after a manual capture even though
|
||||
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus now advances it
|
||||
* on PaymentCaptured.
|
||||
*/
|
||||
class OrderRefundActionsExtension extends ViewPageExtension
|
||||
class OrderActionsExtension extends ViewPageExtension
|
||||
{
|
||||
public function headerActions(array $actions): array
|
||||
{
|
||||
return array_map(
|
||||
fn (Action $action) => match ($action->getName()) {
|
||||
'refund' => $this->fixRefundAction($action),
|
||||
'capture' => $this->fixFailureNotification($action),
|
||||
'capture' => $this->fixCaptureAction($action),
|
||||
default => $action,
|
||||
},
|
||||
$actions,
|
||||
@@ -123,6 +132,41 @@ class OrderRefundActionsExtension extends ViewPageExtension
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors fixRefundAction()'s notification fix, but for the "amount"
|
||||
* field already on the vendor schema — no extra field needed, since
|
||||
* capture always goes back through the transaction's own original
|
||||
* driver (there's no equivalent to refunding via a different driver).
|
||||
*/
|
||||
private function fixCaptureAction(Action $action): Action
|
||||
{
|
||||
return $action->action(function (array $data, Action $action) {
|
||||
$transaction = Transaction::find($data['transaction']);
|
||||
|
||||
if (! $transaction instanceof CoreTransaction) {
|
||||
$action->failureNotification(fn () => Notification::make('capture_failure')->danger()->title('Transaction not found.'))
|
||||
->sendFailureNotification();
|
||||
|
||||
throw new Halt;
|
||||
}
|
||||
|
||||
$response = app(TransactionDriverAdapter::class)->capture(
|
||||
$transaction,
|
||||
(int) bcmul((string) $data['amount'], (string) $transaction->order->currency->factor),
|
||||
);
|
||||
|
||||
if (! $response->success) {
|
||||
$action->failureNotification(
|
||||
fn () => Notification::make('capture_failure')->color('danger')->title($response->message)
|
||||
)->sendFailureNotification();
|
||||
|
||||
throw new Halt;
|
||||
}
|
||||
|
||||
$action->success();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@@ -163,37 +207,4 @@ class OrderRefundActionsExtension extends ViewPageExtension
|
||||
|
||||
return $reflected->getValue($object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the action's own configured action() closure so that, if it
|
||||
* halts (Lunar's closures throw via $action->halt() to signal failure —
|
||||
* see this class's own docblock for why that alone never sends the
|
||||
* notification queued via failureNotification()), we send that
|
||||
* notification ourselves before letting the Halt continue propagating
|
||||
* (still needed — it's what stops callMountedAction() from treating
|
||||
* this as a success and closing the modal/committing the DB transaction).
|
||||
*
|
||||
* $this->evaluate() (not a plain call) matches exactly how Action::call()
|
||||
* itself invokes the closure — Lunar's closures type-hint $data/$record/
|
||||
* $action and rely on Filament's own container-style parameter
|
||||
* resolution, not positional arguments.
|
||||
*/
|
||||
private function fixFailureNotification(Action $action): Action
|
||||
{
|
||||
$originalAction = $action->getActionFunction();
|
||||
|
||||
if ($originalAction === null) {
|
||||
return $action;
|
||||
}
|
||||
|
||||
return $action->action(function (array $arguments) use ($action, $originalAction) {
|
||||
try {
|
||||
return $action->evaluate($originalAction, $arguments);
|
||||
} catch (Halt $exception) {
|
||||
$action->sendFailureNotification();
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use Filament\Tables\Table;
|
||||
use Lunar\Admin\Support\Extending\BaseExtension;
|
||||
|
||||
/**
|
||||
* Same fix as OrderRefundActionsExtension, applied to the order lines
|
||||
* Same fix as OrderActionsExtension, applied to the order lines
|
||||
* table's "bulk_refund" toolbar action (Lunar\Admin\...\OrderItemsTable::
|
||||
* getBulkRefundAction()) — see that class's docblock for the underlying
|
||||
* Filament bug (failureNotification()+failure()+halt() never actually
|
||||
|
||||
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Event;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Order\Enums\PaymentStatus;
|
||||
use Modules\Core\Order\Services\OrderStatusFlow;
|
||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||
use Modules\Core\Order\Support\OrderStatus;
|
||||
use Modules\Core\Payment\Events\PaymentAuthorized;
|
||||
@@ -16,13 +17,16 @@ use Modules\Core\Payment\Events\PaymentRefunded;
|
||||
* Registered against PaymentCaptured, PaymentAuthorized, AND
|
||||
* PaymentRefunded (see OrderServiceProvider).
|
||||
*
|
||||
* A capture/authorization only ever writes Order::paid/paid_at (via
|
||||
* OrderStatusWriter::markPaid()) — never `status`. Confirmed with the
|
||||
* user: status leaving 'awaiting_payment' is always a staff-driven
|
||||
* "Update Status" click, regardless of payment method — no special-casing
|
||||
* prepaid vs. cash-on-delivery. A prepaid order briefly sitting at
|
||||
* 'awaiting_payment' with paid = true (until staff notice and advance it)
|
||||
* is expected, not a bug.
|
||||
* PaymentCaptured writes both Order::paid/paid_at (via
|
||||
* OrderStatusWriter::markPaid()) AND advances `status` out of
|
||||
* 'awaiting_payment' to the next step in the order's flow (see
|
||||
* OrderStatusFlow::nextOptions()) — re-confirmed with the user: a
|
||||
* captured payment, manual or via Stripe's webhook, should never leave an
|
||||
* order sitting at 'awaiting_payment'. Only fires when status is still
|
||||
* exactly 'awaiting_payment', so a duplicate/delayed capture event never
|
||||
* regresses an order staff already advanced further. PaymentAuthorized
|
||||
* only marks paid — an authorization is not yet captured funds, so
|
||||
* status stays put until the actual capture.
|
||||
*
|
||||
* A refund still moves `status` (returned -> refunded/partially_refunded)
|
||||
* — refunds are a normal step in Modules\Core\Order\Services\
|
||||
@@ -44,6 +48,7 @@ class ApplyResolvedPaymentStatus
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderStatusWriter $writer,
|
||||
private readonly OrderStatusFlow $flow,
|
||||
) {}
|
||||
|
||||
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
|
||||
@@ -66,12 +71,30 @@ class ApplyResolvedPaymentStatus
|
||||
|
||||
$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
|
||||
|
||||
@@ -49,6 +49,8 @@ class TransactionRecorder
|
||||
'reference' => $result->reference,
|
||||
'status' => $result->status->name,
|
||||
'notes' => $result->failureReason,
|
||||
'card_type' => $result->meta['card_type'] ?? null,
|
||||
'last_four' => $result->meta['last_four'] ?? null,
|
||||
'meta' => $result->meta,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ use Modules\Core\Payment\Events\PaymentRefunded;
|
||||
* chooses this driver explicitly in the refund action, independent of
|
||||
* which driver the original payment went through (see
|
||||
* Payment\Support\TransactionDriverAdapter::refundVia() and
|
||||
* Order\Filament\Extensions\OrderRefundActionsExtension). pay() exists so
|
||||
* Order\Filament\Extensions\OrderActionsExtension). pay() exists so
|
||||
* the same driver also covers receiving a payment by bank transfer, but
|
||||
* the admin UI for that (bank reference, notes, proof-of-transfer upload)
|
||||
* is deliberately not built yet — see the follow-up work tracked from this
|
||||
|
||||
@@ -4,9 +4,6 @@ namespace Modules\Core\Payment\Drivers;
|
||||
|
||||
use Lunar\DataTypes\Price;
|
||||
use Lunar\Models\Currency;
|
||||
use Lunar\Stripe\Facades\Stripe;
|
||||
use Lunar\Stripe\Managers\StripeManager;
|
||||
use Lunar\Stripe\Models\StripePaymentIntent;
|
||||
use Modules\Core\Payment\Contracts\Configurable;
|
||||
use Modules\Core\Payment\Contracts\HandlesPaymentCallback;
|
||||
use Modules\Core\Payment\Contracts\SupportsAuthorization;
|
||||
@@ -26,17 +23,20 @@ use Modules\Core\Payment\Events\PaymentRefundFailed;
|
||||
use Modules\Core\Payment\Events\PaymentRefunded;
|
||||
use Modules\Core\Payment\Events\PaymentVoidFailed;
|
||||
use Modules\Core\Payment\Events\PaymentVoided;
|
||||
use Modules\Core\Payment\Models\StripePaymentIntent;
|
||||
use Modules\Core\Payment\Support\StripeManager;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
use Stripe\PaymentIntent;
|
||||
|
||||
/**
|
||||
* Talks to Stripe's PaymentIntent API directly — deliberately NOT via
|
||||
* Lunar\Stripe\Facades\Stripe::createIntent()/fetchOrCreateIntent(), which
|
||||
* take a Lunar\Models\Cart and derive amount/currency from it. Payment
|
||||
* must never receive a Cart (see docs/payments.md) — pay()/authorize()
|
||||
* already receive $amount explicitly as their own required Lunar Price
|
||||
* parameter (see PaymentResult's own docblock), the caller's job to
|
||||
* assemble, same as every other driver.
|
||||
* Lunar's own checkout flow (lunarphp/stripe, since removed — see
|
||||
* Modules\Core\Payment\Support\StripeManager's own docblock), which took a
|
||||
* Lunar\Models\Cart and derived amount/currency from it. Payment must
|
||||
* never receive a Cart (see docs/payments.md) — pay()/authorize() already
|
||||
* receive $amount explicitly as their own required Lunar Price parameter
|
||||
* (see PaymentResult's own docblock), the caller's job to assemble, same
|
||||
* as every other driver.
|
||||
*
|
||||
* Every amount that crosses this class's own boundary is converted right
|
||||
* there: Lunar's Price -> Stripe's minor-unit int going INTO a gateway
|
||||
@@ -45,12 +45,11 @@ use Stripe\PaymentIntent;
|
||||
* Nothing outside this class ever sees a Stripe-scaled integer.
|
||||
*
|
||||
* Correlating a later handleCallback() (a separate request — a webhook)
|
||||
* back to whatever $context identified this attempt is solved the same
|
||||
* way lunarphp/stripe's own StripePaymentType/ProcessStripeWebhook solve
|
||||
* it: real cart_id/order_id columns on Lunar\Stripe\Models\
|
||||
* StripePaymentIntent (a table already owned by lunarphp/stripe, already
|
||||
* shaped for exactly this), not a generic context blob. See
|
||||
* docs/payments.md "Async resolution" for the full reasoning.
|
||||
* back to whatever $context identified this attempt is solved via real
|
||||
* cart_id/order_id columns on Modules\Core\Payment\Models\
|
||||
* StripePaymentIntent (a table this app now owns outright, already shaped
|
||||
* for exactly this), not a generic context blob. See docs/payments.md
|
||||
* "Async resolution" for the full reasoning.
|
||||
*/
|
||||
class StripePaymentDriver implements
|
||||
Configurable,
|
||||
@@ -61,16 +60,18 @@ class StripePaymentDriver implements
|
||||
SupportsRefunds,
|
||||
HandlesPaymentCallback
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StripeManager $stripe,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Same key lunarphp/stripe's own StripeManager reads its API key from
|
||||
* (Stripe::setApiKey(config('services.stripe.key'))) — no key, no
|
||||
* usable driver.
|
||||
* Same key StripeManager reads its API key from — no key, no usable
|
||||
* driver.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled(config('services.stripe.key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic charge — capture_method: automatic. Stripe still frequently
|
||||
* confirms into requires_action/requires_confirmation rather than
|
||||
@@ -100,16 +101,22 @@ class StripePaymentDriver implements
|
||||
'currency' => $amount->currency->code,
|
||||
'capture_method' => $captureMethod,
|
||||
'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'])) {
|
||||
$params['payment_method'] = $data['payment_method'];
|
||||
} else {
|
||||
$params['automatic_payment_methods'] = ['enabled' => true];
|
||||
}
|
||||
|
||||
try {
|
||||
$paymentIntent = Stripe::getClient()->paymentIntents->create($params);
|
||||
$paymentIntent = $this->stripe->getClient()->paymentIntents->create($params);
|
||||
} catch (ApiErrorException $e) {
|
||||
return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual');
|
||||
}
|
||||
@@ -123,7 +130,7 @@ class StripePaymentDriver implements
|
||||
{
|
||||
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context, $data['type'] ?? '');
|
||||
|
||||
$paymentIntent = Stripe::getClient()->paymentIntents->retrieve($reference);
|
||||
$paymentIntent = $this->stripe->getClient()->paymentIntents->retrieve($reference);
|
||||
|
||||
$authorizing = $paymentIntent->capture_method === PaymentIntent::CAPTURE_METHOD_MANUAL;
|
||||
|
||||
@@ -131,7 +138,7 @@ class StripePaymentDriver implements
|
||||
// automatic capture_method, but Stripe stopped short of
|
||||
// capturing (rare, but the API contract allows it) — finish
|
||||
// the job pay() started.
|
||||
$paymentIntent = Stripe::getClient()->paymentIntents->capture($reference);
|
||||
$paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference);
|
||||
}
|
||||
|
||||
$intentModel?->update(['status' => $paymentIntent->status]);
|
||||
@@ -146,7 +153,7 @@ class StripePaymentDriver implements
|
||||
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
|
||||
|
||||
try {
|
||||
$paymentIntent = Stripe::getClient()->paymentIntents->capture($reference, [
|
||||
$paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference, [
|
||||
'amount_to_capture' => StripeManager::toStripeAmount($amount->value, $amount->currency),
|
||||
]);
|
||||
} catch (ApiErrorException $e) {
|
||||
@@ -165,6 +172,7 @@ class StripePaymentDriver implements
|
||||
reference: $paymentIntent->id,
|
||||
amount: $amount,
|
||||
raw: $paymentIntent->toArray(),
|
||||
meta: $this->cardMetaFromIntent($paymentIntent),
|
||||
);
|
||||
|
||||
$paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED
|
||||
@@ -179,7 +187,7 @@ class StripePaymentDriver implements
|
||||
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
|
||||
|
||||
try {
|
||||
$paymentIntent = Stripe::getClient()->paymentIntents->cancel($reference);
|
||||
$paymentIntent = $this->stripe->getClient()->paymentIntents->cancel($reference);
|
||||
} catch (ApiErrorException $e) {
|
||||
$result = $this->failure($amount, $e, $reference);
|
||||
PaymentVoidFailed::dispatch($type, $result, $context);
|
||||
@@ -210,7 +218,7 @@ class StripePaymentDriver implements
|
||||
[$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context);
|
||||
|
||||
try {
|
||||
$refund = Stripe::getClient()->refunds->create([
|
||||
$refund = $this->stripe->getClient()->refunds->create([
|
||||
'payment_intent' => $reference,
|
||||
'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
|
||||
]);
|
||||
@@ -247,7 +255,7 @@ class StripePaymentDriver implements
|
||||
'order_id' => $context['order_id'] ?? null,
|
||||
'status' => $paymentIntent->status,
|
||||
'payment_type' => $type,
|
||||
'context' => json_encode($context),
|
||||
'context' => $context,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -272,28 +280,10 @@ class StripePaymentDriver implements
|
||||
return [
|
||||
$intentModel,
|
||||
$intentModel?->payment_type ?? $typeFallback,
|
||||
$this->decodeContext($intentModel) ?? $context,
|
||||
$intentModel?->context ?? $context,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* StripePaymentIntent is a vendor model (lunarphp/stripe) with no cast
|
||||
* declared for our own 'context' column (added by boboko-core's own
|
||||
* migration, see database/migrations/..._add_context_to_stripe_
|
||||
* payment_intents.php) — we can't edit the vendor model to add one, so
|
||||
* decode manually here instead of assuming Eloquent already did it.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function decodeContext(?StripePaymentIntent $intentModel): ?array
|
||||
{
|
||||
if (! $intentModel || ! $intentModel->context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode($intentModel->context, associative: true) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a live Stripe PaymentIntent's own amount/currency back
|
||||
* into Lunar's Price — the one place this class reads a Stripe
|
||||
@@ -335,6 +325,7 @@ class StripePaymentDriver implements
|
||||
amount: $amount,
|
||||
failureReason: $paymentIntent->last_payment_error->message ?? null,
|
||||
raw: $paymentIntent->toArray(),
|
||||
meta: $status === PaymentResultStatus::Pending ? [] : $this->cardMetaFromIntent($paymentIntent),
|
||||
continuation: $continuation,
|
||||
);
|
||||
|
||||
@@ -357,6 +348,40 @@ class StripePaymentDriver implements
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* card_type/last_four for Modules\Core\Order\Services\
|
||||
* TransactionRecorder to map onto Transaction (see PaymentResult::
|
||||
* $meta's own docblock) — same fields, same source
|
||||
* (payment_method_details on the underlying Charge) as lunarphp/
|
||||
* stripe's own StoreCharges, just reached via latest_charge instead of
|
||||
* an order-level charge list, since this driver has no Order/Cart to
|
||||
* enumerate charges from.
|
||||
*
|
||||
* @return array{card_type?: string, last_four?: string}
|
||||
*/
|
||||
private function cardMetaFromIntent(PaymentIntent $paymentIntent): array
|
||||
{
|
||||
$chargeId = $paymentIntent->latest_charge;
|
||||
|
||||
if (blank($chargeId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$charge = $this->stripe->getCharge(is_string($chargeId) ? $chargeId : $chargeId->id);
|
||||
|
||||
$paymentType = collect($charge->payment_method_details)->keys()->first();
|
||||
$details = collect($charge->payment_method_details)->first();
|
||||
|
||||
if (blank($details)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_filter([
|
||||
'card_type' => $details['brand'] ?? $paymentType,
|
||||
'last_four' => $details['last4'] ?? null,
|
||||
], fn ($value) => filled($value));
|
||||
}
|
||||
|
||||
private function declined(string $type, Price $amount, ApiErrorException $e, array $context, bool $authorizing): PaymentResult
|
||||
{
|
||||
$result = $this->failure($amount, $e);
|
||||
|
||||
@@ -9,18 +9,17 @@ use Modules\Core\Payment\Drivers\StripePaymentDriver;
|
||||
use Stripe\Webhook;
|
||||
|
||||
/**
|
||||
* A boboko-owned webhook endpoint for Stripe — deliberately NOT
|
||||
* lunarphp/stripe's own route (vendor/lunarphp/stripe/routes/webhooks.php),
|
||||
* which dispatches into Lunar's own Payments::driver('stripe') flow (the
|
||||
* flow StripePaymentDriver was built to replace, see that class's own
|
||||
* docblock). Signature verification is handled by
|
||||
* Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware, registered on this
|
||||
* route (see src/Payment/routes/webhooks.php) — pure Stripe SDK
|
||||
* verification + event-type filtering, safe to reuse even though this
|
||||
* controller never touches the rest of that vendor package's flow. This
|
||||
* controller verifies the signature again itself (Webhook::constructEvent())
|
||||
* to get the constructed Event object — the middleware doesn't stash one
|
||||
* anywhere reusable, it only gates the request through.
|
||||
* A boboko-owned webhook endpoint for Stripe — never went through Lunar's
|
||||
* own Payments::driver('stripe') flow (the flow StripePaymentDriver was
|
||||
* built to replace, see that class's own docblock), and lunarphp/stripe
|
||||
* has since been removed entirely (see Modules\Core\Payment\Support\
|
||||
* StripeManager's own docblock). Signature verification is handled by
|
||||
* Modules\Core\Payment\Http\Middleware\StripeWebhookMiddleware, registered
|
||||
* on this route (see src/Payment/routes/webhooks.php) — pure Stripe SDK
|
||||
* verification + event-type filtering. This controller verifies the
|
||||
* signature again itself (Webhook::constructEvent()) to get the
|
||||
* constructed Event object — the middleware doesn't stash one anywhere
|
||||
* reusable, it only gates the request through.
|
||||
*
|
||||
* Resolves the driver directly by class, not via
|
||||
* Modules\Core\Payment\Services\PaymentDriverRegistry — this endpoint is
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Stripe\Exception\SignatureVerificationException;
|
||||
use Stripe\Exception\UnexpectedValueException;
|
||||
use Stripe\Webhook;
|
||||
|
||||
/**
|
||||
* First-party replacement for Lunar\Stripe\Http\Middleware\
|
||||
* StripeWebhookMiddleware (lunarphp/stripe removed — see
|
||||
* Modules\Core\Payment\Support\StripeManager's own docblock). Registered
|
||||
* on the same route as before (src/Payment/routes/webhooks.php) purely to
|
||||
* gate malformed/irrelevant requests before they reach
|
||||
* Modules\Core\Payment\Http\Controllers\StripeWebhookController, which
|
||||
* re-verifies the signature itself (see that controller's own docblock)
|
||||
* to get the constructed Event object — this duplication predates the
|
||||
* package removal and is left unchanged here.
|
||||
*/
|
||||
class StripeWebhookMiddleware
|
||||
{
|
||||
public function handle(Request $request, ?Closure $next = null)
|
||||
{
|
||||
$secret = config('services.stripe.webhooks.lunar');
|
||||
$stripeSig = $request->header('Stripe-Signature');
|
||||
|
||||
try {
|
||||
$event = Webhook::constructEvent(
|
||||
$request->getContent(),
|
||||
$stripeSig,
|
||||
$secret
|
||||
);
|
||||
} catch (UnexpectedValueException|SignatureVerificationException $e) {
|
||||
abort(400, $e->getMessage());
|
||||
}
|
||||
|
||||
if (! in_array(
|
||||
$event->type,
|
||||
[
|
||||
'payment_intent.payment_failed',
|
||||
'payment_intent.succeeded',
|
||||
]
|
||||
)) {
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Models;
|
||||
|
||||
use Lunar\Base\BaseModel;
|
||||
|
||||
/**
|
||||
* First-party replacement for Lunar\Stripe\Models\StripePaymentIntent (the
|
||||
* lunarphp/stripe package was removed — see Modules\Core\Payment\Support\
|
||||
* StripeManager's own docblock). Same table (lunar_stripe_payment_intents,
|
||||
* created by database/migrations/..._create_stripe_payment_intents_table,
|
||||
* a first-party copy of the vendor migration), including the app-owned
|
||||
* `context`/`payment_type` columns Modules\Core\Payment\Drivers\
|
||||
* StripePaymentDriver::handleCallback() needs to recover $context/$type
|
||||
* across the separate request a webhook arrives on — see that class's own
|
||||
* docblock for "Async resolution".
|
||||
*
|
||||
* Extends Lunar\Base\BaseModel (from lunarphp/core, unaffected by removing
|
||||
* lunarphp/stripe) purely so table-prefix resolution
|
||||
* (config('lunar.database.table_prefix')) stays identical to how the
|
||||
* vendor model resolved it — this table was created under that prefix.
|
||||
*/
|
||||
class StripePaymentIntent extends BaseModel
|
||||
{
|
||||
protected $table = 'stripe_payment_intents';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'context' => 'array',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Support;
|
||||
|
||||
use Lunar\Models\Contracts\Currency as CurrencyContract;
|
||||
use Stripe\Charge;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
/**
|
||||
* First-party replacement for Lunar\Stripe\Facades\Stripe +
|
||||
* Lunar\Stripe\Managers\StripeManager — lunarphp/stripe was removed once
|
||||
* Modules\Core\Payment\Drivers\StripePaymentDriver already replaced every
|
||||
* bit of Lunar's own Stripe payment flow (see that class's own docblock);
|
||||
* all that remained load-bearing from the package was raw API-client
|
||||
* access and amount conversion, neither of which is Lunar-specific. Only
|
||||
* the methods StripePaymentDriver actually called are kept — no
|
||||
* fetchOrCreateIntent()/cart-bound helpers, which belonged to Lunar's own
|
||||
* (unused) checkout flow.
|
||||
*
|
||||
* getClient()/getCharge() call the Stripe SDK directly rather than going
|
||||
* through a facade — StripePaymentDriver resolves this class via the
|
||||
* container instead, same as every other dependency it takes.
|
||||
*/
|
||||
class StripeManager
|
||||
{
|
||||
public function getClient(): StripeClient
|
||||
{
|
||||
return new StripeClient([
|
||||
'api_key' => config('services.stripe.key'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function getCharge(string $chargeId): Charge
|
||||
{
|
||||
return $this->getClient()->charges->retrieve($chargeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-decimal currencies, per Stripe. The amount sent to Stripe is the
|
||||
* major unit amount as-is.
|
||||
*
|
||||
* @see https://docs.stripe.com/currencies#zero-decimal
|
||||
*/
|
||||
protected const ZERO_DECIMAL_CURRENCIES = [
|
||||
'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg',
|
||||
'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
|
||||
];
|
||||
|
||||
/**
|
||||
* Three-decimal currencies, per Stripe. The amount sent to Stripe is the
|
||||
* major unit amount multiplied by 1000.
|
||||
*
|
||||
* @see https://docs.stripe.com/currencies#three-decimal
|
||||
*/
|
||||
protected const THREE_DECIMAL_CURRENCIES = ['bhd', 'jod', 'kwd', 'omr', 'tnd'];
|
||||
|
||||
/**
|
||||
* HUF, TWD and UGX are ISO zero-decimal currencies, but Stripe still
|
||||
* requires amounts to be sent as if they had two decimal places.
|
||||
*
|
||||
* @see https://docs.stripe.com/currencies#special-cases
|
||||
*/
|
||||
protected const SPECIAL_ZERO_DECIMAL_CURRENCIES = ['huf', 'twd', 'ugx'];
|
||||
|
||||
/**
|
||||
* Convert a Lunar price value to the amount expected by Stripe.
|
||||
*
|
||||
* Lunar stores prices as integers scaled by `Currency::decimal_places`,
|
||||
* which merchants can set independently of what Stripe expects for a
|
||||
* given currency. This converts back to the major unit amount first,
|
||||
* then re-scales it to whatever sub-unit Stripe requires for the
|
||||
* currency, so the result is correct regardless of how the merchant has
|
||||
* configured `Currency::decimal_places`.
|
||||
*
|
||||
* @see https://docs.stripe.com/currencies
|
||||
*/
|
||||
public static function toStripeAmount(int $value, CurrencyContract $currency): int
|
||||
{
|
||||
return self::rescale($value, max($currency->decimal_places, 0), self::stripeDecimalPlaces($currency));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an amount received from Stripe back to a Lunar price value,
|
||||
* scaled by `Currency::decimal_places`. Inverse of `toStripeAmount()`.
|
||||
*/
|
||||
public static function fromStripeAmount(int $amount, CurrencyContract $currency): int
|
||||
{
|
||||
return self::rescale($amount, self::stripeDecimalPlaces($currency), max($currency->decimal_places, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of decimal places Stripe expects amounts in for a currency.
|
||||
*/
|
||||
protected static function stripeDecimalPlaces(CurrencyContract $currency): int
|
||||
{
|
||||
$code = strtolower($currency->code);
|
||||
|
||||
// UGX is also in the zero-decimal list; the special case takes precedence.
|
||||
if (in_array($code, self::SPECIAL_ZERO_DECIMAL_CURRENCIES, true)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (in_array($code, self::ZERO_DECIMAL_CURRENCIES, true)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (in_array($code, self::THREE_DECIMAL_CURRENCIES, true)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
protected static function rescale(int $value, int $fromDecimalPlaces, int $toDecimalPlaces): int
|
||||
{
|
||||
$exponent = $toDecimalPlaces - $fromDecimalPlaces;
|
||||
|
||||
if ($exponent >= 0) {
|
||||
return $value * (10 ** $exponent);
|
||||
}
|
||||
|
||||
$divisor = 10 ** (-$exponent);
|
||||
|
||||
return intdiv(abs($value) + intdiv($divisor, 2), $divisor) * ($value < 0 ? -1 : 1);
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class TransactionDriverAdapter
|
||||
/**
|
||||
* The PaymentDriverRegistry key $transaction was originally taken
|
||||
* through — what refund()/capture() resolve against by default, and
|
||||
* what Order\Filament\Extensions\OrderRefundActionsExtension defaults
|
||||
* what Order\Filament\Extensions\OrderActionsExtension defaults
|
||||
* its "Refund via" driver Select to, before an admin overrides it.
|
||||
*/
|
||||
public function driverKeyFor(Transaction $transaction): ?string
|
||||
@@ -72,7 +72,7 @@ class TransactionDriverAdapter
|
||||
* when refunding through the transaction's own original driver.
|
||||
*
|
||||
* Called directly by Order\Filament\Extensions\
|
||||
* OrderRefundActionsExtension when the admin picks a different driver
|
||||
* OrderActionsExtension when the admin picks a different driver
|
||||
* in the refund modal, bypassing Lunar\Models\Transaction::refund()
|
||||
* (whose fixed refund(int $amount, $notes = null) signature has no
|
||||
* room for a driver override) — see that extension's own docblock.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware;
|
||||
use Modules\Core\Payment\Http\Controllers\StripeWebhookController;
|
||||
use Modules\Core\Payment\Http\Middleware\StripeWebhookMiddleware;
|
||||
|
||||
Route::post(
|
||||
config('payment.stripe.webhook_path', 'payments/stripe/webhook'),
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Modules\Core\Providers;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Core\Command\AnonymizeCommand;
|
||||
use Modules\Core\Command\BackfillMissingSkusCommand;
|
||||
use Modules\Core\Command\ExportCleanupCommand;
|
||||
use Modules\Core\Command\ExportCommand;
|
||||
use Modules\Core\Command\ImportCommand;
|
||||
@@ -36,7 +37,7 @@ class CoreServiceProvider extends ServiceProvider
|
||||
], 'core-assets');
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class]);
|
||||
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class, BackfillMissingSkusCommand::class]);
|
||||
|
||||
//Overriding lunar:install
|
||||
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
|
||||
|
||||
@@ -7,12 +7,7 @@ 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
|
||||
@@ -22,10 +17,5 @@ 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']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user