fix(stripe): add error handling and resilience to subscription operations

- Record refunds immediately before cancellation to prevent retry issues if cancel fails
- Wrap Stripe API calls in try-catch for refunds and quantity reverts with internal notifications
- Add null check in Team.subscriptionEnded() to prevent NPE when subscription doesn't exist
- Fix control flow bug in StripeProcessJob (add missing break statement)
- Cap dynamic server limit with MAX_SERVER_LIMIT in subscription updates
- Add comprehensive tests for refund failures, event handling, and null safety
This commit is contained in:
Andras Bacsai committed 2026-03-18 15:21:59 +01:00
1 parent 23f9156c73
commit 566744b2e0
9 files changed
+350 -18

No files matched your search

@@ -251,6 +251,56 @@ describe('execute', function () {
expect($result['error'])->toContain('No payment intent');
});
test('records refund and proceeds when cancel fails', 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']);
// Cancel throws — simulating Stripe failure after refund
$this->mockSubscriptions
->shouldReceive('cancel')
->with('sub_test_123')
->andThrow(new \Exception('Stripe cancel API error'));
$action = new RefundSubscription($this->mockStripe);
$result = $action->execute($this->team);
// Should still succeed — refund went through
expect($result['success'])->toBeTrue();
expect($result['error'])->toBeNull();
$this->subscription->refresh();
// Refund timestamp must be recorded
expect($this->subscription->stripe_refunded_at)->not->toBeNull();
// Subscription should still be marked as ended locally
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
expect($this->subscription->stripe_subscription_id)->toBeNull();
});
test('fails when subscription is past refund window', function () {
$stripeSubscription = (object) [
'status' => 'active',
@@ -0,0 +1,143 @@
<?php
use App\Jobs\ServerLimitCheckJob;
use App\Jobs\StripeProcessJob;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
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');
config()->set('subscription.stripe_excluded_plans', '');
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
});
describe('customer.subscription.created does not fall through to updated', function () {
test('created event creates subscription without setting stripe_invoice_paid to true', function () {
Queue::fake();
$event = [
'type' => 'customer.subscription.created',
'data' => [
'object' => [
'customer' => 'cus_new_123',
'id' => 'sub_new_123',
'metadata' => [
'team_id' => $this->team->id,
'user_id' => $this->user->id,
],
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
$subscription = Subscription::where('team_id', $this->team->id)->first();
expect($subscription)->not->toBeNull();
expect($subscription->stripe_subscription_id)->toBe('sub_new_123');
expect($subscription->stripe_customer_id)->toBe('cus_new_123');
// Critical: stripe_invoice_paid must remain false — payment not yet confirmed
expect($subscription->stripe_invoice_paid)->toBeFalsy();
});
});
describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () {
test('quantity exceeding MAX is clamped to 100', function () {
Queue::fake();
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_existing',
'stripe_customer_id' => 'cus_clamp_test',
'stripe_invoice_paid' => true,
]);
$event = [
'type' => 'customer.subscription.updated',
'data' => [
'object' => [
'customer' => 'cus_clamp_test',
'id' => 'sub_existing',
'status' => 'active',
'metadata' => [
'team_id' => $this->team->id,
'user_id' => $this->user->id,
],
'items' => [
'data' => [[
'subscription' => 'sub_existing',
'plan' => ['id' => 'price_dynamic_monthly'],
'price' => ['lookup_key' => 'dynamic_monthly'],
'quantity' => 999,
]],
],
'cancel_at_period_end' => false,
'cancellation_details' => ['feedback' => null, 'comment' => null],
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
$this->team->refresh();
expect($this->team->custom_server_limit)->toBe(100);
Queue::assertPushed(ServerLimitCheckJob::class);
});
});
describe('ServerLimitCheckJob dispatch is guarded by team check', function () {
test('does not dispatch ServerLimitCheckJob when team is null', function () {
Queue::fake();
// Create subscription without a valid team relationship
$subscription = Subscription::create([
'team_id' => 99999,
'stripe_subscription_id' => 'sub_orphan',
'stripe_customer_id' => 'cus_orphan_test',
'stripe_invoice_paid' => true,
]);
$event = [
'type' => 'customer.subscription.updated',
'data' => [
'object' => [
'customer' => 'cus_orphan_test',
'id' => 'sub_orphan',
'status' => 'active',
'metadata' => [
'team_id' => null,
'user_id' => null,
],
'items' => [
'data' => [[
'subscription' => 'sub_orphan',
'plan' => ['id' => 'price_dynamic_monthly'],
'price' => ['lookup_key' => 'dynamic_monthly'],
'quantity' => 5,
]],
],
'cancel_at_period_end' => false,
'cancellation_details' => ['feedback' => null, 'comment' => null],
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
Queue::assertNotPushed(ServerLimitCheckJob::class);
});
});
@@ -0,0 +1,16 @@
<?php
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('subscriptionEnded does not throw when team has no subscription', function () {
$team = Team::factory()->create();
// Should return early without error — no NPE
$team->subscriptionEnded();
// If we reach here, no exception was thrown
expect(true)->toBeTrue();
});
@@ -0,0 +1,102 @@
<?php
use App\Jobs\VerifyStripeSubscriptionStatusJob;
use App\Models\Server;
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_verify_123',
'stripe_customer_id' => 'cus_verify_123',
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
});
test('subscriptionEnded is called for unpaid status', function () {
$mockStripe = Mockery::mock(StripeClient::class);
$mockSubscriptions = Mockery::mock(SubscriptionService::class);
$mockStripe->subscriptions = $mockSubscriptions;
$mockSubscriptions
->shouldReceive('retrieve')
->with('sub_verify_123')
->andReturn((object) [
'status' => 'unpaid',
'cancel_at_period_end' => false,
]);
app()->bind(StripeClient::class, fn () => $mockStripe);
// Create a server to verify it gets disabled
$server = Server::factory()->create(['team_id' => $this->team->id]);
$job = new VerifyStripeSubscriptionStatusJob($this->subscription);
$job->handle();
$this->subscription->refresh();
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
expect($this->subscription->stripe_subscription_id)->toBeNull();
});
test('subscriptionEnded is called for incomplete_expired status', function () {
$mockStripe = Mockery::mock(StripeClient::class);
$mockSubscriptions = Mockery::mock(SubscriptionService::class);
$mockStripe->subscriptions = $mockSubscriptions;
$mockSubscriptions
->shouldReceive('retrieve')
->with('sub_verify_123')
->andReturn((object) [
'status' => 'incomplete_expired',
'cancel_at_period_end' => false,
]);
app()->bind(StripeClient::class, fn () => $mockStripe);
$job = new VerifyStripeSubscriptionStatusJob($this->subscription);
$job->handle();
$this->subscription->refresh();
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
expect($this->subscription->stripe_subscription_id)->toBeNull();
});
test('subscriptionEnded is called for canceled status', function () {
$mockStripe = Mockery::mock(StripeClient::class);
$mockSubscriptions = Mockery::mock(SubscriptionService::class);
$mockStripe->subscriptions = $mockSubscriptions;
$mockSubscriptions
->shouldReceive('retrieve')
->with('sub_verify_123')
->andReturn((object) [
'status' => 'canceled',
'cancel_at_period_end' => false,
]);
app()->bind(StripeClient::class, fn () => $mockStripe);
$job = new VerifyStripeSubscriptionStatusJob($this->subscription);
$job->handle();
$this->subscription->refresh();
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
expect($this->subscription->stripe_subscription_id)->toBeNull();
});