diff --git a/app/Actions/SubLicenses/DeleteSubLicense.php b/app/Actions/SubLicenses/DeleteSubLicense.php new file mode 100644 index 00000000..e413fe45 --- /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 00000000..13b81dfe --- /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 00000000..f15c5345 --- /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/Console/Commands/ExtendLicenseExpiryCommand.php b/app/Console/Commands/ExtendLicenseExpiryCommand.php new file mode 100644 index 00000000..4825c7b3 --- /dev/null +++ b/app/Console/Commands/ExtendLicenseExpiryCommand.php @@ -0,0 +1,47 @@ +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/Console/Commands/SendLicenseExpiryWarnings.php b/app/Console/Commands/SendLicenseExpiryWarnings.php new file mode 100644 index 00000000..8439945f --- /dev/null +++ b/app/Console/Commands/SendLicenseExpiryWarnings.php @@ -0,0 +1,69 @@ +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; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e6b9960e..682809d8 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/Enums/Subscription.php b/app/Enums/Subscription.php index f336b0b5..0a3fc45f 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"); } @@ -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 => 9, + self::Max, self::Forever => null, // Unlimited + default => 0, + }; + } } diff --git a/app/Features/ShowAuthButtons.php b/app/Features/ShowAuthButtons.php new file mode 100644 index 00000000..e7ae24bc --- /dev/null +++ b/app/Features/ShowAuthButtons.php @@ -0,0 +1,20 @@ +active(static::class); + } + + return false; + } +} diff --git a/app/Http/Controllers/Auth/CustomerAuthController.php b/app/Http/Controllers/Auth/CustomerAuthController.php new file mode 100644 index 00000000..cc98b7af --- /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 00000000..01b3f1dc --- /dev/null +++ b/app/Http/Controllers/CustomerLicenseController.php @@ -0,0 +1,52 @@ +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 00000000..85c1881f --- /dev/null +++ b/app/Http/Controllers/CustomerSubLicenseController.php @@ -0,0 +1,143 @@ +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, $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.'); + } + + 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'], + '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!'); + } + + 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 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/Controllers/LicenseRenewalController.php b/app/Http/Controllers/LicenseRenewalController.php new file mode 100644 index 00000000..2a249cd0 --- /dev/null +++ b/app/Http/Controllers/LicenseRenewalController.php @@ -0,0 +1,99 @@ +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(forceEap: true), // 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); + $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')); + + $checkoutSession = $stripe->checkout->sessions->create([ + 'payment_method_types' => ['card'], + 'line_items' => [[ + 'price' => $subscriptionType->stripePriceId(forceEap: true), // 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]), + '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, + 'renewal' => 'true', // Flag this as a renewal, not a new purchase + ], + 'consent_collection' => [ + 'terms_of_service' => 'required', + ], + 'tax_id_collection' => [ + 'enabled' => true, + ], + 'subscription_data' => [ + 'metadata' => [ + 'license_key' => $licenseKey, + 'license_id' => $license->id, + 'renewal' => 'true', + ], + ], + ]); + + return redirect($checkoutSession->url); + } +} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index d4ef6447..42d035fc 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 00000000..2dfca7e0 --- /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 00000000..569e9c15 --- /dev/null +++ b/app/Http/Requests/CreateSubLicenseRequest.php @@ -0,0 +1,30 @@ + ['nullable', 'string', 'max:255'], + 'assigned_email' => ['nullable', 'email', 'max:255'], + ]; + } + + 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 new file mode 100644 index 00000000..08dbca63 --- /dev/null +++ b/app/Jobs/CreateAnystackSubLicenseJob.php @@ -0,0 +1,55 @@ +createSubLicenseInAnystack(); + + $subLicense = $this->parentLicense->subLicenses()->create([ + '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 + { + $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/Jobs/HandleCustomerSubscriptionCreatedJob.php b/app/Jobs/HandleCustomerSubscriptionCreatedJob.php index e6634d20..6c822646 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/Jobs/HandleInvoicePaidJob.php b/app/Jobs/HandleInvoicePaidJob.php index bc832305..275a3ce7 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/UpdateAnystackContactAssociationJob.php b/app/Jobs/UpdateAnystackContactAssociationJob.php new file mode 100644 index 00000000..f4a64e02 --- /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 new file mode 100644 index 00000000..67340990 --- /dev/null +++ b/app/Jobs/UpdateAnystackLicenseExpiryJob.php @@ -0,0 +1,75 @@ +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 00000000..5e3925f8 --- /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/app/Models/License.php b/app/Models/License.php index c88fe335..9e4aa67b 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,93 @@ 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()->where('is_suspended', false)->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]); + } + + 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/LicenseExpiryWarning.php b/app/Models/LicenseExpiryWarning.php new file mode 100644 index 00000000..63c3bb59 --- /dev/null +++ b/app/Models/LicenseExpiryWarning.php @@ -0,0 +1,24 @@ + 'datetime', + ]; + + public function license(): BelongsTo + { + return $this->belongsTo(License::class); + } +} diff --git a/app/Models/SubLicense.php b/app/Models/SubLicense.php new file mode 100644 index 00000000..65453944 --- /dev/null +++ b/app/Models/SubLicense.php @@ -0,0 +1,107 @@ + 'datetime', + '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(); + + 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]); + } +} diff --git a/app/Notifications/LicenseExpiryWarning.php b/app/Notifications/LicenseExpiryWarning.php new file mode 100644 index 00000000..5b4107ae --- /dev/null +++ b/app/Notifications/LicenseExpiryWarning.php @@ -0,0 +1,67 @@ +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:** {$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.') + ->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.", + }; + } +} diff --git a/app/Notifications/SubLicenseAssignment.php b/app/Notifications/SubLicenseAssignment.php new file mode 100644 index 00000000..31891fa1 --- /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/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8eb8862a..793d4d11 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); + } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 1cf5f15c..dd39ace7 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/composer.json b/composer.json index dd4a9815..2af41210 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 03baabbd..ffc53b89 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 00000000..d03ebfb3 --- /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/factories/SubLicenseFactory.php b/database/factories/SubLicenseFactory.php new file mode 100644 index 00000000..9fdf0608 --- /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/2022_11_01_000001_create_features_table.php b/database/migrations/2022_11_01_000001_create_features_table.php new file mode 100644 index 00000000..a64eea2f --- /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'); + } +}; 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 00000000..e2781e12 --- /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 00000000..c9234f28 --- /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 00000000..eac712e6 --- /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/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 00000000..02a95246 --- /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'); + } +}; 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 00000000..a3bcf4eb --- /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/phpunit.xml b/phpunit.xml index ebb932db..8f186ec9 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,9 +5,6 @@ colors="true" > - - ./tests/Unit - ./tests/Feature diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php new file mode 100644 index 00000000..1d6e0d76 --- /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 00000000..9364dac6 --- /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 00000000..d166dc07 --- /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/components/footer.blade.php b/resources/views/components/footer.blade.php index d9ea4fa5..32395ad7 100644 --- a/resources/views/components/footer.blade.php +++ b/resources/views/components/footer.blade.php @@ -223,6 +223,16 @@ class="inline-block px-px py-1.5 transition duration-300 will-change-transform h Partners + @feature(App\Features\ShowAuthButtons::class) +
  • + + License Management + +
  • + @endfeature
  • + + {{-- Login/Logout --}} + @feature(App\Features\ShowAuthButtons::class) + + @endfeature
    + {{-- Decorative circle --}} + + + {{-- Login/Logout --}} + @feature(App\Features\ShowAuthButtons::class) + @auth +
    + @csrf + +
    + @else + + Log in + + @endauth + @endfeature + {{-- Theme toggle --}} diff --git a/resources/views/customer/licenses/index.blade.php b/resources/views/customer/licenses/index.blade.php new file mode 100644 index 00000000..8f338de8 --- /dev/null +++ b/resources/views/customer/licenses/index.blade.php @@ -0,0 +1,111 @@ + + + diff --git a/resources/views/customer/licenses/show.blade.php b/resources/views/customer/licenses/show.blade.php new file mode 100644 index 00000000..85d88b1f --- /dev/null +++ b/resources/views/customer/licenses/show.blade.php @@ -0,0 +1,696 @@ + +
    + {{-- Header --}} +
    +
    +
    +
    + +

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

    + @if($license->name) +

    {{ $license->policy_name }}

    + @endif +
    + +
    +
    +
    + + {{-- 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->where('is_suspended', false)->count() }}{{ $license->subLicenseLimit ? '/' . $license->subLicenseLimit : '' }}) + +

    +

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

    +
    + @if($license->canCreateSubLicense()) + + @endif +
    +
    + + @php + $activeSubLicenses = $license->subLicenses->where('is_suspended', false); + $suspendedSubLicenses = $license->subLicenses->where('is_suspended', true); + @endphp + + @if($license->subLicenses->isEmpty()) +
    +
    +

    No keys

    +

    Get started by creating your first key.

    +
    +
    + @else + {{-- Active Sub-Licenses --}} + @if($activeSubLicenses->isNotEmpty()) +
    +
      + @foreach($activeSubLicenses as $subLicense) +
    • +
      +
      +
      +
      + @if($subLicense->name) +
      +

      + {{ $subLicense->name }} +

      +
      + @endif +
      + + {{ $subLicense->key }} + + +
      + @if($subLicense->assigned_email) +
      + Assigned to: {{ $subLicense->assigned_email }} +
      + @endif +
      +
      + @if($subLicense->assigned_email) +
      + @csrf + +
      + @endif + +
      + @csrf + @method('PATCH') + +
      +
      +
      +
      +
      +
    • + @endforeach +
    +
    + @endif + + {{-- Suspended Sub-Licenses --}} + @if($suspendedSubLicenses->isNotEmpty()) +
    +
    +
    +
    +

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

    +

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

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

      + {{ $subLicense->name }} +

      +
      + @endif +
      + + {{ $subLicense->key }} + + +
      + @if($subLicense->assigned_email) +
      + Assigned to: {{ $subLicense->assigned_email }} +
      + @endif +
      +
      +
      +
      +
    • + @endforeach +
    +
    +
    + @endif + @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/resources/views/errors/400.blade.php b/resources/views/errors/400.blade.php new file mode 100644 index 00000000..1dd04ac2 --- /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 00000000..36801abc --- /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 --}} + +
    +
    +
    diff --git a/resources/views/license/renewal-success.blade.php b/resources/views/license/renewal-success.blade.php new file mode 100644 index 00000000..dd11d301 --- /dev/null +++ b/resources/views/license/renewal-success.blade.php @@ -0,0 +1,84 @@ + +
    +
    +
    +
    +
    + + + +
    + +

    + 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 +

    +
    +
    +
    +
    +
    +
    diff --git a/resources/views/license/renewal.blade.php b/resources/views/license/renewal.blade.php new file mode 100644 index 00000000..99a5d6f8 --- /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/resources/views/livewire/license-renewal-success.blade.php b/resources/views/livewire/license-renewal-success.blade.php new file mode 100644 index 00000000..e5fa16ee --- /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 578609a5..4fbcacdb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,9 +1,14 @@ name('docs')->where('page', '.*'); 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'); + +// Customer authentication routes +Route::middleware(['guest', EnsureFeaturesAreActive::using(ShowAuthButtons::class)])->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']) + ->middleware(EnsureFeaturesAreActive::using(ShowAuthButtons::class)) + ->name('customer.logout'); + +// Customer license management routes +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'); + + // 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'); + 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::post('licenses/{licenseKey}/sub-licenses/{subLicense}/send-email', [CustomerSubLicenseController::class, 'sendEmail'])->name('licenses.sub-licenses.send-email'); +}); diff --git a/tests/Feature/CustomerAuthenticationTest.php b/tests/Feature/CustomerAuthenticationTest.php new file mode 100644 index 00000000..be0403db --- /dev/null +++ b/tests/Feature/CustomerAuthenticationTest.php @@ -0,0 +1,98 @@ +get('/login'); + + $response->assertStatus(200); + $response->assertSee('Sign in to your account'); + $response->assertSee('Manage 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 00000000..ba266744 --- /dev/null +++ b/tests/Feature/CustomerLicenseManagementTest.php @@ -0,0 +1,292 @@ +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 00000000..247aef88 --- /dev/null +++ b/tests/Feature/CustomerSubLicenseManagementTest.php @@ -0,0 +1,354 @@ +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_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']); + } +} diff --git a/tests/Feature/MobileRouteTest.php b/tests/Feature/MobileRouteTest.php index d04a858b..06b51279 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() { diff --git a/tests/Feature/StripePurchaseHandlingTest.php b/tests/Feature/StripePurchaseHandlingTest.php index d54b5b03..0efe6b11 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 5773b0ce..00000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -}