mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-16 02:15:32 +00:00
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:
1 parent
23f9156c73
commit
566744b2e0
9 files changed
+350
-18
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user