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,
+ ]);
+ }
+}