Skip to content

Commit 09be8cf

Browse files
simonhampclaude
andcommitted
Add developer sale notification email
Send an email to plugin developers when their plugins are sold, listing the plugins and their 70% payout amount without referencing the buyer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7aa1cce commit 09be8cf

3 files changed

Lines changed: 301 additions & 0 deletions

File tree

app/Jobs/HandleInvoicePaidJob.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use App\Models\Product;
1717
use App\Models\ProductLicense;
1818
use App\Models\User;
19+
use App\Notifications\PluginSaleCompleted;
1920
use App\Services\StripeConnectService;
2021
use App\Support\GitHubOAuth;
2122
use Illuminate\Bus\Queueable;
@@ -233,6 +234,9 @@ private function handleManualInvoice(): void
233234

234235
// Ensure user has a plugin license key
235236
$user->getPluginLicenseKey();
237+
238+
// Notify developers of their sales
239+
$this->sendDeveloperSaleNotifications($this->invoice->id);
236240
}
237241

238242
private function processCartPurchase(string $cartId): void
@@ -296,6 +300,9 @@ private function processCartPurchase(string $cartId): void
296300
// Ensure user has a plugin license key
297301
$user->getPluginLicenseKey();
298302

303+
// Notify developers of their sales
304+
$this->sendDeveloperSaleNotifications($this->invoice->id);
305+
299306
Log::info('Cart purchase completed', [
300307
'invoice_id' => $this->invoice->id,
301308
'cart_id' => $cartId,
@@ -605,6 +612,29 @@ private function createBundlePluginLicense(User $user, Plugin $plugin, PluginBun
605612
return $license;
606613
}
607614

615+
private function sendDeveloperSaleNotifications(string $invoiceId): void
616+
{
617+
$payouts = PluginPayout::query()
618+
->whereHas('pluginLicense', fn ($query) => $query->where('stripe_invoice_id', $invoiceId))
619+
->with(['pluginLicense.plugin', 'developerAccount.user'])
620+
->get();
621+
622+
if ($payouts->isEmpty()) {
623+
return;
624+
}
625+
626+
$payouts->groupBy('developer_account_id')
627+
->each(function ($developerPayouts) {
628+
$developerAccount = $developerPayouts->first()->developerAccount;
629+
630+
if (! $developerAccount || ! $developerAccount->user) {
631+
return;
632+
}
633+
634+
$developerAccount->user->notify(new PluginSaleCompleted($developerPayouts));
635+
});
636+
}
637+
608638
private function billable(): User
609639
{
610640
if ($user = Cashier::findBillable($this->invoice->customer)) {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
use Illuminate\Support\Collection;
10+
11+
class PluginSaleCompleted extends Notification implements ShouldQueue
12+
{
13+
use Queueable;
14+
15+
/**
16+
* @param Collection<int, \App\Models\PluginPayout> $payouts
17+
*/
18+
public function __construct(
19+
public Collection $payouts
20+
) {}
21+
22+
/**
23+
* Get the notification's delivery channels.
24+
*
25+
* @return array<int, string>
26+
*/
27+
public function via(object $notifiable): array
28+
{
29+
return ['mail'];
30+
}
31+
32+
/**
33+
* Get the mail representation of the notification.
34+
*/
35+
public function toMail(object $notifiable): MailMessage
36+
{
37+
$totalPayout = $this->payouts->sum('developer_amount');
38+
39+
$message = (new MailMessage)
40+
->subject("You've made a sale!")
41+
->greeting('Great news!')
42+
->line('A sale has been completed for the following plugin(s):');
43+
44+
foreach ($this->payouts as $payout) {
45+
$pluginName = $payout->pluginLicense->plugin->name ?? 'Unknown Plugin';
46+
$amount = number_format($payout->developer_amount / 100, 2);
47+
$message->line("- **{$pluginName}**: \${$amount}");
48+
}
49+
50+
$formattedTotal = number_format($totalPayout / 100, 2);
51+
52+
$message->line("**Total payout: \${$formattedTotal}**")
53+
->action('View Developer Dashboard', url('/customer/developer/dashboard'))
54+
->line('Thank you for contributing to the NativePHP ecosystem!');
55+
56+
return $message;
57+
}
58+
59+
/**
60+
* Get the array representation of the notification.
61+
*
62+
* @return array<string, mixed>
63+
*/
64+
public function toArray(object $notifiable): array
65+
{
66+
return [
67+
'payout_ids' => $this->payouts->pluck('id')->toArray(),
68+
'total_developer_amount' => $this->payouts->sum('developer_amount'),
69+
];
70+
}
71+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
<?php
2+
3+
namespace Tests\Feature\Notifications;
4+
5+
use App\Enums\PayoutStatus;
6+
use App\Models\DeveloperAccount;
7+
use App\Models\Plugin;
8+
use App\Models\PluginLicense;
9+
use App\Models\PluginPayout;
10+
use App\Models\User;
11+
use App\Notifications\PluginSaleCompleted;
12+
use Illuminate\Foundation\Testing\RefreshDatabase;
13+
use Tests\TestCase;
14+
15+
class PluginSaleCompletedTest extends TestCase
16+
{
17+
use RefreshDatabase;
18+
19+
public function test_email_lists_plugin_names_and_payout_amounts(): void
20+
{
21+
$developer = User::factory()->create();
22+
$developerAccount = DeveloperAccount::factory()
23+
->onboarded()
24+
->withAcceptedTerms()
25+
->create(['user_id' => $developer->id]);
26+
27+
$plugin = Plugin::factory()->paid()->create([
28+
'user_id' => $developer->id,
29+
'developer_account_id' => $developerAccount->id,
30+
'name' => 'acme/camera-plugin',
31+
]);
32+
33+
$license = PluginLicense::factory()->create([
34+
'plugin_id' => $plugin->id,
35+
'stripe_invoice_id' => 'in_test_123',
36+
'price_paid' => 2900,
37+
]);
38+
39+
$payout = PluginPayout::create([
40+
'plugin_license_id' => $license->id,
41+
'developer_account_id' => $developerAccount->id,
42+
'gross_amount' => 2900,
43+
'platform_fee' => 870,
44+
'developer_amount' => 2030,
45+
'status' => PayoutStatus::Pending,
46+
]);
47+
48+
$notification = new PluginSaleCompleted(collect([$payout->load('pluginLicense.plugin')]));
49+
$rendered = $notification->toMail($developer)->render()->toHtml();
50+
51+
$this->assertStringContainsString('acme/camera-plugin', $rendered);
52+
$this->assertStringContainsString('$20.30', $rendered);
53+
$this->assertStringContainsString('Total payout: $20.30', $rendered);
54+
55+
$mail = $notification->toMail($developer);
56+
$this->assertEquals("You've made a sale!", $mail->subject);
57+
}
58+
59+
public function test_email_lists_multiple_plugins_with_correct_total(): void
60+
{
61+
$developer = User::factory()->create();
62+
$developerAccount = DeveloperAccount::factory()
63+
->onboarded()
64+
->withAcceptedTerms()
65+
->create(['user_id' => $developer->id]);
66+
67+
$plugin1 = Plugin::factory()->paid()->create([
68+
'user_id' => $developer->id,
69+
'developer_account_id' => $developerAccount->id,
70+
'name' => 'acme/camera-plugin',
71+
]);
72+
73+
$plugin2 = Plugin::factory()->paid()->create([
74+
'user_id' => $developer->id,
75+
'developer_account_id' => $developerAccount->id,
76+
'name' => 'acme/gps-plugin',
77+
]);
78+
79+
$license1 = PluginLicense::factory()->create([
80+
'plugin_id' => $plugin1->id,
81+
'stripe_invoice_id' => 'in_test_456',
82+
'price_paid' => 2900,
83+
]);
84+
85+
$license2 = PluginLicense::factory()->create([
86+
'plugin_id' => $plugin2->id,
87+
'stripe_invoice_id' => 'in_test_456',
88+
'price_paid' => 4900,
89+
]);
90+
91+
$payout1 = PluginPayout::create([
92+
'plugin_license_id' => $license1->id,
93+
'developer_account_id' => $developerAccount->id,
94+
'gross_amount' => 2900,
95+
'platform_fee' => 870,
96+
'developer_amount' => 2030,
97+
'status' => PayoutStatus::Pending,
98+
]);
99+
100+
$payout2 = PluginPayout::create([
101+
'plugin_license_id' => $license2->id,
102+
'developer_account_id' => $developerAccount->id,
103+
'gross_amount' => 4900,
104+
'platform_fee' => 1470,
105+
'developer_amount' => 3430,
106+
'status' => PayoutStatus::Pending,
107+
]);
108+
109+
$payout1->load('pluginLicense.plugin');
110+
$payout2->load('pluginLicense.plugin');
111+
$payouts = collect([$payout1, $payout2]);
112+
113+
$notification = new PluginSaleCompleted($payouts);
114+
$rendered = $notification->toMail($developer)->render()->toHtml();
115+
116+
$this->assertStringContainsString('acme/camera-plugin', $rendered);
117+
$this->assertStringContainsString('$20.30', $rendered);
118+
$this->assertStringContainsString('acme/gps-plugin', $rendered);
119+
$this->assertStringContainsString('$34.30', $rendered);
120+
$this->assertStringContainsString('Total payout: $54.60', $rendered);
121+
}
122+
123+
public function test_email_does_not_contain_buyer_information(): void
124+
{
125+
$buyer = User::factory()->create([
126+
'name' => 'BuyerFirstName BuyerLastName',
127+
'email' => 'buyer@example.com',
128+
]);
129+
130+
$developer = User::factory()->create();
131+
$developerAccount = DeveloperAccount::factory()
132+
->onboarded()
133+
->withAcceptedTerms()
134+
->create(['user_id' => $developer->id]);
135+
136+
$plugin = Plugin::factory()->paid()->create([
137+
'user_id' => $developer->id,
138+
'developer_account_id' => $developerAccount->id,
139+
'name' => 'acme/test-plugin',
140+
]);
141+
142+
$license = PluginLicense::factory()->create([
143+
'user_id' => $buyer->id,
144+
'plugin_id' => $plugin->id,
145+
'stripe_invoice_id' => 'in_test_789',
146+
'price_paid' => 2900,
147+
]);
148+
149+
$payout = PluginPayout::create([
150+
'plugin_license_id' => $license->id,
151+
'developer_account_id' => $developerAccount->id,
152+
'gross_amount' => 2900,
153+
'platform_fee' => 870,
154+
'developer_amount' => 2030,
155+
'status' => PayoutStatus::Pending,
156+
]);
157+
158+
$notification = new PluginSaleCompleted(collect([$payout->load('pluginLicense.plugin')]));
159+
$rendered = $notification->toMail($developer)->render()->toHtml();
160+
161+
$this->assertStringNotContainsString('BuyerFirstName', $rendered);
162+
$this->assertStringNotContainsString('BuyerLastName', $rendered);
163+
$this->assertStringNotContainsString('buyer@example.com', $rendered);
164+
}
165+
166+
public function test_toarray_contains_payout_ids_and_total(): void
167+
{
168+
$developer = User::factory()->create();
169+
$developerAccount = DeveloperAccount::factory()
170+
->onboarded()
171+
->withAcceptedTerms()
172+
->create(['user_id' => $developer->id]);
173+
174+
$plugin = Plugin::factory()->paid()->create([
175+
'user_id' => $developer->id,
176+
'developer_account_id' => $developerAccount->id,
177+
]);
178+
179+
$license = PluginLicense::factory()->create([
180+
'plugin_id' => $plugin->id,
181+
'stripe_invoice_id' => 'in_test_arr',
182+
'price_paid' => 2900,
183+
]);
184+
185+
$payout = PluginPayout::create([
186+
'plugin_license_id' => $license->id,
187+
'developer_account_id' => $developerAccount->id,
188+
'gross_amount' => 2900,
189+
'platform_fee' => 870,
190+
'developer_amount' => 2030,
191+
'status' => PayoutStatus::Pending,
192+
]);
193+
194+
$notification = new PluginSaleCompleted(collect([$payout]));
195+
$array = $notification->toArray($developer);
196+
197+
$this->assertEquals([$payout->id], $array['payout_ids']);
198+
$this->assertEquals(2030, $array['total_developer_amount']);
199+
}
200+
}

0 commit comments

Comments
 (0)