diff --git a/app/Actions/RefundPluginPurchase.php b/app/Actions/RefundPluginPurchase.php new file mode 100644 index 000000000..10b9a500e --- /dev/null +++ b/app/Actions/RefundPluginPurchase.php @@ -0,0 +1,68 @@ +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 || $payout->isCancelled()) { + continue; + } + + if ($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 f9afb2ed7..def83c755 100644 --- a/app/Enums/PayoutStatus.php +++ b/app/Enums/PayoutStatus.php @@ -8,6 +8,7 @@ enum PayoutStatus: string case Pending = 'pending'; case Transferred = 'transferred'; case Failed = 'failed'; + case Cancelled = 'cancelled'; public function label(): string { @@ -16,6 +17,7 @@ public function label(): string self::Pending => 'Pending', self::Transferred => 'Transferred', self::Failed => 'Failed', + self::Cancelled => 'Cancelled', }; } @@ -26,6 +28,7 @@ public function color(): string self::Pending => 'yellow', self::Transferred => 'green', self::Failed => 'red', + self::Cancelled => 'gray', }; } } diff --git a/app/Filament/Actions/RefundPluginLicenseAction.php b/app/Filament/Actions/RefundPluginLicenseAction.php new file mode 100644 index 000000000..a727aab25 --- /dev/null +++ b/app/Filament/Actions/RefundPluginLicenseAction.php @@ -0,0 +1,56 @@ +label('Refund') + ->icon('heroicon-o-receipt-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 bought as part of a bundle, so every license in the bundle will be refunded.'; + } + + return $description; + }) + ->modalSubmitActionLabel('Yes, refund') + ->visible(fn (PluginLicense $record): bool => $record->isRefundable()) + ->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(); + } + }); + } +} diff --git a/app/Filament/Resources/PluginPayoutResource.php b/app/Filament/Resources/PluginPayoutResource.php index 067607b55..c50fe57e3 100644 --- a/app/Filament/Resources/PluginPayoutResource.php +++ b/app/Filament/Resources/PluginPayoutResource.php @@ -99,6 +99,28 @@ public static function infolist(Schema $schema): Schema ->dateTime(), ]), + Schemas\Components\Section::make('Refund') + ->description(fn (PluginPayout $record): string => $record->wasReversed() + ? 'The customer was refunded after this payout was sent, so the transfer to the seller was reversed.' + : 'The customer was refunded before this payout was sent, so it was cancelled.') + ->inlineLabel() + ->columns(1) + ->schema([ + Infolists\Components\TextEntry::make('pluginLicense.refunded_at') + ->label('Refunded At') + ->dateTime(), + Infolists\Components\TextEntry::make('pluginLicense.refundedBy.email') + ->label('Refunded By') + ->placeholder('—'), + Infolists\Components\TextEntry::make('pluginLicense.stripe_refund_id') + ->label('Stripe Refund ID') + ->copyable() + ->url(fn (PluginPayout $record): ?string => $record->pluginLicense?->stripePaymentUrl()) + ->openUrlInNewTab() + ->placeholder('—'), + ]) + ->visible(fn (PluginPayout $record): bool => $record->wasCancelledByRefund()), + Schemas\Components\Section::make('Latest Failure Reason') ->inlineLabel() ->columns(1) @@ -145,6 +167,11 @@ public static function table(Table $table): Table ->badge() ->color(fn (PayoutStatus $state): string => $state->color()) ->formatStateUsing(fn (PayoutStatus $state): string => $state->label()) + ->description(fn (PluginPayout $record): ?string => match (true) { + $record->wasReversed() => 'Refunded, transfer reversed', + $record->wasCancelledByRefund() => 'Refunded', + default => null, + }) ->sortable(), Tables\Columns\IconColumn::make('paid_out') diff --git a/app/Filament/Resources/PluginResource/RelationManagers/LicensesRelationManager.php b/app/Filament/Resources/PluginResource/RelationManagers/LicensesRelationManager.php index 3cf73b2a9..7edd745ca 100644 --- a/app/Filament/Resources/PluginResource/RelationManagers/LicensesRelationManager.php +++ b/app/Filament/Resources/PluginResource/RelationManagers/LicensesRelationManager.php @@ -2,9 +2,12 @@ namespace App\Filament\Resources\PluginResource\RelationManagers; +use App\Filament\Actions\RefundPluginLicenseAction; +use App\Models\PluginLicense; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; class LicensesRelationManager extends RelationManager { @@ -15,6 +18,7 @@ class LicensesRelationManager extends RelationManager public function table(Table $table): Table { return $table + ->modifyQueryUsing(fn (Builder $query) => $query->with('refundedBy')) ->columns([ Tables\Columns\TextColumn::make('user.email') ->label('User') @@ -40,7 +44,31 @@ public function table(Table $table): Table ->dateTime() ->sortable() ->placeholder('Never'), + + Tables\Columns\TextColumn::make('refunded_at') + ->label('Refunded') + ->dateTime() + ->description(fn (PluginLicense $record): ?string => $record->refundedBy ? 'by '.$record->refundedBy->email : null) + ->sortable() + ->placeholder('-'), + + Tables\Columns\TextColumn::make('stripe_refund_id') + ->label('Stripe Refund') + ->fontFamily('mono') + ->copyable() + ->url(fn (PluginLicense $record): ?string => $record->stripePaymentUrl()) + ->openUrlInNewTab() + ->placeholder('-') + ->toggleable(), + ]) + ->defaultSort('purchased_at', 'desc') + ->filters([ + Tables\Filters\TernaryFilter::make('refunded_at') + ->label('Refunded') + ->nullable(), ]) - ->defaultSort('purchased_at', 'desc'); + ->actions([ + RefundPluginLicenseAction::make(), + ]); } } diff --git a/app/Filament/Resources/ThirdPartySaleResource.php b/app/Filament/Resources/ThirdPartySaleResource.php index 56db11623..f73303fbd 100644 --- a/app/Filament/Resources/ThirdPartySaleResource.php +++ b/app/Filament/Resources/ThirdPartySaleResource.php @@ -4,6 +4,7 @@ use App\Enums\PayoutStatus; use App\Enums\PluginType; +use App\Filament\Actions\RefundPluginLicenseAction; use App\Filament\Resources\ThirdPartySaleResource\Pages; use App\Models\PluginLicense; use Filament\Actions; @@ -44,7 +45,7 @@ public static function getEloquentQuery(): Builder ->where('type', PluginType::Paid); }) ->where('is_grandfathered', false) - ->with(['plugin.developerAccount.user', 'user', 'payout']); + ->with(['plugin.developerAccount.user', 'user', 'payout', 'refundedBy']); } public static function form(Schema $schema): Schema @@ -92,6 +93,10 @@ public static function table(Table $table): Table ->label('Payout') ->badge() ->getStateUsing(function (PluginLicense $record): string { + if ($record->payout?->isCancelled() && $record->isRefunded()) { + return 'Refunded'; + } + if ($record->payout) { return $record->payout->status->label(); } @@ -105,8 +110,28 @@ public static function table(Table $table): Table return $record->price_paid > 0 ? 'danger' : 'gray'; }), + + Tables\Columns\TextColumn::make('refunded_at') + ->label('Refunded') + ->dateTime() + ->description(fn (PluginLicense $record): ?string => $record->refundedBy ? 'by '.$record->refundedBy->email : null) + ->sortable() + ->placeholder('-'), + + Tables\Columns\TextColumn::make('stripe_refund_id') + ->label('Stripe Refund') + ->fontFamily('mono') + ->copyable() + ->url(fn (PluginLicense $record): ?string => $record->stripePaymentUrl()) + ->openUrlInNewTab() + ->placeholder('-') + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ + Tables\Filters\TernaryFilter::make('refunded_at') + ->label('Refunded') + ->nullable(), + Tables\Filters\TernaryFilter::make('missing_payout') ->label('Missing Payout') ->queries( @@ -134,6 +159,8 @@ public static function table(Table $table): Table ->preload(), ]) ->actions([ + RefundPluginLicenseAction::make(), + Actions\Action::make('viewPayout') ->label('View Payout') ->icon('heroicon-o-currency-dollar') diff --git a/app/Filament/Resources/UserResource/RelationManagers/PluginLicensesRelationManager.php b/app/Filament/Resources/UserResource/RelationManagers/PluginLicensesRelationManager.php index 37495a64f..e84d85556 100644 --- a/app/Filament/Resources/UserResource/RelationManagers/PluginLicensesRelationManager.php +++ b/app/Filament/Resources/UserResource/RelationManagers/PluginLicensesRelationManager.php @@ -3,12 +3,15 @@ namespace App\Filament\Resources\UserResource\RelationManagers; use App\Enums\PluginType; +use App\Filament\Actions\RefundPluginLicenseAction; +use App\Models\PluginLicense; use Filament\Actions; use Filament\Forms; use Filament\Resources\RelationManagers\RelationManager; use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; class PluginLicensesRelationManager extends RelationManager { @@ -64,11 +67,29 @@ public function table(Table $table): Table ->dateTime() ->sortable() ->placeholder('Never'), + Tables\Columns\TextColumn::make('refunded_at') + ->label('Refunded') + ->dateTime() + ->description(fn (PluginLicense $record): ?string => $record->refundedBy ? 'by '.$record->refundedBy->email : null) + ->sortable() + ->placeholder('-'), + Tables\Columns\TextColumn::make('stripe_refund_id') + ->label('Stripe Refund') + ->fontFamily('mono') + ->copyable() + ->url(fn (PluginLicense $record): ?string => $record->stripePaymentUrl()) + ->openUrlInNewTab() + ->placeholder('-') + ->toggleable(), ]) + ->modifyQueryUsing(fn (Builder $query) => $query->with('refundedBy')) ->defaultSort('purchased_at', 'desc') ->filters([ Tables\Filters\TernaryFilter::make('is_grandfathered') ->label('Comped'), + Tables\Filters\TernaryFilter::make('refunded_at') + ->label('Refunded') + ->nullable(), ]) ->headerActions([ Actions\CreateAction::make() @@ -80,6 +101,7 @@ public function table(Table $table): Table }), ]) ->actions([ + RefundPluginLicenseAction::make(), Actions\DeleteAction::make(), ]); } diff --git a/app/Livewire/Customer/Dashboard.php b/app/Livewire/Customer/Dashboard.php index 63a50d2fe..e8fd3e86b 100644 --- a/app/Livewire/Customer/Dashboard.php +++ b/app/Livewire/Customer/Dashboard.php @@ -79,7 +79,7 @@ public function teamMemberCount(): int #[Computed] public function pluginLicenseCount(): int { - return auth()->user()->pluginLicenses()->count(); + return auth()->user()->pluginLicenses()->notRefunded()->count(); } #[Computed] diff --git a/app/Livewire/Customer/Developer/Dashboard.php b/app/Livewire/Customer/Developer/Dashboard.php index 3ee97398a..0621d5e0c 100644 --- a/app/Livewire/Customer/Developer/Dashboard.php +++ b/app/Livewire/Customer/Developer/Dashboard.php @@ -40,7 +40,7 @@ public function plugins(): Collection return auth()->user()->plugins() ->where('status', PluginStatus::Approved) ->where('type', PluginType::Paid) - ->withCount('licenses') + ->withCount(['licenses' => fn ($query) => $query->notRefunded()]) ->get(); } diff --git a/app/Livewire/Customer/PurchaseHistory/Index.php b/app/Livewire/Customer/PurchaseHistory/Index.php index 5e32ed1e7..ac37a386a 100644 --- a/app/Livewire/Customer/PurchaseHistory/Index.php +++ b/app/Livewire/Customer/PurchaseHistory/Index.php @@ -32,6 +32,7 @@ public function purchases(): Collection 'is_active' => ! $license->is_suspended && (! $license->expires_at || $license->expires_at->isFuture()), 'href' => route('customer.licenses.show', $license->key), 'is_grandfathered' => false, + 'refunded_at' => null, ]; }); @@ -58,6 +59,7 @@ public function purchases(): Collection 'is_active' => $pluginLicense->isActive(), 'href' => $pluginLicense->plugin ? route('plugins.show', $pluginLicense->plugin->routeParams()) : null, 'is_grandfathered' => $pluginLicense->is_grandfathered, + 'refunded_at' => $pluginLicense->refunded_at, ]; }); @@ -77,6 +79,7 @@ public function purchases(): Collection 'is_active' => true, 'href' => $productLicense->product ? route('products.show', $productLicense->product) : null, 'is_grandfathered' => false, + 'refunded_at' => null, ]; }); diff --git a/app/Livewire/Customer/PurchasedPlugins/Index.php b/app/Livewire/Customer/PurchasedPlugins/Index.php index fbec8e256..0882dcdc6 100644 --- a/app/Livewire/Customer/PurchasedPlugins/Index.php +++ b/app/Livewire/Customer/PurchasedPlugins/Index.php @@ -16,6 +16,7 @@ class Index extends Component public function pluginLicenses(): Collection { return auth()->user()->pluginLicenses() + ->notRefunded() ->with('plugin', 'pluginBundle') ->orderBy('purchased_at', 'desc') ->get(); diff --git a/app/Models/PluginLicense.php b/app/Models/PluginLicense.php index 63ecd96ab..9f2c61660 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,11 +48,31 @@ 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; } + /** + * The Stripe dashboard page for this purchase's payment, which also lists any refund. + */ + public function stripePaymentUrl(): ?string + { + if (! $this->stripe_payment_intent_id) { + return null; + } + + return 'https://dashboard.stripe.com/payments/'.$this->stripe_payment_intent_id; + } + /** * @param Builder $query * @return Builder @@ -59,10 +80,21 @@ 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()); + }); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function notRefunded(Builder $query): Builder + { + return $query->whereNull('refunded_at'); } /** @@ -87,6 +119,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 +135,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 +168,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 736476990..55284443d 100644 --- a/app/Models/PluginPayout.php +++ b/app/Models/PluginPayout.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Carbon; class PluginPayout extends Model { @@ -82,6 +83,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} */ @@ -134,6 +145,45 @@ public function markAsFailed(?string $reason = null): void ]); } + public function markAsCancelled(): void + { + $this->update([ + 'status' => PayoutStatus::Cancelled, + ]); + } + + public function isCancelled(): bool + { + return $this->status === PayoutStatus::Cancelled; + } + + /** + * When a pending payout should be transferred to the developer's Stripe account. + * Older payouts may predate eligible_for_payout_at, so fall back to the 15-day hold. + */ + public function expectedTransferDate(): ?Carbon + { + if (! $this->isPending()) { + return null; + } + + return $this->eligible_for_payout_at ?? $this->created_at->copy()->addDays(15); + } + + public function wasCancelledByRefund(): bool + { + return $this->isCancelled() && $this->pluginLicense?->isRefunded(); + } + + /** + * A cancelled payout that still has a transfer ID had its money sent to the + * developer before the refund, so the refund reversed that transfer. + */ + public function wasReversed(): bool + { + return $this->wasCancelledByRefund() && $this->stripe_transfer_id !== null; + } + protected function casts(): array { return [ diff --git a/app/Services/StripeConnectService.php b/app/Services/StripeConnectService.php index cd23b9554..6a5ff8157 100644 --- a/app/Services/StripeConnectService.php +++ b/app/Services/StripeConnectService.php @@ -15,6 +15,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. @@ -276,6 +278,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 9fb7ab245..14e44bd84 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 index 860315a8a..4a2011146 100644 --- a/database/factories/PluginPayoutFactory.php +++ b/database/factories/PluginPayoutFactory.php @@ -55,4 +55,11 @@ public function failed(): static 'last_attempted_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 000000000..7fe6380de --- /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/resources/views/livewire/customer/developer/dashboard.blade.php b/resources/views/livewire/customer/developer/dashboard.blade.php index 98ceb32b9..72752cbb8 100644 --- a/resources/views/livewire/customer/developer/dashboard.blade.php +++ b/resources/views/livewire/customer/developer/dashboard.blade.php @@ -63,31 +63,36 @@ - + @if ($this->plugins->isNotEmpty()) + + + @foreach ($this->plugins->take(5) as $plugin) + + + + {{ $plugin->display_name ?? $plugin->name }} + + @if ($plugin->display_name) + {{ $plugin->name }} + @endif + + + + {{ trans_choice(':count sale|:count sales', $plugin->licenses_count) }} + + + @endforeach + + + @else + + Submit a plugin + + @endif {{-- Recent Payouts --}} @@ -95,38 +100,57 @@ Recent Payouts -
- @forelse ($this->payouts as $payout) -
-
-
-

- {{ $payout->pluginLicense->plugin->name ?? 'Unknown Plugin' }} -

- {{ $payout->created_at->format('M j, Y') }} -
-
-

- ${{ number_format($payout->developer_amount / 100, 2) }} -

- @if ($payout->status === \App\Enums\PayoutStatus::Transferred) - Paid - @elseif ($payout->status === \App\Enums\PayoutStatus::Pending) - Pending - @else - Failed - @endif -
-
-
- @empty - - @endforelse -
+ @if ($this->payouts->isNotEmpty()) + + + @foreach ($this->payouts as $payout) + + + + {{ $payout->pluginLicense->plugin->name ?? 'Unknown Plugin' }} + + {{ $payout->created_at->format('M j, Y') }} + @if ($expectedAt = $payout->expectedTransferDate()) + + @if ($expectedAt->isFuture()) + Expected in your Stripe account around {{ $expectedAt->format('M j, Y') }} + @else + Expected in your Stripe account soon + @endif + + @endif + + + +
+ @if ($payout->wasCancelledByRefund()) + ${{ number_format($payout->developer_amount / 100, 2) }} + @else + ${{ number_format($payout->developer_amount / 100, 2) }} + @endif + + @if ($payout->status === \App\Enums\PayoutStatus::Transferred) + Paid + @elseif ($payout->status === \App\Enums\PayoutStatus::Pending) + Pending + @elseif ($payout->wasCancelledByRefund()) + Refunded + @else + Failed + @endif +
+
+
+ @endforeach +
+
+ @else + + @endif diff --git a/resources/views/livewire/customer/purchase-history.blade.php b/resources/views/livewire/customer/purchase-history.blade.php index 0e14aa0ee..4e6bf6c49 100644 --- a/resources/views/livewire/customer/purchase-history.blade.php +++ b/resources/views/livewire/customer/purchase-history.blade.php @@ -9,16 +9,24 @@ Purchase Price - Date + @foreach($this->purchases as $purchase) + @php + $dateNote = match (true) { + $purchase['refunded_at'] !== null => 'Refunded '.$purchase['refunded_at']->format('M j, Y'), + $purchase['expires_at']?->isPast() === true => 'Expired '.$purchase['expires_at']->format('M j, Y'), + $purchase['expires_at'] !== null => 'Expires '.$purchase['expires_at']->format('M j, Y'), + default => null, + }; + @endphp - -
+ +
@if($purchase['href']) - + {{ $purchase['name'] }} @else @@ -27,11 +35,22 @@ @if($purchase['description']) {{ $purchase['description'] }} @endif + + {{ $purchase['purchased_at']->format('M j, Y') }} + @if($dateNote) + · {{ $dateNote }} + @endif +
- @if($purchase['price'] !== null && $purchase['price'] > 0) + @if($purchase['refunded_at']) +
+ ${{ number_format($purchase['price'] / 100, 2) }} + Refunded +
+ @elseif($purchase['price'] !== null && $purchase['price'] > 0) ${{ number_format($purchase['price'] / 100, 2) }} @elseif($purchase['price'] === 0 || (isset($purchase['is_grandfathered']) && $purchase['is_grandfathered'])) Free @@ -40,17 +59,11 @@ @endif
- + diff --git a/resources/views/livewire/customer/purchased-plugins.blade.php b/resources/views/livewire/customer/purchased-plugins.blade.php index 2f228caa7..aa594dc4c 100644 --- a/resources/views/livewire/customer/purchased-plugins.blade.php +++ b/resources/views/livewire/customer/purchased-plugins.blade.php @@ -20,14 +20,14 @@ Plugin - Status - Purchased + + @foreach($this->pluginLicenses as $pluginLicense) - +
@if($pluginLicense->plugin->hasLogo()) @@ -42,18 +42,24 @@
@endif
-
- +
+ {{ $pluginLicense->plugin->name }} @if($pluginLicense->plugin->description) {{ $pluginLicense->plugin->description }} @endif + + Purchased {{ $pluginLicense->purchased_at->format('M j, Y') }} + @if($pluginLicense->wasPurchasedAsBundle() && $pluginLicense->pluginBundle) + · Part of {{ $pluginLicense->pluginBundle->name }} + @endif +
- + - +
diff --git a/tests/Feature/CustomerPurchasedPluginsIndexTest.php b/tests/Feature/CustomerPurchasedPluginsIndexTest.php index bd4a06f5c..4e2a3068c 100644 --- a/tests/Feature/CustomerPurchasedPluginsIndexTest.php +++ b/tests/Feature/CustomerPurchasedPluginsIndexTest.php @@ -70,6 +70,29 @@ public function test_customer_sees_their_purchased_plugins(): void $response->assertSee('Licensed'); } + public function test_customer_does_not_see_refunded_plugins(): void + { + $user = User::factory()->create(); + $kept = Plugin::factory()->approved()->create(['name' => 'acme/kept-plugin-333']); + $refunded = Plugin::factory()->approved()->create(['name' => 'acme/refunded-plugin-444']); + + PluginLicense::factory()->create([ + 'user_id' => $user->id, + 'plugin_id' => $kept->id, + ]); + + PluginLicense::factory()->refunded()->create([ + 'user_id' => $user->id, + 'plugin_id' => $refunded->id, + ]); + + $response = $this->actingAs($user)->get('/dashboard/purchased-plugins'); + + $response->assertStatus(200); + $response->assertSee('acme/kept-plugin-333'); + $response->assertDontSee('acme/refunded-plugin-444'); + } + public function test_customer_does_not_see_other_users_plugins(): void { $user1 = User::factory()->create(); diff --git a/tests/Feature/Filament/PluginPayoutResourceTest.php b/tests/Feature/Filament/PluginPayoutResourceTest.php index 3c1dfa6c7..f963d49f1 100644 --- a/tests/Feature/Filament/PluginPayoutResourceTest.php +++ b/tests/Feature/Filament/PluginPayoutResourceTest.php @@ -162,6 +162,63 @@ public function test_retry_action_is_hidden_for_transferred_payouts(): void ->assertTableActionHidden('retryPayout', $payout); } + private function createRefundedPayout(bool $wasTransferred = false): PluginPayout + { + $refunder = User::factory()->create(); + + $license = PluginLicense::factory()->refunded()->create([ + 'stripe_refund_id' => 're_test_abc123', + 'refunded_by' => $refunder->id, + ]); + + $factory = $wasTransferred ? PluginPayout::factory()->transferred() : PluginPayout::factory(); + + return $factory->cancelled()->create(['plugin_license_id' => $license->id]); + } + + public function test_view_page_shows_refund_details_for_a_refunded_payout(): void + { + $payout = $this->createRefundedPayout(); + + Livewire::actingAs($this->admin) + ->test(ViewPluginPayout::class, ['record' => $payout->getRouteKey()]) + ->assertSuccessful() + ->assertSee('The customer was refunded before this payout was sent, so it was cancelled.') + ->assertSee($payout->pluginLicense->refundedBy->email) + ->assertSee('re_test_abc123'); + } + + public function test_view_page_says_the_transfer_was_reversed_when_payout_had_been_sent(): void + { + $payout = $this->createRefundedPayout(wasTransferred: true); + + Livewire::actingAs($this->admin) + ->test(ViewPluginPayout::class, ['record' => $payout->getRouteKey()]) + ->assertSuccessful() + ->assertSee('so the transfer to the seller was reversed'); + } + + public function test_view_page_hides_refund_section_when_not_refunded(): void + { + $payout = PluginPayout::factory()->transferred()->create(); + + Livewire::actingAs($this->admin) + ->test(ViewPluginPayout::class, ['record' => $payout->getRouteKey()]) + ->assertSuccessful() + ->assertDontSee('Refunded By'); + } + + public function test_list_marks_refunded_payouts(): void + { + $refunded = $this->createRefundedPayout(); + $reversed = $this->createRefundedPayout(wasTransferred: true); + + Livewire::actingAs($this->admin) + ->test(ListPluginPayouts::class) + ->assertCanSeeTableRecords([$refunded, $reversed]) + ->assertSee('Refunded, transfer reversed'); + } + public function test_list_filters_by_status(): void { $failed = PluginPayout::factory()->failed()->create(); diff --git a/tests/Feature/Filament/ThirdPartySaleResourceTest.php b/tests/Feature/Filament/ThirdPartySaleResourceTest.php index 2eda21b6d..f15169998 100644 --- a/tests/Feature/Filament/ThirdPartySaleResourceTest.php +++ b/tests/Feature/Filament/ThirdPartySaleResourceTest.php @@ -180,4 +180,60 @@ public function test_filters_sales_missing_payouts(): void ->assertCanSeeTableRecords([$missingPayout]) ->assertCanNotSeeTableRecords([$withPayout]); } + + public function test_refunded_sale_shows_refund_details_and_refunded_payout(): void + { + $plugin = $this->createThirdPartyPlugin(); + $refunder = User::factory()->create(['email' => 'refunder@test.com']); + + $license = PluginLicense::factory()->refunded()->create([ + 'plugin_id' => $plugin->id, + 'refunded_by' => $refunder->id, + ]); + PluginPayout::factory()->cancelled()->create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $plugin->developer_account_id, + ]); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->assertCanSeeTableRecords([$license]) + ->assertTableColumnStateSet('payout_status', 'Refunded', $license) + ->assertSee('by refunder@test.com'); + } + + public function test_filters_refunded_sales(): void + { + $plugin = $this->createThirdPartyPlugin(); + + $refunded = PluginLicense::factory()->refunded()->create(['plugin_id' => $plugin->id]); + $kept = PluginLicense::factory()->create(['plugin_id' => $plugin->id]); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->filterTable('refunded_at', true) + ->assertCanSeeTableRecords([$refunded]) + ->assertCanNotSeeTableRecords([$kept]); + } + + public function test_refund_action_is_only_visible_for_refundable_sales(): void + { + $plugin = $this->createThirdPartyPlugin(); + + $refundable = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'purchased_at' => now()->subDays(3), + ]); + $tooOld = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'purchased_at' => now()->subDays(20), + ]); + $alreadyRefunded = PluginLicense::factory()->refunded()->create(['plugin_id' => $plugin->id]); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->assertTableActionVisible('refund', $refundable) + ->assertTableActionHidden('refund', $tooOld) + ->assertTableActionHidden('refund', $alreadyRefunded); + } } diff --git a/tests/Feature/Livewire/Customer/DeveloperPagesTest.php b/tests/Feature/Livewire/Customer/DeveloperPagesTest.php index ff0b1c567..64ab1adfe 100644 --- a/tests/Feature/Livewire/Customer/DeveloperPagesTest.php +++ b/tests/Feature/Livewire/Customer/DeveloperPagesTest.php @@ -7,6 +7,9 @@ use App\Livewire\Customer\Developer\Dashboard; use App\Livewire\Customer\Developer\Onboarding; use App\Models\DeveloperAccount; +use App\Models\Plugin; +use App\Models\PluginLicense; +use App\Models\PluginPayout; use App\Models\User; use App\Services\StripeConnectService; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -184,6 +187,81 @@ public function test_developer_dashboard_shows_stats_and_status(): void ->assertStatus(200); } + public function test_developer_dashboard_shows_refunded_payouts_and_excludes_them_from_sales(): void + { + $user = User::factory()->create(); + $developerAccount = DeveloperAccount::factory()->create(['user_id' => $user->id]); + $plugin = Plugin::factory()->paid()->approved()->create([ + 'user_id' => $user->id, + 'developer_account_id' => $developerAccount->id, + ]); + + $sale = PluginLicense::factory()->create(['plugin_id' => $plugin->id]); + PluginPayout::factory()->transferred()->create([ + 'plugin_license_id' => $sale->id, + 'developer_account_id' => $developerAccount->id, + ]); + + $refundedSale = PluginLicense::factory()->refunded()->create(['plugin_id' => $plugin->id]); + PluginPayout::factory()->cancelled()->create([ + 'plugin_license_id' => $refundedSale->id, + 'developer_account_id' => $developerAccount->id, + ]); + + $this->mock(StripeConnectService::class, function ($mock): void { + $mock->shouldReceive('refreshAccountStatus')->once(); + }); + + Livewire::actingAs($user) + ->test(Dashboard::class) + ->assertSee('Refunded') + ->assertDontSee('Failed') + ->assertSee('1 sale') + ->assertDontSee('2 sales') + ->assertStatus(200); + } + + public function test_developer_dashboard_shows_when_pending_payouts_are_expected(): void + { + $this->travelTo('2026-09-29 10:00:00'); + + $user = User::factory()->create(); + $developerAccount = DeveloperAccount::factory()->create(['user_id' => $user->id]); + + PluginPayout::factory()->pending()->create([ + 'developer_account_id' => $developerAccount->id, + 'eligible_for_payout_at' => now()->addDays(13), + ]); + PluginPayout::factory()->pending()->create([ + 'developer_account_id' => $developerAccount->id, + 'eligible_for_payout_at' => now()->subHour(), + ]); + PluginPayout::factory()->transferred()->create([ + 'developer_account_id' => $developerAccount->id, + ]); + + $this->mock(StripeConnectService::class, function ($mock): void { + $mock->shouldReceive('refreshAccountStatus')->once(); + }); + + Livewire::actingAs($user) + ->test(Dashboard::class) + ->assertSee('Expected in your Stripe account around Oct 12, 2026') + ->assertSee('Expected in your Stripe account soon') + ->assertStatus(200); + } + + public function test_expected_transfer_date_falls_back_to_fifteen_days_after_the_sale(): void + { + $payout = PluginPayout::factory()->pending()->create([ + 'created_at' => '2026-09-01 12:00:00', + 'eligible_for_payout_at' => null, + ]); + + $this->assertSame('2026-09-16', $payout->expectedTransferDate()->toDateString()); + $this->assertNull(PluginPayout::factory()->transferred()->create()->expectedTransferDate()); + } + public function test_developer_dashboard_shows_empty_states(): void { $user = User::factory()->create(); diff --git a/tests/Feature/PluginPurchaseRefundTest.php b/tests/Feature/PluginPurchaseRefundTest.php new file mode 100644 index 000000000..ad9d137c0 --- /dev/null +++ b/tests/Feature/PluginPurchaseRefundTest.php @@ -0,0 +1,387 @@ +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 = match ($payoutStatus) { + 'transferred' => PluginPayout::factory()->transferred(), + 'failed' => PluginPayout::factory()->failed(), + 'held' => PluginPayout::factory()->state(['status' => PayoutStatus::Held]), + default => PluginPayout::factory(), + }; + + $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 held_payout_gets_cancelled_on_refund(): void + { + [$license, $payout] = $this->createLicenseWithPayout( + ['purchased_at' => now()->subDays(3)], + 'held', + ); + + $this->mockStripeConnectService(); + + app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + + $this->assertEquals(PayoutStatus::Cancelled, $payout->refresh()->status); + } + + #[Test] + public function failed_payout_gets_cancelled_on_refund_so_it_cannot_be_retried(): void + { + [$license, $payout] = $this->createLicenseWithPayout( + ['purchased_at' => now()->subDays(3)], + 'failed', + ); + + $this->mockStripeConnectService(); + + app(RefundPluginPurchase::class)->handle($license, User::factory()->create()); + + $payout->refresh(); + $this->assertEquals(PayoutStatus::Cancelled, $payout->status); + $this->assertFalse($payout->isFailed()); + } + + #[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 buyer_loses_access_when_refunded_and_regains_it_by_buying_again(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_'.uniqid()]); + $developerAccount = DeveloperAccount::factory()->create(); + $plugin = Plugin::factory()->approved()->paid()->create([ + 'name' => 'acme/rebuyable-plugin', + 'is_active' => true, + 'is_official' => false, + 'user_id' => $developerAccount->user_id, + 'developer_account_id' => $developerAccount->id, + ]); + PluginPrice::factory()->regular()->amount(2900)->create(['plugin_id' => $plugin->id]); + + $firstPurchase = PluginLicense::factory()->create([ + 'user_id' => $buyer->id, + 'plugin_id' => $plugin->id, + 'price_paid' => 2900, + 'purchased_at' => now()->subDays(3), + ]); + $this->assertTrue($buyer->hasPluginAccess($plugin)); + + $this->mockStripeConnectService(); + app(RefundPluginPurchase::class)->handle($firstPurchase, User::factory()->create()); + + $this->assertFalse($buyer->hasPluginAccess($plugin)); + + $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' => 2900, + ]); + + $removed = app(CartService::class)->removeAlreadyOwned($cart->fresh('items'), $buyer); + $this->assertSame([], $removed, 'A refunded plugin should not count as already owned'); + + (new HandleInvoicePaidJob(Invoice::constructFrom([ + 'id' => 'in_test_'.uniqid(), + 'billing_reason' => Invoice::BILLING_REASON_MANUAL, + 'customer' => $buyer->stripe_id, + 'payment_intent' => 'pi_test_'.uniqid(), + 'currency' => 'usd', + 'metadata' => ['cart_id' => $cart->id], + 'lines' => [], + ])))->handle(); + + $this->assertTrue($buyer->hasPluginAccess($plugin)); + $this->assertSame(2, $buyer->pluginLicenses()->forPlugin($plugin)->count()); + $this->assertTrue($firstPurchase->fresh()->isRefunded()); + + $secondPurchase = $buyer->pluginLicenses()->forPlugin($plugin)->notRefunded()->sole(); + $this->assertTrue($secondPurchase->isRefundable()); + $this->assertTrue($secondPurchase->payout->isPending()); + } + + #[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); + } +} diff --git a/tests/Feature/PurchaseHistoryTest.php b/tests/Feature/PurchaseHistoryTest.php index a0213a0b8..3abf28e72 100644 --- a/tests/Feature/PurchaseHistoryTest.php +++ b/tests/Feature/PurchaseHistoryTest.php @@ -4,6 +4,8 @@ use App\Features\ShowAuthButtons; use App\Livewire\Customer\Dashboard; +use App\Models\Plugin; +use App\Models\PluginLicense; use App\Models\Product; use App\Models\ProductLicense; use App\Models\User; @@ -121,4 +123,37 @@ public function test_product_purchases_show_on_page(): void $response->assertStatus(200); $response->assertSee('Plugin Dev Kit'); } + + public function test_refunded_plugin_purchase_is_listed_as_refunded(): void + { + $user = User::factory()->create(); + $plugin = Plugin::factory()->approved()->create(['name' => 'acme/refunded-plugin']); + + PluginLicense::factory()->refunded()->create([ + 'user_id' => $user->id, + 'plugin_id' => $plugin->id, + 'price_paid' => 2900, + 'purchased_at' => now()->subDays(5), + 'refunded_at' => now()->subDays(2), + ]); + + $response = $this->actingAs($user)->get('/dashboard/purchase-history'); + + $response->assertStatus(200); + $response->assertSee('acme/refunded-plugin'); + $response->assertSee('$29.00'); + $response->assertSee('Refunded '.now()->subDays(2)->format('M j, Y')); + } + + public function test_dashboard_plugin_count_excludes_refunded_plugins(): void + { + $user = User::factory()->create(); + + PluginLicense::factory()->create(['user_id' => $user->id]); + PluginLicense::factory()->refunded()->create(['user_id' => $user->id]); + + $component = Livewire::actingAs($user)->test(Dashboard::class); + + $this->assertSame(1, $component->instance()->pluginLicenseCount); + } }