feature - 18 - Add payments to subscription flow
This commit is contained in:
parent
71668ea5bd
commit
bcbc1ce8e7
20 changed files with 259 additions and 129 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ RUN install-php-extensions \
|
|||
zip \
|
||||
gd \
|
||||
intl \
|
||||
bcmath \
|
||||
xdebug
|
||||
|
||||
# Install Composer
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum SubscriptionStatusEnum: string
|
||||
{
|
||||
case ACTIVE = 'active';
|
||||
case CANCELED = 'canceled';
|
||||
case PAST_DUE = 'past_due';
|
||||
case TRIALING = 'trialing';
|
||||
case INCOMPLETE = 'incomplete';
|
||||
case UNPAID = 'unpaid';
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this === self::ACTIVE;
|
||||
}
|
||||
|
||||
public function allowsAccess(): bool
|
||||
{
|
||||
return in_array($this, [self::ACTIVE, self::TRIALING]);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,45 +2,70 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SubscriptionStatusEnum;
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SubscriptionController extends Controller
|
||||
{
|
||||
public function subscribe(Request $request): RedirectResponse
|
||||
public function checkout(Request $request)
|
||||
{
|
||||
$planner = $request->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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\SubscriptionStatusEnum;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $planner_id
|
||||
* @property Planner $planner
|
||||
* @property string $stripe_subscription_id
|
||||
* @property string $stripe_customer_id
|
||||
* @property SubscriptionStatusEnum $status
|
||||
* @property string $plan
|
||||
* @property Carbon $trial_ends_at
|
||||
* @property Carbon $ends_at
|
||||
*/
|
||||
class Subscription extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'planner_id',
|
||||
'stripe_subscription_id',
|
||||
'stripe_customer_id',
|
||||
'status',
|
||||
'plan',
|
||||
'trial_ends_at',
|
||||
'ends_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'status' => 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -35,4 +35,12 @@
|
|||
],
|
||||
],
|
||||
|
||||
'stripe' => [
|
||||
'key' => env('STRIPE_KEY'),
|
||||
'secret' => env('STRIPE_SECRET'),
|
||||
'webhook' => [
|
||||
'secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('planners', function (Blueprint $table) {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('subscription_items', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('subscription_items', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('subscription_items', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
<x-layouts.app>
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
@if (session('success'))
|
||||
<div class="mb-6 border-2 border-success rounded-lg p-4 bg-success/10">
|
||||
<p class="text-success font-bold">Welcome to Dish Planner!</p>
|
||||
<p class="text-gray-100 text-sm">Your subscription is now active. Start planning your dishes!</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">DASHBOARD</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">SUBSCRIPTION</h1>
|
||||
|
||||
@if(auth()->user()->hasActiveSubscription())
|
||||
@if(auth()->user()->subscribed())
|
||||
<div class="border-2 border-success rounded-lg p-6 mb-6">
|
||||
<h3 class="text-xl font-bold text-success mb-2">Active Subscription</h3>
|
||||
<p class="text-gray-100 mb-4">You have an active subscription.</p>
|
||||
|
|
@ -17,15 +17,33 @@
|
|||
</div>
|
||||
@else
|
||||
<div class="border-2 border-secondary rounded-lg p-6">
|
||||
<h3 class="text-xl font-bold text-primary mb-2">Subscribe to Dish Planner</h3>
|
||||
<p class="text-gray-100 mb-4">Get access to all features.</p>
|
||||
<h3 class="text-xl font-bold text-primary mb-4">Subscribe to Dish Planner</h3>
|
||||
|
||||
<form action="{{ route('subscription.subscribe') }}" method="POST">
|
||||
@csrf
|
||||
<button type="submit" class="bg-accent-blue hover:bg-blue-600 text-white px-6 py-3 rounded font-bold transition-colors duration-200">
|
||||
Subscribe Now
|
||||
</button>
|
||||
</form>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<form action="{{ route('subscription.checkout') }}" method="POST">
|
||||
@csrf
|
||||
<input type="hidden" name="plan" value="monthly">
|
||||
<div class="border border-gray-600 rounded-lg p-4 hover:border-accent-blue transition-colors">
|
||||
<h4 class="text-lg font-bold text-white mb-2">Monthly</h4>
|
||||
<p class="text-gray-300 mb-4">Billed monthly</p>
|
||||
<button type="submit" class="w-full bg-accent-blue hover:bg-blue-600 text-white px-4 py-2 rounded font-bold transition-colors duration-200">
|
||||
Subscribe Monthly
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action="{{ route('subscription.checkout') }}" method="POST">
|
||||
@csrf
|
||||
<input type="hidden" name="plan" value="yearly">
|
||||
<div class="border border-gray-600 rounded-lg p-4 hover:border-success transition-colors">
|
||||
<h4 class="text-lg font-bold text-white mb-2">Yearly</h4>
|
||||
<p class="text-gray-300 mb-4">Billed annually</p>
|
||||
<button type="submit" class="w-full bg-success hover:bg-green-600 text-white px-4 py-2 rounded font-bold transition-colors duration-200">
|
||||
Subscribe Yearly
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
15
shell.nix
15
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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue