Add subscription tiers, notification preferences, and logging infrastructure
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 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
This commit is contained in:
Ovidiu U
2026-04-14 16:20:51 +01:00
parent 3cd3467178
commit 4220b1b86a
37 changed files with 2749 additions and 2 deletions

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Database\Factories\NotificationLogFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NotificationLog extends Model
{
/** @use HasFactory<NotificationLogFactory> */
use HasFactory;
public $timestamps = false;
protected $table = 'notification_log';
protected $fillable = [
'user_id',
'channel',
'trigger_type',
'fuel_type',
'price',
'sent',
'missed_reason',
'created_at',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** Count sent notifications for this channel today. */
public function scopeSentToday(Builder $query, string $channel): void
{
$query->where('channel', $channel)
->where('sent', true)
->whereDate('created_at', today());
}
/** Notifications that were not sent. */
public function scopeMissed(Builder $query): void
{
$query->where('sent', false);
}
protected function casts(): array
{
return [
'sent' => 'boolean',
'price' => 'decimal:3',
'created_at' => 'datetime',
];
}
}

71
app/Models/Plan.php Normal file
View File

@@ -0,0 +1,71 @@
<?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',
'features',
'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();
return $cache->remember(
"plan_for_user_{$user->id}",
3600,
function () use ($user): self {
$priceId = null;
if (method_exists($user, 'subscriptions')) {
$subscription = $user->subscriptions()->active()->first();
$priceId = $subscription?->stripe_price ?? null;
}
if ($priceId) {
$plan = static::where('stripe_price_id', $priceId)->where('active', true)->first();
if ($plan) {
return $plan;
}
}
return static::where('name', PlanTier::Free->value)->firstOrFail();
}
);
}
protected static function booted(): void
{
static::saved(function (): void {
if (Cache::supportsTags()) {
Cache::tags(['plans'])->flush();
}
});
}
protected function casts(): array
{
return [
'features' => 'array',
'active' => 'boolean',
];
}
}

View File

@@ -58,4 +58,14 @@ class User extends Authenticatable implements FilamentUser
{
return $this->hasMany(SavedStation::class);
}
public function notificationPreferences(): HasMany
{
return $this->hasMany(UserNotificationPreference::class);
}
public function notificationLogs(): HasMany
{
return $this->hasMany(NotificationLog::class);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Database\Factories\UserNotificationPreferenceFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserNotificationPreference extends Model
{
/** @use HasFactory<UserNotificationPreferenceFactory> */
use HasFactory;
protected $fillable = [
'user_id',
'channel',
'fuel_type',
'enabled',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeEnabled(Builder $query): void
{
$query->where('enabled', true);
}
public function scopeForChannel(Builder $query, string $channel): void
{
$query->where('channel', $channel);
}
public function scopeForFuelType(Builder $query, string $fuelType): void
{
$query->where('fuel_type', $fuelType);
}
protected function casts(): array
{
return [
'enabled' => 'boolean',
];
}
}