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:
parent
5bce748a0f
commit
59ed456ccd
1 changed files with 975 additions and 0 deletions
975
tests/Feature/UsageBillingServiceTest.php
Normal file
975
tests/Feature/UsageBillingServiceTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue