chore: prepare for PR
This commit is contained in:
parent
78aea9a7ec
commit
8f2800a9e5
16 changed files with 1212 additions and 242 deletions
60
app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php
Normal file
60
app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Team;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class CancelSubscriptionAtPeriodEnd
|
||||
{
|
||||
private StripeClient $stripe;
|
||||
|
||||
public function __construct(?StripeClient $stripe = null)
|
||||
{
|
||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the team's subscription at the end of the current billing period.
|
||||
*
|
||||
* @return array{success: bool, error: string|null}
|
||||
*/
|
||||
public function execute(Team $team): array
|
||||
{
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if (! $subscription?->stripe_subscription_id) {
|
||||
return ['success' => false, 'error' => 'No active subscription found.'];
|
||||
}
|
||||
|
||||
if (! $subscription->stripe_invoice_paid) {
|
||||
return ['success' => false, 'error' => 'Subscription is not active.'];
|
||||
}
|
||||
|
||||
if ($subscription->stripe_cancel_at_period_end) {
|
||||
return ['success' => false, 'error' => 'Subscription is already set to cancel at the end of the billing period.'];
|
||||
}
|
||||
|
||||
try {
|
||||
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
|
||||
'cancel_at_period_end' => true,
|
||||
]);
|
||||
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => true,
|
||||
]);
|
||||
|
||||
\Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}");
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
\Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Cancel at period end error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
|
||||
}
|
||||
}
|
||||
}
|
||||
141
app/Actions/Stripe/RefundSubscription.php
Normal file
141
app/Actions/Stripe/RefundSubscription.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Team;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class RefundSubscription
|
||||
{
|
||||
private StripeClient $stripe;
|
||||
|
||||
private const REFUND_WINDOW_DAYS = 30;
|
||||
|
||||
public function __construct(?StripeClient $stripe = null)
|
||||
{
|
||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the team's subscription is eligible for a refund.
|
||||
*
|
||||
* @return array{eligible: bool, days_remaining: int, reason: string}
|
||||
*/
|
||||
public function checkEligibility(Team $team): array
|
||||
{
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if ($subscription?->stripe_refunded_at) {
|
||||
return $this->ineligible('A refund has already been processed for this team.');
|
||||
}
|
||||
|
||||
if (! $subscription?->stripe_subscription_id) {
|
||||
return $this->ineligible('No active subscription found.');
|
||||
}
|
||||
|
||||
if (! $subscription->stripe_invoice_paid) {
|
||||
return $this->ineligible('Subscription invoice is not paid.');
|
||||
}
|
||||
|
||||
try {
|
||||
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
return $this->ineligible('Subscription not found in Stripe.');
|
||||
}
|
||||
|
||||
if (! in_array($stripeSubscription->status, ['active', 'trialing'])) {
|
||||
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.");
|
||||
}
|
||||
|
||||
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date);
|
||||
$daysSinceStart = (int) $startDate->diffInDays(now());
|
||||
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
|
||||
|
||||
if ($daysRemaining <= 0) {
|
||||
return $this->ineligible('The 30-day refund window has expired.');
|
||||
}
|
||||
|
||||
return [
|
||||
'eligible' => true,
|
||||
'days_remaining' => $daysRemaining,
|
||||
'reason' => 'Eligible for refund.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a full refund and cancel the subscription.
|
||||
*
|
||||
* @return array{success: bool, error: string|null}
|
||||
*/
|
||||
public function execute(Team $team): array
|
||||
{
|
||||
$eligibility = $this->checkEligibility($team);
|
||||
|
||||
if (! $eligibility['eligible']) {
|
||||
return ['success' => false, 'error' => $eligibility['reason']];
|
||||
}
|
||||
|
||||
$subscription = $team->subscription;
|
||||
|
||||
try {
|
||||
$invoices = $this->stripe->invoices->all([
|
||||
'subscription' => $subscription->stripe_subscription_id,
|
||||
'status' => 'paid',
|
||||
'limit' => 1,
|
||||
]);
|
||||
|
||||
if (empty($invoices->data)) {
|
||||
return ['success' => false, 'error' => 'No paid invoice found to refund.'];
|
||||
}
|
||||
|
||||
$invoice = $invoices->data[0];
|
||||
$paymentIntentId = $invoice->payment_intent;
|
||||
|
||||
if (! $paymentIntentId) {
|
||||
return ['success' => false, 'error' => 'No payment intent found on the invoice.'];
|
||||
}
|
||||
|
||||
$this->stripe->refunds->create([
|
||||
'payment_intent' => $paymentIntentId,
|
||||
]);
|
||||
|
||||
$this->stripe->subscriptions->cancel($subscription->stripe_subscription_id);
|
||||
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_past_due' => false,
|
||||
'stripe_feedback' => 'Refund requested by user',
|
||||
'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(),
|
||||
'stripe_refunded_at' => now(),
|
||||
]);
|
||||
|
||||
$team->subscriptionEnded();
|
||||
|
||||
\Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}");
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
\Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Refund error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{eligible: bool, days_remaining: int, reason: string}
|
||||
*/
|
||||
private function ineligible(string $reason): array
|
||||
{
|
||||
return [
|
||||
'eligible' => false,
|
||||
'days_remaining' => 0,
|
||||
'reason' => $reason,
|
||||
];
|
||||
}
|
||||
}
|
||||
56
app/Actions/Stripe/ResumeSubscription.php
Normal file
56
app/Actions/Stripe/ResumeSubscription.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Team;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class ResumeSubscription
|
||||
{
|
||||
private StripeClient $stripe;
|
||||
|
||||
public function __construct(?StripeClient $stripe = null)
|
||||
{
|
||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a subscription that was set to cancel at the end of the billing period.
|
||||
*
|
||||
* @return array{success: bool, error: string|null}
|
||||
*/
|
||||
public function execute(Team $team): array
|
||||
{
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if (! $subscription?->stripe_subscription_id) {
|
||||
return ['success' => false, 'error' => 'No active subscription found.'];
|
||||
}
|
||||
|
||||
if (! $subscription->stripe_cancel_at_period_end) {
|
||||
return ['success' => false, 'error' => 'Subscription is not set to cancel.'];
|
||||
}
|
||||
|
||||
try {
|
||||
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
|
||||
'cancel_at_period_end' => false,
|
||||
]);
|
||||
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
]);
|
||||
|
||||
\Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}");
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
\Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Resume subscription error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,21 +2,155 @@
|
|||
|
||||
namespace App\Livewire\Subscription;
|
||||
|
||||
use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd;
|
||||
use App\Actions\Stripe\RefundSubscription;
|
||||
use App\Actions\Stripe\ResumeSubscription;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Component;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class Actions extends Component
|
||||
{
|
||||
public $server_limits = 0;
|
||||
|
||||
public function mount()
|
||||
public bool $isRefundEligible = false;
|
||||
|
||||
public int $refundDaysRemaining = 0;
|
||||
|
||||
public bool $refundCheckLoading = true;
|
||||
|
||||
public bool $refundAlreadyUsed = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->server_limits = Team::serverLimit();
|
||||
}
|
||||
|
||||
public function stripeCustomerPortal()
|
||||
public function loadRefundEligibility(): void
|
||||
{
|
||||
$this->checkRefundEligibility();
|
||||
$this->refundCheckLoading = false;
|
||||
}
|
||||
|
||||
public function stripeCustomerPortal(): void
|
||||
{
|
||||
$session = getStripeCustomerPortalSession(currentTeam());
|
||||
redirect($session->url);
|
||||
}
|
||||
|
||||
public function refundSubscription(string $password): bool|string
|
||||
{
|
||||
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
|
||||
return 'Invalid password.';
|
||||
}
|
||||
|
||||
$result = (new RefundSubscription)->execute(currentTeam());
|
||||
|
||||
if ($result['success']) {
|
||||
$this->dispatch('success', 'Subscription refunded successfully.');
|
||||
$this->redirect(route('subscription.index'), navigate: true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->dispatch('error', 'Something went wrong with the refund. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function cancelImmediately(string $password): bool|string
|
||||
{
|
||||
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
|
||||
return 'Invalid password.';
|
||||
}
|
||||
|
||||
$team = currentTeam();
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if (! $subscription?->stripe_subscription_id) {
|
||||
$this->dispatch('error', 'Something went wrong with the cancellation. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripe->subscriptions->cancel($subscription->stripe_subscription_id);
|
||||
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_past_due' => false,
|
||||
'stripe_feedback' => 'Cancelled immediately by user',
|
||||
'stripe_comment' => 'Subscription cancelled immediately by user at '.now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$team->subscriptionEnded();
|
||||
|
||||
\Log::info("Subscription {$subscription->stripe_subscription_id} cancelled immediately for team {$team->name}");
|
||||
|
||||
$this->dispatch('success', 'Subscription cancelled successfully.');
|
||||
$this->redirect(route('subscription.index'), navigate: true);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Immediate cancellation error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
$this->dispatch('error', 'Something went wrong with the cancellation. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public function cancelAtPeriodEnd(string $password): bool|string
|
||||
{
|
||||
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
|
||||
return 'Invalid password.';
|
||||
}
|
||||
|
||||
$result = (new CancelSubscriptionAtPeriodEnd)->execute(currentTeam());
|
||||
|
||||
if ($result['success']) {
|
||||
$this->dispatch('success', 'Subscription will be cancelled at the end of the billing period.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->dispatch('error', 'Something went wrong with the cancellation. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function resumeSubscription(): bool
|
||||
{
|
||||
$result = (new ResumeSubscription)->execute(currentTeam());
|
||||
|
||||
if ($result['success']) {
|
||||
$this->dispatch('success', 'Subscription resumed successfully.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->dispatch('error', 'Something went wrong resuming the subscription. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function checkRefundEligibility(): void
|
||||
{
|
||||
if (! isCloud() || ! currentTeam()->subscription?->stripe_subscription_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->refundAlreadyUsed = currentTeam()->subscription?->stripe_refunded_at !== null;
|
||||
$result = (new RefundSubscription)->checkEligibility(currentTeam());
|
||||
$this->isRefundEligible = $result['eligible'];
|
||||
$this->refundDaysRemaining = $result['days_remaining'];
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning('Refund eligibility check failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@ class Subscription extends Model
|
|||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'stripe_refunded_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function team()
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?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->timestamp('stripe_refunded_at')->nullable()->after('stripe_past_due');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('subscriptions', function (Blueprint $table) {
|
||||
$table->dropColumn('stripe_refunded_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -59,6 +59,7 @@
|
|||
confirmWithPassword: @js($confirmWithPassword && !$skipPasswordConfirmation),
|
||||
submitAction: @js($submitAction),
|
||||
dispatchAction: @js($dispatchAction),
|
||||
submitting: false,
|
||||
passwordError: '',
|
||||
selectedActions: @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all()),
|
||||
dispatchEvent: @js($dispatchEvent),
|
||||
|
|
@ -70,6 +71,7 @@
|
|||
this.step = this.initialStep;
|
||||
this.deleteText = '';
|
||||
this.password = '';
|
||||
this.submitting = false;
|
||||
this.userConfirmationText = '';
|
||||
this.selectedActions = @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all());
|
||||
$wire.$refresh();
|
||||
|
|
@ -320,8 +322,8 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
|
|||
</x-forms.button>
|
||||
@endif
|
||||
<x-forms.button
|
||||
x-bind:disabled="!disableTwoStepConfirmation && confirmWithText && userConfirmationText !==
|
||||
confirmationText"
|
||||
x-bind:disabled="submitting || (!disableTwoStepConfirmation && confirmWithText && userConfirmationText !==
|
||||
confirmationText)"
|
||||
class="w-auto" isError
|
||||
@click="
|
||||
if (dispatchEvent) {
|
||||
|
|
@ -330,12 +332,18 @@ class="w-auto" isError
|
|||
if (confirmWithPassword && !skipPasswordConfirmation) {
|
||||
step++;
|
||||
} else {
|
||||
modalOpen = false;
|
||||
resetModal();
|
||||
submitForm();
|
||||
submitting = true;
|
||||
submitForm().then((result) => {
|
||||
submitting = false;
|
||||
modalOpen = false;
|
||||
resetModal();
|
||||
}).catch(() => {
|
||||
submitting = false;
|
||||
});
|
||||
}
|
||||
">
|
||||
<span x-text="step2ButtonText"></span>
|
||||
<span x-show="!submitting" x-text="step2ButtonText"></span>
|
||||
<x-loading x-show="submitting" text="Processing..." />
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -373,22 +381,27 @@ class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|||
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
|
||||
Back
|
||||
</x-forms.button>
|
||||
<x-forms.button x-bind:disabled="!password" class="w-auto" isError
|
||||
<x-forms.button x-bind:disabled="!password || submitting" class="w-auto" isError
|
||||
@click="
|
||||
if (dispatchEvent) {
|
||||
$wire.dispatch(dispatchEventType, dispatchEventMessage);
|
||||
}
|
||||
submitting = true;
|
||||
submitForm().then((result) => {
|
||||
submitting = false;
|
||||
if (result === true) {
|
||||
modalOpen = false;
|
||||
resetModal();
|
||||
} else {
|
||||
passwordError = result;
|
||||
password = ''; // Clear the password field
|
||||
password = '';
|
||||
}
|
||||
}).catch(() => {
|
||||
submitting = false;
|
||||
});
|
||||
">
|
||||
<span x-text="step3ButtonText"></span>
|
||||
<span x-show="!submitting" x-text="step3ButtonText"></span>
|
||||
<x-loading x-show="submitting" text="Processing..." />
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,6 @@
|
|||
@endif
|
||||
<h1>Dashboard</h1>
|
||||
<div class="subtitle">Your self-hosted infrastructure.</div>
|
||||
@if (request()->query->get('success'))
|
||||
<div class=" mb-10 font-bold alert alert-success">
|
||||
Your subscription has been activated! Welcome onboard! It could take a few seconds before your
|
||||
subscription is activated.<br> Please be patient.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<section class="-mt-2">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
|
|
|
|||
|
|
@ -132,6 +132,33 @@ class="font-bold dark:text-white">Stripe</a></x-forms.button>
|
|||
</x-popup>
|
||||
</span>
|
||||
@endif
|
||||
@if (request()->query->get('cancelled'))
|
||||
<x-banner>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-red-500 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span><span class="font-bold text-red-500">Subscription Error.</span> Something went wrong. Please try
|
||||
again or <a class="underline dark:text-white"
|
||||
href="{{ config('constants.urls.contact') }}" target="_blank">contact support</a>.</span>
|
||||
</div>
|
||||
</x-banner>
|
||||
@endif
|
||||
@if (request()->query->get('success'))
|
||||
<x-banner>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-green-500 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span><span class="font-bold text-green-500">Welcome onboard!</span> Your subscription has been
|
||||
activated. It could take a few seconds before it's fully active.</span>
|
||||
</div>
|
||||
</x-banner>
|
||||
@endif
|
||||
@if (currentTeam()->subscriptionPastOverDue())
|
||||
<x-banner :closable=false>
|
||||
<div><span class="font-bold text-red-500">WARNING:</span> Your subscription is in over-due. If your
|
||||
|
|
|
|||
|
|
@ -1,53 +1,154 @@
|
|||
<div>
|
||||
<div wire:init="loadRefundEligibility">
|
||||
@if (subscriptionProvider() === 'stripe')
|
||||
<div class="pt-4">
|
||||
<h2>Your current plan</h2>
|
||||
<div class="pb-4">Tier: <strong class="dark:text-warning">
|
||||
@if (data_get(currentTeam(), 'subscription')->type() == 'dynamic')
|
||||
Pay-as-you-go
|
||||
@else
|
||||
{{ data_get(currentTeam(), 'subscription')->type() }}
|
||||
@endif
|
||||
{{-- Plan Overview --}}
|
||||
<section class="-mt-2">
|
||||
<h3 class="pb-2">Plan Overview</h3>
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
{{-- Current Plan Card --}}
|
||||
<div class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark:border-coolgray-400">
|
||||
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1">Current Plan</div>
|
||||
<div class="text-xl font-bold dark:text-warning">
|
||||
@if (data_get(currentTeam(), 'subscription')->type() == 'dynamic')
|
||||
Pay-as-you-go
|
||||
@else
|
||||
{{ data_get(currentTeam(), 'subscription')->type() }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="pt-2 text-sm">
|
||||
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||
<span class="text-red-500 font-medium">Cancelling at end of period</span>
|
||||
@else
|
||||
<span class="text-green-500 font-medium">Active</span>
|
||||
<span class="text-neutral-500"> · Invoice
|
||||
{{ currentTeam()->subscription->stripe_invoice_paid ? 'paid' : 'not paid' }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</strong></div>
|
||||
{{-- Server Limit Card --}}
|
||||
<div class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark:border-coolgray-400">
|
||||
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1">Paid Servers</div>
|
||||
<div class="text-xl font-bold dark:text-white">{{ $server_limits }}</div>
|
||||
<div class="pt-2 text-sm text-neutral-500">Included in your plan</div>
|
||||
</div>
|
||||
|
||||
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||
<div class="pb-2">Subscription is active but on cancel period.</div>
|
||||
@else
|
||||
<div class="pb-2">Subscription is active. Last invoice is
|
||||
{{ currentTeam()->subscription->stripe_invoice_paid ? 'paid' : 'not paid' }}.</div>
|
||||
@endif
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-48">Number of paid servers:</div>
|
||||
<div class="text-xl font-bold dark:text-white">{{ $server_limits }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-48">Currently active servers:</div>
|
||||
<div class="text-xl font-bold dark:text-white">{{ currentTeam()->servers->count() }}</div>
|
||||
{{-- Active Servers Card --}}
|
||||
<div
|
||||
class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark:border-coolgray-400 {{ currentTeam()->serverOverflow() ? 'border-red-500 dark:border-red-500' : '' }}">
|
||||
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1">Active Servers</div>
|
||||
<div class="text-xl font-bold {{ currentTeam()->serverOverflow() ? 'text-red-500' : 'dark:text-white' }}">
|
||||
{{ currentTeam()->servers->count() }}
|
||||
</div>
|
||||
<div class="pt-2 text-sm text-neutral-500">Currently running</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (currentTeam()->serverOverflow())
|
||||
<x-callout type="danger" title="WARNING" class="my-4">
|
||||
You must delete {{ currentTeam()->servers->count() - $server_limits }} servers,
|
||||
or upgrade your subscription. {{ currentTeam()->servers->count() - $server_limits }} servers will be
|
||||
deactivated.
|
||||
<x-callout type="danger" title="Server limit exceeded" class="mt-4">
|
||||
You must delete {{ currentTeam()->servers->count() - $server_limits }} servers or upgrade your
|
||||
subscription. Excess servers will be deactivated.
|
||||
</x-callout>
|
||||
@endif
|
||||
<x-forms.button class="gap-2" wire:click='stripeCustomerPortal'>Change Server Quantity
|
||||
</x-forms.button>
|
||||
<h2 class="pt-4">Manage your subscription</h2>
|
||||
<div class="pb-4">Cancel, upgrade or downgrade your subscription.</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.button class="gap-2" wire:click='stripeCustomerPortal'>Go to <svg
|
||||
xmlns="http://www.w3.org/2000/svg" class="w-12 " viewBox="0 0 512 214">
|
||||
<path fill="#635BFF"
|
||||
d="M512 110.08c0-36.409-17.636-65.138-51.342-65.138c-33.85 0-54.33 28.73-54.33 64.854c0 42.808 24.179 64.426 58.88 64.426c16.925 0 29.725-3.84 39.396-9.244v-28.445c-9.67 4.836-20.764 7.823-34.844 7.823c-13.796 0-26.027-4.836-27.591-21.618h69.547c0-1.85.284-9.245.284-12.658Zm-70.258-13.511c0-16.071 9.814-22.756 18.774-22.756c8.675 0 17.92 6.685 17.92 22.756h-36.694Zm-90.31-51.627c-13.939 0-22.899 6.542-27.876 11.094l-1.85-8.818h-31.288v165.83l35.555-7.537l.143-40.249c5.12 3.698 12.657 8.96 25.173 8.96c25.458 0 48.64-20.48 48.64-65.564c-.142-41.245-23.609-63.716-48.498-63.716Zm-8.534 97.991c-8.391 0-13.37-2.986-16.782-6.684l-.143-52.765c3.698-4.124 8.818-6.968 16.925-6.968c12.942 0 21.902 14.506 21.902 33.137c0 19.058-8.818 33.28-21.902 33.28ZM241.493 36.551l35.698-7.68V0l-35.698 7.538V36.55Zm0 10.809h35.698v124.444h-35.698V47.36Zm-38.257 10.524L200.96 47.36h-30.72v124.444h35.556V87.467c8.39-10.951 22.613-8.96 27.022-7.396V47.36c-4.551-1.707-21.191-4.836-29.582 10.524Zm-71.112-41.386l-34.702 7.395l-.142 113.92c0 21.05 15.787 36.551 36.836 36.551c11.662 0 20.195-2.133 24.888-4.693V140.8c-4.55 1.849-27.022 8.391-27.022-12.658V77.653h27.022V47.36h-27.022l.142-30.862ZM35.982 83.484c0-5.546 4.551-7.68 12.09-7.68c10.808 0 24.461 3.272 35.27 9.103V51.484c-11.804-4.693-23.466-6.542-35.27-6.542C19.2 44.942 0 60.018 0 85.192c0 39.252 54.044 32.995 54.044 49.92c0 6.541-5.688 8.675-13.653 8.675c-11.804 0-26.88-4.836-38.827-11.378v33.849c13.227 5.689 26.596 8.106 38.827 8.106c29.582 0 49.92-14.648 49.92-40.106c-.142-42.382-54.329-34.845-54.329-50.774Z" />
|
||||
</svg>
|
||||
</x-forms.button>
|
||||
</section>
|
||||
|
||||
{{-- Manage Plan --}}
|
||||
<section>
|
||||
<h3 class="pb-2">Manage Plan</h3>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<x-forms.button class="gap-2" wire:click='stripeCustomerPortal'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z" />
|
||||
</svg>
|
||||
Manage Billing on Stripe
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-500">Change your server quantity, update payment methods, or view
|
||||
invoices.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
If you have any problems, please <a class="underline dark:text-white" href="{{ config('constants.urls.contact') }}"
|
||||
target="_blank">contact us.</a>
|
||||
</section>
|
||||
|
||||
{{-- Refund Section --}}
|
||||
@if ($refundCheckLoading)
|
||||
<section>
|
||||
<h3 class="pb-2">Refund</h3>
|
||||
<x-loading text="Checking refund eligibility..." />
|
||||
</section>
|
||||
@elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||
<section>
|
||||
<h3 class="pb-2">Refund</h3>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<x-modal-confirmation title="Request Full Refund?" buttonTitle="Request Full Refund"
|
||||
isErrorButton submitAction="refundSubscription"
|
||||
:actions="[
|
||||
'Your latest payment will be fully refunded.',
|
||||
'Your subscription will be cancelled immediately.',
|
||||
'All servers will be deactivated.',
|
||||
]" confirmationText="{{ currentTeam()->name }}"
|
||||
confirmationLabel="Enter your team name to confirm" shortConfirmationLabel="Team Name"
|
||||
step2ButtonText="Confirm Refund & Cancel" />
|
||||
</div>
|
||||
<p class="text-sm text-neutral-500">You are eligible for a full refund.
|
||||
<strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining
|
||||
in the 30-day refund window.</p>
|
||||
</div>
|
||||
</section>
|
||||
@elseif ($refundAlreadyUsed)
|
||||
<section>
|
||||
<h3 class="pb-2">Refund</h3>
|
||||
<p class="text-sm text-neutral-500">A refund has already been processed for this team. Each team is
|
||||
eligible for one refund only to prevent abuse.</p>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- Resume / Cancel Subscription Section --}}
|
||||
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||
<section>
|
||||
<h3 class="pb-2">Resume Subscription</h3>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<x-forms.button wire:click="resumeSubscription">Resume Subscription</x-forms.button>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-500">Your subscription is set to cancel at the end of the billing
|
||||
period. Resume to continue your plan.</p>
|
||||
</div>
|
||||
</section>
|
||||
@else
|
||||
<section>
|
||||
<h3 class="pb-2">Cancel Subscription</h3>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-modal-confirmation title="Cancel at End of Billing Period?"
|
||||
buttonTitle="Cancel at Period End" submitAction="cancelAtPeriodEnd"
|
||||
:actions="[
|
||||
'Your subscription will remain active until the end of the current billing period.',
|
||||
'No further charges will be made after the current period.',
|
||||
'You can resubscribe at any time.',
|
||||
]" confirmationText="{{ currentTeam()->name }}"
|
||||
confirmationLabel="Enter your team name to confirm"
|
||||
shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" />
|
||||
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
|
||||
isErrorButton submitAction="cancelImmediately"
|
||||
:actions="[
|
||||
'Your subscription will be cancelled immediately.',
|
||||
'All servers will be deactivated.',
|
||||
'No refund will be issued for the remaining period.',
|
||||
]" confirmationText="{{ currentTeam()->name }}"
|
||||
confirmationLabel="Enter your team name to confirm"
|
||||
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
|
||||
</div>
|
||||
<p class="text-sm text-neutral-500">Cancel your subscription immediately or at the end of the
|
||||
current billing period.</p>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
<div class="text-sm text-neutral-500">
|
||||
Need help? <a class="underline dark:text-white" href="{{ config('constants.urls.contact') }}"
|
||||
target="_blank">Contact us.</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,29 +3,26 @@
|
|||
Subscribe | Coolify
|
||||
</x-slot>
|
||||
@if (auth()->user()->isAdminFromSession())
|
||||
@if (request()->query->get('cancelled'))
|
||||
<div class="mb-6 rounded-sm alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 stroke-current shrink-0" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Something went wrong with your subscription. Please try again or contact
|
||||
support.</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex gap-2">
|
||||
<h1>Subscriptions</h1>
|
||||
</div>
|
||||
@if ($loading)
|
||||
<div class="flex gap-2" wire:init="getStripeStatus">
|
||||
Loading your subscription status...
|
||||
<div class="flex items-center justify-center min-h-[60vh]" wire:init="getStripeStatus">
|
||||
<x-loading text="Loading your subscription status..." />
|
||||
</div>
|
||||
@else
|
||||
@if ($isUnpaid)
|
||||
<div class="mb-6 rounded-sm alert-error">
|
||||
<span>Your last payment was failed for Coolify Cloud.</span>
|
||||
</div>
|
||||
<x-banner :closable="false">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-red-500 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span><span class="font-bold text-red-500">Payment Failed.</span> Your last payment for Coolify
|
||||
Cloud has failed.</span>
|
||||
</div>
|
||||
</x-banner>
|
||||
<div>
|
||||
<p class="mb-2">Open the following link, navigate to the button and pay your unpaid/past due
|
||||
subscription.
|
||||
|
|
@ -34,18 +31,20 @@
|
|||
</div>
|
||||
@else
|
||||
@if (config('subscription.provider') === 'stripe')
|
||||
<div @class([
|
||||
'pb-4' => $isCancelled,
|
||||
'pb-10' => !$isCancelled,
|
||||
])>
|
||||
@if ($isCancelled)
|
||||
<div class="alert-error">
|
||||
<span>It looks like your previous subscription has been cancelled, because you forgot to
|
||||
pay
|
||||
the bills.<br />Please subscribe again to continue using Coolify.</span>
|
||||
@if ($isCancelled)
|
||||
<x-banner :closable="false">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-red-500 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span><span class="font-bold text-red-500">No Active Subscription.</span> Subscribe to
|
||||
a plan to start using Coolify Cloud.</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-banner>
|
||||
@endif
|
||||
<div @class(['pt-4 pb-4' => $isCancelled, 'pb-10' => !$isCancelled])></div>
|
||||
<livewire:subscription.pricing-plans />
|
||||
@endif
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -1,162 +1,123 @@
|
|||
<div x-data="{ selected: 'monthly' }" class="w-full pb-20">
|
||||
<div class="px-6 mx-auto lg:px-8">
|
||||
<div class="flex justify-center">
|
||||
<fieldset
|
||||
class="grid grid-cols-2 p-1 text-xs font-semibold leading-5 text-center rounded-sm dark:text-white gap-x-1 dark:bg-white/5 bg-black/5">
|
||||
<legend class="sr-only">Payment frequency</legend>
|
||||
<label
|
||||
:class="selected === 'monthly' ?
|
||||
'dark:bg-coollabs-100 bg-warning dark:text-white cursor-pointer rounded-sm px-2.5 py-1' :
|
||||
'cursor-pointer rounded-sm px-2.5 py-1'">
|
||||
<input type="radio" x-on:click="selected = 'monthly'" name="frequency" value="monthly"
|
||||
class="sr-only">
|
||||
<span :class="selected === 'monthly' ? 'dark:text-white' : ''">Monthly</span>
|
||||
</label>
|
||||
<label
|
||||
:class="selected === 'yearly' ?
|
||||
'dark:bg-coollabs-100 bg-warning dark:text-white cursor-pointer rounded-sm px-2.5 py-1' :
|
||||
'cursor-pointer rounded-sm px-2.5 py-1'">
|
||||
<input type="radio" x-on:click="selected = 'yearly'" name="frequency" value="annually"
|
||||
class="sr-only">
|
||||
<span :class="selected === 'yearly' ? 'dark:text-white' : ''">Annually <span
|
||||
class="text-xs dark:text-warning text-coollabs">(save ~20%)</span></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<div x-data="{ selected: 'monthly' }" class="w-full">
|
||||
{{-- Frequency Toggle --}}
|
||||
<div class="flex justify-center mb-8">
|
||||
<fieldset
|
||||
class="grid grid-cols-2 p-1 text-xs font-semibold leading-5 text-center rounded-sm dark:text-white gap-x-1 dark:bg-white/5 bg-black/5">
|
||||
<legend class="sr-only">Payment frequency</legend>
|
||||
<label
|
||||
:class="selected === 'monthly' ?
|
||||
'dark:bg-coollabs-100 bg-warning dark:text-white cursor-pointer rounded-sm px-2.5 py-1' :
|
||||
'cursor-pointer rounded-sm px-2.5 py-1'">
|
||||
<input type="radio" x-on:click="selected = 'monthly'" name="frequency" value="monthly"
|
||||
class="sr-only">
|
||||
<span :class="selected === 'monthly' ? 'dark:text-white' : ''">Monthly</span>
|
||||
</label>
|
||||
<label
|
||||
:class="selected === 'yearly' ?
|
||||
'dark:bg-coollabs-100 bg-warning dark:text-white cursor-pointer rounded-sm px-2.5 py-1' :
|
||||
'cursor-pointer rounded-sm px-2.5 py-1'">
|
||||
<input type="radio" x-on:click="selected = 'yearly'" name="frequency" value="annually"
|
||||
class="sr-only">
|
||||
<span :class="selected === 'yearly' ? 'dark:text-white' : ''">Annually <span
|
||||
class="text-xs dark:text-warning text-coollabs">(save ~20%)</span></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="max-w-xl mx-auto">
|
||||
{{-- Plan Header + Pricing --}}
|
||||
<h3 id="tier-dynamic" class="text-2xl font-bold dark:text-white">Pay-as-you-go</h3>
|
||||
<p class="mt-1 text-sm dark:text-neutral-400">Dynamic pricing based on the number of servers you connect.</p>
|
||||
|
||||
<div class="mt-4 flex items-baseline gap-x-1">
|
||||
<span x-show="selected === 'monthly'" x-cloak>
|
||||
<span class="text-4xl font-bold tracking-tight dark:text-white">$5</span>
|
||||
<span class="text-sm dark:text-neutral-400">/ mo base</span>
|
||||
</span>
|
||||
<span x-show="selected === 'yearly'" x-cloak>
|
||||
<span class="text-4xl font-bold tracking-tight dark:text-white">$4</span>
|
||||
<span class="text-sm dark:text-neutral-400">/ mo base</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flow-root mt-12">
|
||||
<div
|
||||
class="grid grid-cols-1 -mt-16 divide-y divide-neutral-200 dark:divide-coolgray-500 isolate gap-y-16 sm:mx-auto lg:-mx-8 lg:mt-0 lg:max-w-none lg:grid-cols-1 lg:divide-x lg:divide-y-0 xl:-mx-4">
|
||||
<div class="pt-16 lg:px-8 lg:pt-0 xl:px-14">
|
||||
<h3 id="tier-dynamic" class="text-4xl font-semibold leading-7 dark:text-white">Pay-as-you-go</h3>
|
||||
<p class="mt-4 text-sm leading-6 dark:text-neutral-400">
|
||||
Dynamic pricing based on the number of servers you connect.
|
||||
</p>
|
||||
<p class="flex items-baseline mt-6 gap-x-1">
|
||||
<span x-show="selected === 'monthly'" x-cloak>
|
||||
<span class="text-4xl font-bold tracking-tight dark:text-white">$5</span>
|
||||
<span class="text-sm font-semibold leading-6 "> base price</span>
|
||||
</span>
|
||||
<p class="mt-1 text-sm dark:text-neutral-400">
|
||||
<span x-show="selected === 'monthly'" x-cloak>
|
||||
+ <span class="font-semibold dark:text-white">$3</span> per additional server, billed monthly (+VAT)
|
||||
</span>
|
||||
<span x-show="selected === 'yearly'" x-cloak>
|
||||
+ <span class="font-semibold dark:text-white">$2.7</span> per additional server, billed annually (+VAT)
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<span x-show="selected === 'yearly'" x-cloak>
|
||||
<span class="text-4xl font-bold tracking-tight dark:text-white">$4</span>
|
||||
<span class="text-sm font-semibold leading-6 "> base price</span>
|
||||
</span>
|
||||
</p>
|
||||
<p class="flex items-baseline mb-4 gap-x-1">
|
||||
<span x-show="selected === 'monthly'" x-cloak>
|
||||
<span class="text-base font-semibold tracking-tight dark:text-white">$3</span>
|
||||
<span class="text-sm font-semibold leading-6 "> per additional servers <span
|
||||
class="font-normal dark:text-white">billed monthly (+VAT)</span></span>
|
||||
</span>
|
||||
{{-- Subscribe Button --}}
|
||||
<div class="flex mt-6">
|
||||
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-dynamic"
|
||||
class="w-full" wire:click="subscribeStripe('dynamic-monthly')">
|
||||
Subscribe
|
||||
</x-forms.button>
|
||||
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-dynamic"
|
||||
class="w-full" wire:click="subscribeStripe('dynamic-yearly')">
|
||||
Subscribe
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
<span x-show="selected === 'yearly'" x-cloak>
|
||||
<span class="text-base font-semibold tracking-tight dark:text-white">$2.7</span>
|
||||
<span class="text-sm font-semibold leading-6 "> per additional servers <span
|
||||
class="font-normal dark:text-white">billed annually (+VAT)</span></span>
|
||||
</span>
|
||||
</p>
|
||||
<div class="flex items-center pt-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="flex-none w-8 h-8 mr-3 dark:text-warning"
|
||||
fill="currentColor" viewBox="0 0 256 256">
|
||||
<path
|
||||
d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z">
|
||||
</path>
|
||||
</svg>
|
||||
{{-- Features --}}
|
||||
<div class="mt-8 pt-6 border-t dark:border-coolgray-400 border-neutral-200">
|
||||
<ul role="list" class="space-y-2.5 text-sm">
|
||||
<li class="flex items-center gap-2.5">
|
||||
<svg class="w-4 h-4 shrink-0 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="dark:text-neutral-300">Connect <span
|
||||
class="font-bold dark:text-white">unlimited</span> servers</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2.5">
|
||||
<svg class="w-4 h-4 shrink-0 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="dark:text-neutral-300">Deploy <span
|
||||
class="font-bold dark:text-white">unlimited</span> applications per server</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2.5">
|
||||
<svg class="w-4 h-4 shrink-0 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="dark:text-neutral-300">Free email notifications</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2.5">
|
||||
<svg class="w-4 h-4 shrink-0 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="dark:text-neutral-300">Support by email</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2.5 font-bold dark:text-white">
|
||||
<svg class="w-4 h-4 shrink-0 text-green-500" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor"
|
||||
stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<path
|
||||
d="M4 13a8 8 0 0 1 7 7a6 6 0 0 0 3-5a9 9 0 0 0 6-8a3 3 0 0 0-3-3a9 9 0 0 0-8 6a6 6 0 0 0-5 3" />
|
||||
<path d="M7 14a6 6 0 0 0-3 6a6 6 0 0 0 6-3m4-8a1 1 0 1 0 2 0a1 1 0 1 0-2 0" />
|
||||
</svg>
|
||||
+ All Upcoming Features
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col text-sm dark:text-white">
|
||||
<div>
|
||||
You need to bring your own servers from any cloud provider (such as <a class="underline"
|
||||
href="https://coolify.io/hetzner" target="_blank">Hetzner</a>, DigitalOcean, AWS,
|
||||
etc.)
|
||||
</div>
|
||||
<div>
|
||||
(You can connect your RPi, old laptop, or any other device that runs
|
||||
the <a class="underline"
|
||||
href="https://coolify.io/docs/installation#supported-operating-systems"
|
||||
target="_blank">supported operating systems</a>.)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex pt-4 h-14">
|
||||
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-basic"
|
||||
class="w-full" wire:click="subscribeStripe('dynamic-monthly')">
|
||||
Subscribe
|
||||
</x-forms.button>
|
||||
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-basic"
|
||||
class="w-full" wire:click="subscribeStripe('dynamic-yearly')">
|
||||
Subscribe
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<ul role="list" class="mt-8 space-y-3 text-sm leading-6 dark:text-neutral-400">
|
||||
<li class="flex">
|
||||
<svg class="flex-none w-5 h-6 mr-3 dark:text-warning" viewBox="0 0 20 20"
|
||||
fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Connect
|
||||
<span class="px-1 font-bold dark:text-white">unlimited</span> servers
|
||||
</li>
|
||||
<li class="flex">
|
||||
<svg class="flex-none w-5 h-6 mr-3 dark:text-warning" viewBox="0 0 20 20"
|
||||
fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Deploy
|
||||
<span class="px-1 font-bold dark:text-white">unlimited</span> applications per server
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<svg class="flex-none w-5 h-6 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Free email notifications
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<svg class="flex-none w-5 h-6 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Support by email
|
||||
</li>
|
||||
<li class="flex font-bold dark:text-white gap-x-3">
|
||||
<svg width="512" height="512" class="flex-none w-5 h-6 text-green-500"
|
||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2">
|
||||
<path
|
||||
d="M4 13a8 8 0 0 1 7 7a6 6 0 0 0 3-5a9 9 0 0 0 6-8a3 3 0 0 0-3-3a9 9 0 0 0-8 6a6 6 0 0 0-5 3" />
|
||||
<path d="M7 14a6 6 0 0 0-3 6a6 6 0 0 0 6-3m4-8a1 1 0 1 0 2 0a1 1 0 1 0-2 0" />
|
||||
</g>
|
||||
</svg>
|
||||
+ All Upcoming Features
|
||||
</li>
|
||||
<li class="flex dark:text-white gap-x-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="flex-none w-5 h-6 text-green-500"
|
||||
viewBox="0 0 256 256">
|
||||
<rect width="256" height="256" fill="none" />
|
||||
<polyline points="32 136 72 136 88 112 120 160 136 136 160 136" fill="none"
|
||||
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="16" />
|
||||
<path
|
||||
d="M24,104c0-.67,0-1.33,0-2A54,54,0,0,1,78,48c22.59,0,41.94,12.31,50,32,8.06-19.69,27.41-32,50-32a54,54,0,0,1,54,54c0,66-104,122-104,122s-42-22.6-72.58-56"
|
||||
fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="16" />
|
||||
</svg>
|
||||
|
||||
Do you require official support for your self-hosted instance?<a class="underline"
|
||||
href="https://coolify.io/docs/contact" target="_blank">Contact Us</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{-- BYOS Notice + Support --}}
|
||||
<div class="mt-6 pt-6 border-t dark:border-coolgray-400 border-neutral-200 text-sm dark:text-neutral-400">
|
||||
<p>You need to bring your own servers from any cloud provider (<a class="underline" href="https://coolify.io/hetzner" target="_blank">Hetzner</a>, DigitalOcean, AWS, etc.) or connect any device running a <a class="underline" href="https://coolify.io/docs/installation#supported-operating-systems" target="_blank">supported OS</a>.</p>
|
||||
<p class="mt-3">Need official support for your self-hosted instance? <a class="underline dark:text-white" href="https://coolify.io/docs/contact" target="_blank">Contact Us</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,6 @@
|
|||
Subscription | Coolify
|
||||
</x-slot>
|
||||
<h1>Subscription</h1>
|
||||
<div class="subtitle">Here you can see and manage your subscription.</div>
|
||||
<div class="subtitle">Manage your plan, billing, and server limits.</div>
|
||||
<livewire:subscription.actions />
|
||||
</div>
|
||||
|
|
|
|||
96
tests/Feature/Subscription/CancelSubscriptionActionsTest.php
Normal file
96
tests/Feature/Subscription/CancelSubscriptionActionsTest.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
config()->set('subscription.provider', 'stripe');
|
||||
config()->set('subscription.stripe_api_key', 'sk_test_fake');
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->subscription = Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_test_456',
|
||||
'stripe_customer_id' => 'cus_test_456',
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_plan_id' => 'price_test_456',
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
$this->mockStripe = Mockery::mock(StripeClient::class);
|
||||
$this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$this->mockStripe->subscriptions = $this->mockSubscriptions;
|
||||
});
|
||||
|
||||
describe('CancelSubscriptionAtPeriodEnd', function () {
|
||||
test('cancels subscription at period end successfully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('update')
|
||||
->with('sub_test_456', ['cancel_at_period_end' => true])
|
||||
->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => true]);
|
||||
|
||||
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect($result['error'])->toBeNull();
|
||||
|
||||
$this->subscription->refresh();
|
||||
expect($this->subscription->stripe_cancel_at_period_end)->toBeTruthy();
|
||||
expect($this->subscription->stripe_invoice_paid)->toBeTruthy();
|
||||
});
|
||||
|
||||
test('fails when no subscription exists', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
|
||||
$result = $action->execute($team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('No active subscription');
|
||||
});
|
||||
|
||||
test('fails when subscription is not active', function () {
|
||||
$this->subscription->update(['stripe_invoice_paid' => false]);
|
||||
|
||||
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('not active');
|
||||
});
|
||||
|
||||
test('fails when already set to cancel at period end', function () {
|
||||
$this->subscription->update(['stripe_cancel_at_period_end' => true]);
|
||||
|
||||
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('already set to cancel');
|
||||
});
|
||||
|
||||
test('handles stripe API error gracefully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('update')
|
||||
->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
|
||||
|
||||
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('Stripe error');
|
||||
});
|
||||
});
|
||||
271
tests/Feature/Subscription/RefundSubscriptionTest.php
Normal file
271
tests/Feature/Subscription/RefundSubscriptionTest.php
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Stripe\RefundSubscription;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Stripe\Service\InvoiceService;
|
||||
use Stripe\Service\RefundService;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
config()->set('subscription.provider', 'stripe');
|
||||
config()->set('subscription.stripe_api_key', 'sk_test_fake');
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->subscription = Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_test_123',
|
||||
'stripe_customer_id' => 'cus_test_123',
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_plan_id' => 'price_test_123',
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
$this->mockStripe = Mockery::mock(StripeClient::class);
|
||||
$this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$this->mockInvoices = Mockery::mock(InvoiceService::class);
|
||||
$this->mockRefunds = Mockery::mock(RefundService::class);
|
||||
|
||||
$this->mockStripe->subscriptions = $this->mockSubscriptions;
|
||||
$this->mockStripe->invoices = $this->mockInvoices;
|
||||
$this->mockStripe->refunds = $this->mockRefunds;
|
||||
});
|
||||
|
||||
describe('checkEligibility', function () {
|
||||
test('returns eligible when subscription is within 30 days', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($this->team);
|
||||
|
||||
expect($result['eligible'])->toBeTrue();
|
||||
expect($result['days_remaining'])->toBe(20);
|
||||
});
|
||||
|
||||
test('returns ineligible when subscription is past 30 days', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(35)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($this->team);
|
||||
|
||||
expect($result['eligible'])->toBeFalse();
|
||||
expect($result['days_remaining'])->toBe(0);
|
||||
expect($result['reason'])->toContain('30-day refund window has expired');
|
||||
});
|
||||
|
||||
test('returns ineligible when subscription is not active', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'canceled',
|
||||
'start_date' => now()->subDays(5)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($this->team);
|
||||
|
||||
expect($result['eligible'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns ineligible when no subscription exists', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($team);
|
||||
|
||||
expect($result['eligible'])->toBeFalse();
|
||||
expect($result['reason'])->toContain('No active subscription');
|
||||
});
|
||||
|
||||
test('returns ineligible when invoice is not paid', function () {
|
||||
$this->subscription->update(['stripe_invoice_paid' => false]);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($this->team);
|
||||
|
||||
expect($result['eligible'])->toBeFalse();
|
||||
expect($result['reason'])->toContain('not paid');
|
||||
});
|
||||
|
||||
test('returns ineligible when team has already been refunded', function () {
|
||||
$this->subscription->update(['stripe_refunded_at' => now()->subDays(60)]);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($this->team);
|
||||
|
||||
expect($result['eligible'])->toBeFalse();
|
||||
expect($result['reason'])->toContain('already been processed');
|
||||
});
|
||||
|
||||
test('returns ineligible when stripe subscription not found', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription'));
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($this->team);
|
||||
|
||||
expect($result['eligible'])->toBeFalse();
|
||||
expect($result['reason'])->toContain('not found in Stripe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', function () {
|
||||
test('processes refund successfully', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$invoiceCollection = (object) ['data' => [
|
||||
(object) ['payment_intent' => 'pi_test_123'],
|
||||
]];
|
||||
|
||||
$this->mockInvoices
|
||||
->shouldReceive('all')
|
||||
->with([
|
||||
'subscription' => 'sub_test_123',
|
||||
'status' => 'paid',
|
||||
'limit' => 1,
|
||||
])
|
||||
->andReturn($invoiceCollection);
|
||||
|
||||
$this->mockRefunds
|
||||
->shouldReceive('create')
|
||||
->with(['payment_intent' => 'pi_test_123'])
|
||||
->andReturn((object) ['id' => 're_test_123']);
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('cancel')
|
||||
->with('sub_test_123')
|
||||
->andReturn((object) ['status' => 'canceled']);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect($result['error'])->toBeNull();
|
||||
|
||||
$this->subscription->refresh();
|
||||
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
|
||||
expect($this->subscription->stripe_feedback)->toBe('Refund requested by user');
|
||||
expect($this->subscription->stripe_refunded_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('prevents a second refund after re-subscribing', function () {
|
||||
$this->subscription->update([
|
||||
'stripe_refunded_at' => now()->subDays(15),
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_subscription_id' => 'sub_test_new_456',
|
||||
]);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('already been processed');
|
||||
});
|
||||
|
||||
test('fails when no paid invoice found', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$invoiceCollection = (object) ['data' => []];
|
||||
|
||||
$this->mockInvoices
|
||||
->shouldReceive('all')
|
||||
->andReturn($invoiceCollection);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('No paid invoice');
|
||||
});
|
||||
|
||||
test('fails when invoice has no payment intent', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$invoiceCollection = (object) ['data' => [
|
||||
(object) ['payment_intent' => null],
|
||||
]];
|
||||
|
||||
$this->mockInvoices
|
||||
->shouldReceive('all')
|
||||
->andReturn($invoiceCollection);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('No payment intent');
|
||||
});
|
||||
|
||||
test('fails when subscription is past refund window', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(35)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('30-day refund window');
|
||||
});
|
||||
});
|
||||
85
tests/Feature/Subscription/ResumeSubscriptionTest.php
Normal file
85
tests/Feature/Subscription/ResumeSubscriptionTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Stripe\ResumeSubscription;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
config()->set('subscription.provider', 'stripe');
|
||||
config()->set('subscription.stripe_api_key', 'sk_test_fake');
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->subscription = Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_test_789',
|
||||
'stripe_customer_id' => 'cus_test_789',
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_plan_id' => 'price_test_789',
|
||||
'stripe_cancel_at_period_end' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
$this->mockStripe = Mockery::mock(StripeClient::class);
|
||||
$this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$this->mockStripe->subscriptions = $this->mockSubscriptions;
|
||||
});
|
||||
|
||||
describe('ResumeSubscription', function () {
|
||||
test('resumes subscription successfully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('update')
|
||||
->with('sub_test_789', ['cancel_at_period_end' => false])
|
||||
->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => false]);
|
||||
|
||||
$action = new ResumeSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect($result['error'])->toBeNull();
|
||||
|
||||
$this->subscription->refresh();
|
||||
expect($this->subscription->stripe_cancel_at_period_end)->toBeFalsy();
|
||||
});
|
||||
|
||||
test('fails when no subscription exists', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$action = new ResumeSubscription($this->mockStripe);
|
||||
$result = $action->execute($team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('No active subscription');
|
||||
});
|
||||
|
||||
test('fails when subscription is not set to cancel', function () {
|
||||
$this->subscription->update(['stripe_cancel_at_period_end' => false]);
|
||||
|
||||
$action = new ResumeSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('not set to cancel');
|
||||
});
|
||||
|
||||
test('handles stripe API error gracefully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('update')
|
||||
->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
|
||||
|
||||
$action = new ResumeSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('Stripe error');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue