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); + } }