'test_secret']); [$tenant, $package, $session] = $this->prepareSession(); $payload = [ 'meta' => [ 'event_id' => 'evt_123', 'event_name' => 'order_created', 'custom_data' => [ 'checkout_session_id' => $session->id, 'tenant_id' => (string) $tenant->id, 'package_id' => (string) $package->id, ], ], 'data' => [ 'id' => 'ord_123', 'attributes' => [ 'status' => 'paid', 'checkout_id' => 'chk_456', 'subtotal' => 10000, 'discount_total' => 1000, 'tax' => 1900, 'total' => 10900, 'currency' => 'EUR', ], ], ]; $signature = hash_hmac('sha256', json_encode($payload), 'test_secret'); $response = $this->withHeader('X-Signature', $signature) ->postJson('/lemonsqueezy/webhook', $payload); $response->assertOk()->assertJson(['status' => 'processed']); $this->assertDatabaseHas('integration_webhook_events', [ 'provider' => 'lemonsqueezy', 'event_id' => 'evt_123', 'event_type' => 'order_created', 'status' => IntegrationWebhookEvent::STATUS_PROCESSED, ]); $session->refresh(); $this->assertEquals(CheckoutSession::STATUS_COMPLETED, $session->status); $this->assertSame('lemonsqueezy', $session->provider); $this->assertSame('ord_123', Arr::get($session->provider_metadata, 'lemonsqueezy_order_id')); $this->assertTrue( TenantPackage::query() ->where('tenant_id', $tenant->id) ->where('package_id', $package->id) ->where('active', true) ->exists() ); $purchase = PackagePurchase::query() ->where('tenant_id', $tenant->id) ->where('package_id', $package->id) ->where('provider', 'lemonsqueezy') ->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, 'lemonsqueezy_totals.total')); $this->assertSame(109.0, (float) $session->amount_total); } public function test_duplicate_order_is_idempotent(): void { config(['lemonsqueezy.webhook_secret' => 'test_secret']); [$tenant, $package, $session] = $this->prepareSession(); $payload = [ 'meta' => [ 'event_name' => 'order_created', 'custom_data' => [ 'checkout_session_id' => $session->id, 'tenant_id' => (string) $tenant->id, 'package_id' => (string) $package->id, ], ], 'data' => [ 'id' => 'ord_dup', 'attributes' => [ 'status' => 'paid', 'total' => 9900, 'currency' => 'EUR', ], ], ]; $signature = hash_hmac('sha256', json_encode($payload), 'test_secret'); $first = $this->withHeader('X-Signature', $signature) ->postJson('/lemonsqueezy/webhook', $payload); $first->assertOk()->assertJson(['status' => 'processed']); $second = $this->withHeader('X-Signature', $signature) ->postJson('/lemonsqueezy/webhook', $payload); $second->assertOk()->assertJson(['status' => 'processed']); $this->assertSame(1, PackagePurchase::query()->count()); $session->refresh(); $this->assertEquals(CheckoutSession::STATUS_COMPLETED, $session->status); $this->assertEquals('ord_dup', Arr::get($session->provider_metadata, 'lemonsqueezy_order_id')); } public function test_subscription_updated_creates_tenant_package(): void { config(['lemonsqueezy.webhook_secret' => 'test_secret']); $tenant = Tenant::factory()->create([ 'subscription_status' => 'free', ]); $package = Package::factory()->reseller()->create([ 'price' => 129, 'lemonsqueezy_variant_id' => 'var_sub_1', ]); $payload = [ 'meta' => [ 'event_name' => 'subscription_updated', 'custom_data' => [ 'tenant_id' => (string) $tenant->id, 'package_id' => (string) $package->id, ], ], 'data' => [ 'id' => 'sub_123', 'attributes' => [ 'status' => 'active', 'customer_id' => 'cus_123', 'variant_id' => 'var_sub_1', 'renews_at' => Carbon::now()->addMonth()->toIso8601String(), ], ], ]; $signature = hash_hmac('sha256', json_encode($payload), 'test_secret'); $response = $this->withHeader('X-Signature', $signature) ->postJson('/lemonsqueezy/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->lemonsqueezy_subscription_id); $this->assertTrue($tenantPackage->active); $this->assertEquals('active', $tenant->subscription_status); $this->assertNotNull($tenant->subscription_expires_at); } public function test_rejects_invalid_signature(): void { config(['lemonsqueezy.webhook_secret' => 'secret']); $response = $this->withHeader('X-Signature', 'invalid') ->postJson('/lemonsqueezy/webhook', ['meta' => ['event_name' => 'order_created']]); $response->assertStatus(400)->assertJson(['status' => 'invalid']); } public function test_unhandled_event_returns_accepted(): void { config(['lemonsqueezy.webhook_secret' => null]); $response = $this->postJson('/lemonsqueezy/webhook', [ 'meta' => ['event_name' => 'order_unknown'], 'data' => [], ]); $response->assertStatus(202)->assertJson(['status' => 'ignored']); } /** * @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' => 'endcustomer', 'price' => 99, 'lemonsqueezy_variant_id' => 'var_123', ]); /** @var CheckoutSessionService $sessions */ $sessions = app(CheckoutSessionService::class); $session = $sessions->createOrResume($user, $package, ['tenant' => $tenant]); $sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY); return [$tenant, $package, $session]; } }