diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php
index 43408ab..87f2a19 100644
--- a/app/Http/Controllers/ProductController.php
+++ b/app/Http/Controllers/ProductController.php
@@ -3,12 +3,14 @@
namespace App\Http\Controllers;
use App\Models\Category;
+use App\Models\PricingPlan;
use App\Models\Product;
use App\Models\Tag;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
+use Illuminate\Validation\Rule;
use Laravel\Pennant\Feature;
class ProductController extends Controller
@@ -227,6 +229,11 @@ class ProductController extends Controller
'screenshots.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048',
'remove_screenshots' => 'nullable|array',
'remove_screenshots.*' => 'integer',
+ 'plans' => 'nullable|array',
+ 'plans.*.name' => 'required|string|max:100',
+ 'plans.*.price' => 'required|numeric|min:0|max:99999',
+ 'plans.*.period' => ['required', Rule::in(PricingPlan::PERIODS)],
+ 'plans.*.description' => 'nullable|string|max:255',
];
}
@@ -235,6 +242,18 @@ class ProductController extends Controller
*/
protected function syncDetails(Request $request, Product $product): void
{
+ // The submitted plan list replaces all existing plans
+ $product->pricingPlans()->delete();
+ foreach (array_values((array) $request->input('plans', [])) as $i => $plan) {
+ $product->pricingPlans()->create([
+ 'name' => $plan['name'],
+ 'price' => $plan['price'],
+ 'period' => $plan['period'],
+ 'description' => $plan['description'] ?? null,
+ 'position' => $i,
+ ]);
+ }
+
// Only rows belonging to this product may be removed
$ids = array_filter((array) $request->input('remove_screenshots', []));
if ($ids) {
diff --git a/app/Models/PricingPlan.php b/app/Models/PricingPlan.php
new file mode 100644
index 0000000..c5a1e53
--- /dev/null
+++ b/app/Models/PricingPlan.php
@@ -0,0 +1,31 @@
+ 'decimal:2'];
+ }
+
+ public function product(): BelongsTo
+ {
+ return $this->belongsTo(Product::class);
+ }
+
+ /**
+ * Price formatted for display, e.g. "€9.99" ("€0" for free plans).
+ */
+ public function formattedPrice(): string
+ {
+ return (float) $this->price == 0 ? '€0' : '€'.number_format((float) $this->price, 2);
+ }
+}
diff --git a/app/Models/Product.php b/app/Models/Product.php
index d329425..30894fa 100644
--- a/app/Models/Product.php
+++ b/app/Models/Product.php
@@ -130,6 +130,14 @@ class Product extends Model
return $this->hasMany(ProductScreenshot::class)->orderBy('position')->orderBy('id');
}
+ /**
+ * Get the product's pricing plans in display order.
+ */
+ public function pricingPlans(): HasMany
+ {
+ return $this->hasMany(PricingPlan::class)->orderBy('position')->orderBy('id');
+ }
+
/**
* Get the user that owns the product.
*/
diff --git a/database/migrations/2026_10_17_120000_create_pricing_plans_table.php b/database/migrations/2026_10_17_120000_create_pricing_plans_table.php
new file mode 100644
index 0000000..4c67fbe
--- /dev/null
+++ b/database/migrations/2026_10_17_120000_create_pricing_plans_table.php
@@ -0,0 +1,27 @@
+id();
+ $table->foreignId('product_id')->constrained()->cascadeOnDelete();
+ $table->string('name', 100);
+ $table->decimal('price', 8, 2);
+ $table->string('period');
+ $table->string('description')->nullable();
+ $table->unsignedInteger('position')->default(0);
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('pricing_plans');
+ }
+};
diff --git a/resources/views/components/product/plans-form.blade.php b/resources/views/components/product/plans-form.blade.php
new file mode 100644
index 0000000..57536a7
--- /dev/null
+++ b/resources/views/components/product/plans-form.blade.php
@@ -0,0 +1,40 @@
+@props(['plans' => []])
+
+@php
+ $initial = collect(old('plans', $plans))->map(fn ($p) => [
+ 'name' => $p['name'] ?? '',
+ 'price' => $p['price'] ?? '',
+ 'period' => $p['period'] ?? 'monthly',
+ 'description' => $p['description'] ?? '',
+ ])->values()->all();
+ $input = 'block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm';
+@endphp
+
+
+
+
+
+ @foreach($errors->get('plans.*') as $messages)
+
{{ $messages[0] }}
+ @endforeach
+ @error('plans')
+
{{ $message }}
+ @enderror
+
+
+
diff --git a/resources/views/components/product/pricing.blade.php b/resources/views/components/product/pricing.blade.php
new file mode 100644
index 0000000..706bc3b
--- /dev/null
+++ b/resources/views/components/product/pricing.blade.php
@@ -0,0 +1,21 @@
+@props(['product'])
+
+@php($plans = $product->pricingPlans)
+
+@if($plans->isNotEmpty())
+ class('bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6') }}>
+
Pricing
+
+ @foreach($plans as $plan)
+
+
{{ $plan->name }}
+
{{ $plan->formattedPrice() }}
+
{{ $plan->period }}
+ @if($plan->description)
+
{{ $plan->description }}
+ @endif
+
+ @endforeach
+
+
+@endif
diff --git a/resources/views/products/create.blade.php b/resources/views/products/create.blade.php
index d3640a4..fca263d 100644
--- a/resources/views/products/create.blade.php
+++ b/resources/views/products/create.blade.php
@@ -207,6 +207,8 @@
+
+
diff --git a/resources/views/products/edit.blade.php b/resources/views/products/edit.blade.php
index 398514a..4f57482 100644
--- a/resources/views/products/edit.blade.php
+++ b/resources/views/products/edit.blade.php
@@ -251,6 +251,8 @@
+
+
diff --git a/resources/views/products/show.blade.php b/resources/views/products/show.blade.php
index 59efb01..eb54304 100644
--- a/resources/views/products/show.blade.php
+++ b/resources/views/products/show.blade.php
@@ -181,7 +181,7 @@
@endif
-
+
diff --git a/tests/Feature/ProductPricingTest.php b/tests/Feature/ProductPricingTest.php
new file mode 100644
index 0000000..b7faa53
--- /dev/null
+++ b/tests/Feature/ProductPricingTest.php
@@ -0,0 +1,78 @@
+ 'Priced Product',
+ 'description' => 'A product description',
+ 'website_url' => 'https://example.com',
+ 'category_id' => makeCategory()->id,
+ ], $overrides);
+}
+
+test('submitter adds plans', function () {
+ $user = userWithRole('user');
+
+ $this->actingAs($user)->post(route('products.store'), pricingPayload([
+ 'plans' => [
+ ['name' => 'Free', 'price' => '0', 'period' => 'monthly', 'description' => 'Basics'],
+ ['name' => 'Team', 'price' => '9.99', 'period' => 'monthly'],
+ ],
+ ]))->assertRedirect()->assertSessionHasNoErrors();
+
+ $plans = Product::firstOrFail()->pricingPlans;
+ expect($plans->pluck('name')->all())->toBe(['Free', 'Team']);
+ expect($plans->pluck('position')->all())->toBe([0, 1]);
+ expect((float) $plans[1]->price)->toBe(9.99);
+});
+
+test('invalid plans fail validation and save nothing', function (array $plan, string $key) {
+ $user = userWithRole('user');
+
+ $this->actingAs($user)->post(route('products.store'), pricingPayload([
+ 'plans' => [array_merge(['name' => 'Pro', 'price' => '5', 'period' => 'yearly'], $plan)],
+ ]))->assertSessionHasErrors($key);
+
+ expect(Product::count())->toBe(0);
+ expect(PricingPlan::count())->toBe(0);
+})->with([
+ 'negative price' => [['price' => '-1'], 'plans.0.price'],
+ 'non numeric price' => [['price' => 'abc'], 'plans.0.price'],
+ 'bad period' => [['period' => 'weekly'], 'plans.0.period'],
+ 'missing name' => [['name' => ''], 'plans.0.name'],
+]);
+
+test('update replaces the plan list', function () {
+ $user = userWithRole('user');
+ $product = Product::factory()->pending()->create(['user_id' => $user->id]);
+ $product->pricingPlans()->create(['name' => 'Old', 'price' => 1, 'period' => 'monthly', 'position' => 0]);
+ $product->pricingPlans()->create(['name' => 'Older', 'price' => 2, 'period' => 'yearly', 'position' => 1]);
+
+ $this->actingAs($user)->put(route('products.update', $product), pricingPayload([
+ 'category_id' => $product->category_id,
+ 'plans' => [['name' => 'New', 'price' => '3', 'period' => 'one-time']],
+ ]))->assertRedirect();
+
+ expect($product->pricingPlans()->pluck('name')->all())->toBe(['New']);
+});
+
+test('show page prints price and period', function () {
+ $product = Product::factory()->approved()->create();
+ $product->pricingPlans()->create(['name' => 'Free', 'price' => 0, 'period' => 'monthly', 'position' => 0]);
+ $product->pricingPlans()->create(['name' => 'Team', 'price' => 9.99, 'period' => 'monthly', 'position' => 1]);
+
+ $this->get(route('products.show', $product))->assertOk()
+ ->assertSee('€9.99')->assertSee('monthly')->assertSee('€0');
+});
+
+test('no plans, no pricing section', function () {
+ $product = Product::factory()->approved()->create();
+
+ $this->get(route('products.show', $product))->assertOk()->assertDontSee('data-pricing', false);
+});