Align commerce module with the monorepo module structure by updating all namespaces to use the Core\Mod\Commerce convention. This change supports the recent monorepo separation and ensures consistency with other modules. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
551 lines
21 KiB
PHP
551 lines
21 KiB
PHP
<?php
|
|
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Core\Mod\Commerce\Exceptions\PauseLimitExceededException;
|
|
use Core\Mod\Commerce\Models\Subscription;
|
|
use Core\Mod\Commerce\Services\ProrationResult;
|
|
use Core\Mod\Commerce\Services\SubscriptionService;
|
|
use Core\Mod\Tenant\Models\Feature;
|
|
use Core\Mod\Tenant\Models\Package;
|
|
use Core\Mod\Tenant\Models\User;
|
|
use Core\Mod\Tenant\Models\Workspace;
|
|
use Core\Mod\Tenant\Models\WorkspacePackage;
|
|
|
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Cache::flush();
|
|
|
|
$this->user = User::factory()->create();
|
|
$this->workspace = Workspace::factory()->create();
|
|
$this->workspace->users()->attach($this->user->id, [
|
|
'role' => 'owner',
|
|
'is_default' => true,
|
|
]);
|
|
|
|
// Use existing seeded feature (or create test-specific one)
|
|
$this->aiCreditsFeature = Feature::firstOrCreate(
|
|
['code' => 'ai.credits'],
|
|
[
|
|
'name' => 'AI Credits',
|
|
'category' => 'ai',
|
|
'type' => Feature::TYPE_LIMIT,
|
|
'reset_type' => Feature::RESET_MONTHLY,
|
|
'is_active' => true,
|
|
]
|
|
);
|
|
|
|
// Use existing seeded packages
|
|
$this->creatorPackage = Package::where('code', 'creator')->firstOrFail();
|
|
$this->agencyPackage = Package::where('code', 'agency')->firstOrFail();
|
|
$this->enterprisePackage = Package::where('code', 'enterprise')->firstOrFail();
|
|
|
|
// Ensure packages have expected prices for this test
|
|
$this->creatorPackage->update(['monthly_price' => 19.00, 'yearly_price' => 190.00]);
|
|
$this->agencyPackage->update(['monthly_price' => 49.00, 'yearly_price' => 490.00]);
|
|
$this->enterprisePackage->update(['monthly_price' => 99.00, 'yearly_price' => 990.00]);
|
|
|
|
// Attach features if not already attached
|
|
if (! $this->creatorPackage->features()->where('feature_id', $this->aiCreditsFeature->id)->exists()) {
|
|
$this->creatorPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 100]);
|
|
}
|
|
if (! $this->agencyPackage->features()->where('feature_id', $this->aiCreditsFeature->id)->exists()) {
|
|
$this->agencyPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 500]);
|
|
}
|
|
if (! $this->enterprisePackage->features()->where('feature_id', $this->aiCreditsFeature->id)->exists()) {
|
|
$this->enterprisePackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => null]); // Unlimited
|
|
}
|
|
|
|
$this->service = app(SubscriptionService::class);
|
|
});
|
|
|
|
describe('SubscriptionService', function () {
|
|
describe('create() method', function () {
|
|
it('creates a monthly subscription', function () {
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage, 'monthly');
|
|
|
|
expect($subscription)->toBeInstanceOf(Subscription::class)
|
|
->and($subscription->workspace_id)->toBe($this->workspace->id)
|
|
->and($subscription->workspace_package_id)->toBe($workspacePackage->id)
|
|
->and($subscription->status)->toBe('active')
|
|
->and($subscription->billing_cycle)->toBe('monthly')
|
|
->and((int) $subscription->current_period_start->diffInDays($subscription->current_period_end))->toBe(30);
|
|
});
|
|
|
|
it('creates a yearly subscription', function () {
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage, 'yearly');
|
|
|
|
expect($subscription->billing_cycle)->toBe('yearly')
|
|
->and((int) $subscription->current_period_start->diffInDays($subscription->current_period_end))->toBe(365);
|
|
});
|
|
});
|
|
|
|
describe('cancel() method', function () {
|
|
it('cancels a subscription', function () {
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage);
|
|
$subscription = $this->service->cancel($subscription, 'Too expensive');
|
|
|
|
expect($subscription->cancelled_at)->not->toBeNull()
|
|
->and($subscription->cancellation_reason)->toBe('Too expensive');
|
|
});
|
|
});
|
|
|
|
describe('resume() method', function () {
|
|
it('resumes a cancelled subscription within billing period', function () {
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage);
|
|
$this->service->cancel($subscription, 'Changed mind');
|
|
$subscription = $this->service->resume($subscription);
|
|
|
|
expect($subscription->cancelled_at)->toBeNull()
|
|
->and($subscription->cancellation_reason)->toBeNull();
|
|
});
|
|
|
|
it('does not resume if period has ended', function () {
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage);
|
|
$subscription->update(['current_period_end' => now()->subDay()]);
|
|
$this->service->cancel($subscription);
|
|
$subscription = $this->service->resume($subscription);
|
|
|
|
// Should still be cancelled
|
|
expect($subscription->cancelled_at)->not->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('renew() method', function () {
|
|
it('renews a subscription for another period', function () {
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage);
|
|
$originalEnd = $subscription->current_period_end;
|
|
|
|
// Move time forward
|
|
Carbon::setTestNow($originalEnd);
|
|
$subscription = $this->service->renew($subscription);
|
|
|
|
expect($subscription->current_period_start->toDateString())->toBe($originalEnd->toDateString())
|
|
->and((int) $subscription->current_period_start->diffInDays($subscription->current_period_end))->toBe(30);
|
|
|
|
Carbon::setTestNow(); // Reset
|
|
});
|
|
|
|
it('clears cancellation when renewing', function () {
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage);
|
|
$subscription->update([
|
|
'cancelled_at' => now(),
|
|
'cancellation_reason' => 'Test',
|
|
]);
|
|
|
|
$subscription = $this->service->renew($subscription);
|
|
|
|
expect($subscription->cancelled_at)->toBeNull()
|
|
->and($subscription->cancellation_reason)->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('expire() method', function () {
|
|
it('expires a subscription immediately', function () {
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage);
|
|
$subscription = $this->service->expire($subscription);
|
|
|
|
expect($subscription->status)->toBe('expired')
|
|
->and($subscription->ended_at)->not->toBeNull();
|
|
|
|
$workspacePackage->refresh();
|
|
expect($workspacePackage->status)->toBe('expired');
|
|
});
|
|
});
|
|
|
|
describe('pause() and unpause() methods', function () {
|
|
it('pauses a subscription', function () {
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage);
|
|
$subscription = $this->service->pause($subscription);
|
|
|
|
expect($subscription->status)->toBe('paused')
|
|
->and($subscription->paused_at)->not->toBeNull()
|
|
->and($subscription->pause_count)->toBe(1);
|
|
});
|
|
|
|
it('increments pause count on each pause', function () {
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage);
|
|
|
|
// First pause
|
|
$subscription = $this->service->pause($subscription);
|
|
expect($subscription->pause_count)->toBe(1);
|
|
|
|
// Unpause
|
|
$subscription = $this->service->unpause($subscription);
|
|
|
|
// Second pause
|
|
$subscription = $this->service->pause($subscription);
|
|
expect($subscription->pause_count)->toBe(2);
|
|
});
|
|
|
|
it('throws exception when pause limit is exceeded', function () {
|
|
config(['commerce.subscriptions.max_pause_cycles' => 2]);
|
|
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage);
|
|
|
|
// First pause
|
|
$subscription = $this->service->pause($subscription);
|
|
$subscription = $this->service->unpause($subscription);
|
|
|
|
// Second pause (at limit)
|
|
$subscription = $this->service->pause($subscription);
|
|
$subscription = $this->service->unpause($subscription);
|
|
|
|
// Third pause should throw
|
|
expect(fn () => $this->service->pause($subscription))
|
|
->toThrow(PauseLimitExceededException::class);
|
|
});
|
|
|
|
it('allows forced pause even when limit exceeded', function () {
|
|
config(['commerce.subscriptions.max_pause_cycles' => 1]);
|
|
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage);
|
|
|
|
// First pause (uses the limit)
|
|
$subscription = $this->service->pause($subscription);
|
|
$subscription = $this->service->unpause($subscription);
|
|
|
|
// Force pause should work even when limit exceeded
|
|
$subscription = $this->service->pause($subscription, force: true);
|
|
|
|
expect($subscription->status)->toBe('paused')
|
|
->and($subscription->pause_count)->toBe(2);
|
|
});
|
|
|
|
it('reports canPause correctly', function () {
|
|
config(['commerce.subscriptions.max_pause_cycles' => 2]);
|
|
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage);
|
|
|
|
expect($subscription->canPause())->toBeTrue()
|
|
->and($subscription->remainingPauseCycles())->toBe(2);
|
|
|
|
// First pause
|
|
$subscription = $this->service->pause($subscription);
|
|
$subscription = $this->service->unpause($subscription);
|
|
|
|
expect($subscription->canPause())->toBeTrue()
|
|
->and($subscription->remainingPauseCycles())->toBe(1);
|
|
|
|
// Second pause
|
|
$subscription = $this->service->pause($subscription);
|
|
$subscription = $this->service->unpause($subscription);
|
|
|
|
expect($subscription->canPause())->toBeFalse()
|
|
->and($subscription->remainingPauseCycles())->toBe(0);
|
|
});
|
|
|
|
it('unpauses a subscription', function () {
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = $this->service->create($workspacePackage);
|
|
$this->service->pause($subscription);
|
|
$subscription = $this->service->unpause($subscription);
|
|
|
|
expect($subscription->status)->toBe('active')
|
|
->and($subscription->paused_at)->toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Proration calculations', function () {
|
|
beforeEach(function () {
|
|
// Freeze time for predictable day calculations
|
|
Carbon::setTestNow(Carbon::now()->startOfDay()->addHours(12));
|
|
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $this->creatorPackage->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$this->subscription = Subscription::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'workspace_package_id' => $workspacePackage->id,
|
|
'status' => 'active',
|
|
'gateway' => 'btcpay',
|
|
'billing_cycle' => 'monthly',
|
|
'current_period_start' => now()->subDays(15),
|
|
'current_period_end' => now()->addDays(15),
|
|
]);
|
|
});
|
|
|
|
afterEach(function () {
|
|
Carbon::setTestNow(); // Reset frozen time
|
|
});
|
|
|
|
describe('calculateProration() method', function () {
|
|
it('calculates proration for upgrade mid-cycle', function () {
|
|
$proration = $this->service->calculateProration(
|
|
$this->subscription,
|
|
$this->creatorPackage, // £19/month
|
|
$this->agencyPackage, // £49/month
|
|
'monthly'
|
|
);
|
|
|
|
expect($proration)->toBeInstanceOf(ProrationResult::class)
|
|
->and($proration->currentPlanPrice)->toBe(19.00)
|
|
->and($proration->newPlanPrice)->toBe(49.00)
|
|
->and($proration->isUpgrade())->toBeTrue()
|
|
->and($proration->requiresPayment())->toBeTrue();
|
|
});
|
|
|
|
it('calculates proration for downgrade mid-cycle', function () {
|
|
// Start with agency package
|
|
$this->subscription->workspacePackage->update(['package_id' => $this->agencyPackage->id]);
|
|
|
|
$proration = $this->service->calculateProration(
|
|
$this->subscription,
|
|
$this->agencyPackage, // £49/month
|
|
$this->creatorPackage, // £19/month
|
|
'monthly'
|
|
);
|
|
|
|
expect($proration->isDowngrade())->toBeTrue()
|
|
->and($proration->netAmount)->toBeLessThan(0)
|
|
->and($proration->getCreditBalance())->toBeGreaterThan(0);
|
|
});
|
|
|
|
it('calculates days remaining correctly', function () {
|
|
$proration = $this->service->calculateProration(
|
|
$this->subscription,
|
|
$this->creatorPackage,
|
|
$this->agencyPackage,
|
|
'monthly'
|
|
);
|
|
|
|
expect($proration->daysRemaining)->toBe(15)
|
|
->and($proration->totalPeriodDays)->toBe(30);
|
|
});
|
|
|
|
it('calculates credit amount based on unused time', function () {
|
|
$proration = $this->service->calculateProration(
|
|
$this->subscription,
|
|
$this->creatorPackage,
|
|
$this->agencyPackage,
|
|
'monthly'
|
|
);
|
|
|
|
// 15 days remaining out of 30 = 50% unused
|
|
// Credit = £19 * 0.5 = £9.50
|
|
expect($proration->creditAmount)->toBe(9.50)
|
|
->and($proration->usedPercentage)->toBe(0.5);
|
|
});
|
|
|
|
it('calculates prorated new plan cost', function () {
|
|
$proration = $this->service->calculateProration(
|
|
$this->subscription,
|
|
$this->creatorPackage,
|
|
$this->agencyPackage,
|
|
'monthly'
|
|
);
|
|
|
|
// 15 days remaining out of 30 = 50%
|
|
// Prorated new plan = £49 * 0.5 = £24.50
|
|
expect($proration->proratedNewPlanCost)->toBe(24.50);
|
|
});
|
|
|
|
it('calculates net amount correctly', function () {
|
|
$proration = $this->service->calculateProration(
|
|
$this->subscription,
|
|
$this->creatorPackage,
|
|
$this->agencyPackage,
|
|
'monthly'
|
|
);
|
|
|
|
// Net = Prorated new - Credit = £24.50 - £9.50 = £15.00
|
|
expect($proration->netAmount)->toBe(15.00)
|
|
->and($proration->getAmountDue())->toBe(15.00);
|
|
});
|
|
});
|
|
|
|
describe('previewPlanChange() method', function () {
|
|
it('returns proration preview without making changes', function () {
|
|
$proration = $this->service->previewPlanChange(
|
|
$this->subscription,
|
|
$this->agencyPackage
|
|
);
|
|
|
|
expect($proration)->toBeInstanceOf(ProrationResult::class)
|
|
->and($proration->isUpgrade())->toBeTrue();
|
|
|
|
// Verify no changes were made
|
|
$this->subscription->refresh();
|
|
expect($this->subscription->workspacePackage->package->code)->toBe('creator');
|
|
});
|
|
|
|
it('throws exception if subscription has no current package', function () {
|
|
// Mock the workspacePackage to have null package
|
|
$this->subscription->workspacePackage->setRelation('package', null);
|
|
|
|
expect(fn () => $this->service->previewPlanChange($this->subscription, $this->agencyPackage))
|
|
->toThrow(\InvalidArgumentException::class, 'no current package');
|
|
});
|
|
});
|
|
|
|
describe('scheduled plan changes', function () {
|
|
it('schedules plan change for period end', function () {
|
|
$result = $this->service->changePlan(
|
|
$this->subscription,
|
|
$this->agencyPackage,
|
|
prorate: false,
|
|
immediate: false
|
|
);
|
|
|
|
expect($result['immediate'])->toBeFalse()
|
|
->and($this->service->hasPendingPlanChange($result['subscription']))->toBeTrue();
|
|
|
|
$pending = $this->service->getPendingPlanChange($result['subscription']);
|
|
expect($pending['to_package_code'])->toBe('agency');
|
|
});
|
|
|
|
it('cancels scheduled plan change', function () {
|
|
$result = $this->service->changePlan(
|
|
$this->subscription,
|
|
$this->agencyPackage,
|
|
immediate: false
|
|
);
|
|
|
|
$subscription = $this->service->cancelScheduledPlanChange($result['subscription']);
|
|
|
|
expect($this->service->hasPendingPlanChange($subscription))->toBeFalse();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ProrationResult', function () {
|
|
it('converts to array correctly', function () {
|
|
$result = new ProrationResult(
|
|
daysRemaining: 15,
|
|
totalPeriodDays: 30,
|
|
usedPercentage: 0.5,
|
|
currentPlanPrice: 19.00,
|
|
newPlanPrice: 49.00,
|
|
creditAmount: 9.50,
|
|
proratedNewPlanCost: 24.50,
|
|
netAmount: 15.00,
|
|
currency: 'GBP'
|
|
);
|
|
|
|
$array = $result->toArray();
|
|
|
|
expect($array)->toHaveKeys([
|
|
'days_remaining',
|
|
'total_period_days',
|
|
'used_percentage',
|
|
'current_plan_price',
|
|
'new_plan_price',
|
|
'credit_amount',
|
|
'prorated_new_plan_cost',
|
|
'net_amount',
|
|
'currency',
|
|
'is_upgrade',
|
|
'is_downgrade',
|
|
'requires_payment',
|
|
])
|
|
->and($array['is_upgrade'])->toBeTrue()
|
|
->and($array['is_downgrade'])->toBeFalse()
|
|
->and($array['requires_payment'])->toBeTrue();
|
|
});
|
|
|
|
it('identifies same price plans', function () {
|
|
$result = new ProrationResult(
|
|
daysRemaining: 15,
|
|
totalPeriodDays: 30,
|
|
usedPercentage: 0.5,
|
|
currentPlanPrice: 49.00,
|
|
newPlanPrice: 49.00,
|
|
creditAmount: 24.50,
|
|
proratedNewPlanCost: 24.50,
|
|
netAmount: 0.00,
|
|
);
|
|
|
|
expect($result->isSamePrice())->toBeTrue()
|
|
->and($result->isUpgrade())->toBeFalse()
|
|
->and($result->isDowngrade())->toBeFalse()
|
|
->and($result->requiresPayment())->toBeFalse();
|
|
});
|
|
});
|