Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b31f3d315 | |||
| 592a402d23 | |||
| fc6fd87c4b | |||
| bcbc1ce8e7 | |||
| 71668ea5bd | |||
| b1cf8b5f22 |
25 changed files with 639 additions and 28 deletions
|
|
@ -14,7 +14,8 @@ RUN install-php-extensions \
|
||||||
opcache \
|
opcache \
|
||||||
zip \
|
zip \
|
||||||
gd \
|
gd \
|
||||||
intl
|
intl \
|
||||||
|
bcmath
|
||||||
|
|
||||||
# Install Composer
|
# Install Composer
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ RUN install-php-extensions \
|
||||||
zip \
|
zip \
|
||||||
gd \
|
gd \
|
||||||
intl \
|
intl \
|
||||||
|
bcmath \
|
||||||
xdebug
|
xdebug
|
||||||
|
|
||||||
# Install Composer
|
# Install Composer
|
||||||
|
|
|
||||||
24
app/Enums/AppModeEnum.php
Normal file
24
app/Enums/AppModeEnum.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum AppModeEnum: string
|
||||||
|
{
|
||||||
|
case APP = 'app';
|
||||||
|
case SAAS = 'saas';
|
||||||
|
|
||||||
|
public static function current(): self
|
||||||
|
{
|
||||||
|
return self::from(config('app.mode', 'app'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isApp(): bool
|
||||||
|
{
|
||||||
|
return $this === self::APP;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSaas(): bool
|
||||||
|
{
|
||||||
|
return $this === self::SAAS;
|
||||||
|
}
|
||||||
|
}
|
||||||
112
app/Http/Controllers/SubscriptionController.php
Normal file
112
app/Http/Controllers/SubscriptionController.php
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Cashier\Cashier;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SubscriptionController extends Controller
|
||||||
|
{
|
||||||
|
public function checkout(Request $request)
|
||||||
|
{
|
||||||
|
$planner = $request->user();
|
||||||
|
|
||||||
|
if ($planner->subscribed()) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
$plan = $request->input('plan', 'monthly');
|
||||||
|
$priceId = $plan === 'yearly'
|
||||||
|
? config('services.stripe.price_yearly')
|
||||||
|
: config('services.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 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();
|
||||||
|
|
||||||
|
if (! $planner->subscribed()) {
|
||||||
|
return back()->with('error', 'No active subscription found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$planner->subscription()->cancel();
|
||||||
|
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Http/Middleware/RequireSaasMode.php
Normal file
19
app/Http/Middleware/RequireSaasMode.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RequireSaasMode
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! is_mode_saas()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Http/Middleware/RequireSubscription.php
Normal file
25
app/Http/Middleware/RequireSubscription.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RequireSubscription
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (is_mode_app()) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$planner = $request->user();
|
||||||
|
|
||||||
|
if (! $planner?->subscribed()) {
|
||||||
|
return redirect()->route('subscription.index');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Cashier\Billable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -15,7 +16,7 @@
|
||||||
*/
|
*/
|
||||||
class Planner extends Authenticatable
|
class Planner extends Authenticatable
|
||||||
{
|
{
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use Billable, HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name', 'email', 'password',
|
'name', 'email', 'password',
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@
|
||||||
|
|
||||||
use App\Exceptions\CustomException;
|
use App\Exceptions\CustomException;
|
||||||
use App\Models\Dish;
|
use App\Models\Dish;
|
||||||
|
use App\Models\Planner;
|
||||||
use App\Models\Schedule;
|
use App\Models\Schedule;
|
||||||
use App\Models\ScheduledUserDish;
|
use App\Models\ScheduledUserDish;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserDish;
|
use App\Models\UserDish;
|
||||||
|
use Laravel\Cashier\Cashier;
|
||||||
use DishPlanner\Dish\Policies\DishPolicy;
|
use DishPlanner\Dish\Policies\DishPolicy;
|
||||||
use DishPlanner\Schedule\Policies\SchedulePolicy;
|
use DishPlanner\Schedule\Policies\SchedulePolicy;
|
||||||
use DishPlanner\ScheduledUserDish\Policies\ScheduledUserDishPolicy;
|
use DishPlanner\ScheduledUserDish\Policies\ScheduledUserDishPolicy;
|
||||||
|
|
@ -45,6 +47,8 @@ public function render($request, Throwable $e)
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
Cashier::useCustomerModel(Planner::class);
|
||||||
|
|
||||||
Gate::policy(Dish::class, DishPolicy::class);
|
Gate::policy(Dish::class, DishPolicy::class);
|
||||||
Gate::policy(Schedule::class, SchedulePolicy::class);
|
Gate::policy(Schedule::class, SchedulePolicy::class);
|
||||||
Gate::policy(ScheduledUserDish::class, ScheduledUserDishPolicy::class);
|
Gate::policy(ScheduledUserDish::class, ScheduledUserDishPolicy::class);
|
||||||
|
|
|
||||||
17
app/helpers.php
Normal file
17
app/helpers.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\AppModeEnum;
|
||||||
|
|
||||||
|
if (! function_exists('is_mode_app')) {
|
||||||
|
function is_mode_app(): bool
|
||||||
|
{
|
||||||
|
return AppModeEnum::current()->isApp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('is_mode_saas')) {
|
||||||
|
function is_mode_saas(): bool
|
||||||
|
{
|
||||||
|
return AppModeEnum::current()->isSaas();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Middleware\ForceJsonResponse;
|
use App\Http\Middleware\ForceJsonResponse;
|
||||||
use App\Http\Middleware\HandleResourceNotFound;
|
use App\Http\Middleware\RequireSaasMode;
|
||||||
|
use App\Http\Middleware\RequireSubscription;
|
||||||
use App\Services\OutputService;
|
use App\Services\OutputService;
|
||||||
use DishPlanner\Dish\Controllers\DishController;
|
|
||||||
use DishPlanner\Schedule\Console\Commands\GenerateScheduleCommand;
|
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
|
@ -21,10 +19,24 @@
|
||||||
api: __DIR__.'/../routes/api.php',
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
|
then: function () {
|
||||||
|
Route::middleware('web')
|
||||||
|
->group(base_path('routes/web/subscription.php'));
|
||||||
|
},
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
// Apply ForceJsonResponse only to API routes
|
// Apply ForceJsonResponse only to API routes
|
||||||
$middleware->api(ForceJsonResponse::class);
|
$middleware->api(ForceJsonResponse::class);
|
||||||
|
|
||||||
|
$middleware->alias([
|
||||||
|
'subscription' => RequireSubscription::class,
|
||||||
|
'saas' => RequireSaasMode::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Exclude Stripe webhook from CSRF verification
|
||||||
|
$middleware->validateCsrfTokens(except: [
|
||||||
|
'stripe/webhook',
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
||||||
|
|
@ -65,7 +77,4 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
->withCommands([
|
|
||||||
GenerateScheduleCommand::class,
|
|
||||||
])
|
|
||||||
->create();
|
->create();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
|
"laravel/cashier": "^16.1",
|
||||||
"laravel/framework": "^12.9.2",
|
"laravel/framework": "^12.9.2",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/tinker": "^2.9",
|
"laravel/tinker": "^2.9",
|
||||||
|
|
@ -26,6 +27,9 @@
|
||||||
"phpunit/phpunit": "^11.0.1"
|
"phpunit/phpunit": "^11.0.1"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"app/helpers.php"
|
||||||
|
],
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"App\\": "app/",
|
||||||
"DishPlanner\\": "src/DishPlanner/",
|
"DishPlanner\\": "src/DishPlanner/",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,18 @@
|
||||||
|
|
||||||
'env' => env('APP_ENV', 'production'),
|
'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
|
| Application Debug Mode
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,14 @@
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'stripe' => [
|
||||||
|
'key' => env('STRIPE_KEY'),
|
||||||
|
'secret' => env('STRIPE_SECRET'),
|
||||||
|
'webhook' => [
|
||||||
|
'secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||||
|
],
|
||||||
|
'price_monthly' => env('STRIPE_PRICE_MONTHLY'),
|
||||||
|
'price_yearly' => env('STRIPE_PRICE_YEARLY'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?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('subscriptions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
106
resources/views/billing/index.blade.php
Normal file
106
resources/views/billing/index.blade.php
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<x-layouts.app>
|
||||||
|
<div class="px-4 sm:px-6 lg:px-8" x-data="{ showCancelModal: false }">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">BILLING</h1>
|
||||||
|
|
||||||
|
@if (session('success'))
|
||||||
|
<div class="mb-6 border-2 border-success rounded-lg p-4 bg-success/10">
|
||||||
|
<p class="text-success">{{ session('success') }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (session('error'))
|
||||||
|
<div class="mb-6 border-2 border-danger rounded-lg p-4 bg-danger/10">
|
||||||
|
<p class="text-danger">{{ session('error') }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="border-2 border-secondary rounded-lg p-6">
|
||||||
|
<h3 class="text-xl font-bold text-primary mb-6">Subscription Details</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center border-b border-gray-600 pb-4">
|
||||||
|
<span class="text-gray-300">Plan</span>
|
||||||
|
<span class="text-white font-bold">{{ $planType }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center border-b border-gray-600 pb-4">
|
||||||
|
<span class="text-gray-300">Status</span>
|
||||||
|
<span class="font-bold {{ $subscription->stripe_status === 'active' ? 'text-success' : 'text-warning' }}">
|
||||||
|
{{ ucfirst($subscription->stripe_status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($nextBillingDate)
|
||||||
|
<div class="flex justify-between items-center border-b border-gray-600 pb-4">
|
||||||
|
<span class="text-gray-300">Next billing date</span>
|
||||||
|
<span class="text-white">{{ $nextBillingDate->format('F j, Y') }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($subscription->ends_at)
|
||||||
|
<div class="flex justify-between items-center border-b border-gray-600 pb-4">
|
||||||
|
<span class="text-gray-300">Access until</span>
|
||||||
|
<span class="text-warning">{{ $subscription->ends_at->format('F j, Y') }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($planner->pm_last_four)
|
||||||
|
<div class="flex justify-between items-center pb-4">
|
||||||
|
<span class="text-gray-300">Payment method</span>
|
||||||
|
<span class="text-white">
|
||||||
|
<span class="text-gray-400">{{ ucfirst($planner->pm_type ?? 'Card') }}</span>
|
||||||
|
•••• {{ $planner->pm_last_four }}
|
||||||
|
<a href="{{ route('billing.portal') }}" class="ml-2 text-accent-blue hover:underline text-sm">Update</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(!$subscription->ends_at)
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-600">
|
||||||
|
<button @click="showCancelModal = true" class="text-danger hover:text-red-400 text-sm transition-colors">
|
||||||
|
Cancel subscription
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cancel Confirmation Modal -->
|
||||||
|
<div x-show="showCancelModal"
|
||||||
|
x-cloak
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-150"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div @click.away="showCancelModal = false"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0 scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 scale-100"
|
||||||
|
x-transition:leave="transition ease-in duration-150"
|
||||||
|
x-transition:leave-start="opacity-100 scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 scale-95"
|
||||||
|
class="bg-gray-700 border-2 border-secondary rounded-lg p-6 max-w-md mx-4">
|
||||||
|
<h3 class="text-xl font-bold text-white mb-4">Cancel Subscription?</h3>
|
||||||
|
<p class="text-gray-300 mb-6">
|
||||||
|
Are you sure you want to cancel your subscription? You will retain access until the end of your current billing period.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end space-x-4">
|
||||||
|
<button @click="showCancelModal = false" class="px-4 py-2 text-gray-300 hover:text-white transition-colors">
|
||||||
|
Keep subscription
|
||||||
|
</button>
|
||||||
|
<form action="{{ route('subscription.cancel') }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="px-4 py-2 bg-danger hover:bg-red-600 text-white rounded transition-colors">
|
||||||
|
Cancel subscription
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-layouts.app>
|
||||||
|
|
@ -65,6 +65,11 @@ class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->rout
|
||||||
x-transition
|
x-transition
|
||||||
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-gray-700 ring-1 ring-secondary">
|
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-gray-700 ring-1 ring-secondary">
|
||||||
<div class="py-1">
|
<div class="py-1">
|
||||||
|
@if(is_mode_saas())
|
||||||
|
<a href="{{ route('billing') }}" class="block px-4 py-2 text-sm text-gray-100 hover:bg-gray-600 hover:text-accent-blue">
|
||||||
|
Billing
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
@csrf
|
@csrf
|
||||||
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-600 hover:text-accent-blue">
|
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-600 hover:text-accent-blue">
|
||||||
|
|
@ -140,6 +145,11 @@ class="block text-2xl font-medium {{ request()->routeIs('schedule.*') ? 'text-ac
|
||||||
|
|
||||||
<div class="mt-12 pt-6 border-t border-secondary text-center">
|
<div class="mt-12 pt-6 border-t border-secondary text-center">
|
||||||
<div class="text-gray-300 mb-4">{{ Auth::user()->name }}</div>
|
<div class="text-gray-300 mb-4">{{ Auth::user()->name }}</div>
|
||||||
|
@if(is_mode_saas())
|
||||||
|
<a href="{{ route('billing') }}" class="block text-xl text-accent-blue mb-4">
|
||||||
|
Billing
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
@csrf
|
@csrf
|
||||||
<button type="submit" class="text-xl text-danger">
|
<button type="submit" class="text-xl text-danger">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
<x-layouts.app>
|
<x-layouts.app>
|
||||||
<div class="px-4 sm:px-6 lg:px-8">
|
<div class="px-4 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-7xl mx-auto">
|
<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>
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
|
|
||||||
51
resources/views/subscription/index.blade.php
Normal file
51
resources/views/subscription/index.blade.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<x-layouts.app>
|
||||||
|
<div class="px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">SUBSCRIPTION</h1>
|
||||||
|
|
||||||
|
@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>
|
||||||
|
|
||||||
|
<form action="{{ route('subscription.cancel') }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded transition-colors duration-200">
|
||||||
|
Cancel Subscription
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="border-2 border-secondary rounded-lg p-6">
|
||||||
|
<h3 class="text-xl font-bold text-primary mb-4">Subscribe to Dish Planner</h3>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</x-layouts.app>
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Http\Controllers\Auth\LoginController;
|
use App\Http\Controllers\Auth\LoginController;
|
||||||
use App\Http\Controllers\Auth\RegisterController;
|
use App\Http\Controllers\Auth\RegisterController;
|
||||||
|
use App\Http\Controllers\SubscriptionController;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return redirect()->route('dashboard');
|
return redirect()->route('dashboard');
|
||||||
|
|
@ -23,13 +24,14 @@
|
||||||
|
|
||||||
// Authenticated routes
|
// Authenticated routes
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
|
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
|
||||||
|
|
||||||
|
// Routes requiring active subscription in SaaS mode
|
||||||
|
Route::middleware('subscription')->group(function () {
|
||||||
Route::get('/dashboard', function () {
|
Route::get('/dashboard', function () {
|
||||||
return view('dashboard');
|
return view('dashboard');
|
||||||
})->name('dashboard');
|
})->name('dashboard');
|
||||||
|
|
||||||
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
|
|
||||||
|
|
||||||
// Placeholder routes for future Livewire components
|
|
||||||
Route::get('/dishes', function () {
|
Route::get('/dishes', function () {
|
||||||
return view('dishes.index');
|
return view('dishes.index');
|
||||||
})->name('dishes.index');
|
})->name('dishes.index');
|
||||||
|
|
@ -41,4 +43,8 @@
|
||||||
Route::get('/users', function () {
|
Route::get('/users', function () {
|
||||||
return view('users.index');
|
return view('users.index');
|
||||||
})->name('users.index');
|
})->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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
18
routes/web/subscription.php
Normal file
18
routes/web/subscription.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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/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');
|
||||||
|
});
|
||||||
13
shell.nix
13
shell.nix
|
|
@ -112,6 +112,19 @@ pkgs.mkShell {
|
||||||
fi
|
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() {
|
prod-build-push() {
|
||||||
local TAG="''${1:-latest}"
|
local TAG="''${1:-latest}"
|
||||||
prod-build "$TAG" && prod-push "$TAG"
|
prod-build "$TAG" && prod-push "$TAG"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue