From b1cf8b5f2222b076a9ea207e1e183adee1800539 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Mon, 5 Jan 2026 21:43:00 +0100 Subject: [PATCH 1/6] feature - 17 - Add mode config --- app/Enums/AppModeEnum.php | 24 ++++++++++++++++++++++++ app/helpers.php | 17 +++++++++++++++++ composer.json | 3 +++ config/app.php | 12 ++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 app/Enums/AppModeEnum.php create mode 100644 app/helpers.php diff --git a/app/Enums/AppModeEnum.php b/app/Enums/AppModeEnum.php new file mode 100644 index 0000000..9a75881 --- /dev/null +++ b/app/Enums/AppModeEnum.php @@ -0,0 +1,24 @@ +isApp(); + } +} + +if (! function_exists('is_mode_saas')) { + function is_mode_saas(): bool + { + return AppModeEnum::current()->isSaas(); + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index a4197fb..e1022f9 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,9 @@ "phpunit/phpunit": "^11.0.1" }, "autoload": { + "files": [ + "app/helpers.php" + ], "psr-4": { "App\\": "app/", "DishPlanner\\": "src/DishPlanner/", diff --git a/config/app.php b/config/app.php index df3f5f0..09dd952 100644 --- a/config/app.php +++ b/config/app.php @@ -28,6 +28,18 @@ 'env' => env('APP_ENV', 'production'), + /* + |-------------------------------------------------------------------------- + | Application Mode + |-------------------------------------------------------------------------- + | + | Determines the application deployment mode: 'app' for self-hosted, + | 'saas' for multi-tenant SaaS, 'demo' for demonstration instances. + | + */ + + 'mode' => env('APP_MODE', 'app'), + /* |-------------------------------------------------------------------------- | Application Debug Mode -- 2.45.2 From 71668ea5bdc58767246f14c10ec8b9d7216be94f Mon Sep 17 00:00:00 2001 From: myrmidex Date: Mon, 5 Jan 2026 23:51:50 +0100 Subject: [PATCH 2/6] feature - 18 - Add subscriptions --- app/Enums/SubscriptionStatusEnum.php | 23 ++++++++ .../Controllers/SubscriptionController.php | 47 +++++++++++++++ app/Http/Middleware/RequireSubscription.php | 25 ++++++++ app/Models/Planner.php | 12 ++++ app/Models/Subscription.php | 57 +++++++++++++++++++ bootstrap/app.php | 18 +++--- ...1_05_000000_create_subscriptions_table.php | 28 +++++++++ resources/views/subscription/index.blade.php | 33 +++++++++++ routes/web.php | 36 ++++++------ routes/web/subscription.php | 13 +++++ 10 files changed, 267 insertions(+), 25 deletions(-) create mode 100644 app/Enums/SubscriptionStatusEnum.php create mode 100644 app/Http/Controllers/SubscriptionController.php create mode 100644 app/Http/Middleware/RequireSubscription.php create mode 100644 app/Models/Subscription.php create mode 100644 database/migrations/2025_01_05_000000_create_subscriptions_table.php create mode 100644 resources/views/subscription/index.blade.php create mode 100644 routes/web/subscription.php diff --git a/app/Enums/SubscriptionStatusEnum.php b/app/Enums/SubscriptionStatusEnum.php new file mode 100644 index 0000000..29bbf3c --- /dev/null +++ b/app/Enums/SubscriptionStatusEnum.php @@ -0,0 +1,23 @@ +user(); + + if ($planner->hasActiveSubscription()) { + return redirect()->route('dashboard'); + } + + Subscription::create([ + 'planner_id' => $planner->id, + 'stripe_subscription_id' => 'mock_' . Str::random(14), + 'stripe_customer_id' => 'mock_' . Str::random(14), + 'status' => SubscriptionStatusEnum::ACTIVE, + 'plan' => 'default', + ]); + + return redirect()->route('dashboard')->with('success', 'Subscription activated!'); + } + + public function cancel(Request $request): RedirectResponse + { + $subscription = $request->user()->subscription; + + if (! $subscription) { + return back()->with('error', 'No active subscription found.'); + } + + $subscription->update([ + 'status' => SubscriptionStatusEnum::CANCELED, + 'ends_at' => now()->addDays(1), // Placeholder until Stripe webhook sets actual end date + ]); + + return back()->with('success', 'Subscription canceled. Access will continue until the end of your billing period.'); + } +} diff --git a/app/Http/Middleware/RequireSubscription.php b/app/Http/Middleware/RequireSubscription.php new file mode 100644 index 0000000..9387919 --- /dev/null +++ b/app/Http/Middleware/RequireSubscription.php @@ -0,0 +1,25 @@ +user(); + + if (! $planner?->hasActiveSubscription()) { + return redirect()->route('subscription.index'); + } + + return $next($request); + } +} diff --git a/app/Models/Planner.php b/app/Models/Planner.php index 16614bb..0d7cc8a 100644 --- a/app/Models/Planner.php +++ b/app/Models/Planner.php @@ -4,12 +4,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; /** * @property int $id + * @property Subscription $subscription * @property static PlannerFactory factory($count = null, $state = []) * @method static first() */ @@ -33,4 +35,14 @@ public function schedules(): HasMany { return $this->hasMany(Schedule::class); } + + public function subscription(): HasOne + { + return $this->hasOne(Subscription::class); + } + + public function hasActiveSubscription(): bool + { + return $this->subscription?->isValid() ?? false; + } } diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php new file mode 100644 index 0000000..e94d18f --- /dev/null +++ b/app/Models/Subscription.php @@ -0,0 +1,57 @@ + SubscriptionStatusEnum::class, + 'trial_ends_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + + public function planner(): BelongsTo + { + return $this->belongsTo(Planner::class); + } + + public function isValid(): bool + { + if ($this->status->allowsAccess()) { + return true; + } + + // Canceled but still in grace period + if ($this->status === SubscriptionStatusEnum::CANCELED && $this->ends_at?->isFuture()) { + return true; + } + + return false; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c6a8eab..0cc39ff 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,17 +1,14 @@ group(base_path('routes/web/subscription.php')); + }, ) ->withMiddleware(function (Middleware $middleware) { // Apply ForceJsonResponse only to API routes $middleware->api(ForceJsonResponse::class); + + $middleware->alias([ + 'subscription' => RequireSubscription::class, + ]); }) ->withExceptions(function (Exceptions $exceptions) { $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { @@ -65,7 +70,4 @@ } }); }) - ->withCommands([ - GenerateScheduleCommand::class, - ]) ->create(); diff --git a/database/migrations/2025_01_05_000000_create_subscriptions_table.php b/database/migrations/2025_01_05_000000_create_subscriptions_table.php new file mode 100644 index 0000000..c9f815f --- /dev/null +++ b/database/migrations/2025_01_05_000000_create_subscriptions_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('planner_id')->constrained()->cascadeOnDelete(); + $table->string('stripe_subscription_id')->unique(); + $table->string('stripe_customer_id'); + $table->string('status'); + $table->string('plan'); + $table->timestamp('trial_ends_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/resources/views/subscription/index.blade.php b/resources/views/subscription/index.blade.php new file mode 100644 index 0000000..ac19ebe --- /dev/null +++ b/resources/views/subscription/index.blade.php @@ -0,0 +1,33 @@ + +
+
+

SUBSCRIPTION

+ + @if(auth()->user()->hasActiveSubscription()) +
+

Active Subscription

+

You have an active subscription.

+ +
+ @csrf + +
+
+ @else +
+

Subscribe to Dish Planner

+

Get access to all features.

+ +
+ @csrf + +
+
+ @endif +
+
+
diff --git a/routes/web.php b/routes/web.php index eb414a9..6bcee6c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -23,22 +23,24 @@ // Authenticated routes Route::middleware('auth')->group(function () { - Route::get('/dashboard', function () { - return view('dashboard'); - })->name('dashboard'); - Route::post('/logout', [LoginController::class, 'logout'])->name('logout'); - - // Placeholder routes for future Livewire components - Route::get('/dishes', function () { - return view('dishes.index'); - })->name('dishes.index'); - - Route::get('/schedule', function () { - return view('schedule.index'); - })->name('schedule.index'); - - Route::get('/users', function () { - return view('users.index'); - })->name('users.index'); + + // Routes requiring active subscription in SaaS mode + Route::middleware('subscription')->group(function () { + Route::get('/dashboard', function () { + return view('dashboard'); + })->name('dashboard'); + + Route::get('/dishes', function () { + return view('dishes.index'); + })->name('dishes.index'); + + Route::get('/schedule', function () { + return view('schedule.index'); + })->name('schedule.index'); + + Route::get('/users', function () { + return view('users.index'); + })->name('users.index'); + }); }); diff --git a/routes/web/subscription.php b/routes/web/subscription.php new file mode 100644 index 0000000..63bbaf0 --- /dev/null +++ b/routes/web/subscription.php @@ -0,0 +1,13 @@ +group(function () { + Route::get('/subscription', function () { + return view('subscription.index'); + })->name('subscription.index'); + + Route::post('/subscription/subscribe', [SubscriptionController::class, 'subscribe'])->name('subscription.subscribe'); + Route::post('/subscription/cancel', [SubscriptionController::class, 'cancel'])->name('subscription.cancel'); +}); -- 2.45.2 From bcbc1ce8e7d9d10c92090eab409de599232c9912 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Tue, 6 Jan 2026 20:59:16 +0100 Subject: [PATCH 3/6] feature - 18 - Add payments to subscription flow --- Dockerfile | 3 +- Dockerfile.dev | 1 + app/Enums/SubscriptionStatusEnum.php | 23 ------- .../Controllers/SubscriptionController.php | 61 +++++++++++++------ app/Http/Middleware/RequireSubscription.php | 2 +- app/Models/Planner.php | 15 +---- app/Models/Subscription.php | 57 ----------------- app/Providers/AppServiceProvider.php | 4 ++ bootstrap/app.php | 5 ++ composer.json | 1 + config/services.php | 8 +++ ...6_01_06_000525_create_customer_columns.php | 34 +++++++++++ ..._06_000526_create_subscriptions_table.php} | 19 ++++-- ...000527_create_subscription_items_table.php | 34 +++++++++++ ...d_meter_id_to_subscription_items_table.php | 28 +++++++++ ...event_name_to_subscription_items_table.php | 28 +++++++++ resources/views/dashboard.blade.php | 7 +++ resources/views/subscription/index.blade.php | 36 ++++++++--- routes/web/subscription.php | 7 ++- shell.nix | 15 ++++- 20 files changed, 259 insertions(+), 129 deletions(-) delete mode 100644 app/Enums/SubscriptionStatusEnum.php delete mode 100644 app/Models/Subscription.php create mode 100644 database/migrations/2026_01_06_000525_create_customer_columns.php rename database/migrations/{2025_01_05_000000_create_subscriptions_table.php => 2026_01_06_000526_create_subscriptions_table.php} (56%) create mode 100644 database/migrations/2026_01_06_000527_create_subscription_items_table.php create mode 100644 database/migrations/2026_01_06_000528_add_meter_id_to_subscription_items_table.php create mode 100644 database/migrations/2026_01_06_000529_add_meter_event_name_to_subscription_items_table.php diff --git a/Dockerfile b/Dockerfile index c6d7f86..902cec7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,8 @@ RUN install-php-extensions \ opcache \ zip \ gd \ - intl + intl \ + bcmath # Install Composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer diff --git a/Dockerfile.dev b/Dockerfile.dev index f2a67c5..c36d143 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -18,6 +18,7 @@ RUN install-php-extensions \ zip \ gd \ intl \ + bcmath \ xdebug # Install Composer diff --git a/app/Enums/SubscriptionStatusEnum.php b/app/Enums/SubscriptionStatusEnum.php deleted file mode 100644 index 29bbf3c..0000000 --- a/app/Enums/SubscriptionStatusEnum.php +++ /dev/null @@ -1,23 +0,0 @@ -user(); - if ($planner->hasActiveSubscription()) { + if ($planner->subscribed()) { return redirect()->route('dashboard'); } - Subscription::create([ - 'planner_id' => $planner->id, - 'stripe_subscription_id' => 'mock_' . Str::random(14), - 'stripe_customer_id' => 'mock_' . Str::random(14), - 'status' => SubscriptionStatusEnum::ACTIVE, - 'plan' => 'default', - ]); + $plan = $request->input('plan', 'monthly'); + $priceId = $plan === 'yearly' + ? env('STRIPE_PRICE_YEARLY') + : env('STRIPE_PRICE_MONTHLY'); + + return $planner->newSubscription('default', $priceId) + ->checkout([ + 'success_url' => route('subscription.success') . '?session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => route('subscription.index'), + ]); + } + + public function success(Request $request): RedirectResponse + { + $sessionId = $request->query('session_id'); + + if ($sessionId) { + $planner = $request->user(); + $session = Cashier::stripe()->checkout->sessions->retrieve($sessionId, [ + 'expand' => ['subscription'], + ]); + + if ($session->subscription && ! $planner->subscribed()) { + $subscription = $session->subscription; + + $planner->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => $subscription->id, + 'stripe_status' => $subscription->status, + 'stripe_price' => $subscription->items->data[0]->price->id ?? null, + 'quantity' => $subscription->items->data[0]->quantity ?? 1, + 'trial_ends_at' => $subscription->trial_end ? now()->setTimestamp($subscription->trial_end) : null, + 'ends_at' => null, + ]); + } + } return redirect()->route('dashboard')->with('success', 'Subscription activated!'); } public function cancel(Request $request): RedirectResponse { - $subscription = $request->user()->subscription; + $planner = $request->user(); - if (! $subscription) { + if (! $planner->subscribed()) { return back()->with('error', 'No active subscription found.'); } - $subscription->update([ - 'status' => SubscriptionStatusEnum::CANCELED, - 'ends_at' => now()->addDays(1), // Placeholder until Stripe webhook sets actual end date - ]); + $planner->subscription()->cancel(); return back()->with('success', 'Subscription canceled. Access will continue until the end of your billing period.'); } diff --git a/app/Http/Middleware/RequireSubscription.php b/app/Http/Middleware/RequireSubscription.php index 9387919..c07b1d8 100644 --- a/app/Http/Middleware/RequireSubscription.php +++ b/app/Http/Middleware/RequireSubscription.php @@ -16,7 +16,7 @@ public function handle(Request $request, Closure $next): Response $planner = $request->user(); - if (! $planner?->hasActiveSubscription()) { + if (! $planner?->subscribed()) { return redirect()->route('subscription.index'); } diff --git a/app/Models/Planner.php b/app/Models/Planner.php index 0d7cc8a..f8307fd 100644 --- a/app/Models/Planner.php +++ b/app/Models/Planner.php @@ -4,20 +4,19 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Cashier\Billable; use Laravel\Sanctum\HasApiTokens; /** * @property int $id - * @property Subscription $subscription * @property static PlannerFactory factory($count = null, $state = []) * @method static first() */ class Planner extends Authenticatable { - use HasApiTokens, HasFactory, Notifiable; + use Billable, HasApiTokens, HasFactory, Notifiable; protected $fillable = [ 'name', 'email', 'password', @@ -35,14 +34,4 @@ public function schedules(): HasMany { return $this->hasMany(Schedule::class); } - - public function subscription(): HasOne - { - return $this->hasOne(Subscription::class); - } - - public function hasActiveSubscription(): bool - { - return $this->subscription?->isValid() ?? false; - } } diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php deleted file mode 100644 index e94d18f..0000000 --- a/app/Models/Subscription.php +++ /dev/null @@ -1,57 +0,0 @@ - SubscriptionStatusEnum::class, - 'trial_ends_at' => 'datetime', - 'ends_at' => 'datetime', - ]; - - public function planner(): BelongsTo - { - return $this->belongsTo(Planner::class); - } - - public function isValid(): bool - { - if ($this->status->allowsAccess()) { - return true; - } - - // Canceled but still in grace period - if ($this->status === SubscriptionStatusEnum::CANCELED && $this->ends_at?->isFuture()) { - return true; - } - - return false; - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0bbbb1e..3067e8c 100755 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,10 +4,12 @@ use App\Exceptions\CustomException; use App\Models\Dish; +use App\Models\Planner; use App\Models\Schedule; use App\Models\ScheduledUserDish; use App\Models\User; use App\Models\UserDish; +use Laravel\Cashier\Cashier; use DishPlanner\Dish\Policies\DishPolicy; use DishPlanner\Schedule\Policies\SchedulePolicy; use DishPlanner\ScheduledUserDish\Policies\ScheduledUserDishPolicy; @@ -45,6 +47,8 @@ public function render($request, Throwable $e) public function boot(): void { + Cashier::useCustomerModel(Planner::class); + Gate::policy(Dish::class, DishPolicy::class); Gate::policy(Schedule::class, SchedulePolicy::class); Gate::policy(ScheduledUserDish::class, ScheduledUserDishPolicy::class); diff --git a/bootstrap/app.php b/bootstrap/app.php index 0cc39ff..e63f22c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -30,6 +30,11 @@ $middleware->alias([ 'subscription' => RequireSubscription::class, ]); + + // Exclude Stripe webhook from CSRF verification + $middleware->validateCsrfTokens(except: [ + 'stripe/webhook', + ]); }) ->withExceptions(function (Exceptions $exceptions) { $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { diff --git a/composer.json b/composer.json index e1022f9..2ecb17b 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "license": "MIT", "require": { "php": "^8.2", + "laravel/cashier": "^16.1", "laravel/framework": "^12.9.2", "laravel/sanctum": "^4.0", "laravel/tinker": "^2.9", diff --git a/config/services.php b/config/services.php index 27a3617..66bade1 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,12 @@ ], ], + 'stripe' => [ + 'key' => env('STRIPE_KEY'), + 'secret' => env('STRIPE_SECRET'), + 'webhook' => [ + 'secret' => env('STRIPE_WEBHOOK_SECRET'), + ], + ], + ]; diff --git a/database/migrations/2026_01_06_000525_create_customer_columns.php b/database/migrations/2026_01_06_000525_create_customer_columns.php new file mode 100644 index 0000000..131d232 --- /dev/null +++ b/database/migrations/2026_01_06_000525_create_customer_columns.php @@ -0,0 +1,34 @@ +string('stripe_id')->nullable()->index(); + $table->string('pm_type')->nullable(); + $table->string('pm_last_four', 4)->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('planners', function (Blueprint $table) { + $table->dropIndex([ + 'stripe_id', + ]); + + $table->dropColumn([ + 'stripe_id', + 'pm_type', + 'pm_last_four', + 'trial_ends_at', + ]); + }); + } +}; diff --git a/database/migrations/2025_01_05_000000_create_subscriptions_table.php b/database/migrations/2026_01_06_000526_create_subscriptions_table.php similarity index 56% rename from database/migrations/2025_01_05_000000_create_subscriptions_table.php rename to database/migrations/2026_01_06_000526_create_subscriptions_table.php index c9f815f..9043296 100644 --- a/database/migrations/2025_01_05_000000_create_subscriptions_table.php +++ b/database/migrations/2026_01_06_000526_create_subscriptions_table.php @@ -6,21 +6,30 @@ return new class extends Migration { + /** + * Run the migrations. + */ public function up(): void { Schema::create('subscriptions', function (Blueprint $table) { $table->id(); - $table->foreignId('planner_id')->constrained()->cascadeOnDelete(); - $table->string('stripe_subscription_id')->unique(); - $table->string('stripe_customer_id'); - $table->string('status'); - $table->string('plan'); + $table->foreignId('planner_id'); + $table->string('type'); + $table->string('stripe_id')->unique(); + $table->string('stripe_status'); + $table->string('stripe_price')->nullable(); + $table->integer('quantity')->nullable(); $table->timestamp('trial_ends_at')->nullable(); $table->timestamp('ends_at')->nullable(); $table->timestamps(); + + $table->index(['planner_id', 'stripe_status']); }); } + /** + * Reverse the migrations. + */ public function down(): void { Schema::dropIfExists('subscriptions'); diff --git a/database/migrations/2026_01_06_000527_create_subscription_items_table.php b/database/migrations/2026_01_06_000527_create_subscription_items_table.php new file mode 100644 index 0000000..420e23f --- /dev/null +++ b/database/migrations/2026_01_06_000527_create_subscription_items_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('subscription_id'); + $table->string('stripe_id')->unique(); + $table->string('stripe_product'); + $table->string('stripe_price'); + $table->integer('quantity')->nullable(); + $table->timestamps(); + + $table->index(['subscription_id', 'stripe_price']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('subscription_items'); + } +}; diff --git a/database/migrations/2026_01_06_000528_add_meter_id_to_subscription_items_table.php b/database/migrations/2026_01_06_000528_add_meter_id_to_subscription_items_table.php new file mode 100644 index 0000000..033bb82 --- /dev/null +++ b/database/migrations/2026_01_06_000528_add_meter_id_to_subscription_items_table.php @@ -0,0 +1,28 @@ +string('meter_id')->nullable()->after('stripe_price'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscription_items', function (Blueprint $table) { + $table->dropColumn('meter_id'); + }); + } +}; diff --git a/database/migrations/2026_01_06_000529_add_meter_event_name_to_subscription_items_table.php b/database/migrations/2026_01_06_000529_add_meter_event_name_to_subscription_items_table.php new file mode 100644 index 0000000..b157b3a --- /dev/null +++ b/database/migrations/2026_01_06_000529_add_meter_event_name_to_subscription_items_table.php @@ -0,0 +1,28 @@ +string('meter_event_name')->nullable()->after('quantity'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscription_items', function (Blueprint $table) { + $table->dropColumn('meter_event_name'); + }); + } +}; diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 3724df0..e77b7b0 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1,6 +1,13 @@
+ @if (session('success')) +
+

Welcome to Dish Planner!

+

Your subscription is now active. Start planning your dishes!

+
+ @endif +

DASHBOARD

diff --git a/resources/views/subscription/index.blade.php b/resources/views/subscription/index.blade.php index ac19ebe..a44e7e9 100644 --- a/resources/views/subscription/index.blade.php +++ b/resources/views/subscription/index.blade.php @@ -3,7 +3,7 @@

SUBSCRIPTION

- @if(auth()->user()->hasActiveSubscription()) + @if(auth()->user()->subscribed())

Active Subscription

You have an active subscription.

@@ -17,15 +17,33 @@
@else
-

Subscribe to Dish Planner

-

Get access to all features.

+

Subscribe to Dish Planner

-
- @csrf - -
+
+
+ @csrf + +
+

Monthly

+

Billed monthly

+ +
+
+ +
+ @csrf + +
+

Yearly

+

Billed annually

+ +
+
+
@endif
diff --git a/routes/web/subscription.php b/routes/web/subscription.php index 63bbaf0..d3258b9 100644 --- a/routes/web/subscription.php +++ b/routes/web/subscription.php @@ -2,12 +2,17 @@ use App\Http\Controllers\SubscriptionController; use Illuminate\Support\Facades\Route; +use Laravel\Cashier\Http\Controllers\WebhookController; + +// Stripe webhook (no auth, CSRF excluded in bootstrap/app.php) +Route::post('/stripe/webhook', [WebhookController::class, 'handleWebhook'])->name('cashier.webhook'); Route::middleware('auth')->group(function () { Route::get('/subscription', function () { return view('subscription.index'); })->name('subscription.index'); - Route::post('/subscription/subscribe', [SubscriptionController::class, 'subscribe'])->name('subscription.subscribe'); + Route::post('/subscription/checkout', [SubscriptionController::class, 'checkout'])->name('subscription.checkout'); + Route::get('/subscription/success', [SubscriptionController::class, 'success'])->name('subscription.success'); Route::post('/subscription/cancel', [SubscriptionController::class, 'cancel'])->name('subscription.cancel'); }); diff --git a/shell.nix b/shell.nix index b8509d8..4ff3195 100644 --- a/shell.nix +++ b/shell.nix @@ -88,7 +88,7 @@ pkgs.mkShell { local REGISTRY="codeberg.org" local NAMESPACE="lvl0" local IMAGE_NAME="dish-planner" - + echo "🔨 Building production image..." podman build -f Dockerfile -t ''${REGISTRY}/''${NAMESPACE}/''${IMAGE_NAME}:''${TAG} . @@ -112,6 +112,19 @@ pkgs.mkShell { fi } + prod-build-nc() { + local TAG="''${1:-latest}" + local REGISTRY="codeberg.org" + local NAMESPACE="lvl0" + local IMAGE_NAME="dish-planner" + + echo "🔨 Building production image (no cache)..." + podman build --no-cache -f Dockerfile -t ''${REGISTRY}/''${NAMESPACE}/''${IMAGE_NAME}:''${TAG} . + + echo "✅ Build complete: ''${REGISTRY}/''${NAMESPACE}/''${IMAGE_NAME}:''${TAG}" + echo "Run 'prod-push' to push to Codeberg" + } + prod-build-push() { local TAG="''${1:-latest}" prod-build "$TAG" && prod-push "$TAG" -- 2.45.2 From fc6fd87c4b07f8b3965d03eb2bb2ffb84f39a776 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 7 Jan 2026 01:08:58 +0100 Subject: [PATCH 4/6] feature - 18 - Add billing page --- .../Controllers/SubscriptionController.php | 39 ++++++++++++++- app/Http/Middleware/RequireSaasMode.php | 19 +++++++ bootstrap/app.php | 2 + config/services.php | 2 + resources/views/billing/index.blade.php | 49 +++++++++++++++++++ .../views/components/layouts/app.blade.php | 10 ++++ routes/web.php | 3 ++ 7 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 app/Http/Middleware/RequireSaasMode.php create mode 100644 resources/views/billing/index.blade.php diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 46f4c3d..d84e561 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -19,8 +19,8 @@ public function checkout(Request $request) $plan = $request->input('plan', 'monthly'); $priceId = $plan === 'yearly' - ? env('STRIPE_PRICE_YEARLY') - : env('STRIPE_PRICE_MONTHLY'); + ? config('services.stripe.price_yearly') + : config('services.stripe.price_monthly'); return $planner->newSubscription('default', $priceId) ->checkout([ @@ -57,6 +57,41 @@ public function success(Request $request): RedirectResponse return redirect()->route('dashboard')->with('success', 'Subscription activated!'); } + public function billing(Request $request) + { + $planner = $request->user(); + $subscription = $planner->subscription(); + + if (! $subscription) { + return redirect()->route('subscription.index'); + } + + $planType = match ($subscription->stripe_price) { + config('services.stripe.price_yearly') => 'Yearly', + config('services.stripe.price_monthly') => 'Monthly', + default => 'Unknown', + }; + + $nextBillingDate = null; + if ($subscription->stripe_status === 'active') { + try { + $stripeSubscription = Cashier::stripe()->subscriptions->retrieve($subscription->stripe_id); + $nextBillingDate = $stripeSubscription->current_period_end + ? now()->setTimestamp($stripeSubscription->current_period_end) + : null; + } catch (\Exception $e) { + // Stripe API error - continue without next billing date + } + } + + return view('billing.index', [ + 'subscription' => $subscription, + 'planner' => $planner, + 'planType' => $planType, + 'nextBillingDate' => $nextBillingDate, + ]); + } + public function cancel(Request $request): RedirectResponse { $planner = $request->user(); diff --git a/app/Http/Middleware/RequireSaasMode.php b/app/Http/Middleware/RequireSaasMode.php new file mode 100644 index 0000000..0950eb6 --- /dev/null +++ b/app/Http/Middleware/RequireSaasMode.php @@ -0,0 +1,19 @@ +alias([ 'subscription' => RequireSubscription::class, + 'saas' => RequireSaasMode::class, ]); // Exclude Stripe webhook from CSRF verification diff --git a/config/services.php b/config/services.php index 66bade1..cf3ce61 100644 --- a/config/services.php +++ b/config/services.php @@ -41,6 +41,8 @@ 'webhook' => [ 'secret' => env('STRIPE_WEBHOOK_SECRET'), ], + 'price_monthly' => env('STRIPE_PRICE_MONTHLY'), + 'price_yearly' => env('STRIPE_PRICE_YEARLY'), ], ]; diff --git a/resources/views/billing/index.blade.php b/resources/views/billing/index.blade.php new file mode 100644 index 0000000..c41163f --- /dev/null +++ b/resources/views/billing/index.blade.php @@ -0,0 +1,49 @@ + +
+
+

BILLING

+ +
+

Subscription Details

+ +
+
+ Plan + {{ $planType }} +
+ +
+ Status + + {{ ucfirst($subscription->stripe_status) }} + +
+ + @if($nextBillingDate) +
+ Next billing date + {{ $nextBillingDate->format('F j, Y') }} +
+ @endif + + @if($subscription->ends_at) +
+ Access until + {{ $subscription->ends_at->format('F j, Y') }} +
+ @endif + + @if($planner->pm_last_four) +
+ Payment method + + {{ ucfirst($planner->pm_type ?? 'Card') }} + •••• {{ $planner->pm_last_four }} + +
+ @endif +
+
+
+
+
diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index c007beb..d893086 100644 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -65,6 +65,11 @@ class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->rout x-transition class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-gray-700 ring-1 ring-secondary">
+ @if(is_mode_saas()) + + Billing + + @endif
@csrf +
+ @endif +
+
+ + +
+
+

Cancel Subscription?

+

+ Are you sure you want to cancel your subscription? You will retain access until the end of your current billing period. +

+
+ + + @csrf + + +
-- 2.45.2 From 4b31f3d315fd2b0fe8cea6d1496e03bca5e73ed5 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 7 Jan 2026 01:29:58 +0100 Subject: [PATCH 6/6] feature - 30 - Update payment method --- app/Http/Controllers/SubscriptionController.php | 5 +++++ resources/views/billing/index.blade.php | 1 + routes/web.php | 1 + 3 files changed, 7 insertions(+) diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index d84e561..24d0dc9 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -104,4 +104,9 @@ public function cancel(Request $request): RedirectResponse return back()->with('success', 'Subscription canceled. Access will continue until the end of your billing period.'); } + + public function billingPortal(Request $request) + { + return $request->user()->redirectToBillingPortal(route('billing')); + } } diff --git a/resources/views/billing/index.blade.php b/resources/views/billing/index.blade.php index cf811b7..00c8900 100644 --- a/resources/views/billing/index.blade.php +++ b/resources/views/billing/index.blade.php @@ -51,6 +51,7 @@ {{ ucfirst($planner->pm_type ?? 'Card') }} •••• {{ $planner->pm_last_four }} + Update @endif diff --git a/routes/web.php b/routes/web.php index 4f4e3f9..f23fbc5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -45,5 +45,6 @@ })->name('users.index'); Route::get('/billing', [SubscriptionController::class, 'billing'])->name('billing')->middleware('saas'); + Route::get('/billing/portal', [SubscriptionController::class, 'billingPortal'])->name('billing.portal')->middleware('saas'); }); }); -- 2.45.2