feat(commerce): implement CouponService with 5 methods + DTOs (#858)

- 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 <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=858
This commit is contained in:
Snider 2026-04-25 04:41:43 +01:00
parent 6d83c32114
commit cd16c7474e
4 changed files with 1003 additions and 54 deletions

82
Data/Coupon.php Normal file
View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Data;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Core\Mod\Commerce\Models\Coupon as CouponModel;
/**
* Persisted coupon data used by the RFC CouponService API.
*/
readonly class Coupon
{
public function __construct(
public int $id,
public string $code,
public string $type,
public float $value,
public ?int $maxUses,
public ?CarbonImmutable $expiresAt,
public bool $active,
public int $usedCount,
) {}
public static function fromModel(CouponModel $coupon): self
{
return new self(
id: (int) $coupon->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<string, mixed>
*/
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());
}
}

82
Data/ValidationResult.php Normal file
View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Data;
/**
* Coupon validation result for the RFC CouponService API.
*/
readonly class ValidationResult
{
public function __construct(
public bool $valid,
public ?string $reason,
public float $discountAmount,
public string $discountType,
public ?Coupon $coupon = null,
) {}
public static function valid(Coupon $coupon, float $discountAmount, string $discountType): self
{
return new self(
valid: true,
reason: null,
discountAmount: round($discountAmount, 2),
discountType: $discountType,
coupon: $coupon,
);
}
public static function invalid(
string $reason,
string $discountType = 'none',
?Coupon $coupon = null,
): self {
return new self(
valid: false,
reason: $reason,
discountAmount: 0.0,
discountType: $discountType,
coupon: $coupon,
);
}
public function isValid(): bool
{
return $this->valid;
}
public function getMessage(): ?string
{
return $this->reason;
}
public function getCoupon(): ?Coupon
{
return $this->coupon;
}
/**
* @return array<string, mixed>
*/
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,
};
}
}

View file

@ -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<string, mixed> $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<string, mixed>
*/
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<Coupon> Array of created coupons
* @param array<string, mixed> $baseData Base coupon data (shared settings for all coupons)
* @return array<CouponModel> 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<string, mixed> $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<int, float>
*/
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();
}
}

View file

@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
use Carbon\Carbon;
use Core\Mod\Commerce\Data\Coupon as CouponData;
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\Mod\Commerce\Services\CouponService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
beforeEach(function (): void {
Schema::dropIfExists('coupon_usages');
Schema::dropIfExists('order_items');
Schema::dropIfExists('orders');
Schema::dropIfExists('coupons');
Schema::create('coupons', function (Blueprint $table): void {
$table->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');
});
});