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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions app/Actions/RefundPluginPurchase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace App\Actions;

use App\Models\PluginLicense;
use App\Models\User;
use App\Services\StripeConnectService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class RefundPluginPurchase
{
public function __construct(private StripeConnectService $stripeConnectService) {}

/**
* Refund a plugin purchase, revoking the license and cancelling/reversing the payout.
*
* For bundle purchases, all sibling licenses sharing the same stripe_payment_intent_id
* are refunded together.
*/
public function handle(PluginLicense $license, User $refundedBy): void
{
if (! $license->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<int, PluginLicense>
*/
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();
}
}
3 changes: 3 additions & 0 deletions app/Enums/PayoutStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ enum PayoutStatus: string
case Pending = 'pending';
case Transferred = 'transferred';
case Failed = 'failed';
case Cancelled = 'cancelled';

public function label(): string
{
Expand All @@ -16,6 +17,7 @@ public function label(): string
self::Pending => 'Pending',
self::Transferred => 'Transferred',
self::Failed => 'Failed',
self::Cancelled => 'Cancelled',
};
}

Expand All @@ -26,6 +28,7 @@ public function color(): string
self::Pending => 'yellow',
self::Transferred => 'green',
self::Failed => 'red',
self::Cancelled => 'gray',
};
}
}
56 changes: 56 additions & 0 deletions app/Filament/Actions/RefundPluginLicenseAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace App\Filament\Actions;

use App\Actions\RefundPluginPurchase;
use App\Models\PluginLicense;
use Filament\Actions\Action;
use Filament\Notifications\Notification;

class RefundPluginLicenseAction extends Action
{
public static function getDefaultName(): ?string
{
return 'refund';
}

protected function setUp(): void
{
parent::setUp();

$this
->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();
}
});
}
}
27 changes: 27 additions & 0 deletions app/Filament/Resources/PluginPayoutResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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')
Expand All @@ -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(),
]);
}
}
29 changes: 28 additions & 1 deletion app/Filament/Resources/ThirdPartySaleResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
Expand All @@ -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(
Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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()
Expand All @@ -80,6 +101,7 @@ public function table(Table $table): Table
}),
])
->actions([
RefundPluginLicenseAction::make(),
Actions\DeleteAction::make(),
]);
}
Expand Down
2 changes: 1 addition & 1 deletion app/Livewire/Customer/Dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion app/Livewire/Customer/Developer/Dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
Loading
Loading