Skip to content

Commit d00591c

Browse files
simonhampclaude
andauthored
Handle null invoice in upgrade preview and add degraded path for canceled-in-grace subscribers (#387)
Cashier's Subscription::previewInvoice() returns null when Stripe has no upcoming invoice (e.g. canceled subscription in grace period) — the call to asStripeInvoice() on null threw an Error that the \Exception catch didn't catch, surfacing as a 500. Add a null guard, widen the catch to \Throwable, and for canceled-but- in-grace users skip Stripe entirely and show a degraded preview based on configured plan price. Stripe still computes proration correctly at swapAndInvoice time. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4ba6148 commit d00591c

3 files changed

Lines changed: 206 additions & 13 deletions

File tree

app/Livewire/MobilePricing.php

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class MobilePricing extends Component
2222
#[Url]
2323
public string $interval = 'month';
2424

25-
/** @var array{amount_due: string, raw_amount_due: int, new_charge: string, is_prorated: bool, credit: string|null, remaining_credit: string|null}|null */
25+
/** @var array{amount_due: string|null, raw_amount_due: int|null, new_charge: string, is_prorated: bool, credit: string|null, remaining_credit: string|null, proration_pending: bool}|null */
2626
public ?array $upgradePreview = null;
2727

2828
#[Locked]
@@ -113,11 +113,26 @@ public function previewUpgrade(): void
113113
return;
114114
}
115115

116+
// Canceled-in-grace subscriptions have no upcoming invoice in Stripe,
117+
// so previewInvoice() returns null. Stripe still prorates correctly on
118+
// confirm via swapAndInvoice — we just show a degraded preview here.
119+
if ($subscription->canceled() && $subscription->onGracePeriod()) {
120+
$this->upgradePreview = $this->buildDegradedUpgradePreview($user);
121+
122+
return;
123+
}
124+
116125
$newPriceId = Subscription::Max->stripePriceId(forceEap: $user->isEapCustomer(), interval: $this->interval);
117126

118127
try {
119128
$invoice = $subscription->previewInvoice($newPriceId);
120129

130+
if (! $invoice) {
131+
$this->upgradePreview = null;
132+
133+
return;
134+
}
135+
121136
$currency = $invoice->asStripeInvoice()->currency;
122137
$newPlanCharge = 0;
123138
$prorationCredit = 0;
@@ -146,13 +161,40 @@ public function previewUpgrade(): void
146161
'is_prorated' => $prorationCharge > 0,
147162
'credit' => $prorationCredit > 0 ? Cashier::formatAmount($prorationCredit, $currency) : null,
148163
'remaining_credit' => $remainingCredit > 0 ? Cashier::formatAmount($remainingCredit, $currency) : null,
164+
'proration_pending' => false,
149165
];
150-
} catch (\Exception $e) {
166+
} catch (\Throwable $e) {
151167
Log::error('Failed to preview upgrade invoice', ['error' => $e->getMessage()]);
152168
$this->upgradePreview = null;
153169
}
154170
}
155171

172+
/**
173+
* @return array{amount_due: null, raw_amount_due: null, new_charge: string, is_prorated: bool, credit: null, remaining_credit: null, proration_pending: true}
174+
*/
175+
private function buildDegradedUpgradePreview(User $user): array
176+
{
177+
if ($this->interval === 'year') {
178+
$newCharge = $user->isEapCustomer()
179+
? config('subscriptions.plans.max.eap_price_yearly')
180+
: config('subscriptions.plans.max.price_yearly');
181+
} else {
182+
$newCharge = config('subscriptions.plans.max.price_monthly');
183+
}
184+
185+
$newChargeInCents = (int) ($newCharge * 100);
186+
187+
return [
188+
'amount_due' => null,
189+
'raw_amount_due' => null,
190+
'new_charge' => Cashier::formatAmount($newChargeInCents),
191+
'is_prorated' => true,
192+
'credit' => null,
193+
'remaining_credit' => null,
194+
'proration_pending' => true,
195+
];
196+
}
197+
156198
public function upgradeSubscription(): mixed
157199
{
158200
$user = Auth::user();

resources/views/livewire/mobile-pricing.blade.php

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -365,23 +365,29 @@ class="rounded-full px-4 py-1.5 text-sm font-medium transition"
365365
<span class="text-gray-600 dark:text-gray-400">New plan (Ultra)@if($upgradePreview['is_prorated']) <span class="text-gray-400 dark:text-gray-500">(pro-rated)</span>@endif</span>
366366
<span class="font-medium text-gray-900 dark:text-white">{{ $upgradePreview['new_charge'] }}</span>
367367
</div>
368-
@if($upgradePreview['credit'])
368+
@if($upgradePreview['credit'] ?? null)
369369
<div class="flex items-baseline justify-between">
370370
<span class="text-gray-600 dark:text-gray-400">Credit for unused {{ $currentPlanName }} time</span>
371371
<span class="font-medium text-emerald-600 dark:text-emerald-400">-{{ $upgradePreview['credit'] }}</span>
372372
</div>
373373
@endif
374-
<div class="border-t border-gray-200 pt-2 dark:border-zinc-700">
375-
<div class="flex items-baseline justify-between">
376-
<span class="font-medium text-gray-900 dark:text-white">Due today</span>
377-
<span class="text-lg font-semibold text-gray-900 dark:text-white">{{ $upgradePreview['amount_due'] }}</span>
374+
@if($upgradePreview['proration_pending'] ?? false)
375+
<p class="border-t border-gray-200 pt-2 text-xs text-gray-500 dark:border-zinc-700 dark:text-gray-400">
376+
Your remaining {{ $currentPlanName }} time will be credited against this charge at checkout.
377+
</p>
378+
@else
379+
<div class="border-t border-gray-200 pt-2 dark:border-zinc-700">
380+
<div class="flex items-baseline justify-between">
381+
<span class="font-medium text-gray-900 dark:text-white">Due today</span>
382+
<span class="text-lg font-semibold text-gray-900 dark:text-white">{{ $upgradePreview['amount_due'] }}</span>
383+
</div>
384+
@if($upgradePreview['remaining_credit'] ?? null)
385+
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
386+
{{ $upgradePreview['remaining_credit'] }} will be credited to your next invoice.
387+
</p>
388+
@endif
378389
</div>
379-
@if($upgradePreview['remaining_credit'])
380-
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
381-
{{ $upgradePreview['remaining_credit'] }} will be credited to your next invoice.
382-
</p>
383-
@endif
384-
</div>
390+
@endif
385391
</div>
386392
@else
387393
<p class="text-sm text-gray-500 dark:text-gray-400">

tests/Feature/MobilePricingTest.php

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
use Illuminate\Support\Facades\Notification;
1212
use Laravel\Cashier\Cashier;
1313
use Livewire\Livewire;
14+
use Mockery;
1415
use PHPUnit\Framework\Attributes\Test;
16+
use Stripe\Exception\InvalidRequestException;
17+
use Stripe\StripeClient;
1518
use Tests\TestCase;
1619

1720
class MobilePricingTest extends TestCase
@@ -484,4 +487,146 @@ public function non_subscriber_does_not_see_preview_upgrade_button()
484487
Livewire::test(MobilePricing::class)
485488
->assertDontSeeHtml('wire:click="previewUpgrade"');
486489
}
490+
491+
#[Test]
492+
public function preview_upgrade_for_canceled_in_grace_subscriber_shows_degraded_preview_without_calling_stripe()
493+
{
494+
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);
495+
Auth::login($user);
496+
497+
$subscription = Cashier::$subscriptionModel::factory()
498+
->for($user)
499+
->active()
500+
->create([
501+
'stripe_price' => self::PRO_PRICE_ID,
502+
'ends_at' => now()->addDays(15),
503+
]);
504+
505+
Cashier::$subscriptionItemModel::factory()
506+
->for($subscription, 'subscription')
507+
->create(['stripe_price' => self::PRO_PRICE_ID]);
508+
509+
$stripeMock = Mockery::mock(StripeClient::class);
510+
$stripeMock->shouldNotReceive('subscriptions');
511+
$stripeMock->shouldNotReceive('invoices');
512+
513+
$this->app->bind(StripeClient::class, fn () => $stripeMock);
514+
515+
Livewire::test(MobilePricing::class)
516+
->set('interval', 'year')
517+
->call('previewUpgrade')
518+
->assertSet('upgradePreview.proration_pending', true)
519+
->assertSet('upgradePreview.is_prorated', true)
520+
->assertSet('upgradePreview.amount_due', null)
521+
->assertSet('upgradePreview.credit', null)
522+
->assertOk();
523+
}
524+
525+
#[Test]
526+
public function degraded_preview_uses_eap_price_for_eap_customers()
527+
{
528+
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);
529+
License::factory()->eapEligible()->withoutSubscriptionItem()->for($user)->create();
530+
Auth::login($user);
531+
532+
$subscription = Cashier::$subscriptionModel::factory()
533+
->for($user)
534+
->active()
535+
->create([
536+
'stripe_price' => self::PRO_PRICE_ID,
537+
'ends_at' => now()->addDays(10),
538+
]);
539+
540+
Cashier::$subscriptionItemModel::factory()
541+
->for($subscription, 'subscription')
542+
->create(['stripe_price' => self::PRO_PRICE_ID]);
543+
544+
$eapPrice = config('subscriptions.plans.max.eap_price_yearly');
545+
546+
Livewire::test(MobilePricing::class)
547+
->set('interval', 'year')
548+
->call('previewUpgrade')
549+
->assertSet('upgradePreview.proration_pending', true)
550+
->assertSet('upgradePreview.new_charge', '$'.number_format($eapPrice, 2));
551+
}
552+
553+
#[Test]
554+
public function degraded_preview_renders_pending_proration_copy_in_modal()
555+
{
556+
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);
557+
Auth::login($user);
558+
559+
$subscription = Cashier::$subscriptionModel::factory()
560+
->for($user)
561+
->active()
562+
->create(['stripe_price' => self::PRO_PRICE_ID]);
563+
564+
Cashier::$subscriptionItemModel::factory()
565+
->for($subscription, 'subscription')
566+
->create(['stripe_price' => self::PRO_PRICE_ID]);
567+
568+
Livewire::test(MobilePricing::class)
569+
->set('upgradePreview', [
570+
'amount_due' => null,
571+
'raw_amount_due' => null,
572+
'new_charge' => '$350.00',
573+
'is_prorated' => true,
574+
'credit' => null,
575+
'remaining_credit' => null,
576+
'proration_pending' => true,
577+
])
578+
->assertSee('pro-rated')
579+
->assertSee('$350.00')
580+
->assertSee('will be credited against this charge at checkout')
581+
->assertDontSee('Due today')
582+
->assertSee('Confirm upgrade');
583+
}
584+
585+
#[Test]
586+
public function preview_upgrade_sets_preview_to_null_when_stripe_has_no_upcoming_invoice()
587+
{
588+
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);
589+
Auth::login($user);
590+
591+
$subscription = Cashier::$subscriptionModel::factory()
592+
->for($user)
593+
->active()
594+
->create(['stripe_price' => self::PRO_PRICE_ID]);
595+
596+
Cashier::$subscriptionItemModel::factory()
597+
->for($subscription, 'subscription')
598+
->create(['stripe_price' => self::PRO_PRICE_ID]);
599+
600+
$stripeSubscription = (object) [
601+
'items' => (object) [
602+
'data' => [
603+
(object) [
604+
'id' => 'si_test_'.uniqid(),
605+
'price' => (object) [
606+
'id' => self::PRO_PRICE_ID,
607+
'recurring' => (object) ['usage_type' => 'licensed'],
608+
],
609+
],
610+
],
611+
],
612+
];
613+
614+
$subscriptionsMock = Mockery::mock();
615+
$subscriptionsMock->shouldReceive('retrieve')->andReturn($stripeSubscription);
616+
617+
$invoicesMock = Mockery::mock();
618+
$invoicesMock->shouldReceive('upcoming')
619+
->andThrow(new InvalidRequestException('No upcoming invoices for customer'));
620+
621+
$stripeMock = Mockery::mock(StripeClient::class);
622+
$stripeMock->subscriptions = $subscriptionsMock;
623+
$stripeMock->invoices = $invoicesMock;
624+
625+
$this->app->bind(StripeClient::class, fn () => $stripeMock);
626+
627+
Livewire::test(MobilePricing::class)
628+
->call('previewUpgrade')
629+
->assertSet('upgradePreview', null)
630+
->assertOk();
631+
}
487632
}

0 commit comments

Comments
 (0)