test: add tests for UsageBillingService

Cover usage recording (with idempotency, inactive meters, feature flag),
workspace-level recording, usage retrieval and summaries, pending charge
calculations, invoice line item creation, tiered/flat-rate overage pricing,
billing cycle resets, usage aggregation, meter CRUD, and Stripe gate checks.

Fixes #7

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-03-24 16:29:15 +00:00
parent 5bce748a0f
commit 59ed456ccd
No known key found for this signature in database
GPG key ID: AF404715446AEB41

View file

@ -0,0 +1,975 @@
<?php
declare(strict_types=1);
use Carbon\Carbon;
use Core\Mod\Commerce\Models\Invoice;
use Core\Mod\Commerce\Models\InvoiceItem;
use Core\Mod\Commerce\Models\Subscription;
use Core\Mod\Commerce\Models\SubscriptionUsage;
use Core\Mod\Commerce\Models\UsageEvent;
use Core\Mod\Commerce\Models\UsageMeter;
use Core\Mod\Commerce\Services\UsageBillingService;
use Core\Tenant\Models\Package;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Models\WorkspacePackage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
uses(RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
config(['commerce.features.usage_billing' => 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);
});
});
});