From 81368e6feac2be8a8f677ed596f4c84e5a360f1b Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 01:29:43 +0100 Subject: [PATCH 01/21] License management + sublicenses --- app/Actions/SubLicenses/DeleteSubLicense.php | 21 + app/Actions/SubLicenses/SuspendSubLicense.php | 25 + .../SubLicenses/UnsuspendSubLicense.php | 25 + app/Enums/Subscription.php | 14 + .../Auth/CustomerAuthController.php | 86 +++ .../Controllers/CustomerLicenseController.php | 49 ++ .../CustomerSubLicenseController.php | 110 ++++ app/Http/Middleware/Authenticate.php | 2 +- app/Http/Requests/Auth/LoginRequest.php | 64 ++ app/Http/Requests/CreateSubLicenseRequest.php | 27 + app/Jobs/CreateAnystackSubLicenseJob.php | 48 ++ app/Models/License.php | 91 +++ app/Models/SubLicense.php | 102 +++ app/Providers/RouteServiceProvider.php | 2 +- database/factories/SubLicenseFactory.php | 60 ++ ...09_11_224713_create_sub_licenses_table.php | 34 + ..._add_anystack_id_to_sub_licenses_table.php | 29 + ...9_11_231637_add_name_to_licenses_table.php | 28 + .../views/auth/forgot-password.blade.php | 55 ++ resources/views/auth/login.blade.php | 89 +++ resources/views/auth/reset-password.blade.php | 77 +++ .../views/customer/licenses/index.blade.php | 114 ++++ .../views/customer/licenses/show.blade.php | 583 ++++++++++++++++++ routes/web.php | 30 + tests/Feature/CustomerAuthenticationTest.php | 88 +++ .../Feature/CustomerLicenseManagementTest.php | 282 +++++++++ .../CustomerSubLicenseManagementTest.php | 372 +++++++++++ 27 files changed, 2505 insertions(+), 2 deletions(-) create mode 100644 app/Actions/SubLicenses/DeleteSubLicense.php create mode 100644 app/Actions/SubLicenses/SuspendSubLicense.php create mode 100644 app/Actions/SubLicenses/UnsuspendSubLicense.php create mode 100644 app/Http/Controllers/Auth/CustomerAuthController.php create mode 100644 app/Http/Controllers/CustomerLicenseController.php create mode 100644 app/Http/Controllers/CustomerSubLicenseController.php create mode 100644 app/Http/Requests/Auth/LoginRequest.php create mode 100644 app/Http/Requests/CreateSubLicenseRequest.php create mode 100644 app/Jobs/CreateAnystackSubLicenseJob.php create mode 100644 app/Models/SubLicense.php create mode 100644 database/factories/SubLicenseFactory.php create mode 100644 database/migrations/2025_09_11_224713_create_sub_licenses_table.php create mode 100644 database/migrations/2025_09_11_230606_add_anystack_id_to_sub_licenses_table.php create mode 100644 database/migrations/2025_09_11_231637_add_name_to_licenses_table.php create mode 100644 resources/views/auth/forgot-password.blade.php create mode 100644 resources/views/auth/login.blade.php create mode 100644 resources/views/auth/reset-password.blade.php create mode 100644 resources/views/customer/licenses/index.blade.php create mode 100644 resources/views/customer/licenses/show.blade.php create mode 100644 tests/Feature/CustomerAuthenticationTest.php create mode 100644 tests/Feature/CustomerLicenseManagementTest.php create mode 100644 tests/Feature/CustomerSubLicenseManagementTest.php diff --git a/app/Actions/SubLicenses/DeleteSubLicense.php b/app/Actions/SubLicenses/DeleteSubLicense.php new file mode 100644 index 000000000..e413fe452 --- /dev/null +++ b/app/Actions/SubLicenses/DeleteSubLicense.php @@ -0,0 +1,21 @@ +license($subLicense->anystack_id, $subLicense->parentLicense->subscriptionType->anystackProductId()) + ->delete(); + + return $subLicense->delete(); + } +} diff --git a/app/Actions/SubLicenses/SuspendSubLicense.php b/app/Actions/SubLicenses/SuspendSubLicense.php new file mode 100644 index 000000000..13b81dfe2 --- /dev/null +++ b/app/Actions/SubLicenses/SuspendSubLicense.php @@ -0,0 +1,25 @@ +license($subLicense->anystack_id, $subLicense->parentLicense->subscriptionType->anystackProductId()) + ->suspend(); + + $subLicense->update([ + 'is_suspended' => true, + ]); + + return $subLicense; + } +} diff --git a/app/Actions/SubLicenses/UnsuspendSubLicense.php b/app/Actions/SubLicenses/UnsuspendSubLicense.php new file mode 100644 index 000000000..f15c53451 --- /dev/null +++ b/app/Actions/SubLicenses/UnsuspendSubLicense.php @@ -0,0 +1,25 @@ +license($subLicense->anystack_id, $subLicense->parentLicense->subscriptionType->anystackProductId()) + ->suspend(false); + + $subLicense->update([ + 'is_suspended' => false, + ]); + + return $subLicense; + } +} diff --git a/app/Enums/Subscription.php b/app/Enums/Subscription.php index f336b0b56..175cb46b1 100644 --- a/app/Enums/Subscription.php +++ b/app/Enums/Subscription.php @@ -75,4 +75,18 @@ public function anystackPolicyId(): string { return config("subscriptions.plans.{$this->value}.anystack_policy_id"); } + + public function supportsSubLicenses(): bool + { + return in_array($this, [self::Pro, self::Max, self::Forever]); + } + + public function subLicenseLimit(): ?int + { + return match ($this) { + self::Pro => 10, + self::Max, self::Forever => null, // Unlimited + default => 0, + }; + } } diff --git a/app/Http/Controllers/Auth/CustomerAuthController.php b/app/Http/Controllers/Auth/CustomerAuthController.php new file mode 100644 index 000000000..cc98b7af0 --- /dev/null +++ b/app/Http/Controllers/Auth/CustomerAuthController.php @@ -0,0 +1,86 @@ +authenticate(); + + $request->session()->regenerate(); + + return redirect()->intended(route('customer.licenses')); + } + + public function logout(Request $request): RedirectResponse + { + Auth::logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('customer.login'); + } + + public function showForgotPassword(): View + { + return view('auth.forgot-password'); + } + + public function sendPasswordResetLink(Request $request): RedirectResponse + { + $request->validate([ + 'email' => ['required', 'email:rfc,dns'], + ]); + + $status = \Illuminate\Support\Facades\Password::sendResetLink( + $request->only('email') + ); + + return $status === \Illuminate\Auth\Passwords\PasswordBroker::RESET_LINK_SENT + ? back()->with(['status' => __($status)]) + : back()->withErrors(['email' => __($status)]); + } + + public function showResetPassword(string $token): View + { + return view('auth.reset-password', ['token' => $token]); + } + + public function resetPassword(Request $request): RedirectResponse + { + $request->validate([ + 'token' => ['required'], + 'email' => ['required', 'email:rfc,dns'], + 'password' => ['required', 'min:8', 'confirmed'], + ]); + + $status = \Illuminate\Support\Facades\Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function ($user, $password) { + $user->forceFill([ + 'password' => $password, + ]); + + $user->save(); + } + ); + + return $status === \Illuminate\Auth\Passwords\PasswordBroker::PASSWORD_RESET + ? redirect()->route('customer.login')->with('status', __($status)) + : back()->withErrors(['email' => [__($status)]]); + } +} diff --git a/app/Http/Controllers/CustomerLicenseController.php b/app/Http/Controllers/CustomerLicenseController.php new file mode 100644 index 000000000..16356fc25 --- /dev/null +++ b/app/Http/Controllers/CustomerLicenseController.php @@ -0,0 +1,49 @@ +middleware('auth'); + } + + public function index(): View + { + $user = Auth::user(); + $licenses = $user->licenses()->orderBy('created_at', 'desc')->get(); + + return view('customer.licenses.index', compact('licenses')); + } + + public function show(string $licenseKey): View + { + $user = Auth::user(); + $license = $user->licenses()->with('subLicenses')->where('key', $licenseKey)->firstOrFail(); + + return view('customer.licenses.show', compact('license')); + } + + public function update(Request $request, string $licenseKey): RedirectResponse + { + $user = Auth::user(); + $license = $user->licenses()->where('key', $licenseKey)->firstOrFail(); + + $request->validate([ + 'name' => ['nullable', 'string', 'max:255'], + ]); + + $license->update([ + 'name' => $request->name, + ]); + + return redirect()->route('customer.licenses.show', $licenseKey) + ->with('success', 'License name updated successfully!'); + } +} diff --git a/app/Http/Controllers/CustomerSubLicenseController.php b/app/Http/Controllers/CustomerSubLicenseController.php new file mode 100644 index 000000000..b07cb0206 --- /dev/null +++ b/app/Http/Controllers/CustomerSubLicenseController.php @@ -0,0 +1,110 @@ +middleware('auth'); + } + + public function store(CreateSubLicenseRequest $request, string $licenseKey): RedirectResponse + { + $user = Auth::user(); + $license = $user->licenses()->where('key', $licenseKey)->firstOrFail(); + + if (! $license->canCreateSubLicense()) { + return redirect()->route('customer.licenses.show', $licenseKey)->withErrors([ + 'sub_license' => 'Unable to create sub-license. Check license status and limits.', + ]); + } + + // Dispatch job to create sub-license in Anystack and then locally + CreateAnystackSubLicenseJob::dispatch($license, $request->name); + + return redirect()->route('customer.licenses.show', $licenseKey) + ->with('success', 'Sub-license is being created. You will receive an email notification when it\'s ready.'); + } + + public function update(Request $request, string $licenseKey, SubLicense $subLicense): RedirectResponse + { + $user = Auth::user(); + $license = $user->licenses()->where('key', $licenseKey)->firstOrFail(); + + // Verify the sub-license belongs to this license + if ($subLicense->parent_license_id !== $license->id) { + abort(404); + } + + $request->validate([ + 'name' => ['nullable', 'string', 'max:255'], + ]); + + $subLicense->update([ + 'name' => $request->name, + ]); + + return redirect()->route('customer.licenses.show', $licenseKey) + ->with('success', 'Sub-license updated successfully!'); + } + + public function destroy(string $licenseKey, SubLicense $subLicense): RedirectResponse + { + $user = Auth::user(); + $license = $user->licenses()->where('key', $licenseKey)->firstOrFail(); + + // Verify the sub-license belongs to this license + if ($subLicense->parent_license_id !== $license->id) { + abort(404); + } + + app(DeleteSubLicense::class)->handle($subLicense); + + return redirect()->route('customer.licenses.show', $licenseKey) + ->with('success', 'Sub-license deleted successfully!'); + } + + public function suspend(string $licenseKey, SubLicense $subLicense): RedirectResponse + { + $user = Auth::user(); + $license = $user->licenses()->where('key', $licenseKey)->firstOrFail(); + + // Verify the sub-license belongs to this license + if ($subLicense->parent_license_id !== $license->id) { + abort(404); + } + + app(SuspendSubLicense::class)->handle($subLicense); + + return redirect()->route('customer.licenses.show', $licenseKey) + ->with('success', 'Sub-license suspended successfully!'); + } + + public function unsuspend(string $licenseKey, SubLicense $subLicense): RedirectResponse + { + $user = Auth::user(); + $license = $user->licenses()->where('key', $licenseKey)->firstOrFail(); + + // Verify the sub-license belongs to this license + if ($subLicense->parent_license_id !== $license->id) { + abort(404); + } + + app(UnsuspendSubLicense::class)->handle($subLicense); + + return redirect()->route('customer.licenses.show', $licenseKey) + ->with('success', 'Sub-license unsuspended successfully!'); + } +} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index d4ef6447a..42d035fc0 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -12,6 +12,6 @@ class Authenticate extends Middleware */ protected function redirectTo(Request $request): ?string { - return $request->expectsJson() ? null : route('login'); + return $request->expectsJson() ? null : route('customer.login'); } } diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 000000000..2dfca7e0f --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,64 @@ + ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]; + } + + public function authenticate(): void + { + $this->ensureIsNotRateLimited(); + + if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { + RateLimiter::hit($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.failed'), + ]); + } + + RateLimiter::clear($this->throttleKey()); + } + + public function ensureIsNotRateLimited(): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { + return; + } + + event(new Lockout($this)); + + $seconds = RateLimiter::availableIn($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ]), + ]); + } + + public function throttleKey(): string + { + return Str::transliterate(Str::lower($this->input('email')).'|'.$this->ip()); + } +} diff --git a/app/Http/Requests/CreateSubLicenseRequest.php b/app/Http/Requests/CreateSubLicenseRequest.php new file mode 100644 index 000000000..beb6c35fd --- /dev/null +++ b/app/Http/Requests/CreateSubLicenseRequest.php @@ -0,0 +1,27 @@ + ['nullable', 'string', 'max:255'], + ]; + } + + public function messages(): array + { + return [ + 'name.max' => 'The name cannot be longer than 255 characters.', + ]; + } +} diff --git a/app/Jobs/CreateAnystackSubLicenseJob.php b/app/Jobs/CreateAnystackSubLicenseJob.php new file mode 100644 index 000000000..1907e2b69 --- /dev/null +++ b/app/Jobs/CreateAnystackSubLicenseJob.php @@ -0,0 +1,48 @@ +createSubLicenseInAnystack(); + + $subLicense = $this->parentLicense->subLicenses()->create([ + 'anystack_id' => $licenseData['id'], + 'name' => $this->name, + 'key' => $licenseData['key'], + 'expires_at' => $licenseData['expires_at'], + ]); + } + + private function createSubLicenseInAnystack(): array + { + $data = [ + 'policy_id' => $this->parentLicense->subscriptionType->anystackPolicyId(), + 'contact_id' => $this->parentLicense->user->anystack_contact_id, + 'parent_license_id' => $this->parentLicense->anystack_id, + ]; + + return Anystack::api() + ->licenses($this->parentLicense->subscriptionType->anystackProductId()) + ->create($data) + ->throw() + ->json('data'); + } +} diff --git a/app/Models/License.php b/app/Models/License.php index c88fe335d..bf5b85de7 100644 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Laravel\Cashier\SubscriptionItem; class License extends Model @@ -46,8 +47,98 @@ public function scopeWhereActive(Builder $builder): Builder ); } + /** + * @return HasMany + */ + public function subLicenses(): HasMany + { + return $this->hasMany(SubLicense::class, 'parent_license_id'); + } + + /** + * @return HasMany + */ + public function expiryWarnings(): HasMany + { + return $this->hasMany(LicenseExpiryWarning::class); + } + public function getAnystackProductIdAttribute(): string { return Subscription::from($this->policy_name)->anystackProductId(); } + + public function getSubscriptionTypeAttribute(): Subscription + { + return Subscription::from($this->policy_name); + } + + public function supportsSubLicenses(): bool + { + return $this->subscriptionType->supportsSubLicenses(); + } + + public function getSubLicenseLimitAttribute(): ?int + { + return $this->subscriptionType->subLicenseLimit(); + } + + public function getRemainingSubLicensesAttribute(): ?int + { + $limit = $this->subLicenseLimit; + + if ($limit === null) { + return null; // Unlimited + } + + $used = $this->subLicenses()->count(); + + return max(0, $limit - $used); + } + + public function canCreateSubLicense(): bool + { + if (! $this->supportsSubLicenses()) { + return false; + } + + if ($this->is_suspended) { + return false; + } + + if ($this->expires_at && $this->expires_at->isPast()) { + return false; + } + + $remaining = $this->remainingSubLicenses; + + return $remaining === null || $remaining > 0; + } + + public function suspendAllSubLicenses(): int + { + return $this->subLicenses()->update(['is_suspended' => true]); + } + + public function unsuspendAllSubLicenses(): int + { + return $this->subLicenses()->update(['is_suspended' => false]); + } + + protected static function boot(): void + { + parent::boot(); + + static::updated(function (self $license) { + // If parent license is suspended, suspend all sub-licenses + if ($license->isDirty('is_suspended') && $license->is_suspended) { + $license->suspendAllSubLicenses(); + } + + // If parent license expiry changed, update all sub-license expiry dates + if ($license->isDirty('expires_at')) { + $license->subLicenses()->update(['expires_at' => $license->expires_at]); + } + }); + } } diff --git a/app/Models/SubLicense.php b/app/Models/SubLicense.php new file mode 100644 index 000000000..845bf8f37 --- /dev/null +++ b/app/Models/SubLicense.php @@ -0,0 +1,102 @@ + 'datetime', + 'is_suspended' => 'boolean', + ]; + + protected static function boot(): void + { + parent::boot(); + + static::creating(function (self $subLicense) { + // Sub-licenses must always come from Anystack with real license keys + // Never auto-generate keys locally + + // Set expiry to match parent license only if not explicitly set + if ($subLicense->parentLicense && $subLicense->parentLicense->expires_at && ! $subLicense->expires_at) { + $subLicense->expires_at = $subLicense->parentLicense->expires_at; + } + }); + } + + /** + * @return BelongsTo + */ + public function parentLicense(): BelongsTo + { + return $this->belongsTo(License::class, 'parent_license_id'); + } + + public function scopeWhereActive(Builder $builder): Builder + { + return $builder->where(fn ($where) => $where + ->where('is_suspended', false) + ->where(fn ($expiry) => $expiry + ->whereNull('expires_at') + ->orWhere('expires_at', '>', now()) + ) + ); + } + + public function scopeWhereSuspended(Builder $builder): Builder + { + return $builder->where('is_suspended', true); + } + + public function scopeWhereExpired(Builder $builder): Builder + { + return $builder->where('is_suspended', false) + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()); + } + + public function isActive(): bool + { + return ! $this->is_suspended && + (! $this->expires_at || $this->expires_at->isFuture()); + } + + public function isExpired(): bool + { + return ! $this->is_suspended && + $this->expires_at && + $this->expires_at->isPast(); + } + + public function getStatusAttribute(): string + { + if ($this->is_suspended) { + return 'Suspended'; + } + + if ($this->expires_at && $this->expires_at->isPast()) { + return 'Expired'; + } + + return 'Active'; + } + + public function suspend(): bool + { + return $this->update(['is_suspended' => true]); + } + + public function unsuspend(): bool + { + return $this->update(['is_suspended' => false]); + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 1cf5f15c2..dd39ace7b 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -17,7 +17,7 @@ class RouteServiceProvider extends ServiceProvider * * @var string */ - public const HOME = '/home'; + public const HOME = '/customer/licenses'; /** * Define your route model bindings, pattern filters, and other route configuration. diff --git a/database/factories/SubLicenseFactory.php b/database/factories/SubLicenseFactory.php new file mode 100644 index 000000000..9fdf06086 --- /dev/null +++ b/database/factories/SubLicenseFactory.php @@ -0,0 +1,60 @@ + + */ +class SubLicenseFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'parent_license_id' => License::factory(), + 'anystack_id' => fake()->uuid(), + 'name' => fake()->optional()->words(2, true), + 'key' => fake()->uuid(), // In tests, we'll use fake keys since we're not hitting Anystack + 'is_suspended' => false, + 'expires_at' => null, + ]; + } + + /** + * Indicate that the sub-license is suspended. + */ + public function suspended(): static + { + return $this->state(fn (array $attributes) => [ + 'is_suspended' => true, + ]); + } + + /** + * Indicate that the sub-license has an expiry date. + */ + public function withExpiry(?\DateTime $expiresAt = null): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => $expiresAt ?: fake()->dateTimeBetween('now', '+1 year'), + ]); + } + + /** + * Indicate that the sub-license is expired. + */ + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => fake()->dateTimeBetween('-1 year', '-1 day'), + 'is_suspended' => false, + ]); + } +} diff --git a/database/migrations/2025_09_11_224713_create_sub_licenses_table.php b/database/migrations/2025_09_11_224713_create_sub_licenses_table.php new file mode 100644 index 000000000..e2781e12d --- /dev/null +++ b/database/migrations/2025_09_11_224713_create_sub_licenses_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('parent_license_id')->constrained('licenses')->onDelete('cascade'); + $table->string('name')->nullable(); + $table->uuid('key')->unique(); + $table->boolean('is_suspended')->default(false); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index(['parent_license_id', 'is_suspended']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sub_licenses'); + } +}; diff --git a/database/migrations/2025_09_11_230606_add_anystack_id_to_sub_licenses_table.php b/database/migrations/2025_09_11_230606_add_anystack_id_to_sub_licenses_table.php new file mode 100644 index 000000000..c9234f28b --- /dev/null +++ b/database/migrations/2025_09_11_230606_add_anystack_id_to_sub_licenses_table.php @@ -0,0 +1,29 @@ +uuid('anystack_id')->nullable()->after('parent_license_id')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sub_licenses', function (Blueprint $table) { + $table->dropIndex(['anystack_id']); + $table->dropColumn('anystack_id'); + }); + } +}; diff --git a/database/migrations/2025_09_11_231637_add_name_to_licenses_table.php b/database/migrations/2025_09_11_231637_add_name_to_licenses_table.php new file mode 100644 index 000000000..eac712e6f --- /dev/null +++ b/database/migrations/2025_09_11_231637_add_name_to_licenses_table.php @@ -0,0 +1,28 @@ +string('name')->nullable()->after('policy_name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('licenses', function (Blueprint $table) { + $table->dropColumn('name'); + }); + } +}; diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php new file mode 100644 index 000000000..60ae25700 --- /dev/null +++ b/resources/views/auth/forgot-password.blade.php @@ -0,0 +1,55 @@ + +
+
+
+

+ Reset your password +

+

+ Enter your email address and we'll send you a link to reset your password. +

+
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+ @csrf + +
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ +
+ +
+ + +
+
+
+
\ No newline at end of file diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 000000000..00747008d --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,89 @@ + +
+
+
+

+ Sign in to your account +

+

+ Manage your NativePHP licenses +

+
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+ @csrf + +
+
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('password') +

{{ $message }}

+ @enderror +
+ +
+
+ + +
+ + +
+
+ +
+ +
+
+
+
+
diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php new file mode 100644 index 000000000..e0b6088fb --- /dev/null +++ b/resources/views/auth/reset-password.blade.php @@ -0,0 +1,77 @@ + +
+
+
+

+ Set new password +

+
+ +
+ @csrf + + + +
+
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('password') +

{{ $message }}

+ @enderror +
+ +
+ + +
+
+ +
+ +
+
+
+
+
\ No newline at end of file diff --git a/resources/views/customer/licenses/index.blade.php b/resources/views/customer/licenses/index.blade.php new file mode 100644 index 000000000..40105921a --- /dev/null +++ b/resources/views/customer/licenses/index.blade.php @@ -0,0 +1,114 @@ + + + \ No newline at end of file diff --git a/resources/views/customer/licenses/show.blade.php b/resources/views/customer/licenses/show.blade.php new file mode 100644 index 000000000..4fd6bcb90 --- /dev/null +++ b/resources/views/customer/licenses/show.blade.php @@ -0,0 +1,583 @@ + +
+ {{-- Header --}} +
+
+
+
+ +

+ {{ $license->name ?: $license->policy_name }} +

+ @if($license->name) +

{{ $license->policy_name }}

+ @endif +
+
+
+ @csrf + +
+
+
+
+
+ + {{-- Content --}} +
+
+
+
+
+

+ License Information +

+

+ Details about your NativePHP license. +

+
+
+ @if($license->is_suspended) + + + + + Suspended + + @elseif($license->expires_at && $license->expires_at->isPast()) + + + + + Expired + + @else + + + + + Active + + @endif +
+
+
+
+
+
+
+ License Key +
+
+
+ + {{ $license->key }} + + +
+
+
+
+
+ License Name +
+
+
+ + {{ $license->name ?: 'No name set' }} + + +
+
+
+
+
+ License Type +
+
+ {{ $license->policy_name }} +
+
+
+
+ Created +
+
+ {{ $license->created_at->format('F j, Y \a\t g:i A') }} + + ({{ $license->created_at->diffForHumans() }}) + +
+
+ @if($license->expires_at) +
+
+ Expires +
+
+ {{ $license->expires_at->format('F j, Y \a\t g:i A') }} + + @if($license->expires_at->isPast()) + (Expired {{ $license->expires_at->diffForHumans() }}) + @else + ({{ $license->expires_at->diffForHumans() }}) + @endif + +
+
+ @else +
+
+ Expires +
+
+ Never +
+
+ @endif +
+
+
+ + {{-- Keys section --}} + @if($license->supportsSubLicenses()) +
+
+
+
+

+ Keys + + ({{ $license->subLicenses->count() }}{{ $license->subLicenseLimit ? '/' . $license->subLicenseLimit : '' }}) + +

+

+ Manage license keys for team members or additional devices. +

+
+ @if($license->canCreateSubLicense()) + + @endif +
+
+ + @if($license->subLicenses->isEmpty()) +
+
+

No keys

+

Get started by creating your first key.

+
+
+ @else +
+
    + @foreach($license->subLicenses as $subLicense) +
  • +
    +
    +
    +
    +
    +

    + {{ $subLicense->name ?: 'Unnamed Key' }} +

    + + {{ $subLicense->status }} + +
    +
    + + {{ $subLicense->key }} + + +
    +
    +
    + + @if($subLicense->is_suspended) +
    + @csrf + @method('PATCH') + +
    + @else +
    + @csrf + @method('PATCH') + +
    + @endif +
    +
    +
    +
    +
  • + @endforeach +
+
+ @endif + + @if(!$license->canCreateSubLicense()) +
+
+
+ + + +
+
+

+ @if($license->remainingSubLicenses === 0) + You have reached the maximum number of keys for this plan. + @elseif($license->is_suspended) + Keys cannot be created for suspended licenses. + @elseif($license->expires_at && $license->expires_at->isPast()) + Keys cannot be created for expired licenses. + @else + Keys cannot be created at this time. + @endif +

+
+
+
+ @endif +
+ @endif + + @php + $isLegacyLicense = !$license->subscription_item_id && $license->expires_at; + $daysUntilExpiry = $license->expires_at ? $license->expires_at->diffInDays(now()) : null; + $needsRenewal = $isLegacyLicense && $daysUntilExpiry !== null && $daysUntilExpiry <= 30; + @endphp + + @if($needsRenewal && !$license->expires_at->isPast()) +
+
+
+ + + +
+
+

+ 🎉 Renewal Available with Early Access Pricing +

+
+

+ Your license expires in {{ $daysUntilExpiry }} day{{ $daysUntilExpiry === 1 ? '' : 's' }}. + Set up automatic renewal now to avoid service interruption and lock in your Early Access Pricing! +

+ +
+
+
+
+ @endif + + @if($license->is_suspended || ($license->expires_at && $license->expires_at->isPast())) +
+
+
+ +
+
+

+ @if($license->is_suspended) + License Suspended + @else + License Expired + @endif +

+
+

+ @if($license->is_suspended) + This license has been suspended. Please contact support for assistance. + @elseif($isLegacyLicense) + This license has expired. You can still renew it to restore access. + + Renew now + + @else + This license has expired. Please renew your subscription to continue using NativePHP. + @endif +

+
+
+
+
+ @endif +
+
+ + {{-- Create Key Modal --}} + + + {{-- Edit Key Modal --}} + + + {{-- Edit License Name Modal --}} + + + +
diff --git a/routes/web.php b/routes/web.php index 578609a5e..0bb10f53f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,8 @@ name('docs')->where('page', '.*'); Route::get('order/{checkoutSessionId}', App\Livewire\OrderSuccess::class)->name('order.success'); +// Customer authentication routes +Route::middleware('guest')->group(function () { + Route::get('login', [CustomerAuthController::class, 'showLogin'])->name('customer.login'); + Route::post('login', [CustomerAuthController::class, 'login']); + + Route::get('forgot-password', [CustomerAuthController::class, 'showForgotPassword'])->name('password.request'); + Route::post('forgot-password', [CustomerAuthController::class, 'sendPasswordResetLink'])->name('password.email'); + + Route::get('reset-password/{token}', [CustomerAuthController::class, 'showResetPassword'])->name('password.reset'); + Route::post('reset-password', [CustomerAuthController::class, 'resetPassword'])->name('password.update'); +}); + +Route::post('logout', [CustomerAuthController::class, 'logout'])->name('customer.logout'); + +// Customer license management routes +Route::middleware('auth')->prefix('customer')->name('customer.')->group(function () { + Route::get('licenses', [CustomerLicenseController::class, 'index'])->name('licenses'); + Route::get('licenses/{licenseKey}', [CustomerLicenseController::class, 'show'])->name('licenses.show'); + Route::patch('licenses/{licenseKey}', [CustomerLicenseController::class, 'update'])->name('licenses.update'); + + // Sub-license management routes + Route::post('licenses/{licenseKey}/sub-licenses', [CustomerSubLicenseController::class, 'store'])->name('licenses.sub-licenses.store'); + Route::patch('licenses/{licenseKey}/sub-licenses/{subLicense}', [CustomerSubLicenseController::class, 'update'])->name('licenses.sub-licenses.update'); + Route::delete('licenses/{licenseKey}/sub-licenses/{subLicense}', [CustomerSubLicenseController::class, 'destroy'])->name('licenses.sub-licenses.destroy'); + Route::patch('licenses/{licenseKey}/sub-licenses/{subLicense}/suspend', [CustomerSubLicenseController::class, 'suspend'])->name('licenses.sub-licenses.suspend'); + Route::patch('licenses/{licenseKey}/sub-licenses/{subLicense}/unsuspend', [CustomerSubLicenseController::class, 'unsuspend'])->name('licenses.sub-licenses.unsuspend'); +}); diff --git a/tests/Feature/CustomerAuthenticationTest.php b/tests/Feature/CustomerAuthenticationTest.php new file mode 100644 index 000000000..ac5d1e2bb --- /dev/null +++ b/tests/Feature/CustomerAuthenticationTest.php @@ -0,0 +1,88 @@ +get('/login'); + + $response->assertStatus(200); + $response->assertSee('Sign in to your account'); + $response->assertSee('Access your NativePHP licenses'); + } + + public function test_customer_can_login_with_valid_credentials(): void + { + $user = User::factory()->create([ + 'email' => 'customer@example.com', + 'password' => Hash::make('password'), + ]); + + $response = $this->post('/login', [ + 'email' => 'customer@example.com', + 'password' => 'password', + ]); + + $response->assertRedirect('/customer/licenses'); + $this->assertAuthenticatedAs($user); + } + + public function test_customer_cannot_login_with_invalid_credentials(): void + { + $user = User::factory()->create([ + 'email' => 'customer@example.com', + 'password' => Hash::make('password'), + ]); + + $response = $this->post('/login', [ + 'email' => 'customer@example.com', + 'password' => 'wrong-password', + ]); + + $response->assertSessionHasErrors(['email']); + $this->assertGuest(); + } + + public function test_customer_can_logout(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/logout'); + + $response->assertRedirect('/login'); + $this->assertGuest(); + } + + public function test_customer_can_view_forgot_password_page(): void + { + $response = $this->get('/forgot-password'); + + $response->assertStatus(200); + $response->assertSee('Reset your password'); + } + + public function test_authenticated_customer_is_redirected_from_login_page(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/login'); + + $response->assertRedirect('/customer/licenses'); + } + + public function test_unauthenticated_customer_is_redirected_to_login(): void + { + $response = $this->get('/customer/licenses'); + + $response->assertRedirect('/login'); + } +} diff --git a/tests/Feature/CustomerLicenseManagementTest.php b/tests/Feature/CustomerLicenseManagementTest.php new file mode 100644 index 000000000..c07dc25be --- /dev/null +++ b/tests/Feature/CustomerLicenseManagementTest.php @@ -0,0 +1,282 @@ +create(); + + $response = $this->actingAs($user)->get('/customer/licenses'); + + $response->assertStatus(200); + $response->assertSee('Your Licenses'); + $response->assertSee('Manage your NativePHP licenses'); + } + + public function test_customer_sees_no_licenses_message_when_no_licenses_exist(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/customer/licenses'); + + $response->assertStatus(200); + $response->assertSee('No licenses found'); + $response->assertSee('believe this is an error'); + } + + public function test_customer_can_view_their_licenses(): void + { + $user = User::factory()->create(); + $license1 = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'Standard License', + 'key' => 'test-key-1', + ]); + $license2 = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'Premium License', + 'key' => 'test-key-2', + ]); + + $response = $this->actingAs($user)->get('/customer/licenses'); + + $response->assertStatus(200); + $response->assertSee('Standard License'); + $response->assertSee('Premium License'); + $response->assertSee('test-key-1'); + $response->assertSee('test-key-2'); + } + + public function test_customer_cannot_view_other_customers_licenses(): void + { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $license1 = License::factory()->create([ + 'user_id' => $user1->id, + 'policy_name' => 'User 1 License', + ]); + $license2 = License::factory()->create([ + 'user_id' => $user2->id, + 'policy_name' => 'User 2 License', + ]); + + $response = $this->actingAs($user1)->get('/customer/licenses'); + + $response->assertStatus(200); + $response->assertSee('User 1 License'); + $response->assertDontSee('User 2 License'); + } + + public function test_customer_can_view_individual_license_details(): void + { + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', + 'key' => 'test-license-key-123', + 'expires_at' => now()->addDays(30), + ]); + + $response = $this->actingAs($user)->get('/customer/licenses/'.$license->key); + + $response->assertStatus(200); + $response->assertSee('pro'); + $response->assertSee('test-license-key-123'); + $response->assertSee('License Information'); + $response->assertSee('Active'); + } + + public function test_customer_cannot_view_other_customers_license_details(): void + { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $license = License::factory()->create([ + 'user_id' => $user2->id, + 'key' => 'other-user-license', + ]); + + $response = $this->actingAs($user1)->get('/customer/licenses/'.$license->key); + + $response->assertStatus(404); + } + + public function test_license_status_displays_correctly(): void + { + $user = User::factory()->create(); + + // Active license + $activeLicense = License::factory()->create([ + 'user_id' => $user->id, + 'expires_at' => now()->addDays(30), + 'is_suspended' => false, + ]); + + // Expired license + $expiredLicense = License::factory()->create([ + 'user_id' => $user->id, + 'expires_at' => now()->subDays(1), + 'is_suspended' => false, + ]); + + // Suspended license + $suspendedLicense = License::factory()->create([ + 'user_id' => $user->id, + 'is_suspended' => true, + ]); + + $response = $this->actingAs($user)->get('/customer/licenses'); + + $response->assertStatus(200); + $response->assertSee('Active'); + $response->assertSee('Expired'); + $response->assertSee('Suspended'); + } + + public function test_customer_can_update_license_name(): void + { + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'key' => 'test-license-key', + 'name' => null, + ]); + + $response = $this->actingAs($user) + ->patch('/customer/licenses/'.$license->key, [ + 'name' => 'My Production License', + ]); + + $response->assertRedirect('/customer/licenses/'.$license->key); + $response->assertSessionHas('success', 'License name updated successfully!'); + + $this->assertDatabaseHas('licenses', [ + 'id' => $license->id, + 'name' => 'My Production License', + ]); + } + + public function test_customer_can_clear_license_name(): void + { + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'key' => 'test-license-key', + 'name' => 'Old Name', + ]); + + $response = $this->actingAs($user) + ->patch('/customer/licenses/'.$license->key, [ + 'name' => '', + ]); + + $response->assertRedirect('/customer/licenses/'.$license->key); + $response->assertSessionHas('success', 'License name updated successfully!'); + + $this->assertDatabaseHas('licenses', [ + 'id' => $license->id, + 'name' => null, + ]); + } + + public function test_license_name_validation(): void + { + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'key' => 'test-license-key', + ]); + + $response = $this->actingAs($user) + ->patch('/customer/licenses/'.$license->key, [ + 'name' => str_repeat('a', 256), // Too long + ]); + + $response->assertSessionHasErrors(['name']); + } + + public function test_customer_cannot_update_other_customers_license_name(): void + { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $license = License::factory()->create([ + 'user_id' => $user2->id, + 'key' => 'other-user-license', + ]); + + $response = $this->actingAs($user1) + ->patch('/customer/licenses/'.$license->key, [ + 'name' => 'Hacked Name', + ]); + + $response->assertStatus(404); + } + + public function test_license_names_display_on_index_page(): void + { + $user = User::factory()->create(); + + $namedLicense = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', + 'name' => 'My Custom License Name', + ]); + + $unnamedLicense = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'starter', + 'name' => null, + ]); + + $response = $this->actingAs($user)->get('/customer/licenses'); + + $response->assertStatus(200); + // Named license should show custom name prominently + $response->assertSee('My Custom License Name'); + $response->assertSee('pro'); + // Unnamed license should show policy name + $response->assertSee('starter'); + } + + public function test_license_name_displays_on_show_page(): void + { + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'key' => 'test-license-key', + 'name' => 'My Custom License', + ]); + + $response = $this->actingAs($user)->get('/customer/licenses/'.$license->key); + + $response->assertStatus(200); + $response->assertSee('My Custom License'); + $response->assertSee('License Name'); + } + + public function test_license_show_page_displays_no_name_set_when_name_is_null(): void + { + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'key' => 'test-license-key', + 'name' => null, + ]); + + $response = $this->actingAs($user)->get('/customer/licenses/'.$license->key); + + $response->assertStatus(200); + $response->assertSee('No name set'); + } +} diff --git a/tests/Feature/CustomerSubLicenseManagementTest.php b/tests/Feature/CustomerSubLicenseManagementTest.php new file mode 100644 index 000000000..5936ab243 --- /dev/null +++ b/tests/Feature/CustomerSubLicenseManagementTest.php @@ -0,0 +1,372 @@ +create([ + 'anystack_contact_id' => fake()->uuid(), + ]); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', // Pro supports sub-licenses with limit of 10 + 'is_suspended' => false, + 'expires_at' => now()->addDays(30), + 'anystack_id' => fake()->uuid(), + ]); + + $response = $this->actingAs($user) + ->post("/customer/licenses/{$license->key}/sub-licenses", [ + 'name' => 'Development Team', + ]); + + $response->assertRedirect("/customer/licenses/{$license->key}") + ->assertSessionHas('success', 'Sub-license is being created. You will receive an email notification when it\'s ready.'); + + Queue::assertPushed(\App\Jobs\CreateAnystackSubLicenseJob::class); + } + + public function test_customer_can_create_sub_license_without_name(): void + { + Queue::fake(); + + $user = User::factory()->create([ + 'anystack_contact_id' => fake()->uuid(), + ]); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', + 'is_suspended' => false, + 'expires_at' => now()->addDays(30), + 'anystack_id' => fake()->uuid(), + ]); + + $response = $this->actingAs($user) + ->post("/customer/licenses/{$license->key}/sub-licenses", [ + 'name' => '', + ]); + + $response->assertRedirect("/customer/licenses/{$license->key}") + ->assertSessionHas('success', 'Sub-license is being created. You will receive an email notification when it\'s ready.'); + + Queue::assertPushed(\App\Jobs\CreateAnystackSubLicenseJob::class); + } + + public function test_customer_cannot_create_sub_license_for_suspended_license(): void + { + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', + 'is_suspended' => true, + ]); + + $response = $this->actingAs($user) + ->post("/customer/licenses/{$license->key}/sub-licenses", [ + 'name' => 'Development Team', + ]); + + $response->assertRedirect("/customer/licenses/{$license->key}") + ->assertSessionHasErrors(['sub_license']); + + $this->assertDatabaseMissing('sub_licenses', [ + 'parent_license_id' => $license->id, + ]); + } + + public function test_customer_cannot_create_sub_license_for_expired_license(): void + { + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', + 'is_suspended' => false, + 'expires_at' => now()->subDays(1), + ]); + + $response = $this->actingAs($user) + ->post("/customer/licenses/{$license->key}/sub-licenses", [ + 'name' => 'Development Team', + ]); + + $response->assertRedirect("/customer/licenses/{$license->key}") + ->assertSessionHasErrors(['sub_license']); + + $this->assertDatabaseMissing('sub_licenses', [ + 'parent_license_id' => $license->id, + ]); + } + + public function test_customer_can_update_sub_license_name(): void + { + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', + ]); + $subLicense = SubLicense::factory()->create([ + 'parent_license_id' => $license->id, + 'name' => 'Old Name', + ]); + + $response = $this->actingAs($user) + ->patch("/customer/licenses/{$license->key}/sub-licenses/{$subLicense->id}", [ + 'name' => 'New Name', + ]); + + $response->assertRedirect("/customer/licenses/{$license->key}") + ->assertSessionHas('success', 'Sub-license updated successfully!'); + + $this->assertDatabaseHas('sub_licenses', [ + 'id' => $subLicense->id, + 'name' => 'New Name', + ]); + } + + public function test_customer_can_suspend_sub_license(): void + { + Http::fake([ + 'api.anystack.sh/v1/products/*/licenses/*' => Http::response(['success' => true], 200), + ]); + + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', + ]); + $subLicense = SubLicense::factory()->create([ + 'parent_license_id' => $license->id, + 'is_suspended' => false, + ]); + + $response = $this->actingAs($user) + ->patch("/customer/licenses/{$license->key}/sub-licenses/{$subLicense->id}/suspend"); + + $response->assertRedirect("/customer/licenses/{$license->key}") + ->assertSessionHas('success', 'Sub-license suspended successfully!'); + + $this->assertDatabaseHas('sub_licenses', [ + 'id' => $subLicense->id, + 'is_suspended' => true, + ]); + } + + public function test_customer_can_unsuspend_sub_license(): void + { + Http::fake([ + 'api.anystack.sh/v1/products/*/licenses/*' => Http::response(['success' => true], 200), + ]); + + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', + ]); + $subLicense = SubLicense::factory()->create([ + 'parent_license_id' => $license->id, + 'is_suspended' => true, + ]); + + $response = $this->actingAs($user) + ->patch("/customer/licenses/{$license->key}/sub-licenses/{$subLicense->id}/unsuspend"); + + $response->assertRedirect("/customer/licenses/{$license->key}") + ->assertSessionHas('success', 'Sub-license unsuspended successfully!'); + + $this->assertDatabaseHas('sub_licenses', [ + 'id' => $subLicense->id, + 'is_suspended' => false, + ]); + } + + public function test_customer_can_delete_sub_license(): void + { + Http::fake([ + 'api.anystack.sh/v1/products/*/licenses/*' => Http::response(['success' => true], 200), + ]); + + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', + ]); + $subLicense = SubLicense::factory()->create([ + 'parent_license_id' => $license->id, + ]); + + $response = $this->actingAs($user) + ->delete("/customer/licenses/{$license->key}/sub-licenses/{$subLicense->id}"); + + $response->assertRedirect("/customer/licenses/{$license->key}") + ->assertSessionHas('success', 'Sub-license deleted successfully!'); + + $this->assertDatabaseMissing('sub_licenses', [ + 'id' => $subLicense->id, + ]); + } + + public function test_customer_cannot_access_other_customer_sub_licenses(): void + { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $license1 = License::factory()->create(['user_id' => $user1->id]); + $license2 = License::factory()->create(['user_id' => $user2->id]); + + $subLicense = SubLicense::factory()->create([ + 'parent_license_id' => $license2->id, + ]); + + // Try to update another user's sub-license + $response = $this->actingAs($user1) + ->patch("/customer/licenses/{$license2->key}/sub-licenses/{$subLicense->id}", [ + 'name' => 'Malicious Update', + ]); + + $response->assertStatus(404); + + // Try to delete another user's sub-license + $response = $this->actingAs($user1) + ->delete("/customer/licenses/{$license2->key}/sub-licenses/{$subLicense->id}"); + + $response->assertStatus(404); + } + + public function test_customer_cannot_manage_sub_license_with_wrong_parent_license(): void + { + $user = User::factory()->create(); + $license1 = License::factory()->create(['user_id' => $user->id]); + $license2 = License::factory()->create(['user_id' => $user->id]); + + $subLicense = SubLicense::factory()->create([ + 'parent_license_id' => $license2->id, + ]); + + // Try to manage sub-license using wrong parent license key + $response = $this->actingAs($user) + ->patch("/customer/licenses/{$license1->key}/sub-licenses/{$subLicense->id}", [ + 'name' => 'Wrong Parent', + ]); + + $response->assertStatus(404); + } + + public function test_sub_license_inherits_expiry_from_parent_license(): void + { + $user = User::factory()->create(); + $expiresAt = now()->addDays(30); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'expires_at' => $expiresAt, + ]); + + // Test the model boot logic directly by creating a sub-license + $subLicense = SubLicense::factory()->create([ + 'parent_license_id' => $license->id, + 'expires_at' => null, // Let the boot method set it + ]); + + $this->assertEquals($expiresAt->toDateString(), $subLicense->expires_at->toDateString()); + } + + public function test_sub_license_shows_correct_status(): void + { + $user = User::factory()->create(); + $license = License::factory()->create(['user_id' => $user->id]); + + // Test active status + $activeSubLicense = SubLicense::factory()->create([ + 'parent_license_id' => $license->id, + 'is_suspended' => false, + 'expires_at' => now()->addDays(30), + ]); + + $this->assertEquals('Active', $activeSubLicense->status); + $this->assertTrue($activeSubLicense->isActive()); + $this->assertFalse($activeSubLicense->isExpired()); + + // Test suspended status + $suspendedSubLicense = SubLicense::factory()->create([ + 'parent_license_id' => $license->id, + 'is_suspended' => true, + 'expires_at' => now()->addDays(30), + ]); + + $this->assertEquals('Suspended', $suspendedSubLicense->status); + $this->assertFalse($suspendedSubLicense->isActive()); + + // Test expired status + $expiredSubLicense = SubLicense::factory()->create([ + 'parent_license_id' => $license->id, + 'is_suspended' => false, + 'expires_at' => now()->subDays(1), + ]); + + $this->assertEquals('Expired', $expiredSubLicense->status); + $this->assertFalse($expiredSubLicense->isActive()); + $this->assertTrue($expiredSubLicense->isExpired()); + } + + public function test_license_show_page_displays_sub_licenses(): void + { + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', + ]); + + $subLicense1 = SubLicense::factory()->create([ + 'parent_license_id' => $license->id, + 'name' => 'Development Team', + 'is_suspended' => false, + ]); + + $subLicense2 = SubLicense::factory()->create([ + 'parent_license_id' => $license->id, + 'name' => 'Testing Team', + 'is_suspended' => true, + ]); + + $response = $this->actingAs($user)->get("/customer/licenses/{$license->key}"); + + $response->assertStatus(200); + $response->assertSee('Keys'); + $response->assertSee('Development Team'); + $response->assertSee('Testing Team'); + $response->assertSee($subLicense1->key); + $response->assertSee($subLicense2->key); + $response->assertSee('Active'); + $response->assertSee('Suspended'); + } + + public function test_validation_for_sub_license_name(): void + { + $user = User::factory()->create(); + $license = License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', + ]); + + // Test name too long + $response = $this->actingAs($user) + ->post("/customer/licenses/{$license->key}/sub-licenses", [ + 'name' => str_repeat('a', 256), // 256 characters, should fail + ]); + + $response->assertSessionHasErrors(['name']); + } +} From ea20ff0417406941320330a022366aa7c8852e26 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 01:30:05 +0100 Subject: [PATCH 02/21] License expiry and renewal --- .../Commands/SendLicenseExpiryWarnings.php | 70 ++++++++++ app/Console/Kernel.php | 6 +- .../Controllers/LicenseRenewalController.php | 100 ++++++++++++++ .../HandleCustomerSubscriptionCreatedJob.php | 38 ++++++ app/Models/LicenseExpiryWarning.php | 24 ++++ app/Notifications/LicenseExpiryWarning.php | 65 +++++++++ ...9_create_license_expiry_warnings_table.php | 26 ++++ .../views/license/renewal-success.blade.php | 83 ++++++++++++ resources/views/license/renewal.blade.php | 126 ++++++++++++++++++ routes/web.php | 6 + 10 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/SendLicenseExpiryWarnings.php create mode 100644 app/Http/Controllers/LicenseRenewalController.php create mode 100644 app/Models/LicenseExpiryWarning.php create mode 100644 app/Notifications/LicenseExpiryWarning.php create mode 100644 database/migrations/2025_09_12_001249_create_license_expiry_warnings_table.php create mode 100644 resources/views/license/renewal-success.blade.php create mode 100644 resources/views/license/renewal.blade.php diff --git a/app/Console/Commands/SendLicenseExpiryWarnings.php b/app/Console/Commands/SendLicenseExpiryWarnings.php new file mode 100644 index 000000000..60fe1663e --- /dev/null +++ b/app/Console/Commands/SendLicenseExpiryWarnings.php @@ -0,0 +1,70 @@ +sendWarningsForDays($days); + $totalSent += $sent; + + $this->info("Sent {$sent} warning emails for licenses expiring in {$days} day(s)"); + } + + $this->info("Total warning emails sent: {$totalSent}"); + + return Command::SUCCESS; + } + + private function sendWarningsForDays(int $days): int + { + $targetDate = now()->addDays($days)->startOfDay(); + $sent = 0; + + // Find licenses that: + // 1. Expire on the target date + // 2. Don't have an active subscription (legacy licenses) + // 3. Haven't been sent a warning for this specific day count recently + $licenses = License::query() + ->whereDate('expires_at', $targetDate) + ->whereNull('subscription_item_id') // Legacy licenses without subscriptions + ->whereDoesntHave('expiryWarnings', function ($query) use ($days) { + $query->where('warning_days', $days) + ->where('sent_at', '>=', now()->subHours(23)); // Prevent duplicate emails within 23 hours + }) + ->with('user') + ->get(); + + foreach ($licenses as $license) { + if ($license->user) { + $license->user->notify(new LicenseExpiryWarning($license, $days)); + + // Track that we sent this warning + $license->expiryWarnings()->create([ + 'warning_days' => $days, + 'sent_at' => now(), + ]); + + $sent++; + + $this->line("Sent {$days}-day warning to {$license->user->email} for license {$license->key}"); + } + } + + return $sent; + } +} \ No newline at end of file diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e6b9960ec..682809d84 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -12,7 +12,11 @@ class Kernel extends ConsoleKernel */ protected function schedule(Schedule $schedule): void { - // $schedule->command('inspire')->hourly(); + // Send license expiry warnings daily at 9 AM UTC + $schedule->command('licenses:send-expiry-warnings') + ->dailyAt('09:00') + ->onOneServer() + ->runInBackground(); } /** diff --git a/app/Http/Controllers/LicenseRenewalController.php b/app/Http/Controllers/LicenseRenewalController.php new file mode 100644 index 000000000..a666c3729 --- /dev/null +++ b/app/Http/Controllers/LicenseRenewalController.php @@ -0,0 +1,100 @@ +whereNull('subscription_item_id') // Only legacy licenses + ->whereNotNull('expires_at') // Must have an expiry date + ->where('expires_at', '>', now()) // Must not already be expired + ->with('user') + ->firstOrFail(); + + // Ensure the user owns this license (if they're logged in) + if (auth()->check() && $license->user_id !== auth()->id()) { + abort(403, 'You can only renew your own licenses.'); + } + + $subscriptionType = Subscription::from($license->policy_name); + $isNearExpiry = $license->expires_at->diffInDays(now()) <= 30; + + return view('license.renewal', [ + 'license' => $license, + 'subscriptionType' => $subscriptionType, + 'isNearExpiry' => $isNearExpiry, + 'stripePriceId' => $subscriptionType->stripePriceId(), // Will use EAP pricing + 'stripePublishableKey' => config('cashier.key'), + ]); + } + + public function createCheckoutSession(Request $request, string $licenseKey) + { + $license = License::where('key', $licenseKey) + ->whereNull('subscription_item_id') // Only legacy licenses + ->whereNotNull('expires_at') // Must have an expiry date + ->where('expires_at', '>', now()) // Must not already be expired + ->with('user') + ->firstOrFail(); + + // Ensure the user owns this license (if they're logged in) + if (auth()->check() && $license->user_id !== auth()->id()) { + abort(403, 'You can only renew your own licenses.'); + } + + $subscriptionType = Subscription::from($license->policy_name); + + // Create Stripe checkout session + $stripe = new \Stripe\StripeClient(config('cashier.secret')); + + $checkoutSession = $stripe->checkout->sessions->create([ + 'payment_method_types' => ['card'], + 'line_items' => [[ + 'price' => $subscriptionType->stripePriceId(), // Uses EAP pricing + 'quantity' => 1, + ]], + 'mode' => 'subscription', + 'success_url' => route('license.renewal.success', ['license' => $licenseKey]) . '?session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => route('license.renewal', ['license' => $licenseKey]), + 'metadata' => [ + 'license_key' => $licenseKey, + 'license_id' => $license->id, + 'renewal' => 'true', // Flag this as a renewal, not a new purchase + ], + 'customer_email' => $license->user->email, + 'subscription_data' => [ + 'metadata' => [ + 'license_key' => $licenseKey, + 'license_id' => $license->id, + 'renewal' => 'true', + ], + ], + ]); + + return redirect($checkoutSession->url); + } + + public function success(Request $request, string $licenseKey): View + { + $sessionId = $request->get('session_id'); + + if (!$sessionId) { + return redirect()->route('license.renewal', ['license' => $licenseKey]) + ->with('error', 'Invalid session.'); + } + + $license = License::where('key', $licenseKey)->firstOrFail(); + + return view('license.renewal-success', [ + 'license' => $license, + 'sessionId' => $sessionId, + ]); + } +} \ No newline at end of file diff --git a/app/Jobs/HandleCustomerSubscriptionCreatedJob.php b/app/Jobs/HandleCustomerSubscriptionCreatedJob.php index e6634d20c..6c8226466 100644 --- a/app/Jobs/HandleCustomerSubscriptionCreatedJob.php +++ b/app/Jobs/HandleCustomerSubscriptionCreatedJob.php @@ -44,6 +44,44 @@ public function handle(): void ->first() ->id; + // Check if this is a renewal (has renewal metadata) + $isRenewal = isset($stripeSubscription->metadata['renewal']) && $stripeSubscription->metadata['renewal'] === 'true'; + $licenseKey = $stripeSubscription->metadata['license_key'] ?? null; + $licenseId = $stripeSubscription->metadata['license_id'] ?? null; + + if ($isRenewal && $licenseKey && $licenseId) { + // This is a renewal - link the subscription to the existing license + $license = \App\Models\License::where('id', $licenseId) + ->where('key', $licenseKey) + ->where('user_id', $user->id) // Ensure user owns the license + ->first(); + + if ($license) { + // Link the subscription to the existing license + $license->update([ + 'subscription_item_id' => $cashierSubscriptionItemId, + ]); + + // Log this renewal + logger('License renewal completed', [ + 'license_id' => $license->id, + 'license_key' => $license->key, + 'user_id' => $user->id, + 'subscription_item_id' => $cashierSubscriptionItemId, + ]); + + return; // Exit early - don't create a new license + } else { + // License not found - log error but continue with normal flow + logger('Renewal license not found, creating new license instead', [ + 'license_key' => $licenseKey, + 'license_id' => $licenseId, + 'user_id' => $user->id, + ]); + } + } + + // Normal flow - create a new license $nameParts = explode(' ', $user->name ?? '', 2); $firstName = $nameParts[0] ?: null; $lastName = $nameParts[1] ?? null; diff --git a/app/Models/LicenseExpiryWarning.php b/app/Models/LicenseExpiryWarning.php new file mode 100644 index 000000000..0f8538916 --- /dev/null +++ b/app/Models/LicenseExpiryWarning.php @@ -0,0 +1,24 @@ + 'datetime', + ]; + + public function license(): BelongsTo + { + return $this->belongsTo(License::class); + } +} \ No newline at end of file diff --git a/app/Notifications/LicenseExpiryWarning.php b/app/Notifications/LicenseExpiryWarning.php new file mode 100644 index 000000000..90aab5b9b --- /dev/null +++ b/app/Notifications/LicenseExpiryWarning.php @@ -0,0 +1,65 @@ +getSubject(); + $renewalUrl = route('license.renewal', ['license' => $this->license->key]); + + return (new MailMessage) + ->subject($subject) + ->greeting("Hi {$notifiable->name},") + ->line($this->getMainMessage()) + ->line("**License:** {$this->license->name ?: $this->license->policy_name}") + ->line("**License Key:** {$this->license->key}") + ->line("**Expires:** {$this->license->expires_at->format('F j, Y \\a\\t g:i A T')}") + ->line('To ensure uninterrupted access to NativePHP, you need to set up a subscription for automatic renewal.') + ->line('**Good news:** As an early adopter, you qualify for our Early Access Pricing - the same great rates you enjoyed when you first purchased!') + ->action('Renew Your License', $renewalUrl) + ->line('If you have any questions about your renewal, please don\'t hesitate to contact our support team.') + ->salutation('Best regards,
The NativePHP Team'); + } + + private function getSubject(): string + { + return match ($this->daysUntilExpiry) { + 30 => 'Your NativePHP License Expires in 30 Days', + 7 => 'Important: Your NativePHP License Expires in 7 Days', + 1 => 'Urgent: Your NativePHP License Expires Tomorrow', + 0 => 'Your NativePHP License Expires Today', + default => "Your NativePHP License Expires in {$this->daysUntilExpiry} Days", + }; + } + + private function getMainMessage(): string + { + return match ($this->daysUntilExpiry) { + 30 => 'This is a friendly reminder that your NativePHP license will expire in 30 days.', + 7 => 'Your NativePHP license will expire in just 7 days. This is an important reminder to set up your renewal.', + 1 => 'Your NativePHP license expires tomorrow! Please take immediate action to avoid service interruption.', + 0 => 'Your NativePHP license expires today. Renew now to maintain access.', + default => "Your NativePHP license will expire in {$this->daysUntilExpiry} days.", + }; + } +} \ No newline at end of file diff --git a/database/migrations/2025_09_12_001249_create_license_expiry_warnings_table.php b/database/migrations/2025_09_12_001249_create_license_expiry_warnings_table.php new file mode 100644 index 000000000..edb65ce1b --- /dev/null +++ b/database/migrations/2025_09_12_001249_create_license_expiry_warnings_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('license_id')->constrained()->cascadeOnDelete(); + $table->integer('warning_days'); // 30, 7, 1, etc. + $table->timestamp('sent_at'); + $table->timestamps(); + + $table->index(['license_id', 'warning_days', 'sent_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('license_expiry_warnings'); + } +}; \ No newline at end of file diff --git a/resources/views/license/renewal-success.blade.php b/resources/views/license/renewal-success.blade.php new file mode 100644 index 000000000..2c89706db --- /dev/null +++ b/resources/views/license/renewal-success.blade.php @@ -0,0 +1,83 @@ + +
+
+
+
+
+ + + +
+ +

+ License Renewal Successful! +

+ +

+ Your automatic renewal has been set up successfully. Your license will now automatically renew before it expires. +

+
+ +
+
+
+
+ + + +
+
+

+ What's Next? +

+
+
    +
  • Your existing license key continues to work without any changes
  • +
  • Your license will automatically renew before the expiry date
  • +
  • You'll receive a confirmation email with your subscription details
  • +
  • You can manage your subscription from your account dashboard
  • +
+
+
+
+
+ +
+

License Information:

+
+
+
License Key
+
+ + {{ $license->key }} + +
+
+
+
Current Expiry
+
+ {{ $license->expires_at->format('F j, Y') }} +
+
+
+
+ + + +
+

+ Questions about your renewal? Contact our support team +

+
+
+
+
+
+
\ No newline at end of file diff --git a/resources/views/license/renewal.blade.php b/resources/views/license/renewal.blade.php new file mode 100644 index 000000000..8e7681d32 --- /dev/null +++ b/resources/views/license/renewal.blade.php @@ -0,0 +1,126 @@ + +
+
+
+ {{-- Header --}} +
+

+ Renew Your NativePHP License +

+

+ Set up automatic renewal to keep your license active beyond its expiry date. +

+
+ + {{-- License Information --}} +
+
+
+
License
+
+ {{ $license->name ?: $subscriptionType->name() }} +
+
+
+
License Key
+
+ + {{ $license->key }} + +
+
+
+
Current Expiry
+
+ {{ $license->expires_at->format('F j, Y \a\t g:i A T') }} + + ({{ $license->expires_at->diffForHumans() }}) + +
+
+
+
+ + {{-- Renewal Information --}} +
+ @if($isNearExpiry) +
+
+
+ + + +
+
+

+ Action Required +

+
+

Your license expires soon. Set up automatic renewal now to avoid service interruption.

+
+
+
+
+ @endif + +
+

+ 🎉 Early Access Pricing Available! +

+

+ As an early adopter, you're eligible for our special Early Access Pricing - the same great + rates you enjoyed when you first purchased your license. This pricing is only available + until your license expires. After that you will have to renew at full price. +

+
+ +
+

What happens when you renew:

+
    +
  • + + + + Your existing license will continue to work without interruption +
  • +
  • + + + + Automatic renewal will be set up to prevent future expiry +
  • +
  • + + + + You'll receive Early Access Pricing for your renewal +
  • +
  • + + + + No new license key - your existing key continues to work +
  • +
+
+ +
+
+ @csrf + +
+ +

+ You'll be redirected to Stripe to complete your subscription setup. +

+
+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php index 0bb10f53f..c4fe598f2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -85,6 +85,12 @@ })->name('docs')->where('page', '.*'); Route::get('order/{checkoutSessionId}', App\Livewire\OrderSuccess::class)->name('order.success'); + +// License renewal routes +Route::get('license/{license}/renewal', [App\Http\Controllers\LicenseRenewalController::class, 'show'])->name('license.renewal'); +Route::post('license/{license}/renewal/checkout', [App\Http\Controllers\LicenseRenewalController::class, 'createCheckoutSession'])->name('license.renewal.checkout'); +Route::get('license/{license}/renewal/success', [App\Http\Controllers\LicenseRenewalController::class, 'success'])->name('license.renewal.success'); + // Customer authentication routes Route::middleware('guest')->group(function () { Route::get('login', [CustomerAuthController::class, 'showLogin'])->name('customer.login'); From 121ee46f0f813dc31dfe0fb277e86f2665cdfc16 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 09:54:57 +0100 Subject: [PATCH 03/21] Stripe billing center --- resources/views/customer/licenses/index.blade.php | 6 ++++-- resources/views/customer/licenses/show.blade.php | 7 +++++-- routes/web.php | 12 ++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/resources/views/customer/licenses/index.blade.php b/resources/views/customer/licenses/index.blade.php index 40105921a..c6f066ae4 100644 --- a/resources/views/customer/licenses/index.blade.php +++ b/resources/views/customer/licenses/index.blade.php @@ -11,12 +11,14 @@

+ + Billing + Buy more
- @csrf -
diff --git a/resources/views/customer/licenses/show.blade.php b/resources/views/customer/licenses/show.blade.php index 4fd6bcb90..6e92744d4 100644 --- a/resources/views/customer/licenses/show.blade.php +++ b/resources/views/customer/licenses/show.blade.php @@ -29,7 +29,10 @@

{{ $license->policy_name }}

@endif
-
+
+ + Billing +
@csrf
- + {{-- Content --}}
diff --git a/routes/web.php b/routes/web.php index c4fe598f2..5f0b20436 100644 --- a/routes/web.php +++ b/routes/web.php @@ -111,6 +111,18 @@ Route::get('licenses/{licenseKey}', [CustomerLicenseController::class, 'show'])->name('licenses.show'); Route::patch('licenses/{licenseKey}', [CustomerLicenseController::class, 'update'])->name('licenses.update'); + // Billing portal + Route::get('billing-portal', function (Illuminate\Http\Request $request) { + $user = $request->user(); + + // Check if user exists in Stripe, create if they don't + if (!$user->hasStripeId()) { + $user->createAsStripeCustomer(); + } + + return $user->redirectToBillingPortal(route('customer.licenses')); + })->name('billing-portal'); + // Sub-license management routes Route::post('licenses/{licenseKey}/sub-licenses', [CustomerSubLicenseController::class, 'store'])->name('licenses.sub-licenses.store'); Route::patch('licenses/{licenseKey}/sub-licenses/{subLicense}', [CustomerSubLicenseController::class, 'update'])->name('licenses.sub-licenses.update'); From 7244d1160af7aa97f37b5db0b350d742f72dd6b9 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 09:55:33 +0100 Subject: [PATCH 04/21] Styles --- resources/views/auth/forgot-password.blade.php | 2 +- resources/views/auth/login.blade.php | 2 +- resources/views/auth/reset-password.blade.php | 2 +- resources/views/customer/licenses/index.blade.php | 11 ++++++----- resources/views/customer/licenses/show.blade.php | 6 +++--- resources/views/license/renewal-success.blade.php | 11 ++++++----- resources/views/license/renewal.blade.php | 2 +- 7 files changed, 19 insertions(+), 17 deletions(-) diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php index 60ae25700..1d6e0d768 100644 --- a/resources/views/auth/forgot-password.blade.php +++ b/resources/views/auth/forgot-password.blade.php @@ -1,5 +1,5 @@ -
+

diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 00747008d..9364dac66 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -1,5 +1,5 @@ -
+

diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php index e0b6088fb..d166dc074 100644 --- a/resources/views/auth/reset-password.blade.php +++ b/resources/views/auth/reset-password.blade.php @@ -1,5 +1,5 @@ -
+

diff --git a/resources/views/customer/licenses/index.blade.php b/resources/views/customer/licenses/index.blade.php index c6f066ae4..2b60ed0db 100644 --- a/resources/views/customer/licenses/index.blade.php +++ b/resources/views/customer/licenses/index.blade.php @@ -1,7 +1,7 @@ -
+
{{-- Header --}} -
+
@@ -18,14 +18,15 @@ Buy more - @csrf
-
+ {{-- Content --}}
@@ -113,4 +114,4 @@ @endif
- \ No newline at end of file + diff --git a/resources/views/customer/licenses/show.blade.php b/resources/views/customer/licenses/show.blade.php index 6e92744d4..7e34741e3 100644 --- a/resources/views/customer/licenses/show.blade.php +++ b/resources/views/customer/licenses/show.blade.php @@ -1,7 +1,7 @@ -
+
{{-- Header --}} -
+
@@ -159,7 +159,7 @@ class="ml-2 inline-flex items-center px-2.5 py-1.5 border border-gray-300 dark:b
@else -
+
Expires
diff --git a/resources/views/license/renewal-success.blade.php b/resources/views/license/renewal-success.blade.php index 2c89706db..dd11d3017 100644 --- a/resources/views/license/renewal-success.blade.php +++ b/resources/views/license/renewal-success.blade.php @@ -1,5 +1,5 @@ -
+
@@ -8,13 +8,14 @@
- +

License Renewal Successful!

- +

- Your automatic renewal has been set up successfully. Your license will now automatically renew before it expires. + Your automatic renewal has been set up successfully.
+ Your license will now automatically renew before it expires.

@@ -80,4 +81,4 @@
-
\ No newline at end of file + diff --git a/resources/views/license/renewal.blade.php b/resources/views/license/renewal.blade.php index 8e7681d32..99a5d6f8c 100644 --- a/resources/views/license/renewal.blade.php +++ b/resources/views/license/renewal.blade.php @@ -1,5 +1,5 @@ -
+
{{-- Header --}} From f5703997b91774638eebb1181ff1f5b1d54f5a8a Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 09:55:47 +0100 Subject: [PATCH 05/21] Renewal flow improvements --- app/Enums/Subscription.php | 4 ++-- .../Controllers/LicenseRenewalController.php | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/Enums/Subscription.php b/app/Enums/Subscription.php index 175cb46b1..d6cc9b83c 100644 --- a/app/Enums/Subscription.php +++ b/app/Enums/Subscription.php @@ -53,10 +53,10 @@ public function name(): string return config("subscriptions.plans.{$this->value}.name"); } - public function stripePriceId(): string + public function stripePriceId(bool $forceEap = false): string { // EAP ends June 1st at midnight UTC - return now()->isBefore('2025-06-01 00:00:00') + return now()->isBefore('2025-06-01 00:00:00') || $forceEap ? config("subscriptions.plans.{$this->value}.stripe_price_id_eap") : config("subscriptions.plans.{$this->value}.stripe_price_id"); } diff --git a/app/Http/Controllers/LicenseRenewalController.php b/app/Http/Controllers/LicenseRenewalController.php index a666c3729..1e28d0209 100644 --- a/app/Http/Controllers/LicenseRenewalController.php +++ b/app/Http/Controllers/LicenseRenewalController.php @@ -30,7 +30,7 @@ public function show(Request $request, string $licenseKey): View 'license' => $license, 'subscriptionType' => $subscriptionType, 'isNearExpiry' => $isNearExpiry, - 'stripePriceId' => $subscriptionType->stripePriceId(), // Will use EAP pricing + 'stripePriceId' => $subscriptionType->stripePriceId(forceEap: true), // Will use EAP pricing 'stripePublishableKey' => config('cashier.key'), ]); } @@ -50,14 +50,14 @@ public function createCheckoutSession(Request $request, string $licenseKey) } $subscriptionType = Subscription::from($license->policy_name); - + // Create Stripe checkout session $stripe = new \Stripe\StripeClient(config('cashier.secret')); - + $checkoutSession = $stripe->checkout->sessions->create([ 'payment_method_types' => ['card'], 'line_items' => [[ - 'price' => $subscriptionType->stripePriceId(), // Uses EAP pricing + 'price' => $subscriptionType->stripePriceId(forceEap: true), // Uses EAP pricing 'quantity' => 1, ]], 'mode' => 'subscription', @@ -68,6 +68,12 @@ public function createCheckoutSession(Request $request, string $licenseKey) 'license_id' => $license->id, 'renewal' => 'true', // Flag this as a renewal, not a new purchase ], + 'consent_collection' => [ + 'terms_of_service' => 'required', + ], + 'tax_id_collection' => [ + 'enabled' => true, + ], 'customer_email' => $license->user->email, 'subscription_data' => [ 'metadata' => [ @@ -84,7 +90,7 @@ public function createCheckoutSession(Request $request, string $licenseKey) public function success(Request $request, string $licenseKey): View { $sessionId = $request->get('session_id'); - + if (!$sessionId) { return redirect()->route('license.renewal', ['license' => $licenseKey]) ->with('error', 'Invalid session.'); @@ -97,4 +103,4 @@ public function success(Request $request, string $licenseKey): View 'sessionId' => $sessionId, ]); } -} \ No newline at end of file +} From f9ee690337ac6a7c2da2527bab3a7616500e2e5c Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 21:20:09 +0100 Subject: [PATCH 06/21] Improve tests --- tests/Feature/CustomerAuthenticationTest.php | 2 +- tests/Feature/StripePurchaseHandlingTest.php | 12 ++++++++++++ tests/Unit/ExampleTest.php | 16 ---------------- 3 files changed, 13 insertions(+), 17 deletions(-) delete mode 100644 tests/Unit/ExampleTest.php diff --git a/tests/Feature/CustomerAuthenticationTest.php b/tests/Feature/CustomerAuthenticationTest.php index ac5d1e2bb..81f3556bb 100644 --- a/tests/Feature/CustomerAuthenticationTest.php +++ b/tests/Feature/CustomerAuthenticationTest.php @@ -17,7 +17,7 @@ public function test_customer_can_view_login_page(): void $response->assertStatus(200); $response->assertSee('Sign in to your account'); - $response->assertSee('Access your NativePHP licenses'); + $response->assertSee('Manage your NativePHP licenses'); } public function test_customer_can_login_with_valid_credentials(): void diff --git a/tests/Feature/StripePurchaseHandlingTest.php b/tests/Feature/StripePurchaseHandlingTest.php index d54b5b030..0efe6b11d 100644 --- a/tests/Feature/StripePurchaseHandlingTest.php +++ b/tests/Feature/StripePurchaseHandlingTest.php @@ -275,6 +275,18 @@ public function retrieve() } }; + $mockStripeClient->subscriptions = new class + { + public function retrieve($subscriptionId) + { + return \Stripe\Subscription::constructFrom([ + 'id' => $subscriptionId, + 'metadata' => [], // No renewal metadata for normal tests + 'current_period_end' => now()->addYear()->timestamp, + ]); + } + }; + $this->app->bind(StripeClient::class, function ($app, $parameters) use ($mockStripeClient) { return $mockStripeClient; }); diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 5773b0ceb..000000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -} From 31774858f45d4385e4dc5dac32aed8088dd432ec Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 21:21:31 +0100 Subject: [PATCH 07/21] Improve license renewal --- .../Commands/ExtendLicenseExpiryCommand.php | 48 +++++ .../Controllers/LicenseRenewalController.php | 17 -- app/Jobs/HandleInvoicePaidJob.php | 115 +++++++++++- app/Jobs/UpdateAnystackLicenseExpiryJob.php | 76 ++++++++ app/Livewire/LicenseRenewalSuccess.php | 58 ++++++ .../license-renewal-success.blade.php | 167 ++++++++++++++++++ routes/web.php | 8 +- 7 files changed, 466 insertions(+), 23 deletions(-) create mode 100644 app/Console/Commands/ExtendLicenseExpiryCommand.php create mode 100644 app/Jobs/UpdateAnystackLicenseExpiryJob.php create mode 100644 app/Livewire/LicenseRenewalSuccess.php create mode 100644 resources/views/livewire/license-renewal-success.blade.php diff --git a/app/Console/Commands/ExtendLicenseExpiryCommand.php b/app/Console/Commands/ExtendLicenseExpiryCommand.php new file mode 100644 index 000000000..7c63cfc78 --- /dev/null +++ b/app/Console/Commands/ExtendLicenseExpiryCommand.php @@ -0,0 +1,48 @@ +argument('license_id'); + + // Find the license + $license = License::find($licenseId); + if (! $license) { + $this->error("License with ID {$licenseId} not found"); + + return Command::FAILURE; + } + + // Dispatch the job to update the license expiry + UpdateAnystackLicenseExpiryJob::dispatch($license); + + $this->info("License expiry updated to {$license->expires_at->format('Y-m-d')}"); + + return Command::SUCCESS; + } +} diff --git a/app/Http/Controllers/LicenseRenewalController.php b/app/Http/Controllers/LicenseRenewalController.php index 1e28d0209..27b9f6b96 100644 --- a/app/Http/Controllers/LicenseRenewalController.php +++ b/app/Http/Controllers/LicenseRenewalController.php @@ -86,21 +86,4 @@ public function createCheckoutSession(Request $request, string $licenseKey) return redirect($checkoutSession->url); } - - public function success(Request $request, string $licenseKey): View - { - $sessionId = $request->get('session_id'); - - if (!$sessionId) { - return redirect()->route('license.renewal', ['license' => $licenseKey]) - ->with('error', 'Invalid session.'); - } - - $license = License::where('key', $licenseKey)->firstOrFail(); - - return view('license.renewal-success', [ - 'license' => $license, - 'sessionId' => $sessionId, - ]); - } } diff --git a/app/Jobs/HandleInvoicePaidJob.php b/app/Jobs/HandleInvoicePaidJob.php index bc8323057..275a3ce70 100644 --- a/app/Jobs/HandleInvoicePaidJob.php +++ b/app/Jobs/HandleInvoicePaidJob.php @@ -27,13 +27,85 @@ public function __construct(public Invoice $invoice) {} public function handle(): void { match ($this->invoice->billing_reason) { - Invoice::BILLING_REASON_SUBSCRIPTION_CREATE => $this->createLicense(), + Invoice::BILLING_REASON_SUBSCRIPTION_CREATE => $this->handleSubscriptionCreated(), Invoice::BILLING_REASON_SUBSCRIPTION_UPDATE => null, // TODO: Handle subscription update - Invoice::BILLING_REASON_SUBSCRIPTION_CYCLE => null, // TODO: Handle subscription renewal + Invoice::BILLING_REASON_SUBSCRIPTION_CYCLE => $this->handleSubscriptionRenewal(), default => null, }; } + private function handleSubscriptionCreated(): void + { + // Get the subscription to check for renewal metadata + $subscription = Cashier::stripe()->subscriptions->retrieve($this->invoice->subscription); + + // Check if this is our "renewal" process (new subscription for existing legacy license) + $isRenewal = isset($subscription->metadata['renewal']) && $subscription->metadata['renewal'] === 'true'; + $licenseKey = $subscription->metadata['license_key'] ?? null; + $licenseId = $subscription->metadata['license_id'] ?? null; + + if ($isRenewal && $licenseKey && $licenseId) { + $this->handleLegacyLicenseRenewal($subscription, $licenseKey, $licenseId); + + return; + } + + // Normal flow - create a new license + $this->createLicense(); + } + + private function handleLegacyLicenseRenewal($subscription, string $licenseKey, string $licenseId): void + { + $user = $this->billable(); + + // Find the existing legacy license + $license = License::where('id', $licenseId) + ->where('key', $licenseKey) + ->where('user_id', $user->id) // Ensure user owns the license + ->first(); + + if (! $license) { + logger('Legacy license renewal failed - license not found', [ + 'license_key' => $licenseKey, + 'license_id' => $licenseId, + 'user_id' => $user->id, + 'subscription_id' => $subscription->id, + ]); + // Fallback to creating a new license + $this->createLicense(); + + return; + } + + // Get the subscription item + if (blank($subscriptionItemId = $this->invoice->lines->first()->subscription_item)) { + throw new UnexpectedValueException('Failed to retrieve the Stripe subscription item id from invoice lines.'); + } + + $subscriptionItemModel = SubscriptionItem::query()->where('stripe_id', $subscriptionItemId)->firstOrFail(); + + // Calculate new expiry date from subscription period end + $newExpiryDate = \Carbon\Carbon::createFromTimestamp($subscription->current_period_end); + + // Link the subscription to the existing license (expiry will be updated by Anystack job) + $license->update([ + 'subscription_item_id' => $subscriptionItemModel->id, + ]); + + // Update the Anystack license expiry date (which will also update the database on success) + dispatch(new UpdateAnystackLicenseExpiryJob($license, $newExpiryDate)); + + logger('Legacy license renewal completed', [ + 'license_id' => $license->id, + 'license_key' => $license->key, + 'user_id' => $user->id, + 'subscription_item_id' => $subscriptionItemModel->id, + 'subscription_id' => $subscription->id, + 'old_expiry' => $license->getOriginal('expires_at'), + 'new_expiry' => $newExpiryDate, + ]); + } + private function createLicense(): void { // Assert the invoice line item is for a price_id that relates to a license plan. @@ -63,6 +135,45 @@ private function createLicense(): void )); } + private function handleSubscriptionRenewal(): void + { + // Get the subscription item ID from the invoice line + if (blank($subscriptionItemId = $this->invoice->lines->first()->subscription_item)) { + throw new UnexpectedValueException('Failed to retrieve the Stripe subscription item id from invoice lines.'); + } + + // Find the subscription item model + $subscriptionItemModel = SubscriptionItem::query()->where('stripe_id', $subscriptionItemId)->firstOrFail(); + + // Find the license associated with this subscription item + $license = License::query()->whereBelongsTo($subscriptionItemModel)->first(); + + if (! $license) { + // No existing license found - this might be a new subscription, handle as create + $this->createLicense(); + + return; + } + + // Get the subscription to find the current period end + $subscription = Cashier::stripe()->subscriptions->retrieve($this->invoice->subscription); + + // Update the license expiry date to match the subscription's current period end + $newExpiryDate = \Carbon\Carbon::createFromTimestamp($subscription->current_period_end); + + // Update the Anystack license expiry date (which will also update the database on success) + dispatch(new UpdateAnystackLicenseExpiryJob($license, $newExpiryDate)); + + logger('License renewal processed', [ + 'license_id' => $license->id, + 'license_key' => $license->key, + 'old_expiry' => $license->getOriginal('expires_at'), + 'new_expiry' => $newExpiryDate, + 'subscription_id' => $this->invoice->subscription, + 'invoice_id' => $this->invoice->id, + ]); + } + private function billable(): User { if ($user = Cashier::findBillable($this->invoice->customer)) { diff --git a/app/Jobs/UpdateAnystackLicenseExpiryJob.php b/app/Jobs/UpdateAnystackLicenseExpiryJob.php new file mode 100644 index 000000000..006ad6c44 --- /dev/null +++ b/app/Jobs/UpdateAnystackLicenseExpiryJob.php @@ -0,0 +1,76 @@ +license->anystack_id) { + logger('Cannot update Anystack license expiry - no anystack_id', [ + 'license_id' => $this->license->id, + 'license_key' => $this->license->key, + ]); + + return; + } + + try { + // Update the license expiry in Anystack first using the renew endpoint + $response = $this->anystackClient() + ->patch("https://api.anystack.sh/v1/products/{$this->license->anystack_product_id}/licenses/{$this->license->anystack_id}/renew") + ->throw() + ->json('data'); + + // Only update the database if Anystack update succeeded + $oldExpiryDate = $this->license->expires_at; + $newExpiryDate = $response['expires_at']; + + $this->license->update([ + 'expires_at' => $newExpiryDate, + ]); + + logger('Successfully updated license expiry (Anystack + Database)', [ + 'license_id' => $this->license->id, + 'license_key' => $this->license->key, + 'anystack_id' => $this->license->anystack_id, + 'old_expiry' => $oldExpiryDate, + 'new_expiry' => $newExpiryDate, + ]); + + } catch (\Exception $e) { + logger('Failed to update Anystack license expiry', [ + 'license_id' => $this->license->id, + 'license_key' => $this->license->key, + 'anystack_id' => $this->license->anystack_id, + 'error' => $e->getMessage(), + ]); + + // Re-throw to trigger job failure and potential retry + throw $e; + } + } + + private function anystackClient(): PendingRequest + { + return Http::withToken(config('services.anystack.key')) + ->acceptJson() + ->asJson(); + } +} diff --git a/app/Livewire/LicenseRenewalSuccess.php b/app/Livewire/LicenseRenewalSuccess.php new file mode 100644 index 000000000..5e3925f87 --- /dev/null +++ b/app/Livewire/LicenseRenewalSuccess.php @@ -0,0 +1,58 @@ +license = $license; + $this->sessionId = request()->get('session_id'); + $this->originalExpiryDate = $this->license->expires_at?->format('F j, Y') ?? ''; + + if ($this->license->expires_at->lt(now()->addDays(30))) { + $this->checkRenewalStatus(); + } else { + $this->renewalCompleted = true; + } + } + + public function checkRenewalStatus() + { + // Refresh the license from database + $this->license->refresh(); + + // Check if renewal is complete + $hasSubscription = ! is_null($this->license->subscription_item_id); + + // Check if expiry was updated recently (within last 15 minutes) and is different from original + $expiryUpdatedRecently = $this->license->updated_at > now()->subMinutes(15); + $expiryChanged = $this->originalExpiryDate !== ($this->license->expires_at?->format('F j, Y') ?? ''); + + if ($hasSubscription && $expiryUpdatedRecently && $expiryChanged) { + $this->renewalCompleted = true; + } elseif ($this->license->updated_at > now()->subMinutes(30) && ! $hasSubscription) { + // If it's been over 30 minutes and still no subscription, consider it failed + $this->renewalFailed = true; + } + } + + public function render() + { + return view('livewire.license-renewal-success') + ->layout('components.layout', ['title' => 'Renewal Successful']); + } +} diff --git a/resources/views/livewire/license-renewal-success.blade.php b/resources/views/livewire/license-renewal-success.blade.php new file mode 100644 index 000000000..e5fa16eee --- /dev/null +++ b/resources/views/livewire/license-renewal-success.blade.php @@ -0,0 +1,167 @@ +@if($renewalCompleted) +
+@else +
+@endif +
+
+
+
+ @if($renewalCompleted) + + + + @elseif($renewalFailed) + + + + @else + + + + + @endif +
+ +

+ @if($renewalCompleted) + License Renewal Complete! + @elseif($renewalFailed) + Renewal Processing Failed + @else + Processing Your Renewal... + @endif +

+ +

+ @if($renewalCompleted) + Your automatic renewal has been set up successfully and your license expiry date has been updated. + @elseif($renewalFailed) + There was an issue processing your renewal. Please contact support for assistance. + @else + We're updating your license details. This usually takes a few moments. + @endif +

+
+ +
+ @if($renewalCompleted) +
+
+
+ + + +
+
+

+ Renewal Successful! +

+
+
    +
  • Your license has been successfully renewed
  • +
  • Automatic renewal has been set up for future renewals
  • +
  • Your existing license key continues to work
  • +
  • You'll receive a confirmation email shortly
  • +
+
+
+
+
+ @elseif($renewalFailed) +
+
+
+ + + +
+
+

+ Processing Failed +

+
+

We encountered an issue while processing your renewal. Your payment may have been successful, but we're having trouble updating your license details.

+

Please contact our support team and reference session ID: {{ $sessionId }}

+
+
+
+
+ @else +
+
+
+ + + +
+
+

+ Processing Your Renewal +

+
+

We're currently updating your license details with the new expiry date. This process usually completes within a few minutes.

+

This page will automatically refresh when processing is complete.

+
+
+
+
+ @endif + +
+

License Information:

+
+
+
License Key
+
+ + {{ $license->key }} + +
+
+
+
+ @if($renewalCompleted) + New Expiry Date + @else + Current Expiry + @endif +
+
+ @if($renewalCompleted && $license->expires_at) + + {{ $license->expires_at->format('F j, Y') }} + + + ({{ $license->expires_at->diffForHumans() }}) + + @else + {{ $originalExpiryDate ?: 'N/A' }} + @endif +
+
+
+
+ + + +
+

+ Questions about your renewal? Contact our support team + @if(!$renewalCompleted && !$renewalFailed) +
Session ID: {{ $sessionId }} + @endif +

+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php index 5f0b20436..d2e023754 100644 --- a/routes/web.php +++ b/routes/web.php @@ -87,9 +87,9 @@ Route::get('order/{checkoutSessionId}', App\Livewire\OrderSuccess::class)->name('order.success'); // License renewal routes +Route::get('license/{license:key}/renewal/success', App\Livewire\LicenseRenewalSuccess::class)->name('license.renewal.success'); Route::get('license/{license}/renewal', [App\Http\Controllers\LicenseRenewalController::class, 'show'])->name('license.renewal'); Route::post('license/{license}/renewal/checkout', [App\Http\Controllers\LicenseRenewalController::class, 'createCheckoutSession'])->name('license.renewal.checkout'); -Route::get('license/{license}/renewal/success', [App\Http\Controllers\LicenseRenewalController::class, 'success'])->name('license.renewal.success'); // Customer authentication routes Route::middleware('guest')->group(function () { @@ -114,12 +114,12 @@ // Billing portal Route::get('billing-portal', function (Illuminate\Http\Request $request) { $user = $request->user(); - + // Check if user exists in Stripe, create if they don't - if (!$user->hasStripeId()) { + if (! $user->hasStripeId()) { $user->createAsStripeCustomer(); } - + return $user->redirectToBillingPortal(route('customer.licenses')); })->name('billing-portal'); From 0342aec43021810f0823b14cbc97ceaaf26aecaf Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 21:21:51 +0100 Subject: [PATCH 08/21] Code style --- app/Console/Commands/SendLicenseExpiryWarnings.php | 13 ++++++------- app/Models/LicenseExpiryWarning.php | 2 +- app/Notifications/LicenseExpiryWarning.php | 6 ++++-- ..._001249_create_license_expiry_warnings_table.php | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/app/Console/Commands/SendLicenseExpiryWarnings.php b/app/Console/Commands/SendLicenseExpiryWarnings.php index 60fe1663e..8439945fb 100644 --- a/app/Console/Commands/SendLicenseExpiryWarnings.php +++ b/app/Console/Commands/SendLicenseExpiryWarnings.php @@ -4,7 +4,6 @@ use App\Models\License; use App\Notifications\LicenseExpiryWarning; -use Carbon\Carbon; use Illuminate\Console\Command; class SendLicenseExpiryWarnings extends Command @@ -21,7 +20,7 @@ public function handle(): int foreach ($warningDays as $days) { $sent = $this->sendWarningsForDays($days); $totalSent += $sent; - + $this->info("Sent {$sent} warning emails for licenses expiring in {$days} day(s)"); } @@ -44,7 +43,7 @@ private function sendWarningsForDays(int $days): int ->whereNull('subscription_item_id') // Legacy licenses without subscriptions ->whereDoesntHave('expiryWarnings', function ($query) use ($days) { $query->where('warning_days', $days) - ->where('sent_at', '>=', now()->subHours(23)); // Prevent duplicate emails within 23 hours + ->where('sent_at', '>=', now()->subHours(23)); // Prevent duplicate emails within 23 hours }) ->with('user') ->get(); @@ -52,19 +51,19 @@ private function sendWarningsForDays(int $days): int foreach ($licenses as $license) { if ($license->user) { $license->user->notify(new LicenseExpiryWarning($license, $days)); - + // Track that we sent this warning $license->expiryWarnings()->create([ 'warning_days' => $days, 'sent_at' => now(), ]); - + $sent++; - + $this->line("Sent {$days}-day warning to {$license->user->email} for license {$license->key}"); } } return $sent; } -} \ No newline at end of file +} diff --git a/app/Models/LicenseExpiryWarning.php b/app/Models/LicenseExpiryWarning.php index 0f8538916..63c3bb59a 100644 --- a/app/Models/LicenseExpiryWarning.php +++ b/app/Models/LicenseExpiryWarning.php @@ -21,4 +21,4 @@ public function license(): BelongsTo { return $this->belongsTo(License::class); } -} \ No newline at end of file +} diff --git a/app/Notifications/LicenseExpiryWarning.php b/app/Notifications/LicenseExpiryWarning.php index 90aab5b9b..5b4107aec 100644 --- a/app/Notifications/LicenseExpiryWarning.php +++ b/app/Notifications/LicenseExpiryWarning.php @@ -27,11 +27,13 @@ public function toMail($notifiable): MailMessage $subject = $this->getSubject(); $renewalUrl = route('license.renewal', ['license' => $this->license->key]); + $licenseName = $this->license->name ?: $this->license->policy_name; + return (new MailMessage) ->subject($subject) ->greeting("Hi {$notifiable->name},") ->line($this->getMainMessage()) - ->line("**License:** {$this->license->name ?: $this->license->policy_name}") + ->line("**License:** {$licenseName}") ->line("**License Key:** {$this->license->key}") ->line("**Expires:** {$this->license->expires_at->format('F j, Y \\a\\t g:i A T')}") ->line('To ensure uninterrupted access to NativePHP, you need to set up a subscription for automatic renewal.') @@ -62,4 +64,4 @@ private function getMainMessage(): string default => "Your NativePHP license will expire in {$this->daysUntilExpiry} days.", }; } -} \ No newline at end of file +} diff --git a/database/migrations/2025_09_12_001249_create_license_expiry_warnings_table.php b/database/migrations/2025_09_12_001249_create_license_expiry_warnings_table.php index edb65ce1b..02a952467 100644 --- a/database/migrations/2025_09_12_001249_create_license_expiry_warnings_table.php +++ b/database/migrations/2025_09_12_001249_create_license_expiry_warnings_table.php @@ -23,4 +23,4 @@ public function down(): void { Schema::dropIfExists('license_expiry_warnings'); } -}; \ No newline at end of file +}; From 300cd8993ffa3e3058408aa8aceb97c262f04966 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 21:24:15 +0100 Subject: [PATCH 09/21] Fix renewal customer config --- app/Http/Controllers/LicenseRenewalController.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/LicenseRenewalController.php b/app/Http/Controllers/LicenseRenewalController.php index 27b9f6b96..2a249cd07 100644 --- a/app/Http/Controllers/LicenseRenewalController.php +++ b/app/Http/Controllers/LicenseRenewalController.php @@ -50,6 +50,12 @@ public function createCheckoutSession(Request $request, string $licenseKey) } $subscriptionType = Subscription::from($license->policy_name); + $user = $license->user; + + // Ensure the user has a Stripe customer ID + if (! $user->hasStripeId()) { + $user->createAsStripeCustomer(); + } // Create Stripe checkout session $stripe = new \Stripe\StripeClient(config('cashier.secret')); @@ -61,8 +67,13 @@ public function createCheckoutSession(Request $request, string $licenseKey) 'quantity' => 1, ]], 'mode' => 'subscription', - 'success_url' => route('license.renewal.success', ['license' => $licenseKey]) . '?session_id={CHECKOUT_SESSION_ID}', + 'success_url' => route('license.renewal.success', ['license' => $licenseKey]).'?session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => route('license.renewal', ['license' => $licenseKey]), + 'customer' => $user->stripe_id, // Use existing customer ID + 'customer_update' => [ + 'name' => 'auto', // Allow Stripe to update customer name for tax ID collection + 'address' => 'auto', // Allow Stripe to update customer address for tax ID collection + ], 'metadata' => [ 'license_key' => $licenseKey, 'license_id' => $license->id, @@ -74,7 +85,6 @@ public function createCheckoutSession(Request $request, string $licenseKey) 'tax_id_collection' => [ 'enabled' => true, ], - 'customer_email' => $license->user->email, 'subscription_data' => [ 'metadata' => [ 'license_key' => $licenseKey, From d50ce4d9fa128f9d30824f19f0544719e2f4a4a1 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 22:45:03 +0100 Subject: [PATCH 10/21] Add license assignment --- .../Commands/ExtendLicenseExpiryCommand.php | 1 - .../CustomerSubLicenseController.php | 52 ++++- app/Http/Requests/CreateSubLicenseRequest.php | 3 + app/Jobs/CreateAnystackSubLicenseJob.php | 7 + .../UpdateAnystackContactAssociationJob.php | 98 ++++++++++ app/Jobs/UpdateAnystackLicenseExpiryJob.php | 1 - app/Models/SubLicense.php | 10 + app/Notifications/SubLicenseAssignment.php | 56 ++++++ ...204139_add_email_to_sub_licenses_table.php | 28 +++ .../views/customer/licenses/show.blade.php | 179 ++++++++++++++---- routes/web.php | 1 + 11 files changed, 401 insertions(+), 35 deletions(-) create mode 100644 app/Jobs/UpdateAnystackContactAssociationJob.php create mode 100644 app/Notifications/SubLicenseAssignment.php create mode 100644 database/migrations/2025_09_12_204139_add_email_to_sub_licenses_table.php diff --git a/app/Console/Commands/ExtendLicenseExpiryCommand.php b/app/Console/Commands/ExtendLicenseExpiryCommand.php index 7c63cfc78..4825c7b36 100644 --- a/app/Console/Commands/ExtendLicenseExpiryCommand.php +++ b/app/Console/Commands/ExtendLicenseExpiryCommand.php @@ -4,7 +4,6 @@ use App\Jobs\UpdateAnystackLicenseExpiryJob; use App\Models\License; -use Carbon\Carbon; use Illuminate\Console\Command; class ExtendLicenseExpiryCommand extends Command diff --git a/app/Http/Controllers/CustomerSubLicenseController.php b/app/Http/Controllers/CustomerSubLicenseController.php index b07cb0206..e4453f234 100644 --- a/app/Http/Controllers/CustomerSubLicenseController.php +++ b/app/Http/Controllers/CustomerSubLicenseController.php @@ -7,11 +7,15 @@ use App\Actions\SubLicenses\UnsuspendSubLicense; use App\Http\Requests\CreateSubLicenseRequest; use App\Jobs\CreateAnystackSubLicenseJob; +use App\Jobs\UpdateAnystackContactAssociationJob; use App\Models\License; use App\Models\SubLicense; +use App\Notifications\SubLicenseAssignment; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\RateLimiter; class CustomerSubLicenseController extends Controller { @@ -32,7 +36,7 @@ public function store(CreateSubLicenseRequest $request, string $licenseKey): Red } // Dispatch job to create sub-license in Anystack and then locally - CreateAnystackSubLicenseJob::dispatch($license, $request->name); + CreateAnystackSubLicenseJob::dispatch($license, $request->name, $request->assigned_email); return redirect()->route('customer.licenses.show', $licenseKey) ->with('success', 'Sub-license is being created. You will receive an email notification when it\'s ready.'); @@ -50,12 +54,21 @@ public function update(Request $request, string $licenseKey, SubLicense $subLice $request->validate([ 'name' => ['nullable', 'string', 'max:255'], + 'assigned_email' => ['nullable', 'email', 'max:255'], ]); + $oldEmail = $subLicense->assigned_email; + $subLicense->update([ 'name' => $request->name, + 'assigned_email' => $request->assigned_email, ]); + // If the email was changed and there's a new email, update the contact association + if ($oldEmail !== $request->assigned_email && $request->assigned_email) { + UpdateAnystackContactAssociationJob::dispatch($subLicense, $request->assigned_email); + } + return redirect()->route('customer.licenses.show', $licenseKey) ->with('success', 'Sub-license updated successfully!'); } @@ -107,4 +120,41 @@ public function unsuspend(string $licenseKey, SubLicense $subLicense): RedirectR return redirect()->route('customer.licenses.show', $licenseKey) ->with('success', 'Sub-license unsuspended successfully!'); } + + public function sendEmail(string $licenseKey, SubLicense $subLicense): RedirectResponse + { + $user = Auth::user(); + $license = $user->licenses()->where('key', $licenseKey)->firstOrFail(); + + // Verify the sub-license belongs to this license + if ($subLicense->parent_license_id !== $license->id) { + abort(404); + } + + // Verify the sub-license has an assigned email + if (! $subLicense->assigned_email) { + return redirect()->route('customer.licenses.show', $licenseKey) + ->withErrors(['email' => 'This sub-license does not have an assigned email address.']); + } + + // Rate limiting: max 1 email per minute per sub-license + $rateLimiterKey = "send-license-email.{$subLicense->id}"; + + if (RateLimiter::tooManyAttempts($rateLimiterKey, 1)) { + $secondsUntilAvailable = RateLimiter::availableIn($rateLimiterKey); + + return redirect()->route('customer.licenses.show', $licenseKey) + ->withErrors(['rate_limit' => "Please wait {$secondsUntilAvailable} seconds before sending another email for this license."]); + } + + // Record the attempt + RateLimiter::hit($rateLimiterKey, 60); // 60 seconds = 1 minute + + // Send the notification + Notification::route('mail', $subLicense->assigned_email) + ->notify(new SubLicenseAssignment($subLicense)); + + return redirect()->route('customer.licenses.show', $licenseKey) + ->with('success', "License details sent to {$subLicense->assigned_email} successfully!"); + } } diff --git a/app/Http/Requests/CreateSubLicenseRequest.php b/app/Http/Requests/CreateSubLicenseRequest.php index beb6c35fd..569e9c15c 100644 --- a/app/Http/Requests/CreateSubLicenseRequest.php +++ b/app/Http/Requests/CreateSubLicenseRequest.php @@ -15,6 +15,7 @@ public function rules(): array { return [ 'name' => ['nullable', 'string', 'max:255'], + 'assigned_email' => ['nullable', 'email', 'max:255'], ]; } @@ -22,6 +23,8 @@ public function messages(): array { return [ 'name.max' => 'The name cannot be longer than 255 characters.', + 'assigned_email.email' => 'Please enter a valid email address.', + 'assigned_email.max' => 'The email address cannot be longer than 255 characters.', ]; } } diff --git a/app/Jobs/CreateAnystackSubLicenseJob.php b/app/Jobs/CreateAnystackSubLicenseJob.php index 1907e2b69..08dbca630 100644 --- a/app/Jobs/CreateAnystackSubLicenseJob.php +++ b/app/Jobs/CreateAnystackSubLicenseJob.php @@ -17,6 +17,7 @@ class CreateAnystackSubLicenseJob implements ShouldQueue public function __construct( public License $parentLicense, public ?string $name = null, + public ?string $assignedEmail = null, ) {} public function handle(): void @@ -27,8 +28,14 @@ public function handle(): void 'anystack_id' => $licenseData['id'], 'name' => $this->name, 'key' => $licenseData['key'], + 'assigned_email' => $this->assignedEmail, 'expires_at' => $licenseData['expires_at'], ]); + + // If an email was assigned, update the contact association in Anystack + if ($this->assignedEmail) { + UpdateAnystackContactAssociationJob::dispatch($subLicense, $this->assignedEmail); + } } private function createSubLicenseInAnystack(): array diff --git a/app/Jobs/UpdateAnystackContactAssociationJob.php b/app/Jobs/UpdateAnystackContactAssociationJob.php new file mode 100644 index 000000000..f4a64e02c --- /dev/null +++ b/app/Jobs/UpdateAnystackContactAssociationJob.php @@ -0,0 +1,98 @@ +subLicense->anystack_id) { + logger('Cannot update Anystack contact association - no anystack_id', [ + 'sub_license_id' => $this->subLicense->id, + 'sub_license_key' => $this->subLicense->key, + ]); + + return; + } + + try { + // Create or find contact in Anystack by email + $contactData = $this->createOrFindContact(); + + // Update the sub-license to associate with the new contact + $this->updateLicenseContact($contactData['id']); + + logger('Successfully updated Anystack contact association', [ + 'sub_license_id' => $this->subLicense->id, + 'sub_license_key' => $this->subLicense->key, + 'email' => $this->email, + 'contact_id' => $contactData['id'], + ]); + + } catch (\Exception $e) { + logger('Failed to update Anystack contact association', [ + 'sub_license_id' => $this->subLicense->id, + 'sub_license_key' => $this->subLicense->key, + 'email' => $this->email, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + private function createOrFindContact(): array + { + $client = Anystack::api()->prepareRequest(); + + // Try to find existing contact by email + try { + $contacts = $client->get('contacts', [ + 'filter' => ['email' => $this->email], + ])->json('data'); + + if (! empty($contacts)) { + return $contacts[0]; + } + } catch (\Exception $e) { + // If search fails, continue to create new contact + logger('Contact search failed, will create new contact', [ + 'email' => $this->email, + 'error' => $e->getMessage(), + ]); + } + + // Create new contact + return $client->post('contacts', [ + 'email' => $this->email, + 'name' => $this->email, // Use email as name if no name is provided + ])->json('data'); + } + + private function updateLicenseContact(string $contactId): array + { + $productId = $this->subLicense->parentLicense->anystack_product_id; + + return Anystack::api() + ->prepareRequest() + ->patch("products/{$productId}/licenses/{$this->subLicense->anystack_id}", [ + 'contact_id' => $contactId, + ]) + ->json('data'); + } +} diff --git a/app/Jobs/UpdateAnystackLicenseExpiryJob.php b/app/Jobs/UpdateAnystackLicenseExpiryJob.php index 006ad6c44..67340990c 100644 --- a/app/Jobs/UpdateAnystackLicenseExpiryJob.php +++ b/app/Jobs/UpdateAnystackLicenseExpiryJob.php @@ -3,7 +3,6 @@ namespace App\Jobs; use App\Models\License; -use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; diff --git a/app/Models/SubLicense.php b/app/Models/SubLicense.php index 845bf8f37..3f0f95f90 100644 --- a/app/Models/SubLicense.php +++ b/app/Models/SubLicense.php @@ -18,6 +18,16 @@ class SubLicense extends Model 'is_suspended' => 'boolean', ]; + protected $fillable = [ + 'parent_license_id', + 'anystack_id', + 'name', + 'key', + 'assigned_email', + 'is_suspended', + 'expires_at', + ]; + protected static function boot(): void { parent::boot(); diff --git a/app/Notifications/SubLicenseAssignment.php b/app/Notifications/SubLicenseAssignment.php new file mode 100644 index 000000000..31891fa1f --- /dev/null +++ b/app/Notifications/SubLicenseAssignment.php @@ -0,0 +1,56 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('Your NativePHP License Key') + ->greeting('Hello!') + ->line('You have been assigned a NativePHP license key.') + ->line('**License Key:** `'.$this->subLicense->key.'`') + ->action('View Documentation', 'https://nativephp.com/docs/mobile/getting-started/installation') + ->line('If you have any questions, feel free to reach out to our support team.'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'sub_license_id' => $this->subLicense->id, + 'license_key' => $this->subLicense->key, + 'assigned_email' => $this->subLicense->assigned_email, + ]; + } +} diff --git a/database/migrations/2025_09_12_204139_add_email_to_sub_licenses_table.php b/database/migrations/2025_09_12_204139_add_email_to_sub_licenses_table.php new file mode 100644 index 000000000..a3bcf4ebe --- /dev/null +++ b/database/migrations/2025_09_12_204139_add_email_to_sub_licenses_table.php @@ -0,0 +1,28 @@ +string('assigned_email')->nullable()->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sub_licenses', function (Blueprint $table) { + $table->dropColumn('assigned_email'); + }); + } +}; diff --git a/resources/views/customer/licenses/show.blade.php b/resources/views/customer/licenses/show.blade.php index 7e34741e3..6d789e33c 100644 --- a/resources/views/customer/licenses/show.blade.php +++ b/resources/views/customer/licenses/show.blade.php @@ -200,6 +200,11 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font
+ @php + $activeSubLicenses = $license->subLicenses->where('is_suspended', false); + $suspendedSubLicenses = $license->subLicenses->where('is_suspended', true); + @endphp + @if($license->subLicenses->isEmpty())
@@ -208,26 +213,23 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font
@else -
-
    - @foreach($license->subLicenses as $subLicense) + {{-- Active Sub-Licenses --}} + @if($activeSubLicenses->isNotEmpty()) +
    +
      + @foreach($activeSubLicenses as $subLicense)
    • -
      -

      - {{ $subLicense->name ?: 'Unnamed Key' }} -

      - - {{ $subLicense->status }} - -
      + @if($subLicense->name) +
      +

      + {{ $subLicense->name }} +

      +
      + @endif
      {{ $subLicense->key }} @@ -240,16 +242,104 @@ class="ml-2 text-xs text-blue-600 hover:text-blue-500" Copy
      + @if($subLicense->assigned_email) +
      + Assigned to: {{ $subLicense->assigned_email }} +
      + @endif
      + @if($subLicense->assigned_email) +
      + @csrf + +
      + @endif - @if($subLicense->is_suspended) +
      + @csrf + @method('PATCH') + +
      +
      +
      +
      +
      +
    • + @endforeach +
    +
    + @endif + + {{-- Suspended Sub-Licenses --}} + @if($suspendedSubLicenses->isNotEmpty()) +
    +
    +
    +
    +

    + Suspended Keys + + ({{ $suspendedSubLicenses->count() }}) + +

    +

    + These keys are currently suspended and cannot be used. +

    +
    +
    +
    +
    +
      + @foreach($suspendedSubLicenses as $subLicense) +
    • +
      +
      +
      +
      + @if($subLicense->name) +
      +

      + {{ $subLicense->name }} +

      +
      + @endif +
      + + {{ $subLicense->key }} + + +
      + @if($subLicense->assigned_email) +
      + Assigned to: {{ $subLicense->assigned_email }} +
      + @endif +
      +
      +
      @csrf @method('PATCH') @@ -257,23 +347,16 @@ class="text-blue-600 hover:text-blue-500 text-sm" Unsuspend
      - @else -
      - @csrf - @method('PATCH') - -
      - @endif +
      -
    - - @endforeach -
-
+ + @endforeach + +
+
+ @endif @endif @if(!$license->canCreateSubLicense()) @@ -408,6 +491,20 @@ class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md s />

Give your key a descriptive name to help identify its purpose.

+
+ + +

Assign this license to a team member. They'll receive usage instructions via email.

+
+
+ + +

Assign this license to a team member. They'll receive usage instructions via email.

+
-
diff --git a/resources/views/customer/licenses/show.blade.php b/resources/views/customer/licenses/show.blade.php index 6d789e33c..85d88b1f6 100644 --- a/resources/views/customer/licenses/show.blade.php +++ b/resources/views/customer/licenses/show.blade.php @@ -33,12 +33,6 @@ Billing -
- @csrf - -
@@ -181,7 +175,7 @@ class="ml-2 inline-flex items-center px-2.5 py-1.5 border border-gray-300 dark:b

Keys - ({{ $license->subLicenses->count() }}{{ $license->subLicenseLimit ? '/' . $license->subLicenseLimit : '' }}) + ({{ $license->subLicenses->where('is_suspended', false)->count() }}{{ $license->subLicenseLimit ? '/' . $license->subLicenseLimit : '' }})

@@ -264,7 +258,7 @@ class="text-blue-600 hover:text-blue-500 text-sm" > Edit -

+ @csrf @method('PATCH')

- These keys are currently suspended and cannot be used. + These keys are permanently suspended and cannot be used or reactivated.

@@ -332,22 +326,6 @@ class="ml-2 text-xs text-blue-600 hover:text-blue-500"
@endif
-
- - - @csrf - @method('PATCH') - - -

@@ -697,5 +675,22 @@ function hideEditLicenseNameModal() { hideEditLicenseNameModal(); } }); + + // Confirm suspension with warning about permanence + function confirmSuspension(event) { + const confirmed = confirm( + 'Are you sure you want to suspend this license?\n\n' + + 'WARNING: Once suspended, this license cannot be reactivated. ' + + 'You will need to create a new license instead.\n\n' + + 'This action is permanent and cannot be undone.' + ); + + if (!confirmed) { + event.preventDefault(); + return false; + } + + return true; + } diff --git a/routes/web.php b/routes/web.php index 20b1db28f..188beda53 100644 --- a/routes/web.php +++ b/routes/web.php @@ -128,6 +128,5 @@ Route::patch('licenses/{licenseKey}/sub-licenses/{subLicense}', [CustomerSubLicenseController::class, 'update'])->name('licenses.sub-licenses.update'); Route::delete('licenses/{licenseKey}/sub-licenses/{subLicense}', [CustomerSubLicenseController::class, 'destroy'])->name('licenses.sub-licenses.destroy'); Route::patch('licenses/{licenseKey}/sub-licenses/{subLicense}/suspend', [CustomerSubLicenseController::class, 'suspend'])->name('licenses.sub-licenses.suspend'); - Route::patch('licenses/{licenseKey}/sub-licenses/{subLicense}/unsuspend', [CustomerSubLicenseController::class, 'unsuspend'])->name('licenses.sub-licenses.unsuspend'); Route::post('licenses/{licenseKey}/sub-licenses/{subLicense}/send-email', [CustomerSubLicenseController::class, 'sendEmail'])->name('licenses.sub-licenses.send-email'); }); From 145bd0454962510dcda8e4b82225a8f1b3e3b1d5 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 23:27:44 +0100 Subject: [PATCH 12/21] Adjust key limit --- app/Enums/Subscription.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Enums/Subscription.php b/app/Enums/Subscription.php index d6cc9b83c..0a3fc45fd 100644 --- a/app/Enums/Subscription.php +++ b/app/Enums/Subscription.php @@ -84,7 +84,7 @@ public function supportsSubLicenses(): bool public function subLicenseLimit(): ?int { return match ($this) { - self::Pro => 10, + self::Pro => 9, self::Max, self::Forever => null, // Unlimited default => 0, }; From cb13d6f2b2714fcc70ce7a4bff4a9bef7d05f57b Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 23:28:40 +0100 Subject: [PATCH 13/21] Move log in/log out --- resources/views/components/footer.blade.php | 8 +++++++ .../components/navbar/mobile-menu.blade.php | 24 +++++++++++++++++++ .../views/components/navigation-bar.blade.php | 23 ++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/resources/views/components/footer.blade.php b/resources/views/components/footer.blade.php index d9ea4fa56..76a5da94c 100644 --- a/resources/views/components/footer.blade.php +++ b/resources/views/components/footer.blade.php @@ -223,6 +223,14 @@ class="inline-block px-px py-1.5 transition duration-300 will-change-transform h Partners +
  • + + License Management + +
  • + + {{-- Login/Logout --}} +
    + {{-- Decorative circle --}} + + + {{-- Login/Logout --}} + @auth +
    + @csrf + +
    + @else + + Log in + + @endauth + {{-- Theme toggle --}} From 27368b6fdcdd890c67ed6d0ed5331126c5ff8ae7 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 23:37:07 +0100 Subject: [PATCH 14/21] Remove test --- .../CustomerSubLicenseManagementTest.php | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/tests/Feature/CustomerSubLicenseManagementTest.php b/tests/Feature/CustomerSubLicenseManagementTest.php index 5936ab243..8550c3c61 100644 --- a/tests/Feature/CustomerSubLicenseManagementTest.php +++ b/tests/Feature/CustomerSubLicenseManagementTest.php @@ -165,34 +165,6 @@ public function test_customer_can_suspend_sub_license(): void ]); } - public function test_customer_can_unsuspend_sub_license(): void - { - Http::fake([ - 'api.anystack.sh/v1/products/*/licenses/*' => Http::response(['success' => true], 200), - ]); - - $user = User::factory()->create(); - $license = License::factory()->create([ - 'user_id' => $user->id, - 'policy_name' => 'pro', - ]); - $subLicense = SubLicense::factory()->create([ - 'parent_license_id' => $license->id, - 'is_suspended' => true, - ]); - - $response = $this->actingAs($user) - ->patch("/customer/licenses/{$license->key}/sub-licenses/{$subLicense->id}/unsuspend"); - - $response->assertRedirect("/customer/licenses/{$license->key}") - ->assertSessionHas('success', 'Sub-license unsuspended successfully!'); - - $this->assertDatabaseHas('sub_licenses', [ - 'id' => $subLicense->id, - 'is_suspended' => false, - ]); - } - public function test_customer_can_delete_sub_license(): void { Http::fake([ From e8d386518098e33e640dfa290cff14026fc9f2f3 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 12 Sep 2025 23:37:36 +0100 Subject: [PATCH 15/21] Code style --- app/Http/Controllers/CustomerSubLicenseController.php | 5 ++--- app/Models/License.php | 1 - app/Models/SubLicense.php | 1 - 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/CustomerSubLicenseController.php b/app/Http/Controllers/CustomerSubLicenseController.php index 97f47106f..85c1881fa 100644 --- a/app/Http/Controllers/CustomerSubLicenseController.php +++ b/app/Http/Controllers/CustomerSubLicenseController.php @@ -104,7 +104,6 @@ public function suspend(string $licenseKey, SubLicense $subLicense): RedirectRes ->with('success', 'Sub-license suspended successfully!'); } - public function sendEmail(string $licenseKey, SubLicense $subLicense): RedirectResponse { $user = Auth::user(); @@ -123,10 +122,10 @@ public function sendEmail(string $licenseKey, SubLicense $subLicense): RedirectR // Rate limiting: max 1 email per minute per sub-license $rateLimiterKey = "send-license-email.{$subLicense->id}"; - + if (RateLimiter::tooManyAttempts($rateLimiterKey, 1)) { $secondsUntilAvailable = RateLimiter::availableIn($rateLimiterKey); - + return redirect()->route('customer.licenses.show', $licenseKey) ->withErrors(['rate_limit' => "Please wait {$secondsUntilAvailable} seconds before sending another email for this license."]); } diff --git a/app/Models/License.php b/app/Models/License.php index e8a4d6cba..9e4aa67b4 100644 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -115,7 +115,6 @@ public function canCreateSubLicense(): bool return $remaining === null || $remaining > 0; } - public function suspendAllSubLicenses(): int { return $this->subLicenses()->update(['is_suspended' => true]); diff --git a/app/Models/SubLicense.php b/app/Models/SubLicense.php index d7780cdab..654539444 100644 --- a/app/Models/SubLicense.php +++ b/app/Models/SubLicense.php @@ -104,5 +104,4 @@ public function suspend(): bool { return $this->update(['is_suspended' => true]); } - } From 3346ed52d96b62a4be459451d4b71639bdd3261c Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 18 Sep 2025 17:58:18 +0100 Subject: [PATCH 16/21] Fix test suite --- phpunit.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index ebb932dbd..8f186ec9d 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,9 +5,6 @@ colors="true" > - - ./tests/Unit - ./tests/Feature From 5d68b28eda06473dcfc46f9b66c03872d79d6891 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 19 Sep 2025 04:32:17 +0100 Subject: [PATCH 17/21] Add Pennant --- composer.json | 1 + composer.lock | 79 ++++++++++++++++++- config/pennant.php | 44 +++++++++++ ...022_11_01_000001_create_features_table.php | 32 ++++++++ 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 config/pennant.php create mode 100644 database/migrations/2022_11_01_000001_create_features_table.php diff --git a/composer.json b/composer.json index dd4a98151..2af412102 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "guzzlehttp/guzzle": "^7.2", "laravel/cashier": "^15.6", "laravel/framework": "^10.10", + "laravel/pennant": "^1.18", "laravel/sanctum": "^3.3", "laravel/tinker": "^2.8", "league/commonmark": "^2.4", diff --git a/composer.lock b/composer.lock index 03baabbd1..ffc53b89e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e92e65fe8e79603ace8319a36effc1db", + "content-hash": "e5931e2d5533bbfc228e4d03e83324cc", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2549,6 +2549,83 @@ }, "time": "2025-03-12T14:42:01+00:00" }, + { + "name": "laravel/pennant", + "version": "v1.18.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/pennant.git", + "reference": "6d3ea4874ee4da4a7d02826f545ef6309160d041" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pennant/zipball/6d3ea4874ee4da4a7d02826f545ef6309160d041", + "reference": "6d3ea4874ee4da4a7d02826f545ef6309160d041", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/container": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/queue": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1", + "symfony/console": "^6.0|^7.0", + "symfony/finder": "^6.0|^7.0" + }, + "require-dev": { + "laravel/octane": "^1.4|^2.0", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.0|^10.4|^11.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Feature": "Laravel\\Pennant\\Feature" + }, + "providers": [ + "Laravel\\Pennant\\PennantServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Pennant\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "A simple, lightweight library for managing feature flags.", + "homepage": "https://github.com/laravel/pennant", + "keywords": [ + "feature", + "flags", + "laravel", + "pennant" + ], + "support": { + "issues": "https://github.com/laravel/pennant/issues", + "source": "https://github.com/laravel/pennant" + }, + "time": "2025-08-18T13:24:56+00:00" + }, { "name": "laravel/prompts", "version": "v0.1.25", diff --git a/config/pennant.php b/config/pennant.php new file mode 100644 index 000000000..d03ebfb33 --- /dev/null +++ b/config/pennant.php @@ -0,0 +1,44 @@ + env('PENNANT_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Pennant Stores + |-------------------------------------------------------------------------- + | + | Here you may configure each of the stores that should be available to + | Pennant. These stores shall be used to store resolved feature flag + | values - you may configure as many as your application requires. + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => null, + 'table' => 'features', + ], + + ], +]; diff --git a/database/migrations/2022_11_01_000001_create_features_table.php b/database/migrations/2022_11_01_000001_create_features_table.php new file mode 100644 index 000000000..a64eea2f5 --- /dev/null +++ b/database/migrations/2022_11_01_000001_create_features_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->string('scope'); + $table->text('value'); + $table->timestamps(); + + $table->unique(['name', 'scope']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('features'); + } +}; From fd590a18c185b35de93d7a7fdfea26fdacbe3ff3 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 19 Sep 2025 04:32:26 +0100 Subject: [PATCH 18/21] Add feature flag --- app/Features/ShowAuthButtons.php | 20 ++++++++++++++++++++ app/Providers/AppServiceProvider.php | 9 +++++++++ 2 files changed, 29 insertions(+) create mode 100644 app/Features/ShowAuthButtons.php diff --git a/app/Features/ShowAuthButtons.php b/app/Features/ShowAuthButtons.php new file mode 100644 index 000000000..e7ae24bc6 --- /dev/null +++ b/app/Features/ShowAuthButtons.php @@ -0,0 +1,20 @@ +active(static::class); + } + + return false; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8eb8862a3..793d4d11c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Features\ShowAuthButtons; use App\Support\GitHub; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Queue\Events\JobFailed; @@ -9,6 +10,7 @@ use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; +use Laravel\Pennant\Feature; use Sentry\State\Scope; use function Sentry\captureException; @@ -33,6 +35,8 @@ public function boot(): void $this->sendFailingJobsToSentry(); + $this->registerFeatureFlags(); + RateLimiter::for('anystack', function () { return Limit::perMinute(30); }); @@ -67,4 +71,9 @@ private function sendFailingJobsToSentry(): void } }); } + + private function registerFeatureFlags(): void + { + Feature::define(ShowAuthButtons::class); + } } From 6a12e3e5f67bfd88c2f72e8c7d693b067b331f63 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 19 Sep 2025 04:32:37 +0100 Subject: [PATCH 19/21] Use feature flag --- resources/views/components/footer.blade.php | 18 +++++----- .../components/navbar/mobile-menu.blade.php | 36 ++++++++++--------- .../views/components/navigation-bar.blade.php | 32 +++++++++-------- routes/web.php | 10 ++++-- 4 files changed, 53 insertions(+), 43 deletions(-) diff --git a/resources/views/components/footer.blade.php b/resources/views/components/footer.blade.php index 76a5da94c..32395ad75 100644 --- a/resources/views/components/footer.blade.php +++ b/resources/views/components/footer.blade.php @@ -223,14 +223,16 @@ class="inline-block px-px py-1.5 transition duration-300 will-change-transform h Partners -
  • - - License Management - -
  • + @feature(App\Features\ShowAuthButtons::class) +
  • + + License Management + +
  • + @endfeature
  • {{-- Login/Logout --}} - + @feature(App\Features\ShowAuthButtons::class) +
    + @auth +
    + @csrf + +
    + @else + +
    Log in
    +
    + @endauth +
    + @endfeature
    - @csrf - - - @else - - Log in - - @endauth + @feature(App\Features\ShowAuthButtons::class) + @auth +
    + @csrf + +
    + @else + + Log in + + @endauth + @endfeature {{-- Theme toggle --}} diff --git a/routes/web.php b/routes/web.php index 188beda53..4fbcacdbb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ name('license.renewal.checkout'); // Customer authentication routes -Route::middleware('guest')->group(function () { +Route::middleware(['guest', EnsureFeaturesAreActive::using(ShowAuthButtons::class)])->group(function () { Route::get('login', [CustomerAuthController::class, 'showLogin'])->name('customer.login'); Route::post('login', [CustomerAuthController::class, 'login']); @@ -103,10 +105,12 @@ Route::post('reset-password', [CustomerAuthController::class, 'resetPassword'])->name('password.update'); }); -Route::post('logout', [CustomerAuthController::class, 'logout'])->name('customer.logout'); +Route::post('logout', [CustomerAuthController::class, 'logout']) + ->middleware(EnsureFeaturesAreActive::using(ShowAuthButtons::class)) + ->name('customer.logout'); // Customer license management routes -Route::middleware('auth')->prefix('customer')->name('customer.')->group(function () { +Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class)])->prefix('customer')->name('customer.')->group(function () { Route::get('licenses', [CustomerLicenseController::class, 'index'])->name('licenses'); Route::get('licenses/{licenseKey}', [CustomerLicenseController::class, 'show'])->name('licenses.show'); Route::patch('licenses/{licenseKey}', [CustomerLicenseController::class, 'update'])->name('licenses.update'); From 0e1325f310dd19e9a6e43f5bc5e7041b3d007422 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 19 Sep 2025 04:33:55 +0100 Subject: [PATCH 20/21] Custom error pages --- resources/views/errors/400.blade.php | 43 ++++++++++++++++++++++++++++ resources/views/errors/404.blade.php | 43 ++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 resources/views/errors/400.blade.php create mode 100644 resources/views/errors/404.blade.php diff --git a/resources/views/errors/400.blade.php b/resources/views/errors/400.blade.php new file mode 100644 index 000000000..1dd04ac2c --- /dev/null +++ b/resources/views/errors/400.blade.php @@ -0,0 +1,43 @@ + +
    +
    + {{-- Error illustration or icon --}} +
    +
    + + + +
    +
    + + {{-- Error message --}} +

    + Not Available +

    + +

    + This feature is currently not available. Please check back later or contact support if you need assistance. +

    + + {{-- Action buttons --}} + +
    +
    +
    diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 000000000..36801abc3 --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1,43 @@ + +
    +
    + {{-- Error illustration or icon --}} +
    +
    + + + +
    +
    + + {{-- Error message --}} +

    + Page Not Found +

    + +

    + The page you're looking for doesn't exist. It may have been moved, deleted, or you entered the wrong URL. +

    + + {{-- Action buttons --}} + +
    +
    +
    From fbabb486a02536c16f30dad55fdd778f11a5cc84 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 19 Sep 2025 04:49:21 +0100 Subject: [PATCH 21/21] Fix tests --- tests/Feature/CustomerAuthenticationTest.php | 10 ++++++++++ tests/Feature/CustomerLicenseManagementTest.php | 10 ++++++++++ tests/Feature/CustomerSubLicenseManagementTest.php | 10 ++++++++++ tests/Feature/MobileRouteTest.php | 13 +++++++++++++ 4 files changed, 43 insertions(+) diff --git a/tests/Feature/CustomerAuthenticationTest.php b/tests/Feature/CustomerAuthenticationTest.php index 81f3556bb..be0403db5 100644 --- a/tests/Feature/CustomerAuthenticationTest.php +++ b/tests/Feature/CustomerAuthenticationTest.php @@ -2,15 +2,25 @@ namespace Tests\Feature; +use App\Features\ShowAuthButtons; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; +use Laravel\Pennant\Feature; use Tests\TestCase; class CustomerAuthenticationTest extends TestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + + // Enable the auth feature flag for all authentication tests + Feature::define(ShowAuthButtons::class, true); + } + public function test_customer_can_view_login_page(): void { $response = $this->get('/login'); diff --git a/tests/Feature/CustomerLicenseManagementTest.php b/tests/Feature/CustomerLicenseManagementTest.php index c07dc25be..ba2667444 100644 --- a/tests/Feature/CustomerLicenseManagementTest.php +++ b/tests/Feature/CustomerLicenseManagementTest.php @@ -2,15 +2,25 @@ namespace Tests\Feature; +use App\Features\ShowAuthButtons; use App\Models\License; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Laravel\Pennant\Feature; use Tests\TestCase; class CustomerLicenseManagementTest extends TestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + + // Enable the auth feature flag for all license management tests + Feature::define(ShowAuthButtons::class, true); + } + public function test_customer_can_view_licenses_page(): void { $user = User::factory()->create(); diff --git a/tests/Feature/CustomerSubLicenseManagementTest.php b/tests/Feature/CustomerSubLicenseManagementTest.php index 8550c3c61..247aef883 100644 --- a/tests/Feature/CustomerSubLicenseManagementTest.php +++ b/tests/Feature/CustomerSubLicenseManagementTest.php @@ -2,18 +2,28 @@ namespace Tests\Feature; +use App\Features\ShowAuthButtons; use App\Models\License; use App\Models\SubLicense; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; +use Laravel\Pennant\Feature; use Tests\TestCase; class CustomerSubLicenseManagementTest extends TestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + + // Enable the auth feature flag for all sub-license management tests + Feature::define(ShowAuthButtons::class, true); + } + public function test_customer_can_create_sub_license_when_license_supports_it(): void { Queue::fake(); diff --git a/tests/Feature/MobileRouteTest.php b/tests/Feature/MobileRouteTest.php index d04a858bd..06b512799 100644 --- a/tests/Feature/MobileRouteTest.php +++ b/tests/Feature/MobileRouteTest.php @@ -2,11 +2,24 @@ namespace Tests\Feature; +use App\Features\ShowAuthButtons; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Laravel\Pennant\Feature; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; class MobileRouteTest extends TestCase { + use RefreshDatabase; + + protected function setUp(): void + { + parent::setUp(); + + // Enable the auth feature flag since the pricing page might reference it + Feature::define(ShowAuthButtons::class, false); + } + #[Test] public function mobile_route_does_not_include_stripe_payment_links() {