Files
fuel-price/app/Services/PlanFeatures.php
Ovidiu U c2466e5a61 feat(tiers): add display-name layer, push.frequency entitlement, and rename pricing cards
Reconciles tier docs with `PlanSeeder` reality (basic has price_threshold
and score_alerts; schema is stripe_price_id_monthly + stripe_price_id_annual)
and introduces the display-name layer from pricing-plan.md v2.

- PlanTier::label() + Plan::displayName() + PlanFeatures::displayName()
  expose user-facing names (Free/Daily/Smart/Pro); backend identifiers stay
  basic/plus/pro so every call site, Stripe mapping, and test keeps working.
- push.frequency key added to features JSON (none/daily/triggered), mirroring
  email.frequency so Daily's daily push is distinguishable from Smart/Pro's
  triggered push. Seeder, factory, free-tier stubs, and Filament form updated.
- Homepage pricing cards renamed: Basic→Daily, Plus→Smart; badge
  "Most Popular"→"Most pick this"; CTAs refreshed.
- docs/tiers.md change log records the full diff.

Fleet tier, 14-day trial copy, and Smart dark-card treatment deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:57:24 +01:00

205 lines
5.9 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, 'frequency' => 'none'],
'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';
}
/** User-facing display label for the resolved tier (e.g. basic → "Daily"). */
public function displayName(): string
{
return $this->plan->displayName();
}
}