Skip to content

Commit d624cf5

Browse files
simonhampclaude
andcommitted
Add plugin purchase refund system
Adds ability for admins to refund plugin purchases from Filament, including Stripe refund, license revocation, and payout cancellation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent de77a4e commit d624cf5

File tree

12 files changed

+693
-6
lines changed

12 files changed

+693
-6
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
namespace App\Actions;
4+
5+
use App\Models\PluginLicense;
6+
use App\Models\User;
7+
use App\Services\StripeConnectService;
8+
use Illuminate\Support\Collection;
9+
use Illuminate\Support\Facades\DB;
10+
11+
class RefundPluginPurchase
12+
{
13+
public function __construct(private StripeConnectService $stripeConnectService) {}
14+
15+
/**
16+
* Refund a plugin purchase, revoking the license and cancelling/reversing the payout.
17+
*
18+
* For bundle purchases, all sibling licenses sharing the same stripe_payment_intent_id
19+
* are refunded together.
20+
*/
21+
public function handle(PluginLicense $license, User $refundedBy): void
22+
{
23+
if (! $license->isRefundable()) {
24+
throw new \RuntimeException('This license is not eligible for a refund.');
25+
}
26+
27+
$licenses = $this->collectLicensesToRefund($license);
28+
29+
$refund = $this->stripeConnectService->refundPaymentIntent($license->stripe_payment_intent_id);
30+
31+
DB::transaction(function () use ($licenses, $refund, $refundedBy): void {
32+
foreach ($licenses as $licenseToRefund) {
33+
$licenseToRefund->update([
34+
'refunded_at' => now(),
35+
'stripe_refund_id' => $refund->id,
36+
'refunded_by' => $refundedBy->id,
37+
]);
38+
39+
$payout = $licenseToRefund->payout;
40+
41+
if (! $payout) {
42+
continue;
43+
}
44+
45+
if ($payout->isPending()) {
46+
$payout->markAsCancelled();
47+
} elseif ($payout->isTransferred()) {
48+
$this->stripeConnectService->reverseTransfer($payout->stripe_transfer_id);
49+
$payout->markAsCancelled();
50+
}
51+
}
52+
});
53+
}
54+
55+
/**
56+
* @return Collection<int, PluginLicense>
57+
*/
58+
private function collectLicensesToRefund(PluginLicense $license): Collection
59+
{
60+
if (! $license->wasPurchasedAsBundle()) {
61+
return collect([$license]);
62+
}
63+
64+
return PluginLicense::query()
65+
->where('stripe_payment_intent_id', $license->stripe_payment_intent_id)
66+
->where('plugin_bundle_id', $license->plugin_bundle_id)
67+
->get();
68+
}
69+
}

app/Enums/PayoutStatus.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ enum PayoutStatus: string
77
case Pending = 'pending';
88
case Transferred = 'transferred';
99
case Failed = 'failed';
10+
case Cancelled = 'cancelled';
1011

1112
public function label(): string
1213
{
1314
return match ($this) {
1415
self::Pending => 'Pending',
1516
self::Transferred => 'Transferred',
1617
self::Failed => 'Failed',
18+
self::Cancelled => 'Cancelled',
1719
};
1820
}
1921

@@ -23,6 +25,7 @@ public function color(): string
2325
self::Pending => 'yellow',
2426
self::Transferred => 'green',
2527
self::Failed => 'red',
28+
self::Cancelled => 'gray',
2629
};
2730
}
2831
}

app/Filament/Resources/PluginResource/RelationManagers/LicensesRelationManager.php

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
namespace App\Filament\Resources\PluginResource\RelationManagers;
44

5+
use App\Actions\RefundPluginPurchase;
6+
use App\Models\PluginLicense;
7+
use Filament\Actions;
8+
use Filament\Notifications\Notification;
59
use Filament\Resources\RelationManagers\RelationManager;
610
use Filament\Tables;
711
use Filament\Tables\Table;
@@ -40,7 +44,48 @@ public function table(Table $table): Table
4044
->dateTime()
4145
->sortable()
4246
->placeholder('Never'),
47+
48+
Tables\Columns\TextColumn::make('refunded_at')
49+
->label('Refunded')
50+
->dateTime()
51+
->sortable()
52+
->placeholder('-'),
4353
])
44-
->defaultSort('purchased_at', 'desc');
54+
->defaultSort('purchased_at', 'desc')
55+
->actions([
56+
Actions\Action::make('refund')
57+
->label('Refund')
58+
->color('danger')
59+
->requiresConfirmation()
60+
->modalHeading('Refund purchase')
61+
->modalDescription(function (PluginLicense $record): string {
62+
$amount = '$'.number_format($record->price_paid / 100, 2);
63+
$description = "This will issue a full {$amount} refund to {$record->user->email} and revoke their license.";
64+
65+
if ($record->wasPurchasedAsBundle()) {
66+
$description .= ' This license was purchased as part of a bundle — all licenses in the bundle will be refunded.';
67+
}
68+
69+
return $description;
70+
})
71+
->modalSubmitActionLabel('Yes, refund')
72+
->action(function (PluginLicense $record): void {
73+
try {
74+
app(RefundPluginPurchase::class)->handle($record, auth()->user());
75+
76+
Notification::make()
77+
->title('Purchase refunded successfully')
78+
->success()
79+
->send();
80+
} catch (\Exception $e) {
81+
Notification::make()
82+
->title('Refund failed')
83+
->body($e->getMessage())
84+
->danger()
85+
->send();
86+
}
87+
})
88+
->visible(fn (PluginLicense $record): bool => $record->isRefundable()),
89+
]);
4590
}
4691
}

app/Filament/Resources/UserResource/RelationManagers/PluginLicensesRelationManager.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
namespace App\Filament\Resources\UserResource\RelationManagers;
44

5+
use App\Actions\RefundPluginPurchase;
56
use App\Enums\PluginType;
7+
use App\Models\PluginLicense;
68
use Filament\Actions;
79
use Filament\Forms;
10+
use Filament\Notifications\Notification;
811
use Filament\Resources\RelationManagers\RelationManager;
912
use Filament\Schemas\Schema;
1013
use Filament\Tables;
@@ -64,6 +67,11 @@ public function table(Table $table): Table
6467
->dateTime()
6568
->sortable()
6669
->placeholder('Never'),
70+
Tables\Columns\TextColumn::make('refunded_at')
71+
->label('Refunded')
72+
->dateTime()
73+
->sortable()
74+
->placeholder('-'),
6775
])
6876
->defaultSort('purchased_at', 'desc')
6977
->filters([
@@ -80,6 +88,39 @@ public function table(Table $table): Table
8088
}),
8189
])
8290
->actions([
91+
Actions\Action::make('refund')
92+
->label('Refund')
93+
->color('danger')
94+
->requiresConfirmation()
95+
->modalHeading('Refund purchase')
96+
->modalDescription(function (PluginLicense $record): string {
97+
$amount = '$'.number_format($record->price_paid / 100, 2);
98+
$description = "This will issue a full {$amount} refund to {$record->user->email} for {$record->plugin->name} and revoke their license.";
99+
100+
if ($record->wasPurchasedAsBundle()) {
101+
$description .= ' This license was purchased as part of a bundle — all licenses in the bundle will be refunded.';
102+
}
103+
104+
return $description;
105+
})
106+
->modalSubmitActionLabel('Yes, refund')
107+
->action(function (PluginLicense $record): void {
108+
try {
109+
app(RefundPluginPurchase::class)->handle($record, auth()->user());
110+
111+
Notification::make()
112+
->title('Purchase refunded successfully')
113+
->success()
114+
->send();
115+
} catch (\Exception $e) {
116+
Notification::make()
117+
->title('Refund failed')
118+
->body($e->getMessage())
119+
->danger()
120+
->send();
121+
}
122+
})
123+
->visible(fn (PluginLicense $record): bool => $record->isRefundable()),
83124
Actions\DeleteAction::make(),
84125
]);
85126
}

app/Models/PluginLicense.php

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Illuminate\Database\Eloquent\Model;
99
use Illuminate\Database\Eloquent\Relations\BelongsTo;
1010
use Illuminate\Database\Eloquent\Relations\HasOne;
11+
use Illuminate\Support\Carbon;
1112

1213
class PluginLicense extends Model
1314
{
@@ -47,6 +48,14 @@ public function pluginBundle(): BelongsTo
4748
return $this->belongsTo(PluginBundle::class);
4849
}
4950

51+
/**
52+
* @return BelongsTo<User, PluginLicense>
53+
*/
54+
public function refundedBy(): BelongsTo
55+
{
56+
return $this->belongsTo(User::class, 'refunded_by');
57+
}
58+
5059
public function wasPurchasedAsBundle(): bool
5160
{
5261
return $this->plugin_bundle_id !== null;
@@ -59,10 +68,11 @@ public function wasPurchasedAsBundle(): bool
5968
#[Scope]
6069
protected function active(Builder $query): Builder
6170
{
62-
return $query->where(function ($q): void {
63-
$q->whereNull('expires_at')
64-
->orWhere('expires_at', '>', now());
65-
});
71+
return $query->whereNull('refunded_at')
72+
->where(function ($q): void {
73+
$q->whereNull('expires_at')
74+
->orWhere('expires_at', '>', now());
75+
});
6676
}
6777

6878
/**
@@ -87,6 +97,10 @@ protected function forPlugin(Builder $query, Plugin $plugin): Builder
8797

8898
public function isActive(): bool
8999
{
100+
if ($this->isRefunded()) {
101+
return false;
102+
}
103+
90104
if ($this->expires_at === null) {
91105
return true;
92106
}
@@ -99,13 +113,40 @@ public function isExpired(): bool
99113
return ! $this->isActive();
100114
}
101115

116+
public function isRefunded(): bool
117+
{
118+
return $this->refunded_at !== null;
119+
}
120+
121+
public function isRefundable(): bool
122+
{
123+
if ($this->isRefunded()) {
124+
return false;
125+
}
126+
127+
if ($this->is_grandfathered) {
128+
return false;
129+
}
130+
131+
if ($this->price_paid <= 0) {
132+
return false;
133+
}
134+
135+
if (! $this->stripe_payment_intent_id) {
136+
return false;
137+
}
138+
139+
return $this->purchased_at->diffInDays(Carbon::now()) <= 14;
140+
}
141+
102142
protected function casts(): array
103143
{
104144
return [
105145
'price_paid' => 'integer',
106146
'is_grandfathered' => 'boolean',
107147
'purchased_at' => 'datetime',
108148
'expires_at' => 'datetime',
149+
'refunded_at' => 'datetime',
109150
];
110151
}
111152
}

app/Models/PluginPayout.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,16 @@ protected function failed(Builder $query): Builder
6363
return $query->where('status', PayoutStatus::Failed);
6464
}
6565

66+
/**
67+
* @param Builder<PluginPayout> $query
68+
* @return Builder<PluginPayout>
69+
*/
70+
#[Scope]
71+
protected function cancelled(Builder $query): Builder
72+
{
73+
return $query->where('status', PayoutStatus::Cancelled);
74+
}
75+
6676
/**
6777
* @return array{platform_fee: int, developer_amount: int}
6878
*/
@@ -108,6 +118,18 @@ public function markAsFailed(): void
108118
]);
109119
}
110120

121+
public function markAsCancelled(): void
122+
{
123+
$this->update([
124+
'status' => PayoutStatus::Cancelled,
125+
]);
126+
}
127+
128+
public function isCancelled(): bool
129+
{
130+
return $this->status === PayoutStatus::Cancelled;
131+
}
132+
111133
protected function casts(): array
112134
{
113135
return [

app/Services/StripeConnectService.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
use Illuminate\Support\Facades\Log;
1414
use Laravel\Cashier\Cashier;
1515
use Stripe\Account;
16+
use Stripe\Refund;
17+
use Stripe\TransferReversal;
1618

1719
/**
1820
* Service for managing Stripe Connect accounts and processing developer payouts.
@@ -186,6 +188,19 @@ protected function determineStatus(Account $account): StripeConnectStatus
186188
return StripeConnectStatus::Pending;
187189
}
188190

191+
public function refundPaymentIntent(string $paymentIntentId): Refund
192+
{
193+
return Cashier::stripe()->refunds->create([
194+
'payment_intent' => $paymentIntentId,
195+
]);
196+
}
197+
198+
public function reverseTransfer(string $stripeTransferId): TransferReversal
199+
{
200+
return Cashier::stripe()->transfers->retrieve($stripeTransferId)
201+
->reversals->create();
202+
}
203+
189204
public function createProductAndPrice(Plugin $plugin, int $amountCents, string $currency = 'usd'): PluginPrice
190205
{
191206
$product = Cashier::stripe()->products->create([

database/factories/PluginLicenseFactory.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,13 @@ public function grandfathered(): static
5151
'is_grandfathered' => true,
5252
]);
5353
}
54+
55+
public function refunded(): static
56+
{
57+
return $this->state(fn (array $attributes) => [
58+
'refunded_at' => now(),
59+
'stripe_refund_id' => 're_'.$this->faker->uuid(),
60+
'refunded_by' => User::factory(),
61+
]);
62+
}
5463
}

0 commit comments

Comments
 (0)