File: /home/xedaptot/ai.naniguide.com/app/Model/Plan.php
<?php
/**
* Plan — flat model (no polymorphism, no plans.options JSON, no plans.type).
*
* All plan capabilities live in the 4-concept tables:
* - plan_credits (consumable cycle caps)
* - plan_quotas (static resource limits)
* - plan_entitlements (boolean capabilities, explicit enabled flag)
* - plan_rate_limits (time-bounded throttle)
*
* See docs/payment-order-plan-subscription-saas/SUBSCRIPTION-COMPREHENSIVE-DESIGN.md.
*/
namespace App\Model;
use App\Library\Traits\HasUid;
use App\Services\Plans\Credits\CreditKey;
use App\Services\Plans\Entitlements\EntitlementKey;
use App\Services\Plans\Quotas\QuotaKey;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Plan extends Model
{
use HasFactory;
use HasUid;
protected $connection = 'mysql';
protected $table = 'plans';
protected $guarded = [];
public const STATUS_INACTIVE = 'inactive';
public const STATUS_ACTIVE = 'active';
public const UNLIMITED_TIME = 'unlimited';
protected static function newFactory()
{
return \Database\Factories\PlanFactory::new();
}
public static function findByUid($uid)
{
return self::where('uid', $uid)->first();
}
// =========================================================================
// RELATIONS
// =========================================================================
public function admin() { return $this->belongsTo(Admin::class); }
public function currency() { return $this->belongsTo(Currency::class); }
public function subscriptions() { return $this->hasMany(Subscription::class, 'plan_id'); }
public function activeSubscriptions() { return $this->subscriptions()->active(); }
public function remoteMappings() { return $this->hasMany(PlanRemoteMapping::class, 'plan_id'); }
public function plansSendingServers() { return $this->hasMany(PlansSendingServer::class, 'plan_id'); }
public function plansEmailVerificationServers() { return $this->hasMany(PlansEmailVerificationServer::class, 'plan_id'); }
public function emailVerificationServers()
{
return $this->belongsToMany(
EmailVerificationServer::class,
'plans_email_verification_servers',
'plan_id',
'server_id'
);
}
public function sendingServers()
{
return SendingServer::whereIn('id', $this->plansSendingServers()->pluck('sending_server_id')->toArray());
}
public function activeSendingServers()
{
return $this->plansSendingServers()
->join('sending_servers', 'sending_servers.id', 'plans_sending_servers.sending_server_id');
}
public function getRemoteMappingForGateway(PaymentGateway $gateway): ?PlanRemoteMapping
{
return $this->remoteMappings()->where('payment_gateway_id', $gateway->id)->first();
}
public function hasRemoteMappings(): bool
{
return $this->remoteMappings()->exists();
}
// =========================================================================
// SCOPES
// =========================================================================
public function scopeActive($query) { return $query->where('plans.status', self::STATUS_ACTIVE); }
public function scopeAvailable($query) { return $query->where('plans.visible', true); }
public static function scopeFilter($query, $request)
{
$query = $query->select('plans.*');
if (!empty($request->admin_id)) {
$query = $query->where('plans.admin_id', $request->admin_id);
}
return $query;
}
public static function scopeSearch($query, $keyword)
{
if (!empty(trim($keyword))) {
foreach (explode(' ', trim($keyword)) as $k) {
$query = $query->where(function ($q) use ($k) {
$q->orWhere('plans.name', 'like', "%{$k}%");
});
}
}
return $query;
}
// =========================================================================
// STATE / STATUS
// =========================================================================
public function isActive(): bool { return $this->status === self::STATUS_ACTIVE; }
public function isFree(): bool { return $this->price == 0; }
public function isUnlimited(): bool { return $this->frequency_unit === self::UNLIMITED_TIME; }
public function canRenew(): bool { return $this->price > 0 || $this->isFree(); }
public function periodEndsAt($startDate)
{
return getPeriodEndsAt($startDate, $this->frequency_amount, $this->frequency_unit);
}
public function trialPeriodEndsAt($startDate)
{
return getPeriodEndsAt($startDate, $this->trial_amount, $this->trial_unit);
}
public function visibleOff() { $this['visible'] = 0; $this->save(); }
public function visibleOn() { $this['visible'] = true; $this->save(); }
public function disable()
{
$this->status = self::STATUS_INACTIVE;
$this['visible'] = false;
return $this->save();
}
public function enable()
{
$this->status = self::STATUS_ACTIVE;
return $this->save();
}
public function isValid(): bool
{
if ($this->useSystemSendingServer() && !$this->hasPrimarySendingServer()) {
return false;
}
return true;
}
public function checkStatus()
{
if (!$this->isValid()) {
$this->disable();
$this->visibleOff();
} else {
$this->enable();
}
}
public function copy($name)
{
$copy = $this->replicate(['cache', 'last_error', 'run_at']);
$copy->uid = uniqid();
$copy->name = $name;
$copy->created_at = \Carbon\Carbon::now();
$copy->updated_at = \Carbon\Carbon::now();
$copy->status = self::STATUS_ACTIVE;
$copy->save();
$copy->checkStatus();
}
// =========================================================================
// GETTERS
// =========================================================================
public function getPrice() { return $this->price; }
public function getFormattedPrice() { return \App\Library\Tool::format_price($this->price, $this->currency->format); }
public function getFrequencyAmount() { return $this->frequency_amount; }
public function getFrequencyUnit() { return $this->frequency_unit; }
public function getTrialAmount() { return $this->trial_amount; }
public function getTrialUnit() { return $this->trial_unit; }
public function hasTrial(): bool { return $this->trial_amount > 0; }
public function subscriptionsCount() { return $this->subscriptions()->count(); }
public function customersCount() { return $this->activeSubscriptions()->count(); }
public function getTrialPeriodTimePhrase()
{
return trans_choice('messages.' . $this->getTrialUnit() . '.choice', $this->getTrialAmount(), [
'amount' => $this->trial_amount,
]);
}
public function displayFrequencyTime()
{
return trans('messages.plan.period', [
'amount' => number_with_delimiter($this->getFrequencyAmount()),
'unit' => trans('messages.' . \App\Library\Tool::getPluralPrase($this->getFrequencyUnit(), $this->getFrequencyAmount())),
]);
}
/**
* Human-readable cap for a credit (4-state, per SUBSCRIPTION-COMPREHENSIVE-DESIGN §5.5):
* - no plan_credits row → "Not included" (canUse returns false at runtime)
* - row + NULL amount → "Unlimited"
* - row + int 0 → "0" (supported but exhausted — upsell prompt)
* - row + int > 0 → "10,000"
*/
public function displayCredit(CreditKey $key): string
{
$amount = app(\App\Services\Plans\Credits\CreditsService::class)->creditForPlan($this, $key);
if ($amount === false) return trans('messages.not_included');
if ($amount === null) return trans('messages.unlimited');
return number_with_delimiter($amount); // includes 0 (supported but exhausted)
}
/**
* Human-readable limit for a quota (4-state, per SUBSCRIPTION-COMPREHENSIVE-DESIGN §5.5):
* - no plan_quotas row → "Not included" (canCreate/canAccept returns false at runtime)
* - row + NULL limit → "Unlimited"
* - row + int 0 → "0" (granted but zero allocation)
* - row + int > 0 → "5,000"
*/
public function displayQuota(QuotaKey $key): string
{
$limit = app(\App\Services\Plans\Quotas\QuotasService::class)->limitForPlan($this, $key);
if ($limit === false) return trans('messages.not_included');
if ($limit === null) return trans('messages.unlimited');
return number_with_delimiter($limit); // includes 0
}
// =========================================================================
// ENTITLEMENT-BACKED SENDING SERVER HELPERS
// =========================================================================
/**
* Whether the plan allows customers to bring their own sending servers.
* Backed by HAS_OWN_SENDING_SERVER entitlement (plan_entitlements table).
*/
public function useOwnSendingServer(): bool
{
return $this->hasEntitlement(EntitlementKey::HAS_OWN_SENDING_SERVER);
}
/**
* Whether the plan forces customers onto the system's primary sending server.
* Inverse of useOwnSendingServer by convention.
*/
public function useSystemSendingServer(): bool
{
return !$this->useOwnSendingServer();
}
private function hasEntitlement(EntitlementKey $key): bool
{
return app(\App\Services\Plans\Entitlements\EntitlementsService::class)->hasForPlan($this, $key);
}
public function hasPrimarySendingServer(): bool
{
return !is_null($this->primarySendingServer());
}
public function primarySendingServer()
{
if (!$this->useSystemSendingServer()) {
throw new \Exception('ACELLE ERROR: 120000700392');
}
$pss = $this->plansSendingServers()->where('is_primary', true)->first();
return $pss ? $pss->sendingServer : null;
}
// getVerifiedIdentities() removed — call
// app(\App\SendingServers\Identities\IdentityCatalog::class)->valuesForPlan($plan)
public function addSendingServerByUid($uid)
{
$server = SendingServer::findByUid($uid);
$row = new PlansSendingServer();
$row->plan_id = $this->id;
$row->sending_server_id = $server->id;
$row->fitness = '50';
if (!$this->plansSendingServers()->where('is_primary', true)->count()) {
$row->is_primary = true;
}
$row->save();
}
public function removeSendingServerByUid($uid)
{
$server = SendingServer::findByUid($uid);
$this->plansSendingServers()->where('sending_server_id', $server->id)->delete();
$query = $this->plansSendingServers()->where('is_primary', true);
if (!$query->count()) {
$first = $this->plansSendingServers()->first();
if ($first) {
$first->is_primary = true;
$first->save();
}
}
}
public function setPrimarySendingServer($uid)
{
$this->plansSendingServers()->update(['is_primary' => false]);
$server = SendingServer::findByUid($uid);
$this->plansSendingServers()->where('sending_server_id', $server->id)->update(['is_primary' => true]);
}
public function updateFitnesses($hash)
{
foreach ($hash as $uid => $value) {
$server = SendingServer::findByUid($uid);
PlansSendingServer::where('sending_server_id', $server->id)->update(['fitness' => $value]);
}
}
public function updateSendingServers($servers)
{
$this->plansSendingServers()->delete();
foreach ($servers as $key => $param) {
if ($param['check']) {
$server = SendingServer::findByUid($key);
$row = new PlansSendingServer();
$row->plan_id = $this->id;
$row->sending_server_id = $server->id;
$row->fitness = $param['fitness'];
$row->save();
}
}
}
public function allowSenderVerification()
{
if (!$this->useSystemSendingServer()) {
return true;
}
$server = $this->primarySendingServer();
if (!$server) {
return false;
}
$driver = $server->driver();
return $driver instanceof \App\SendingServers\Capabilities\SupportsIdentitySync
|| $driver instanceof \App\SendingServers\Capabilities\AllowsLocalEmailVerify;
}
// =========================================================================
// EMAIL VERIFICATION SERVER PLAN HELPERS
// =========================================================================
public function activePlansEmailVerificationServers()
{
return $this->plansEmailVerificationServers();
}
public function getEmailVerificationServers()
{
if ($this->hasEntitlement(EntitlementKey::HAS_ALL_EMAIL_VERIFICATION_SERVERS)) {
return EmailVerificationServer::getAllAdminActive()->get()->map(fn ($s) => $s);
}
return $this->activePlansEmailVerificationServers()->get()->map(fn ($r) => $r->emailVerificationServer);
}
public function fillPlansEmailVerificationServers($params)
{
$this->plansEmailVerificationServers = collect([]);
foreach ($params as $key => $param) {
if ($param['check']) {
$server = EmailVerificationServer::findByUid($key);
$row = new PlansEmailVerificationServer();
$row->plan_id = $this->id;
$row->server_id = $server->id;
$this->plansEmailVerificationServers->push($row);
}
}
}
public function updateEmailVerificationServers($servers)
{
$this->plansEmailVerificationServers()->delete();
foreach ($servers as $key => $param) {
if ($param['check']) {
$server = EmailVerificationServer::findByUid($key);
$row = new PlansEmailVerificationServer();
$row->plan_id = $this->id;
$row->server_id = $server->id;
$row->save();
}
}
}
public function getItsOnlyEmailVerificationServer()
{
if ($this->emailVerificationServers()->count() > 1) {
throw new \Exception("There are more than one email verification server of the plan {$this->name}");
}
return $this->emailVerificationServers()->first();
}
public function setItsOnlyEmailVerificationServer($server)
{
$this->emailVerificationServers()->detach();
$this->emailVerificationServers()->attach($server->id);
}
// =========================================================================
// STATIC FACTORIES / QUERIES
// =========================================================================
public static function newDefaultPlan()
{
$plan = new self([
'price' => 0,
'frequency_amount' => 1,
'frequency_unit' => 'month',
'trial_amount' => 0,
'trial_unit' => 'day',
]);
$plan->status = self::STATUS_INACTIVE;
return $plan;
}
public static function getAvailablePlans()
{
return self::active()->available()->get();
}
public static function billingCycleValues(): array
{
return [
'daily' => ['frequency_amount' => 1, 'frequency_unit' => 'day'],
'monthly' => ['frequency_amount' => 1, 'frequency_unit' => 'month'],
'yearly' => ['frequency_amount' => 1, 'frequency_unit' => 'year'],
];
}
public static function timeUnitOptions(): array
{
return [
['value' => 'day', 'text' => trans('messages.day')],
['value' => 'week', 'text' => trans('messages.week')],
['value' => 'month', 'text' => trans('messages.month')],
['value' => 'year', 'text' => trans('messages.year')],
];
}
public static function select2($request)
{
$data = ['items' => [], 'more' => true];
$query = self::active();
if (isset($request->q)) {
$keyword = $request->q;
$query = $query->where(function ($q) use ($keyword) {
$q->orwhere('plans.name', 'like', '%' . $keyword . '%');
});
}
if ($request->user()->admin && !$request->user()->admin->can('readAll', self::newDefaultPlan())) {
$query = $query->where('plans.admin_id', $request->user()->admin->id);
}
if ($request->change_from_uid) {
$plan = self::findByUid($request->change_from_uid);
$query = $query->where('id', '<>', $request->change_from_uid);
$query = $query->where('uid', '<>', $request->change_from_uid);
$query = $query->where('frequency_amount', $plan->getFrequencyAmount());
$query = $query->where('frequency_unit', $plan->getFrequencyUnit());
}
foreach ($query->limit(20)->get() as $plan) {
$data['items'][] = [
'id' => $plan->uid,
'text' => htmlspecialchars($plan->name) . '|||' . htmlspecialchars(\App\Library\Tool::format_price($plan->getPrice(), $plan->currency->format)),
];
}
return json_encode($data);
}
// =========================================================================
// TERMS OF SERVICE (app-level setting, not plan field — kept on Plan for legacy caller)
// =========================================================================
public function updateTos($params)
{
$rules = [];
if ($params['terms_of_service']['enabled'] === 'no') {
$rules['terms_of_service.content'] = 'required';
}
$validator = \Validator::make($params, $rules);
if ($validator->fails()) {
return $validator;
}
Setting::set('terms_of_service.enabled', $params['terms_of_service']['enabled']);
Setting::set('terms_of_service.content', $params['terms_of_service']['content']);
return $validator;
}
}