'test_secret']); [$tenant, $package, $session] = $this->prepareSession(); $payload = [ 'event_type' => 'transaction.completed', 'data' => [ 'id' => 'txn_123', 'status' => 'completed', 'checkout_id' => 'chk_456', 'details' => [ 'totals' => [ 'subtotal' => ['amount' => '10000'], 'discount' => ['amount' => '1000'], 'tax' => ['amount' => '1900'], 'total' => ['amount' => '10900'], 'currency_code' => 'EUR', ], ], 'custom_data' => [ 'checkout_session_id' => $session->id, 'tenant_id' => (string) $tenant->id, 'package_id' => (string) $package->id, ], ], ]; $signature = hash_hmac('sha256', json_encode($payload), 'test_secret'); $response = $this->withHeader('Paddle-Webhook-Signature', $signature) ->postJson('/paddle/webhook', $payload); $response->assertOk()->assertJson(['status' => 'processed']); $session->refresh(); $this->assertEquals(CheckoutSession::STATUS_COMPLETED, $session->status); $this->assertSame('paddle', $session->provider); $this->assertSame('txn_123', Arr::get($session->provider_metadata, 'paddle_transaction_id')); $this->assertTrue( TenantPackage::query() ->where('tenant_id', $tenant->id) ->where('package_id', $package->id) ->where('active', true) ->exists() ); $this->assertTrue( PackagePurchase::query() ->where('tenant_id', $tenant->id) ->where('package_id', $package->id) ->where('provider', 'paddle') ->exists() ); $purchase = PackagePurchase::query() ->where('tenant_id', $tenant->id) ->where('package_id', $package->id) ->first(); $this->assertNotNull($purchase); $this->assertSame(109.0, (float) $purchase->price); $this->assertSame('EUR', Arr::get($purchase->metadata, 'currency')); $this->assertSame(109.0, (float) Arr::get($purchase->metadata, 'paddle_totals.total')); $this->assertSame(109.0, (float) $session->amount_total); } public function test_duplicate_transaction_is_idempotent(): void { config(['paddle.webhook_secret' => 'test_secret']); [$tenant, $package, $session] = $this->prepareSession(); $payload = [ 'event_type' => 'transaction.completed', 'data' => [ 'id' => 'txn_dup', 'status' => 'completed', 'checkout_id' => 'chk_dup', 'custom_data' => [ 'checkout_session_id' => $session->id, 'tenant_id' => (string) $tenant->id, 'package_id' => (string) $package->id, ], ], ]; $signature = hash_hmac('sha256', json_encode($payload), 'test_secret'); $first = $this->withHeader('Paddle-Webhook-Signature', $signature) ->postJson('/paddle/webhook', $payload); $first->assertOk()->assertJson(['status' => 'processed']); $second = $this->withHeader('Paddle-Webhook-Signature', $signature) ->postJson('/paddle/webhook', $payload); $second->assertStatus(200)->assertJson(['status' => 'processed']); $this->assertSame(1, PackagePurchase::query()->count()); $session->refresh(); $this->assertEquals(CheckoutSession::STATUS_COMPLETED, $session->status); $this->assertEquals('txn_dup', Arr::get($session->provider_metadata, 'paddle_transaction_id')); } public function test_transaction_completed_updates_tenant_status_for_one_time_package(): void { config(['paddle.webhook_secret' => 'test_secret']); $user = User::factory()->create(['email_verified_at' => now()]); $tenant = Tenant::factory()->create([ 'user_id' => $user->id, 'subscription_status' => 'free', ]); $user->forceFill(['tenant_id' => $tenant->id])->save(); $package = Package::factory()->create([ 'type' => 'endcustomer', 'price' => 49, 'paddle_price_id' => 'price_one_time', ]); /** @var CheckoutSessionService $sessions */ $sessions = app(CheckoutSessionService::class); $session = $sessions->createOrResume($user, $package, ['tenant' => $tenant]); $sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); $payload = [ 'event_type' => 'transaction.completed', 'data' => [ 'id' => 'txn_one_time', 'status' => 'completed', 'details' => [ 'totals' => [ 'total' => ['amount' => '4900'], 'currency_code' => 'EUR', ], ], 'custom_data' => [ 'checkout_session_id' => $session->id, 'tenant_id' => (string) $tenant->id, 'package_id' => (string) $package->id, ], ], ]; $signature = hash_hmac('sha256', json_encode($payload), 'test_secret'); $response = $this->withHeader('Paddle-Webhook-Signature', $signature) ->postJson('/paddle/webhook', $payload); $response->assertOk()->assertJson(['status' => 'processed']); $tenant->refresh(); $this->assertSame('active', $tenant->subscription_status); $this->assertNotNull($tenant->subscription_expires_at); } public function test_rejects_invalid_signature(): void { config(['paddle.webhook_secret' => 'secret']); $response = $this->withHeader('Paddle-Webhook-Signature', 'invalid') ->postJson('/paddle/webhook', ['event_type' => 'transaction.completed']); $response->assertStatus(400)->assertJson(['status' => 'invalid']); } public function test_unhandled_event_returns_accepted(): void { config(['paddle.webhook_secret' => null]); $response = $this->postJson('/paddle/webhook', [ 'event_type' => 'transaction.unknown', 'data' => [], ]); $response->assertStatus(202)->assertJson(['status' => 'ignored']); } public function test_subscription_activation_creates_tenant_package(): void { config(['paddle.webhook_secret' => 'test_secret']); $tenant = Tenant::factory()->create([ 'paddle_customer_id' => 'cus_123', 'subscription_status' => 'free', ]); $package = Package::factory()->create([ 'type' => 'reseller', 'price' => 129, 'paddle_price_id' => 'price_sub_1', ]); $payload = [ 'event_type' => 'subscription.created', 'data' => [ 'id' => 'sub_123', 'status' => 'active', 'customer_id' => 'cus_123', 'created_at' => Carbon::now()->subDay()->toIso8601String(), 'next_billing_date' => Carbon::now()->addMonth()->toIso8601String(), 'custom_data' => [ 'tenant_id' => (string) $tenant->id, 'package_id' => (string) $package->id, ], 'items' => [ [ 'price_id' => 'price_sub_1', ], ], ], ]; $signature = hash_hmac('sha256', json_encode($payload), 'test_secret'); $response = $this->withHeader('Paddle-Webhook-Signature', $signature) ->postJson('/paddle/webhook', $payload); $response->assertOk()->assertJson(['status' => 'processed']); $tenant->refresh(); $tenantPackage = TenantPackage::where('tenant_id', $tenant->id) ->where('package_id', $package->id) ->first(); $this->assertNotNull($tenantPackage); $this->assertSame('sub_123', $tenantPackage->paddle_subscription_id); $this->assertTrue($tenantPackage->active); $this->assertEquals('active', $tenant->subscription_status); $this->assertNotNull($tenant->subscription_expires_at); } public function test_subscription_cancellation_marks_package_inactive(): void { config(['paddle.webhook_secret' => 'test_secret']); $tenant = Tenant::factory()->create([ 'paddle_customer_id' => 'cus_cancel', 'subscription_status' => 'active', ]); $package = Package::factory()->create([ 'type' => 'reseller', 'price' => 199, 'paddle_price_id' => 'price_cancel', ]); TenantPackage::factory()->create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, 'paddle_subscription_id' => 'sub_cancel', 'active' => true, ]); $payload = [ 'event_type' => 'subscription.cancelled', 'data' => [ 'id' => 'sub_cancel', 'status' => 'cancelled', 'customer_id' => 'cus_cancel', 'custom_data' => [ 'tenant_id' => (string) $tenant->id, 'package_id' => (string) $package->id, ], 'items' => [ [ 'price_id' => 'price_cancel', ], ], ], ]; $signature = hash_hmac('sha256', json_encode($payload), 'test_secret'); $response = $this->withHeader('Paddle-Webhook-Signature', $signature) ->postJson('/paddle/webhook', $payload); $response->assertOk()->assertJson(['status' => 'processed']); $tenant->refresh(); $tenantPackage = TenantPackage::where('tenant_id', $tenant->id) ->where('package_id', $package->id) ->first(); $this->assertNotNull($tenantPackage); $this->assertFalse($tenantPackage->active); $this->assertEquals('expired', $tenant->subscription_status); } /** * @return array{\App\Models\Tenant, \App\Models\Package, \App\Models\CheckoutSession} */ protected function prepareSession(): array { $user = User::factory()->create(['email_verified_at' => now()]); $tenant = Tenant::factory()->create(['user_id' => $user->id]); $user->forceFill(['tenant_id' => $tenant->id])->save(); $package = Package::factory()->create([ 'type' => 'reseller', 'price' => 99, 'paddle_price_id' => 'price_123', ]); /** @var CheckoutSessionService $sessions */ $sessions = app(CheckoutSessionService::class); $session = $sessions->createOrResume($user, $package, ['tenant' => $tenant]); $sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); return [$tenant, $package, $session]; } }