diff --git a/app/Console/Commands/SendUltraFreeUserPromotion.php b/app/Console/Commands/SendUltraFreeUserPromotion.php new file mode 100644 index 00000000..e6708723 --- /dev/null +++ b/app/Console/Commands/SendUltraFreeUserPromotion.php @@ -0,0 +1,48 @@ +option('dry-run'); + + if ($dryRun) { + $this->info('DRY RUN - No emails will be sent'); + } + + $users = User::query() + ->whereDoesntHave('licenses') + ->whereDoesntHave('subscriptions') + ->get(); + + $sent = 0; + + foreach ($users as $user) { + if ($dryRun) { + $this->line("Would send to: {$user->email}"); + } else { + $user->notify(new UltraFreeUserPromotion); + $this->line("Sent to: {$user->email}"); + } + + $sent++; + } + + $this->newLine(); + $this->info("Found {$sent} eligible user(s)"); + $this->info($dryRun ? "Would send: {$sent} email(s)" : "Sent: {$sent} email(s)"); + + return Command::SUCCESS; + } +} diff --git a/app/Notifications/UltraFreeUserPromotion.php b/app/Notifications/UltraFreeUserPromotion.php new file mode 100644 index 00000000..4ce0a741 --- /dev/null +++ b/app/Notifications/UltraFreeUserPromotion.php @@ -0,0 +1,43 @@ +name ? explode(' ', $notifiable->name)[0] : null; + $greeting = $firstName ? "Hi {$firstName}," : 'Hi there,'; + + return (new MailMessage) + ->subject('NativePHP is Free — And Ultra Takes It Further') + ->greeting($greeting) + ->line('We wanted to make sure you heard the news: **[NativePHP for Mobile is now completely free and open source!](https://nativephp.com/blog/nativephp-for-mobile-is-now-free)**') + ->line('That means you can build native iOS and Android apps with Laravel and PHP — no license required. Just install and go.') + ->line('But if you want to take things to the next level, **NativePHP Ultra** gives you some incredible benefits:') + ->line('- **Teams** - up to 5 seats (you + 4 collaborators) to share your plugin access') + ->line('- **Free official plugins** - every NativePHP-published plugin, included with your subscription') + ->line('- **Plugin Dev Kit** - tools and resources to build and publish your own plugins') + ->line('- **90% Marketplace revenue** - keep up to 90% of earnings on paid plugins you publish') + ->line('- **Priority support** - get help faster when you need it') + ->line('- **Early access** - be first to try new features and plugins') + ->line('- **Exclusive content** - tutorials, guides, and deep dives just for Ultra members') + ->line('- **Shape the roadmap** - your feedback directly influences what we build next') + ->line('---') + ->line('Ultra is available with **annual or monthly billing** - choose what works best for you.') + ->action('See Ultra Plans', route('pricing')) + ->salutation("Cheers,\n\nThe NativePHP Team"); + } +} diff --git a/tests/Feature/SendUltraFreeUserPromotionTest.php b/tests/Feature/SendUltraFreeUserPromotionTest.php new file mode 100644 index 00000000..d3b79c9f --- /dev/null +++ b/tests/Feature/SendUltraFreeUserPromotionTest.php @@ -0,0 +1,189 @@ +for($user) + ->withoutSubscriptionItem() + ->state(['policy_name' => $policyName]) + ->create(); + } + + private function createActiveSubscription(User $user, string $priceId): \Laravel\Cashier\Subscription + { + $user->update(['stripe_id' => 'cus_'.uniqid()]); + + $subscription = \Laravel\Cashier\Subscription::factory() + ->for($user) + ->active() + ->create([ + 'stripe_price' => $priceId, + 'is_comped' => false, + ]); + + SubscriptionItem::factory() + ->for($subscription, 'subscription') + ->create([ + 'stripe_price' => $priceId, + 'quantity' => 1, + ]); + + return $subscription; + } + + public function test_sends_to_user_with_no_licenses_and_no_subscriptions(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->artisan('ultra:send-free-user-promo') + ->expectsOutputToContain('Found 1 eligible user(s)') + ->expectsOutputToContain('Sent: 1 email(s)') + ->assertSuccessful(); + + Notification::assertSentTo($user, UltraFreeUserPromotion::class); + } + + public function test_skips_user_with_license(): void + { + Notification::fake(); + + $user = User::factory()->create(); + $this->createLegacyLicense($user, 'mini'); + + $this->artisan('ultra:send-free-user-promo') + ->expectsOutputToContain('Found 0 eligible user(s)') + ->assertSuccessful(); + + Notification::assertNotSentTo($user, UltraFreeUserPromotion::class); + } + + public function test_skips_user_with_active_subscription(): void + { + Notification::fake(); + + $user = User::factory()->create(); + $this->createActiveSubscription($user, Subscription::Pro->stripePriceId()); + + $this->artisan('ultra:send-free-user-promo') + ->expectsOutputToContain('Found 0 eligible user(s)') + ->assertSuccessful(); + + Notification::assertNotSentTo($user, UltraFreeUserPromotion::class); + } + + public function test_sends_to_multiple_eligible_users(): void + { + Notification::fake(); + + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $user3 = User::factory()->create(); + + // Give user3 a license so they should be excluded + $this->createLegacyLicense($user3, 'pro'); + + $this->artisan('ultra:send-free-user-promo') + ->expectsOutputToContain('Found 2 eligible user(s)') + ->expectsOutputToContain('Sent: 2 email(s)') + ->assertSuccessful(); + + Notification::assertSentTo($user1, UltraFreeUserPromotion::class); + Notification::assertSentTo($user2, UltraFreeUserPromotion::class); + Notification::assertNotSentTo($user3, UltraFreeUserPromotion::class); + } + + public function test_dry_run_does_not_send(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->artisan('ultra:send-free-user-promo --dry-run') + ->expectsOutputToContain('DRY RUN') + ->expectsOutputToContain("Would send to: {$user->email}") + ->expectsOutputToContain('Would send: 1 email(s)') + ->assertSuccessful(); + + Notification::assertNotSentTo($user, UltraFreeUserPromotion::class); + } + + public function test_notification_has_correct_subject(): void + { + $user = User::factory()->create(['name' => 'Jane Doe']); + + $notification = new UltraFreeUserPromotion; + $mail = $notification->toMail($user); + + $this->assertEquals('NativePHP is Free — And Ultra Takes It Further', $mail->subject); + } + + public function test_notification_greeting_uses_first_name(): void + { + $user = User::factory()->create(['name' => 'Jane Doe']); + + $notification = new UltraFreeUserPromotion; + $mail = $notification->toMail($user); + + $this->assertEquals('Hi Jane,', $mail->greeting); + } + + public function test_notification_greeting_fallback_when_no_name(): void + { + $user = User::factory()->create(['name' => null]); + + $notification = new UltraFreeUserPromotion; + $mail = $notification->toMail($user); + + $this->assertEquals('Hi there,', $mail->greeting); + } + + public function test_notification_contains_ultra_benefits(): void + { + $user = User::factory()->create(['name' => 'Test']); + + $notification = new UltraFreeUserPromotion; + $mail = $notification->toMail($user); + + $rendered = $mail->render()->__toString(); + + $this->assertStringContainsString('free and open source', $rendered); + $this->assertStringContainsString('Teams', $rendered); + $this->assertStringContainsString('Free official plugins', $rendered); + $this->assertStringContainsString('Plugin Dev Kit', $rendered); + $this->assertStringContainsString('Priority support', $rendered); + $this->assertStringContainsString('Early access', $rendered); + $this->assertStringContainsString('Exclusive content', $rendered); + $this->assertStringContainsString('Shape the roadmap', $rendered); + $this->assertStringContainsString('monthly billing', $rendered); + } + + public function test_notification_mentions_nativephp_is_free(): void + { + $user = User::factory()->create(['name' => 'Test']); + + $notification = new UltraFreeUserPromotion; + $mail = $notification->toMail($user); + + $rendered = $mail->render()->__toString(); + + $this->assertStringContainsString('free and open source', $rendered); + $this->assertStringContainsString('no license required', $rendered); + } +}