Updates all references from Core\Mod\Tenant to Core\Tenant following the monorepo separation. The Tenant module now lives in its own package with the simplified namespace. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
361 lines
12 KiB
PHP
361 lines
12 KiB
PHP
<?php
|
|
|
|
use Core\Mod\Commerce\Models\Coupon;
|
|
use Core\Mod\Commerce\Models\CouponUsage;
|
|
use Core\Mod\Commerce\Models\Order;
|
|
use Core\Mod\Commerce\Services\CouponService;
|
|
use Core\Tenant\Models\Package;
|
|
use Core\Tenant\Models\User;
|
|
use Core\Tenant\Models\Workspace;
|
|
|
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->user = User::factory()->create();
|
|
$this->workspace = Workspace::factory()->create();
|
|
$this->workspace->users()->attach($this->user->id, [
|
|
'role' => 'owner',
|
|
'is_default' => true,
|
|
]);
|
|
|
|
// Use existing seeded package
|
|
$this->package = Package::where('code', 'creator')->first();
|
|
|
|
// Create test coupons
|
|
$this->percentCoupon = Coupon::create([
|
|
'code' => 'SAVE20',
|
|
'name' => '20% Off',
|
|
'type' => 'percentage',
|
|
'value' => 20.00,
|
|
'applies_to' => 'all',
|
|
'is_active' => true,
|
|
'max_uses' => 100,
|
|
'max_uses_per_workspace' => 1,
|
|
'used_count' => 0,
|
|
]);
|
|
|
|
$this->fixedCoupon = Coupon::create([
|
|
'code' => 'FLAT10',
|
|
'name' => '£10 Off',
|
|
'type' => 'fixed_amount',
|
|
'value' => 10.00,
|
|
'applies_to' => 'all',
|
|
'is_active' => true,
|
|
'max_uses_per_workspace' => 1,
|
|
]);
|
|
|
|
$this->service = app(CouponService::class);
|
|
});
|
|
|
|
describe('CouponService', function () {
|
|
describe('findByCode() method', function () {
|
|
it('finds coupon by code (case insensitive)', function () {
|
|
$coupon = $this->service->findByCode('save20');
|
|
|
|
expect($coupon)->not->toBeNull()
|
|
->and($coupon->code)->toBe('SAVE20');
|
|
});
|
|
|
|
it('returns null for non-existent code', function () {
|
|
$coupon = $this->service->findByCode('NOTREAL');
|
|
|
|
expect($coupon)->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('validate() method', function () {
|
|
it('validates active coupon', function () {
|
|
$result = $this->service->validate(
|
|
$this->percentCoupon,
|
|
$this->workspace,
|
|
$this->package
|
|
);
|
|
|
|
expect($result->isValid())->toBeTrue();
|
|
});
|
|
|
|
it('rejects inactive coupon', function () {
|
|
$this->percentCoupon->update(['is_active' => false]);
|
|
|
|
$result = $this->service->validate(
|
|
$this->percentCoupon,
|
|
$this->workspace,
|
|
$this->package
|
|
);
|
|
|
|
expect($result->isValid())->toBeFalse();
|
|
});
|
|
|
|
it('rejects expired coupon', function () {
|
|
$this->percentCoupon->update(['valid_until' => now()->subDay()]);
|
|
|
|
$result = $this->service->validate(
|
|
$this->percentCoupon,
|
|
$this->workspace,
|
|
$this->package
|
|
);
|
|
|
|
expect($result->isValid())->toBeFalse();
|
|
});
|
|
|
|
it('rejects coupon before start date', function () {
|
|
$this->percentCoupon->update(['valid_from' => now()->addDay()]);
|
|
|
|
$result = $this->service->validate(
|
|
$this->percentCoupon,
|
|
$this->workspace,
|
|
$this->package
|
|
);
|
|
|
|
expect($result->isValid())->toBeFalse();
|
|
});
|
|
|
|
it('rejects coupon that has reached max uses', function () {
|
|
$this->percentCoupon->update([
|
|
'max_uses' => 5,
|
|
'used_count' => 5,
|
|
]);
|
|
|
|
$result = $this->service->validate(
|
|
$this->percentCoupon,
|
|
$this->workspace,
|
|
$this->package
|
|
);
|
|
|
|
expect($result->isValid())->toBeFalse();
|
|
});
|
|
|
|
it('rejects coupon already used by workspace', function () {
|
|
// Need an order for coupon usage
|
|
$order = Order::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'order_number' => 'ORD-001',
|
|
'status' => 'paid',
|
|
'subtotal' => 19.00,
|
|
'total' => 15.20,
|
|
'currency' => 'GBP',
|
|
]);
|
|
|
|
CouponUsage::create([
|
|
'coupon_id' => $this->percentCoupon->id,
|
|
'workspace_id' => $this->workspace->id,
|
|
'order_id' => $order->id,
|
|
'discount_amount' => 3.80,
|
|
]);
|
|
|
|
$result = $this->service->validate(
|
|
$this->percentCoupon,
|
|
$this->workspace,
|
|
$this->package
|
|
);
|
|
|
|
expect($result->isValid())->toBeFalse();
|
|
});
|
|
|
|
it('validates coupon restricted to specific packages', function () {
|
|
// Set applies_to to 'packages' and provide package IDs
|
|
$this->percentCoupon->update([
|
|
'applies_to' => 'packages',
|
|
'package_ids' => [$this->package->id],
|
|
]);
|
|
|
|
$result = $this->service->validate(
|
|
$this->percentCoupon,
|
|
$this->workspace,
|
|
$this->package
|
|
);
|
|
|
|
expect($result->isValid())->toBeTrue();
|
|
|
|
// Use existing seeded agency package
|
|
$otherPackage = Package::where('code', 'agency')->first();
|
|
|
|
$result = $this->service->validate(
|
|
$this->percentCoupon,
|
|
$this->workspace,
|
|
$otherPackage
|
|
);
|
|
|
|
expect($result->isValid())->toBeFalse();
|
|
});
|
|
|
|
it('validates minimum purchase amount', function () {
|
|
$this->fixedCoupon->update(['min_amount' => 50.00]);
|
|
|
|
// When min_amount is set, the validation in CouponService doesn't check it
|
|
// The calculateDiscount method returns 0 for amounts below min_amount
|
|
// So this test should check the discount calculation instead
|
|
$discount = $this->fixedCoupon->calculateDiscount(19.00);
|
|
|
|
expect($discount)->toBe(0.0);
|
|
});
|
|
});
|
|
|
|
describe('recordUsage() method', function () {
|
|
it('records coupon usage', function () {
|
|
$order = Order::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'order_number' => 'ORD-001',
|
|
'status' => 'paid',
|
|
'subtotal' => 19.00,
|
|
'total' => 15.20,
|
|
'currency' => 'GBP',
|
|
]);
|
|
|
|
$usage = $this->service->recordUsage(
|
|
$this->percentCoupon,
|
|
$this->workspace,
|
|
$order,
|
|
3.80
|
|
);
|
|
|
|
expect($usage)->toBeInstanceOf(CouponUsage::class)
|
|
->and($usage->coupon_id)->toBe($this->percentCoupon->id)
|
|
->and($usage->workspace_id)->toBe($this->workspace->id)
|
|
->and($usage->order_id)->toBe($order->id)
|
|
->and((float) $usage->discount_amount)->toBe(3.80);
|
|
|
|
// Check used_count was incremented
|
|
$this->percentCoupon->refresh();
|
|
expect($this->percentCoupon->used_count)->toBe(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Coupon model', function () {
|
|
describe('calculateDiscount() method', function () {
|
|
it('calculates percentage discount', function () {
|
|
$discount = $this->percentCoupon->calculateDiscount(100.00);
|
|
|
|
expect($discount)->toBe(20.00);
|
|
});
|
|
|
|
it('calculates fixed discount', function () {
|
|
$discount = $this->fixedCoupon->calculateDiscount(100.00);
|
|
|
|
expect($discount)->toBe(10.00);
|
|
});
|
|
|
|
it('caps fixed discount at subtotal', function () {
|
|
$discount = $this->fixedCoupon->calculateDiscount(5.00);
|
|
|
|
expect($discount)->toBe(5.00); // Can't discount more than subtotal
|
|
});
|
|
|
|
it('respects max discount amount', function () {
|
|
$this->percentCoupon->update(['max_discount' => 15.00]);
|
|
|
|
$discount = $this->percentCoupon->calculateDiscount(100.00);
|
|
|
|
expect($discount)->toBe(15.00); // Capped at max
|
|
});
|
|
});
|
|
|
|
describe('isValid() method', function () {
|
|
it('returns true for valid coupon', function () {
|
|
expect($this->percentCoupon->isValid())->toBeTrue();
|
|
});
|
|
|
|
it('returns false for inactive coupon', function () {
|
|
$this->percentCoupon->update(['is_active' => false]);
|
|
|
|
expect($this->percentCoupon->isValid())->toBeFalse();
|
|
});
|
|
|
|
it('returns false for expired coupon', function () {
|
|
$this->percentCoupon->update(['valid_until' => now()->subHour()]);
|
|
|
|
expect($this->percentCoupon->isValid())->toBeFalse();
|
|
});
|
|
|
|
it('returns true within date range', function () {
|
|
$this->percentCoupon->update([
|
|
'valid_from' => now()->subDay(),
|
|
'valid_until' => now()->addDay(),
|
|
]);
|
|
|
|
expect($this->percentCoupon->isValid())->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('hasReachedMaxUses() method', function () {
|
|
it('returns false when under limit', function () {
|
|
$this->percentCoupon->update([
|
|
'max_uses' => 100,
|
|
'used_count' => 50,
|
|
]);
|
|
|
|
expect($this->percentCoupon->hasReachedMaxUses())->toBeFalse();
|
|
});
|
|
|
|
it('returns true when at limit', function () {
|
|
$this->percentCoupon->update([
|
|
'max_uses' => 100,
|
|
'used_count' => 100,
|
|
]);
|
|
|
|
expect($this->percentCoupon->hasReachedMaxUses())->toBeTrue();
|
|
});
|
|
|
|
it('returns false when no limit set', function () {
|
|
$this->percentCoupon->update([
|
|
'max_uses' => null,
|
|
'used_count' => 1000,
|
|
]);
|
|
|
|
expect($this->percentCoupon->hasReachedMaxUses())->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('isRestrictedToPackage() method', function () {
|
|
it('returns false when no package restrictions', function () {
|
|
expect($this->percentCoupon->isRestrictedToPackage('creator'))->toBeFalse();
|
|
});
|
|
|
|
it('returns true for allowed package', function () {
|
|
$this->percentCoupon->update(['package_ids' => ['creator', 'agency']]);
|
|
|
|
expect($this->percentCoupon->isRestrictedToPackage('creator'))->toBeTrue()
|
|
->and($this->percentCoupon->isRestrictedToPackage('agency'))->toBeTrue();
|
|
});
|
|
|
|
it('returns false for restricted package', function () {
|
|
$this->percentCoupon->update(['package_ids' => ['creator']]);
|
|
|
|
expect($this->percentCoupon->isRestrictedToPackage('agency'))->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('scopes', function () {
|
|
it('scopes to active coupons', function () {
|
|
Coupon::create([
|
|
'code' => 'INACTIVE',
|
|
'name' => 'Inactive',
|
|
'type' => 'percentage',
|
|
'value' => 10.00,
|
|
'is_active' => false,
|
|
]);
|
|
|
|
$active = Coupon::active()->get();
|
|
|
|
expect($active->pluck('code')->toArray())->toContain('SAVE20', 'FLAT10')
|
|
->and($active->pluck('code')->toArray())->not->toContain('INACTIVE');
|
|
});
|
|
|
|
it('scopes to valid coupons', function () {
|
|
Coupon::create([
|
|
'code' => 'EXPIRED',
|
|
'name' => 'Expired',
|
|
'type' => 'percentage',
|
|
'value' => 10.00,
|
|
'is_active' => true,
|
|
'valid_until' => now()->subDay(),
|
|
]);
|
|
|
|
$valid = Coupon::valid()->get();
|
|
|
|
expect($valid->pluck('code')->toArray())->toContain('SAVE20', 'FLAT10')
|
|
->and($valid->pluck('code')->toArray())->not->toContain('EXPIRED');
|
|
});
|
|
});
|
|
});
|