php-commerce/Models/Coupon.php
Snider a774f4e285 refactor: migrate namespace from Core\Commerce to Core\Mod\Commerce
Align commerce module with the monorepo module structure by updating
all namespaces to use the Core\Mod\Commerce convention. This change
supports the recent monorepo separation and ensures consistency with
other modules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:23:12 +00:00

281 lines
6.9 KiB
PHP

<?php
namespace Core\Mod\Commerce\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Core\Mod\Commerce\Contracts\Orderable;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Coupon model for discount codes.
*
* @property int $id
* @property string $code
* @property string $name
* @property string|null $description
* @property string $type
* @property float $value
* @property float|null $min_amount
* @property float|null $max_discount
* @property string $applies_to
* @property array|null $package_ids
* @property int|null $max_uses
* @property int $max_uses_per_workspace
* @property int $used_count
* @property string $duration
* @property int|null $duration_months
* @property \Carbon\Carbon|null $valid_from
* @property \Carbon\Carbon|null $valid_until
* @property bool $is_active
*/
class Coupon extends Model
{
use HasFactory;
use LogsActivity;
protected static function newFactory(): \Core\Mod\Commerce\Database\Factories\CouponFactory
{
return \Core\Mod\Commerce\Database\Factories\CouponFactory::new();
}
protected $fillable = [
'code',
'name',
'description',
'type',
'value',
'min_amount',
'max_discount',
'applies_to',
'package_ids',
'max_uses',
'max_uses_per_workspace',
'used_count',
'duration',
'duration_months',
'valid_from',
'valid_until',
'is_active',
'stripe_coupon_id',
'btcpay_coupon_id',
];
protected $casts = [
'value' => 'decimal:2',
'min_amount' => 'decimal:2',
'max_discount' => 'decimal:2',
'package_ids' => 'array',
'max_uses' => 'integer',
'max_uses_per_workspace' => 'integer',
'used_count' => 'integer',
'duration_months' => 'integer',
'valid_from' => 'datetime',
'valid_until' => 'datetime',
'is_active' => 'boolean',
];
// Relationships
public function usages(): HasMany
{
return $this->hasMany(CouponUsage::class);
}
// Type helpers
public function isPercentage(): bool
{
return $this->type === 'percentage';
}
public function isFixedAmount(): bool
{
return $this->type === 'fixed_amount';
}
// Duration helpers
public function isOnce(): bool
{
return $this->duration === 'once';
}
public function isRepeating(): bool
{
return $this->duration === 'repeating';
}
public function isForever(): bool
{
return $this->duration === 'forever';
}
// Validation
public function isValid(): bool
{
if (! $this->is_active) {
return false;
}
if ($this->valid_from && $this->valid_from->isFuture()) {
return false;
}
if ($this->valid_until && $this->valid_until->isPast()) {
return false;
}
if ($this->max_uses && $this->used_count >= $this->max_uses) {
return false;
}
return true;
}
public function canBeUsedByWorkspace(int $workspaceId): bool
{
if (! $this->isValid()) {
return false;
}
$workspaceUsageCount = $this->usages()
->where('workspace_id', $workspaceId)
->count();
return $workspaceUsageCount < $this->max_uses_per_workspace;
}
/**
* Check if an Orderable entity can use this coupon.
*
* Uses the order's orderable relationship to check usage limits.
*/
public function canBeUsedByOrderable(Orderable&Model $orderable): bool
{
if (! $this->isValid()) {
return false;
}
// Check usage via orders linked to this orderable
$usageCount = $this->usages()
->whereHas('order', function ($query) use ($orderable) {
$query->where('orderable_type', get_class($orderable))
->where('orderable_id', $orderable->id);
})
->count();
return $usageCount < $this->max_uses_per_workspace;
}
/**
* Check if coupon has reached its maximum usage limit.
*/
public function hasReachedMaxUses(): bool
{
if ($this->max_uses === null) {
return false;
}
return $this->used_count >= $this->max_uses;
}
/**
* Check if coupon is restricted to a specific package.
*
* Returns true if the package is in the allowed list.
* Returns false if no restrictions (applies to all) or package not in list.
*/
public function isRestrictedToPackage(string $packageCode): bool
{
if (empty($this->package_ids)) {
return false;
}
return in_array($packageCode, $this->package_ids);
}
public function appliesToPackage(int $packageId): bool
{
if ($this->applies_to === 'all') {
return true;
}
if ($this->applies_to !== 'packages') {
return false;
}
return in_array($packageId, $this->package_ids ?? []);
}
// Calculation
public function calculateDiscount(float $amount): float
{
if ($this->min_amount && $amount < $this->min_amount) {
return 0;
}
if ($this->isPercentage()) {
$discount = $amount * ($this->value / 100);
} else {
$discount = $this->value;
}
// Cap at max_discount if set
if ($this->max_discount && $discount > $this->max_discount) {
$discount = $this->max_discount;
}
// Cap at order amount
return min($discount, $amount);
}
// Actions
public function incrementUsage(): void
{
$this->increment('used_count');
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeValid($query)
{
return $query->active()
->where(function ($q) {
$q->whereNull('valid_from')
->orWhere('valid_from', '<=', now());
})
->where(function ($q) {
$q->whereNull('valid_until')
->orWhere('valid_until', '>=', now());
})
->where(function ($q) {
$q->whereNull('max_uses')
->orWhereRaw('used_count < max_uses');
});
}
public function scopeByCode($query, string $code)
{
return $query->where('code', strtoupper($code));
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['code', 'name', 'is_active', 'value', 'type'])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn (string $eventName) => "Coupon {$eventName}");
}
}