diff --git a/app/Enums/Subscription.php b/app/Enums/Subscription.php index 0b06551b..760caff0 100644 --- a/app/Enums/Subscription.php +++ b/app/Enums/Subscription.php @@ -83,6 +83,12 @@ public function name(): string public function stripePriceId(bool $forceEap = false, bool $discounted = false, string $interval = 'year'): string { + // Monthly billing uses the regular monthly price (no EAP/discounted monthly prices exist) + if ($interval === 'month') { + return config("subscriptions.plans.{$this->value}.stripe_price_id_monthly") + ?? config("subscriptions.plans.{$this->value}.stripe_price_id"); + } + // EAP ends June 1st at midnight UTC if (now()->isBefore('2025-06-01 00:00:00') || $forceEap) { return config("subscriptions.plans.{$this->value}.stripe_price_id_eap"); @@ -92,10 +98,6 @@ public function stripePriceId(bool $forceEap = false, bool $discounted = false, return config("subscriptions.plans.{$this->value}.stripe_price_id_discounted"); } - if ($interval === 'month') { - return config("subscriptions.plans.{$this->value}.stripe_price_id_monthly"); - } - return config("subscriptions.plans.{$this->value}.stripe_price_id"); } diff --git a/app/Livewire/MobilePricing.php b/app/Livewire/MobilePricing.php index 4c71ecb6..9692aef5 100644 --- a/app/Livewire/MobilePricing.php +++ b/app/Livewire/MobilePricing.php @@ -20,7 +20,7 @@ class MobilePricing extends Component #[Url] public string $interval = 'month'; - /** @var array{amount_due: string, raw_amount_due: int, credit: string, new_charge: string}|null */ + /** @var array{amount_due: string, raw_amount_due: int, new_charge: string, is_prorated: bool, credit: string|null, remaining_credit: string|null}|null */ public ?array $upgradePreview = null; #[Locked] @@ -112,24 +112,33 @@ public function previewUpgrade(): void $invoice = $subscription->previewInvoice($newPriceId); $currency = $invoice->asStripeInvoice()->currency; - $credit = 0; - $newCharge = 0; + $newPlanCharge = 0; + $prorationCredit = 0; + $prorationCharge = 0; foreach ($invoice->invoiceLineItems() as $item) { - $amount = $item->asStripeInvoiceLineItem()->amount; + $raw = $item->asStripeInvoiceLineItem(); - if ($amount < 0) { - $credit += abs($amount); + if (! $raw->proration) { + $newPlanCharge += $raw->amount; + } elseif ($raw->amount < 0) { + $prorationCredit += abs($raw->amount); } else { - $newCharge += $amount; + $prorationCharge += $raw->amount; } } + $displayedCharge = $prorationCharge ?: $newPlanCharge; + $amountDue = max(0, $displayedCharge - $prorationCredit); + $remainingCredit = max(0, $prorationCredit - $displayedCharge); + $this->upgradePreview = [ - 'amount_due' => $invoice->amountDue(), - 'raw_amount_due' => $invoice->rawAmountDue(), - 'credit' => Cashier::formatAmount($credit, $currency), - 'new_charge' => Cashier::formatAmount($newCharge, $currency), + 'amount_due' => Cashier::formatAmount($amountDue, $currency), + 'raw_amount_due' => $amountDue, + 'new_charge' => Cashier::formatAmount($displayedCharge, $currency), + 'is_prorated' => $prorationCharge > 0, + 'credit' => $prorationCredit > 0 ? Cashier::formatAmount($prorationCredit, $currency) : null, + 'remaining_credit' => $remainingCredit > 0 ? Cashier::formatAmount($remainingCredit, $currency) : null, ]; } catch (\Exception $e) { Log::error('Failed to preview upgrade invoice', ['error' => $e->getMessage()]); @@ -159,7 +168,7 @@ public function upgradeSubscription(): mixed $subscription->skipTrial()->swapAndInvoice($newPriceId); - return redirect(route('customer.dashboard'))->with('success', 'Your subscription has been upgraded to Ultra!'); + return redirect(route('dashboard'))->with('success', 'Your subscription has been upgraded to Ultra!'); } private function findOrCreateUser(string $email): User diff --git a/resources/views/livewire/mobile-pricing.blade.php b/resources/views/livewire/mobile-pricing.blade.php index 6bc1650a..bc9d06a8 100644 --- a/resources/views/livewire/mobile-pricing.blade.php +++ b/resources/views/livewire/mobile-pricing.blade.php @@ -353,18 +353,25 @@ class="rounded-full px-4 py-1.5 text-sm font-medium transition" @if($upgradePreview)
- New plan (Ultra) + New plan (Ultra)@if($upgradePreview['is_prorated']) (pro-rated)@endif {{ $upgradePreview['new_charge'] }}
-
- Credit for unused {{ $currentPlanName }} time - -{{ $upgradePreview['credit'] }} -
+ @if($upgradePreview['credit']) +
+ Credit for unused {{ $currentPlanName }} time + -{{ $upgradePreview['credit'] }} +
+ @endif
Due today {{ $upgradePreview['amount_due'] }}
+ @if($upgradePreview['remaining_credit']) +

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

+ @endif
@else diff --git a/tests/Feature/MobilePricingTest.php b/tests/Feature/MobilePricingTest.php index 47b4973f..0b31d4b9 100644 --- a/tests/Feature/MobilePricingTest.php +++ b/tests/Feature/MobilePricingTest.php @@ -311,14 +311,81 @@ public function upgrade_modal_shows_proration_breakdown_when_preview_loaded() ->set('upgradePreview', [ 'amount_due' => '$28.50', 'raw_amount_due' => 2850, - 'credit' => '$6.50', 'new_charge' => '$35.00', + 'is_prorated' => false, + 'credit' => '$6.50', + 'remaining_credit' => null, ]) ->assertSee('Due today') ->assertSee('$28.50') ->assertSee('$6.50') ->assertSee('$35.00') - ->assertSee('Credit for unused'); + ->assertSee('Credit for unused') + ->assertDontSee('pro-rated') + ->assertDontSee('credited to your next invoice'); + } + + #[Test] + public function upgrade_modal_shows_prorated_label_when_charge_is_prorated() + { + $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' => '$200.00', + 'raw_amount_due' => 20000, + 'new_charge' => '$250.00', + 'is_prorated' => true, + 'credit' => '$50.00', + 'remaining_credit' => null, + ]) + ->assertSee('New plan (Ultra)') + ->assertSee('pro-rated') + ->assertSee('$250.00') + ->assertSee('Credit for unused') + ->assertSee('$50.00') + ->assertSee('$200.00') + ->assertDontSee('credited to your next invoice'); + } + + #[Test] + public function upgrade_modal_shows_remaining_credit_note_when_credit_exceeds_charge() + { + $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' => '$0.00', + 'raw_amount_due' => 0, + 'new_charge' => '$35.00', + 'is_prorated' => false, + 'credit' => '$50.00', + 'remaining_credit' => '$15.00', + ]) + ->assertSee('$0.00') + ->assertSee('$35.00') + ->assertSee('$50.00') + ->assertSee('$15.00 will be credited to your next invoice'); } #[Test] diff --git a/tests/Unit/SubscriptionStripePriceIdTest.php b/tests/Unit/SubscriptionStripePriceIdTest.php new file mode 100644 index 00000000..42fdc2b0 --- /dev/null +++ b/tests/Unit/SubscriptionStripePriceIdTest.php @@ -0,0 +1,70 @@ +assertEquals(self::YEARLY_PRICE, Subscription::Max->stripePriceId()); + } + + #[Test] + public function monthly_returns_monthly_price(): void + { + $this->assertEquals(self::MONTHLY_PRICE, Subscription::Max->stripePriceId(interval: 'month')); + } + + #[Test] + public function eap_yearly_returns_eap_price(): void + { + $this->assertEquals(self::EAP_PRICE, Subscription::Max->stripePriceId(forceEap: true)); + } + + #[Test] + public function eap_monthly_returns_monthly_price_not_eap(): void + { + $this->assertEquals( + self::MONTHLY_PRICE, + Subscription::Max->stripePriceId(forceEap: true, interval: 'month') + ); + } + + #[Test] + public function discounted_returns_discounted_price(): void + { + $this->assertEquals(self::DISCOUNTED_PRICE, Subscription::Max->stripePriceId(discounted: true)); + } + + #[Test] + public function monthly_falls_back_to_default_when_no_monthly_price(): void + { + Config::set('subscriptions.plans.max.stripe_price_id_monthly', null); + + $this->assertEquals(self::YEARLY_PRICE, Subscription::Max->stripePriceId(interval: 'month')); + } +}