Skip to content

Commit 5a82526

Browse files
simonhampclaude
andcommitted
Add Max-to-Ultra announcement email, command, and tests
One-time notification for paying Max subscribers about the Ultra rename. Includes dry-run support, comped/Pro exclusion, and full test coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 18deb57 commit 5a82526

3 files changed

Lines changed: 352 additions & 0 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\User;
6+
use App\Notifications\MaxToUltraAnnouncement;
7+
use Illuminate\Console\Command;
8+
9+
class SendMaxToUltraAnnouncement extends Command
10+
{
11+
protected $signature = 'ultra:send-announcement
12+
{--dry-run : Show what would be sent without actually sending}';
13+
14+
protected $description = 'Send a one-time announcement email to paying Max subscribers about the Ultra upgrade';
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+
$maxPriceIds = array_filter([
25+
config('subscriptions.plans.max.stripe_price_id'),
26+
config('subscriptions.plans.max.stripe_price_id_monthly'),
27+
config('subscriptions.plans.max.stripe_price_id_eap'),
28+
config('subscriptions.plans.max.stripe_price_id_discounted'),
29+
]);
30+
31+
$users = User::query()
32+
->whereHas('subscriptions', function ($query) use ($maxPriceIds) {
33+
$query->where('stripe_status', 'active')
34+
->where('is_comped', false)
35+
->whereIn('stripe_price', $maxPriceIds);
36+
})
37+
->get();
38+
39+
$this->info("Found {$users->count()} paying Max subscriber(s)");
40+
41+
$sent = 0;
42+
43+
foreach ($users as $user) {
44+
if ($dryRun) {
45+
$this->line("Would send to: {$user->email}");
46+
} else {
47+
$user->notify(new MaxToUltraAnnouncement);
48+
$this->line("Sent to: {$user->email}");
49+
}
50+
51+
$sent++;
52+
}
53+
54+
$this->newLine();
55+
$this->info($dryRun ? "Would send: {$sent} email(s)" : "Sent: {$sent} email(s)");
56+
57+
return Command::SUCCESS;
58+
}
59+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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 MaxToUltraAnnouncement 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('Your Max Plan is Now NativePHP Ultra')
26+
->greeting($greeting)
27+
->line('We have some exciting news: **your Max plan has been upgraded to NativePHP Ultra** - at no extra cost.')
28+
->line('Here\'s what you now get as an Ultra subscriber:')
29+
->line('- **Teams** - invite up to 10 collaborators to share your plugin access')
30+
->line('- **Free official plugins** - every NativePHP-published plugin, included with your subscription')
31+
->line('- **Priority support** - get help faster when you need it')
32+
->line('- **Early access** - be first to try new features and plugins')
33+
->line('- **Exclusive content** - tutorials, guides, and deep dives just for Ultra members')
34+
->line('- **Shape the roadmap** - your feedback directly influences what we build next')
35+
->line('---')
36+
->line('**Nothing changes on your end.** Your billing stays exactly the same - you just get more.')
37+
->action('See All Ultra Benefits', route('pricing'))
38+
->salutation("Cheers,\n\nThe NativePHP Team");
39+
}
40+
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Enums\Subscription;
6+
use App\Models\User;
7+
use App\Notifications\MaxToUltraAnnouncement;
8+
use Illuminate\Foundation\Testing\RefreshDatabase;
9+
use Illuminate\Support\Facades\Notification;
10+
use Tests\TestCase;
11+
12+
class SendMaxToUltraAnnouncementTest 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 createPaidMaxSubscription(User $user, ?string $priceId = null): \Laravel\Cashier\Subscription
26+
{
27+
$user->update(['stripe_id' => 'cus_'.uniqid()]);
28+
29+
$price = $priceId ?? Subscription::Max->stripePriceId();
30+
31+
$subscription = \Laravel\Cashier\Subscription::factory()
32+
->for($user)
33+
->active()
34+
->create([
35+
'stripe_price' => $price,
36+
'is_comped' => false,
37+
]);
38+
39+
\Laravel\Cashier\SubscriptionItem::factory()
40+
->for($subscription, 'subscription')
41+
->create([
42+
'stripe_price' => $price,
43+
'quantity' => 1,
44+
]);
45+
46+
return $subscription;
47+
}
48+
49+
private function createCompedMaxSubscription(User $user): \Laravel\Cashier\Subscription
50+
{
51+
$user->update(['stripe_id' => 'cus_'.uniqid()]);
52+
53+
$subscription = \Laravel\Cashier\Subscription::factory()
54+
->for($user)
55+
->active()
56+
->create([
57+
'stripe_price' => Subscription::Max->stripePriceId(),
58+
'is_comped' => true,
59+
]);
60+
61+
\Laravel\Cashier\SubscriptionItem::factory()
62+
->for($subscription, 'subscription')
63+
->create([
64+
'stripe_price' => Subscription::Max->stripePriceId(),
65+
'quantity' => 1,
66+
]);
67+
68+
return $subscription;
69+
}
70+
71+
private function createProSubscription(User $user): \Laravel\Cashier\Subscription
72+
{
73+
$user->update(['stripe_id' => 'cus_'.uniqid()]);
74+
75+
$subscription = \Laravel\Cashier\Subscription::factory()
76+
->for($user)
77+
->active()
78+
->create([
79+
'stripe_price' => Subscription::Pro->stripePriceId(),
80+
'is_comped' => false,
81+
]);
82+
83+
\Laravel\Cashier\SubscriptionItem::factory()
84+
->for($subscription, 'subscription')
85+
->create([
86+
'stripe_price' => Subscription::Pro->stripePriceId(),
87+
'quantity' => 1,
88+
]);
89+
90+
return $subscription;
91+
}
92+
93+
private function createCompedUltraSubscription(User $user): \Laravel\Cashier\Subscription
94+
{
95+
$user->update(['stripe_id' => 'cus_'.uniqid()]);
96+
97+
$subscription = \Laravel\Cashier\Subscription::factory()
98+
->for($user)
99+
->active()
100+
->create([
101+
'stripe_price' => self::COMPED_ULTRA_PRICE_ID,
102+
]);
103+
104+
\Laravel\Cashier\SubscriptionItem::factory()
105+
->for($subscription, 'subscription')
106+
->create([
107+
'stripe_price' => self::COMPED_ULTRA_PRICE_ID,
108+
'quantity' => 1,
109+
]);
110+
111+
return $subscription;
112+
}
113+
114+
public function test_sends_to_paying_max_subscriber(): void
115+
{
116+
Notification::fake();
117+
118+
$user = User::factory()->create();
119+
$this->createPaidMaxSubscription($user);
120+
121+
$this->artisan('ultra:send-announcement')
122+
->expectsOutputToContain('Found 1 paying Max subscriber(s)')
123+
->expectsOutputToContain('Sent: 1 email(s)')
124+
->assertSuccessful();
125+
126+
Notification::assertSentTo($user, MaxToUltraAnnouncement::class);
127+
}
128+
129+
public function test_sends_to_monthly_max_subscriber(): void
130+
{
131+
Notification::fake();
132+
133+
$monthlyPriceId = config('subscriptions.plans.max.stripe_price_id_monthly');
134+
135+
if (! $monthlyPriceId) {
136+
$this->markTestSkipped('Monthly Max price ID not configured');
137+
}
138+
139+
$user = User::factory()->create();
140+
$this->createPaidMaxSubscription($user, $monthlyPriceId);
141+
142+
$this->artisan('ultra:send-announcement')
143+
->expectsOutputToContain('Sent: 1 email(s)')
144+
->assertSuccessful();
145+
146+
Notification::assertSentTo($user, MaxToUltraAnnouncement::class);
147+
}
148+
149+
public function test_skips_comped_max_subscriber(): void
150+
{
151+
Notification::fake();
152+
153+
$user = User::factory()->create();
154+
$this->createCompedMaxSubscription($user);
155+
156+
$this->artisan('ultra:send-announcement')
157+
->expectsOutputToContain('Found 0 paying Max subscriber(s)')
158+
->assertSuccessful();
159+
160+
Notification::assertNotSentTo($user, MaxToUltraAnnouncement::class);
161+
}
162+
163+
public function test_skips_comped_ultra_subscriber(): void
164+
{
165+
Notification::fake();
166+
167+
$user = User::factory()->create();
168+
$this->createCompedUltraSubscription($user);
169+
170+
$this->artisan('ultra:send-announcement')
171+
->expectsOutputToContain('Found 0 paying Max subscriber(s)')
172+
->assertSuccessful();
173+
174+
Notification::assertNotSentTo($user, MaxToUltraAnnouncement::class);
175+
}
176+
177+
public function test_skips_pro_subscriber(): void
178+
{
179+
Notification::fake();
180+
181+
$user = User::factory()->create();
182+
$this->createProSubscription($user);
183+
184+
$this->artisan('ultra:send-announcement')
185+
->expectsOutputToContain('Found 0 paying Max subscriber(s)')
186+
->assertSuccessful();
187+
188+
Notification::assertNotSentTo($user, MaxToUltraAnnouncement::class);
189+
}
190+
191+
public function test_dry_run_does_not_send(): void
192+
{
193+
Notification::fake();
194+
195+
$user = User::factory()->create();
196+
$this->createPaidMaxSubscription($user);
197+
198+
$this->artisan('ultra:send-announcement --dry-run')
199+
->expectsOutputToContain('DRY RUN')
200+
->expectsOutputToContain("Would send to: {$user->email}")
201+
->expectsOutputToContain('Would send: 1 email(s)')
202+
->assertSuccessful();
203+
204+
Notification::assertNotSentTo($user, MaxToUltraAnnouncement::class);
205+
}
206+
207+
public function test_notification_has_correct_subject(): void
208+
{
209+
$user = User::factory()->create(['name' => 'Jane Doe']);
210+
211+
$notification = new MaxToUltraAnnouncement;
212+
$mail = $notification->toMail($user);
213+
214+
$this->assertEquals('Your Max Plan is Now NativePHP Ultra', $mail->subject);
215+
}
216+
217+
public function test_notification_greeting_uses_first_name(): void
218+
{
219+
$user = User::factory()->create(['name' => 'Jane Doe']);
220+
221+
$notification = new MaxToUltraAnnouncement;
222+
$mail = $notification->toMail($user);
223+
224+
$this->assertEquals('Hi Jane,', $mail->greeting);
225+
}
226+
227+
public function test_notification_greeting_fallback_when_no_name(): void
228+
{
229+
$user = User::factory()->create(['name' => null]);
230+
231+
$notification = new MaxToUltraAnnouncement;
232+
$mail = $notification->toMail($user);
233+
234+
$this->assertEquals('Hi there,', $mail->greeting);
235+
}
236+
237+
public function test_notification_contains_ultra_benefits(): void
238+
{
239+
$user = User::factory()->create(['name' => 'Test']);
240+
241+
$notification = new MaxToUltraAnnouncement;
242+
$mail = $notification->toMail($user);
243+
244+
$rendered = $mail->render()->__toString();
245+
246+
$this->assertStringContainsString('Teams', $rendered);
247+
$this->assertStringContainsString('Free official plugins', $rendered);
248+
$this->assertStringContainsString('Priority support', $rendered);
249+
$this->assertStringContainsString('Early access', $rendered);
250+
$this->assertStringContainsString('Exclusive content', $rendered);
251+
$this->assertStringContainsString('Shape the roadmap', $rendered);
252+
}
253+
}

0 commit comments

Comments
 (0)