Skip to content
Draft
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
69 changes: 69 additions & 0 deletions app/Actions/RefundPluginPurchase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?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) {
continue;
}

if ($payout->isPending()) {
$payout->markAsCancelled();
} elseif ($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 @@ -7,13 +7,15 @@ enum PayoutStatus: string
case Pending = 'pending';
case Transferred = 'transferred';
case Failed = 'failed';
case Cancelled = 'cancelled';

public function label(): string
{
return match ($this) {
self::Pending => 'Pending',
self::Transferred => 'Transferred',
self::Failed => 'Failed',
self::Cancelled => 'Cancelled',
};
}

Expand All @@ -23,6 +25,7 @@ public function color(): string
self::Pending => 'yellow',
self::Transferred => 'green',
self::Failed => 'red',
self::Cancelled => 'gray',
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()),
]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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([
Expand All @@ -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(),
]);
}
Expand Down
49 changes: 45 additions & 4 deletions app/Models/PluginLicense.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -47,6 +48,14 @@ public function pluginBundle(): BelongsTo
return $this->belongsTo(PluginBundle::class);
}

/**
* @return BelongsTo<User, PluginLicense>
*/
public function refundedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'refunded_by');
}

public function wasPurchasedAsBundle(): bool
{
return $this->plugin_bundle_id !== null;
Expand All @@ -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());
});
}

/**
Expand All @@ -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;
}
Expand All @@ -99,13 +113,40 @@ 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 [
'price_paid' => 'integer',
'is_grandfathered' => 'boolean',
'purchased_at' => 'datetime',
'expires_at' => 'datetime',
'refunded_at' => 'datetime',
];
}
}
22 changes: 22 additions & 0 deletions app/Models/PluginPayout.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ protected function failed(Builder $query): Builder
return $query->where('status', PayoutStatus::Failed);
}

/**
* @param Builder<PluginPayout> $query
* @return Builder<PluginPayout>
*/
#[Scope]
protected function cancelled(Builder $query): Builder
{
return $query->where('status', PayoutStatus::Cancelled);
}

/**
* @return array{platform_fee: int, developer_amount: int}
*/
Expand Down Expand Up @@ -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 [
Expand Down
15 changes: 15 additions & 0 deletions app/Services/StripeConnectService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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([
Expand Down
9 changes: 9 additions & 0 deletions database/factories/PluginLicenseFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]);
}
}
Loading
Loading