diff --git a/app/Console/Commands/BackfillMissingPayouts.php b/app/Console/Commands/BackfillMissingPayouts.php new file mode 100644 index 00000000..3001937f --- /dev/null +++ b/app/Console/Commands/BackfillMissingPayouts.php @@ -0,0 +1,92 @@ +whereDoesntHave('payout') + ->where('is_grandfathered', false) + ->where('price_paid', '>', 0) + ->whereHas('plugin', function (Builder $query): void { + $query->where('is_official', false) + ->where('type', PluginType::Paid) + ->whereNotNull('developer_account_id'); + }) + ->with('plugin.developerAccount') + ->get(); + + if ($licenses->isEmpty()) { + $this->info('No sales are missing payout records.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $created = 0; + + foreach ($licenses as $license) { + $developerAccount = $license->plugin->developerAccount; + + $split = PluginPayout::calculateSplit($license->price_paid, $developerAccount->platformFeePercent()); + + $status = $developerAccount->canReceivePayouts() + ? PayoutStatus::Pending + : PayoutStatus::Held; + + $eligibleAt = $license->purchased_at?->clone()->addDays(15) ?? now(); + + $this->line(sprintf( + '%sLicense #%d (%s): gross $%s, developer $%s, status %s', + $dryRun ? '[dry-run] ' : '', + $license->id, + $license->plugin->name, + number_format($license->price_paid / 100, 2), + number_format($split['developer_amount'] / 100, 2), + $status->value, + )); + + if ($dryRun) { + continue; + } + + PluginPayout::create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $developerAccount->id, + 'gross_amount' => $license->price_paid, + 'platform_fee' => $split['platform_fee'], + 'developer_amount' => $split['developer_amount'], + 'status' => $status, + 'eligible_for_payout_at' => $eligibleAt, + ]); + + $created++; + + Log::info('Backfilled missing payout', [ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $developerAccount->id, + 'status' => $status->value, + ]); + } + + $this->info($dryRun + ? "Found {$licenses->count()} sale(s) missing payout records." + : "Created {$created} payout record(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ProcessEligiblePayouts.php b/app/Console/Commands/ProcessEligiblePayouts.php index 546ea0d9..389ec6a2 100644 --- a/app/Console/Commands/ProcessEligiblePayouts.php +++ b/app/Console/Commands/ProcessEligiblePayouts.php @@ -2,18 +2,22 @@ namespace App\Console\Commands; +use App\Enums\PayoutStatus; use App\Jobs\ProcessPayoutTransfer; use App\Models\PluginPayout; use Illuminate\Console\Command; +use Illuminate\Support\Facades\Log; class ProcessEligiblePayouts extends Command { protected $signature = 'payouts:process-eligible'; - protected $description = 'Dispatch transfer jobs for pending payouts that have passed the 15-day holding period'; + protected $description = 'Heal held payouts and dispatch transfer jobs for pending payouts that have passed the 15-day holding period'; public function handle(): int { + $this->healHeldPayouts(); + $eligiblePayouts = PluginPayout::pending() ->where('eligible_for_payout_at', '<=', now()) ->get(); @@ -32,4 +36,35 @@ public function handle(): int return self::SUCCESS; } + + /** + * Promote held payouts to pending once the developer's Stripe Connect + * account is able to receive payouts. + */ + private function healHeldPayouts(): void + { + $heldPayouts = PluginPayout::held() + ->with('developerAccount') + ->get(); + + $healed = 0; + + foreach ($heldPayouts as $payout) { + if (! $payout->developerAccount?->canReceivePayouts()) { + continue; + } + + $payout->update(['status' => PayoutStatus::Pending]); + $healed++; + + Log::info('Healed held payout', [ + 'payout_id' => $payout->id, + 'developer_account_id' => $payout->developer_account_id, + ]); + } + + if ($healed > 0) { + $this->info("Healed {$healed} held payout(s)."); + } + } } diff --git a/app/Enums/PayoutStatus.php b/app/Enums/PayoutStatus.php index b548934e..f9afb2ed 100644 --- a/app/Enums/PayoutStatus.php +++ b/app/Enums/PayoutStatus.php @@ -4,6 +4,7 @@ enum PayoutStatus: string { + case Held = 'held'; case Pending = 'pending'; case Transferred = 'transferred'; case Failed = 'failed'; @@ -11,6 +12,7 @@ enum PayoutStatus: string public function label(): string { return match ($this) { + self::Held => 'Held', self::Pending => 'Pending', self::Transferred => 'Transferred', self::Failed => 'Failed', @@ -20,6 +22,7 @@ public function label(): string public function color(): string { return match ($this) { + self::Held => 'gray', self::Pending => 'yellow', self::Transferred => 'green', self::Failed => 'red', diff --git a/app/Filament/Resources/PluginPayoutResource.php b/app/Filament/Resources/PluginPayoutResource.php new file mode 100644 index 00000000..00983f6d --- /dev/null +++ b/app/Filament/Resources/PluginPayoutResource.php @@ -0,0 +1,213 @@ +schema([]); + } + + public static function infolist(Schema $schema): Schema + { + return $schema + ->inlineLabel() + ->columns(1) + ->schema([ + Schemas\Components\Section::make('Payout Details') + ->inlineLabel() + ->columns(1) + ->schema([ + Infolists\Components\TextEntry::make('pluginLicense.plugin.display_name') + ->label('Plugin') + ->default(fn (PluginPayout $record): ?string => $record->pluginLicense?->plugin?->name), + Infolists\Components\TextEntry::make('pluginLicense.user.email') + ->label('Customer') + ->copyable(), + Infolists\Components\TextEntry::make('developerAccount.user.email') + ->label('Seller') + ->copyable(), + Infolists\Components\TextEntry::make('status') + ->badge() + ->color(fn (PayoutStatus $state): string => $state->color()) + ->formatStateUsing(fn (PayoutStatus $state): string => $state->label()), + Infolists\Components\TextEntry::make('gross_amount') + ->label('Customer Paid') + ->formatStateUsing(fn (int $state): string => '$'.number_format($state / 100, 2)), + Infolists\Components\TextEntry::make('developer_amount') + ->label('Due to Seller') + ->formatStateUsing(fn (int $state): string => '$'.number_format($state / 100, 2)), + Infolists\Components\TextEntry::make('platform_fee') + ->label('Platform Fee') + ->formatStateUsing(fn (int $state): string => '$'.number_format($state / 100, 2)), + Infolists\Components\TextEntry::make('stripe_transfer_id') + ->label('Stripe Transfer ID') + ->copyable() + ->placeholder('—'), + Infolists\Components\TextEntry::make('attempt_count') + ->label('Total Attempts'), + Infolists\Components\TextEntry::make('last_attempted_at') + ->label('Last Attempt') + ->dateTime() + ->placeholder('—'), + Infolists\Components\TextEntry::make('transferred_at') + ->label('Transferred At') + ->dateTime() + ->placeholder('—'), + Infolists\Components\TextEntry::make('eligible_for_payout_at') + ->label('Eligible From') + ->dateTime() + ->placeholder('—'), + Infolists\Components\TextEntry::make('created_at') + ->label('Created') + ->dateTime(), + ]), + + Schemas\Components\Section::make('Latest Failure Reason') + ->inlineLabel() + ->columns(1) + ->schema([ + Infolists\Components\TextEntry::make('failure_reason') + ->label('Reason') + ->placeholder('—'), + ]) + ->visible(fn (PluginPayout $record): bool => $record->failure_reason !== null), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('#') + ->sortable(), + + Tables\Columns\TextColumn::make('pluginLicense.plugin.name') + ->label('Plugin') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('pluginLicense.user.email') + ->label('Customer') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('gross_amount') + ->label('Paid') + ->formatStateUsing(fn (int $state): string => '$'.number_format($state / 100, 2)) + ->sortable(), + + Tables\Columns\TextColumn::make('developer_amount') + ->label('Due to Seller') + ->formatStateUsing(fn (int $state): string => '$'.number_format($state / 100, 2)) + ->sortable(), + + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (PayoutStatus $state): string => $state->color()) + ->formatStateUsing(fn (PayoutStatus $state): string => $state->label()) + ->sortable(), + + Tables\Columns\IconColumn::make('paid_out') + ->label('Paid Out') + ->boolean() + ->getStateUsing(fn (PluginPayout $record): bool => $record->isTransferred()), + + Tables\Columns\TextColumn::make('attempt_count') + ->label('Attempts') + ->sortable(), + + Tables\Columns\TextColumn::make('created_at') + ->label('Created') + ->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status') + ->options(collect(PayoutStatus::cases()) + ->mapWithKeys(fn (PayoutStatus $status) => [$status->value => $status->label()]) + ->toArray()), + ]) + ->actions([ + Actions\ViewAction::make(), + Actions\Action::make('retryPayout') + ->label('Retry Payout') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->modalHeading('Retry Payout') + ->modalDescription('This will reset the payout to pending and dispatch the transfer job. Continue?') + ->modalSubmitActionLabel('Retry') + ->visible(fn (PluginPayout $record): bool => $record->isFailed()) + ->action(function (PluginPayout $record): void { + $record->update([ + 'status' => PayoutStatus::Pending, + ]); + + ProcessPayoutTransfer::dispatch($record); + + Notification::make() + ->title("Payout #{$record->id} queued for retry") + ->success() + ->send(); + }), + ]) + ->bulkActions([]) + ->defaultSort('created_at', 'desc') + ->recordUrl( + fn ($record) => static::getUrl('view', ['record' => $record]) + ); + } + + public static function getRelations(): array + { + return [ + RelationManagers\AttemptsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPluginPayouts::route('/'), + 'view' => Pages\ViewPluginPayout::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/PluginPayoutResource/Pages/ListPluginPayouts.php b/app/Filament/Resources/PluginPayoutResource/Pages/ListPluginPayouts.php new file mode 100644 index 00000000..2edb4139 --- /dev/null +++ b/app/Filament/Resources/PluginPayoutResource/Pages/ListPluginPayouts.php @@ -0,0 +1,16 @@ +label('Retry Payout') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->modalHeading('Retry Payout') + ->modalDescription('This will reset the payout to pending and dispatch the transfer job. Continue?') + ->modalSubmitActionLabel('Retry') + ->visible(fn (): bool => $this->record instanceof PluginPayout && $this->record->isFailed()) + ->action(function (): void { + /** @var PluginPayout $payout */ + $payout = $this->record; + + $payout->update([ + 'status' => PayoutStatus::Pending, + ]); + + ProcessPayoutTransfer::dispatch($payout); + + Notification::make() + ->title("Payout #{$payout->id} queued for retry") + ->success() + ->send(); + + $this->refreshFormData(['status']); + }), + ]; + } +} diff --git a/app/Filament/Resources/PluginPayoutResource/RelationManagers/AttemptsRelationManager.php b/app/Filament/Resources/PluginPayoutResource/RelationManagers/AttemptsRelationManager.php new file mode 100644 index 00000000..87de6782 --- /dev/null +++ b/app/Filament/Resources/PluginPayoutResource/RelationManagers/AttemptsRelationManager.php @@ -0,0 +1,53 @@ +columns([ + Tables\Columns\TextColumn::make('id') + ->label('#'), + + Tables\Columns\TextColumn::make('created_at') + ->label('When') + ->dateTime() + ->sortable(), + + Tables\Columns\IconColumn::make('succeeded') + ->label('Succeeded') + ->boolean(), + + Tables\Columns\TextColumn::make('charge_id') + ->label('Charge') + ->copyable() + ->placeholder('—'), + + Tables\Columns\TextColumn::make('stripe_transfer_id') + ->label('Transfer') + ->copyable() + ->placeholder('—'), + + Tables\Columns\TextColumn::make('error_message') + ->label('Error') + ->wrap() + ->placeholder('—') + ->tooltip(fn (PluginPayoutAttempt $record): ?string => $record->error_message), + ]) + ->headerActions([]) + ->actions([]) + ->bulkActions([]) + ->defaultSort('created_at', 'desc'); + } +} diff --git a/app/Filament/Resources/ThirdPartySaleResource.php b/app/Filament/Resources/ThirdPartySaleResource.php new file mode 100644 index 00000000..fca543b9 --- /dev/null +++ b/app/Filament/Resources/ThirdPartySaleResource.php @@ -0,0 +1,153 @@ +whereHas('plugin', function (Builder $query): void { + $query->where('is_official', false) + ->where('type', PluginType::Paid); + }) + ->where('is_grandfathered', false) + ->with(['plugin.developerAccount.user', 'user', 'payout']); + } + + public static function form(Schema $schema): Schema + { + return $schema->schema([]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('purchased_at') + ->label('Date') + ->dateTime() + ->sortable(), + + Tables\Columns\TextColumn::make('plugin.name') + ->label('Plugin') + ->searchable() + ->sortable() + ->fontFamily('mono'), + + Tables\Columns\TextColumn::make('user.email') + ->label('Customer') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('plugin.developerAccount.user.email') + ->label('Seller') + ->searchable(), + + Tables\Columns\TextColumn::make('price_paid') + ->label('Paid') + ->formatStateUsing(fn (int $state): string => '$'.number_format($state / 100, 2)) + ->sortable(), + + Tables\Columns\TextColumn::make('payout.developer_amount') + ->label('Due to Seller') + ->formatStateUsing(fn (?int $state): string => $state === null ? '—' : '$'.number_format($state / 100, 2)) + ->placeholder('—'), + + Tables\Columns\TextColumn::make('payout_status') + ->label('Payout') + ->badge() + ->getStateUsing(function (PluginLicense $record): string { + if ($record->payout) { + return $record->payout->status->label(); + } + + return $record->price_paid > 0 ? 'Missing' : 'N/A'; + }) + ->color(function (PluginLicense $record): string { + if ($record->payout) { + return $record->payout->status->color(); + } + + return $record->price_paid > 0 ? 'danger' : 'gray'; + }), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('missing_payout') + ->label('Missing Payout') + ->queries( + true: fn (Builder $query) => $query->whereDoesntHave('payout')->where('price_paid', '>', 0), + false: fn (Builder $query) => $query->whereHas('payout'), + ), + + Tables\Filters\SelectFilter::make('payout_status') + ->label('Payout Status') + ->options(collect(PayoutStatus::cases()) + ->mapWithKeys(fn (PayoutStatus $status) => [$status->value => $status->label()]) + ->toArray()) + ->query(function (Builder $query, array $data): Builder { + if (blank($data['value'])) { + return $query; + } + + return $query->whereHas('payout', fn (Builder $q) => $q->where('status', $data['value'])); + }), + + Tables\Filters\SelectFilter::make('plugin_id') + ->label('Plugin') + ->relationship('plugin', 'name', fn (Builder $query) => $query->where('is_official', false)->where('type', PluginType::Paid)) + ->searchable() + ->preload(), + ]) + ->actions([ + Actions\Action::make('viewPayout') + ->label('View Payout') + ->icon('heroicon-o-currency-dollar') + ->url(fn (PluginLicense $record): ?string => $record->payout + ? PluginPayoutResource::getUrl('view', ['record' => $record->payout]) + : null) + ->visible(fn (PluginLicense $record): bool => $record->payout !== null), + ]) + ->bulkActions([]) + ->defaultSort('purchased_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListThirdPartySales::route('/'), + ]; + } +} diff --git a/app/Filament/Resources/ThirdPartySaleResource/Pages/ListThirdPartySales.php b/app/Filament/Resources/ThirdPartySaleResource/Pages/ListThirdPartySales.php new file mode 100644 index 00000000..89faf63a --- /dev/null +++ b/app/Filament/Resources/ThirdPartySaleResource/Pages/ListThirdPartySales.php @@ -0,0 +1,16 @@ + now(), ]); - // Create payout record for developer if applicable - if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $amount > 0) { - $platformFeePercent = ($user->hasActiveUltraSubscription() && ! $plugin->isOfficial()) - ? 0 - : $plugin->developerAccount->platformFeePercent(); - - $split = PluginPayout::calculateSplit($amount, $platformFeePercent); - - 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), - ]); - } + $this->createPayoutForLicense($license, $plugin, $amount); Log::info('Created plugin license from invoice', [ 'invoice_id' => $this->invoice->id, @@ -604,24 +587,7 @@ private function createBundlePluginLicense(User $user, Plugin $plugin, PluginBun 'purchased_at' => now(), ]); - // Create proportional payout for developer - if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $allocatedAmount > 0) { - $platformFeePercent = ($user->hasActiveUltraSubscription() && ! $plugin->isOfficial()) - ? 0 - : $plugin->developerAccount->platformFeePercent(); - - $split = PluginPayout::calculateSplit($allocatedAmount, $platformFeePercent); - - 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), - ]); - } + $this->createPayoutForLicense($license, $plugin, $allocatedAmount); Log::info('Created bundle plugin license from invoice', [ 'invoice_id' => $this->invoice->id, @@ -633,6 +599,44 @@ private function createBundlePluginLicense(User $user, Plugin $plugin, PluginBun return $license; } + /** + * Payouts are 1:1 with paid sales. If the developer cannot yet receive payouts, + * the payout is created as Held and promoted to Pending by the daily + * payouts:process-eligible run once their Stripe Connect account is ready. + */ + private function createPayoutForLicense(PluginLicense $license, Plugin $plugin, int $amount): void + { + if ($amount <= 0) { + return; + } + + if (! $plugin->developerAccount) { + if (! $plugin->isOfficial()) { + Log::warning('Paid third-party plugin sale has no developer account; payout not created', [ + 'invoice_id' => $this->invoice->id, + 'license_id' => $license->id, + 'plugin_id' => $plugin->id, + ]); + } + + return; + } + + $split = PluginPayout::calculateSplit($amount, $plugin->developerAccount->platformFeePercent()); + + 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' => $plugin->developerAccount->canReceivePayouts() + ? PayoutStatus::Pending + : PayoutStatus::Held, + 'eligible_for_payout_at' => now()->addDays(15), + ]); + } + /** * Update the price paid on the local Cashier subscription from the invoice total. */ diff --git a/app/Models/PluginPayout.php b/app/Models/PluginPayout.php index f20b413d..73647699 100644 --- a/app/Models/PluginPayout.php +++ b/app/Models/PluginPayout.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class PluginPayout extends Model { @@ -33,6 +34,24 @@ public function developerAccount(): BelongsTo return $this->belongsTo(DeveloperAccount::class); } + /** + * @return HasMany + */ + public function attempts(): HasMany + { + return $this->hasMany(PluginPayoutAttempt::class); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function held(Builder $query): Builder + { + return $query->where('status', PayoutStatus::Held); + } + /** * @param Builder $query * @return Builder @@ -77,6 +96,11 @@ public static function calculateSplit(int $grossAmount, int $platformFeePercent ]; } + public function isHeld(): bool + { + return $this->status === PayoutStatus::Held; + } + public function isPending(): bool { return $this->status === PayoutStatus::Pending; @@ -98,13 +122,15 @@ public function markAsTransferred(string $stripeTransferId): void 'status' => PayoutStatus::Transferred, 'stripe_transfer_id' => $stripeTransferId, 'transferred_at' => now(), + 'failure_reason' => null, ]); } - public function markAsFailed(): void + public function markAsFailed(?string $reason = null): void { $this->update([ 'status' => PayoutStatus::Failed, + 'failure_reason' => $reason, ]); } @@ -117,6 +143,8 @@ protected function casts(): array 'status' => PayoutStatus::class, 'transferred_at' => 'datetime', 'eligible_for_payout_at' => 'datetime', + 'last_attempted_at' => 'datetime', + 'attempt_count' => 'integer', ]; } } diff --git a/app/Models/PluginPayoutAttempt.php b/app/Models/PluginPayoutAttempt.php new file mode 100644 index 00000000..7c36ea96 --- /dev/null +++ b/app/Models/PluginPayoutAttempt.php @@ -0,0 +1,29 @@ + + */ + public function pluginPayout(): BelongsTo + { + return $this->belongsTo(PluginPayout::class); + } + + protected function casts(): array + { + return [ + 'succeeded' => 'boolean', + ]; + } +} diff --git a/app/Services/StripeConnectService.php b/app/Services/StripeConnectService.php index 8402de61..1e6a1484 100644 --- a/app/Services/StripeConnectService.php +++ b/app/Services/StripeConnectService.php @@ -8,6 +8,7 @@ use App\Models\Plugin; use App\Models\PluginLicense; use App\Models\PluginPayout; +use App\Models\PluginPayoutAttempt; use App\Models\PluginPrice; use App\Models\User; use Illuminate\Support\Facades\Log; @@ -114,6 +115,9 @@ public function processTransfer(PluginPayout $payout): bool $transferCurrency = $chargeDetails['currency'] ?? strtolower($developerAccount->payout_currency ?? 'usd'); + $payout->increment('attempt_count'); + $payout->update(['last_attempted_at' => now()]); + try { $transferParams = [ 'amount' => $payout->developer_amount, @@ -134,6 +138,13 @@ public function processTransfer(PluginPayout $payout): bool $payout->markAsTransferred($transfer->id); + PluginPayoutAttempt::create([ + 'plugin_payout_id' => $payout->id, + 'succeeded' => true, + 'charge_id' => $chargeId, + 'stripe_transfer_id' => $transfer->id, + ]); + Log::info('Processed transfer for payout', [ 'payout_id' => $payout->id, 'transfer_id' => $transfer->id, @@ -150,7 +161,14 @@ public function processTransfer(PluginPayout $payout): bool 'error' => $e->getMessage(), ]); - $payout->markAsFailed(); + $payout->markAsFailed($e->getMessage()); + + PluginPayoutAttempt::create([ + 'plugin_payout_id' => $payout->id, + 'succeeded' => false, + 'charge_id' => $chargeId, + 'error_message' => $e->getMessage(), + ]); return false; } diff --git a/database/factories/PluginPayoutAttemptFactory.php b/database/factories/PluginPayoutAttemptFactory.php new file mode 100644 index 00000000..7cc5cd1b --- /dev/null +++ b/database/factories/PluginPayoutAttemptFactory.php @@ -0,0 +1,36 @@ + + */ +class PluginPayoutAttemptFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'plugin_payout_id' => PluginPayout::factory(), + 'succeeded' => false, + 'charge_id' => 'ch_'.$this->faker->uuid(), + 'stripe_transfer_id' => null, + 'error_message' => 'Stripe error: something went wrong', + ]; + } + + public function succeeded(): static + { + return $this->state(fn () => [ + 'succeeded' => true, + 'stripe_transfer_id' => 'tr_'.$this->faker->uuid(), + 'error_message' => null, + ]); + } +} diff --git a/database/factories/PluginPayoutFactory.php b/database/factories/PluginPayoutFactory.php new file mode 100644 index 00000000..860315a8 --- /dev/null +++ b/database/factories/PluginPayoutFactory.php @@ -0,0 +1,58 @@ + + */ +class PluginPayoutFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $gross = $this->faker->numberBetween(1000, 10000); + $split = PluginPayout::calculateSplit($gross); + + return [ + 'plugin_license_id' => PluginLicense::factory(), + 'developer_account_id' => DeveloperAccount::factory(), + 'gross_amount' => $gross, + 'platform_fee' => $split['platform_fee'], + 'developer_amount' => $split['developer_amount'], + 'status' => PayoutStatus::Pending, + 'eligible_for_payout_at' => now()->subDay(), + ]; + } + + public function pending(): static + { + return $this->state(fn () => ['status' => PayoutStatus::Pending]); + } + + public function transferred(): static + { + return $this->state(fn () => [ + 'status' => PayoutStatus::Transferred, + 'stripe_transfer_id' => 'tr_'.$this->faker->uuid(), + 'transferred_at' => now(), + ]); + } + + public function failed(): static + { + return $this->state(fn () => [ + 'status' => PayoutStatus::Failed, + 'failure_reason' => 'Stripe error: insufficient funds', + 'attempt_count' => 1, + 'last_attempted_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_06_09_145059_add_failure_reason_to_plugin_payouts_table.php b/database/migrations/2026_06_09_145059_add_failure_reason_to_plugin_payouts_table.php new file mode 100644 index 00000000..de502f78 --- /dev/null +++ b/database/migrations/2026_06_09_145059_add_failure_reason_to_plugin_payouts_table.php @@ -0,0 +1,24 @@ +text('failure_reason')->nullable()->after('status'); + $table->unsignedInteger('attempt_count')->default(0)->after('failure_reason'); + $table->timestamp('last_attempted_at')->nullable()->after('attempt_count'); + }); + } + + public function down(): void + { + Schema::table('plugin_payouts', function (Blueprint $table) { + $table->dropColumn(['failure_reason', 'attempt_count', 'last_attempted_at']); + }); + } +}; diff --git a/database/migrations/2026_06_09_145142_create_plugin_payout_attempts_table.php b/database/migrations/2026_06_09_145142_create_plugin_payout_attempts_table.php new file mode 100644 index 00000000..8c7fdb30 --- /dev/null +++ b/database/migrations/2026_06_09_145142_create_plugin_payout_attempts_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('plugin_payout_id')->constrained()->cascadeOnDelete(); + $table->boolean('succeeded')->default(false); + $table->string('charge_id')->nullable(); + $table->string('stripe_transfer_id')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamps(); + + $table->index('plugin_payout_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('plugin_payout_attempts'); + } +}; diff --git a/tests/Feature/Commands/BackfillMissingPayoutsTest.php b/tests/Feature/Commands/BackfillMissingPayoutsTest.php new file mode 100644 index 00000000..5a4cf6c2 --- /dev/null +++ b/tests/Feature/Commands/BackfillMissingPayoutsTest.php @@ -0,0 +1,127 @@ +create(); + + return Plugin::factory()->paid()->create([ + 'is_official' => false, + 'user_id' => $developerAccount->user_id, + 'developer_account_id' => $developerAccount->id, + ]); + } + + public function test_creates_pending_payout_using_developer_percentage(): void + { + $developerAccount = DeveloperAccount::factory()->create(['payout_percentage' => 80]); + $plugin = $this->createThirdPartyPlugin($developerAccount); + + $license = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'price_paid' => 10000, + 'purchased_at' => now()->subMonth(), + ]); + + $this->artisan('payouts:backfill') + ->expectsOutputToContain('Created 1 payout record(s).') + ->assertExitCode(0); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals($license->id, $payout->plugin_license_id); + $this->assertEquals($developerAccount->id, $payout->developer_account_id); + $this->assertEquals(10000, $payout->gross_amount); + $this->assertEquals(2000, $payout->platform_fee); + $this->assertEquals(8000, $payout->developer_amount); + $this->assertEquals(PayoutStatus::Pending, $payout->status); + $this->assertTrue($payout->eligible_for_payout_at->isPast()); + } + + public function test_creates_held_payout_when_developer_cannot_receive_payouts(): void + { + $developerAccount = DeveloperAccount::factory()->pending()->create(); + $plugin = $this->createThirdPartyPlugin($developerAccount); + + PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'price_paid' => 5000, + ]); + + $this->artisan('payouts:backfill')->assertExitCode(0); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals(PayoutStatus::Held, $payout->status); + } + + public function test_skips_sales_that_already_have_payouts(): void + { + $plugin = $this->createThirdPartyPlugin(); + $license = PluginLicense::factory()->create(['plugin_id' => $plugin->id]); + + PluginPayout::factory()->create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $plugin->developer_account_id, + ]); + + $this->artisan('payouts:backfill') + ->expectsOutputToContain('No sales are missing payout records.') + ->assertExitCode(0); + + $this->assertEquals(1, PluginPayout::count()); + } + + public function test_skips_official_free_and_grandfathered_sales(): void + { + $officialPlugin = Plugin::factory()->paid()->create(['is_official' => true]); + PluginLicense::factory()->create(['plugin_id' => $officialPlugin->id]); + + $freePlugin = Plugin::factory()->create([ + 'is_official' => false, + 'type' => PluginType::Free, + ]); + PluginLicense::factory()->create(['plugin_id' => $freePlugin->id]); + + $thirdPartyPlugin = $this->createThirdPartyPlugin(); + PluginLicense::factory()->grandfathered()->create([ + 'plugin_id' => $thirdPartyPlugin->id, + 'price_paid' => 0, + ]); + + $this->artisan('payouts:backfill') + ->expectsOutputToContain('No sales are missing payout records.') + ->assertExitCode(0); + + $this->assertEquals(0, PluginPayout::count()); + } + + public function test_dry_run_does_not_create_payouts(): void + { + $plugin = $this->createThirdPartyPlugin(); + PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'price_paid' => 5000, + ]); + + $this->artisan('payouts:backfill --dry-run') + ->expectsOutputToContain('Found 1 sale(s) missing payout records.') + ->assertExitCode(0); + + $this->assertEquals(0, PluginPayout::count()); + } +} diff --git a/tests/Feature/Commands/ProcessEligiblePayoutsTest.php b/tests/Feature/Commands/ProcessEligiblePayoutsTest.php index a51387c3..8cce9c8d 100644 --- a/tests/Feature/Commands/ProcessEligiblePayoutsTest.php +++ b/tests/Feature/Commands/ProcessEligiblePayoutsTest.php @@ -146,4 +146,89 @@ public function test_returns_success_when_no_payouts_exist(): void Queue::assertNothingPushed(); } + + public function test_heals_held_payout_when_developer_can_now_receive_payouts(): void + { + Queue::fake(); + + $developerAccount = DeveloperAccount::factory()->create(); + $plugin = Plugin::factory()->paid()->create(['user_id' => $developerAccount->user_id]); + $license = PluginLicense::factory()->create(['plugin_id' => $plugin->id]); + + $payout = PluginPayout::create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $developerAccount->id, + 'gross_amount' => 1000, + 'platform_fee' => 300, + 'developer_amount' => 700, + 'status' => PayoutStatus::Held, + 'eligible_for_payout_at' => now()->subDay(), + ]); + + $this->artisan('payouts:process-eligible') + ->expectsOutputToContain('Healed 1 held payout(s)') + ->expectsOutputToContain('Dispatched 1 payout transfer job(s)') + ->assertExitCode(0); + + $this->assertEquals(PayoutStatus::Pending, $payout->fresh()->status); + + Queue::assertPushed(ProcessPayoutTransfer::class, function ($job) use ($payout) { + return $job->payout->id === $payout->id; + }); + } + + public function test_does_not_heal_held_payout_when_developer_still_cannot_receive_payouts(): void + { + Queue::fake(); + + $developerAccount = DeveloperAccount::factory()->pending()->create(); + $plugin = Plugin::factory()->paid()->create(['user_id' => $developerAccount->user_id]); + $license = PluginLicense::factory()->create(['plugin_id' => $plugin->id]); + + $payout = PluginPayout::create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $developerAccount->id, + 'gross_amount' => 1000, + 'platform_fee' => 300, + 'developer_amount' => 700, + 'status' => PayoutStatus::Held, + 'eligible_for_payout_at' => now()->subDay(), + ]); + + $this->artisan('payouts:process-eligible') + ->expectsOutputToContain('No eligible payouts') + ->assertExitCode(0); + + $this->assertEquals(PayoutStatus::Held, $payout->fresh()->status); + + Queue::assertNothingPushed(); + } + + public function test_healed_payout_within_holding_period_is_not_dispatched(): void + { + Queue::fake(); + + $developerAccount = DeveloperAccount::factory()->create(); + $plugin = Plugin::factory()->paid()->create(['user_id' => $developerAccount->user_id]); + $license = PluginLicense::factory()->create(['plugin_id' => $plugin->id]); + + $payout = PluginPayout::create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $developerAccount->id, + 'gross_amount' => 1000, + 'platform_fee' => 300, + 'developer_amount' => 700, + 'status' => PayoutStatus::Held, + 'eligible_for_payout_at' => now()->addDays(10), + ]); + + $this->artisan('payouts:process-eligible') + ->expectsOutputToContain('Healed 1 held payout(s)') + ->expectsOutputToContain('No eligible payouts') + ->assertExitCode(0); + + $this->assertEquals(PayoutStatus::Pending, $payout->fresh()->status); + + Queue::assertNothingPushed(); + } } diff --git a/tests/Feature/DeveloperAccountPayoutTest.php b/tests/Feature/DeveloperAccountPayoutTest.php index 1ff07324..0fe8df31 100644 --- a/tests/Feature/DeveloperAccountPayoutTest.php +++ b/tests/Feature/DeveloperAccountPayoutTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Enums\PayoutStatus; use App\Jobs\HandleInvoicePaidJob; use App\Models\Cart; use App\Models\CartItem; @@ -87,7 +88,7 @@ public function custom_payout_percentage_is_used_for_plugin_purchase(): void } #[Test] - public function ultra_subscriber_overrides_custom_payout_percentage_to_full(): void + public function ultra_subscriber_purchase_respects_custom_payout_percentage(): void { $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); Subscription::factory()->for($buyer)->active()->create(['stripe_price' => self::MAX_PRICE_ID]); @@ -116,8 +117,42 @@ public function ultra_subscriber_overrides_custom_payout_percentage_to_full(): v $payout = PluginPayout::first(); $this->assertNotNull($payout); $this->assertEquals(10000, $payout->gross_amount); - $this->assertEquals(0, $payout->platform_fee); - $this->assertEquals(10000, $payout->developer_amount); + $this->assertEquals(2000, $payout->platform_fee); + $this->assertEquals(8000, $payout->developer_amount); + } + + #[Test] + public function payout_is_held_when_developer_cannot_receive_payouts(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); + + $developerAccount = DeveloperAccount::factory()->pending()->create(); + $plugin = Plugin::factory()->approved()->paid()->create([ + 'is_active' => true, + 'is_official' => false, + 'user_id' => $developerAccount->user_id, + 'developer_account_id' => $developerAccount->id, + ]); + PluginPrice::factory()->regular()->amount(5000)->create(['plugin_id' => $plugin->id]); + + $cart = Cart::factory()->for($buyer)->create(); + CartItem::create([ + 'cart_id' => $cart->id, + 'plugin_id' => $plugin->id, + 'plugin_price_id' => $plugin->prices->first()->id, + 'price_at_addition' => 5000, + ]); + + $invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id); + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals(PayoutStatus::Held, $payout->status); + $this->assertEquals(5000, $payout->gross_amount); + $this->assertEquals(1500, $payout->platform_fee); + $this->assertEquals(3500, $payout->developer_amount); } #[Test] diff --git a/tests/Feature/Filament/PluginPayoutResourceTest.php b/tests/Feature/Filament/PluginPayoutResourceTest.php new file mode 100644 index 00000000..ace755b8 --- /dev/null +++ b/tests/Feature/Filament/PluginPayoutResourceTest.php @@ -0,0 +1,153 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + public function test_list_page_renders_successfully(): void + { + PluginPayout::factory()->count(3)->create(); + + Livewire::actingAs($this->admin) + ->test(ListPluginPayouts::class) + ->assertSuccessful(); + } + + public function test_list_page_shows_plugin_customer_and_amounts(): void + { + $plugin = Plugin::factory()->paid()->create(['name' => 'acme/super-plugin']); + $customer = User::factory()->create(['email' => 'buyer@example.com']); + $license = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'user_id' => $customer->id, + 'price_paid' => 2000, + ]); + + PluginPayout::factory()->create([ + 'plugin_license_id' => $license->id, + 'gross_amount' => 2000, + 'platform_fee' => 600, + 'developer_amount' => 1400, + ]); + + Livewire::actingAs($this->admin) + ->test(ListPluginPayouts::class) + ->assertSuccessful() + ->assertSee('acme/super-plugin') + ->assertSee('buyer@example.com') + ->assertSee('$20.00') + ->assertSee('$14.00'); + } + + public function test_view_page_renders_successfully(): void + { + $payout = PluginPayout::factory()->failed()->create(); + + Livewire::actingAs($this->admin) + ->test(ViewPluginPayout::class, ['record' => $payout->getRouteKey()]) + ->assertSuccessful(); + } + + public function test_view_page_shows_failure_reason(): void + { + $payout = PluginPayout::factory()->create([ + 'status' => PayoutStatus::Failed, + 'failure_reason' => 'currency mismatch (usd != eur)', + ]); + + Livewire::actingAs($this->admin) + ->test(ViewPluginPayout::class, ['record' => $payout->getRouteKey()]) + ->assertSuccessful() + ->assertSee('currency mismatch'); + } + + public function test_attempts_relation_manager_lists_history(): void + { + $payout = PluginPayout::factory()->failed()->create(); + + PluginPayoutAttempt::factory()->create([ + 'plugin_payout_id' => $payout->id, + 'succeeded' => false, + 'error_message' => 'first error', + ]); + + PluginPayoutAttempt::factory()->create([ + 'plugin_payout_id' => $payout->id, + 'succeeded' => false, + 'error_message' => 'second error', + ]); + + Livewire::actingAs($this->admin) + ->test(AttemptsRelationManager::class, [ + 'ownerRecord' => $payout, + 'pageClass' => ViewPluginPayout::class, + ]) + ->assertCanSeeTableRecords($payout->attempts()->get()) + ->assertCountTableRecords(2); + } + + public function test_retry_action_dispatches_job_and_resets_status(): void + { + Queue::fake(); + + $payout = PluginPayout::factory()->failed()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPluginPayouts::class) + ->callTableAction('retryPayout', $payout); + + Queue::assertPushed(ProcessPayoutTransfer::class, function ($job) use ($payout) { + return $job->payout->is($payout); + }); + + $this->assertEquals(PayoutStatus::Pending, $payout->fresh()->status); + } + + public function test_retry_action_is_hidden_for_transferred_payouts(): void + { + $payout = PluginPayout::factory()->transferred()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPluginPayouts::class) + ->assertTableActionHidden('retryPayout', $payout); + } + + public function test_list_filters_by_status(): void + { + $failed = PluginPayout::factory()->failed()->create(); + $transferred = PluginPayout::factory()->transferred()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPluginPayouts::class) + ->filterTable('status', PayoutStatus::Failed->value) + ->assertCanSeeTableRecords([$failed]) + ->assertCanNotSeeTableRecords([$transferred]); + } +} diff --git a/tests/Feature/Filament/ThirdPartySaleResourceTest.php b/tests/Feature/Filament/ThirdPartySaleResourceTest.php new file mode 100644 index 00000000..4848f1e7 --- /dev/null +++ b/tests/Feature/Filament/ThirdPartySaleResourceTest.php @@ -0,0 +1,146 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + private function createThirdPartyPlugin(): Plugin + { + $developerAccount = DeveloperAccount::factory()->create(); + + return Plugin::factory()->paid()->create([ + 'is_official' => false, + 'user_id' => $developerAccount->user_id, + 'developer_account_id' => $developerAccount->id, + ]); + } + + public function test_list_page_renders_successfully(): void + { + $plugin = $this->createThirdPartyPlugin(); + PluginLicense::factory()->count(2)->create(['plugin_id' => $plugin->id]); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->assertSuccessful(); + } + + public function test_shows_sales_with_missing_payout(): void + { + $plugin = $this->createThirdPartyPlugin(); + $license = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'price_paid' => 5000, + ]); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->assertSuccessful() + ->assertCanSeeTableRecords([$license]) + ->assertSee('Missing'); + } + + public function test_shows_payout_status_when_payout_exists(): void + { + $plugin = $this->createThirdPartyPlugin(); + $license = PluginLicense::factory()->create(['plugin_id' => $plugin->id]); + + PluginPayout::factory()->transferred()->create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $plugin->developer_account_id, + ]); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->assertSuccessful() + ->assertSee('Transferred'); + } + + public function test_excludes_official_plugin_sales(): void + { + $officialPlugin = Plugin::factory()->paid()->create(['is_official' => true]); + $officialSale = PluginLicense::factory()->create(['plugin_id' => $officialPlugin->id]); + + $thirdPartyPlugin = $this->createThirdPartyPlugin(); + $thirdPartySale = PluginLicense::factory()->create(['plugin_id' => $thirdPartyPlugin->id]); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->assertCanSeeTableRecords([$thirdPartySale]) + ->assertCanNotSeeTableRecords([$officialSale]); + } + + public function test_excludes_free_plugin_sales(): void + { + $freePlugin = Plugin::factory()->create([ + 'is_official' => false, + 'type' => PluginType::Free, + ]); + $freeSale = PluginLicense::factory()->create(['plugin_id' => $freePlugin->id]); + + $paidPlugin = $this->createThirdPartyPlugin(); + $paidSale = PluginLicense::factory()->create(['plugin_id' => $paidPlugin->id]); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->assertCanSeeTableRecords([$paidSale]) + ->assertCanNotSeeTableRecords([$freeSale]); + } + + public function test_excludes_grandfathered_licenses(): void + { + $plugin = $this->createThirdPartyPlugin(); + $granted = PluginLicense::factory()->grandfathered()->create(['plugin_id' => $plugin->id]); + $sale = PluginLicense::factory()->create(['plugin_id' => $plugin->id]); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->assertCanSeeTableRecords([$sale]) + ->assertCanNotSeeTableRecords([$granted]); + } + + public function test_filters_sales_missing_payouts(): void + { + $plugin = $this->createThirdPartyPlugin(); + + $missingPayout = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'price_paid' => 5000, + ]); + + $withPayout = PluginLicense::factory()->create(['plugin_id' => $plugin->id]); + PluginPayout::factory()->create([ + 'plugin_license_id' => $withPayout->id, + 'developer_account_id' => $plugin->developer_account_id, + ]); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->filterTable('missing_payout', true) + ->assertCanSeeTableRecords([$missingPayout]) + ->assertCanNotSeeTableRecords([$withPayout]); + } +} diff --git a/tests/Feature/MaxSubscriberPayoutTest.php b/tests/Feature/MaxSubscriberPayoutTest.php index fd07266a..0f9ea93d 100644 --- a/tests/Feature/MaxSubscriberPayoutTest.php +++ b/tests/Feature/MaxSubscriberPayoutTest.php @@ -56,7 +56,7 @@ private function createSubscription(User $user, string $priceId): Subscription } #[Test] - public function max_subscriber_gets_zero_platform_fee_for_third_party_plugin(): void + public function max_subscriber_pays_normal_platform_fee_for_third_party_plugin(): void { $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); $this->createSubscription($buyer, self::MAX_PRICE_ID); @@ -85,8 +85,8 @@ public function max_subscriber_gets_zero_platform_fee_for_third_party_plugin(): $payout = PluginPayout::first(); $this->assertNotNull($payout); $this->assertEquals(2999, $payout->gross_amount); - $this->assertEquals(0, $payout->platform_fee); - $this->assertEquals(2999, $payout->developer_amount); + $this->assertEquals(900, $payout->platform_fee); + $this->assertEquals(2099, $payout->developer_amount); $this->assertEquals(PayoutStatus::Pending, $payout->status); } diff --git a/tests/Feature/StripeConnectPayoutFailureTest.php b/tests/Feature/StripeConnectPayoutFailureTest.php new file mode 100644 index 00000000..35d99c8c --- /dev/null +++ b/tests/Feature/StripeConnectPayoutFailureTest.php @@ -0,0 +1,113 @@ +errorMessage); + } + }; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->transfers = $mockTransfers; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + + $developerAccount = DeveloperAccount::factory()->create(['payout_currency' => 'EUR']); + $plugin = Plugin::factory()->paid()->create(['user_id' => $developerAccount->user_id]); + $license = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'stripe_payment_intent_id' => null, + ]); + + $payout = PluginPayout::factory()->create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $developerAccount->id, + 'status' => PayoutStatus::Pending, + ]); + + $service = app(StripeConnectService::class); + $result = $service->processTransfer($payout); + + $this->assertFalse($result); + + $payout->refresh(); + + $this->assertTrue($payout->isFailed()); + $this->assertStringContainsString('currency of source_transaction', $payout->failure_reason); + $this->assertEquals(1, $payout->attempt_count); + $this->assertNotNull($payout->last_attempted_at); + + $this->assertCount(1, $payout->attempts()->get()); + $attempt = $payout->attempts()->first(); + $this->assertFalse($attempt->succeeded); + $this->assertStringContainsString('currency of source_transaction', $attempt->error_message); + } + + public function test_successful_transfer_records_succeeded_attempt(): void + { + $mockTransfers = new class + { + public function create(array $params): object + { + return (object) ['id' => 'tr_test_123']; + } + }; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->transfers = $mockTransfers; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + + $developerAccount = DeveloperAccount::factory()->create(); + $plugin = Plugin::factory()->paid()->create(['user_id' => $developerAccount->user_id]); + $license = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'stripe_payment_intent_id' => null, + ]); + + $payout = PluginPayout::factory()->create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $developerAccount->id, + 'status' => PayoutStatus::Pending, + ]); + + $service = app(StripeConnectService::class); + $result = $service->processTransfer($payout); + + $this->assertTrue($result); + + $payout->refresh(); + + $this->assertTrue($payout->isTransferred()); + $this->assertEquals('tr_test_123', $payout->stripe_transfer_id); + $this->assertEquals(1, $payout->attempt_count); + $this->assertNull($payout->failure_reason); + + $attempt = $payout->attempts()->first(); + $this->assertTrue($attempt->succeeded); + $this->assertEquals('tr_test_123', $attempt->stripe_transfer_id); + } +}