From bcbc1ce8e7d9d10c92090eab409de599232c9912 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Tue, 6 Jan 2026 20:59:16 +0100 Subject: [PATCH] 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"