diff --git a/app/Console/Commands/ProcessEligiblePayouts.php b/app/Console/Commands/ProcessEligiblePayouts.php index 389ec6a27..9448e2919 100644 --- a/app/Console/Commands/ProcessEligiblePayouts.php +++ b/app/Console/Commands/ProcessEligiblePayouts.php @@ -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() @@ -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. diff --git a/app/Console/Commands/RecreateConnectAccount.php b/app/Console/Commands/RecreateConnectAccount.php new file mode 100644 index 000000000..2674ea198 --- /dev/null +++ b/app/Console/Commands/RecreateConnectAccount.php @@ -0,0 +1,58 @@ +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; + } +} diff --git a/app/Console/Commands/SendDailyPayoutSummary.php b/app/Console/Commands/SendDailyPayoutSummary.php new file mode 100644 index 000000000..d108adcde --- /dev/null +++ b/app/Console/Commands/SendDailyPayoutSummary.php @@ -0,0 +1,51 @@ +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; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 6600f5e4d..55812c394 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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(); } /** diff --git a/app/Jobs/ProcessPluginCheckoutJob.php b/app/Jobs/ProcessPluginCheckoutJob.php deleted file mode 100644 index a3483387a..000000000 --- a/app/Jobs/ProcessPluginCheckoutJob.php +++ /dev/null @@ -1,318 +0,0 @@ -metadata['user_id'] ?? null; - $cartId = $this->metadata['cart_id'] ?? null; - - if (! $userId) { - Log::error('No user_id in checkout session metadata', ['session_id' => $this->checkoutSessionId]); - - return; - } - - $user = User::find($userId); - - if (! $user) { - Log::error('User not found for checkout session', ['session_id' => $this->checkoutSessionId, 'user_id' => $userId]); - - return; - } - - // Handle bundle checkout - if (isset($this->metadata['bundle_ids']) && ! empty($this->metadata['bundle_ids'])) { - $this->processBundleCheckout($user); - } - - // Handle cart checkout (individual plugins) - if ($cartId && isset($this->metadata['plugin_ids']) && ! empty($this->metadata['plugin_ids'])) { - $this->processCartCheckout($user); - - return; - } - - // Handle single plugin checkout - if (isset($this->metadata['plugin_id'])) { - // Check if single plugin already processed - if (PluginLicense::where('stripe_checkout_session_id', $this->checkoutSessionId)->exists()) { - Log::info('Single plugin checkout already processed', ['session_id' => $this->checkoutSessionId]); - - return; - } - - $this->processSinglePluginCheckout($user); - - return; - } - - Log::error('Unknown checkout session format', ['session_id' => $this->checkoutSessionId, 'metadata' => $this->metadata]); - } - - protected function processCartCheckout(User $user): void - { - Log::info('Starting cart checkout processing', [ - 'session_id' => $this->checkoutSessionId, - 'metadata' => $this->metadata, - ]); - - $pluginIds = explode(',', $this->metadata['plugin_ids']); - $priceIds = explode(',', $this->metadata['price_ids'] ?? ''); - - Log::info('Parsed plugin IDs from metadata', [ - 'session_id' => $this->checkoutSessionId, - 'raw_plugin_ids' => $this->metadata['plugin_ids'], - 'parsed_plugin_ids' => $pluginIds, - 'plugin_count' => count($pluginIds), - ]); - - // Get already processed plugin IDs for this session - $alreadyProcessedPluginIds = PluginLicense::where('stripe_checkout_session_id', $this->checkoutSessionId) - ->pluck('plugin_id') - ->toArray(); - - $processedCount = 0; - $skippedCount = count($alreadyProcessedPluginIds); - - foreach ($pluginIds as $index => $pluginId) { - Log::info('Processing plugin in cart', [ - 'session_id' => $this->checkoutSessionId, - 'index' => $index, - 'plugin_id' => $pluginId, - 'already_processed' => in_array((int) $pluginId, $alreadyProcessedPluginIds), - ]); - - // Skip if this plugin was already processed for this session - if (in_array((int) $pluginId, $alreadyProcessedPluginIds)) { - continue; - } - - $plugin = Plugin::find($pluginId); - - if (! $plugin) { - Log::warning('Plugin not found during checkout processing', ['plugin_id' => $pluginId]); - - continue; - } - - $priceId = $priceIds[$index] ?? null; - $price = $priceId ? PluginPrice::find($priceId) : $plugin->activePrice; - $amount = $price ? $price->amount : 0; - - try { - $this->createLicense($user, $plugin, $amount); - $processedCount++; - Log::info('Successfully created license for plugin', [ - 'session_id' => $this->checkoutSessionId, - 'plugin_id' => $pluginId, - 'plugin_name' => $plugin->name, - ]); - } catch (\Exception $e) { - Log::error('Failed to create license for plugin', [ - 'session_id' => $this->checkoutSessionId, - 'plugin_id' => $pluginId, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - throw $e; - } - } - - // Ensure user has a plugin license key - $user->getPluginLicenseKey(); - - Log::info('Processed cart checkout', [ - 'session_id' => $this->checkoutSessionId, - 'user_id' => $user->id, - 'total_plugins' => count($pluginIds), - 'processed_now' => $processedCount, - 'already_processed' => $skippedCount, - ]); - } - - protected function processBundleCheckout(User $user): void - { - $bundleIds = array_filter(explode(',', $this->metadata['bundle_ids'])); - $bundlePluginIds = json_decode($this->metadata['bundle_plugin_ids'] ?? '{}', true); - - Log::info('Processing bundle checkout', [ - 'session_id' => $this->checkoutSessionId, - 'bundle_ids' => $bundleIds, - ]); - - foreach ($bundleIds as $bundleId) { - $bundle = PluginBundle::with(['plugins.developerAccount', 'plugins.activePrice']) - ->find($bundleId); - - if (! $bundle) { - Log::warning('Bundle not found during checkout processing', ['bundle_id' => $bundleId]); - - continue; - } - - // Check how many plugins are already processed for this bundle in this session - $existingLicenseCount = PluginLicense::where('stripe_checkout_session_id', $this->checkoutSessionId) - ->where('plugin_bundle_id', $bundleId) - ->count(); - - if ($existingLicenseCount === $bundle->plugins->count()) { - Log::info('Bundle already fully processed', [ - 'session_id' => $this->checkoutSessionId, - 'bundle_id' => $bundleId, - ]); - - continue; - } - - // Calculate proportional allocation for developer payouts - $allocations = $bundle->calculateProportionalAllocation(); - - foreach ($bundle->plugins as $plugin) { - // Skip if license already exists for this plugin in this session - if (PluginLicense::where('stripe_checkout_session_id', $this->checkoutSessionId) - ->where('plugin_id', $plugin->id) - ->where('plugin_bundle_id', $bundleId) - ->exists()) { - continue; - } - - $allocatedAmount = $allocations[$plugin->id] ?? 0; - - $this->createBundleLicense($user, $plugin, $bundle, $allocatedAmount); - } - - Log::info('Processed bundle checkout', [ - 'session_id' => $this->checkoutSessionId, - 'bundle_id' => $bundleId, - 'bundle_name' => $bundle->name, - 'plugin_count' => $bundle->plugins->count(), - ]); - } - - $user->getPluginLicenseKey(); - } - - protected function createBundleLicense(User $user, Plugin $plugin, PluginBundle $bundle, int $allocatedAmount): PluginLicense - { - $license = PluginLicense::create([ - 'user_id' => $user->id, - 'plugin_id' => $plugin->id, - 'plugin_bundle_id' => $bundle->id, - 'stripe_checkout_session_id' => $this->checkoutSessionId, - 'stripe_payment_intent_id' => $this->paymentIntentId, - 'price_paid' => $allocatedAmount, - 'currency' => strtoupper($this->currency), - 'is_grandfathered' => false, - 'purchased_at' => now(), - ]); - - // Create proportional payout for developer - if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $allocatedAmount > 0) { - $split = PluginPayout::calculateSplit($allocatedAmount); - - PluginPayout::create([ - 'plugin_license_id' => $license->id, - 'developer_account_id' => $plugin->developerAccount->id, - 'gross_amount' => $allocatedAmount, - 'platform_fee' => $split['platform_fee'], - 'developer_amount' => $split['developer_amount'], - 'status' => PayoutStatus::Pending, - 'eligible_for_payout_at' => now()->addDays(15), - ]); - } - - Log::info('Created bundle license', [ - 'session_id' => $this->checkoutSessionId, - 'bundle_id' => $bundle->id, - 'plugin_id' => $plugin->id, - 'allocated_amount' => $allocatedAmount, - ]); - - return $license; - } - - protected function processSinglePluginCheckout(User $user): void - { - $pluginId = $this->metadata['plugin_id']; - $priceId = $this->metadata['price_id'] ?? null; - - $plugin = Plugin::find($pluginId); - - if (! $plugin) { - Log::error('Plugin not found for single checkout', ['plugin_id' => $pluginId]); - - return; - } - - $price = $priceId ? PluginPrice::find($priceId) : $plugin->activePrice; - $amount = $price ? $price->amount : $this->amountTotal; - - $this->createLicense($user, $plugin, $amount); - - $user->getPluginLicenseKey(); - - Log::info('Processed single plugin checkout', [ - 'session_id' => $this->checkoutSessionId, - 'user_id' => $user->id, - 'plugin_id' => $pluginId, - ]); - } - - protected function createLicense(User $user, Plugin $plugin, int $amount): PluginLicense - { - $license = PluginLicense::create([ - 'user_id' => $user->id, - 'plugin_id' => $plugin->id, - 'stripe_checkout_session_id' => $this->checkoutSessionId, - 'stripe_payment_intent_id' => $this->paymentIntentId, - 'price_paid' => $amount, - 'currency' => strtoupper($this->currency), - 'is_grandfathered' => false, - 'purchased_at' => now(), - ]); - - // Create payout record for developer if applicable - if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts()) { - $split = PluginPayout::calculateSplit($amount); - - PluginPayout::create([ - 'plugin_license_id' => $license->id, - 'developer_account_id' => $plugin->developerAccount->id, - 'gross_amount' => $amount, - 'platform_fee' => $split['platform_fee'], - 'developer_amount' => $split['developer_amount'], - 'status' => PayoutStatus::Pending, - 'eligible_for_payout_at' => now()->addDays(15), - ]); - } - - return $license; - } -} diff --git a/app/Notifications/DailyPayoutSummary.php b/app/Notifications/DailyPayoutSummary.php new file mode 100644 index 000000000..5094fa81f --- /dev/null +++ b/app/Notifications/DailyPayoutSummary.php @@ -0,0 +1,93 @@ + $attemptedPayouts Transferred or failed in the last 24 hours + * @param Collection $upcomingPayouts Waiting to be sent to developers we can pay + * @param Collection $pendingPayouts Held until the developer's Stripe account is active + */ + public function __construct( + public Collection $attemptedPayouts, + public Collection $upcomingPayouts, + public Collection $pendingPayouts, + public int $totalPaidOut, + ) {} + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $transferred = $this->attemptedPayouts->filter(fn (PluginPayout $payout): bool => $payout->isTransferred()); + $failed = $this->attemptedPayouts->filter(fn (PluginPayout $payout): bool => $payout->isFailed()); + + $message = (new MailMessage) + ->subject($this->subjectLine($failed->count())) + ->greeting('Plugin payout summary') + ->line('**Last 24 hours**') + ->line("Total payouts: {$this->attemptedPayouts->count()}") + ->line('Total amount: '.$this->formatAmount($this->attemptedPayouts->sum('developer_amount'))) + ->line("Successful: {$transferred->count()} (".$this->formatAmount($transferred->sum('developer_amount')).' paid out)') + ->line("Failed: {$failed->count()}") + ->line('**Not paid yet**') + ->line("Upcoming: {$this->upcomingPayouts->count()} (".$this->formatAmount($this->upcomingPayouts->sum('developer_amount')).'), for active accounts') + ->line("Pending: {$this->pendingPayouts->count()} (".$this->formatAmount($this->pendingPayouts->sum('developer_amount')).'), held until the account is active') + ->line('**Paid out to date:** '.$this->formatAmount($this->totalPaidOut)); + + if ($failed->isNotEmpty()) { + $message->line('**Failed payouts**'); + } + + foreach ($failed as $payout) { + $developer = $payout->developerAccount?->user; + $pluginName = $payout->pluginLicense?->plugin?->name ?? 'Unknown plugin'; + $payoutUrl = route('filament.admin.resources.plugin-payouts.view', $payout); + + $message + ->line("Payout #{$payout->id}: ".$this->formatAmount($payout->developer_amount)." for {$pluginName} to {$developer?->name} ({$developer?->email}). [View payout]({$payoutUrl})") + ->line('Stripe error: '.($payout->failure_reason ?? 'none recorded')); + } + + return $message; + } + + private function subjectLine(int $failedCount): string + { + if ($failedCount > 0) { + return "Payout summary: {$failedCount} failed"; + } + + if ($this->attemptedPayouts->isEmpty()) { + return 'Payout summary: nothing sent'; + } + + return "Payout summary: all {$this->attemptedPayouts->count()} sent"; + } + + private function formatAmount(int $cents): string + { + return '$'.number_format($cents / 100, 2); + } +} diff --git a/app/Services/StripeConnectService.php b/app/Services/StripeConnectService.php index 1e6a1484f..fbe82bdb2 100644 --- a/app/Services/StripeConnectService.php +++ b/app/Services/StripeConnectService.php @@ -11,6 +11,7 @@ use App\Models\PluginPayoutAttempt; use App\Models\PluginPrice; use App\Models\User; +use App\Support\StripeConnectCountries; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Cashier; use Stripe\Account; @@ -22,18 +23,7 @@ class StripeConnectService { public function createConnectAccount(User $user, string $country, string $payoutCurrency): DeveloperAccount { - $account = Cashier::stripe()->accounts->create([ - 'type' => 'express', - 'country' => $country, - 'email' => $user->email, - 'metadata' => [ - 'user_id' => $user->id, - ], - 'capabilities' => [ - 'card_payments' => ['requested' => true], - 'transfers' => ['requested' => true], - ], - ]); + $account = $this->createStripeAccount($user, $country); return DeveloperAccount::create([ 'user_id' => $user->id, @@ -46,6 +36,33 @@ public function createConnectAccount(User $user, string $country, string $payout ]); } + /** + * Point the developer at a brand new Stripe account, created with the service agreement + * their country needs. Stripe won't change the agreement on an account that has already + * accepted one, so the developer has to go through onboarding again. + */ + public function replaceConnectAccount(DeveloperAccount $developerAccount, string $country): void + { + $previousAccountId = $developerAccount->stripe_connect_account_id; + + $account = $this->createStripeAccount($developerAccount->user, $country); + + $developerAccount->update([ + 'stripe_connect_account_id' => $account->id, + 'stripe_connect_status' => StripeConnectStatus::Pending, + 'payouts_enabled' => false, + 'charges_enabled' => false, + 'onboarding_completed_at' => null, + 'country' => $country, + ]); + + Log::info('Replaced developer Stripe Connect account', [ + 'developer_account_id' => $developerAccount->id, + 'previous_stripe_account_id' => $previousAccountId, + 'stripe_account_id' => $account->id, + ]); + } + public function createOnboardingLink(DeveloperAccount $account): string { $accountLink = Cashier::stripe()->accountLinks->create([ @@ -66,7 +83,9 @@ public function refreshAccountStatus(DeveloperAccount $account): void 'payouts_enabled' => $stripeAccount->payouts_enabled, 'charges_enabled' => $stripeAccount->charges_enabled, 'stripe_connect_status' => $this->determineStatus($stripeAccount), - 'onboarding_completed_at' => $stripeAccount->details_submitted ? now() : null, + 'onboarding_completed_at' => $stripeAccount->details_submitted + ? ($account->onboarding_completed_at ?? now()) + : null, ]); Log::info('Refreshed developer account status', [ @@ -202,6 +221,37 @@ protected function getChargeDetailsFromPayout(PluginPayout $payout): ?array } } + /** + * Developers outside the countries we can pay on the `full` service agreement get a + * `recipient` account, which can only receive transfers and can't take payments. + */ + protected function createStripeAccount(User $user, string $country): Account + { + $params = [ + 'type' => 'express', + 'country' => $country, + 'email' => $user->email, + 'metadata' => [ + 'user_id' => $user->id, + ], + 'capabilities' => [ + 'card_payments' => ['requested' => true], + 'transfers' => ['requested' => true], + ], + ]; + + if (StripeConnectCountries::requiresRecipientServiceAgreement($country)) { + $params['capabilities'] = [ + 'transfers' => ['requested' => true], + ]; + $params['tos_acceptance'] = [ + 'service_agreement' => 'recipient', + ]; + } + + return Cashier::stripe()->accounts->create($params); + } + protected function determineStatus(Account $account): StripeConnectStatus { if ($account->payouts_enabled && $account->charges_enabled) { diff --git a/app/Support/StripeConnectCountries.php b/app/Support/StripeConnectCountries.php index 40e9bd5dc..4faff04c5 100644 --- a/app/Support/StripeConnectCountries.php +++ b/app/Support/StripeConnectCountries.php @@ -115,6 +115,21 @@ class StripeConnectCountries 'ZA' => ['name' => 'South Africa', 'flag' => "\u{1F1FF}\u{1F1E6}", 'default_currency' => 'ZAR', 'currencies' => ['ZAR']], ]; + /** + * Countries our US platform can transfer to on Stripe's `full` service agreement. + * Everywhere else needs the `recipient` service agreement, otherwise Stripe rejects + * transfers to the account (or refuses to create it at all). + * + * @see https://docs.stripe.com/connect/service-agreement-types + * @see https://docs.stripe.com/connect/account-capabilities#transfers-cross-border + * + * @var list + */ + public const FULL_SERVICE_AGREEMENT_COUNTRIES = [ + 'AT', 'BE', 'BG', 'CA', 'CH', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'HU', + 'IE', 'IT', 'LI', 'LT', 'LU', 'LV', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK', 'US', + ]; + /** * @var array */ @@ -231,6 +246,11 @@ public static function isValidCurrencyForCountry(string $countryCode, string $cu return in_array(strtoupper($currencyCode), self::availableCurrencies($countryCode), true); } + public static function requiresRecipientServiceAgreement(string $countryCode): bool + { + return ! in_array(strtoupper($countryCode), self::FULL_SERVICE_AGREEMENT_COUNTRIES, true); + } + /** * @return list */ diff --git a/tests/Feature/Commands/ProcessEligiblePayoutsTest.php b/tests/Feature/Commands/ProcessEligiblePayoutsTest.php index 8cce9c8d4..cbeb80f70 100644 --- a/tests/Feature/Commands/ProcessEligiblePayoutsTest.php +++ b/tests/Feature/Commands/ProcessEligiblePayoutsTest.php @@ -10,6 +10,9 @@ use App\Models\PluginPayout; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; +use Stripe\Account; +use Stripe\Exception\ApiConnectionException; +use Stripe\StripeClient; use Tests\TestCase; class ProcessEligiblePayoutsTest extends TestCase @@ -195,15 +198,120 @@ public function test_does_not_heal_held_payout_when_developer_still_cannot_recei 'eligible_for_payout_at' => now()->subDay(), ]); + $accounts = $this->fakeStripeAccounts(canBePaid: false); + $this->artisan('payouts:process-eligible') ->expectsOutputToContain('No eligible payouts') ->assertExitCode(0); + $this->assertSame([$developerAccount->stripe_connect_account_id], $accounts->retrieved); $this->assertEquals(PayoutStatus::Held, $payout->fresh()->status); Queue::assertNothingPushed(); } + public function test_checks_stripe_and_releases_held_payouts_once_the_developer_can_be_paid(): void + { + Queue::fake(); + + $developerAccount = DeveloperAccount::factory()->pending()->create(); + $payout = PluginPayout::factory()->create([ + 'developer_account_id' => $developerAccount->id, + 'status' => PayoutStatus::Held, + 'eligible_for_payout_at' => now()->subDay(), + ]); + + $accounts = $this->fakeStripeAccounts(canBePaid: true); + + $this->artisan('payouts:process-eligible') + ->expectsOutputToContain('Healed 1 held payout(s)') + ->expectsOutputToContain('Dispatched 1 payout transfer job(s)') + ->assertExitCode(0); + + $this->assertSame([$developerAccount->stripe_connect_account_id], $accounts->retrieved); + $this->assertTrue($developerAccount->fresh()->canReceivePayouts()); + $this->assertEquals(PayoutStatus::Pending, $payout->fresh()->status); + + Queue::assertPushed(ProcessPayoutTransfer::class, function ($job) use ($payout) { + return $job->payout->id === $payout->id; + }); + } + + public function test_checks_stripe_for_developers_with_pending_payouts_that_are_due(): void + { + Queue::fake(); + + $developerAccount = DeveloperAccount::factory()->pending()->create(); + PluginPayout::factory()->create([ + 'developer_account_id' => $developerAccount->id, + 'status' => PayoutStatus::Pending, + 'eligible_for_payout_at' => now()->subDay(), + ]); + + $accounts = $this->fakeStripeAccounts(canBePaid: true); + + $this->artisan('payouts:process-eligible')->assertExitCode(0); + + $this->assertSame([$developerAccount->stripe_connect_account_id], $accounts->retrieved); + $this->assertTrue($developerAccount->fresh()->canReceivePayouts()); + } + + public function test_does_not_check_stripe_for_developers_who_can_be_paid_or_have_nothing_due(): void + { + Queue::fake(); + + $activeDeveloperAccount = DeveloperAccount::factory()->create(); + PluginPayout::factory()->create([ + 'developer_account_id' => $activeDeveloperAccount->id, + 'status' => PayoutStatus::Held, + ]); + + $developerAccountWithNothingDue = DeveloperAccount::factory()->pending()->create(); + PluginPayout::factory()->create([ + 'developer_account_id' => $developerAccountWithNothingDue->id, + 'status' => PayoutStatus::Pending, + 'eligible_for_payout_at' => now()->addDays(10), + ]); + + $accounts = $this->fakeStripeAccounts(canBePaid: true); + + $this->artisan('payouts:process-eligible')->assertExitCode(0); + + $this->assertSame([], $accounts->retrieved); + } + + public function test_carries_on_with_other_payouts_when_stripe_cannot_be_reached(): void + { + Queue::fake(); + + $unreachableDeveloperAccount = DeveloperAccount::factory()->pending()->create(); + $heldPayout = PluginPayout::factory()->create([ + 'developer_account_id' => $unreachableDeveloperAccount->id, + 'status' => PayoutStatus::Held, + 'eligible_for_payout_at' => now()->subDay(), + ]); + + $duePayout = PluginPayout::factory()->create([ + 'developer_account_id' => DeveloperAccount::factory()->create()->id, + 'status' => PayoutStatus::Pending, + 'eligible_for_payout_at' => now()->subDay(), + ]); + + $this->fakeStripeAccounts(reachable: false); + + $this->artisan('payouts:process-eligible') + ->expectsOutputToContain("Could not check Stripe for developer account #{$unreachableDeveloperAccount->id}") + ->expectsOutputToContain('Dispatched 1 payout transfer job(s)') + ->assertExitCode(0); + + $this->assertEquals(PayoutStatus::Held, $heldPayout->fresh()->status); + + Queue::assertPushed(ProcessPayoutTransfer::class, 1); + Queue::assertPushed(ProcessPayoutTransfer::class, function ($job) use ($duePayout) { + return $job->payout->id === $duePayout->id; + }); + } + public function test_healed_payout_within_holding_period_is_not_dispatched(): void { Queue::fake(); @@ -231,4 +339,39 @@ public function test_healed_payout_within_holding_period_is_not_dispatched(): vo Queue::assertNothingPushed(); } + + private function fakeStripeAccounts(bool $canBePaid = false, bool $reachable = true): object + { + $accounts = new class($canBePaid, $reachable) + { + /** @var list */ + public array $retrieved = []; + + public function __construct(private bool $canBePaid, private bool $reachable) {} + + public function retrieve(string $id): Account + { + $this->retrieved[] = $id; + + if (! $this->reachable) { + throw new ApiConnectionException('Could not connect to Stripe'); + } + + return Account::constructFrom([ + 'id' => $id, + 'payouts_enabled' => $this->canBePaid, + 'charges_enabled' => $this->canBePaid, + 'details_submitted' => $this->canBePaid, + 'requirements' => ['disabled_reason' => null], + ]); + } + }; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->accounts = $accounts; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + + return $accounts; + } } diff --git a/tests/Feature/Commands/RecreateConnectAccountTest.php b/tests/Feature/Commands/RecreateConnectAccountTest.php new file mode 100644 index 000000000..846dcc4f3 --- /dev/null +++ b/tests/Feature/Commands/RecreateConnectAccountTest.php @@ -0,0 +1,141 @@ +create([ + 'stripe_connect_account_id' => 'acct_old_full', + 'country' => 'MX', + 'payout_currency' => 'MXN', + ]); + + $failedPayout = PluginPayout::factory()->failed()->create(['developer_account_id' => $developerAccount->id]); + $transferredPayout = PluginPayout::factory()->transferred()->create(['developer_account_id' => $developerAccount->id]); + $otherDevelopersFailedPayout = PluginPayout::factory()->failed()->create(); + + $accounts = $this->fakeStripeAccounts(country: 'MX', serviceAgreement: 'full'); + + $this->artisan('payouts:recreate-connect-account', ['developerAccount' => $developerAccount->id]) + ->expectsOutputToContain('Replaced acct_old_full with acct_new_recipient') + ->expectsOutputToContain('Moved 1 failed payout(s) back to held') + ->assertExitCode(0); + + $this->assertSame('MX', $accounts->createdWith['country']); + $this->assertSame(['service_agreement' => 'recipient'], $accounts->createdWith['tos_acceptance']); + $this->assertSame(['transfers' => ['requested' => true]], $accounts->createdWith['capabilities']); + + $developerAccount->refresh(); + + $this->assertSame('acct_new_recipient', $developerAccount->stripe_connect_account_id); + $this->assertSame(StripeConnectStatus::Pending, $developerAccount->stripe_connect_status); + $this->assertFalse($developerAccount->hasCompletedOnboarding()); + + $this->assertSame(PayoutStatus::Held, $failedPayout->fresh()->status); + $this->assertSame(PayoutStatus::Transferred, $transferredPayout->fresh()->status); + $this->assertSame(PayoutStatus::Failed, $otherDevelopersFailedPayout->fresh()->status); + } + + public function test_uses_the_stripe_account_country_when_the_developer_account_has_none(): void + { + $developerAccount = DeveloperAccount::factory()->create(['country' => null]); + + $accounts = $this->fakeStripeAccounts(country: 'MX', serviceAgreement: 'full'); + + $this->artisan('payouts:recreate-connect-account', ['developerAccount' => $developerAccount->id]) + ->assertExitCode(0); + + $this->assertSame('MX', $accounts->createdWith['country']); + $this->assertSame('MX', $developerAccount->fresh()->country); + } + + public function test_leaves_accounts_already_on_the_recipient_agreement_alone(): void + { + $developerAccount = DeveloperAccount::factory()->create([ + 'stripe_connect_account_id' => 'acct_already_recipient', + 'country' => 'MX', + ]); + $failedPayout = PluginPayout::factory()->failed()->create(['developer_account_id' => $developerAccount->id]); + + $accounts = $this->fakeStripeAccounts(country: 'MX', serviceAgreement: 'recipient'); + + $this->artisan('payouts:recreate-connect-account', ['developerAccount' => $developerAccount->id]) + ->expectsOutputToContain('already on the recipient service agreement') + ->assertExitCode(0); + + $this->assertNull($accounts->createdWith); + $this->assertSame('acct_already_recipient', $developerAccount->fresh()->stripe_connect_account_id); + $this->assertSame(PayoutStatus::Failed, $failedPayout->fresh()->status); + } + + public function test_refuses_to_replace_accounts_that_can_be_paid_on_the_full_agreement(): void + { + $developerAccount = DeveloperAccount::factory()->create([ + 'stripe_connect_account_id' => 'acct_germany', + 'country' => 'DE', + ]); + + $accounts = $this->fakeStripeAccounts(country: 'DE', serviceAgreement: 'full'); + + $this->artisan('payouts:recreate-connect-account', ['developerAccount' => $developerAccount->id]) + ->expectsOutputToContain('can already pay on the full service agreement') + ->assertExitCode(1); + + $this->assertNull($accounts->createdWith); + $this->assertSame('acct_germany', $developerAccount->fresh()->stripe_connect_account_id); + $this->assertTrue($developerAccount->fresh()->hasCompletedOnboarding()); + } + + public function test_fails_when_the_developer_account_does_not_exist(): void + { + $this->artisan('payouts:recreate-connect-account', ['developerAccount' => 999]) + ->expectsOutputToContain('Developer account #999 not found') + ->assertExitCode(1); + } + + private function fakeStripeAccounts(string $country, string $serviceAgreement): object + { + $accounts = new class($country, $serviceAgreement) + { + public ?array $createdWith = null; + + public function __construct(private string $country, private string $serviceAgreement) {} + + public function retrieve(string $id): Account + { + return Account::constructFrom([ + 'id' => $id, + 'country' => $this->country, + 'tos_acceptance' => ['service_agreement' => $this->serviceAgreement], + ]); + } + + public function create(array $params): Account + { + $this->createdWith = $params; + + return Account::constructFrom(['id' => 'acct_new_recipient']); + } + }; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->accounts = $accounts; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + + return $accounts; + } +} diff --git a/tests/Feature/Commands/SendDailyPayoutSummaryTest.php b/tests/Feature/Commands/SendDailyPayoutSummaryTest.php new file mode 100644 index 000000000..dca5f7feb --- /dev/null +++ b/tests/Feature/Commands/SendDailyPayoutSummaryTest.php @@ -0,0 +1,81 @@ +transferred()->create([ + 'developer_amount' => 2000, + 'last_attempted_at' => now()->subHours(2), + ]); + $failedPayout = PluginPayout::factory()->failed()->create(['last_attempted_at' => now()->subHour()]); + PluginPayout::factory()->transferred()->create([ + 'developer_amount' => 5000, + 'last_attempted_at' => now()->subDays(2), + ]); + PluginPayout::factory()->failed()->create(['last_attempted_at' => now()->subHours(25)]); + + $upcomingPayout = PluginPayout::factory()->pending()->create(['eligible_for_payout_at' => now()->addDays(10)]); + $heldPayout = PluginPayout::factory()->create(['status' => PayoutStatus::Held]); + $payoutForInactiveAccount = PluginPayout::factory()->pending()->create([ + 'developer_account_id' => DeveloperAccount::factory()->pending()->create()->id, + ]); + + $this->artisan('payouts:send-daily-summary') + ->expectsOutputToContain('Sent the daily payout summary') + ->assertExitCode(0); + + Notification::assertSentOnDemand( + DailyPayoutSummary::class, + fn (DailyPayoutSummary $notification, array $channels, AnonymousNotifiable $notifiable) => $notifiable->routes['mail'] === config('mail.support_address') + && $notification->attemptedPayouts->pluck('id')->all() === [$transferredPayout->id, $failedPayout->id] + && $notification->upcomingPayouts->pluck('id')->all() === [$upcomingPayout->id] + && $notification->pendingPayouts->pluck('id')->all() === [$heldPayout->id, $payoutForInactiveAccount->id] + && $notification->totalPaidOut === 7000 + ); + } + + public function test_emails_when_nothing_was_sent_but_payouts_are_waiting(): void + { + Notification::fake(); + + $heldPayout = PluginPayout::factory()->create(['status' => PayoutStatus::Held]); + + $this->artisan('payouts:send-daily-summary')->assertExitCode(0); + + Notification::assertSentOnDemand( + DailyPayoutSummary::class, + fn (DailyPayoutSummary $notification) => $notification->attemptedPayouts->isEmpty() + && $notification->pendingPayouts->pluck('id')->all() === [$heldPayout->id] + ); + } + + public function test_does_not_email_when_there_is_nothing_to_report(): void + { + Notification::fake(); + + PluginPayout::factory()->transferred()->create(['last_attempted_at' => now()->subDays(2)]); + PluginPayout::factory()->failed()->create(['last_attempted_at' => now()->subDays(3)]); + + $this->artisan('payouts:send-daily-summary') + ->expectsOutputToContain('No payouts to report') + ->assertExitCode(0); + + Notification::assertNothingSent(); + } +} diff --git a/tests/Feature/Notifications/DailyPayoutSummaryTest.php b/tests/Feature/Notifications/DailyPayoutSummaryTest.php new file mode 100644 index 000000000..93136aed3 --- /dev/null +++ b/tests/Feature/Notifications/DailyPayoutSummaryTest.php @@ -0,0 +1,102 @@ +create(['name' => 'Ana Developer', 'email' => 'ana@example.com']); + $developerAccount = DeveloperAccount::factory()->create(['user_id' => $developer->id]); + $plugin = Plugin::factory()->paid()->create(['user_id' => $developer->id, 'name' => 'acme/camera-plugin']); + + $failedPayout = PluginPayout::factory()->failed()->create([ + 'plugin_license_id' => PluginLicense::factory()->create(['plugin_id' => $plugin->id])->id, + 'developer_account_id' => $developerAccount->id, + 'developer_amount' => 1000, + 'failure_reason' => 'Funds cannot be sent to accounts located in MX when the account is under the full service agreement.', + ]); + + $notification = new DailyPayoutSummary( + attemptedPayouts: collect([ + PluginPayout::factory()->transferred()->create(['developer_amount' => 2030]), + PluginPayout::factory()->transferred()->create(['developer_amount' => 3430]), + $failedPayout, + ]), + upcomingPayouts: collect([ + PluginPayout::factory()->pending()->create(['developer_amount' => 2500]), + ]), + pendingPayouts: collect([ + PluginPayout::factory()->create(['status' => PayoutStatus::Held, 'developer_amount' => 1000]), + PluginPayout::factory()->create(['status' => PayoutStatus::Held, 'developer_amount' => 2000]), + ]), + totalPaidOut: 123456, + ); + + $mail = $notification->toMail(new AnonymousNotifiable); + $rendered = $mail->render()->toHtml(); + + $this->assertSame('Payout summary: 1 failed', $mail->subject); + $this->assertStringContainsString('Total payouts: 3', $rendered); + $this->assertStringContainsString('Total amount: $64.60', $rendered); + $this->assertStringContainsString('Successful: 2 ($54.60 paid out)', $rendered); + $this->assertStringContainsString('Failed: 1', $rendered); + $this->assertStringContainsString('Upcoming: 1 ($25.00), for active accounts', $rendered); + $this->assertStringContainsString('Pending: 2 ($30.00), held until the account is active', $rendered); + $this->assertStringContainsString('Paid out to date: $1,234.56', $rendered); + $this->assertStringContainsString("Payout #{$failedPayout->id}: $10.00 for acme/camera-plugin to Ana Developer", $rendered); + $this->assertStringContainsString('ana@example.com', $rendered); + $this->assertStringContainsString('Stripe error: Funds cannot be sent to accounts located in MX when the account is under the full service agreement.', $rendered); + $this->assertStringContainsString(route('filament.admin.resources.plugin-payouts.view', $failedPayout), $rendered); + } + + public function test_subject_says_every_payout_was_sent_when_none_failed(): void + { + $notification = new DailyPayoutSummary( + attemptedPayouts: collect([ + PluginPayout::factory()->transferred()->create(), + PluginPayout::factory()->transferred()->create(), + ]), + upcomingPayouts: collect(), + pendingPayouts: collect(), + totalPaidOut: 5000, + ); + + $mail = $notification->toMail(new AnonymousNotifiable); + $rendered = $mail->render()->toHtml(); + + $this->assertSame('Payout summary: all 2 sent', $mail->subject); + $this->assertStringContainsString('Failed: 0', $rendered); + $this->assertStringNotContainsString('Failed payouts', $rendered); + } + + public function test_subject_says_nothing_was_sent_when_payouts_are_only_waiting(): void + { + $notification = new DailyPayoutSummary( + attemptedPayouts: collect(), + upcomingPayouts: collect(), + pendingPayouts: collect([ + PluginPayout::factory()->create(['status' => PayoutStatus::Held, 'developer_amount' => 1500]), + ]), + totalPaidOut: 5000, + ); + + $mail = $notification->toMail(new AnonymousNotifiable); + + $this->assertSame('Payout summary: nothing sent', $mail->subject); + $this->assertStringContainsString('Pending: 1 ($15.00), held until the account is active', $mail->render()->toHtml()); + } +} diff --git a/tests/Feature/Services/StripeConnectServiceTest.php b/tests/Feature/Services/StripeConnectServiceTest.php index 4ae683345..e5bcda8c0 100644 --- a/tests/Feature/Services/StripeConnectServiceTest.php +++ b/tests/Feature/Services/StripeConnectServiceTest.php @@ -3,13 +3,16 @@ namespace Tests\Feature\Services; use App\Enums\PayoutStatus; +use App\Enums\StripeConnectStatus; use App\Models\DeveloperAccount; use App\Models\Plugin; use App\Models\PluginLicense; use App\Models\PluginPayout; +use App\Models\User; use App\Services\StripeConnectService; use Illuminate\Foundation\Testing\RefreshDatabase; use PHPUnit\Framework\Attributes\Test; +use Stripe\Account; use Stripe\PaymentIntent; use Stripe\StripeClient; use Stripe\Transfer; @@ -136,4 +139,118 @@ public function create(array $params): Transfer $this->assertSame('eur', $capturedTransferParams['currency']); $this->assertArrayNotHasKey('source_transaction', $capturedTransferParams); } + + #[Test] + public function create_connect_account_uses_the_recipient_service_agreement_where_full_accounts_cannot_be_paid(): void + { + $user = User::factory()->create(); + $accounts = $this->fakeStripeAccounts(); + + $developerAccount = app(StripeConnectService::class)->createConnectAccount($user, 'MX', 'MXN'); + + $this->assertSame(['transfers' => ['requested' => true]], $accounts->createdWith['capabilities']); + $this->assertSame(['service_agreement' => 'recipient'], $accounts->createdWith['tos_acceptance']); + $this->assertSame('MX', $accounts->createdWith['country']); + $this->assertSame('acct_test_new', $developerAccount->stripe_connect_account_id); + $this->assertSame('MX', $developerAccount->country); + } + + #[Test] + public function create_connect_account_uses_the_full_service_agreement_where_full_accounts_can_be_paid(): void + { + $user = User::factory()->create(); + $accounts = $this->fakeStripeAccounts(); + + app(StripeConnectService::class)->createConnectAccount($user, 'GB', 'GBP'); + + $this->assertSame([ + 'card_payments' => ['requested' => true], + 'transfers' => ['requested' => true], + ], $accounts->createdWith['capabilities']); + $this->assertArrayNotHasKey('tos_acceptance', $accounts->createdWith); + } + + #[Test] + public function replace_connect_account_moves_the_developer_to_a_new_account_that_needs_onboarding(): void + { + $developerAccount = DeveloperAccount::factory()->create([ + 'stripe_connect_account_id' => 'acct_test_full', + 'country' => 'MX', + 'payout_currency' => 'MXN', + ]); + $accounts = $this->fakeStripeAccounts(); + + app(StripeConnectService::class)->replaceConnectAccount($developerAccount, 'MX'); + + $this->assertSame(['service_agreement' => 'recipient'], $accounts->createdWith['tos_acceptance']); + $this->assertSame($developerAccount->user->email, $accounts->createdWith['email']); + + $developerAccount->refresh(); + + $this->assertSame('acct_test_new', $developerAccount->stripe_connect_account_id); + $this->assertSame(StripeConnectStatus::Pending, $developerAccount->stripe_connect_status); + $this->assertFalse($developerAccount->payouts_enabled); + $this->assertFalse($developerAccount->hasCompletedOnboarding()); + $this->assertFalse($developerAccount->canReceivePayouts()); + $this->assertSame('MXN', $developerAccount->payout_currency); + } + + #[Test] + public function refresh_account_status_keeps_the_date_onboarding_was_first_completed(): void + { + $onboardedAt = now()->subMonths(3)->startOfSecond(); + $developerAccount = DeveloperAccount::factory()->create(['onboarding_completed_at' => $onboardedAt]); + $this->fakeStripeAccounts(); + + app(StripeConnectService::class)->refreshAccountStatus($developerAccount); + + $this->assertTrue($developerAccount->fresh()->onboarding_completed_at->equalTo($onboardedAt)); + } + + #[Test] + public function refresh_account_status_marks_a_developer_who_finished_onboarding_as_active(): void + { + $developerAccount = DeveloperAccount::factory()->pending()->create(); + $this->fakeStripeAccounts(); + + app(StripeConnectService::class)->refreshAccountStatus($developerAccount); + + $developerAccount->refresh(); + + $this->assertTrue($developerAccount->canReceivePayouts()); + $this->assertTrue($developerAccount->hasCompletedOnboarding()); + } + + private function fakeStripeAccounts(): object + { + $accounts = new class + { + public ?array $createdWith = null; + + public function create(array $params): Account + { + $this->createdWith = $params; + + return Account::constructFrom(['id' => 'acct_test_new']); + } + + public function retrieve(string $id): Account + { + return Account::constructFrom([ + 'id' => $id, + 'payouts_enabled' => true, + 'charges_enabled' => true, + 'details_submitted' => true, + 'requirements' => ['disabled_reason' => null], + ]); + } + }; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->accounts = $accounts; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + + return $accounts; + } } diff --git a/tests/Unit/StripeConnectCountriesTest.php b/tests/Unit/StripeConnectCountriesTest.php index 8bd9ebb62..301bcad61 100644 --- a/tests/Unit/StripeConnectCountriesTest.php +++ b/tests/Unit/StripeConnectCountriesTest.php @@ -163,6 +163,40 @@ public function namibia_is_not_in_supported_countries(): void $this->assertArrayNotHasKey('NA', StripeConnectCountries::all()); } + /** @test */ + public function countries_we_cannot_pay_on_the_full_agreement_require_the_recipient_service_agreement(): void + { + $this->assertTrue(StripeConnectCountries::requiresRecipientServiceAgreement('MX')); + $this->assertTrue(StripeConnectCountries::requiresRecipientServiceAgreement('AU')); + $this->assertTrue(StripeConnectCountries::requiresRecipientServiceAgreement('JP')); + $this->assertTrue(StripeConnectCountries::requiresRecipientServiceAgreement('AL')); + $this->assertTrue(StripeConnectCountries::requiresRecipientServiceAgreement('mx')); + $this->assertTrue( + StripeConnectCountries::requiresRecipientServiceAgreement('IS'), + 'Iceland is in the EEA, but Stripe only offers it the recipient service agreement' + ); + } + + /** @test */ + public function us_canada_uk_switzerland_and_eea_countries_use_the_full_service_agreement(): void + { + $this->assertFalse(StripeConnectCountries::requiresRecipientServiceAgreement('US')); + $this->assertFalse(StripeConnectCountries::requiresRecipientServiceAgreement('CA')); + $this->assertFalse(StripeConnectCountries::requiresRecipientServiceAgreement('GB')); + $this->assertFalse(StripeConnectCountries::requiresRecipientServiceAgreement('CH')); + $this->assertFalse(StripeConnectCountries::requiresRecipientServiceAgreement('DE')); + $this->assertFalse(StripeConnectCountries::requiresRecipientServiceAgreement('NO')); + $this->assertFalse(StripeConnectCountries::requiresRecipientServiceAgreement('us')); + } + + /** @test */ + public function every_full_service_agreement_country_is_supported(): void + { + foreach (StripeConnectCountries::FULL_SERVICE_AGREEMENT_COUNTRIES as $code) { + $this->assertTrue(StripeConnectCountries::isSupported($code), "Full service agreement country {$code} is not a supported country"); + } + } + /** @test */ public function each_country_has_required_keys(): void {