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>
184 lines
5.0 KiB
PHP
184 lines
5.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\NotificationLog;
|
|
use App\Models\Plan;
|
|
use App\Models\User;
|
|
use App\Models\UserNotificationPreference;
|
|
|
|
final class PlanFeatures
|
|
{
|
|
/** @var string[] */
|
|
private const array CHANNELS = ['email', 'push', 'whatsapp', 'sms'];
|
|
|
|
private Plan $plan;
|
|
|
|
private function __construct(private readonly User $user)
|
|
{
|
|
$this->plan = Plan::resolveForUser($user);
|
|
}
|
|
|
|
public static function for(User $user): self
|
|
{
|
|
return new self($user);
|
|
}
|
|
|
|
/**
|
|
* Channels allowed for a given trigger type, filtered by:
|
|
* tier allows → user has enabled → daily limit not hit.
|
|
*
|
|
* @return string[]
|
|
*/
|
|
public function channelsFor(string $triggerType): array
|
|
{
|
|
$allowed = [];
|
|
|
|
foreach (self::CHANNELS as $channel) {
|
|
if (! $this->canUseChannel($channel)) {
|
|
continue;
|
|
}
|
|
|
|
if (! $this->userHasEnabledChannel($channel)) {
|
|
continue;
|
|
}
|
|
|
|
if (! $this->canSendNow($channel)) {
|
|
continue;
|
|
}
|
|
|
|
$allowed[] = $channel;
|
|
}
|
|
|
|
return $allowed;
|
|
}
|
|
|
|
/** Whether the plan allows this channel at all. */
|
|
public function canUseChannel(string $channel): bool
|
|
{
|
|
return (bool) $this->plan->{"{$channel}_enabled"};
|
|
}
|
|
|
|
/**
|
|
* Whether a notification can be sent right now on this channel.
|
|
* Checks both the plan cap and today's live count in notification_log.
|
|
*/
|
|
public function canSendNow(string $channel): bool
|
|
{
|
|
if (! $this->canUseChannel($channel)) {
|
|
return false;
|
|
}
|
|
|
|
$dailyLimit = $this->dailyLimit($channel);
|
|
|
|
// null = unlimited; 0 = blocked even though enabled
|
|
if ($dailyLimit === null) {
|
|
return true;
|
|
}
|
|
|
|
if ($dailyLimit === 0) {
|
|
return false;
|
|
}
|
|
|
|
$sentToday = NotificationLog::where('user_id', $this->user->id)
|
|
->where('channel', $channel)
|
|
->where('sent', true)
|
|
->whereDate('created_at', today())
|
|
->count();
|
|
|
|
return $sentToday < $dailyLimit;
|
|
}
|
|
|
|
/** Whether the user can track an additional fuel type. */
|
|
public function canTrackFuelType(string $fuelType): bool
|
|
{
|
|
$limit = $this->fuelTypeLimit();
|
|
|
|
if ($limit === null) {
|
|
return true;
|
|
}
|
|
|
|
$alreadyTracking = UserNotificationPreference::where('user_id', $this->user->id)
|
|
->where('fuel_type', $fuelType)
|
|
->exists();
|
|
|
|
if ($alreadyTracking) {
|
|
return true;
|
|
}
|
|
|
|
return $this->trackedFuelTypeCount() < $limit;
|
|
}
|
|
|
|
/** Maximum fuel types allowed, or null for unlimited. */
|
|
public function fuelTypeLimit(): ?int
|
|
{
|
|
return $this->plan->max_fuel_types;
|
|
}
|
|
|
|
/** Count of distinct fuel types the user has preferences for. */
|
|
public function trackedFuelTypeCount(): int
|
|
{
|
|
return UserNotificationPreference::where('user_id', $this->user->id)
|
|
->distinct('fuel_type')
|
|
->count('fuel_type');
|
|
}
|
|
|
|
/** Generic boolean feature flag check. */
|
|
public function can(string $feature): bool
|
|
{
|
|
return (bool) ($this->plan->{$feature} ?? false);
|
|
}
|
|
|
|
/** Count of notifications missed today on a channel. */
|
|
public function missedToday(string $channel): int
|
|
{
|
|
return NotificationLog::where('user_id', $this->user->id)
|
|
->where('channel', $channel)
|
|
->where('sent', false)
|
|
->whereDate('created_at', today())
|
|
->count();
|
|
}
|
|
|
|
/** Count of notifications missed this month on a channel. */
|
|
public function missedThisMonth(string $channel): int
|
|
{
|
|
return NotificationLog::where('user_id', $this->user->id)
|
|
->where('channel', $channel)
|
|
->where('sent', false)
|
|
->whereMonth('created_at', now()->month)
|
|
->whereYear('created_at', now()->year)
|
|
->count();
|
|
}
|
|
|
|
/** The resolved plan tier name. */
|
|
public function tier(): string
|
|
{
|
|
return $this->plan->name;
|
|
}
|
|
|
|
/** User-facing display label for the resolved tier (e.g. basic → "Daily"). */
|
|
public function displayName(): string
|
|
{
|
|
return $this->plan->displayName();
|
|
}
|
|
|
|
/** Whether the user has opted in to this channel for at least one fuel type. */
|
|
private function userHasEnabledChannel(string $channel): bool
|
|
{
|
|
return UserNotificationPreference::where('user_id', $this->user->id)
|
|
->where('channel', $channel)
|
|
->where('enabled', true)
|
|
->exists();
|
|
}
|
|
|
|
/** Per-channel daily limit. Null on email/push (no cap), int on whatsapp/sms. */
|
|
private function dailyLimit(string $channel): ?int
|
|
{
|
|
return match ($channel) {
|
|
'whatsapp' => $this->plan->whatsapp_daily_limit,
|
|
'sms' => $this->plan->sms_daily_limit,
|
|
default => null,
|
|
};
|
|
}
|
|
}
|