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
This commit is contained in:
36
database/factories/NotificationLogFactory.php
Normal file
36
database/factories/NotificationLogFactory.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Models\NotificationLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<NotificationLog>
|
||||
*/
|
||||
class NotificationLogFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'channel' => fake()->randomElement(['email', 'push', 'whatsapp', 'sms']),
|
||||
'trigger_type' => fake()->randomElement(['price_threshold', 'score_change', 'scheduled_morning', 'scheduled_evening']),
|
||||
'fuel_type' => fake()->randomElement(array_column(FuelType::cases(), 'value')),
|
||||
'price' => fake()->optional()->randomFloat(3, 100, 180),
|
||||
'sent' => true,
|
||||
'missed_reason' => null,
|
||||
'created_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
public function missed(string $reason = 'daily_limit'): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'sent' => false,
|
||||
'missed_reason' => $reason,
|
||||
]);
|
||||
}
|
||||
}
|
||||
97
database/factories/PlanFactory.php
Normal file
97
database/factories/PlanFactory.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\PlanTier;
|
||||
use App\Models\Plan;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Plan>
|
||||
*/
|
||||
class PlanFactory extends Factory
|
||||
{
|
||||
private static array $defaultFeatures = [
|
||||
'fuel_types' => ['max' => 1],
|
||||
'email' => ['enabled' => false, '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 function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => PlanTier::Free->value,
|
||||
'stripe_price_id' => null,
|
||||
'features' => self::$defaultFeatures,
|
||||
'active' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public function free(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'name' => PlanTier::Free->value,
|
||||
'stripe_price_id' => null,
|
||||
'features' => self::$defaultFeatures,
|
||||
]);
|
||||
}
|
||||
|
||||
public function basic(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'name' => PlanTier::Basic->value,
|
||||
'stripe_price_id' => 'price_basic_test',
|
||||
'features' => [
|
||||
'fuel_types' => ['max' => 1],
|
||||
'email' => ['enabled' => true, 'frequency' => 'daily'],
|
||||
'push' => ['enabled' => true],
|
||||
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
|
||||
'sms' => ['enabled' => false, 'daily_limit' => 0],
|
||||
'ai_predictions' => false,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function plus(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'name' => PlanTier::Plus->value,
|
||||
'stripe_price_id' => 'price_plus_test',
|
||||
'features' => [
|
||||
'fuel_types' => ['max' => 1],
|
||||
'email' => ['enabled' => true, 'frequency' => 'triggered'],
|
||||
'push' => ['enabled' => true],
|
||||
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
|
||||
'sms' => ['enabled' => true, 'daily_limit' => 1],
|
||||
'ai_predictions' => true,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function pro(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'name' => PlanTier::Pro->value,
|
||||
'stripe_price_id' => 'price_pro_test',
|
||||
'features' => [
|
||||
'fuel_types' => ['max' => null],
|
||||
'email' => ['enabled' => true, 'frequency' => 'triggered'],
|
||||
'push' => ['enabled' => true],
|
||||
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
|
||||
'sms' => ['enabled' => true, 'daily_limit' => 3],
|
||||
'ai_predictions' => true,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
database/factories/UserNotificationPreferenceFactory.php
Normal file
24
database/factories/UserNotificationPreferenceFactory.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<UserNotificationPreference>
|
||||
*/
|
||||
class UserNotificationPreferenceFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'channel' => fake()->randomElement(['email', 'push', 'whatsapp', 'sms']),
|
||||
'fuel_type' => fake()->randomElement(array_column(FuelType::cases(), 'value')),
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notification_log', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('channel')->comment('email | push | whatsapp | sms');
|
||||
$table->string('trigger_type')->comment('price_threshold | score_change | scheduled_morning | scheduled_evening');
|
||||
$table->string('fuel_type');
|
||||
$table->decimal('price', 8, 3)->nullable();
|
||||
$table->boolean('sent');
|
||||
$table->string('missed_reason')->nullable()->comment('daily_limit | tier_restricted | user_disabled');
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['user_id', 'channel', 'created_at']);
|
||||
$table->index(['user_id', 'sent', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notification_log');
|
||||
}
|
||||
};
|
||||
25
database/migrations/2026_04_14_105915_create_plans_table.php
Normal file
25
database/migrations/2026_04_14_105915_create_plans_table.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('plans', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique()->comment('free | basic | plus | pro');
|
||||
$table->string('stripe_price_id')->nullable()->comment('Maps Cashier price ID to this plan');
|
||||
$table->json('features');
|
||||
$table->boolean('active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('plans');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_notification_preferences', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('channel')->comment('email | push | whatsapp | sms');
|
||||
$table->string('fuel_type')->comment('e10 | e5 | b7_standard | b7_premium | b10 | hvo');
|
||||
$table->boolean('enabled')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'channel', 'fuel_type']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_notification_preferences');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('stripe_id')->nullable()->index();
|
||||
$table->string('pm_type')->nullable();
|
||||
$table->string('pm_last_four', 4)->nullable();
|
||||
$table->timestamp('trial_ends_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropIndex([
|
||||
'stripe_id',
|
||||
]);
|
||||
|
||||
$table->dropColumn([
|
||||
'stripe_id',
|
||||
'pm_type',
|
||||
'pm_last_four',
|
||||
'trial_ends_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('subscriptions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id');
|
||||
$table->string('type');
|
||||
$table->string('stripe_id')->unique();
|
||||
$table->string('stripe_status');
|
||||
$table->string('stripe_price')->nullable();
|
||||
$table->integer('quantity')->nullable();
|
||||
$table->timestamp('trial_ends_at')->nullable();
|
||||
$table->timestamp('ends_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'stripe_status']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('subscriptions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('subscription_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('subscription_id');
|
||||
$table->string('stripe_id')->unique();
|
||||
$table->string('stripe_product');
|
||||
$table->string('stripe_price');
|
||||
$table->integer('quantity')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['subscription_id', 'stripe_price']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('subscription_items');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('subscription_items', function (Blueprint $table) {
|
||||
$table->string('meter_id')->nullable()->after('stripe_price');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('subscription_items', function (Blueprint $table) {
|
||||
$table->dropColumn('meter_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('subscription_items', function (Blueprint $table) {
|
||||
$table->string('meter_event_name')->nullable()->after('quantity');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('subscription_items', function (Blueprint $table) {
|
||||
$table->dropColumn('meter_event_name');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -13,7 +13,10 @@ class DatabaseSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->call(AdminSeeder::class);
|
||||
$this->call([
|
||||
PlanSeeder::class,
|
||||
AdminSeeder::class,
|
||||
]);
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
|
||||
79
database/seeders/PlanSeeder.php
Normal file
79
database/seeders/PlanSeeder.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Enums\PlanTier;
|
||||
use App\Models\Plan;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class PlanSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$plans = [
|
||||
PlanTier::Free->value => [
|
||||
'stripe_price_id' => null,
|
||||
'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,
|
||||
],
|
||||
],
|
||||
PlanTier::Basic->value => [
|
||||
'stripe_price_id' => config('services.stripe.prices.basic'),
|
||||
'features' => [
|
||||
'fuel_types' => ['max' => 1],
|
||||
'email' => ['enabled' => true, 'frequency' => 'daily'],
|
||||
'push' => ['enabled' => true],
|
||||
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
|
||||
'sms' => ['enabled' => false, 'daily_limit' => 0],
|
||||
'ai_predictions' => false,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
],
|
||||
],
|
||||
PlanTier::Plus->value => [
|
||||
'stripe_price_id' => config('services.stripe.prices.plus'),
|
||||
'features' => [
|
||||
'fuel_types' => ['max' => 1],
|
||||
'email' => ['enabled' => true, 'frequency' => 'triggered'],
|
||||
'push' => ['enabled' => true],
|
||||
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
|
||||
'sms' => ['enabled' => true, 'daily_limit' => 1],
|
||||
'ai_predictions' => true,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
],
|
||||
],
|
||||
PlanTier::Pro->value => [
|
||||
'stripe_price_id' => config('services.stripe.prices.pro'),
|
||||
'features' => [
|
||||
'fuel_types' => ['max' => null],
|
||||
'email' => ['enabled' => true, 'frequency' => 'triggered'],
|
||||
'push' => ['enabled' => true],
|
||||
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
|
||||
'sms' => ['enabled' => true, 'daily_limit' => 3],
|
||||
'ai_predictions' => true,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($plans as $name => $data) {
|
||||
Plan::updateOrCreate(
|
||||
['name' => $name],
|
||||
[
|
||||
'stripe_price_id' => $data['stripe_price_id'],
|
||||
'features' => $data['features'],
|
||||
'active' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user