chore: prepare for PR

This commit is contained in:
Andras Bacsai committed 2026-02-26 18:22:03 +01:00
1 parent 78aea9a7ec
commit 8f2800a9e5
16 files changed
+1212 -242

No files matched your search

@@ -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
View 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
View 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.'];
}
}
}
+136 -2
View File
@@ -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());
}
}
}
+7
View File
@@ -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);