Muzibu.com Multi-Tenant SaaS - Abonelik ve Cihaz Yönetim Sistemi
Sistemde bir kullanıcının premium üye olup olmadığını kontrol etmek için tek bir kaynak vardır:
users.subscription_expires_at alanı.
Tek bir alan (subscription_expires_at) üzerinden kontrol yapmak, sistem performansını artırır ve karmaşıklığı azaltır. Her seferinde subscriptions tablosunu sorgulamaya gerek kalmaz. Kullanıcı tablosundan tek bir date kontrolü yeterlidir.
📁 app/Models/User.php (lines 463-481)
/**
* Premium üye mi?
* 🔴 TEK KAYNAK: users.subscription_expires_at
*/
public function isPremium(): bool {
// Tenant yoksa false
if (!$this->isMuzibuTenant()) {
return false;
}
// 1. Model değeri (hızlı)
if ($this->subscription_expires_at && $this->subscription_expires_at->isFuture()) {
return true;
}
// 2. Fresh DB kontrolü (model stale olabilir)
$freshExpiry = \DB::table('users')
->where('id', $this->id)
->value('subscription_expires_at');
return $freshExpiry && \Carbon\Carbon::parse($freshExpiry)->isFuture();
}
📌 Kullanım:
@if(auth()->user()->isPremium())
<span class="badge-premium">Premium Üye</span>
@else
<a href="{{ route('subscription.plans') }}">Premium Ol</a>
@endif
📁 Modules/Subscription/App/Services/SubscriptionService.php (lines 314-356)
/**
* Kullanıcı erişim kontrolü (request-level cache ile)
*/
public function checkUserAccess(User $user): array {
$cacheKey = 'user_' . $user->id;
// Request cache'den al (3 query → 1 query optimizasyonu)
if (isset(self::$accessCache[$cacheKey])) {
return self::$accessCache[$cacheKey];
}
// 🔴 SINGLE SOURCE OF TRUTH
$expiresAt = $user->subscription_expires_at;
if ($expiresAt && $expiresAt->isFuture()) {
$daysRemaining = (int) now()->diffInDays($expiresAt, false);
// Trial mi yoksa normal premium mi?
$isTrial = Subscription::where('user_id', $user->id)
->where('status', 'trial')
->whereNotNull('trial_ends_at')
->where('trial_ends_at', '>', now())
->exists();
$result = [
'status' => 'unlimited',
'is_trial' => $isTrial,
'expires_at' => $expiresAt,
'days_remaining' => max(0, $daysRemaining),
];
} else {
$result = [
'status' => 'subscription_required',
'message' => 'Müzik dinlemek için premium üyelik gereklidir',
'expires_at' => null,
'days_remaining' => 0,
];
}
// Request cache'e kaydet
self::$accessCache[$cacheKey] = $result;
return $result;
}
📊 users tablosu (Tenant DB) ┌──────────────────────────┬───────────┬──────────────────────┐ │ Kolon │ Tip │ Açıklama │ ├──────────────────────────┼───────────┼──────────────────────┤ │ id │ bigint │ PK │ │ email │ varchar │ Unique │ │ subscription_expires_at │ timestamp │ 🔴 SINGLE SOURCE │ │ device_limit │ int │ Override (nullable) │ │ has_used_trial │ boolean │ Trial kullanıldı mı │ └──────────────────────────┴───────────┴──────────────────────┘ 🎯 KRITIK: - subscription_expires_at NULL = Abonelik yok - subscription_expires_at > NOW() = Premium - subscription_expires_at < NOW() = Süresi dolmuş
Her kullanıcının bir veya birden fazla abonelik kaydı (subscription) olabilir. Her abonelik bir plana (SubscriptionPlan) bağlıdır ve bir durum içerir.
Abonelik devam ediyor, premium özellikler kullanılabilir
Ücretsiz deneme süresi, premium özellikler kullanılabilir
Gelecekte başlayacak (chain sistemi), henüz aktif değil
Sipariş oluşturuldu, ödeme henüz tamamlanmadı
Abonelik süresi bitmiş, yenileme gerekli
Kullanıcı tarafından iptal edildi
Geçici olarak durdurulmuş
Plan, bir abonelik paketinin şablonudur. Örneğin: "Premium Yıllık" adında bir plan:
Plan ve subscription ayrımı sayesinde sistem çok esnek çalışır. Aynı plan binlerce kullanıcı tarafından kullanılabilir. Plan fiyatı değiştiğinde yeni abonelikler yeni fiyatı kullanır ama mevcut abonelikler etkilenmez. Her kullanıcının kendi subscription kaydı kendi süresini tutar.
📁 Modules/Subscription/App/Models/Subscription.php
class Subscription extends BaseModel {
protected $fillable = [
'user_id',
'subscription_plan_id',
'subscription_number', // Unique: SUB-XXXXX
'status', // active, trial, pending, expired, cancelled, paused
'cycle_key', // '1-month', '1-year' (dinamik)
'cycle_metadata', // Cycle bilgileri (label, duration_days, price)
'price_per_cycle',
'currency',
'has_trial',
'trial_days',
'trial_ends_at',
'current_period_start', // Bu dönemin başlangıcı
'current_period_end', // Bu dönemin bitişi (ÖNEMLİ!)
'next_billing_date',
'auto_renew',
'metadata', // Extra bilgiler (order_id, vb.)
];
// İlişkiler
public function user() { return $this->belongsTo(User::class); }
public function plan() { return $this->belongsTo(SubscriptionPlan::class); }
// Scope'lar
public function scopeActive($query) {
return $query->where('status', 'active')
->where(function($q) {
$q->whereNull('current_period_end')
->orWhere('current_period_end', '>', now());
});
}
// Status Checks
public function isActive(): bool {
return $this->status === 'active' &&
($this->current_period_end === null || $this->current_period_end->isFuture());
}
public function isTrial(): bool {
return $this->status === 'trial' &&
$this->trial_ends_at !== null &&
$this->trial_ends_at->isFuture();
}
public function daysRemaining(): int {
if ($this->isTrial() && $this->trial_ends_at) {
return max(0, (int) floor(now()->diffInDays($this->trial_ends_at, false)));
}
if ($this->current_period_end) {
return max(0, (int) floor(now()->diffInDays($this->current_period_end, false)));
}
return 0;
}
// Cache temizleme (subscription değişince otomatik)
protected static function boot() {
parent::boot();
$clearPremiumCache = function ($subscription) {
if ($subscription->user_id) {
\Cache::forget('user_' . $subscription->user_id . '_is_premium_tenant_1001');
}
};
static::created($clearPremiumCache);
static::updated($clearPremiumCache);
static::deleted($clearPremiumCache);
}
}
📁 Modules/Subscription/App/Models/SubscriptionPlan.php
class SubscriptionPlan extends BaseModel {
protected $fillable = [
'title', // ['tr' => 'Premium Yıllık', 'en' => 'Premium Yearly']
'slug', // premium-yearly
'description', // Çoklu dil açıklama
'features', // ['Reklamsız', 'Offline', 'HD Kalite']
'billing_cycles', // 🔥 DİNAMİK CYCLES
'device_limit', // Bu planın cihaz limiti
'trial_days', // Deneme süresi (gün)
'is_trial', // Trial planı mı?
'is_featured', // Öne çıkan plan mı?
'is_active',
'is_public',
];
// Dinamik Billing Cycles Örneği:
'billing_cycles' => [
'1-month' => [
'label' => ['tr' => '1 Ay', 'en' => '1 Month'],
'duration_days' => 30,
'price' => 29.90,
'price_type' => 'without_tax', // KDV hariç
'sort_order' => 1,
],
'1-year' => [
'label' => ['tr' => '1 Yıl', 'en' => '1 Year'],
'duration_days' => 365,
'price' => 240.00,
'price_type' => 'without_tax',
'sort_order' => 2,
],
'2-year' => [
'label' => ['tr' => '2 Yıl', 'en' => '2 Years'],
'duration_days' => 730,
'price' => 400.00,
'price_type' => 'without_tax',
'sort_order' => 3,
],
]
// Cycle metodları
public function getCyclePrice(string $cycleKey): ?float {
$cycles = $this->billing_cycles ?? [];
return isset($cycles[$cycleKey]['price']) ? (float) $cycles[$cycleKey]['price'] : null;
}
public function getCycleBasePrice(string $cycleKey): ?float {
// KDV hariç fiyat (Cart için)
$cycle = $this->getCycle($cycleKey);
$price = (float) $cycle['price'];
$priceType = $cycle['price_type'] ?? 'without_tax';
if ($priceType === 'with_tax') {
// KDV'yi ayrıştır
return $price / (1 + ($this->tax_rate ?? 20.0) / 100);
}
return $price; // Zaten KDV hariç
}
public function getCyclePriceWithTax(string $cycleKey): ?float {
// KDV dahil fiyat (gösterim için)
$basePrice = $this->getCycleBasePrice($cycleKey);
return $basePrice * (1 + ($this->tax_rate ?? 20.0) / 100);
}
}
📊 subscriptions tablosu (Tenant DB)
┌──────────────────────────┬───────────┬──────────────────────────┐
│ Kolon │ Tip │ Açıklama │
├──────────────────────────┼───────────┼──────────────────────────┤
│ subscription_id │ bigint │ PK │
│ user_id │ bigint │ FK users │
│ subscription_plan_id │ bigint │ FK subscription_plans │
│ subscription_number │ varchar │ Unique: SUB-678ABCD │
│ status │ varchar │ active/trial/pending... │
│ cycle_key │ varchar │ '1-month', '1-year' │
│ cycle_metadata │ json │ {label, duration, price} │
│ price_per_cycle │ decimal │ Bu abonelik için fiyat │
│ current_period_start │ timestamp │ Dönem başlangıcı │
│ current_period_end │ timestamp │ Dönem bitişi (ÖNEMLİ) │
│ trial_ends_at │ timestamp │ Deneme bitişi │
│ has_trial │ boolean │ Trial kullanıldı mı │
│ metadata │ json │ {order_id: 123} │
└──────────────────────────┴───────────┴──────────────────────────┘
📊 subscription_plans tablosu (Tenant DB)
┌──────────────────────────┬───────────┬──────────────────────────┐
│ subscription_plan_id │ bigint │ PK │
│ title │ json │ {'tr': 'Premium Yıllık'} │
│ billing_cycles │ json │ Dinamik cycles (yukarıda)│
│ device_limit │ int │ Cihaz limiti │
│ trial_days │ int │ Deneme süresi │
│ is_trial │ boolean │ Trial planı mı? │
│ is_featured │ boolean │ Öne çıkan mı? │
└──────────────────────────┴───────────┴──────────────────────────┘
Kullanıcılar birden fazla abonelik satın alabilir ve bu abonelikler zincir şeklinde birbirine bağlanır. Yani bir abonelik bittikten sonra otomatik olarak bir sonraki abonelik başlar.
Zincir sistemi sayesinde kullanıcılar istedikleri kadar ileriye dönük abonelik satın alabilir. Özel günlerde (yılbaşı, kampanyalar) çok sayıda abonelik alıp yıllarca premium kalabilirler. Sistem otomatik olarak sırayı yönetir, hiçbir süre kaybolmaz.
📁 app/Models/User.php (lines 506-573)
/**
* Kullanıcının subscription_expires_at değerini yeniden hesapla
* Tüm active ve pending subscription'ların en son bitiş tarihini bul
*/
public function recalculateSubscriptionExpiry(): void {
$connection = $this->getConnectionName();
if (!$connection) return;
// Sadece ÖDENMİŞ veya MANUEL abonelikleri dahil et
$validSubscriptions = $this->subscriptions()
->whereIn('status', ['active', 'pending'])
->get()
->filter(function ($sub) {
$orderId = $sub->metadata['order_id'] ?? null;
// Order yoksa = manuel oluşturulmuş, dahil et
if (!$orderId) return true;
// Order varsa ödeme durumunu kontrol et
if (class_exists(\Modules\Cart\App\Models\Order::class)) {
$order = \Modules\Cart\App\Models\Order::find($orderId);
if ($order && in_array($order->payment_status, ['paid', 'completed'])) {
return true;
}
}
return false;
});
// En son bitiş tarihini bul
$lastSubscription = $validSubscriptions->sortByDesc('current_period_end')->first();
$expiresAt = $lastSubscription?->current_period_end;
// users.subscription_expires_at güncelle
\DB::connection($connection)
->table('users')
->where('id', $this->id)
->update(['subscription_expires_at' => $expiresAt]);
$this->subscription_expires_at = $expiresAt;
// Premium cache'i temizle
\Cache::forget('user_' . $this->id . '_is_premium_tenant_' . (tenant()?->id ?? 0));
}
📁 Modules/Subscription/App/Models/Subscription.php (lines 370-459)
/**
* Kullanıcının tüm abonelik zincirini yeniden hesapla
* Bir abonelik silindiğinde veya değiştiğinde çağrılır
*/
public static function rechainUserSubscriptions(int $userId): void {
if (!tenant()) return;
\DB::transaction(function () use ($userId) {
// Aktif aboneliği bul
$active = self::where('user_id', $userId)
->where('status', 'active')
->orderBy('current_period_end', 'desc')
->first();
if (!$active) {
// Aktif yok, sadece subscription_expires_at güncelle
$user = User::find($userId);
$user?->recalculateSubscriptionExpiry();
return;
}
// Pending abonelikleri bul (oluşturma sırasına göre)
$pendings = self::where('user_id', $userId)
->where('status', 'pending')
->orderBy('subscription_id', 'asc')
->get();
if ($pendings->isEmpty()) {
$user = User::find($userId);
$user?->recalculateSubscriptionExpiry();
return;
}
// Zinciri hesapla
$chainEnd = $active->current_period_end;
$updated = 0;
foreach ($pendings as $pending) {
// Bu aboneliğin süresi
$duration = 365; // Default
if ($pending->current_period_start && $pending->current_period_end) {
$duration = $pending->current_period_start->diffInDays($pending->current_period_end);
}
$newStart = $chainEnd->copy();
$newEnd = $chainEnd->copy()->addDays($duration);
// Tarihler farklıysa güncelle
if ($pending->current_period_start->format('Y-m-d') !== $newStart->format('Y-m-d')
|| $pending->current_period_end->format('Y-m-d') !== $newEnd->format('Y-m-d')) {
$pending->updateQuietly([
'current_period_start' => $newStart,
'current_period_end' => $newEnd,
'next_billing_date' => $newEnd,
]);
$updated++;
}
// Zinciri ilerlet
$chainEnd = $newEnd;
}
// subscription_expires_at güncelle
$user = User::find($userId);
$user?->recalculateSubscriptionExpiry();
});
}
📁 Subscription Model - getChainPositionAttribute
/**
* Subscription'ın zincirdeki sırasını al
* 1 = aktif, 2 = ilk bekleyen, 3 = ikinci bekleyen...
*/
public function getChainPositionAttribute(): int {
if ($this->status === 'active') {
return 1;
}
if ($this->status !== 'pending') {
return 0; // Zincirde değil
}
$position = self::where('user_id', $this->user_id)
->whereIn('status', ['active', 'pending'])
->where('current_period_start', '<', $this->current_period_start)
->count();
return $position + 1;
}
📌 Kullanım:
@foreach($user->subscriptions as $sub)
Sıra: {{ $sub->chain_position }}
Durum: {{ $sub->status }}
Başlangıç: {{ $sub->current_period_start }}
@endforeach
Her kullanıcının kaç cihazdan aynı anda giriş yapabileceği sınırlıdır. Bu limit 3 farklı yerden belirlenebilir ve öncelik sırası vardır:
Admin, bir kullanıcıya özel limit verebilir. Örnek: VIP kullanıcıya 10 cihaz, test kullanıcısına 99 cihaz.
Kullanıcının aktif planına göre belirlenir. Örnek: "Premium" planı = 5 cihaz, "Basic" planı = 2 cihaz.
Yukarıdakilerin hiçbiri yoksa sistem ayarı kullanılır. Örnek: Tüm ücretsiz kullanıcılar için 1 cihaz.
Kullanıcı limit aşarsa ne olur? En eski cihaz otomatik çıkış yapar!
3-tier sistem sayesinde hem genel kurallar hem de özel durumlar yönetilebilir. LIFO mekanizması sayesinde kullanıcı yeni cihazdan giriş yaptığında eski cihaz otomatik çıkış yapar, kullanıcının müdahalesine gerek kalmaz.
📁 Modules/Muzibu/App/Services/DeviceService.php (lines 537-571)
/**
* Device limit al (3-tier hierarchy)
* 🔴 SINGLE SOURCE OF TRUTH: users.subscription_expires_at
*/
public function getDeviceLimit(User $user): int {
if (!$this->shouldRun()) {
return 999; // Sistem kapalıysa sınırsız
}
// 1️⃣ User override (en yüksek öncelik)
if ($user->device_limit !== null && $user->device_limit > 0) {
return $user->device_limit;
}
// 2️⃣ Subscription Plan
$expiresAt = $user->subscription_expires_at;
$hasPremium = $expiresAt && $expiresAt->isFuture();
if ($hasPremium) {
// Plan'dan device_limit al
$subscription = $user->subscriptions()
->whereIn('status', ['active', 'trial'])
->with('plan')
->orderByDesc('created_at')
->first();
if ($subscription && $subscription->plan && $subscription->plan->device_limit) {
return (int) $subscription->plan->device_limit;
}
}
// 3️⃣ Tenant setting fallback
if (function_exists('setting') && setting('auth_device_limit')) {
return (int) setting('auth_device_limit');
}
return 1; // Son çare: 1 cihaz
}
📁 Modules/Muzibu/App/Services/DeviceService.php (lines 48-137)
/**
* Yeni session kaydet (login sonrası)
* Cookie kontrolü ile aynı tarayıcı mı yoksa yeni cihaz mı belirlenir
*/
public function registerSession(User $user): void {
$sessionId = session()->getId();
$cookieName = 'mzb_login_token';
// 🔥 1. AYNI TARAYICI MI? Cookie kontrolü
$existingToken = request()->cookie($cookieName);
if ($existingToken) {
$existingSession = DB::table('user_active_sessions')
->where('user_id', $user->id)
->where('login_token', $existingToken)
->first();
if ($existingSession) {
// ✅ AYNI TARAYICI - Sadece güncelle, yeni token oluşturma!
DB::table('user_active_sessions')
->where('id', $existingSession->id)
->update([
'session_id' => $sessionId,
'last_activity' => now(),
]);
// Cookie süresini uzat
cookie()->queue(cookie($cookieName, $existingToken, $lifetime));
return; // İŞLEM BİTTİ
}
}
// 🔥 2. FARKLI TARAYICI - Yeni session oluştur
$lock = Cache::lock("user_login:{$user->id}", 10);
$lock->get();
try {
$loginToken = bin2hex(random_bytes(32)); // 64 char
DB::table('user_active_sessions')->insert([
'user_id' => $user->id,
'session_id' => $sessionId,
'login_token' => $loginToken,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'device_type' => $this->getDeviceType($agent),
'device_name' => $this->getDeviceName($agent),
'last_activity' => now(),
]);
// Cookie oluştur
cookie()->queue(cookie($cookieName, $loginToken, $lifetime));
// 🔥 3. LIFO KONTROLÜ - Limit aşıldıysa eski cihaz çıkış yapsın
$this->enforceDeviceLimit($user, $sessionId);
} finally {
$lock->release();
}
}
protected function enforceDeviceLimit(User $user, string $currentSessionId): void {
$limit = $this->getDeviceLimit($user);
$sessions = DB::table('user_active_sessions')
->where('user_id', $user->id)
->orderBy('last_activity', 'asc') // En eski önce
->get();
if ($sessions->count() > $limit) {
$toRemove = $sessions->count() - $limit;
// En eski session'ları sil (mevcut hariç)
$oldSessions = $sessions
->filter(fn($s) => $s->session_id !== $currentSessionId)
->take($toRemove);
foreach ($oldSessions as $old) {
$this->terminateSessionAtomicByRow($old, 'lifo', $user);
}
}
}
📁 DeviceService::sessionExists() - Polling için
/**
* Session DB'de var mı? (polling için)
* Cookie'deki login_token ile DB'deki login_token eşleşirse = geçerli session
*
* LIFO ile çalışması:
* - Tab A login → token_A, cookie + DB
* - Tab B login → token_B, LIFO token_A'yı DB'den siler
* - Tab A polling → cookie'de token_A var ama DB'de YOK → LOGOUT
*/
public function sessionExists(User $user): bool {
// 1. Cookie'den login_token al
$cookieToken = request()->cookie('mzb_login_token');
if (!$cookieToken) {
return false; // Cookie yok = invalid
}
// 2. Login token ile DB'de kontrol et
$session = DB::table('user_active_sessions')
->where('user_id', $user->id)
->where('login_token', $cookieToken)
->first();
if ($session) {
// Token eşleşti - session geçerli, activity güncelle
DB::table('user_active_sessions')
->where('id', $session->id)
->update(['last_activity' => now()]);
return true;
}
// 3. Token eşleşmiyor = LIFO tarafından silindi → LOGOUT
return false;
}
📊 user_active_sessions tablosu
┌────────────────┬───────────┬─────────────────────────┐
│ Kolon │ Tip │ Açıklama │
├────────────────┼───────────┼─────────────────────────┤
│ id │ bigint │ PK │
│ user_id │ bigint │ FK users │
│ session_id │ varchar │ Laravel session ID │
│ login_token │ varchar │ 64 char unique token │
│ ip_address │ varchar │ Giriş IP │
│ user_agent │ text │ Browser bilgisi │
│ device_type │ varchar │ mobile/tablet/desktop │
│ device_name │ varchar │ Platform - Browser │
│ last_activity │ timestamp │ Son aktivite │
└────────────────┴───────────┴─────────────────────────┘
Yeni kullanıcılar ücretsiz deneme (trial) hakkına sahiptir. Deneme süresi boyunca tüm premium özellikler kullanılabilir.
Trial sistemi yeni kullanıcıların servisi denemesini sağlar. has_used_trial flag'i sayesinde her kullanıcı sadece 1 kez deneme yapabilir, sistemin kötüye kullanımı engellenir.
📁 Modules/Subscription/App/Services/SubscriptionService.php (lines 232-283)
public function createTrialForUser(User $user): ?Subscription {
// 1. Setting kontrolü
if (!setting('auth_subscription')) {
return null;
}
// 2. Trial plan kontrolü
$trialPlan = $this->getTrialPlan();
if (!$trialPlan) {
return null;
}
// 3. has_used_trial kontrolü
if ($user->has_used_trial) {
return null; // Trial hakkı tükendi
}
// 4. Subscription oluştur
$duration = $this->getTrialDuration(); // Trial plan'dan al (örn: 7 gün)
$cycles = $trialPlan->billing_cycles;
$firstCycle = array_values($cycles)[0];
$subscription = Subscription::create([
'user_id' => $user->id,
'subscription_plan_id' => $trialPlan->subscription_plan_id,
'status' => 'active', // Trial da active sayılır
'started_at' => now(),
'current_period_start' => now(),
'current_period_end' => now()->addDays($duration),
'price_per_cycle' => 0, // Trial ücretsiz
'currency' => $trialPlan->currency ?? 'TRY',
'cycle_key' => array_keys($cycles)[0] ?? 'deneme-7-gun',
'cycle_metadata' => $firstCycle,
'has_trial' => true,
'trial_days' => $duration,
'trial_ends_at' => now()->addDays($duration),
]);
// 5. has_used_trial = true
$user->update(['has_used_trial' => true]);
return $subscription;
}
/**
* Kullanıcı daha önce trial kullandı mı?
*/
public static function userHasUsedTrial(int $userId): bool {
return self::where('user_id', $userId)
->where('has_trial', true)
->where('status', '!=', 'pending_payment') // Pending olanları sayma
->exists();
}
📊 Trial Plan Örneği (subscription_plans tablosu)
{
"subscription_plan_id": 1,
"title": {"tr": "Ücretsiz Deneme", "en": "Free Trial"},
"slug": "free-trial",
"is_trial": true, // 🔴 KRİTİK: Trial planı olduğunu belirtir
"is_public": true,
"is_active": true,
"billing_cycles": {
"deneme-7-gun": {
"label": {"tr": "7 Gün Deneme", "en": "7 Days Trial"},
"duration_days": 7,
"price": 0, // Ücretsiz
"sort_order": 1
}
},
"device_limit": 3, // Trial sırasında 3 cihaz
"features": ["Reklamsız", "HD Kalite", "Offline"]
}
📌 Kodda Kullanım:
$trialPlan = SubscriptionPlan::where('is_trial', true)
->where('is_active', true)
->first();
📊 users tablosu ┌──────────────────┬──────────┬────────────────────────────┐ │ has_used_trial │ boolean │ Trial hakkı kullanıldı mı │ └──────────────────┴──────────┴────────────────────────────┘ 📊 subscriptions tablosu ┌──────────────────┬──────────┬────────────────────────────┐ │ has_trial │ boolean │ Bu subscription trial mı │ │ trial_days │ int │ Trial süresi (gün) │ │ trial_ends_at │ timestamp│ Trial bitiş tarihi │ │ status │ varchar │ trial / active / expired │ └──────────────────┴──────────┴────────────────────────────┘ 📊 subscription_plans tablosu ┌──────────────────┬──────────┬────────────────────────────┐ │ is_trial │ boolean │ Trial planı mı │ │ trial_days │ int │ Default trial süresi │ └──────────────────┴──────────┴────────────────────────────┘
Kurumsal hesaplar, bir şirketin birden fazla çalışanına toplu abonelik vermesi için tasarlanmıştır. Sadece Muzibu.com tenant'ında (tenant_id: 1001) kullanılır.
Kurumsal sistem sayesinde şirketler toplu alım yapıp çalışanlarına premium hizmet verebilir. Her çalışan ayrı hesap açmak zorunda kalmaz, merkezi yönetim sağlanır.
📁 Modules/Muzibu/App/Models/MuzibuCorporateAccount.php
class MuzibuCorporateAccount extends Model {
protected $fillable = [
'user_id', // Kurum sahibi
'parent_id', // Ana şube (NULL ise ana firma)
'corporate_code', // Davet kodu (8 char)
'company_name', // Şirket adı
'branch_name', // Şube adı
'is_active',
// Spot sistemi
'spot_enabled', // Spot aktif mi
'spot_songs_between', // Kaç şarkıda bir spot
'spot_current_index', // Mevcut spot sırası
'spot_is_paused', // Spot duraklatıldı mı
];
// İlişkiler
public function owner(): BelongsTo {
return $this->belongsTo(User::class, 'user_id');
}
public function parent(): BelongsTo {
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany {
return $this->hasMany(self::class, 'parent_id'); // Şubeler
}
public function spots(): HasMany {
return $this->hasMany(CorporateSpot::class);
}
// Helper metodlar
public function isParent(): bool {
return $this->parent_id === null;
}
public static function generateCode(): string {
do {
$code = strtoupper(substr(md5(uniqid()), 0, 8));
} while (self::where('corporate_code', $code)->exists());
return $code; // Örn: ABC12345
}
public static function getCorporateForUser(int $userId): ?self {
$record = self::where('user_id', $userId)->first();
if (!$record) return null;
// Üyeyse parent kurumu döndür
if ($record->parent_id) {
return $record->parent;
}
// Kurum sahibiyse kendisini döndür
return $record;
}
}
📁 app/Models/User.php (lines 248-447)
// Corporate Account ilişkileri (Sadece Muzibu)
public function corporateAccount() {
if (!$this->isMuzibuTenant()) {
return $this->belongsTo(self::class, 'id')->whereRaw('1=0'); // Empty
}
return $this->belongsTo(MuzibuCorporateAccount::class);
}
public function ownedCorporateAccount() {
if (!$this->isMuzibuTenant()) {
return $this->hasOne(self::class, 'id')->whereRaw('1=0');
}
return $this->hasOne(MuzibuCorporateAccount::class, 'user_id');
}
// Kurum kontrolü
public function isCorporateOwner(): bool {
if (!$this->isMuzibuTenant()) return false;
return $this->ownedCorporateAccount()->exists();
}
public function isCorporateMember(): bool {
if (!$this->isMuzibuTenant()) return false;
return $this->corporate_account_id !== null;
}
// Effective Subscription (kurum üyesi ise owner'ın subscription'ını al)
public function getEffectiveSubscription() {
if (!$this->isMuzibuTenant()) {
return $this->subscriptions()->active()->first();
}
// Kurum üyesi ise owner'ın subscription'ını kullan
if ($this->isCorporateMember() && $this->corporateAccount) {
return $this->corporateAccount->owner->subscriptions()->active()->first();
}
// Normal kullanıcı
return $this->subscriptions()->active()->first();
}
📊 muzibu_corporate_accounts tablosu (Tenant 1001) ┌────────────────────────┬──────────┬──────────────────────────┐ │ Kolon │ Tip │ Açıklama │ ├────────────────────────┼──────────┼──────────────────────────┤ │ id │ bigint │ PK │ │ user_id │ bigint │ FK users (owner) │ │ parent_id │ bigint │ Ana şube (NULL = owner) │ │ corporate_code │ varchar │ Davet kodu (ABC12345) │ │ company_name │ varchar │ Şirket adı │ │ branch_name │ varchar │ Şube adı │ │ is_active │ boolean │ Aktif mi │ │ spot_enabled │ boolean │ Spot sistemi aktif mi │ │ spot_songs_between │ int │ Kaç şarkıda bir spot │ │ spot_current_index │ int │ Mevcut spot sırası │ │ spot_is_paused │ boolean │ Spot duraklatıldı mı │ └────────────────────────┴──────────┴──────────────────────────┘ 📊 users tablosu eklentisi ┌────────────────────────┬──────────┬──────────────────────────┐ │ corporate_account_id │ bigint │ FK muzibu_corporate_... │ └────────────────────────┴──────────┴──────────────────────────┘ 🎯 İlişki: - Owner: corporate_account_id = NULL, ownedCorporateAccount var - Member: corporate_account_id = X, corporateAccount var