feat(subscription): add Stripe action controls

Add subscription action handling and tests for cancel, resume, and refund flows while resolving StripeClient through the container.
This commit is contained in:
Andras Bacsai 2026-06-12 15:54:21 +02:00
parent 1833ba7ce6
commit 61d2d17f52
17 changed files with 203 additions and 46 deletions

View file

@ -5,6 +5,7 @@
use App\Models\Subscription; use App\Models\Subscription;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient; use Stripe\StripeClient;
class CancelSubscription class CancelSubscription
@ -21,7 +22,7 @@ public function __construct(User $user, bool $isDryRun = false)
$this->isDryRun = $isDryRun; $this->isDryRun = $isDryRun;
if (! $isDryRun && isCloud()) { if (! $isDryRun && isCloud()) {
$this->stripe = new StripeClient(config('subscription.stripe_api_key')); $this->stripe = app(StripeClient::class);
} }
} }
@ -64,7 +65,7 @@ public function verifySubscriptionsInStripe(): array
]; ];
} }
$stripe = new StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$subscriptions = $this->getSubscriptionsPreview(); $subscriptions = $this->getSubscriptionsPreview();
$verified = collect(); $verified = collect();
@ -88,7 +89,7 @@ public function verifySubscriptionsInStripe(): array
'reason' => "Status in Stripe: {$stripeSubscription->status}", 'reason' => "Status in Stripe: {$stripeSubscription->status}",
]); ]);
} }
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
// Subscription doesn't exist in Stripe // Subscription doesn't exist in Stripe
$notFound->push([ $notFound->push([
'subscription' => $subscription, 'subscription' => $subscription,
@ -181,7 +182,7 @@ public static function cancelById(string $subscriptionId): bool
return false; return false;
} }
$stripe = new StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$stripe->subscriptions->cancel($subscriptionId, []); $stripe->subscriptions->cancel($subscriptionId, []);
// Update local record if exists // Update local record if exists

View file

@ -3,6 +3,7 @@
namespace App\Actions\Stripe; namespace App\Actions\Stripe;
use App\Models\Team; use App\Models\Team;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient; use Stripe\StripeClient;
class CancelSubscriptionAtPeriodEnd class CancelSubscriptionAtPeriodEnd
@ -11,7 +12,7 @@ class CancelSubscriptionAtPeriodEnd
public function __construct(?StripeClient $stripe = null) public function __construct(?StripeClient $stripe = null)
{ {
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); $this->stripe = $stripe ?? app(StripeClient::class);
} }
/** /**
@ -47,7 +48,7 @@ public function execute(Team $team): array
\Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}"); \Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}");
return ['success' => true, 'error' => null]; return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
\Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage()); \Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];

View file

@ -3,6 +3,8 @@
namespace App\Actions\Stripe; namespace App\Actions\Stripe;
use App\Models\Team; use App\Models\Team;
use Carbon\Carbon;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient; use Stripe\StripeClient;
class RefundSubscription class RefundSubscription
@ -13,7 +15,7 @@ class RefundSubscription
public function __construct(?StripeClient $stripe = null) public function __construct(?StripeClient $stripe = null)
{ {
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); $this->stripe = $stripe ?? app(StripeClient::class);
} }
/** /**
@ -39,7 +41,7 @@ public function checkEligibility(Team $team): array
try { try {
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id); $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
return $this->ineligible('Subscription not found in Stripe.'); return $this->ineligible('Subscription not found in Stripe.');
} }
@ -49,7 +51,7 @@ public function checkEligibility(Team $team): array
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd); return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd);
} }
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date); $startDate = Carbon::createFromTimestamp($stripeSubscription->start_date);
$daysSinceStart = (int) $startDate->diffInDays(now()); $daysSinceStart = (int) $startDate->diffInDays(now());
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart; $daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
@ -130,7 +132,7 @@ public function execute(Team $team): array
\Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}"); \Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}");
return ['success' => true, 'error' => null]; return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
\Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage()); \Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];

View file

@ -3,6 +3,7 @@
namespace App\Actions\Stripe; namespace App\Actions\Stripe;
use App\Models\Team; use App\Models\Team;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient; use Stripe\StripeClient;
class ResumeSubscription class ResumeSubscription
@ -11,7 +12,7 @@ class ResumeSubscription
public function __construct(?StripeClient $stripe = null) public function __construct(?StripeClient $stripe = null)
{ {
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); $this->stripe = $stripe ?? app(StripeClient::class);
} }
/** /**
@ -43,7 +44,7 @@ public function execute(Team $team): array
\Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}"); \Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}");
return ['success' => true, 'error' => null]; return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
\Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage()); \Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];

View file

@ -17,7 +17,7 @@ class UpdateSubscriptionQuantity
public function __construct(?StripeClient $stripe = null) public function __construct(?StripeClient $stripe = null)
{ {
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); $this->stripe = $stripe ?? app(StripeClient::class);
} }
/** /**

View file

@ -4,6 +4,8 @@
use App\Models\Team; use App\Models\Team;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class CloudFixSubscription extends Command class CloudFixSubscription extends Command
{ {
@ -31,7 +33,7 @@ class CloudFixSubscription extends Command
*/ */
public function handle() public function handle()
{ {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
if ($this->option('verify-all')) { if ($this->option('verify-all')) {
return $this->verifyAllActiveSubscriptions($stripe); return $this->verifyAllActiveSubscriptions($stripe);
@ -111,7 +113,7 @@ public function handle()
/** /**
* Fix canceled subscriptions in the database * Fix canceled subscriptions in the database
*/ */
private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe) private function fixCanceledSubscriptions(StripeClient $stripe)
{ {
$isDryRun = $this->option('dry-run'); $isDryRun = $this->option('dry-run');
$checkOne = $this->option('one'); $checkOne = $this->option('one');
@ -220,7 +222,7 @@ private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
break; break;
} }
} }
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
if ($e->getStripeCode() === 'resource_missing') { if ($e->getStripeCode() === 'resource_missing') {
$toFixCount++; $toFixCount++;
@ -326,7 +328,7 @@ private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
/** /**
* Verify all active subscriptions against Stripe API * Verify all active subscriptions against Stripe API
*/ */
private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe) private function verifyAllActiveSubscriptions(StripeClient $stripe)
{ {
$isDryRun = $this->option('dry-run'); $isDryRun = $this->option('dry-run');
$shouldFix = $this->option('fix-verified'); $shouldFix = $this->option('fix-verified');
@ -570,7 +572,7 @@ private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
break; break;
} }
} catch (\Stripe\Exception\InvalidRequestException $e) { } catch (InvalidRequestException $e) {
$this->error(' → Error: '.$e->getMessage()); $this->error(' → Error: '.$e->getMessage());
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) { if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
@ -730,7 +732,7 @@ private function fixSubscription($team, $subscription, $status)
/** /**
* Search for subscriptions by customer ID * Search for subscriptions by customer ID
*/ */
private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false) private function searchSubscriptionsByCustomer(StripeClient $stripe, $customerId, $requireActive = false)
{ {
try { try {
$subscriptions = $stripe->subscriptions->all([ $subscriptions = $stripe->subscriptions->all([
@ -770,7 +772,7 @@ private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $cu
/** /**
* Search for subscriptions by team member emails * Search for subscriptions by team member emails
*/ */
private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails) private function searchSubscriptionsByEmails(StripeClient $stripe, $emails)
{ {
$this->line(' → Searching by team member emails...'); $this->line(' → Searching by team member emails...');

View file

@ -36,7 +36,7 @@ public function handle(): void
$data = data_get($this->event, 'data.object'); $data = data_get($this->event, 'data.object');
switch ($type) { switch ($type) {
case 'radar.early_fraud_warning.created': case 'radar.early_fraud_warning.created':
$stripe = new StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$id = data_get($data, 'id'); $id = data_get($data, 'id');
$charge = data_get($data, 'charge'); $charge = data_get($data, 'charge');
if ($charge) { if ($charge) {
@ -100,7 +100,7 @@ public function handle(): void
if ($subscription->stripe_subscription_id) { if ($subscription->stripe_subscription_id) {
try { try {
$stripe = new StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$stripeSubscription = $stripe->subscriptions->retrieve( $stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id $subscription->stripe_subscription_id
); );
@ -166,7 +166,7 @@ public function handle(): void
// Verify payment status with Stripe API before sending failure notification // Verify payment status with Stripe API before sending failure notification
if ($paymentIntentId) { if ($paymentIntentId) {
try { try {
$stripe = new StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId); $paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) { if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {

View file

@ -10,6 +10,7 @@
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Stripe\StripeClient;
class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue
{ {
@ -27,7 +28,7 @@ public function handle()
$subscription = $this->team->subscription; $subscription = $this->team->subscription;
if ($subscription && $subscription->stripe_customer_id) { if ($subscription && $subscription->stripe_customer_id) {
try { try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
if ($subscription->stripe_subscription_id) { if ($subscription->stripe_subscription_id) {
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id); $stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);

View file

@ -9,6 +9,7 @@
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Stripe\StripeClient;
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
{ {
@ -33,7 +34,7 @@ public function handle(?\Closure $onProgress = null): array
->where('stripe_invoice_paid', true) ->where('stripe_invoice_paid', true)
->get(); ->get();
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
// Bulk fetch all valid subscription IDs from Stripe (active + past_due) // Bulk fetch all valid subscription IDs from Stripe (active + past_due)
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress); $validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
@ -123,7 +124,7 @@ public function handle(?\Closure $onProgress = null): array
* *
* @return array{email: string, customer_id: string, subscription_id: string, status: string}|null * @return array{email: string, customer_id: string, subscription_id: string, status: string}|null
*/ */
private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array private function findActiveSubscriptionByEmail(StripeClient $stripe, string $customerId): ?array
{ {
try { try {
$customer = $stripe->customers->retrieve($customerId); $customer = $stripe->customers->retrieve($customerId);
@ -177,7 +178,7 @@ private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, str
* *
* @return array<string> * @return array<string>
*/ */
private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure $onProgress = null): array
{ {
$validIds = []; $validIds = [];
$fetched = 0; $fetched = 0;

View file

@ -9,6 +9,7 @@
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Stripe\StripeClient;
class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue
{ {
@ -29,7 +30,7 @@ public function handle(): void
if (! $this->subscription->stripe_subscription_id && if (! $this->subscription->stripe_subscription_id &&
$this->subscription->stripe_customer_id) { $this->subscription->stripe_customer_id) {
try { try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$subscriptions = $stripe->subscriptions->all([ $subscriptions = $stripe->subscriptions->all([
'customer' => $this->subscription->stripe_customer_id, 'customer' => $this->subscription->stripe_customer_id,
'limit' => 1, 'limit' => 1,
@ -50,7 +51,7 @@ public function handle(): void
} }
try { try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$stripeSubscription = $stripe->subscriptions->retrieve( $stripeSubscription = $stripe->subscriptions->retrieve(
$this->subscription->stripe_subscription_id $this->subscription->stripe_subscription_id
); );

View file

@ -32,6 +32,8 @@ class Actions extends Component
public bool $refundAlreadyUsed = false; public bool $refundAlreadyUsed = false;
public bool $refundLatestPayment = false;
public string $billingInterval = 'monthly'; public string $billingInterval = 'monthly';
public ?string $nextBillingDate = null; public ?string $nextBillingDate = null;
@ -100,7 +102,7 @@ public function refundSubscription(string $password): bool|string
return 'Invalid password.'; return 'Invalid password.';
} }
$result = (new RefundSubscription)->execute(currentTeam()); $result = app(RefundSubscription::class)->execute(currentTeam());
if ($result['success']) { if ($result['success']) {
$this->dispatch('success', 'Subscription refunded successfully.'); $this->dispatch('success', 'Subscription refunded successfully.');
@ -114,12 +116,28 @@ public function refundSubscription(string $password): bool|string
return true; return true;
} }
public function cancelImmediately(string $password): bool|string public function cancelImmediately(string $password, array $selectedActions = []): bool|string
{ {
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) { if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
return 'Invalid password.'; return 'Invalid password.';
} }
if (in_array('refundLatestPayment', $selectedActions, true)) {
// Eligibility is re-validated server-side inside RefundSubscription::execute()
$result = app(RefundSubscription::class)->execute(currentTeam());
if ($result['success']) {
$this->dispatch('success', 'Subscription refunded and cancelled 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;
}
$team = currentTeam(); $team = currentTeam();
$subscription = $team->subscription; $subscription = $team->subscription;
@ -130,7 +148,7 @@ public function cancelImmediately(string $password): bool|string
} }
try { try {
$stripe = new StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$stripe->subscriptions->cancel($subscription->stripe_subscription_id); $stripe->subscriptions->cancel($subscription->stripe_subscription_id);
$subscription->update([ $subscription->update([

View file

@ -5,6 +5,7 @@
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Livewire\Component; use Livewire\Component;
use Stripe\StripeClient;
class Index extends Component class Index extends Component
{ {
@ -52,7 +53,7 @@ public function getStripeStatus()
{ {
try { try {
$subscription = currentTeam()->subscription; $subscription = currentTeam()->subscription;
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = app(StripeClient::class);
$customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id); $customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id);
if ($customer) { if ($customer) {
$subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]); $subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]);

View file

@ -11,6 +11,7 @@
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use Laravel\Telescope\TelescopeServiceProvider; use Laravel\Telescope\TelescopeServiceProvider;
use Stripe\StripeClient;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -19,6 +20,8 @@ public function register(): void
if (App::isLocal()) { if (App::isLocal()) {
$this->app->register(TelescopeServiceProvider::class); $this->app->register(TelescopeServiceProvider::class);
} }
$this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key')));
} }
public function boot(): void public function boot(): void

View file

@ -11,12 +11,21 @@
*/ */
public function up(): void public function up(): void
{ {
// SQLite (testing) uses type affinity, so json columns already accept text
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text');
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
} }
public function down(): void public function down(): void
{ {
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json');
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json');
} }

View file

@ -230,15 +230,37 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
]" confirmationText="{{ currentTeam()->name }}" ]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm" confirmationLabel="Enter your team name to confirm"
shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" /> shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" />
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately" @if ($isRefundEligible)
isErrorButton submitAction="cancelImmediately" <div wire:key="cancel-immediately-refundable">
:actions="[ <x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
'Your subscription will be cancelled immediately.', isErrorButton submitAction="cancelImmediately"
'All servers will be deactivated.', :checkboxes="[
'No refund will be issued for the remaining period.', [
]" confirmationText="{{ currentTeam()->name }}" 'id' => 'refundLatestPayment',
confirmationLabel="Enter your team name to confirm" 'label' => 'Refund my latest payment (eligible for '.$refundDaysRemaining.' more days).',
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" /> 'default_warning' => 'No refund will be issued for the remaining period.',
],
]"
:actions="[
'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="Permanently Cancel" />
</div>
@else
<div wire:key="cancel-immediately-standard">
<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>
@endif
@endif @endif
</div> </div>
@if (currentTeam()->subscription->stripe_cancel_at_period_end) @if (currentTeam()->subscription->stripe_cancel_at_period_end)
@ -249,7 +271,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
{{-- Refund --}} {{-- Refund --}}
<section> <section>
<h3 class="pb-2">Refund</h3> <h3 class="pb-2">Refund</h3>
@if ($refundCheckLoading || ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)) @if ($refundCheckLoading || $isRefundEligible)
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@if ($refundCheckLoading) @if ($refundCheckLoading)
<x-forms.button disabled>Request Full Refund</x-forms.button> <x-forms.button disabled>Request Full Refund</x-forms.button>
@ -269,7 +291,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
<p class="mt-2 text-sm text-neutral-500"> <p class="mt-2 text-sm text-neutral-500">
@if ($refundCheckLoading) @if ($refundCheckLoading)
Checking refund eligibility... Checking refund eligibility...
@elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) @elseif ($isRefundEligible)
Eligible for a full refund &mdash; <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining. Eligible for a full refund &mdash; <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining.
@elseif ($refundAlreadyUsed) @elseif ($refundAlreadyUsed)
Refund already processed. Each team is eligible for one refund only. Refund already processed. Each team is eligible for one refund only.

View file

@ -5,6 +5,7 @@
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Exception\InvalidRequestException;
use Stripe\Service\InvoiceService; use Stripe\Service\InvoiceService;
use Stripe\Service\RefundService; use Stripe\Service\RefundService;
use Stripe\Service\SubscriptionService; use Stripe\Service\SubscriptionService;
@ -85,6 +86,28 @@
expect($result['current_period_end'])->toBe($periodEnd); expect($result['current_period_end'])->toBe($periodEnd);
}); });
test('returns eligible when subscription is set to cancel at period end', function () {
$this->subscription->update(['stripe_cancel_at_period_end' => true]);
$periodEnd = now()->addDays(20)->timestamp;
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
'current_period_end' => $periodEnd,
];
$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 not active', function () { test('returns ineligible when subscription is not active', function () {
$periodEnd = now()->addDays(25)->timestamp; $periodEnd = now()->addDays(25)->timestamp;
$stripeSubscription = (object) [ $stripeSubscription = (object) [
@ -141,7 +164,7 @@
$this->mockSubscriptions $this->mockSubscriptions
->shouldReceive('retrieve') ->shouldReceive('retrieve')
->with('sub_test_123') ->with('sub_test_123')
->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription')); ->andThrow(new InvalidRequestException('No such subscription'));
$action = new RefundSubscription($this->mockStripe); $action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($this->team); $result = $action->checkEligibility($this->team);
@ -269,6 +292,7 @@
$stripeSubscription = (object) [ $stripeSubscription = (object) [
'status' => 'active', 'status' => 'active',
'start_date' => now()->subDays(10)->timestamp, 'start_date' => now()->subDays(10)->timestamp,
'current_period_end' => now()->addDays(20)->timestamp,
]; ];
$this->mockSubscriptions $this->mockSubscriptions
@ -298,7 +322,7 @@
$this->mockSubscriptions $this->mockSubscriptions
->shouldReceive('cancel') ->shouldReceive('cancel')
->with('sub_test_123') ->with('sub_test_123')
->andThrow(new \Exception('Stripe cancel API error')); ->andThrow(new Exception('Stripe cancel API error'));
$action = new RefundSubscription($this->mockStripe); $action = new RefundSubscription($this->mockStripe);
$result = $action->execute($this->team); $result = $action->execute($this->team);

View file

@ -0,0 +1,70 @@
<?php
use App\Actions\Stripe\RefundSubscription;
use App\Livewire\Subscription\Actions;
use App\Models\InstanceSettings;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
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');
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
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->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('cancelImmediately with refund option', function () {
test('refunds and cancels via RefundSubscription when refund checkbox is selected', function () {
$mock = Mockery::mock(RefundSubscription::class);
$mock->shouldReceive('execute')->once()->andReturn(['success' => true, 'error' => null]);
$this->instance(RefundSubscription::class, $mock);
Livewire::test(Actions::class)
->call('cancelImmediately', 'password', ['refundLatestPayment'])
->assertDispatched('success')
->assertRedirect(route('subscription.index'));
});
test('dispatches error when refund fails', function () {
$mock = Mockery::mock(RefundSubscription::class);
$mock->shouldReceive('execute')->once()->andReturn(['success' => false, 'error' => 'No paid invoice found to refund.']);
$this->instance(RefundSubscription::class, $mock);
Livewire::test(Actions::class)
->call('cancelImmediately', 'password', ['refundLatestPayment'])
->assertDispatched('error');
});
test('rejects invalid password before refunding', function () {
$mock = Mockery::mock(RefundSubscription::class);
$mock->shouldNotReceive('execute');
$this->instance(RefundSubscription::class, $mock);
Livewire::test(Actions::class)
->call('cancelImmediately', 'wrong-password', ['refundLatestPayment'])
->assertReturned('Invalid password.');
});
});