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.
416 lines
15 KiB
PHP
416 lines
15 KiB
PHP
<?php
|
||
|
||
use App\Actions\Stripe\UpdateSubscriptionQuantity;
|
||
use App\Jobs\ServerLimitCheckJob;
|
||
use App\Models\Subscription;
|
||
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;
|
||
use Stripe\StripeClient;
|
||
|
||
uses(RefreshDatabase::class);
|
||
|
||
beforeEach(function () {
|
||
config()->set('constants.coolify.self_hosted', false);
|
||
config()->set('subscription.provider', 'stripe');
|
||
config()->set('subscription.stripe_api_key', 'sk_test_fake');
|
||
|
||
$this->team = Team::factory()->create();
|
||
$this->user = User::factory()->create();
|
||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||
|
||
$this->subscription = Subscription::create([
|
||
'team_id' => $this->team->id,
|
||
'stripe_subscription_id' => 'sub_test_qty',
|
||
'stripe_customer_id' => 'cus_test_qty',
|
||
'stripe_invoice_paid' => true,
|
||
'stripe_plan_id' => 'price_test_qty',
|
||
'stripe_cancel_at_period_end' => false,
|
||
'stripe_past_due' => false,
|
||
]);
|
||
|
||
$this->mockStripe = Mockery::mock(StripeClient::class);
|
||
$this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
|
||
$this->mockInvoices = Mockery::mock(InvoiceService::class);
|
||
$this->mockTaxRates = Mockery::mock(TaxRateService::class);
|
||
$this->mockStripe->subscriptions = $this->mockSubscriptions;
|
||
$this->mockStripe->invoices = $this->mockInvoices;
|
||
$this->mockStripe->taxRates = $this->mockTaxRates;
|
||
|
||
$this->stripeSubscriptionResponse = (object) [
|
||
'items' => (object) [
|
||
'data' => [(object) [
|
||
'id' => 'si_item_123',
|
||
'quantity' => 2,
|
||
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd', 'recurring' => (object) ['interval' => 'month']],
|
||
]],
|
||
],
|
||
];
|
||
});
|
||
|
||
describe('UpdateSubscriptionQuantity::execute', function () {
|
||
test('updates quantity successfully', function () {
|
||
Queue::fake();
|
||
|
||
$this->mockSubscriptions
|
||
->shouldReceive('retrieve')
|
||
->with('sub_test_qty')
|
||
->andReturn($this->stripeSubscriptionResponse);
|
||
|
||
$this->mockSubscriptions
|
||
->shouldReceive('update')
|
||
->with('sub_test_qty', [
|
||
'items' => [
|
||
['id' => 'si_item_123', 'quantity' => 5],
|
||
],
|
||
'proration_behavior' => 'always_invoice',
|
||
'expand' => ['latest_invoice'],
|
||
])
|
||
->andReturn((object) [
|
||
'status' => 'active',
|
||
'latest_invoice' => (object) ['status' => 'paid'],
|
||
]);
|
||
|
||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||
$result = $action->execute($this->team, 5);
|
||
|
||
expect($result['success'])->toBeTrue();
|
||
expect($result['error'])->toBeNull();
|
||
|
||
$this->team->refresh();
|
||
expect($this->team->custom_server_limit)->toBe(5);
|
||
|
||
Queue::assertPushed(ServerLimitCheckJob::class, function ($job) {
|
||
return $job->team->id === $this->team->id;
|
||
});
|
||
});
|
||
|
||
test('reverts subscription and voids invoice when payment fails', function () {
|
||
Queue::fake();
|
||
|
||
$this->mockSubscriptions
|
||
->shouldReceive('retrieve')
|
||
->with('sub_test_qty')
|
||
->andReturn($this->stripeSubscriptionResponse);
|
||
|
||
// First update: changes quantity but payment fails
|
||
$this->mockSubscriptions
|
||
->shouldReceive('update')
|
||
->with('sub_test_qty', [
|
||
'items' => [
|
||
['id' => 'si_item_123', 'quantity' => 5],
|
||
],
|
||
'proration_behavior' => 'always_invoice',
|
||
'expand' => ['latest_invoice'],
|
||
])
|
||
->andReturn((object) [
|
||
'status' => 'active',
|
||
'latest_invoice' => (object) ['id' => 'in_failed_123', 'status' => 'open'],
|
||
]);
|
||
|
||
// Revert: restores original quantity
|
||
$this->mockSubscriptions
|
||
->shouldReceive('update')
|
||
->with('sub_test_qty', [
|
||
'items' => [
|
||
['id' => 'si_item_123', 'quantity' => 2],
|
||
],
|
||
'proration_behavior' => 'none',
|
||
])
|
||
->andReturn((object) ['status' => 'active']);
|
||
|
||
// Void the unpaid invoice
|
||
$this->mockInvoices
|
||
->shouldReceive('voidInvoice')
|
||
->with('in_failed_123')
|
||
->once();
|
||
|
||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||
$result = $action->execute($this->team, 5);
|
||
|
||
expect($result['success'])->toBeFalse();
|
||
expect($result['error'])->toContain('Payment failed');
|
||
|
||
$this->team->refresh();
|
||
expect($this->team->custom_server_limit)->not->toBe(5);
|
||
|
||
Queue::assertNotPushed(ServerLimitCheckJob::class);
|
||
});
|
||
|
||
test('rejects quantity below minimum of 2', function () {
|
||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||
$result = $action->execute($this->team, 1);
|
||
|
||
expect($result['success'])->toBeFalse();
|
||
expect($result['error'])->toContain('Minimum server limit is 2');
|
||
});
|
||
|
||
test('fails when no subscription exists', function () {
|
||
$team = Team::factory()->create();
|
||
|
||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||
$result = $action->execute($team, 5);
|
||
|
||
expect($result['success'])->toBeFalse();
|
||
expect($result['error'])->toContain('No active subscription');
|
||
});
|
||
|
||
test('fails when subscription is not active', function () {
|
||
$this->subscription->update(['stripe_invoice_paid' => false]);
|
||
|
||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||
$result = $action->execute($this->team, 5);
|
||
|
||
expect($result['success'])->toBeFalse();
|
||
expect($result['error'])->toContain('not active');
|
||
});
|
||
|
||
test('fails when subscription item cannot be found', function () {
|
||
$this->mockSubscriptions
|
||
->shouldReceive('retrieve')
|
||
->with('sub_test_qty')
|
||
->andReturn((object) [
|
||
'items' => (object) ['data' => []],
|
||
]);
|
||
|
||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||
$result = $action->execute($this->team, 5);
|
||
|
||
expect($result['success'])->toBeFalse();
|
||
expect($result['error'])->toContain('Could not find subscription item');
|
||
});
|
||
|
||
test('handles stripe API error gracefully', function () {
|
||
$this->mockSubscriptions
|
||
->shouldReceive('retrieve')
|
||
->andThrow(new InvalidRequestException('Subscription not found'));
|
||
|
||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||
$result = $action->execute($this->team, 5);
|
||
|
||
expect($result['success'])->toBeFalse();
|
||
expect($result['error'])->toContain('Stripe error');
|
||
});
|
||
|
||
test('handles generic exception gracefully', function () {
|
||
$this->mockSubscriptions
|
||
->shouldReceive('retrieve')
|
||
->andThrow(new RuntimeException('Network error'));
|
||
|
||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||
$result = $action->execute($this->team, 5);
|
||
|
||
expect($result['success'])->toBeFalse();
|
||
expect($result['error'])->toContain('unexpected error');
|
||
});
|
||
});
|
||
|
||
describe('UpdateSubscriptionQuantity::fetchPricePreview', function () {
|
||
test('returns full preview with proration and recurring cost with tax', function () {
|
||
$this->mockSubscriptions
|
||
->shouldReceive('retrieve')
|
||
->with('sub_test_qty')
|
||
->andReturn($this->stripeSubscriptionResponse);
|
||
|
||
$this->mockInvoices
|
||
->shouldReceive('upcoming')
|
||
->with([
|
||
'customer' => 'cus_test_qty',
|
||
'subscription' => 'sub_test_qty',
|
||
'subscription_items' => [
|
||
['id' => 'si_item_123', 'quantity' => 3],
|
||
],
|
||
'subscription_proration_behavior' => 'create_prorations',
|
||
])
|
||
->andReturn((object) [
|
||
'amount_due' => 2540,
|
||
'total' => 2540,
|
||
'subtotal' => 2000,
|
||
'tax' => 540,
|
||
'currency' => 'usd',
|
||
'lines' => (object) [
|
||
'data' => [
|
||
(object) ['amount' => -300, 'proration' => true], // credit for unused
|
||
(object) ['amount' => 800, 'proration' => true], // charge for new qty
|
||
(object) ['amount' => 1500, 'proration' => false], // next cycle
|
||
],
|
||
],
|
||
'total_tax_amounts' => [
|
||
(object) ['tax_rate' => 'txr_123'],
|
||
],
|
||
]);
|
||
|
||
$this->mockTaxRates
|
||
->shouldReceive('retrieve')
|
||
->with('txr_123')
|
||
->andReturn((object) [
|
||
'display_name' => 'VAT',
|
||
'jurisdiction' => 'HU',
|
||
'percentage' => 27,
|
||
]);
|
||
|
||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||
$result = $action->fetchPricePreview($this->team, 3);
|
||
|
||
expect($result['success'])->toBeTrue();
|
||
// Due now: invoice total (2540) - recurring total (1905) = 635
|
||
expect($result['preview']['due_now'])->toBe(635);
|
||
// Recurring: 3 × $5.00 = $15.00
|
||
expect($result['preview']['recurring_subtotal'])->toBe(1500);
|
||
// Tax: $15.00 × 27% = $4.05
|
||
expect($result['preview']['recurring_tax'])->toBe(405);
|
||
// Total: $15.00 + $4.05 = $19.05
|
||
expect($result['preview']['recurring_total'])->toBe(1905);
|
||
expect($result['preview']['unit_price'])->toBe(500);
|
||
expect($result['preview']['tax_description'])->toContain('VAT');
|
||
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 () {
|
||
$this->mockSubscriptions
|
||
->shouldReceive('retrieve')
|
||
->with('sub_test_qty')
|
||
->andReturn($this->stripeSubscriptionResponse);
|
||
|
||
$this->mockInvoices
|
||
->shouldReceive('upcoming')
|
||
->andReturn((object) [
|
||
'amount_due' => 1250,
|
||
'total' => 1250,
|
||
'subtotal' => 1250,
|
||
'tax' => 0,
|
||
'currency' => 'usd',
|
||
'lines' => (object) [
|
||
'data' => [
|
||
(object) ['amount' => 250, 'proration' => true], // proration charge
|
||
(object) ['amount' => 1000, 'proration' => false], // next cycle
|
||
],
|
||
],
|
||
'total_tax_amounts' => [],
|
||
]);
|
||
|
||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||
$result = $action->fetchPricePreview($this->team, 2);
|
||
|
||
expect($result['success'])->toBeTrue();
|
||
// Due now: invoice total (1250) - recurring total (1000) = 250
|
||
expect($result['preview']['due_now'])->toBe(250);
|
||
// 2 × $5.00 = $10.00, no tax
|
||
expect($result['preview']['recurring_subtotal'])->toBe(1000);
|
||
expect($result['preview']['recurring_tax'])->toBe(0);
|
||
expect($result['preview']['recurring_total'])->toBe(1000);
|
||
expect($result['preview']['tax_description'])->toBeNull();
|
||
});
|
||
|
||
test('fails when no subscription exists', function () {
|
||
$team = Team::factory()->create();
|
||
|
||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||
$result = $action->fetchPricePreview($team, 5);
|
||
|
||
expect($result['success'])->toBeFalse();
|
||
expect($result['preview'])->toBeNull();
|
||
});
|
||
|
||
test('fails when subscription item not found', function () {
|
||
$this->mockSubscriptions
|
||
->shouldReceive('retrieve')
|
||
->with('sub_test_qty')
|
||
->andReturn((object) [
|
||
'items' => (object) ['data' => []],
|
||
]);
|
||
|
||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||
$result = $action->fetchPricePreview($this->team, 5);
|
||
|
||
expect($result['success'])->toBeFalse();
|
||
expect($result['error'])->toContain('Could not retrieve subscription details');
|
||
});
|
||
|
||
test('handles Stripe API error gracefully', function () {
|
||
$this->mockSubscriptions
|
||
->shouldReceive('retrieve')
|
||
->andThrow(new RuntimeException('API error'));
|
||
|
||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||
$result = $action->fetchPricePreview($this->team, 5);
|
||
|
||
expect($result['success'])->toBeFalse();
|
||
expect($result['error'])->toContain('Could not load price preview');
|
||
expect($result['preview'])->toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('Subscription billingInterval', function () {
|
||
test('returns monthly for monthly plan', function () {
|
||
config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly_123');
|
||
|
||
$this->subscription->update(['stripe_plan_id' => 'price_monthly_123']);
|
||
$this->subscription->refresh();
|
||
|
||
expect($this->subscription->billingInterval())->toBe('monthly');
|
||
});
|
||
|
||
test('returns yearly for yearly plan', function () {
|
||
config()->set('subscription.stripe_price_id_dynamic_yearly', 'price_yearly_123');
|
||
|
||
$this->subscription->update(['stripe_plan_id' => 'price_yearly_123']);
|
||
$this->subscription->refresh();
|
||
|
||
expect($this->subscription->billingInterval())->toBe('yearly');
|
||
});
|
||
|
||
test('defaults to monthly when plan id is null', function () {
|
||
$this->subscription->update(['stripe_plan_id' => null]);
|
||
$this->subscription->refresh();
|
||
|
||
expect($this->subscription->billingInterval())->toBe('monthly');
|
||
});
|
||
});
|