add stripe subscription

This commit is contained in:
Andras Bacsai 2023-08-24 16:14:09 +02:00
parent 2d8f166e4a
commit 39890b319a
36 changed files with 753 additions and 121 deletions

View File

@ -30,7 +30,7 @@ public function subscription()
if (!is_cloud()) {
abort(404);
}
return view('subscription', [
return view('subscription.show', [
'settings' => InstanceSettings::get(),
]);
}

View File

@ -69,4 +69,8 @@ public function resume()
return general_error_handler($e, $this);
}
}
public function stripeCustomerPortal() {
$session = getStripeCustomerPortalSession(currentTeam());
redirect($session->url);
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Http\Livewire\Subscription;
use Livewire\Component;
use Stripe\Stripe;
use Stripe\Checkout\Session;
class PricingPlans extends Component
{
public function subscribeStripe($type)
{
Stripe::setApiKey(config('subscription.stripe_api_key'));
switch ($type) {
case 'basic-monthly':
$priceId = config('subscription.stripe_price_id_basic_monthly');
break;
case 'basic-yearly':
$priceId = config('subscription.stripe_price_id_basic_yearly');
break;
case 'ultimate-monthly':
$priceId = config('subscription.stripe_price_id_ultimate_monthly');
break;
case 'pro-monthly':
$priceId = config('subscription.stripe_price_id_pro_monthly');
break;
case 'pro-yearly':
$priceId = config('subscription.stripe_price_id_pro_yearly');
break;
case 'ultimate-yearly':
$priceId = config('subscription.stripe_price_id_ultimate_yearly');
break;
default:
$priceId = config('subscription.stripe_price_id_basic_monthly');
break;
}
if (!$priceId) {
$this->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);
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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);
}
}

View File

@ -28,7 +28,6 @@ public function __construct(
*/
public function handle(): void
{
$remote_process = resolve(RunRemoteProcess::class, [
'activity' => $this->activity,
'ignore_errors' => $this->ignore_errors,

View File

@ -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

View File

@ -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());
}
}

View File

@ -49,6 +49,7 @@ public function handle(): void
}
}
} catch (\Exception $e) {
send_internal_notification('DockerCleanupJob failed with: ' . $e->getMessage());
ray($e->getMessage());
}
}

View File

@ -33,6 +33,7 @@ public function handle()
}
} catch (\Throwable $th) {
ray($th->getMessage());
send_internal_notification('ProxyCheckJob failed with: ' . $th->getMessage());
//throw $th;
}
}

View File

@ -57,6 +57,7 @@ public function handle(): void
$this->server->proxy->status = 'exited';
$this->server->save();
}
send_internal_notification('ProxyContainerStatusJob failed with: ' . $e->getMessage());
}
}
}

View File

@ -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;
}
}
}

View File

@ -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());
}
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Jobs;
use App\Models\Team;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Stripe\Stripe;
class SubscriptionInvoiceFailedJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(protected Team $team)
{
}
public function handle()
{
try {
$session = getStripeCustomerPortalSession($this->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());
}
}
}

View File

@ -8,6 +8,7 @@ class Webhook extends Model
{
protected $guarded = [];
protected $casts = [
'type' => 'string',
'payload' => 'encrypted',
];
}

View File

@ -1,6 +1,8 @@
<?php
use App\Models\Team;
use Illuminate\Support\Carbon;
use Stripe\Stripe;
function getSubscriptionLink($type)
{
@ -43,40 +45,79 @@ function getEndDate()
return Carbon::parse(currentTeam()->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'
];
}

View File

@ -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"
},

63
composer.lock generated
View File

@ -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",

View File

@ -1,6 +1,33 @@
<?php
return [
'provider'=> 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),

View File

@ -0,0 +1,29 @@
<?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('webhooks', function (Blueprint $table) {
$table->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();
});
}
};

View File

@ -0,0 +1,52 @@
<?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('subscriptions', function (Blueprint $table) {
$table->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();
});
}
};

View File

@ -9,7 +9,7 @@
@env('local')
<title>Coolify - localhost</title>
<link rel="icon" href="{{ asset('favicon-dev.png') }}" type="image/x-icon" />
@else
@else
<title>{{ $title ?? 'Coolify' }}</title>
<link rel="icon" href="{{ asset('coolify-transparent.png') }}" type="image/x-icon" />
@endenv
@ -26,7 +26,7 @@
<body>
@livewireScripts
<x-toaster-hub />
@if (isInstanceAdmin() || is_subscription_in_grace_period())
@if (isSubscriptionOnGracePeriod())
<div class="fixed top-3 left-4" id="vue">
<magic-bar></magic-bar>
</div>
@ -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)
})
</script>
</body>

View File

@ -0,0 +1,80 @@
<x-slot:basic>
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-basic" class="w-full h-10 buyme"
x-on:click="subscribe('basic-monthly')"> Subscribe
</x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-basic" class="w-full h-10 buyme"
x-on:click="subscribe('basic-yearly')"> Subscribe
</x-forms.button>
</x-slot:basic>
<x-slot:pro>
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-pro" class="w-full h-10 buyme"
x-on:click="subscribe('pro-monthly')"> Subscribe
</x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-pro" class="w-full h-10 buyme"
x-on:click="subscribe('pro-yearly')"> Subscribe
</x-forms.button>
</x-slot:pro>
<x-slot:ultimate>
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-ultimate" class="w-full h-10 buyme"
x-on:click="subscribe('ultimate-monthly')"> Subscribe
</x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-ultimate" class="w-full h-10 buyme"
x-on:click="subscribe('ultimate-yearly')"> Subscribe
</x-forms.button>
</x-slot:ultimate>
<x-slot:other>
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<script type="text/javascript">
Paddle.Environment.set("{{ is_dev() ? 'sandbox' : 'production' }}");
Paddle.Setup({
seller: {{ config('subscription.paddle_vendor_id') }},
checkout: {
settings: {
displayMode: "overlay",
theme: "light",
}
}
});
function subscribe(type) {
let priceId = null
switch (type) {
case 'basic-monthly':
priceId = "{{ config('subscription.paddle_price_id_basic_monthly') }}"
break;
case 'basic-yearly':
priceId = "{{ config('subscription.paddle_price_id_basic_yearly') }}"
break;
case 'pro-monthly':
priceId = "{{ config('subscription.paddle_price_id_pro_monthly') }}"
break;
case 'pro-yearly':
priceId = "{{ config('subscription.paddle_price_id_pro_yearly') }}"
break;
case 'ultimate-monthly':
priceId = "{{ config('subscription.paddle_price_id_ultimate_monthly') }}"
break;
case 'ultimate-yearly':
priceId = "{{ config('subscription.paddle_price_id_ultimate_yearly') }}"
break;
default:
break;
}
Paddle.Checkout.open({
customer: {
email: '{{ auth()->user()->email }}',
},
customData: {
"team_id": "{{ currentTeam()->id }}",
},
items: [{
priceId,
quantity: 1
}],
});
}
</script>
</x-slot:other>

View File

@ -105,10 +105,9 @@ class="grid max-w-sm grid-cols-1 -mt-16 divide-y divide-coolgray-500 isolate gap
<span>billed annually</span>
</span>
@if ($showSubscribeButtons)
<a x-show="selected === 'monthly'" x-cloak aria-describedby="tier-basic" class="buyme"
href="{{ getSubscriptionLink('monthly_basic') }}">Subscribe</a>
<a x-show="selected === 'yearly'" x-cloak aria-describedby="tier-basic" class="buyme"
href="{{ getSubscriptionLink('yearly_basic') }}">Subscribe</a>
@isset($basic)
{{ $basic }}
@endisset
@endif
<p class="mt-10 text-sm leading-6 text-white h-[6.5rem]">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
<span>billed annually</span>
</span>
@if ($showSubscribeButtons)
<a x-show="selected === 'monthly'" x-cloak aria-describedby="tier-pro" class="buyme"
href="{{ getSubscriptionLink('monthly_pro') }}">Subscribe</a>
<a x-show="selected === 'yearly'" x-cloak aria-describedby="tier-pro" class="buyme"
href="{{ getSubscriptionLink('yearly_pro') }}">Subscribe</a>
@isset($pro)
{{ $pro }}
@endisset
@endif
<p class="h-20 mt-10 text-sm leading-6 text-white">Scale your business or self-hosting environment.
</p>
@ -227,10 +225,9 @@ class="grid max-w-sm grid-cols-1 -mt-16 divide-y divide-coolgray-500 isolate gap
<span>billed annually</span>
</span>
@if ($showSubscribeButtons)
<a x-show="selected === 'monthly'" x-cloak aria-describedby="tier-ultimate" class="buyme"
href="{{ getSubscriptionLink('monthly_ultimate') }}">Subscribe</a>
<a x-show="selected === 'yearly'" x-cloak aria-describedby="tier-ultimate" class="buyme"
href="{{ getSubscriptionLink('yearly_ultimate') }}">Subscribe</a>
@isset($ultimate)
{{ $ultimate }}
@endisset
@endif
<p class="h-20 mt-10 text-sm leading-6 text-white">Deploy complex infrastuctures and
manage them easily in one place.</p>
@ -274,3 +271,6 @@ class="grid max-w-sm grid-cols-1 -mt-16 divide-y divide-coolgray-500 isolate gap
</div>
</div>
</div>
@isset($other)
{{ $other }}
@endisset

View File

@ -0,0 +1,4 @@
Your last invoice has failed to be paid for Coolify Cloud. Please <a href="{{$stripeCustomerPortal}}">update payment details on your Stripe Customer Portal</a>.
<br><br>
Thanks,<br>
Coolify Cloud

View File

@ -1,4 +1,5 @@
Congratulations!<br>
Congratulations!<br>
<br>
You have been invited to join the Coolify Cloud. <a href="{{base_url()}}/login">Login here</a>
<br>

View File

@ -1,31 +1,48 @@
<div>
<div>Status: {{ currentTeam()->subscription->lemon_status }}</div>
<div>Type: {{ currentTeam()->subscription->lemon_variant_name }}</div>
@if (currentTeam()->subscription->lemon_status === 'cancelled')
<div class="pb-4">Subscriptions ends at: {{ getRenewDate() }}</div>
<div class="py-4">If you would like to change the subscription to a lower/higher plan, <a
class="text-white underline" href="https://docs.coollabs.io/contact" target="_blank">please
contact
us.</a></div>
@else
<div class="pb-4">Renews at: {{ getRenewDate() }}</div>
@if (subscriptionProvider() === 'stripe')
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
<div>Subscription is active but on cancel period.</div>
@else
<div>Subscription is active. Last invoice is
{{ currentTeam()->subscription->stripe_invoice_paid ? 'paid' : 'not paid' }}.</div>
@endif
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
<a class="hover:no-underline" href="{{ route('subscription') }}"><x-forms.button>Subscribe
again</x-forms.button></a>
@endif
<x-forms.button wire:click='stripeCustomerPortal'>Manage My Subscription</x-forms.button>
@endif
<div class="flex flex-col gap-2">
<div class="flex gap-2">
@if (currentTeam()->subscription->lemon_status === 'cancelled')
<x-forms.button class="bg-coollabs-gradient" wire:click='resume'>Resume Subscription
@if (subscriptionProvider() === 'lemon')
<div>Status: {{ currentTeam()->subscription->lemon_status }}</div>
<div>Type: {{ currentTeam()->subscription->lemon_variant_name }}</div>
@if (currentTeam()->subscription->lemon_status === 'cancelled')
<div class="pb-4">Subscriptions ends at: {{ getRenewDate() }}</div>
<div class="py-4">If you would like to change the subscription to a lower/higher plan, <a
class="text-white underline" href="https://docs.coollabs.io/contact" target="_blank">please
contact
us.</a></div>
@else
<div class="pb-4">Renews at: {{ getRenewDate() }}</div>
@endif
<div class="flex flex-col gap-2">
<div class="flex gap-2">
@if (currentTeam()->subscription->lemon_status === 'cancelled')
<x-forms.button class="bg-coollabs-gradient" wire:click='resume'>Resume Subscription
</x-forms.button>
@else
<x-forms.button wire:click='cancel'>Cancel Subscription</x-forms.button>
@endif
</div>
<div>
<x-forms.button><a class="text-white hover:no-underline" href="{{ getPaymentLink() }}">Update Payment
Details</a>
</x-forms.button>
@else
<x-forms.button wire:click='cancel'>Cancel Subscription</x-forms.button>
@endif
<a class="text-white hover:no-underline"
href="https://app.lemonsqueezy.com/my-orders"><x-forms.button>Manage My
Subscription</x-forms.button></a>
</div>
</div>
<div>
<x-forms.button><a class="text-white hover:no-underline" href="{{ getPaymentLink() }}">Update Payment
Details</a>
</x-forms.button>
<a class="text-white hover:no-underline"
href="https://app.lemonsqueezy.com/my-orders"><x-forms.button>Manage My
Subscription</x-forms.button></a>
</div>
</div>
@endif
</div>

View File

@ -0,0 +1,63 @@
<x-pricing-plans>
@if (config('subscription.provider') === 'stripe')
<x-slot:basic>
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-basic"
class="w-full h-10 buyme" wire:click="subscribeStripe('basic-monthly')"> Subscribe
</x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-basic"
class="w-full h-10 buyme" wire:click="subscribeStripe('basic-yearly')"> Subscribe
</x-forms.button>
</x-slot:basic>
<x-slot:pro>
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-pro"
class="w-full h-10 buyme" wire:click="subscribeStripe('pro-monthly')"> Subscribe
</x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-pro" class="w-full h-10 buyme"
wire:click="subscribeStripe('pro-yearly')"> Subscribe
</x-forms.button>
</x-slot:pro>
<x-slot:ultimate>
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-ultimate"
class="w-full h-10 buyme" wire:click="subscribeStripe('ultimate-monthly')"> Subscribe
</x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-ultimate"
class="w-full h-10 buyme" wire:click="subscribeStripe('ultimate-yearly')"> Subscribe
</x-forms.button>
</x-slot:ultimate>
@endif
@if (config('subscription.provider') === 'paddle')
<x-paddle />
@endif
@if (config('subscription.provider') === 'lemon')
<x-slot:basic>
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-basic"
class="w-full h-10 buyme" wire:click="getSubscriptionLink('basic-monthly')"> Subscribe
</x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-basic"
class="w-full h-10 buyme" wire:click="getSubscriptionLink('basic-yearly')"> Subscribe
</x-forms.button>
</x-slot:basic>
<x-slot:pro>
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-pro"
class="w-full h-10 buyme" wire:click="getSubscriptionLink('pro-monthly')"> Subscribe
</x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-pro" class="w-full h-10 buyme"
wire:click="getSubscriptionLink('pro-yearly')"> Subscribe
</x-forms.button>
</x-slot:pro>
<x-slot:ultimate>
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-ultimate"
class="w-full h-10 buyme" wire:click="getSubscriptionLink('ultimate-monthly')"> Subscribe
</x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-ultimate"
class="w-full h-10 buyme" wire:click="getSubscriptionLink('ultimate-yearly')"> Subscribe
</x-forms.button>
</x-slot:ultimate>
@endif
</x-pricing-plans>

View File

@ -0,0 +1,3 @@
<x-layout-subscription>
Cancel
</x-layout-subscription>

View File

@ -1,7 +1,8 @@
<x-layout-subscription>
@if ($settings->is_resale_license_active)
<div class="flex justify-center mx-10">
<div>
<div x-data>
<div class="flex gap-2">
<h2>Subscription</h2>
<livewire:switch-team />
@ -10,7 +11,12 @@
<span>Currently active team: <span
class="text-warning">{{ session('currentTeam.name') }}</span></span>
</div>
<x-pricing-plans />
@if(request()->query->get('cancelled'))
<div class="text-xl text-center text-red-500">Something went wrong. Please try again.</div>
@endif
@if (config('subscription.provider') !== null)
<livewire:subscription.pricing-plans />
@endif
</div>
</div>
@else

View File

@ -0,0 +1,3 @@
<x-layout>
Success
</x-layout>

View File

@ -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');

View File

@ -1,5 +1,6 @@
<?php
use App\Jobs\SubscriptionInvoiceFailedJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\GithubApp;
@ -206,7 +207,131 @@
return redirect()->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();

View File

@ -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