Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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')
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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([
Expand Down
58 changes: 46 additions & 12 deletions app/Http/Controllers/CartController.php
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ protected function createMultiItemCheckoutSession($cart, $user): Session

$lineItems = [];
$purchasedItemIds = [];
$couponIds = [];

Log::info('Creating multi-item checkout session', [
'cart_id' => $cart->id,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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}',
Expand All @@ -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]);
Expand Down
34 changes: 29 additions & 5 deletions app/Livewire/Customer/Course/Index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]
Expand All @@ -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]
Expand Down
8 changes: 7 additions & 1 deletion app/Models/Product.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
}

Expand Down
Loading
Loading