diff --git a/app/Actions/Billing/BuyCreditPackAction.php b/app/Actions/Billing/BuyCreditPackAction.php new file mode 100644 index 0000000..3d938cd --- /dev/null +++ b/app/Actions/Billing/BuyCreditPackAction.php @@ -0,0 +1,35 @@ +packRepository->find($packKey); + + if ($pack === null) { + throw ValidationException::withMessages(['pack' => 'This credit pack is no longer available.']); + } + + return $this->paymentService->createCreditPackCheckout( + user: $user, + pack: $pack, + successUrl: $successUrl, + cancelUrl: $cancelUrl, + ); + } +} diff --git a/app/Actions/Billing/StartSubscriptionCheckoutAction.php b/app/Actions/Billing/StartSubscriptionCheckoutAction.php new file mode 100644 index 0000000..636720a --- /dev/null +++ b/app/Actions/Billing/StartSubscriptionCheckoutAction.php @@ -0,0 +1,46 @@ + 'Invalid plan selected.']); + } + + if ($plan === Plan::Free) { + throw ValidationException::withMessages(['plan' => 'Free plan does not require checkout.']); + } + + if ($user->plan === $plan) { + throw ValidationException::withMessages(['plan' => 'You are already on this plan.']); + } + + $billingPlan = $this->planRepository->findOrFail($planKey); + + return $this->paymentService->createSubscriptionCheckout( + user: $user, + plan: $billingPlan, + successUrl: $successUrl, + cancelUrl: $cancelUrl, + ); + } +} diff --git a/app/Livewire/Billing/BillingPage.php b/app/Livewire/Billing/BillingPage.php new file mode 100644 index 0000000..41e0a36 --- /dev/null +++ b/app/Livewire/Billing/BillingPage.php @@ -0,0 +1,124 @@ +query('checkout'); + + if (in_array($status, ['success', 'cancelled'], true)) { + $this->checkoutStatus = $status; + } + } + + public function getAuthUserProperty(): User + { + /** @var User */ + return auth()->user(); + } + + public function getCreditsBalanceProperty(): int + { + return app(CreditLedger::class)->balance($this->authUser); + } + + public function getPlanLimitsProperty(): PlanLimit + { + return app(FeatureAccessService::class)->limits($this->authUser); + } + + public function getApiAccessProperty(): bool + { + return app(FeatureAccessService::class)->allows($this->authUser, 'api_access'); + } + + public function getBatchConversionProperty(): bool + { + return app(FeatureAccessService::class)->allows($this->authUser, 'batch_conversion'); + } + + /** @return BillingPlanDto[] */ + public function getPlansProperty(): array + { + return app(BillingPlanRepository::class)->all(); + } + + /** @return Collection */ + public function getCreditPacksProperty(): Collection + { + return app(CreditPackRepository::class)->all(); + } + + /** @return LengthAwarePaginator */ + public function getTransactionsProperty() + { + return CreditTransaction::query() + ->where('user_id', $this->authUser->id) + ->latest() + ->paginate(10); + } + + public function startSubscriptionCheckout(string $planKey): void + { + $session = app(StartSubscriptionCheckoutAction::class)->handle( + user: $this->authUser, + planKey: $planKey, + successUrl: route('billing', ['checkout' => 'success']), + cancelUrl: route('billing', ['checkout' => 'cancelled']), + ); + + $this->redirect($session->url, navigate: false); + } + + public function buyCreditPack(string $packKey): void + { + $session = app(BuyCreditPackAction::class)->handle( + user: $this->authUser, + packKey: $packKey, + successUrl: route('billing', ['checkout' => 'success']), + cancelUrl: route('billing', ['checkout' => 'cancelled']), + ); + + $this->redirect($session->url, navigate: false); + } + + public function openCustomerPortal(): void + { + throw ValidationException::withMessages(['portal' => 'Billing portal will be available after payment provider setup.']); + } + + public function render(): View + { + return view('livewire.billing.billing-page'); + } +} diff --git a/resources/views/livewire/billing/billing-page.blade.php b/resources/views/livewire/billing/billing-page.blade.php new file mode 100644 index 0000000..0b0056a --- /dev/null +++ b/resources/views/livewire/billing/billing-page.blade.php @@ -0,0 +1,246 @@ +
+ {{-- Page header --}} +
+

Billing

+

Manage your plan, credits, and payment details.

+
+ + {{-- Checkout result notices --}} + @if($checkoutStatus === 'success') +
+ Payment received. Your plan or credits will update after payment confirmation. +
+ @elseif($checkoutStatus === 'cancelled') +
+ Checkout was cancelled. No changes were made. +
+ @endif + + {{-- Validation errors --}} + @if($errors->any()) +
+ @foreach($errors->all() as $error) +

{{ $error }}

+ @endforeach +
+ @endif + + {{-- Summary row --}} +
+ + {{-- Current plan card --}} + +

Current plan

+
+ + {{ $this->authUser->plan->name }} + + + {{ ucfirst($this->authUser->plan->value) }} + +
+
+ + {{-- Credits balance card --}} + +

Credits balance

+
+ + {{ number_format($this->creditsBalance) }} + + credits +
+
+ + {{-- Plan limits card --}} + +

Plan limits

+ @php $limits = $this->planLimits; @endphp +
    +
  • + Max file size + {{ $limits->maxFileSizeMb }} MB +
  • +
  • + Storage + {{ number_format($limits->storageMb) }} MB +
  • +
  • + Retention + {{ $limits->retentionDays }} {{ Str::plural('day', $limits->retentionDays) }} +
  • +
  • + API access + @if($this->apiAccess) + Enabled + @else + Disabled + @endif +
  • +
  • + Batch conversion + @if($this->batchConversion) + Enabled + @else + Disabled + @endif +
  • +
+
+ +
+ + {{-- Credit packs section --}} +
+

Buy credits

+

Top up your credits balance with a one-time purchase.

+ +
+ @foreach($this->creditPacks as $pack) + +

{{ $pack->label }}

+

{{ $pack->description }}

+
+ + Buy {{ $pack->label }} + +
+
+ @endforeach +
+
+ + {{-- Available plans section --}} +
+

Available plans

+

Choose the plan that fits your needs.

+ +
+ @foreach($this->plans as $plan) + @php $isCurrent = $this->authUser->plan->value === $plan->key; @endphp + +
+
+

{{ $plan->label }}

+

{{ number_format($plan->monthlyCredits) }} credits / month

+
+ @if($isCurrent) + Current plan + @endif +
+
+ @if($isCurrent) + Current plan + @elseif(!$plan->isPaid) + Free + @else + + Upgrade to {{ $plan->label }} + + @endif +
+
+ @endforeach +
+
+ + {{-- Credit history section --}} +
+

Credit history

+

Your credit grants and conversion charges.

+ + @php + $transactions = $this->transactions; + $typeLabels = [ + 'registration_grant' => 'Registration grant', + 'subscription_monthly_grant' => 'Monthly subscription credits', + 'credit_pack_purchase' => 'Credit pack purchase', + 'conversion_completed' => 'Conversion completed', + 'conversion_refund' => 'Conversion refund', + ]; + @endphp + +
+ @if($transactions->isEmpty()) + +
+

No credit transactions yet

+

Your credit grants and conversion charges will appear here.

+
+
+ @else + + + + + + + + + + + + + @foreach($transactions as $tx) + @php + $isPositive = $tx->amount > 0; + $typeVariant = match($tx->type->value) { + 'grant', 'purchase', 'refund' => 'success', + 'spend', 'expiration' => 'warning', + default => 'neutral', + }; + $label = $typeLabels[$tx->reason] ?? $tx->reason; + @endphp + + + + + + + + @endforeach + +
DateTypeReasonAmountBalance after
{{ $tx->created_at->format('M j, Y') }} + {{ ucfirst($tx->type->value) }} + {{ $label }} + {{ $isPositive ? '+' : '' }}{{ $tx->amount }} + {{ number_format($tx->balance_after) }}
+ @if($transactions->hasPages()) +
+ {{ $transactions->links() }} +
+ @endif +
+ @endif +
+
+ + {{-- Manage billing section --}} +
+

Manage billing

+

Payment method, invoices, and subscription details.

+ + +

+ Payment method, invoices, and subscription details are handled securely by our payment provider. +

+
+ + Invoices & payment portal + +
+ @error('portal') +

{{ $message }}

+ @enderror +
+
+ +
diff --git a/routes/web.php b/routes/web.php index 248654a..7351fe3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Billing\StartSubscriptionCheckoutController; use App\Http\Controllers\DownloadConversionResultController; use App\Http\Controllers\ProfileController; +use App\Livewire\Billing\BillingPage; use Illuminate\Support\Facades\Route; Route::post('/stripe/webhook', [CashierWebhookController::class, 'handleWebhook']) @@ -21,6 +22,8 @@ Route::view('/ui-kit', 'ui-kit')->name('ui-kit'); Route::middleware('auth')->group(function () { + Route::get('/billing', BillingPage::class)->name('billing'); + Route::post('/billing/checkout/{plan}', StartSubscriptionCheckoutController::class) ->name('billing.checkout'); diff --git a/tests/Feature/Billing/BillingCheckoutNoticeTest.php b/tests/Feature/Billing/BillingCheckoutNoticeTest.php new file mode 100644 index 0000000..4a2429e --- /dev/null +++ b/tests/Feature/Billing/BillingCheckoutNoticeTest.php @@ -0,0 +1,33 @@ +create(); + + $this->actingAs($user) + ->get('/billing?checkout=success') + ->assertOk() + ->assertSee('Payment received'); +}); + +it('shows billing cancelled notice', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/billing?checkout=cancelled') + ->assertOk() + ->assertSee('Checkout was cancelled'); +}); + +it('ignores unknown checkout query value', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/billing?checkout=unknown') + ->assertOk() + ->assertDontSee('Payment received') + ->assertDontSee('Checkout was cancelled'); +}); diff --git a/tests/Feature/Billing/BillingPageRouteTest.php b/tests/Feature/Billing/BillingPageRouteTest.php new file mode 100644 index 0000000..e196f91 --- /dev/null +++ b/tests/Feature/Billing/BillingPageRouteTest.php @@ -0,0 +1,19 @@ +get('/billing') + ->assertRedirect('/login'); +}); + +it('allows authenticated user to access billing page', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/billing') + ->assertOk() + ->assertSee('Billing'); +}); diff --git a/tests/Feature/Livewire/Billing/BillingPageCheckoutTest.php b/tests/Feature/Livewire/Billing/BillingPageCheckoutTest.php new file mode 100644 index 0000000..30d0660 --- /dev/null +++ b/tests/Feature/Livewire/Billing/BillingPageCheckoutTest.php @@ -0,0 +1,47 @@ +bind(SubscriptionCheckoutGateway::class, FakeSubscriptionCheckoutGateway::class); +}); + +it('starts subscription checkout for paid plan', function () { + $user = User::factory()->create([ + 'plan' => Plan::Free, + ]); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->call('startSubscriptionCheckout', 'pro') + ->assertRedirect('https://checkout.stripe.test/fake-session'); +}); + +it('rejects checkout for free plan', function () { + $user = User::factory()->create([ + 'plan' => Plan::Free, + ]); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->call('startSubscriptionCheckout', 'free') + ->assertHasErrors(['plan']); +}); + +it('rejects checkout for current plan', function () { + $user = User::factory()->create([ + 'plan' => Plan::Pro, + ]); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->call('startSubscriptionCheckout', 'pro') + ->assertHasErrors(['plan']); +}); diff --git a/tests/Feature/Livewire/Billing/BillingPageCreditHistoryTest.php b/tests/Feature/Livewire/Billing/BillingPageCreditHistoryTest.php new file mode 100644 index 0000000..d97a48e --- /dev/null +++ b/tests/Feature/Livewire/Billing/BillingPageCreditHistoryTest.php @@ -0,0 +1,48 @@ +create(); + + app(CreditLedger::class)->grant($user, 50, 'registration_grant'); + app(CreditLedger::class)->spend($user, 2, 'conversion_completed'); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Credit history') + ->assertSee('Registration grant') + ->assertSee('Conversion completed') + ->assertSee('+50') + ->assertSee('-2'); +}); + +it('shows empty state when user has no credit transactions', function () { + $user = User::factory()->create([ + 'plan' => Plan::Free, + ]); + + CreditTransaction::where('user_id', $user->id)->delete(); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('No credit transactions yet'); +}); + +it('does not show another users credit transactions', function () { + $user = User::factory()->create(); + $other = User::factory()->create(); + + app(CreditLedger::class)->grant($other, 999, 'other_user_grant'); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertDontSee('other_user_grant'); +}); diff --git a/tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php b/tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php new file mode 100644 index 0000000..16a6d5d --- /dev/null +++ b/tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php @@ -0,0 +1,40 @@ +create(); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Buy credits') + ->assertSee('500 Credits') + ->assertSee('2,000 Credits'); +}); + +it('starts credit pack checkout', function () { + app()->bind(CreditPackCheckoutGateway::class, FakeCreditPackCheckoutGateway::class); + config()->set('billing.credit_packs.small.stripe_price_id', 'price_small_test'); + + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->call('buyCreditPack', 'small') + ->assertRedirect('https://checkout.stripe.test/fake-credit-pack-session'); +}); + +it('rejects invalid credit pack', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->call('buyCreditPack', 'invalid-pack') + ->assertHasErrors(['pack']); +}); diff --git a/tests/Feature/Livewire/Billing/BillingPageErrorStateTest.php b/tests/Feature/Livewire/Billing/BillingPageErrorStateTest.php new file mode 100644 index 0000000..2dead21 --- /dev/null +++ b/tests/Feature/Livewire/Billing/BillingPageErrorStateTest.php @@ -0,0 +1,43 @@ +create(); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->call('startSubscriptionCheckout', 'invalid-plan') + ->assertHasErrors(['plan']); +}); + +it('shows validation error for invalid credit pack', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->call('buyCreditPack', 'invalid-pack-xyz') + ->assertHasErrors(['pack']); +}); + +it('shows manage billing section', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Manage billing') + ->assertSee('Invoices'); +}); + +it('shows portal placeholder when portal is not configured', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->call('openCustomerPortal') + ->assertHasErrors(['portal']); +}); diff --git a/tests/Feature/Livewire/Billing/BillingPageSmokeTest.php b/tests/Feature/Livewire/Billing/BillingPageSmokeTest.php new file mode 100644 index 0000000..131a387 --- /dev/null +++ b/tests/Feature/Livewire/Billing/BillingPageSmokeTest.php @@ -0,0 +1,44 @@ +create([ + 'plan' => Plan::Free, + ]); + + app(CreditLedger::class)->grant($user, 50, 'registration_grant'); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Billing') + ->assertSee('Current plan') + ->assertSee('Credits balance') + ->assertSee('Plan limits') + ->assertSee('Available plans') + ->assertSee('Buy credits') + ->assertSee('Credit history') + ->assertSee('Manage billing'); +}); + +it('guest cannot access billing page', function () { + $this->get('/billing') + ->assertRedirect('/login'); +}); + +it('does not show another users credit transactions', function () { + $user = User::factory()->create(); + $other = User::factory()->create(); + + app(CreditLedger::class)->grant($other, 999, 'other_user_grant'); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertDontSee('other_user_grant'); +}); diff --git a/tests/Feature/Livewire/Billing/BillingPageTest.php b/tests/Feature/Livewire/Billing/BillingPageTest.php new file mode 100644 index 0000000..0e3f5a8 --- /dev/null +++ b/tests/Feature/Livewire/Billing/BillingPageTest.php @@ -0,0 +1,120 @@ +create(); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Billing'); +}); + +it('shows current user plan on billing page', function () { + $user = User::factory()->create([ + 'plan' => Plan::Pro, + ]); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Current plan') + ->assertSee('Pro'); +}); + +it('shows free plan for free user on billing page', function () { + $user = User::factory()->create([ + 'plan' => Plan::Free, + ]); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Current plan') + ->assertSee('Free'); +}); + +it('shows current credits balance on billing page', function () { + $user = User::factory()->create(); + + app(CreditLedger::class)->grant( + user: $user, + amount: 500, + reason: 'test_grant', + ); + + $balance = app(CreditLedger::class)->balance($user); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Credits balance') + ->assertSee((string) $balance); +}); + +it('shows zero credits balance when user has no credits', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Credits balance') + ->assertSee('0'); +}); + +it('shows current plan limits on billing page', function () { + $user = User::factory()->create([ + 'plan' => Plan::Free, + ]); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Plan limits') + ->assertSee('Max file size') + ->assertSee('API access'); +}); + +it('shows correct free plan limit values', function () { + $user = User::factory()->create([ + 'plan' => Plan::Free, + ]); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('25 MB') + ->assertSee('Disabled'); +}); + +it('shows available billing plans', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Available plans') + ->assertSee('Free') + ->assertSee('Pro') + ->assertSee('Max'); +}); + +it('highlights the current plan', function () { + $user = User::factory()->create([ + 'plan' => Plan::Pro, + ]); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Current plan') + ->assertSee('Pro'); +}); + +it('does not show upgrade button for current plan', function () { + $user = User::factory()->create([ + 'plan' => Plan::Pro, + ]); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertDontSee('Upgrade to Pro'); +});