From 1e05dc98ae58b2b0f80977b94c4006dc53d4d247 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 19 Sep 2026 12:44:25 +0100 Subject: [PATCH 1/4] Create recipient Connect accounts where full accounts can't be paid Our Stripe platform is in the US, so it can only transfer to connected accounts on the full service agreement in the US, Canada, the UK, the EEA and Switzerland. Developers everywhere else now get a recipient account that only requests the transfers capability. Adds payouts:recreate-connect-account to move an existing developer onto a new recipient account and hold their failed payouts until they finish onboarding again. Co-Authored-By: Claude Opus 5 (1M context) --- .../Commands/RecreateConnectAccount.php | 58 +++++++ app/Services/StripeConnectService.php | 72 +++++++-- app/Support/StripeConnectCountries.php | 20 +++ .../Commands/RecreateConnectAccountTest.php | 141 ++++++++++++++++++ .../Services/StripeConnectServiceTest.php | 80 ++++++++++ tests/Unit/StripeConnectCountriesTest.php | 34 +++++ 6 files changed, 393 insertions(+), 12 deletions(-) create mode 100644 app/Console/Commands/RecreateConnectAccount.php create mode 100644 tests/Feature/Commands/RecreateConnectAccountTest.php 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/Services/StripeConnectService.php b/app/Services/StripeConnectService.php index 1e6a1484f..fa6cbba98 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([ @@ -202,6 +219,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/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/Services/StripeConnectServiceTest.php b/tests/Feature/Services/StripeConnectServiceTest.php index 4ae683345..ff0817171 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,81 @@ 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); + } + + 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']); + } + }; + + $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 { From 82e527767ec9393e0a57165b9d28e194c7b524c1 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 19 Sep 2026 14:07:59 +0100 Subject: [PATCH 2/4] Remove unused ProcessPluginCheckoutJob Nothing has dispatched it since plugin checkout moved to invoices in #261. Its payout logic had drifted from HandleInvoicePaidJob, which made it easy to misread how payouts work. Co-Authored-By: Claude Opus 5 (1M context) --- app/Jobs/ProcessPluginCheckoutJob.php | 318 -------------------------- 1 file changed, 318 deletions(-) delete mode 100644 app/Jobs/ProcessPluginCheckoutJob.php 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; - } -} From aa71512f68fd4b36306500c8a3d0d9538eacd96d Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 19 Sep 2026 14:07:59 +0100 Subject: [PATCH 3/4] Check Stripe for developers we can't pay yet in the daily payout run We only refreshed a developer's Stripe status when they came back from onboarding or opened their dashboard. If they finished onboarding and closed the tab, their held payouts never went out. The daily run now refreshes anyone we can't pay yet who has held or due payouts before it decides what to send. Refreshing no longer overwrites the date onboarding was completed. Co-Authored-By: Claude Opus 5 (1M context) --- .../Commands/ProcessEligiblePayouts.php | 35 ++++- app/Services/StripeConnectService.php | 4 +- .../Commands/ProcessEligiblePayoutsTest.php | 143 ++++++++++++++++++ .../Services/StripeConnectServiceTest.php | 37 +++++ 4 files changed, 216 insertions(+), 3 deletions(-) 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/Services/StripeConnectService.php b/app/Services/StripeConnectService.php index fa6cbba98..fbe82bdb2 100644 --- a/app/Services/StripeConnectService.php +++ b/app/Services/StripeConnectService.php @@ -83,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', [ 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/Services/StripeConnectServiceTest.php b/tests/Feature/Services/StripeConnectServiceTest.php index ff0817171..e5bcda8c0 100644 --- a/tests/Feature/Services/StripeConnectServiceTest.php +++ b/tests/Feature/Services/StripeConnectServiceTest.php @@ -195,6 +195,32 @@ public function replace_connect_account_moves_the_developer_to_a_new_account_tha $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 @@ -207,6 +233,17 @@ public function create(array $params): Account 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); From eceb8e52897ad58af35467b52cc51cb42df8c3dc Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 19 Sep 2026 15:09:26 +0100 Subject: [PATCH 4/4] Email a daily payout summary to support payouts:send-daily-summary runs at 12:00, an hour after the payout run, and emails the support address. It covers payouts sent or failed in the last 24 hours with Stripe's error for each failure, the upcoming and pending payouts still waiting, and the total paid out to date. It only skips a day when nothing was sent and nothing is waiting. Co-Authored-By: Claude Opus 5 (1M context) --- .../Commands/SendDailyPayoutSummary.php | 51 +++++++++ app/Console/Kernel.php | 5 + app/Notifications/DailyPayoutSummary.php | 93 ++++++++++++++++ .../Commands/SendDailyPayoutSummaryTest.php | 81 ++++++++++++++ .../Notifications/DailyPayoutSummaryTest.php | 102 ++++++++++++++++++ 5 files changed, 332 insertions(+) create mode 100644 app/Console/Commands/SendDailyPayoutSummary.php create mode 100644 app/Notifications/DailyPayoutSummary.php create mode 100644 tests/Feature/Commands/SendDailyPayoutSummaryTest.php create mode 100644 tests/Feature/Notifications/DailyPayoutSummaryTest.php 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/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/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()); + } +}