feat(packages): implement package-based business model
This commit is contained in:
@@ -50,4 +50,32 @@ class Event extends Model
|
||||
return $this->belongsToMany(Task::class, 'event_task', 'event_id', 'task_id')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function eventPackage(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EventPackage::class);
|
||||
}
|
||||
|
||||
public function hasActivePackage(): bool
|
||||
{
|
||||
return $this->eventPackage && $this->eventPackage->isActive();
|
||||
}
|
||||
|
||||
public function getPackageLimits(): array
|
||||
{
|
||||
if (!$this->hasActivePackage()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->eventPackage->package->limits;
|
||||
}
|
||||
|
||||
public function canUploadPhoto(): bool
|
||||
{
|
||||
if (!$this->hasActivePackage()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->eventPackage->canUploadPhoto();
|
||||
}
|
||||
}
|
||||
|
||||
95
app/Models/EventPackage.php
Normal file
95
app/Models/EventPackage.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class EventPackage extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'event_packages';
|
||||
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'package_id',
|
||||
'purchased_price',
|
||||
'purchased_at',
|
||||
'used_photos',
|
||||
'used_guests',
|
||||
'gallery_expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'purchased_price' => 'decimal:2',
|
||||
'purchased_at' => 'datetime',
|
||||
'gallery_expires_at' => 'datetime',
|
||||
'used_photos' => 'integer',
|
||||
'used_guests' => 'integer',
|
||||
];
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function package(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Package::class);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->gallery_expires_at && $this->gallery_expires_at->isFuture();
|
||||
}
|
||||
|
||||
public function canUploadPhoto(): bool
|
||||
{
|
||||
if (!$this->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxPhotos = $this->package->max_photos ?? 0;
|
||||
return $this->used_photos < $maxPhotos;
|
||||
}
|
||||
|
||||
public function canAddGuest(): bool
|
||||
{
|
||||
if (!$this->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxGuests = $this->package->max_guests ?? 0;
|
||||
return $this->used_guests < $maxGuests;
|
||||
}
|
||||
|
||||
public function getRemainingPhotosAttribute(): int
|
||||
{
|
||||
$max = $this->package->max_photos ?? 0;
|
||||
return max(0, $this->max_photos - $this->used_photos);
|
||||
}
|
||||
|
||||
public function getRemainingGuestsAttribute(): int
|
||||
{
|
||||
$max = $this->package->max_guests ?? 0;
|
||||
return max(0, $this->max_guests - $this->used_guests);
|
||||
}
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($eventPackage) {
|
||||
if (!$eventPackage->purchased_at) {
|
||||
$eventPackage->purchased_at = now();
|
||||
}
|
||||
if (!$eventPackage->gallery_expires_at && $eventPackage->package) {
|
||||
$days = $eventPackage->package->gallery_days ?? 30;
|
||||
$eventPackage->gallery_expires_at = now()->addDays($days);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
86
app/Models/Package.php
Normal file
86
app/Models/Package.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
|
||||
class Package extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'type',
|
||||
'price',
|
||||
'max_photos',
|
||||
'max_guests',
|
||||
'gallery_days',
|
||||
'max_tasks',
|
||||
'watermark_allowed',
|
||||
'branding_allowed',
|
||||
'max_events_per_year',
|
||||
'expires_after',
|
||||
'features',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'max_photos' => 'integer',
|
||||
'max_guests' => 'integer',
|
||||
'gallery_days' => 'integer',
|
||||
'max_tasks' => 'integer',
|
||||
'max_events_per_year' => 'integer',
|
||||
'expires_after' => 'datetime',
|
||||
'watermark_allowed' => 'boolean',
|
||||
'branding_allowed' => 'boolean',
|
||||
'features' => 'array',
|
||||
];
|
||||
|
||||
protected function features(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value) => $value ? json_decode($value, true) : [],
|
||||
set: fn (array $value) => json_encode($value),
|
||||
);
|
||||
}
|
||||
|
||||
public function eventPackages(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventPackage::class);
|
||||
}
|
||||
|
||||
public function tenantPackages(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantPackage::class);
|
||||
}
|
||||
|
||||
public function packagePurchases(): HasMany
|
||||
{
|
||||
return $this->hasMany(PackagePurchase::class);
|
||||
}
|
||||
|
||||
public function isEndcustomer(): bool
|
||||
{
|
||||
return $this->type === 'endcustomer';
|
||||
}
|
||||
|
||||
public function isReseller(): bool
|
||||
{
|
||||
return $this->type === 'reseller';
|
||||
}
|
||||
|
||||
public function getLimitsAttribute(): array
|
||||
{
|
||||
return [
|
||||
'max_photos' => $this->max_photos,
|
||||
'max_guests' => $this->max_guests,
|
||||
'gallery_days' => $this->gallery_days,
|
||||
'max_tasks' => $this->max_tasks,
|
||||
'max_events_per_year' => $this->max_events_per_year,
|
||||
];
|
||||
}
|
||||
}
|
||||
87
app/Models/PackagePurchase.php
Normal file
87
app/Models/PackagePurchase.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PackagePurchase extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'package_purchases';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'event_id',
|
||||
'package_id',
|
||||
'provider_id',
|
||||
'price',
|
||||
'type',
|
||||
'metadata',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'refunded',
|
||||
'purchased_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'purchased_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
'refunded' => 'boolean',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function package(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Package::class);
|
||||
}
|
||||
|
||||
public function isEndcustomerEvent(): bool
|
||||
{
|
||||
return $this->type === 'endcustomer_event';
|
||||
}
|
||||
|
||||
public function isResellerSubscription(): bool
|
||||
{
|
||||
return $this->type === 'reseller_subscription';
|
||||
}
|
||||
|
||||
public function isRefunded(): bool
|
||||
{
|
||||
return $this->refunded;
|
||||
}
|
||||
|
||||
public function getMetadataAttribute($value)
|
||||
{
|
||||
return $value ? json_decode($value, true) : [];
|
||||
}
|
||||
|
||||
public function setMetadataAttribute($value)
|
||||
{
|
||||
$this->attributes['metadata'] = is_array($value) ? json_encode($value) : $value;
|
||||
}
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($purchase) {
|
||||
if (!$purchase->purchased_at) {
|
||||
$purchase->purchased_at = now();
|
||||
}
|
||||
$purchase->refunded = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,6 @@ class Tenant extends Model
|
||||
'features' => 'array',
|
||||
'settings' => 'array',
|
||||
'last_activity_at' => 'datetime',
|
||||
'event_credits_balance' => 'integer',
|
||||
'subscription_tier' => 'string',
|
||||
'subscription_expires_at' => 'datetime',
|
||||
'total_revenue' => 'decimal:2',
|
||||
'settings_updated_at' => 'datetime',
|
||||
];
|
||||
@@ -46,17 +43,38 @@ class Tenant extends Model
|
||||
|
||||
public function purchases(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseHistory::class);
|
||||
return $this->hasMany(PackagePurchase::class);
|
||||
}
|
||||
|
||||
public function eventPurchases(): HasMany
|
||||
public function tenantPackages(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventPurchase::class);
|
||||
return $this->hasMany(TenantPackage::class);
|
||||
}
|
||||
|
||||
public function creditsLedger(): HasMany
|
||||
public function activeResellerPackage()
|
||||
{
|
||||
return $this->hasMany(EventCreditsLedger::class);
|
||||
return $this->tenantPackages()->where('active', true)->first();
|
||||
}
|
||||
|
||||
public function canCreateEvent(): bool
|
||||
{
|
||||
$package = $this->activeResellerPackage();
|
||||
if (!$package) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $package->canCreateEvent();
|
||||
}
|
||||
|
||||
public function incrementUsedEvents(int $amount = 1): bool
|
||||
{
|
||||
$package = $this->activeResellerPackage();
|
||||
if (!$package) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$package->increment('used_events', $amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function setSettingsAttribute($value): void
|
||||
@@ -72,88 +90,7 @@ class Tenant extends Model
|
||||
public function activeSubscription(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->subscription_expires_at && $this->subscription_expires_at->isFuture(),
|
||||
get: fn () => $this->activeResellerPackage() !== null,
|
||||
);
|
||||
}
|
||||
|
||||
public function decrementCredits(int $amount, string $reason = 'event_create', ?string $note = null, ?int $relatedPurchaseId = null): bool
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$operation = function () use ($amount, $reason, $note, $relatedPurchaseId) {
|
||||
$locked = static::query()
|
||||
->whereKey($this->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $locked || $locked->event_credits_balance < $amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
EventCreditsLedger::create([
|
||||
'tenant_id' => $this->id,
|
||||
'delta' => -$amount,
|
||||
'reason' => $reason,
|
||||
'related_purchase_id' => $relatedPurchaseId,
|
||||
'note' => $note,
|
||||
]);
|
||||
|
||||
$locked->event_credits_balance -= $amount;
|
||||
$locked->save();
|
||||
|
||||
$this->event_credits_balance = $locked->event_credits_balance;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return $this->runCreditOperation($operation);
|
||||
}
|
||||
|
||||
public function incrementCredits(int $amount, string $reason = 'manual_adjust', ?string $note = null, ?int $relatedPurchaseId = null): bool
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$operation = function () use ($amount, $reason, $note, $relatedPurchaseId) {
|
||||
$locked = static::query()
|
||||
->whereKey($this->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $locked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
EventCreditsLedger::create([
|
||||
'tenant_id' => $this->id,
|
||||
'delta' => $amount,
|
||||
'reason' => $reason,
|
||||
'related_purchase_id' => $relatedPurchaseId,
|
||||
'note' => $note,
|
||||
]);
|
||||
|
||||
$locked->event_credits_balance += $amount;
|
||||
$locked->save();
|
||||
|
||||
$this->event_credits_balance = $locked->event_credits_balance;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return $this->runCreditOperation($operation);
|
||||
}
|
||||
|
||||
private function runCreditOperation(callable $operation): bool
|
||||
{
|
||||
$connection = DB::connection();
|
||||
|
||||
if ($connection->transactionLevel() > 0) {
|
||||
return (bool) $operation();
|
||||
}
|
||||
|
||||
return (bool) $connection->transaction($operation);
|
||||
}
|
||||
}
|
||||
|
||||
93
app/Models/TenantPackage.php
Normal file
93
app/Models/TenantPackage.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class TenantPackage extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'tenant_packages';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'package_id',
|
||||
'purchased_price',
|
||||
'purchased_at',
|
||||
'expires_at',
|
||||
'used_events',
|
||||
'active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'purchased_price' => 'decimal:2',
|
||||
'purchased_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'used_events' => 'integer',
|
||||
'active' => 'boolean',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function package(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Package::class);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->active && (!$this->expires_at || $this->expires_at->isFuture());
|
||||
}
|
||||
|
||||
public function canCreateEvent(): bool
|
||||
{
|
||||
if (!$this->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->package->isReseller()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxEvents = $this->package->max_events_per_year ?? 0;
|
||||
return $this->used_events < $maxEvents;
|
||||
}
|
||||
|
||||
public function getRemainingEventsAttribute(): int
|
||||
{
|
||||
if (!$this->package->isReseller()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$max = $this->package->max_events_per_year ?? 0;
|
||||
return max(0, $max - $this->used_events);
|
||||
}
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($tenantPackage) {
|
||||
if (!$tenantPackage->purchased_at) {
|
||||
$tenantPackage->purchased_at = now();
|
||||
}
|
||||
if (!$tenantPackage->expires_at && $tenantPackage->package) {
|
||||
$tenantPackage->expires_at = now()->addYear(); // Standard für Reseller
|
||||
}
|
||||
$tenantPackage->active = true;
|
||||
});
|
||||
|
||||
static::updating(function ($tenantPackage) {
|
||||
if ($tenantPackage->isDirty('expires_at') && $tenantPackage->expires_at->isPast()) {
|
||||
$tenantPackage->active = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user