diff --git a/app/Actions/RefundPluginPurchase.php b/app/Actions/RefundPluginPurchase.php new file mode 100644 index 00000000..90794b95 --- /dev/null +++ b/app/Actions/RefundPluginPurchase.php @@ -0,0 +1,69 @@ +isRefundable()) { + throw new \RuntimeException('This license is not eligible for a refund.'); + } + + $licenses = $this->collectLicensesToRefund($license); + + $refund = $this->stripeConnectService->refundPaymentIntent($license->stripe_payment_intent_id); + + DB::transaction(function () use ($licenses, $refund, $refundedBy): void { + foreach ($licenses as $licenseToRefund) { + $licenseToRefund->update([ + 'refunded_at' => now(), + 'stripe_refund_id' => $refund->id, + 'refunded_by' => $refundedBy->id, + ]); + + $payout = $licenseToRefund->payout; + + if (! $payout) { + continue; + } + + if ($payout->isPending()) { + $payout->markAsCancelled(); + } elseif ($payout->isTransferred()) { + $this->stripeConnectService->reverseTransfer($payout->stripe_transfer_id); + $payout->markAsCancelled(); + } + } + }); + } + + /** + * @return Collection + */ + private function collectLicensesToRefund(PluginLicense $license): Collection + { + if (! $license->wasPurchasedAsBundle()) { + return collect([$license]); + } + + return PluginLicense::query() + ->where('stripe_payment_intent_id', $license->stripe_payment_intent_id) + ->where('plugin_bundle_id', $license->plugin_bundle_id) + ->get(); + } +} diff --git a/app/Enums/PayoutStatus.php b/app/Enums/PayoutStatus.php index b548934e..a58be43b 100644 --- a/app/Enums/PayoutStatus.php +++ b/app/Enums/PayoutStatus.php @@ -7,6 +7,7 @@ enum PayoutStatus: string case Pending = 'pending'; case Transferred = 'transferred'; case Failed = 'failed'; + case Cancelled = 'cancelled'; public function label(): string { @@ -14,6 +15,7 @@ public function label(): string self::Pending => 'Pending', self::Transferred => 'Transferred', self::Failed => 'Failed', + self::Cancelled => 'Cancelled', }; } @@ -23,6 +25,7 @@ public function color(): string self::Pending => 'yellow', self::Transferred => 'green', self::Failed => 'red', + self::Cancelled => 'gray', }; } } diff --git a/app/Filament/Resources/PluginResource/RelationManagers/LicensesRelationManager.php b/app/Filament/Resources/PluginResource/RelationManagers/LicensesRelationManager.php index 3cf73b2a..8207deaf 100644 --- a/app/Filament/Resources/PluginResource/RelationManagers/LicensesRelationManager.php +++ b/app/Filament/Resources/PluginResource/RelationManagers/LicensesRelationManager.php @@ -2,6 +2,10 @@ namespace App\Filament\Resources\PluginResource\RelationManagers; +use App\Actions\RefundPluginPurchase; +use App\Models\PluginLicense; +use Filament\Actions; +use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; @@ -40,7 +44,48 @@ public function table(Table $table): Table ->dateTime() ->sortable() ->placeholder('Never'), + + Tables\Columns\TextColumn::make('refunded_at') + ->label('Refunded') + ->dateTime() + ->sortable() + ->placeholder('-'), ]) - ->defaultSort('purchased_at', 'desc'); + ->defaultSort('purchased_at', 'desc') + ->actions([ + Actions\Action::make('refund') + ->label('Refund') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Refund purchase') + ->modalDescription(function (PluginLicense $record): string { + $amount = '$'.number_format($record->price_paid / 100, 2); + $description = "This will issue a full {$amount} refund to {$record->user->email} and revoke their license."; + + if ($record->wasPurchasedAsBundle()) { + $description .= ' This license was purchased as part of a bundle — all licenses in the bundle will be refunded.'; + } + + return $description; + }) + ->modalSubmitActionLabel('Yes, refund') + ->action(function (PluginLicense $record): void { + try { + app(RefundPluginPurchase::class)->handle($record, auth()->user()); + + Notification::make() + ->title('Purchase refunded successfully') + ->success() + ->send(); + } catch (\Exception $e) { + Notification::make() + ->title('Refund failed') + ->body($e->getMessage()) + ->danger() + ->send(); + } + }) + ->visible(fn (PluginLicense $record): bool => $record->isRefundable()), + ]); } } diff --git a/app/Filament/Resources/UserResource/RelationManagers/PluginLicensesRelationManager.php b/app/Filament/Resources/UserResource/RelationManagers/PluginLicensesRelationManager.php index 37495a64..4f0cda5e 100644 --- a/app/Filament/Resources/UserResource/RelationManagers/PluginLicensesRelationManager.php +++ b/app/Filament/Resources/UserResource/RelationManagers/PluginLicensesRelationManager.php @@ -2,9 +2,12 @@ namespace App\Filament\Resources\UserResource\RelationManagers; +use App\Actions\RefundPluginPurchase; use App\Enums\PluginType; +use App\Models\PluginLicense; use Filament\Actions; use Filament\Forms; +use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Schemas\Schema; use Filament\Tables; @@ -64,6 +67,11 @@ public function table(Table $table): Table ->dateTime() ->sortable() ->placeholder('Never'), + Tables\Columns\TextColumn::make('refunded_at') + ->label('Refunded') + ->dateTime() + ->sortable() + ->placeholder('-'), ]) ->defaultSort('purchased_at', 'desc') ->filters([ @@ -80,6 +88,39 @@ public function table(Table $table): Table }), ]) ->actions([ + Actions\Action::make('refund') + ->label('Refund') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Refund purchase') + ->modalDescription(function (PluginLicense $record): string { + $amount = '$'.number_format($record->price_paid / 100, 2); + $description = "This will issue a full {$amount} refund to {$record->user->email} for {$record->plugin->name} and revoke their license."; + + if ($record->wasPurchasedAsBundle()) { + $description .= ' This license was purchased as part of a bundle — all licenses in the bundle will be refunded.'; + } + + return $description; + }) + ->modalSubmitActionLabel('Yes, refund') + ->action(function (PluginLicense $record): void { + try { + app(RefundPluginPurchase::class)->handle($record, auth()->user()); + + Notification::make() + ->title('Purchase refunded successfully') + ->success() + ->send(); + } catch (\Exception $e) { + Notification::make() + ->title('Refund failed') + ->body($e->getMessage()) + ->danger() + ->send(); + } + }) + ->visible(fn (PluginLicense $record): bool => $record->isRefundable()), Actions\DeleteAction::make(), ]); } diff --git a/app/Models/PluginLicense.php b/app/Models/PluginLicense.php index 63ecd96a..969ed27d 100644 --- a/app/Models/PluginLicense.php +++ b/app/Models/PluginLicense.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Support\Carbon; class PluginLicense extends Model { @@ -47,6 +48,14 @@ public function pluginBundle(): BelongsTo return $this->belongsTo(PluginBundle::class); } + /** + * @return BelongsTo + */ + public function refundedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'refunded_by'); + } + public function wasPurchasedAsBundle(): bool { return $this->plugin_bundle_id !== null; @@ -59,10 +68,11 @@ public function wasPurchasedAsBundle(): bool #[Scope] protected function active(Builder $query): Builder { - return $query->where(function ($q): void { - $q->whereNull('expires_at') - ->orWhere('expires_at', '>', now()); - }); + return $query->whereNull('refunded_at') + ->where(function ($q): void { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); } /** @@ -87,6 +97,10 @@ protected function forPlugin(Builder $query, Plugin $plugin): Builder public function isActive(): bool { + if ($this->isRefunded()) { + return false; + } + if ($this->expires_at === null) { return true; } @@ -99,6 +113,32 @@ public function isExpired(): bool return ! $this->isActive(); } + public function isRefunded(): bool + { + return $this->refunded_at !== null; + } + + public function isRefundable(): bool + { + if ($this->isRefunded()) { + return false; + } + + if ($this->is_grandfathered) { + return false; + } + + if ($this->price_paid <= 0) { + return false; + } + + if (! $this->stripe_payment_intent_id) { + return false; + } + + return $this->purchased_at->diffInDays(Carbon::now()) <= 14; + } + protected function casts(): array { return [ @@ -106,6 +146,7 @@ protected function casts(): array 'is_grandfathered' => 'boolean', 'purchased_at' => 'datetime', 'expires_at' => 'datetime', + 'refunded_at' => 'datetime', ]; } } diff --git a/app/Models/PluginPayout.php b/app/Models/PluginPayout.php index f20b413d..768a763a 100644 --- a/app/Models/PluginPayout.php +++ b/app/Models/PluginPayout.php @@ -63,6 +63,16 @@ protected function failed(Builder $query): Builder return $query->where('status', PayoutStatus::Failed); } + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function cancelled(Builder $query): Builder + { + return $query->where('status', PayoutStatus::Cancelled); + } + /** * @return array{platform_fee: int, developer_amount: int} */ @@ -108,6 +118,18 @@ public function markAsFailed(): void ]); } + public function markAsCancelled(): void + { + $this->update([ + 'status' => PayoutStatus::Cancelled, + ]); + } + + public function isCancelled(): bool + { + return $this->status === PayoutStatus::Cancelled; + } + protected function casts(): array { return [ diff --git a/app/Services/StripeConnectService.php b/app/Services/StripeConnectService.php index 925b2ea0..0d171e84 100644 --- a/app/Services/StripeConnectService.php +++ b/app/Services/StripeConnectService.php @@ -13,6 +13,8 @@ use Illuminate\Support\Facades\Log; use Laravel\Cashier\Cashier; use Stripe\Account; +use Stripe\Refund; +use Stripe\TransferReversal; /** * Service for managing Stripe Connect accounts and processing developer payouts. @@ -186,6 +188,19 @@ protected function determineStatus(Account $account): StripeConnectStatus return StripeConnectStatus::Pending; } + public function refundPaymentIntent(string $paymentIntentId): Refund + { + return Cashier::stripe()->refunds->create([ + 'payment_intent' => $paymentIntentId, + ]); + } + + public function reverseTransfer(string $stripeTransferId): TransferReversal + { + return Cashier::stripe()->transfers->retrieve($stripeTransferId) + ->reversals->create(); + } + public function createProductAndPrice(Plugin $plugin, int $amountCents, string $currency = 'usd'): PluginPrice { $product = Cashier::stripe()->products->create([ diff --git a/database/factories/PluginLicenseFactory.php b/database/factories/PluginLicenseFactory.php index 9fb7ab24..14e44bd8 100644 --- a/database/factories/PluginLicenseFactory.php +++ b/database/factories/PluginLicenseFactory.php @@ -51,4 +51,13 @@ public function grandfathered(): static 'is_grandfathered' => true, ]); } + + public function refunded(): static + { + return $this->state(fn (array $attributes) => [ + 'refunded_at' => now(), + 'stripe_refund_id' => 're_'.$this->faker->uuid(), + 'refunded_by' => User::factory(), + ]); + } } diff --git a/database/factories/PluginPayoutFactory.php b/database/factories/PluginPayoutFactory.php new file mode 100644 index 00000000..d98538fa --- /dev/null +++ b/database/factories/PluginPayoutFactory.php @@ -0,0 +1,51 @@ + + */ +class PluginPayoutFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $grossAmount = $this->faker->numberBetween(1000, 10000); + $split = PluginPayout::calculateSplit($grossAmount); + + return [ + 'plugin_license_id' => PluginLicense::factory(), + 'developer_account_id' => DeveloperAccount::factory(), + 'gross_amount' => $grossAmount, + 'platform_fee' => $split['platform_fee'], + 'developer_amount' => $split['developer_amount'], + '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 cancelled(): static + { + return $this->state(fn () => [ + 'status' => PayoutStatus::Cancelled, + ]); + } +} diff --git a/database/migrations/2026_04_08_180245_add_refund_fields_to_plugin_licenses_and_update_sales_view.php b/database/migrations/2026_04_08_180245_add_refund_fields_to_plugin_licenses_and_update_sales_view.php new file mode 100644 index 00000000..7fe6380d --- /dev/null +++ b/database/migrations/2026_04_08_180245_add_refund_fields_to_plugin_licenses_and_update_sales_view.php @@ -0,0 +1,101 @@ +timestamp('refunded_at')->nullable()->after('expires_at'); + $table->string('stripe_refund_id')->nullable()->after('refunded_at')->index(); + $table->foreignId('refunded_by')->nullable()->after('stripe_refund_id') + ->constrained('users')->nullOnDelete(); + }); + + DB::statement(" + CREATE VIEW sales_view AS + SELECT + CONCAT('pl_', pl.id) AS id, + pl.user_id, + p.name AS product_name, + pb.name AS bundle_name, + pl.price_paid, + pl.currency, + pl.is_grandfathered AS is_comped, + pl.purchased_at, + pl.created_at, + pl.updated_at + FROM plugin_licenses pl + LEFT JOIN plugins p ON p.id = pl.plugin_id + LEFT JOIN plugin_bundles pb ON pb.id = pl.plugin_bundle_id + WHERE pl.refunded_at IS NULL + + UNION ALL + + SELECT + CONCAT('pr_', pdl.id) AS id, + pdl.user_id, + pd.name AS product_name, + NULL AS bundle_name, + pdl.price_paid, + pdl.currency, + pdl.is_comped, + pdl.purchased_at, + pdl.created_at, + pdl.updated_at + FROM product_licenses pdl + LEFT JOIN products pd ON pd.id = pdl.product_id + "); + } + + public function down(): void + { + DB::statement('DROP VIEW IF EXISTS sales_view'); + + DB::statement(" + CREATE VIEW sales_view AS + SELECT + CONCAT('pl_', pl.id) AS id, + pl.user_id, + p.name AS product_name, + pb.name AS bundle_name, + pl.price_paid, + pl.currency, + pl.is_grandfathered AS is_comped, + pl.purchased_at, + pl.created_at, + pl.updated_at + FROM plugin_licenses pl + LEFT JOIN plugins p ON p.id = pl.plugin_id + LEFT JOIN plugin_bundles pb ON pb.id = pl.plugin_bundle_id + + UNION ALL + + SELECT + CONCAT('pr_', pdl.id) AS id, + pdl.user_id, + pd.name AS product_name, + NULL AS bundle_name, + pdl.price_paid, + pdl.currency, + pdl.is_comped, + pdl.purchased_at, + pdl.created_at, + pdl.updated_at + FROM product_licenses pdl + LEFT JOIN products pd ON pd.id = pdl.product_id + "); + + Schema::table('plugin_licenses', function (Blueprint $table) { + $table->dropForeign(['refunded_by']); + $table->dropIndex(['stripe_refund_id']); + $table->dropColumn(['refunded_at', 'stripe_refund_id', 'refunded_by']); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 135b7064..f42c625a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "brave-lemur", + "name": "vast-condor", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/tests/Feature/PluginPurchaseRefundTest.php b/tests/Feature/PluginPurchaseRefundTest.php new file mode 100644 index 00000000..a2567bf6 --- /dev/null +++ b/tests/Feature/PluginPurchaseRefundTest.php @@ -0,0 +1,290 @@ +create(); + $plugin = Plugin::factory()->paid()->create(['user_id' => $developerAccount->user_id]); + $license = PluginLicense::factory()->create(array_merge( + ['plugin_id' => $plugin->id], + $licenseAttributes, + )); + + $payout = null; + if ($payoutStatus) { + $payoutData = [ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $developerAccount->id, + ]; + + $factory = PluginPayout::factory(); + if ($payoutStatus === 'transferred') { + $factory = $factory->transferred(); + } + + $payout = $factory->create($payoutData); + } + + return [$license, $payout, $developerAccount]; + } + + private function makeStripeRefund(string $id = 're_test_refund_123'): Refund + { + return Refund::constructFrom(['id' => $id]); + } + + private function makeStripeTransferReversal(string $id = 'trr_test_reversal_123'): TransferReversal + { + return TransferReversal::constructFrom(['id' => $id]); + } + + private function mockStripeConnectService(?MockInterface &$mock = null): void + { + $this->mock(StripeConnectService::class, function (MockInterface $m) use (&$mock) { + $m->shouldReceive('refundPaymentIntent') + ->andReturn($this->makeStripeRefund()); + $m->shouldReceive('reverseTransfer') + ->andReturn($this->makeStripeTransferReversal()); + $mock = $m; + }); + } + + #[Test] + public function refund_within_14_days_succeeds(): void + { + [$license, $payout] = $this->createLicenseWithPayout([ + 'purchased_at' => now()->subDays(5), + ]); + + $this->mockStripeConnectService(); + + $admin = User::factory()->create(); + app(RefundPluginPurchase::class)->handle($license, $admin); + + $license->refresh(); + $this->assertNotNull($license->refunded_at); + $this->assertEquals('re_test_refund_123', $license->stripe_refund_id); + $this->assertEquals($admin->id, $license->refunded_by); + + $payout->refresh(); + $this->assertTrue($payout->isCancelled()); + } + + #[Test] + public function refund_after_14_days_is_blocked(): void + { + [$license] = $this->createLicenseWithPayout([ + 'purchased_at' => now()->subDays(15), + ]); + + $this->mockStripeConnectService(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('not eligible for a refund'); + + app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + } + + #[Test] + public function refund_of_comped_license_is_blocked(): void + { + [$license] = $this->createLicenseWithPayout([ + 'is_grandfathered' => true, + 'price_paid' => 5000, + ]); + + $this->mockStripeConnectService(); + + $this->expectException(\RuntimeException::class); + + app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + } + + #[Test] + public function refund_of_zero_price_license_is_blocked(): void + { + [$license] = $this->createLicenseWithPayout([ + 'price_paid' => 0, + ]); + + $this->mockStripeConnectService(); + + $this->expectException(\RuntimeException::class); + + app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + } + + #[Test] + public function refund_of_already_refunded_license_is_blocked(): void + { + [$license] = $this->createLicenseWithPayout([ + 'refunded_at' => now(), + 'stripe_refund_id' => 're_existing', + ]); + + $this->mockStripeConnectService(); + + $this->expectException(\RuntimeException::class); + + app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + } + + #[Test] + public function refund_of_license_without_stripe_payment_intent_is_blocked(): void + { + [$license] = $this->createLicenseWithPayout([ + 'stripe_payment_intent_id' => null, + ]); + + $this->mockStripeConnectService(); + + $this->expectException(\RuntimeException::class); + + app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + } + + #[Test] + public function pending_payout_gets_cancelled_on_refund(): void + { + [$license, $payout] = $this->createLicenseWithPayout( + ['purchased_at' => now()->subDays(3)], + 'pending', + ); + + $mock = null; + $this->mockStripeConnectService($mock); + $mock->shouldNotHaveReceived('reverseTransfer'); + + app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + + $payout->refresh(); + $this->assertEquals(PayoutStatus::Cancelled, $payout->status); + } + + #[Test] + public function transferred_payout_gets_reversed_and_cancelled_on_refund(): void + { + [$license, $payout] = $this->createLicenseWithPayout( + ['purchased_at' => now()->subDays(3)], + 'transferred', + ); + + $this->mock(StripeConnectService::class, function (MockInterface $mock) use ($payout) { + $mock->shouldReceive('refundPaymentIntent') + ->once() + ->andReturn($this->makeStripeRefund()); + $mock->shouldReceive('reverseTransfer') + ->once() + ->with($payout->stripe_transfer_id) + ->andReturn($this->makeStripeTransferReversal()); + }); + + app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + + $payout->refresh(); + $this->assertEquals(PayoutStatus::Cancelled, $payout->status); + } + + #[Test] + public function bundle_refund_processes_all_sibling_licenses(): void + { + $bundle = PluginBundle::factory()->create(); + $developerAccount = DeveloperAccount::factory()->create(); + $paymentIntentId = 'pi_bundle_test_123'; + + $licenses = collect(); + for ($i = 0; $i < 3; $i++) { + $plugin = Plugin::factory()->paid()->create(['user_id' => $developerAccount->user_id]); + $license = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'plugin_bundle_id' => $bundle->id, + 'stripe_payment_intent_id' => $paymentIntentId, + 'purchased_at' => now()->subDays(3), + 'price_paid' => 3000, + ]); + + PluginPayout::factory()->create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $developerAccount->id, + ]); + + $licenses->push($license); + } + + $this->mock(StripeConnectService::class, function (MockInterface $mock) { + $mock->shouldReceive('refundPaymentIntent') + ->once() + ->andReturn($this->makeStripeRefund('re_bundle_refund')); + $mock->shouldReceive('reverseTransfer')->never(); + }); + + app(RefundPluginPurchase::class)->handle($licenses->first(), User::factory()->create()); + + foreach ($licenses as $license) { + $license->refresh(); + $this->assertNotNull($license->refunded_at); + $this->assertEquals('re_bundle_refund', $license->stripe_refund_id); + + $this->assertEquals(PayoutStatus::Cancelled, $license->payout->status); + } + } + + #[Test] + public function stripe_failure_leaves_everything_unchanged(): void + { + [$license, $payout] = $this->createLicenseWithPayout([ + 'purchased_at' => now()->subDays(3), + ]); + + $this->mock(StripeConnectService::class, function (MockInterface $mock) { + $mock->shouldReceive('refundPaymentIntent') + ->once() + ->andThrow(new \Exception('Stripe API error')); + }); + + try { + app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + } catch (\Exception) { + // Expected + } + + $license->refresh(); + $this->assertNull($license->refunded_at); + $this->assertNull($license->stripe_refund_id); + + $payout->refresh(); + $this->assertEquals(PayoutStatus::Pending, $payout->status); + } + + #[Test] + public function refunded_license_is_excluded_from_is_active_and_active_scope(): void + { + $license = PluginLicense::factory()->refunded()->create(); + + $this->assertFalse($license->isActive()); + + $activeCount = PluginLicense::query()->active()->where('id', $license->id)->count(); + $this->assertEquals(0, $activeCount); + } +}