'client', 'services.paypal.secret' => 'secret', 'services.paypal.sandbox' => true, 'services.paypal.webhook_id' => 'wh_123', ]); Http::fake([ 'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([ 'access_token' => 'token', 'expires_in' => 3600, ]), 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([ 'verification_status' => 'SUCCESS', ]), ]); [$tenant, $package, $session, $coupon] = $this->prepareSession(withCoupon: true); $session->forceFill(['paypal_order_id' => 'ORDER-123'])->save(); $payload = [ 'id' => 'WH-1', 'event_type' => 'PAYMENT.CAPTURE.COMPLETED', 'resource' => [ 'id' => 'CAPTURE-1', 'status' => 'COMPLETED', 'amount' => [ 'value' => '99.00', 'currency_code' => 'EUR', ], 'supplementary_data' => [ 'related_ids' => [ 'order_id' => 'ORDER-123', ], ], ], ]; $response = $this->withHeaders($this->paypalHeaders()) ->postJson('/paypal/webhook', $payload); $response->assertOk()->assertJson(['status' => 'processed']); $this->assertDatabaseHas('integration_webhook_events', [ 'provider' => 'paypal', 'event_id' => 'WH-1', 'event_type' => 'PAYMENT.CAPTURE.COMPLETED', 'status' => IntegrationWebhookEvent::STATUS_PROCESSED, ]); $session->refresh(); $this->assertEquals(CheckoutSession::STATUS_COMPLETED, $session->status); $this->assertSame('paypal', $session->provider); $this->assertSame('ORDER-123', $session->paypal_order_id); $this->assertSame('CAPTURE-1', $session->paypal_capture_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', 'paypal') ->first(); $this->assertNotNull($purchase); $this->assertSame(99.0, (float) $purchase->price); $this->assertSame('EUR', Arr::get($purchase->metadata, 'currency')); $this->assertTrue( CouponRedemption::query() ->where('coupon_id', $coupon->id) ->where('checkout_session_id', $session->id) ->where('status', CouponRedemption::STATUS_SUCCESS) ->exists() ); } public function test_rejects_invalid_signature(): void { config([ 'services.paypal.client_id' => 'client', 'services.paypal.secret' => 'secret', 'services.paypal.sandbox' => true, 'services.paypal.webhook_id' => 'wh_123', ]); Http::fake([ 'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([ 'access_token' => 'token', 'expires_in' => 3600, ]), 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([ 'verification_status' => 'FAILURE', ]), ]); $response = $this->withHeaders($this->paypalHeaders()) ->postJson('/paypal/webhook', [ 'id' => 'WH-FAIL', 'event_type' => 'PAYMENT.CAPTURE.COMPLETED', 'resource' => [ 'id' => 'CAPTURE-FAIL', 'status' => 'COMPLETED', ], ]); $response->assertStatus(400)->assertJson(['status' => 'invalid']); } public function test_capture_completed_applies_addon_purchase(): void { Notification::fake(); config([ 'services.paypal.client_id' => 'client', 'services.paypal.secret' => 'secret', 'services.paypal.sandbox' => true, 'services.paypal.webhook_id' => 'wh_123', ]); Http::fake([ 'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([ 'access_token' => 'token', 'expires_in' => 3600, ]), 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([ 'verification_status' => 'SUCCESS', ]), ]); $tenant = Tenant::factory()->create([ 'contact_email' => 'tenant@example.com', ]); $package = Package::factory()->endcustomer()->create([ 'max_photos' => 100, ]); $event = Event::factory()->for($tenant)->create([ 'status' => 'published', ]); $eventPackage = EventPackage::create([ 'event_id' => $event->id, 'package_id' => $package->id, 'purchased_price' => $package->price, 'purchased_at' => now(), 'used_photos' => 0, 'used_guests' => 0, 'gallery_expires_at' => now()->addDays(7), ]); $addon = EventPackageAddon::create([ 'event_package_id' => $eventPackage->id, 'event_id' => $event->id, 'tenant_id' => $tenant->id, 'addon_key' => 'extra_photos_small', 'quantity' => 1, 'extra_photos' => 0, 'status' => 'pending', 'checkout_id' => 'ORDER-ADDON-1', 'metadata' => [ 'increments' => ['extra_photos' => 500], ], ]); $payload = [ 'id' => 'WH-ADDON-1', 'event_type' => 'PAYMENT.CAPTURE.COMPLETED', 'resource' => [ 'id' => 'CAPTURE-ADDON-1', 'status' => 'COMPLETED', 'amount' => [ 'value' => '12.50', 'currency_code' => 'EUR', ], 'supplementary_data' => [ 'related_ids' => [ 'order_id' => 'ORDER-ADDON-1', ], ], ], ]; $response = $this->withHeaders($this->paypalHeaders()) ->postJson('/paypal/webhook', $payload); $response->assertOk()->assertJson(['status' => 'processed']); $addon->refresh(); $eventPackage->refresh(); $this->assertSame('completed', $addon->status); $this->assertSame('CAPTURE-ADDON-1', $addon->transaction_id); $this->assertSame(500, $eventPackage->extra_photos); } public function test_capture_completed_issues_gift_voucher(): void { Mail::fake(); config()->set('gift-vouchers.reminder_days', 0); config()->set('gift-vouchers.expiry_reminder_days', 0); config([ 'services.paypal.client_id' => 'client', 'services.paypal.secret' => 'secret', 'services.paypal.sandbox' => true, 'services.paypal.webhook_id' => 'wh_123', ]); Http::fake([ 'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([ 'access_token' => 'token', 'expires_in' => 3600, ]), 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([ 'verification_status' => 'SUCCESS', ]), ]); $voucher = GiftVoucher::factory()->create([ 'status' => GiftVoucher::STATUS_PENDING, 'paypal_order_id' => 'ORDER-GIFT-1', ]); $payload = [ 'id' => 'WH-GIFT-1', 'event_type' => 'PAYMENT.CAPTURE.COMPLETED', 'resource' => [ 'id' => 'CAPTURE-GIFT-1', 'status' => 'COMPLETED', 'amount' => [ 'value' => '29.00', 'currency_code' => 'EUR', ], 'supplementary_data' => [ 'related_ids' => [ 'order_id' => 'ORDER-GIFT-1', ], ], ], ]; $response = $this->withHeaders($this->paypalHeaders()) ->postJson('/paypal/webhook', $payload); $response->assertOk()->assertJson(['status' => 'processed']); $voucher->refresh(); $this->assertSame(GiftVoucher::STATUS_ISSUED, $voucher->status); $this->assertSame('CAPTURE-GIFT-1', $voucher->paypal_capture_id); $this->assertNotNull($voucher->coupon_id); } /** * @return array{ * 0: \App\Models\Tenant, * 1: \App\Models\Package, * 2: \App\Models\CheckoutSession, * 3: \App\Models\Coupon * } */ protected function prepareSession(bool $withCoupon = false): 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, ]); /** @var CheckoutSessionService $sessions */ $sessions = app(CheckoutSessionService::class); $session = $sessions->createOrResume($user, $package, ['tenant' => $tenant]); $sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL); $coupon = Coupon::factory()->create([ 'lemonsqueezy_discount_id' => null, ]); if ($withCoupon) { /** @var CouponService $coupons */ $coupons = app(CouponService::class); $preview = $coupons->preview($coupon->code, $package, $tenant, CheckoutSession::PROVIDER_PAYPAL); $sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']); } return [$tenant, $package, $session, $coupon]; } /** * @return array */ protected function paypalHeaders(): array { return [ 'PAYPAL-AUTH-ALGO' => 'SHA256withRSA', 'PAYPAL-CERT-URL' => 'https://example.test/cert', 'PAYPAL-TRANSMISSION-ID' => 'transmission-1', 'PAYPAL-TRANSMISSION-SIG' => 'signature', 'PAYPAL-TRANSMISSION-TIME' => '2026-02-04T12:00:00Z', ]; } }