diff --git a/app/Livewire/MobilePricing.php b/app/Livewire/MobilePricing.php index 062da46f..6fcf7e66 100644 --- a/app/Livewire/MobilePricing.php +++ b/app/Livewire/MobilePricing.php @@ -22,7 +22,7 @@ class MobilePricing extends Component #[Url] public string $interval = 'month'; - /** @var array{amount_due: string, raw_amount_due: int, new_charge: string, is_prorated: bool, credit: string|null, remaining_credit: string|null}|null */ + /** @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 */ public ?array $upgradePreview = null; #[Locked] @@ -113,11 +113,26 @@ public function previewUpgrade(): void return; } + // Canceled-in-grace subscriptions have no upcoming invoice in Stripe, + // so previewInvoice() returns null. Stripe still prorates correctly on + // confirm via swapAndInvoice — we just show a degraded preview here. + if ($subscription->canceled() && $subscription->onGracePeriod()) { + $this->upgradePreview = $this->buildDegradedUpgradePreview($user); + + return; + } + $newPriceId = Subscription::Max->stripePriceId(forceEap: $user->isEapCustomer(), interval: $this->interval); try { $invoice = $subscription->previewInvoice($newPriceId); + if (! $invoice) { + $this->upgradePreview = null; + + return; + } + $currency = $invoice->asStripeInvoice()->currency; $newPlanCharge = 0; $prorationCredit = 0; @@ -146,13 +161,40 @@ public function previewUpgrade(): void 'is_prorated' => $prorationCharge > 0, 'credit' => $prorationCredit > 0 ? Cashier::formatAmount($prorationCredit, $currency) : null, 'remaining_credit' => $remainingCredit > 0 ? Cashier::formatAmount($remainingCredit, $currency) : null, + 'proration_pending' => false, ]; - } catch (\Exception $e) { + } catch (\Throwable $e) { Log::error('Failed to preview upgrade invoice', ['error' => $e->getMessage()]); $this->upgradePreview = null; } } + /** + * @return array{amount_due: null, raw_amount_due: null, new_charge: string, is_prorated: bool, credit: null, remaining_credit: null, proration_pending: true} + */ + private function buildDegradedUpgradePreview(User $user): array + { + if ($this->interval === 'year') { + $newCharge = $user->isEapCustomer() + ? config('subscriptions.plans.max.eap_price_yearly') + : config('subscriptions.plans.max.price_yearly'); + } else { + $newCharge = config('subscriptions.plans.max.price_monthly'); + } + + $newChargeInCents = (int) ($newCharge * 100); + + return [ + 'amount_due' => null, + 'raw_amount_due' => null, + 'new_charge' => Cashier::formatAmount($newChargeInCents), + 'is_prorated' => true, + 'credit' => null, + 'remaining_credit' => null, + 'proration_pending' => true, + ]; + } + public function upgradeSubscription(): mixed { $user = Auth::user(); diff --git a/resources/views/livewire/mobile-pricing.blade.php b/resources/views/livewire/mobile-pricing.blade.php index 410633fd..f8fbc2f1 100644 --- a/resources/views/livewire/mobile-pricing.blade.php +++ b/resources/views/livewire/mobile-pricing.blade.php @@ -365,23 +365,29 @@ class="rounded-full px-4 py-1.5 text-sm font-medium transition" New plan (Ultra)@if($upgradePreview['is_prorated']) (pro-rated)@endif {{ $upgradePreview['new_charge'] }} - @if($upgradePreview['credit']) + @if($upgradePreview['credit'] ?? null)
Credit for unused {{ $currentPlanName }} time -{{ $upgradePreview['credit'] }}
@endif -
-
- Due today - {{ $upgradePreview['amount_due'] }} + @if($upgradePreview['proration_pending'] ?? false) +

+ Your remaining {{ $currentPlanName }} time will be credited against this charge at checkout. +

+ @else +
+
+ Due today + {{ $upgradePreview['amount_due'] }} +
+ @if($upgradePreview['remaining_credit'] ?? null) +

+ {{ $upgradePreview['remaining_credit'] }} will be credited to your next invoice. +

+ @endif
- @if($upgradePreview['remaining_credit']) -

- {{ $upgradePreview['remaining_credit'] }} will be credited to your next invoice. -

- @endif -
+ @endif
@else

diff --git a/tests/Feature/MobilePricingTest.php b/tests/Feature/MobilePricingTest.php index 1ce2239e..5f27d9af 100644 --- a/tests/Feature/MobilePricingTest.php +++ b/tests/Feature/MobilePricingTest.php @@ -11,7 +11,10 @@ use Illuminate\Support\Facades\Notification; use Laravel\Cashier\Cashier; use Livewire\Livewire; +use Mockery; use PHPUnit\Framework\Attributes\Test; +use Stripe\Exception\InvalidRequestException; +use Stripe\StripeClient; use Tests\TestCase; class MobilePricingTest extends TestCase @@ -484,4 +487,146 @@ public function non_subscriber_does_not_see_preview_upgrade_button() Livewire::test(MobilePricing::class) ->assertDontSeeHtml('wire:click="previewUpgrade"'); } + + #[Test] + public function preview_upgrade_for_canceled_in_grace_subscriber_shows_degraded_preview_without_calling_stripe() + { + $user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]); + Auth::login($user); + + $subscription = Cashier::$subscriptionModel::factory() + ->for($user) + ->active() + ->create([ + 'stripe_price' => self::PRO_PRICE_ID, + 'ends_at' => now()->addDays(15), + ]); + + Cashier::$subscriptionItemModel::factory() + ->for($subscription, 'subscription') + ->create(['stripe_price' => self::PRO_PRICE_ID]); + + $stripeMock = Mockery::mock(StripeClient::class); + $stripeMock->shouldNotReceive('subscriptions'); + $stripeMock->shouldNotReceive('invoices'); + + $this->app->bind(StripeClient::class, fn () => $stripeMock); + + Livewire::test(MobilePricing::class) + ->set('interval', 'year') + ->call('previewUpgrade') + ->assertSet('upgradePreview.proration_pending', true) + ->assertSet('upgradePreview.is_prorated', true) + ->assertSet('upgradePreview.amount_due', null) + ->assertSet('upgradePreview.credit', null) + ->assertOk(); + } + + #[Test] + public function degraded_preview_uses_eap_price_for_eap_customers() + { + $user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]); + License::factory()->eapEligible()->withoutSubscriptionItem()->for($user)->create(); + Auth::login($user); + + $subscription = Cashier::$subscriptionModel::factory() + ->for($user) + ->active() + ->create([ + 'stripe_price' => self::PRO_PRICE_ID, + 'ends_at' => now()->addDays(10), + ]); + + Cashier::$subscriptionItemModel::factory() + ->for($subscription, 'subscription') + ->create(['stripe_price' => self::PRO_PRICE_ID]); + + $eapPrice = config('subscriptions.plans.max.eap_price_yearly'); + + Livewire::test(MobilePricing::class) + ->set('interval', 'year') + ->call('previewUpgrade') + ->assertSet('upgradePreview.proration_pending', true) + ->assertSet('upgradePreview.new_charge', '$'.number_format($eapPrice, 2)); + } + + #[Test] + public function degraded_preview_renders_pending_proration_copy_in_modal() + { + $user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]); + Auth::login($user); + + $subscription = Cashier::$subscriptionModel::factory() + ->for($user) + ->active() + ->create(['stripe_price' => self::PRO_PRICE_ID]); + + Cashier::$subscriptionItemModel::factory() + ->for($subscription, 'subscription') + ->create(['stripe_price' => self::PRO_PRICE_ID]); + + Livewire::test(MobilePricing::class) + ->set('upgradePreview', [ + 'amount_due' => null, + 'raw_amount_due' => null, + 'new_charge' => '$350.00', + 'is_prorated' => true, + 'credit' => null, + 'remaining_credit' => null, + 'proration_pending' => true, + ]) + ->assertSee('pro-rated') + ->assertSee('$350.00') + ->assertSee('will be credited against this charge at checkout') + ->assertDontSee('Due today') + ->assertSee('Confirm upgrade'); + } + + #[Test] + public function preview_upgrade_sets_preview_to_null_when_stripe_has_no_upcoming_invoice() + { + $user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]); + Auth::login($user); + + $subscription = Cashier::$subscriptionModel::factory() + ->for($user) + ->active() + ->create(['stripe_price' => self::PRO_PRICE_ID]); + + Cashier::$subscriptionItemModel::factory() + ->for($subscription, 'subscription') + ->create(['stripe_price' => self::PRO_PRICE_ID]); + + $stripeSubscription = (object) [ + 'items' => (object) [ + 'data' => [ + (object) [ + 'id' => 'si_test_'.uniqid(), + 'price' => (object) [ + 'id' => self::PRO_PRICE_ID, + 'recurring' => (object) ['usage_type' => 'licensed'], + ], + ], + ], + ], + ]; + + $subscriptionsMock = Mockery::mock(); + $subscriptionsMock->shouldReceive('retrieve')->andReturn($stripeSubscription); + + $invoicesMock = Mockery::mock(); + $invoicesMock->shouldReceive('upcoming') + ->andThrow(new InvalidRequestException('No upcoming invoices for customer')); + + $stripeMock = Mockery::mock(StripeClient::class); + $stripeMock->subscriptions = $subscriptionsMock; + $stripeMock->invoices = $invoicesMock; + + $this->app->bind(StripeClient::class, fn () => $stripeMock); + + Livewire::test(MobilePricing::class) + ->call('previewUpgrade') + ->assertSet('upgradePreview', null) + ->assertOk(); + } }