Skip to content

Commit 1eac0c8

Browse files
simonhampclaude
andauthored
Add Ultra promo command and notification for free users (#343)
Introduces `ultra:send-free-user-promo` artisan command targeting users who signed up but never purchased a license or subscription. The email reminds them NativePHP is now free and promotes Ultra benefits. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a8aeda3 commit 1eac0c8

3 files changed

Lines changed: 280 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\User;
6+
use App\Notifications\UltraFreeUserPromotion;
7+
use Illuminate\Console\Command;
8+
9+
class SendUltraFreeUserPromotion extends Command
10+
{
11+
protected $signature = 'ultra:send-free-user-promo
12+
{--dry-run : Show what would be sent without actually sending}';
13+
14+
protected $description = 'Send a promotional email to users who signed up but never purchased a license or subscription, encouraging them to try Ultra';
15+
16+
public function handle(): int
17+
{
18+
$dryRun = $this->option('dry-run');
19+
20+
if ($dryRun) {
21+
$this->info('DRY RUN - No emails will be sent');
22+
}
23+
24+
$users = User::query()
25+
->whereDoesntHave('licenses')
26+
->whereDoesntHave('subscriptions')
27+
->get();
28+
29+
$sent = 0;
30+
31+
foreach ($users as $user) {
32+
if ($dryRun) {
33+
$this->line("Would send to: {$user->email}");
34+
} else {
35+
$user->notify(new UltraFreeUserPromotion);
36+
$this->line("Sent to: {$user->email}");
37+
}
38+
39+
$sent++;
40+
}
41+
42+
$this->newLine();
43+
$this->info("Found {$sent} eligible user(s)");
44+
$this->info($dryRun ? "Would send: {$sent} email(s)" : "Sent: {$sent} email(s)");
45+
46+
return Command::SUCCESS;
47+
}
48+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace App\Notifications;
4+
5+
use Illuminate\Bus\Queueable;
6+
use Illuminate\Contracts\Queue\ShouldQueue;
7+
use Illuminate\Notifications\Messages\MailMessage;
8+
use Illuminate\Notifications\Notification;
9+
10+
class UltraFreeUserPromotion extends Notification implements ShouldQueue
11+
{
12+
use Queueable;
13+
14+
public function via($notifiable): array
15+
{
16+
return ['mail'];
17+
}
18+
19+
public function toMail($notifiable): MailMessage
20+
{
21+
$firstName = $notifiable->name ? explode(' ', $notifiable->name)[0] : null;
22+
$greeting = $firstName ? "Hi {$firstName}," : 'Hi there,';
23+
24+
return (new MailMessage)
25+
->subject('NativePHP is Free — And Ultra Takes It Further')
26+
->greeting($greeting)
27+
->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)**')
28+
->line('That means you can build native iOS and Android apps with Laravel and PHP — no license required. Just install and go.')
29+
->line('But if you want to take things to the next level, **NativePHP Ultra** gives you some incredible benefits:')
30+
->line('- **Teams** - up to 5 seats (you + 4 collaborators) to share your plugin access')
31+
->line('- **Free official plugins** - every NativePHP-published plugin, included with your subscription')
32+
->line('- **Plugin Dev Kit** - tools and resources to build and publish your own plugins')
33+
->line('- **90% Marketplace revenue** - keep up to 90% of earnings on paid plugins you publish')
34+
->line('- **Priority support** - get help faster when you need it')
35+
->line('- **Early access** - be first to try new features and plugins')
36+
->line('- **Exclusive content** - tutorials, guides, and deep dives just for Ultra members')
37+
->line('- **Shape the roadmap** - your feedback directly influences what we build next')
38+
->line('---')
39+
->line('Ultra is available with **annual or monthly billing** - choose what works best for you.')
40+
->action('See Ultra Plans', route('pricing'))
41+
->salutation("Cheers,\n\nThe NativePHP Team");
42+
}
43+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Enums\Subscription;
6+
use App\Models\License;
7+
use App\Models\User;
8+
use App\Notifications\UltraFreeUserPromotion;
9+
use Illuminate\Foundation\Testing\RefreshDatabase;
10+
use Illuminate\Support\Facades\Notification;
11+
use Laravel\Cashier\SubscriptionItem;
12+
use Tests\TestCase;
13+
14+
class SendUltraFreeUserPromotionTest extends TestCase
15+
{
16+
use RefreshDatabase;
17+
18+
private function createLegacyLicense(User $user, string $policyName = 'mini'): License
19+
{
20+
return License::factory()
21+
->for($user)
22+
->withoutSubscriptionItem()
23+
->state(['policy_name' => $policyName])
24+
->create();
25+
}
26+
27+
private function createActiveSubscription(User $user, string $priceId): \Laravel\Cashier\Subscription
28+
{
29+
$user->update(['stripe_id' => 'cus_'.uniqid()]);
30+
31+
$subscription = \Laravel\Cashier\Subscription::factory()
32+
->for($user)
33+
->active()
34+
->create([
35+
'stripe_price' => $priceId,
36+
'is_comped' => false,
37+
]);
38+
39+
SubscriptionItem::factory()
40+
->for($subscription, 'subscription')
41+
->create([
42+
'stripe_price' => $priceId,
43+
'quantity' => 1,
44+
]);
45+
46+
return $subscription;
47+
}
48+
49+
public function test_sends_to_user_with_no_licenses_and_no_subscriptions(): void
50+
{
51+
Notification::fake();
52+
53+
$user = User::factory()->create();
54+
55+
$this->artisan('ultra:send-free-user-promo')
56+
->expectsOutputToContain('Found 1 eligible user(s)')
57+
->expectsOutputToContain('Sent: 1 email(s)')
58+
->assertSuccessful();
59+
60+
Notification::assertSentTo($user, UltraFreeUserPromotion::class);
61+
}
62+
63+
public function test_skips_user_with_license(): void
64+
{
65+
Notification::fake();
66+
67+
$user = User::factory()->create();
68+
$this->createLegacyLicense($user, 'mini');
69+
70+
$this->artisan('ultra:send-free-user-promo')
71+
->expectsOutputToContain('Found 0 eligible user(s)')
72+
->assertSuccessful();
73+
74+
Notification::assertNotSentTo($user, UltraFreeUserPromotion::class);
75+
}
76+
77+
public function test_skips_user_with_active_subscription(): void
78+
{
79+
Notification::fake();
80+
81+
$user = User::factory()->create();
82+
$this->createActiveSubscription($user, Subscription::Pro->stripePriceId());
83+
84+
$this->artisan('ultra:send-free-user-promo')
85+
->expectsOutputToContain('Found 0 eligible user(s)')
86+
->assertSuccessful();
87+
88+
Notification::assertNotSentTo($user, UltraFreeUserPromotion::class);
89+
}
90+
91+
public function test_sends_to_multiple_eligible_users(): void
92+
{
93+
Notification::fake();
94+
95+
$user1 = User::factory()->create();
96+
$user2 = User::factory()->create();
97+
$user3 = User::factory()->create();
98+
99+
// Give user3 a license so they should be excluded
100+
$this->createLegacyLicense($user3, 'pro');
101+
102+
$this->artisan('ultra:send-free-user-promo')
103+
->expectsOutputToContain('Found 2 eligible user(s)')
104+
->expectsOutputToContain('Sent: 2 email(s)')
105+
->assertSuccessful();
106+
107+
Notification::assertSentTo($user1, UltraFreeUserPromotion::class);
108+
Notification::assertSentTo($user2, UltraFreeUserPromotion::class);
109+
Notification::assertNotSentTo($user3, UltraFreeUserPromotion::class);
110+
}
111+
112+
public function test_dry_run_does_not_send(): void
113+
{
114+
Notification::fake();
115+
116+
$user = User::factory()->create();
117+
118+
$this->artisan('ultra:send-free-user-promo --dry-run')
119+
->expectsOutputToContain('DRY RUN')
120+
->expectsOutputToContain("Would send to: {$user->email}")
121+
->expectsOutputToContain('Would send: 1 email(s)')
122+
->assertSuccessful();
123+
124+
Notification::assertNotSentTo($user, UltraFreeUserPromotion::class);
125+
}
126+
127+
public function test_notification_has_correct_subject(): void
128+
{
129+
$user = User::factory()->create(['name' => 'Jane Doe']);
130+
131+
$notification = new UltraFreeUserPromotion;
132+
$mail = $notification->toMail($user);
133+
134+
$this->assertEquals('NativePHP is Free — And Ultra Takes It Further', $mail->subject);
135+
}
136+
137+
public function test_notification_greeting_uses_first_name(): void
138+
{
139+
$user = User::factory()->create(['name' => 'Jane Doe']);
140+
141+
$notification = new UltraFreeUserPromotion;
142+
$mail = $notification->toMail($user);
143+
144+
$this->assertEquals('Hi Jane,', $mail->greeting);
145+
}
146+
147+
public function test_notification_greeting_fallback_when_no_name(): void
148+
{
149+
$user = User::factory()->create(['name' => null]);
150+
151+
$notification = new UltraFreeUserPromotion;
152+
$mail = $notification->toMail($user);
153+
154+
$this->assertEquals('Hi there,', $mail->greeting);
155+
}
156+
157+
public function test_notification_contains_ultra_benefits(): void
158+
{
159+
$user = User::factory()->create(['name' => 'Test']);
160+
161+
$notification = new UltraFreeUserPromotion;
162+
$mail = $notification->toMail($user);
163+
164+
$rendered = $mail->render()->__toString();
165+
166+
$this->assertStringContainsString('free and open source', $rendered);
167+
$this->assertStringContainsString('Teams', $rendered);
168+
$this->assertStringContainsString('Free official plugins', $rendered);
169+
$this->assertStringContainsString('Plugin Dev Kit', $rendered);
170+
$this->assertStringContainsString('Priority support', $rendered);
171+
$this->assertStringContainsString('Early access', $rendered);
172+
$this->assertStringContainsString('Exclusive content', $rendered);
173+
$this->assertStringContainsString('Shape the roadmap', $rendered);
174+
$this->assertStringContainsString('monthly billing', $rendered);
175+
}
176+
177+
public function test_notification_mentions_nativephp_is_free(): void
178+
{
179+
$user = User::factory()->create(['name' => 'Test']);
180+
181+
$notification = new UltraFreeUserPromotion;
182+
$mail = $notification->toMail($user);
183+
184+
$rendered = $mail->render()->__toString();
185+
186+
$this->assertStringContainsString('free and open source', $rendered);
187+
$this->assertStringContainsString('no license required', $rendered);
188+
}
189+
}

0 commit comments

Comments
 (0)