From 51ab6ff2f08af3a0573bd61e14a975ae2d392e70 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 27 Sep 2026 18:52:14 +0100 Subject: [PATCH] Pay developers on Stripe recipient accounts A Connect account was only marked active when both payouts_enabled and charges_enabled were true. Accounts on the recipient service agreement can never take charges, so those developers stayed pending and their payouts stayed held. We use separate charges and transfers, so the developer's account never charges anyone. It now counts as active when the transfers capability is active and payouts are enabled. A disabled account is checked first so it can never come out active. Co-Authored-By: Claude Opus 5.5 --- app/Filament/Resources/UserResource.php | 3 +- app/Services/StripeConnectService.php | 19 ++- .../Commands/ProcessEligiblePayoutsTest.php | 55 +++++- .../Filament/UserResourceDeveloperTest.php | 3 +- .../Services/StripeConnectServiceTest.php | 160 ++++++++++++++++-- 5 files changed, 220 insertions(+), 20 deletions(-) diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 71eead622..f451fdc99 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -104,7 +104,8 @@ public static function form(Schema $schema): Schema ->content(fn (User $record) => $record->developerAccount->payouts_enabled ? 'Yes' : 'No'), Forms\Components\Placeholder::make('developerAccount.charges_enabled') ->label('Charges Enabled') - ->content(fn (User $record) => $record->developerAccount->charges_enabled ? 'Yes' : 'No'), + ->content(fn (User $record) => $record->developerAccount->charges_enabled ? 'Yes' : 'No') + ->helperText('Not needed for payouts. Accounts on the recipient service agreement never have charges enabled.'), Forms\Components\Placeholder::make('developerAccount.onboarding_completed_at') ->label('Onboarding Completed') ->content(fn (User $record) => $record->developerAccount->onboarding_completed_at?->format('M j, Y g:i A') ?? '—'), diff --git a/app/Services/StripeConnectService.php b/app/Services/StripeConnectService.php index fbe82bdb2..cd23b9554 100644 --- a/app/Services/StripeConnectService.php +++ b/app/Services/StripeConnectService.php @@ -252,16 +252,27 @@ protected function createStripeAccount(User $user, string $country): Account return Cashier::stripe()->accounts->create($params); } + /** + * Buyers pay the platform and we send the developer's share with a transfer, so a + * developer's account never takes a charge itself. All we need is for it to receive + * transfers and pay them out to the bank. Recipient accounts can't take charges at all, + * so charges_enabled is always false for them and mustn't hold them back. + * + * Capabilities are read as an array because Stripe hands back an account with none as an + * empty array rather than an object. + */ protected function determineStatus(Account $account): StripeConnectStatus { - if ($account->payouts_enabled && $account->charges_enabled) { - return StripeConnectStatus::Active; - } - if ($account->requirements?->disabled_reason) { return StripeConnectStatus::Disabled; } + $transfersCapability = $account->capabilities['transfers'] ?? null; + + if ($transfersCapability === 'active' && $account->payouts_enabled) { + return StripeConnectStatus::Active; + } + return StripeConnectStatus::Pending; } diff --git a/tests/Feature/Commands/ProcessEligiblePayoutsTest.php b/tests/Feature/Commands/ProcessEligiblePayoutsTest.php index cbeb80f70..b88a6d932 100644 --- a/tests/Feature/Commands/ProcessEligiblePayoutsTest.php +++ b/tests/Feature/Commands/ProcessEligiblePayoutsTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Commands; use App\Enums\PayoutStatus; +use App\Enums\StripeConnectStatus; use App\Jobs\ProcessPayoutTransfer; use App\Models\DeveloperAccount; use App\Models\Plugin; @@ -237,6 +238,44 @@ public function test_checks_stripe_and_releases_held_payouts_once_the_developer_ }); } + public function test_releases_held_payouts_for_a_recipient_account_stuck_at_pending(): void + { + Queue::fake(); + + // How a developer on the recipient service agreement was left before charges_enabled + // stopped counting: onboarded and able to be paid out, but never marked active. + $developerAccount = DeveloperAccount::factory()->pending()->create([ + 'country' => 'MX', + 'payout_currency' => 'MXN', + 'payouts_enabled' => true, + 'onboarding_completed_at' => now()->subMonth(), + ]); + $payout = PluginPayout::factory()->create([ + 'developer_account_id' => $developerAccount->id, + 'status' => PayoutStatus::Held, + 'eligible_for_payout_at' => now()->subDay(), + ]); + + $accounts = $this->fakeStripeAccounts(canBePaid: true, serviceAgreement: 'recipient'); + + $this->artisan('payouts:process-eligible') + ->expectsOutputToContain('Healed 1 held payout(s)') + ->expectsOutputToContain('Dispatched 1 payout transfer job(s)') + ->assertExitCode(0); + + $developerAccount->refresh(); + + $this->assertSame([$developerAccount->stripe_connect_account_id], $accounts->retrieved); + $this->assertSame(StripeConnectStatus::Active, $developerAccount->stripe_connect_status); + $this->assertFalse($developerAccount->charges_enabled); + $this->assertTrue($developerAccount->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(); @@ -340,14 +379,14 @@ public function test_healed_payout_within_holding_period_is_not_dispatched(): vo Queue::assertNothingPushed(); } - private function fakeStripeAccounts(bool $canBePaid = false, bool $reachable = true): object + private function fakeStripeAccounts(bool $canBePaid = false, bool $reachable = true, string $serviceAgreement = 'full'): object { - $accounts = new class($canBePaid, $reachable) + $accounts = new class($canBePaid, $reachable, $serviceAgreement) { /** @var list */ public array $retrieved = []; - public function __construct(private bool $canBePaid, private bool $reachable) {} + public function __construct(private bool $canBePaid, private bool $reachable, private string $serviceAgreement) {} public function retrieve(string $id): Account { @@ -357,12 +396,20 @@ public function retrieve(string $id): Account throw new ApiConnectionException('Could not connect to Stripe'); } + $capability = $this->canBePaid ? 'active' : 'inactive'; + $isRecipient = $this->serviceAgreement === 'recipient'; + return Account::constructFrom([ 'id' => $id, 'payouts_enabled' => $this->canBePaid, - 'charges_enabled' => $this->canBePaid, + // Recipient accounts can't take payments, so Stripe never enables charges on them. + 'charges_enabled' => $this->canBePaid && ! $isRecipient, 'details_submitted' => $this->canBePaid, + 'capabilities' => $isRecipient + ? ['transfers' => $capability] + : ['card_payments' => $capability, 'transfers' => $capability], 'requirements' => ['disabled_reason' => null], + 'tos_acceptance' => ['service_agreement' => $this->serviceAgreement], ]); } }; diff --git a/tests/Feature/Filament/UserResourceDeveloperTest.php b/tests/Feature/Filament/UserResourceDeveloperTest.php index e4982aaee..b4477ae82 100644 --- a/tests/Feature/Filament/UserResourceDeveloperTest.php +++ b/tests/Feature/Filament/UserResourceDeveloperTest.php @@ -39,7 +39,8 @@ public function test_developer_account_section_is_visible_when_user_has_develope $this->actingAs($this->admin) ->get(EditUser::getUrl(['record' => $this->user])) ->assertSee('Developer Account') - ->assertSee($this->user->developerAccount->stripe_connect_account_id); + ->assertSee($this->user->developerAccount->stripe_connect_account_id) + ->assertSee('Not needed for payouts.'); } public function test_developer_account_section_is_hidden_when_user_has_no_developer_account(): void diff --git a/tests/Feature/Services/StripeConnectServiceTest.php b/tests/Feature/Services/StripeConnectServiceTest.php index e5bcda8c0..f69aed4dc 100644 --- a/tests/Feature/Services/StripeConnectServiceTest.php +++ b/tests/Feature/Services/StripeConnectServiceTest.php @@ -11,6 +11,7 @@ use App\Models\User; use App\Services\StripeConnectService; use Illuminate\Foundation\Testing\RefreshDatabase; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Stripe\Account; use Stripe\PaymentIntent; @@ -211,22 +212,111 @@ public function refresh_account_status_keeps_the_date_onboarding_was_first_compl public function refresh_account_status_marks_a_developer_who_finished_onboarding_as_active(): void { $developerAccount = DeveloperAccount::factory()->pending()->create(); - $this->fakeStripeAccounts(); + $this->fakeStripeAccounts(self::fullAccount()); app(StripeConnectService::class)->refreshAccountStatus($developerAccount); $developerAccount->refresh(); + $this->assertSame(StripeConnectStatus::Active, $developerAccount->stripe_connect_status); + $this->assertTrue($developerAccount->charges_enabled); $this->assertTrue($developerAccount->canReceivePayouts()); $this->assertTrue($developerAccount->hasCompletedOnboarding()); } - private function fakeStripeAccounts(): object + #[Test] + public function refresh_account_status_marks_a_recipient_account_as_active_even_though_it_cannot_take_charges(): void + { + $developerAccount = DeveloperAccount::factory()->pending()->create([ + 'country' => 'MX', + 'payout_currency' => 'MXN', + ]); + $this->fakeStripeAccounts(self::recipientAccount()); + + app(StripeConnectService::class)->refreshAccountStatus($developerAccount); + + $developerAccount->refresh(); + + $this->assertSame(StripeConnectStatus::Active, $developerAccount->stripe_connect_status); + $this->assertFalse($developerAccount->charges_enabled); + $this->assertTrue($developerAccount->payouts_enabled); + $this->assertTrue($developerAccount->canReceivePayouts()); + } + + /** + * @param array $account + */ + #[Test] + #[DataProvider('accountsThatCannotBePaidYet')] + public function refresh_account_status_keeps_a_developer_pending_until_stripe_can_pay_them(array $account): void + { + $developerAccount = DeveloperAccount::factory()->pending()->create(); + $this->fakeStripeAccounts($account); + + app(StripeConnectService::class)->refreshAccountStatus($developerAccount); + + $developerAccount->refresh(); + + $this->assertSame(StripeConnectStatus::Pending, $developerAccount->stripe_connect_status); + $this->assertFalse($developerAccount->canReceivePayouts()); + } + + /** + * @return array}> + */ + public static function accountsThatCannotBePaidYet(): array { - $accounts = new class + return [ + 'recipient account with transfers pending' => [self::recipientAccount(['capabilities' => ['transfers' => 'pending']])], + 'recipient account with transfers inactive' => [self::recipientAccount(['capabilities' => ['transfers' => 'inactive']])], + 'recipient account without payouts enabled' => [self::recipientAccount(['payouts_enabled' => false])], + 'full account with transfers pending' => [self::fullAccount(['capabilities' => ['transfers' => 'pending']])], + 'full account with transfers inactive' => [self::fullAccount(['capabilities' => ['transfers' => 'inactive']])], + 'account without the transfers capability' => [array_merge(self::recipientAccount(), ['capabilities' => []])], + ]; + } + + /** + * @param array $account + */ + #[Test] + #[DataProvider('disabledAccounts')] + public function refresh_account_status_marks_a_disabled_account_as_disabled_even_if_it_could_otherwise_be_paid(array $account): void + { + $developerAccount = DeveloperAccount::factory()->create(); + $this->fakeStripeAccounts($account); + + app(StripeConnectService::class)->refreshAccountStatus($developerAccount); + + $developerAccount->refresh(); + + $this->assertSame(StripeConnectStatus::Disabled, $developerAccount->stripe_connect_status); + $this->assertTrue($developerAccount->payouts_enabled); + $this->assertFalse($developerAccount->canReceivePayouts()); + } + + /** + * @return array}> + */ + public static function disabledAccounts(): array + { + return [ + 'recipient account' => [self::recipientAccount(['requirements' => ['disabled_reason' => 'requirements.past_due']])], + 'full account' => [self::fullAccount(['requirements' => ['disabled_reason' => 'under_review']])], + ]; + } + + /** + * @param array|null $retrieved What Stripe returns when the account is retrieved. + */ + private function fakeStripeAccounts(?array $retrieved = null): object + { + $accounts = new class($retrieved ?? self::fullAccount()) { public ?array $createdWith = null; + public function __construct(private array $retrieved) {} + public function create(array $params): Account { $this->createdWith = $params; @@ -236,13 +326,7 @@ public function create(array $params): Account public function retrieve(string $id): Account { - return Account::constructFrom([ - 'id' => $id, - 'payouts_enabled' => true, - 'charges_enabled' => true, - 'details_submitted' => true, - 'requirements' => ['disabled_reason' => null], - ]); + return Account::constructFrom(['id' => $id] + $this->retrieved); } }; @@ -253,4 +337,60 @@ public function retrieve(string $id): Account return $accounts; } + + /** + * An onboarded Express account on the full service agreement, the way Stripe returns it. + * + * @param array $overrides + * @return array + */ + private static function fullAccount(array $overrides = []): array + { + return array_replace_recursive([ + 'object' => 'account', + 'type' => 'express', + 'country' => 'GB', + 'charges_enabled' => true, + 'payouts_enabled' => true, + 'details_submitted' => true, + 'capabilities' => [ + 'card_payments' => 'active', + 'transfers' => 'active', + ], + 'requirements' => [ + 'currently_due' => [], + 'past_due' => [], + 'disabled_reason' => null, + ], + 'tos_acceptance' => ['service_agreement' => 'full'], + ], $overrides); + } + + /** + * An onboarded Express account on the recipient service agreement. It only has the + * transfers capability, so Stripe never enables charges on it. + * + * @param array $overrides + * @return array + */ + private static function recipientAccount(array $overrides = []): array + { + return array_replace_recursive([ + 'object' => 'account', + 'type' => 'express', + 'country' => 'MX', + 'charges_enabled' => false, + 'payouts_enabled' => true, + 'details_submitted' => true, + 'capabilities' => [ + 'transfers' => 'active', + ], + 'requirements' => [ + 'currently_due' => [], + 'past_due' => [], + 'disabled_reason' => null, + ], + 'tos_acceptance' => ['service_agreement' => 'recipient'], + ], $overrides); + } }