feat(subscription): add billing interval to price preview

Extract and return the billing interval (month/year) from subscription pricing
data in fetchPricePreview. Update the view to dynamically display the correct
billing period based on the preview response instead of using static PHP logic.
This commit is contained in:
Andras Bacsai
2026-03-27 19:05:13 +01:00
parent 9b0088072c
commit 638f1d37f1
3 changed files with 50 additions and 6 deletions
@@ -4,6 +4,7 @@ namespace App\Actions\Stripe;
use App\Jobs\ServerLimitCheckJob;
use App\Models\Team;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class UpdateSubscriptionQuantity
@@ -42,6 +43,7 @@ class UpdateSubscriptionQuantity
}
$currency = strtoupper($item->price->currency ?? 'usd');
$billingInterval = $item->price->recurring->interval ?? 'month';
// Upcoming invoice gives us the prorated amount due now
$upcomingInvoice = $this->stripe->invoices->upcoming([
@@ -99,6 +101,7 @@ class UpdateSubscriptionQuantity
'tax_description' => $taxDescription,
'quantity' => $quantity,
'currency' => $currency,
'billing_interval' => $billingInterval,
],
];
} catch (\Exception $e) {
@@ -184,7 +187,7 @@ class UpdateSubscriptionQuantity
\Log::info("Subscription {$subscription->stripe_subscription_id} quantity updated to {$quantity} for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
\Log::error("Stripe update quantity error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
@@ -160,7 +160,7 @@
<span class="dark:text-white" x-text="fmt(preview?.recurring_tax)"></span>
</div>
<div class="flex justify-between gap-6 text-sm font-bold pt-1.5 border-t dark:border-coolgray-400 border-neutral-200">
<span class="dark:text-white">Total / {{ $billingInterval === 'yearly' ? 'year' : 'month' }}</span>
<span class="dark:text-white">Total / <span x-text="preview?.billing_interval === 'year' ? 'year' : 'month'">month</span></span>
<span class="dark:text-white" x-text="fmt(preview?.recurring_total)"></span>
</div>
</div>
@@ -7,6 +7,7 @@ use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Stripe\Exception\InvalidRequestException;
use Stripe\Service\InvoiceService;
use Stripe\Service\SubscriptionService;
use Stripe\Service\TaxRateService;
@@ -46,7 +47,7 @@ beforeEach(function () {
'data' => [(object) [
'id' => 'si_item_123',
'quantity' => 2,
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'],
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd', 'recurring' => (object) ['interval' => 'month']],
]],
],
];
@@ -187,7 +188,7 @@ describe('UpdateSubscriptionQuantity::execute', function () {
test('handles stripe API error gracefully', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
->andThrow(new InvalidRequestException('Subscription not found'));
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->execute($this->team, 5);
@@ -199,7 +200,7 @@ describe('UpdateSubscriptionQuantity::execute', function () {
test('handles generic exception gracefully', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->andThrow(new \RuntimeException('Network error'));
->andThrow(new RuntimeException('Network error'));
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->execute($this->team, 5);
@@ -270,6 +271,46 @@ describe('UpdateSubscriptionQuantity::fetchPricePreview', function () {
expect($result['preview']['tax_description'])->toContain('27%');
expect($result['preview']['quantity'])->toBe(3);
expect($result['preview']['currency'])->toBe('USD');
expect($result['preview']['billing_interval'])->toBe('month');
});
test('returns yearly billing interval for annual subscriptions', function () {
$yearlySubscriptionResponse = (object) [
'items' => (object) [
'data' => [(object) [
'id' => 'si_item_123',
'quantity' => 2,
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd', 'recurring' => (object) ['interval' => 'year']],
]],
],
];
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_qty')
->andReturn($yearlySubscriptionResponse);
$this->mockInvoices
->shouldReceive('upcoming')
->andReturn((object) [
'amount_due' => 1000,
'total' => 1000,
'subtotal' => 1000,
'tax' => 0,
'currency' => 'usd',
'lines' => (object) [
'data' => [
(object) ['amount' => 1000, 'proration' => false],
],
],
'total_tax_amounts' => [],
]);
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->fetchPricePreview($this->team, 2);
expect($result['success'])->toBeTrue();
expect($result['preview']['billing_interval'])->toBe('year');
});
test('returns preview without tax when no tax applies', function () {
@@ -336,7 +377,7 @@ describe('UpdateSubscriptionQuantity::fetchPricePreview', function () {
test('handles Stripe API error gracefully', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->andThrow(new \RuntimeException('API error'));
->andThrow(new RuntimeException('API error'));
$action = new UpdateSubscriptionQuantity($this->mockStripe);
$result = $action->fetchPricePreview($this->team, 5);