From cfa1791c8681b6bcac055f3b949cedfee9b956ae Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 9 Mar 2026 15:28:55 +0000 Subject: [PATCH 1/2] Add post-purchase flow with sync license creation and success banner Co-Authored-By: Claude Opus 4.6 --- resources/views/course.blade.php | 29 ++++++++++++++++++++ routes/web.php | 46 ++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/resources/views/course.blade.php b/resources/views/course.blade.php index 6d8c1d8e..81e74e20 100644 --- a/resources/views/course.blade.php +++ b/resources/views/course.blade.php @@ -1,5 +1,34 @@
+ @if (! empty($purchased)) + {{-- Purchase Success Banner --}} +
+
+ + + +

+ You're in! Thank you for your purchase. +

+

+ We'll notify you as soon as the masterclass is ready. + Keep an eye on your inbox for updates and early access content. +

+
+
+ @endif + + @if (session('error')) +
+ {{ session('error') }} +
+ @endif + {{-- Hero Section --}}
name('welcome'); Route::redirect('pricing', 'blog/nativephp-for-mobile-is-now-free')->name('pricing'); Route::view('alt-pricing', 'alt-pricing')->name('alt-pricing')->middleware('signed'); -Route::view('course', 'course')->name('course'); +Route::get('course', function (\Illuminate\Http\Request $request) { + $purchased = false; + + if ($request->has('session_id') && $request->user()) { + try { + $session = \Laravel\Cashier\Cashier::stripe()->checkout->sessions->retrieve($request->query('session_id')); + + if ($session->payment_status === 'paid' && ! empty($session->metadata['cart_id'])) { + $cart = \App\Models\Cart::with('items.product')->find($session->metadata['cart_id']); + + if ($cart && ! $cart->isCompleted()) { + $user = $request->user(); + + foreach ($cart->items as $item) { + if ($item->isProduct() && $item->product) { + if (! \App\Models\ProductLicense::where('user_id', $user->id)->where('product_id', $item->product_id)->exists()) { + \App\Models\ProductLicense::create([ + 'user_id' => $user->id, + 'product_id' => $item->product_id, + 'stripe_payment_intent_id' => $session->payment_intent, + 'price_paid' => $item->product_price_at_addition, + 'currency' => strtoupper($item->currency), + 'purchased_at' => now(), + ]); + } + } + } + + $cart->markAsCompleted(); + } + + $purchased = true; + } + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::warning('Failed to verify checkout session', [ + 'session_id' => $request->query('session_id'), + 'error' => $e->getMessage(), + ]); + } + } + + return view('course', ['purchased' => $purchased]); +})->name('course'); Route::post('course/checkout', function (\Illuminate\Http\Request $request) { $user = $request->user(); @@ -106,7 +148,7 @@ ], 'quantity' => 1, ]], - 'success_url' => route('course').'?purchased=1', + 'success_url' => route('course').'?session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => route('course'), 'customer' => $user->stripe_id, 'metadata' => $metadata, From 5523544c39e676ef90662198586fe7de6a87b170 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 9 Mar 2026 15:38:22 +0000 Subject: [PATCH 2/2] Redirect course checkout to cart success page for webhook-based purchase confirmation Instead of synchronously creating licenses on the /course page after Stripe checkout, redirect to the existing cart/success page that polls for webhook confirmation. Co-Authored-By: Claude Opus 4.6 --- resources/views/course.blade.php | 23 ------------- routes/web.php | 46 ++----------------------- tests/Feature/CoursePageTest.php | 58 ++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 67 deletions(-) diff --git a/resources/views/course.blade.php b/resources/views/course.blade.php index 81e74e20..3f404c70 100644 --- a/resources/views/course.blade.php +++ b/resources/views/course.blade.php @@ -1,28 +1,5 @@
- @if (! empty($purchased)) - {{-- Purchase Success Banner --}} -
-
- - - -

- You're in! Thank you for your purchase. -

-

- We'll notify you as soon as the masterclass is ready. - Keep an eye on your inbox for updates and early access content. -

-
-
- @endif - @if (session('error'))
{{ session('error') }} diff --git a/routes/web.php b/routes/web.php index 3cdb2275..952e3726 100644 --- a/routes/web.php +++ b/routes/web.php @@ -64,49 +64,7 @@ Route::view('/', 'welcome')->name('welcome'); Route::redirect('pricing', 'blog/nativephp-for-mobile-is-now-free')->name('pricing'); Route::view('alt-pricing', 'alt-pricing')->name('alt-pricing')->middleware('signed'); -Route::get('course', function (\Illuminate\Http\Request $request) { - $purchased = false; - - if ($request->has('session_id') && $request->user()) { - try { - $session = \Laravel\Cashier\Cashier::stripe()->checkout->sessions->retrieve($request->query('session_id')); - - if ($session->payment_status === 'paid' && ! empty($session->metadata['cart_id'])) { - $cart = \App\Models\Cart::with('items.product')->find($session->metadata['cart_id']); - - if ($cart && ! $cart->isCompleted()) { - $user = $request->user(); - - foreach ($cart->items as $item) { - if ($item->isProduct() && $item->product) { - if (! \App\Models\ProductLicense::where('user_id', $user->id)->where('product_id', $item->product_id)->exists()) { - \App\Models\ProductLicense::create([ - 'user_id' => $user->id, - 'product_id' => $item->product_id, - 'stripe_payment_intent_id' => $session->payment_intent, - 'price_paid' => $item->product_price_at_addition, - 'currency' => strtoupper($item->currency), - 'purchased_at' => now(), - ]); - } - } - } - - $cart->markAsCompleted(); - } - - $purchased = true; - } - } catch (\Exception $e) { - \Illuminate\Support\Facades\Log::warning('Failed to verify checkout session', [ - 'session_id' => $request->query('session_id'), - 'error' => $e->getMessage(), - ]); - } - } - - return view('course', ['purchased' => $purchased]); -})->name('course'); +Route::view('course', 'course')->name('course'); Route::post('course/checkout', function (\Illuminate\Http\Request $request) { $user = $request->user(); @@ -148,7 +106,7 @@ ], 'quantity' => 1, ]], - 'success_url' => route('course').'?session_id={CHECKOUT_SESSION_ID}', + 'success_url' => route('cart.success').'?session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => route('course'), 'customer' => $user->stripe_id, 'metadata' => $metadata, diff --git a/tests/Feature/CoursePageTest.php b/tests/Feature/CoursePageTest.php index 8eb19c19..5d3c1ee7 100644 --- a/tests/Feature/CoursePageTest.php +++ b/tests/Feature/CoursePageTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -48,4 +49,61 @@ public function course_checkout_redirects_guests_to_login(): void ->post(route('course.checkout')) ->assertRedirect(route('customer.login')); } + + #[Test] + public function course_checkout_redirects_to_stripe_with_cart_success_url(): void + { + $user = User::factory()->create(['stripe_id' => 'cus_test123']); + + $stripeSessionUrl = 'https://checkout.stripe.com/test-session'; + $capturedParams = null; + + $mockCheckoutSessions = new class($stripeSessionUrl, $capturedParams) + { + public function __construct( + private string $url, + private &$capturedParams, + ) {} + + public function create(array $params): object + { + $this->capturedParams = $params; + + return (object) [ + 'id' => 'cs_test123', + 'url' => $this->url, + ]; + } + }; + + $mockCheckout = new \stdClass; + $mockCheckout->sessions = $mockCheckoutSessions; + + $mockCustomers = new class + { + public function retrieve(): \Stripe\Customer + { + return \Stripe\Customer::constructFrom([ + 'id' => 'cus_test123', + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } + }; + + $mockStripeClient = $this->createMock(\Stripe\StripeClient::class); + $mockStripeClient->checkout = $mockCheckout; + $mockStripeClient->customers = $mockCustomers; + + $this->app->bind(\Stripe\StripeClient::class, fn () => $mockStripeClient); + + $this + ->actingAs($user) + ->post(route('course.checkout')) + ->assertRedirect($stripeSessionUrl); + + $this->assertNotNull($capturedParams, 'Stripe checkout session should have been created'); + $this->assertStringContainsString(route('cart.success'), $capturedParams['success_url']); + $this->assertStringContainsString('{CHECKOUT_SESSION_ID}', $capturedParams['success_url']); + } }