zeroOrMoreTimes() ->withAnyArgs(); Log::shouldReceive('warning') ->zeroOrMoreTimes() ->withAnyArgs(); Log::shouldReceive('info') ->zeroOrMoreTimes() ->withAnyArgs(); // Create payment provider configuration in database PaymentProvider::factory()->create([ 'name' => 'polar', 'display_name' => 'Polar.sh', 'is_active' => true, 'configuration' => [ 'api_key' => 'test_api_key', 'webhook_secret' => $this->webhookSecret, 'sandbox' => false, // Important: disable sandbox for validation tests 'sandbox_api_key' => 'sandbox_test_api_key', 'sandbox_webhook_secret' => 'sandbox_test_webhook_secret', 'access_token' => 'test_access_token', ], ]); // Create test data $this->user = User::factory()->create([ 'email' => 'test@example.com', 'polar_cust_id' => 'cus_test123', ]); $this->plan = Plan::factory()->create([ 'name' => 'Test Plan', 'price' => 29.99, 'monthly_billing' => true, ]); $this->subscription = Subscription::factory()->create([ 'user_id' => $this->user->id, 'plan_id' => $this->plan->id, 'provider' => 'polar', 'provider_subscription_id' => 'sub_test123', 'status' => 'active', ]); } /** @test */ public function it_rejects_webhook_with_invalid_signature(): void { $payload = $this->createPolarWebhookPayload('subscription.created'); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => 'invalid_signature', 'webhook-timestamp' => (string) time(), 'webhook-id' => 'wh_test123', ]); $response->assertStatus(400); // Check that we get some kind of error response (actual format may vary) $this->assertStringContainsString('error', $response->getContent()); } /** @test */ public function it_rejects_webhook_with_missing_timestamp(): void { $payload = $this->createPolarWebhookPayload('subscription.created'); $signature = $this->generatePolarSignature($payload); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(400); $response->assertJson(['error' => 'Invalid webhook signature']); } /** @test */ public function it_rejects_webhook_with_old_timestamp(): void { $payload = $this->createPolarWebhookPayload('subscription.created'); $oldTimestamp = time() - 600; // 10 minutes ago $signature = $this->generatePolarSignature($payload, $oldTimestamp); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $oldTimestamp, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(400); $response->assertJson(['error' => 'Invalid webhook signature']); } /** @test */ public function it_rejects_webhook_with_future_timestamp(): void { $payload = $this->createPolarWebhookPayload('subscription.created'); $futureTimestamp = time() + 600; // 10 minutes in future $signature = $this->generatePolarSignature($payload, $futureTimestamp); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $futureTimestamp, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(400); $response->assertJson(['error' => 'Invalid webhook signature']); } /** @test */ public function it_processes_valid_webhook_with_correct_signature(): void { $payload = $this->createPolarWebhookPayload('subscription.created'); $timestamp = time(); $signature = $this->generatePolarSignature($payload, $timestamp); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $timestamp, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); } /** @test */ public function it_handles_webhook_idempotency(): void { $payload = $this->createPolarWebhookPayload('subscription.created', [ 'id' => 'wh_test123', ]); $timestamp = time(); $signature = $this->generatePolarSignature($payload, $timestamp); // First request should succeed $response1 = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $timestamp, 'webhook-id' => 'wh_test123', ]); $response1->assertStatus(200); $response1->assertJson(['status' => 'success']); // Second request with same webhook ID should be ignored $response2 = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $timestamp, 'webhook-id' => 'wh_test123', ]); $response2->assertStatus(200); $response2->assertJson(['status' => 'success', 'message' => 'Webhook already processed']); } /** @test */ public function it_handles_subscription_created_webhook(): void { $payload = $this->createPolarWebhookPayload('subscription.created', [ 'data' => [ 'object' => [ 'id' => 'sub_new123', 'customer_id' => 'cus_test123', 'product_id' => 'prod_test123', 'status' => 'active', 'current_period_start' => now()->toISOString(), 'current_period_end' => now()->addMonth()->toISOString(), 'created_at' => now()->toISOString(), ], ], ]); $timestamp = time(); $signature = $this->generatePolarSignature($payload, $timestamp); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $timestamp, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); } /** @test */ public function it_handles_subscription_active_webhook(): void { $payload = $this->createPolarWebhookPayload('subscription.active', [ 'data' => [ 'object' => [ 'id' => $this->subscription->provider_subscription_id, 'customer_id' => 'cus_test123', 'status' => 'active', 'current_period_start' => now()->toISOString(), 'current_period_end' => now()->addMonth()->toISOString(), ], ], ]); $timestamp = time(); $signature = $this->generatePolarSignature($payload, $timestamp); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $timestamp, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); // Verify subscription was updated $this->subscription->refresh(); $this->assertEquals('active', $this->subscription->status); } /** @test */ public function it_handles_subscription_cancelled_webhook_with_reason(): void { $payload = $this->createPolarWebhookPayload('subscription.cancelled', [ 'data' => [ 'object' => [ 'id' => $this->subscription->provider_subscription_id, 'customer_id' => 'cus_test123', 'status' => 'cancelled', 'customer_cancellation_reason' => 'too_expensive', 'customer_cancellation_comment' => 'Found a cheaper alternative', 'cancelled_at' => now()->toISOString(), 'ends_at' => now()->addMonth()->toISOString(), ], ], ]); $timestamp = time(); $signature = $this->generatePolarSignature($payload, $timestamp); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $timestamp, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); // Verify subscription was cancelled with reason $this->subscription->refresh(); $this->assertEquals('cancelled', $this->subscription->status); $this->assertEquals('too_expensive', $this->subscription->cancellation_reason); } /** @test */ public function it_handles_subscription_paused_webhook(): void { $payload = $this->createPolarWebhookPayload('subscription.paused', [ 'data' => [ 'object' => [ 'id' => $this->subscription->provider_subscription_id, 'customer_id' => 'cus_test123', 'status' => 'paused', 'paused_at' => now()->toISOString(), 'pause_reason' => 'customer_request', ], ], ]); $timestamp = time(); $signature = $this->generatePolarSignature($payload, $timestamp); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $timestamp, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); // Verify subscription was paused $this->subscription->refresh(); $this->assertEquals('paused', $this->subscription->status); } /** @test */ public function it_handles_subscription_resumed_webhook(): void { // First pause the subscription $this->subscription->update(['status' => 'paused']); $payload = $this->createPolarWebhookPayload('subscription.resumed', [ 'data' => [ 'object' => [ 'id' => $this->subscription->provider_subscription_id, 'customer_id' => 'cus_test123', 'status' => 'active', 'resumed_at' => now()->toISOString(), 'resume_reason' => 'customer_request', 'current_period_start' => now()->toISOString(), 'current_period_end' => now()->addMonth()->toISOString(), ], ], ]); $timestamp = time(); $signature = $this->generatePolarSignature($payload, $timestamp); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $timestamp, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); // Verify subscription was resumed $this->subscription->refresh(); $this->assertEquals('active', $this->subscription->status); } /** @test */ public function it_handles_subscription_trial_will_end_webhook(): void { $payload = $this->createPolarWebhookPayload('subscription.trial_will_end', [ 'data' => [ 'object' => [ 'id' => $this->subscription->provider_subscription_id, 'customer_id' => 'cus_test123', 'status' => 'trialing', 'trial_ends_at' => now()->addDays(3)->toISOString(), ], ], ]); $timestamp = time(); $signature = $this->generatePolarSignature($payload, $timestamp); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $timestamp, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); // Verify trial end date was updated $this->subscription->refresh(); $this->assertNotNull($this->subscription->trial_ends_at); } /** @test */ public function it_handles_subscription_trial_ended_webhook(): void { $payload = $this->createPolarWebhookPayload('subscription.trial_ended', [ 'data' => [ 'object' => [ 'id' => $this->subscription->provider_subscription_id, 'customer_id' => 'cus_test123', 'status' => 'active', 'trial_ended_at' => now()->toISOString(), 'current_period_start' => now()->toISOString(), 'current_period_end' => now()->addMonth()->toISOString(), ], ], ]); $timestamp = time(); $signature = $this->generatePolarSignature($payload, $timestamp); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $timestamp, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); // Verify subscription was converted from trial $this->subscription->refresh(); $this->assertEquals('active', $this->subscription->status); } /** @test */ public function it_handles_subscription_lookup_by_checkout_id_fallback(): void { // Create subscription without provider_subscription_id but with checkout_id $subscriptionWithoutId = Subscription::factory()->create([ 'user_id' => $this->user->id, 'plan_id' => $this->plan->id, 'provider' => 'polar', 'provider_subscription_id' => null, 'provider_checkout_id' => 'chk_test123', 'status' => 'pending', ]); $payload = $this->createPolarWebhookPayload('subscription.active', [ 'data' => [ 'object' => [ 'id' => 'sub_new456', 'checkout_id' => 'chk_test123', // This should match the subscription 'customer_id' => 'cus_test123', 'status' => 'active', 'current_period_start' => now()->toISOString(), 'current_period_end' => now()->addMonth()->toISOString(), ], ], ]); $timestamp = time(); $signature = $this->generatePolarSignature($payload, $timestamp); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $timestamp, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); // Verify subscription was found and updated $subscriptionWithoutId->refresh(); $this->assertEquals('active', $subscriptionWithoutId->status); $this->assertEquals('sub_new456', $subscriptionWithoutId->provider_subscription_id); } /** @test */ public function it_handles_unknown_webhook_type_gracefully(): void { $payload = $this->createPolarWebhookPayload('unknown.event', [ 'data' => [ 'object' => [ 'id' => 'test123', ], ], ]); $timestamp = time(); $signature = $this->generatePolarSignature($payload, $timestamp); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $timestamp, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); } /** @test */ public function it_handles_webhook_processing_errors_gracefully(): void { // Create invalid payload that will cause processing error $payload = $this->createPolarWebhookPayload('subscription.active', [ 'data' => [ 'object' => [ 'id' => $this->subscription->provider_subscription_id, 'customer_id' => 'cus_test123', 'status' => 'active', 'current_period_start' => 'invalid-date', // This will cause error 'current_period_end' => now()->addMonth()->toISOString(), ], ], ]); $timestamp = time(); $signature = $this->generatePolarSignature($payload, $timestamp); $response = $this->postJson('/webhook/polar', $payload, [ 'webhook-signature' => $signature, 'webhook-timestamp' => (string) $timestamp, 'webhook-id' => 'wh_test123', ]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); } /** * Create a Polar webhook payload with the given event type and data. */ protected function createPolarWebhookPayload(string $eventType, array $additionalData = []): array { return array_merge([ 'id' => 'wh_'.uniqid(), 'type' => $eventType, 'created_at' => now()->toISOString(), 'object' => 'webhook_event', 'data' => [ 'object' => [ 'id' => 'sub_test123', 'customer_id' => 'cus_test123', 'status' => 'active', ], ], ], $additionalData); } /** * Generate a Polar webhook signature using Standard Webhooks format. */ protected function generatePolarSignature(array $payload, ?int $timestamp = null): string { $timestamp = $timestamp ?? time(); $webhookId = 'wh_test123'; $payloadJson = json_encode($payload); $signedPayload = $webhookId.'.'.$timestamp.'.'.$payloadJson; // Use raw secret (as stored in database) and base64 encode for HMAC $encodedSecret = base64_encode($this->webhookSecret); $hexHash = hash_hmac('sha256', $signedPayload, $encodedSecret); $signature = base64_encode(pack('H*', $hexHash)); return 'v1,'.$signature; } }