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'));
+ }
+}