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); }); }); });