diff --git a/app/Enums/LicenseSource.php b/app/Enums/LicenseSource.php index 70e46ca8..9d1a7150 100644 --- a/app/Enums/LicenseSource.php +++ b/app/Enums/LicenseSource.php @@ -7,4 +7,5 @@ enum LicenseSource: string case Stripe = 'stripe'; case Bifrost = 'bifrost'; case Manual = 'manual'; + case OpenCollective = 'opencollective'; } diff --git a/app/Filament/Resources/OpenCollectiveDonationResource.php b/app/Filament/Resources/OpenCollectiveDonationResource.php new file mode 100644 index 00000000..e1969676 --- /dev/null +++ b/app/Filament/Resources/OpenCollectiveDonationResource.php @@ -0,0 +1,154 @@ +schema([ + Infolists\Components\Section::make('Donation Details') + ->schema([ + Infolists\Components\TextEntry::make('order_id') + ->label('Order ID') + ->copyable(), + Infolists\Components\TextEntry::make('order_idv2') + ->label('Order ID (v2)') + ->copyable(), + Infolists\Components\TextEntry::make('amount') + ->formatStateUsing(fn ($state, $record) => Number::currency($state / 100, $record->currency)), + Infolists\Components\TextEntry::make('interval') + ->default('One-time'), + Infolists\Components\TextEntry::make('created_at') + ->label('Received') + ->dateTime(), + ])->columns(2), + Infolists\Components\Section::make('Contributor') + ->schema([ + Infolists\Components\TextEntry::make('from_collective_name') + ->label('Name'), + Infolists\Components\TextEntry::make('from_collective_slug') + ->label('Slug') + ->url(fn ($state) => "https://opencollective.com/{$state}") + ->openUrlInNewTab(), + Infolists\Components\TextEntry::make('from_collective_id') + ->label('Collective ID'), + ])->columns(3), + Infolists\Components\Section::make('Claim Status') + ->schema([ + Infolists\Components\IconEntry::make('claimed_at') + ->label('Claimed') + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger'), + Infolists\Components\TextEntry::make('claimed_at') + ->label('Claimed At') + ->dateTime() + ->placeholder('Not claimed'), + Infolists\Components\TextEntry::make('user.email') + ->label('Claimed By') + ->placeholder('Not claimed') + ->url(fn ($record) => $record->user_id ? UserResource::getUrl('edit', ['record' => $record->user_id]) : null), + ])->columns(3), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('created_at', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('order_id') + ->label('Order ID') + ->searchable() + ->sortable() + ->copyable(), + Tables\Columns\TextColumn::make('from_collective_name') + ->label('Contributor') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('amount') + ->formatStateUsing(fn ($state, $record) => Number::currency($state / 100, $record->currency)) + ->sortable(), + Tables\Columns\TextColumn::make('interval') + ->badge() + ->default('One-time') + ->color(fn (?string $state): string => $state ? 'success' : 'gray'), + Tables\Columns\IconColumn::make('claimed_at') + ->label('Claimed') + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-clock') + ->trueColor('success') + ->falseColor('warning'), + Tables\Columns\TextColumn::make('user.email') + ->label('Claimed By') + ->searchable() + ->placeholder('-') + ->toggleable(), + Tables\Columns\TextColumn::make('created_at') + ->label('Received') + ->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\Filter::make('claimed') + ->label('Claimed') + ->query(fn (Builder $query): Builder => $query->whereNotNull('claimed_at')), + Tables\Filters\Filter::make('unclaimed') + ->label('Unclaimed') + ->query(fn (Builder $query): Builder => $query->whereNull('claimed_at')), + Tables\Filters\Filter::make('recurring') + ->label('Recurring') + ->query(fn (Builder $query): Builder => $query->whereNotNull('interval')), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListOpenCollectiveDonations::route('/'), + 'view' => Pages\ViewOpenCollectiveDonation::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/OpenCollectiveDonationResource/Pages/ListOpenCollectiveDonations.php b/app/Filament/Resources/OpenCollectiveDonationResource/Pages/ListOpenCollectiveDonations.php new file mode 100644 index 00000000..46f67d65 --- /dev/null +++ b/app/Filament/Resources/OpenCollectiveDonationResource/Pages/ListOpenCollectiveDonations.php @@ -0,0 +1,19 @@ +verifySignature($request); + } + + $payload = $request->all(); + $type = $payload['type'] ?? null; + + Log::info('OpenCollective webhook received', [ + 'type' => $type, + 'payload' => $payload, + ]); + + // Handle different webhook types + match ($type) { + 'order.processed' => $this->handleOrderProcessed($payload), + default => Log::info('Unhandled OpenCollective webhook type', ['type' => $type]), + }; + + return response()->json(['status' => 'success']); + } + + protected function verifySignature(Request $request): void + { + $secret = config('services.opencollective.webhook_secret'); + $signature = $request->header('X-OpenCollective-Signature'); + + if (! $signature) { + abort(401, 'Missing webhook signature'); + } + + $payload = $request->getContent(); + $expectedSignature = hash_hmac('sha256', $payload, $secret); + + if (! hash_equals($expectedSignature, $signature)) { + abort(401, 'Invalid webhook signature'); + } + } + + protected function handleOrderProcessed(array $payload): void + { + $webhookId = $payload['id'] ?? null; + $data = $payload['data'] ?? []; + $order = $data['order'] ?? []; + $fromCollective = $data['fromCollective'] ?? []; + + $orderId = $order['id'] ?? null; + + if (! $orderId) { + Log::warning('OpenCollective order.processed missing order ID', ['payload' => $payload]); + + return; + } + + // Check if we've already processed this order + if (OpenCollectiveDonation::where('order_id', $orderId)->exists()) { + Log::info('OpenCollective order already processed', ['order_id' => $orderId]); + + return; + } + + // Store the donation for later claiming + OpenCollectiveDonation::create([ + 'webhook_id' => $webhookId, + 'order_id' => $orderId, + 'order_idv2' => $order['idV2'] ?? null, + 'amount' => $order['totalAmount'] ?? 0, + 'currency' => $order['currency'] ?? 'USD', + 'interval' => $order['interval'] ?? null, + 'from_collective_id' => $fromCollective['id'] ?? $order['FromCollectiveId'] ?? 0, + 'from_collective_name' => $fromCollective['name'] ?? null, + 'from_collective_slug' => $fromCollective['slug'] ?? null, + 'raw_payload' => $payload, + ]); + + Log::info('OpenCollective donation stored for claiming', [ + 'order_id' => $orderId, + 'amount' => $order['totalAmount'] ?? 0, + ]); + } +} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index a067f42e..0c163a38 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -13,5 +13,6 @@ class VerifyCsrfToken extends Middleware */ protected $except = [ 'stripe/webhook', + 'opencollective/contribution', ]; } diff --git a/app/Livewire/ClaimDonationLicense.php b/app/Livewire/ClaimDonationLicense.php new file mode 100644 index 00000000..b6c21702 --- /dev/null +++ b/app/Livewire/ClaimDonationLicense.php @@ -0,0 +1,142 @@ + ['required', 'string'], + ]; + + // Only require these fields if user is not logged in + if (! Auth::check()) { + $rules['email'] = ['required', 'email', 'max:255']; + $rules['name'] = ['required', 'string', 'max:255']; + $rules['password'] = ['required', 'confirmed', Password::defaults()]; + } + + return $rules; + } + + protected $messages = [ + 'order_id.required' => 'Please enter your OpenCollective Transaction ID.', + ]; + + public function claim(): void + { + $this->validate(); + + // Find the donation by order ID + $donation = OpenCollectiveDonation::where('order_id', $this->order_id)->first(); + + if (! $donation) { + $this->addError('order_id', 'We could not find a donation with this order ID. Please check and try again.'); + + return; + } + + if ($donation->isClaimed()) { + $this->addError('order_id', 'This donation has already been claimed.'); + + return; + } + + // Check if any donation from this contributor has already been claimed + $alreadyClaimedByContributor = OpenCollectiveDonation::where('from_collective_id', $donation->from_collective_id) + ->whereNotNull('claimed_at') + ->exists(); + + if ($alreadyClaimedByContributor) { + $this->addError('order_id', 'A license has already been claimed for this OpenCollective account.'); + + return; + } + + // Get or create user + if (Auth::check()) { + $user = Auth::user(); + + // Verify they don't already have an OpenCollective license + $existingLicense = $user->licenses() + ->where('source', LicenseSource::OpenCollective) + ->first(); + + if ($existingLicense) { + $this->addError('order_id', 'You already have a license from OpenCollective.'); + + return; + } + } else { + // Check if email already exists + $existingUser = User::where('email', $this->email)->first(); + + if ($existingUser) { + // Email exists but user isn't logged in - tell them to log in + $this->addError('email', 'An account with this email already exists. Please log in first, then return to claim your license.'); + + return; + } + + // Create new user + $user = User::create([ + 'email' => $this->email, + 'name' => $this->name, + 'password' => Hash::make($this->password), + ]); + } + + // Parse name for first/last + $name = Auth::check() ? $user->name : $this->name; + $nameParts = explode(' ', $name, 2); + $firstName = $nameParts[0] ?? null; + $lastName = $nameParts[1] ?? null; + + // Create the license + CreateAnystackLicenseJob::dispatch( + user: $user, + subscription: Subscription::Mini, + subscriptionItemId: null, + firstName: $firstName, + lastName: $lastName, + source: LicenseSource::OpenCollective + ); + + // Mark donation as claimed + $donation->markAsClaimed($user); + + // Log the user in + Auth::login($user); + + $this->claimed = true; + } + + public function render() + { + return view('livewire.claim-donation-license') + ->layout('components.layout', ['title' => 'Claim Your License']); + } +} diff --git a/app/Models/OpenCollectiveDonation.php b/app/Models/OpenCollectiveDonation.php new file mode 100644 index 00000000..070be6ab --- /dev/null +++ b/app/Models/OpenCollectiveDonation.php @@ -0,0 +1,42 @@ + 'array', + 'claimed_at' => 'datetime', + ]; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isClaimed(): bool + { + return $this->claimed_at !== null; + } + + public function markAsClaimed(User $user): void + { + $this->update([ + 'user_id' => $user->id, + 'claimed_at' => now(), + ]); + } +} diff --git a/config/services.php b/config/services.php index d4605099..805e88dd 100644 --- a/config/services.php +++ b/config/services.php @@ -39,11 +39,15 @@ 'api_key' => env('BIFROST_API_KEY'), ], + 'opencollective' => [ + 'webhook_secret' => env('OPENCOLLECTIVE_WEBHOOK_SECRET'), + ], + 'github' => [ 'client_id' => env('GITHUB_CLIENT_ID'), 'client_secret' => env('GITHUB_CLIENT_SECRET'), 'redirect' => env('APP_URL').'/auth/github/callback', - 'token' => env('GITHUB_TOKEN'), // For API calls (admin:org scope required) + 'token' => env('GITHUB_TOKEN'), ], 'turnstile' => [ diff --git a/database/factories/OpenCollectiveDonationFactory.php b/database/factories/OpenCollectiveDonationFactory.php new file mode 100644 index 00000000..ca8535da --- /dev/null +++ b/database/factories/OpenCollectiveDonationFactory.php @@ -0,0 +1,40 @@ + + */ +class OpenCollectiveDonationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'webhook_id' => fake()->unique()->randomNumber(6), + 'order_id' => fake()->unique()->randomNumber(5), + 'order_idv2' => fake()->uuid(), + 'amount' => fake()->numberBetween(1000, 10000), + 'currency' => 'USD', + 'interval' => null, + 'from_collective_id' => fake()->randomNumber(5), + 'from_collective_name' => fake()->name(), + 'from_collective_slug' => fake()->slug(2), + 'raw_payload' => [], + ]; + } + + public function claimed(): static + { + return $this->state(fn (array $attributes) => [ + 'user_id' => \App\Models\User::factory(), + 'claimed_at' => now(), + ]); + } +} diff --git a/database/migrations/2025_12_17_103019_create_opencollective_donations_table.php b/database/migrations/2025_12_17_103019_create_opencollective_donations_table.php new file mode 100644 index 00000000..8cd83672 --- /dev/null +++ b/database/migrations/2025_12_17_103019_create_opencollective_donations_table.php @@ -0,0 +1,42 @@ +id(); + $table->unsignedBigInteger('webhook_id')->unique(); + $table->unsignedBigInteger('order_id')->unique(); + $table->string('order_idv2')->nullable(); + $table->unsignedInteger('amount'); + $table->string('currency', 3)->default('USD'); + $table->string('interval')->nullable(); + $table->unsignedBigInteger('from_collective_id'); + $table->string('from_collective_name')->nullable(); + $table->string('from_collective_slug')->nullable(); + $table->json('raw_payload'); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->timestamp('claimed_at')->nullable(); + $table->timestamps(); + + $table->index('order_id'); + $table->index(['claimed_at', 'order_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('opencollective_donations'); + } +}; diff --git a/database/seeders/OpenCollectiveDonationSeeder.php b/database/seeders/OpenCollectiveDonationSeeder.php new file mode 100644 index 00000000..481c5a20 --- /dev/null +++ b/database/seeders/OpenCollectiveDonationSeeder.php @@ -0,0 +1,29 @@ +count(3)->create(); + + // Create a couple of claimed donations + OpenCollectiveDonation::factory() + ->count(2) + ->claimed() + ->create(); + + // Create one with a specific order ID for easy testing + OpenCollectiveDonation::factory()->create([ + 'order_id' => 12345, + 'from_collective_name' => 'Test Donor', + 'from_collective_slug' => 'test-donor', + 'amount' => 5000, + ]); + } +} diff --git a/public/img/opencollective.png b/public/img/opencollective.png new file mode 100644 index 00000000..3602e570 Binary files /dev/null and b/public/img/opencollective.png differ diff --git a/resources/views/livewire/claim-donation-license.blade.php b/resources/views/livewire/claim-donation-license.blade.php new file mode 100644 index 00000000..7adfcd49 --- /dev/null +++ b/resources/views/livewire/claim-donation-license.blade.php @@ -0,0 +1,169 @@ +
+
+ @if ($claimed) +
+
+ + + +
+

+ License Claimed! +

+

+ Your license is being generated and will be sent to your email shortly. +

+ +
+ @else +
+

+ Claim Your License +

+

+ Thank you for supporting NativePHP via OpenCollective! +
Enter your details below to claim your Mini license. +

+
+ +
+
+ @auth +
+

+ Claiming as {{ auth()->user()->email }} +

+
+ @endauth + +
+ + + @error('order_id') +

{{ $message }}

+ @enderror +
+ + @guest +
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('password') +

{{ $message }}

+ @enderror +
+ +
+ + +
+ @endguest +
+ +
+ +
+ + @guest +

+ Already have an account? + + Sign in + +

+ @endguest +
+ +
+

+ How to find your Transaction ID +

+

+ You can find your Transaction ID on your OpenCollective transactions page. +

+
+ Screenshot showing where to find your OpenCollective Transaction ID +
+
+ @endif +
+
diff --git a/resources/views/livewire/mobile-pricing.blade.php b/resources/views/livewire/mobile-pricing.blade.php index 355107bd..71bc19df 100644 --- a/resources/views/livewire/mobile-pricing.blade.php +++ b/resources/views/livewire/mobile-pricing.blade.php @@ -100,7 +100,7 @@ class="rounded-full bg-zinc-300 px-4 dark:bg-zinc-600" {{-- Price --}}
FREE
@@ -118,11 +118,41 @@ class="underline underline-offset-2 transition-colors hover:text-zinc-400 dark:t - Get started + Get started with Bifrost + {{-- OpenCollective Alternative --}} +
+
+
+
+
+ or +
+
+ + +
@endif diff --git a/routes/web.php b/routes/web.php index 420bda93..13823448 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Auth\CustomerAuthController; use App\Http\Controllers\CustomerLicenseController; use App\Http\Controllers\CustomerSubLicenseController; +use App\Http\Controllers\OpenCollectiveWebhookController; use App\Http\Controllers\ShowBlogController; use App\Http\Controllers\ShowDocumentationController; use Illuminate\Support\Facades\Route; @@ -33,6 +34,12 @@ Route::redirect('t-shirt', 'pricing'); Route::redirect('tshirt', 'pricing'); +// Webhook routes (must be outside web middleware for CSRF bypass) +Route::post('opencollective/contribution', [OpenCollectiveWebhookController::class, 'handle'])->name('opencollective.webhook'); + +// OpenCollective donation claim route +Route::get('opencollective/claim', App\Livewire\ClaimDonationLicense::class)->name('opencollective.claim'); + Route::view('/', 'welcome')->name('welcome'); Route::view('pricing', 'pricing')->name('pricing'); Route::view('alt-pricing', 'alt-pricing')->name('alt-pricing')->middleware('signed'); diff --git a/tests/Feature/ClaimDonationLicenseTest.php b/tests/Feature/ClaimDonationLicenseTest.php new file mode 100644 index 00000000..2ea40e90 --- /dev/null +++ b/tests/Feature/ClaimDonationLicenseTest.php @@ -0,0 +1,316 @@ +get('/opencollective/claim'); + + $response->assertStatus(200); + $response->assertSeeLivewire(ClaimDonationLicense::class); + } + + #[Test] + public function user_can_claim_a_valid_donation(): void + { + Queue::fake(); + + $donation = OpenCollectiveDonation::factory()->create([ + 'order_id' => 51763, + ]); + + Livewire::test(ClaimDonationLicense::class) + ->set('order_id', '51763') + ->set('name', 'John Doe') + ->set('email', 'john@example.com') + ->set('password', 'password123') + ->set('password_confirmation', 'password123') + ->call('claim') + ->assertSet('claimed', true); + + // Verify user was created + $this->assertDatabaseHas('users', [ + 'email' => 'john@example.com', + 'name' => 'John Doe', + ]); + + // Verify donation was marked as claimed + $donation->refresh(); + $this->assertTrue($donation->isClaimed()); + $this->assertNotNull($donation->user_id); + + // Verify license creation job was dispatched + Queue::assertPushed(CreateAnystackLicenseJob::class, function ($job) { + return $job->user->email === 'john@example.com' + && $job->subscription === Subscription::Mini + && $job->source === LicenseSource::OpenCollective + && $job->firstName === 'John' + && $job->lastName === 'Doe'; + }); + } + + #[Test] + public function claim_fails_with_invalid_order_id(): void + { + Queue::fake(); + + Livewire::test(ClaimDonationLicense::class) + ->set('order_id', '99999') + ->set('name', 'John Doe') + ->set('email', 'john@example.com') + ->set('password', 'password123') + ->set('password_confirmation', 'password123') + ->call('claim') + ->assertHasErrors(['order_id']) + ->assertSet('claimed', false); + + Queue::assertNotPushed(CreateAnystackLicenseJob::class); + } + + #[Test] + public function claim_fails_for_already_claimed_donation(): void + { + Queue::fake(); + + $user = User::factory()->create(); + $donation = OpenCollectiveDonation::factory()->claimed()->create([ + 'order_id' => 51763, + 'user_id' => $user->id, + ]); + + Livewire::test(ClaimDonationLicense::class) + ->set('order_id', '51763') + ->set('name', 'Jane Doe') + ->set('email', 'jane@example.com') + ->set('password', 'password123') + ->set('password_confirmation', 'password123') + ->call('claim') + ->assertHasErrors(['order_id']) + ->assertSet('claimed', false); + + Queue::assertNotPushed(CreateAnystackLicenseJob::class); + } + + #[Test] + public function claim_fails_if_contributor_already_claimed_another_donation(): void + { + Queue::fake(); + + $user = User::factory()->create(); + + // First donation from this contributor - already claimed + OpenCollectiveDonation::factory()->claimed()->create([ + 'order_id' => 11111, + 'from_collective_id' => 99999, + 'user_id' => $user->id, + ]); + + // Second donation from the same contributor - not yet claimed + $donation = OpenCollectiveDonation::factory()->create([ + 'order_id' => 22222, + 'from_collective_id' => 99999, + ]); + + Livewire::test(ClaimDonationLicense::class) + ->set('order_id', '22222') + ->set('name', 'Jane Doe') + ->set('email', 'jane@example.com') + ->set('password', 'password123') + ->set('password_confirmation', 'password123') + ->call('claim') + ->assertHasErrors(['order_id']) + ->assertSet('claimed', false); + + Queue::assertNotPushed(CreateAnystackLicenseJob::class); + + // Verify the donation was not claimed + $this->assertNull($donation->fresh()->claimed_at); + } + + #[Test] + public function existing_user_not_logged_in_is_told_to_log_in_first(): void + { + Queue::fake(); + + $user = User::factory()->create([ + 'email' => 'john@example.com', + ]); + + $donation = OpenCollectiveDonation::factory()->create([ + 'order_id' => 51763, + ]); + + Livewire::test(ClaimDonationLicense::class) + ->set('order_id', '51763') + ->set('name', 'John Doe') + ->set('email', 'john@example.com') + ->set('password', 'password123') + ->set('password_confirmation', 'password123') + ->call('claim') + ->assertHasErrors(['email']) + ->assertSet('claimed', false); + + Queue::assertNotPushed(CreateAnystackLicenseJob::class); + } + + #[Test] + public function logged_in_user_cannot_claim_if_they_already_have_opencollective_license(): void + { + Queue::fake(); + + $user = User::factory()->create([ + 'email' => 'john@example.com', + ]); + + License::factory()->create([ + 'user_id' => $user->id, + 'source' => LicenseSource::OpenCollective, + ]); + + $donation = OpenCollectiveDonation::factory()->create([ + 'order_id' => 51763, + ]); + + $this->actingAs($user); + + Livewire::test(ClaimDonationLicense::class) + ->set('order_id', '51763') + ->call('claim') + ->assertHasErrors(['order_id']) + ->assertSet('claimed', false); + + Queue::assertNotPushed(CreateAnystackLicenseJob::class); + } + + #[Test] + public function logged_in_user_without_opencollective_license_can_claim(): void + { + Queue::fake(); + + $user = User::factory()->create([ + 'email' => 'john@example.com', + 'name' => 'John Doe', + ]); + + // User has a Stripe license, not OpenCollective + License::factory()->create([ + 'user_id' => $user->id, + 'source' => LicenseSource::Stripe, + ]); + + $donation = OpenCollectiveDonation::factory()->create([ + 'order_id' => 51763, + ]); + + $this->actingAs($user); + + Livewire::test(ClaimDonationLicense::class) + ->set('order_id', '51763') + ->call('claim') + ->assertSet('claimed', true); + + Queue::assertPushed(CreateAnystackLicenseJob::class); + + // Verify donation was claimed by the existing user + $donation->refresh(); + $this->assertEquals($user->id, $donation->user_id); + } + + #[Test] + public function logged_in_user_only_needs_order_id_to_claim(): void + { + Queue::fake(); + + $user = User::factory()->create(); + + $donation = OpenCollectiveDonation::factory()->create([ + 'order_id' => 51763, + ]); + + $this->actingAs($user); + + Livewire::test(ClaimDonationLicense::class) + ->set('order_id', '51763') + ->call('claim') + ->assertHasNoErrors() + ->assertSet('claimed', true); + + Queue::assertPushed(CreateAnystackLicenseJob::class); + } + + #[Test] + public function validation_requires_all_fields_for_guests(): void + { + Livewire::test(ClaimDonationLicense::class) + ->call('claim') + ->assertHasErrors(['order_id', 'name', 'email', 'password']); + } + + #[Test] + public function validation_only_requires_order_id_for_logged_in_users(): void + { + $user = User::factory()->create(); + + $this->actingAs($user); + + Livewire::test(ClaimDonationLicense::class) + ->call('claim') + ->assertHasErrors(['order_id']) + ->assertHasNoErrors(['name', 'email', 'password']); + } + + #[Test] + public function password_confirmation_must_match(): void + { + OpenCollectiveDonation::factory()->create([ + 'order_id' => 51763, + ]); + + Livewire::test(ClaimDonationLicense::class) + ->set('order_id', '51763') + ->set('name', 'John Doe') + ->set('email', 'john@example.com') + ->set('password', 'password123') + ->set('password_confirmation', 'different') + ->call('claim') + ->assertHasErrors(['password']); + } + + #[Test] + public function user_is_logged_in_after_claiming(): void + { + Queue::fake(); + + $donation = OpenCollectiveDonation::factory()->create([ + 'order_id' => 51763, + ]); + + Livewire::test(ClaimDonationLicense::class) + ->set('order_id', '51763') + ->set('name', 'John Doe') + ->set('email', 'john@example.com') + ->set('password', 'password123') + ->set('password_confirmation', 'password123') + ->call('claim'); + + $this->assertAuthenticated(); + } +} diff --git a/tests/Feature/OpenCollectiveWebhookTest.php b/tests/Feature/OpenCollectiveWebhookTest.php new file mode 100644 index 00000000..7267f6b3 --- /dev/null +++ b/tests/Feature/OpenCollectiveWebhookTest.php @@ -0,0 +1,207 @@ +post('/opencollective/contribution'); + + $this->assertNotEquals(404, $response->getStatusCode()); + } + + #[Test] + public function opencollective_webhook_route_is_excluded_from_csrf_verification(): void + { + $reflection = new \ReflectionClass(VerifyCsrfToken::class); + $property = $reflection->getProperty('except'); + $exceptPaths = $property->getValue(app(VerifyCsrfToken::class)); + + $this->assertContains('opencollective/contribution', $exceptPaths); + } + + #[Test] + public function it_stores_donation_for_order_processed_webhook(): void + { + $payload = $this->getOrderProcessedPayload(); + + $response = $this->postJson('/opencollective/contribution', $payload); + + $response->assertStatus(200); + $response->assertJson(['status' => 'success']); + + $this->assertDatabaseHas('opencollective_donations', [ + 'webhook_id' => 335409, + 'order_id' => 51763, + 'order_idv2' => '88rzownx-l9e50pxj-z836ymvb-dgk7j43a', + 'amount' => 2000, + 'currency' => 'USD', + 'interval' => null, + 'from_collective_id' => 54797, + 'from_collective_name' => 'Testing User', + 'from_collective_slug' => 'sudharaka', + ]); + } + + #[Test] + public function it_does_not_store_duplicate_orders(): void + { + $payload = $this->getOrderProcessedPayload(); + + // First webhook + $this->postJson('/opencollective/contribution', $payload); + + // Second webhook with same order + $response = $this->postJson('/opencollective/contribution', $payload); + + $response->assertStatus(200); + $this->assertDatabaseCount('opencollective_donations', 1); + } + + #[Test] + public function it_handles_missing_order_id_gracefully(): void + { + $payload = [ + 'id' => 335409, + 'type' => 'order.processed', + 'data' => [ + 'order' => [], + ], + ]; + + $response = $this->postJson('/opencollective/contribution', $payload); + + $response->assertStatus(200); + $this->assertDatabaseCount('opencollective_donations', 0); + } + + #[Test] + public function it_verifies_webhook_signature_when_secret_is_configured(): void + { + Config::set('services.opencollective.webhook_secret', 'test-secret'); + + $payload = $this->getOrderProcessedPayload(); + + $response = $this->postJson('/opencollective/contribution', $payload); + + $response->assertStatus(401); + } + + #[Test] + public function it_accepts_valid_webhook_signature(): void + { + Config::set('services.opencollective.webhook_secret', 'test-secret'); + + $payload = $this->getOrderProcessedPayload(); + + $payloadJson = json_encode($payload); + $signature = hash_hmac('sha256', $payloadJson, 'test-secret'); + + $response = $this->postJson('/opencollective/contribution', $payload, [ + 'X-OpenCollective-Signature' => $signature, + ]); + + $response->assertStatus(200); + } + + #[Test] + public function it_handles_unhandled_webhook_types(): void + { + $payload = [ + 'id' => 12345, + 'type' => 'some.other.event', + 'data' => [], + ]; + + $response = $this->postJson('/opencollective/contribution', $payload); + + $response->assertStatus(200); + $response->assertJson(['status' => 'success']); + } + + #[Test] + public function donation_can_be_marked_as_claimed(): void + { + $donation = OpenCollectiveDonation::factory()->create(); + $user = \App\Models\User::factory()->create(); + + $this->assertFalse($donation->isClaimed()); + + $donation->markAsClaimed($user); + + $this->assertTrue($donation->fresh()->isClaimed()); + $this->assertEquals($user->id, $donation->fresh()->user_id); + $this->assertNotNull($donation->fresh()->claimed_at); + } + + protected function getOrderProcessedPayload(): array + { + return [ + 'createdAt' => '2025-12-04T16:20:34.260Z', + 'id' => 335409, + 'type' => 'order.processed', + 'CollectiveId' => 20206, + 'data' => [ + 'firstPayment' => true, + 'order' => [ + 'idV2' => '88rzownx-l9e50pxj-z836ymvb-dgk7j43a', + 'id' => 51763, + 'totalAmount' => 2000, + 'currency' => 'USD', + 'description' => 'Financial contribution to BackYourStack', + 'tags' => null, + 'interval' => null, + 'createdAt' => '2025-12-04T16:20:31.861Z', + 'quantity' => 1, + 'FromCollectiveId' => 54797, + 'TierId' => null, + 'formattedAmount' => '$20.00', + 'formattedAmountWithInterval' => '$20.00', + ], + 'host' => [ + 'idV2' => '8a47byg9-nxozdp80-xm6mjlv0-3rek5w8k', + 'id' => 11004, + 'type' => 'ORGANIZATION', + 'slug' => 'opensource', + 'name' => 'Open Source Collective', + ], + 'collective' => [ + 'idV2' => 'rvedj9wr-oz3a56d3-d35p7blg-8x4m0ykn', + 'id' => 20206, + 'type' => 'COLLECTIVE', + 'slug' => 'backyourstack', + 'name' => 'BackYourStack', + ], + 'fromCollective' => [ + 'idV2' => 'eeng0kzd-yvor4pz7-37gqbma8-37xlw95j', + 'id' => 54797, + 'type' => 'USER', + 'slug' => 'sudharaka', + 'name' => 'Testing User', + 'twitterHandle' => null, + 'githubHandle' => 'SudharakaP', + 'repositoryUrl' => 'https://github.com/test', + ], + ], + ]; + } +}