The features JSON column required defensive fallback stubs in three places (Plan::resolveForUser, PlanFeatures::__construct, PlanSeeder) and silently swallowed misspelled keys. Typed columns give Eloquent type-safe reads, simplify the Filament form (no more dotted JSON paths), and let resolveForUser fail loud when the free row is missing. PlanFeatures public API is unchanged so consumers (jobs, middleware) need no rewrites — one missed JSON read in SendScheduledWhatsAppJob was caught and converted to a typed where() query. tests/Pest.php seeds PlanSeeder in beforeEach so any feature test that resolves a plan finds the free row, mirroring production where plans always exist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
144 lines
4.0 KiB
PHP
144 lines
4.0 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Enums\PlanTier;
|
|
use Database\Factories\PlanFactory;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
class Plan extends Model
|
|
{
|
|
/** @use HasFactory<PlanFactory> */
|
|
use HasFactory;
|
|
|
|
protected $fillable = [
|
|
'name',
|
|
'stripe_price_id_monthly',
|
|
'stripe_price_id_annual',
|
|
'max_fuel_types',
|
|
'email_enabled',
|
|
'email_frequency',
|
|
'push_enabled',
|
|
'push_frequency',
|
|
'whatsapp_enabled',
|
|
'whatsapp_daily_limit',
|
|
'whatsapp_scheduled_updates',
|
|
'sms_enabled',
|
|
'sms_daily_limit',
|
|
'ai_predictions',
|
|
'price_threshold',
|
|
'score_alerts',
|
|
'active',
|
|
];
|
|
|
|
/**
|
|
* Resolve the active plan for a user.
|
|
* Falls back to the free plan when no active Cashier subscription exists.
|
|
*/
|
|
public static function resolveForUser(User $user): self
|
|
{
|
|
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
|
|
|
|
$planId = $cache->remember(
|
|
"plan_for_user_{$user->id}",
|
|
3600,
|
|
function () use ($user): ?int {
|
|
$priceId = null;
|
|
|
|
if (method_exists($user, 'subscriptions')) {
|
|
$subscription = $user->subscriptions()->active()->first();
|
|
$priceId = $subscription?->stripe_price ?? null;
|
|
}
|
|
|
|
if ($priceId) {
|
|
$plan = static::where(fn ($q) => $q
|
|
->where('stripe_price_id_monthly', $priceId)
|
|
->orWhere('stripe_price_id_annual', $priceId))
|
|
->where('active', true)
|
|
->first();
|
|
|
|
if ($plan) {
|
|
return $plan->id;
|
|
}
|
|
}
|
|
|
|
return static::where('name', PlanTier::Free->value)->value('id');
|
|
}
|
|
);
|
|
|
|
return static::findOrFail($planId);
|
|
}
|
|
|
|
/**
|
|
* Resolve the active subscription cadence for a user.
|
|
* Returns 'monthly' | 'annual', or null if the user has no paid subscription.
|
|
*/
|
|
public static function resolveCadenceForUser(User $user): ?string
|
|
{
|
|
if (! method_exists($user, 'subscriptions')) {
|
|
return null;
|
|
}
|
|
|
|
$priceId = $user->subscriptions()->active()->value('stripe_price');
|
|
|
|
if ($priceId === null) {
|
|
return null;
|
|
}
|
|
|
|
$plan = static::where('stripe_price_id_monthly', $priceId)
|
|
->orWhere('stripe_price_id_annual', $priceId)
|
|
->first();
|
|
|
|
if ($plan === null) {
|
|
return null;
|
|
}
|
|
|
|
if ($plan->stripe_price_id_monthly === $priceId) {
|
|
return 'monthly';
|
|
}
|
|
|
|
if ($plan->stripe_price_id_annual === $priceId) {
|
|
return 'annual';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected static function booted(): void
|
|
{
|
|
static::saved(function (): void {
|
|
if (Cache::supportsTags()) {
|
|
Cache::tags(['plans'])->flush();
|
|
}
|
|
});
|
|
}
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'max_fuel_types' => 'integer',
|
|
'email_enabled' => 'boolean',
|
|
'push_enabled' => 'boolean',
|
|
'whatsapp_enabled' => 'boolean',
|
|
'whatsapp_daily_limit' => 'integer',
|
|
'whatsapp_scheduled_updates' => 'integer',
|
|
'sms_enabled' => 'boolean',
|
|
'sms_daily_limit' => 'integer',
|
|
'ai_predictions' => 'boolean',
|
|
'price_threshold' => 'boolean',
|
|
'score_alerts' => 'boolean',
|
|
'active' => 'boolean',
|
|
];
|
|
}
|
|
|
|
/** User-facing display label for this plan (e.g. basic → "Daily"). */
|
|
public function displayName(): string
|
|
{
|
|
$tier = PlanTier::tryFrom((string) $this->name) ?? PlanTier::Free;
|
|
|
|
return $tier->label();
|
|
}
|
|
}
|