Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions app/Enums/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
}

Expand Down
33 changes: 21 additions & 12 deletions app/Livewire/MobilePricing.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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()]);
Expand Down Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions resources/views/livewire/mobile-pricing.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -353,18 +353,25 @@ class="rounded-full px-4 py-1.5 text-sm font-medium transition"
@if($upgradePreview)
<div class="space-y-2 text-sm">
<div class="flex items-baseline justify-between">
<span class="text-gray-600 dark:text-gray-400">New plan (Ultra)</span>
<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>
<span class="font-medium text-gray-900 dark:text-white">{{ $upgradePreview['new_charge'] }}</span>
</div>
<div class="flex items-baseline justify-between">
<span class="text-gray-600 dark:text-gray-400">Credit for unused {{ $currentPlanName }} time</span>
<span class="font-medium text-emerald-600 dark:text-emerald-400">-{{ $upgradePreview['credit'] }}</span>
</div>
@if($upgradePreview['credit'])
<div class="flex items-baseline justify-between">
<span class="text-gray-600 dark:text-gray-400">Credit for unused {{ $currentPlanName }} time</span>
<span class="font-medium text-emerald-600 dark:text-emerald-400">-{{ $upgradePreview['credit'] }}</span>
</div>
@endif
<div class="border-t border-gray-200 pt-2 dark:border-zinc-700">
<div class="flex items-baseline justify-between">
<span class="font-medium text-gray-900 dark:text-white">Due today</span>
<span class="text-lg font-semibold text-gray-900 dark:text-white">{{ $upgradePreview['amount_due'] }}</span>
</div>
@if($upgradePreview['remaining_credit'])
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $upgradePreview['remaining_credit'] }} will be credited to your next invoice.
</p>
@endif
</div>
</div>
@else
Expand Down
71 changes: 69 additions & 2 deletions tests/Feature/MobilePricingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
70 changes: 70 additions & 0 deletions tests/Unit/SubscriptionStripePriceIdTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace Tests\Unit;

use App\Enums\Subscription;
use Illuminate\Support\Facades\Config;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class SubscriptionStripePriceIdTest extends TestCase
{
private const YEARLY_PRICE = 'price_max_yearly';

private const MONTHLY_PRICE = 'price_max_monthly';

private const EAP_PRICE = 'price_max_eap';

private const DISCOUNTED_PRICE = 'price_max_discounted';

protected function setUp(): void
{
parent::setUp();

Config::set('subscriptions.plans.max.stripe_price_id', self::YEARLY_PRICE);
Config::set('subscriptions.plans.max.stripe_price_id_monthly', self::MONTHLY_PRICE);
Config::set('subscriptions.plans.max.stripe_price_id_eap', self::EAP_PRICE);
Config::set('subscriptions.plans.max.stripe_price_id_discounted', self::DISCOUNTED_PRICE);
}

#[Test]
public function yearly_returns_default_price(): void
{
$this->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'));
}
}
Loading