diff --git a/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php b/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php index 79b80665..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,36 @@ 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') + ->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') @@ -48,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 @@ -71,6 +120,16 @@ 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('โ€”') + ->toggleable(), + Tables\Columns\IconColumn::make('is_active') ->label('Active') ->boolean() @@ -88,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 a505e09d..06b3b240 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,17 +489,32 @@ protected function createMultiItemCheckoutSession($cart, $user): Session } elseif ($item->isProduct()) { $product = $item->product; - $lineItems[] = [ - 'price_data' => [ - 'currency' => strtolower($item->currency), - 'unit_amount' => $item->product_price_at_addition, - 'product_data' => [ - 'name' => $product->name, - 'description' => $product->description ?? 'NativePHP Product', + $bestPrice = $product->getBestPriceForUser($user); + + if ($bestPrice?->stripe_coupon_id) { + $couponIds[] = $bestPrice->stripe_coupon_id; + } + + // 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; @@ -527,7 +543,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 +565,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..6f747901 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,121 @@ 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; + } + } + ); + } + + /** + * 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 086cc3f4..115ba9f5 100644 --- a/database/factories/ProductPriceFactory.php +++ b/database/factories/ProductPriceFactory.php @@ -59,4 +59,18 @@ 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, + ]); + } + + 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_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/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/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'); @@ -144,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.'); @@ -161,7 +171,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 +189,17 @@ 'metadata' => $metadata, ], ], - ]); + ]; + + // Stripe accepts either allow_promotion_codes or discounts on a session, never both. + $couponId = $bestPrice?->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..a55e2502 --- /dev/null +++ b/tests/Feature/CheckoutCouponTest.php @@ -0,0 +1,343 @@ +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); + } + + #[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/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')) 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, + ]); + } +}