Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions app/Console/Commands/ProcessEligiblePayouts.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,23 @@

use App\Enums\PayoutStatus;
use App\Jobs\ProcessPayoutTransfer;
use App\Models\DeveloperAccount;
use App\Models\PluginPayout;
use App\Services\StripeConnectService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;

class ProcessEligiblePayouts extends Command
{
protected $signature = 'payouts:process-eligible';

protected $description = 'Heal held payouts and dispatch transfer jobs for pending payouts that have passed the 15-day holding period';
protected $description = 'Check Stripe for developers we can\'t pay yet, heal held payouts and dispatch transfer jobs for pending payouts that have passed the 15-day holding period';

public function handle(): int
public function handle(StripeConnectService $stripeConnectService): int
{
$this->refreshWaitingDeveloperAccounts($stripeConnectService);

$this->healHeldPayouts();

$eligiblePayouts = PluginPayout::pending()
Expand All @@ -37,6 +42,32 @@ public function handle(): int
return self::SUCCESS;
}

/**
* We only refresh a developer's Stripe status when they visit the site, so check
* Stripe for anyone we can't pay yet who has payouts waiting on them.
*/
private function refreshWaitingDeveloperAccounts(StripeConnectService $stripeConnectService): void
{
$developerAccounts = DeveloperAccount::query()
->whereHas('payouts', fn (Builder $query) => $query->held())
->orWhereHas('payouts', fn (Builder $query) => $query->pending()->where('eligible_for_payout_at', '<=', now()))
->get()
->reject(fn (DeveloperAccount $developerAccount) => $developerAccount->canReceivePayouts());

foreach ($developerAccounts as $developerAccount) {
try {
$stripeConnectService->refreshAccountStatus($developerAccount);
} catch (\Exception $e) {
Log::warning('Could not refresh developer account status before processing payouts', [
'developer_account_id' => $developerAccount->id,
'error' => $e->getMessage(),
]);

$this->warn("Could not check Stripe for developer account #{$developerAccount->id}: {$e->getMessage()}");
}
}
}

/**
* Promote held payouts to pending once the developer's Stripe Connect
* account is able to receive payouts.
Expand Down
58 changes: 58 additions & 0 deletions app/Console/Commands/RecreateConnectAccount.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace App\Console\Commands;

use App\Enums\PayoutStatus;
use App\Models\DeveloperAccount;
use App\Services\StripeConnectService;
use App\Support\StripeConnectCountries;
use Illuminate\Console\Command;
use Laravel\Cashier\Cashier;

class RecreateConnectAccount extends Command
{
protected $signature = 'payouts:recreate-connect-account {developerAccount : The developer account ID}';

protected $description = 'Move a developer to a new Stripe Connect account on the recipient service agreement so payouts can reach their country';

public function handle(StripeConnectService $stripeConnectService): int
{
$developerAccountId = $this->argument('developerAccount');
$developerAccount = DeveloperAccount::find($developerAccountId);

if (! $developerAccount) {
$this->error("Developer account #{$developerAccountId} not found.");

return self::FAILURE;
}

$stripeAccount = Cashier::stripe()->accounts->retrieve($developerAccount->stripe_connect_account_id);

if ($stripeAccount->tos_acceptance?->service_agreement === 'recipient') {
$this->info("Developer account #{$developerAccount->id} is already on the recipient service agreement.");

return self::SUCCESS;
}

if (! StripeConnectCountries::requiresRecipientServiceAgreement($stripeAccount->country)) {
$this->error("Stripe account {$stripeAccount->id} is in {$stripeAccount->country}, which we can already pay on the full service agreement.");

return self::FAILURE;
}

$previousAccountId = $developerAccount->stripe_connect_account_id;

$stripeConnectService->replaceConnectAccount($developerAccount, $stripeAccount->country);

$heldPayouts = $developerAccount->payouts()
->failed()
->update(['status' => PayoutStatus::Held]);

$this->info("Replaced {$previousAccountId} with {$developerAccount->stripe_connect_account_id}.");
$this->info("Moved {$heldPayouts} failed payout(s) back to held. They will be sent once the developer finishes onboarding.");
$this->line('Ask the developer to complete onboarding again at '.route('customer.developer.onboarding'));
$this->line("The old Stripe account {$previousAccountId} can't receive payouts any more and can be deleted from the Stripe dashboard.");

return self::SUCCESS;
}
}
51 changes: 51 additions & 0 deletions app/Console/Commands/SendDailyPayoutSummary.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace App\Console\Commands;

use App\Enums\PayoutStatus;
use App\Models\PluginPayout;
use App\Notifications\DailyPayoutSummary;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Notification;

class SendDailyPayoutSummary extends Command
{
protected $signature = 'payouts:send-daily-summary';

protected $description = 'Email a summary of the plugin payouts sent in the last 24 hours and the ones still waiting';

public function handle(): int
{
$attemptedPayouts = PluginPayout::query()
->whereIn('status', [PayoutStatus::Transferred, PayoutStatus::Failed])
->where('last_attempted_at', '>=', now()->subDay())
->with(['developerAccount.user', 'pluginLicense.plugin'])
->oldest('id')
->get();

[$upcomingPayouts, $pendingPayouts] = PluginPayout::query()
->whereIn('status', [PayoutStatus::Pending, PayoutStatus::Held])
->with('developerAccount')
->oldest('id')
->get()
->partition(fn (PluginPayout $payout): bool => $payout->isPending() && $payout->developerAccount?->canReceivePayouts());

if ($attemptedPayouts->isEmpty() && $upcomingPayouts->isEmpty() && $pendingPayouts->isEmpty()) {
$this->info('No payouts to report.');

return self::SUCCESS;
}

Notification::route('mail', config('mail.support_address'))
->notify(new DailyPayoutSummary(
attemptedPayouts: $attemptedPayouts,
upcomingPayouts: $upcomingPayouts,
pendingPayouts: $pendingPayouts,
totalPaidOut: (int) PluginPayout::transferred()->sum('developer_amount'),
));

$this->info('Sent the daily payout summary.');

return self::SUCCESS;
}
}
5 changes: 5 additions & 0 deletions app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ protected function schedule(Schedule $schedule): void
$schedule->command('payouts:process-eligible')
->dailyAt('11:00')
->onOneServer();

// Email a summary of the day's payouts, once the queued transfers have run
$schedule->command('payouts:send-daily-summary')
->dailyAt('12:00')
->onOneServer();
}

/**
Expand Down
Loading
Loading