Files
fuel-price/app/Services/PlanFeatures.php
Ovidiu U 4220b1b86a
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Add subscription tiers, notification preferences, and logging infrastructure
- Add database migrations for plans, subscriptions, notification preferences, and notification log tables
- Implement DispatchUserNotificationJob to handle channel resolution, daily limits, and logging (sent/tier_restricted/daily_limit)
- Add SendScheduledWhatsAppJob for scheduled notification delivery
- Create PlanFeatures service to resolve tier capabilities, check daily limits, and validate fuel
2026-04-14 16:20:51 +01:00

199 lines
5.7 KiB
PHP

<?php
namespace App\Services;
use App\Models\NotificationLog;
use App\Models\Plan;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Throwable;
final class PlanFeatures
{
private Plan $plan;
private function __construct(private readonly User $user)
{
try {
$this->plan = Plan::resolveForUser($user);
} catch (Throwable) {
// Never throw — fall back to a free-tier stub if resolution fails.
$this->plan = new Plan([
'name' => 'free',
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
]);
}
}
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
{
$allChannels = ['email', 'push', 'whatsapp', 'sms'];
$allowed = [];
foreach ($allChannels 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->feature($channel, 'enabled') ?? false);
}
/** Read a nested feature value, e.g. feature('sms', 'daily_limit'). */
private function feature(string $channel, string $key): mixed
{
$features = $this->plan->features ?? [];
return $features[$channel][$key] ?? null;
}
/** 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();
}
/**
* 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->feature($channel, 'daily_limit');
// null or 0 in the feature means no SMS/unlimited — treat 0 as blocked, null as unlimited
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;
}
$count = $this->trackedFuelTypeCount();
// Allow if already tracking this type (not adding a new one)
$alreadyTracking = UserNotificationPreference::where('user_id', $this->user->id)
->where('fuel_type', $fuelType)
->exists();
if ($alreadyTracking) {
return true;
}
return $count < $limit;
}
/** Maximum fuel types allowed, or null for unlimited. */
public function fuelTypeLimit(): ?int
{
$features = $this->plan->features ?? [];
return $features['fuel_types']['max'] ?? 1;
}
/** 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
{
$features = $this->plan->features ?? [];
return (bool) ($features[$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 ?? 'free';
}
}