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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/Filament/Resources/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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') ?? '—'),
Expand Down
19 changes: 15 additions & 4 deletions app/Services/StripeConnectService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
55 changes: 51 additions & 4 deletions tests/Feature/Commands/ProcessEligiblePayoutsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string> */
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
{
Expand All @@ -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],
]);
}
};
Expand Down
3 changes: 2 additions & 1 deletion tests/Feature/Filament/UserResourceDeveloperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
160 changes: 150 additions & 10 deletions tests/Feature/Services/StripeConnectServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, mixed> $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<string, array{array<string, mixed>}>
*/
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<string, mixed> $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<string, array{array<string, mixed>}>
*/
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<string, mixed>|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;
Expand All @@ -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);
}
};

Expand All @@ -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<string, mixed> $overrides
* @return array<string, mixed>
*/
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<string, mixed> $overrides
* @return array<string, mixed>
*/
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);
}
}
Loading