diff --git a/routes/api.php b/routes/api.php index 23d1b63..1c0e3fc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -52,10 +52,12 @@ Route::prefix('webhooks')->group(function () { // }); // ───────────────────────────────────────────────────────────────────────────── -// Commerce Billing API (authenticated) +// Commerce Billing API (authenticated + verified) // ───────────────────────────────────────────────────────────────────────────── -Route::middleware('auth')->prefix('commerce')->group(function () { +Route::middleware(['auth', 'verified'])->prefix('commerce')->group(function () { + // ── Read-only endpoints ────────────────────────────────────────────── + // Billing overview Route::get('/billing', [CommerceController::class, 'billing']) ->name('api.commerce.billing'); @@ -74,21 +76,27 @@ Route::middleware('auth')->prefix('commerce')->group(function () { Route::get('/invoices/{invoice}/download', [CommerceController::class, 'downloadInvoice']) ->name('api.commerce.invoices.download'); - // Subscription + // Subscription (read) Route::get('/subscription', [CommerceController::class, 'subscription']) ->name('api.commerce.subscription'); - Route::post('/cancel', [CommerceController::class, 'cancelSubscription']) - ->name('api.commerce.cancel'); - Route::post('/resume', [CommerceController::class, 'resumeSubscription']) - ->name('api.commerce.resume'); // Usage Route::get('/usage', [CommerceController::class, 'usage']) ->name('api.commerce.usage'); - // Plan changes - Route::post('/upgrade/preview', [CommerceController::class, 'previewUpgrade']) - ->name('api.commerce.upgrade.preview'); - Route::post('/upgrade', [CommerceController::class, 'executeUpgrade']) - ->name('api.commerce.upgrade'); + // ── State-changing endpoints (rate-limited) ────────────────────────── + + Route::middleware('throttle:6,1')->group(function () { + // Subscription management + Route::post('/cancel', [CommerceController::class, 'cancelSubscription']) + ->name('api.commerce.cancel'); + Route::post('/resume', [CommerceController::class, 'resumeSubscription']) + ->name('api.commerce.resume'); + + // Plan changes + Route::post('/upgrade/preview', [CommerceController::class, 'previewUpgrade']) + ->name('api.commerce.upgrade.preview'); + Route::post('/upgrade', [CommerceController::class, 'executeUpgrade']) + ->name('api.commerce.upgrade'); + }); }); diff --git a/tests/Feature/ProductCatalogServiceTest.php b/tests/Feature/ProductCatalogServiceTest.php new file mode 100644 index 0000000..93d9fe8 --- /dev/null +++ b/tests/Feature/ProductCatalogServiceTest.php @@ -0,0 +1,1077 @@ +service = app(ProductCatalogService::class); + + // Create entity hierarchy: M1 -> M2 -> M3 + $this->m1 = Entity::createMaster('ACME', 'Acme Corporation'); + $this->m2 = $this->m1->createFacade('SHOP', 'Acme Shop'); + $this->m3 = $this->m2->createDropshipper('DROP', 'Dropship Partner'); +}); + +// ────────────────────────────────────────────────────────────────── +// Product CRUD +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → createProduct', function () { + it('creates a product owned by an M1 entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'WIDGET-100', + 'name' => 'Super Widget', + 'description' => 'A very fine widget.', + 'price' => 2500, + 'currency' => 'GBP', + 'type' => Product::TYPE_SIMPLE, + ]); + + expect($product)->toBeInstanceOf(Product::class) + ->and($product->exists)->toBeTrue() + ->and($product->owner_entity_id)->toBe($this->m1->id) + ->and($product->sku)->toBe('WIDGET-100') + ->and($product->name)->toBe('Super Widget') + ->and($product->price)->toBe(2500); + }); + + it('upper-cases the SKU on creation', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'lower-sku', + 'name' => 'Lowercase SKU Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + expect($product->sku)->toBe('LOWER-SKU'); + }); + + it('generates a SKU when none is provided', function () { + $product = $this->service->createProduct($this->m1, [ + 'name' => 'Auto SKU Product', + 'price' => 500, + 'currency' => 'GBP', + ]); + + expect($product->sku)->not->toBeEmpty() + ->and($product->sku)->toStartWith('ACME-'); + }); + + it('throws when a non-M1 entity tries to own a product', function () { + $this->service->createProduct($this->m2, [ + 'sku' => 'NOPE', + 'name' => 'Forbidden', + 'price' => 100, + ]); + })->throws(InvalidArgumentException::class, 'Only M1 (Master) entities can own products'); + + it('throws when an M3 entity tries to own a product', function () { + $this->service->createProduct($this->m3, [ + 'sku' => 'NOPE', + 'name' => 'Forbidden', + 'price' => 100, + ]); + })->throws(InvalidArgumentException::class, 'Only M1 (Master) entities can own products'); +}); + +describe('ProductCatalogService → updateProduct', function () { + it('updates mutable product fields', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'UPD-001', + 'name' => 'Original Name', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $updated = $this->service->updateProduct($product, [ + 'name' => 'Updated Name', + 'price' => 1500, + 'description' => 'Now with a description.', + ]); + + expect($updated->name)->toBe('Updated Name') + ->and($updated->price)->toBe(1500) + ->and($updated->description)->toBe('Now with a description.'); + }); + + it('prevents SKU from being changed', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'IMMUTABLE', + 'name' => 'Immutable SKU Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $updated = $this->service->updateProduct($product, [ + 'sku' => 'CHANGED', + 'name' => 'New Name', + ]); + + expect($updated->sku)->toBe('IMMUTABLE') + ->and($updated->name)->toBe('New Name'); + }); + + it('prevents owner_entity_id from being changed', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'OWNER-LOCK', + 'name' => 'Owner Lock Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $otherM1 = Entity::createMaster('OTHER', 'Other Corp'); + + $updated = $this->service->updateProduct($product, [ + 'owner_entity_id' => $otherM1->id, + ]); + + expect($updated->owner_entity_id)->toBe($this->m1->id); + }); +}); + +describe('ProductCatalogService → deleteProduct', function () { + it('soft-deletes a product', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'DEL-001', + 'name' => 'Deletable Product', + 'price' => 999, + 'currency' => 'GBP', + ]); + + $result = $this->service->deleteProduct($product); + + expect($result)->toBeTrue() + ->and(Product::find($product->id))->toBeNull() + ->and(Product::withTrashed()->find($product->id))->not->toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// SKU management +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → SKU management', function () { + it('builds full SKU with entity lineage for M1', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PROD500', + 'name' => 'SKU Lineage Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $effective = $this->service->getEffectiveProduct($this->m1, $product); + + expect($effective['sku'])->toBe('ACME-PROD500'); + }); + + it('builds full SKU with entity lineage for M2 assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PROD500', + 'name' => 'SKU Lineage Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $product); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective['sku'])->toContain('SHOP'); + }); + + it('uses sku_suffix override when set on assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PROD500', + 'name' => 'SKU Override Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $product, [ + 'sku_suffix' => 'CUSTOM', + ]); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective['sku'])->toContain('CUSTOM'); + }); + + it('resolves a valid SKU to its product and entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'RESOLVE', + 'name' => 'Resolvable Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $result = $this->service->resolveSku('ACME-RESOLVE'); + + expect($result)->not->toBeNull() + ->and($result['product']->id)->toBe($product->id) + ->and($result['entity']->id)->toBe($this->m1->id) + ->and($result['assignment'])->toBeNull(); // M1 owns directly + }); + + it('returns null for an unresolvable SKU', function () { + $result = $this->service->resolveSku('UNKNOWN-DOESNOTEXIST'); + + expect($result)->toBeNull(); + }); + + it('returns null for a SKU with fewer than two parts', function () { + $result = $this->service->resolveSku('SINGLE'); + + expect($result)->toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Pricing +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → pricing', function () { + it('returns base price for M1 effective product', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PRICE-001', + 'name' => 'Base Price Product', + 'price' => 4999, + 'currency' => 'GBP', + ]); + + $effective = $this->service->getEffectiveProduct($this->m1, $product); + + expect($effective['price'])->toBe(4999); + }); + + it('returns base price when no price override on assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PRICE-002', + 'name' => 'Inherited Price Product', + 'price' => 3000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $product); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective['price'])->toBe(3000); + }); + + it('returns overridden price when price_override is set', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PRICE-003', + 'name' => 'Price Override Product', + 'price' => 3000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 3500, + ]); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective['price'])->toBe(3500); + }); + + it('returns null effective product when M2 has no assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'PRICE-004', + 'name' => 'Unassigned Product', + 'price' => 2000, + 'currency' => 'GBP', + ]); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective)->toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Product assignment +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → assignProduct', function () { + it('assigns a product to an M2 entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'ASSIGN-001', + 'name' => 'Assignable Product', + 'price' => 1500, + 'currency' => 'GBP', + ]); + + $assignment = $this->service->assignProduct($this->m2, $product); + + expect($assignment)->toBeInstanceOf(ProductAssignment::class) + ->and($assignment->entity_id)->toBe($this->m2->id) + ->and($assignment->product_id)->toBe($product->id); + }); + + it('assigns a product to an M3 entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'ASSIGN-002', + 'name' => 'M3 Product', + 'price' => 1500, + 'currency' => 'GBP', + ]); + + $assignment = $this->service->assignProduct($this->m3, $product); + + expect($assignment)->toBeInstanceOf(ProductAssignment::class) + ->and($assignment->entity_id)->toBe($this->m3->id); + }); + + it('throws when assigning to an M1 entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'ASSIGN-003', + 'name' => 'M1 No-Assign Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m1, $product); + })->throws(InvalidArgumentException::class, 'M1 entities own products directly'); + + it('updates existing assignment instead of creating duplicate', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'ASSIGN-004', + 'name' => 'Duplicate Guard Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $first = $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 1200, + ]); + + $second = $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 1500, + ]); + + expect($first->id)->toBe($second->id) + ->and($second->price_override)->toBe(1500); + + // Only one assignment row should exist + $count = ProductAssignment::where('entity_id', $this->m2->id) + ->where('product_id', $product->id) + ->count(); + expect($count)->toBe(1); + }); + + it('accepts overrides on assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'ASSIGN-005', + 'name' => 'Override Product', + 'price' => 2000, + 'currency' => 'GBP', + ]); + + $assignment = $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 2500, + 'name_override' => 'Custom Shop Name', + 'is_featured' => true, + ]); + + expect($assignment->price_override)->toBe(2500) + ->and($assignment->name_override)->toBe('Custom Shop Name') + ->and($assignment->is_featured)->toBeTrue(); + }); +}); + +describe('ProductCatalogService → updateAssignment', function () { + it('updates overrides on an existing assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'UPASSIGN-001', + 'name' => 'Updatable Assignment Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $assignment = $this->service->assignProduct($this->m2, $product); + + $updated = $this->service->updateAssignment($assignment, [ + 'price_override' => 1200, + 'description_override' => 'Custom description', + ]); + + expect($updated->price_override)->toBe(1200) + ->and($updated->description_override)->toBe('Custom description'); + }); +}); + +describe('ProductCatalogService → removeAssignment', function () { + it('removes a product assignment', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'RMASSIGN-001', + 'name' => 'Removable Assignment Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $assignment = $this->service->assignProduct($this->m2, $product); + $assignmentId = $assignment->id; + + $result = $this->service->removeAssignment($assignment); + + expect($result)->toBeTrue() + ->and(ProductAssignment::find($assignmentId))->toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Bulk operations +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → bulkAssign', function () { + it('assigns multiple products to an entity at once', function () { + $products = collect(['BULK-A', 'BULK-B', 'BULK-C'])->map( + fn (string $sku) => $this->service->createProduct($this->m1, [ + 'sku' => $sku, + 'name' => "Bulk Product {$sku}", + 'price' => 1000, + 'currency' => 'GBP', + ]) + ); + + $count = $this->service->bulkAssign( + $this->m2, + $products->pluck('id')->toArray() + ); + + expect($count)->toBe(3); + + $assignments = ProductAssignment::where('entity_id', $this->m2->id)->count(); + expect($assignments)->toBe(3); + }); + + it('applies default overrides to all bulk assignments', function () { + $products = collect(['BULKD-A', 'BULKD-B'])->map( + fn (string $sku) => $this->service->createProduct($this->m1, [ + 'sku' => $sku, + 'name' => "Bulk Default {$sku}", + 'price' => 1000, + 'currency' => 'GBP', + ]) + ); + + $this->service->bulkAssign( + $this->m2, + $products->pluck('id')->toArray(), + ['is_featured' => true] + ); + + $featured = ProductAssignment::where('entity_id', $this->m2->id) + ->where('is_featured', true) + ->count(); + + expect($featured)->toBe(2); + }); + + it('skips non-existent product IDs without error', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'BULK-REAL', + 'name' => 'Real Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $count = $this->service->bulkAssign( + $this->m2, + [$product->id, 99999, 88888] + ); + + expect($count)->toBe(1); + }); +}); + +describe('ProductCatalogService → copyAssignments', function () { + it('copies all assignments from source to target entity', function () { + $productA = $this->service->createProduct($this->m1, [ + 'sku' => 'COPY-A', + 'name' => 'Copy Product A', + 'price' => 1000, + 'currency' => 'GBP', + ]); + $productB = $this->service->createProduct($this->m1, [ + 'sku' => 'COPY-B', + 'name' => 'Copy Product B', + 'price' => 2000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $productA, [ + 'price_override' => 1200, + 'name_override' => 'Custom A', + ]); + $this->service->assignProduct($this->m2, $productB, [ + 'is_featured' => true, + ]); + + $m3b = $this->m2->createDropshipper('DRP2', 'Dropship Partner 2'); + $count = $this->service->copyAssignments($this->m2, $m3b); + + expect($count)->toBe(2); + + $targetAssignments = ProductAssignment::where('entity_id', $m3b->id)->get(); + expect($targetAssignments)->toHaveCount(2); + }); + + it('includes overrides when flag is true', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'COPYOV-A', + 'name' => 'Copy Override Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 1500, + 'name_override' => 'Overridden Name', + 'is_featured' => true, + ]); + + $m3b = $this->m2->createDropshipper('DRPC', 'Dropship Copy'); + $this->service->copyAssignments($this->m2, $m3b, includeOverrides: true); + + $copied = ProductAssignment::where('entity_id', $m3b->id) + ->where('product_id', $product->id) + ->first(); + + expect($copied->price_override)->toBe(1500) + ->and($copied->name_override)->toBe('Overridden Name') + ->and($copied->is_featured)->toBeTrue(); + }); + + it('excludes overrides when flag is false', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'COPYNO-A', + 'name' => 'Copy No Override Product', + 'price' => 1000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 1500, + 'name_override' => 'Overridden Name', + ]); + + $m3b = $this->m2->createDropshipper('DRPN', 'Dropship No Overrides'); + $this->service->copyAssignments($this->m2, $m3b, includeOverrides: false); + + $copied = ProductAssignment::where('entity_id', $m3b->id) + ->where('product_id', $product->id) + ->first(); + + expect($copied->price_override)->toBeNull() + ->and($copied->name_override)->toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Category and catalogue queries +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → getProductsForEntity', function () { + it('returns owned products for M1', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'CAT-A', + 'name' => 'Product A', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'CAT-B', + 'name' => 'Product B', + 'price' => 2000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $products = $this->service->getProductsForEntity($this->m1); + + expect($products)->toHaveCount(2); + }); + + it('excludes inactive products for M1 when activeOnly is true', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'ACT-A', + 'name' => 'Active Product', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'ACT-B', + 'name' => 'Inactive Product', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => false, + 'is_visible' => true, + ]); + + $products = $this->service->getProductsForEntity($this->m1, activeOnly: true); + + expect($products)->toHaveCount(1) + ->and($products->first()->name)->toBe('Active Product'); + }); + + it('includes inactive products when activeOnly is false', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'ALL-A', + 'name' => 'Active', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'ALL-B', + 'name' => 'Inactive', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => false, + 'is_visible' => true, + ]); + + $products = $this->service->getProductsForEntity($this->m1, activeOnly: false); + + expect($products)->toHaveCount(2); + }); + + it('returns assigned products for M2', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'M2-A', + 'name' => 'M2 Visible Product', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $this->service->assignProduct($this->m2, $product, ['is_active' => true]); + + $assignments = $this->service->getProductsForEntity($this->m2); + + expect($assignments)->toHaveCount(1); + }); +}); + +describe('ProductCatalogService → getByCategory', function () { + it('returns M1 products filtered by category', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'ELEC-001', + 'name' => 'Laptop', + 'price' => 99900, + 'currency' => 'GBP', + 'category' => 'electronics', + 'is_active' => true, + 'is_visible' => true, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'FURN-001', + 'name' => 'Desk', + 'price' => 29900, + 'currency' => 'GBP', + 'category' => 'furniture', + 'is_active' => true, + 'is_visible' => true, + ]); + + $electronics = $this->service->getByCategory($this->m1, 'electronics'); + + expect($electronics)->toHaveCount(1) + ->and($electronics->first()->name)->toBe('Laptop'); + }); + + it('returns M2 assigned products filtered by category', function () { + $laptop = $this->service->createProduct($this->m1, [ + 'sku' => 'ELEC-002', + 'name' => 'Tablet', + 'price' => 49900, + 'currency' => 'GBP', + 'category' => 'electronics', + 'is_active' => true, + 'is_visible' => true, + ]); + $desk = $this->service->createProduct($this->m1, [ + 'sku' => 'FURN-002', + 'name' => 'Chair', + 'price' => 19900, + 'currency' => 'GBP', + 'category' => 'furniture', + 'is_active' => true, + 'is_visible' => true, + ]); + + $this->service->assignProduct($this->m2, $laptop, ['is_active' => true]); + $this->service->assignProduct($this->m2, $desk, ['is_active' => true]); + + $electronics = $this->service->getByCategory($this->m2, 'electronics'); + + expect($electronics)->toHaveCount(1); + }); + + it('returns empty collection when no products match category', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'NOCAT-001', + 'name' => 'Widget', + 'price' => 500, + 'currency' => 'GBP', + 'category' => 'widgets', + 'is_active' => true, + 'is_visible' => true, + ]); + + $result = $this->service->getByCategory($this->m1, 'nonexistent'); + + expect($result)->toHaveCount(0); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Search +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → searchProducts', function () { + it('finds products by name', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'SRCH-001', + 'name' => 'Wireless Headphones', + 'price' => 7999, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'SRCH-002', + 'name' => 'Wired Mouse', + 'price' => 1999, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $results = $this->service->searchProducts($this->m1, 'Wireless'); + + expect($results)->toHaveCount(1) + ->and($results->first()->name)->toBe('Wireless Headphones'); + }); + + it('finds products by SKU', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'SRCH-ABC', + 'name' => 'SKU Search Product', + 'price' => 500, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $results = $this->service->searchProducts($this->m1, 'SRCH-ABC'); + + expect($results)->toHaveCount(1) + ->and($results->first()->sku)->toBe('SRCH-ABC'); + }); + + it('respects the limit parameter', function () { + for ($i = 1; $i <= 5; $i++) { + $this->service->createProduct($this->m1, [ + 'sku' => "LIM-{$i}", + 'name' => "Limited Product {$i}", + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + } + + $results = $this->service->searchProducts($this->m1, 'Limited', limit: 3); + + expect($results)->toHaveCount(3); + }); + + it('returns empty collection for non-matching query', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'SRCH-NOM', + 'name' => 'Real Product', + 'price' => 500, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $results = $this->service->searchProducts($this->m1, 'xyznonexistent'); + + expect($results)->toHaveCount(0); + }); + + it('only returns products owned by the given entity', function () { + $otherM1 = Entity::createMaster('OTHER', 'Other Corp'); + + $this->service->createProduct($this->m1, [ + 'sku' => 'MINE-001', + 'name' => 'Shared Name Product', + 'price' => 500, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + $this->service->createProduct($otherM1, [ + 'sku' => 'THEIRS-001', + 'name' => 'Shared Name Product', + 'price' => 500, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $results = $this->service->searchProducts($this->m1, 'Shared Name'); + + expect($results)->toHaveCount(1) + ->and($results->first()->sku)->toBe('MINE-001'); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Featured products +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → getFeaturedProducts', function () { + it('returns featured products for M1', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'FEAT-A', + 'name' => 'Featured Product', + 'price' => 2000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + 'is_featured' => true, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'FEAT-B', + 'name' => 'Normal Product', + 'price' => 1500, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + 'is_featured' => false, + ]); + + $featured = $this->service->getFeaturedProducts($this->m1); + + expect($featured)->toHaveCount(1) + ->and($featured->first()->name)->toBe('Featured Product'); + }); + + it('returns featured assignments for M2', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'FEAT-M2A', + 'name' => 'Featured M2 Product', + 'price' => 2000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + ]); + + $this->service->assignProduct($this->m2, $product, [ + 'is_active' => true, + 'is_featured' => true, + ]); + + $featured = $this->service->getFeaturedProducts($this->m2); + + expect($featured)->toHaveCount(1); + }); + + it('respects limit parameter', function () { + for ($i = 1; $i <= 5; $i++) { + $this->service->createProduct($this->m1, [ + 'sku' => "FLIM-{$i}", + 'name' => "Featured Limited {$i}", + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_visible' => true, + 'is_featured' => true, + ]); + } + + $featured = $this->service->getFeaturedProducts($this->m1, limit: 3); + + expect($featured)->toHaveCount(3); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Product statistics +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → getProductStats', function () { + it('returns correct stats for M1 entity', function () { + $this->service->createProduct($this->m1, [ + 'sku' => 'STAT-A', + 'name' => 'Active Featured In-Stock', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_featured' => true, + 'track_stock' => true, + 'stock_quantity' => 50, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'STAT-B', + 'name' => 'Active Not-Featured In-Stock', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => true, + 'is_featured' => false, + 'track_stock' => true, + 'stock_quantity' => 10, + ]); + $this->service->createProduct($this->m1, [ + 'sku' => 'STAT-C', + 'name' => 'Inactive Out-of-Stock', + 'price' => 1000, + 'currency' => 'GBP', + 'is_active' => false, + 'track_stock' => true, + 'stock_quantity' => 0, + 'stock_status' => Product::STOCK_OUT, + ]); + + $stats = $this->service->getProductStats($this->m1); + + expect($stats['total'])->toBe(3) + ->and($stats['active'])->toBe(2) + ->and($stats['featured'])->toBe(1) + ->and($stats['out_of_stock'])->toBe(1); + }); + + it('returns correct stats for M2 entity', function () { + $productA = $this->service->createProduct($this->m1, [ + 'sku' => 'STAM2-A', + 'name' => 'M2 Stat Product A', + 'price' => 1000, + 'currency' => 'GBP', + ]); + $productB = $this->service->createProduct($this->m1, [ + 'sku' => 'STAM2-B', + 'name' => 'M2 Stat Product B', + 'price' => 2000, + 'currency' => 'GBP', + ]); + + $this->service->assignProduct($this->m2, $productA, [ + 'is_active' => true, + 'is_featured' => true, + ]); + $this->service->assignProduct($this->m2, $productB, [ + 'is_active' => false, + 'is_featured' => false, + ]); + + $stats = $this->service->getProductStats($this->m2); + + expect($stats['total'])->toBe(2) + ->and($stats['active'])->toBe(1) + ->and($stats['featured'])->toBe(1); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Effective product data +// ────────────────────────────────────────────────────────────────── + +describe('ProductCatalogService → getEffectiveProduct', function () { + it('returns full effective data for M1 entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'EFF-001', + 'name' => 'Effective Product', + 'description' => 'A product with all fields.', + 'price' => 4500, + 'currency' => 'GBP', + 'image_url' => 'https://example.com/image.jpg', + 'track_stock' => true, + 'stock_quantity' => 25, + ]); + + $effective = $this->service->getEffectiveProduct($this->m1, $product); + + expect($effective)->toBeArray() + ->and($effective['product']->id)->toBe($product->id) + ->and($effective['assignment'])->toBeNull() + ->and($effective['price'])->toBe(4500) + ->and($effective['name'])->toBe('Effective Product') + ->and($effective['description'])->toBe('A product with all fields.') + ->and($effective['image'])->toBe('https://example.com/image.jpg') + ->and($effective['available_stock'])->toBe(25); + }); + + it('returns effective data with overrides for M2 entity', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'EFF-002', + 'name' => 'Master Product', + 'description' => 'Master description.', + 'price' => 3000, + 'currency' => 'GBP', + 'image_url' => 'https://example.com/master.jpg', + 'track_stock' => true, + 'stock_quantity' => 100, + ]); + + $this->service->assignProduct($this->m2, $product, [ + 'price_override' => 3500, + 'name_override' => 'Shop Product', + 'description_override' => 'Shop description.', + 'image_override' => 'https://example.com/shop.jpg', + 'allocated_stock' => 20, + ]); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective)->toBeArray() + ->and($effective['product']->id)->toBe($product->id) + ->and($effective['assignment'])->not->toBeNull() + ->and($effective['price'])->toBe(3500) + ->and($effective['name'])->toBe('Shop Product') + ->and($effective['description'])->toBe('Shop description.') + ->and($effective['image'])->toBe('https://example.com/shop.jpg') + ->and($effective['available_stock'])->toBe(20); + }); + + it('falls back to master values when no overrides set', function () { + $product = $this->service->createProduct($this->m1, [ + 'sku' => 'EFF-003', + 'name' => 'Fallback Product', + 'description' => 'Fallback description.', + 'price' => 2000, + 'currency' => 'GBP', + 'image_url' => 'https://example.com/fallback.jpg', + 'track_stock' => true, + 'stock_quantity' => 50, + ]); + + $this->service->assignProduct($this->m2, $product); + + $effective = $this->service->getEffectiveProduct($this->m2, $product); + + expect($effective['price'])->toBe(2000) + ->and($effective['name'])->toBe('Fallback Product') + ->and($effective['description'])->toBe('Fallback description.') + ->and($effective['image'])->toBe('https://example.com/fallback.jpg') + ->and($effective['available_stock'])->toBe(50); + }); +}); 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); + }); + }); +});