php-tenant/tests/Feature/ResetBillingCyclesTest.php
Snider d0ad2737cb refactor: rename namespace from Core\Mod\Tenant to Core\Tenant
Simplifies the namespace hierarchy by removing the intermediate Mod
segment. Updates all 118 files including models, services, controllers,
middleware, tests, and composer.json autoload configuration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:30:46 +00:00

462 lines
17 KiB
PHP

<?php
use Core\Tenant\Models\Boost;
use Core\Tenant\Models\EntitlementLog;
use Core\Tenant\Models\Feature;
use Core\Tenant\Models\Package;
use Core\Tenant\Models\UsageRecord;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Notifications\BoostExpiredNotification;
use Core\Tenant\Services\EntitlementService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
Notification::fake();
// Create test user and workspace
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
// Create features
$this->aiCreditsFeature = Feature::create([
'code' => 'ai.credits',
'name' => 'AI Credits',
'description' => 'AI generation credits',
'category' => 'ai',
'type' => Feature::TYPE_LIMIT,
'reset_type' => Feature::RESET_MONTHLY,
'is_active' => true,
'sort_order' => 1,
]);
$this->socialPostsFeature = Feature::create([
'code' => 'social.posts',
'name' => 'Scheduled Posts',
'description' => 'Monthly scheduled posts',
'category' => 'social',
'type' => Feature::TYPE_LIMIT,
'reset_type' => Feature::RESET_MONTHLY,
'is_active' => true,
'sort_order' => 1,
]);
// Create base package
$this->creatorPackage = Package::create([
'code' => 'creator',
'name' => 'Creator',
'description' => 'For individual creators',
'is_stackable' => false,
'is_base_package' => true,
'is_active' => true,
'is_public' => true,
'sort_order' => 1,
]);
$this->creatorPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 100]);
$this->creatorPackage->features()->attach($this->socialPostsFeature->id, ['limit_value' => 50]);
$this->service = app(EntitlementService::class);
});
describe('ResetBillingCycles Command', function () {
describe('expiring cycle-bound boosts', function () {
it('expires cycle-bound boosts', function () {
// Provision package
$this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => now()->startOfMonth(),
]);
// Create cycle-bound boost
$boost = Boost::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
'limit_value' => 50,
'consumed_quantity' => 10,
'status' => Boost::STATUS_ACTIVE,
'starts_at' => now()->subDays(15),
]);
// Run command
$this->artisan('tenant:reset-billing-cycles', [
'--workspace' => $this->workspace->id,
])->assertExitCode(0);
$boost->refresh();
expect($boost->status)->toBe(Boost::STATUS_EXPIRED);
});
it('does not expire permanent boosts', function () {
$this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => now()->startOfMonth(),
]);
$boost = Boost::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'duration_type' => Boost::DURATION_PERMANENT,
'limit_value' => 50,
'consumed_quantity' => 0,
'status' => Boost::STATUS_ACTIVE,
'starts_at' => now()->subDays(15),
]);
$this->artisan('tenant:reset-billing-cycles', [
'--workspace' => $this->workspace->id,
])->assertExitCode(0);
$boost->refresh();
expect($boost->status)->toBe(Boost::STATUS_ACTIVE);
});
it('creates audit log entries for expired boosts', function () {
$this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => now()->startOfMonth(),
]);
Boost::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
'limit_value' => 50,
'consumed_quantity' => 0,
'status' => Boost::STATUS_ACTIVE,
'starts_at' => now()->subDays(15),
]);
$this->artisan('tenant:reset-billing-cycles', [
'--workspace' => $this->workspace->id,
])->assertExitCode(0);
$log = EntitlementLog::where('workspace_id', $this->workspace->id)
->where('action', EntitlementLog::ACTION_BOOST_EXPIRED)
->first();
expect($log)->not->toBeNull()
->and($log->metadata['reason'])->toBe('Billing cycle ended');
});
});
describe('expiring timed boosts', function () {
it('expires boosts past their expiry date', function () {
$this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => now()->startOfMonth(),
]);
$boost = Boost::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'duration_type' => Boost::DURATION_DURATION,
'limit_value' => 100,
'consumed_quantity' => 0,
'status' => Boost::STATUS_ACTIVE,
'starts_at' => now()->subDays(30),
'expires_at' => now()->subDay(), // Expired yesterday
]);
$this->artisan('tenant:reset-billing-cycles', [
'--workspace' => $this->workspace->id,
])->assertExitCode(0);
$boost->refresh();
expect($boost->status)->toBe(Boost::STATUS_EXPIRED);
});
it('does not expire boosts with future expiry', function () {
$this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => now()->startOfMonth(),
]);
$boost = Boost::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'duration_type' => Boost::DURATION_DURATION,
'limit_value' => 100,
'consumed_quantity' => 0,
'status' => Boost::STATUS_ACTIVE,
'starts_at' => now(),
'expires_at' => now()->addWeek(), // Expires next week
]);
$this->artisan('tenant:reset-billing-cycles', [
'--workspace' => $this->workspace->id,
])->assertExitCode(0);
$boost->refresh();
expect($boost->status)->toBe(Boost::STATUS_ACTIVE);
});
});
describe('notifications', function () {
it('sends notification to workspace owner when boosts expire', function () {
$this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => now()->startOfMonth(),
]);
Boost::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
'limit_value' => 50,
'consumed_quantity' => 10,
'status' => Boost::STATUS_ACTIVE,
'starts_at' => now()->subDays(15),
]);
$this->artisan('tenant:reset-billing-cycles', [
'--workspace' => $this->workspace->id,
])->assertExitCode(0);
Notification::assertSentTo(
$this->user,
BoostExpiredNotification::class
);
});
it('does not send notification in dry-run mode', function () {
$this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => now()->startOfMonth(),
]);
Boost::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
'limit_value' => 50,
'consumed_quantity' => 0,
'status' => Boost::STATUS_ACTIVE,
'starts_at' => now()->subDays(15),
]);
$this->artisan('tenant:reset-billing-cycles', [
'--workspace' => $this->workspace->id,
'--dry-run' => true,
])->assertExitCode(0);
Notification::assertNothingSent();
});
});
describe('dry-run mode', function () {
it('does not modify boosts in dry-run mode', function () {
$this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => now()->startOfMonth(),
]);
$boost = Boost::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
'limit_value' => 50,
'consumed_quantity' => 0,
'status' => Boost::STATUS_ACTIVE,
'starts_at' => now()->subDays(15),
]);
$this->artisan('tenant:reset-billing-cycles', [
'--workspace' => $this->workspace->id,
'--dry-run' => true,
])->assertExitCode(0);
$boost->refresh();
expect($boost->status)->toBe(Boost::STATUS_ACTIVE);
});
it('does not create log entries in dry-run mode', function () {
$this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => now()->startOfMonth(),
]);
Boost::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
'limit_value' => 50,
'consumed_quantity' => 0,
'status' => Boost::STATUS_ACTIVE,
'starts_at' => now()->subDays(15),
]);
// Clear any existing logs
EntitlementLog::where('workspace_id', $this->workspace->id)->delete();
$this->artisan('tenant:reset-billing-cycles', [
'--workspace' => $this->workspace->id,
'--dry-run' => true,
])->assertExitCode(0);
$logs = EntitlementLog::where('workspace_id', $this->workspace->id)
->where('action', EntitlementLog::ACTION_BOOST_EXPIRED)
->count();
expect($logs)->toBe(0);
});
});
describe('processing all workspaces', function () {
it('processes multiple workspaces', function () {
// Create second workspace
$workspace2 = Workspace::factory()->create(['is_active' => true]);
$user2 = User::factory()->create();
$workspace2->users()->attach($user2->id, ['role' => 'owner', 'is_default' => true]);
// Provision packages for both
$this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => now()->startOfMonth(),
]);
$this->service->provisionPackage($workspace2, 'creator', [
'billing_cycle_anchor' => now()->startOfMonth(),
]);
// Create boosts for both
Boost::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
'limit_value' => 50,
'status' => Boost::STATUS_ACTIVE,
'starts_at' => now()->subDays(15),
]);
Boost::create([
'workspace_id' => $workspace2->id,
'feature_code' => 'ai.credits',
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
'limit_value' => 100,
'status' => Boost::STATUS_ACTIVE,
'starts_at' => now()->subDays(15),
]);
$this->artisan('tenant:reset-billing-cycles')
->assertExitCode(0);
// Both boosts should be expired
expect(Boost::where('status', Boost::STATUS_EXPIRED)->count())->toBe(2);
});
it('skips workspaces without active packages', function () {
// Don't provision a package for this workspace
$workspace2 = Workspace::factory()->create(['is_active' => true]);
$this->artisan('tenant:reset-billing-cycles')
->assertExitCode(0);
// No errors should occur
});
it('skips inactive workspaces', function () {
$this->workspace->update(['is_active' => false]);
$this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => now()->startOfMonth(),
]);
Boost::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
'limit_value' => 50,
'status' => Boost::STATUS_ACTIVE,
'starts_at' => now()->subDays(15),
]);
$this->artisan('tenant:reset-billing-cycles')
->assertExitCode(0);
// Boost should not be expired (workspace is inactive)
expect(Boost::where('status', Boost::STATUS_ACTIVE)->count())->toBe(1);
});
});
describe('usage counter reset logging', function () {
it('logs cycle reset when at cycle boundary with previous usage', function () {
// Set billing cycle to start today
$this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => now(),
]);
// Create usage record from previous cycle
UsageRecord::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'quantity' => 25,
'recorded_at' => now()->subMonth(), // Previous cycle
]);
// Clear logs from provisioning
EntitlementLog::where('workspace_id', $this->workspace->id)->delete();
$this->artisan('tenant:reset-billing-cycles', [
'--workspace' => $this->workspace->id,
])->assertExitCode(0);
$log = EntitlementLog::where('workspace_id', $this->workspace->id)
->where('action', 'cycle.reset')
->first();
expect($log)->not->toBeNull()
->and($log->metadata['previous_cycle_records'])->toBe(1);
});
});
describe('cache invalidation', function () {
it('invalidates entitlement cache after processing', function () {
$this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => now()->startOfMonth(),
]);
// Create and verify boost is counted in limit
$boost = Boost::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
'limit_value' => 50,
'consumed_quantity' => 0,
'status' => Boost::STATUS_ACTIVE,
'starts_at' => now()->subDays(15),
]);
Cache::flush();
$resultBefore = $this->service->can($this->workspace, 'ai.credits');
expect($resultBefore->limit)->toBe(150); // 100 + 50 boost
// Run command
$this->artisan('tenant:reset-billing-cycles', [
'--workspace' => $this->workspace->id,
])->assertExitCode(0);
// Limit should be back to package only
$resultAfter = $this->service->can($this->workspace, 'ai.credits');
expect($resultAfter->limit)->toBe(100);
});
});
});