From 2c0b0dd54fdb23f8c0f372c97f22c7492f62bd0f Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 16 Jul 2026 11:09:12 +0100 Subject: [PATCH 1/2] feat(checkout): pre-apply Stripe coupons to product prices Add an optional Stripe coupon ID to product prices so admins can offer a discount (e.g. subscriber pricing for the Masterclass) without creating a separate Stripe price. When a buyer's resolved price carries a coupon, it is pre-applied to the checkout session via `discounts`. - Add nullable stripe_coupon_id column to product_prices - Course and cart checkout pre-apply the coupon (dropping the manual promo code box, which Stripe disallows alongside discounts) - Course pages now display the real price from the database per user, with the discounted amount derived from the coupon's Stripe config (cached), replacing the hardcoded 199/299 values - Tie-break equal-amount prices by tier priority so a coupon-bearing subscriber price wins over an equal regular price - Filament pricing form/table expose the coupon field Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PricesRelationManager.php | 10 + app/Http/Controllers/CartController.php | 29 +- app/Livewire/Customer/Course/Index.php | 34 ++- app/Models/Product.php | 8 +- app/Models/ProductPrice.php | 87 ++++++ database/factories/ProductPriceFactory.php | 7 + ...ripe_coupon_id_to_product_prices_table.php | 28 ++ package-lock.json | 2 +- resources/views/course.blade.php | 23 +- .../views/livewire/customer/course.blade.php | 9 +- routes/web.php | 21 +- tests/Feature/CheckoutCouponTest.php | 287 ++++++++++++++++++ tests/Feature/CourseContentTest.php | 56 +++- tests/Feature/CoursePageTest.php | 81 ++++- 14 files changed, 658 insertions(+), 24 deletions(-) create mode 100644 database/migrations/2026_07_10_112717_add_stripe_coupon_id_to_product_prices_table.php create mode 100644 tests/Feature/CheckoutCouponTest.php diff --git a/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php b/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php index 79b80665..1fd83bb9 100644 --- a/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php +++ b/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php @@ -41,6 +41,11 @@ public function form(Schema $schema): Schema ->default('USD') ->required(), + Forms\Components\TextInput::make('stripe_coupon_id') + ->label('Stripe coupon ID') + ->maxLength(255) + ->helperText('Coupon ID from the Stripe dashboard. Keep the amount above at full price — the price shown to buyers is calculated from the coupon\'s discount, and the coupon is pre-applied at Stripe checkout. Buyers cannot enter a promotion code when a coupon is pre-applied.'), + Forms\Components\Toggle::make('is_active') ->label('Active') ->default(true) @@ -71,6 +76,11 @@ public function table(Table $table): Table ->badge() ->color('gray'), + Tables\Columns\TextColumn::make('stripe_coupon_id') + ->label('Coupon') + ->placeholder('—') + ->toggleable(), + Tables\Columns\IconColumn::make('is_active') ->label('Active') ->boolean() diff --git a/app/Http/Controllers/CartController.php b/app/Http/Controllers/CartController.php index a505e09d..aec1734c 100644 --- a/app/Http/Controllers/CartController.php +++ b/app/Http/Controllers/CartController.php @@ -456,6 +456,7 @@ protected function createMultiItemCheckoutSession($cart, $user): Session $lineItems = []; $purchasedItemIds = []; + $couponIds = []; Log::info('Creating multi-item checkout session', [ 'cart_id' => $cart->id, @@ -488,6 +489,12 @@ protected function createMultiItemCheckoutSession($cart, $user): Session } elseif ($item->isProduct()) { $product = $item->product; + $couponId = $product->getBestPriceForUser($user)?->stripe_coupon_id; + + if ($couponId) { + $couponIds[] = $couponId; + } + $lineItems[] = [ 'price_data' => [ 'currency' => strtolower($item->currency), @@ -527,7 +534,7 @@ protected function createMultiItemCheckoutSession($cart, $user): Session 'cart_item_ids' => implode(',', $purchasedItemIds), ]; - $session = Cashier::stripe()->checkout->sessions->create([ + $sessionParams = [ 'mode' => 'payment', 'line_items' => $lineItems, 'success_url' => route('cart.success').'?session_id={CHECKOUT_SESSION_ID}', @@ -549,7 +556,25 @@ protected function createMultiItemCheckoutSession($cart, $user): Session 'metadata' => $metadata, ], ], - ]); + ]; + + // Stripe accepts either allow_promotion_codes or discounts on a session, + // never both, and at most one discount per session. + $couponIds = array_values(array_unique($couponIds)); + + if ($couponIds !== []) { + unset($sessionParams['allow_promotion_codes']); + $sessionParams['discounts'] = [['coupon' => $couponIds[0]]]; + + if (count($couponIds) > 1) { + Log::warning('Cart resolved multiple pre-applied coupons; only the first was applied', [ + 'cart_id' => $cart->id, + 'coupon_ids' => $couponIds, + ]); + } + } + + $session = Cashier::stripe()->checkout->sessions->create($sessionParams); // Store the Stripe checkout session ID on the cart $cart->update(['stripe_checkout_session_id' => $session->id]); diff --git a/app/Livewire/Customer/Course/Index.php b/app/Livewire/Customer/Course/Index.php index cc19996d..9fff27cd 100644 --- a/app/Livewire/Customer/Course/Index.php +++ b/app/Livewire/Customer/Course/Index.php @@ -5,6 +5,7 @@ use App\Models\Course; use App\Models\LessonProgress; use App\Models\Product; +use App\Models\ProductPrice; use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -29,11 +30,15 @@ public function course(): ?Course } #[Computed] - public function hasPurchased(): bool + public function masterclass(): ?Product { - $product = Product::where('slug', 'nativephp-masterclass')->first(); + return Product::where('slug', 'nativephp-masterclass')->first(); + } - return $product && $product->isOwnedBy(auth()->user()); + #[Computed] + public function hasPurchased(): bool + { + return $this->masterclass && $this->masterclass->isOwnedBy(auth()->user()); } #[Computed] @@ -49,9 +54,28 @@ public function priceIncreased(): bool } #[Computed] - public function currentPrice(): int + public function bestPrice(): ?ProductPrice + { + return $this->masterclass?->getBestPriceForUser(auth()->user()); + } + + #[Computed] + public function regularPrice(): ?ProductPrice + { + return $this->masterclass?->getRegularPrice(); + } + + #[Computed] + public function currentPrice(): string + { + return $this->bestPrice?->discountedDisplayAmount() ?? ($this->priceIncreased() ? '299' : '199'); + } + + #[Computed] + public function hasDiscount(): bool { - return $this->priceIncreased() ? 299 : 199; + return $this->bestPrice && $this->regularPrice + && $this->bestPrice->discountedAmount() < $this->regularPrice->amount; } #[Computed] diff --git a/app/Models/Product.php b/app/Models/Product.php index 80244463..8e465630 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -43,6 +43,8 @@ public function licenses(): HasMany /** * Get the best (lowest) active price for a user based on their eligible tiers. + * Equal amounts are broken by tier priority so a more specific tier (e.g. Subscriber) + * wins over Regular, ensuring perks like pre-applied coupons are picked up. * Returns null if no price exists for the user's eligible tiers. */ public function getBestPriceForUser(?User $user): ?ProductPrice @@ -52,7 +54,11 @@ public function getBestPriceForUser(?User $user): ?ProductPrice return $this->prices() ->active() ->forTiers($eligibleTiers) - ->orderBy('amount', 'asc') + ->get() + ->sort(function (ProductPrice $a, ProductPrice $b): int { + return $a->amount <=> $b->amount + ?: $a->tier->priority() <=> $b->tier->priority(); + }) ->first(); } diff --git a/app/Models/ProductPrice.php b/app/Models/ProductPrice.php index c4fe8183..8be531d2 100644 --- a/app/Models/ProductPrice.php +++ b/app/Models/ProductPrice.php @@ -9,6 +9,9 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; +use Laravel\Cashier\Cashier; class ProductPrice extends Model { @@ -66,6 +69,90 @@ protected function formattedAmount(): Attribute }); } + /** + * Marketing-style amount: whole dollars without decimals ("299"), otherwise two decimals ("49.99"). + */ + protected function displayAmount(): Attribute + { + return Attribute::make(get: fn () => self::formatAmountForDisplay($this->amount)); + } + + public static function formatAmountForDisplay(int $amount): string + { + return $amount % 100 === 0 + ? number_format($amount / 100) + : number_format($amount / 100, 2); + } + + /** + * The amount after deducting the attached Stripe coupon's discount. + * Falls back to the full amount when no coupon is attached or it can't be resolved. + */ + public function discountedAmount(): int + { + $discount = $this->stripeCouponDiscount(); + + if ($discount === false) { + return $this->amount; + } + + if ($discount['amount_off']) { + return max(0, $this->amount - $discount['amount_off']); + } + + if ($discount['percent_off']) { + return (int) round($this->amount * (1 - $discount['percent_off'] / 100)); + } + + return $this->amount; + } + + public function discountedDisplayAmount(): string + { + return self::formatAmountForDisplay($this->discountedAmount()); + } + + /** + * Fetch the attached coupon's discount from Stripe, cached briefly. + * Returns false (also cached, to avoid hammering Stripe with a bad ID) + * when there is no coupon or it is invalid/unresolvable. + * + * @return array{amount_off: ?int, percent_off: ?float}|false + */ + protected function stripeCouponDiscount(): array|false + { + if (! $this->stripe_coupon_id) { + return false; + } + + return Cache::remember( + "stripe-coupon.{$this->stripe_coupon_id}", + now()->addMinutes(10), + function (): array|false { + try { + $coupon = Cashier::stripe()->coupons->retrieve($this->stripe_coupon_id); + + if (! $coupon->valid) { + return false; + } + + return [ + 'amount_off' => $coupon->amount_off, + 'percent_off' => $coupon->percent_off, + ]; + } catch (\Throwable $e) { + Log::warning('Failed to retrieve Stripe coupon for product price', [ + 'product_price_id' => $this->id, + 'stripe_coupon_id' => $this->stripe_coupon_id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + ); + } + public function isRegularTier(): bool { return $this->tier === PriceTier::Regular; diff --git a/database/factories/ProductPriceFactory.php b/database/factories/ProductPriceFactory.php index 086cc3f4..b5cdb28a 100644 --- a/database/factories/ProductPriceFactory.php +++ b/database/factories/ProductPriceFactory.php @@ -59,4 +59,11 @@ public function amount(int $amount): static 'amount' => $amount, ]); } + + public function withCoupon(string $couponId = 'coupon_test123'): static + { + return $this->state(fn (array $attributes) => [ + 'stripe_coupon_id' => $couponId, + ]); + } } diff --git a/database/migrations/2026_07_10_112717_add_stripe_coupon_id_to_product_prices_table.php b/database/migrations/2026_07_10_112717_add_stripe_coupon_id_to_product_prices_table.php new file mode 100644 index 00000000..213d4619 --- /dev/null +++ b/database/migrations/2026_07_10_112717_add_stripe_coupon_id_to_product_prices_table.php @@ -0,0 +1,28 @@ +string('stripe_coupon_id')->nullable()->after('currency'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('product_prices', function (Blueprint $table) { + $table->dropColumn('stripe_coupon_id'); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index d1765e41..b1b6149e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "fluffy-macaw", + "name": "upper-ant", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/resources/views/course.blade.php b/resources/views/course.blade.php index 45dd1e95..3fadf51b 100644 --- a/resources/views/course.blade.php +++ b/resources/views/course.blade.php @@ -549,11 +549,13 @@ class="relative overflow-hidden rounded-3xl bg-gradient-to-br from-emerald-50 to

You have full access to all content.

@else - {{-- $199 pricing (before deadline) --}} + {{-- Early bird pricing (before deadline) --}}
- $199 - $299 + ${{ $currentPrice }} + @if ($hasDiscount) + ${{ $regularPrice }} + @endif one-time

Early bird pricing. Lock it in now — full access forever.

@@ -565,13 +567,22 @@ class="mt-4 rounded-xl bg-white/60 p-4 dark:bg-white/5" />
- {{-- $299 pricing (after deadline) --}} + {{-- Standard pricing (after deadline) --}}
- $299 + ${{ $currentPrice }} + @if ($hasDiscount) + ${{ $regularPrice }} + @endif one-time
-

One price. Full access forever.

+

+ @if ($hasDiscount) + Your discount is applied automatically at checkout. + @else + One price. Full access forever. + @endif +

@endif diff --git a/resources/views/livewire/customer/course.blade.php b/resources/views/livewire/customer/course.blade.php index 46ccd42c..65545f2c 100644 --- a/resources/views/livewire/customer/course.blade.php +++ b/resources/views/livewire/customer/course.blade.php @@ -140,11 +140,14 @@ class="h-2 rounded-full bg-emerald-500 transition-all" {{-- Price --}}
${{ $this->currentPrice }} - @unless ($this->priceIncreased) - $299 - @endunless + @if ($this->hasDiscount) + ${{ $this->regularPrice->display_amount }} + @endif one-time
+ @if ($this->hasDiscount) +

Your discount is applied automatically at checkout.

+ @endif {{-- Countdown --}} gte($priceIncreaseAt); + $bestPrice = $product?->getBestPriceForUser($user); + $regularPrice = $product?->getRegularPrice(); + return view('course', [ 'alreadyOwned' => $alreadyOwned, 'course' => $course, 'priceIncreaseAt' => $priceIncreaseAt, 'priceIncreased' => $priceIncreased, - 'currentPrice' => $priceIncreased ? 299 : 199, + 'currentPrice' => $bestPrice?->discountedDisplayAmount() ?? ($priceIncreased ? '299' : '199'), + 'regularPrice' => $regularPrice?->display_amount, + 'hasDiscount' => $bestPrice && $regularPrice && $bestPrice->discountedAmount() < $regularPrice->amount, ]); })->name('course'); @@ -161,7 +166,7 @@ $metadata = ['cart_id' => (string) $cart->id]; - return $user->checkout([$priceId => 1], [ + $sessionOptions = [ 'success_url' => route('cart.success').'?session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => route('course'), 'metadata' => $metadata, @@ -179,7 +184,17 @@ 'metadata' => $metadata, ], ], - ]); + ]; + + // Stripe accepts either allow_promotion_codes or discounts on a session, never both. + $couponId = $product->getBestPriceForUser($user)?->stripe_coupon_id; + + if ($couponId) { + unset($sessionOptions['allow_promotion_codes']); + $sessionOptions['discounts'] = [['coupon' => $couponId]]; + } + + return $user->checkout([$priceId => 1], $sessionOptions); })->name('course.checkout'); Route::view('wall-of-love', 'wall-of-love')->name('wall-of-love'); diff --git a/tests/Feature/CheckoutCouponTest.php b/tests/Feature/CheckoutCouponTest.php new file mode 100644 index 00000000..a394aa37 --- /dev/null +++ b/tests/Feature/CheckoutCouponTest.php @@ -0,0 +1,287 @@ +params + * property captures the checkout session params sent to Stripe. The coupons + * endpoint serves $coupon, or throws when null to simulate a bad coupon ID. + */ + private function captureStripeCheckoutParams(?array $coupon = [ + 'id' => 'coupon_test123', + 'valid' => true, + 'amount_off' => 10000, + 'percent_off' => null, + ]): \stdClass + { + $captured = new \stdClass; + $captured->params = null; + + $mockCheckoutSessions = new class($captured) + { + public function __construct(private \stdClass $captured) {} + + public function create(array $params): Session + { + $this->captured->params = $params; + + return Session::constructFrom([ + 'id' => 'cs_test123', + 'url' => 'https://checkout.stripe.com/test-session', + ]); + } + }; + + $mockCheckout = new \stdClass; + $mockCheckout->sessions = $mockCheckoutSessions; + + $mockCustomers = new class + { + public function retrieve(): Customer + { + return Customer::constructFrom([ + 'id' => 'cus_test123', + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } + }; + + $mockCoupons = new class($coupon) + { + public function __construct(private ?array $coupon) {} + + public function retrieve(): Coupon + { + if ($this->coupon === null) { + throw new \RuntimeException('No such coupon'); + } + + return Coupon::constructFrom($this->coupon); + } + }; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->checkout = $mockCheckout; + $mockStripeClient->customers = $mockCustomers; + $mockStripeClient->coupons = $mockCoupons; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + + return $captured; + } + + private function createSubscriber(): User + { + $user = User::factory()->create(['stripe_id' => 'cus_test123']); + + Subscription::factory() + ->for($user) + ->active() + ->create(['stripe_price' => 'price_test_pro']); + + return $user; + } + + #[Test] + public function subscriber_tier_price_wins_amount_tie_over_regular_tier(): void + { + $product = Product::factory()->active()->create(); + ProductPrice::factory()->for($product)->regular()->amount(29900)->create(); + $subscriberPrice = ProductPrice::factory() + ->for($product) + ->subscriber() + ->amount(29900) + ->withCoupon('coupon_test123') + ->create(); + + $bestPrice = $product->getBestPriceForUser($this->createSubscriber()); + + $this->assertTrue($bestPrice->is($subscriberPrice)); + $this->assertSame('coupon_test123', $bestPrice->stripe_coupon_id); + } + + #[Test] + public function guest_resolves_regular_tier_price_without_coupon(): void + { + $product = Product::factory()->active()->create(); + $regularPrice = ProductPrice::factory()->for($product)->regular()->amount(29900)->create(); + ProductPrice::factory() + ->for($product) + ->subscriber() + ->amount(29900) + ->withCoupon('coupon_test123') + ->create(); + + $bestPrice = $product->getBestPriceForUser(null); + + $this->assertTrue($bestPrice->is($regularPrice)); + $this->assertNull($bestPrice->stripe_coupon_id); + } + + #[Test] + public function discounted_amount_deducts_coupon_amount_off_from_stripe(): void + { + $this->captureStripeCheckoutParams(); + + $price = ProductPrice::factory()->subscriber()->amount(29900)->withCoupon('coupon_test123')->create(); + + $this->assertSame(19900, $price->discountedAmount()); + $this->assertSame('199', $price->discountedDisplayAmount()); + } + + #[Test] + public function discounted_amount_supports_percent_off_coupons(): void + { + $this->captureStripeCheckoutParams([ + 'id' => 'coupon_test123', + 'valid' => true, + 'amount_off' => null, + 'percent_off' => 50.0, + ]); + + $price = ProductPrice::factory()->subscriber()->amount(29900)->withCoupon('coupon_test123')->create(); + + $this->assertSame(14950, $price->discountedAmount()); + $this->assertSame('149.50', $price->discountedDisplayAmount()); + } + + #[Test] + public function discounted_amount_falls_back_to_full_amount_when_coupon_cannot_be_retrieved(): void + { + $this->captureStripeCheckoutParams(null); + + $price = ProductPrice::factory()->subscriber()->amount(29900)->withCoupon('coupon_missing')->create(); + + $this->assertSame(29900, $price->discountedAmount()); + } + + #[Test] + public function course_checkout_pre_applies_coupon_for_subscribers(): void + { + Carbon::setTestNow('2026-06-14 23:59:59'); + config(['services.stripe.course_price_id_199' => 'price_test123']); + + $captured = $this->captureStripeCheckoutParams(); + $subscriber = $this->createSubscriber(); + + $masterclass = Product::where('slug', 'nativephp-masterclass')->firstOrFail(); + $masterclass->prices()->update(['amount' => 29900]); + ProductPrice::factory() + ->for($masterclass) + ->subscriber() + ->amount(29900) + ->withCoupon('coupon_test123') + ->create(); + + $this->actingAs($subscriber) + ->post(route('course.checkout')) + ->assertRedirect('https://checkout.stripe.com/test-session'); + + $this->assertNotNull($captured->params, 'Stripe checkout session should have been created'); + $this->assertSame([['coupon' => 'coupon_test123']], $captured->params['discounts']); + $this->assertArrayNotHasKey('allow_promotion_codes', $captured->params); + + Carbon::setTestNow(); + } + + #[Test] + public function course_checkout_keeps_manual_promotion_codes_for_non_subscribers(): void + { + Carbon::setTestNow('2026-06-14 23:59:59'); + config(['services.stripe.course_price_id_199' => 'price_test123']); + + $captured = $this->captureStripeCheckoutParams(); + $user = User::factory()->create(['stripe_id' => 'cus_test123']); + + $masterclass = Product::where('slug', 'nativephp-masterclass')->firstOrFail(); + $masterclass->prices()->update(['amount' => 29900]); + ProductPrice::factory() + ->for($masterclass) + ->subscriber() + ->amount(29900) + ->withCoupon('coupon_test123') + ->create(); + + $this->actingAs($user) + ->post(route('course.checkout')) + ->assertRedirect('https://checkout.stripe.com/test-session'); + + $this->assertNotNull($captured->params, 'Stripe checkout session should have been created'); + $this->assertTrue($captured->params['allow_promotion_codes']); + $this->assertArrayNotHasKey('discounts', $captured->params); + + Carbon::setTestNow(); + } + + #[Test] + public function cart_checkout_pre_applies_coupon_from_product_price(): void + { + $captured = $this->captureStripeCheckoutParams(); + $subscriber = $this->createSubscriber(); + + $product = Product::factory()->active()->create(); + ProductPrice::factory()->for($product)->regular()->amount(29900)->create(); + ProductPrice::factory() + ->for($product) + ->subscriber() + ->amount(29900) + ->withCoupon('coupon_test123') + ->create(); + + $cartService = resolve(CartService::class); + $cart = $cartService->getCart($subscriber); + $cartService->addProduct($cart, $product); + + $this->actingAs($subscriber) + ->post(route('cart.checkout')) + ->assertRedirect('https://checkout.stripe.com/test-session'); + + $this->assertNotNull($captured->params, 'Stripe checkout session should have been created'); + $this->assertSame([['coupon' => 'coupon_test123']], $captured->params['discounts']); + $this->assertArrayNotHasKey('allow_promotion_codes', $captured->params); + + // The full price is charged; Stripe deducts the coupon's discount itself. + $this->assertSame(29900, $captured->params['line_items'][0]['price_data']['unit_amount']); + } + + #[Test] + public function cart_checkout_keeps_manual_promotion_codes_when_none_pre_applied(): void + { + $captured = $this->captureStripeCheckoutParams(); + $user = User::factory()->create(['stripe_id' => 'cus_test123']); + + $product = Product::factory()->active()->create(); + ProductPrice::factory()->for($product)->regular()->amount(29900)->create(); + + $cartService = resolve(CartService::class); + $cart = $cartService->getCart($user); + $cartService->addProduct($cart, $product); + + $this->actingAs($user) + ->post(route('cart.checkout')) + ->assertRedirect('https://checkout.stripe.com/test-session'); + + $this->assertNotNull($captured->params, 'Stripe checkout session should have been created'); + $this->assertTrue($captured->params['allow_promotion_codes']); + $this->assertArrayNotHasKey('discounts', $captured->params); + } +} diff --git a/tests/Feature/CourseContentTest.php b/tests/Feature/CourseContentTest.php index ee11ff30..0e4023d6 100644 --- a/tests/Feature/CourseContentTest.php +++ b/tests/Feature/CourseContentTest.php @@ -10,11 +10,15 @@ use App\Models\LessonProgress; use App\Models\Product; use App\Models\ProductLicense; +use App\Models\ProductPrice; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; +use Laravel\Cashier\Subscription; use Livewire\Livewire; use PHPUnit\Framework\Attributes\Test; +use Stripe\Coupon; +use Stripe\StripeClient; use Tests\TestCase; class CourseContentTest extends TestCase @@ -34,11 +38,13 @@ public function course_page_loads_successfully(): void #[Test] public function course_page_shows_pricing(): void { + Product::where('slug', 'nativephp-masterclass')->first() + ->prices()->update(['amount' => 29900]); + $this ->withoutVite() ->get(route('course')) ->assertStatus(200) - ->assertSee('$199') ->assertSee('$299'); } @@ -67,6 +73,9 @@ public function course_dashboard_shows_purchase_page_for_non_owners(): void { Carbon::setTestNow('2026-06-14 23:59:59'); + Product::where('slug', 'nativephp-masterclass')->first() + ->prices()->update(['amount' => 19900]); + $user = User::factory()->create(); Livewire::actingAs($user) @@ -83,6 +92,9 @@ public function course_dashboard_shows_299_pricing_after_deadline(): void { Carbon::setTestNow('2026-06-15 00:00:01'); + Product::where('slug', 'nativephp-masterclass')->first() + ->prices()->update(['amount' => 29900]); + $user = User::factory()->create(); Livewire::actingAs($user) @@ -94,6 +106,48 @@ public function course_dashboard_shows_299_pricing_after_deadline(): void Carbon::setTestNow(); } + #[Test] + public function course_dashboard_shows_subscriber_price_with_discount(): void + { + $mockCoupons = new class + { + public function retrieve(): Coupon + { + return Coupon::constructFrom([ + 'id' => 'coupon_test123', + 'valid' => true, + 'amount_off' => 10000, + 'percent_off' => null, + ]); + } + }; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->coupons = $mockCoupons; + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + + $masterclass = Product::where('slug', 'nativephp-masterclass')->first(); + $masterclass->prices()->update(['amount' => 29900]); + ProductPrice::factory() + ->for($masterclass) + ->subscriber() + ->amount(29900) + ->withCoupon('coupon_test123') + ->create(); + + $user = User::factory()->create(); + Subscription::factory() + ->for($user) + ->active() + ->create(['stripe_price' => 'price_test_pro']); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('$199') + ->assertSee('$299') + ->assertSee('Your discount is applied automatically at checkout.'); + } + #[Test] public function course_dashboard_shows_modules_and_lessons_for_owners(): void { diff --git a/tests/Feature/CoursePageTest.php b/tests/Feature/CoursePageTest.php index a6b875ac..5aab3453 100644 --- a/tests/Feature/CoursePageTest.php +++ b/tests/Feature/CoursePageTest.php @@ -2,11 +2,15 @@ namespace Tests\Feature; +use App\Models\Product; +use App\Models\ProductPrice; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; +use Laravel\Cashier\Subscription; use PHPUnit\Framework\Attributes\Test; use Stripe\Checkout\Session; +use Stripe\Coupon; use Stripe\Customer; use Stripe\StripeClient; use Tests\TestCase; @@ -15,22 +19,95 @@ class CoursePageTest extends TestCase { use RefreshDatabase; + /** + * Bind a StripeClient mock that serves a $100-off coupon for display price calculations. + */ + private function mockStripeCoupon(): void + { + $mockCoupons = new class + { + public function retrieve(): Coupon + { + return Coupon::constructFrom([ + 'id' => 'coupon_test123', + 'valid' => true, + 'amount_off' => 10000, + 'percent_off' => null, + ]); + } + }; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->coupons = $mockCoupons; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + } + #[Test] public function course_page_loads_successfully(): void { + Product::where('slug', 'nativephp-masterclass')->first() + ->prices()->update(['amount' => 29900]); + $this ->withoutVite() ->get(route('course')) ->assertStatus(200) ->assertSee('The NativePHP Masterclass') - ->assertSee('$199'); + ->assertSee('$299'); } #[Test] - public function course_page_shows_299_pricing_after_deadline(): void + public function course_page_shows_price_from_database(): void + { + Product::where('slug', 'nativephp-masterclass')->first() + ->prices()->update(['amount' => 24900]); + + $this + ->withoutVite() + ->get(route('course')) + ->assertStatus(200) + ->assertSee('$249'); + } + + #[Test] + public function course_page_shows_discounted_price_to_subscribers(): void + { + $this->mockStripeCoupon(); + + $masterclass = Product::where('slug', 'nativephp-masterclass')->first(); + $masterclass->prices()->update(['amount' => 29900]); + ProductPrice::factory() + ->for($masterclass) + ->subscriber() + ->amount(29900) + ->withCoupon('coupon_test123') + ->create(); + + $user = User::factory()->create(); + Subscription::factory() + ->for($user) + ->active() + ->create(['stripe_price' => 'price_test_pro']); + + $this + ->withoutVite() + ->actingAs($user) + ->get(route('course')) + ->assertStatus(200) + ->assertSee('$199') + ->assertSee('$299') + ->assertSee('Your discount is applied automatically at checkout.'); + } + + #[Test] + public function course_page_falls_back_to_299_pricing_without_database_prices(): void { Carbon::setTestNow('2026-06-15 00:00:01'); + Product::where('slug', 'nativephp-masterclass')->first() + ->prices()->delete(); + $this ->withoutVite() ->get(route('course')) From 1967e887cf403343000d9585d2b3d0bdf8944a17 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 16 Jul 2026 12:30:10 +0100 Subject: [PATCH 2/2] feat(pricing): admin-configurable Stripe price ID synced from Stripe Add an optional stripe_price_id to product prices. When set, the amount and currency are locked in the admin and synced from Stripe on every save, and checkout charges that Stripe price instead of an ad-hoc amount (course checkout falls back to the legacy env price IDs when unset). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PricesRelationManager.php | 61 +++++- app/Http/Controllers/CartController.php | 35 ++-- app/Models/ProductPrice.php | 31 +++ database/factories/ProductPriceFactory.php | 7 + ...tripe_price_id_to_product_prices_table.php | 32 ++++ routes/web.php | 13 +- tests/Feature/CheckoutCouponTest.php | 56 ++++++ tests/Feature/StripePriceSyncTest.php | 177 ++++++++++++++++++ 8 files changed, 390 insertions(+), 22 deletions(-) create mode 100644 database/migrations/2026_07_16_101310_add_stripe_price_id_to_product_prices_table.php create mode 100644 tests/Feature/StripePriceSyncTest.php diff --git a/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php b/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php index 1fd83bb9..1a522819 100644 --- a/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php +++ b/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php @@ -3,10 +3,14 @@ namespace App\Filament\Resources\ProductResource\RelationManagers; use App\Enums\PriceTier; +use App\Models\ProductPrice; use Filament\Actions; use Filament\Forms; +use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; +use Filament\Support\Exceptions\Halt; use Filament\Tables; use Filament\Tables\Table; @@ -26,20 +30,31 @@ public function form(Schema $schema): Schema ->default(PriceTier::Regular) ->helperText('Regular = standard price, Subscriber = Pro/Max holders, EAP = Early Access'), + Forms\Components\TextInput::make('stripe_price_id') + ->label('Stripe price ID') + ->placeholder('price_...') + ->live(onBlur: true) + ->maxLength(255) + ->helperText('Optional. When set, checkout charges this Stripe price and the amount & currency are synced from Stripe on save (and are no longer editable here).'), + Forms\Components\TextInput::make('amount') ->label('Price (cents)') - ->required() ->numeric() ->minValue(0) ->suffix('¢') - ->helperText('Enter price in cents (e.g., 4999 = $49.99)'), + ->required(fn (Get $get): bool => blank($get('stripe_price_id'))) + ->disabled(fn (Get $get): bool => filled($get('stripe_price_id'))) + ->helperText(fn (Get $get): string => filled($get('stripe_price_id')) + ? 'Synced from the Stripe price on save.' + : 'Enter price in cents (e.g., 4999 = $49.99)'), Forms\Components\Select::make('currency') ->options([ 'USD' => 'USD', ]) ->default('USD') - ->required(), + ->required(fn (Get $get): bool => blank($get('stripe_price_id'))) + ->disabled(fn (Get $get): bool => filled($get('stripe_price_id'))), Forms\Components\TextInput::make('stripe_coupon_id') ->label('Stripe coupon ID') @@ -53,6 +68,35 @@ public function form(Schema $schema): Schema ]); } + /** + * When a Stripe price ID is set, fetch the price from Stripe and overwrite + * the amount & currency so the database always reflects Stripe. + */ + protected function syncStripePrice(array $data): array + { + if (blank($data['stripe_price_id'] ?? null)) { + return $data; + } + + try { + $details = ProductPrice::detailsFromStripePrice($data['stripe_price_id']); + } catch (\InvalidArgumentException $e) { + Notification::make() + ->title('Could not sync Stripe price') + ->body($e->getMessage()) + ->danger() + ->persistent() + ->send(); + + throw new Halt; + } + + $data['amount'] = $details['amount']; + $data['currency'] = $details['currency']; + + return $data; + } + public function table(Table $table): Table { return $table @@ -76,6 +120,11 @@ public function table(Table $table): Table ->badge() ->color('gray'), + Tables\Columns\TextColumn::make('stripe_price_id') + ->label('Stripe price') + ->placeholder('—') + ->toggleable(), + Tables\Columns\TextColumn::make('stripe_coupon_id') ->label('Coupon') ->placeholder('—') @@ -98,10 +147,12 @@ public function table(Table $table): Table ->label('Active'), ]) ->headerActions([ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->mutateFormDataUsing(fn (array $data): array => $this->syncStripePrice($data)), ]) ->actions([ - Actions\EditAction::make(), + Actions\EditAction::make() + ->mutateFormDataUsing(fn (array $data): array => $this->syncStripePrice($data)), Actions\DeleteAction::make(), ]) ->bulkActions([ diff --git a/app/Http/Controllers/CartController.php b/app/Http/Controllers/CartController.php index aec1734c..06b3b240 100644 --- a/app/Http/Controllers/CartController.php +++ b/app/Http/Controllers/CartController.php @@ -489,23 +489,32 @@ protected function createMultiItemCheckoutSession($cart, $user): Session } elseif ($item->isProduct()) { $product = $item->product; - $couponId = $product->getBestPriceForUser($user)?->stripe_coupon_id; + $bestPrice = $product->getBestPriceForUser($user); - if ($couponId) { - $couponIds[] = $couponId; + if ($bestPrice?->stripe_coupon_id) { + $couponIds[] = $bestPrice->stripe_coupon_id; } - $lineItems[] = [ - 'price_data' => [ - 'currency' => strtolower($item->currency), - 'unit_amount' => $item->product_price_at_addition, - 'product_data' => [ - 'name' => $product->name, - 'description' => $product->description ?? 'NativePHP Product', + // Prefer the real Stripe price when the resolved price is backed by one; + // otherwise fall back to an ad-hoc price built from the stored amount. + if ($bestPrice?->stripe_price_id) { + $lineItems[] = [ + 'price' => $bestPrice->stripe_price_id, + 'quantity' => 1, + ]; + } else { + $lineItems[] = [ + 'price_data' => [ + 'currency' => strtolower($item->currency), + 'unit_amount' => $item->product_price_at_addition, + 'product_data' => [ + 'name' => $product->name, + 'description' => $product->description ?? 'NativePHP Product', + ], ], - ], - 'quantity' => 1, - ]; + 'quantity' => 1, + ]; + } } else { $plugin = $item->plugin; diff --git a/app/Models/ProductPrice.php b/app/Models/ProductPrice.php index 8be531d2..6f747901 100644 --- a/app/Models/ProductPrice.php +++ b/app/Models/ProductPrice.php @@ -153,6 +153,37 @@ function (): array|false { ); } + /** + * Retrieve the amount (in cents) and currency from a Stripe price so a + * price row backed by a Stripe price ID always reflects Stripe as the + * source of truth. + * + * @return array{amount: int, currency: string} + * + * @throws \InvalidArgumentException when the price is missing, archived, or has no fixed amount + */ + public static function detailsFromStripePrice(string $stripePriceId): array + { + try { + $price = Cashier::stripe()->prices->retrieve($stripePriceId); + } catch (\Throwable $e) { + throw new \InvalidArgumentException("Unable to retrieve Stripe price [{$stripePriceId}]: {$e->getMessage()}", previous: $e); + } + + if (! $price->active) { + throw new \InvalidArgumentException("Stripe price [{$stripePriceId}] is archived. Activate it in Stripe or use a different price."); + } + + if ($price->unit_amount === null) { + throw new \InvalidArgumentException("Stripe price [{$stripePriceId}] has no fixed unit amount (metered or customer-chosen pricing is not supported)."); + } + + return [ + 'amount' => $price->unit_amount, + 'currency' => strtoupper($price->currency), + ]; + } + public function isRegularTier(): bool { return $this->tier === PriceTier::Regular; diff --git a/database/factories/ProductPriceFactory.php b/database/factories/ProductPriceFactory.php index b5cdb28a..115ba9f5 100644 --- a/database/factories/ProductPriceFactory.php +++ b/database/factories/ProductPriceFactory.php @@ -66,4 +66,11 @@ public function withCoupon(string $couponId = 'coupon_test123'): static 'stripe_coupon_id' => $couponId, ]); } + + public function withStripePrice(string $stripePriceId = 'price_test123'): static + { + return $this->state(fn (array $attributes) => [ + 'stripe_price_id' => $stripePriceId, + ]); + } } diff --git a/database/migrations/2026_07_16_101310_add_stripe_price_id_to_product_prices_table.php b/database/migrations/2026_07_16_101310_add_stripe_price_id_to_product_prices_table.php new file mode 100644 index 00000000..fb9e8799 --- /dev/null +++ b/database/migrations/2026_07_16_101310_add_stripe_price_id_to_product_prices_table.php @@ -0,0 +1,32 @@ +string('stripe_price_id')->nullable()->after('currency'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('product_prices', function (Blueprint $table) { + $table->dropColumn('stripe_price_id'); + }); + } +}; diff --git a/routes/web.php b/routes/web.php index 4ce9c41f..fecd1cea 100644 --- a/routes/web.php +++ b/routes/web.php @@ -149,10 +149,15 @@ return to_route('course')->with('error', 'You already own this course.'); } + $bestPrice = $product->getBestPriceForUser($user); + + // Prefer the Stripe price ID configured on the resolved price in the admin, + // falling back to the legacy env-configured course price IDs. $priceIncreased = now()->gte(config('services.stripe.course_price_increase_at')); - $priceId = $priceIncreased - ? config('services.stripe.course_price_id_299') - : config('services.stripe.course_price_id_199'); + $priceId = $bestPrice?->stripe_price_id + ?: ($priceIncreased + ? config('services.stripe.course_price_id_299') + : config('services.stripe.course_price_id_199')); if (! $priceId) { return to_route('course')->with('error', 'Course checkout is not configured yet.'); @@ -187,7 +192,7 @@ ]; // Stripe accepts either allow_promotion_codes or discounts on a session, never both. - $couponId = $product->getBestPriceForUser($user)?->stripe_coupon_id; + $couponId = $bestPrice?->stripe_coupon_id; if ($couponId) { unset($sessionOptions['allow_promotion_codes']); diff --git a/tests/Feature/CheckoutCouponTest.php b/tests/Feature/CheckoutCouponTest.php index a394aa37..a55e2502 100644 --- a/tests/Feature/CheckoutCouponTest.php +++ b/tests/Feature/CheckoutCouponTest.php @@ -284,4 +284,60 @@ public function cart_checkout_keeps_manual_promotion_codes_when_none_pre_applied $this->assertTrue($captured->params['allow_promotion_codes']); $this->assertArrayNotHasKey('discounts', $captured->params); } + + #[Test] + public function course_checkout_uses_stripe_price_id_from_the_database_over_config(): void + { + Carbon::setTestNow('2026-06-14 23:59:59'); + config(['services.stripe.course_price_id_199' => 'price_env_config']); + + $captured = $this->captureStripeCheckoutParams(); + $subscriber = $this->createSubscriber(); + + $masterclass = Product::where('slug', 'nativephp-masterclass')->firstOrFail(); + $masterclass->prices()->update(['amount' => 29900]); + ProductPrice::factory() + ->for($masterclass) + ->subscriber() + ->amount(29900) + ->withStripePrice('price_db_override') + ->create(); + + $this->actingAs($subscriber) + ->post(route('course.checkout')) + ->assertRedirect('https://checkout.stripe.com/test-session'); + + $this->assertNotNull($captured->params, 'Stripe checkout session should have been created'); + $this->assertSame('price_db_override', $captured->params['line_items'][0]['price']); + } + + #[Test] + public function cart_checkout_uses_stripe_price_line_item_when_price_is_backed_by_stripe(): void + { + $captured = $this->captureStripeCheckoutParams(); + $subscriber = $this->createSubscriber(); + + $product = Product::factory()->active()->create(); + ProductPrice::factory()->for($product)->regular()->amount(29900)->create(); + ProductPrice::factory() + ->for($product) + ->subscriber() + ->amount(29900) + ->withStripePrice('price_backed') + ->withCoupon('coupon_test123') + ->create(); + + $cartService = resolve(CartService::class); + $cart = $cartService->getCart($subscriber); + $cartService->addProduct($cart, $product); + + $this->actingAs($subscriber) + ->post(route('cart.checkout')) + ->assertRedirect('https://checkout.stripe.com/test-session'); + + $this->assertNotNull($captured->params, 'Stripe checkout session should have been created'); + $this->assertSame('price_backed', $captured->params['line_items'][0]['price']); + $this->assertArrayNotHasKey('price_data', $captured->params['line_items'][0]); + $this->assertSame([['coupon' => 'coupon_test123']], $captured->params['discounts']); + } } diff --git a/tests/Feature/StripePriceSyncTest.php b/tests/Feature/StripePriceSyncTest.php new file mode 100644 index 00000000..97bd2ef0 --- /dev/null +++ b/tests/Feature/StripePriceSyncTest.php @@ -0,0 +1,177 @@ +retrieve returns the given Price data, + * or throws to simulate a missing/unknown price ID. + */ + private function mockStripePrice(?array $price): void + { + $mockPrices = new class($price) + { + public function __construct(private ?array $price) {} + + public function retrieve($id): Price + { + if ($this->price === null) { + throw new InvalidRequestException('No such price: '.$id); + } + + return Price::constructFrom(['id' => $id] + $this->price); + } + }; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->prices = $mockPrices; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + } + + #[Test] + public function details_from_stripe_price_returns_amount_and_currency(): void + { + $this->mockStripePrice(['active' => true, 'unit_amount' => 29900, 'currency' => 'usd']); + + $details = ProductPrice::detailsFromStripePrice('price_test123'); + + $this->assertSame(['amount' => 29900, 'currency' => 'USD'], $details); + } + + #[Test] + public function details_from_stripe_price_rejects_archived_price(): void + { + $this->mockStripePrice(['active' => false, 'unit_amount' => 29900, 'currency' => 'usd']); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('archived'); + + ProductPrice::detailsFromStripePrice('price_test123'); + } + + #[Test] + public function details_from_stripe_price_rejects_price_without_fixed_amount(): void + { + $this->mockStripePrice(['active' => true, 'unit_amount' => null, 'currency' => 'usd']); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('no fixed unit amount'); + + ProductPrice::detailsFromStripePrice('price_test123'); + } + + #[Test] + public function details_from_stripe_price_rejects_unknown_price(): void + { + $this->mockStripePrice(null); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to retrieve Stripe price'); + + ProductPrice::detailsFromStripePrice('price_missing'); + } + + #[Test] + public function admin_creating_price_with_stripe_price_id_syncs_amount_from_stripe(): void + { + $this->mockStripePrice(['active' => true, 'unit_amount' => 29900, 'currency' => 'usd']); + + $admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + + $product = Product::factory()->create(); + + Livewire::actingAs($admin) + ->test(PricesRelationManager::class, [ + 'ownerRecord' => $product, + 'pageClass' => EditProduct::class, + ]) + ->callTableAction('create', data: [ + 'tier' => 'regular', + 'stripe_price_id' => 'price_masterclass', + 'is_active' => true, + ]) + ->assertHasNoTableActionErrors(); + + $this->assertDatabaseHas('product_prices', [ + 'product_id' => $product->id, + 'stripe_price_id' => 'price_masterclass', + 'amount' => 29900, + 'currency' => 'USD', + ]); + } + + #[Test] + public function admin_creating_price_with_invalid_stripe_price_id_is_halted(): void + { + $this->mockStripePrice(null); + + $admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + + $product = Product::factory()->create(); + + Livewire::actingAs($admin) + ->test(PricesRelationManager::class, [ + 'ownerRecord' => $product, + 'pageClass' => EditProduct::class, + ]) + ->callTableAction('create', data: [ + 'tier' => 'regular', + 'stripe_price_id' => 'price_missing', + 'is_active' => true, + ]); + + $this->assertDatabaseMissing('product_prices', [ + 'product_id' => $product->id, + 'stripe_price_id' => 'price_missing', + ]); + } + + #[Test] + public function admin_editing_price_to_add_stripe_price_id_syncs_amount(): void + { + $this->mockStripePrice(['active' => true, 'unit_amount' => 15000, 'currency' => 'usd']); + + $admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + + $product = Product::factory()->create(); + $price = ProductPrice::factory()->for($product)->regular()->amount(9900)->create(); + + Livewire::actingAs($admin) + ->test(PricesRelationManager::class, [ + 'ownerRecord' => $product, + 'pageClass' => EditProduct::class, + ]) + ->callTableAction('edit', $price, data: [ + 'tier' => 'regular', + 'stripe_price_id' => 'price_new', + 'is_active' => true, + ]) + ->assertHasNoTableActionErrors(); + + $this->assertDatabaseHas('product_prices', [ + 'id' => $price->id, + 'stripe_price_id' => 'price_new', + 'amount' => 15000, + ]); + } +}