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

+15 -4
View File
@@ -99,16 +99,27 @@ class RefundSubscription
'payment_intent' => $paymentIntentId,
]);
$this->stripe->subscriptions->cancel($subscription->stripe_subscription_id);
// Record refund immediately so it cannot be retried if cancel fails
$subscription->update([
'stripe_refunded_at' => now(),
'stripe_feedback' => 'Refund requested by user',
'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(),
]);
try {
$this->stripe->subscriptions->cancel($subscription->stripe_subscription_id);
} catch (\Exception $e) {
\Log::critical("Refund succeeded but subscription cancel failed for team {$team->id}: ".$e->getMessage());
send_internal_notification(
"CRITICAL: Refund succeeded but cancel failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual intervention required."
);
}
$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();
@@ -153,12 +153,19 @@ class UpdateSubscriptionQuantity
\Log::warning("Subscription {$subscription->stripe_subscription_id} quantity updated but invoice not paid (status: {$latestInvoice->status}) for team {$team->name}. Reverting to {$previousQuantity}.");
// Revert subscription quantity on Stripe
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
'items' => [
['id' => $item->id, 'quantity' => $previousQuantity],
],
'proration_behavior' => 'none',
]);
try {
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
'items' => [
['id' => $item->id, 'quantity' => $previousQuantity],
],
'proration_behavior' => 'none',
]);
} catch (\Exception $revertException) {
\Log::critical("Failed to revert Stripe quantity for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Stripe may have quantity {$quantity} but local is {$previousQuantity}. Error: ".$revertException->getMessage());
send_internal_notification(
"CRITICAL: Stripe quantity revert failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual reconciliation required."
);
}
// Void the unpaid invoice
if ($latestInvoice->id) {
+4 -2
View File
@@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Actions\Stripe\UpdateSubscriptionQuantity;
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -238,6 +239,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
'stripe_invoice_paid' => false,
]);
}
break;
case 'customer.subscription.updated':
$teamId = data_get($data, 'metadata.team_id');
$userId = data_get($data, 'metadata.user_id');
@@ -272,14 +274,14 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$comment = data_get($data, 'cancellation_details.comment');
$lookup_key = data_get($data, 'items.data.0.price.lookup_key');
if (str($lookup_key)->contains('dynamic')) {
$quantity = data_get($data, 'items.data.0.quantity', 2);
$quantity = min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT);
$team = data_get($subscription, 'team');
if ($team) {
$team->update([
'custom_server_limit' => $quantity,
]);
ServerLimitCheckJob::dispatch($team);
}
ServerLimitCheckJob::dispatch($team);
}
$subscription->update([
'stripe_feedback' => $feedback,
@@ -82,12 +82,9 @@ class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueu
'stripe_past_due' => false,
]);
// Trigger subscription ended logic if canceled
if ($stripeSubscription->status === 'canceled') {
$team = $this->subscription->team;
if ($team) {
$team->subscriptionEnded();
}
$team = $this->subscription->team;
if ($team) {
$team->subscriptionEnded();
}
break;
+4
View File
@@ -197,6 +197,10 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
public function subscriptionEnded()
{
if (! $this->subscription) {
return;
}
$this->subscription->update([
'stripe_subscription_id' => null,
'stripe_cancel_at_period_end' => false,