diff --git a/tests/Feature/UsageBillingServiceTest.php b/tests/Feature/UsageBillingServiceTest.php new file mode 100644 index 0000000..10a335c --- /dev/null +++ b/tests/Feature/UsageBillingServiceTest.php @@ -0,0 +1,975 @@ + true]); + + $this->user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + $this->package = Package::where('code', 'creator')->firstOrFail(); + + $this->workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->package->id, + 'status' => 'active', + ]); + + $this->subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $this->workspacePackage->id, + 'status' => 'active', + 'gateway' => 'btcpay', + 'billing_cycle' => 'monthly', + 'current_period_start' => now()->startOfDay(), + 'current_period_end' => now()->addDays(30)->startOfDay(), + ]); + + $this->meter = UsageMeter::create([ + 'code' => 'api_calls', + 'name' => 'API Calls', + 'description' => 'Metered API call usage', + 'aggregation_type' => UsageMeter::AGGREGATION_SUM, + 'unit_price' => 0.01, + 'currency' => 'GBP', + 'unit_label' => 'calls', + 'is_active' => true, + ]); + + $this->service = app(UsageBillingService::class); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +// ------------------------------------------------------------------------- +// Usage Recording +// ------------------------------------------------------------------------- + +describe('UsageBillingService', function () { + describe('recordUsage()', function () { + it('records a usage event and updates aggregated usage', function () { + $event = $this->service->recordUsage( + $this->subscription, + 'api_calls', + 5 + ); + + expect($event)->toBeInstanceOf(UsageEvent::class) + ->and($event->subscription_id)->toBe($this->subscription->id) + ->and($event->meter_id)->toBe($this->meter->id) + ->and($event->workspace_id)->toBe($this->workspace->id) + ->and($event->quantity)->toBe(5); + + // Check aggregated usage was updated + $usage = SubscriptionUsage::where('subscription_id', $this->subscription->id) + ->where('meter_id', $this->meter->id) + ->first(); + + expect($usage)->not->toBeNull() + ->and($usage->quantity)->toBe(5); + }); + + it('accumulates quantity across multiple recordings', function () { + $this->service->recordUsage($this->subscription, 'api_calls', 3); + $this->service->recordUsage($this->subscription, 'api_calls', 7); + $this->service->recordUsage($this->subscription, 'api_calls', 10); + + $usage = SubscriptionUsage::where('subscription_id', $this->subscription->id) + ->where('meter_id', $this->meter->id) + ->first(); + + expect($usage->quantity)->toBe(20); + }); + + it('defaults quantity to 1', function () { + $event = $this->service->recordUsage($this->subscription, 'api_calls'); + + expect($event->quantity)->toBe(1); + + $usage = SubscriptionUsage::where('subscription_id', $this->subscription->id) + ->where('meter_id', $this->meter->id) + ->first(); + + expect($usage->quantity)->toBe(1); + }); + + it('records optional user, action, and metadata', function () { + $event = $this->service->recordUsage( + $this->subscription, + 'api_calls', + 1, + $this->user, + 'image.generate', + ['model' => 'sdxl', 'resolution' => '1024x1024'] + ); + + expect($event->user_id)->toBe($this->user->id) + ->and($event->action)->toBe('image.generate') + ->and($event->metadata)->toBe(['model' => 'sdxl', 'resolution' => '1024x1024']); + }); + + it('returns null when usage_billing feature is disabled', function () { + config(['commerce.features.usage_billing' => false]); + + $event = $this->service->recordUsage($this->subscription, 'api_calls', 5); + + expect($event)->toBeNull(); + expect(UsageEvent::count())->toBe(0); + }); + + it('returns null for unknown meter code', function () { + Log::shouldReceive('warning')->once()->withArgs(function ($message, $context) { + return $message === 'Usage meter not found or inactive' + && $context['meter_code'] === 'nonexistent_meter'; + }); + + $event = $this->service->recordUsage( + $this->subscription, + 'nonexistent_meter', + 5 + ); + + expect($event)->toBeNull(); + }); + + it('returns null for inactive meter', function () { + $this->meter->update(['is_active' => false]); + + Log::shouldReceive('warning')->once(); + + $event = $this->service->recordUsage($this->subscription, 'api_calls', 5); + + expect($event)->toBeNull(); + }); + + it('respects idempotency key to prevent duplicates', function () { + $first = $this->service->recordUsage( + $this->subscription, + 'api_calls', + 5, + idempotencyKey: 'unique-key-123' + ); + + Log::shouldReceive('info')->once()->withArgs(function ($message) { + return $message === 'Duplicate usage event skipped'; + }); + + $duplicate = $this->service->recordUsage( + $this->subscription, + 'api_calls', + 5, + idempotencyKey: 'unique-key-123' + ); + + expect($first)->toBeInstanceOf(UsageEvent::class) + ->and($duplicate)->toBeNull(); + + // Only one event should exist + expect(UsageEvent::count())->toBe(1); + + // Aggregated usage should only count the first event + $usage = SubscriptionUsage::where('subscription_id', $this->subscription->id) + ->where('meter_id', $this->meter->id) + ->first(); + + expect($usage->quantity)->toBe(5); + }); + + it('allows different idempotency keys', function () { + $this->service->recordUsage( + $this->subscription, + 'api_calls', + 5, + idempotencyKey: 'key-a' + ); + + $this->service->recordUsage( + $this->subscription, + 'api_calls', + 3, + idempotencyKey: 'key-b' + ); + + expect(UsageEvent::count())->toBe(2); + + $usage = SubscriptionUsage::where('subscription_id', $this->subscription->id) + ->where('meter_id', $this->meter->id) + ->first(); + + expect($usage->quantity)->toBe(8); + }); + }); + + describe('recordUsageForWorkspace()', function () { + it('records usage using the active subscription for a workspace', function () { + $event = $this->service->recordUsageForWorkspace( + $this->workspace, + 'api_calls', + 10 + ); + + expect($event)->toBeInstanceOf(UsageEvent::class) + ->and($event->subscription_id)->toBe($this->subscription->id) + ->and($event->quantity)->toBe(10); + }); + + it('returns null when workspace has no active subscription', function () { + $this->subscription->update(['status' => 'expired']); + + $event = $this->service->recordUsageForWorkspace( + $this->workspace, + 'api_calls', + 5 + ); + + expect($event)->toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // Usage Retrieval + // ------------------------------------------------------------------------- + + describe('getCurrentUsage()', function () { + it('returns usage for the current billing period', function () { + $this->service->recordUsage($this->subscription, 'api_calls', 25); + + $usage = $this->service->getCurrentUsage($this->subscription); + + expect($usage)->toHaveCount(1) + ->and($usage->first()->quantity)->toBe(25) + ->and($usage->first()->meter->code)->toBe('api_calls'); + }); + + it('filters by meter code when provided', function () { + $storageMeter = UsageMeter::create([ + 'code' => 'storage_gb', + 'name' => 'Storage', + 'unit_price' => 0.10, + 'currency' => 'GBP', + 'unit_label' => 'GB', + 'is_active' => true, + ]); + + $this->service->recordUsage($this->subscription, 'api_calls', 100); + $this->service->recordUsage($this->subscription, 'storage_gb', 5); + + $apiUsage = $this->service->getCurrentUsage($this->subscription, 'api_calls'); + $storageUsage = $this->service->getCurrentUsage($this->subscription, 'storage_gb'); + + expect($apiUsage)->toHaveCount(1) + ->and($apiUsage->first()->meter->code)->toBe('api_calls') + ->and($storageUsage)->toHaveCount(1) + ->and($storageUsage->first()->meter->code)->toBe('storage_gb'); + }); + + it('returns empty collection when no usage exists', function () { + $usage = $this->service->getCurrentUsage($this->subscription); + + expect($usage)->toHaveCount(0); + }); + }); + + describe('getUsageSummary()', function () { + it('returns formatted summary of current usage', function () { + $this->service->recordUsage($this->subscription, 'api_calls', 500); + + $summary = $this->service->getUsageSummary($this->subscription); + + expect($summary)->toHaveCount(1) + ->and($summary[0]['meter_code'])->toBe('api_calls') + ->and($summary[0]['meter_name'])->toBe('API Calls') + ->and($summary[0]['quantity'])->toBe(500) + ->and($summary[0]['unit_label'])->toBe('calls') + ->and($summary[0]['estimated_charge'])->toBe(5.0) // 500 * 0.01 + ->and($summary[0]['currency'])->toBe('GBP') + ->and($summary[0])->toHaveKeys(['period_start', 'period_end']); + }); + + it('returns empty array when no usage exists', function () { + $summary = $this->service->getUsageSummary($this->subscription); + + expect($summary)->toBeEmpty(); + }); + }); + + describe('getUsageHistory()', function () { + it('returns historical usage records ordered by most recent first', function () { + // Create past period usage + SubscriptionUsage::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $this->meter->id, + 'quantity' => 200, + 'period_start' => now()->subDays(60), + 'period_end' => now()->subDays(30), + 'billed' => true, + ]); + + // Create current period usage + $this->service->recordUsage($this->subscription, 'api_calls', 100); + + $history = $this->service->getUsageHistory($this->subscription); + + expect($history)->toHaveCount(2) + ->and($history->first()->period_start->gt($history->last()->period_start))->toBeTrue(); + }); + + it('limits results to the specified number of periods', function () { + for ($i = 0; $i < 10; $i++) { + SubscriptionUsage::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $this->meter->id, + 'quantity' => ($i + 1) * 10, + 'period_start' => now()->subDays(($i + 1) * 30), + 'period_end' => now()->subDays($i * 30), + ]); + } + + $history = $this->service->getUsageHistory($this->subscription, periods: 3); + + expect($history)->toHaveCount(3); + }); + + it('filters by meter code', function () { + $storageMeter = UsageMeter::create([ + 'code' => 'storage_gb', + 'name' => 'Storage', + 'unit_price' => 0.10, + 'currency' => 'GBP', + 'unit_label' => 'GB', + 'is_active' => true, + ]); + + SubscriptionUsage::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $this->meter->id, + 'quantity' => 100, + 'period_start' => now()->subDays(30), + 'period_end' => now(), + ]); + + SubscriptionUsage::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $storageMeter->id, + 'quantity' => 5, + 'period_start' => now()->subDays(30), + 'period_end' => now(), + ]); + + $apiHistory = $this->service->getUsageHistory($this->subscription, 'api_calls'); + + expect($apiHistory)->toHaveCount(1) + ->and($apiHistory->first()->meter->code)->toBe('api_calls'); + }); + }); + + // ------------------------------------------------------------------------- + // Billing & Invoicing + // ------------------------------------------------------------------------- + + describe('calculatePendingCharges()', function () { + it('calculates total charges for unbilled, completed periods', function () { + // Create unbilled usage from a past period + SubscriptionUsage::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $this->meter->id, + 'quantity' => 1000, + 'period_start' => now()->subDays(60), + 'period_end' => now()->subDays(30), + 'billed' => false, + ]); + + $charges = $this->service->calculatePendingCharges($this->subscription); + + // 1000 * 0.01 = 10.00 + expect($charges)->toBe(10.0); + }); + + it('returns zero when all usage is already billed', function () { + SubscriptionUsage::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $this->meter->id, + 'quantity' => 500, + 'period_start' => now()->subDays(60), + 'period_end' => now()->subDays(30), + 'billed' => true, + ]); + + $charges = $this->service->calculatePendingCharges($this->subscription); + + expect($charges)->toBe(0.0); + }); + + it('excludes usage from periods that have not ended', function () { + // Current period usage (not yet ended) + $this->service->recordUsage($this->subscription, 'api_calls', 500); + + $charges = $this->service->calculatePendingCharges($this->subscription); + + expect($charges)->toBe(0.0); + }); + + it('sums charges across multiple meters', function () { + $storageMeter = UsageMeter::create([ + 'code' => 'storage_gb', + 'name' => 'Storage', + 'unit_price' => 0.50, + 'currency' => 'GBP', + 'unit_label' => 'GB', + 'is_active' => true, + ]); + + SubscriptionUsage::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $this->meter->id, + 'quantity' => 1000, + 'period_start' => now()->subDays(60), + 'period_end' => now()->subDays(30), + 'billed' => false, + ]); + + SubscriptionUsage::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $storageMeter->id, + 'quantity' => 10, + 'period_start' => now()->subDays(60), + 'period_end' => now()->subDays(30), + 'billed' => false, + ]); + + $charges = $this->service->calculatePendingCharges($this->subscription); + + // (1000 * 0.01) + (10 * 0.50) = 10.00 + 5.00 = 15.00 + expect($charges)->toBe(15.0); + }); + }); + + describe('createUsageLineItems()', function () { + it('creates invoice line items for unbilled usage', function () { + SubscriptionUsage::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $this->meter->id, + 'quantity' => 1000, + 'period_start' => now()->subDays(60), + 'period_end' => now()->subDays(30), + 'billed' => false, + ]); + + $invoice = Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2026-0001', + 'status' => 'draft', + 'subtotal' => 0, + 'total' => 0, + 'amount_due' => 0, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->addDays(14), + ]); + + $lineItems = $this->service->createUsageLineItems($invoice, $this->subscription); + + expect($lineItems)->toHaveCount(1); + + $item = $lineItems->first(); + expect($item)->toBeInstanceOf(InvoiceItem::class) + ->and($item->invoice_id)->toBe($invoice->id) + ->and((float) $item->unit_price)->toBe(10.0) // 1000 * 0.01 + ->and((float) $item->line_total)->toBe(10.0) + ->and($item->taxable)->toBeTrue() + ->and($item->description)->toContain('API Calls') + ->and($item->description)->toContain('1,000') + ->and($item->metadata['type'])->toBe('usage') + ->and($item->metadata['meter_code'])->toBe('api_calls') + ->and($item->metadata['usage_quantity'])->toBe(1000); + }); + + it('marks usage as billed after creating line items', function () { + $usage = SubscriptionUsage::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $this->meter->id, + 'quantity' => 500, + 'period_start' => now()->subDays(60), + 'period_end' => now()->subDays(30), + 'billed' => false, + ]); + + $invoice = Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2026-0002', + 'status' => 'draft', + 'subtotal' => 0, + 'total' => 0, + 'amount_due' => 0, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->addDays(14), + ]); + + $this->service->createUsageLineItems($invoice, $this->subscription); + + $usage->refresh(); + expect($usage->billed)->toBeTrue() + ->and($usage->invoice_item_id)->not->toBeNull(); + }); + + it('skips usage records with zero charge', function () { + SubscriptionUsage::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $this->meter->id, + 'quantity' => 0, + 'period_start' => now()->subDays(60), + 'period_end' => now()->subDays(30), + 'billed' => false, + ]); + + $invoice = Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2026-0003', + 'status' => 'draft', + 'subtotal' => 0, + 'total' => 0, + 'amount_due' => 0, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->addDays(14), + ]); + + $lineItems = $this->service->createUsageLineItems($invoice, $this->subscription); + + expect($lineItems)->toHaveCount(0); + }); + + it('returns empty collection when no unbilled usage exists', function () { + $invoice = Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2026-0004', + 'status' => 'draft', + 'subtotal' => 0, + 'total' => 0, + 'amount_due' => 0, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->addDays(14), + ]); + + $lineItems = $this->service->createUsageLineItems($invoice, $this->subscription); + + expect($lineItems)->toHaveCount(0); + }); + }); + + // ------------------------------------------------------------------------- + // Overage / Tiered Pricing + // ------------------------------------------------------------------------- + + describe('tiered pricing and overage calculations', function () { + it('calculates charges using tiered pricing', function () { + $tieredMeter = UsageMeter::create([ + 'code' => 'emails', + 'name' => 'Emails Sent', + 'unit_price' => 0, + 'currency' => 'GBP', + 'unit_label' => 'emails', + 'is_active' => true, + 'pricing_tiers' => [ + ['up_to' => 100, 'unit_price' => 0.10], + ['up_to' => 1000, 'unit_price' => 0.05], + ['up_to' => null, 'unit_price' => 0.01], + ], + ]); + + // Record usage that spans multiple tiers: 1500 emails + // Tier 1: 100 * 0.10 = 10.00 + // Tier 2: 900 * 0.05 = 45.00 + // Tier 3: 500 * 0.01 = 5.00 + // Total: 60.00 + $this->service->recordUsage($this->subscription, 'emails', 1500); + + $usage = SubscriptionUsage::where('subscription_id', $this->subscription->id) + ->where('meter_id', $tieredMeter->id) + ->first(); + + expect($usage->calculateCharge())->toBe(60.0); + }); + + it('calculates charges within first tier only', function () { + $tieredMeter = UsageMeter::create([ + 'code' => 'emails', + 'name' => 'Emails Sent', + 'unit_price' => 0, + 'currency' => 'GBP', + 'unit_label' => 'emails', + 'is_active' => true, + 'pricing_tiers' => [ + ['up_to' => 100, 'unit_price' => 0.10], + ['up_to' => 1000, 'unit_price' => 0.05], + ['up_to' => null, 'unit_price' => 0.01], + ], + ]); + + $this->service->recordUsage($this->subscription, 'emails', 50); + + $usage = SubscriptionUsage::where('meter_id', $tieredMeter->id)->first(); + + // 50 * 0.10 = 5.00 + expect($usage->calculateCharge())->toBe(5.0); + }); + + it('calculates flat-rate charges correctly', function () { + // The default meter uses flat-rate pricing at 0.01 per call + $this->service->recordUsage($this->subscription, 'api_calls', 250); + + $usage = SubscriptionUsage::where('meter_id', $this->meter->id)->first(); + + // 250 * 0.01 = 2.50 + expect($usage->calculateCharge())->toBe(2.5); + }); + + it('handles tiered billing across period boundaries in invoice line items', function () { + $tieredMeter = UsageMeter::create([ + 'code' => 'ai_tokens', + 'name' => 'AI Tokens', + 'unit_price' => 0, + 'currency' => 'GBP', + 'unit_label' => 'tokens', + 'is_active' => true, + 'pricing_tiers' => [ + ['up_to' => 1000, 'unit_price' => 0.001], + ['up_to' => 10000, 'unit_price' => 0.0005], + ['up_to' => null, 'unit_price' => 0.0001], + ], + ]); + + // Past period with 15,000 tokens + // Tier 1: 1000 * 0.001 = 1.00 + // Tier 2: 9000 * 0.0005 = 4.50 + // Tier 3: 5000 * 0.0001 = 0.50 + // Total: 6.00 + SubscriptionUsage::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $tieredMeter->id, + 'quantity' => 15000, + 'period_start' => now()->subDays(60), + 'period_end' => now()->subDays(30), + 'billed' => false, + ]); + + $invoice = Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2026-0005', + 'status' => 'draft', + 'subtotal' => 0, + 'total' => 0, + 'amount_due' => 0, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->addDays(14), + ]); + + $lineItems = $this->service->createUsageLineItems($invoice, $this->subscription); + + expect($lineItems)->toHaveCount(1) + ->and((float) $lineItems->first()->line_total)->toBe(6.0); + }); + }); + + // ------------------------------------------------------------------------- + // Billing Cycle Resets + // ------------------------------------------------------------------------- + + describe('onPeriodReset()', function () { + it('creates fresh usage records for all active meters in the new period', function () { + $storageMeter = UsageMeter::create([ + 'code' => 'storage_gb', + 'name' => 'Storage', + 'unit_price' => 0.50, + 'currency' => 'GBP', + 'unit_label' => 'GB', + 'is_active' => true, + ]); + + // Move subscription to new period + $newPeriodStart = now()->addDays(30)->startOfDay(); + $newPeriodEnd = now()->addDays(60)->startOfDay(); + $this->subscription->update([ + 'current_period_start' => $newPeriodStart, + 'current_period_end' => $newPeriodEnd, + ]); + + $this->service->onPeriodReset($this->subscription); + + $newUsage = SubscriptionUsage::where('subscription_id', $this->subscription->id) + ->where('period_start', $newPeriodStart) + ->get(); + + // Should have one record per active meter + expect($newUsage)->toHaveCount(2); + + foreach ($newUsage as $record) { + expect($record->quantity)->toBe(0) + ->and($record->period_start->toDateString())->toBe($newPeriodStart->toDateString()) + ->and($record->period_end->toDateString())->toBe($newPeriodEnd->toDateString()); + } + }); + + it('does not include inactive meters in reset', function () { + $inactiveMeter = UsageMeter::create([ + 'code' => 'deprecated_metric', + 'name' => 'Deprecated', + 'unit_price' => 1.00, + 'currency' => 'GBP', + 'unit_label' => 'units', + 'is_active' => false, + ]); + + $newPeriodStart = now()->addDays(30)->startOfDay(); + $newPeriodEnd = now()->addDays(60)->startOfDay(); + $this->subscription->update([ + 'current_period_start' => $newPeriodStart, + 'current_period_end' => $newPeriodEnd, + ]); + + $this->service->onPeriodReset($this->subscription); + + $newUsage = SubscriptionUsage::where('subscription_id', $this->subscription->id) + ->where('period_start', $newPeriodStart) + ->get(); + + // Only the one active meter (api_calls) should be created + expect($newUsage)->toHaveCount(1) + ->and($newUsage->first()->meter_id)->toBe($this->meter->id); + }); + + it('preserves previous period usage when resetting', function () { + // Record some usage in current period + $this->service->recordUsage($this->subscription, 'api_calls', 500); + + $oldPeriodStart = $this->subscription->current_period_start; + + // Move to new period + $newPeriodStart = now()->addDays(30)->startOfDay(); + $newPeriodEnd = now()->addDays(60)->startOfDay(); + $this->subscription->update([ + 'current_period_start' => $newPeriodStart, + 'current_period_end' => $newPeriodEnd, + ]); + + $this->service->onPeriodReset($this->subscription); + + // Old usage should still exist + $oldUsage = SubscriptionUsage::where('subscription_id', $this->subscription->id) + ->where('period_start', $oldPeriodStart) + ->first(); + + expect($oldUsage)->not->toBeNull() + ->and($oldUsage->quantity)->toBe(500); + + // New usage should start at zero + $newUsage = SubscriptionUsage::where('subscription_id', $this->subscription->id) + ->where('period_start', $newPeriodStart) + ->first(); + + expect($newUsage)->not->toBeNull() + ->and($newUsage->quantity)->toBe(0); + }); + }); + + // ------------------------------------------------------------------------- + // Usage Aggregation + // ------------------------------------------------------------------------- + + describe('aggregateUsage()', function () { + it('aggregates usage events into subscription usage records', function () { + $periodStart = now()->subDays(30); + $periodEnd = now(); + + // Create raw events + UsageEvent::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $this->meter->id, + 'workspace_id' => $this->workspace->id, + 'quantity' => 100, + 'event_at' => now()->subDays(20), + ]); + + UsageEvent::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $this->meter->id, + 'workspace_id' => $this->workspace->id, + 'quantity' => 200, + 'event_at' => now()->subDays(10), + ]); + + $results = $this->service->aggregateUsage($this->subscription, $periodStart, $periodEnd); + + expect($results)->toHaveCount(1); + + $usage = $results->first(); + expect($usage->quantity)->toBe(300) + ->and($usage->meter_id)->toBe($this->meter->id); + }); + + it('creates zero-quantity records for meters with no events', function () { + $periodStart = now()->subDays(30); + $periodEnd = now(); + + $results = $this->service->aggregateUsage($this->subscription, $periodStart, $periodEnd); + + // Should create a record for the active meter even with no events + expect($results)->toHaveCount(1) + ->and($results->first()->quantity)->toBe(0); + }); + + it('updates existing aggregation records on re-run', function () { + $periodStart = now()->subDays(30); + $periodEnd = now(); + + // First run + UsageEvent::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $this->meter->id, + 'workspace_id' => $this->workspace->id, + 'quantity' => 50, + 'event_at' => now()->subDays(10), + ]); + + $this->service->aggregateUsage($this->subscription, $periodStart, $periodEnd); + + // Add more events + UsageEvent::create([ + 'subscription_id' => $this->subscription->id, + 'meter_id' => $this->meter->id, + 'workspace_id' => $this->workspace->id, + 'quantity' => 75, + 'event_at' => now()->subDays(5), + ]); + + // Re-aggregate + $results = $this->service->aggregateUsage($this->subscription, $periodStart, $periodEnd); + + // Should update the existing record rather than creating a duplicate + expect($results)->toHaveCount(1) + ->and($results->first()->quantity)->toBe(125); + + // Confirm only one record exists for this period + $count = SubscriptionUsage::where('subscription_id', $this->subscription->id) + ->where('meter_id', $this->meter->id) + ->where('period_start', $periodStart) + ->count(); + + expect($count)->toBe(1); + }); + }); + + // ------------------------------------------------------------------------- + // Meter Management + // ------------------------------------------------------------------------- + + describe('getActiveMeters()', function () { + it('returns only active meters ordered by name', function () { + UsageMeter::create([ + 'code' => 'zebra_metric', + 'name' => 'Zebra Metric', + 'unit_price' => 1.00, + 'currency' => 'GBP', + 'unit_label' => 'units', + 'is_active' => true, + ]); + + UsageMeter::create([ + 'code' => 'disabled_metric', + 'name' => 'Disabled Metric', + 'unit_price' => 1.00, + 'currency' => 'GBP', + 'unit_label' => 'units', + 'is_active' => false, + ]); + + $meters = $this->service->getActiveMeters(); + + expect($meters)->toHaveCount(2); // api_calls + zebra_metric + expect($meters->pluck('code')->toArray()) + ->toBe(['api_calls', 'zebra_metric']); // Ordered by name: "API Calls" < "Zebra Metric" + }); + }); + + describe('createMeter()', function () { + it('creates a new usage meter', function () { + $meter = $this->service->createMeter([ + 'code' => 'bandwidth_gb', + 'name' => 'Bandwidth', + 'description' => 'Network bandwidth usage', + 'unit_price' => 0.05, + 'currency' => 'GBP', + 'unit_label' => 'GB', + 'is_active' => true, + ]); + + expect($meter)->toBeInstanceOf(UsageMeter::class) + ->and($meter->code)->toBe('bandwidth_gb') + ->and($meter->name)->toBe('Bandwidth') + ->and($meter->exists)->toBeTrue(); + }); + }); + + describe('updateMeter()', function () { + it('updates an existing meter and returns fresh instance', function () { + $updated = $this->service->updateMeter($this->meter, [ + 'name' => 'API Requests', + 'unit_price' => 0.02, + ]); + + expect($updated->name)->toBe('API Requests') + ->and((float) $updated->unit_price)->toBe(0.02) + ->and($updated->code)->toBe('api_calls'); // Unchanged fields preserved + }); + }); + + // ------------------------------------------------------------------------- + // Stripe Integration + // ------------------------------------------------------------------------- + + describe('syncToStripe()', function () { + it('returns zero for non-Stripe subscriptions', function () { + $synced = $this->service->syncToStripe($this->subscription); + + expect($synced)->toBe(0); + }); + + it('returns zero when subscription has no gateway subscription ID', function () { + $this->subscription->update(['gateway' => 'stripe']); + + $synced = $this->service->syncToStripe($this->subscription); + + expect($synced)->toBe(0); + }); + }); +});