Skip to content

Commit 176b0f6

Browse files
simonhampclaude
andcommitted
Add Ultra upgrade promotion email, command, and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5a82526 commit 176b0f6

3 files changed

Lines changed: 367 additions & 0 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Enums\Subscription;
6+
use App\Models\User;
7+
use App\Notifications\UltraUpgradePromotion;
8+
use Illuminate\Console\Command;
9+
10+
class SendUltraUpgradePromotion extends Command
11+
{
12+
protected $signature = 'ultra:send-upgrade-promo
13+
{--dry-run : Show what would be sent without actually sending}';
14+
15+
protected $description = 'Send a promotional email to Mini and Pro subscribers encouraging them to upgrade to Ultra';
16+
17+
public function handle(): int
18+
{
19+
$dryRun = $this->option('dry-run');
20+
21+
if ($dryRun) {
22+
$this->info('DRY RUN - No emails will be sent');
23+
}
24+
25+
$miniPriceIds = array_filter([
26+
config('subscriptions.plans.mini.stripe_price_id'),
27+
config('subscriptions.plans.mini.stripe_price_id_eap'),
28+
]);
29+
30+
$proPriceIds = array_filter([
31+
config('subscriptions.plans.pro.stripe_price_id'),
32+
config('subscriptions.plans.pro.stripe_price_id_eap'),
33+
config('subscriptions.plans.pro.stripe_price_id_discounted'),
34+
]);
35+
36+
$eligiblePriceIds = array_merge($miniPriceIds, $proPriceIds);
37+
38+
$users = User::query()
39+
->whereHas('subscriptions', function ($query) use ($eligiblePriceIds) {
40+
$query->where('stripe_status', 'active')
41+
->where('is_comped', false)
42+
->whereIn('stripe_price', $eligiblePriceIds);
43+
})
44+
->get();
45+
46+
$this->info("Found {$users->count()} eligible subscriber(s)");
47+
48+
$sent = 0;
49+
50+
foreach ($users as $user) {
51+
$priceId = $user->subscriptions()
52+
->where('stripe_status', 'active')
53+
->where('is_comped', false)
54+
->whereIn('stripe_price', $eligiblePriceIds)
55+
->value('stripe_price');
56+
57+
$planName = Subscription::fromStripePriceId($priceId)->name();
58+
59+
if ($dryRun) {
60+
$this->line("Would send to: {$user->email} ({$planName})");
61+
} else {
62+
$user->notify(new UltraUpgradePromotion($planName));
63+
$this->line("Sent to: {$user->email} ({$planName})");
64+
}
65+
66+
$sent++;
67+
}
68+
69+
$this->newLine();
70+
$this->info($dryRun ? "Would send: {$sent} email(s)" : "Sent: {$sent} email(s)");
71+
72+
return Command::SUCCESS;
73+
}
74+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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 UltraUpgradePromotion extends Notification implements ShouldQueue
11+
{
12+
use Queueable;
13+
14+
public function __construct(public string $currentPlanName) {}
15+
16+
public function via($notifiable): array
17+
{
18+
return ['mail'];
19+
}
20+
21+
public function toMail($notifiable): MailMessage
22+
{
23+
$firstName = $notifiable->name ? explode(' ', $notifiable->name)[0] : null;
24+
$greeting = $firstName ? "Hi {$firstName}," : 'Hi there,';
25+
26+
return (new MailMessage)
27+
->subject('Unlock More with NativePHP Ultra')
28+
->greeting($greeting)
29+
->line("You're currently on the **{$this->currentPlanName}** plan - and we'd love to show you what you're missing.")
30+
->line('**NativePHP Ultra** gives you everything you need to build and ship faster:')
31+
->line('- **Teams** - invite up to 10 collaborators to share your plugin access')
32+
->line('- **Free official plugins** - every NativePHP-published plugin, included with your subscription')
33+
->line('- **Priority support** - get help faster when you need it')
34+
->line('- **Early access** - be first to try new features and plugins')
35+
->line('- **Exclusive content** - tutorials, guides, and deep dives just for Ultra members')
36+
->line('- **Shape the roadmap** - your feedback directly influences what we build next')
37+
->line('---')
38+
->line('**Upgrading is seamless.** You\'ll only pay the prorated difference for the rest of your billing cycle - no double charges.')
39+
->action('Upgrade to Ultra', route('pricing'))
40+
->salutation("Cheers,\n\nThe NativePHP Team");
41+
}
42+
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Enums\Subscription;
6+
use App\Models\User;
7+
use App\Notifications\UltraUpgradePromotion;
8+
use Illuminate\Foundation\Testing\RefreshDatabase;
9+
use Illuminate\Support\Facades\Notification;
10+
use Tests\TestCase;
11+
12+
class SendUltraUpgradePromotionTest extends TestCase
13+
{
14+
use RefreshDatabase;
15+
16+
private const COMPED_ULTRA_PRICE_ID = 'price_test_ultra_comped';
17+
18+
protected function setUp(): void
19+
{
20+
parent::setUp();
21+
22+
config(['subscriptions.plans.max.stripe_price_id_comped' => self::COMPED_ULTRA_PRICE_ID]);
23+
}
24+
25+
private function createSubscription(User $user, string $priceId, bool $isComped = false): \Laravel\Cashier\Subscription
26+
{
27+
$user->update(['stripe_id' => 'cus_'.uniqid()]);
28+
29+
$subscription = \Laravel\Cashier\Subscription::factory()
30+
->for($user)
31+
->active()
32+
->create([
33+
'stripe_price' => $priceId,
34+
'is_comped' => $isComped,
35+
]);
36+
37+
\Laravel\Cashier\SubscriptionItem::factory()
38+
->for($subscription, 'subscription')
39+
->create([
40+
'stripe_price' => $priceId,
41+
'quantity' => 1,
42+
]);
43+
44+
return $subscription;
45+
}
46+
47+
public function test_sends_to_mini_subscriber(): void
48+
{
49+
Notification::fake();
50+
51+
$user = User::factory()->create();
52+
$this->createSubscription($user, Subscription::Mini->stripePriceId());
53+
54+
$this->artisan('ultra:send-upgrade-promo')
55+
->expectsOutputToContain('Found 1 eligible subscriber(s)')
56+
->expectsOutputToContain('Sent: 1 email(s)')
57+
->assertSuccessful();
58+
59+
Notification::assertSentTo($user, UltraUpgradePromotion::class);
60+
}
61+
62+
public function test_sends_to_pro_subscriber(): void
63+
{
64+
Notification::fake();
65+
66+
$user = User::factory()->create();
67+
$this->createSubscription($user, Subscription::Pro->stripePriceId());
68+
69+
$this->artisan('ultra:send-upgrade-promo')
70+
->expectsOutputToContain('Found 1 eligible subscriber(s)')
71+
->expectsOutputToContain('Sent: 1 email(s)')
72+
->assertSuccessful();
73+
74+
Notification::assertSentTo($user, UltraUpgradePromotion::class);
75+
}
76+
77+
public function test_skips_max_subscriber(): void
78+
{
79+
Notification::fake();
80+
81+
$user = User::factory()->create();
82+
$this->createSubscription($user, Subscription::Max->stripePriceId());
83+
84+
$this->artisan('ultra:send-upgrade-promo')
85+
->expectsOutputToContain('Found 0 eligible subscriber(s)')
86+
->assertSuccessful();
87+
88+
Notification::assertNotSentTo($user, UltraUpgradePromotion::class);
89+
}
90+
91+
public function test_skips_comped_ultra_subscriber(): void
92+
{
93+
Notification::fake();
94+
95+
$user = User::factory()->create();
96+
$this->createSubscription($user, self::COMPED_ULTRA_PRICE_ID);
97+
98+
$this->artisan('ultra:send-upgrade-promo')
99+
->expectsOutputToContain('Found 0 eligible subscriber(s)')
100+
->assertSuccessful();
101+
102+
Notification::assertNotSentTo($user, UltraUpgradePromotion::class);
103+
}
104+
105+
public function test_skips_comped_mini_subscriber(): void
106+
{
107+
Notification::fake();
108+
109+
$user = User::factory()->create();
110+
$this->createSubscription($user, Subscription::Mini->stripePriceId(), isComped: true);
111+
112+
$this->artisan('ultra:send-upgrade-promo')
113+
->expectsOutputToContain('Found 0 eligible subscriber(s)')
114+
->assertSuccessful();
115+
116+
Notification::assertNotSentTo($user, UltraUpgradePromotion::class);
117+
}
118+
119+
public function test_skips_comped_pro_subscriber(): void
120+
{
121+
Notification::fake();
122+
123+
$user = User::factory()->create();
124+
$this->createSubscription($user, Subscription::Pro->stripePriceId(), isComped: true);
125+
126+
$this->artisan('ultra:send-upgrade-promo')
127+
->expectsOutputToContain('Found 0 eligible subscriber(s)')
128+
->assertSuccessful();
129+
130+
Notification::assertNotSentTo($user, UltraUpgradePromotion::class);
131+
}
132+
133+
public function test_dry_run_does_not_send(): void
134+
{
135+
Notification::fake();
136+
137+
$user = User::factory()->create();
138+
$this->createSubscription($user, Subscription::Mini->stripePriceId());
139+
140+
$this->artisan('ultra:send-upgrade-promo --dry-run')
141+
->expectsOutputToContain('DRY RUN')
142+
->expectsOutputToContain("Would send to: {$user->email}")
143+
->expectsOutputToContain('Would send: 1 email(s)')
144+
->assertSuccessful();
145+
146+
Notification::assertNotSentTo($user, UltraUpgradePromotion::class);
147+
}
148+
149+
public function test_notification_has_correct_subject(): void
150+
{
151+
$user = User::factory()->create(['name' => 'Jane Doe']);
152+
153+
$notification = new UltraUpgradePromotion('Mini');
154+
$mail = $notification->toMail($user);
155+
156+
$this->assertEquals('Unlock More with NativePHP Ultra', $mail->subject);
157+
}
158+
159+
public function test_notification_greeting_uses_first_name(): void
160+
{
161+
$user = User::factory()->create(['name' => 'Jane Doe']);
162+
163+
$notification = new UltraUpgradePromotion('Mini');
164+
$mail = $notification->toMail($user);
165+
166+
$this->assertEquals('Hi Jane,', $mail->greeting);
167+
}
168+
169+
public function test_notification_greeting_fallback_when_no_name(): void
170+
{
171+
$user = User::factory()->create(['name' => null]);
172+
173+
$notification = new UltraUpgradePromotion('Mini');
174+
$mail = $notification->toMail($user);
175+
176+
$this->assertEquals('Hi there,', $mail->greeting);
177+
}
178+
179+
public function test_notification_contains_current_plan_name(): void
180+
{
181+
$user = User::factory()->create(['name' => 'Test']);
182+
183+
$notification = new UltraUpgradePromotion('Mini');
184+
$mail = $notification->toMail($user);
185+
186+
$rendered = $mail->render()->__toString();
187+
188+
$this->assertStringContainsString('Mini', $rendered);
189+
}
190+
191+
public function test_notification_contains_ultra_benefits(): void
192+
{
193+
$user = User::factory()->create(['name' => 'Test']);
194+
195+
$notification = new UltraUpgradePromotion('Pro');
196+
$mail = $notification->toMail($user);
197+
198+
$rendered = $mail->render()->__toString();
199+
200+
$this->assertStringContainsString('Teams', $rendered);
201+
$this->assertStringContainsString('Free official plugins', $rendered);
202+
$this->assertStringContainsString('Priority support', $rendered);
203+
$this->assertStringContainsString('Early access', $rendered);
204+
$this->assertStringContainsString('Exclusive content', $rendered);
205+
$this->assertStringContainsString('Shape the roadmap', $rendered);
206+
}
207+
208+
public function test_notification_mentions_prorated_billing(): void
209+
{
210+
$user = User::factory()->create(['name' => 'Test']);
211+
212+
$notification = new UltraUpgradePromotion('Mini');
213+
$mail = $notification->toMail($user);
214+
215+
$rendered = $mail->render()->__toString();
216+
217+
$this->assertStringContainsString('prorated', $rendered);
218+
}
219+
220+
public function test_personalizes_plan_name_for_mini(): void
221+
{
222+
Notification::fake();
223+
224+
$user = User::factory()->create();
225+
$this->createSubscription($user, Subscription::Mini->stripePriceId());
226+
227+
$this->artisan('ultra:send-upgrade-promo')
228+
->expectsOutputToContain('(Mini)')
229+
->assertSuccessful();
230+
231+
Notification::assertSentTo($user, UltraUpgradePromotion::class, function ($notification) {
232+
return $notification->currentPlanName === 'Mini';
233+
});
234+
}
235+
236+
public function test_personalizes_plan_name_for_pro(): void
237+
{
238+
Notification::fake();
239+
240+
$user = User::factory()->create();
241+
$this->createSubscription($user, Subscription::Pro->stripePriceId());
242+
243+
$this->artisan('ultra:send-upgrade-promo')
244+
->expectsOutputToContain('(Pro)')
245+
->assertSuccessful();
246+
247+
Notification::assertSentTo($user, UltraUpgradePromotion::class, function ($notification) {
248+
return $notification->currentPlanName === 'Pro';
249+
});
250+
}
251+
}

0 commit comments

Comments
 (0)