+ Enter your email address and we'll send you a link to reset your password.
+
+
+
+ @if (session('status'))
+
+ {{ session('status') }}
+
+ @endif
+
+
+
+
+
\ 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 @@
+
+
+
\ 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 @@
+
+
+ You don't have any licenses yet. If you believe this is an error, please contact support.
+
+
+
+ @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
new file mode 100644
index 000000000..4fd6bcb90
--- /dev/null
+++ b/resources/views/customer/licenses/show.blade.php
@@ -0,0 +1,583 @@
+
+
+ @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
+
+ 🎉 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!
+
+ @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
+
+
\ 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->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
+
+
+
+
+
+
+
+
+ You'll be redirected to Stripe to complete your subscription setup.
+
-
\ 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 @@
-
- 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)
+
+ @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
+