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'], ]], ], ]; }); 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 \Stripe\Exception\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'); }); 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'); }); });