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:
parent
6d83c32114
commit
cd16c7474e
4 changed files with 1003 additions and 54 deletions
82
Data/Coupon.php
Normal file
82
Data/Coupon.php
Normal 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
82
Data/ValidationResult.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
295
tests/Unit/Services/CouponServiceTest.php
Normal file
295
tests/Unit/Services/CouponServiceTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue