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:
idevakk
2025-12-06 02:01:17 -08:00
parent ebb041c0cc
commit 5ee5c5b8dc
5 changed files with 447 additions and 5 deletions

View 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');
}
}

View File

@@ -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) {

View File

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

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

View File

@@ -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');
});