From 61d2d17f525c1b5dd4d95f095c0102dec51315ae Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:54:21 +0200 Subject: [PATCH] feat(subscription): add Stripe action controls Add subscription action handling and tests for cancel, resume, and refund flows while resolving StripeClient through the container. --- app/Actions/Stripe/CancelSubscription.php | 9 +-- .../Stripe/CancelSubscriptionAtPeriodEnd.php | 5 +- app/Actions/Stripe/RefundSubscription.php | 10 +-- app/Actions/Stripe/ResumeSubscription.php | 5 +- .../Stripe/UpdateSubscriptionQuantity.php | 2 +- .../Commands/Cloud/CloudFixSubscription.php | 16 +++-- app/Jobs/StripeProcessJob.php | 6 +- app/Jobs/SubscriptionInvoiceFailedJob.php | 3 +- app/Jobs/SyncStripeSubscriptionsJob.php | 7 +- .../VerifyStripeSubscriptionStatusJob.php | 5 +- app/Livewire/Subscription/Actions.php | 24 ++++++- app/Livewire/Subscription/Index.php | 3 +- app/Providers/AppServiceProvider.php | 3 + ...ation_deployment_configuration_columns.php | 9 +++ .../livewire/subscription/actions.blade.php | 44 +++++++++--- .../Subscription/RefundSubscriptionTest.php | 28 +++++++- .../Subscription/SubscriptionActionsTest.php | 70 +++++++++++++++++++ 17 files changed, 203 insertions(+), 46 deletions(-) create mode 100644 tests/Feature/Subscription/SubscriptionActionsTest.php diff --git a/app/Actions/Stripe/CancelSubscription.php b/app/Actions/Stripe/CancelSubscription.php index 71b6ed52b..47aa5e2e9 100644 --- a/app/Actions/Stripe/CancelSubscription.php +++ b/app/Actions/Stripe/CancelSubscription.php @@ -5,6 +5,7 @@ use App\Models\Subscription; use App\Models\User; use Illuminate\Support\Collection; +use Stripe\Exception\InvalidRequestException; use Stripe\StripeClient; class CancelSubscription @@ -21,7 +22,7 @@ public function __construct(User $user, bool $isDryRun = false) $this->isDryRun = $isDryRun; 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(); $verified = collect(); @@ -88,7 +89,7 @@ public function verifySubscriptionsInStripe(): array 'reason' => "Status in Stripe: {$stripeSubscription->status}", ]); } - } catch (\Stripe\Exception\InvalidRequestException $e) { + } catch (InvalidRequestException $e) { // Subscription doesn't exist in Stripe $notFound->push([ 'subscription' => $subscription, @@ -181,7 +182,7 @@ public static function cancelById(string $subscriptionId): bool return false; } - $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $stripe->subscriptions->cancel($subscriptionId, []); // Update local record if exists diff --git a/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php b/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php index 34c7d194a..bb5d02103 100644 --- a/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php +++ b/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php @@ -3,6 +3,7 @@ namespace App\Actions\Stripe; use App\Models\Team; +use Stripe\Exception\InvalidRequestException; use Stripe\StripeClient; class CancelSubscriptionAtPeriodEnd @@ -11,7 +12,7 @@ class CancelSubscriptionAtPeriodEnd 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}"); 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()); return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; diff --git a/app/Actions/Stripe/RefundSubscription.php b/app/Actions/Stripe/RefundSubscription.php index b10d783db..f891a252f 100644 --- a/app/Actions/Stripe/RefundSubscription.php +++ b/app/Actions/Stripe/RefundSubscription.php @@ -3,6 +3,8 @@ namespace App\Actions\Stripe; use App\Models\Team; +use Carbon\Carbon; +use Stripe\Exception\InvalidRequestException; use Stripe\StripeClient; class RefundSubscription @@ -13,7 +15,7 @@ class RefundSubscription 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 { $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.'); } @@ -49,7 +51,7 @@ public function checkEligibility(Team $team): array 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()); $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}"); return ['success' => true, 'error' => null]; - } catch (\Stripe\Exception\InvalidRequestException $e) { + } catch (InvalidRequestException $e) { \Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage()); return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; diff --git a/app/Actions/Stripe/ResumeSubscription.php b/app/Actions/Stripe/ResumeSubscription.php index d8019def7..87b114d56 100644 --- a/app/Actions/Stripe/ResumeSubscription.php +++ b/app/Actions/Stripe/ResumeSubscription.php @@ -3,6 +3,7 @@ namespace App\Actions\Stripe; use App\Models\Team; +use Stripe\Exception\InvalidRequestException; use Stripe\StripeClient; class ResumeSubscription @@ -11,7 +12,7 @@ class ResumeSubscription 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}"); 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()); return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php index d4d29af20..458a91d55 100644 --- a/app/Actions/Stripe/UpdateSubscriptionQuantity.php +++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php @@ -17,7 +17,7 @@ class UpdateSubscriptionQuantity public function __construct(?StripeClient $stripe = null) { - $this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + $this->stripe = $stripe ?? app(StripeClient::class); } /** diff --git a/app/Console/Commands/Cloud/CloudFixSubscription.php b/app/Console/Commands/Cloud/CloudFixSubscription.php index 194e9bb5f..55012c006 100644 --- a/app/Console/Commands/Cloud/CloudFixSubscription.php +++ b/app/Console/Commands/Cloud/CloudFixSubscription.php @@ -4,6 +4,8 @@ use App\Models\Team; use Illuminate\Console\Command; +use Stripe\Exception\InvalidRequestException; +use Stripe\StripeClient; class CloudFixSubscription extends Command { @@ -31,7 +33,7 @@ class CloudFixSubscription extends Command */ public function handle() { - $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); if ($this->option('verify-all')) { return $this->verifyAllActiveSubscriptions($stripe); @@ -111,7 +113,7 @@ public function handle() /** * Fix canceled subscriptions in the database */ - private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe) + private function fixCanceledSubscriptions(StripeClient $stripe) { $isDryRun = $this->option('dry-run'); $checkOne = $this->option('one'); @@ -220,7 +222,7 @@ private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe) break; } } - } catch (\Stripe\Exception\InvalidRequestException $e) { + } catch (InvalidRequestException $e) { if ($e->getStripeCode() === 'resource_missing') { $toFixCount++; @@ -326,7 +328,7 @@ private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe) /** * Verify all active subscriptions against Stripe API */ - private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe) + private function verifyAllActiveSubscriptions(StripeClient $stripe) { $isDryRun = $this->option('dry-run'); $shouldFix = $this->option('fix-verified'); @@ -570,7 +572,7 @@ private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe) break; } - } catch (\Stripe\Exception\InvalidRequestException $e) { + } catch (InvalidRequestException $e) { $this->error(' → Error: '.$e->getMessage()); if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) { @@ -730,7 +732,7 @@ private function fixSubscription($team, $subscription, $status) /** * Search for subscriptions by customer ID */ - private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false) + private function searchSubscriptionsByCustomer(StripeClient $stripe, $customerId, $requireActive = false) { try { $subscriptions = $stripe->subscriptions->all([ @@ -770,7 +772,7 @@ private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $cu /** * 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...'); diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index b031b9c7d..c3827d9fb 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -36,7 +36,7 @@ public function handle(): void $data = data_get($this->event, 'data.object'); switch ($type) { case 'radar.early_fraud_warning.created': - $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $id = data_get($data, 'id'); $charge = data_get($data, 'charge'); if ($charge) { @@ -100,7 +100,7 @@ public function handle(): void if ($subscription->stripe_subscription_id) { try { - $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $stripeSubscription = $stripe->subscriptions->retrieve( $subscription->stripe_subscription_id ); @@ -166,7 +166,7 @@ public function handle(): void // Verify payment status with Stripe API before sending failure notification if ($paymentIntentId) { try { - $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId); if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) { diff --git a/app/Jobs/SubscriptionInvoiceFailedJob.php b/app/Jobs/SubscriptionInvoiceFailedJob.php index 927d50467..99b3f8be9 100755 --- a/app/Jobs/SubscriptionInvoiceFailedJob.php +++ b/app/Jobs/SubscriptionInvoiceFailedJob.php @@ -10,6 +10,7 @@ use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Stripe\StripeClient; class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue { @@ -27,7 +28,7 @@ public function handle() $subscription = $this->team->subscription; if ($subscription && $subscription->stripe_customer_id) { try { - $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); if ($subscription->stripe_subscription_id) { $stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id); diff --git a/app/Jobs/SyncStripeSubscriptionsJob.php b/app/Jobs/SyncStripeSubscriptionsJob.php index 0e221756d..572d6e78c 100644 --- a/app/Jobs/SyncStripeSubscriptionsJob.php +++ b/app/Jobs/SyncStripeSubscriptionsJob.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Stripe\StripeClient; class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue { @@ -33,7 +34,7 @@ public function handle(?\Closure $onProgress = null): array ->where('stripe_invoice_paid', true) ->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) $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 */ - private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array + private function findActiveSubscriptionByEmail(StripeClient $stripe, string $customerId): ?array { try { $customer = $stripe->customers->retrieve($customerId); @@ -177,7 +178,7 @@ private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, str * * @return array */ - private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array + private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure $onProgress = null): array { $validIds = []; $fetched = 0; diff --git a/app/Jobs/VerifyStripeSubscriptionStatusJob.php b/app/Jobs/VerifyStripeSubscriptionStatusJob.php index f7addacf1..35b41a369 100644 --- a/app/Jobs/VerifyStripeSubscriptionStatusJob.php +++ b/app/Jobs/VerifyStripeSubscriptionStatusJob.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Stripe\StripeClient; class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue { @@ -29,7 +30,7 @@ public function handle(): void if (! $this->subscription->stripe_subscription_id && $this->subscription->stripe_customer_id) { try { - $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $subscriptions = $stripe->subscriptions->all([ 'customer' => $this->subscription->stripe_customer_id, 'limit' => 1, @@ -50,7 +51,7 @@ public function handle(): void } try { - $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $stripeSubscription = $stripe->subscriptions->retrieve( $this->subscription->stripe_subscription_id ); diff --git a/app/Livewire/Subscription/Actions.php b/app/Livewire/Subscription/Actions.php index 33eed3a6a..ea560b039 100644 --- a/app/Livewire/Subscription/Actions.php +++ b/app/Livewire/Subscription/Actions.php @@ -32,6 +32,8 @@ class Actions extends Component public bool $refundAlreadyUsed = false; + public bool $refundLatestPayment = false; + public string $billingInterval = 'monthly'; public ?string $nextBillingDate = null; @@ -100,7 +102,7 @@ public function refundSubscription(string $password): bool|string return 'Invalid password.'; } - $result = (new RefundSubscription)->execute(currentTeam()); + $result = app(RefundSubscription::class)->execute(currentTeam()); if ($result['success']) { $this->dispatch('success', 'Subscription refunded successfully.'); @@ -114,12 +116,28 @@ public function refundSubscription(string $password): bool|string 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)) { 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 contact us.'); + + return true; + } + $team = currentTeam(); $subscription = $team->subscription; @@ -130,7 +148,7 @@ public function cancelImmediately(string $password): bool|string } try { - $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $stripe->subscriptions->cancel($subscription->stripe_subscription_id); $subscription->update([ diff --git a/app/Livewire/Subscription/Index.php b/app/Livewire/Subscription/Index.php index ac37cca05..022f6fdee 100644 --- a/app/Livewire/Subscription/Index.php +++ b/app/Livewire/Subscription/Index.php @@ -5,6 +5,7 @@ use App\Models\InstanceSettings; use App\Providers\RouteServiceProvider; use Livewire\Component; +use Stripe\StripeClient; class Index extends Component { @@ -52,7 +53,7 @@ public function getStripeStatus() { try { $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); if ($customer) { $subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 717daf2a2..2d0094f33 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,6 +11,7 @@ use Illuminate\Validation\Rules\Password; use Laravel\Sanctum\Sanctum; use Laravel\Telescope\TelescopeServiceProvider; +use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider { @@ -19,6 +20,8 @@ public function register(): void if (App::isLocal()) { $this->app->register(TelescopeServiceProvider::class); } + + $this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key'))); } public function boot(): void diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php index 123fd226d..63bd39f6c 100644 --- a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php +++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php @@ -11,12 +11,21 @@ */ 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_diff TYPE text USING configuration_diff::text'); } 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_diff TYPE json USING configuration_diff::json'); } diff --git a/resources/views/livewire/subscription/actions.blade.php b/resources/views/livewire/subscription/actions.blade.php index 1b622285a..2689dccd5 100644 --- a/resources/views/livewire/subscription/actions.blade.php +++ b/resources/views/livewire/subscription/actions.blade.php @@ -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 }}" confirmationLabel="Enter your team name to confirm" shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" /> - + @if ($isRefundEligible) +
+ +
+ @else +
+ +
+ @endif @endif @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

- @if ($refundCheckLoading || ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)) + @if ($refundCheckLoading || $isRefundEligible)
@if ($refundCheckLoading) Request Full Refund @@ -269,7 +291,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg

@if ($refundCheckLoading) Checking refund eligibility... - @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) + @elseif ($isRefundEligible) Eligible for a full refund — {{ $refundDaysRemaining }} days remaining. @elseif ($refundAlreadyUsed) Refund already processed. Each team is eligible for one refund only. diff --git a/tests/Feature/Subscription/RefundSubscriptionTest.php b/tests/Feature/Subscription/RefundSubscriptionTest.php index 6f67fca2b..c1d5adda1 100644 --- a/tests/Feature/Subscription/RefundSubscriptionTest.php +++ b/tests/Feature/Subscription/RefundSubscriptionTest.php @@ -5,6 +5,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Stripe\Exception\InvalidRequestException; use Stripe\Service\InvoiceService; use Stripe\Service\RefundService; use Stripe\Service\SubscriptionService; @@ -85,6 +86,28 @@ 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 () { $periodEnd = now()->addDays(25)->timestamp; $stripeSubscription = (object) [ @@ -141,7 +164,7 @@ $this->mockSubscriptions ->shouldReceive('retrieve') ->with('sub_test_123') - ->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription')); + ->andThrow(new InvalidRequestException('No such subscription')); $action = new RefundSubscription($this->mockStripe); $result = $action->checkEligibility($this->team); @@ -269,6 +292,7 @@ $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => now()->addDays(20)->timestamp, ]; $this->mockSubscriptions @@ -298,7 +322,7 @@ $this->mockSubscriptions ->shouldReceive('cancel') ->with('sub_test_123') - ->andThrow(new \Exception('Stripe cancel API error')); + ->andThrow(new Exception('Stripe cancel API error')); $action = new RefundSubscription($this->mockStripe); $result = $action->execute($this->team); diff --git a/tests/Feature/Subscription/SubscriptionActionsTest.php b/tests/Feature/Subscription/SubscriptionActionsTest.php new file mode 100644 index 000000000..698c84d95 --- /dev/null +++ b/tests/Feature/Subscription/SubscriptionActionsTest.php @@ -0,0 +1,70 @@ +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.'); + }); +});