withoutMiddleware(ConditionalValidateCsrfToken::class); $this->configureBilling(); } public function test_guest_cannot_start_checkout_and_is_redirected_to_login(): void { $this->post(route('academy.billing.checkout'), ['plan' => 'creator_monthly']) ->assertRedirect(route('login')); } public function test_guest_can_view_pricing(): void { $this->get(route('academy.pricing')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/Billing/Pricing') ->where('currentTier', 'free') ->where('isSubscribed', false) ->where('activePlanKey', null) ->where('catalog.0.plans.0.price_display', '4.99 EUR') ->where('catalog.1.plans.0.price_display', '9.99 EUR')); } public function test_pricing_shows_active_plan_for_subscriber(): void { $user = User::factory()->create([ 'email_verified_at' => now(), 'stripe_id' => 'cus_pricing_active_plan', ]); $subscription = $user->subscriptions()->create([ 'type' => 'academy', 'stripe_id' => 'sub_pricing_active_plan', 'stripe_status' => 'active', 'stripe_price' => 'price_1creatormonth', 'quantity' => 1, ]); $subscription->items()->create([ 'stripe_id' => 'si_pricing_active_plan', 'stripe_product' => 'prod_creator', 'stripe_price' => 'price_1creatormonth', 'quantity' => 1, ]); $this->actingAs($user) ->get(route('academy.pricing')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/Billing/Pricing') ->where('currentTier', 'creator') ->where('isSubscribed', true) ->where('activePlanKey', 'creator_monthly') ->where('activePlanLabel', 'Creator Monthly')); } public function test_invalid_plan_returns_validation_error(): void { $user = User::factory()->create(['email_verified_at' => now()]); $this->actingAs($user) ->from(route('academy.pricing')) ->post(route('academy.billing.checkout'), ['plan' => 'not_real']) ->assertRedirect(route('academy.pricing')) ->assertSessionHasErrors('plan'); } public function test_missing_price_id_fails_safely(): void { config()->set('academy_billing.plans.creator_monthly.stripe_price_id', ''); $user = User::factory()->create(['email_verified_at' => now()]); $this->actingAs($user) ->postJson(route('academy.billing.checkout'), ['plan' => 'creator_monthly']) ->assertStatus(422) ->assertJsonPath('code', 'academy_billing_price_missing'); } public function test_numeric_price_id_fails_safely_before_stripe(): void { config()->set('academy_billing.plans.creator_monthly.stripe_price_id', '9.99'); $user = User::factory()->create(['email_verified_at' => now()]); $this->actingAs($user) ->postJson(route('academy.billing.checkout'), ['plan' => 'creator_monthly']) ->assertStatus(422) ->assertJsonPath('code', 'academy_billing_price_invalid'); } public function test_invalid_price_id_redirects_back_with_visible_flash_error(): void { config()->set('academy_billing.plans.creator_monthly.stripe_price_id', 'prod_not_a_price'); $user = User::factory()->create(['email_verified_at' => now()]); $this->actingAs($user) ->from(route('academy.pricing')) ->post(route('academy.billing.checkout'), ['plan' => 'creator_monthly']) ->assertRedirect(route('academy.pricing')) ->assertSessionHas('error', 'The selected Academy plan is misconfigured. Please contact support before continuing.'); $this->actingAs($user) ->withSession([ 'error' => 'The selected Academy plan is misconfigured. Please contact support before continuing.', ]) ->get(route('academy.pricing')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/Billing/Pricing') ->where('flash.error', 'The selected Academy plan is misconfigured. Please contact support before continuing.')); } public function test_verified_user_can_start_checkout_for_configured_plan(): void { $user = User::factory()->create(['email_verified_at' => now()]); $user = Mockery::mock($user)->makePartial(); $builder = Mockery::mock(new SubscriptionBuilder($user, 'academy', 'price_1creatormonth'))->makePartial(); $builder->shouldReceive('withMetadata') ->once() ->andReturnSelf(); $builder->shouldReceive('checkout') ->once() ->andReturn(redirect('https://checkout.stripe.test/session')); $user->shouldReceive('subscription')->once()->with('academy')->andReturn(null); $user->shouldReceive('newSubscription')->once()->with('academy', 'price_1creatormonth')->andReturn($builder); $this->actingAs($user) ->post(route('academy.billing.checkout'), ['plan' => 'creator_monthly']) ->assertRedirect('https://checkout.stripe.test/session'); } public function test_existing_subscriber_is_redirected_to_billing_portal(): void { $user = User::factory()->create([ 'email_verified_at' => now(), 'stripe_id' => 'cus_checkout_redirect', ]); $subscription = $user->subscriptions()->create([ 'type' => 'academy', 'stripe_id' => 'sub_checkout_redirect', 'stripe_status' => 'active', 'stripe_price' => 'price_1creatormonth', 'quantity' => 1, ]); $subscription->items()->create([ 'stripe_id' => 'si_checkout_redirect', 'stripe_product' => 'prod_creator', 'stripe_price' => 'price_1creatormonth', 'quantity' => 1, ]); $this->actingAs($user) ->post(route('academy.billing.checkout'), ['plan' => 'creator_monthly']) ->assertRedirect(route('academy.billing.portal')); } public function test_support_report_sends_mail_immediately_and_stores_record(): void { Mail::fake(); config()->set('mail.from.address', 'info@skinbase.org'); $user = User::factory()->create([ 'email_verified_at' => now(), 'name' => 'Billing Tester', 'email' => 'tester@example.com', ]); $this->actingAs($user) ->from(route('academy.billing.account')) ->post(route('academy.billing.report_issue'), [ 'issue_type' => 'access', 'contact_email' => 'reply@example.com', 'session_id' => 'cs_test_123', 'message' => 'I paid but access did not update.', ]) ->assertRedirect(route('academy.billing.account')) ->assertSessionHas('success', 'Support request sent — we will verify and activate your access shortly.'); Mail::assertSent(AcademyAccessIssue::class, function (AcademyAccessIssue $mail) use ($user): bool { return $mail->user->is($user) && $mail->issueType === 'access' && $mail->contactEmail === 'reply@example.com' && $mail->sessionId === 'cs_test_123' && $mail->message === 'I paid but access did not update.'; }); $this->assertDatabaseHas('staff_applications', [ 'topic' => 'contact', 'email' => 'reply@example.com', 'role' => 'academy_billing_support', ]); $application = StaffApplication::query()->latest('created_at')->first(); $this->assertNotNull($application); $this->assertSame('academy_billing', data_get($application?->payload, 'data.source')); $this->assertSame('access', data_get($application?->payload, 'data.issue_type')); } private function configureBilling(): void { config()->set('academy.enabled', true); config()->set('academy.payments_enabled', true); config()->set('academy_billing.enabled', true); config()->set('academy_billing.subscription_name', 'academy'); config()->set('academy_billing.plans', [ 'creator_monthly' => [ 'label' => 'Creator Monthly', 'tier' => 'creator', 'interval' => 'monthly', 'amount' => '4.99', 'currency' => 'EUR', 'stripe_price_id' => 'price_1creatormonth', 'featured' => false, ], 'pro_monthly' => [ 'label' => 'Pro Monthly', 'tier' => 'pro', 'interval' => 'monthly', 'amount' => '9.99', 'currency' => 'EUR', 'stripe_price_id' => 'price_1promonth', 'featured' => true, ], ]); } }