php-tenant/Models/Package.php
Claude 126d454bba
chore: add IDE helper annotations to Eloquent models
Add @property, @property-read, @method, and @mixin PHPDoc annotations
to the seven core Eloquent models for IDE autocompletion support.

Models annotated:
- Workspace: all columns, relationships, scopes (active, ordered)
- User: all columns, relationships, factory
- WorkspaceMember: relationship props, scope methods (forWorkspace, forUser, withRole, inTeam, owners)
- WorkspaceInvitation: all columns, relationships, scopes (pending, expired, accepted)
- Namespace_: all columns, relationships, scopes (active, ordered, ownedByUser, ownedByWorkspace, accessibleBy)
- Package: all columns, relationships, scopes (active, public, base, addons, purchasable, free, ordered)
- Feature: all columns, relationships, scopes (active, inCategory, root)

Fixes #31

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:55:50 +00:00

287 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Tenant\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Package model - entitlement package definition (e.g. Free, Creator, Agency).
*
* @property int $id
* @property string $code
* @property string $name
* @property string|null $description
* @property string|null $icon
* @property string|null $color
* @property int $sort_order
* @property bool $is_stackable
* @property bool $is_base_package
* @property bool $is_active
* @property bool $is_public
* @property string|null $blesta_package_id
* @property float|null $monthly_price
* @property float|null $yearly_price
* @property float $setup_fee
* @property int $trial_days
* @property string|null $stripe_price_id_monthly
* @property string|null $stripe_price_id_yearly
* @property string|null $btcpay_price_id_monthly
* @property string|null $btcpay_price_id_yearly
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, Feature> $features
* @property-read \Illuminate\Database\Eloquent\Collection<int, WorkspacePackage> $workspacePackages
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|Package active()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Package public()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Package base()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Package addons()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Package purchasable()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Package free()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Package ordered()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Package newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Package newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Package query()
*
* @mixin \Eloquent
*/
class Package extends Model
{
use HasFactory;
protected $table = 'entitlement_packages';
protected $fillable = [
'code',
'name',
'description',
'icon',
'color',
'sort_order',
'is_stackable',
'is_base_package',
'is_active',
'is_public',
'blesta_package_id',
// Pricing fields
'monthly_price',
'yearly_price',
'setup_fee',
'trial_days',
'stripe_price_id_monthly',
'stripe_price_id_yearly',
'btcpay_price_id_monthly',
'btcpay_price_id_yearly',
];
protected $casts = [
'is_stackable' => 'boolean',
'is_base_package' => 'boolean',
'is_active' => 'boolean',
'is_public' => 'boolean',
'sort_order' => 'integer',
'monthly_price' => 'decimal:2',
'yearly_price' => 'decimal:2',
'setup_fee' => 'decimal:2',
'trial_days' => 'integer',
];
/**
* Features included in this package.
*/
public function features(): BelongsToMany
{
return $this->belongsToMany(Feature::class, 'entitlement_package_features', 'package_id', 'feature_id')
->withPivot('limit_value')
->withTimestamps();
}
/**
* Workspaces that have this package assigned.
*/
public function workspacePackages(): HasMany
{
return $this->hasMany(WorkspacePackage::class, 'package_id');
}
/**
* Scope to active packages.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope to public packages (shown on pricing page).
*/
public function scopePublic($query)
{
return $query->where('is_public', true);
}
/**
* Scope to base packages (only one per workspace).
*/
public function scopeBase($query)
{
return $query->where('is_base_package', true);
}
/**
* Scope to addon packages (stackable).
*/
public function scopeAddons($query)
{
return $query->where('is_base_package', false);
}
/**
* Get the limit for a specific feature in this package.
*/
public function getFeatureLimit(string $featureCode): ?int
{
$feature = $this->features()->where('code', $featureCode)->first();
if (! $feature) {
return null;
}
return $feature->pivot->limit_value;
}
/**
* Check if package includes a feature (regardless of limit).
*/
public function hasFeature(string $featureCode): bool
{
return $this->features()->where('code', $featureCode)->exists();
}
// Pricing Helpers
/**
* Check if package is free.
*/
public function isFree(): bool
{
return ($this->monthly_price ?? 0) == 0 && ($this->yearly_price ?? 0) == 0;
}
/**
* Check if package has pricing set.
*/
public function hasPricing(): bool
{
return $this->monthly_price !== null || $this->yearly_price !== null;
}
/**
* Get price for a billing cycle.
*/
public function getPrice(string $cycle = 'monthly'): float
{
return match ($cycle) {
'yearly', 'annual' => (float) ($this->yearly_price ?? 0),
default => (float) ($this->monthly_price ?? 0),
};
}
/**
* Get yearly savings compared to monthly.
*/
public function getYearlySavings(): float
{
if (! $this->monthly_price || ! $this->yearly_price) {
return 0;
}
$monthlyTotal = $this->monthly_price * 12;
return max(0, $monthlyTotal - $this->yearly_price);
}
/**
* Get yearly savings as percentage.
*/
public function getYearlySavingsPercent(): int
{
if (! $this->monthly_price || ! $this->yearly_price) {
return 0;
}
$monthlyTotal = $this->monthly_price * 12;
if ($monthlyTotal == 0) {
return 0;
}
return (int) round(($this->getYearlySavings() / $monthlyTotal) * 100);
}
/**
* Get gateway price ID for a cycle.
*/
public function getGatewayPriceId(string $gateway, string $cycle = 'monthly'): ?string
{
$field = match ($cycle) {
'yearly', 'annual' => "{$gateway}_price_id_yearly",
default => "{$gateway}_price_id_monthly",
};
return $this->{$field};
}
/**
* Check if package has trial period.
*/
public function hasTrial(): bool
{
return ($this->trial_days ?? 0) > 0;
}
/**
* Check if package has setup fee.
*/
public function hasSetupFee(): bool
{
return ($this->setup_fee ?? 0) > 0;
}
/**
* Scope to packages with pricing (purchasable).
*/
public function scopePurchasable($query)
{
return $query->where(function ($q) {
$q->whereNotNull('monthly_price')
->orWhereNotNull('yearly_price');
});
}
/**
* Scope to free packages.
*/
public function scopeFree($query)
{
return $query->where(function ($q) {
$q->whereNull('monthly_price')
->orWhere('monthly_price', 0);
})->where(function ($q) {
$q->whereNull('yearly_price')
->orWhere('yearly_price', 0);
});
}
/**
* Scope to order by sort_order.
*/
public function scopeOrdered($query)
{
return $query->orderBy('sort_order');
}
}