From cd16c7474e34743a3f33654eb5d3230bfcc055f4 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 04:41:43 +0100 Subject: [PATCH] feat(commerce): implement CouponService with 5 methods + DTOs (#858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create(code, type, value, maxUses, expiresAt) → Coupon - validate(code, order) → ValidationResult - apply(coupon, order) → Order (mutates line-item totals) - expire(coupon) → void - report() → array of redemption stats Data/Coupon.php and Data/ValidationResult.php as readonly DTOs. Pest unit tests with _Good/_Bad/_Ugly per AX-10 for all 5 methods. pint/pest skipped (vendor binaries missing in sandbox). Legacy helpers in CouponService preserved. Co-authored-by: Codex Closes tasks.lthn.sh/view.php?id=858 --- Data/Coupon.php | 82 +++ Data/ValidationResult.php | 82 +++ Services/CouponService.php | 598 ++++++++++++++++++++-- tests/Unit/Services/CouponServiceTest.php | 295 +++++++++++ 4 files changed, 1003 insertions(+), 54 deletions(-) create mode 100644 Data/Coupon.php create mode 100644 Data/ValidationResult.php create mode 100644 tests/Unit/Services/CouponServiceTest.php diff --git a/Data/Coupon.php b/Data/Coupon.php new file mode 100644 index 0000000..90b160c --- /dev/null +++ b/Data/Coupon.php @@ -0,0 +1,82 @@ +id, + code: (string) $coupon->code, + type: in_array((string) $coupon->type, ['percent', 'percentage'], true) ? 'percent' : 'fixed', + value: (float) $coupon->value, + maxUses: $coupon->max_uses === null ? null : (int) $coupon->max_uses, + expiresAt: self::immutableDate($coupon->valid_until), + active: (bool) $coupon->is_active, + usedCount: (int) $coupon->used_count, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'code' => $this->code, + 'type' => $this->type, + 'value' => $this->value, + 'max_uses' => $this->maxUses, + 'expires_at' => $this->expiresAt?->toIso8601String(), + 'active' => $this->active, + 'used_count' => $this->usedCount, + ]; + } + + public function isExpired(): bool + { + return $this->expiresAt?->isPast() ?? false; + } + + public function __get(string $name): mixed + { + return match ($name) { + 'max_uses' => $this->maxUses, + 'expires_at' => $this->expiresAt, + 'is_active' => $this->active, + 'used_count' => $this->usedCount, + default => null, + }; + } + + private static function immutableDate(mixed $value): ?CarbonImmutable + { + if (! $value instanceof CarbonInterface) { + return null; + } + + return CarbonImmutable::instance($value->toDateTime()); + } +} diff --git a/Data/ValidationResult.php b/Data/ValidationResult.php new file mode 100644 index 0000000..93f921c --- /dev/null +++ b/Data/ValidationResult.php @@ -0,0 +1,82 @@ +valid; + } + + public function getMessage(): ?string + { + return $this->reason; + } + + public function getCoupon(): ?Coupon + { + return $this->coupon; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'valid' => $this->valid, + 'reason' => $this->reason, + 'discount_amount' => $this->discountAmount, + 'discount_type' => $this->discountType, + 'coupon' => $this->coupon?->toArray(), + ]; + } + + public function __get(string $name): mixed + { + return match ($name) { + 'discount_amount' => $this->discountAmount, + 'discount_type' => $this->discountType, + default => null, + }; + } +} diff --git a/Services/CouponService.php b/Services/CouponService.php index 45f3ecc..086083d 100644 --- a/Services/CouponService.php +++ b/Services/CouponService.php @@ -4,15 +4,23 @@ declare(strict_types=1); namespace Core\Mod\Commerce\Services; +use Carbon\Carbon; +use Carbon\CarbonInterface; use Core\Mod\Commerce\Contracts\Orderable; +use Core\Mod\Commerce\Data\Coupon as CouponData; use Core\Mod\Commerce\Data\CouponValidationResult; -use Core\Mod\Commerce\Models\Coupon; +use Core\Mod\Commerce\Data\ValidationResult; +use Core\Mod\Commerce\Models\Coupon as CouponModel; use Core\Mod\Commerce\Models\CouponUsage; use Core\Mod\Commerce\Models\Order; +use Core\Mod\Commerce\Models\OrderItem; use Core\Tenant\Models\Package; use Core\Tenant\Models\Workspace; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; +use InvalidArgumentException; +use RuntimeException; /** * Coupon validation and application service. @@ -45,7 +53,7 @@ class CouponService * * Sanitises the code before querying to prevent abuse. */ - public function findByCode(string $code): ?Coupon + public function findByCode(string $code): ?CouponModel { $sanitised = $this->sanitiseCode($code); @@ -53,7 +61,7 @@ class CouponService return null; } - return Coupon::byCode($sanitised)->first(); + return CouponModel::byCode($sanitised)->first(); } /** @@ -70,16 +78,13 @@ class CouponService */ public function sanitiseCode(string $code): ?string { - // Trim whitespace and convert to uppercase $sanitised = strtoupper(trim($code)); - // Check length constraints $length = strlen($sanitised); if ($length < self::MIN_CODE_LENGTH || $length > self::MAX_CODE_LENGTH) { return null; } - // Validate allowed characters (alphanumeric, hyphens, underscores only) if (! preg_match(self::VALID_CODE_PATTERN, $sanitised)) { return null; } @@ -98,26 +103,209 @@ class CouponService } /** - * Validate a coupon for a workspace and package. + * Create a persisted coupon. + * + * The scalar signature is the RFC API and returns a DTO. The array form is + * retained for older module code that passes Eloquent attributes directly. + * + * @param string|array $code */ - public function validate(Coupon $coupon, Workspace $workspace, ?Package $package = null): CouponValidationResult + public function create( + string|array $code, + ?string $type = null, + float|int|null $value = null, + ?int $maxUses = null, + CarbonInterface|string|null $expiresAt = null, + ): CouponData|CouponModel { + if (is_array($code)) { + return $this->createModel($code); + } + + if ($type === null || $value === null) { + throw new InvalidArgumentException('Coupon type and value are required.'); + } + + $coupon = $this->createModel([ + 'code' => $code, + 'name' => $code, + 'type' => $type, + 'value' => $value, + 'max_uses' => $maxUses, + 'max_uses_per_workspace' => 1, + 'duration' => 'once', + 'valid_until' => $this->parseExpiresAt($expiresAt), + 'is_active' => true, + 'applies_to' => 'all', + 'used_count' => 0, + ]); + + return CouponData::fromModel($coupon); + } + + /** + * Validate a coupon by code for an order, or use the legacy model/workspace flow. + */ + public function validate( + string|CouponModel $code, + Order|Workspace $order, + ?Package $package = null, + ): ValidationResult|CouponValidationResult { + if ($code instanceof CouponModel) { + if (! $order instanceof Workspace) { + throw new InvalidArgumentException('Legacy coupon validation requires a workspace.'); + } + + return $this->validateLegacy($code, $order, $package); + } + + if (! $order instanceof Order) { + throw new InvalidArgumentException('Coupon code validation requires an order.'); + } + + $sanitised = $this->sanitiseCode($code); + + if ($sanitised === null) { + return ValidationResult::invalid('Invalid coupon code format'); + } + + $coupon = CouponModel::byCode($sanitised)->first(); + + if (! $coupon) { + return ValidationResult::invalid('Coupon not found'); + } + + return $this->validateCouponForOrder($coupon, $order); + } + + /** + * Apply a coupon to an order by mutating eligible line-item totals. + */ + public function apply(CouponData|CouponModel $coupon, Order $order): Order { - // Check if coupon is valid (active, within dates, not maxed out) - if (! $coupon->isValid()) { - return CouponValidationResult::invalid('This coupon is no longer valid'); + $couponModel = $this->resolveCouponModel($coupon); + + if (! $order->exists) { + throw new InvalidArgumentException('Coupon application requires a persisted order.'); } - // Check workspace usage limit - if (! $coupon->canBeUsedByWorkspace($workspace->id)) { - return CouponValidationResult::invalid('You have already used this coupon'); - } + return DB::transaction(function () use ($couponModel, $order): Order { + /** @var Order $lockedOrder */ + $lockedOrder = Order::query() + ->with('items') + ->lockForUpdate() + ->findOrFail($order->id); - // Check if coupon applies to the package - if ($package && ! $coupon->appliesToPackage($package->id)) { - return CouponValidationResult::invalid('This coupon does not apply to the selected plan'); - } + if ($this->hasAppliedCoupon($couponModel, $lockedOrder)) { + return $lockedOrder->load('items', 'coupon'); + } - return CouponValidationResult::valid($coupon); + if ($lockedOrder->coupon_id && (int) $lockedOrder->coupon_id !== (int) $couponModel->id) { + throw new RuntimeException('Order already has a different coupon applied.'); + } + + $result = $this->validateCouponForOrder($couponModel, $lockedOrder); + + if (! $result->valid) { + throw new RuntimeException($result->reason ?? 'Coupon is not valid for this order.'); + } + + $eligibleItems = $this->eligibleItems($couponModel, $lockedOrder); + $discounts = $this->allocateDiscount($couponModel, $eligibleItems, $result->discountAmount); + + foreach ($eligibleItems as $item) { + $baseLineTotal = $this->lineBaseTotal($item); + $lineDiscount = $discounts[(int) $item->id] ?? 0.0; + $metadata = $item->metadata ?? []; + + $item->forceFill([ + 'line_total' => round(max(0.0, $baseLineTotal - $lineDiscount), 2), + 'metadata' => array_merge($metadata, [ + 'original_line_total' => $baseLineTotal, + 'coupon_id' => $couponModel->id, + 'coupon_code' => $couponModel->code, + 'coupon_discount_amount' => round($lineDiscount, 2), + ]), + ])->save(); + } + + $lockedOrder->load('items'); + + $subtotal = round((float) $lockedOrder->items->sum( + fn (OrderItem $item): float => $this->lineBaseTotal($item) + ), 2); + $lineTotal = round((float) $lockedOrder->items->sum( + fn (OrderItem $item): float => (float) $item->line_total + ), 2); + $discountAmount = round(max(0.0, $subtotal - $lineTotal), 2); + $taxAmount = (float) ($lockedOrder->tax_amount ?? 0); + + $lockedOrder->forceFill([ + 'subtotal' => $subtotal, + 'discount_amount' => $discountAmount, + 'total' => round($lineTotal + $taxAmount, 2), + 'coupon_id' => $couponModel->id, + ])->save(); + + $this->recordOrderUsage($couponModel, $lockedOrder, $discountAmount); + + return $lockedOrder->load('items', 'coupon'); + }); + } + + /** + * Expire a coupon immediately. + */ + public function expire(CouponData|CouponModel $coupon): void + { + $couponModel = $this->resolveCouponModel($coupon); + + $couponModel->forceFill([ + 'is_active' => false, + 'valid_until' => Carbon::now(), + ])->save(); + } + + /** + * Return redemption statistics for all coupons. + * + * @return array + */ + public function report(): array + { + $now = Carbon::now(); + $couponRows = CouponModel::query() + ->withCount('usages') + ->withSum('usages as discount_total', 'discount_amount') + ->orderByDesc('usages_count') + ->orderBy('code') + ->get(); + + return [ + 'total_coupons' => CouponModel::query()->count(), + 'active_coupons' => CouponModel::query()->where('is_active', true)->count(), + 'expired_coupons' => CouponModel::query() + ->whereNotNull('valid_until') + ->where('valid_until', '<', $now) + ->count(), + 'total_redemptions' => CouponUsage::query()->count(), + 'total_discount_amount' => round((float) CouponUsage::query()->sum('discount_amount'), 2), + 'by_coupon' => $couponRows->map(function (CouponModel $coupon): array { + $redemptions = (int) ($coupon->getAttribute('usages_count') ?? 0); + + return [ + 'id' => $coupon->id, + 'code' => $coupon->code, + 'type' => $this->discountType($coupon), + 'value' => (float) $coupon->value, + 'active' => (bool) $coupon->is_active, + 'max_uses' => $coupon->max_uses, + 'used_count' => max((int) $coupon->used_count, $redemptions), + 'redemptions' => $redemptions, + 'discount_total' => round((float) ($coupon->getAttribute('discount_total') ?? 0), 2), + 'expires_at' => $coupon->valid_until?->toIso8601String(), + ]; + })->values()->all(), + ]; } /** @@ -125,19 +313,16 @@ class CouponService * * Returns boolean for use in CommerceService order creation. */ - public function validateForOrderable(Coupon $coupon, Orderable&Model $orderable, ?Package $package = null): bool + public function validateForOrderable(CouponModel $coupon, Orderable&Model $orderable, ?Package $package = null): bool { - // Check if coupon is valid (active, within dates, not maxed out) if (! $coupon->isValid()) { return false; } - // Check orderable usage limit if (! $coupon->canBeUsedByOrderable($orderable)) { return false; } - // Check if coupon applies to the package if ($package && ! $coupon->appliesToPackage($package->id)) { return false; } @@ -153,26 +338,25 @@ class CouponService */ public function validateByCode(string $code, Workspace $workspace, ?Package $package = null): CouponValidationResult { - // Sanitise the code first - reject invalid formats early $sanitised = $this->sanitiseCode($code); if ($sanitised === null) { return CouponValidationResult::invalid('Invalid coupon code format'); } - $coupon = Coupon::byCode($sanitised)->first(); + $coupon = CouponModel::byCode($sanitised)->first(); if (! $coupon) { return CouponValidationResult::invalid('Invalid coupon code'); } - return $this->validate($coupon, $workspace, $package); + return $this->validateLegacy($coupon, $workspace, $package); } /** * Calculate discount for an amount. */ - public function calculateDiscount(Coupon $coupon, float $amount): float + public function calculateDiscount(CouponModel $coupon, float $amount): float { return $coupon->calculateDiscount($amount); } @@ -180,7 +364,7 @@ class CouponService /** * Record coupon usage after successful payment. */ - public function recordUsage(Coupon $coupon, Workspace $workspace, Order $order, float $discountAmount): CouponUsage + public function recordUsage(CouponModel $coupon, Workspace $workspace, Order $order, float $discountAmount): CouponUsage { $usage = CouponUsage::create([ 'coupon_id' => $coupon->id, @@ -189,7 +373,6 @@ class CouponService 'discount_amount' => $discountAmount, ]); - // Increment global usage count $coupon->incrementUsage(); return $usage; @@ -198,8 +381,12 @@ class CouponService /** * Record coupon usage for any Orderable entity. */ - public function recordUsageForOrderable(Coupon $coupon, Orderable&Model $orderable, Order $order, float $discountAmount): CouponUsage - { + public function recordUsageForOrderable( + CouponModel $coupon, + Orderable&Model $orderable, + Order $order, + float $discountAmount, + ): CouponUsage { $workspaceId = $orderable instanceof Workspace ? $orderable->id : null; $usage = CouponUsage::create([ @@ -209,7 +396,6 @@ class CouponService 'discount_amount' => $discountAmount, ]); - // Increment global usage count $coupon->incrementUsage(); return $usage; @@ -218,7 +404,7 @@ class CouponService /** * Get usage history for a coupon. */ - public function getUsageHistory(Coupon $coupon, int $limit = 50): Collection + public function getUsageHistory(CouponModel $coupon, int $limit = 50): Collection { return $coupon->usages() ->with(['workspace', 'order']) @@ -230,7 +416,7 @@ class CouponService /** * Get usage count for a workspace. */ - public function getWorkspaceUsageCount(Coupon $coupon, Workspace $workspace): int + public function getWorkspaceUsageCount(CouponModel $coupon, Workspace $workspace): int { return $coupon->usages() ->where('workspace_id', $workspace->id) @@ -240,26 +426,15 @@ class CouponService /** * Get total discount amount for a coupon. */ - public function getTotalDiscountAmount(Coupon $coupon): float + public function getTotalDiscountAmount(CouponModel $coupon): float { - return $coupon->usages()->sum('discount_amount'); - } - - /** - * Create a new coupon. - */ - public function create(array $data): Coupon - { - // Normalise code to uppercase - $data['code'] = strtoupper($data['code']); - - return Coupon::create($data); + return (float) $coupon->usages()->sum('discount_amount'); } /** * Deactivate a coupon. */ - public function deactivate(Coupon $coupon): void + public function deactivate(CouponModel $coupon): void { $coupon->update(['is_active' => false]); } @@ -276,8 +451,7 @@ class CouponService $code .= $characters[random_int(0, strlen($characters) - 1)]; } - // Ensure uniqueness - while (Coupon::where('code', $code)->exists()) { + while (CouponModel::where('code', $code)->exists()) { $code = $this->generateCode($length); } @@ -288,8 +462,8 @@ class CouponService * Generate multiple coupons with unique codes. * * @param int $count Number of coupons to generate (1-100) - * @param array $baseData Base coupon data (shared settings for all coupons) - * @return array Array of created coupons + * @param array $baseData Base coupon data (shared settings for all coupons) + * @return array Array of created coupons */ public function generateBulk(int $count, array $baseData): array { @@ -301,9 +475,325 @@ class CouponService for ($i = 0; $i < $count; $i++) { $code = $prefix ? $prefix.'-'.$this->generateCode(6) : $this->generateCode(8); $data = array_merge($baseData, ['code' => $code]); - $coupons[] = $this->create($data); + $coupons[] = $this->createModel($data); } return $coupons; } + + private function validateLegacy(CouponModel $coupon, Workspace $workspace, ?Package $package = null): CouponValidationResult + { + if (! $coupon->isValid()) { + return CouponValidationResult::invalid('This coupon is no longer valid'); + } + + if (! $coupon->canBeUsedByWorkspace($workspace->id)) { + return CouponValidationResult::invalid('You have already used this coupon'); + } + + if ($package && ! $coupon->appliesToPackage($package->id)) { + return CouponValidationResult::invalid('This coupon does not apply to the selected plan'); + } + + return CouponValidationResult::valid($coupon); + } + + /** + * @param array $data + */ + private function createModel(array $data): CouponModel + { + if (! isset($data['code'])) { + throw new InvalidArgumentException('Coupon code is required.'); + } + + $sanitised = $this->sanitiseCode((string) $data['code']); + + if ($sanitised === null) { + throw new InvalidArgumentException('Invalid coupon code format.'); + } + + if (CouponModel::byCode($sanitised)->exists()) { + throw new InvalidArgumentException('A coupon with this code already exists.'); + } + + if (! isset($data['type'])) { + throw new InvalidArgumentException('Coupon type is required.'); + } + + if (! array_key_exists('value', $data)) { + throw new InvalidArgumentException('Coupon value is required.'); + } + + $modelType = $this->normaliseModelType((string) $data['type']); + $value = round((float) $data['value'], 2); + + if ($modelType === 'percentage' && ($value <= 0 || $value > 100)) { + throw new InvalidArgumentException('Percentage coupon value must be between 0 and 100.'); + } + + if ($modelType === 'fixed_amount' && $value <= 0) { + throw new InvalidArgumentException('Fixed coupon value must be greater than zero.'); + } + + $maxUses = $data['max_uses'] ?? null; + if ($maxUses !== null && (int) $maxUses < 1) { + throw new InvalidArgumentException('Coupon max uses must be at least one.'); + } + + $data['code'] = $sanitised; + $data['name'] = $data['name'] ?? $sanitised; + $data['type'] = $modelType; + $data['value'] = $value; + $data['max_uses'] = $maxUses === null ? null : (int) $maxUses; + $data['max_uses_per_workspace'] = (int) ($data['max_uses_per_workspace'] ?? 1); + $data['used_count'] = (int) ($data['used_count'] ?? 0); + $data['duration'] = $data['duration'] ?? 'once'; + $data['applies_to'] = $data['applies_to'] ?? 'all'; + $data['valid_until'] = $this->parseExpiresAt($data['valid_until'] ?? null); + $data['is_active'] = (bool) ($data['is_active'] ?? true); + + return CouponModel::create($data); + } + + private function validateCouponForOrder(CouponModel $coupon, Order $order): ValidationResult + { + $discountType = $this->discountType($coupon); + $couponData = CouponData::fromModel($coupon); + + if (! $coupon->is_active) { + return ValidationResult::invalid('Coupon is inactive', $discountType, $couponData); + } + + if ($coupon->valid_from && $coupon->valid_from->isFuture()) { + return ValidationResult::invalid('Coupon is not active yet', $discountType, $couponData); + } + + if ($coupon->valid_until && $coupon->valid_until->isPast()) { + return ValidationResult::invalid('Coupon has expired', $discountType, $couponData); + } + + if ($this->usageCount($coupon) >= $this->usageLimit($coupon)) { + return ValidationResult::invalid('Coupon usage limit reached', $discountType, $couponData); + } + + $workspaceId = $this->resolveWorkspaceId($order); + if ($workspaceId !== null && $this->workspaceUsageLimitReached($coupon, $workspaceId)) { + return ValidationResult::invalid('Coupon already used by this workspace', $discountType, $couponData); + } + + $eligibleItems = $this->eligibleItems($coupon, $order); + + if ($eligibleItems->isEmpty()) { + return ValidationResult::invalid('Coupon is not applicable to this order', $discountType, $couponData); + } + + $discountAmount = $this->calculateOrderDiscount($coupon, $eligibleItems); + + if ($discountAmount <= 0) { + return ValidationResult::invalid('Order has no discountable amount', $discountType, $couponData); + } + + return ValidationResult::valid($couponData, $discountAmount, $discountType); + } + + private function resolveCouponModel(CouponData|CouponModel $coupon): CouponModel + { + if ($coupon instanceof CouponModel) { + return $coupon; + } + + return CouponModel::query()->findOrFail($coupon->id); + } + + private function parseExpiresAt(CarbonInterface|string|null $expiresAt): ?Carbon + { + if ($expiresAt === null || $expiresAt === '') { + return null; + } + + if ($expiresAt instanceof CarbonInterface) { + return Carbon::instance($expiresAt->toDateTime()); + } + + return Carbon::parse($expiresAt); + } + + private function normaliseModelType(string $type): string + { + return match (strtolower(trim($type))) { + 'percent', 'percentage' => 'percentage', + 'fixed', 'fixed_amount' => 'fixed_amount', + default => throw new InvalidArgumentException('Coupon type must be percent or fixed.'), + }; + } + + private function discountType(CouponModel $coupon): string + { + return $this->isPercentCoupon($coupon) ? 'percent' : 'fixed'; + } + + private function isPercentCoupon(CouponModel $coupon): bool + { + return in_array((string) $coupon->type, ['percent', 'percentage'], true); + } + + private function usageLimit(CouponModel $coupon): int + { + return $coupon->max_uses === null ? PHP_INT_MAX : (int) $coupon->max_uses; + } + + private function usageCount(CouponModel $coupon): int + { + return max((int) $coupon->used_count, $coupon->usages()->count()); + } + + private function workspaceUsageLimitReached(CouponModel $coupon, int $workspaceId): bool + { + $limit = (int) ($coupon->max_uses_per_workspace ?? 0); + + if ($limit <= 0) { + return false; + } + + return $coupon->usages() + ->where('workspace_id', $workspaceId) + ->count() >= $limit; + } + + private function resolveWorkspaceId(Order $order): ?int + { + $rawWorkspaceId = $order->getAttributes()['workspace_id'] ?? null; + + if ($rawWorkspaceId !== null) { + return (int) $rawWorkspaceId; + } + + return $order->workspace_id; + } + + private function eligibleItems(CouponModel $coupon, Order $order): Collection + { + $order->loadMissing('items'); + + return $order->items + ->filter(fn (OrderItem $item): bool => $this->lineBaseTotal($item) > 0 + && $this->couponAppliesToItem($coupon, $item)) + ->values(); + } + + private function couponAppliesToItem(CouponModel $coupon, OrderItem $item): bool + { + if ($coupon->applies_to === 'all' || $coupon->applies_to === null) { + return true; + } + + $allowedIds = array_map('intval', $coupon->package_ids ?? []); + + if ($allowedIds === []) { + return false; + } + + if (in_array($coupon->applies_to, ['package', 'packages', 'product', 'products'], true)) { + return $item->item_id !== null && in_array((int) $item->item_id, $allowedIds, true); + } + + return false; + } + + private function lineBaseTotal(OrderItem $item): float + { + $metadata = $item->metadata ?? []; + + if (isset($metadata['original_line_total'])) { + return round((float) $metadata['original_line_total'], 2); + } + + return round((float) $item->line_total, 2); + } + + private function calculateOrderDiscount(CouponModel $coupon, Collection $eligibleItems): float + { + $subtotal = round((float) $eligibleItems->sum( + fn (OrderItem $item): float => $this->lineBaseTotal($item) + ), 2); + + if ($subtotal <= 0) { + return 0.0; + } + + if ($this->isPercentCoupon($coupon)) { + return round(min($subtotal, $subtotal * ((float) $coupon->value / 100)), 2); + } + + return round(min($subtotal, (float) $coupon->value), 2); + } + + /** + * @return array + */ + private function allocateDiscount(CouponModel $coupon, Collection $eligibleItems, float $discountAmount): array + { + $discountAmount = round($discountAmount, 2); + $allocated = []; + $allocatedTotal = 0.0; + $items = $eligibleItems->values(); + $lastIndex = $items->count() - 1; + $eligibleSubtotal = round((float) $items->sum( + fn (OrderItem $item): float => $this->lineBaseTotal($item) + ), 2); + + foreach ($items as $index => $item) { + $baseLineTotal = $this->lineBaseTotal($item); + + if ($index === $lastIndex) { + $lineDiscount = round($discountAmount - $allocatedTotal, 2); + } elseif ($this->isPercentCoupon($coupon)) { + $lineDiscount = round($baseLineTotal * ((float) $coupon->value / 100), 2); + } else { + $lineDiscount = round($discountAmount * ($baseLineTotal / $eligibleSubtotal), 2); + } + + $lineDiscount = round(min($baseLineTotal, max(0.0, $lineDiscount)), 2); + $allocated[(int) $item->id] = $lineDiscount; + $allocatedTotal = round($allocatedTotal + $lineDiscount, 2); + } + + return $allocated; + } + + private function hasAppliedCoupon(CouponModel $coupon, Order $order): bool + { + if ((int) ($order->coupon_id ?? 0) !== (int) $coupon->id) { + return false; + } + + return CouponUsage::query() + ->where('coupon_id', $coupon->id) + ->where('order_id', $order->id) + ->exists(); + } + + private function recordOrderUsage(CouponModel $coupon, Order $order, float $discountAmount): void + { + if (CouponUsage::query() + ->where('coupon_id', $coupon->id) + ->where('order_id', $order->id) + ->exists()) { + return; + } + + $workspaceId = $this->resolveWorkspaceId($order); + + if ($workspaceId !== null) { + CouponUsage::create([ + 'coupon_id' => $coupon->id, + 'workspace_id' => $workspaceId, + 'order_id' => $order->id, + 'discount_amount' => $discountAmount, + ]); + } + + $coupon->incrementUsage(); + } } diff --git a/tests/Unit/Services/CouponServiceTest.php b/tests/Unit/Services/CouponServiceTest.php new file mode 100644 index 0000000..a0d2440 --- /dev/null +++ b/tests/Unit/Services/CouponServiceTest.php @@ -0,0 +1,295 @@ +id(); + $table->string('code')->index(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('type'); + $table->decimal('value', 10, 2); + $table->decimal('min_amount', 10, 2)->nullable(); + $table->decimal('max_discount', 10, 2)->nullable(); + $table->string('applies_to')->default('all'); + $table->json('package_ids')->nullable(); + $table->unsignedInteger('max_uses')->nullable(); + $table->unsignedInteger('max_uses_per_workspace')->default(1); + $table->unsignedInteger('used_count')->default(0); + $table->string('duration')->default('once'); + $table->unsignedInteger('duration_months')->nullable(); + $table->timestamp('valid_from')->nullable(); + $table->timestamp('valid_until')->nullable(); + $table->boolean('is_active')->default(true); + $table->string('stripe_coupon_id')->nullable(); + $table->string('btcpay_coupon_id')->nullable(); + $table->timestamps(); + }); + + Schema::create('orders', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('workspace_id')->nullable(); + $table->string('orderable_type')->nullable(); + $table->unsignedBigInteger('orderable_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('order_number')->unique(); + $table->string('status')->default('pending'); + $table->string('type')->default('new'); + $table->string('billing_cycle')->nullable(); + $table->string('currency', 3)->default('GBP'); + $table->decimal('subtotal', 10, 2)->default(0); + $table->decimal('tax_amount', 10, 2)->default(0); + $table->decimal('discount_amount', 10, 2)->default(0); + $table->decimal('total', 10, 2)->default(0); + $table->unsignedBigInteger('coupon_id')->nullable(); + $table->json('billing_address')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('order_items', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('order_id'); + $table->string('item_type'); + $table->unsignedBigInteger('item_id')->nullable(); + $table->string('item_code')->nullable(); + $table->string('description'); + $table->unsignedInteger('quantity')->default(1); + $table->decimal('unit_price', 10, 2); + $table->decimal('line_total', 10, 2); + $table->string('billing_cycle')->default('onetime'); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('coupon_usages', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('coupon_id'); + $table->unsignedBigInteger('workspace_id'); + $table->unsignedBigInteger('order_id'); + $table->decimal('discount_amount', 10, 2); + $table->timestamp('created_at')->nullable(); + }); + + CouponModel::unsetEventDispatcher(); + CouponUsage::unsetEventDispatcher(); + Order::unsetEventDispatcher(); + OrderItem::unsetEventDispatcher(); + + $this->service = new CouponService(); +}); + +afterEach(function (): void { + Schema::dropIfExists('coupon_usages'); + Schema::dropIfExists('order_items'); + Schema::dropIfExists('orders'); + Schema::dropIfExists('coupons'); +}); + +function couponServiceTestOrder(array $lineTotals = [100.00], int $workspaceId = 10): Order +{ + $order = Order::forceCreate([ + 'workspace_id' => $workspaceId, + 'order_number' => 'ORD-'.uniqid(), + 'status' => 'pending', + 'type' => 'new', + 'currency' => 'GBP', + 'subtotal' => array_sum($lineTotals), + 'tax_amount' => 0, + 'discount_amount' => 0, + 'total' => array_sum($lineTotals), + ]); + + foreach ($lineTotals as $index => $lineTotal) { + OrderItem::create([ + 'order_id' => $order->id, + 'item_type' => 'package', + 'item_id' => $index + 1, + 'item_code' => 'PKG-'.$index, + 'description' => 'Package '.$index, + 'quantity' => 1, + 'unit_price' => $lineTotal, + 'line_total' => $lineTotal, + 'billing_cycle' => 'monthly', + ]); + } + + return $order->load('items'); +} + +describe('CouponService create()', function (): void { + it('Good: creates and persists a percent coupon DTO', function (): void { + $coupon = $this->service->create(' save20 ', 'percent', 20, 5, Carbon::now()->addMonth()); + + expect($coupon)->toBeInstanceOf(CouponData::class) + ->and($coupon->code)->toBe('SAVE20') + ->and($coupon->type)->toBe('percent') + ->and($coupon->maxUses)->toBe(5) + ->and(CouponModel::byCode('SAVE20')->exists())->toBeTrue(); + }); + + it('Bad: rejects an invalid discount type', function (): void { + $this->service->create('SAVE20', 'bogus', 20, 5, null); + })->throws(InvalidArgumentException::class); + + it('Ugly: rejects duplicate sanitised codes', function (): void { + $this->service->create('SAVE20', 'percent', 20, 5, null); + + $this->service->create(' save20 ', 'percent', 25, 5, null); + })->throws(InvalidArgumentException::class); +}); + +describe('CouponService validate()', function (): void { + it('Good: validates a live coupon and calculates the order discount', function (): void { + $this->service->create('SAVE20', 'percent', 20, 5, Carbon::now()->addMonth()); + $order = couponServiceTestOrder([100.00]); + + $result = $this->service->validate('SAVE20', $order); + + expect($result)->toBeInstanceOf(ValidationResult::class) + ->and($result->valid)->toBeTrue() + ->and($result->discountAmount)->toBe(20.00) + ->and($result->discountType)->toBe('percent'); + }); + + it('Bad: rejects an expired coupon', function (): void { + $this->service->create('OLD10', 'fixed', 10, 5, Carbon::now()->subDay()); + $order = couponServiceTestOrder([50.00]); + + $result = $this->service->validate('OLD10', $order); + + expect($result->valid)->toBeFalse() + ->and($result->reason)->toBe('Coupon has expired'); + }); + + it('Ugly: rejects hostile coupon code input before lookup', function (): void { + $order = couponServiceTestOrder([50.00]); + + $result = $this->service->validate("'; DROP TABLE coupons; --", $order); + + expect($result->valid)->toBeFalse() + ->and($result->reason)->toBe('Invalid coupon code format'); + }); +}); + +describe('CouponService apply()', function (): void { + it('Good: applies a fixed coupon across line items and records usage', function (): void { + $coupon = $this->service->create('FLAT30', 'fixed', 30, 5, Carbon::now()->addMonth()); + $order = couponServiceTestOrder([100.00, 50.00], 22); + + $applied = $this->service->apply($coupon, $order); + + expect((float) $applied->discount_amount)->toBe(30.00) + ->and((float) $applied->total)->toBe(120.00) + ->and($applied->items->pluck('line_total')->map(fn (mixed $value): float => (float) $value)->all()) + ->toBe([80.00, 40.00]) + ->and(CouponUsage::query()->count())->toBe(1) + ->and((float) CouponUsage::query()->first()->discount_amount)->toBe(30.00); + }); + + it('Bad: refuses to apply an inactive coupon', function (): void { + $coupon = $this->service->create('PAUSED', 'fixed', 10, 5, Carbon::now()->addMonth()); + CouponModel::byCode('PAUSED')->firstOrFail()->update(['is_active' => false]); + $order = couponServiceTestOrder([100.00]); + + $this->service->apply($coupon, $order); + })->throws(RuntimeException::class, 'Coupon is inactive'); + + it('Ugly: caps a large fixed coupon at the order subtotal', function (): void { + $coupon = $this->service->create('FREEBIE', 'fixed', 999, 5, Carbon::now()->addMonth()); + $order = couponServiceTestOrder([20.00]); + + $applied = $this->service->apply($coupon, $order); + + expect((float) $applied->discount_amount)->toBe(20.00) + ->and((float) $applied->total)->toBe(0.00) + ->and((float) $applied->items->first()->line_total)->toBe(0.00); + }); +}); + +describe('CouponService expire()', function (): void { + it('Good: expires an active coupon immediately', function (): void { + $coupon = $this->service->create('SPRING', 'percent', 15, 5, Carbon::now()->addMonth()); + + $this->service->expire($coupon); + + $model = CouponModel::byCode('SPRING')->firstOrFail(); + expect($model->is_active)->toBeFalse() + ->and($model->valid_until?->isPast() || $model->valid_until?->isCurrentSecond())->toBeTrue(); + }); + + it('Bad: fails when the DTO no longer points to a persisted coupon', function (): void { + $coupon = $this->service->create('GONE', 'fixed', 10, 5, null); + CouponModel::byCode('GONE')->firstOrFail()->delete(); + + $this->service->expire($coupon); + })->throws(ModelNotFoundException::class); + + it('Ugly: can expire an already expired coupon without reactivating it', function (): void { + $coupon = $this->service->create('ANCIENT', 'fixed', 10, 5, Carbon::now()->subMonth()); + + $this->service->expire($coupon); + + $model = CouponModel::byCode('ANCIENT')->firstOrFail(); + expect($model->is_active)->toBeFalse() + ->and($model->valid_until?->isPast() || $model->valid_until?->isCurrentSecond())->toBeTrue(); + }); +}); + +describe('CouponService report()', function (): void { + it('Good: reports redemption totals by coupon', function (): void { + $coupon = $this->service->create('SAVE10', 'fixed', 10, 5, Carbon::now()->addMonth()); + $this->service->apply($coupon, couponServiceTestOrder([50.00], 44)); + + $report = $this->service->report(); + + expect($report['total_coupons'])->toBe(1) + ->and($report['total_redemptions'])->toBe(1) + ->and($report['total_discount_amount'])->toBe(10.00) + ->and($report['by_coupon'][0]['code'])->toBe('SAVE10') + ->and($report['by_coupon'][0]['redemptions'])->toBe(1); + }); + + it('Bad: reports zero redemption stats when nothing has been applied', function (): void { + $this->service->create('UNUSED', 'percent', 5, 5, Carbon::now()->addMonth()); + + $report = $this->service->report(); + + expect($report['total_coupons'])->toBe(1) + ->and($report['total_redemptions'])->toBe(0) + ->and($report['total_discount_amount'])->toBe(0.00) + ->and($report['by_coupon'][0]['redemptions'])->toBe(0); + }); + + it('Ugly: includes expired coupon counts alongside active redemption data', function (): void { + $coupon = $this->service->create('LIVE10', 'fixed', 10, 5, Carbon::now()->addMonth()); + $this->service->create('EXPIRED', 'fixed', 10, 5, Carbon::now()->subMonth()); + $this->service->apply($coupon, couponServiceTestOrder([40.00], 55)); + + $report = $this->service->report(); + + expect($report['total_coupons'])->toBe(2) + ->and($report['active_coupons'])->toBe(2) + ->and($report['expired_coupons'])->toBe(1) + ->and($report['by_coupon'][0]['code'])->toBe('LIVE10'); + }); +});