Skip to content

Commit d413fab

Browse files
simonhampclaude
andauthored
Add plugin developer terms and conditions (#273)
* Add plugin developer terms and conditions for paid plugin marketplace Introduces formal developer terms covering 30% platform fee, developer responsibility/liability, listing criteria, and pricing discretion. Requires terms acceptance during developer onboarding before Stripe Connect. Updates general ToS and Privacy Policy for third-party plugins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Require developer terms acceptance for all plugin submissions - Move terms check to apply to all plugin types, not just paid - Block plugin create form until terms are accepted (full gate) - Fix onboarding redirect to show terms even if Stripe is set up - Fix null stripe_price error in CustomerLicenseController - Update onboarding FAQ copy - Update tests to reflect universal terms requirement Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Improve developer terms flow for updated terms and new submissions - Differentiate between "never onboarded" and "outdated terms" on plugin create page - Add updated terms banner to customer dashboard and developer dashboard - Fix onboarding to skip Stripe redirect when only re-accepting terms - Fix onboarding page to not show "Incomplete" banner for completed accounts - Fix null stripe_price error in CustomerLicenseController - Update onboarding FAQ and copy - Add test for outdated terms version banner Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix CI: add developer account to plugin review check tests The terms requirement now applies to all plugin submissions, so existing tests that POST to customer.plugins.store need a developer account with accepted terms. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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> * Add 15-day payout holding period and 14-day buyer refund window Delay developer payouts by 15 days to allow for buyer refunds. Payouts are now dispatched as queued jobs by a daily scheduled command instead of being transferred immediately at purchase time. - Add eligible_for_payout_at column to plugin_payouts - Remove immediate processTransfer() calls from HandleInvoicePaidJob and ProcessPluginCheckoutJob - Add ProcessEligiblePayouts command and ProcessPayoutTransfer job - Update buyer terms with 14-day plugin refund policy - Update developer terms with holding period and clawback provisions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 04b35f8 commit d413fab

28 files changed

Lines changed: 1814 additions & 83 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Jobs\ProcessPayoutTransfer;
6+
use App\Models\PluginPayout;
7+
use Illuminate\Console\Command;
8+
9+
class ProcessEligiblePayouts extends Command
10+
{
11+
protected $signature = 'payouts:process-eligible';
12+
13+
protected $description = 'Dispatch transfer jobs for pending payouts that have passed the 15-day holding period';
14+
15+
public function handle(): int
16+
{
17+
$eligiblePayouts = PluginPayout::pending()
18+
->where('eligible_for_payout_at', '<=', now())
19+
->get();
20+
21+
if ($eligiblePayouts->isEmpty()) {
22+
$this->info('No eligible payouts to process.');
23+
24+
return self::SUCCESS;
25+
}
26+
27+
foreach ($eligiblePayouts as $payout) {
28+
ProcessPayoutTransfer::dispatch($payout);
29+
}
30+
31+
$this->info("Dispatched {$eligiblePayouts->count()} payout transfer job(s).");
32+
33+
return self::SUCCESS;
34+
}
35+
}

app/Console/Kernel.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ protected function schedule(Schedule $schedule): void
2323
->dailyAt('10:30')
2424
->onOneServer()
2525
->runInBackground();
26+
27+
// Process developer payouts that have passed the 15-day holding period
28+
$schedule->command('payouts:process-eligible')
29+
->dailyAt('11:00')
30+
->onOneServer();
2631
}
2732

2833
/**

app/Http/Controllers/CustomerLicenseController.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ public function index(): View
6767
$productLicenseCount = $user->productLicenses()->count();
6868
$totalPurchases = $licenseCount + $pluginLicenseCount + $productLicenseCount;
6969

70+
$developerAccount = $user->developerAccount;
71+
7072
return view('customer.dashboard', compact(
7173
'licenseCount',
7274
'isEapCustomer',
@@ -76,7 +78,8 @@ public function index(): View
7678
'renewalLicenseKey',
7779
'connectedAccountsCount',
7880
'connectedAccountsDescription',
79-
'totalPurchases'
81+
'totalPurchases',
82+
'developerAccount'
8083
));
8184
}
8285

app/Http/Controllers/CustomerPluginController.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,26 @@ public function index(): View
3737

3838
public function create(): View
3939
{
40-
return view('customer.plugins.create');
40+
$developerAccount = Auth::user()->developerAccount;
41+
42+
return view('customer.plugins.create', [
43+
'hasAcceptedTerms' => $developerAccount?->hasAcceptedCurrentTerms() ?? false,
44+
'hasCompletedOnboarding' => $developerAccount?->hasCompletedOnboarding() ?? false,
45+
]);
4146
}
4247

4348
public function store(SubmitPluginRequest $request, PluginSyncService $syncService): RedirectResponse
4449
{
4550
$user = Auth::user();
4651

52+
// Require developer onboarding and terms acceptance for all plugin submissions
53+
$developerAccount = $user->developerAccount;
54+
55+
if (! $developerAccount || ! $developerAccount->hasAcceptedCurrentTerms()) {
56+
return to_route('customer.developer.onboarding')
57+
->with('message', 'You must accept the Plugin Developer Terms and Conditions before submitting a plugin.');
58+
}
59+
4760
// Reject paid plugin submissions if the feature is disabled
4861
if ($request->type === 'paid' && ! Feature::active(AllowPaidPlugins::class)) {
4962
return to_route('customer.plugins.create')

app/Http/Controllers/DeveloperOnboardingController.php

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Http\Controllers;
44

5+
use App\Models\DeveloperAccount;
56
use App\Services\StripeConnectService;
67
use Illuminate\Http\RedirectResponse;
78
use Illuminate\Http\Request;
@@ -16,7 +17,7 @@ public function show(Request $request): View|RedirectResponse
1617
$user = $request->user();
1718
$developerAccount = $user->developerAccount;
1819

19-
if ($developerAccount && $developerAccount->hasCompletedOnboarding()) {
20+
if ($developerAccount && $developerAccount->hasCompletedOnboarding() && $developerAccount->hasAcceptedCurrentTerms()) {
2021
return to_route('customer.developer.dashboard')
2122
->with('message', 'Your developer account is already set up.');
2223
}
@@ -29,14 +30,34 @@ public function show(Request $request): View|RedirectResponse
2930

3031
public function start(Request $request): RedirectResponse
3132
{
33+
$request->validate([
34+
'accepted_plugin_terms' => ['required', 'accepted'],
35+
], [
36+
'accepted_plugin_terms.required' => 'You must accept the Plugin Developer Terms and Conditions.',
37+
'accepted_plugin_terms.accepted' => 'You must accept the Plugin Developer Terms and Conditions.',
38+
]);
39+
3240
$user = $request->user();
3341
$developerAccount = $user->developerAccount;
3442

35-
try {
36-
if (! $developerAccount) {
37-
$developerAccount = $this->stripeConnectService->createConnectAccount($user);
38-
}
43+
if (! $developerAccount) {
44+
$developerAccount = $this->stripeConnectService->createConnectAccount($user);
45+
}
3946

47+
if (! $developerAccount->hasAcceptedCurrentTerms()) {
48+
$developerAccount->update([
49+
'accepted_plugin_terms_at' => now(),
50+
'plugin_terms_version' => DeveloperAccount::CURRENT_PLUGIN_TERMS_VERSION,
51+
]);
52+
}
53+
54+
// If Stripe onboarding is already complete, skip the Stripe redirect
55+
if ($developerAccount->hasCompletedOnboarding()) {
56+
return to_route('customer.plugins.create')
57+
->with('success', 'Terms accepted! You can now submit plugins.');
58+
}
59+
60+
try {
4061
$onboardingUrl = $this->stripeConnectService->createOnboardingLink($developerAccount);
4162

4263
return redirect($onboardingUrl);

app/Jobs/HandleInvoicePaidJob.php

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
use App\Models\Product;
1717
use App\Models\ProductLicense;
1818
use App\Models\User;
19-
use App\Services\StripeConnectService;
19+
use App\Notifications\PluginSaleCompleted;
2020
use App\Support\GitHubOAuth;
2121
use Illuminate\Bus\Queueable;
2222
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -233,6 +233,9 @@ private function handleManualInvoice(): void
233233

234234
// Ensure user has a plugin license key
235235
$user->getPluginLicenseKey();
236+
237+
// Notify developers of their sales
238+
$this->sendDeveloperSaleNotifications($this->invoice->id);
236239
}
237240

238241
private function processCartPurchase(string $cartId): void
@@ -296,6 +299,9 @@ private function processCartPurchase(string $cartId): void
296299
// Ensure user has a plugin license key
297300
$user->getPluginLicenseKey();
298301

302+
// Notify developers of their sales
303+
$this->sendDeveloperSaleNotifications($this->invoice->id);
304+
299305
Log::info('Cart purchase completed', [
300306
'invoice_id' => $this->invoice->id,
301307
'cart_id' => $cartId,
@@ -542,17 +548,15 @@ private function createPluginLicense(User $user, Plugin $plugin, int $amount): P
542548
if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $amount > 0) {
543549
$split = PluginPayout::calculateSplit($amount);
544550

545-
$payout = PluginPayout::create([
551+
PluginPayout::create([
546552
'plugin_license_id' => $license->id,
547553
'developer_account_id' => $plugin->developerAccount->id,
548554
'gross_amount' => $amount,
549555
'platform_fee' => $split['platform_fee'],
550556
'developer_amount' => $split['developer_amount'],
551557
'status' => PayoutStatus::Pending,
558+
'eligible_for_payout_at' => now()->addDays(15),
552559
]);
553-
554-
$stripeConnectService = resolve(StripeConnectService::class);
555-
$stripeConnectService->processTransfer($payout);
556560
}
557561

558562
Log::info('Created plugin license from invoice', [
@@ -582,17 +586,15 @@ private function createBundlePluginLicense(User $user, Plugin $plugin, PluginBun
582586
if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $allocatedAmount > 0) {
583587
$split = PluginPayout::calculateSplit($allocatedAmount);
584588

585-
$payout = PluginPayout::create([
589+
PluginPayout::create([
586590
'plugin_license_id' => $license->id,
587591
'developer_account_id' => $plugin->developerAccount->id,
588592
'gross_amount' => $allocatedAmount,
589593
'platform_fee' => $split['platform_fee'],
590594
'developer_amount' => $split['developer_amount'],
591595
'status' => PayoutStatus::Pending,
596+
'eligible_for_payout_at' => now()->addDays(15),
592597
]);
593-
594-
$stripeConnectService = resolve(StripeConnectService::class);
595-
$stripeConnectService->processTransfer($payout);
596598
}
597599

598600
Log::info('Created bundle plugin license from invoice', [
@@ -605,6 +607,29 @@ private function createBundlePluginLicense(User $user, Plugin $plugin, PluginBun
605607
return $license;
606608
}
607609

610+
private function sendDeveloperSaleNotifications(string $invoiceId): void
611+
{
612+
$payouts = PluginPayout::query()
613+
->whereHas('pluginLicense', fn ($query) => $query->where('stripe_invoice_id', $invoiceId))
614+
->with(['pluginLicense.plugin', 'developerAccount.user'])
615+
->get();
616+
617+
if ($payouts->isEmpty()) {
618+
return;
619+
}
620+
621+
$payouts->groupBy('developer_account_id')
622+
->each(function ($developerPayouts) {
623+
$developerAccount = $developerPayouts->first()->developerAccount;
624+
625+
if (! $developerAccount || ! $developerAccount->user) {
626+
return;
627+
}
628+
629+
$developerAccount->user->notify(new PluginSaleCompleted($developerPayouts));
630+
});
631+
}
632+
608633
private function billable(): User
609634
{
610635
if ($user = Cashier::findBillable($this->invoice->customer)) {

app/Jobs/ProcessPayoutTransfer.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use App\Models\PluginPayout;
6+
use App\Services\StripeConnectService;
7+
use Illuminate\Contracts\Queue\ShouldQueue;
8+
use Illuminate\Foundation\Queue\Queueable;
9+
10+
class ProcessPayoutTransfer implements ShouldQueue
11+
{
12+
use Queueable;
13+
14+
public int $tries = 3;
15+
16+
public int $backoff = 60;
17+
18+
public function __construct(public PluginPayout $payout) {}
19+
20+
public function handle(StripeConnectService $stripeConnectService): void
21+
{
22+
$stripeConnectService->processTransfer($this->payout);
23+
}
24+
}

app/Jobs/ProcessPluginCheckoutJob.php

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
use App\Models\PluginPayout;
1010
use App\Models\PluginPrice;
1111
use App\Models\User;
12-
use App\Services\StripeConnectService;
1312
use Illuminate\Bus\Queueable;
1413
use Illuminate\Contracts\Queue\ShouldQueue;
1514
use Illuminate\Foundation\Bus\Dispatchable;
@@ -238,17 +237,15 @@ protected function createBundleLicense(User $user, Plugin $plugin, PluginBundle
238237
if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $allocatedAmount > 0) {
239238
$split = PluginPayout::calculateSplit($allocatedAmount);
240239

241-
$payout = PluginPayout::create([
240+
PluginPayout::create([
242241
'plugin_license_id' => $license->id,
243242
'developer_account_id' => $plugin->developerAccount->id,
244243
'gross_amount' => $allocatedAmount,
245244
'platform_fee' => $split['platform_fee'],
246245
'developer_amount' => $split['developer_amount'],
247246
'status' => PayoutStatus::Pending,
247+
'eligible_for_payout_at' => now()->addDays(15),
248248
]);
249-
250-
$stripeConnectService = resolve(StripeConnectService::class);
251-
$stripeConnectService->processTransfer($payout);
252249
}
253250

254251
Log::info('Created bundle license', [
@@ -305,30 +302,15 @@ protected function createLicense(User $user, Plugin $plugin, int $amount): Plugi
305302
if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts()) {
306303
$split = PluginPayout::calculateSplit($amount);
307304

308-
$payout = PluginPayout::create([
305+
PluginPayout::create([
309306
'plugin_license_id' => $license->id,
310307
'developer_account_id' => $plugin->developerAccount->id,
311308
'gross_amount' => $amount,
312309
'platform_fee' => $split['platform_fee'],
313310
'developer_amount' => $split['developer_amount'],
314311
'status' => PayoutStatus::Pending,
312+
'eligible_for_payout_at' => now()->addDays(15),
315313
]);
316-
317-
// For cart checkouts, we need to manually transfer since transfer_data wasn't used
318-
// For single plugin checkouts, transfer_data already handled the transfer at checkout time
319-
$isCartCheckout = isset($this->metadata['cart_id']);
320-
321-
if ($isCartCheckout) {
322-
$stripeConnectService = resolve(StripeConnectService::class);
323-
$stripeConnectService->processTransfer($payout);
324-
} else {
325-
// Single plugin purchase - transfer already happened via transfer_data
326-
// Just mark the payout as transferred for tracking
327-
$payout->update([
328-
'status' => PayoutStatus::Transferred,
329-
'transferred_at' => now(),
330-
]);
331-
}
332314
}
333315

334316
return $license;

app/Models/DeveloperAccount.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ class DeveloperAccount extends Model
1212
{
1313
use HasFactory;
1414

15+
public const CURRENT_PLUGIN_TERMS_VERSION = '1.0';
16+
1517
protected $guarded = [];
1618

1719
/**
@@ -53,13 +55,25 @@ public function hasCompletedOnboarding(): bool
5355
return $this->onboarding_completed_at !== null;
5456
}
5557

58+
public function hasAcceptedPluginTerms(): bool
59+
{
60+
return $this->accepted_plugin_terms_at !== null;
61+
}
62+
63+
public function hasAcceptedCurrentTerms(): bool
64+
{
65+
return $this->hasAcceptedPluginTerms()
66+
&& $this->plugin_terms_version === self::CURRENT_PLUGIN_TERMS_VERSION;
67+
}
68+
5669
protected function casts(): array
5770
{
5871
return [
5972
'stripe_connect_status' => StripeConnectStatus::class,
6073
'payouts_enabled' => 'boolean',
6174
'charges_enabled' => 'boolean',
6275
'onboarding_completed_at' => 'datetime',
76+
'accepted_plugin_terms_at' => 'datetime',
6377
];
6478
}
6579
}

app/Models/PluginPayout.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ protected function casts(): array
115115
'developer_amount' => 'integer',
116116
'status' => PayoutStatus::class,
117117
'transferred_at' => 'datetime',
118+
'eligible_for_payout_at' => 'datetime',
118119
];
119120
}
120121
}

0 commit comments

Comments
 (0)