From 39890b319af37d112b9bb3a277a9658ee99b1325 Mon Sep 17 00:00:00 2001 From: Andras Bacsai Date: Thu, 24 Aug 2023 16:14:09 +0200 Subject: [PATCH] add stripe subscription --- app/Http/Controllers/Controller.php | 2 +- app/Http/Livewire/Subscription/Actions.php | 4 + .../Livewire/Subscription/PricingPlans.php | 66 +++++++++ app/Http/Middleware/IsBoardingFlow.php | 8 +- app/Http/Middleware/SubscriptionValid.php | 28 +--- app/Jobs/CheckResaleLicenseJob.php | 1 + app/Jobs/CoolifyTask.php | 1 - app/Jobs/DatabaseBackupJob.php | 59 ++++---- app/Jobs/DatabaseContainerStatusJob.php | 1 + app/Jobs/DockerCleanupJob.php | 1 + app/Jobs/ProxyCheckJob.php | 1 + app/Jobs/ProxyContainerStatusJob.php | 1 + app/Jobs/ProxyStartJob.php | 2 +- app/Jobs/ResourceStatusJob.php | 4 +- app/Jobs/SubscriptionInvoiceFailedJob.php | 41 ++++++ app/Models/Webhook.php | 1 + bootstrap/helpers/subscriptions.php | 71 +++++++--- composer.json | 1 + composer.lock | 63 ++++++++- config/subscription.php | 27 ++++ ...2023_08_22_071049_update_webhooks_type.php | 29 ++++ ..._22_071050_update_subscriptions_stripe.php | 52 +++++++ .../components/layout-subscription.blade.php | 16 ++- resources/views/components/paddle.blade.php | 80 +++++++++++ .../views/components/pricing-plans.blade.php | 24 ++-- resources/views/components/stripe.blade.php | 0 .../subscription-invoice-failed.blade.php | 4 + .../emails/waitlist-invitation.blade.php | 1 + .../livewire/subscription/actions.blade.php | 69 ++++++---- .../subscription/pricing-plans.blade.php | 63 +++++++++ resources/views/subscription/cancel.blade.php | 3 + .../show.blade.php} | 10 +- .../views/subscription/success.blade.php | 3 + routes/web.php | 4 +- routes/webhooks.php | 127 +++++++++++++++++- scripts/run | 6 +- 36 files changed, 753 insertions(+), 121 deletions(-) create mode 100644 app/Http/Livewire/Subscription/PricingPlans.php create mode 100755 app/Jobs/SubscriptionInvoiceFailedJob.php create mode 100644 database/migrations/2023_08_22_071049_update_webhooks_type.php create mode 100644 database/migrations/2023_08_22_071050_update_subscriptions_stripe.php create mode 100644 resources/views/components/paddle.blade.php create mode 100644 resources/views/components/stripe.blade.php create mode 100644 resources/views/emails/subscription-invoice-failed.blade.php create mode 100644 resources/views/livewire/subscription/pricing-plans.blade.php create mode 100644 resources/views/subscription/cancel.blade.php rename resources/views/{subscription.blade.php => subscription/show.blade.php} (65%) create mode 100644 resources/views/subscription/success.blade.php diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index d78e67505..a194c3ad9 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -30,7 +30,7 @@ public function subscription() if (!is_cloud()) { abort(404); } - return view('subscription', [ + return view('subscription.show', [ 'settings' => InstanceSettings::get(), ]); } diff --git a/app/Http/Livewire/Subscription/Actions.php b/app/Http/Livewire/Subscription/Actions.php index 7830a935b..7de0ec728 100644 --- a/app/Http/Livewire/Subscription/Actions.php +++ b/app/Http/Livewire/Subscription/Actions.php @@ -69,4 +69,8 @@ public function resume() return general_error_handler($e, $this); } } + public function stripeCustomerPortal() { + $session = getStripeCustomerPortalSession(currentTeam()); + redirect($session->url); + } } diff --git a/app/Http/Livewire/Subscription/PricingPlans.php b/app/Http/Livewire/Subscription/PricingPlans.php new file mode 100644 index 000000000..bfdf48683 --- /dev/null +++ b/app/Http/Livewire/Subscription/PricingPlans.php @@ -0,0 +1,66 @@ +emit('error', 'Price ID not found! Please contact the administrator.'); + return; + } + $payload = [ + 'client_reference_id' => auth()->user()->id . ':' . currentTeam()->id, + 'line_items' => [[ + 'price' => $priceId, + 'quantity' => 1, + ]], + 'customer_update' =>[ + 'name' => 'auto' + ], + 'tax_id_collection' => [ + 'enabled' => true, + ], + 'mode' => 'subscription', + 'success_url' => route('subscription.success'), + 'cancel_url' => route('subscription.show',['cancelled' => true]), + ]; + $customer = currentTeam()->subscription?->stripe_customer_id ?? null; + if ($customer) { + $payload['customer'] = $customer; + } else { + $payload['customer_email'] = auth()->user()->email; + } + $session = Session::create($payload); + return redirect($session->url, 303); + } +} diff --git a/app/Http/Middleware/IsBoardingFlow.php b/app/Http/Middleware/IsBoardingFlow.php index e13e71f31..74a5bba97 100644 --- a/app/Http/Middleware/IsBoardingFlow.php +++ b/app/Http/Middleware/IsBoardingFlow.php @@ -15,12 +15,8 @@ class IsBoardingFlow */ public function handle(Request $request, Closure $next): Response { - $allowed_paths = [ - 'subscription', - 'boarding', - 'livewire/message/boarding' - ]; - if (showBoarding() && !in_array($request->path(), $allowed_paths)) { + ray('IsBoardingFlow Middleware'); + if (showBoarding() && !in_array($request->path(), allowedPaths())) { return redirect('boarding'); } return $next($request); diff --git a/app/Http/Middleware/SubscriptionValid.php b/app/Http/Middleware/SubscriptionValid.php index 01bd7a0e5..d6d98a8cc 100644 --- a/app/Http/Middleware/SubscriptionValid.php +++ b/app/Http/Middleware/SubscriptionValid.php @@ -17,31 +17,17 @@ public function handle(Request $request, Closure $next): Response return $next($request); } } - if (isInstanceAdmin()) { - return $next($request); - } - - if (is_subscription_active() && $request->path() === 'subscription') { + if (isSubscriptionActive() && $request->path() === 'subscription') { + // ray('active subscription Middleware'); return redirect('/'); } - if (is_subscription_in_grace_period()) { + if (isSubscriptionOnGracePeriod()) { + // ray('is_subscription_in_grace_period Middleware'); return $next($request); } - if (!is_subscription_active() && !is_subscription_in_grace_period()) { - ray('SubscriptionValid Middleware'); - - $allowed_paths = [ - 'subscription', - 'login', - 'register', - 'waitlist', - 'force-password-reset', - 'logout', - 'livewire/message/force-password-reset', - 'livewire/message/check-license', - 'livewire/message/switch-team', - ]; - if (!in_array($request->path(), $allowed_paths)) { + if (!isSubscriptionActive() && !isSubscriptionOnGracePeriod()) { + // ray('SubscriptionValid Middleware'); + if (!in_array($request->path(), allowedPaths())) { return redirect('subscription'); } else { return $next($request); diff --git a/app/Jobs/CheckResaleLicenseJob.php b/app/Jobs/CheckResaleLicenseJob.php index f14214733..96211451b 100644 --- a/app/Jobs/CheckResaleLicenseJob.php +++ b/app/Jobs/CheckResaleLicenseJob.php @@ -22,6 +22,7 @@ public function handle(): void try { resolve(CheckResaleLicense::class)(); } catch (\Throwable $th) { + send_internal_notification('CheckResaleLicenseJob failed with: ' . $th->getMessage()); ray($th); } } diff --git a/app/Jobs/CoolifyTask.php b/app/Jobs/CoolifyTask.php index e3dd9640f..176841b20 100755 --- a/app/Jobs/CoolifyTask.php +++ b/app/Jobs/CoolifyTask.php @@ -28,7 +28,6 @@ public function __construct( */ public function handle(): void { - $remote_process = resolve(RunRemoteProcess::class, [ 'activity' => $this->activity, 'ignore_errors' => $this->ignore_errors, diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index eed6c5de8..44e5601ba 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -64,35 +64,42 @@ public function uniqueId(): int public function handle(): void { - if ($this->database_status !== 'running') { - ray('database not running'); - return; - } - $this->container_name = $this->database->uuid; - $this->backup_dir = backup_dir() . "/databases/" . Str::of($this->team->name)->slug() . '-' . $this->team->id . '/' . $this->container_name; + try { + if ($this->database_status !== 'running') { + ray('database not running'); + return; + } + $this->container_name = $this->database->uuid; + $this->backup_dir = backup_dir() . "/databases/" . Str::of($this->team->name)->slug() . '-' . $this->team->id . '/' . $this->container_name; - if ($this->database->name === 'coolify-db') { - $this->container_name = "coolify-db"; - $ip = Str::slug($this->server->ip); - $this->backup_dir = backup_dir() . "/coolify" . "/coolify-db-$ip"; - } - $this->backup_file = "/dumpall-" . Carbon::now()->timestamp . ".sql"; - $this->backup_location = $this->backup_dir . $this->backup_file; + if ($this->database->name === 'coolify-db') { + $this->container_name = "coolify-db"; + $ip = Str::slug($this->server->ip); + $this->backup_dir = backup_dir() . "/coolify" . "/coolify-db-$ip"; + } + $this->backup_file = "/dumpall-" . Carbon::now()->timestamp . ".sql"; + $this->backup_location = $this->backup_dir . $this->backup_file; - $this->backup_log = ScheduledDatabaseBackupExecution::create([ - 'filename' => $this->backup_location, - 'scheduled_database_backup_id' => $this->backup->id, - ]); - if ($this->database_type === 'standalone-postgresql') { - $this->backup_standalone_postgresql(); + $this->backup_log = ScheduledDatabaseBackupExecution::create([ + 'filename' => $this->backup_location, + 'scheduled_database_backup_id' => $this->backup->id, + ]); + if ($this->database_type === 'standalone-postgresql') { + $this->backup_standalone_postgresql(); + } + $this->calculate_size(); + $this->remove_old_backups(); + if ($this->backup->save_s3) { + $this->upload_to_s3(); + } + $this->save_backup_logs(); + // TODO: Notify user + } catch (\Throwable $th) { + ray($th->getMessage()); + send_internal_notification('DatabaseBackupJob failed with: ' . $th->getMessage()); + //throw $th; } - $this->calculate_size(); - $this->remove_old_backups(); - if ($this->backup->save_s3) { - $this->upload_to_s3(); - } - $this->save_backup_logs(); - // TODO: Notify user + } private function backup_standalone_postgresql(): void diff --git a/app/Jobs/DatabaseContainerStatusJob.php b/app/Jobs/DatabaseContainerStatusJob.php index f2fc7053c..3993367fc 100644 --- a/app/Jobs/DatabaseContainerStatusJob.php +++ b/app/Jobs/DatabaseContainerStatusJob.php @@ -46,6 +46,7 @@ public function handle(): void $this->database->save(); } } catch (\Exception $e) { + send_internal_notification('DatabaseContainerStatusJob failed with: ' . $e->getMessage()); ray($e->getMessage()); } } diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index 52279c241..821688754 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -49,6 +49,7 @@ public function handle(): void } } } catch (\Exception $e) { + send_internal_notification('DockerCleanupJob failed with: ' . $e->getMessage()); ray($e->getMessage()); } } diff --git a/app/Jobs/ProxyCheckJob.php b/app/Jobs/ProxyCheckJob.php index bcaf8c8bd..6316c204a 100755 --- a/app/Jobs/ProxyCheckJob.php +++ b/app/Jobs/ProxyCheckJob.php @@ -33,6 +33,7 @@ public function handle() } } catch (\Throwable $th) { ray($th->getMessage()); + send_internal_notification('ProxyCheckJob failed with: ' . $th->getMessage()); //throw $th; } } diff --git a/app/Jobs/ProxyContainerStatusJob.php b/app/Jobs/ProxyContainerStatusJob.php index 3f8c21a3e..ddd6e5b8d 100644 --- a/app/Jobs/ProxyContainerStatusJob.php +++ b/app/Jobs/ProxyContainerStatusJob.php @@ -57,6 +57,7 @@ public function handle(): void $this->server->proxy->status = 'exited'; $this->server->save(); } + send_internal_notification('ProxyContainerStatusJob failed with: ' . $e->getMessage()); } } } diff --git a/app/Jobs/ProxyStartJob.php b/app/Jobs/ProxyStartJob.php index 90811d836..7f1df6377 100755 --- a/app/Jobs/ProxyStartJob.php +++ b/app/Jobs/ProxyStartJob.php @@ -29,8 +29,8 @@ public function handle() } resolve(StartProxy::class)($this->server); } catch (\Throwable $th) { + send_internal_notification('ProxyStartJob failed with: ' . $th->getMessage()); ray($th->getMessage()); - //throw $th; } } } diff --git a/app/Jobs/ResourceStatusJob.php b/app/Jobs/ResourceStatusJob.php index 122423f0a..4ff4063d4 100644 --- a/app/Jobs/ResourceStatusJob.php +++ b/app/Jobs/ResourceStatusJob.php @@ -37,8 +37,8 @@ public function handle(): void database: $postgresql, )); } - } catch (\Exception $e) { - ray($e->getMessage()); + } catch (\Exception $th) { + send_internal_notification('ResourceStatusJob failed with: ' . $th->getMessage()); } } } diff --git a/app/Jobs/SubscriptionInvoiceFailedJob.php b/app/Jobs/SubscriptionInvoiceFailedJob.php new file mode 100755 index 000000000..3d0e1bb8d --- /dev/null +++ b/app/Jobs/SubscriptionInvoiceFailedJob.php @@ -0,0 +1,41 @@ +team); + $mail = new MailMessage(); + $mail->view('emails.subscription-invoice-failed', [ + 'stripeCustomerPortal' => $session->url, + ]); + $mail->subject('Your last payment was failed for Coolify Cloud.'); + $this->team->members()->each(function ($member) use ($mail) { + ray($member); + if ($member->isAdmin()) { + send_user_an_email($mail, $member->email); + } + }); + } catch (\Throwable $th) { + send_internal_notification('SubscriptionInvoiceFailedJob failed with: ' . $th->getMessage()); + } + } +} diff --git a/app/Models/Webhook.php b/app/Models/Webhook.php index 308f17370..e259d16c1 100644 --- a/app/Models/Webhook.php +++ b/app/Models/Webhook.php @@ -8,6 +8,7 @@ class Webhook extends Model { protected $guarded = []; protected $casts = [ + 'type' => 'string', 'payload' => 'encrypted', ]; } diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index b514fb5af..fa16a1997 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -1,6 +1,8 @@ subscription->lemon_renews_at)->format('Y-M-d H:i:s'); } -function is_subscription_active() +function isSubscriptionActive() { $team = currentTeam(); - if (!$team) { return false; } - if (isInstanceAdmin()) { - return true; - } $subscription = $team?->subscription; if (!$subscription) { return false; } - $is_active = $subscription->lemon_status === 'active'; + if (config('subscription.provider') === 'lemon') { + return $subscription->lemon_status === 'active'; + } + if (config('subscription.provider') === 'stripe') { + return $subscription->stripe_invoice_paid === true && $subscription->stripe_cancel_at_period_end === false; + } + return false; + // if (config('subscription.provider') === 'paddle') { + // return $subscription->paddle_status === 'active'; + // } - return $is_active; } -function is_subscription_in_grace_period() +function isSubscriptionOnGracePeriod() { + $team = currentTeam(); if (!$team) { return false; } - if (isInstanceAdmin()) { - return true; - } $subscription = $team?->subscription; if (!$subscription) { return false; } - $is_still_grace_period = $subscription->lemon_ends_at && - Carbon::parse($subscription->lemon_ends_at) > Carbon::now(); - - return $is_still_grace_period; + if (config('subscription.provider') === 'lemon') { + $is_still_grace_period = $subscription->lemon_ends_at && + Carbon::parse($subscription->lemon_ends_at) > Carbon::now(); + return $is_still_grace_period; + } + if (config('subscription.provider') === 'stripe') { + return $subscription->stripe_cancel_at_period_end; + } + return false; +} +function subscriptionProvider() +{ + return config('subscription.provider'); +} +function getStripeCustomerPortalSession(Team $team) +{ + Stripe::setApiKey(config('subscription.stripe_api_key')); + $return_url = route('team.show'); + $stripe_customer_id = $team->subscription->stripe_customer_id; + $session = \Stripe\BillingPortal\Session::create([ + 'customer' => $stripe_customer_id, + 'return_url' => $return_url, + ]); + return $session; +} +function allowedPaths() +{ + return [ + 'subscription', + 'login', + 'register', + 'waitlist', + 'force-password-reset', + 'logout', + 'boarding', + 'livewire/message/boarding', + 'livewire/message/force-password-reset', + 'livewire/message/check-license', + 'livewire/message/switch-team', + 'livewire/message/subscription.pricing-plans' + ]; } diff --git a/composer.json b/composer.json index fd8a5eb01..bcb74242b 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "spatie/laravel-ray": "^1.32.4", "spatie/laravel-schemaless-attributes": "^2.4", "spatie/url": "^2.2", + "stripe/stripe-php": "^12.0", "symfony/yaml": "^6.2", "visus/cuid2": "^2.0.0" }, diff --git a/composer.lock b/composer.lock index cf0183f12..0dcd5cd72 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a4143cdb58c02a0490f9aa03d05b8e9a", + "content-hash": "da14dce99d76abcaaa6393166eda049a", "packages": [ { "name": "aws/aws-crt-php", @@ -6582,6 +6582,67 @@ ], "time": "2023-04-27T11:07:22+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v12.0.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "732996be0714154716f19f73f956d77bafc99334" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/732996be0714154716f19f73f956d77bafc99334", + "reference": "732996be0714154716f19f73f956d77bafc99334", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.5.0", + "php-coveralls/php-coveralls": "^2.5", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^5.7 || ^9.0", + "squizlabs/php_codesniffer": "^3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v12.0.0" + }, + "time": "2023-08-18T18:55:28+00:00" + }, { "name": "symfony/console", "version": "v6.3.2", diff --git a/config/subscription.php b/config/subscription.php index d4e94c7d8..de2f6d568 100644 --- a/config/subscription.php +++ b/config/subscription.php @@ -1,6 +1,33 @@ env('SUBSCRIPTION_PROVIDER', null), // stripe, paddle, lemon + // Stripe + 'stripe_api_key' => env('STRIPE_API_KEY', null), + 'stripe_secret' => env('STRIPE_SECRET', null), + 'stripe_webhook_secret' => env('STRIPE_WEBHOOK_SECRET', null), + 'stripe_price_id_basic_monthly' => env('STRIPE_PRICE_ID_BASIC_MONTHLY', null), + 'stripe_price_id_basic_yearly' => env('STRIPE_PRICE_ID_BASIC_YEARLY', null), + 'stripe_price_id_pro_monthly' => env('STRIPE_PRICE_ID_PRO_MONTHLY', null), + 'stripe_price_id_pro_yearly' => env('STRIPE_PRICE_ID_PRO_YEARLY', null), + 'stripe_price_id_ultimate_monthly' => env('STRIPE_PRICE_ID_ULTIMATE_MONTHLY', null), + 'stripe_price_id_ultimate_yearly' => env('STRIPE_PRICE_ID_ULTIMATE_YEARLY', null), + + + // Paddle + 'paddle_vendor_id' => env('PADDLE_VENDOR_ID', null), + 'paddle_vendor_auth_code' => env('PADDLE_VENDOR_AUTH_CODE', null), + 'paddle_public_key' => env('PADDLE_PUBLIC_KEY', null), + 'paddle_price_id_basic_monthly' => env('PADDLE_PRICE_ID_BASIC_MONTHLY', null), + 'paddle_price_id_basic_yearly' => env('PADDLE_PRICE_ID_BASIC_YEARLY', null), + 'paddle_price_id_pro_monthly' => env('PADDLE_PRICE_ID_PRO_MONTHLY', null), + 'paddle_price_id_pro_yearly' => env('PADDLE_PRICE_ID_PRO_YEARLY', null), + 'paddle_price_id_ultimate_monthly' => env('PADDLE_PRICE_ID_ULTIMATE_MONTHLY', null), + 'paddle_price_id_ultimate_yearly' => env('PADDLE_PRICE_ID_ULTIMATE_YEARLY', null), + 'paddle_webhook_secret' => env('PADDLE_WEBHOOK_SECRET', null), + + + // Lemon 'lemon_squeezy_api_key' => env('LEMON_SQUEEZY_API_KEY', null), 'lemon_squeezy_webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET', null), 'lemon_squeezy_checkout_id_monthly_basic' => env('LEMON_SQUEEZY_CHECKOUT_ID_MONTHLY_BASIC', null), diff --git a/database/migrations/2023_08_22_071049_update_webhooks_type.php b/database/migrations/2023_08_22_071049_update_webhooks_type.php new file mode 100644 index 000000000..7f60ca973 --- /dev/null +++ b/database/migrations/2023_08_22_071049_update_webhooks_type.php @@ -0,0 +1,29 @@ +string('type')->change(); + }); + DB::statement("ALTER TABLE webhooks DROP CONSTRAINT webhooks_type_check"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('webhooks', function (Blueprint $table) { + $table->string('type')->change(); + }); + } +}; diff --git a/database/migrations/2023_08_22_071050_update_subscriptions_stripe.php b/database/migrations/2023_08_22_071050_update_subscriptions_stripe.php new file mode 100644 index 000000000..70462fe20 --- /dev/null +++ b/database/migrations/2023_08_22_071050_update_subscriptions_stripe.php @@ -0,0 +1,52 @@ +boolean('stripe_invoice_paid')->default(false); + $table->string('stripe_subscription_id')->nullable(); + $table->string('stripe_customer_id')->nullable(); + $table->boolean('stripe_cancel_at_period_end')->default(false); + $table->string('lemon_subscription_id')->nullable()->change(); + $table->string('lemon_order_id')->nullable()->change(); + $table->string('lemon_product_id')->nullable()->change(); + $table->string('lemon_variant_id')->nullable()->change(); + $table->string('lemon_variant_name')->nullable()->change(); + $table->string('lemon_customer_id')->nullable()->change(); + $table->string('lemon_status')->nullable()->change(); + $table->string('lemon_renews_at')->nullable()->change(); + $table->string('lemon_update_payment_menthod_url')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn('stripe_invoice_paid'); + $table->dropColumn('stripe_subscription_id'); + $table->dropColumn('stripe_customer_id'); + $table->dropColumn('stripe_cancel_at_period_end'); + $table->string('lemon_subscription_id')->change(); + $table->string('lemon_order_id')->change(); + $table->string('lemon_product_id')->change(); + $table->string('lemon_variant_id')->change(); + $table->string('lemon_variant_name')->change(); + $table->string('lemon_customer_id')->change(); + $table->string('lemon_status')->change(); + $table->string('lemon_renews_at')->change(); + $table->string('lemon_update_payment_menthod_url')->change(); + }); + } +}; diff --git a/resources/views/components/layout-subscription.blade.php b/resources/views/components/layout-subscription.blade.php index d450c22bd..a3b0bf806 100644 --- a/resources/views/components/layout-subscription.blade.php +++ b/resources/views/components/layout-subscription.blade.php @@ -9,7 +9,7 @@ @env('local') Coolify - localhost - @else +@else {{ $title ?? 'Coolify' }} @endenv @@ -26,7 +26,7 @@ @livewireScripts - @if (isInstanceAdmin() || is_subscription_in_grace_period()) + @if (isSubscriptionOnGracePeriod())
@@ -68,6 +68,18 @@ function changePasswordFieldType(event) { window.location.reload(); } }) + Livewire.on('info', (message) => { + if (message) Toaster.info(message) + }) + Livewire.on('error', (message) => { + if (message) Toaster.error(message) + }) + Livewire.on('warning', (message) => { + if (message) Toaster.warning(message) + }) + Livewire.on('success', (message) => { + if (message) Toaster.success(message) + }) diff --git a/resources/views/components/paddle.blade.php b/resources/views/components/paddle.blade.php new file mode 100644 index 000000000..095487a6d --- /dev/null +++ b/resources/views/components/paddle.blade.php @@ -0,0 +1,80 @@ + + Subscribe + + + Subscribe + + + + Subscribe + + + Subscribe + + + + Subscribe + + + Subscribe + + + + + + diff --git a/resources/views/components/pricing-plans.blade.php b/resources/views/components/pricing-plans.blade.php index c00958415..3353d87d7 100644 --- a/resources/views/components/pricing-plans.blade.php +++ b/resources/views/components/pricing-plans.blade.php @@ -105,10 +105,9 @@ class="grid max-w-sm grid-cols-1 -mt-16 divide-y divide-coolgray-500 isolate gap billed annually @if ($showSubscribeButtons) - Subscribe - Subscribe + @isset($basic) + {{ $basic }} + @endisset @endif

Start self-hosting in the cloud @@ -168,10 +167,9 @@ class="grid max-w-sm grid-cols-1 -mt-16 divide-y divide-coolgray-500 isolate gap billed annually @if ($showSubscribeButtons) - Subscribe - Subscribe + @isset($pro) + {{ $pro }} + @endisset @endif

Scale your business or self-hosting environment.

@@ -227,10 +225,9 @@ class="grid max-w-sm grid-cols-1 -mt-16 divide-y divide-coolgray-500 isolate gap billed annually @if ($showSubscribeButtons) - Subscribe - Subscribe + @isset($ultimate) + {{ $ultimate }} + @endisset @endif

Deploy complex infrastuctures and manage them easily in one place.

@@ -274,3 +271,6 @@ class="grid max-w-sm grid-cols-1 -mt-16 divide-y divide-coolgray-500 isolate gap +@isset($other) + {{ $other }} +@endisset diff --git a/resources/views/components/stripe.blade.php b/resources/views/components/stripe.blade.php new file mode 100644 index 000000000..e69de29bb diff --git a/resources/views/emails/subscription-invoice-failed.blade.php b/resources/views/emails/subscription-invoice-failed.blade.php new file mode 100644 index 000000000..9d04eebd4 --- /dev/null +++ b/resources/views/emails/subscription-invoice-failed.blade.php @@ -0,0 +1,4 @@ +Your last invoice has failed to be paid for Coolify Cloud. Please update payment details on your Stripe Customer Portal. +

+Thanks,
+Coolify Cloud diff --git a/resources/views/emails/waitlist-invitation.blade.php b/resources/views/emails/waitlist-invitation.blade.php index 8a7647f59..7c941d925 100644 --- a/resources/views/emails/waitlist-invitation.blade.php +++ b/resources/views/emails/waitlist-invitation.blade.php @@ -1,4 +1,5 @@ Congratulations!
+Congratulations!

You have been invited to join the Coolify Cloud. Login here
diff --git a/resources/views/livewire/subscription/actions.blade.php b/resources/views/livewire/subscription/actions.blade.php index 8188ccf05..8b812b41d 100644 --- a/resources/views/livewire/subscription/actions.blade.php +++ b/resources/views/livewire/subscription/actions.blade.php @@ -1,31 +1,48 @@
-
Status: {{ currentTeam()->subscription->lemon_status }}
-
Type: {{ currentTeam()->subscription->lemon_variant_name }}
- @if (currentTeam()->subscription->lemon_status === 'cancelled') -
Subscriptions ends at: {{ getRenewDate() }}
-
If you would like to change the subscription to a lower/higher plan, please - contact - us.
- @else -
Renews at: {{ getRenewDate() }}
+ @if (subscriptionProvider() === 'stripe') + @if (currentTeam()->subscription->stripe_cancel_at_period_end) +
Subscription is active but on cancel period.
+ @else +
Subscription is active. Last invoice is + {{ currentTeam()->subscription->stripe_invoice_paid ? 'paid' : 'not paid' }}.
+ @endif + + @if (currentTeam()->subscription->stripe_cancel_at_period_end) + Subscribe + again + @endif + Manage My Subscription @endif -
-
- @if (currentTeam()->subscription->lemon_status === 'cancelled') - Resume Subscription + @if (subscriptionProvider() === 'lemon') +
Status: {{ currentTeam()->subscription->lemon_status }}
+
Type: {{ currentTeam()->subscription->lemon_variant_name }}
+ @if (currentTeam()->subscription->lemon_status === 'cancelled') +
Subscriptions ends at: {{ getRenewDate() }}
+
If you would like to change the subscription to a lower/higher plan, please + contact + us.
+ @else +
Renews at: {{ getRenewDate() }}
+ @endif +
+
+ @if (currentTeam()->subscription->lemon_status === 'cancelled') + Resume Subscription + + @else + Cancel Subscription + @endif +
+
+ Update Payment + Details - @else - Cancel Subscription - @endif + Manage My + Subscription +
- -
+ @endif +
diff --git a/resources/views/livewire/subscription/pricing-plans.blade.php b/resources/views/livewire/subscription/pricing-plans.blade.php new file mode 100644 index 000000000..c19e7e075 --- /dev/null +++ b/resources/views/livewire/subscription/pricing-plans.blade.php @@ -0,0 +1,63 @@ + + @if (config('subscription.provider') === 'stripe') + + Subscribe + + + Subscribe + + + + Subscribe + + + Subscribe + + + + Subscribe + + + Subscribe + + + @endif + @if (config('subscription.provider') === 'paddle') + + @endif + @if (config('subscription.provider') === 'lemon') + + Subscribe + + + Subscribe + + + + Subscribe + + + Subscribe + + + + Subscribe + + + Subscribe + + + @endif + diff --git a/resources/views/subscription/cancel.blade.php b/resources/views/subscription/cancel.blade.php new file mode 100644 index 000000000..dc7e3b55b --- /dev/null +++ b/resources/views/subscription/cancel.blade.php @@ -0,0 +1,3 @@ + + Cancel + diff --git a/resources/views/subscription.blade.php b/resources/views/subscription/show.blade.php similarity index 65% rename from resources/views/subscription.blade.php rename to resources/views/subscription/show.blade.php index 382bf6cb0..1cab51638 100644 --- a/resources/views/subscription.blade.php +++ b/resources/views/subscription/show.blade.php @@ -1,7 +1,8 @@ @if ($settings->is_resale_license_active)
-
+ +

Subscription

@@ -10,7 +11,12 @@ Currently active team: {{ session('currentTeam.name') }}
- + @if(request()->query->get('cancelled')) +
Something went wrong. Please try again.
+ @endif + @if (config('subscription.provider') !== null) + + @endif
@else diff --git a/resources/views/subscription/success.blade.php b/resources/views/subscription/success.blade.php new file mode 100644 index 000000000..9cb5911cb --- /dev/null +++ b/resources/views/subscription/success.blade.php @@ -0,0 +1,3 @@ + + Success + diff --git a/routes/web.php b/routes/web.php index f48b31ac7..ce98b65b7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -97,7 +97,9 @@ Route::middleware(['throttle:force-password-reset'])->group(function() { Route::get('/force-password-reset', [Controller::class, 'force_passoword_reset'])->name('auth.force-password-reset'); }); - Route::get('/subscription', [Controller::class, 'subscription'])->name('subscription'); + Route::get('/subscription', [Controller::class, 'subscription'])->name('subscription.show'); + Route::get('/subscription/success', fn () => view('subscription.success'))->name('subscription.success'); + Route::get('/subscription/cancel', fn () => view('profile'))->name('subscription.cancel'); Route::get('/settings', [Controller::class, 'settings'])->name('settings.configuration'); Route::get('/settings/license', [Controller::class, 'license'])->name('settings.license'); Route::get('/profile', fn () => view('profile', ['request' => request()]))->name('profile'); diff --git a/routes/webhooks.php b/routes/webhooks.php index 8d4917f17..8364e903f 100644 --- a/routes/webhooks.php +++ b/routes/webhooks.php @@ -1,5 +1,6 @@ route('dashboard'); } })->name('webhooks.waitlist.cancel'); -Route::post('/payments/events', function () { + + +Route::post('/payments/stripe/events', function () { + try { + $webhookSecret = config('subscription.stripe_webhook_secret'); + $signature = request()->header('Stripe-Signature'); + + $event = \Stripe\Webhook::constructEvent( + request()->getContent(), + $signature, + $webhookSecret + ); + $webhook = Webhook::create([ + 'type' => 'stripe', + 'payload' => request()->getContent() + ]); + $type = data_get($event, 'type'); + $data = data_get($event, 'data.object'); + switch ($type) { + case 'checkout.session.completed': + $clientReferenceId = data_get($data, 'client_reference_id'); + $userId = Str::before($clientReferenceId, ':'); + $teamId = Str::after($clientReferenceId, ':'); + $subscriptionId = data_get($data, 'subscription'); + $customerId = data_get($data, 'customer'); + $team = Team::find($teamId); + $found = $team->members->where('id', $userId)->first(); + if (!$found->isAdmin()) { + throw new Exception("User {$userId} is not an admin or owner of team {$team->id}."); + } + $subscription = Subscription::where('team_id', $teamId)->first(); + if ($subscription) { + $subscription->update([ + 'stripe_subscription_id' => $subscriptionId, + 'stripe_customer_id' => $customerId, + 'stripe_invoice_paid' => true, + ]); + } else { + Subscription::create([ + 'team_id' => $teamId, + 'stripe_subscription_id' => $subscriptionId, + 'stripe_customer_id' => $customerId, + 'stripe_invoice_paid' => true, + ]); + } + break; + case 'invoice.paid': + $subscriptionId = data_get($data, 'items.data.0.subscription'); + $subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->firstOrFail(); + $subscription->update([ + 'stripe_invoice_paid' => true, + ]); + break; + case 'invoice.payment_failed': + $customerId = data_get($data, 'customer'); + $subscription = Subscription::where('stripe_customer_id', $customerId)->firstOrFail(); + SubscriptionInvoiceFailedJob::dispatch($subscription->team); + break; + case 'customer.subscription.updated': + $subscriptionId = data_get($data, 'items.data.0.subscription'); + $cancelAtPeriodEnd = data_get($data, 'cancel_at_period_end'); + $subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->firstOrFail(); + $subscription->update([ + 'stripe_cancel_at_period_end' => $cancelAtPeriodEnd, + ]); + break; + case 'customer.subscription.deleted': + $subscriptionId = data_get($data, 'items.data.0.subscription'); + $subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->firstOrFail(); + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + 'stripe_invoice_paid' => false, + ]); + break; + default: + // Unhandled event type + } + } catch (Exception $e) { + ray($e->getMessage()); + send_internal_notification('Subscription webhook failed: ' . $e->getMessage()); + $webhook->update([ + 'status' => 'failed', + 'failure_reason' => $e->getMessage(), + ]); + return response($e->getMessage(), 400); + } +}); +Route::post('/payments/paddle/events', function () { + try { + $payload = request()->all(); + $signature = request()->header('Paddle-Signature'); + $ts = Str::of($signature)->after('ts=')->before(';'); + $h1 = Str::of($signature)->after('h1='); + $signedPayload = $ts->value . ':' . request()->getContent(); + $verify = hash_hmac('sha256', $signedPayload, config('subscription.paddle_webhook_secret')); + ray($verify, $h1->value, hash_equals($verify, $h1->value)); + if (!hash_equals($verify, $h1->value)) { + return response('Invalid signature.', 400); + } + $eventType = data_get($payload, 'event_type'); + ray($eventType); + $webhook = Webhook::create([ + 'type' => 'paddle', + 'payload' => $payload, + ]); + // TODO - Handle events + switch ($eventType) { + case 'subscription.activated': + } + ray('Subscription event: ' . $eventType); + $webhook->update([ + 'status' => 'success', + ]); + } catch (Exception $e) { + ray($e->getMessage()); + send_internal_notification('Subscription webhook failed: ' . $e->getMessage()); + $webhook->update([ + 'status' => 'failed', + 'failure_reason' => $e->getMessage(), + ]); + } finally { + return response('OK'); + } +}); +Route::post('/payments/lemon/events', function () { try { $secret = config('subscription.lemon_squeezy_webhook_secret'); $payload = request()->collect(); diff --git a/scripts/run b/scripts/run index 2723f55c6..9c3a04eca 100755 --- a/scripts/run +++ b/scripts/run @@ -47,9 +47,6 @@ function schedule:run { bash spin exec -u webuser coolify php artisan schedule:run } -function db:reset { - bash spin exec -u webuser coolify php artisan migrate:fresh --seed -} function db { bash spin exec -u webuser coolify php artisan db @@ -59,6 +56,9 @@ function db:migrate { bash spin exec -u webuser coolify php artisan migrate } +function db:reset { + bash spin exec -u webuser coolify php artisan migrate:fresh --seed +} function db:reset-prod { bash spin exec -u webuser coolify php artisan migrate:fresh --force --seed --seeder=ProductionSeeder || php artisan migrate:fresh --force --seed --seeder=ProductionSeeder