From d624cf56a7f179a29e1b5a57a9c5d4a28177f837 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 10 Apr 2026 00:26:08 +0100 Subject: [PATCH 1/3] 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 --- app/Actions/RefundPluginPurchase.php | 69 +++++ app/Enums/PayoutStatus.php | 3 + .../LicensesRelationManager.php | 47 ++- .../PluginLicensesRelationManager.php | 41 +++ app/Models/PluginLicense.php | 49 ++- app/Models/PluginPayout.php | 22 ++ app/Services/StripeConnectService.php | 15 + database/factories/PluginLicenseFactory.php | 9 + database/factories/PluginPayoutFactory.php | 51 +++ ..._plugin_licenses_and_update_sales_view.php | 101 ++++++ package-lock.json | 2 +- tests/Feature/PluginPurchaseRefundTest.php | 290 ++++++++++++++++++ 12 files changed, 693 insertions(+), 6 deletions(-) create mode 100644 app/Actions/RefundPluginPurchase.php create mode 100644 database/factories/PluginPayoutFactory.php create mode 100644 database/migrations/2026_04_08_180245_add_refund_fields_to_plugin_licenses_and_update_sales_view.php create mode 100644 tests/Feature/PluginPurchaseRefundTest.php diff --git a/app/Actions/RefundPluginPurchase.php b/app/Actions/RefundPluginPurchase.php new file mode 100644 index 000000000..90794b954 --- /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 b548934e0..a58be43bc 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 3cf73b2a9..8207deaf2 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 37495a64f..4f0cda5e0 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 63ecd96ab..969ed27d4 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 f20b413d0..768a763a6 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 925b2ea02..0d171e84b 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 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 new file mode 100644 index 000000000..d98538fad --- /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 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/package-lock.json b/package-lock.json index 135b70645..f42c625a6 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 000000000..a2567bf67 --- /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); + } +} From f73178b1fdca6567d43b0f184073a329bf2d72d6 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 29 Sep 2026 16:17:09 +0100 Subject: [PATCH 2/3] Show refund details to admins, buyers and sellers Admin license tables, Third-Party Sales and Payouts now show who refunded a sale, when, and the Stripe refund ID. Third-Party Sales gets the Refund button and a Refunded filter too. The refund action is now one shared Filament action. Buyers no longer see refunded plugins under Purchased Plugins; they show as refunded in Purchase History instead. Both pages fit on mobile without scrolling sideways. On the developer dashboard, refunded payouts show as Refunded instead of Failed, refunds don't count as sales, pending payouts say when they should reach the seller's Stripe account, and the plugin and payout lists use the same table style as the rest of the dashboard. Co-Authored-By: Claude Opus 5.5 --- .../Actions/RefundPluginLicenseAction.php | 56 +++++++ .../Resources/PluginPayoutResource.php | 27 ++++ .../LicensesRelationManager.php | 55 +++---- .../Resources/ThirdPartySaleResource.php | 29 +++- .../PluginLicensesRelationManager.php | 51 ++----- app/Livewire/Customer/Dashboard.php | 2 +- app/Livewire/Customer/Developer/Dashboard.php | 2 +- .../Customer/PurchaseHistory/Index.php | 3 + .../Customer/PurchasedPlugins/Index.php | 1 + app/Models/PluginLicense.php | 22 +++ app/Models/PluginPayout.php | 28 ++++ .../customer/developer/dashboard.blade.php | 138 ++++++++++-------- .../customer/purchase-history.blade.php | 41 ++++-- .../customer/purchased-plugins.blade.php | 20 ++- .../CustomerPurchasedPluginsIndexTest.php | 23 +++ .../Filament/PluginPayoutResourceTest.php | 57 ++++++++ .../Filament/ThirdPartySaleResourceTest.php | 56 +++++++ .../Livewire/Customer/DeveloperPagesTest.php | 78 ++++++++++ tests/Feature/PurchaseHistoryTest.php | 35 +++++ 19 files changed, 572 insertions(+), 152 deletions(-) create mode 100644 app/Filament/Actions/RefundPluginLicenseAction.php 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 8207deaf2..7edd745ca 100644 --- a/app/Filament/Resources/PluginResource/RelationManagers/LicensesRelationManager.php +++ b/app/Filament/Resources/PluginResource/RelationManagers/LicensesRelationManager.php @@ -2,13 +2,12 @@ namespace App\Filament\Resources\PluginResource\RelationManagers; -use App\Actions\RefundPluginPurchase; +use App\Filament\Actions\RefundPluginLicenseAction; use App\Models\PluginLicense; -use Filament\Actions; -use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; class LicensesRelationManager extends RelationManager { @@ -19,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') @@ -48,44 +48,27 @@ public function table(Table $table): Table 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(), + ]) ->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()), + 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 4f0cda5e0..e84d85556 100644 --- a/app/Filament/Resources/UserResource/RelationManagers/PluginLicensesRelationManager.php +++ b/app/Filament/Resources/UserResource/RelationManagers/PluginLicensesRelationManager.php @@ -2,16 +2,16 @@ namespace App\Filament\Resources\UserResource\RelationManagers; -use App\Actions\RefundPluginPurchase; use App\Enums\PluginType; +use App\Filament\Actions\RefundPluginLicenseAction; 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; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; class PluginLicensesRelationManager extends RelationManager { @@ -70,13 +70,26 @@ public function table(Table $table): Table 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() @@ -88,39 +101,7 @@ 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()), + 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 969ed27d4..9f2c61660 100644 --- a/app/Models/PluginLicense.php +++ b/app/Models/PluginLicense.php @@ -61,6 +61,18 @@ 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 @@ -75,6 +87,16 @@ protected function active(Builder $query): Builder }); } + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function notRefunded(Builder $query): Builder + { + return $query->whereNull('refunded_at'); + } + /** * @param Builder $query * @return Builder diff --git a/app/Models/PluginPayout.php b/app/Models/PluginPayout.php index 4148ed1d6..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 { @@ -156,6 +157,33 @@ 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/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/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); + } } From 5c9ad04e74409ec97a7eecf05759e0d1aecc07cd Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Wed, 30 Sep 2026 12:45:47 +0100 Subject: [PATCH 3/3] Restore main's package-lock.json and test re-buying after a refund The lockfile got regenerated with a local npm while resolving merge conflicts, which dropped @docsearch/css. CI installs from the lockfile, so the asset build failed before any tests ran. This branch doesn't touch JS dependencies, so it now uses main's lockfile as-is. Also adds a test that a refunded buyer loses access and gets it back by buying the plugin again through the normal checkout. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 1233 +++++++++++++++----- tests/Feature/PluginPurchaseRefundTest.php | 63 + 2 files changed, 1021 insertions(+), 275 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4acddec78..77181b85f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "vast-condor", + "name": "nativephp.com", "lockfileVersion": 3, "requires": true, "packages": { @@ -31,31 +31,546 @@ "node": ">=20.19.0 || >=22.12.0" } }, + "node_modules/@ai-sdk/gateway": { + "version": "2.0.130", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.130.tgz", + "integrity": "sha512-OM6zvO+w5N5VROSeHRjzo/tKUVJZ6G75GCytokKuDsrwzXoLqR3CoNQaplJXRa8ixKavBbQyWrHqkTCyfDgmoA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.31", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.3.tgz", + "integrity": "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "3.0.31", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.31.tgz", + "integrity": "sha512-hlxLxqbdV+f7gBpL2SbLL+UhjV+DfUpJ28J4vSHekVV9fiTRrhqcd/ewJwaVbR/ahtLCGJPAVYzj+yJzaddc6g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.6", + "undici": "^5.29.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "2.0.234", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.234.tgz", + "integrity": "sha512-R/WPGkKrit3ByqYUyW0j0VUtoVG7aQdhTC7ly2SeYCDxMbInYdxdB74WBElGvegSWG3KqZNh/tGoXD7sv8LciA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "3.0.31", + "ai": "5.0.231", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", + "zod": "^3.25.76 || ^4.1.8" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.22.0.tgz", + "integrity": "sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.2.tgz", + "integrity": "sha512-mKv7RyuAzXvwmq+0XRK8HqZXt9iZ5Kkm2huLjgn5JoCPtDy+oh9yxUMfDDaVCw0oyzZ1isdJBc7l9nuCyyR7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.19.2", + "@algolia/autocomplete-shared": "1.19.2" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.2.tgz", + "integrity": "sha512-TjxbcC/r4vwmnZaPwrHtkXNeqvlpdyR+oR9Wi2XyfORkiGkLTVhX2j+O9SaCCINbKoDfc+c2PB8NjfOnz7+oKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.19.2" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.2.tgz", + "integrity": "sha512-jEazxZTVD2nLrC+wYlVHQgpBoBB5KPStrJxLzsIFl6Kqd1AlG9sIAGl39V5tECLpIQzB3Qa2T6ZPJ1ChkwMK/w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.56.0.tgz", + "integrity": "sha512-7r4Z3NC7yU1oAQVWJNA2HX7tX481F3pJvCGyLIXiTdBcthz4Q/o21jwcMYDFkuI92UWTNBQQmHYgwHo1zS5dzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.56.0.tgz", + "integrity": "sha512-avmjXQSq+jadFO8Xl2em05/uQdQnEmHsJyOAdVbZkmVgpMfxL12aJwVVfGNwYr9nulcpuJN1X0lTaQ5wxuNGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.56.0.tgz", + "integrity": "sha512-v2TPStUhY//ripPjIVclZ8AWc7DEGooXULZGFlFu37zNatgHjw34oZZ+OSbbc/YHO+xZwPl62I1k8xH1m4S2eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.56.0.tgz", + "integrity": "sha512-P0ehROpM4Sem3Sqo5x2cKPgj67D3G3jy0rh1Amwkcvsfr6tkvIcdCmerieanqTF7NxUMPNFLkpIFeMO8Rpa50w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.56.0.tgz", + "integrity": "sha512-SXK3Vn3WVxyzbm31oePZBJkp1wpOyuWdd4B/Pv7n0aXDxmeSWhC1R1FC1517mMrFAIaPH4Rt0x6RUe7ZNjz8FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.56.0.tgz", + "integrity": "sha512-5+ZdX8garFnmycnZgKhtXHePEaLj5zqDxI/0lkhhluzCcvTn0/PvvTirTg8hHYetQHvn7GDyeAiqTAieMvMW4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.56.0.tgz", + "integrity": "sha512-+mKUdYvqOi0BcvpAEyCEw49vSBptufIcfibtHz2bdr1pI789M46Yt0uQEk/sxtK3teh71OQvVFHaTDzShUWewQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.56.0.tgz", + "integrity": "sha512-9g/zj+AZx5moFcdFIrYQoVrueXivjUcc3MQHtCYT8WhIuk1lUh1AyEhvJCS0XBZld09cLvd1AZ3BvDBpVpX2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.56.0.tgz", + "integrity": "sha512-Qf3Sr6f9A9uxCZUf3MXS0d2b877uYzEB5yxqpVGXAhcJnBCQjrRRon0KvefpGkxy+BshrIJs96OUoMtGqXTFDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.56.0.tgz", + "integrity": "sha512-GXWG1rWc5wu8hY4N33Y3b6ernY6sAdAvmKWN/zHAiACOx40WnpG0TVX5YazCAr/9gOYGInSiM2A0y2jy2xbiDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.56.0.tgz", + "integrity": "sha512-7t24cBxaInS3mZb7ddEaZT/tp6q+/aR4YttsQVyP1/i+LmwPR34atO35KjaLFCcRVrlP7sYOAqkCfg6lIRB+ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.56.0.tgz", + "integrity": "sha512-R7ePHgVYmDFjZpvrsVAfbDz/d4RxKAYZ5/vgLfIsCVRZRryjWl/3INOxpOICzitehQ5FjNtNjcLQTrmHPTcHBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.56.0.tgz", + "integrity": "sha512-PIOUXlSnrqM0S+WOgDRb4RzotydJH7ZoT6tOyL7tAO7qJOfvX5wsEW8Pe+PMKMwvuI4/gIyK9cg2H7lJXqnc4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/@alpinejs/collapse": { - "version": "3.15.9", - "resolved": "https://registry.npmjs.org/@alpinejs/collapse/-/collapse-3.15.9.tgz", - "integrity": "sha512-8u3So/YaCRpa6K/JNG5fIkTe7bbdz5AhQ9CuCMy/xreIHYiZoLnDhlZWYnhRvzMhgxGiO6PIIR4SaF3ALJV9Zw==", + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/@alpinejs/collapse/-/collapse-3.15.12.tgz", + "integrity": "sha512-BKNANLtNXuWYOSAnajSKLPjTsmHRNrv0ALFTbpmqt2/klHFooPhctSwkhFVPQb7rZ8BjEKHmNaBwnSbgtpk6xg==", "dev": true, "license": "MIT" }, "node_modules/@alpinejs/persist": { - "version": "3.15.9", - "resolved": "https://registry.npmjs.org/@alpinejs/persist/-/persist-3.15.9.tgz", - "integrity": "sha512-Ed/kdLxGjwxl77p6F/Bt6WUW+uUuE9xYSKkxSzgKDpx2sWxOAHjlISxyafhRiRarzUyH616AW98qxvmFzd0n/Q==", + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/@alpinejs/persist/-/persist-3.15.12.tgz", + "integrity": "sha512-vzDRf/3r8B5ihHvLYUkkg+XbJz4nNst+5jR5BM4Uff3BhdRenJlXKFnd3kYbM2Z/KCU65zg5neqOVhbNpytZ9A==", "dev": true, "license": "MIT" }, "node_modules/@alpinejs/resize": { - "version": "3.15.9", - "resolved": "https://registry.npmjs.org/@alpinejs/resize/-/resize-3.15.9.tgz", - "integrity": "sha512-SlgTZsAPBC1kU/PWDgflcPz48Rzt2a7V7vofApppOxykhR0ug2uFDt9in4mbjTO66NCC8GW7E6on2ney//+DcA==", + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/@alpinejs/resize/-/resize-3.15.12.tgz", + "integrity": "sha512-NsPIlwRHsl3cZYXPPGAfJnTzubRz8jwAaZ/VFq2L8kmmR+hNGXaA+d7a4Eavav/hapuWIFlI9owDkRgb2jSDfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@base-ui/react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.7.0.tgz", + "integrity": "sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.3.2", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.2.tgz", + "integrity": "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.12", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@docsearch/core": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-5.0.0.tgz", + "integrity": "sha512-pl12R90X1WuGZhs/Bo1aeIqDjWzl3kQ2TOLX6xoCnCq5pRjtmtxEGYdmTRTtyV/dYDuMK857cKNzTfhEpogztg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": ">= 16.8.0 < 20.0.0", + "react": ">= 16.8.0 < 20.0.0", + "react-dom": ">= 16.8.0 < 20.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@docsearch/css": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-5.0.0.tgz", + "integrity": "sha512-KlCnJI+uZeTEYnPD1vjDj++PJ6oXcyga0J7B9eUDk38wmILI/Vfsl3tDd8arUzrZatxyF5S5HlKflGI99Axd7w==", "dev": true, "license": "MIT" }, "node_modules/@docsearch/js": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-5.1.1.tgz", - "integrity": "sha512-Kez8gF8uqDM0KasXCa48I5u9Gctjas3mnmPRIdVhWPu+chiglm5mL5XfWxHLnfc9JBTvKZVZ5fNYN/w+aGGVpA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-5.0.0.tgz", + "integrity": "sha512-xGG9PIKf/YxdfHsYAc9B6yt6qznX58tuD8fpYAPPvydzaMGq1R0IRKsHyMhfJZ50fxjfJiTykemlctSxGRxlrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/core": "5.0.0", + "@docsearch/react": "5.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-5.0.0.tgz", + "integrity": "sha512-7bnRy0XKkARW4D+oQUbnqhu+hY+MU+n50P7MegKdyOFcn8RjeMeK44ZMZw5mb97MoRkeomAVEDNLNIxbvNI/3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ai-sdk/react": "^2.0.30", + "@algolia/autocomplete-core": "1.19.2", + "@base-ui/react": "^1.5.0", + "@docsearch/core": "5.0.0", + "@docsearch/css": "5.0.0", + "ai": "^5.0.30", + "algoliasearch": "^5.28.0", + "marked": "^16.3.0", + "zod": "^4.1.8" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 20.0.0", + "react": ">= 16.8.0 < 20.0.0", + "react-dom": ">= 16.8.0 < 20.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "dev": true, "license": "MIT" }, @@ -119,10 +634,20 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@oxc-project/types": { - "version": "0.151.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", - "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", "dev": true, "license": "MIT", "funding": { @@ -130,9 +655,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", - "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", "cpu": [ "arm" ], @@ -147,9 +672,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", - "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", "cpu": [ "arm64" ], @@ -164,9 +689,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", - "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", "cpu": [ "arm64" ], @@ -181,9 +706,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", - "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", "cpu": [ "x64" ], @@ -198,9 +723,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", - "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", "cpu": [ "x64" ], @@ -215,9 +740,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", - "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", "cpu": [ "arm" ], @@ -232,16 +757,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", - "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -252,16 +774,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", - "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -272,16 +791,13 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", - "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -292,16 +808,13 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", - "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -312,16 +825,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", - "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -332,16 +842,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", - "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -352,9 +859,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", - "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", "cpu": [ "arm64" ], @@ -369,9 +876,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", - "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", "cpu": [ "arm64" ], @@ -386,9 +893,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", - "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", "cpu": [ "x64" ], @@ -409,6 +916,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/forms": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", @@ -423,56 +937,56 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" + "tailwindcss": "4.3.2" } }, "node_modules/@tailwindcss/node/node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", "dev": true, "license": "MIT" }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", "cpu": [ "arm64" ], @@ -487,9 +1001,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", "cpu": [ "arm64" ], @@ -504,9 +1018,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", "cpu": [ "x64" ], @@ -521,9 +1035,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", "cpu": [ "x64" ], @@ -538,9 +1052,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", "cpu": [ "arm" ], @@ -555,16 +1069,13 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -575,16 +1086,13 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -595,16 +1103,13 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -615,16 +1120,13 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -635,9 +1137,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -653,11 +1155,11 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { @@ -665,18 +1167,18 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", + "version": "1.11.1", "dev": true, "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", + "version": "1.11.1", "dev": true, "inBundle": true, "license": "MIT", @@ -686,7 +1188,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", + "version": "1.2.2", "dev": true, "inBundle": true, "license": "MIT", @@ -696,23 +1198,25 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", + "version": "1.1.4", "dev": true, "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", + "version": "0.10.2", "dev": true, "inBundle": true, "license": "MIT", @@ -729,9 +1233,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", "cpu": [ "arm64" ], @@ -746,9 +1250,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", "cpu": [ "x64" ], @@ -763,40 +1267,50 @@ } }, "node_modules/@tailwindcss/typography": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", - "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", "dev": true, "license": "MIT", "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", - "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "tailwindcss": "4.2.2" + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", "dev": true, "license": "MIT" }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vue/reactivity": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", @@ -827,10 +1341,55 @@ "node": ">= 6.0.0" } }, + "node_modules/ai": { + "version": "5.0.231", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.231.tgz", + "integrity": "sha512-Hgoc8w2p703YC6TnOBmaCioP3MZKFr9QIylhtZ0yea1iswUaljkbaNxp18Qqg2KFH9L0vcQ8vaskTaBwQGBK0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "2.0.130", + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.31", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/algoliasearch": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.56.0.tgz", + "integrity": "sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.22.0", + "@algolia/client-abtesting": "5.56.0", + "@algolia/client-analytics": "5.56.0", + "@algolia/client-common": "5.56.0", + "@algolia/client-insights": "5.56.0", + "@algolia/client-personalization": "5.56.0", + "@algolia/client-query-suggestions": "5.56.0", + "@algolia/client-search": "5.56.0", + "@algolia/ingestion": "1.56.0", + "@algolia/monitoring": "1.56.0", + "@algolia/recommend": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/alpinejs": { - "version": "3.15.9", - "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.9.tgz", - "integrity": "sha512-O30m8Tw/aARbLXmeTnISAFgrNm0K71PT7bZy/1NgRqFD36QGb34VJ4a6WBL1iIO/bofN+LkIkKLikUTkfPL2wQ==", + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz", + "integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -855,9 +1414,9 @@ } }, "node_modules/axios": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", - "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "dev": true, "license": "MIT", "dependencies": { @@ -935,6 +1494,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -961,14 +1530,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -1030,6 +1599,16 @@ "dev": true, "license": "MIT" }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1087,14 +1666,14 @@ } }, "node_modules/framer-motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", - "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", + "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", "dev": true, "license": "MIT", "dependencies": { - "motion-dom": "^12.38.0", - "motion-utils": "^12.36.0", + "motion-dom": "^12.42.2", + "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -1199,9 +1778,9 @@ "license": "ISC" }, "node_modules/gsap": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz", - "integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", + "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", "dev": true, "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, @@ -1262,15 +1841,22 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/laravel-vite-plugin": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz", @@ -1441,9 +2027,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1465,9 +2048,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1489,9 +2069,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1513,9 +2090,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1581,6 +2155,19 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1625,13 +2212,13 @@ } }, "node_modules/motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", - "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", + "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", "dev": true, "license": "MIT", "dependencies": { - "framer-motion": "^12.38.0", + "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { @@ -1652,19 +2239,19 @@ } }, "node_modules/motion-dom": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", - "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", + "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", "dev": true, "license": "MIT", "dependencies": { - "motion-utils": "^12.36.0" + "motion-utils": "^12.39.0" } }, "node_modules/motion-utils": { - "version": "12.36.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", - "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", "dev": true, "license": "MIT" }, @@ -1676,9 +2263,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -1712,9 +2299,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -1725,9 +2312,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -1745,7 +2332,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1768,9 +2355,9 @@ } }, "node_modules/prettier": { - "version": "3.9.9", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", - "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -1893,14 +2480,46 @@ "node": ">=10" } }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "dev": true, + "license": "MIT" + }, "node_modules/rolldown": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", - "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.151.0", + "@oxc-project/types": "=0.148.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1910,23 +2529,39 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.11", - "@rolldown/binding-android-arm64": "1.2.11", - "@rolldown/binding-darwin-arm64": "1.2.11", - "@rolldown/binding-darwin-x64": "1.2.11", - "@rolldown/binding-freebsd-x64": "1.2.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", - "@rolldown/binding-linux-arm64-gnu": "1.2.11", - "@rolldown/binding-linux-arm64-musl": "1.2.11", - "@rolldown/binding-linux-ppc64-gnu": "1.2.11", - "@rolldown/binding-linux-s390x-gnu": "1.2.11", - "@rolldown/binding-linux-x64-gnu": "1.2.11", - "@rolldown/binding-linux-x64-musl": "1.2.11", - "@rolldown/binding-openharmony-arm64": "1.2.11", - "@rolldown/binding-win32-arm64-msvc": "1.2.11", - "@rolldown/binding-win32-x64-msvc": "1.2.11" + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1937,6 +2572,20 @@ "node": ">=0.10.0" } }, + "node_modules/swr": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.5.0.tgz", + "integrity": "sha512-W0GomadRJe9OfzIoRX0kZHhDSaRRfdiOUeH6uazgR05SibBhDNwfdTbhAxmFtObvkf59fFymo22mooKukosvNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -1945,9 +2594,9 @@ "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -1958,6 +2607,19 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1982,6 +2644,29 @@ "dev": true, "license": "0BSD" }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -1990,16 +2675,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", - "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.9", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -2016,7 +2701,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -2234,9 +2919,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2258,9 +2940,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2282,9 +2961,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2306,9 +2982,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2363,6 +3036,16 @@ "type": "opencollective", "url": "https://opencollective.com/parcel" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/tests/Feature/PluginPurchaseRefundTest.php b/tests/Feature/PluginPurchaseRefundTest.php index 3cf799c2d..ad9d137c0 100644 --- a/tests/Feature/PluginPurchaseRefundTest.php +++ b/tests/Feature/PluginPurchaseRefundTest.php @@ -4,16 +4,22 @@ use App\Actions\RefundPluginPurchase; use App\Enums\PayoutStatus; +use App\Jobs\HandleInvoicePaidJob; +use App\Models\Cart; +use App\Models\CartItem; use App\Models\DeveloperAccount; use App\Models\Plugin; use App\Models\PluginBundle; use App\Models\PluginLicense; use App\Models\PluginPayout; +use App\Models\PluginPrice; use App\Models\User; +use App\Services\CartService; use App\Services\StripeConnectService; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\Test; +use Stripe\Invoice; use Stripe\Refund; use Stripe\TransferReversal; use Tests\TestCase; @@ -311,6 +317,63 @@ public function stripe_failure_leaves_everything_unchanged(): void $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 {