Skip to content

Commit de8554b

Browse files
simonhampclaude
andauthored
Fix upgrade price calculation for EAP customers selecting monthly billing (#318)
* Fix upgrade price calculation for EAP customers selecting monthly billing stripePriceId() checked EAP status before billing interval, so EAP customers always got the yearly EAP price regardless of monthly/yearly selection. Move the monthly interval check first since no monthly EAP prices exist. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Improve upgrade modal pricing breakdown with proration handling - Separate plan price from Stripe proration line items so "New plan" shows the actual charge, not inflated totals - Calculate "Due today" from displayed charge minus credit (clamped to 0) - Show "(pro-rated)" label when using a prorated price - Show remaining credit note when unused time exceeds the new plan cost - Fix undefined route: customer.dashboard → dashboard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 95d89df commit de8554b

File tree

5 files changed

+178
-23
lines changed

5 files changed

+178
-23
lines changed

app/Enums/Subscription.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ public function name(): string
8383

8484
public function stripePriceId(bool $forceEap = false, bool $discounted = false, string $interval = 'year'): string
8585
{
86+
// Monthly billing uses the regular monthly price (no EAP/discounted monthly prices exist)
87+
if ($interval === 'month') {
88+
return config("subscriptions.plans.{$this->value}.stripe_price_id_monthly")
89+
?? config("subscriptions.plans.{$this->value}.stripe_price_id");
90+
}
91+
8692
// EAP ends June 1st at midnight UTC
8793
if (now()->isBefore('2025-06-01 00:00:00') || $forceEap) {
8894
return config("subscriptions.plans.{$this->value}.stripe_price_id_eap");
@@ -92,10 +98,6 @@ public function stripePriceId(bool $forceEap = false, bool $discounted = false,
9298
return config("subscriptions.plans.{$this->value}.stripe_price_id_discounted");
9399
}
94100

95-
if ($interval === 'month') {
96-
return config("subscriptions.plans.{$this->value}.stripe_price_id_monthly");
97-
}
98-
99101
return config("subscriptions.plans.{$this->value}.stripe_price_id");
100102
}
101103

app/Livewire/MobilePricing.php

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class MobilePricing extends Component
2020
#[Url]
2121
public string $interval = 'month';
2222

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

2626
#[Locked]
@@ -112,24 +112,33 @@ public function previewUpgrade(): void
112112
$invoice = $subscription->previewInvoice($newPriceId);
113113

114114
$currency = $invoice->asStripeInvoice()->currency;
115-
$credit = 0;
116-
$newCharge = 0;
115+
$newPlanCharge = 0;
116+
$prorationCredit = 0;
117+
$prorationCharge = 0;
117118

118119
foreach ($invoice->invoiceLineItems() as $item) {
119-
$amount = $item->asStripeInvoiceLineItem()->amount;
120+
$raw = $item->asStripeInvoiceLineItem();
120121

121-
if ($amount < 0) {
122-
$credit += abs($amount);
122+
if (! $raw->proration) {
123+
$newPlanCharge += $raw->amount;
124+
} elseif ($raw->amount < 0) {
125+
$prorationCredit += abs($raw->amount);
123126
} else {
124-
$newCharge += $amount;
127+
$prorationCharge += $raw->amount;
125128
}
126129
}
127130

131+
$displayedCharge = $prorationCharge ?: $newPlanCharge;
132+
$amountDue = max(0, $displayedCharge - $prorationCredit);
133+
$remainingCredit = max(0, $prorationCredit - $displayedCharge);
134+
128135
$this->upgradePreview = [
129-
'amount_due' => $invoice->amountDue(),
130-
'raw_amount_due' => $invoice->rawAmountDue(),
131-
'credit' => Cashier::formatAmount($credit, $currency),
132-
'new_charge' => Cashier::formatAmount($newCharge, $currency),
136+
'amount_due' => Cashier::formatAmount($amountDue, $currency),
137+
'raw_amount_due' => $amountDue,
138+
'new_charge' => Cashier::formatAmount($displayedCharge, $currency),
139+
'is_prorated' => $prorationCharge > 0,
140+
'credit' => $prorationCredit > 0 ? Cashier::formatAmount($prorationCredit, $currency) : null,
141+
'remaining_credit' => $remainingCredit > 0 ? Cashier::formatAmount($remainingCredit, $currency) : null,
133142
];
134143
} catch (\Exception $e) {
135144
Log::error('Failed to preview upgrade invoice', ['error' => $e->getMessage()]);
@@ -159,7 +168,7 @@ public function upgradeSubscription(): mixed
159168

160169
$subscription->skipTrial()->swapAndInvoice($newPriceId);
161170

162-
return redirect(route('customer.dashboard'))->with('success', 'Your subscription has been upgraded to Ultra!');
171+
return redirect(route('dashboard'))->with('success', 'Your subscription has been upgraded to Ultra!');
163172
}
164173

165174
private function findOrCreateUser(string $email): User

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -353,18 +353,25 @@ class="rounded-full px-4 py-1.5 text-sm font-medium transition"
353353
@if($upgradePreview)
354354
<div class="space-y-2 text-sm">
355355
<div class="flex items-baseline justify-between">
356-
<span class="text-gray-600 dark:text-gray-400">New plan (Ultra)</span>
356+
<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>
357357
<span class="font-medium text-gray-900 dark:text-white">{{ $upgradePreview['new_charge'] }}</span>
358358
</div>
359-
<div class="flex items-baseline justify-between">
360-
<span class="text-gray-600 dark:text-gray-400">Credit for unused {{ $currentPlanName }} time</span>
361-
<span class="font-medium text-emerald-600 dark:text-emerald-400">-{{ $upgradePreview['credit'] }}</span>
362-
</div>
359+
@if($upgradePreview['credit'])
360+
<div class="flex items-baseline justify-between">
361+
<span class="text-gray-600 dark:text-gray-400">Credit for unused {{ $currentPlanName }} time</span>
362+
<span class="font-medium text-emerald-600 dark:text-emerald-400">-{{ $upgradePreview['credit'] }}</span>
363+
</div>
364+
@endif
363365
<div class="border-t border-gray-200 pt-2 dark:border-zinc-700">
364366
<div class="flex items-baseline justify-between">
365367
<span class="font-medium text-gray-900 dark:text-white">Due today</span>
366368
<span class="text-lg font-semibold text-gray-900 dark:text-white">{{ $upgradePreview['amount_due'] }}</span>
367369
</div>
370+
@if($upgradePreview['remaining_credit'])
371+
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
372+
{{ $upgradePreview['remaining_credit'] }} will be credited to your next invoice.
373+
</p>
374+
@endif
368375
</div>
369376
</div>
370377
@else

tests/Feature/MobilePricingTest.php

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,14 +311,81 @@ public function upgrade_modal_shows_proration_breakdown_when_preview_loaded()
311311
->set('upgradePreview', [
312312
'amount_due' => '$28.50',
313313
'raw_amount_due' => 2850,
314-
'credit' => '$6.50',
315314
'new_charge' => '$35.00',
315+
'is_prorated' => false,
316+
'credit' => '$6.50',
317+
'remaining_credit' => null,
316318
])
317319
->assertSee('Due today')
318320
->assertSee('$28.50')
319321
->assertSee('$6.50')
320322
->assertSee('$35.00')
321-
->assertSee('Credit for unused');
323+
->assertSee('Credit for unused')
324+
->assertDontSee('pro-rated')
325+
->assertDontSee('credited to your next invoice');
326+
}
327+
328+
#[Test]
329+
public function upgrade_modal_shows_prorated_label_when_charge_is_prorated()
330+
{
331+
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);
332+
Auth::login($user);
333+
334+
$subscription = Cashier::$subscriptionModel::factory()
335+
->for($user)
336+
->active()
337+
->create(['stripe_price' => self::PRO_PRICE_ID]);
338+
339+
Cashier::$subscriptionItemModel::factory()
340+
->for($subscription, 'subscription')
341+
->create(['stripe_price' => self::PRO_PRICE_ID]);
342+
343+
Livewire::test(MobilePricing::class)
344+
->set('upgradePreview', [
345+
'amount_due' => '$200.00',
346+
'raw_amount_due' => 20000,
347+
'new_charge' => '$250.00',
348+
'is_prorated' => true,
349+
'credit' => '$50.00',
350+
'remaining_credit' => null,
351+
])
352+
->assertSee('New plan (Ultra)')
353+
->assertSee('pro-rated')
354+
->assertSee('$250.00')
355+
->assertSee('Credit for unused')
356+
->assertSee('$50.00')
357+
->assertSee('$200.00')
358+
->assertDontSee('credited to your next invoice');
359+
}
360+
361+
#[Test]
362+
public function upgrade_modal_shows_remaining_credit_note_when_credit_exceeds_charge()
363+
{
364+
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);
365+
Auth::login($user);
366+
367+
$subscription = Cashier::$subscriptionModel::factory()
368+
->for($user)
369+
->active()
370+
->create(['stripe_price' => self::PRO_PRICE_ID]);
371+
372+
Cashier::$subscriptionItemModel::factory()
373+
->for($subscription, 'subscription')
374+
->create(['stripe_price' => self::PRO_PRICE_ID]);
375+
376+
Livewire::test(MobilePricing::class)
377+
->set('upgradePreview', [
378+
'amount_due' => '$0.00',
379+
'raw_amount_due' => 0,
380+
'new_charge' => '$35.00',
381+
'is_prorated' => false,
382+
'credit' => '$50.00',
383+
'remaining_credit' => '$15.00',
384+
])
385+
->assertSee('$0.00')
386+
->assertSee('$35.00')
387+
->assertSee('$50.00')
388+
->assertSee('$15.00 will be credited to your next invoice');
322389
}
323390

324391
#[Test]
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
namespace Tests\Unit;
4+
5+
use App\Enums\Subscription;
6+
use Illuminate\Support\Facades\Config;
7+
use PHPUnit\Framework\Attributes\Test;
8+
use Tests\TestCase;
9+
10+
class SubscriptionStripePriceIdTest extends TestCase
11+
{
12+
private const YEARLY_PRICE = 'price_max_yearly';
13+
14+
private const MONTHLY_PRICE = 'price_max_monthly';
15+
16+
private const EAP_PRICE = 'price_max_eap';
17+
18+
private const DISCOUNTED_PRICE = 'price_max_discounted';
19+
20+
protected function setUp(): void
21+
{
22+
parent::setUp();
23+
24+
Config::set('subscriptions.plans.max.stripe_price_id', self::YEARLY_PRICE);
25+
Config::set('subscriptions.plans.max.stripe_price_id_monthly', self::MONTHLY_PRICE);
26+
Config::set('subscriptions.plans.max.stripe_price_id_eap', self::EAP_PRICE);
27+
Config::set('subscriptions.plans.max.stripe_price_id_discounted', self::DISCOUNTED_PRICE);
28+
}
29+
30+
#[Test]
31+
public function yearly_returns_default_price(): void
32+
{
33+
$this->assertEquals(self::YEARLY_PRICE, Subscription::Max->stripePriceId());
34+
}
35+
36+
#[Test]
37+
public function monthly_returns_monthly_price(): void
38+
{
39+
$this->assertEquals(self::MONTHLY_PRICE, Subscription::Max->stripePriceId(interval: 'month'));
40+
}
41+
42+
#[Test]
43+
public function eap_yearly_returns_eap_price(): void
44+
{
45+
$this->assertEquals(self::EAP_PRICE, Subscription::Max->stripePriceId(forceEap: true));
46+
}
47+
48+
#[Test]
49+
public function eap_monthly_returns_monthly_price_not_eap(): void
50+
{
51+
$this->assertEquals(
52+
self::MONTHLY_PRICE,
53+
Subscription::Max->stripePriceId(forceEap: true, interval: 'month')
54+
);
55+
}
56+
57+
#[Test]
58+
public function discounted_returns_discounted_price(): void
59+
{
60+
$this->assertEquals(self::DISCOUNTED_PRICE, Subscription::Max->stripePriceId(discounted: true));
61+
}
62+
63+
#[Test]
64+
public function monthly_falls_back_to_default_when_no_monthly_price(): void
65+
{
66+
Config::set('subscriptions.plans.max.stripe_price_id_monthly', null);
67+
68+
$this->assertEquals(self::YEARLY_PRICE, Subscription::Max->stripePriceId(interval: 'month'));
69+
}
70+
}

0 commit comments

Comments
 (0)