From 9ad015eb81587801113472db15a908ef8c690d08 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 14:50:44 +0300 Subject: [PATCH 01/15] CONV-274: Create Billing page route Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Billing/BillingPage.php | 20 +++++++++++++++++++ .../livewire/billing/billing-page.blade.php | 6 ++++++ routes/web.php | 3 +++ .../Feature/Billing/BillingPageRouteTest.php | 19 ++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 app/Livewire/Billing/BillingPage.php create mode 100644 resources/views/livewire/billing/billing-page.blade.php create mode 100644 tests/Feature/Billing/BillingPageRouteTest.php diff --git a/app/Livewire/Billing/BillingPage.php b/app/Livewire/Billing/BillingPage.php new file mode 100644 index 0000000..f7f3087 --- /dev/null +++ b/app/Livewire/Billing/BillingPage.php @@ -0,0 +1,20 @@ + +
+

Billing

+

Manage your plan, credits, and payment details.

+
+ 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/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'); +}); From 17bc30e712dccd1e7d4b554737b17de709708308 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 14:55:55 +0300 Subject: [PATCH 02/15] CONV-275: Create Billing page Livewire component Co-Authored-By: Claude Sonnet 4.6 --- .../Feature/Livewire/Billing/BillingPageTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/Feature/Livewire/Billing/BillingPageTest.php diff --git a/tests/Feature/Livewire/Billing/BillingPageTest.php b/tests/Feature/Livewire/Billing/BillingPageTest.php new file mode 100644 index 0000000..4283992 --- /dev/null +++ b/tests/Feature/Livewire/Billing/BillingPageTest.php @@ -0,0 +1,15 @@ +create(); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Billing'); +}); From 9051e77cb67b8ce1b709e2ca2ab10c551e1bde4e Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 14:56:49 +0300 Subject: [PATCH 03/15] CONV-276: Add current plan summary card Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Billing/BillingPage.php | 28 +++++++++++++++++++ .../livewire/billing/billing-page.blade.php | 19 +++++++++++++ .../Livewire/Billing/BillingPageTest.php | 22 +++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/app/Livewire/Billing/BillingPage.php b/app/Livewire/Billing/BillingPage.php index f7f3087..28cb6cb 100644 --- a/app/Livewire/Billing/BillingPage.php +++ b/app/Livewire/Billing/BillingPage.php @@ -4,6 +4,12 @@ namespace App\Livewire\Billing; +use App\Billing\BillingPlanDto; +use App\Billing\BillingPlanRepository; +use App\Contracts\Billing\CreditLedger; +use App\Models\User; +use App\Services\FeatureAccess\FeatureAccessService; +use App\Services\FeatureAccess\PlanLimit; use Illuminate\Contracts\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -13,6 +19,28 @@ #[Title('Billing — ConvertAI')] class BillingPage extends Component { + 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); + } + + /** @return BillingPlanDto[] */ + public function getPlansProperty(): array + { + return app(BillingPlanRepository::class)->all(); + } + 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 index b68be48..bfb7a1b 100644 --- a/resources/views/livewire/billing/billing-page.blade.php +++ b/resources/views/livewire/billing/billing-page.blade.php @@ -1,6 +1,25 @@
+ {{-- Page header --}}

Billing

Manage your plan, credits, and payment details.

+ + {{-- Summary row --}} +
+ + {{-- Current plan card --}} + +

Current plan

+
+ + {{ $this->authUser->plan->name }} + + + {{ ucfirst($this->authUser->plan->value) }} + +
+
+ +
diff --git a/tests/Feature/Livewire/Billing/BillingPageTest.php b/tests/Feature/Livewire/Billing/BillingPageTest.php index 4283992..7fc44fc 100644 --- a/tests/Feature/Livewire/Billing/BillingPageTest.php +++ b/tests/Feature/Livewire/Billing/BillingPageTest.php @@ -13,3 +13,25 @@ ->test(BillingPage::class) ->assertSee('Billing'); }); + +it('shows current user plan on billing page', function () { + $user = User::factory()->create([ + 'plan' => \App\Enums\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' => \App\Enums\Plan::Free, + ]); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('Current plan') + ->assertSee('Free'); +}); From f84019103aa8c3dc072eae59a9f8d82ef50f4e1c Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 14:57:40 +0300 Subject: [PATCH 04/15] CONV-277: Add credits balance card Co-Authored-By: Claude Sonnet 4.6 --- .../livewire/billing/billing-page.blade.php | 11 ++++++++ .../Livewire/Billing/BillingPageTest.php | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/resources/views/livewire/billing/billing-page.blade.php b/resources/views/livewire/billing/billing-page.blade.php index bfb7a1b..1742296 100644 --- a/resources/views/livewire/billing/billing-page.blade.php +++ b/resources/views/livewire/billing/billing-page.blade.php @@ -21,5 +21,16 @@ + {{-- Credits balance card --}} + +

Credits balance

+
+ + {{ number_format($this->creditsBalance) }} + + credits +
+
+ diff --git a/tests/Feature/Livewire/Billing/BillingPageTest.php b/tests/Feature/Livewire/Billing/BillingPageTest.php index 7fc44fc..255eabc 100644 --- a/tests/Feature/Livewire/Billing/BillingPageTest.php +++ b/tests/Feature/Livewire/Billing/BillingPageTest.php @@ -35,3 +35,29 @@ ->assertSee('Current plan') ->assertSee('Free'); }); + +it('shows current credits balance on billing page', function () { + $user = User::factory()->create(); + + app(\App\Contracts\Billing\CreditLedger::class)->grant( + user: $user, + amount: 500, + reason: 'test_grant', + ); + + $balance = app(\App\Contracts\Billing\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'); +}); From d6911202d836530ad53c9a863153ad81ce623b11 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 14:58:10 +0300 Subject: [PATCH 05/15] CONV-278: Add plan limits summary card Co-Authored-By: Claude Sonnet 4.6 --- .../livewire/billing/billing-page.blade.php | 36 +++++++++++++++++++ .../Livewire/Billing/BillingPageTest.php | 23 ++++++++++++ 2 files changed, 59 insertions(+) diff --git a/resources/views/livewire/billing/billing-page.blade.php b/resources/views/livewire/billing/billing-page.blade.php index 1742296..6c60610 100644 --- a/resources/views/livewire/billing/billing-page.blade.php +++ b/resources/views/livewire/billing/billing-page.blade.php @@ -32,5 +32,41 @@ + {{-- Plan limits card --}} + +

Plan limits

+ @php $limits = $this->planLimits; $featureAccess = app(\App\Services\FeatureAccess\FeatureAccessService::class); $user = $this->authUser; @endphp +
    +
  • + Max file size + {{ $limits->maxFileSizeMb }} MB +
  • +
  • + Storage + {{ number_format($limits->storageMb) }} MB +
  • +
  • + Retention + {{ $limits->retentionDays }} {{ Str::plural('day', $limits->retentionDays) }} +
  • +
  • + API access + @if($featureAccess->allows($user, 'api_access')) + Enabled + @else + Disabled + @endif +
  • +
  • + Batch conversion + @if($featureAccess->allows($user, 'batch_conversion')) + Enabled + @else + Disabled + @endif +
  • +
+
+ diff --git a/tests/Feature/Livewire/Billing/BillingPageTest.php b/tests/Feature/Livewire/Billing/BillingPageTest.php index 255eabc..ab9c233 100644 --- a/tests/Feature/Livewire/Billing/BillingPageTest.php +++ b/tests/Feature/Livewire/Billing/BillingPageTest.php @@ -61,3 +61,26 @@ ->assertSee('Credits balance') ->assertSee('0'); }); + +it('shows current plan limits on billing page', function () { + $user = User::factory()->create([ + 'plan' => \App\Enums\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' => \App\Enums\Plan::Free, + ]); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertSee('25 MB') + ->assertSee('Disabled'); +}); From bdac9c68752c864ba360c972f31bf874a289580f Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 14:58:39 +0300 Subject: [PATCH 06/15] CONV-279: Add available plans section Co-Authored-By: Claude Sonnet 4.6 --- .../livewire/billing/billing-page.blade.php | 38 +++++++++++++++++++ .../Livewire/Billing/BillingPageTest.php | 11 ++++++ 2 files changed, 49 insertions(+) diff --git a/resources/views/livewire/billing/billing-page.blade.php b/resources/views/livewire/billing/billing-page.blade.php index 6c60610..f5416c4 100644 --- a/resources/views/livewire/billing/billing-page.blade.php +++ b/resources/views/livewire/billing/billing-page.blade.php @@ -69,4 +69,42 @@ + + {{-- Available plans section --}} +
+

Available plans

+

Choose the plan that fits your needs.

+ +
+ @foreach($this->plans as $plan) + +
+
+

{{ $plan->label }}

+

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

+
+ @if($this->authUser->plan->value === $plan->key) + Current plan + @endif +
+
+ @if($this->authUser->plan->value === $plan->key) + Current plan + @elseif(!$plan->isPaid) + Free + @else + + Upgrade to {{ $plan->label }} + + @endif +
+
+ @endforeach +
+
+ diff --git a/tests/Feature/Livewire/Billing/BillingPageTest.php b/tests/Feature/Livewire/Billing/BillingPageTest.php index ab9c233..6e3dba3 100644 --- a/tests/Feature/Livewire/Billing/BillingPageTest.php +++ b/tests/Feature/Livewire/Billing/BillingPageTest.php @@ -84,3 +84,14 @@ ->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'); +}); From b588802521d873c73c200fcc627f23763edaf55c Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 14:58:51 +0300 Subject: [PATCH 07/15] CONV-280: Highlight current plan Co-Authored-By: Claude Sonnet 4.6 --- .../Livewire/Billing/BillingPageTest.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/Feature/Livewire/Billing/BillingPageTest.php b/tests/Feature/Livewire/Billing/BillingPageTest.php index 6e3dba3..1ce73d4 100644 --- a/tests/Feature/Livewire/Billing/BillingPageTest.php +++ b/tests/Feature/Livewire/Billing/BillingPageTest.php @@ -95,3 +95,24 @@ ->assertSee('Pro') ->assertSee('Max'); }); + +it('highlights the current plan', function () { + $user = User::factory()->create([ + 'plan' => \App\Enums\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' => \App\Enums\Plan::Pro, + ]); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->assertDontSee('Upgrade to Pro'); +}); From da8a0e5052d8b781e976ef325c8bb3c72871ca0d Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 14:59:52 +0300 Subject: [PATCH 08/15] CONV-281: Add subscription checkout actions Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Billing/BillingPage.php | 77 +++++++++++++++++++ .../Billing/BillingPageCheckoutTest.php | 47 +++++++++++ 2 files changed, 124 insertions(+) create mode 100644 tests/Feature/Livewire/Billing/BillingPageCheckoutTest.php diff --git a/app/Livewire/Billing/BillingPage.php b/app/Livewire/Billing/BillingPage.php index 28cb6cb..e9fe955 100644 --- a/app/Livewire/Billing/BillingPage.php +++ b/app/Livewire/Billing/BillingPage.php @@ -6,19 +6,30 @@ use App\Billing\BillingPlanDto; use App\Billing\BillingPlanRepository; +use App\Billing\BillingPaymentService; +use App\Billing\Exceptions\UnknownBillingPlanException; use App\Contracts\Billing\CreditLedger; +use App\Data\Billing\CreditPackDto; +use App\Enums\Plan; +use App\Models\CreditTransaction; use App\Models\User; +use App\Services\Billing\CreditPackRepository; use App\Services\FeatureAccess\FeatureAccessService; use App\Services\FeatureAccess\PlanLimit; use Illuminate\Contracts\View\View; +use Illuminate\Support\Collection; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Component; +use Livewire\WithPagination; #[Layout('components.layouts.app')] #[Title('Billing — ConvertAI')] class BillingPage extends Component { + use WithPagination; + public function getAuthUserProperty(): User { /** @var User */ @@ -41,6 +52,72 @@ public function getPlansProperty(): array return app(BillingPlanRepository::class)->all(); } + /** @return Collection */ + public function getCreditPacksProperty(): Collection + { + return app(CreditPackRepository::class)->all(); + } + + /** @return \Illuminate\Pagination\LengthAwarePaginator */ + public function getTransactionsProperty() + { + return CreditTransaction::query() + ->where('user_id', $this->authUser->id) + ->latest() + ->paginate(10); + } + + public function startSubscriptionCheckout(string $planKey): void + { + try { + $plan = Plan::from($planKey); + } catch (\ValueError) { + throw ValidationException::withMessages(['plan' => 'Invalid plan selected.']); + } + + if ($plan === Plan::Free) { + throw ValidationException::withMessages(['plan' => 'Free plan does not require checkout.']); + } + + if ($this->authUser->plan === $plan) { + throw ValidationException::withMessages(['plan' => 'You are already on this plan.']); + } + + $billingPlan = app(BillingPlanRepository::class)->findOrFail($planKey); + + $session = app(BillingPaymentService::class)->createSubscriptionCheckout( + user: $this->authUser, + plan: $billingPlan, + successUrl: route('billing', ['checkout' => 'success']), + cancelUrl: route('billing', ['checkout' => 'cancelled']), + ); + + $this->redirect($session->url, navigate: false); + } + + public function buyCreditPack(string $packKey): void + { + $pack = app(CreditPackRepository::class)->find($packKey); + + if ($pack === null) { + throw ValidationException::withMessages(['pack' => 'This credit pack is no longer available.']); + } + + $session = app(BillingPaymentService::class)->createCreditPackCheckout( + user: $this->authUser, + pack: $pack, + 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/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']); +}); From a8eae2a4200d7876821a2ac2ebf57495443f8b8a Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 15:00:20 +0300 Subject: [PATCH 09/15] CONV-282: Add checkout result notices Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Billing/BillingPage.php | 11 +++++++ .../livewire/billing/billing-page.blade.php | 11 +++++++ .../Billing/BillingCheckoutNoticeTest.php | 33 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 tests/Feature/Billing/BillingCheckoutNoticeTest.php diff --git a/app/Livewire/Billing/BillingPage.php b/app/Livewire/Billing/BillingPage.php index e9fe955..3260f0e 100644 --- a/app/Livewire/Billing/BillingPage.php +++ b/app/Livewire/Billing/BillingPage.php @@ -30,6 +30,17 @@ class BillingPage extends Component { use WithPagination; + public ?string $checkoutStatus = null; + + public function mount(): void + { + $status = request()->query('checkout'); + + if (in_array($status, ['success', 'cancelled'], true)) { + $this->checkoutStatus = $status; + } + } + public function getAuthUserProperty(): User { /** @var User */ diff --git a/resources/views/livewire/billing/billing-page.blade.php b/resources/views/livewire/billing/billing-page.blade.php index f5416c4..d4a8966 100644 --- a/resources/views/livewire/billing/billing-page.blade.php +++ b/resources/views/livewire/billing/billing-page.blade.php @@ -5,6 +5,17 @@

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 + {{-- Summary row --}}
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'); +}); From eaee82e6aeaa5e651c4bc423461cb401504127f4 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 15:01:00 +0300 Subject: [PATCH 10/15] CONV-283: Add credit packs section CONV-284: Add buy credit pack actions Co-Authored-By: Claude Sonnet 4.6 --- .../livewire/billing/billing-page.blade.php | 24 +++++++++++ .../Billing/BillingPageCreditPacksTest.php | 40 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php diff --git a/resources/views/livewire/billing/billing-page.blade.php b/resources/views/livewire/billing/billing-page.blade.php index d4a8966..3e72b96 100644 --- a/resources/views/livewire/billing/billing-page.blade.php +++ b/resources/views/livewire/billing/billing-page.blade.php @@ -81,6 +81,30 @@
+ {{-- 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

diff --git a/tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php b/tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php new file mode 100644 index 0000000..eb358cf --- /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(); +}); + +it('rejects invalid credit pack', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(BillingPage::class) + ->call('buyCreditPack', 'invalid-pack') + ->assertHasErrors(['pack']); +}); From 08e8ceb41fb88d67903019efd25cd459bc7bfb73 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 15:02:18 +0300 Subject: [PATCH 11/15] CONV-285: Add credit transaction history table CONV-286: Add credit transaction details CONV-287: Add customer portal and invoice links Co-Authored-By: Claude Sonnet 4.6 --- .../livewire/billing/billing-page.blade.php | 107 +++++++++++++++++- .../Billing/BillingPageCreditHistoryTest.php | 46 ++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/Livewire/Billing/BillingPageCreditHistoryTest.php diff --git a/resources/views/livewire/billing/billing-page.blade.php b/resources/views/livewire/billing/billing-page.blade.php index 3e72b96..d7db310 100644 --- a/resources/views/livewire/billing/billing-page.blade.php +++ b/resources/views/livewire/billing/billing-page.blade.php @@ -16,6 +16,15 @@ @endif + {{-- Validation errors --}} + @if($errors->any()) +
+ @foreach($errors->all() as $error) +

{{ $error }}

+ @endforeach +
+ @endif + {{-- Summary row --}}
@@ -112,18 +121,19 @@ class="w-full"
@foreach($this->plans as $plan) - + @php $isCurrent = $this->authUser->plan->value === $plan->key; @endphp +

{{ $plan->label }}

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

- @if($this->authUser->plan->value === $plan->key) + @if($isCurrent) Current plan @endif
- @if($this->authUser->plan->value === $plan->key) + @if($isCurrent) Current plan @elseif(!$plan->isPaid) Free @@ -142,4 +152,95 @@ class="w-full"
+ {{-- 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/tests/Feature/Livewire/Billing/BillingPageCreditHistoryTest.php b/tests/Feature/Livewire/Billing/BillingPageCreditHistoryTest.php new file mode 100644 index 0000000..7672a09 --- /dev/null +++ b/tests/Feature/Livewire/Billing/BillingPageCreditHistoryTest.php @@ -0,0 +1,46 @@ +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' => \App\Enums\Plan::Free, + ]); + + \App\Models\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'); +}); From 2aac18d05104833666c0e22a9360ac47b1c4822e Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 15:02:31 +0300 Subject: [PATCH 12/15] CONV-288: Add billing empty and error states Co-Authored-By: Claude Sonnet 4.6 --- .../Billing/BillingPageErrorStateTest.php | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/Feature/Livewire/Billing/BillingPageErrorStateTest.php 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']); +}); From 223e3767a1df687f3f0a0afb5f02784b86082671 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 15:13:58 +0300 Subject: [PATCH 13/15] CONV-289: Add billing responsive layout Fix lint issues Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Billing/BillingPage.php | 6 +++--- .../Billing/BillingPageCreditHistoryTest.php | 6 ++++-- .../Billing/BillingPageCreditPacksTest.php | 2 +- .../Livewire/Billing/BillingPageTest.php | 18 ++++++++++-------- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/app/Livewire/Billing/BillingPage.php b/app/Livewire/Billing/BillingPage.php index 3260f0e..8d32407 100644 --- a/app/Livewire/Billing/BillingPage.php +++ b/app/Livewire/Billing/BillingPage.php @@ -4,10 +4,9 @@ namespace App\Livewire\Billing; +use App\Billing\BillingPaymentService; use App\Billing\BillingPlanDto; use App\Billing\BillingPlanRepository; -use App\Billing\BillingPaymentService; -use App\Billing\Exceptions\UnknownBillingPlanException; use App\Contracts\Billing\CreditLedger; use App\Data\Billing\CreditPackDto; use App\Enums\Plan; @@ -17,6 +16,7 @@ use App\Services\FeatureAccess\FeatureAccessService; use App\Services\FeatureAccess\PlanLimit; use Illuminate\Contracts\View\View; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Layout; @@ -69,7 +69,7 @@ public function getCreditPacksProperty(): Collection return app(CreditPackRepository::class)->all(); } - /** @return \Illuminate\Pagination\LengthAwarePaginator */ + /** @return LengthAwarePaginator */ public function getTransactionsProperty() { return CreditTransaction::query() diff --git a/tests/Feature/Livewire/Billing/BillingPageCreditHistoryTest.php b/tests/Feature/Livewire/Billing/BillingPageCreditHistoryTest.php index 7672a09..d97a48e 100644 --- a/tests/Feature/Livewire/Billing/BillingPageCreditHistoryTest.php +++ b/tests/Feature/Livewire/Billing/BillingPageCreditHistoryTest.php @@ -3,7 +3,9 @@ declare(strict_types=1); use App\Contracts\Billing\CreditLedger; +use App\Enums\Plan; use App\Livewire\Billing\BillingPage; +use App\Models\CreditTransaction; use App\Models\User; use Livewire\Livewire; @@ -24,10 +26,10 @@ it('shows empty state when user has no credit transactions', function () { $user = User::factory()->create([ - 'plan' => \App\Enums\Plan::Free, + 'plan' => Plan::Free, ]); - \App\Models\CreditTransaction::where('user_id', $user->id)->delete(); + CreditTransaction::where('user_id', $user->id)->delete(); Livewire::actingAs($user) ->test(BillingPage::class) diff --git a/tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php b/tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php index eb358cf..89300ed 100644 --- a/tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php +++ b/tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use App\Billing\Gateway\FakeCreditPackCheckoutGateway; use App\Billing\Gateway\CreditPackCheckoutGateway; +use App\Billing\Gateway\FakeCreditPackCheckoutGateway; use App\Livewire\Billing\BillingPage; use App\Models\User; use Livewire\Livewire; diff --git a/tests/Feature/Livewire/Billing/BillingPageTest.php b/tests/Feature/Livewire/Billing/BillingPageTest.php index 1ce73d4..0e3f5a8 100644 --- a/tests/Feature/Livewire/Billing/BillingPageTest.php +++ b/tests/Feature/Livewire/Billing/BillingPageTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use App\Contracts\Billing\CreditLedger; +use App\Enums\Plan; use App\Livewire\Billing\BillingPage; use App\Models\User; use Livewire\Livewire; @@ -16,7 +18,7 @@ it('shows current user plan on billing page', function () { $user = User::factory()->create([ - 'plan' => \App\Enums\Plan::Pro, + 'plan' => Plan::Pro, ]); Livewire::actingAs($user) @@ -27,7 +29,7 @@ it('shows free plan for free user on billing page', function () { $user = User::factory()->create([ - 'plan' => \App\Enums\Plan::Free, + 'plan' => Plan::Free, ]); Livewire::actingAs($user) @@ -39,13 +41,13 @@ it('shows current credits balance on billing page', function () { $user = User::factory()->create(); - app(\App\Contracts\Billing\CreditLedger::class)->grant( + app(CreditLedger::class)->grant( user: $user, amount: 500, reason: 'test_grant', ); - $balance = app(\App\Contracts\Billing\CreditLedger::class)->balance($user); + $balance = app(CreditLedger::class)->balance($user); Livewire::actingAs($user) ->test(BillingPage::class) @@ -64,7 +66,7 @@ it('shows current plan limits on billing page', function () { $user = User::factory()->create([ - 'plan' => \App\Enums\Plan::Free, + 'plan' => Plan::Free, ]); Livewire::actingAs($user) @@ -76,7 +78,7 @@ it('shows correct free plan limit values', function () { $user = User::factory()->create([ - 'plan' => \App\Enums\Plan::Free, + 'plan' => Plan::Free, ]); Livewire::actingAs($user) @@ -98,7 +100,7 @@ it('highlights the current plan', function () { $user = User::factory()->create([ - 'plan' => \App\Enums\Plan::Pro, + 'plan' => Plan::Pro, ]); Livewire::actingAs($user) @@ -109,7 +111,7 @@ it('does not show upgrade button for current plan', function () { $user = User::factory()->create([ - 'plan' => \App\Enums\Plan::Pro, + 'plan' => Plan::Pro, ]); Livewire::actingAs($user) From 0108a14598ce8bed8407d3e7b93c025fde6dd6a5 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 15:14:25 +0300 Subject: [PATCH 14/15] CONV-290: Add billing page final smoke tests Co-Authored-By: Claude Sonnet 4.6 --- .../Livewire/Billing/BillingPageSmokeTest.php | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/Feature/Livewire/Billing/BillingPageSmokeTest.php 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'); +}); From 49266ac0a6ebb13226056a20256aca56224f4617 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 15:43:41 +0300 Subject: [PATCH 15/15] Fix billing page inline review issues - Extract checkout orchestration into StartSubscriptionCheckoutAction and BuyCreditPackAction - Add getApiAccessProperty/getBatchConversionProperty computed properties to remove app() calls from Blade - Tighten assertRedirect to exact URL in BillingPageCreditPacksTest Co-Authored-By: Claude Sonnet 4.6 --- app/Actions/Billing/BuyCreditPackAction.php | 35 ++++++++++++++ .../StartSubscriptionCheckoutAction.php | 46 +++++++++++++++++++ app/Livewire/Billing/BillingPage.php | 44 +++++++----------- .../livewire/billing/billing-page.blade.php | 6 +-- .../Billing/BillingPageCreditPacksTest.php | 2 +- 5 files changed, 101 insertions(+), 32 deletions(-) create mode 100644 app/Actions/Billing/BuyCreditPackAction.php create mode 100644 app/Actions/Billing/StartSubscriptionCheckoutAction.php 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 index 8d32407..41e0a36 100644 --- a/app/Livewire/Billing/BillingPage.php +++ b/app/Livewire/Billing/BillingPage.php @@ -4,12 +4,12 @@ namespace App\Livewire\Billing; -use App\Billing\BillingPaymentService; +use App\Actions\Billing\BuyCreditPackAction; +use App\Actions\Billing\StartSubscriptionCheckoutAction; use App\Billing\BillingPlanDto; use App\Billing\BillingPlanRepository; use App\Contracts\Billing\CreditLedger; use App\Data\Billing\CreditPackDto; -use App\Enums\Plan; use App\Models\CreditTransaction; use App\Models\User; use App\Services\Billing\CreditPackRepository; @@ -57,6 +57,16 @@ 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 { @@ -80,25 +90,9 @@ public function getTransactionsProperty() public function startSubscriptionCheckout(string $planKey): void { - try { - $plan = Plan::from($planKey); - } catch (\ValueError) { - throw ValidationException::withMessages(['plan' => 'Invalid plan selected.']); - } - - if ($plan === Plan::Free) { - throw ValidationException::withMessages(['plan' => 'Free plan does not require checkout.']); - } - - if ($this->authUser->plan === $plan) { - throw ValidationException::withMessages(['plan' => 'You are already on this plan.']); - } - - $billingPlan = app(BillingPlanRepository::class)->findOrFail($planKey); - - $session = app(BillingPaymentService::class)->createSubscriptionCheckout( + $session = app(StartSubscriptionCheckoutAction::class)->handle( user: $this->authUser, - plan: $billingPlan, + planKey: $planKey, successUrl: route('billing', ['checkout' => 'success']), cancelUrl: route('billing', ['checkout' => 'cancelled']), ); @@ -108,15 +102,9 @@ public function startSubscriptionCheckout(string $planKey): void public function buyCreditPack(string $packKey): void { - $pack = app(CreditPackRepository::class)->find($packKey); - - if ($pack === null) { - throw ValidationException::withMessages(['pack' => 'This credit pack is no longer available.']); - } - - $session = app(BillingPaymentService::class)->createCreditPackCheckout( + $session = app(BuyCreditPackAction::class)->handle( user: $this->authUser, - pack: $pack, + packKey: $packKey, successUrl: route('billing', ['checkout' => 'success']), cancelUrl: route('billing', ['checkout' => 'cancelled']), ); diff --git a/resources/views/livewire/billing/billing-page.blade.php b/resources/views/livewire/billing/billing-page.blade.php index d7db310..0b0056a 100644 --- a/resources/views/livewire/billing/billing-page.blade.php +++ b/resources/views/livewire/billing/billing-page.blade.php @@ -55,7 +55,7 @@ {{-- Plan limits card --}}

Plan limits

- @php $limits = $this->planLimits; $featureAccess = app(\App\Services\FeatureAccess\FeatureAccessService::class); $user = $this->authUser; @endphp + @php $limits = $this->planLimits; @endphp
  • Max file size @@ -71,7 +71,7 @@
  • API access - @if($featureAccess->allows($user, 'api_access')) + @if($this->apiAccess) Enabled @else Disabled @@ -79,7 +79,7 @@
  • Batch conversion - @if($featureAccess->allows($user, 'batch_conversion')) + @if($this->batchConversion) Enabled @else Disabled diff --git a/tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php b/tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php index 89300ed..16a6d5d 100644 --- a/tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php +++ b/tests/Feature/Livewire/Billing/BillingPageCreditPacksTest.php @@ -27,7 +27,7 @@ Livewire::actingAs($user) ->test(BillingPage::class) ->call('buyCreditPack', 'small') - ->assertRedirect(); + ->assertRedirect('https://checkout.stripe.test/fake-credit-pack-session'); }); it('rejects invalid credit pack', function () {