feat(billing): implement Polar customer portal integration
- Add comprehensive billing page with current subscription display and transaction history - Integrate Polar.sh customer portal for subscription management - Fix Polar API endpoint from /customer-portal to /customer-sessions - Use Polar's direct customer_portal_url response for seamless redirect - Add responsive button layout with cursor-pointer styling - Implement human-readable timestamps using diffForHumans() - Add subscription sync functionality with 30-minute recheck window - Include subscription cancellation with modal confirmation - Support activation key provider with pending activation display - Add proper error handling and user feedback messages
This commit is contained in:
156
app/Livewire/Settings/Billing.php
Normal file
156
app/Livewire/Settings/Billing.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Payments\PaymentOrchestrator;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('components.layouts.dashboard')]
|
||||
class Billing extends Component
|
||||
{
|
||||
public $subscriptions;
|
||||
|
||||
public $showCancelModal = false;
|
||||
|
||||
public $subscriptionToCancel = null;
|
||||
|
||||
public $cancellationReason = '';
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->loadBillingData();
|
||||
}
|
||||
|
||||
public function loadBillingData()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Get user's subscriptions
|
||||
$this->subscriptions = $user->subscriptions()
|
||||
->with('plan')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function managePolarSubscription()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
try {
|
||||
// Check if user has polar_cust_id
|
||||
if (! $user->polar_cust_id) {
|
||||
$this->dispatch('error', 'No Polar customer account found. Please create a subscription first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$paymentOrchestrator = app(PaymentOrchestrator::class);
|
||||
$activeProviders = $paymentOrchestrator->getActiveProviders();
|
||||
$polarProvider = $activeProviders->first(function ($provider) {
|
||||
return $provider->getName() === 'polar';
|
||||
});
|
||||
|
||||
if (! $polarProvider || ! $polarProvider->isActive()) {
|
||||
$this->dispatch('error', 'Polar payment provider is not available.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Create customer portal session using user's polar_cust_id
|
||||
$portalSession = $polarProvider->createCustomerPortalSession($user);
|
||||
|
||||
if (isset($portalSession['portal_url'])) {
|
||||
return redirect()->away($portalSession['portal_url']);
|
||||
}
|
||||
|
||||
$this->dispatch('error', 'Unable to access Polar billing portal.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to create Polar portal session', [
|
||||
'user_id' => $user->id,
|
||||
'polar_cust_id' => $user->polar_cust_id ?? 'none',
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->dispatch('error', 'Failed to access Polar billing portal. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmCancelSubscription(Subscription $subscription)
|
||||
{
|
||||
// Verify ownership
|
||||
if ($subscription->user_id !== Auth::id()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$this->subscriptionToCancel = $subscription;
|
||||
$this->showCancelModal = true;
|
||||
}
|
||||
|
||||
public function cancelSubscription()
|
||||
{
|
||||
if (! $this->subscriptionToCancel) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$reason = $this->cancellationReason ?: 'User requested cancellation via billing portal';
|
||||
$success = $this->subscriptionToCancel->cancel($reason);
|
||||
|
||||
if ($success) {
|
||||
$this->dispatch('success', 'Subscription cancelled successfully.');
|
||||
$this->loadBillingData(); // Refresh data
|
||||
} else {
|
||||
$this->dispatch('error', 'Failed to cancel subscription. Please try again.');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to cancel subscription', [
|
||||
'subscription_id' => $this->subscriptionToCancel->id,
|
||||
'user_id' => Auth::id(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->dispatch('error', 'Failed to cancel subscription. Please try again.');
|
||||
}
|
||||
|
||||
$this->reset(['showCancelModal', 'subscriptionToCancel', 'cancellationReason']);
|
||||
}
|
||||
|
||||
public function syncSubscription(Subscription $subscription)
|
||||
{
|
||||
// Verify ownership
|
||||
if ($subscription->user_id !== Auth::id()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
$success = $subscription->syncWithProvider();
|
||||
|
||||
if ($success) {
|
||||
$this->dispatch('success', 'Subscription synced successfully.');
|
||||
$this->loadBillingData(); // Refresh data
|
||||
} else {
|
||||
$this->dispatch('error', 'Failed to sync subscription. Please try again.');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to sync subscription', [
|
||||
'subscription_id' => $subscription->id,
|
||||
'user_id' => Auth::id(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->dispatch('error', 'Failed to sync subscription. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.billing');
|
||||
}
|
||||
}
|
||||
@@ -257,7 +257,7 @@ class PolarProvider implements PaymentProviderContract
|
||||
$subscription = Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'type' => 'recurring',
|
||||
'type' => 'default',
|
||||
'stripe_id' => $checkout['id'], // Using stripe_id field for Polar checkout ID
|
||||
'stripe_status' => 'pending',
|
||||
'provider' => $this->getName(),
|
||||
@@ -529,20 +529,38 @@ class PolarProvider implements PaymentProviderContract
|
||||
try {
|
||||
$customer = $this->getOrCreateCustomer($user);
|
||||
|
||||
$response = $this->makeAuthenticatedRequest('POST', '/customer-portal', [
|
||||
// Create customer session using correct Polar API endpoint
|
||||
$response = $this->makeAuthenticatedRequest('POST', '/customer-sessions', [
|
||||
'customer_id' => $customer['id'],
|
||||
'return_url' => route('dashboard'),
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Polar customer portal creation failed: '.$response->body());
|
||||
Log::error('Polar customer session creation failed: '.$response->body());
|
||||
throw new \Exception('Failed to create customer session');
|
||||
}
|
||||
|
||||
$portal = $response->json();
|
||||
$session = $response->json();
|
||||
|
||||
// Polar provides a direct customer_portal_url in the response
|
||||
if (! isset($session['customer_portal_url'])) {
|
||||
Log::error('Invalid Polar customer session response', [
|
||||
'response' => $session,
|
||||
]);
|
||||
throw new \Exception('Invalid customer session response - missing portal URL');
|
||||
}
|
||||
|
||||
Log::info('Polar customer portal session created successfully', [
|
||||
'user_id' => $user->id,
|
||||
'customer_id' => $customer['id'],
|
||||
'portal_url' => $session['customer_portal_url'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'portal_url' => $portal['url'],
|
||||
'portal_url' => $session['customer_portal_url'],
|
||||
'customer_id' => $customer['id'],
|
||||
'session_token' => $session['token'] ?? null,
|
||||
'expires_at' => $session['expires_at'] ?? null,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="me-10 w-full pb-4 md:w-[220px]">
|
||||
<flux:navlist>
|
||||
<flux:navlist.item :href="route('settings.profile')" wire:navigate>{{ __('Profile') }}</flux:navlist.item>
|
||||
<flux:navlist.item :href="route('settings.billing')" wire:navigate>{{ __('Billing') }}</flux:navlist.item>
|
||||
<flux:navlist.item :href="route('settings.password')" wire:navigate>{{ __('Password') }}</flux:navlist.item>
|
||||
<flux:navlist.item :href="route('settings.appearance')" wire:navigate>{{ __('Appearance') }}</flux:navlist.item>
|
||||
</flux:navlist>
|
||||
|
||||
265
resources/views/livewire/settings/billing.blade.php
Normal file
265
resources/views/livewire/settings/billing.blade.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<section class="w-full">
|
||||
@include('partials.settings-heading')
|
||||
|
||||
<x-settings.layout :heading="__('Billing & Subscription')" :subheading="__('Manage your subscriptions and payment methods')">
|
||||
<!-- Success/Error Messages -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
Livewire.on('success', function(message) {
|
||||
// Show success message
|
||||
const successDiv = document.createElement('div');
|
||||
successDiv.className = 'mb-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md';
|
||||
successDiv.innerHTML = '<div class="text-green-800 dark:text-green-200 text-sm">' + message + '</div>';
|
||||
// Insert at the beginning of the content area
|
||||
const contentArea = document.querySelector('.max-w-lg').parentElement;
|
||||
contentArea.insertBefore(successDiv, contentArea.firstChild);
|
||||
|
||||
// Remove after 5 seconds
|
||||
setTimeout(() => successDiv.remove(), 5000);
|
||||
});
|
||||
|
||||
Livewire.on('error', function(message) {
|
||||
// Show error message
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md';
|
||||
errorDiv.innerHTML = '<div class="text-red-800 dark:text-red-200 text-sm">' + message + '</div>';
|
||||
// Insert at the beginning of the content area
|
||||
const contentArea = document.querySelector('.max-w-lg').parentElement;
|
||||
contentArea.insertBefore(errorDiv, contentArea.firstChild);
|
||||
|
||||
// Remove after 5 seconds
|
||||
setTimeout(() => errorDiv.remove(), 5000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Current Subscription -->
|
||||
<div class="space-y-6">
|
||||
<flux:heading level="2">{{ __('Current Subscription') }}</flux:heading>
|
||||
|
||||
@php
|
||||
$latestActiveSubscription = $subscriptions->first(function($sub) {
|
||||
return $sub->isActive();
|
||||
});
|
||||
|
||||
$latestSubscription = $subscriptions->first();
|
||||
$isWithin30Minutes = $latestSubscription &&
|
||||
$latestSubscription->created_at->diffInMinutes(now()) <= 30;
|
||||
@endphp
|
||||
|
||||
@if($latestActiveSubscription)
|
||||
<div class="border rounded-lg p-6 dark:border-gray-700">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:heading level="3">{{ $latestActiveSubscription->plan->name }}</flux:heading>
|
||||
<flux:badge variant="success">
|
||||
{{ ucfirst($latestActiveSubscription->status) }}
|
||||
</flux:badge>
|
||||
<flux:badge variant="neutral">
|
||||
{{ $latestActiveSubscription->getProviderDisplayName() }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
|
||||
<flux:text class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
{{ $latestActiveSubscription->plan->description ?? 'Subscription plan' }}
|
||||
</flux:text>
|
||||
|
||||
@if($latestActiveSubscription->isActive())
|
||||
<flux:text class="mt-1 text-sm text-gray-500 dark:text-gray-500">
|
||||
@if($latestActiveSubscription->ends_at)
|
||||
{{ __('Renews on :date', ['date' => $latestActiveSubscription->ends_at->format('M j, Y')]) }}
|
||||
@else
|
||||
{{ __('Active subscription') }}
|
||||
@endif
|
||||
</flux:text>
|
||||
@endif
|
||||
|
||||
@if($latestActiveSubscription->provider === 'activation_key' && $latestActiveSubscription->status === 'pending_activation')
|
||||
<div class="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-md">
|
||||
<flux:text class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{{ __('Activation Key:') }}
|
||||
<code class="bg-yellow-100 dark:bg-yellow-800 px-2 py-1 rounded text-xs">
|
||||
{{ $latestActiveSubscription->getProviderData('activation_key') }}
|
||||
</code>
|
||||
</flux:text>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col sm:flex-row sm:items-center sm:gap-2 space-y-2 sm:space-y-0">
|
||||
@if($latestActiveSubscription->provider === 'polar' && $latestActiveSubscription->isActive())
|
||||
<flux:button
|
||||
wire:click="managePolarSubscription"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full sm:w-auto cursor-pointer"
|
||||
>
|
||||
{{ __('Manage in Polar') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
|
||||
@if(in_array($latestActiveSubscription->provider, ['polar']) && $latestActiveSubscription->isActive())
|
||||
<flux:button
|
||||
wire:click="syncSubscription({{ $latestActiveSubscription->id }})"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
wire:loading.attr="disabled"
|
||||
class="w-full sm:w-auto cursor-pointer"
|
||||
>
|
||||
<span wire:loading.remove>{{ __('Sync') }}</span>
|
||||
<span wire:loading>{{ __('Syncing...') }}</span>
|
||||
</flux:button>
|
||||
@endif
|
||||
|
||||
@if($latestActiveSubscription->isActive() && $latestActiveSubscription->provider !== 'activation_key')
|
||||
<flux:button
|
||||
wire:click="confirmCancelSubscription({{ $latestActiveSubscription->id }})"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
class="w-full sm:w-auto cursor-pointer"
|
||||
>
|
||||
{{ __('Cancel') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-12 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<flux:text class="text-gray-500 dark:text-gray-400 mb-6">
|
||||
{{ __('You have no active subscriptions.') }}
|
||||
</flux:text>
|
||||
<flux:button variant="primary" href="{{ route('dashboard') }}" class="cursor-pointer">
|
||||
{{ __('Choose a plan') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Transaction History -->
|
||||
<div class="mt-12 space-y-6">
|
||||
<flux:heading level="2">{{ __('Transaction History') }}</flux:heading>
|
||||
|
||||
@if($subscriptions->count() > 0)
|
||||
<div class="space-y-4">
|
||||
@foreach($subscriptions as $subscription)
|
||||
<div class="border rounded-lg p-4 dark:border-gray-700">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:heading level="4">{{ $subscription->plan->name }}</flux:heading>
|
||||
<flux:badge
|
||||
variant="{{ $subscription->isActive() ? 'success' : ($subscription->isCancelled() ? 'danger' : 'warning') }}"
|
||||
>
|
||||
{{ ucfirst($subscription->status) }}
|
||||
</flux:badge>
|
||||
<flux:badge variant="neutral">
|
||||
{{ $subscription->getProviderDisplayName() }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div>{{ __('Created:') }} {{ $subscription->created_at->diffForHumans() }}</div>
|
||||
@if($subscription->starts_at)
|
||||
<div>{{ __('Started:') }} {{ $subscription->starts_at->diffForHumans() }}</div>
|
||||
@endif
|
||||
@if($subscription->ends_at)
|
||||
<div>{{ __('Ends:') }} {{ $subscription->ends_at->diffForHumans() }}</div>
|
||||
@endif
|
||||
@if($subscription->cancelled_at)
|
||||
<div>{{ __('Cancelled:') }} {{ $subscription->cancelled_at->diffForHumans() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($subscription->provider === 'activation_key' && $subscription->status === 'pending_activation')
|
||||
<div class="mt-2 p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded-md">
|
||||
<flux:text class="text-xs text-yellow-800 dark:text-yellow-200">
|
||||
{{ __('Activation Key:') }}
|
||||
<code class="bg-yellow-100 dark:bg-yellow-800 px-1 py-0.5 rounded text-xs">
|
||||
{{ $subscription->getProviderData('activation_key') }}
|
||||
</code>
|
||||
</flux:text>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<flux:text class="text-lg font-semibold">
|
||||
${{ number_format($subscription->plan->price, 2) }}
|
||||
</flux:text>
|
||||
|
||||
@if($isWithin30Minutes && $subscription->id === $latestSubscription->id)
|
||||
<div class="mt-2">
|
||||
<flux:button
|
||||
wire:click="syncSubscription({{ $subscription->id }})"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
wire:loading.attr="disabled"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<span wire:loading.remove>{{ __('Recheck Status') }}</span>
|
||||
<span wire:loading>{{ __('Checking...') }}</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<flux:text class="text-gray-500 dark:text-gray-400">
|
||||
{{ __('No subscription history found.') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Cancel Subscription Modal -->
|
||||
<flux:modal name="cancel-subscription" wire:model="showCancelModal">
|
||||
<div class="space-y-4">
|
||||
<flux:heading level="3">{{ __('Cancel Subscription') }}</flux:heading>
|
||||
|
||||
<flux:text>
|
||||
{{ __('Are you sure you want to cancel your subscription? This action cannot be undone.') }}
|
||||
</flux:text>
|
||||
|
||||
@if($subscriptionToCancel)
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-md">
|
||||
<flux:text class="font-medium">{{ $subscriptionToCancel->plan->name }}</flux:text>
|
||||
<flux:text class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $subscriptionToCancel->getProviderDisplayName() }}
|
||||
</flux:text>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:input
|
||||
wire:model="cancellationReason"
|
||||
label="{{ __('Reason (optional)') }}"
|
||||
placeholder="{{ __('Why are you cancelling?') }}"
|
||||
/>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<flux:button
|
||||
variant="outline"
|
||||
wire:click="$set('showCancelModal', false)"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
{{ __('Nevermind') }}
|
||||
</flux:button>
|
||||
<flux:button
|
||||
variant="danger"
|
||||
wire:click="cancelSubscription"
|
||||
wire:loading.attr="disabled"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
<span wire:loading.remove>{{ __('Cancel Subscription') }}</span>
|
||||
<span wire:loading>{{ __('Cancelling...') }}</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</x-settings.layout>
|
||||
</section>
|
||||
@@ -39,6 +39,7 @@ use App\Livewire\Home;
|
||||
use App\Livewire\ListBlog;
|
||||
use App\Livewire\Page;
|
||||
use App\Livewire\Settings\Appearance;
|
||||
use App\Livewire\Settings\Billing;
|
||||
use App\Livewire\Settings\Password;
|
||||
use App\Livewire\Settings\Profile;
|
||||
use App\Models\Email;
|
||||
@@ -174,6 +175,7 @@ Route::middleware(['auth'])->group(function (): void {
|
||||
Route::redirect('settings', 'settings/profile');
|
||||
|
||||
Route::get('settings/profile', Profile::class)->name('settings.profile');
|
||||
Route::get('settings/billing', Billing::class)->name('settings.billing');
|
||||
Route::get('settings/password', Password::class)->name('settings.password');
|
||||
Route::get('settings/appearance', Appearance::class)->name('settings.appearance');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user