diff --git a/.env.example b/.env.example index 8a9420c..82dc222 100644 --- a/.env.example +++ b/.env.example @@ -117,14 +117,22 @@ PAYPAL_CLIENT_ID= PAYPAL_SECRET= PAYPAL_SANDBOX=true -# Paddle Billing -PADDLE_SANDBOX=true -PADDLE_API_KEY= -PADDLE_CLIENT_ID= -PADDLE_WEBHOOK_SECRET= -PADDLE_PUBLIC_KEY= -PADDLE_BASE_URL= -PADDLE_CONSOLE_URL= +# Lemon Squeezy Billing +LEMONSQUEEZY_STORE_ID=284860 +LEMONSQUEEZY_API_KEY= +LEMONSQUEEZY_WEBHOOK_SECRET= +LEMONSQUEEZY_WEBHOOK_EVENTS= +LEMONSQUEEZY_TEST_MODE=false +LEMONSQUEEZY_BASE_URL=https://api.lemonsqueezy.com/v1 +LEMONSQUEEZY_GIFT_VARIANT_STARTER= +LEMONSQUEEZY_GIFT_VARIANT_STARTER_USD= +LEMONSQUEEZY_GIFT_VARIANT_STARTER_GBP= +LEMONSQUEEZY_GIFT_VARIANT_STANDARD= +LEMONSQUEEZY_GIFT_VARIANT_STANDARD_USD= +LEMONSQUEEZY_GIFT_VARIANT_STANDARD_GBP= +LEMONSQUEEZY_GIFT_VARIANT_PREMIUM= +LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_USD= +LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_GBP= # Sanctum / SPA auth SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000 diff --git a/.tamagui/tamagui.config.cjs b/.tamagui/tamagui.config.cjs index c0a5994..4e0c77f 100644 --- a/.tamagui/tamagui.config.cjs +++ b/.tamagui/tamagui.config.cjs @@ -3599,6 +3599,8 @@ var tokens3 = { // ... existing radius tokens ... card: 16, tile: 14, + bento: 24, + bentoLg: 32, pill: 999 } // ... diff --git a/.tamagui/tamagui.config.json b/.tamagui/tamagui.config.json index 5db8d95..56369e9 100644 --- a/.tamagui/tamagui.config.json +++ b/.tamagui/tamagui.config.json @@ -503083,6 +503083,20 @@ "val": 14, "variable": "var(--c-radius-tile)" }, + "bento": { + "isVar": true, + "key": "$bento", + "name": "c-radius-bento", + "val": 24, + "variable": "var(--c-radius-bento)" + }, + "bentoLg": { + "isVar": true, + "key": "$bentoLg", + "name": "c-radius-bentoLg", + "val": 32, + "variable": "var(--c-radius-bentoLg)" + }, "pill": { "isVar": true, "key": "$pill", @@ -505293,6 +505307,20 @@ "val": "hsl(53, 92.0%, 50.0%)", "variable": "var(--c-yellow9Light)" }, + "$radius.bento": { + "isVar": true, + "key": "$bento", + "name": "c-radius-bento", + "val": 24, + "variable": "var(--c-radius-bento)" + }, + "$radius.bentoLg": { + "isVar": true, + "key": "$bentoLg", + "name": "c-radius-bentoLg", + "val": 32, + "variable": "var(--c-radius-bentoLg)" + }, "$radius.card": { "isVar": true, "key": "$card", diff --git a/AGENTS.md b/AGENTS.md index 108a814..9b7cb0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,8 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, - Languages/Frameworks: PHP 8.2+ (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4. - Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev), Playwright, Vitest, TypeScript. -- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Paddle API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds. -- Payment Systems: Paddle (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use). +- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Lemon Squeezy API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds. +- Payment Systems: Lemon Squeezy (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use). - PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync. ## Repo Structure (high-level) @@ -61,7 +61,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, #### Billing & Packages - package:check-status — check event package status. - packages:migrate-legacy — migrate legacy package purchases. -- paddle:sync-packages — sync packages with Paddle (push/pull/queue/dry-run). +- lemonsqueezy:sync-packages — sync packages with Lemon Squeezy (push/pull/queue/dry-run). - coupons:export — export coupon redemptions. - checkout:send-reminders — send abandoned checkout reminders (dry-run supported). @@ -96,7 +96,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, - metrics:package-limits — inspect/reset package limit metrics (routes/console.php). - inspire — inspiring quote (routes/console.php). - Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/archive/prp/03-api.md. -- Payment Integration: Paddle webhooks, RevenueCat mobile subscriptions. +- Payment Integration: Lemon Squeezy webhooks, RevenueCat mobile subscriptions. ## PWA Architecture - Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required). diff --git a/app/Console/Commands/LemonSqueezyRegisterWebhooks.php b/app/Console/Commands/LemonSqueezyRegisterWebhooks.php new file mode 100644 index 0000000..cbf7dc9 --- /dev/null +++ b/app/Console/Commands/LemonSqueezyRegisterWebhooks.php @@ -0,0 +1,130 @@ +option('url') ?: $this->defaultWebhookUrl()); + + if ($destination === '') { + $this->error('Webhook destination URL is required. Use --url=...'); + + return self::FAILURE; + } + + $events = collect((array) $this->option('events')) + ->filter() + ->map(fn ($event) => trim((string) $event)) + ->filter() + ->values() + ->all(); + + if ($events === []) { + $events = config('lemonsqueezy.webhook_events', []); + } + + if ($events === [] || ! is_array($events)) { + $this->error('No webhook events configured. Set config(lemonsqueezy.webhook_events) or pass --events.'); + + return self::FAILURE; + } + + $secret = (string) ($this->option('secret') ?: config('lemonsqueezy.webhook_secret')); + if ($secret === '') { + $this->error('Webhook signing secret is required. Set LEMONSQUEEZY_WEBHOOK_SECRET or pass --secret.'); + + return self::FAILURE; + } + + $storeId = (string) config('lemonsqueezy.store_id'); + if ($storeId === '') { + $this->error('Lemon Squeezy store id is required. Set LEMONSQUEEZY_STORE_ID.'); + + return self::FAILURE; + } + + $testMode = (bool) $this->option('test-mode') || (bool) config('lemonsqueezy.test_mode', false); + + $attributes = array_filter([ + 'url' => $destination, + 'events' => $events, + 'secret' => $secret, + 'test_mode' => $testMode ? true : null, + ], static fn ($value) => $value !== null && $value !== ''); + + $payload = [ + 'data' => [ + 'type' => 'webhooks', + 'attributes' => $attributes, + 'relationships' => [ + 'store' => [ + 'data' => [ + 'type' => 'stores', + 'id' => $storeId, + ], + ], + ], + ], + ]; + + if ((bool) $this->option('dry-run')) { + $this->line(json_encode($payload, JSON_PRETTY_PRINT)); + + return self::SUCCESS; + } + + $response = $client->post('/webhooks', $payload); + $data = Arr::get($response, 'data', $response); + $id = Arr::get($data, 'id'); + + Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy webhook registered', [ + 'webhook_id' => $id, + 'destination' => $destination, + 'test_mode' => $testMode, + ]); + + $this->info('Lemon Squeezy webhook registered.'); + + if ($id) { + $this->line('ID: '.$id); + } + + return self::SUCCESS; + } + + protected function defaultWebhookUrl(): string + { + $base = rtrim((string) config('app.url'), '/'); + + return $base !== '' ? $base.'/lemonsqueezy/webhook' : ''; + } +} diff --git a/app/Console/Commands/PaddleSyncPackages.php b/app/Console/Commands/LemonSqueezySyncPackages.php similarity index 77% rename from app/Console/Commands/PaddleSyncPackages.php rename to app/Console/Commands/LemonSqueezySyncPackages.php index a51179b..a75a158 100644 --- a/app/Console/Commands/PaddleSyncPackages.php +++ b/app/Console/Commands/LemonSqueezySyncPackages.php @@ -2,23 +2,23 @@ namespace App\Console\Commands; -use App\Jobs\PullPackageFromPaddle; -use App\Jobs\SyncPackageToPaddle; +use App\Jobs\PullPackageFromLemonSqueezy; +use App\Jobs\SyncPackageToLemonSqueezy; use App\Models\Package; use Illuminate\Console\Command; use Illuminate\Support\Collection; use Illuminate\Support\Str; -class PaddleSyncPackages extends Command +class LemonSqueezySyncPackages extends Command { - protected $signature = 'paddle:sync-packages + protected $signature = 'lemonsqueezy:sync-packages {--package=* : Limit sync to the given package IDs or slugs} - {--dry-run : Generate payload snapshots without calling Paddle} - {--pull : Fetch remote Paddle state instead of pushing local changes} - {--allow-unmapped : Allow sync when packages are missing Paddle product/price IDs} + {--dry-run : Generate payload snapshots without calling Lemon Squeezy} + {--pull : Fetch remote Lemon Squeezy state instead of pushing local changes} + {--allow-unmapped : Allow sync when packages are missing Lemon Squeezy product/variant IDs} {--queue : Dispatch jobs onto the queue instead of running synchronously}'; - protected $description = 'Synchronise local packages with Paddle products and prices.'; + protected $description = 'Synchronise local packages with Lemon Squeezy products and variants.'; public function handle(): int { @@ -52,7 +52,7 @@ class PaddleSyncPackages extends Command }); $this->info(sprintf( - 'Queued %d package %s for Paddle %s.', + 'Queued %d package %s for Lemon Squeezy %s.', $packages->count(), Str::plural('entry', $packages->count()), $pull ? 'pull' : 'sync' @@ -97,22 +97,22 @@ class PaddleSyncPackages extends Command protected function guardUnmappedPackages(Collection $packages): bool { - $unmapped = $packages->filter(fn (Package $package) => blank($package->paddle_product_id) || blank($package->paddle_price_id)); + $unmapped = $packages->filter(fn (Package $package) => blank($package->lemonsqueezy_product_id) || blank($package->lemonsqueezy_variant_id)); if ($unmapped->isEmpty()) { return true; } - $this->error('Unmapped Paddle package IDs detected. Resolve legacy mappings or pass --allow-unmapped.'); + $this->error('Unmapped Lemon Squeezy package IDs detected. Resolve mappings or pass --allow-unmapped.'); $this->table( ['ID', 'Slug', 'Missing'], $unmapped->map(function (Package $package): array { $missing = []; - if (blank($package->paddle_product_id)) { + if (blank($package->lemonsqueezy_product_id)) { $missing[] = 'product_id'; } - if (blank($package->paddle_price_id)) { - $missing[] = 'price_id'; + if (blank($package->lemonsqueezy_variant_id)) { + $missing[] = 'variant_id'; } return [ @@ -133,26 +133,26 @@ class PaddleSyncPackages extends Command ]; if ($queue) { - SyncPackageToPaddle::dispatch($package->id, $context); + SyncPackageToLemonSqueezy::dispatch($package->id, $context); $this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug)); return; } - SyncPackageToPaddle::dispatchSync($package->id, $context); + SyncPackageToLemonSqueezy::dispatchSync($package->id, $context); $this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug)); } protected function dispatchPullJob(Package $package, bool $queue): void { if ($queue) { - PullPackageFromPaddle::dispatch($package->id); + PullPackageFromLemonSqueezy::dispatch($package->id); $this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug)); return; } - PullPackageFromPaddle::dispatchSync($package->id); + PullPackageFromLemonSqueezy::dispatchSync($package->id); $this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug)); } } diff --git a/app/Console/Commands/PaddleRegisterWebhooks.php b/app/Console/Commands/PaddleRegisterWebhooks.php deleted file mode 100644 index d35d99f..0000000 --- a/app/Console/Commands/PaddleRegisterWebhooks.php +++ /dev/null @@ -1,132 +0,0 @@ -option('url') ?: $this->defaultWebhookUrl()); - - if ($destination === '') { - $this->error('Webhook destination URL is required. Use --url=...'); - - return self::FAILURE; - } - - $events = collect((array) $this->option('events')) - ->filter() - ->map(fn ($event) => trim((string) $event)) - ->filter() - ->values() - ->all(); - - if ($events === []) { - $events = config('paddle.webhook_events', []); - } - - if ($events === [] || ! is_array($events)) { - $this->error('No webhook events configured. Set config(paddle.webhook_events) or pass --events.'); - - return self::FAILURE; - } - - $trafficSource = (string) $this->option('traffic-source'); - $allowedSources = ['platform', 'simulation', 'all']; - - if (! in_array($trafficSource, $allowedSources, true)) { - $this->error(sprintf('Invalid traffic source. Use one of: %s', implode(', ', $allowedSources))); - - return self::FAILURE; - } - - $payload = [ - 'type' => 'url', - 'destination' => $destination, - 'description' => $this->resolveDescription(), - 'subscribed_events' => $events, - 'traffic_source' => $trafficSource, - 'include_sensitive_fields' => (bool) $this->option('include-sensitive'), - ]; - - if ((bool) $this->option('dry-run')) { - $this->line(json_encode($payload, JSON_PRETTY_PRINT)); - - return self::SUCCESS; - } - - $response = $client->post('/notification-settings', $payload); - $data = Arr::get($response, 'data', $response); - $id = Arr::get($data, 'id'); - $secret = Arr::get($data, 'endpoint_secret_key'); - - Log::channel('paddle-sync')->info('Paddle webhook registered', [ - 'notification_setting_id' => $id, - 'destination' => $destination, - 'traffic_source' => $trafficSource, - ]); - - $this->info('Paddle webhook registered.'); - - if ($id) { - $this->line('ID: '.$id); - } - - if ($secret && $this->option('show-secret')) { - $this->line('Secret: '.$secret); - } elseif ($secret) { - $this->line('Secret returned (hidden). Use --show-secret to display.'); - } - - return self::SUCCESS; - } - - protected function defaultWebhookUrl(): string - { - $base = rtrim((string) config('app.url'), '/'); - - return $base !== '' ? $base.'/paddle/webhook' : ''; - } - - protected function resolveDescription(): string - { - $description = (string) $this->option('description'); - - if ($description !== '') { - return $description; - } - - $environment = (string) config('paddle.environment', 'production'); - - return sprintf('Fotospiel Paddle webhooks (%s)', $environment); - } -} diff --git a/app/Filament/Clusters/DailyOps/Resources/TenantLemonSqueezyHealths/Pages/ListTenantLemonSqueezyHealths.php b/app/Filament/Clusters/DailyOps/Resources/TenantLemonSqueezyHealths/Pages/ListTenantLemonSqueezyHealths.php new file mode 100644 index 0000000..50be0ad --- /dev/null +++ b/app/Filament/Clusters/DailyOps/Resources/TenantLemonSqueezyHealths/Pages/ListTenantLemonSqueezyHealths.php @@ -0,0 +1,16 @@ +label(__('admin.tenants.fields.contact_email')) ->searchable() ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('paddle_customer_id') - ->label('Paddle customer') + TextColumn::make('lemonsqueezy_customer_id') + ->label('Lemon Squeezy customer') ->toggleable(isToggledHiddenByDefault: true) ->copyable() ->formatStateUsing(fn (?string $state) => $state ?: '—'), @@ -56,27 +56,27 @@ class TenantPaddleHealthTable ->badge() ->color(fn (string $state) => $state === '—' ? 'gray' : 'success') ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('paddle_subscription_id') - ->label('Paddle subscription') + TextColumn::make('lemonsqueezy_subscription_id') + ->label('Lemon Squeezy subscription') ->toggleable(isToggledHiddenByDefault: true) ->copyable() - ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->paddle_subscription_id) + ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->lemonsqueezy_subscription_id) ->formatStateUsing(fn (?string $state) => $state ?: '—'), - IconColumn::make('missing_paddle_subscription') - ->label('Missing Paddle subscription') + IconColumn::make('missing_lemonsqueezy_subscription') + ->label('Missing Lemon Squeezy subscription') ->boolean() - ->getStateUsing(fn (Tenant $record) => self::missingPaddleSubscription($record)), + ->getStateUsing(fn (Tenant $record) => self::missingLemonSqueezySubscription($record)), IconColumn::make('status_mismatch') ->label('Status mismatch') ->boolean() ->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)), - TextColumn::make('paddle_customer_duplicates') - ->label('Paddle duplicates') + TextColumn::make('lemonsqueezy_customer_duplicates') + ->label('Lemon Squeezy duplicates') ->sortable() ->toggleable(isToggledHiddenByDefault: true) ->formatStateUsing(fn (?int $state) => $state && $state > 1 ? (string) $state : '—'), - TextColumn::make('paddle_sync_status') - ->label('Paddle sync') + TextColumn::make('lemonsqueezy_sync_status') + ->label('Lemon Squeezy sync') ->badge() ->color(fn (?string $state) => match ($state) { 'synced' => 'success', @@ -87,101 +87,101 @@ class TenantPaddleHealthTable default => 'gray', }) ->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—') - ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_sync_status) + ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->lemonsqueezy_sync_status) ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('paddle_synced_at') - ->label('Paddle synced') + TextColumn::make('lemonsqueezy_synced_at') + ->label('Lemon Squeezy synced') ->badge() ->color(fn ($state) => self::syncAgeColor($state)) ->formatStateUsing(fn ($state) => $state?->diffForHumans() ?? '—') - ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_synced_at), - TextColumn::make('last_paddle_transaction_at') - ->label('Last Paddle tx') + ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->lemonsqueezy_synced_at), + TextColumn::make('last_lemonsqueezy_transaction_at') + ->label('Last Lemon Squeezy tx') ->badge() ->color(fn (?Carbon $state) => self::transactionAgeColor($state)) - ->getStateUsing(fn (Tenant $record) => $record->last_paddle_transaction_at - ? Carbon::parse($record->last_paddle_transaction_at) + ->getStateUsing(fn (Tenant $record) => $record->last_lemonsqueezy_transaction_at + ? Carbon::parse($record->last_lemonsqueezy_transaction_at) : null) ->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—') ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('paddle_transaction_count_window') - ->label('Paddle tx (30d)') + TextColumn::make('lemonsqueezy_transaction_count_window') + ->label('Lemon Squeezy tx (30d)') ->default('0') ->sortable() ->toggleable(), - TextColumn::make('paddle_transaction_total_window') - ->label('Paddle total (30d)') + TextColumn::make('lemonsqueezy_transaction_total_window') + ->label('Lemon Squeezy total (30d)') ->default(0) ->money('EUR') ->sortable() ->toggleable(), - TextColumn::make('paddle_refund_count_window') + TextColumn::make('lemonsqueezy_refund_count_window') ->label('Refunds (30d)') ->badge() ->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray') ->default('0') ->sortable() ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('paddle_refund_total_window') + TextColumn::make('lemonsqueezy_refund_total_window') ->label('Refund total (30d)') ->default(0) ->money('EUR') ->sortable() ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('paddle_checkout_requires_action_count') + TextColumn::make('lemonsqueezy_checkout_requires_action_count') ->label('Checkout action required') ->badge() ->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray') ->default('0') ->sortable() ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('paddle_checkout_processing_count') + TextColumn::make('lemonsqueezy_checkout_processing_count') ->label('Checkout processing') ->badge() ->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray') ->default('0') ->sortable() ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('paddle_checkout_expired_count') + TextColumn::make('lemonsqueezy_checkout_expired_count') ->label('Checkout expired') ->badge() ->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray') ->default('0') ->sortable() ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('paddle_transaction_count') - ->label('Paddle tx (all)') + TextColumn::make('lemonsqueezy_transaction_count') + ->label('Lemon Squeezy tx (all)') ->default('0') ->sortable() ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('paddle_transaction_total') - ->label('Paddle total (all)') + TextColumn::make('lemonsqueezy_transaction_total') + ->label('Lemon Squeezy total (all)') ->default(0) ->money('EUR') ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ - Filter::make('missing_paddle_customer') - ->label('Missing Paddle customer') - ->indicator('Missing Paddle customer') - ->query(fn (Builder $query) => $query->whereNull('paddle_customer_id')), - Filter::make('missing_paddle_subscription') - ->label('Missing Paddle subscription') - ->indicator('Missing Paddle subscription') + Filter::make('missing_lemonsqueezy_customer') + ->label('Missing Lemon Squeezy customer') + ->indicator('Missing Lemon Squeezy customer') + ->query(fn (Builder $query) => $query->whereNull('lemonsqueezy_customer_id')), + Filter::make('missing_lemonsqueezy_subscription') + ->label('Missing Lemon Squeezy subscription') + ->indicator('Missing Lemon Squeezy subscription') ->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', fn (Builder $query) => $query ->where('active', true) - ->whereNull('paddle_subscription_id'))), - Filter::make('duplicate_paddle_customer') - ->label('Duplicate Paddle customer') - ->indicator('Duplicate Paddle customer') + ->whereNull('lemonsqueezy_subscription_id'))), + Filter::make('duplicate_lemonsqueezy_customer') + ->label('Duplicate Lemon Squeezy customer') + ->indicator('Duplicate Lemon Squeezy customer') ->query(fn (Builder $query) => $query - ->whereNotNull('paddle_customer_id') - ->whereIn('paddle_customer_id', function ($subquery) { - $subquery->select('paddle_customer_id') + ->whereNotNull('lemonsqueezy_customer_id') + ->whereIn('lemonsqueezy_customer_id', function ($subquery) { + $subquery->select('lemonsqueezy_customer_id') ->from('tenants') - ->whereNotNull('paddle_customer_id') - ->groupBy('paddle_customer_id') + ->whereNotNull('lemonsqueezy_customer_id') + ->groupBy('lemonsqueezy_customer_id') ->havingRaw('count(*) > 1'); })), Filter::make('status_mismatch') @@ -205,39 +205,39 @@ class TenantPaddleHealthTable ->where('is_suspended', false) ->whereNull('pending_deletion_at') ->whereNull('anonymized_at')), - Filter::make('paddle_sync_failed') - ->label('Paddle sync failed') - ->indicator('Paddle sync failed') + Filter::make('lemonsqueezy_sync_failed') + ->label('Lemon Squeezy sync failed') + ->indicator('Lemon Squeezy sync failed') ->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query - ->whereIn('paddle_sync_status', self::FAILED_SYNC_STATUSES))), - Filter::make('paddle_sync_stale') - ->label('Paddle sync stale') - ->indicator('Paddle sync stale') + ->whereIn('lemonsqueezy_sync_status', self::FAILED_SYNC_STATUSES))), + Filter::make('lemonsqueezy_sync_stale') + ->label('Lemon Squeezy sync stale') + ->indicator('Lemon Squeezy sync stale') ->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query - ->whereNotNull('paddle_synced_at') - ->where('paddle_synced_at', '<', now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS)))), - Filter::make('paddle_sync_missing') - ->label('Missing Paddle sync timestamp') - ->indicator('Missing Paddle sync timestamp') + ->whereNotNull('lemonsqueezy_synced_at') + ->where('lemonsqueezy_synced_at', '<', now()->subDays(TenantLemonSqueezyHealthResource::STALE_SYNC_DAYS)))), + Filter::make('lemonsqueezy_sync_missing') + ->label('Missing Lemon Squeezy sync timestamp') + ->indicator('Missing Lemon Squeezy sync timestamp') ->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query - ->whereNull('paddle_synced_at'))), - Filter::make('paddle_transaction_stale') - ->label('Stale Paddle transactions') - ->indicator('Stale Paddle transactions') + ->whereNull('lemonsqueezy_synced_at'))), + Filter::make('lemonsqueezy_transaction_stale') + ->label('Stale Lemon Squeezy transactions') + ->indicator('Stale Lemon Squeezy transactions') ->query(function (Builder $query): Builder { - $cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS); + $cutoff = now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS); return $query - ->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'paddle')) + ->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'lemonsqueezy')) ->whereDoesntHave('purchases', fn (Builder $query) => $query - ->where('provider', 'paddle') + ->where('provider', 'lemonsqueezy') ->where('purchased_at', '>=', $cutoff)); }), Filter::make('checkout_attention') ->label('Checkout attention') ->indicator('Checkout attention') ->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) { - $query->where('provider', 'paddle') + $query->where('provider', 'lemonsqueezy') ->where(function (Builder $query) { $query->whereIn('status', [ CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION, @@ -274,10 +274,10 @@ class TenantPaddleHealthTable return $query; } - $cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS); + $cutoff = now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS); return $query->whereHas('purchases', fn (Builder $query) => $query - ->where('provider', 'paddle') + ->where('provider', 'lemonsqueezy') ->where('refunded', true) ->where('purchased_at', '>=', $cutoff), '>=', $min); }), @@ -314,11 +314,11 @@ class TenantPaddleHealthTable return false; } - private static function missingPaddleSubscription(Tenant $record): bool + private static function missingLemonSqueezySubscription(Tenant $record): bool { $package = $record->activeResellerPackage; - return $package && $package->active && ! $package->paddle_subscription_id; + return $package && $package->active && ! $package->lemonsqueezy_subscription_id; } private static function applyStatusMismatchFilter(Builder $query): Builder @@ -344,7 +344,7 @@ class TenantPaddleHealthTable return 'gray'; } - if ($state->lt(now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS))) { + if ($state->lt(now()->subDays(TenantLemonSqueezyHealthResource::STALE_SYNC_DAYS))) { return 'danger'; } @@ -357,7 +357,7 @@ class TenantPaddleHealthTable return 'gray'; } - if ($state->lt(now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) { + if ($state->lt(now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS))) { return 'danger'; } diff --git a/app/Filament/Clusters/DailyOps/Resources/TenantPaddleHealths/TenantPaddleHealthResource.php b/app/Filament/Clusters/DailyOps/Resources/TenantLemonSqueezyHealths/TenantLemonSqueezyHealthResource.php similarity index 55% rename from app/Filament/Clusters/DailyOps/Resources/TenantPaddleHealths/TenantPaddleHealthResource.php rename to app/Filament/Clusters/DailyOps/Resources/TenantLemonSqueezyHealths/TenantLemonSqueezyHealthResource.php index 76208b0..43288e4 100644 --- a/app/Filament/Clusters/DailyOps/Resources/TenantPaddleHealths/TenantPaddleHealthResource.php +++ b/app/Filament/Clusters/DailyOps/Resources/TenantLemonSqueezyHealths/TenantLemonSqueezyHealthResource.php @@ -1,10 +1,10 @@ with(['activeResellerPackage.package']) ->withExists('activeResellerPackage as has_active_reseller_package') ->addSelect([ - 'paddle_customer_duplicates' => Tenant::query() + 'lemonsqueezy_customer_duplicates' => Tenant::query() ->selectRaw('count(*)') - ->whereColumn('paddle_customer_id', 'tenants.paddle_customer_id') - ->whereNotNull('paddle_customer_id'), + ->whereColumn('lemonsqueezy_customer_id', 'tenants.lemonsqueezy_customer_id') + ->whereNotNull('lemonsqueezy_customer_id'), ]) ->withCount([ - 'purchases as paddle_transaction_count' => fn (Builder $query) => $query - ->where('provider', 'paddle') + 'purchases as lemonsqueezy_transaction_count' => fn (Builder $query) => $query + ->where('provider', 'lemonsqueezy') ->where('refunded', false), - 'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query - ->where('provider', 'paddle') + 'purchases as lemonsqueezy_transaction_count_window' => fn (Builder $query) => $query + ->where('provider', 'lemonsqueezy') ->where('refunded', false) ->where('purchased_at', '>=', $windowStart), - 'purchases as paddle_refund_count_window' => fn (Builder $query) => $query - ->where('provider', 'paddle') + 'purchases as lemonsqueezy_refund_count_window' => fn (Builder $query) => $query + ->where('provider', 'lemonsqueezy') ->where('refunded', true) ->where('purchased_at', '>=', $windowStart), - 'checkoutSessions as paddle_checkout_requires_action_count' => fn (Builder $query) => $query - ->where('provider', CheckoutSession::PROVIDER_PADDLE) + 'checkoutSessions as lemonsqueezy_checkout_requires_action_count' => fn (Builder $query) => $query + ->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY) ->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION), - 'checkoutSessions as paddle_checkout_processing_count' => fn (Builder $query) => $query - ->where('provider', CheckoutSession::PROVIDER_PADDLE) + 'checkoutSessions as lemonsqueezy_checkout_processing_count' => fn (Builder $query) => $query + ->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY) ->where('status', CheckoutSession::STATUS_PROCESSING), - 'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query - ->where('provider', CheckoutSession::PROVIDER_PADDLE) + 'checkoutSessions as lemonsqueezy_checkout_expired_count' => fn (Builder $query) => $query + ->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY) ->whereNotIn('status', [ CheckoutSession::STATUS_COMPLETED, CheckoutSession::STATUS_CANCELLED, @@ -90,32 +90,32 @@ class TenantPaddleHealthResource extends Resource ->where('expires_at', '<', now()), ]) ->withSum([ - 'purchases as paddle_transaction_total' => fn (Builder $query) => $query - ->where('provider', 'paddle') + 'purchases as lemonsqueezy_transaction_total' => fn (Builder $query) => $query + ->where('provider', 'lemonsqueezy') ->where('refunded', false), ], 'price') ->withSum([ - 'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query - ->where('provider', 'paddle') + 'purchases as lemonsqueezy_transaction_total_window' => fn (Builder $query) => $query + ->where('provider', 'lemonsqueezy') ->where('refunded', false) ->where('purchased_at', '>=', $windowStart), ], 'price') ->withSum([ - 'purchases as paddle_refund_total_window' => fn (Builder $query) => $query - ->where('provider', 'paddle') + 'purchases as lemonsqueezy_refund_total_window' => fn (Builder $query) => $query + ->where('provider', 'lemonsqueezy') ->where('refunded', true) ->where('purchased_at', '>=', $windowStart), ], 'price') ->withMax([ - 'purchases as last_paddle_transaction_at' => fn (Builder $query) => $query - ->where('provider', 'paddle'), + 'purchases as last_lemonsqueezy_transaction_at' => fn (Builder $query) => $query + ->where('provider', 'lemonsqueezy'), ], 'purchased_at'); } public static function getPages(): array { return [ - 'index' => ListTenantPaddleHealths::route('/'), + 'index' => ListTenantLemonSqueezyHealths::route('/'), ]; } } diff --git a/app/Filament/Clusters/DailyOps/Resources/TenantPaddleHealths/Pages/ListTenantPaddleHealths.php b/app/Filament/Clusters/DailyOps/Resources/TenantPaddleHealths/Pages/ListTenantPaddleHealths.php deleted file mode 100644 index e6f1896..0000000 --- a/app/Filament/Clusters/DailyOps/Resources/TenantPaddleHealths/Pages/ListTenantPaddleHealths.php +++ /dev/null @@ -1,16 +0,0 @@ -record); + SyncCouponToLemonSqueezy::dispatch($this->record); } } diff --git a/app/Filament/Resources/Coupons/Pages/EditCoupon.php b/app/Filament/Resources/Coupons/Pages/EditCoupon.php index 42d1af9..780a909 100644 --- a/app/Filament/Resources/Coupons/Pages/EditCoupon.php +++ b/app/Filament/Resources/Coupons/Pages/EditCoupon.php @@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages; use App\Filament\Resources\Coupons\CouponResource; use App\Filament\Resources\Pages\AuditedEditRecord; -use App\Jobs\SyncCouponToPaddle; +use App\Jobs\SyncCouponToLemonSqueezy; use App\Services\Audit\SuperAdminAuditLogger; use Filament\Actions\DeleteAction; use Filament\Actions\ForceDeleteAction; @@ -27,7 +27,7 @@ class EditCoupon extends AuditedEditRecord source: static::class ); - SyncCouponToPaddle::dispatch($record, true); + SyncCouponToLemonSqueezy::dispatch($record, true); }), ForceDeleteAction::make() ->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation( @@ -48,6 +48,6 @@ class EditCoupon extends AuditedEditRecord { parent::afterSave(); - SyncCouponToPaddle::dispatch($this->record); + SyncCouponToLemonSqueezy::dispatch($this->record); } } diff --git a/app/Filament/Resources/Coupons/RelationManagers/RedemptionsRelationManager.php b/app/Filament/Resources/Coupons/RelationManagers/RedemptionsRelationManager.php index 86eae8a..fab2078 100644 --- a/app/Filament/Resources/Coupons/RelationManagers/RedemptionsRelationManager.php +++ b/app/Filament/Resources/Coupons/RelationManagers/RedemptionsRelationManager.php @@ -21,7 +21,7 @@ class RedemptionsRelationManager extends RelationManager public function table(Table $table): Table { return $table - ->recordTitleAttribute('paddle_transaction_id') + ->recordTitleAttribute('lemonsqueezy_order_id') ->columns([ TextColumn::make('tenant.name') ->label(__('Tenant')) @@ -65,7 +65,7 @@ class RedemptionsRelationManager extends RelationManager 'failed' => 'danger', default => 'warning', }), - TextColumn::make('paddle_transaction_id') + TextColumn::make('lemonsqueezy_order_id') ->label(__('Transaction')) ->copyable() ->toggleable(isToggledHiddenByDefault: true), diff --git a/app/Filament/Resources/Coupons/Schemas/CouponForm.php b/app/Filament/Resources/Coupons/Schemas/CouponForm.php index 5ab68e2..761cd4f 100644 --- a/app/Filament/Resources/Coupons/Schemas/CouponForm.php +++ b/app/Filament/Resources/Coupons/Schemas/CouponForm.php @@ -123,22 +123,22 @@ class CouponForm ->nullable() ->columnSpanFull(), ]), - Section::make(__('Paddle sync')) + Section::make(__('Lemon Squeezy sync')) ->columns(2) ->schema([ - Select::make('paddle_mode') - ->label(__('Paddle mode')) + Select::make('lemonsqueezy_mode') + ->label(__('Lemon Squeezy mode')) ->options([ 'standard' => __('Standard'), 'custom' => __('Custom (one-off)'), ]) ->default('standard'), - Placeholder::make('paddle_discount_id') - ->label(__('Paddle Discount ID')) - ->content(fn ($record) => $record?->paddle_discount_id ?? '—'), - Placeholder::make('paddle_last_synced_at') + Placeholder::make('lemonsqueezy_discount_id') + ->label(__('Lemon Squeezy Discount ID')) + ->content(fn ($record) => $record?->lemonsqueezy_discount_id ?? '—'), + Placeholder::make('lemonsqueezy_last_synced_at') ->label(__('Last synced')) - ->content(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'), + ->content(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'), Placeholder::make('redemptions_count') ->label(__('Total redemptions')) ->content(fn ($record) => number_format($record?->redemptions_count ?? 0)), diff --git a/app/Filament/Resources/Coupons/Schemas/CouponInfolist.php b/app/Filament/Resources/Coupons/Schemas/CouponInfolist.php index 40ef5f6..1fc1c78 100644 --- a/app/Filament/Resources/Coupons/Schemas/CouponInfolist.php +++ b/app/Filament/Resources/Coupons/Schemas/CouponInfolist.php @@ -63,17 +63,17 @@ class CouponInfolist TextEntry::make('description')->label(__('Description'))->columnSpanFull(), KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(), ]), - Section::make(__('Paddle')) + Section::make(__('Lemon Squeezy')) ->columns(3) ->schema([ - TextEntry::make('paddle_discount_id') + TextEntry::make('lemonsqueezy_discount_id') ->label(__('Discount ID')) ->copyable() ->placeholder('—'), - TextEntry::make('paddle_last_synced_at') + TextEntry::make('lemonsqueezy_last_synced_at') ->label(__('Last synced')) - ->state(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'), - TextEntry::make('paddle_mode') + ->state(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'), + TextEntry::make('lemonsqueezy_mode') ->label(__('Mode')) ->badge() ->placeholder('standard'), diff --git a/app/Filament/Resources/Coupons/Tables/CouponsTable.php b/app/Filament/Resources/Coupons/Tables/CouponsTable.php index 99725cb..fca92c7 100644 --- a/app/Filament/Resources/Coupons/Tables/CouponsTable.php +++ b/app/Filament/Resources/Coupons/Tables/CouponsTable.php @@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Tables; use App\Enums\CouponStatus; use App\Enums\CouponType; -use App\Jobs\SyncCouponToPaddle; +use App\Jobs\SyncCouponToLemonSqueezy; use App\Services\Audit\SuperAdminAuditLogger; use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; @@ -105,9 +105,9 @@ class CouponsTable static::class )), Action::make('sync') - ->label(__('Sync to Paddle')) + ->label(__('Sync to Lemon Squeezy')) ->icon('heroicon-m-arrow-path') - ->action(fn ($record) => SyncCouponToPaddle::dispatch($record)) + ->action(fn ($record) => SyncCouponToLemonSqueezy::dispatch($record)) ->requiresConfirmation(), ]) ->toolbarActions([ diff --git a/app/Filament/Resources/GiftVoucherResource.php b/app/Filament/Resources/GiftVoucherResource.php index 12c5067..99ae461 100644 --- a/app/Filament/Resources/GiftVoucherResource.php +++ b/app/Filament/Resources/GiftVoucherResource.php @@ -63,8 +63,8 @@ class GiftVoucherResource extends Resource ->label('Empfänger') ->toggleable() ->searchable(), - TextColumn::make('paddle_transaction_id') - ->label('Paddle Tx') + TextColumn::make('lemonsqueezy_order_id') + ->label('Lemon Squeezy Order') ->toggleable() ->copyable() ->wrap(), diff --git a/app/Filament/Resources/GiftVoucherResource/Pages/ListGiftVouchers.php b/app/Filament/Resources/GiftVoucherResource/Pages/ListGiftVouchers.php index 95467e5..d650f65 100644 --- a/app/Filament/Resources/GiftVoucherResource/Pages/ListGiftVouchers.php +++ b/app/Filament/Resources/GiftVoucherResource/Pages/ListGiftVouchers.php @@ -46,24 +46,27 @@ class ListGiftVouchers extends ListRecords ]) ->action(function (array $data, GiftVoucherService $service): void { $payload = [ - 'id' => null, - 'metadata' => [ - 'type' => 'gift_voucher', - 'purchaser_email' => $data['purchaser_email'], - 'recipient_email' => $data['recipient_email'] ?? null, - 'recipient_name' => $data['recipient_name'] ?? null, - 'message' => $data['message'] ?? null, - 'gift_code' => $data['code'] ?? null, + 'meta' => [ + 'custom_data' => [ + 'type' => 'gift_voucher', + 'purchaser_email' => $data['purchaser_email'], + 'recipient_email' => $data['recipient_email'] ?? null, + 'recipient_name' => $data['recipient_name'] ?? null, + 'message' => $data['message'] ?? null, + 'gift_code' => $data['code'] ?? null, + ], ], - 'currency_code' => $data['currency'] ?? 'EUR', - 'totals' => [ - 'grand_total' => [ - 'amount' => (float) $data['amount'], + 'data' => [ + 'id' => 'manual_'.Str::uuid(), + 'attributes' => [ + 'currency' => $data['currency'] ?? 'EUR', + 'total' => (float) $data['amount'] * 100, + 'user_email' => $data['purchaser_email'], ], ], ]; - $voucher = $service->issueFromPaddle($payload); + $voucher = $service->issueFromLemonSqueezy($payload); app(SuperAdminAuditLogger::class)->recordModelMutation( 'issued', diff --git a/app/Filament/Resources/PackageAddonResource.php b/app/Filament/Resources/PackageAddonResource.php index c4b5953..8637208 100644 --- a/app/Filament/Resources/PackageAddonResource.php +++ b/app/Filament/Resources/PackageAddonResource.php @@ -4,7 +4,7 @@ namespace App\Filament\Resources; use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster; use App\Filament\Resources\PackageAddonResource\Pages; -use App\Jobs\SyncPackageAddonToPaddle; +use App\Jobs\SyncPackageAddonToLemonSqueezy; use App\Models\PackageAddon; use App\Services\Audit\SuperAdminAuditLogger; use Filament\Actions; @@ -50,9 +50,9 @@ class PackageAddonResource extends Resource ->required() ->unique(ignoreRecord: true) ->maxLength(191), - TextInput::make('price_id') - ->label('Paddle Preis-ID') - ->helperText('Paddle Billing Preis-ID für dieses Add-on') + TextInput::make('variant_id') + ->label('Lemon Squeezy Variant-ID') + ->helperText('Variant-ID aus Lemon Squeezy für dieses Add-on') ->maxLength(191), TextInput::make('sort') ->label('Sortierung') @@ -96,8 +96,8 @@ class PackageAddonResource extends Resource ->label('Schlüssel') ->copyable() ->sortable(), - TextColumn::make('price_id') - ->label('Paddle Preis-ID') + TextColumn::make('variant_id') + ->label('Lemon Squeezy Variant-ID') ->toggleable() ->copyable(), TextColumn::make('extra_photos')->label('Fotos +'), @@ -120,16 +120,16 @@ class PackageAddonResource extends Resource ->label('Aktiv'), ]) ->actions([ - Actions\Action::make('syncPaddle') - ->label('Mit Paddle synchronisieren') + Actions\Action::make('syncLemonSqueezy') + ->label('Mit Lemon Squeezy synchronisieren') ->icon('heroicon-o-cloud-arrow-up') ->action(function (PackageAddon $record) { - SyncPackageAddonToPaddle::dispatch($record->id); + SyncPackageAddonToLemonSqueezy::dispatch($record->id); Notification::make() ->success() - ->title('Paddle-Sync gestartet') - ->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.') + ->title('Lemon Squeezy-Sync gestartet') + ->body('Das Add-on wird im Hintergrund mit Lemon Squeezy abgeglichen.') ->send(); }), Actions\EditAction::make() diff --git a/app/Filament/Resources/PackageResource.php b/app/Filament/Resources/PackageResource.php index 61d9ba1..4d49859 100644 --- a/app/Filament/Resources/PackageResource.php +++ b/app/Filament/Resources/PackageResource.php @@ -4,8 +4,8 @@ namespace App\Filament\Resources; use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster; use App\Filament\Resources\PackageResource\Pages; -use App\Jobs\PullPackageFromPaddle; -use App\Jobs\SyncPackageToPaddle; +use App\Jobs\PullPackageFromLemonSqueezy; +use App\Jobs\SyncPackageToLemonSqueezy; use App\Models\Package; use App\Services\Audit\SuperAdminAuditLogger; use BackedEnum; @@ -172,31 +172,31 @@ class PackageResource extends Resource ->columnSpanFull() ->default([]), ]), - Section::make('Paddle Billing') + Section::make('Lemon Squeezy Billing') ->columns(2) ->schema([ - TextInput::make('paddle_product_id') - ->label('Paddle Produkt-ID') + TextInput::make('lemonsqueezy_product_id') + ->label('Lemon Squeezy Produkt-ID') ->maxLength(191) - ->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.') + ->helperText('Produkt aus Lemon Squeezy. Leer lassen, wenn noch nicht synchronisiert.') ->placeholder('nicht verknüpft'), - TextInput::make('paddle_price_id') - ->label('Paddle Preis-ID') + TextInput::make('lemonsqueezy_variant_id') + ->label('Lemon Squeezy Variant-ID') ->maxLength(191) - ->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.') + ->helperText('Variant-ID aus Lemon Squeezy, verknüpft mit diesem Paket.') ->placeholder('nicht verknüpft'), - Placeholder::make('paddle_sync_status') + Placeholder::make('lemonsqueezy_sync_status') ->label('Sync-Status') - ->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '–') + ->content(fn (?Package $record) => $record?->lemonsqueezy_sync_status ? Str::headline($record->lemonsqueezy_sync_status) : '–') ->columnSpanFull(), - Placeholder::make('paddle_synced_at') + Placeholder::make('lemonsqueezy_synced_at') ->label('Zuletzt synchronisiert') - ->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '–') + ->content(fn (?Package $record) => $record?->lemonsqueezy_synced_at ? $record->lemonsqueezy_synced_at->diffForHumans() : '–') ->columnSpanFull(), - Placeholder::make('paddle_sync_error') + Placeholder::make('lemonsqueezy_sync_error') ->label('Letzter Fehler') - ->content(fn (?Package $record) => $record?->paddle_sync_error_message ?? '–') - ->visible(fn (?Package $record) => filled($record?->paddle_sync_error_message)) + ->content(fn (?Package $record) => $record?->lemonsqueezy_sync_error_message ?? '–') + ->visible(fn (?Package $record) => filled($record?->lemonsqueezy_sync_error_message)) ->columnSpanFull(), ]), ]); @@ -263,15 +263,15 @@ class PackageResource extends Resource ->label('Features') ->wrap() ->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)), - TextColumn::make('paddle_product_id') - ->label('Paddle Produkt') + TextColumn::make('lemonsqueezy_product_id') + ->label('Lemon Squeezy Produkt') ->toggleable(isToggledHiddenByDefault: true) ->formatStateUsing(fn ($state) => $state ?: '-'), - TextColumn::make('paddle_price_id') - ->label('Paddle Preis') + TextColumn::make('lemonsqueezy_variant_id') + ->label('Lemon Squeezy Variant') ->toggleable(isToggledHiddenByDefault: true) ->formatStateUsing(fn ($state) => $state ?: '-'), - BadgeColumn::make('paddle_sync_status') + BadgeColumn::make('lemonsqueezy_sync_status') ->label('Sync-Status') ->colors([ 'success' => 'synced', @@ -281,13 +281,13 @@ class PackageResource extends Resource ]) ->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null) ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('paddle_synced_at') + TextColumn::make('lemonsqueezy_synced_at') ->label('Sync am') ->dateTime() ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('paddle_sync_error_message') + TextColumn::make('lemonsqueezy_sync_error_message') ->label('Sync-Fehler') - ->getStateUsing(fn (Package $record) => $record->paddle_sync_error_message) + ->getStateUsing(fn (Package $record) => $record->lemonsqueezy_sync_error_message) ->wrap() ->toggleable(isToggledHiddenByDefault: true), ]) @@ -301,43 +301,43 @@ class PackageResource extends Resource TrashedFilter::make(), ]) ->actions([ - Actions\Action::make('syncPaddle') - ->label('Mit Paddle abgleichen') + Actions\Action::make('syncLemonSqueezy') + ->label('Mit Lemon Squeezy abgleichen') ->icon('heroicon-o-cloud-arrow-up') ->color('success') ->requiresConfirmation() - ->disabled(fn (Package $record) => $record->paddle_sync_status === 'syncing') + ->disabled(fn (Package $record) => $record->lemonsqueezy_sync_status === 'syncing') ->action(function (Package $record) { - SyncPackageToPaddle::dispatch($record->id); + SyncPackageToLemonSqueezy::dispatch($record->id); Notification::make() ->success() - ->title('Paddle-Sync gestartet') - ->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.') + ->title('Lemon Squeezy-Sync gestartet') + ->body('Das Paket wird im Hintergrund mit Lemon Squeezy abgeglichen.') ->send(); }), - Actions\Action::make('linkPaddle') - ->label('Paddle verknüpfen') + Actions\Action::make('linkLemonSqueezy') + ->label('Lemon Squeezy verknüpfen') ->icon('heroicon-o-link') ->color('info') ->form([ - TextInput::make('paddle_product_id') - ->label('Paddle Produkt-ID') + TextInput::make('lemonsqueezy_product_id') + ->label('Lemon Squeezy Produkt-ID') ->required() ->maxLength(191), - TextInput::make('paddle_price_id') - ->label('Paddle Preis-ID') + TextInput::make('lemonsqueezy_variant_id') + ->label('Lemon Squeezy Variant-ID') ->required() ->maxLength(191), ]) ->fillForm(fn (Package $record) => [ - 'paddle_product_id' => $record->paddle_product_id, - 'paddle_price_id' => $record->paddle_price_id, + 'lemonsqueezy_product_id' => $record->lemonsqueezy_product_id, + 'lemonsqueezy_variant_id' => $record->lemonsqueezy_variant_id, ]) ->action(function (Package $record, array $data): void { - $record->linkPaddleIds($data['paddle_product_id'], $data['paddle_price_id']); + $record->linkLemonSqueezyIds($data['lemonsqueezy_product_id'], $data['lemonsqueezy_variant_id']); - PullPackageFromPaddle::dispatch($record->id); + PullPackageFromLemonSqueezy::dispatch($record->id); app(SuperAdminAuditLogger::class)->recordModelMutation( 'linked', @@ -348,22 +348,22 @@ class PackageResource extends Resource Notification::make() ->success() - ->title('Paddle-Verknüpfung gespeichert') + ->title('Lemon Squeezy-Verknüpfung gespeichert') ->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.') ->send(); }), - Actions\Action::make('pullPaddle') - ->label('Status von Paddle holen') + Actions\Action::make('pullLemonSqueezy') + ->label('Status von Lemon Squeezy holen') ->icon('heroicon-o-cloud-arrow-down') - ->disabled(fn (Package $record) => ! $record->paddle_product_id && ! $record->paddle_price_id) + ->disabled(fn (Package $record) => ! $record->lemonsqueezy_product_id && ! $record->lemonsqueezy_variant_id) ->requiresConfirmation() ->action(function (Package $record) { - PullPackageFromPaddle::dispatch($record->id); + PullPackageFromLemonSqueezy::dispatch($record->id); Notification::make() ->info() - ->title('Paddle-Abgleich angefordert') - ->body('Der aktuelle Stand aus Paddle wird geladen und hier hinterlegt.') + ->title('Lemon Squeezy-Abgleich angefordert') + ->body('Der aktuelle Stand aus Lemon Squeezy wird geladen und hier hinterlegt.') ->send(); }), ViewAction::make(), diff --git a/app/Filament/Resources/PurchaseResource.php b/app/Filament/Resources/PurchaseResource.php index 4422410..6b1ffd0 100644 --- a/app/Filament/Resources/PurchaseResource.php +++ b/app/Filament/Resources/PurchaseResource.php @@ -8,7 +8,7 @@ use App\Models\PackagePurchase; use App\Notifications\Customer\RefundReceipt; use App\Notifications\Ops\RefundProcessed; use App\Services\Audit\SuperAdminAuditLogger; -use App\Services\Paddle\PaddleTransactionService; +use App\Services\LemonSqueezy\LemonSqueezyOrderService; use BackedEnum; use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; @@ -203,15 +203,15 @@ class PurchaseResource extends Resource $refundSuccess = true; $errorMessage = null; - if ($record->provider === 'paddle' && $record->provider_id) { + if ($record->provider === 'lemonsqueezy' && $record->provider_id) { try { - /** @var PaddleTransactionService $paddle */ - $paddle = App::make(PaddleTransactionService::class); - $paddle->refund($record->provider_id, ['reason' => $reason]); + /** @var LemonSqueezyOrderService $lemonsqueezy */ + $lemonsqueezy = App::make(LemonSqueezyOrderService::class); + $lemonsqueezy->refund($record->provider_id, ['reason' => $reason]); } catch (\Throwable $exception) { $refundSuccess = false; $errorMessage = $exception->getMessage(); - Log::warning('Paddle refund failed', [ + Log::warning('Lemon Squeezy refund failed', [ 'purchase_id' => $record->id, 'provider_id' => $record->provider_id, 'error' => $exception->getMessage(), diff --git a/app/Filament/Resources/PurchaseResource/Pages/ViewPurchase.php b/app/Filament/Resources/PurchaseResource/Pages/ViewPurchase.php index c6b2aa8..5109a16 100644 --- a/app/Filament/Resources/PurchaseResource/Pages/ViewPurchase.php +++ b/app/Filament/Resources/PurchaseResource/Pages/ViewPurchase.php @@ -35,7 +35,7 @@ class ViewPurchase extends ViewRecord ->visible(fn ($record): bool => ! $record->refunded) ->action(function ($record) { $record->update(['refunded' => true]); - // TODO: Call Paddle API for actual refund + // TODO: Call Lemon Squeezy API for actual refund app(SuperAdminAuditLogger::class)->record( 'purchase.refunded', diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index cf33188..33ebfe4 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -73,10 +73,10 @@ class TenantResource extends Resource ->email() ->required() ->maxLength(255), - TextInput::make('paddle_customer_id') - ->label('Paddle Customer ID') + TextInput::make('lemonsqueezy_customer_id') + ->label('Lemon Squeezy Customer ID') ->maxLength(191) - ->helperText('Verknuepfung mit Paddle Billing Kundenkonto.') + ->helperText('Verknüpfung mit Lemon Squeezy Kundenkonto.') ->nullable(), TextInput::make('total_revenue') ->label(__('admin.tenants.fields.total_revenue')) @@ -135,8 +135,8 @@ class TenantResource extends Resource ->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'), Tables\Columns\TextColumn::make('slug')->searchable(), Tables\Columns\TextColumn::make('contact_email'), - Tables\Columns\TextColumn::make('paddle_customer_id') - ->label('Paddle Customer') + Tables\Columns\TextColumn::make('lemonsqueezy_customer_id') + ->label('Lemon Squeezy Customer') ->toggleable(isToggledHiddenByDefault: true) ->formatStateUsing(fn ($state) => $state ?: '-'), Tables\Columns\TextColumn::make('active_reseller_package_id') diff --git a/app/Filament/Resources/TenantResource/RelationManagers/PackagePurchasesRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/PackagePurchasesRelationManager.php index 9ca7e70..478c8ab 100644 --- a/app/Filament/Resources/TenantResource/RelationManagers/PackagePurchasesRelationManager.php +++ b/app/Filament/Resources/TenantResource/RelationManagers/PackagePurchasesRelationManager.php @@ -44,7 +44,7 @@ class PackagePurchasesRelationManager extends RelationManager Select::make('provider') ->label('Anbieter') ->options([ - 'paddle' => 'Paddle', + 'lemonsqueezy' => 'Lemon Squeezy', 'manual' => 'Manuell', 'free' => 'Kostenlos', ]) @@ -89,7 +89,7 @@ class PackagePurchasesRelationManager extends RelationManager TextColumn::make('provider') ->badge() ->color(fn (string $state): string => match ($state) { - 'paddle' => 'success', + 'lemonsqueezy' => 'success', 'manual' => 'gray', 'free' => 'success', default => 'gray', @@ -116,7 +116,7 @@ class PackagePurchasesRelationManager extends RelationManager ]), SelectFilter::make('provider') ->options([ - 'paddle' => 'Paddle', + 'lemonsqueezy' => 'Lemon Squeezy', 'manual' => 'Manuell', 'free' => 'Kostenlos', ]), diff --git a/app/Filament/Resources/TenantResource/RelationManagers/TenantPackagesRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/TenantPackagesRelationManager.php index c02508d..32cb5fa 100644 --- a/app/Filament/Resources/TenantResource/RelationManagers/TenantPackagesRelationManager.php +++ b/app/Filament/Resources/TenantResource/RelationManagers/TenantPackagesRelationManager.php @@ -40,10 +40,10 @@ class TenantPackagesRelationManager extends RelationManager DateTimePicker::make('expires_at') ->label('Ablaufdatum') ->required(), - TextInput::make('paddle_subscription_id') - ->label('Paddle Subscription ID') + TextInput::make('lemonsqueezy_subscription_id') + ->label('Lemon Squeezy Subscription ID') ->maxLength(191) - ->helperText('Abonnement-ID aus Paddle Billing.') + ->helperText('Abonnement-ID aus Lemon Squeezy.') ->nullable(), Toggle::make('active') ->label('Aktiv'), @@ -75,8 +75,8 @@ class TenantPackagesRelationManager extends RelationManager TextColumn::make('expires_at') ->dateTime() ->sortable(), - TextColumn::make('paddle_subscription_id') - ->label('Paddle Subscription') + TextColumn::make('lemonsqueezy_subscription_id') + ->label('Lemon Squeezy Subscription') ->toggleable(isToggledHiddenByDefault: true) ->formatStateUsing(fn ($state) => $state ?: '-'), IconColumn::make('active') diff --git a/app/Filament/Resources/TenantResource/Schemas/TenantInfolist.php b/app/Filament/Resources/TenantResource/Schemas/TenantInfolist.php index 9e7cfbf..d5b2074 100644 --- a/app/Filament/Resources/TenantResource/Schemas/TenantInfolist.php +++ b/app/Filament/Resources/TenantResource/Schemas/TenantInfolist.php @@ -22,8 +22,8 @@ class TenantInfolist TextEntry::make('user.full_name') ->label(__('admin.tenants.fields.owner')) ->state(fn (Tenant $record) => $record->user?->full_name ?? '—'), - TextEntry::make('paddle_customer_id') - ->label('Paddle Customer ID') + TextEntry::make('lemonsqueezy_customer_id') + ->label('Lemon Squeezy Customer ID') ->placeholder('—'), TextEntry::make('total_revenue') ->label(__('admin.tenants.fields.total_revenue')) diff --git a/app/Http/Controllers/Api/Marketing/CouponPreviewController.php b/app/Http/Controllers/Api/Marketing/CouponPreviewController.php index 255f4ba..5bf1881 100644 --- a/app/Http/Controllers/Api/Marketing/CouponPreviewController.php +++ b/app/Http/Controllers/Api/Marketing/CouponPreviewController.php @@ -24,7 +24,7 @@ class CouponPreviewController extends Controller $package = Package::findOrFail($data['package_id']); - if (! $package->paddle_price_id) { + if (! $package->lemonsqueezy_variant_id) { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.package_not_configured'), ]); diff --git a/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php b/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php index bdf7f0e..c530ddd 100644 --- a/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php +++ b/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php @@ -36,7 +36,7 @@ class GiftVoucherCheckoutController extends Controller if (! $checkout['checkout_url']) { throw ValidationException::withMessages([ - 'tier_key' => __('Unable to create Paddle checkout.'), + 'tier_key' => __('Unable to create Lemon Squeezy checkout.'), ]); } @@ -46,19 +46,19 @@ class GiftVoucherCheckoutController extends Controller public function show(Request $request): JsonResponse { $data = $request->validate([ - 'checkout_id' => ['nullable', 'string', 'required_without_all:transaction_id,code'], - 'transaction_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'], - 'code' => ['nullable', 'string', 'required_without_all:checkout_id,transaction_id'], + 'checkout_id' => ['nullable', 'string', 'required_without_all:order_id,code'], + 'order_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'], + 'code' => ['nullable', 'string', 'required_without_all:checkout_id,order_id'], ]); $voucherQuery = GiftVoucher::query(); if (! empty($data['checkout_id'])) { - $voucherQuery->where('paddle_checkout_id', $data['checkout_id']); + $voucherQuery->where('lemonsqueezy_checkout_id', $data['checkout_id']); } - if (! empty($data['transaction_id'])) { - $voucherQuery->orWhere('paddle_transaction_id', $data['transaction_id']); + if (! empty($data['order_id'])) { + $voucherQuery->orWhere('lemonsqueezy_order_id', $data['order_id']); } if (! empty($data['code'])) { diff --git a/app/Http/Controllers/Api/PackageController.php b/app/Http/Controllers/Api/PackageController.php index 66dc41d..5bb0903 100644 --- a/app/Http/Controllers/Api/PackageController.php +++ b/app/Http/Controllers/Api/PackageController.php @@ -9,7 +9,7 @@ use App\Models\Package; use App\Models\PackagePurchase; use App\Models\TenantPackage; use App\Services\Checkout\CheckoutSessionService; -use App\Services\Paddle\PaddleCheckoutService; +use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -18,7 +18,7 @@ use Illuminate\Validation\ValidationException; class PackageController extends Controller { public function __construct( - private readonly PaddleCheckoutService $paddleCheckout, + private readonly LemonSqueezyCheckoutService $lemonsqueezyCheckout, private readonly CheckoutSessionService $sessions, ) {} @@ -53,7 +53,7 @@ class PackageController extends Controller $request->validate([ 'package_id' => 'required|exists:packages,id', 'type' => 'required|in:endcustomer,reseller', - 'payment_method' => 'required|in:paddle', + 'payment_method' => 'required|in:lemonsqueezy', 'event_id' => 'nullable|exists:events,id', // For endcustomer 'success_url' => 'nullable|url', 'return_url' => 'nullable|url', @@ -79,7 +79,7 @@ class PackageController extends Controller { $request->validate([ 'package_id' => 'required|exists:packages,id', - 'paddle_transaction_id' => 'required|string', + 'lemonsqueezy_order_id' => 'required|string', ]); $package = Package::findOrFail($request->package_id); @@ -89,14 +89,14 @@ class PackageController extends Controller throw ValidationException::withMessages(['tenant' => 'Tenant not found.']); } - $provider = 'paddle'; + $provider = 'lemonsqueezy'; DB::transaction(function () use ($request, $package, $tenant, $provider) { PackagePurchase::create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, 'provider' => $provider, - 'provider_id' => $request->input('paddle_transaction_id'), + 'provider_id' => $request->input('lemonsqueezy_order_id'), 'price' => $package->price, 'type' => 'endcustomer_event', 'purchased_at' => now(), @@ -161,7 +161,7 @@ class PackageController extends Controller ], 201); } - public function createPaddleCheckout(Request $request): JsonResponse + public function createLemonSqueezyCheckout(Request $request): JsonResponse { $request->validate([ 'package_id' => 'required|exists:packages,id', @@ -181,15 +181,15 @@ class PackageController extends Controller throw ValidationException::withMessages(['user' => 'User context missing.']); } - if (! $package->paddle_price_id) { - throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); + if (! $package->lemonsqueezy_variant_id) { + throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']); } $session = $this->sessions->createOrResume($user, $package, [ 'tenant' => $tenant, ]); - $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); + $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY); $now = now(); @@ -211,14 +211,14 @@ class PackageController extends Controller ], ]; - $checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload); + $checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, $payload); $session->forceFill([ - 'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id, + 'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id, 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ - 'paddle_checkout_id' => $checkout['id'] ?? null, - 'paddle_checkout_url' => $checkout['checkout_url'] ?? null, - 'paddle_expires_at' => $checkout['expires_at'] ?? null, + 'lemonsqueezy_checkout_id' => $checkout['id'] ?? null, + 'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null, + 'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null, ])), ])->save(); @@ -239,7 +239,7 @@ class PackageController extends Controller } } - $checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url'); + $checkoutUrl = data_get($session->provider_metadata ?? [], 'lemonsqueezy_checkout_url'); return response()->json([ 'status' => $session->status, @@ -297,11 +297,11 @@ class PackageController extends Controller private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse { - if (! $package->paddle_price_id) { - throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); + if (! $package->lemonsqueezy_variant_id) { + throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']); } - $checkout = $this->paddleCheckout->createCheckout($tenant, $package, [ + $checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, [ 'success_url' => $request->input('success_url'), 'return_url' => $request->input('return_url'), 'metadata' => array_filter([ diff --git a/app/Http/Controllers/Api/Tenant/EventAddonCatalogController.php b/app/Http/Controllers/Api/Tenant/EventAddonCatalogController.php index f61b0fa..47e86a7 100644 --- a/app/Http/Controllers/Api/Tenant/EventAddonCatalogController.php +++ b/app/Http/Controllers/Api/Tenant/EventAddonCatalogController.php @@ -13,7 +13,7 @@ class EventAddonCatalogController extends Controller public function index(): JsonResponse { $addons = collect($this->catalog->all()) - ->filter(fn (array $addon) => ! empty($addon['price_id'])) + ->filter(fn (array $addon) => ! empty($addon['variant_id'])) ->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key])) ->values() ->all(); diff --git a/app/Http/Controllers/Api/TenantBillingController.php b/app/Http/Controllers/Api/TenantBillingController.php index 77ee6d6..9ebe6f8 100644 --- a/app/Http/Controllers/Api/TenantBillingController.php +++ b/app/Http/Controllers/Api/TenantBillingController.php @@ -4,10 +4,9 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\EventPackageAddon; -use App\Services\Paddle\Exceptions\PaddleException; -use App\Services\Paddle\PaddleCustomerPortalService; -use App\Services\Paddle\PaddleCustomerService; -use App\Services\Paddle\PaddleTransactionService; +use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException; +use App\Services\LemonSqueezy\LemonSqueezyOrderService; +use App\Services\LemonSqueezy\LemonSqueezySubscriptionService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; @@ -16,9 +15,8 @@ use Illuminate\Support\Facades\Log; class TenantBillingController extends Controller { public function __construct( - private readonly PaddleTransactionService $paddleTransactions, - private readonly PaddleCustomerService $paddleCustomers, - private readonly PaddleCustomerPortalService $portalSessions, + private readonly LemonSqueezyOrderService $orders, + private readonly LemonSqueezySubscriptionService $subscriptions, ) {} public function transactions(Request $request): JsonResponse @@ -32,20 +30,15 @@ class TenantBillingController extends Controller ], 404); } - if (! $tenant->paddle_customer_id) { - try { - $this->paddleCustomers->ensureCustomerId($tenant); - } catch (\Throwable $exception) { - Log::warning('Failed to resolve Paddle customer for tenant', [ - 'tenant_id' => $tenant->id, - 'error' => $exception->getMessage(), - ]); - - return response()->json([ - 'data' => [], - 'message' => 'Failed to resolve Paddle customer.', - ], 502); - } + if (! $tenant->lemonsqueezy_customer_id) { + return response()->json([ + 'data' => [], + 'meta' => [ + 'next' => null, + 'previous' => null, + 'has_more' => false, + ], + ]); } $cursor = $request->query('cursor'); @@ -60,16 +53,16 @@ class TenantBillingController extends Controller } try { - $result = $this->paddleTransactions->listForCustomer($tenant->paddle_customer_id, $query); + $result = $this->orders->listForCustomer($tenant->lemonsqueezy_customer_id, $query); } catch (\Throwable $exception) { - Log::warning('Failed to load Paddle transactions', [ + Log::warning('Failed to load Lemon Squeezy transactions', [ 'tenant_id' => $tenant->id, 'error' => $exception->getMessage(), ]); return response()->json([ 'data' => [], - 'message' => 'Failed to load Paddle transactions.', + 'message' => 'Failed to load Lemon Squeezy transactions.', ], 502); } @@ -143,68 +136,64 @@ class TenantBillingController extends Controller ], 404); } - $customerId = null; + $subscriptionId = null; try { - $customerId = $this->paddleCustomers->ensureCustomerId($tenant); + $subscriptionId = $tenant->getActiveResellerPackage()?->lemonsqueezy_subscription_id; + if (! $subscriptionId) { + return response()->json([ + 'message' => 'No active subscription found.', + ], 404); + } - Log::debug('Creating Paddle customer portal session', [ + Log::debug('Fetching Lemon Squeezy subscription portal URL', [ 'tenant_id' => $tenant->id, - 'paddle_customer_id' => $customerId, - 'paddle_environment' => config('paddle.environment'), - 'paddle_base_url' => config('paddle.base_url'), + 'lemonsqueezy_subscription_id' => $subscriptionId, ]); - $session = $this->portalSessions->createSession($customerId); + $subscription = $this->subscriptions->retrieve($subscriptionId); } catch (\Throwable $exception) { $context = [ 'tenant_id' => $tenant->id, - 'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id, + 'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id, + 'lemonsqueezy_subscription_id' => $subscriptionId ?? null, 'error' => $exception->getMessage(), - 'paddle_environment' => config('paddle.environment'), - 'paddle_base_url' => config('paddle.base_url'), ]; - if ($exception instanceof PaddleException) { - $context['paddle_status'] = $exception->status(); - $context['paddle_error_code'] = Arr::get($exception->context(), 'error.code'); - $context['paddle_error_message'] = Arr::get($exception->context(), 'error.message'); - $context['paddle_error_detail'] = Arr::get($exception->context(), 'error.detail'); - $context['paddle_error_doc_url'] = Arr::get($exception->context(), 'error.documentation_url'); - $context['paddle_request_id'] = Arr::get($exception->context(), 'meta.request_id'); - $context['paddle_errors'] = Arr::get($exception->context(), 'error.errors'); + if ($exception instanceof LemonSqueezyException) { + $context['lemonsqueezy_status'] = $exception->status(); + $context['lemonsqueezy_error'] = Arr::get($exception->context(), 'errors.0'); + $context['lemonsqueezy_errors'] = Arr::get($exception->context(), 'errors'); + $context['lemonsqueezy_request_id'] = Arr::get($exception->context(), 'meta.request_id'); } - Log::warning('Failed to create Paddle customer portal session', [ + Log::warning('Failed to fetch Lemon Squeezy subscription portal URL', [ ...$context, ]); return response()->json([ - 'message' => 'Failed to create Paddle customer portal session.', + 'message' => 'Failed to fetch Lemon Squeezy subscription portal URL.', ], 502); } - $url = Arr::get($session, 'data.urls.general.overview') - ?? Arr::get($session, 'data.urls.general') - ?? Arr::get($session, 'urls.general.overview') - ?? Arr::get($session, 'urls.general'); + $url = $this->subscriptions->portalUrl($subscription) + ?? $this->subscriptions->updatePaymentMethodUrl($subscription); if (! $url) { - $sessionData = Arr::get($session, 'data'); - $sessionUrls = Arr::get($session, 'data.urls') ?? Arr::get($session, 'urls'); + $sessionData = Arr::get($subscription, 'data'); + $sessionUrls = Arr::get($subscription, 'attributes.urls'); - Log::warning('Paddle customer portal session missing URL', [ + Log::warning('Lemon Squeezy subscription missing portal URL', [ 'tenant_id' => $tenant->id, - 'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id, - 'paddle_environment' => config('paddle.environment'), - 'paddle_base_url' => config('paddle.base_url'), - 'session_keys' => array_keys($session), + 'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id, + 'lemonsqueezy_subscription_id' => $subscriptionId ?? null, + 'subscription_keys' => array_keys($subscription), 'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null, 'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null, ]); return response()->json([ - 'message' => 'Paddle customer portal session missing URL.', + 'message' => 'Lemon Squeezy subscription missing portal URL.', ], 502); } diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index b6a1086..c221bc6 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -15,8 +15,8 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Checkout\CheckoutAssignmentService; use App\Services\Checkout\CheckoutSessionService; -use App\Services\Paddle\Exceptions\PaddleException; -use App\Services\Paddle\PaddleTransactionService; +use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException; +use App\Services\LemonSqueezy\LemonSqueezyOrderService; use App\Support\CheckoutRequestContext; use App\Support\CheckoutRoutes; use App\Support\Concerns\PresentsPackages; @@ -74,9 +74,9 @@ class CheckoutController extends Controller 'error' => $facebookError, 'profile' => $facebookProfile, ], - 'paddle' => [ - 'environment' => config('paddle.environment'), - 'client_token' => config('paddle.client_token'), + 'lemonsqueezy' => [ + 'store_id' => config('lemonsqueezy.store_id'), + 'test_mode' => config('lemonsqueezy.test_mode', false), ], ]); } @@ -271,9 +271,9 @@ class CheckoutController extends Controller CheckoutSession $session, CheckoutSessionService $sessions, CheckoutAssignmentService $assignment, - PaddleTransactionService $transactions, + LemonSqueezyOrderService $orders, ): JsonResponse { - $this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions); + $this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders); $session->refresh(); @@ -288,56 +288,56 @@ class CheckoutController extends Controller CheckoutSession $session, CheckoutSessionService $sessions, CheckoutAssignmentService $assignment, - PaddleTransactionService $transactions, + LemonSqueezyOrderService $orders, ): JsonResponse { $validated = $request->validated(); - $transactionId = $validated['transaction_id'] ?? null; + $orderId = $validated['order_id'] ?? null; $checkoutId = $validated['checkout_id'] ?? null; $metadata = $session->provider_metadata ?? []; $metadataUpdated = false; - if ($transactionId) { - $session->paddle_transaction_id = $transactionId; - $metadata['paddle_transaction_id'] = $transactionId; + if ($orderId) { + $session->lemonsqueezy_order_id = $orderId; + $metadata['lemonsqueezy_order_id'] = $orderId; $metadataUpdated = true; } if ($checkoutId) { - $metadata['paddle_checkout_id'] = $checkoutId; + $metadata['lemonsqueezy_checkout_id'] = $checkoutId; $metadataUpdated = true; } if ($metadataUpdated) { - $metadata['paddle_client_event_at'] = now()->toIso8601String(); + $metadata['lemonsqueezy_client_event_at'] = now()->toIso8601String(); $session->provider_metadata = $metadata; $session->save(); } if (app()->environment('local') - && $session->provider === CheckoutSession::PROVIDER_PADDLE + && $session->provider === CheckoutSession::PROVIDER_LEMONSQUEEZY && ! in_array($session->status, [ CheckoutSession::STATUS_COMPLETED, CheckoutSession::STATUS_FAILED, CheckoutSession::STATUS_CANCELLED, ], true) - && ($transactionId || $checkoutId) + && ($orderId || $checkoutId) ) { $sessions->markProcessing($session, array_filter([ - 'paddle_status' => 'completed', - 'paddle_transaction_id' => $transactionId, - 'paddle_local_confirmed_at' => now()->toIso8601String(), + 'lemonsqueezy_status' => 'paid', + 'lemonsqueezy_order_id' => $orderId, + 'lemonsqueezy_local_confirmed_at' => now()->toIso8601String(), ])); $assignment->finalise($session, [ - 'source' => 'paddle_local', - 'provider' => CheckoutSession::PROVIDER_PADDLE, - 'provider_reference' => $transactionId ?? $checkoutId, + 'source' => 'lemonsqueezy_local', + 'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY, + 'provider_reference' => $orderId ?? $checkoutId, ]); $sessions->markCompleted($session); } else { - $this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions); + $this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders); } $session->refresh(); @@ -419,13 +419,13 @@ class CheckoutController extends Controller return $price <= 0; } - private function attemptPaddleRecovery( + private function attemptLemonSqueezyRecovery( CheckoutSession $session, CheckoutSessionService $sessions, CheckoutAssignmentService $assignment, - PaddleTransactionService $transactions + LemonSqueezyOrderService $orders ): void { - if ($session->provider !== CheckoutSession::PROVIDER_PADDLE) { + if ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) { return; } @@ -438,7 +438,7 @@ class CheckoutController extends Controller } $metadata = $session->provider_metadata ?? []; - $lastPollAt = $metadata['paddle_poll_at'] ?? null; + $lastPollAt = $metadata['lemonsqueezy_poll_at'] ?? null; $now = now(); if ($lastPollAt) { @@ -452,39 +452,31 @@ class CheckoutController extends Controller } } - $checkoutId = $metadata['paddle_checkout_id'] ?? $session->paddle_checkout_id ?? null; - $transactionId = $metadata['paddle_transaction_id'] ?? $session->paddle_transaction_id ?? null; + $checkoutId = $metadata['lemonsqueezy_checkout_id'] ?? $session->lemonsqueezy_checkout_id ?? null; + $orderId = $metadata['lemonsqueezy_order_id'] ?? $session->lemonsqueezy_order_id ?? null; - if (! $checkoutId && ! $transactionId) { - Log::info('[Checkout] Paddle recovery missing checkout reference, falling back to custom data scan', [ + if (! $checkoutId && ! $orderId) { + Log::info('[Checkout] Lemon Squeezy recovery missing checkout reference', [ 'session_id' => $session->id, ]); } - $metadata['paddle_poll_at'] = $now->toIso8601String(); + $metadata['lemonsqueezy_poll_at'] = $now->toIso8601String(); $session->forceFill([ 'provider_metadata' => $metadata, ])->save(); try { - $transaction = $transactionId ? $transactions->retrieve($transactionId) : null; + $order = $orderId ? $orders->retrieve($orderId) : null; - if (! $transaction && $checkoutId) { - $transaction = $transactions->findByCheckoutId($checkoutId); + if (! $order && $checkoutId) { + $order = $orders->findByCheckoutId($checkoutId); } - - if (! $transaction) { - $transaction = $transactions->findByCustomData([ - 'checkout_session_id' => $session->id, - 'package_id' => (string) $session->package_id, - 'tenant_id' => (string) $session->tenant_id, - ]); - } - } catch (PaddleException $exception) { - Log::warning('[Checkout] Paddle recovery failed', [ + } catch (LemonSqueezyException $exception) { + Log::warning('[Checkout] Lemon Squeezy recovery failed', [ 'session_id' => $session->id, 'checkout_id' => $checkoutId, - 'transaction_id' => $transactionId, + 'order_id' => $orderId, 'status' => $exception->status(), 'message' => $exception->getMessage(), 'context' => $exception->context(), @@ -492,77 +484,77 @@ class CheckoutController extends Controller return; } catch (\Throwable $exception) { - Log::warning('[Checkout] Paddle recovery failed', [ + Log::warning('[Checkout] Lemon Squeezy recovery failed', [ 'session_id' => $session->id, 'checkout_id' => $checkoutId, - 'transaction_id' => $transactionId, + 'order_id' => $orderId, 'message' => $exception->getMessage(), ]); return; } - if (! $transaction) { - Log::info('[Checkout] Paddle recovery: transaction not found', [ + if (! $order) { + Log::info('[Checkout] Lemon Squeezy recovery: order not found', [ 'session_id' => $session->id, 'checkout_id' => $checkoutId, - 'transaction_id' => $transactionId, + 'order_id' => $orderId, ]); return; } - $status = strtolower((string) ($transaction['status'] ?? '')); - $transactionId = $transactionId ?: ($transaction['id'] ?? null); + $status = strtolower((string) data_get($order, 'attributes.status', '')); + $resolvedOrderId = $orderId ?: data_get($order, 'id'); - if ($transactionId && $session->paddle_transaction_id !== $transactionId) { + if ($resolvedOrderId && $session->lemonsqueezy_order_id !== $resolvedOrderId) { $session->forceFill([ - 'paddle_transaction_id' => $transactionId, + 'lemonsqueezy_order_id' => $resolvedOrderId, ])->save(); } - if ($status === 'completed') { + if (in_array($status, ['paid', 'completed'], true)) { $sessions->markProcessing($session, [ - 'paddle_status' => $status, - 'paddle_transaction_id' => $transactionId, - 'paddle_recovered_at' => $now->toIso8601String(), + 'lemonsqueezy_status' => $status, + 'lemonsqueezy_order_id' => $resolvedOrderId, + 'lemonsqueezy_recovered_at' => $now->toIso8601String(), ]); $assignment->finalise($session, [ - 'source' => 'paddle_poll', - 'provider' => CheckoutSession::PROVIDER_PADDLE, - 'provider_reference' => $transactionId, - 'payload' => $transaction, + 'source' => 'lemonsqueezy_poll', + 'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY, + 'provider_reference' => $resolvedOrderId, + 'payload' => $order, ]); $sessions->markCompleted($session, $now); - Log::info('[Checkout] Paddle session recovered via API', [ + Log::info('[Checkout] Lemon Squeezy session recovered via API', [ 'session_id' => $session->id, 'checkout_id' => $checkoutId, - 'transaction_id' => $transactionId, + 'order_id' => $resolvedOrderId, ]); return; } - if (in_array($status, ['failed', 'cancelled', 'canceled'], true)) { - $sessions->markFailed($session, 'paddle_'.$status); + if (in_array($status, ['failed', 'cancelled', 'canceled', 'refunded', 'voided'], true)) { + $sessions->markFailed($session, 'lemonsqueezy_'.$status); - Log::info('[Checkout] Paddle transaction failed', [ + Log::info('[Checkout] Lemon Squeezy order failed', [ 'session_id' => $session->id, 'checkout_id' => $checkoutId, - 'transaction_id' => $transactionId, + 'order_id' => $resolvedOrderId, 'status' => $status, ]); return; } - Log::info('[Checkout] Paddle transaction pending', [ + Log::info('[Checkout] Lemon Squeezy order pending', [ 'session_id' => $session->id, 'checkout_id' => $checkoutId, - 'transaction_id' => $transactionId, + 'order_id' => $resolvedOrderId, 'status' => $status, ]); } diff --git a/app/Http/Controllers/PaddleCheckoutController.php b/app/Http/Controllers/LemonSqueezyCheckoutController.php similarity index 57% rename from app/Http/Controllers/PaddleCheckoutController.php rename to app/Http/Controllers/LemonSqueezyCheckoutController.php index 66466e2..2393f42 100644 --- a/app/Http/Controllers/PaddleCheckoutController.php +++ b/app/Http/Controllers/LemonSqueezyCheckoutController.php @@ -2,27 +2,27 @@ namespace App\Http\Controllers; -use App\Http\Requests\Paddle\PaddleCheckoutRequest; +use App\Http\Requests\LemonSqueezy\LemonSqueezyCheckoutRequest; use App\Models\CheckoutSession; use App\Models\Package; use App\Services\Checkout\CheckoutSessionService; use App\Services\Coupons\CouponService; -use App\Services\Paddle\PaddleCheckoutService; +use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; use App\Support\CheckoutRequestContext; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; -class PaddleCheckoutController extends Controller +class LemonSqueezyCheckoutController extends Controller { public function __construct( - private readonly PaddleCheckoutService $checkout, + private readonly LemonSqueezyCheckoutService $checkout, private readonly CheckoutSessionService $sessions, private readonly CouponService $coupons, ) {} - public function create(PaddleCheckoutRequest $request): JsonResponse + public function create(LemonSqueezyCheckoutRequest $request): JsonResponse { $data = $request->validated(); @@ -35,8 +35,8 @@ class PaddleCheckoutController extends Controller $package = Package::findOrFail((int) $data['package_id']); - if (! $package->paddle_price_id) { - throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); + if (! $package->lemonsqueezy_variant_id) { + throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']); } $session = $this->sessions->createOrResume($user, $package, array_merge( @@ -46,7 +46,7 @@ class PaddleCheckoutController extends Controller ] )); - $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); + $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY); $now = now(); @@ -59,44 +59,10 @@ class PaddleCheckoutController extends Controller ])->save(); $couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? ''))); - $discountId = null; if ($couponCode !== '') { $preview = $this->coupons->preview($couponCode, $package, $tenant); $this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']); - $discountId = $preview['coupon']->paddle_discount_id; - } - - if ($request->boolean('inline') && $discountId === null) { - $metadata = array_merge($session->provider_metadata ?? [], [ - 'mode' => 'inline', - ]); - - $session->forceFill([ - 'provider_metadata' => $metadata, - ])->save(); - - return response()->json([ - 'checkout_session_id' => $session->id, - 'mode' => 'inline', - 'items' => [ - [ - 'priceId' => $package->paddle_price_id, - 'quantity' => 1, - ], - ], - 'custom_data' => [ - 'tenant_id' => (string) $tenant->id, - 'package_id' => (string) $package->id, - 'checkout_session_id' => (string) $session->id, - 'legal_version' => $session->legal_version, - 'accepted_terms' => '1', - ], - 'customer' => array_filter([ - 'email' => $user->email, - 'name' => trim(($user->first_name ?? '').' '.($user->last_name ?? '')) ?: ($user->name ?? null), - ]), - ]); } $checkout = $this->checkout->createCheckout($tenant, $package, [ @@ -108,15 +74,17 @@ class PaddleCheckoutController extends Controller 'legal_version' => $session->legal_version, 'accepted_terms' => true, ], - 'discount_id' => $discountId, + 'discount_code' => $couponCode ?: null, + 'customer_email' => $user?->email, + 'customer_name' => trim(($user?->first_name ?? '').' '.($user?->last_name ?? '')) ?: ($user?->name ?? null), ]); $session->forceFill([ - 'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id, + 'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id, 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ - 'paddle_checkout_id' => $checkout['id'] ?? null, - 'paddle_checkout_url' => $checkout['checkout_url'] ?? null, - 'paddle_expires_at' => $checkout['expires_at'] ?? null, + 'lemonsqueezy_checkout_id' => $checkout['id'] ?? null, + 'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null, + 'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null, ])), ])->save(); diff --git a/app/Http/Controllers/PaddleReturnController.php b/app/Http/Controllers/LemonSqueezyReturnController.php similarity index 50% rename from app/Http/Controllers/PaddleReturnController.php rename to app/Http/Controllers/LemonSqueezyReturnController.php index 9089091..3fc8bb3 100644 --- a/app/Http/Controllers/PaddleReturnController.php +++ b/app/Http/Controllers/LemonSqueezyReturnController.php @@ -2,35 +2,32 @@ namespace App\Http\Controllers; -use App\Services\Paddle\Exceptions\PaddleException; -use App\Services\Paddle\PaddleTransactionService; +use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException; +use App\Services\LemonSqueezy\LemonSqueezyOrderService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; -class PaddleReturnController extends Controller +class LemonSqueezyReturnController extends Controller { - public function __construct(private readonly PaddleTransactionService $transactions) {} + public function __construct(private readonly LemonSqueezyOrderService $orders) {} - /** - * Handle the incoming request. - */ public function __invoke(Request $request): RedirectResponse { - $transactionId = $this->resolveTransactionId($request); + $orderId = $this->resolveOrderId($request); $fallback = $this->resolveFallbackUrl(); - if (! $transactionId) { + if (! $orderId) { return redirect()->to($fallback); } try { - $transaction = $this->transactions->retrieve($transactionId); - } catch (PaddleException $exception) { - Log::warning('Paddle return failed to load transaction', [ - 'transaction_id' => $transactionId, + $order = $this->orders->retrieve($orderId); + } catch (LemonSqueezyException $exception) { + Log::warning('Lemon Squeezy return failed to load order', [ + 'order_id' => $orderId, 'error' => $exception->getMessage(), 'status' => $exception->status(), ]); @@ -38,10 +35,10 @@ class PaddleReturnController extends Controller return redirect()->to($fallback); } - $customData = $this->extractCustomData($transaction); - $status = Str::lower((string) ($transaction['status'] ?? '')); + $customData = $this->extractCustomData($order); + $status = Str::lower((string) Arr::get($order, 'attributes.status', '')); $successUrl = $customData['success_url'] ?? null; - $cancelUrl = $customData['cancel_url'] ?? $customData['return_url'] ?? null; + $cancelUrl = $customData['return_url'] ?? null; $target = $this->isSuccessStatus($status) ? $successUrl : $cancelUrl; $target = $this->resolveSafeRedirect($target, $fallback); @@ -49,11 +46,10 @@ class PaddleReturnController extends Controller return redirect()->to($target); } - protected function resolveTransactionId(Request $request): ?string + protected function resolveOrderId(Request $request): ?string { - $candidate = $request->query('_ptxn') - ?? $request->query('ptxn') - ?? $request->query('transaction_id'); + $candidate = $request->query('order_id') + ?? $request->query('order'); if (! is_string($candidate) || $candidate === '') { return null; @@ -68,33 +64,19 @@ class PaddleReturnController extends Controller } /** - * @param array $transaction + * @param array $order * @return array */ - protected function extractCustomData(array $transaction): array + protected function extractCustomData(array $order): array { - $customData = Arr::get($transaction, 'custom_data', []); + $customData = Arr::get($order, 'attributes.custom_data', []); - if (! is_array($customData)) { - $customData = []; - } - - $legacy = Arr::get($transaction, 'customData'); - if (is_array($legacy)) { - $customData = array_merge($customData, $legacy); - } - - $metadata = Arr::get($transaction, 'metadata'); - if (is_array($metadata)) { - $customData = array_merge($customData, $metadata); - } - - return $customData; + return is_array($customData) ? $customData : []; } protected function isSuccessStatus(string $status): bool { - return in_array($status, ['completed', 'paid'], true); + return in_array($status, ['paid', 'completed'], true); } protected function resolveSafeRedirect(?string $target, string $fallback): string diff --git a/app/Http/Controllers/PaddleWebhookController.php b/app/Http/Controllers/LemonSqueezyWebhookController.php similarity index 51% rename from app/Http/Controllers/PaddleWebhookController.php rename to app/Http/Controllers/LemonSqueezyWebhookController.php index aa2fad6..3dde5d4 100644 --- a/app/Http/Controllers/PaddleWebhookController.php +++ b/app/Http/Controllers/LemonSqueezyWebhookController.php @@ -10,7 +10,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Symfony\Component\HttpFoundation\Response; -class PaddleWebhookController extends Controller +class LemonSqueezyWebhookController extends Controller { public function __construct( private readonly CheckoutWebhookService $webhooks, @@ -22,7 +22,7 @@ class PaddleWebhookController extends Controller { try { if (! $this->verify($request)) { - Log::warning('Paddle webhook signature verification failed'); + Log::warning('Lemon Squeezy webhook signature verification failed'); return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST); } @@ -33,29 +33,27 @@ class PaddleWebhookController extends Controller return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED); } - $eventType = $payload['event_type'] ?? null; - $eventId = $payload['event_id'] ?? $payload['id'] ?? data_get($payload, 'data.id'); + $eventType = $payload['meta']['event_name'] ?? $request->headers->get('X-Event-Name'); + $eventId = $payload['meta']['event_id'] ?? $payload['data']['id'] ?? null; $webhookEvent = $this->recorder->recordReceived( - 'paddle', + 'lemonsqueezy', $eventId ? (string) $eventId : null, $eventType ? (string) $eventType : null, ); $handled = false; - $this->logDev('Paddle webhook received', [ + $this->logDev('Lemon Squeezy webhook received', [ 'event_type' => $eventType, - 'checkout_id' => data_get($payload, 'data.checkout_id'), - 'transaction_id' => data_get($payload, 'data.id'), - 'has_billing_signature' => (string) $request->headers->get('Paddle-Signature', '') !== '', - 'has_legacy_signature' => (string) $request->headers->get('Paddle-Webhook-Signature', '') !== '', + 'order_id' => data_get($payload, 'data.id'), + 'has_signature' => (string) $request->headers->get('X-Signature', '') !== '', ]); if ($eventType) { - $handled = $this->webhooks->handlePaddleEvent($payload); + $handled = $this->webhooks->handleLemonSqueezyEvent($payload); $handled = $this->addonWebhooks->handle($payload) || $handled; } - Log::info('Paddle webhook processed', [ + Log::info('Lemon Squeezy webhook processed', [ 'event_type' => $eventType, 'handled' => $handled, ]); @@ -71,13 +69,13 @@ class PaddleWebhookController extends Controller } catch (\Throwable $exception) { $eventId = $this->captureWebhookException($exception); - Log::error('Paddle webhook processing failed', [ + Log::error('Lemon Squeezy webhook processing failed', [ 'message' => $exception->getMessage(), - 'event_type' => (string) $request->json('event_type'), + 'event_type' => (string) data_get($request->json()->all(), 'meta.event_name'), 'sentry_event_id' => $eventId, ]); - $this->logDev('Paddle webhook error payload', $this->reducePayload($request->json()->all())); + $this->logDev('Lemon Squeezy webhook error payload', $this->reducePayload($request->json()->all())); if (isset($webhookEvent)) { $this->recorder->markFailed($webhookEvent, $exception->getMessage()); @@ -89,85 +87,33 @@ class PaddleWebhookController extends Controller protected function verify(Request $request): bool { - $secret = config('paddle.webhook_secret'); + $secret = config('lemonsqueezy.webhook_secret'); if (! $secret) { - // Allow processing in sandbox or when secret not configured return true; } - $billingSignature = (string) $request->headers->get('Paddle-Signature', ''); - - if ($billingSignature !== '') { - $parts = $this->parseSignatureHeader($billingSignature); - $timestamp = $parts['ts'] ?? null; - $hash = $parts['h1'] ?? null; - - if (! $timestamp || ! $hash) { - $this->logDev('Paddle webhook signature missing parts', [ - 'has_timestamp' => (bool) $timestamp, - 'has_hash' => (bool) $hash, - ]); - - return false; - } - - $payload = $request->getContent(); - $expected = hash_hmac('sha256', $timestamp.':'.$payload, $secret); - - $valid = hash_equals($expected, $hash); - if (! $valid) { - $this->logDev('Paddle webhook signature mismatch (billing)', [ - 'timestamp' => $timestamp, - ]); - } - - return $valid; - } - - $payload = $request->getContent(); - $signature = (string) $request->headers->get('Paddle-Webhook-Signature', ''); + $signature = (string) $request->headers->get('X-Signature', ''); if ($signature === '') { - $this->logDev('Paddle webhook missing signature header', [ - 'header' => 'Paddle-Webhook-Signature', + $this->logDev('Lemon Squeezy webhook missing signature header', [ + 'header' => 'X-Signature', ]); return false; } + $payload = $request->getContent(); $expected = hash_hmac('sha256', $payload, $secret); $valid = hash_equals($expected, $signature); if (! $valid) { - $this->logDev('Paddle webhook signature mismatch (legacy)', []); + $this->logDev('Lemon Squeezy webhook signature mismatch', []); } return $valid; } - /** - * @return array - */ - protected function parseSignatureHeader(string $header): array - { - $parts = []; - - foreach (explode(',', $header) as $chunk) { - $chunk = trim($chunk); - if ($chunk === '' || ! str_contains($chunk, '=')) { - continue; - } - - [$key, $value] = array_map('trim', explode('=', $chunk, 2)); - if ($key !== '' && $value !== '') { - $parts[$key] = $value; - } - } - - return $parts; - } - /** * @param array $context */ @@ -177,7 +123,7 @@ class PaddleWebhookController extends Controller return; } - Log::info('[PaddleWebhook] '.$message, $context); + Log::info('[LemonSqueezyWebhook] '.$message, $context); } /** @@ -186,12 +132,11 @@ class PaddleWebhookController extends Controller protected function reducePayload(array $payload): array { return array_filter([ - 'event_type' => $payload['event_type'] ?? null, - 'transaction_id' => data_get($payload, 'data.id'), - 'checkout_id' => data_get($payload, 'data.checkout_id'), - 'status' => data_get($payload, 'data.status'), - 'customer_id' => data_get($payload, 'data.customer_id'), - 'has_custom_data' => is_array(data_get($payload, 'data.custom_data')), + 'event_type' => data_get($payload, 'meta.event_name'), + 'order_id' => data_get($payload, 'data.id'), + 'status' => data_get($payload, 'data.attributes.status'), + 'customer_id' => data_get($payload, 'data.attributes.customer_id'), + 'has_custom_data' => is_array(data_get($payload, 'meta.custom_data')), ], static fn ($value) => $value !== null); } diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index e735b05..7a82ab5 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -13,7 +13,7 @@ use App\Models\TenantPackage; use App\Services\Checkout\CheckoutSessionService; use App\Services\Coupons\CouponService; use App\Services\GiftVouchers\GiftVoucherCheckoutService; -use App\Services\Paddle\PaddleCheckoutService; +use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; use App\Support\CheckoutRequestContext; use App\Support\CheckoutRoutes; use App\Support\Concerns\PresentsPackages; @@ -41,7 +41,7 @@ class MarketingController extends Controller public function __construct( private readonly CheckoutSessionService $checkoutSessions, - private readonly PaddleCheckoutService $paddleCheckout, + private readonly LemonSqueezyCheckoutService $lemonsqueezyCheckout, private readonly CouponService $coupons, private readonly GiftVoucherCheckoutService $giftVouchers, ) {} @@ -194,14 +194,14 @@ class MarketingController extends Controller return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned')); } - if (! $package->paddle_price_id) { - Log::warning('Package missing Paddle price id', ['package_id' => $package->id]); + if (! $package->lemonsqueezy_variant_id) { + Log::warning('Package missing Lemon Squeezy variant id', ['package_id' => $package->id]); return redirect()->route('packages', [ 'locale' => app()->getLocale(), 'highlight' => $package->slug, ]) - ->with('error', __('marketing.packages.paddle_not_configured')); + ->with('error', __('marketing.packages.lemonsqueezy_not_configured')); } $session = $this->checkoutSessions->createOrResume($user, $package, array_merge( @@ -211,7 +211,7 @@ class MarketingController extends Controller ] )); - $this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); + $this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY); $now = now(); @@ -223,20 +223,17 @@ class MarketingController extends Controller 'legal_version' => $this->resolveLegalVersion(), ])->save(); - $appliedDiscountId = null; - if ($couponCode) { try { $preview = $this->coupons->preview($couponCode, $package, $tenant); $this->checkoutSessions->applyCoupon($session, $preview['coupon'], $preview['pricing']); - $appliedDiscountId = $preview['coupon']->paddle_discount_id; $request->session()->forget('marketing.checkout.coupon'); } catch (ValidationException $exception) { $request->session()->flash('coupon_error', $exception->errors()['code'][0] ?? __('marketing.coupon.errors.generic')); } } - $checkout = $this->paddleCheckout->createCheckout($tenant, $package, [ + $checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, [ 'success_url' => route('marketing.success', [ 'locale' => app()->getLocale(), 'packageId' => $package->id, @@ -252,15 +249,15 @@ class MarketingController extends Controller 'accepted_terms' => (bool) $session->accepted_terms_at, 'accepted_waiver' => $requiresWaiver && (bool) $session->digital_content_waiver_at, ], - 'discount_id' => $appliedDiscountId, + 'discount_code' => $couponCode, ]); $session->forceFill([ - 'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id, + 'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id, 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ - 'paddle_checkout_id' => $checkout['id'] ?? null, - 'paddle_checkout_url' => $checkout['checkout_url'] ?? null, - 'paddle_expires_at' => $checkout['expires_at'] ?? null, + 'lemonsqueezy_checkout_id' => $checkout['id'] ?? null, + 'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null, + 'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null, ])), ])->save(); @@ -268,7 +265,7 @@ class MarketingController extends Controller if (! $redirectUrl) { throw ValidationException::withMessages([ - 'paddle' => __('marketing.packages.paddle_checkout_failed'), + 'lemonsqueezy' => __('marketing.packages.lemonsqueezy_checkout_failed'), ]); } diff --git a/app/Http/Controllers/Testing/TestCheckoutController.php b/app/Http/Controllers/Testing/TestCheckoutController.php index 114e762..077fed0 100644 --- a/app/Http/Controllers/Testing/TestCheckoutController.php +++ b/app/Http/Controllers/Testing/TestCheckoutController.php @@ -61,7 +61,7 @@ class TestCheckoutController extends Controller ]); } - public function simulatePaddle( + public function simulateLemonSqueezy( Request $request, CheckoutWebhookService $webhooks, CheckoutSession $session @@ -70,13 +70,13 @@ class TestCheckoutController extends Controller $validated = $request->validate([ 'event_type' => ['nullable', 'string'], - 'transaction_id' => ['nullable', 'string'], + 'order_id' => ['nullable', 'string'], 'status' => ['nullable', 'string'], 'checkout_id' => ['nullable', 'string'], 'metadata' => ['nullable', 'array'], ]); - $eventType = $validated['event_type'] ?? 'transaction.completed'; + $eventType = $validated['event_type'] ?? 'order_created'; $metadata = array_merge([ 'tenant_id' => $session->tenant_id, 'package_id' => $session->package_id, @@ -84,16 +84,21 @@ class TestCheckoutController extends Controller ], $validated['metadata'] ?? []); $payload = [ - 'event_type' => $eventType, - 'data' => array_filter([ - 'id' => $validated['transaction_id'] ?? ('txn_'.Str::uuid()), - 'status' => $validated['status'] ?? 'completed', + 'meta' => [ + 'event_name' => $eventType, 'custom_data' => $metadata, - 'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['paddle_checkout_id'] ?? 'chk_'.Str::uuid(), + ], + 'data' => array_filter([ + 'id' => $validated['order_id'] ?? ('order_'.Str::uuid()), + 'attributes' => array_filter([ + 'status' => $validated['status'] ?? 'paid', + 'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['lemonsqueezy_checkout_id'] ?? 'chk_'.Str::uuid(), + 'custom_data' => $metadata, + ]), ]), ]; - $handled = $webhooks->handlePaddleEvent($payload); + $handled = $webhooks->handleLemonSqueezyEvent($payload); return response()->json([ 'data' => [ diff --git a/app/Http/Controllers/WithdrawalController.php b/app/Http/Controllers/WithdrawalController.php index 44c9b04..889d6e9 100644 --- a/app/Http/Controllers/WithdrawalController.php +++ b/app/Http/Controllers/WithdrawalController.php @@ -7,7 +7,7 @@ use App\Models\EventPackage; use App\Models\PackagePurchase; use App\Models\Tenant; use App\Notifications\Customer\WithdrawalConfirmed; -use App\Services\Paddle\PaddleTransactionService; +use App\Services\LemonSqueezy\LemonSqueezyOrderService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -36,7 +36,7 @@ class WithdrawalController extends Controller public function confirm( WithdrawalConfirmRequest $request, - PaddleTransactionService $transactions, + LemonSqueezyOrderService $orders, string $locale ): RedirectResponse { $user = $request->user(); @@ -60,10 +60,10 @@ class WithdrawalController extends Controller ->with('error', __('marketing.withdrawal.errors.not_eligible', [], $locale)); } - $transactionId = $this->resolveTransactionId($purchase); + $orderId = $this->resolveOrderId($purchase); - if (! $transactionId) { - Log::warning('Withdrawal missing Paddle transaction reference.', [ + if (! $orderId) { + Log::warning('Withdrawal missing Lemon Squeezy order reference.', [ 'purchase_id' => $purchase->id, 'provider' => $purchase->provider, ]); @@ -74,11 +74,11 @@ class WithdrawalController extends Controller } try { - $transactions->refund($transactionId, ['reason' => 'withdrawal']); + $orders->refund($orderId, ['reason' => 'withdrawal']); } catch (\Throwable $exception) { Log::warning('Withdrawal refund failed', [ 'purchase_id' => $purchase->id, - 'transaction_id' => $transactionId, + 'order_id' => $orderId, 'error' => $exception->getMessage(), ]); @@ -94,13 +94,13 @@ class WithdrawalController extends Controller $withdrawalMeta = array_merge($withdrawalMeta, [ 'confirmed_at' => $confirmedAt->toIso8601String(), 'confirmed_by' => $user?->id, - 'transaction_id' => $transactionId, + 'order_id' => $orderId, ]); $metadata['withdrawal'] = $withdrawalMeta; $purchase->forceFill([ - 'provider_id' => $transactionId, + 'provider_id' => $orderId, 'refunded' => true, 'metadata' => $metadata, ])->save(); @@ -127,7 +127,7 @@ class WithdrawalController extends Controller ->with('package') ->where('tenant_id', $tenant->id) ->where('type', 'endcustomer_event') - ->where('provider', 'paddle') + ->where('provider', 'lemonsqueezy') ->where('refunded', false) ->orderByDesc('purchased_at') ->orderByDesc('id') @@ -151,7 +151,7 @@ class WithdrawalController extends Controller $reasons[] = 'type'; } - if ($purchase->provider !== 'paddle') { + if ($purchase->provider !== 'lemonsqueezy') { $reasons[] = 'provider'; } @@ -159,7 +159,7 @@ class WithdrawalController extends Controller $reasons[] = 'refunded'; } - if (! $this->resolveTransactionId($purchase)) { + if (! $this->resolveOrderId($purchase)) { $reasons[] = 'missing_reference'; } @@ -224,13 +224,13 @@ class WithdrawalController extends Controller ]; } - private function resolveTransactionId(PackagePurchase $purchase): ?string + private function resolveOrderId(PackagePurchase $purchase): ?string { - if ($purchase->provider === 'paddle' && $purchase->provider_id) { + if ($purchase->provider === 'lemonsqueezy' && $purchase->provider_id) { return (string) $purchase->provider_id; } - return data_get($purchase->metadata, 'paddle_transaction_id'); + return data_get($purchase->metadata, 'lemonsqueezy_order_id'); } private function deactivateTenantPackage(Tenant $tenant, PackagePurchase $purchase): void diff --git a/app/Http/Middleware/ContentSecurityPolicy.php b/app/Http/Middleware/ContentSecurityPolicy.php index 4b61f49..bb02b11 100644 --- a/app/Http/Middleware/ContentSecurityPolicy.php +++ b/app/Http/Middleware/ContentSecurityPolicy.php @@ -37,7 +37,7 @@ class ContentSecurityPolicy $scriptSources = [ "'self'", "'nonce-{$scriptNonce}'", - 'https://cdn.paddle.com', + 'https://app.lemonsqueezy.com', 'https://global.localizecdn.com', ]; @@ -49,21 +49,16 @@ class ContentSecurityPolicy $connectSources = [ "'self'", - 'https://api.paddle.com', - 'https://sandbox-api.paddle.com', - 'https://checkout.paddle.com', - 'https://sandbox-checkout.paddle.com', - 'https://checkout-service.paddle.com', - 'https://sandbox-checkout-service.paddle.com', + 'https://api.lemonsqueezy.com', + 'https://app.lemonsqueezy.com', + 'https://fotospiel.lemonsqueezy.com', 'https://global.localizecdn.com', ]; $frameSources = [ "'self'", - 'https://checkout.paddle.com', - 'https://sandbox-checkout.paddle.com', - 'https://checkout-service.paddle.com', - 'https://sandbox-checkout-service.paddle.com', + 'https://app.lemonsqueezy.com', + 'https://fotospiel.lemonsqueezy.com', ]; $imgSources = [ diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 8098394..8e5f1ff 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -14,6 +14,6 @@ class VerifyCsrfToken extends Middleware protected $except = [ 'api/v1/photos/*/like', 'api/v1/events/*/upload', - 'paddle/webhook*', + 'lemonsqueezy/webhook*', ]; } diff --git a/app/Http/Requests/Checkout/CheckoutSessionConfirmRequest.php b/app/Http/Requests/Checkout/CheckoutSessionConfirmRequest.php index b875a2a..b52f45e 100644 --- a/app/Http/Requests/Checkout/CheckoutSessionConfirmRequest.php +++ b/app/Http/Requests/Checkout/CheckoutSessionConfirmRequest.php @@ -35,16 +35,16 @@ class CheckoutSessionConfirmRequest extends FormRequest public function rules(): array { return [ - 'transaction_id' => ['nullable', 'string', 'required_without:checkout_id'], - 'checkout_id' => ['nullable', 'string', 'required_without:transaction_id'], + 'order_id' => ['nullable', 'string', 'required_without:checkout_id'], + 'checkout_id' => ['nullable', 'string', 'required_without:order_id'], ]; } public function messages(): array { return [ - 'transaction_id.required_without' => 'Transaction ID oder Checkout ID fehlt.', - 'checkout_id.required_without' => 'Checkout ID oder Transaction ID fehlt.', + 'order_id.required_without' => 'Order ID oder Checkout ID fehlt.', + 'checkout_id.required_without' => 'Checkout ID oder Order ID fehlt.', ]; } } diff --git a/app/Http/Requests/Paddle/PaddleCheckoutRequest.php b/app/Http/Requests/LemonSqueezy/LemonSqueezyCheckoutRequest.php similarity index 82% rename from app/Http/Requests/Paddle/PaddleCheckoutRequest.php rename to app/Http/Requests/LemonSqueezy/LemonSqueezyCheckoutRequest.php index be8ed88..891294d 100644 --- a/app/Http/Requests/Paddle/PaddleCheckoutRequest.php +++ b/app/Http/Requests/LemonSqueezy/LemonSqueezyCheckoutRequest.php @@ -1,17 +1,17 @@ user(); } /** @@ -25,15 +25,11 @@ class PaddleCheckoutRequest extends FormRequest 'package_id' => ['required', 'exists:packages,id'], 'success_url' => ['nullable', 'url'], 'return_url' => ['nullable', 'url'], - 'inline' => ['sometimes', 'boolean'], 'coupon_code' => ['nullable', 'string', 'max:64'], 'accepted_terms' => ['required', 'boolean', 'accepted'], ]; } - /** - * Get custom validation messages. - */ public function messages(): array { return [ diff --git a/app/Http/Requests/Support/Resources/SupportTenantResourceRequest.php b/app/Http/Requests/Support/Resources/SupportTenantResourceRequest.php index 0a0f659..6220f13 100644 --- a/app/Http/Requests/Support/Resources/SupportTenantResourceRequest.php +++ b/app/Http/Requests/Support/Resources/SupportTenantResourceRequest.php @@ -19,7 +19,7 @@ class SupportTenantResourceRequest extends SupportResourceFormRequest Rule::unique('tenants', 'slug')->ignore($tenantId), ], 'contact_email' => ['sometimes', 'email', 'max:255'], - 'paddle_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'], + 'lemonsqueezy_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'], 'is_active' => ['sometimes', 'boolean'], 'is_suspended' => ['sometimes', 'boolean'], 'features' => ['sometimes', 'array'], @@ -31,7 +31,7 @@ class SupportTenantResourceRequest extends SupportResourceFormRequest return [ 'slug', 'contact_email', - 'paddle_customer_id', + 'lemonsqueezy_customer_id', 'is_active', 'is_suspended', 'features', diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index 14a0f05..e9c997a 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -212,7 +212,7 @@ class EventResource extends JsonResource 'key' => $addon->addon_key, 'label' => $addon->metadata['label'] ?? null, 'status' => $addon->status, - 'price_id' => $addon->price_id, + 'variant_id' => $addon->variant_id, 'transaction_id' => $addon->transaction_id, 'extra_photos' => (int) $addon->extra_photos, 'extra_guests' => (int) $addon->extra_guests, diff --git a/app/Jobs/PullPackageFromPaddle.php b/app/Jobs/PullPackageFromLemonSqueezy.php similarity index 54% rename from app/Jobs/PullPackageFromPaddle.php rename to app/Jobs/PullPackageFromLemonSqueezy.php index c4c5535..6ac7b81 100644 --- a/app/Jobs/PullPackageFromPaddle.php +++ b/app/Jobs/PullPackageFromLemonSqueezy.php @@ -3,7 +3,7 @@ namespace App\Jobs; use App\Models\Package; -use App\Services\Paddle\PaddleCatalogService; +use App\Services\LemonSqueezy\LemonSqueezyCatalogService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -13,7 +13,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Throwable; -class PullPackageFromPaddle implements ShouldQueue +class PullPackageFromLemonSqueezy implements ShouldQueue { use Dispatchable; use InteractsWithQueue; @@ -22,7 +22,7 @@ class PullPackageFromPaddle implements ShouldQueue public function __construct(private readonly int $packageId) {} - public function handle(PaddleCatalogService $catalog): void + public function handle(LemonSqueezyCatalogService $catalog): void { $package = Package::query()->find($this->packageId); @@ -30,8 +30,8 @@ class PullPackageFromPaddle implements ShouldQueue return; } - if (! $package->paddle_product_id && ! $package->paddle_price_id) { - Log::channel('paddle-sync')->warning('Paddle pull skipped for package without linkage', [ + if (! $package->lemonsqueezy_product_id && ! $package->lemonsqueezy_variant_id) { + Log::channel('lemonsqueezy-sync')->warning('Lemon Squeezy pull skipped for package without linkage', [ 'package_id' => $package->id, ]); @@ -39,41 +39,41 @@ class PullPackageFromPaddle implements ShouldQueue } try { - $product = $package->paddle_product_id ? $catalog->fetchProduct($package->paddle_product_id) : null; - $price = $package->paddle_price_id ? $catalog->fetchPrice($package->paddle_price_id) : null; + $product = $package->lemonsqueezy_product_id ? $catalog->fetchProduct($package->lemonsqueezy_product_id) : null; + $price = $package->lemonsqueezy_variant_id ? $catalog->fetchPrice($package->lemonsqueezy_variant_id) : null; - $snapshot = $package->paddle_snapshot ?? []; + $snapshot = $package->lemonsqueezy_snapshot ?? []; $snapshot['remote'] = array_filter([ 'product' => $product, 'price' => $price, ], static fn ($value) => $value !== null); $package->forceFill([ - 'paddle_sync_status' => 'pulled', - 'paddle_synced_at' => now(), - 'paddle_snapshot' => $snapshot, + 'lemonsqueezy_sync_status' => 'pulled', + 'lemonsqueezy_synced_at' => now(), + 'lemonsqueezy_snapshot' => $snapshot, ])->save(); - Log::channel('paddle-sync')->info('Paddle package pull completed', [ + Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy package pull completed', [ 'package_id' => $package->id, ]); } catch (Throwable $exception) { - Log::channel('paddle-sync')->error('Paddle package pull failed', [ + Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy package pull failed', [ 'package_id' => $package->id, 'message' => $exception->getMessage(), 'exception' => $exception, ]); - $snapshot = $package->paddle_snapshot ?? []; + $snapshot = $package->lemonsqueezy_snapshot ?? []; $snapshot['error'] = array_merge(Arr::get($snapshot, 'error', []), [ 'message' => $exception->getMessage(), 'class' => $exception::class, ]); $package->forceFill([ - 'paddle_sync_status' => 'pull-failed', - 'paddle_synced_at' => now(), - 'paddle_snapshot' => $snapshot, + 'lemonsqueezy_sync_status' => 'pull-failed', + 'lemonsqueezy_synced_at' => now(), + 'lemonsqueezy_snapshot' => $snapshot, ])->save(); throw $exception; diff --git a/app/Jobs/SyncCouponToPaddle.php b/app/Jobs/SyncCouponToLemonSqueezy.php similarity index 62% rename from app/Jobs/SyncCouponToPaddle.php rename to app/Jobs/SyncCouponToLemonSqueezy.php index fa46f6a..7bfd3cf 100644 --- a/app/Jobs/SyncCouponToPaddle.php +++ b/app/Jobs/SyncCouponToLemonSqueezy.php @@ -3,8 +3,8 @@ namespace App\Jobs; use App\Models\Coupon; -use App\Services\Paddle\Exceptions\PaddleException; -use App\Services\Paddle\PaddleDiscountService; +use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException; +use App\Services\LemonSqueezy\LemonSqueezyDiscountService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -12,7 +12,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -class SyncCouponToPaddle implements ShouldQueue +class SyncCouponToLemonSqueezy implements ShouldQueue { use Dispatchable; use InteractsWithQueue; @@ -24,16 +24,16 @@ class SyncCouponToPaddle implements ShouldQueue public bool $archive = false, ) {} - public function handle(PaddleDiscountService $discounts): void + public function handle(LemonSqueezyDiscountService $discounts): void { try { if ($this->archive) { $discounts->archiveDiscount($this->coupon); $this->coupon->forceFill([ - 'paddle_discount_id' => null, - 'paddle_snapshot' => null, - 'paddle_last_synced_at' => now(), + 'lemonsqueezy_discount_id' => null, + 'lemonsqueezy_snapshot' => null, + 'lemonsqueezy_last_synced_at' => now(), ])->save(); return; @@ -42,12 +42,12 @@ class SyncCouponToPaddle implements ShouldQueue $data = $discounts->updateDiscount($this->coupon); $this->coupon->forceFill([ - 'paddle_discount_id' => $data['id'] ?? $this->coupon->paddle_discount_id, - 'paddle_snapshot' => $data, - 'paddle_last_synced_at' => now(), + 'lemonsqueezy_discount_id' => $data['id'] ?? $this->coupon->lemonsqueezy_discount_id, + 'lemonsqueezy_snapshot' => $data, + 'lemonsqueezy_last_synced_at' => now(), ])->save(); - } catch (PaddleException $exception) { - Log::channel('paddle-sync')->error('Failed syncing coupon to Paddle', [ + } catch (LemonSqueezyException $exception) { + Log::channel('lemonsqueezy-sync')->error('Failed syncing coupon to Lemon Squeezy', [ 'coupon_id' => $this->coupon->id, 'message' => $exception->getMessage(), 'status' => $exception->status(), @@ -55,7 +55,7 @@ class SyncCouponToPaddle implements ShouldQueue ]); $this->coupon->forceFill([ - 'paddle_snapshot' => [ + 'lemonsqueezy_snapshot' => [ 'error' => $exception->getMessage(), 'status' => $exception->status(), 'context' => $exception->context(), diff --git a/app/Jobs/SyncPackageAddonToPaddle.php b/app/Jobs/SyncPackageAddonToLemonSqueezy.php similarity index 66% rename from app/Jobs/SyncPackageAddonToPaddle.php rename to app/Jobs/SyncPackageAddonToLemonSqueezy.php index 59aa63d..1423cc5 100644 --- a/app/Jobs/SyncPackageAddonToPaddle.php +++ b/app/Jobs/SyncPackageAddonToLemonSqueezy.php @@ -3,8 +3,8 @@ namespace App\Jobs; use App\Models\PackageAddon; -use App\Services\Paddle\Exceptions\PaddleException; -use App\Services\Paddle\PaddleAddonCatalogService; +use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException; +use App\Services\LemonSqueezy\LemonSqueezyAddonCatalogService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -14,7 +14,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Throwable; -class SyncPackageAddonToPaddle implements ShouldQueue +class SyncPackageAddonToLemonSqueezy implements ShouldQueue { use Dispatchable; use InteractsWithQueue; @@ -26,7 +26,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue */ public function __construct(private readonly int $addonId, private readonly array $options = []) {} - public function handle(PaddleAddonCatalogService $catalog): void + public function handle(LemonSqueezyAddonCatalogService $catalog): void { $addon = PackageAddon::query()->find($this->addonId); @@ -39,7 +39,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue $priceOverrides = Arr::get($this->options, 'price', []); if ($dryRun) { - $this->storeDryRunSnapshot($catalog, $addon, $productOverrides, $priceOverrides); + $this->storeDryRunSnapshot($addon, $productOverrides, $priceOverrides); return; } @@ -47,41 +47,41 @@ class SyncPackageAddonToPaddle implements ShouldQueue // Mark syncing (metadata) $addon->forceFill([ 'metadata' => array_merge($addon->metadata ?? [], [ - 'paddle_sync_status' => 'syncing', - 'paddle_synced_at' => now()->toIso8601String(), + 'lemonsqueezy_sync_status' => 'syncing', + 'lemonsqueezy_synced_at' => now()->toIso8601String(), ]), ])->save(); try { $payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides); - $productResponse = $addon->metadata['paddle_product_id'] ?? null - ? $catalog->updateProduct($addon->metadata['paddle_product_id'], $addon, $payloadOverrides['product']) + $productResponse = $addon->metadata['lemonsqueezy_product_id'] ?? null + ? $catalog->updateProduct($addon->metadata['lemonsqueezy_product_id'], $addon, $payloadOverrides['product']) : $catalog->createProduct($addon, $payloadOverrides['product']); - $productId = (string) ($productResponse['id'] ?? $addon->metadata['paddle_product_id'] ?? null); + $productId = (string) ($productResponse['id'] ?? $addon->metadata['lemonsqueezy_product_id'] ?? null); if (! $productId) { - throw new PaddleException('Paddle product ID missing after addon sync.'); + throw new LemonSqueezyException('Lemon Squeezy product ID missing after addon sync.'); } - $priceResponse = $addon->price_id - ? $catalog->updatePrice($addon->price_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId])) + $priceResponse = $addon->variant_id + ? $catalog->updatePrice($addon->variant_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId])) : $catalog->createPrice($addon, $productId, $payloadOverrides['price']); - $priceId = (string) ($priceResponse['id'] ?? $addon->price_id); + $priceId = (string) ($priceResponse['id'] ?? $addon->variant_id); if (! $priceId) { - throw new PaddleException('Paddle price ID missing after addon sync.'); + throw new LemonSqueezyException('Lemon Squeezy variant ID missing after addon sync.'); } $addon->forceFill([ - 'price_id' => $priceId, + 'variant_id' => $priceId, 'metadata' => array_merge($addon->metadata ?? [], [ - 'paddle_sync_status' => 'synced', - 'paddle_synced_at' => now()->toIso8601String(), - 'paddle_product_id' => $productId, - 'paddle_snapshot' => [ + 'lemonsqueezy_sync_status' => 'synced', + 'lemonsqueezy_synced_at' => now()->toIso8601String(), + 'lemonsqueezy_product_id' => $productId, + 'lemonsqueezy_snapshot' => [ 'product' => $productResponse, 'price' => $priceResponse, 'payload' => $payloadOverrides, @@ -89,7 +89,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue ]), ])->save(); } catch (Throwable $exception) { - Log::channel('paddle-sync')->error('Paddle addon sync failed', [ + Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy addon sync failed', [ 'addon_id' => $addon->id, 'message' => $exception->getMessage(), 'exception' => $exception, @@ -97,9 +97,9 @@ class SyncPackageAddonToPaddle implements ShouldQueue $addon->forceFill([ 'metadata' => array_merge($addon->metadata ?? [], [ - 'paddle_sync_status' => 'failed', - 'paddle_synced_at' => now()->toIso8601String(), - 'paddle_error' => [ + 'lemonsqueezy_sync_status' => 'failed', + 'lemonsqueezy_synced_at' => now()->toIso8601String(), + 'lemonsqueezy_error' => [ 'message' => $exception->getMessage(), 'class' => $exception::class, ], @@ -145,22 +145,22 @@ class SyncPackageAddonToPaddle implements ShouldQueue * @param array $productOverrides * @param array $priceOverrides */ - protected function storeDryRunSnapshot(PaddleCatalogService $catalog, PackageAddon $addon, array $productOverrides, array $priceOverrides): void + protected function storeDryRunSnapshot(PackageAddon $addon, array $productOverrides, array $priceOverrides): void { $payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides); $addon->forceFill([ 'metadata' => array_merge($addon->metadata ?? [], [ - 'paddle_sync_status' => 'dry-run', - 'paddle_synced_at' => now()->toIso8601String(), - 'paddle_snapshot' => [ + 'lemonsqueezy_sync_status' => 'dry-run', + 'lemonsqueezy_synced_at' => now()->toIso8601String(), + 'lemonsqueezy_snapshot' => [ 'dry_run' => true, 'payload' => $payloadOverrides, ], ]), ])->save(); - Log::channel('paddle-sync')->info('Paddle addon dry-run snapshot generated', [ + Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy addon dry-run snapshot generated', [ 'addon_id' => $addon->id, ]); } diff --git a/app/Jobs/SyncPackageToPaddle.php b/app/Jobs/SyncPackageToLemonSqueezy.php similarity index 61% rename from app/Jobs/SyncPackageToPaddle.php rename to app/Jobs/SyncPackageToLemonSqueezy.php index e50a500..4f14745 100644 --- a/app/Jobs/SyncPackageToPaddle.php +++ b/app/Jobs/SyncPackageToLemonSqueezy.php @@ -3,8 +3,8 @@ namespace App\Jobs; use App\Models\Package; -use App\Services\Paddle\Exceptions\PaddleException; -use App\Services\Paddle\PaddleCatalogService; +use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException; +use App\Services\LemonSqueezy\LemonSqueezyCatalogService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -14,7 +14,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Throwable; -class SyncPackageToPaddle implements ShouldQueue +class SyncPackageToLemonSqueezy implements ShouldQueue { use Dispatchable; use InteractsWithQueue; @@ -26,7 +26,7 @@ class SyncPackageToPaddle implements ShouldQueue */ public function __construct(private readonly int $packageId, private readonly array $options = []) {} - public function handle(PaddleCatalogService $catalog): void + public function handle(LemonSqueezyCatalogService $catalog): void { $package = Package::query()->find($this->packageId); @@ -45,37 +45,37 @@ class SyncPackageToPaddle implements ShouldQueue } $package->forceFill([ - 'paddle_sync_status' => 'syncing', + 'lemonsqueezy_sync_status' => 'syncing', ])->save(); try { - $productResponse = $package->paddle_product_id - ? $catalog->updateProduct($package->paddle_product_id, $package, $productOverrides) + $productResponse = $package->lemonsqueezy_product_id + ? $catalog->updateProduct($package->lemonsqueezy_product_id, $package, $productOverrides) : $catalog->createProduct($package, $productOverrides); - $productId = (string) ($productResponse['id'] ?? $package->paddle_product_id); + $productId = (string) ($productResponse['id'] ?? $package->lemonsqueezy_product_id); if (! $productId) { - throw new PaddleException('Paddle product ID missing after sync.'); + throw new LemonSqueezyException('Lemon Squeezy product ID missing after sync.'); } - $package->paddle_product_id = $productId; + $package->lemonsqueezy_product_id = $productId; - $priceResponse = $package->paddle_price_id - ? $catalog->updatePrice($package->paddle_price_id, $package, array_merge($priceOverrides, ['product_id' => $productId])) + $priceResponse = $package->lemonsqueezy_variant_id + ? $catalog->updatePrice($package->lemonsqueezy_variant_id, $package, array_merge($priceOverrides, ['product_id' => $productId])) : $catalog->createPrice($package, $productId, $priceOverrides); - $priceId = (string) ($priceResponse['id'] ?? $package->paddle_price_id); + $priceId = (string) ($priceResponse['id'] ?? $package->lemonsqueezy_variant_id); if (! $priceId) { - throw new PaddleException('Paddle price ID missing after sync.'); + throw new LemonSqueezyException('Lemon Squeezy variant ID missing after sync.'); } $package->forceFill([ - 'paddle_price_id' => $priceId, - 'paddle_sync_status' => 'synced', - 'paddle_synced_at' => now(), - 'paddle_snapshot' => [ + 'lemonsqueezy_variant_id' => $priceId, + 'lemonsqueezy_sync_status' => 'synced', + 'lemonsqueezy_synced_at' => now(), + 'lemonsqueezy_snapshot' => [ 'product' => $productResponse, 'price' => $priceResponse, 'payload' => [ @@ -85,16 +85,16 @@ class SyncPackageToPaddle implements ShouldQueue ], ])->save(); } catch (Throwable $exception) { - Log::channel('paddle-sync')->error('Paddle package sync failed', [ + Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy package sync failed', [ 'package_id' => $package->id, 'message' => $exception->getMessage(), 'exception' => $exception, ]); $package->forceFill([ - 'paddle_sync_status' => 'failed', - 'paddle_synced_at' => now(), - 'paddle_snapshot' => array_merge($package->paddle_snapshot ?? [], [ + 'lemonsqueezy_sync_status' => 'failed', + 'lemonsqueezy_synced_at' => now(), + 'lemonsqueezy_snapshot' => array_merge($package->lemonsqueezy_snapshot ?? [], [ 'error' => [ 'message' => $exception->getMessage(), 'class' => $exception::class, @@ -110,19 +110,19 @@ class SyncPackageToPaddle implements ShouldQueue * @param array $productOverrides * @param array $priceOverrides */ - protected function storeDryRunSnapshot(PaddleCatalogService $catalog, Package $package, array $productOverrides, array $priceOverrides): void + protected function storeDryRunSnapshot(LemonSqueezyCatalogService $catalog, Package $package, array $productOverrides, array $priceOverrides): void { $productPayload = $catalog->buildProductPayload($package, $productOverrides); $pricePayload = $catalog->buildPricePayload( $package, - $package->paddle_product_id ?: ($priceOverrides['product_id'] ?? 'pending'), + $package->lemonsqueezy_product_id ?: ($priceOverrides['product_id'] ?? 'pending'), $priceOverrides ); $package->forceFill([ - 'paddle_sync_status' => 'dry-run', - 'paddle_synced_at' => now(), - 'paddle_snapshot' => [ + 'lemonsqueezy_sync_status' => 'dry-run', + 'lemonsqueezy_synced_at' => now(), + 'lemonsqueezy_snapshot' => [ 'dry_run' => true, 'payload' => [ 'product' => $productPayload, @@ -131,7 +131,7 @@ class SyncPackageToPaddle implements ShouldQueue ], ])->save(); - Log::channel('paddle-sync')->info('Paddle package dry-run snapshot generated', [ + Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy package dry-run snapshot generated', [ 'package_id' => $package->id, ]); } diff --git a/app/Mail/PurchaseConfirmation.php b/app/Mail/PurchaseConfirmation.php index cbe3d72..e552c08 100644 --- a/app/Mail/PurchaseConfirmation.php +++ b/app/Mail/PurchaseConfirmation.php @@ -60,7 +60,7 @@ class PurchaseConfirmation extends Mailable private function formattedTotal(): string { - $totals = $this->purchase->metadata['paddle_totals'] ?? []; + $totals = $this->purchase->metadata['lemonsqueezy_totals'] ?? []; $currency = $totals['currency'] ?? $this->purchase->metadata['currency'] ?? $this->purchase->package?->currency @@ -113,7 +113,7 @@ class PurchaseConfirmation extends Mailable private function providerLabel(): string { - $provider = $this->purchase->provider ?? 'paddle'; + $provider = $this->purchase->provider ?? 'lemonsqueezy'; $labelKey = 'emails.purchase.provider.'.$provider; $label = __($labelKey); diff --git a/app/Models/CheckoutSession.php b/app/Models/CheckoutSession.php index b9c2efe..1aa4634 100644 --- a/app/Models/CheckoutSession.php +++ b/app/Models/CheckoutSession.php @@ -30,7 +30,7 @@ class CheckoutSession extends Model public const PROVIDER_NONE = 'none'; - public const PROVIDER_PADDLE = 'paddle'; + public const PROVIDER_LEMONSQUEEZY = 'lemonsqueezy'; public const PROVIDER_FREE = 'free'; diff --git a/app/Models/Coupon.php b/app/Models/Coupon.php index c7442d5..40aabbe 100644 --- a/app/Models/Coupon.php +++ b/app/Models/Coupon.php @@ -38,10 +38,10 @@ class Coupon extends Model 'metadata', 'starts_at', 'ends_at', - 'paddle_discount_id', - 'paddle_mode', - 'paddle_snapshot', - 'paddle_last_synced_at', + 'lemonsqueezy_discount_id', + 'lemonsqueezy_mode', + 'lemonsqueezy_snapshot', + 'lemonsqueezy_last_synced_at', 'created_by', 'updated_by', ]; @@ -54,10 +54,10 @@ class Coupon extends Model 'enabled_for_checkout' => 'boolean', 'auto_apply' => 'boolean', 'metadata' => 'array', - 'paddle_snapshot' => 'array', + 'lemonsqueezy_snapshot' => 'array', 'starts_at' => 'datetime', 'ends_at' => 'datetime', - 'paddle_last_synced_at' => 'datetime', + 'lemonsqueezy_last_synced_at' => 'datetime', ]; protected static function booted(): void diff --git a/app/Models/CouponRedemption.php b/app/Models/CouponRedemption.php index a50b659..1cdd05c 100644 --- a/app/Models/CouponRedemption.php +++ b/app/Models/CouponRedemption.php @@ -23,7 +23,7 @@ class CouponRedemption extends Model 'package_id', 'tenant_id', 'user_id', - 'paddle_transaction_id', + 'lemonsqueezy_order_id', 'status', 'failure_reason', 'ip_address', diff --git a/app/Models/EventPackageAddon.php b/app/Models/EventPackageAddon.php index 5fa7235..b7edcef 100644 --- a/app/Models/EventPackageAddon.php +++ b/app/Models/EventPackageAddon.php @@ -20,7 +20,7 @@ class EventPackageAddon extends Model 'extra_photos', 'extra_guests', 'extra_gallery_days', - 'price_id', + 'variant_id', 'checkout_id', 'transaction_id', 'status', diff --git a/app/Models/GiftVoucher.php b/app/Models/GiftVoucher.php index c00115a..db45d18 100644 --- a/app/Models/GiftVoucher.php +++ b/app/Models/GiftVoucher.php @@ -32,9 +32,9 @@ class GiftVoucher extends Model 'recipient_email', 'recipient_name', 'message', - 'paddle_transaction_id', - 'paddle_checkout_id', - 'paddle_price_id', + 'lemonsqueezy_order_id', + 'lemonsqueezy_checkout_id', + 'lemonsqueezy_variant_id', 'coupon_id', 'expires_at', 'redeemed_at', diff --git a/app/Models/Package.php b/app/Models/Package.php index 742ae69..726bca0 100644 --- a/app/Models/Package.php +++ b/app/Models/Package.php @@ -33,11 +33,11 @@ class Package extends Model 'description', 'description_translations', 'description_table', - 'paddle_product_id', - 'paddle_price_id', - 'paddle_sync_status', - 'paddle_synced_at', - 'paddle_snapshot', + 'lemonsqueezy_product_id', + 'lemonsqueezy_variant_id', + 'lemonsqueezy_sync_status', + 'lemonsqueezy_synced_at', + 'lemonsqueezy_snapshot', ]; protected $casts = [ @@ -54,8 +54,8 @@ class Package extends Model 'name_translations' => 'array', 'description_translations' => 'array', 'description_table' => 'array', - 'paddle_synced_at' => 'datetime', - 'paddle_snapshot' => 'array', + 'lemonsqueezy_synced_at' => 'datetime', + 'lemonsqueezy_snapshot' => 'array', ]; protected $appends = [ @@ -146,20 +146,20 @@ class Package extends Model ]; } - public function getPaddleSyncErrorMessageAttribute(): ?string + public function getLemonSqueezySyncErrorMessageAttribute(): ?string { - $message = data_get($this->paddle_snapshot, 'error.message'); + $message = data_get($this->lemonsqueezy_snapshot, 'error.message'); return is_string($message) && $message !== '' ? $message : null; } - public function linkPaddleIds(string $productId, string $priceId): void + public function linkLemonSqueezyIds(string $productId, string $variantId): void { $this->forceFill([ - 'paddle_product_id' => $productId, - 'paddle_price_id' => $priceId, - 'paddle_sync_status' => 'linked', - 'paddle_synced_at' => now(), + 'lemonsqueezy_product_id' => $productId, + 'lemonsqueezy_variant_id' => $variantId, + 'lemonsqueezy_sync_status' => 'linked', + 'lemonsqueezy_synced_at' => now(), ])->save(); } diff --git a/app/Models/PackageAddon.php b/app/Models/PackageAddon.php index dda8347..4a4a5f8 100644 --- a/app/Models/PackageAddon.php +++ b/app/Models/PackageAddon.php @@ -13,7 +13,7 @@ class PackageAddon extends Model protected $fillable = [ 'key', 'label', - 'price_id', + 'variant_id', 'extra_photos', 'extra_guests', 'extra_gallery_days', diff --git a/app/Models/TenantPackage.php b/app/Models/TenantPackage.php index 8c92e5a..7771c54 100644 --- a/app/Models/TenantPackage.php +++ b/app/Models/TenantPackage.php @@ -16,7 +16,7 @@ class TenantPackage extends Model protected $fillable = [ 'tenant_id', 'package_id', - 'paddle_subscription_id', + 'lemonsqueezy_subscription_id', 'price', 'purchased_at', 'expires_at', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 26aa171..d8969d4 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -187,8 +187,8 @@ class AppServiceProvider extends ServiceProvider ]; }); - RateLimiter::for('paddle-webhook', function (Request $request) { - return Limit::perMinute(30)->by('paddle:'.$request->ip()); + RateLimiter::for('lemonsqueezy-webhook', function (Request $request) { + return Limit::perMinute(30)->by('lemonsqueezy:'.$request->ip()); }); RateLimiter::for('gift-lookup', function (Request $request) { diff --git a/app/Services/Addons/EventAddonCatalog.php b/app/Services/Addons/EventAddonCatalog.php index ddedf07..53c839b 100644 --- a/app/Services/Addons/EventAddonCatalog.php +++ b/app/Services/Addons/EventAddonCatalog.php @@ -19,7 +19,7 @@ class EventAddonCatalog ->mapWithKeys(function (PackageAddon $addon) { return [$addon->key => [ 'label' => $addon->label, - 'price_id' => $addon->price_id, + 'variant_id' => $addon->variant_id, 'increments' => $addon->increments, ]]; }) @@ -39,11 +39,11 @@ class EventAddonCatalog return $this->all()[$key] ?? null; } - public function resolvePriceId(string $key): ?string + public function resolveVariantId(string $key): ?string { $addon = $this->find($key); - return $addon['price_id'] ?? null; + return $addon['variant_id'] ?? null; } /** diff --git a/app/Services/Addons/EventAddonCheckoutService.php b/app/Services/Addons/EventAddonCheckoutService.php index 59150c9..e59b607 100644 --- a/app/Services/Addons/EventAddonCheckoutService.php +++ b/app/Services/Addons/EventAddonCheckoutService.php @@ -5,20 +5,16 @@ namespace App\Services\Addons; use App\Models\Event; use App\Models\EventPackageAddon; use App\Models\Tenant; -use App\Services\Paddle\PaddleClient; -use App\Services\Paddle\PaddleCustomerService; -use Illuminate\Support\Arr; +use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; -use Throwable; class EventAddonCheckoutService { public function __construct( private readonly EventAddonCatalog $catalog, - private readonly PaddleClient $paddle, - private readonly PaddleCustomerService $customers, + private readonly LemonSqueezyCheckoutService $checkout, ) {} /** @@ -32,25 +28,17 @@ class EventAddonCheckoutService $acceptedWaiver = (bool) ($payload['accepted_waiver'] ?? false); $acceptedTerms = (bool) ($payload['accepted_terms'] ?? false); - try { - $customerId = $this->customers->ensureCustomerId($tenant); - } catch (Throwable $exception) { - throw ValidationException::withMessages([ - 'customer' => __('Konnte Paddle-Kundenkonto nicht anlegen: :message', ['message' => $exception->getMessage()]), - ]); - } - if (! $addonKey || ! $this->catalog->find($addonKey)) { throw ValidationException::withMessages([ 'addon_key' => __('Unbekanntes Add-on.'), ]); } - $priceId = $this->catalog->resolvePriceId($addonKey); + $variantId = $this->catalog->resolveVariantId($addonKey); - if (! $priceId) { + if (! $variantId) { throw ValidationException::withMessages([ - 'addon_key' => __('Für dieses Add-on ist kein Paddle-Preis hinterlegt.'), + 'addon_key' => __('Für dieses Add-on ist kein Lemon Squeezy Variant hinterlegt.'), ]); } @@ -73,6 +61,7 @@ class EventAddonCheckoutService 'addon_key' => $addonKey, 'addon_intent' => $addonIntent, 'quantity' => $quantity, + 'lemonsqueezy_variant_id' => $variantId, 'legal_version' => $this->resolveLegalVersion(), 'accepted_terms' => $acceptedTerms ? '1' : '0', 'accepted_waiver' => $acceptedWaiver ? '1' : '0', @@ -80,31 +69,18 @@ class EventAddonCheckoutService 'cancel_url' => $payload['cancel_url'] ?? null, ], static fn ($value) => $value !== null && $value !== ''); - $requestPayload = array_filter([ - 'customer_id' => $customerId, - 'items' => [ - [ - 'price_id' => $priceId, - 'quantity' => $quantity, - ], - ], - 'custom_data' => $metadata, - ], static fn ($value) => $value !== null && $value !== ''); + $response = $this->checkout->createVariantCheckout($variantId, $metadata, [ + 'success_url' => $payload['success_url'] ?? null, + 'return_url' => $payload['cancel_url'] ?? null, + 'customer_email' => $tenant->contact_email ?? $tenant->user?->email, + ]); - $response = $this->paddle->post('/transactions', $requestPayload); - - $checkoutUrl = Arr::get($response, 'data.checkout.url') - ?? Arr::get($response, 'checkout.url') - ?? Arr::get($response, 'data.url') - ?? Arr::get($response, 'url'); - $checkoutId = Arr::get($response, 'data.checkout_id') - ?? Arr::get($response, 'data.checkout.id') - ?? Arr::get($response, 'checkout_id') - ?? Arr::get($response, 'checkout.id'); - $transactionId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id'); + $checkoutUrl = $response['checkout_url'] ?? null; + $checkoutId = $response['id'] ?? null; + $transactionId = null; if (! $checkoutUrl) { - Log::warning('Paddle addon checkout response missing url', ['response' => $response]); + Log::warning('Lemon Squeezy addon checkout response missing url', ['response' => $response]); } EventPackageAddon::create([ @@ -113,7 +89,7 @@ class EventAddonCheckoutService 'tenant_id' => $tenant->id, 'addon_key' => $addonKey, 'quantity' => $quantity, - 'price_id' => $priceId, + 'variant_id' => $variantId, 'checkout_id' => $checkoutId, 'transaction_id' => $transactionId, 'status' => 'pending', @@ -133,10 +109,8 @@ class EventAddonCheckoutService return [ 'checkout_url' => $checkoutUrl, - 'expires_at' => Arr::get($response, 'data.checkout.expires_at') - ?? Arr::get($response, 'data.expires_at') - ?? Arr::get($response, 'expires_at'), - 'id' => $transactionId ?? $checkoutId, + 'expires_at' => $response['expires_at'] ?? null, + 'id' => $checkoutId, ]; } diff --git a/app/Services/Addons/EventAddonWebhookService.php b/app/Services/Addons/EventAddonWebhookService.php index 759abd6..cb6b2c6 100644 --- a/app/Services/Addons/EventAddonWebhookService.php +++ b/app/Services/Addons/EventAddonWebhookService.php @@ -17,14 +17,19 @@ class EventAddonWebhookService public function handle(array $payload): bool { - $eventType = $payload['event_type'] ?? null; + $eventType = $payload['meta']['event_name'] ?? null; $data = $payload['data'] ?? []; - if ($eventType !== 'transaction.completed') { + if (! in_array($eventType, ['order_created', 'order_updated'], true)) { return false; } - $metadata = $this->extractMetadata($data); + $status = strtolower((string) data_get($data, 'attributes.status', '')); + if ($status !== 'paid') { + return false; + } + + $metadata = $this->extractMetadata($payload); $intentId = $metadata['addon_intent'] ?? null; $addonKey = $metadata['addon_key'] ?? null; @@ -32,8 +37,8 @@ class EventAddonWebhookService return false; } - $transactionId = $data['id'] ?? $data['transaction_id'] ?? null; - $checkoutId = $data['checkout_id'] ?? null; + $transactionId = $data['id'] ?? null; + $checkoutId = data_get($data, 'attributes.checkout_id') ?? null; $addon = EventPackageAddon::query() ->where('addon_key', $addonKey) @@ -66,10 +71,12 @@ class EventAddonWebhookService 'transaction_id' => $transactionId, 'checkout_id' => $addon->checkout_id ?: $checkoutId, 'status' => 'completed', - 'amount' => Arr::get($data, 'totals.grand_total') ?? Arr::get($data, 'amount'), - 'currency' => Arr::get($data, 'currency_code') ?? Arr::get($data, 'currency'), + 'amount' => $this->resolveAmount($data), + 'currency' => Arr::get($data, 'attributes.currency') ?? Arr::get($data, 'currency'), 'metadata' => array_merge($addon->metadata ?? [], ['webhook_payload' => $data]), - 'receipt_payload' => Arr::get($data, 'receipt_url') ? ['receipt_url' => Arr::get($data, 'receipt_url')] : null, + 'receipt_payload' => Arr::get($data, 'attributes.urls.receipt') + ? ['receipt_url' => Arr::get($data, 'attributes.urls.receipt')] + : null, 'purchased_at' => now(), ])->save(); @@ -118,17 +125,36 @@ class EventAddonWebhookService { $metadata = []; - if (isset($data['metadata']) && is_array($data['metadata'])) { - $metadata = $data['metadata']; + if (isset($data['meta']['custom_data']) && is_array($data['meta']['custom_data'])) { + $metadata = $data['meta']['custom_data']; } - if (isset($data['custom_data']) && is_array($data['custom_data'])) { - $metadata = array_merge($metadata, $data['custom_data']); + if (isset($data['metadata']) && is_array($data['metadata'])) { + $metadata = array_merge($metadata, $data['metadata']); + } + + if (isset($data['attributes']['custom_data']) && is_array($data['attributes']['custom_data'])) { + $metadata = array_merge($metadata, $data['attributes']['custom_data']); } return $metadata; } + private function resolveAmount(array $data): ?float + { + $total = Arr::get($data, 'attributes.total'); + + if ($total === null || $total === '') { + return null; + } + + if (! is_numeric($total)) { + return null; + } + + return round(((float) $total) / 100, 2); + } + /** * @return array */ diff --git a/app/Services/Checkout/CheckoutAssignmentService.php b/app/Services/Checkout/CheckoutAssignmentService.php index d0482ad..1f9be58 100644 --- a/app/Services/Checkout/CheckoutAssignmentService.php +++ b/app/Services/Checkout/CheckoutAssignmentService.php @@ -60,16 +60,16 @@ class CheckoutAssignmentService $consents = array_filter($consents); $providerReference = $options['provider_reference'] - ?? $metadata['paddle_transaction_id'] ?? null - ?? $metadata['paddle_checkout_id'] ?? null + ?? $metadata['lemonsqueezy_order_id'] ?? null + ?? $metadata['lemonsqueezy_checkout_id'] ?? null ?? CheckoutSession::PROVIDER_FREE; $providerName = $options['provider'] ?? $session->provider - ?? ($metadata['paddle_transaction_id'] ?? $metadata['paddle_checkout_id'] ? CheckoutSession::PROVIDER_PADDLE : null) + ?? ($metadata['lemonsqueezy_order_id'] ?? $metadata['lemonsqueezy_checkout_id'] ? CheckoutSession::PROVIDER_LEMONSQUEEZY : null) ?? CheckoutSession::PROVIDER_FREE; - $totals = $this->resolvePaddleTotals($session, $options['payload'] ?? []); + $totals = $this->resolveLemonSqueezyTotals($session, $options['payload'] ?? []); $currency = $totals['currency'] ?? $session->currency ?? $package->currency ?? 'EUR'; $price = array_key_exists('total', $totals) ? $totals['total'] : (float) $session->amount_total; @@ -88,7 +88,7 @@ class CheckoutAssignmentService 'payload' => $options['payload'] ?? null, 'checkout_session_id' => $session->id, 'consents' => $consents ?: null, - 'paddle_totals' => $totals !== [] ? $totals : null, + 'lemonsqueezy_totals' => $totals !== [] ? $totals : null, 'currency' => $currency, ], static fn ($value) => $value !== null && $value !== ''), ] @@ -223,34 +223,25 @@ class CheckoutAssignmentService * @param array $payload * @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float} */ - protected function resolvePaddleTotals(CheckoutSession $session, array $payload): array + protected function resolveLemonSqueezyTotals(CheckoutSession $session, array $payload): array { - $metadataTotals = $session->provider_metadata['paddle_totals'] ?? null; + $metadataTotals = $session->provider_metadata['lemonsqueezy_totals'] ?? null; if (is_array($metadataTotals) && $metadataTotals !== []) { return $metadataTotals; } - $totals = Arr::get($payload, 'details.totals', Arr::get($payload, 'totals', [])); - if (! is_array($totals) || $totals === []) { + $attributes = Arr::get($payload, 'attributes', []); + if (! is_array($attributes) || $attributes === []) { return []; } - $currency = Arr::get($totals, 'currency_code') - ?? Arr::get($payload, 'currency_code') - ?? Arr::get($totals, 'currency') - ?? Arr::get($payload, 'currency'); + $currency = Arr::get($attributes, 'currency'); - $subtotal = $this->convertMinorAmount(Arr::get($totals, 'subtotal.amount', $totals['subtotal'] ?? null)); - $discount = $this->convertMinorAmount(Arr::get($totals, 'discount.amount', $totals['discount'] ?? null)); - $tax = $this->convertMinorAmount(Arr::get($totals, 'tax.amount', $totals['tax'] ?? null)); - $total = $this->convertMinorAmount( - Arr::get( - $totals, - 'total.amount', - $totals['total'] ?? Arr::get($totals, 'grand_total.amount', $totals['grand_total'] ?? null) - ) - ); + $subtotal = $this->convertMinorAmount(Arr::get($attributes, 'subtotal')); + $discount = $this->convertMinorAmount(Arr::get($attributes, 'discount_total')); + $tax = $this->convertMinorAmount(Arr::get($attributes, 'tax')); + $total = $this->convertMinorAmount(Arr::get($attributes, 'total')); return array_filter([ 'currency' => $currency ? strtoupper((string) $currency) : null, diff --git a/app/Services/Checkout/CheckoutSessionService.php b/app/Services/Checkout/CheckoutSessionService.php index 7746ac0..5ce0829 100644 --- a/app/Services/Checkout/CheckoutSessionService.php +++ b/app/Services/Checkout/CheckoutSessionService.php @@ -72,8 +72,8 @@ class CheckoutSessionService $session->amount_discount = 0; $session->provider = CheckoutSession::PROVIDER_NONE; $session->status = CheckoutSession::STATUS_DRAFT; - $session->paddle_checkout_id = null; - $session->paddle_transaction_id = null; + $session->lemonsqueezy_checkout_id = null; + $session->lemonsqueezy_order_id = null; $session->provider_metadata = []; $session->failure_reason = null; $session->coupon()->dissociate(); @@ -118,7 +118,7 @@ class CheckoutSessionService $provider = strtolower($provider); if (! in_array($provider, [ - CheckoutSession::PROVIDER_PADDLE, + CheckoutSession::PROVIDER_LEMONSQUEEZY, CheckoutSession::PROVIDER_FREE, ], true)) { throw new RuntimeException("Unsupported checkout provider [{$provider}]"); diff --git a/app/Services/Checkout/CheckoutWebhookService.php b/app/Services/Checkout/CheckoutWebhookService.php index 256444c..0a6272d 100644 --- a/app/Services/Checkout/CheckoutWebhookService.php +++ b/app/Services/Checkout/CheckoutWebhookService.php @@ -8,7 +8,7 @@ use App\Models\Tenant; use App\Models\TenantPackage; use App\Services\Coupons\CouponRedemptionService; use App\Services\GiftVouchers\GiftVoucherService; -use App\Services\Paddle\PaddleSubscriptionService; +use App\Services\LemonSqueezy\LemonSqueezySubscriptionService; use Carbon\Carbon; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; @@ -20,52 +20,52 @@ class CheckoutWebhookService public function __construct( private readonly CheckoutSessionService $sessions, private readonly CheckoutAssignmentService $assignment, - private readonly PaddleSubscriptionService $paddleSubscriptions, + private readonly LemonSqueezySubscriptionService $lemonsqueezySubscriptions, private readonly CouponRedemptionService $couponRedemptions, private readonly GiftVoucherService $giftVouchers, ) {} - public function handlePaddleEvent(array $event): bool + public function handleLemonSqueezyEvent(array $event): bool { - $eventType = $event['event_type'] ?? null; - $data = $event['data'] ?? []; + $eventType = $event['meta']['event_name'] ?? $event['event_name'] ?? null; + $data = $event['data'] ?? null; if (! $eventType || ! is_array($data)) { return false; } - if (Str::startsWith($eventType, 'subscription.')) { - return $this->handlePaddleSubscriptionEvent($eventType, $data); + if (Str::startsWith($eventType, 'subscription_')) { + return $this->handleLemonSqueezySubscriptionEvent($eventType, $data, $event); } - if ($this->isGiftVoucherEvent($data)) { - if ($eventType === 'transaction.completed') { - $this->giftVouchers->issueFromPaddle($data); + if ($this->isGiftVoucherEvent($event)) { + if ($eventType === 'order_created') { + $this->giftVouchers->issueFromLemonSqueezy($event); return true; } - return in_array($eventType, ['transaction.processing', 'transaction.created', 'transaction.failed', 'transaction.cancelled'], true); + return in_array($eventType, ['order_created', 'order_refunded', 'order_payment_failed', 'order_updated'], true); } - $session = $this->locatePaddleSession($data); + $session = $this->locateLemonSqueezySession($event); if (! $session) { - Log::info('[CheckoutWebhook] Paddle session not resolved', [ + Log::info('[CheckoutWebhook] Lemon Squeezy session not resolved', [ 'event_type' => $eventType, - 'transaction_id' => $data['id'] ?? null, + 'order_id' => $data['id'] ?? null, ]); return false; } - $transactionId = $data['id'] ?? $data['transaction_id'] ?? null; - $lockKey = 'checkout:webhook:paddle:'.($transactionId ?: $session->id); + $orderId = $data['id'] ?? null; + $lockKey = 'checkout:webhook:lemonsqueezy:'.($orderId ?: $session->id); $lock = Cache::lock($lockKey, 30); if (! $lock->get()) { - Log::info('[CheckoutWebhook] Paddle lock busy', [ - 'transaction_id' => $transactionId, + Log::info('[CheckoutWebhook] Lemon Squeezy lock busy', [ + 'order_id' => $orderId, 'session_id' => $session->id, ]); @@ -73,75 +73,90 @@ class CheckoutWebhookService } try { - if ($transactionId) { + if ($orderId) { $session->forceFill([ - 'paddle_transaction_id' => $transactionId, - 'provider' => CheckoutSession::PROVIDER_PADDLE, + 'lemonsqueezy_order_id' => $orderId, + 'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY, ])->save(); - } elseif ($session->provider !== CheckoutSession::PROVIDER_PADDLE) { - $session->forceFill(['provider' => CheckoutSession::PROVIDER_PADDLE])->save(); + } elseif ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) { + $session->forceFill(['provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY])->save(); } $metadata = [ - 'paddle_last_event' => $eventType, - 'paddle_transaction_id' => $transactionId, - 'paddle_status' => $data['status'] ?? null, - 'paddle_last_update_at' => now()->toIso8601String(), + 'lemonsqueezy_last_event' => $eventType, + 'lemonsqueezy_order_id' => $orderId, + 'lemonsqueezy_status' => data_get($data, 'attributes.status'), + 'lemonsqueezy_last_update_at' => now()->toIso8601String(), ]; - if (! empty($data['checkout_id'])) { - $metadata['paddle_checkout_id'] = $data['checkout_id']; + $checkoutId = data_get($data, 'attributes.checkout_id') ?? data_get($event, 'meta.custom_data.checkout_id'); + if (! empty($checkoutId)) { + $metadata['lemonsqueezy_checkout_id'] = $checkoutId; } $this->mergeProviderMetadata($session, $metadata); - return $this->applyPaddleEvent($session, $eventType, $data); + $customerId = data_get($data, 'attributes.customer_id') + ?? data_get($data, 'relationships.customer.data.id'); + + if ($customerId && $session->tenant && ! $session->tenant->lemonsqueezy_customer_id) { + $session->tenant->forceFill([ + 'lemonsqueezy_customer_id' => (string) $customerId, + ])->save(); + } + + return $this->applyLemonSqueezyEvent($session, $eventType, $data, $event); } finally { $lock->release(); } } - protected function applyPaddleEvent(CheckoutSession $session, string $eventType, array $data): bool + protected function applyLemonSqueezyEvent(CheckoutSession $session, string $eventType, array $data, array $event): bool { - $status = strtolower((string) ($data['status'] ?? '')); + $status = Str::lower((string) data_get($data, 'attributes.status', '')); switch ($eventType) { - case 'transaction.created': - case 'transaction.processing': - $this->sessions->markProcessing($session, [ - 'paddle_status' => $status ?: null, - ]); - - return true; - - case 'transaction.completed': + case 'order_created': + case 'order_updated': $this->syncSessionTotals($session, $data); - if ($session->status !== CheckoutSession::STATUS_COMPLETED) { + + if ($status === 'paid') { + if ($session->status !== CheckoutSession::STATUS_COMPLETED) { + $this->sessions->markProcessing($session, [ + 'lemonsqueezy_status' => $status ?: 'paid', + ]); + + $this->assignment->finalise($session, [ + 'source' => 'lemonsqueezy_webhook', + 'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY, + 'provider_reference' => $data['id'] ?? null, + 'payload' => $data, + ]); + + $this->sessions->markCompleted($session, now()); + $this->couponRedemptions->recordSuccess($session, $data); + } + } else { $this->sessions->markProcessing($session, [ - 'paddle_status' => $status ?: 'completed', + 'lemonsqueezy_status' => $status ?: null, ]); - - $this->assignment->finalise($session, [ - 'source' => 'paddle_webhook', - 'provider' => CheckoutSession::PROVIDER_PADDLE, - 'provider_reference' => $data['id'] ?? null, - 'payload' => $data, - ]); - - $this->sessions->markCompleted($session, now()); - $this->couponRedemptions->recordSuccess($session, $data); } return true; - case 'transaction.failed': - case 'transaction.cancelled': - $reason = $status ?: ($eventType === 'transaction.failed' ? 'paddle_failed' : 'paddle_cancelled'); + case 'order_payment_failed': + $reason = $status ?: 'lemonsqueezy_failed'; $this->sessions->markFailed($session, $reason); $this->couponRedemptions->recordFailure($session, $reason); return true; + case 'order_refunded': + $this->sessions->markFailed($session, 'lemonsqueezy_refunded'); + $this->couponRedemptions->recordFailure($session, 'lemonsqueezy_refunded'); + + return true; + default: return false; } @@ -149,7 +164,7 @@ class CheckoutWebhookService protected function syncSessionTotals(CheckoutSession $session, array $data): void { - $totals = $this->normalizePaddleTotals($data); + $totals = $this->normalizeLemonSqueezyTotals($data); if ($totals === []) { return; @@ -178,29 +193,22 @@ class CheckoutWebhookService } $this->mergeProviderMetadata($session, [ - 'paddle_totals' => $totals, + 'lemonsqueezy_totals' => $totals, ]); } /** * @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float} */ - protected function normalizePaddleTotals(array $data): array + protected function normalizeLemonSqueezyTotals(array $data): array { - $totals = Arr::get($data, 'details.totals', Arr::get($data, 'totals', [])); - $currency = Arr::get($totals, 'currency_code') - ?? $data['currency_code'] ?? Arr::get($totals, 'currency') ?? Arr::get($data, 'currency'); + $attributes = Arr::get($data, 'attributes', []); + $currency = Arr::get($attributes, 'currency'); - $subtotal = $this->convertMinorAmount(Arr::get($totals, 'subtotal.amount', $totals['subtotal'] ?? null)); - $discount = $this->convertMinorAmount(Arr::get($totals, 'discount.amount', $totals['discount'] ?? null)); - $tax = $this->convertMinorAmount(Arr::get($totals, 'tax.amount', $totals['tax'] ?? null)); - $total = $this->convertMinorAmount( - Arr::get( - $totals, - 'total.amount', - $totals['total'] ?? Arr::get($totals, 'grand_total.amount', $totals['grand_total'] ?? null) - ) - ); + $subtotal = $this->convertMinorAmount(Arr::get($attributes, 'subtotal')); + $discount = $this->convertMinorAmount(Arr::get($attributes, 'discount_total')); + $tax = $this->convertMinorAmount(Arr::get($attributes, 'tax')); + $total = $this->convertMinorAmount(Arr::get($attributes, 'total')); return array_filter([ 'currency' => $currency ? strtoupper((string) $currency) : null, @@ -228,7 +236,7 @@ class CheckoutWebhookService return round(((float) $value) / 100, 2); } - protected function handlePaddleSubscriptionEvent(string $eventType, array $data): bool + protected function handleLemonSqueezySubscriptionEvent(string $eventType, array $data, array $event): bool { $subscriptionId = $data['id'] ?? null; @@ -236,11 +244,11 @@ class CheckoutWebhookService return false; } - $customData = $this->extractCustomData($data); + $customData = $this->extractCustomData($event); $tenant = $this->resolveTenantFromSubscription($data, $customData, $subscriptionId); if (! $tenant) { - Log::info('[CheckoutWebhook] Paddle subscription tenant not resolved', [ + Log::info('[CheckoutWebhook] Lemon Squeezy subscription tenant not resolved', [ 'subscription_id' => $subscriptionId, ]); @@ -250,14 +258,14 @@ class CheckoutWebhookService $package = $this->resolvePackageFromSubscription($data, $customData, $subscriptionId); if (! $package) { - Log::info('[CheckoutWebhook] Paddle subscription package not resolved', [ + Log::info('[CheckoutWebhook] Lemon Squeezy subscription package not resolved', [ 'subscription_id' => $subscriptionId, ]); return false; } - $status = strtolower((string) ($data['status'] ?? '')); + $status = Str::lower((string) Arr::get($data, 'attributes.status', '')); $expiresAt = $this->resolveSubscriptionExpiry($data); $startedAt = $this->resolveSubscriptionStart($data); @@ -267,7 +275,7 @@ class CheckoutWebhookService ]); $tenantPackage->fill([ - 'paddle_subscription_id' => $subscriptionId, + 'lemonsqueezy_subscription_id' => $subscriptionId, 'price' => $package->price, ]); @@ -279,17 +287,17 @@ class CheckoutWebhookService $tenantPackage->active = $this->isSubscriptionActive($status); $tenantPackage->save(); - if ($eventType === 'subscription.cancelled' || $eventType === 'subscription.paused') { + if (in_array($eventType, ['subscription_cancelled', 'subscription_expired', 'subscription_paused'], true)) { $tenantPackage->forceFill(['active' => false])->save(); } $tenant->forceFill([ 'subscription_status' => $this->mapSubscriptionStatus($status), 'subscription_expires_at' => $expiresAt, - 'paddle_customer_id' => $tenant->paddle_customer_id ?: ($data['customer_id'] ?? null), + 'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id ?: Arr::get($data, 'attributes.customer_id'), ])->save(); - Log::info('[CheckoutWebhook] Paddle subscription event processed', [ + Log::info('[CheckoutWebhook] Lemon Squeezy subscription event processed', [ 'tenant_id' => $tenant->id, 'package_id' => $package->id, 'subscription_id' => $subscriptionId, @@ -309,20 +317,20 @@ class CheckoutWebhookService } } - $customerId = $data['customer_id'] ?? null; + $customerId = Arr::get($data, 'attributes.customer_id') ?? Arr::get($data, 'relationships.customer.data.id'); if ($customerId) { - $tenant = Tenant::where('paddle_customer_id', $customerId)->first(); + $tenant = Tenant::where('lemonsqueezy_customer_id', $customerId)->first(); if ($tenant) { return $tenant; } } - $subscription = $this->paddleSubscriptions->retrieve($subscriptionId); - $customerId = Arr::get($subscription, 'data.customer_id'); + $subscription = $this->lemonsqueezySubscriptions->retrieve($subscriptionId); + $customerId = Arr::get($subscription, 'attributes.customer_id') ?? Arr::get($subscription, 'relationships.customer.data.id'); if ($customerId) { - return Tenant::where('paddle_customer_id', $customerId)->first(); + return Tenant::where('lemonsqueezy_customer_id', $customerId)->first(); } return null; @@ -337,20 +345,20 @@ class CheckoutWebhookService } } - $priceId = Arr::get($data, 'items.0.price_id') ?? Arr::get($data, 'items.0.price.id'); + $variantId = Arr::get($data, 'attributes.variant_id') ?? Arr::get($data, 'relationships.variant.data.id'); - if ($priceId) { - $package = Package::withTrashed()->where('paddle_price_id', $priceId)->first(); + if ($variantId) { + $package = Package::withTrashed()->where('lemonsqueezy_variant_id', $variantId)->first(); if ($package) { return $package; } } - $subscription = $this->paddleSubscriptions->retrieve($subscriptionId); - $priceId = Arr::get($subscription, 'data.items.0.price_id') ?? Arr::get($subscription, 'data.items.0.price.id'); + $subscription = $this->lemonsqueezySubscriptions->retrieve($subscriptionId); + $variantId = Arr::get($subscription, 'attributes.variant_id') ?? Arr::get($subscription, 'relationships.variant.data.id'); - if ($priceId) { - return Package::withTrashed()->where('paddle_price_id', $priceId)->first(); + if ($variantId) { + return Package::withTrashed()->where('lemonsqueezy_variant_id', $variantId)->first(); } return null; @@ -358,35 +366,35 @@ class CheckoutWebhookService protected function resolveSubscriptionExpiry(array $data): ?Carbon { - $nextBilling = Arr::get($data, 'next_billing_date') ?? Arr::get($data, 'next_payment_date'); + $nextBilling = Arr::get($data, 'attributes.renews_at'); if ($nextBilling) { return Carbon::parse($nextBilling); } - $endsAt = Arr::get($data, 'billing_period_ends_at') ?? Arr::get($data, 'pays_out_at'); + $endsAt = Arr::get($data, 'attributes.ends_at'); return $endsAt ? Carbon::parse($endsAt) : null; } protected function resolveSubscriptionStart(array $data): Carbon { - $created = Arr::get($data, 'created_at') ?? Arr::get($data, 'activated_at'); + $created = Arr::get($data, 'attributes.created_at'); return $created ? Carbon::parse($created) : now(); } protected function isSubscriptionActive(string $status): bool { - return in_array($status, ['active', 'trialing'], true); + return in_array($status, ['active', 'on_trial'], true); } protected function mapSubscriptionStatus(string $status): string { return match ($status) { - 'active', 'trialing' => 'active', - 'paused' => 'suspended', - 'cancelled', 'past_due', 'halted' => 'expired', + 'active', 'on_trial' => 'active', + 'past_due', 'unpaid', 'paused' => 'suspended', + 'cancelled', 'expired' => 'expired', default => 'free', }; } @@ -397,9 +405,9 @@ class CheckoutWebhookService $session->save(); } - protected function isGiftVoucherEvent(array $data): bool + protected function isGiftVoucherEvent(array $event): bool { - $metadata = $this->extractCustomData($data); + $metadata = $this->extractCustomData($event); $type = is_array($metadata) ? ($metadata['type'] ?? $metadata['kind'] ?? $metadata['category'] ?? null) : null; @@ -407,18 +415,19 @@ class CheckoutWebhookService return true; } - $priceId = $data['price_id'] ?? Arr::get($metadata, 'paddle_price_id'); + $variantId = data_get($event, 'data.attributes.variant_id') + ?? Arr::get($metadata, 'lemonsqueezy_variant_id'); $tiers = collect(config('gift-vouchers.tiers', [])) - ->pluck('paddle_price_id') + ->pluck('lemonsqueezy_variant_id') ->filter() ->all(); - return $priceId && in_array($priceId, $tiers, true); + return $variantId && in_array($variantId, $tiers, true); } - protected function locatePaddleSession(array $data): ?CheckoutSession + protected function locateLemonSqueezySession(array $event): ?CheckoutSession { - $metadata = $this->extractCustomData($data); + $metadata = $this->extractCustomData($event); if (is_array($metadata)) { $sessionId = $metadata['checkout_session_id'] ?? null; @@ -444,11 +453,13 @@ class CheckoutWebhookService } } - $checkoutId = $data['checkout_id'] ?? Arr::get($data, 'details.checkout_id'); + $checkoutId = data_get($event, 'data.attributes.checkout_id') + ?? Arr::get($metadata, 'lemonsqueezy_checkout_id') + ?? Arr::get($metadata, 'checkout_id'); if ($checkoutId) { return CheckoutSession::query() - ->where('provider_metadata->paddle_checkout_id', $checkoutId) + ->where('provider_metadata->lemonsqueezy_checkout_id', $checkoutId) ->first(); } @@ -463,8 +474,16 @@ class CheckoutWebhookService { $customData = []; + if (isset($data['meta']['custom_data']) && is_array($data['meta']['custom_data'])) { + $customData = $data['meta']['custom_data']; + } + + if (isset($data['attributes']['custom_data']) && is_array($data['attributes']['custom_data'])) { + $customData = array_merge($customData, $data['attributes']['custom_data']); + } + if (isset($data['custom_data']) && is_array($data['custom_data'])) { - $customData = $data['custom_data']; + $customData = array_merge($customData, $data['custom_data']); } if (isset($data['customData']) && is_array($data['customData'])) { diff --git a/app/Services/Coupons/CouponRedemptionService.php b/app/Services/Coupons/CouponRedemptionService.php index a9b37cf..418fe0c 100644 --- a/app/Services/Coupons/CouponRedemptionService.php +++ b/app/Services/Coupons/CouponRedemptionService.php @@ -31,7 +31,7 @@ class CouponRedemptionService return; } - $transactionId = Arr::get($payload, 'id') ?? $session->paddle_transaction_id; + $transactionId = Arr::get($payload, 'id') ?? $session->lemonsqueezy_order_id; $context = $this->resolveRequestContext($session); $fraudSnapshot = $this->buildFraudSnapshot($context); @@ -40,7 +40,7 @@ class CouponRedemptionService 'tenant_id' => $session->tenant_id, 'user_id' => $session->user_id, 'package_id' => $session->package_id, - 'paddle_transaction_id' => $transactionId, + 'lemonsqueezy_order_id' => $transactionId, 'status' => CouponRedemption::STATUS_SUCCESS, 'failure_reason' => null, 'amount_discounted' => $session->amount_discount, @@ -84,7 +84,7 @@ class CouponRedemptionService 'tenant_id' => $session->tenant_id, 'user_id' => $session->user_id, 'package_id' => $session->package_id, - 'paddle_transaction_id' => $session->paddle_transaction_id, + 'lemonsqueezy_order_id' => $session->lemonsqueezy_order_id, 'status' => CouponRedemption::STATUS_FAILED, 'failure_reason' => $reason, 'amount_discounted' => $session->amount_discount, diff --git a/app/Services/Coupons/CouponService.php b/app/Services/Coupons/CouponService.php index ccd4344..81769c6 100644 --- a/app/Services/Coupons/CouponService.php +++ b/app/Services/Coupons/CouponService.php @@ -8,16 +8,12 @@ use App\Models\Coupon; use App\Models\CouponRedemption; use App\Models\Package; use App\Models\Tenant; -use App\Services\Paddle\Exceptions\PaddleException; -use App\Services\Paddle\PaddleDiscountService; -use Illuminate\Support\Arr; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; class CouponService { - public function __construct(private readonly PaddleDiscountService $paddleDiscounts) {} + public function __construct() {} /** * @return array{coupon: Coupon, pricing: array, source: string} @@ -39,7 +35,7 @@ class CouponService public function ensureCouponCanBeApplied(Coupon $coupon, Package $package, ?Tenant $tenant = null): void { - if (! $coupon->paddle_discount_id) { + if (! $coupon->lemonsqueezy_discount_id) { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.not_synced'), ]); @@ -124,58 +120,12 @@ class CouponService $currency = Str::upper($package->currency ?? 'EUR'); $subtotal = (float) $package->price; - if ($package->paddle_price_id) { - try { - $preview = $this->paddleDiscounts->previewDiscount( - $coupon, - [ - [ - 'price_id' => $package->paddle_price_id, - 'quantity' => 1, - ], - ], - array_filter([ - 'currency' => $currency, - 'customer_id' => $tenant?->paddle_customer_id, - ]) - ); - - $mapped = $this->mapPaddlePreview($preview, $currency, $subtotal); - - return [ - 'pricing' => $mapped, - 'source' => 'paddle', - ]; - } catch (PaddleException $exception) { - Log::warning('Paddle preview failed, falling back to manual pricing', [ - 'coupon_id' => $coupon->id, - 'package_id' => $package->id, - 'message' => $exception->getMessage(), - ]); - } - } - return [ 'pricing' => $this->manualPricing($coupon, $currency, $subtotal), 'source' => 'manual', ]; } - protected function mapPaddlePreview(array $preview, string $currency, float $fallbackSubtotal): array - { - $totals = $this->extractTotals($preview); - - $subtotal = $totals['subtotal'] ?? $fallbackSubtotal; - $discount = $totals['discount'] ?? 0.0; - $tax = $totals['tax'] ?? 0.0; - $total = $totals['total'] ?? max($subtotal - $discount + $tax, 0); - - return $this->formatPricing($currency, $subtotal, $discount, $tax, $total, [ - 'raw' => $preview, - 'breakdown' => $totals['breakdown'] ?? [], - ]); - } - protected function manualPricing(Coupon $coupon, string $currency, float $subtotal): array { $discount = match ($coupon->type) { @@ -199,42 +149,6 @@ class CouponService ]); } - protected function extractTotals(array $preview): array - { - $totals = Arr::get($preview, 'totals', Arr::get($preview, 'details.totals', [])); - - $subtotal = $this->convertMinorAmount($totals['subtotal'] ?? ($totals['subtotal']['amount'] ?? null)); - $discount = $this->convertMinorAmount($totals['discount'] ?? ($totals['discount']['amount'] ?? null)); - $tax = $this->convertMinorAmount($totals['tax'] ?? ($totals['tax']['amount'] ?? null)); - $total = $this->convertMinorAmount($totals['total'] ?? ($totals['total']['amount'] ?? null)); - - return array_filter([ - 'currency' => $totals['currency_code'] ?? Arr::get($preview, 'currency_code'), - 'subtotal' => $subtotal, - 'discount' => $discount, - 'tax' => $tax, - 'total' => $total, - 'breakdown' => Arr::get($preview, 'discounts', []), - ], static fn ($value) => $value !== null && $value !== ''); - } - - protected function convertMinorAmount(mixed $value): ?float - { - if ($value === null || $value === '') { - return null; - } - - if (is_array($value) && isset($value['amount'])) { - $value = $value['amount']; - } - - if (! is_numeric($value)) { - return null; - } - - return round(((float) $value) / 100, 2); - } - protected function formatPricing(string $currency, float $subtotal, float $discount, float $tax, float $total, array $extra = []): array { $locale = $this->mapLocale(app()->getLocale()); diff --git a/app/Services/GiftVouchers/GiftVoucherCheckoutService.php b/app/Services/GiftVouchers/GiftVoucherCheckoutService.php index 19280dc..9da6751 100644 --- a/app/Services/GiftVouchers/GiftVoucherCheckoutService.php +++ b/app/Services/GiftVouchers/GiftVoucherCheckoutService.php @@ -2,34 +2,32 @@ namespace App\Services\GiftVouchers; -use App\Services\Paddle\Exceptions\PaddleException; -use App\Services\Paddle\PaddleClient; -use Illuminate\Support\Arr; +use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; use Illuminate\Support\Facades\App; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; class GiftVoucherCheckoutService { - public function __construct(private readonly PaddleClient $client) {} + public function __construct(private readonly LemonSqueezyCheckoutService $checkout) {} /** - * @return array + * @return array */ public function tiers(): array { return collect(config('gift-vouchers.tiers', [])) ->map(function (array $tier): array { $currency = Str::upper($tier['currency'] ?? 'EUR'); - $priceId = $tier['paddle_price_id'] ?? null; + $variantId = $tier['lemonsqueezy_variant_id'] ?? null; return [ 'key' => $tier['key'], 'label' => $tier['label'], 'amount' => (float) $tier['amount'], 'currency' => $currency, - 'paddle_price_id' => $priceId, - 'can_checkout' => ! empty($priceId), + 'lemonsqueezy_variant_id' => $variantId, + 'can_checkout' => ! empty($variantId), ]; }) ->values() @@ -44,47 +42,34 @@ class GiftVoucherCheckoutService { $tier = $this->findTier($data['tier_key']); - if (! $tier || empty($tier['paddle_price_id'])) { + if (! $tier || empty($tier['lemonsqueezy_variant_id'])) { throw ValidationException::withMessages([ 'tier_key' => __('Gift voucher is not available right now.'), ]); } - $customerId = $this->ensureCustomerId($data['purchaser_email']); + $customData = array_filter([ + 'type' => 'gift_voucher', + 'tier_key' => $tier['key'], + 'purchaser_email' => $data['purchaser_email'], + 'recipient_email' => $data['recipient_email'] ?? null, + 'recipient_name' => $data['recipient_name'] ?? null, + 'message' => $data['message'] ?? null, + 'app_locale' => App::getLocale(), + 'success_url' => $data['success_url'] ?? null, + 'return_url' => $data['return_url'] ?? null, + 'lemonsqueezy_variant_id' => $tier['lemonsqueezy_variant_id'], + ], static fn ($value) => $value !== null && $value !== ''); - $payload = [ - 'items' => [ - [ - 'price_id' => $tier['paddle_price_id'], - 'quantity' => 1, - ], - ], - 'customer_id' => $customerId, - 'custom_data' => array_filter([ - 'type' => 'gift_voucher', - 'tier_key' => $tier['key'], - 'purchaser_email' => $data['purchaser_email'], - 'recipient_email' => $data['recipient_email'] ?? null, - 'recipient_name' => $data['recipient_name'] ?? null, - 'message' => $data['message'] ?? null, - 'app_locale' => App::getLocale(), + return $this->checkout->createVariantCheckout( + (string) $tier['lemonsqueezy_variant_id'], + $customData, + [ 'success_url' => $data['success_url'] ?? null, - 'cancel_url' => $data['return_url'] ?? null, - ]), - ]; - - $response = $this->client->post('/transactions', $payload); - - return [ - 'checkout_url' => Arr::get($response, 'data.checkout.url') - ?? Arr::get($response, 'checkout.url') - ?? Arr::get($response, 'data.url') - ?? Arr::get($response, 'url'), - 'expires_at' => Arr::get($response, 'data.checkout.expires_at') - ?? Arr::get($response, 'data.expires_at') - ?? Arr::get($response, 'expires_at'), - 'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'), - ]; + 'return_url' => $data['return_url'] ?? null, + 'customer_email' => $data['purchaser_email'], + ] + ); } /** @@ -105,43 +90,4 @@ class GiftVoucherCheckoutService return $tier; } - - protected function ensureCustomerId(string $email): string - { - $payload = ['email' => $email]; - - try { - $response = $this->client->post('/customers', $payload); - } catch (PaddleException $exception) { - $customerId = $this->resolveExistingCustomerId($email, $exception); - - if ($customerId) { - return $customerId; - } - - throw $exception; - } - - $customerId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id'); - - if (! $customerId) { - throw new PaddleException('Failed to create Paddle customer.'); - } - - return $customerId; - } - - protected function resolveExistingCustomerId(string $email, PaddleException $exception): ?string - { - if ($exception->status() !== 409 || Arr::get($exception->context(), 'error.code') !== 'customer_already_exists') { - return null; - } - - $response = $this->client->get('/customers', [ - 'email' => $email, - 'per_page' => 1, - ]); - - return Arr::get($response, 'data.0.id') ?? Arr::get($response, 'data.0.customer_id'); - } } diff --git a/app/Services/GiftVouchers/GiftVoucherService.php b/app/Services/GiftVouchers/GiftVoucherService.php index 84ab212..24f288b 100644 --- a/app/Services/GiftVouchers/GiftVoucherService.php +++ b/app/Services/GiftVouchers/GiftVoucherService.php @@ -5,12 +5,11 @@ namespace App\Services\GiftVouchers; use App\Enums\CouponStatus; use App\Enums\CouponType; use App\Jobs\NotifyGiftVoucherReminder; -use App\Jobs\SyncCouponToPaddle; use App\Mail\GiftVoucherIssued; use App\Models\Coupon; use App\Models\GiftVoucher; use App\Models\Package; -use App\Services\Paddle\PaddleTransactionService; +use App\Services\LemonSqueezy\LemonSqueezyOrderService; use Carbon\Carbon; use Illuminate\Support\Arr; use Illuminate\Support\Collection; @@ -21,15 +20,15 @@ use Illuminate\Validation\ValidationException; class GiftVoucherService { - public function __construct(private readonly PaddleTransactionService $transactions) {} + public function __construct(private readonly LemonSqueezyOrderService $orders) {} /** - * Create a voucher from a Paddle transaction payload. + * Create a voucher from a Lemon Squeezy order payload. */ - public function issueFromPaddle(array $payload): GiftVoucher + public function issueFromLemonSqueezy(array $payload): GiftVoucher { $metadata = $this->extractCustomData($payload); - $priceId = $this->resolvePriceId($payload); + $variantId = $this->resolveVariantId($payload); $amount = $this->resolveAmount($payload); $currency = Str::upper($this->resolveCurrency($payload)); $locale = $metadata['app_locale'] ?? app()->getLocale(); @@ -37,9 +36,10 @@ class GiftVoucherService $expiresAt = now()->addYears((int) config('gift-vouchers.default_valid_years', 5)); - if (! empty($payload['id'])) { + $orderId = data_get($payload, 'data.id'); + if ($orderId) { $existing = GiftVoucher::query() - ->where('paddle_transaction_id', $payload['id']) + ->where('lemonsqueezy_order_id', $orderId) ->first(); } @@ -47,19 +47,19 @@ class GiftVoucherService $voucher = GiftVoucher::query()->updateOrCreate( [ - 'paddle_transaction_id' => $payload['id'] ?? null, + 'lemonsqueezy_order_id' => $orderId, ], [ 'code' => $metadata['gift_code'] ?? $this->generateCode(), 'amount' => $amount, 'currency' => $currency, 'status' => GiftVoucher::STATUS_ISSUED, - 'purchaser_email' => $metadata['purchaser_email'] ?? Arr::get($payload, 'customer.email'), + 'purchaser_email' => $metadata['purchaser_email'] ?? data_get($payload, 'data.attributes.user_email'), 'recipient_email' => $metadata['recipient_email'] ?? null, 'recipient_name' => $metadata['recipient_name'] ?? null, 'message' => $metadata['message'] ?? null, - 'paddle_checkout_id' => $payload['checkout_id'] ?? Arr::get($payload, 'details.checkout_id'), - 'paddle_price_id' => $priceId, + 'lemonsqueezy_checkout_id' => data_get($payload, 'data.attributes.checkout_id'), + 'lemonsqueezy_variant_id' => $variantId, 'metadata' => $mergedMetadata, 'expires_at' => $expiresAt, 'refunded_at' => null, @@ -70,7 +70,6 @@ class GiftVoucherService if (! $voucher->coupon_id) { $coupon = $this->createCouponForVoucher($voucher); $voucher->forceFill(['coupon_id' => $coupon->id])->save(); - SyncCouponToPaddle::dispatch($coupon); } $notificationsSent = (bool) Arr::get($voucher->metadata ?? [], 'notifications_sent', false); @@ -128,13 +127,13 @@ class GiftVoucherService ]); } - if (! $voucher->paddle_transaction_id) { + if (! $voucher->lemonsqueezy_order_id) { throw ValidationException::withMessages([ - 'voucher' => __('Missing Paddle transaction for refund.'), + 'voucher' => __('Missing Lemon Squeezy order for refund.'), ]); } - $response = $this->transactions->refund($voucher->paddle_transaction_id, array_filter([ + $response = $this->orders->refund($voucher->lemonsqueezy_order_id, array_filter([ 'reason' => $reason, ])); @@ -172,6 +171,7 @@ class GiftVoucherService 'description' => 'Geschenkgutschein '.number_format((float) $voucher->amount, 2).' '.$voucher->currency.' für Endkunden-Pakete.', 'starts_at' => now(), 'ends_at' => $voucher->expires_at, + 'lemonsqueezy_discount_id' => $voucher->code, ]); if ($packages->isNotEmpty()) { @@ -187,41 +187,32 @@ class GiftVoucherService return Package::query() ->whereIn('type', $types) - ->whereNotNull('paddle_price_id') + ->whereNotNull('lemonsqueezy_variant_id') ->get(['id']); } - protected function resolvePriceId(array $payload): ?string + protected function resolveVariantId(array $payload): ?string { $metadata = $this->extractCustomData($payload); - if (is_array($metadata) && ! empty($metadata['paddle_price_id'])) { - return $metadata['paddle_price_id']; + if (is_array($metadata) && ! empty($metadata['lemonsqueezy_variant_id'])) { + return $metadata['lemonsqueezy_variant_id']; } - $items = Arr::get($payload, 'items', Arr::get($payload, 'details.items', [])); - if (is_array($items) && isset($items[0]['price_id'])) { - return $items[0]['price_id']; - } - - return $payload['price_id'] ?? null; + return data_get($payload, 'data.attributes.variant_id'); } protected function resolveAmount(array $payload): float { $tiers = Collection::make(config('gift-vouchers.tiers', [])) - ->keyBy(fn ($tier) => $tier['paddle_price_id'] ?? null); + ->keyBy(fn ($tier) => $tier['lemonsqueezy_variant_id'] ?? null); - $priceId = $this->resolvePriceId($payload); - if ($priceId && $tiers->has($priceId)) { - return (float) $tiers->get($priceId)['amount']; + $variantId = $this->resolveVariantId($payload); + if ($variantId && $tiers->has($variantId)) { + return (float) $tiers->get($variantId)['amount']; } - $amount = Arr::get($payload, 'totals.grand_total.amount') - ?? Arr::get($payload, 'totals.grand_total') - ?? Arr::get($payload, 'details.totals.grand_total.amount') - ?? Arr::get($payload, 'details.totals.grand_total') - ?? Arr::get($payload, 'amount'); + $amount = data_get($payload, 'data.attributes.total'); if (is_numeric($amount)) { $value = (float) $amount; @@ -236,10 +227,7 @@ class GiftVoucherService protected function resolveCurrency(array $payload): string { - return $payload['currency_code'] - ?? Arr::get($payload, 'details.totals.currency_code') - ?? Arr::get($payload, 'currency') - ?? 'EUR'; + return (string) (data_get($payload, 'data.attributes.currency') ?? 'EUR'); } /** @@ -250,8 +238,16 @@ class GiftVoucherService { $customData = []; + if (isset($payload['meta']['custom_data']) && is_array($payload['meta']['custom_data'])) { + $customData = $payload['meta']['custom_data']; + } + + if (isset($payload['attributes']['custom_data']) && is_array($payload['attributes']['custom_data'])) { + $customData = array_merge($customData, $payload['attributes']['custom_data']); + } + if (isset($payload['custom_data']) && is_array($payload['custom_data'])) { - $customData = $payload['custom_data']; + $customData = array_merge($customData, $payload['custom_data']); } if (isset($payload['customData']) && is_array($payload['customData'])) { diff --git a/app/Services/Integrations/IntegrationHealthService.php b/app/Services/Integrations/IntegrationHealthService.php index 7f0338f..61d8e76 100644 --- a/app/Services/Integrations/IntegrationHealthService.php +++ b/app/Services/Integrations/IntegrationHealthService.php @@ -15,8 +15,8 @@ class IntegrationHealthService public function providers(): array { return [ - $this->buildProvider('paddle', 'Paddle', [ - 'is_configured' => filled(config('paddle.webhook_secret')), + $this->buildProvider('lemonsqueezy', 'Lemon Squeezy', [ + 'is_configured' => filled(config('lemonsqueezy.webhook_secret')), 'label' => 'Webhook secret', ]), $this->buildProvider('revenuecat', 'RevenueCat', [ diff --git a/app/Services/Paddle/Exceptions/PaddleException.php b/app/Services/LemonSqueezy/Exceptions/LemonSqueezyException.php similarity index 79% rename from app/Services/Paddle/Exceptions/PaddleException.php rename to app/Services/LemonSqueezy/Exceptions/LemonSqueezyException.php index a6e8370..6fa337e 100644 --- a/app/Services/Paddle/Exceptions/PaddleException.php +++ b/app/Services/LemonSqueezy/Exceptions/LemonSqueezyException.php @@ -1,10 +1,10 @@ @@ -124,7 +124,7 @@ class PaddleAddonCatalogService $metaPrice = $addon->metadata['price_eur'] ?? null; if (! is_numeric($metaPrice)) { - throw new PaddleException('No unit price specified for addon. Provide metadata[price_eur] or overrides.unit_price.'); + throw new LemonSqueezyException('No unit price specified for addon. Provide metadata[price_eur] or overrides.unit_price.'); } $amountCents = (int) round(((float) $metaPrice) * 100); diff --git a/app/Services/Paddle/PaddleCatalogService.php b/app/Services/LemonSqueezy/LemonSqueezyCatalogService.php similarity index 97% rename from app/Services/Paddle/PaddleCatalogService.php rename to app/Services/LemonSqueezy/LemonSqueezyCatalogService.php index 63d3f4d..c76bfc7 100644 --- a/app/Services/Paddle/PaddleCatalogService.php +++ b/app/Services/LemonSqueezy/LemonSqueezyCatalogService.php @@ -1,14 +1,14 @@ @@ -63,7 +63,7 @@ class PaddleCatalogService { $payload = $this->buildPricePayload( $package, - $overrides['product_id'] ?? $package->paddle_product_id, + $overrides['product_id'] ?? $package->lemonsqueezy_product_id, $overrides, includeProduct: false ); diff --git a/app/Services/LemonSqueezy/LemonSqueezyCheckoutService.php b/app/Services/LemonSqueezy/LemonSqueezyCheckoutService.php new file mode 100644 index 0000000..6e67f0c --- /dev/null +++ b/app/Services/LemonSqueezy/LemonSqueezyCheckoutService.php @@ -0,0 +1,128 @@ +buildCustomData( + $tenant, + $package, + array_merge( + $options['metadata'] ?? [], + $options['custom_data'] ?? [], + array_filter([ + 'success_url' => $options['success_url'] ?? null, + 'return_url' => $options['return_url'] ?? null, + ], static fn ($value) => $value !== null && $value !== '') + ) + ); + + return $this->createVariantCheckout((string) $package->lemonsqueezy_variant_id, $customData, $options + [ + 'customer_email' => $options['customer_email'] ?? null, + 'customer_name' => $options['customer_name'] ?? null, + 'store_id' => $storeId, + ]); + } + + /** + * @param array{success_url?: string|null, return_url?: string|null, discount_code?: string|null, customer_email?: string|null, customer_name?: string|null, store_id?: string|null} $options + */ + public function createVariantCheckout(string $variantId, array $customData, array $options = []): array + { + $storeId = $options['store_id'] ?? (string) config('lemonsqueezy.store_id'); + + $attributes = array_filter([ + 'checkout_data' => array_filter([ + 'custom' => $customData, + 'email' => $options['customer_email'] ?? null, + 'name' => $options['customer_name'] ?? null, + 'discount_code' => $options['discount_code'] ?? null, + ], static fn ($value) => $value !== null && $value !== ''), + 'checkout_options' => [ + 'embed' => true, + ], + 'product_options' => array_filter([ + 'redirect_url' => $options['success_url'] ?? null, + ], static fn ($value) => $value !== null && $value !== ''), + 'test_mode' => (bool) config('lemonsqueezy.test_mode', false), + ], static fn ($value) => $value !== null && $value !== ''); + + $payload = [ + 'data' => [ + 'type' => 'checkouts', + 'attributes' => $attributes, + 'relationships' => [ + 'store' => [ + 'data' => [ + 'type' => 'stores', + 'id' => $storeId, + ], + ], + 'variant' => [ + 'data' => [ + 'type' => 'variants', + 'id' => $variantId, + ], + ], + ], + ], + ]; + + $response = $this->client->post('/checkouts', $payload); + + $checkoutUrl = Arr::get($response, 'data.attributes.url') + ?? Arr::get($response, 'data.attributes.checkout_url') + ?? Arr::get($response, 'data.url') + ?? Arr::get($response, 'url'); + + if (! $checkoutUrl) { + Log::warning('Lemon Squeezy checkout response missing url', ['response' => $response]); + } + + return [ + 'checkout_url' => $checkoutUrl, + 'expires_at' => Arr::get($response, 'data.attributes.expires_at'), + 'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'), + ]; + } + + /** + * @param array $extra + * @return array + */ + protected function buildCustomData(Tenant $tenant, Package $package, array $extra = []): array + { + $metadata = [ + 'tenant_id' => (string) $tenant->id, + 'package_id' => (string) $package->id, + ]; + + foreach ($extra as $key => $value) { + if (! is_string($key)) { + continue; + } + + if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) { + $metadata[$key] = (string) $value; + } + } + + return $metadata; + } +} diff --git a/app/Services/Paddle/PaddleClient.php b/app/Services/LemonSqueezy/LemonSqueezyClient.php similarity index 60% rename from app/Services/Paddle/PaddleClient.php rename to app/Services/LemonSqueezy/LemonSqueezyClient.php index 2ca6b6e..e423537 100644 --- a/app/Services/Paddle/PaddleClient.php +++ b/app/Services/LemonSqueezy/LemonSqueezyClient.php @@ -1,15 +1,14 @@ send(strtoupper($method), ltrim($endpoint, '/'), $options); } catch (RequestException $exception) { - throw new PaddleException($exception->getMessage(), $exception->response?->status(), $exception->response?->json() ?? []); + throw new LemonSqueezyException( + $exception->getMessage(), + $exception->response?->status(), + $exception->response?->json() ?? [] + ); } if ($response->failed()) { $body = $response->json() ?? []; - $message = Arr::get($body, 'error.message') + $message = Arr::get($body, 'errors.0.detail') + ?? Arr::get($body, 'error') ?? Arr::get($body, 'message') - ?? sprintf('Paddle request failed with status %s', $response->status()); + ?? sprintf('Lemon Squeezy request failed with status %s', $response->status()); - throw new PaddleException($message, $response->status(), $body); + throw new LemonSqueezyException($message, $response->status(), $body); } return $response->json() ?? []; @@ -59,23 +63,20 @@ class PaddleClient protected function preparedRequest(): PendingRequest { - $apiKey = config('paddle.api_key'); + $apiKey = config('lemonsqueezy.api_key'); if (! $apiKey) { - throw new PaddleException('Paddle API key is not configured.'); + throw new LemonSqueezyException('Lemon Squeezy API key is not configured.'); } - $baseUrl = rtrim((string) config('paddle.base_url'), '/'); - $environment = (string) config('paddle.environment', 'production'); - - $headers = [ - 'User-Agent' => sprintf('FotospielApp/%s PaddleClient', app()->version()), - 'Paddle-Environment' => Str::lower($environment) === 'sandbox' ? 'sandbox' : 'production', - 'Paddle-Version' => '1', - ]; + $baseUrl = rtrim((string) config('lemonsqueezy.base_url'), '/'); return $this->http ->baseUrl($baseUrl) - ->withHeaders($headers) + ->withHeaders([ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + 'User-Agent' => sprintf('FotospielApp/%s LemonSqueezyClient', app()->version()), + ]) ->withToken($apiKey) ->acceptJson() ->asJson(); diff --git a/app/Services/Paddle/PaddleDiscountService.php b/app/Services/LemonSqueezy/LemonSqueezyDiscountService.php similarity index 89% rename from app/Services/Paddle/PaddleDiscountService.php rename to app/Services/LemonSqueezy/LemonSqueezyDiscountService.php index d0e9430..819a2c4 100644 --- a/app/Services/Paddle/PaddleDiscountService.php +++ b/app/Services/LemonSqueezy/LemonSqueezyDiscountService.php @@ -1,6 +1,6 @@ @@ -34,24 +34,24 @@ class PaddleDiscountService */ public function updateDiscount(Coupon $coupon): array { - if (! $coupon->paddle_discount_id) { + if (! $coupon->lemonsqueezy_discount_id) { return $this->createDiscount($coupon); } $payload = $this->buildDiscountPayload($coupon); - $response = $this->client->patch('/discounts/'.$coupon->paddle_discount_id, $payload); + $response = $this->client->patch('/discounts/'.$coupon->lemonsqueezy_discount_id, $payload); return Arr::get($response, 'data', $response); } public function archiveDiscount(Coupon $coupon): void { - if (! $coupon->paddle_discount_id) { + if (! $coupon->lemonsqueezy_discount_id) { return; } - $this->client->delete('/discounts/'.$coupon->paddle_discount_id); + $this->client->delete('/discounts/'.$coupon->lemonsqueezy_discount_id); } /** @@ -63,7 +63,7 @@ class PaddleDiscountService { $payload = [ 'items' => $items, - 'discount_id' => $coupon->paddle_discount_id, + 'discount_id' => $coupon->lemonsqueezy_discount_id, ]; if (isset($context['currency'])) { @@ -128,7 +128,7 @@ class PaddleDiscountService 'currency_code' => Str::upper((string) ($coupon->currency ?? config('app.currency', 'EUR'))), 'enabled_for_checkout' => $coupon->enabled_for_checkout, 'description' => $this->resolveDescription($coupon), - 'mode' => $coupon->paddle_mode ?? 'standard', + 'mode' => $coupon->lemonsqueezy_mode ?? 'standard', 'usage_limit' => $coupon->usage_limit, 'maximum_recurring_intervals' => null, 'recur' => false, @@ -168,13 +168,13 @@ class PaddleDiscountService $packages = ($coupon->relationLoaded('packages') ? $coupon->packages : $coupon->packages()->get()) - ->whereNotNull('paddle_price_id'); + ->whereNotNull('lemonsqueezy_variant_id'); if ($packages->isEmpty()) { return null; } - $prices = $packages->pluck('paddle_price_id')->filter()->values(); + $prices = $packages->pluck('lemonsqueezy_variant_id')->filter()->values(); return $prices->isEmpty() ? null : $prices->all(); } diff --git a/app/Services/Paddle/PaddleGiftVoucherCatalogService.php b/app/Services/LemonSqueezy/LemonSqueezyGiftVoucherCatalogService.php similarity index 80% rename from app/Services/Paddle/PaddleGiftVoucherCatalogService.php rename to app/Services/LemonSqueezy/LemonSqueezyGiftVoucherCatalogService.php index a7d9976..63921b9 100644 --- a/app/Services/Paddle/PaddleGiftVoucherCatalogService.php +++ b/app/Services/LemonSqueezy/LemonSqueezyGiftVoucherCatalogService.php @@ -1,34 +1,34 @@ createProduct($tier)['id']; } - if (! $price) { - $price = $this->createPrice($tier, $product)['id']; + if (! $variant) { + $variant = $this->createPrice($tier, $product)['id']; } return [ 'product_id' => $product, - 'price_id' => $price, + 'variant_id' => $variant, ]; } diff --git a/app/Services/LemonSqueezy/LemonSqueezyOrderService.php b/app/Services/LemonSqueezy/LemonSqueezyOrderService.php new file mode 100644 index 0000000..8a4da0f --- /dev/null +++ b/app/Services/LemonSqueezy/LemonSqueezyOrderService.php @@ -0,0 +1,151 @@ +>, meta: array} + */ + public function listForCustomer(string $customerId, array $query = []): array + { + $payload = array_filter(array_merge([ + 'filter[customer_id]' => $customerId, + 'sort' => '-created_at', + ], $query), static fn ($value) => $value !== null && $value !== ''); + + $response = $this->client->get('/orders', $payload); + + $orders = Arr::get($response, 'data', []); + $meta = Arr::get($response, 'meta', []); + + if (! is_array($orders)) { + $orders = []; + } + + return [ + 'data' => array_map([$this, 'mapOrder'], $orders), + 'meta' => $this->mapPagination($meta), + ]; + } + + /** + * @return array + */ + public function retrieve(string $orderId): array + { + $response = $this->client->get("/orders/{$orderId}"); + $order = Arr::get($response, 'data'); + + return is_array($order) ? $order : (is_array($response) ? $response : []); + } + + /** + * @return array|null + */ + public function findByCheckoutId(string $checkoutId): ?array + { + $response = $this->client->get("/checkouts/{$checkoutId}"); + $checkout = Arr::get($response, 'data'); + + if (! is_array($checkout)) { + return null; + } + + $orderId = Arr::get($checkout, 'attributes.order_id'); + if (! $orderId) { + return null; + } + + return $this->retrieve((string) $orderId); + } + + /** + * Issue a refund for a Lemon Squeezy order. + * + * @param array{reason?: string|null} $options + * @return array + */ + public function refund(string $orderId, array $options = []): array + { + $payload = [ + 'data' => [ + 'type' => 'refunds', + 'attributes' => array_filter([ + 'order_id' => $orderId, + 'reason' => $options['reason'] ?? null, + ], static fn ($value) => $value !== null && $value !== ''), + ], + ]; + + return $this->client->post('/refunds', $payload); + } + + /** + * @param array $order + * @return array + */ + protected function mapOrder(array $order): array + { + $attributes = Arr::get($order, 'attributes', []); + + return [ + 'id' => $order['id'] ?? null, + 'order_number' => $attributes['order_number'] ?? null, + 'status' => $attributes['status'] ?? null, + 'amount' => $this->convertAmount($attributes['subtotal'] ?? null), + 'currency' => $attributes['currency'] ?? 'EUR', + 'origin' => 'lemonsqueezy', + 'checkout_id' => $attributes['checkout_id'] ?? null, + 'created_at' => $attributes['created_at'] ?? null, + 'updated_at' => $attributes['updated_at'] ?? null, + 'receipt_url' => Arr::get($attributes, 'urls.receipt'), + 'tax' => $this->convertAmount($attributes['tax'] ?? null), + 'grand_total' => $this->convertAmount($attributes['total'] ?? null), + ]; + } + + /** + * @param array $meta + * @return array + */ + protected function mapPagination(array $meta): array + { + $page = Arr::get($meta, 'page', []); + $current = (int) ($page['currentPage'] ?? $page['current_page'] ?? 1); + $totalPages = (int) ($page['totalPages'] ?? $page['total_pages'] ?? 1); + + return [ + 'next' => $current < $totalPages ? (string) ($current + 1) : null, + 'previous' => $current > 1 ? (string) ($current - 1) : null, + 'has_more' => $current < $totalPages, + ]; + } + + protected function convertAmount(mixed $value): ?float + { + if ($value === null || $value === '') { + return null; + } + + if (is_array($value) && isset($value['amount'])) { + $value = $value['amount']; + } + + if (is_string($value)) { + $value = preg_replace('/[^0-9.-]/', '', $value); + } + + if ($value === '' || $value === null) { + return null; + } + + $amount = (float) $value; + + return $amount / 100; + } +} diff --git a/app/Services/LemonSqueezy/LemonSqueezySubscriptionService.php b/app/Services/LemonSqueezy/LemonSqueezySubscriptionService.php new file mode 100644 index 0000000..1221812 --- /dev/null +++ b/app/Services/LemonSqueezy/LemonSqueezySubscriptionService.php @@ -0,0 +1,45 @@ + + */ + public function retrieve(string $subscriptionId): array + { + $response = $this->client->get("/subscriptions/{$subscriptionId}"); + + return Arr::get($response, 'data', is_array($response) ? $response : []); + } + + /** + * @param array $subscription + * @return array + */ + public function customData(array $subscription): array + { + $attributes = Arr::get($subscription, 'attributes', []); + + $custom = Arr::get($attributes, 'custom_data', Arr::get($attributes, 'custom', [])); + + return is_array($custom) ? $custom : []; + } + + public function portalUrl(array $subscription): ?string + { + return Arr::get($subscription, 'attributes.urls.customer_portal') + ?? Arr::get($subscription, 'attributes.urls.customer_portal_url'); + } + + public function updatePaymentMethodUrl(array $subscription): ?string + { + return Arr::get($subscription, 'attributes.urls.update_payment_method') + ?? Arr::get($subscription, 'attributes.urls.update_payment_method_url'); + } +} diff --git a/app/Services/Paddle/PaddleCheckoutService.php b/app/Services/Paddle/PaddleCheckoutService.php deleted file mode 100644 index 0f94d3d..0000000 --- a/app/Services/Paddle/PaddleCheckoutService.php +++ /dev/null @@ -1,95 +0,0 @@ -customers->ensureCustomerId($tenant); - - $customData = $this->buildMetadata( - $tenant, - $package, - array_merge( - $options['metadata'] ?? [], - $options['custom_data'] ?? [], - array_filter([ - 'success_url' => $options['success_url'] ?? null, - 'cancel_url' => $options['return_url'] ?? null, - ], static fn ($value) => $value !== null && $value !== '') - ) - ); - - $payload = [ - 'customer_id' => $customerId, - 'items' => [ - [ - 'price_id' => $package->paddle_price_id, - 'quantity' => 1, - ], - ], - 'custom_data' => $customData, - ]; - - if (! empty($options['discount_id'])) { - $payload['discount_id'] = $options['discount_id']; - } - - $response = $this->client->post('/transactions', $payload); - - $checkoutUrl = Arr::get($response, 'data.checkout.url') - ?? Arr::get($response, 'checkout.url') - ?? Arr::get($response, 'data.url') - ?? Arr::get($response, 'url'); - - if (! $checkoutUrl) { - Log::warning('Paddle checkout response missing url', ['response' => $response]); - } - - return [ - 'checkout_url' => $checkoutUrl, - 'expires_at' => Arr::get($response, 'data.checkout.expires_at') - ?? Arr::get($response, 'data.expires_at') - ?? Arr::get($response, 'expires_at'), - 'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'), - ]; - } - - /** - * @param array $extra - * @return array - */ - protected function buildMetadata(Tenant $tenant, Package $package, array $extra = []): array - { - $metadata = [ - 'tenant_id' => (string) $tenant->id, - 'package_id' => (string) $package->id, - ]; - - foreach ($extra as $key => $value) { - if (! is_string($key)) { - continue; - } - - if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) { - $metadata[$key] = (string) $value; - } - } - - return $metadata; - } -} diff --git a/app/Services/Paddle/PaddleCustomerPortalService.php b/app/Services/Paddle/PaddleCustomerPortalService.php deleted file mode 100644 index fdb3edc..0000000 --- a/app/Services/Paddle/PaddleCustomerPortalService.php +++ /dev/null @@ -1,29 +0,0 @@ -} $options - * @return array - */ - public function createSession(string $customerId, array $options = []): array - { - $payload = []; - - if (! empty($options['subscription_ids'])) { - $payload['subscription_ids'] = array_values( - array_filter($options['subscription_ids'], 'is_string') - ); - } - - if ($payload === []) { - $payload = (object) []; - } - - return $this->client->post("/customers/{$customerId}/portal-sessions", $payload); - } -} diff --git a/app/Services/Paddle/PaddleCustomerService.php b/app/Services/Paddle/PaddleCustomerService.php deleted file mode 100644 index 2437268..0000000 --- a/app/Services/Paddle/PaddleCustomerService.php +++ /dev/null @@ -1,80 +0,0 @@ -paddle_customer_id) { - return $tenant->paddle_customer_id; - } - - $email = $tenant->contact_email ?: ($tenant->user?->email ?? null); - - $payload = [ - 'email' => $email, - 'name' => $tenant->name, - ]; - - if (! $payload['email']) { - throw new PaddleException('Tenant email address required to create Paddle customer.'); - } - - try { - $response = $this->client->post('/customers', $payload); - } catch (PaddleException $exception) { - $existingCustomerId = $this->resolveExistingCustomerId($tenant, $email, $exception); - if ($existingCustomerId) { - $tenant->forceFill(['paddle_customer_id' => $existingCustomerId])->save(); - - return $existingCustomerId; - } - - throw $exception; - } - - $customerId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id'); - - if (! $customerId) { - Log::error('Paddle customer creation returned no id', ['tenant' => $tenant->id, 'response' => $response]); - throw new PaddleException('Failed to create Paddle customer.'); - } - - $tenant->forceFill(['paddle_customer_id' => $customerId])->save(); - - return $customerId; - } - - protected function resolveExistingCustomerId(Tenant $tenant, string $email, PaddleException $exception): ?string - { - if ($exception->status() !== 409 || Arr::get($exception->context(), 'error.code') !== 'customer_already_exists') { - return null; - } - - $response = $this->client->get('/customers', [ - 'email' => $email, - 'per_page' => 1, - ]); - - $customerId = Arr::get($response, 'data.0.id') ?? Arr::get($response, 'data.0.customer_id'); - - if (! $customerId) { - Log::warning('Paddle customer lookup by email returned no id', [ - 'tenant' => $tenant->id, - 'error_code' => Arr::get($exception->context(), 'error.code'), - ]); - - return null; - } - - return $customerId; - } -} diff --git a/app/Services/Paddle/PaddleSubscriptionService.php b/app/Services/Paddle/PaddleSubscriptionService.php deleted file mode 100644 index 081c55c..0000000 --- a/app/Services/Paddle/PaddleSubscriptionService.php +++ /dev/null @@ -1,41 +0,0 @@ - - */ - public function retrieve(string $subscriptionId): array - { - $response = $this->client->get("/subscriptions/{$subscriptionId}"); - - return is_array($response) ? $response : []; - } - - /** - * Convenience helper to extract metadata from the subscription response. - * - * @param array $subscription - * @return array - */ - public function metadata(array $subscription): array - { - $customData = Arr::get($subscription, 'data.custom_data'); - - if (is_array($customData)) { - return $customData; - } - - $metadata = Arr::get($subscription, 'data.metadata'); - - return is_array($metadata) ? $metadata : []; - } -} diff --git a/app/Services/Paddle/PaddleTransactionService.php b/app/Services/Paddle/PaddleTransactionService.php deleted file mode 100644 index 3bbac57..0000000 --- a/app/Services/Paddle/PaddleTransactionService.php +++ /dev/null @@ -1,188 +0,0 @@ ->, meta: array} - */ - public function listForCustomer(string $customerId, array $query = []): array - { - $payload = array_filter(array_merge([ - 'customer_id' => $customerId, - 'order_by' => 'created_at[desc]', - ], $query), static fn ($value) => $value !== null && $value !== ''); - - $response = $this->client->get('/transactions', $payload); - - $transactions = Arr::get($response, 'data', []); - $meta = Arr::get($response, 'meta.pagination', []); - - if (! is_array($transactions)) { - $transactions = []; - } - - return [ - 'data' => array_map([$this, 'mapTransaction'], $transactions), - 'meta' => $this->mapPagination($meta), - ]; - } - - /** - * @return array - */ - public function retrieve(string $transactionId): array - { - $response = $this->client->get("/transactions/{$transactionId}"); - $transaction = Arr::get($response, 'data'); - - return is_array($transaction) ? $transaction : (is_array($response) ? $response : []); - } - - /** - * @return array|null - */ - public function findByCheckoutId(string $checkoutId): ?array - { - $response = $this->client->get('/transactions', [ - 'checkout_id' => $checkoutId, - 'order_by' => 'created_at[desc]', - ]); - - $transactions = Arr::get($response, 'data', []); - - if (! is_array($transactions) || $transactions === []) { - return null; - } - - $first = $transactions[0] ?? null; - - return is_array($first) ? $first : null; - } - - /** - * @param array $criteria - * @return array|null - */ - public function findByCustomData(array $criteria, int $limit = 20): ?array - { - $payload = array_filter([ - 'order_by' => 'created_at[desc]', - 'per_page' => max(1, min($limit, 50)), - ], static fn ($value) => $value !== null && $value !== ''); - - $response = $this->client->get('/transactions', $payload); - $transactions = Arr::get($response, 'data', []); - - if (! is_array($transactions) || $transactions === []) { - return null; - } - - foreach ($transactions as $transaction) { - if (! is_array($transaction)) { - continue; - } - - $customData = Arr::get($transaction, 'custom_data', Arr::get($transaction, 'customData', [])); - if (! is_array($customData) || $customData === []) { - continue; - } - - $matches = true; - foreach ($criteria as $key => $value) { - if ($value === null || $value === '') { - continue; - } - - $candidate = $customData[$key] ?? null; - if ((string) $candidate !== (string) $value) { - $matches = false; - break; - } - } - - if ($matches) { - return $transaction; - } - } - - return null; - } - - /** - * Issue a refund for a Paddle transaction. - * - * @param array{reason?: string|null} $options - * @return array - */ - public function refund(string $transactionId, array $options = []): array - { - $payload = array_filter([ - 'reason' => $options['reason'] ?? null, - ], static fn ($value) => $value !== null && $value !== ''); - - return $this->client->post("/transactions/{$transactionId}/refunds", $payload); - } - - /** - * @param array $transaction - * @return array - */ - protected function mapTransaction(array $transaction): array - { - $totals = Arr::get($transaction, 'totals', []); - - return [ - 'id' => $transaction['id'] ?? null, - 'status' => $transaction['status'] ?? null, - 'amount' => $this->resolveAmount($transaction, $totals), - 'currency' => $transaction['currency_code'] ?? Arr::get($transaction, 'currency') ?? 'EUR', - 'origin' => $transaction['origin'] ?? null, - 'checkout_id' => $transaction['checkout_id'] ?? Arr::get($transaction, 'details.checkout_id'), - 'created_at' => $transaction['created_at'] ?? null, - 'updated_at' => $transaction['updated_at'] ?? null, - 'receipt_url' => Arr::get($transaction, 'invoice_url') ?? Arr::get($transaction, 'receipt_url'), - 'tax' => Arr::get($totals, 'tax_total') ?? null, - 'grand_total' => Arr::get($totals, 'grand_total') ?? null, - ]; - } - - /** - * @param array $transaction - * @param array|null $totals - */ - protected function resolveAmount(array $transaction, $totals): ?float - { - $amount = Arr::get($totals ?? [], 'subtotal') ?? Arr::get($totals ?? [], 'grand_total'); - - if ($amount !== null) { - return (float) $amount; - } - - $raw = $transaction['amount'] ?? null; - - if ($raw === null) { - return null; - } - - return (float) $raw; - } - - /** - * @param array $pagination - * @return array - */ - protected function mapPagination(array $pagination): array - { - return [ - 'next' => $pagination['next'] ?? null, - 'previous' => $pagination['previous'] ?? null, - 'has_more' => (bool) ($pagination['has_more'] ?? false), - ]; - } -} diff --git a/app/Support/Concerns/PresentsPackages.php b/app/Support/Concerns/PresentsPackages.php index c359aeb..1e26433 100644 --- a/app/Support/Concerns/PresentsPackages.php +++ b/app/Support/Concerns/PresentsPackages.php @@ -46,8 +46,8 @@ trait PresentsPackages 'type' => $package->type, 'included_package_slug' => $package->included_package_slug, 'price' => $package->price, - 'paddle_product_id' => $package->paddle_product_id, - 'paddle_price_id' => $package->paddle_price_id, + 'lemonsqueezy_product_id' => $package->lemonsqueezy_product_id, + 'lemonsqueezy_variant_id' => $package->lemonsqueezy_variant_id, 'description' => $description, 'description_breakdown' => $table, 'gallery_duration_label' => $galleryDuration, diff --git a/bootstrap/app.php b/bootstrap/app.php index 1359db5..af5a73f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -110,8 +110,8 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); $middleware->validateCsrfTokens(except: [ - 'paddle/webhook', - 'paddle/webhook*', + 'lemonsqueezy/webhook', + 'lemonsqueezy/webhook*', ]); $middleware->web(append: [ diff --git a/config/gift-vouchers.php b/config/gift-vouchers.php index 8109fd8..742c49d 100644 --- a/config/gift-vouchers.php +++ b/config/gift-vouchers.php @@ -5,70 +5,70 @@ return [ 'reminder_days' => 7, 'expiry_reminder_days' => 14, - // Map voucher tiers to Paddle price IDs (create matching prices in Paddle Billing). + // Map voucher tiers to Lemon Squeezy variant IDs (create matching variants in Lemon Squeezy). 'tiers' => [ [ 'key' => 'gift-starter', 'label' => 'Geschenk Starter', 'amount' => 29.00, 'currency' => 'EUR', - 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STARTER', 'pri_01kbwccfe1mpwh7hh60eygemx6'), + 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STARTER'), ], [ 'key' => 'gift-starter-usd', 'label' => 'Gift Starter (USD)', 'amount' => 32.00, 'currency' => 'USD', - 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STARTER_USD'), + 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STARTER_USD'), ], [ 'key' => 'gift-starter-gbp', 'label' => 'Gift Starter (GBP)', 'amount' => 25.00, 'currency' => 'GBP', - 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STARTER_GBP'), + 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STARTER_GBP'), ], [ 'key' => 'gift-standard', 'label' => 'Geschenk Classic', 'amount' => 59.00, 'currency' => 'EUR', - 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD', 'pri_01kbwccfvzrf4z2f1r62vns7gh'), + 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STANDARD'), ], [ 'key' => 'gift-standard-usd', 'label' => 'Gift Classic (USD)', 'amount' => 65.00, 'currency' => 'USD', - 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_USD'), + 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STANDARD_USD'), ], [ 'key' => 'gift-standard-gbp', 'label' => 'Gift Classic (GBP)', 'amount' => 55.00, 'currency' => 'GBP', - 'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_GBP'), + 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STANDARD_GBP'), ], [ 'key' => 'gift-premium', 'label' => 'Geschenk Premium', 'amount' => 129.00, 'currency' => 'EUR', - 'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM', 'pri_01kbwccg8vjc5cwz0kftfvf9wm'), + 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_PREMIUM'), ], [ 'key' => 'gift-premium-usd', 'label' => 'Gift Premium (USD)', 'amount' => 139.00, 'currency' => 'USD', - 'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_USD'), + 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_USD'), ], [ 'key' => 'gift-premium-gbp', 'label' => 'Gift Premium (GBP)', 'amount' => 119.00, 'currency' => 'GBP', - 'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_GBP'), + 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_GBP'), ], ], diff --git a/config/lemonsqueezy.php b/config/lemonsqueezy.php new file mode 100644 index 0000000..5e3c495 --- /dev/null +++ b/config/lemonsqueezy.php @@ -0,0 +1,12 @@ + env('LEMONSQUEEZY_API_KEY'), + 'store_id' => env('LEMONSQUEEZY_STORE_ID', '284860'), + 'test_mode' => $testMode, + 'webhook_secret' => env('LEMONSQUEEZY_WEBHOOK_SECRET'), + 'webhook_events' => array_filter(array_map('trim', explode(',', (string) env('LEMONSQUEEZY_WEBHOOK_EVENTS', 'order_created,order_updated,order_payment_failed,order_refunded,subscription_created,subscription_updated,subscription_cancelled,subscription_expired,subscription_paused')))), + 'base_url' => env('LEMONSQUEEZY_BASE_URL', 'https://api.lemonsqueezy.com/v1'), +]; diff --git a/config/logging.php b/config/logging.php index 34a92c2..01bcb61 100644 --- a/config/logging.php +++ b/config/logging.php @@ -80,14 +80,14 @@ return [ 'days' => env('LOG_DAILY_DAYS', 14), 'replace_placeholders' => true, ], - 'paddle-sync' => [ + 'lemonsqueezy-sync' => [ 'driver' => 'stack', - 'channels' => explode(',', env('PADDLE_SYNC_LOG_STACK', 'paddle-sync-file')), + 'channels' => explode(',', env('LEMONSQUEEZY_SYNC_LOG_STACK', 'lemonsqueezy-sync-file')), 'ignore_exceptions' => false, ], - 'paddle-sync-file' => [ + 'lemonsqueezy-sync-file' => [ 'driver' => 'daily', - 'path' => storage_path('logs/paddle-sync.log'), + 'path' => storage_path('logs/lemonsqueezy-sync.log'), 'level' => env('LOG_LEVEL', 'info'), 'days' => env('LOG_DAILY_DAYS', 14), 'replace_placeholders' => true, diff --git a/config/package-addons.php b/config/package-addons.php index b620b6c..3e52e95 100644 --- a/config/package-addons.php +++ b/config/package-addons.php @@ -1,31 +1,31 @@ [ 'label' => 'Extra photos (500)', - 'price_id' => env('PADDLE_ADDON_EXTRA_PHOTOS_SMALL_PRICE_ID'), + 'variant_id' => env('LEMONSQUEEZY_ADDON_EXTRA_PHOTOS_SMALL_VARIANT_ID'), 'increments' => [ 'extra_photos' => 500, ], ], 'extra_photos_large' => [ 'label' => 'Extra photos (2,000)', - 'price_id' => env('PADDLE_ADDON_EXTRA_PHOTOS_LARGE_PRICE_ID'), + 'variant_id' => env('LEMONSQUEEZY_ADDON_EXTRA_PHOTOS_LARGE_VARIANT_ID'), 'increments' => [ 'extra_photos' => 2000, ], ], 'extra_guests' => [ 'label' => 'Extra guests (100)', - 'price_id' => env('PADDLE_ADDON_EXTRA_GUESTS_PRICE_ID'), + 'variant_id' => env('LEMONSQUEEZY_ADDON_EXTRA_GUESTS_VARIANT_ID'), 'increments' => [ 'extra_guests' => 100, ], ], 'extend_gallery_30d' => [ 'label' => 'Gallery extension (30 days)', - 'price_id' => env('PADDLE_ADDON_EXTEND_GALLERY_30D_PRICE_ID'), + 'variant_id' => env('LEMONSQUEEZY_ADDON_EXTEND_GALLERY_30D_VARIANT_ID'), 'increments' => [ 'extra_gallery_days' => 30, ], diff --git a/config/paddle.php b/config/paddle.php deleted file mode 100644 index 72ef9c2..0000000 --- a/config/paddle.php +++ /dev/null @@ -1,48 +0,0 @@ - $apiKey, - 'client_token' => $clientToken, - 'environment' => $environment, - 'base_url' => $baseUrl, - 'console_url' => $consoleUrl, - 'webhook_secret' => $webhookSecret, - 'public_key' => $publicKey, - 'webhook_events' => [ - 'transaction.created', - 'transaction.processing', - 'transaction.completed', - 'transaction.failed', - 'transaction.cancelled', - 'subscription.created', - 'subscription.updated', - 'subscription.paused', - 'subscription.resumed', - 'subscription.cancelled', - 'subscription.past_due', - ], -]; diff --git a/config/services.php b/config/services.php index c512dc6..264680b 100644 --- a/config/services.php +++ b/config/services.php @@ -45,11 +45,11 @@ return [ 'sandbox' => env('PAYPAL_SANDBOX', true), ], - 'paddle' => [ - 'api_key' => env('PADDLE_API_KEY'), - 'client_id' => env('PADDLE_CLIENT_ID'), - 'sandbox' => env('PADDLE_SANDBOX', false), - 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), + 'lemonsqueezy' => [ + 'api_key' => env('LEMONSQUEEZY_API_KEY'), + 'store_id' => env('LEMONSQUEEZY_STORE_ID'), + 'webhook_secret' => env('LEMONSQUEEZY_WEBHOOK_SECRET'), + 'test_mode' => env('LEMONSQUEEZY_TEST_MODE', false), ], 'google' => [ diff --git a/config/support-api.php b/config/support-api.php index 8ce907f..ed991a4 100644 --- a/config/support-api.php +++ b/config/support-api.php @@ -60,7 +60,7 @@ return [ 'resources' => [ 'tenants' => [ 'model' => Tenant::class, - 'search' => ['name', 'slug', 'contact_email', 'paddle_customer_id'], + 'search' => ['name', 'slug', 'contact_email', 'lemonsqueezy_customer_id'], 'with' => ['user', 'activeResellerPackage'], 'abilities' => [ 'read' => ['support:read'], diff --git a/database/factories/CouponFactory.php b/database/factories/CouponFactory.php index 2713bdb..6625179 100644 --- a/database/factories/CouponFactory.php +++ b/database/factories/CouponFactory.php @@ -44,8 +44,8 @@ class CouponFactory extends Factory 'metadata' => ['note' => 'factory'], 'starts_at' => now()->subDay(), 'ends_at' => now()->addMonth(), - 'paddle_discount_id' => 'dsc_'.Str::upper(Str::random(10)), - 'paddle_mode' => 'standard', + 'lemonsqueezy_discount_id' => 'dsc_'.Str::upper(Str::random(10)), + 'lemonsqueezy_mode' => 'standard', ]; } } diff --git a/database/factories/IntegrationWebhookEventFactory.php b/database/factories/IntegrationWebhookEventFactory.php index bb3af48..f1f06c5 100644 --- a/database/factories/IntegrationWebhookEventFactory.php +++ b/database/factories/IntegrationWebhookEventFactory.php @@ -23,7 +23,7 @@ class IntegrationWebhookEventFactory extends Factory $processedAt = (clone $receivedAt)->modify('+2 minutes'); return [ - 'provider' => $this->faker->randomElement(['paddle', 'revenuecat']), + 'provider' => $this->faker->randomElement(['lemonsqueezy', 'revenuecat']), 'event_id' => $this->faker->uuid(), 'event_type' => $this->faker->word(), 'status' => IntegrationWebhookEvent::STATUS_PROCESSED, diff --git a/database/factories/PackageFactory.php b/database/factories/PackageFactory.php index 08ee4d1..e62009c 100644 --- a/database/factories/PackageFactory.php +++ b/database/factories/PackageFactory.php @@ -30,9 +30,9 @@ class PackageFactory extends Factory 'advanced_analytics' => $this->faker->boolean(), ]), 'type' => $this->faker->randomElement(['endcustomer', 'reseller']), - 'paddle_sync_status' => null, - 'paddle_synced_at' => null, - 'paddle_snapshot' => null, + 'lemonsqueezy_sync_status' => null, + 'lemonsqueezy_synced_at' => null, + 'lemonsqueezy_snapshot' => null, ]; } diff --git a/database/migrations/2025_10_05_190719_create_checkout_sessions_table.php b/database/migrations/2025_10_05_190719_create_checkout_sessions_table.php index c4ed7a6..8241b3b 100644 --- a/database/migrations/2025_10_05_190719_create_checkout_sessions_table.php +++ b/database/migrations/2025_10_05_190719_create_checkout_sessions_table.php @@ -34,8 +34,8 @@ return new class extends Migration $table->string('stripe_payment_intent_id')->nullable(); $table->string('stripe_customer_id')->nullable(); $table->string('stripe_subscription_id')->nullable(); - $table->string('paddle_checkout_id')->nullable(); - $table->string('paddle_transaction_id')->nullable(); + $table->string('lemonsqueezy_checkout_id')->nullable(); + $table->string('lemonsqueezy_order_id')->nullable(); $table->json('provider_metadata')->nullable(); $table->string('locale', 5)->nullable(); @@ -47,8 +47,8 @@ return new class extends Migration $table->softDeletes(); $table->unique('stripe_payment_intent_id'); - $table->unique('paddle_checkout_id'); - $table->unique('paddle_transaction_id'); + $table->unique('lemonsqueezy_checkout_id'); + $table->unique('lemonsqueezy_order_id'); $table->index(['provider', 'status']); $table->index('expires_at'); }); diff --git a/database/migrations/2025_10_26_182621_add_paddle_columns_to_billing_tables.php b/database/migrations/2025_10_26_182621_add_paddle_columns_to_billing_tables.php index 9c7cc3f..7ce2bcb 100644 --- a/database/migrations/2025_10_26_182621_add_paddle_columns_to_billing_tables.php +++ b/database/migrations/2025_10_26_182621_add_paddle_columns_to_billing_tables.php @@ -11,26 +11,26 @@ return new class extends Migration */ public function up(): void { - if (! Schema::hasColumn('packages', 'paddle_product_id')) { + if (! Schema::hasColumn('packages', 'lemonsqueezy_product_id')) { Schema::table('packages', function (Blueprint $table) { - $table->string('paddle_product_id')->nullable()->after('price'); - $table->string('paddle_price_id')->nullable()->after('paddle_product_id'); - $table->index('paddle_product_id'); - $table->index('paddle_price_id'); + $table->string('lemonsqueezy_product_id')->nullable()->after('price'); + $table->string('lemonsqueezy_variant_id')->nullable()->after('lemonsqueezy_product_id'); + $table->index('lemonsqueezy_product_id'); + $table->index('lemonsqueezy_variant_id'); }); } - if (! Schema::hasColumn('tenants', 'paddle_customer_id')) { + if (! Schema::hasColumn('tenants', 'lemonsqueezy_customer_id')) { Schema::table('tenants', function (Blueprint $table) { - $table->string('paddle_customer_id')->nullable()->after('subscription_status'); - $table->index('paddle_customer_id'); + $table->string('lemonsqueezy_customer_id')->nullable()->after('subscription_status'); + $table->index('lemonsqueezy_customer_id'); }); } - if (! Schema::hasColumn('tenant_packages', 'paddle_subscription_id')) { + if (! Schema::hasColumn('tenant_packages', 'lemonsqueezy_subscription_id')) { Schema::table('tenant_packages', function (Blueprint $table) { - $table->string('paddle_subscription_id')->nullable()->after('package_id'); - $table->index('paddle_subscription_id'); + $table->string('lemonsqueezy_subscription_id')->nullable()->after('package_id'); + $table->index('lemonsqueezy_subscription_id'); }); } @@ -47,31 +47,31 @@ return new class extends Migration */ public function down(): void { - if (Schema::hasColumn('packages', 'paddle_price_id')) { + if (Schema::hasColumn('packages', 'lemonsqueezy_variant_id')) { Schema::table('packages', function (Blueprint $table) { - $table->dropIndex('packages_paddle_price_id_index'); - $table->dropColumn('paddle_price_id'); + $table->dropIndex('packages_lemonsqueezy_variant_id_index'); + $table->dropColumn('lemonsqueezy_variant_id'); }); } - if (Schema::hasColumn('packages', 'paddle_product_id')) { + if (Schema::hasColumn('packages', 'lemonsqueezy_product_id')) { Schema::table('packages', function (Blueprint $table) { - $table->dropIndex('packages_paddle_product_id_index'); - $table->dropColumn('paddle_product_id'); + $table->dropIndex('packages_lemonsqueezy_product_id_index'); + $table->dropColumn('lemonsqueezy_product_id'); }); } - if (Schema::hasColumn('tenants', 'paddle_customer_id')) { + if (Schema::hasColumn('tenants', 'lemonsqueezy_customer_id')) { Schema::table('tenants', function (Blueprint $table) { - $table->dropIndex('tenants_paddle_customer_id_index'); - $table->dropColumn('paddle_customer_id'); + $table->dropIndex('tenants_lemonsqueezy_customer_id_index'); + $table->dropColumn('lemonsqueezy_customer_id'); }); } - if (Schema::hasColumn('tenant_packages', 'paddle_subscription_id')) { + if (Schema::hasColumn('tenant_packages', 'lemonsqueezy_subscription_id')) { Schema::table('tenant_packages', function (Blueprint $table) { - $table->dropIndex('tenant_packages_paddle_subscription_id_index'); - $table->dropColumn('paddle_subscription_id'); + $table->dropIndex('tenant_packages_lemonsqueezy_subscription_id_index'); + $table->dropColumn('lemonsqueezy_subscription_id'); }); } diff --git a/database/migrations/2025_10_27_090531_add_paddle_sync_columns_to_packages_table.php b/database/migrations/2025_10_27_090531_add_paddle_sync_columns_to_packages_table.php index 920be66..d591d30 100644 --- a/database/migrations/2025_10_27_090531_add_paddle_sync_columns_to_packages_table.php +++ b/database/migrations/2025_10_27_090531_add_paddle_sync_columns_to_packages_table.php @@ -12,15 +12,15 @@ return new class extends Migration public function up(): void { Schema::table('packages', function (Blueprint $table) { - $table->string('paddle_sync_status', 50) + $table->string('lemonsqueezy_sync_status', 50) ->nullable() - ->after('paddle_price_id'); - $table->timestamp('paddle_synced_at') + ->after('lemonsqueezy_variant_id'); + $table->timestamp('lemonsqueezy_synced_at') ->nullable() - ->after('paddle_sync_status'); - $table->json('paddle_snapshot') + ->after('lemonsqueezy_sync_status'); + $table->json('lemonsqueezy_snapshot') ->nullable() - ->after('paddle_synced_at'); + ->after('lemonsqueezy_synced_at'); }); } @@ -30,7 +30,7 @@ return new class extends Migration public function down(): void { Schema::table('packages', function (Blueprint $table) { - $table->dropColumn(['paddle_sync_status', 'paddle_synced_at', 'paddle_snapshot']); + $table->dropColumn(['lemonsqueezy_sync_status', 'lemonsqueezy_synced_at', 'lemonsqueezy_snapshot']); }); } }; diff --git a/database/migrations/2025_11_07_142138_create_coupons_table.php b/database/migrations/2025_11_07_142138_create_coupons_table.php index be03e1c..f1ccd0a 100644 --- a/database/migrations/2025_11_07_142138_create_coupons_table.php +++ b/database/migrations/2025_11_07_142138_create_coupons_table.php @@ -36,10 +36,10 @@ return new class extends Migration $table->timestamp('starts_at')->nullable(); $table->timestamp('ends_at')->nullable(); - $table->string('paddle_discount_id')->nullable()->unique(); - $table->string('paddle_mode', 40)->default('standard'); - $table->json('paddle_snapshot')->nullable(); - $table->timestamp('paddle_last_synced_at')->nullable(); + $table->string('lemonsqueezy_discount_id')->nullable()->unique(); + $table->string('lemonsqueezy_mode', 40)->default('standard'); + $table->json('lemonsqueezy_snapshot')->nullable(); + $table->timestamp('lemonsqueezy_last_synced_at')->nullable(); $table->foreignIdFor(\App\Models\User::class, 'created_by')->nullable()->constrained('users')->nullOnDelete(); $table->foreignIdFor(\App\Models\User::class, 'updated_by')->nullable()->constrained('users')->nullOnDelete(); diff --git a/database/migrations/2025_11_07_142223_create_coupon_redemptions_table.php b/database/migrations/2025_11_07_142223_create_coupon_redemptions_table.php index c333aa6..6b34e03 100644 --- a/database/migrations/2025_11_07_142223_create_coupon_redemptions_table.php +++ b/database/migrations/2025_11_07_142223_create_coupon_redemptions_table.php @@ -20,7 +20,7 @@ return new class extends Migration $table->foreignIdFor(\App\Models\Tenant::class)->nullable()->constrained()->nullOnDelete(); $table->foreignIdFor(\App\Models\User::class)->nullable()->constrained()->nullOnDelete(); - $table->string('paddle_transaction_id')->nullable(); + $table->string('lemonsqueezy_order_id')->nullable(); $table->string('status', 40)->default('pending'); $table->text('failure_reason')->nullable(); @@ -33,7 +33,7 @@ return new class extends Migration $table->timestamps(); $table->index(['status', 'redeemed_at']); - $table->unique('paddle_transaction_id'); + $table->unique('lemonsqueezy_order_id'); }); } diff --git a/database/migrations/2025_12_07_130315_create_gift_vouchers_table.php b/database/migrations/2025_12_07_130315_create_gift_vouchers_table.php index e9d707b..dc9062f 100644 --- a/database/migrations/2025_12_07_130315_create_gift_vouchers_table.php +++ b/database/migrations/2025_12_07_130315_create_gift_vouchers_table.php @@ -24,9 +24,9 @@ return new class extends Migration $table->string('recipient_name')->nullable(); $table->string('message', 500)->nullable(); - $table->string('paddle_transaction_id')->nullable()->unique(); - $table->string('paddle_checkout_id')->nullable()->unique(); - $table->string('paddle_price_id')->nullable(); + $table->string('lemonsqueezy_order_id')->nullable()->unique(); + $table->string('lemonsqueezy_checkout_id')->nullable()->unique(); + $table->string('lemonsqueezy_variant_id')->nullable(); $table->foreignIdFor(\App\Models\Coupon::class)->nullable()->constrained()->nullOnDelete(); diff --git a/database/migrations/2026_02_02_223108_rename_paddle_columns_to_lemonsqueezy.php b/database/migrations/2026_02_02_223108_rename_paddle_columns_to_lemonsqueezy.php new file mode 100644 index 0000000..c6bb11b --- /dev/null +++ b/database/migrations/2026_02_02_223108_rename_paddle_columns_to_lemonsqueezy.php @@ -0,0 +1,229 @@ +dropIndex('packages_paddle_product_id_index'); + $table->dropColumn('paddle_product_id'); + } + if (Schema::hasColumn('packages', 'paddle_price_id')) { + $table->dropIndex('packages_paddle_price_id_index'); + $table->dropColumn('paddle_price_id'); + } + if (Schema::hasColumn('packages', 'paddle_sync_status')) { + $table->dropColumn(['paddle_sync_status', 'paddle_synced_at', 'paddle_snapshot']); + } + + if (! Schema::hasColumn('packages', 'lemonsqueezy_product_id')) { + $table->string('lemonsqueezy_product_id')->nullable()->after('price'); + $table->index('lemonsqueezy_product_id'); + } + if (! Schema::hasColumn('packages', 'lemonsqueezy_variant_id')) { + $table->string('lemonsqueezy_variant_id')->nullable()->after('lemonsqueezy_product_id'); + $table->index('lemonsqueezy_variant_id'); + } + if (! Schema::hasColumn('packages', 'lemonsqueezy_sync_status')) { + $table->string('lemonsqueezy_sync_status', 50)->nullable()->after('lemonsqueezy_variant_id'); + $table->timestamp('lemonsqueezy_synced_at')->nullable()->after('lemonsqueezy_sync_status'); + $table->json('lemonsqueezy_snapshot')->nullable()->after('lemonsqueezy_synced_at'); + } + }); + + Schema::table('tenants', function (Blueprint $table) { + if (Schema::hasColumn('tenants', 'paddle_customer_id')) { + $table->dropIndex('tenants_paddle_customer_id_index'); + $table->dropColumn('paddle_customer_id'); + } + if (! Schema::hasColumn('tenants', 'lemonsqueezy_customer_id')) { + $table->string('lemonsqueezy_customer_id')->nullable()->after('subscription_status'); + $table->index('lemonsqueezy_customer_id'); + } + }); + + Schema::table('tenant_packages', function (Blueprint $table) { + if (Schema::hasColumn('tenant_packages', 'paddle_subscription_id')) { + $table->dropIndex('tenant_packages_paddle_subscription_id_index'); + $table->dropColumn('paddle_subscription_id'); + } + if (! Schema::hasColumn('tenant_packages', 'lemonsqueezy_subscription_id')) { + $table->string('lemonsqueezy_subscription_id')->nullable()->after('package_id'); + $table->index('lemonsqueezy_subscription_id'); + } + }); + + Schema::table('checkout_sessions', function (Blueprint $table) { + if (Schema::hasColumn('checkout_sessions', 'paddle_checkout_id')) { + $table->dropUnique('checkout_sessions_paddle_checkout_id_unique'); + $table->dropColumn('paddle_checkout_id'); + } + if (Schema::hasColumn('checkout_sessions', 'paddle_transaction_id')) { + $table->dropUnique('checkout_sessions_paddle_transaction_id_unique'); + $table->dropColumn('paddle_transaction_id'); + } + if (! Schema::hasColumn('checkout_sessions', 'lemonsqueezy_checkout_id')) { + $table->string('lemonsqueezy_checkout_id')->nullable()->after('stripe_subscription_id'); + $table->unique('lemonsqueezy_checkout_id'); + } + if (! Schema::hasColumn('checkout_sessions', 'lemonsqueezy_order_id')) { + $table->string('lemonsqueezy_order_id')->nullable()->after('lemonsqueezy_checkout_id'); + $table->unique('lemonsqueezy_order_id'); + } + }); + + Schema::table('coupons', function (Blueprint $table) { + if (Schema::hasColumn('coupons', 'paddle_discount_id')) { + $table->dropUnique('coupons_paddle_discount_id_unique'); + $table->dropColumn(['paddle_discount_id', 'paddle_mode', 'paddle_snapshot', 'paddle_last_synced_at']); + } + if (! Schema::hasColumn('coupons', 'lemonsqueezy_discount_id')) { + $table->string('lemonsqueezy_discount_id')->nullable()->unique()->after('ends_at'); + $table->string('lemonsqueezy_mode', 40)->default('standard')->after('lemonsqueezy_discount_id'); + $table->json('lemonsqueezy_snapshot')->nullable()->after('lemonsqueezy_mode'); + $table->timestamp('lemonsqueezy_last_synced_at')->nullable()->after('lemonsqueezy_snapshot'); + } + }); + + Schema::table('coupon_redemptions', function (Blueprint $table) { + if (Schema::hasColumn('coupon_redemptions', 'paddle_transaction_id')) { + $table->dropUnique('coupon_redemptions_paddle_transaction_id_unique'); + $table->dropColumn('paddle_transaction_id'); + } + if (! Schema::hasColumn('coupon_redemptions', 'lemonsqueezy_order_id')) { + $table->string('lemonsqueezy_order_id')->nullable()->after('user_id'); + $table->unique('lemonsqueezy_order_id'); + } + }); + + Schema::table('gift_vouchers', function (Blueprint $table) { + if (Schema::hasColumn('gift_vouchers', 'paddle_transaction_id')) { + $table->dropUnique('gift_vouchers_paddle_transaction_id_unique'); + $table->dropColumn('paddle_transaction_id'); + } + if (Schema::hasColumn('gift_vouchers', 'paddle_checkout_id')) { + $table->dropUnique('gift_vouchers_paddle_checkout_id_unique'); + $table->dropColumn('paddle_checkout_id'); + } + if (Schema::hasColumn('gift_vouchers', 'paddle_price_id')) { + $table->dropColumn('paddle_price_id'); + } + if (! Schema::hasColumn('gift_vouchers', 'lemonsqueezy_order_id')) { + $table->string('lemonsqueezy_order_id')->nullable()->unique()->after('message'); + $table->string('lemonsqueezy_checkout_id')->nullable()->unique()->after('lemonsqueezy_order_id'); + $table->string('lemonsqueezy_variant_id')->nullable()->after('lemonsqueezy_checkout_id'); + } + }); + + Schema::table('package_addons', function (Blueprint $table) { + if (Schema::hasColumn('package_addons', 'price_id')) { + $table->dropColumn('price_id'); + } + if (! Schema::hasColumn('package_addons', 'variant_id')) { + $table->string('variant_id')->nullable()->after('label'); + } + }); + + Schema::table('event_package_addons', function (Blueprint $table) { + if (Schema::hasColumn('event_package_addons', 'price_id')) { + $table->dropColumn('price_id'); + } + if (! Schema::hasColumn('event_package_addons', 'variant_id')) { + $table->string('variant_id')->nullable()->after('extra_gallery_days'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('packages', function (Blueprint $table) { + if (Schema::hasColumn('packages', 'lemonsqueezy_product_id')) { + $table->dropIndex('packages_lemonsqueezy_product_id_index'); + $table->dropColumn('lemonsqueezy_product_id'); + } + if (Schema::hasColumn('packages', 'lemonsqueezy_variant_id')) { + $table->dropIndex('packages_lemonsqueezy_variant_id_index'); + $table->dropColumn('lemonsqueezy_variant_id'); + } + if (Schema::hasColumn('packages', 'lemonsqueezy_sync_status')) { + $table->dropColumn(['lemonsqueezy_sync_status', 'lemonsqueezy_synced_at', 'lemonsqueezy_snapshot']); + } + }); + + Schema::table('tenants', function (Blueprint $table) { + if (Schema::hasColumn('tenants', 'lemonsqueezy_customer_id')) { + $table->dropIndex('tenants_lemonsqueezy_customer_id_index'); + $table->dropColumn('lemonsqueezy_customer_id'); + } + }); + + Schema::table('tenant_packages', function (Blueprint $table) { + if (Schema::hasColumn('tenant_packages', 'lemonsqueezy_subscription_id')) { + $table->dropIndex('tenant_packages_lemonsqueezy_subscription_id_index'); + $table->dropColumn('lemonsqueezy_subscription_id'); + } + }); + + Schema::table('checkout_sessions', function (Blueprint $table) { + if (Schema::hasColumn('checkout_sessions', 'lemonsqueezy_checkout_id')) { + $table->dropUnique('checkout_sessions_lemonsqueezy_checkout_id_unique'); + $table->dropColumn('lemonsqueezy_checkout_id'); + } + if (Schema::hasColumn('checkout_sessions', 'lemonsqueezy_order_id')) { + $table->dropUnique('checkout_sessions_lemonsqueezy_order_id_unique'); + $table->dropColumn('lemonsqueezy_order_id'); + } + }); + + Schema::table('coupons', function (Blueprint $table) { + if (Schema::hasColumn('coupons', 'lemonsqueezy_discount_id')) { + $table->dropUnique('coupons_lemonsqueezy_discount_id_unique'); + $table->dropColumn(['lemonsqueezy_discount_id', 'lemonsqueezy_mode', 'lemonsqueezy_snapshot', 'lemonsqueezy_last_synced_at']); + } + }); + + Schema::table('coupon_redemptions', function (Blueprint $table) { + if (Schema::hasColumn('coupon_redemptions', 'lemonsqueezy_order_id')) { + $table->dropUnique('coupon_redemptions_lemonsqueezy_order_id_unique'); + $table->dropColumn('lemonsqueezy_order_id'); + } + }); + + Schema::table('gift_vouchers', function (Blueprint $table) { + if (Schema::hasColumn('gift_vouchers', 'lemonsqueezy_order_id')) { + $table->dropUnique('gift_vouchers_lemonsqueezy_order_id_unique'); + $table->dropColumn('lemonsqueezy_order_id'); + } + if (Schema::hasColumn('gift_vouchers', 'lemonsqueezy_checkout_id')) { + $table->dropUnique('gift_vouchers_lemonsqueezy_checkout_id_unique'); + $table->dropColumn('lemonsqueezy_checkout_id'); + } + if (Schema::hasColumn('gift_vouchers', 'lemonsqueezy_variant_id')) { + $table->dropColumn('lemonsqueezy_variant_id'); + } + }); + + Schema::table('package_addons', function (Blueprint $table) { + if (Schema::hasColumn('package_addons', 'variant_id')) { + $table->dropColumn('variant_id'); + } + }); + + Schema::table('event_package_addons', function (Blueprint $table) { + if (Schema::hasColumn('event_package_addons', 'variant_id')) { + $table->dropColumn('variant_id'); + } + }); + } +}; diff --git a/database/seeders/CouponSeeder.php b/database/seeders/CouponSeeder.php index 4a96ee2..3d40f58 100644 --- a/database/seeders/CouponSeeder.php +++ b/database/seeders/CouponSeeder.php @@ -21,7 +21,7 @@ class CouponSeeder extends Seeder /** @var Collection $packageIds */ $packageIds = Package::query() - ->whereNotNull('paddle_price_id') + ->whereNotNull('lemonsqueezy_variant_id') ->pluck('id', 'slug'); $coupons = [ diff --git a/database/seeders/GiftVoucherTierSeeder.php b/database/seeders/GiftVoucherTierSeeder.php index d99b560..02b6863 100644 --- a/database/seeders/GiftVoucherTierSeeder.php +++ b/database/seeders/GiftVoucherTierSeeder.php @@ -2,75 +2,32 @@ namespace Database\Seeders; -use App\Models\Package; -use App\Services\Paddle\PaddleGiftVoucherCatalogService; +use App\Services\LemonSqueezy\LemonSqueezyGiftVoucherCatalogService; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\Schema; class GiftVoucherTierSeeder extends Seeder { - public function __construct(private readonly PaddleGiftVoucherCatalogService $catalog) {} + public function __construct(private readonly LemonSqueezyGiftVoucherCatalogService $catalog) {} public function run(): void { - if (! config('paddle.api_key')) { - $this->command?->warn('Skipping gift voucher Paddle sync: paddle.api_key not configured.'); + if (! config('lemonsqueezy.api_key')) { + $this->command?->warn('Skipping gift voucher Lemon Squeezy sync: lemonsqueezy.api_key not configured.'); return; } - $tiers = $this->buildTiers(); + $tiers = config('gift-vouchers.tiers', []); foreach ($tiers as $tier) { $result = $this->catalog->ensureTier($tier); $this->command?->info(sprintf( - '%s → product %s, price %s', + '%s → product %s, variant %s', $tier['key'], $result['product_id'], - $result['price_id'] + $result['variant_id'] )); } } - - /** - * @return array - */ - protected function buildTiers(): array - { - $columns = ['slug', 'name', 'price']; - if (Schema::hasColumn('packages', 'currency')) { - $columns[] = 'currency'; - } - - $packages = Package::query() - ->where('type', 'endcustomer') - ->whereNotNull('price') - ->get($columns) - ->unique(fn (Package $package) => $package->price.'|'.($package->currency ?? 'EUR')); - - return $packages->map(function (Package $package): array { - $amount = (float) $package->price; - $currency = $package->currency ?? 'EUR'; - - return [ - 'key' => 'gift-'.$package->slug, - 'label' => 'Gutschein '.$package->name, - 'amount' => $amount, - 'currency' => $currency, - 'paddle_price_id' => $this->lookupPaddlePriceId($package->slug), - ]; - })->values()->all(); - } - - protected function lookupPaddlePriceId(string $slug): ?string - { - return match ($slug) { - 'starter' => 'pri_01kbwccfe1mpwh7hh60eygemx6', - 'standard' => 'pri_01kbwccfvzrf4z2f1r62vns7gh', - 'pro' => 'pri_01kbwccg8vjc5cwz0kftfvf9wm', - 'premium' => 'pri_01kbwccgnjzwrjy5xg1yp981p6', - default => null, - }; - } } diff --git a/database/seeders/PackageSeeder.php b/database/seeders/PackageSeeder.php index 5f03e70..87b6970 100644 --- a/database/seeders/PackageSeeder.php +++ b/database/seeders/PackageSeeder.php @@ -32,8 +32,8 @@ class PackageSeeder extends Seeder 'watermark_allowed' => false, 'branding_allowed' => false, 'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks', 'live_slideshow'], - 'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej', - 'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27', + 'lemonsqueezy_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej', + 'lemonsqueezy_variant_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27', 'description' => <<<'TEXT' Ideal für Geburtstage, Gartenpartys oder Polterabende! {{max_guests}} Gäste teilen ihre besten Schnappschüsse, lösen {{max_tasks}} Fotoaufgaben und haben {{gallery_duration}} Zugriff auf die Online-Galerie. {{max_photos}} Bilder sind inklusive – genug Platz für jede Menge Lieblingsmomente. TEXT, @@ -65,8 +65,8 @@ TEXT, 'watermark_allowed' => true, 'branding_allowed' => true, 'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'no_watermark'], - 'paddle_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j', - 'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc', + 'lemonsqueezy_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j', + 'lemonsqueezy_variant_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc', 'description' => <<<'TEXT' Das Rundum-Sorglos-Paket für Hochzeiten, Firmenfeiern oder Jubiläen. {{max_photos}} Bilder, {{max_guests}} Gäste und {{max_tasks}} Fotoaufgaben – dazu eine Galerie, die {{gallery_duration}} online bleibt. Eigenes Logo oder Wasserzeichen inklusive. TEXT, @@ -98,8 +98,8 @@ TEXT, 'watermark_allowed' => true, 'branding_allowed' => true, 'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'advanced_analytics', 'priority_support'], - 'paddle_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s', - 'paddle_price_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy', + 'lemonsqueezy_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s', + 'lemonsqueezy_variant_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy', 'description' => <<<'TEXT' Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben – dazu eigenes Wasserzeichen, Live-Slideshow und Premium-Support. TEXT, @@ -134,8 +134,8 @@ TEXT, 'max_events_per_year' => 5, 'expires_after' => null, 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'], - 'paddle_product_id' => 'pro_01k8jcxvax48mhmwsfydw8ha9y', - 'paddle_price_id' => 'pri_01k8jcxvhe0bfasg9gg1rw70sy', + 'lemonsqueezy_product_id' => 'pro_01k8jcxvax48mhmwsfydw8ha9y', + 'lemonsqueezy_variant_id' => 'pri_01k8jcxvhe0bfasg9gg1rw70sy', 'description' => <<<'TEXT' Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Starter‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen. TEXT, @@ -168,8 +168,8 @@ TEXT, 'max_events_per_year' => 15, 'expires_after' => null, 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'], - 'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q', - 'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v', + 'lemonsqueezy_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q', + 'lemonsqueezy_variant_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v', 'description' => <<<'TEXT' Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Classic‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen. TEXT, @@ -202,8 +202,8 @@ TEXT, 'max_events_per_year' => 35, 'expires_after' => null, 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow'], - 'paddle_product_id' => 'pro_01k8jcxt7gc6g6ddavmq65txzz', - 'paddle_price_id' => 'pri_01k8jcxtfa07gvq43kpvpe0t8z', + 'lemonsqueezy_product_id' => 'pro_01k8jcxt7gc6g6ddavmq65txzz', + 'lemonsqueezy_variant_id' => 'pri_01k8jcxtfa07gvq43kpvpe0t8z', 'description' => <<<'TEXT' Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen. TEXT, @@ -236,8 +236,8 @@ TEXT, 'max_events_per_year' => 5, 'expires_after' => null, 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'], - 'paddle_product_id' => 'pro_01kf16ttp0fph79j59x0z1cdqc', - 'paddle_price_id' => 'pri_01kf16v0v2z4hse5cxq5wnah4b', + 'lemonsqueezy_product_id' => 'pro_01kf16ttp0fph79j59x0z1cdqc', + 'lemonsqueezy_variant_id' => 'pri_01kf16v0v2z4hse5cxq5wnah4b', 'description' => <<<'TEXT' Premium Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen. TEXT, @@ -270,8 +270,8 @@ TEXT, 'max_events_per_year' => 24, 'expires_after' => null, 'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'], - 'paddle_product_id' => 'pro_01k8jct3gz9ks5mg6z61q6nrxb', - 'paddle_price_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06', + 'lemonsqueezy_product_id' => 'pro_01k8jct3gz9ks5mg6z61q6nrxb', + 'lemonsqueezy_variant_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06', 'description' => <<<'TEXT' Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Classic‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen. TEXT, diff --git a/docs/help/README.md b/docs/help/README.md index 850257c..d1c8b38 100644 --- a/docs/help/README.md +++ b/docs/help/README.md @@ -84,5 +84,5 @@ related: - Ensure every `.en.md` file has a matching `.de.md` file with equal `slug`/`version_introduced`. - Validate required front matter keys. - Future enhancements: - - Integrate with Paddle customer portal for billing-related admin help. + - Integrate with Lemon Squeezy customer portal for billing-related admin help. - Add analytics event (non-PII) for article views through the app to measure usefulness. diff --git a/docs/ops/admin-issue-resolution.md b/docs/ops/admin-issue-resolution.md index 287cce2..faf6828 100644 --- a/docs/ops/admin-issue-resolution.md +++ b/docs/ops/admin-issue-resolution.md @@ -14,7 +14,7 @@ Internal troubleshooting guide for superadmins and on-call. - **Guest cannot join**: confirm event is published and the join link is current. ## Billing and quota blocks -- Check Paddle / RevenueCat status dashboards. +- Check Lemon Squeezy / RevenueCat status dashboards. - Confirm webhook freshness and retry failures if needed. ## Communications diff --git a/docs/ops/billing-ops.md b/docs/ops/billing-ops.md index 02bc480..9323b13 100644 --- a/docs/ops/billing-ops.md +++ b/docs/ops/billing-ops.md @@ -3,33 +3,33 @@ title: Billing & Zahlungs-Operationen sidebar_label: Billing-Runbook --- -Dieses Runbook beschreibt, wie mit Zahlungsproblemen, Paddle/RevenueCat‑Webhooks und Paket‑Inkonsistenzen operativ umzugehen ist. +Dieses Runbook beschreibt, wie mit Zahlungsproblemen, Lemon Squeezy/RevenueCat‑Webhooks und Paket‑Inkonsistenzen operativ umzugehen ist. ## 1. Komponentenüberblick -- **Paddle** +- **Lemon Squeezy** - Abwicklung von Web‑Checkout, Paketen und Subscriptions. - - Webhooks für Käufe, Verlängerungen, Stornos. + - Webhooks für Bestellungen, Verlängerungen, Stornos. - **Fotospiel Backend** - Modelle wie `Tenant`, `Packages`, `tenant_packages`, `event_packages`. - Services/Jobs zur Paket‑Zuweisung, Limit‑Berechnung und Nutzungstracking. -> Details zur Architektur findest du in den PRP‑Kapiteln (Billing/Freemium) sowie in den bd-Issues zur Paddle‑Migration und zum Katalog‑Sync. +> Details zur Architektur findest du in den PRP‑Kapiteln (Billing/Freemium) sowie in den bd-Issues zur Lemon Squeezy‑Migration und zum Katalog‑Sync. ## 2. Typische Problemszenarien - **Webhook kommt nicht an / schlägt fehl** - - Symptom: Paddle zeigt Zahlung „completed“, Tenant‑Paket im Backend bleibt unverändert. + - Symptom: Lemon Squeezy zeigt Zahlung „completed/paid“, Tenant‑Paket im Backend bleibt unverändert. - Checkliste: - Logs der Webhook‑Routes prüfen (Statuscodes, Exceptions). - - Endpoint: `POST /paddle/webhook` → `PaddleWebhookController::handle()`. - - Controller ruft `CheckoutWebhookService::handlePaddleEvent()` auf. - - Webhook‑Replay über das Paddle Dashboard auslösen (für einzelne Events). + - Endpoint: `POST /lemonsqueezy/webhook` → `LemonSqueezyWebhookController::handle()`. + - Controller ruft `CheckoutWebhookService::handleLemonSqueezyEvent()` auf. + - Webhook‑Replay über das Lemon Squeezy Dashboard auslösen (für einzelne Events). - Queue‑Status prüfen: - Falls Webhooks über Queues verarbeitet werden, auf `default`/`billing`‑Queues achten (je nach Konfiguration). - **Doppelte oder fehlende Abbuchungen** - - Abgleich von Zahlungsprovider‑Daten (Paddle/RevenueCat) mit internem Ledger. - - Bei doppelten Buchungen: Prozess definieren (Refund via Paddle, Anpassung im Ledger). + - Abgleich von Zahlungsprovider‑Daten (Lemon Squeezy/RevenueCat) mit internem Ledger. + - Bei doppelten Buchungen: Prozess definieren (Refund via Lemon Squeezy, Anpassung im Ledger). - Bei fehlenden Buchungen: ggf. manuelle Paketzuweisung nach erfolgter Zahlung. - **Pakete/Limits passen nicht zur Realität** - Tenant meldet: „Paket falsch“, „Galerie schon abgelaufen“ o.Ä. @@ -43,21 +43,21 @@ Dieses Runbook beschreibt, wie mit Zahlungsproblemen, Paddle/RevenueCat‑Webhoo 1. **Event/Tenant identifizieren** - IDs und relevante Paket‑Infos aus DB/Admin UI holen. 2. **Provider-Status prüfen** - - Paddle‑Dashboard: ist die Zahlung dort korrekt verbucht? (Transaktions‑/Abonnement‑Ansicht). + - Lemon Squeezy‑Dashboard: ist die Zahlung dort korrekt verbucht? (Order‑/Subscription‑Ansicht). 3. **Backend-Status prüfen** - Paketzuweisung und Limits in der DB (Read‑only!) inspizieren: - - `checkout_sessions` – wurde die Session korrekt auf `completed` gesetzt? (`provider = paddle`, `paddle_transaction_id` gefüllt?) + - `checkout_sessions` – wurde die Session korrekt auf `completed` gesetzt? (`provider = lemonsqueezy`, `lemonsqueezy_order_id` gefüllt?) - `package_purchases` – existiert ein Eintrag für Tenant/Package mit erwarteter Provider‑Referenz? - `tenant_packages` – stimmt der `active`‑Status und `expires_at` mit dem erwarteten Abostatus überein? 4. **Entscheidung** - Automatische Nachverarbeitung via Webhook‑Replay/Job‑Retry: - - Paddle‑Events erneut senden lassen, ggf. `tests/api/_testing/checkout/sessions/{session}/simulate-paddle` (in Test‑Umgebungen) nutzen. + - Lemon Squeezy‑Events erneut senden lassen, ggf. `tests/api/_testing/checkout/sessions/{session}/simulate-lemonsqueezy` (in Test‑Umgebungen) nutzen. - Notfall: manuelle Paket‑Anpassung (nur mit klar dokumentierter Begründung): - Paket in `tenant_packages` aktivieren/verlängern und `package_purchases` sauber nachziehen. 5. **Dokumentation** - Vorgang im Ticket oder als bd-Issue festhalten, falls wiederkehrend. -> TODO: Ergänze konkrete Tabellen-/Modellnamen und die relevanten Jobs/Artisan Commands, sobald Paddle/RevenueCat Migration finalisiert ist. +> TODO: Ergänze konkrete Tabellen-/Modellnamen und die relevanten Jobs/Artisan Commands, sobald Lemon Squeezy/RevenueCat Migration finalisiert ist. ## 4. Zusammenarbeit mit Finance/Support @@ -69,141 +69,138 @@ Dieses Runbook beschreibt, wie mit Zahlungsproblemen, Paddle/RevenueCat‑Webhoo ## 5. Hinweise zur Implementierung - **Konfiguration** - - Paddle‑Keys, Webhook‑Secrets und Feature‑Flags sollten ausschließlich in `.env`/Config‑Dateien liegen und niemals im Code/Logs landen. + - Lemon Squeezy‑Keys, Webhook‑Secrets und Feature‑Flags sollten ausschließlich in `.env`/Config‑Dateien liegen und niemals im Code/Logs landen. - Sandbox vs. Live‑Keys klar trennen; Ops sollte wissen, welche Umgebung gerade aktiv ist. - **Sicherheit** - Webhook‑Signaturen und Timestamps prüfen; bei verdächtigen Mustern (z.B. Replay‑Angriffe) Security‑Runbooks konsultieren. - Keine sensiblen Payment‑Details in Applikations‑Logs ausgeben. -Diese Sektion ist bewusst generisch gehalten, damit sie auch nach Implementation der finalen Billing‑Architektur noch passt. Details zu Tabellen/Jobs sollten ergänzt werden, sobald die Paddle‑Migration abgeschlossen ist. +Diese Sektion ist bewusst generisch gehalten, damit sie auch nach Implementation der finalen Billing‑Architektur noch passt. Details zu Tabellen/Jobs sollten ergänzt werden, sobald die Lemon Squeezy‑Migration abgeschlossen ist. -## 6. Konkrete Paddle-Flows im System +## 6. Konkrete Lemon Squeezy-Flows im System ### 6.1 Checkout-Erstellung - Marketing-Checkout / API: - - `MarketingController` und `PackageController` nutzen `PaddleCheckoutService::createCheckout()` (`App\Services\Paddle\PaddleCheckoutService`). + - `MarketingController` und `PackageController` nutzen `LemonSqueezyCheckoutService::createCheckout()` (`App\Services\LemonSqueezy\LemonSqueezyCheckoutService`). - Der Service: - - Stellt sicher, dass ein `paddle_customer_id` für den Tenant existiert (`PaddleCustomerService::ensureCustomerId()`). - - Baut Metadaten (`tenant_id`, `package_id`, optional `checkout_session_id`) für spätere Zuordnung. - - Ruft `POST /checkout/links` im Paddle‑API auf und erhält eine `checkout_url`. + - Baut `custom_data` (`tenant_id`, `package_id`, optional `checkout_session_id`) für spätere Zuordnung. + - Ruft `POST /checkouts` im Lemon Squeezy‑API auf und erhält eine `checkout_url`. - Ops-Sicht: - - Wenn `paddle_price_id` bei einem Package fehlt, wird kein Checkout erzeugt – Marketing‑UI zeigt entsprechende Fehlertexte (siehe `resources/lang/*/marketing.php`). - - Bei wiederkehrenden „checkout failed“‑Fehlern die Logs (`PaddleCheckoutService`, Controller) und Package‑Konfiguration prüfen. + - Wenn `lemonsqueezy_variant_id` bei einem Package fehlt, wird kein Checkout erzeugt – Marketing‑UI zeigt entsprechende Fehlertexte (siehe `resources/lang/*/marketing.php`). + - Bei wiederkehrenden „checkout failed“‑Fehlern die Logs (`LemonSqueezyCheckoutService`, Controller) und Package‑Konfiguration prüfen. ### 6.2 Webhook-Verarbeitung & Idempotenz -- Endpoint: `POST /paddle/webhook` → `PaddleWebhookController::handle()`. +- Endpoint: `POST /lemonsqueezy/webhook` → `LemonSqueezyWebhookController::handle()`. - Service: `CheckoutWebhookService` (`App\Services\Checkout\CheckoutWebhookService`): - - Unterscheidet zwischen **Transaktions‑Events** (`transaction.*`) und **Subscription‑Events** (`subscription.*`). + - Unterscheidet zwischen **Order‑Events** (`order_*`) und **Subscription‑Events** (`subscription_*`). - Idempotenz: - - Nutzt ein Cache‑Lock (`checkout:webhook:paddle:{transaction_id|session_id}`), um parallele Verarbeitung desselben Events zu verhindern. - - Schreibt Metadaten (`paddle_last_event`, `paddle_status`, `paddle_checkout_id`) in `checkout_sessions.provider_metadata`. + - Nutzt ein Cache‑Lock (`checkout:webhook:lemonsqueezy:{order_id|session_id}`), um parallele Verarbeitung desselben Events zu verhindern. + - Schreibt Metadaten (`lemonsqueezy_last_event`, `lemonsqueezy_status`, `lemonsqueezy_checkout_id`, `lemonsqueezy_order_id`) in `checkout_sessions.provider_metadata`. - Ergebnis: - - Bei `transaction.completed`: + - Bei `order_created`/`order_updated` mit Status `paid`: - `CheckoutSession` wird als `processing` markiert. - `CheckoutAssignmentService::finalise()` weist Paket/Tenant zu. - Session wird auf `completed` gesetzt. - - Bei `transaction.failed` / `transaction.cancelled`: + - Bei `order_payment_failed` / `order_refunded`: - Session wird auf `failed` gesetzt, Coupons werden als fehlgeschlagen markiert. ### 6.2.1 Sandbox‑Webhook registrieren -- Command (Sandbox-Umgebung aktiv): - - `php artisan paddle:webhooks:register --traffic-source=simulation` +- Command (Test‑Mode aktiv): + - `php artisan lemonsqueezy:webhooks:register --test-mode` - Optional mit URL-Override: - - `php artisan paddle:webhooks:register --url=https://staging.example.com/paddle/webhook --traffic-source=simulation` -- Event‑Liste kommt aus `config/paddle.php` (`webhook_events`). Override möglich: - - `php artisan paddle:webhooks:register --events=transaction.completed --events=subscription.created` + - `php artisan lemonsqueezy:webhooks:register --url=https://staging.example.com/lemonsqueezy/webhook --test-mode` +- Event‑Liste kommt aus `config/lemonsqueezy.php` (`webhook_events`). Override möglich: + - `php artisan lemonsqueezy:webhooks:register --events=order_created --events=subscription_created` -### 6.2.2 Verarbeitete Paddle-Events +### 6.2.2 Verarbeitete Lemon Squeezy-Events Die Webhook‑Handler erwarten mindestens diese Events: -- `transaction.created` -- `transaction.processing` -- `transaction.completed` (inkl. Add-ons/Gift‑Vouchers) -- `transaction.failed` -- `transaction.cancelled` -- `subscription.created` -- `subscription.updated` -- `subscription.paused` -- `subscription.resumed` -- `subscription.cancelled` -- `subscription.past_due` +- `order_created` +- `order_updated` +- `order_payment_failed` +- `order_refunded` +- `subscription_created` +- `subscription_updated` +- `subscription_cancelled` +- `subscription_expired` +- `subscription_paused` ### 6.3 Subscriptions & TenantPackages -- Subscription‑Events (`subscription.*`) werden ebenfalls von `CheckoutWebhookService` behandelt: - - Tenant wird aus `metadata.tenant_id` oder `paddle_customer_id` ermittelt. - - Package wird über `metadata.package_id` oder `paddle_price_id` aufgelöst. - - `TenantPackage` wird erstellt/aktualisiert (`paddle_subscription_id`, `expires_at`, `active`). +- Subscription‑Events (`subscription_*`) werden ebenfalls von `CheckoutWebhookService` behandelt: + - Tenant wird aus `metadata.tenant_id` oder `lemonsqueezy_customer_id` ermittelt. + - Package wird über `metadata.package_id` oder `lemonsqueezy_variant_id` aufgelöst. + - `TenantPackage` wird erstellt/aktualisiert (`lemonsqueezy_subscription_id`, `expires_at`, `active`). - `Tenant.subscription_status` und `subscription_expires_at` werden gesteuert. - Ops-Sicht: - - Bei abweichenden Abostatus (z.B. Paddle zeigt „active“, Tenant nicht): - - Subscription‑Events im Paddle‑Dashboard prüfen. - - Letzte `subscription.*`‑Events in den Logs, `TenantPackage`‑ und `Tenant`‑Felder gegenprüfen. + - Bei abweichenden Abostatus (z.B. Lemon Squeezy zeigt „active“, Tenant nicht): + - Subscription‑Events im Lemon Squeezy‑Dashboard prüfen. + - Letzte `subscription_*`‑Events in den Logs, `TenantPackage`‑ und `Tenant`‑Felder gegenprüfen. ### 6.4 Paket- & Coupon-Synchronisation - Pakete: - - Artisan‑Command `paddle:sync-packages` (`App\Console\Commands\PaddleSyncPackages`) stößt für ausgewählte oder alle Pakete `SyncPackageToPaddle`/`PullPackageFromPaddle` Jobs an. - - Sync‑Jobs nutzen `PaddleCatalogService`, um Produkte/Preise in Paddle zu erstellen/aktualisieren und `paddle_product_id`/`paddle_price_id` lokal zu pflegen. + - Artisan‑Command `lemonsqueezy:sync-packages` (`App\Console\Commands\LemonSqueezySyncPackages`) stößt für ausgewählte oder alle Pakete `SyncPackageToLemonSqueezy`/`PullPackageFromLemonSqueezy` Jobs an. + - Sync‑Jobs nutzen `LemonSqueezyCatalogService`, um Produkte/Preise in Lemon Squeezy zu erstellen/aktualisieren und `lemonsqueezy_product_id`/`lemonsqueezy_variant_id` lokal zu pflegen. - Coupons: - - `SyncCouponToPaddle`‑Job spiegelt interne Coupon‑Konfiguration in Paddle Discounts (`PaddleDiscountService`). + - `SyncCouponToLemonSqueezy`‑Job spiegelt interne Coupon‑Konfiguration in Lemon Squeezy Discounts (`LemonSqueezyDiscountService`). - Ops-Sicht: - - Bei Katalog‑Abweichungen `paddle:sync-packages --dry-run` verwenden, um Snapshots zu prüfen, bevor tatsächliche Änderungen gesendet werden. - - Fehlgeschlagene Syncs in den Logs (`Paddle package sync failed`, `Paddle discount sync failed`) beobachten. + - Bei Katalog‑Abweichungen `lemonsqueezy:sync-packages --dry-run` verwenden, um Snapshots zu prüfen, bevor tatsächliche Änderungen gesendet werden. + - Fehlgeschlagene Syncs in den Logs (`Lemon Squeezy package sync failed`, `Lemon Squeezy addon sync failed`, `Failed syncing coupon to Lemon Squeezy`) beobachten. ### 6.5 Recovery-Playbook: Katalog‑Sync fehlgeschlagen Wenn der Katalog‑Sync fehlschlägt oder Pakete nicht mehr korrekt verknüpft sind: 1. **Status im Admin prüfen** - - `PackageResource` → Felder `paddle_sync_status`, `paddle_synced_at` und `Letzter Fehler`. - - Fehlermeldung stammt aus `paddle_snapshot.error.message`. + - `PackageResource` → Felder `lemonsqueezy_sync_status`, `lemonsqueezy_synced_at` und `Letzter Fehler`. + - Fehlermeldung stammt aus `lemonsqueezy_snapshot.error.message`. 2. **Trockenlauf ausführen** - - `php artisan paddle:sync-packages --package= --dry-run` - - Prüfe die erzeugten Payload‑Snapshots (in `paddle_snapshot`) auf falsche IDs/Preise. + - `php artisan lemonsqueezy:sync-packages --package= --dry-run` + - Prüfe die erzeugten Payload‑Snapshots (in `lemonsqueezy_snapshot`) auf falsche IDs/Preise. 3. **Mapping prüfen** - - Falls `paddle_product_id` oder `paddle_price_id` fehlt oder falsch ist: im Paket‑Admin korrigieren. + - Falls `lemonsqueezy_product_id` oder `lemonsqueezy_variant_id` fehlt oder falsch ist: im Paket‑Admin korrigieren. - Bulk‑Sync blockiert unmapped Pakete; gezielte Korrektur vor dem nächsten Lauf ist Pflicht. 4. **Sync erneut anstoßen** - - `php artisan paddle:sync-packages --package= --queue` + - `php artisan lemonsqueezy:sync-packages --package= --queue` - Bei Bedarf: `--allow-unmapped` nur bewusst verwenden (z.B. initiales Mapping). 5. **Pull für Abgleich** - - `php artisan paddle:sync-packages --package= --pull` zum Abgleich mit Paddle. + - `php artisan lemonsqueezy:sync-packages --package= --pull` zum Abgleich mit Lemon Squeezy. 6. **Logs prüfen** - - Erwartete Logeinträge: `Paddle package sync failed`, `Paddle addon sync failed`, `Paddle discount sync failed`. + - Erwartete Logeinträge: `Lemon Squeezy package sync failed`, `Lemon Squeezy addon sync failed`, `Failed syncing coupon to Lemon Squeezy`. - Achte auf wiederkehrende Fehler (z.B. invalid product/price IDs). -Diese Untersektion soll dir als Operator helfen zu verstehen, wie Paddle‑Aktionen im System abgebildet sind und an welchen Stellen du im Fehlerfall ansetzen kannst. +Diese Untersektion soll dir als Operator helfen zu verstehen, wie Lemon Squeezy‑Aktionen im System abgebildet sind und an welchen Stellen du im Fehlerfall ansetzen kannst. -## 7. Production Cutover: Paddle Migration +## 7. Production Cutover: Lemon Squeezy Migration -Diese Checkliste beschreibt den kontrollierten Wechsel auf Paddle in Produktion. +Diese Checkliste beschreibt den kontrollierten Wechsel auf Lemon Squeezy in Produktion. 1. **Vorbereitung (T‑1 Woche)** - - Confirm: `PADDLE_ENVIRONMENT=production`, `PADDLE_API_KEY`, `PADDLE_CLIENT_TOKEN`, `PADDLE_WEBHOOK_SECRET`. - - Package IDs validieren: alle aktiven Packages haben `paddle_product_id` und `paddle_price_id`. - - `paddle:sync-packages --dry-run` auf eine Stichprobe anwenden. - - Event‑Liste prüfen: `config/paddle.php` (`webhook_events`). + - Confirm: `LEMONSQUEEZY_API_KEY`, `LEMONSQUEEZY_STORE_ID`, `LEMONSQUEEZY_WEBHOOK_SECRET`, `LEMONSQUEEZY_TEST_MODE=false`. + - Package IDs validieren: alle aktiven Packages haben `lemonsqueezy_product_id` und `lemonsqueezy_variant_id`. + - `lemonsqueezy:sync-packages --dry-run` auf eine Stichprobe anwenden. + - Event‑Liste prüfen: `config/lemonsqueezy.php` (`webhook_events`). 2. **Staging Smoke (T‑2 Tage)** - - `paddle:webhooks:register --traffic-source=simulation` auf Staging ausfuehren. - - Testkauf via Paddle Sandbox und Webhook Replay verifizieren. + - `lemonsqueezy:webhooks:register --test-mode` auf Staging ausführen. + - Testkauf via Lemon Squeezy Test Mode und Webhook Replay verifizieren. 3. **Cutover Window (T‑0)** - Marketing‑Checkout kurz einfrieren (kein Checkout waehrend der Umschaltung). - Production Webhook registrieren: - - `paddle:webhooks:register --traffic-source=platform --url=https:///paddle/webhook` + - `lemonsqueezy:webhooks:register --url=https:///lemonsqueezy/webhook` - Queue worker laufen lassen (Queue: `webhooks`/`billing` sofern konfiguriert). 4. **Activation** - Erstes Produktions‑Checkout ausfuehren. - - Verify: `checkout_sessions.provider_metadata` wird mit `paddle_*` Feldern befuellt. + - Verify: `checkout_sessions.provider_metadata` wird mit `lemonsqueezy_*` Feldern befuellt. - Verify: `TenantPackage` aktiv und `subscription_status` korrekt. 5. **Rollback (falls notwendig)** - Checkout wieder deaktivieren (Marketing‑Checkout ausblenden). - - Paddle Webhook Destination im Paddle Dashboard deaktivieren. - - Status und Logs sichern (Webhooks, `paddle_sync` Log). + - Lemon Squeezy Webhook Destination im Lemon Squeezy Dashboard deaktivieren. + - Status und Logs sichern (Webhooks, `lemonsqueezy-sync` Log). 6. **Post‑Cutover (T+1)** - Stichproben auf neue Tenants/Packages. - Monitoring: Fehlgeschlagene Webhooks, Sync‑Fehler, Support Tickets. diff --git a/docs/ops/diagrams.md b/docs/ops/diagrams.md index 41bec92..6a2b811 100644 --- a/docs/ops/diagrams.md +++ b/docs/ops/diagrams.md @@ -30,13 +30,12 @@ flowchart LR flowchart LR Tenant[Browser Tenant-Admin] -->|Paket wählen| App[Laravel App] App -->|CheckoutSession anlegen| DB[(DB: checkout_sessions,\n tenant_packages)] - App -->|Redirect| Paddle[Paddle Checkout] + App -->|Redirect| LemonSqueezy[Lemon Squeezy Checkout] - Paddle -->|Zahlung erfolgreich| Webhook[Paddle Webhook Endpoint] + LemonSqueezy -->|Zahlung erfolgreich| Webhook[Lemon Squeezy Webhook Endpoint] Webhook -->|Event verarbeiten| BillingService[CheckoutWebhookService] BillingService -->|TenantPackage aktualisieren| DB DB --> App App --> Tenant ``` - diff --git a/docs/ops/howto-tenant-full-export.md b/docs/ops/howto-tenant-full-export.md index 04f361e..cc3add4 100644 --- a/docs/ops/howto-tenant-full-export.md +++ b/docs/ops/howto-tenant-full-export.md @@ -34,7 +34,7 @@ Dieses How‑to beschreibt, wie du für einen Tenant kurz vor Vertragsende einen ## 4. Billing-Unterlagen - Rechnungen / Zahlungsbelege: - - Paddle‑Belege (Links oder PDFs). + - Lemon Squeezy‑Belege (Links oder PDFs). - Interne Rechnungs‑PDFs (falls generiert). ## 5. Nach dem Export @@ -49,4 +49,3 @@ Siehe auch: - `docs/ops/compliance-dsgvo-ops.md` - `docs/ops/backup-restore.md` - diff --git a/docs/ops/howto-tenant-package-not-active.md b/docs/ops/howto-tenant-package-not-active.md index df91992..88a1ad3 100644 --- a/docs/ops/howto-tenant-package-not-active.md +++ b/docs/ops/howto-tenant-package-not-active.md @@ -13,34 +13,34 @@ Bevor du nachschaust: - Tenant‑ID oder Tenant‑Slug. - Betroffenes Paket (Name oder Beschreibung, z.B. „Pro‑Paket 79 €“). - Zeitpunkt der Zahlung (Datum/Uhrzeit, ggf. Screenshot). -- Ggf. Auszug aus der Paddle‑Bestätigung (ohne vollständige Kartendaten!). +- Ggf. Auszug aus der Lemon Squeezy‑Bestätigung (ohne vollständige Kartendaten!). -Diese Infos erlauben dir, die korrekte Transaktion sowohl in Paddle als auch im Backend zu finden. +Diese Infos erlauben dir, die korrekte Transaktion sowohl in Lemon Squeezy als auch im Backend zu finden. -## 2. Paddle-Status prüfen +## 2. Lemon Squeezy-Status prüfen -1. Im Paddle‑Dashboard: - - Suche nach E‑Mail, Tenant‑Name oder dem vom Tenant genannten Transaktions‑Identifier. - - Stelle sicher, dass die Zahlung dort als „completed“/„paid“ markiert ist. +1. Im Lemon Squeezy‑Dashboard: + - Suche nach E‑Mail, Tenant‑Name oder der vom Tenant genannten Order‑ID. + - Stelle sicher, dass die Zahlung dort als „paid“/„completed“ markiert ist. 2. Notiere: - - Paddle‑Transaction‑ID und ggf. Checkout‑ID. + - Lemon Squeezy‑Order‑ID und ggf. Checkout‑ID. - Status (paid/processing/failed/cancelled). -Wenn Paddle die Zahlung nicht als erfolgreich zeigt, ist dies primär ein Finance‑/Customer‑Topic – ggf. mit Customer Support klären, ob eine neue Zahlung oder Klärung mit dem Kunden notwendig ist. +Wenn Lemon Squeezy die Zahlung nicht als erfolgreich zeigt, ist dies primär ein Finance‑/Customer‑Topic – ggf. mit Customer Support klären, ob eine neue Zahlung oder Klärung mit dem Kunden notwendig ist. ## 3. Backend-Status prüfen -Mit bestätigter Zahlung in Paddle: +Mit bestätigter Zahlung in Lemon Squeezy: 1. `checkout_sessions`: - Suche nach Sessions des Tenants (`tenant_id`) mit dem betroffenen `package_id`: - - Achte auf `status` (erwartet `completed`) und `provider = paddle`. - - Prüfe `provider_metadata` auf `paddle_last_event`, `paddle_status`, `paddle_checkout_id`. - - Wenn du die Session über Paddle‑Metadaten finden möchtest: - - `paddle_checkout_id` aus dem Webhook/Provider‑Metadata oder `transaction_id` verwenden. + - Achte auf `status` (erwartet `completed`) und `provider = lemonsqueezy`. + - Prüfe `provider_metadata` auf `lemonsqueezy_last_event`, `lemonsqueezy_status`, `lemonsqueezy_checkout_id`, `lemonsqueezy_order_id`. + - Wenn du die Session über Lemon Squeezy‑Metadaten finden möchtest: + - `lemonsqueezy_checkout_id` oder `lemonsqueezy_order_id` verwenden. 2. `package_purchases`: - Prüfe, ob ein Eintrag für `(tenant_id, package_id)` mit passender Provider‑Referenz existiert: - - z.B. `provider = 'paddle'`, `provider_id` = Transaction‑ID. + - z.B. `provider = 'lemonsqueezy'`, `provider_id` = Order‑ID. 3. `tenant_packages`: - Prüfe, ob es einen aktiven Eintrag für `(tenant_id, package_id)` gibt: - `active = 1`, `expires_at` in der Zukunft. @@ -51,34 +51,34 @@ Wenn `checkout_sessions` noch nicht auf `completed` steht oder `tenant_packages` 1. Logs prüfen: - `storage/logs/laravel.log` und ggf. `billing`‑Channel. - - Suche nach Einträgen von `PaddleWebhookController` / `CheckoutWebhookService` rund um den Zahlungszeitpunkt. + - Suche nach Einträgen von `LemonSqueezyWebhookController` / `CheckoutWebhookService` rund um den Zahlungszeitpunkt. 2. Typische Ursachen: - Webhook nicht zugestellt (Netzwerk/SSL). - - Webhook konnte die Session nicht auflösen (`[CheckoutWebhook] Paddle session not resolved`). - - Idempotenz‑Lock (`Paddle lock busy`) hat dazu geführt, dass Event nur teilweise verarbeitet wurde. + - Webhook konnte die Session nicht auflösen (`[CheckoutWebhook] Lemon Squeezy session not resolved`). + - Idempotenz‑Lock (`Lemon Squeezy lock busy`) hat dazu geführt, dass Event nur teilweise verarbeitet wurde. ## 5. Korrektur-Schritte ### 5.1 Automatischer Replay (empfohlen) -1. Im Paddle‑Dashboard: - - Den betreffenden `transaction.*`‑Event finden. +1. Im Lemon Squeezy‑Dashboard: + - Den betreffenden `order_*`‑Event finden. - Webhook‑Replay auslösen. 2. In den Logs beobachten: - - Ob `CheckoutWebhookService::handlePaddleEvent()` diesmal die Session findet und `CheckoutAssignmentService::finalise()` ausführt. + - Ob `CheckoutWebhookService::handleLemonSqueezyEvent()` diesmal die Session findet und `CheckoutAssignmentService::finalise()` ausführt. 3. Nochmal `checkout_sessions` und `tenant_packages` prüfen: - Session sollte auf `completed` stehen, Paket aktiv sein. ### 5.2 Manuelle Korrektur (Notfall) -Nur anwenden, wenn klare Freigabe vorliegt und Paddle die Zahlung eindeutig als erfolgreich listet. +Nur anwenden, wenn klare Freigabe vorliegt und Lemon Squeezy die Zahlung eindeutig als erfolgreich listet. 1. `tenant_packages` aktualisieren: - Entweder neuen Eintrag anlegen oder bestehenden für `(tenant_id, package_id)` so setzen, dass: - - `active = 1`, - - `purchased_at` und `expires_at` zu Paddle‑Daten passen. + - `active = 1`, + - `purchased_at` und `expires_at` zu Lemon Squeezy‑Daten passen. 2. `package_purchases` ergänzen: - - Sicherstellen, dass die Zahlung als Zeile mit `provider = 'paddle'`, `provider_id = Transaction‑ID` und passender `price` existiert (für spätere Audits). + - Sicherstellen, dass die Zahlung als Zeile mit `provider = 'lemonsqueezy'`, `provider_id = Order‑ID` und passender `price` existiert (für spätere Audits). 3. Konsistenz prüfen: - Admin UI für Tenant öffnen und prüfen, ob Limits/Paketstatus jetzt korrekt angezeigt werden. 4. Dokumentation: @@ -88,7 +88,7 @@ Nur anwenden, wenn klare Freigabe vorliegt und Paddle die Zahlung eindeutig als - Sobald der Backend‑Status korrigiert ist: - Kurz bestätigen, dass das Paket aktiv ist und welche Auswirkungen das hat (z.B. neue Limits, verlängerte Galerie). -- Falls Paddle die Zahlung nicht als erfolgreich führt: +- Falls Lemon Squeezy die Zahlung nicht als erfolgreich führt: - Ehrlich kommunizieren, dass laut Zahlungsprovider noch keine endgültige Zahlung vorliegt und welche Optionen es gibt (z.B. neue Zahlung, Klärung mit Bank/Kreditkarte). Dieses How‑to sollte dem Support/On‑Call helfen, den gängigsten Billing‑Fehlerfall strukturiert abzuarbeiten. Für tiefere Ursachenanalysen siehe `docs/ops/billing-ops.md`. diff --git a/docs/ops/incidents-major.md b/docs/ops/incidents-major.md index 1ce60ba..e89bc75 100644 --- a/docs/ops/incidents-major.md +++ b/docs/ops/incidents-major.md @@ -49,7 +49,7 @@ Nutze bei Public‑API‑Problems zusätzlich das `docs/ops/deployment/public-ap - Fälle: FTP nicht erreichbar, Ingest nicht laufend, falsche Credentials, Security‑Vorfälle. - **Abrechnung & Billing** - Siehe `docs/ops/billing-ops.md`. - - Fälle: Paddle/RevenueCat‑Webhook‑Fehler, falsche Paket‑Zustände, doppelte/fehlende Buchungen. + - Fälle: Lemon Squeezy/RevenueCat‑Webhook‑Fehler, falsche Paket‑Zustände, doppelte/fehlende Buchungen. Dieses Dokument verweist immer nur auf die jeweils tieferen Runbooks – bei konkreten Problemen gehst du dort in die Details. diff --git a/docs/ops/monitoring-observability.md b/docs/ops/monitoring-observability.md index e8dbc81..b84fb54 100644 --- a/docs/ops/monitoring-observability.md +++ b/docs/ops/monitoring-observability.md @@ -20,7 +20,7 @@ Dieses Dokument sammelt die wichtigsten Monitoring‑Punkte der Plattform und so - HTTP 5xx/4xx spitzenweise, gruppiert nach Route/Service. - Applikations‑Logs mit Error/Warning‑Level. - **Billing & Webhooks** - - Fehlgeschlagene Paddle/RevenueCat‑Webhooks. + - Fehlgeschlagene Lemon Squeezy/RevenueCat‑Webhooks. - Differenz zwischen erwarteten und verarbeiteten Zahlungen (optional). ## 2. Werkzeuge & Quellen @@ -134,7 +134,7 @@ Nachfolgend beispielhafte Formulierungen, wie Alerts unabhängig vom verwendeten ### 6.4 Billing-Webhook Alert -**Ziel**: Fehler bei Paddle/RevenueCat‑Webhook‑Verarbeitung erkennen. +**Ziel**: Fehler bei Lemon Squeezy/RevenueCat‑Webhook‑Verarbeitung erkennen. - Bedingung: - Mehr als 10 fehlgeschlagene Webhook‑Verarbeitungen innerhalb von 10 Minuten, oder Verhältnis `failed/success` > 0,2. diff --git a/docs/ops/oncall-cheatsheet.md b/docs/ops/oncall-cheatsheet.md index 0990e55..bd48f12 100644 --- a/docs/ops/oncall-cheatsheet.md +++ b/docs/ops/oncall-cheatsheet.md @@ -34,7 +34,7 @@ Dieser Spickzettel ist für On‑Call‑Personen gedacht, die im Incident schnel - API‑Fehler‑Rate (5xx, 4xx für Public API). - Queue‑Backlog (`default`, `media-storage`, `media-security`, `notifications`). - Response‑Time Guest‑/Tenant‑PWA. -- Paddle‑Webhook‑Fehler (falls im Monitoring abgebildet). +- Lemon Squeezy‑Webhook‑Fehler (falls im Monitoring abgebildet). > Ergänze hier konkrete Links zu euren Grafana/Datadog‑Dashboards, sobald diese stabil sind. @@ -45,4 +45,3 @@ Dieser Spickzettel ist für On‑Call‑Personen gedacht, die im Incident schnel - SEV‑3: Einzelne Tenants oder Funktionen, Workaround vorhanden. Siehe auch `docs/ops/incidents-major.md` für detaillierte SEV‑Definitionen und Kommunikationsregeln. - diff --git a/docs/ops/operations-manual.md b/docs/ops/operations-manual.md index f965e4c..9dab94a 100644 --- a/docs/ops/operations-manual.md +++ b/docs/ops/operations-manual.md @@ -17,7 +17,7 @@ Ziel ist, dass du von hier aus schnell zu den relevanten Runbooks und Referenzen - Laravel App + Nginx + Redis + MySQL. - Async‑Pipeline: Queues (`default`, `media-storage`, `media-security`, `notifications`) und Horizon. - Satelliten: Photobooth‑FTP + Control‑Service, Docs‑Site (`/internal-docs`), Monitoring/Dokploy. - - Externe Dienste: Paddle (Billing), RevenueCat (Mobile‑Abos), E‑Mail Provider, Logging/Monitoring (Loki/Grafana o.ä.). + - Externe Dienste: Lemon Squeezy (Billing), RevenueCat (Mobile‑Abos), E‑Mail Provider, Logging/Monitoring (Loki/Grafana o.ä.). > TODO: Ergänze ein Architekturdiagramm aus Sicht des Betriebs (z.B. als PNG oder PlantUML) und verlinke es hier. @@ -61,7 +61,7 @@ Diese Seiten sollen praktische Steuerung über Tenant‑Admins und die Guest‑E - **Compliance‑Tools:** DSGVO‑Export‑Requests und Retention‑Overrides pro Tenant/Event. - **Superadmin‑Audit‑Log:** Jede Admin‑Aktion nachvollziehbar (ohne PII‑Payloads). - **Tenant‑Announcements:** Zielgerichtete Hinweise/Release‑Notes an Tenant‑Admins, inkl. Zeitplanung. -- **Integrations‑Health:** Status‑Board für Paddle/RevenueCat/Webhooks inkl. Störungen. +- **Integrations‑Health:** Status‑Board für Lemon Squeezy/RevenueCat/Webhooks inkl. Störungen. ## 2. Deployments & Infrastruktur diff --git a/docs/ops/support-escalation-guide.md b/docs/ops/support-escalation-guide.md index 1c6a50a..fdeb1c1 100644 --- a/docs/ops/support-escalation-guide.md +++ b/docs/ops/support-escalation-guide.md @@ -32,7 +32,7 @@ Dieses Dokument beschreibt, welche Informationen der Support einsammeln sollte, - Zeitpunkt der letzten sichtbaren Fotos. - Ob die Photobooth selbst Fehler anzeigt. - **Paket nicht aktiv / Limits falsch** - - Bestellnummer / Paddle‑Checkout‑ID (falls vorhanden). + - Bestellnummer / Lemon Squeezy‑Checkout‑ID (falls vorhanden). - Zeitpunkt der Zahlung. - Welches Paket wurde erwartet? @@ -47,4 +47,3 @@ Siehe auch: - `docs/ops/oncall-roles.md` - `docs/ops/oncall-cheatsheet.md` - diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index ac13d34..297593e 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -7,7 +7,7 @@ This document tracks the UI/E2E automation efforts. The suites now live under `t - `npm install` - Laravel app running at `http://localhost:8000` - Seeded tenant admin account (see below) -- Paddle sandbox credentials/config applied to the local `.env` +- Lemon Squeezy sandbox credentials/config applied to the local `.env` ## Deterministic Data @@ -42,7 +42,7 @@ The backend exposes `/api/_testing/...` endpoints (local/testing env only): | `GET /api/_testing/mailbox` | Returns every captured email (see `App\Testing\Mailbox`). | | `DELETE /api/_testing/mailbox` | Flushes the captured emails. | | `GET /api/_testing/checkout/sessions/latest` | Fetches the newest checkout session for a given email/tenant filter. | -| `POST /api/_testing/checkout/sessions/{session}/simulate-paddle` | Triggers the Paddle webhook handler for the given session with a mock payload. | +| `POST /api/_testing/checkout/sessions/{session}/simulate-lemonsqueezy` | Triggers the Lemon Squeezy webhook handler for the given session with a mock payload. | | `GET /api/_testing/events/join-token` | Resolves (and optionally regenerates) a join token + QR for a given event ID or slug. | | `POST /api/_testing/guest-events` | Provisions a deterministic guest/tenant event with sample tasks and returns its slug + join token. | @@ -64,7 +64,7 @@ This section provides a staged, repeatable checklist for dynamic security review ### Environment Assumptions (Required) - **Run in staging/test only** — never against production data. - **Dedicated test tenants/users** — use seeded accounts (see above) and avoid real customer data. -- **Sandbox billing** — Paddle sandbox and mock webhook endpoints only. +- **Sandbox billing** — Lemon Squeezy sandbox and mock webhook endpoints only. - **Testing token enabled** — set `E2E_TESTING_TOKEN` and ensure the backend accepts it for `/api/_testing/*`. - **Stable base URL** — set `E2E_BASE_URL` to the target environment (`http://localhost:8000` or staging). - **Email sink** — use `/api/_testing/mailbox` instead of real email delivery. @@ -95,7 +95,7 @@ This section provides a staged, repeatable checklist for dynamic security review ### Checklist: Webhooks/Billing (Dynamic) 1) **Signature validation**: invalid signature is rejected (401/403) and logged. 2) **Freshness**: stale timestamps are rejected; replayed webhook payloads are idempotent. -3) **Paddle sandbox flow**: use `/api/_testing/checkout/sessions/{session}/simulate-paddle` to simulate success/failure; verify ledger updates. +3) **Lemon Squeezy sandbox flow**: use `/api/_testing/checkout/sessions/{session}/simulate-lemonsqueezy` to simulate success/failure; verify ledger updates. 4) **Webhook retries**: transient failures produce retry‑safe behavior (no duplicate ledger entries). 5) **Error handling**: malformed payload returns 4xx (not 500), with minimal error detail. @@ -103,9 +103,9 @@ This section provides a staged, repeatable checklist for dynamic security review | Suite | Location | Primary Coverage | | --- | --- | --- | -| Purchase | `tests/ui/purchase` | Marketing site package selection, checkout flow, coupon handling, Paddle sandbox hand-off, post-purchase dashboard verification. | +| Purchase | `tests/ui/purchase` | Marketing site package selection, checkout flow, coupon handling, Lemon Squeezy sandbox hand-off, post-purchase dashboard verification. | | Auth | `tests/ui/auth` | Registration/login fuzzing, password reset, Social/OAuth hooks, email delivery assertions, throttling/error UX. | -| Admin | `tests/ui/admin` | Tenant onboarding wizard, dashboard widgets, event creation (incl. wedding preset), task assignment, join-token + QR verification, Paddle billing history. | +| Admin | `tests/ui/admin` | Tenant onboarding wizard, dashboard widgets, event creation (incl. wedding preset), task assignment, join-token + QR verification, Lemon Squeezy billing history. | | Guest | `tests/ui/guest` | Guest PWA onboarding, join-token entry, offline sync, uploads/likes/tasks for ≥15 guests, achievement + notification UX. | Each suite should be executable independently to keep CI fast and to allow targeted debugging. @@ -123,7 +123,7 @@ Traces are recorded on first retry (`playwright.config.ts`); open via `npx playw 1. **Purchase suite** - Seed coupons via helper. - - Cover `/de/packages` Standard selection, coupon states (valid/invalid/expired), Paddle inline + hosted checkout using sandbox card `4000 0566 5566 5557 / CVV 100`. + - Cover `/de/packages` Standard selection, coupon states (valid/invalid/expired), Lemon Squeezy inline + hosted checkout using sandbox card `4000 0566 5566 5557 / CVV 100`. - Simulate webhook success (helper endpoint TBD) so dashboard reflects the purchase. - Assert confirmation emails captured via mailbox API. @@ -135,7 +135,7 @@ Traces are recorded on first retry (`playwright.config.ts`); open via `npx playw 3. **Admin suite** - After purchase, log into `/event-admin`, confirm latest package appears, create a wedding event, assign predefined tasks, fetch join token + QR (helper should expose raw token/URL). - Cover task management UX (assign, reorder, complete). - - Verify billing history shows the recent Paddle transaction. + - Verify billing history shows the recent Lemon Squeezy transaction. 4. **Guest suite** - Use join token from Admin suite (or seed via helper) to onboard 15 simulated guests in parallel contexts. @@ -143,7 +143,7 @@ Traces are recorded on first retry (`playwright.config.ts`); open via `npx playw - Validate guest-facing error states (expired token, upload failure, network loss). 5. **Shared helpers (backend + Playwright)** - - Webhook trigger endpoint for Paddle sandbox. + - Webhook trigger endpoint for Lemon Squeezy sandbox. - Join token + QR extraction endpoint for tests. - Task template seeding helper. - Optional guest factory endpoint to mint attendees quickly. diff --git a/lang/de/emails.php b/lang/de/emails.php index 847af77..0f71716 100644 --- a/lang/de/emails.php +++ b/lang/de/emails.php @@ -60,7 +60,7 @@ return [ 'cta_link' => 'Falls der Button nicht funktioniert, nutze diesen Link: :url', 'benefits_title' => 'Warum jetzt abschliessen?', 'benefit1' => 'Sofortige Aktivierung nach Zahlung', - 'benefit2' => 'Sicherer Checkout mit Paddle', + 'benefit2' => 'Sicherer Checkout mit Lemon Squeezy', 'benefit3' => 'Automatische Rechnungen und Steuerabwicklung', 'benefit4' => 'Unterstuetzung, wenn du sie brauchst', 'footer' => 'Brauchst du Hilfe? Antworte einfach auf diese E-Mail.', diff --git a/lang/en/emails.php b/lang/en/emails.php index d403411..7fc5e45 100644 --- a/lang/en/emails.php +++ b/lang/en/emails.php @@ -60,7 +60,7 @@ return [ 'cta_link' => 'If the button does not work, use this link: :url', 'benefits_title' => 'Why finish now?', 'benefit1' => 'Instant activation after payment', - 'benefit2' => 'Secure checkout with Paddle', + 'benefit2' => 'Secure checkout with Lemon Squeezy', 'benefit3' => 'Automatic invoices and tax handling', 'benefit4' => 'Friendly support whenever you need help', 'footer' => 'Need help? Reply to this email.', diff --git a/package-lock.json b/package-lock.json index 5143fbc..11f3803 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "fotospiel-app", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/utilities": "^3.2.2", diff --git a/public/lang/de/legal.json b/public/lang/de/legal.json index b7bcfd0..4864e2b 100644 --- a/public/lang/de/legal.json +++ b/public/lang/de/legal.json @@ -11,14 +11,14 @@ "contact": "Kontakt", "vat_id": "Umsatzsteuer-ID: DE123456789", "monetization": "Monetarisierung", - "monetization_desc": "Wir monetarisieren über Packages (Einmalkäufe und Abos) via Paddle. Preise exkl. MwSt. Support: support@fotospiel.de", + "monetization_desc": "Wir monetarisieren über Packages (Einmalkäufe und Abos) via Lemon Squeezy. Preise exkl. MwSt. Support: support@fotospiel.de", "register_court": "Registergericht: Amtsgericht Musterstadt", "commercial_register": "Handelsregister: HRB 12345", "datenschutz_intro": "Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.", "responsible": "Verantwortlich: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt", "data_collection": "Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.", "payments": "Zahlungen und Packages", - "payments_desc": "Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsdaten werden als Merchant of Record sicher und verschlüsselt durch Paddle verarbeitet.", + "payments_desc": "Wir verarbeiten Zahlungen für Packages über Lemon Squeezy. Zahlungsdaten werden als Merchant of Record sicher und verschlüsselt durch Lemon Squeezy verarbeitet.", "data_retention": "Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.", "rights": "Ihre Rechte: Auskunft, Löschung, Widerspruch.", "cookies": "Cookies: Nur funktionale Cookies für die PWA.", diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index d67c3d3..c959f02 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -146,7 +146,7 @@ "faq_q3": "Was passiert bei Ablauf?", "faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.", "faq_q4": "Zahlungssicher?", - "faq_a4": "Ja, via Paddle – sicher und GDPR-konform.", + "faq_a4": "Ja, via Lemon Squeezy – sicher und GDPR-konform.", "final_cta": "Bereit für Ihr nächstes Event?", "contact_us": "Kontaktieren Sie uns", "feature_live_slideshow": "Live-Slideshow", @@ -179,7 +179,7 @@ "billing_per_kontingent": "pro Kontingent", "more_features": "+{{count}} weitere Features", "feature_overview": "Feature-Überblick", - "order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.", + "order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung über Lemon Squeezy.", "features_label": "Features", "feature_highlights": "Feature-Highlights", "detail_labels": { @@ -362,8 +362,8 @@ "euro": "€" }, "feature": "Feature", - "paddle_not_configured": "Dieses Package ist noch nicht für den Paddle-Checkout konfiguriert. Bitte kontaktiere den Support.", - "paddle_checkout_failed": "Der Paddle-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.", + "lemonsqueezy_not_configured": "Dieses Package ist noch nicht für den Lemon Squeezy-Checkout konfiguriert. Bitte kontaktiere den Support.", + "lemonsqueezy_checkout_failed": "Der Lemon Squeezy-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.", "gift_cta": "Paket verschenken" }, "blog": { @@ -685,24 +685,24 @@ "free_package_desc": "Dieses Paket ist kostenlos. Wir aktivieren es direkt nach der Bestätigung.", "activate_package": "Paket aktivieren", "loading_payment": "Zahlungsdaten werden geladen...", - "secure_payment_desc": "Sichere Zahlung über Paddle.", - "paddle_intro": "Starte den Paddle-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.", - "guided_title": "Sichere Zahlung mit Paddle – unserem geprüften Partner", - "guided_body": "Wir führen dich Schritt für Schritt durch den Bezahlprozess. Paddle wickelt den Kauf als Merchant of Record ab und sorgt dafür, dass Steuern und Rechnungen automatisch korrekt erstellt werden.", - "paddle_partner": "Powered by Paddle", + "secure_payment_desc": "Sichere Zahlung über Lemon Squeezy.", + "lemonsqueezy_intro": "Starte den Lemon Squeezy-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.", + "guided_title": "Sichere Zahlung mit Lemon Squeezy – unserem geprüften Partner", + "guided_body": "Wir führen dich Schritt für Schritt durch den Bezahlprozess. Lemon Squeezy wickelt den Kauf als Merchant of Record ab und sorgt dafür, dass Steuern und Rechnungen automatisch korrekt erstellt werden.", + "lemonsqueezy_partner": "Powered by Lemon Squeezy", "trust_secure": "Verschlüsselte Zahlung", "trust_tax": "Automatische Steuerberechnung", "trust_support": "Support in Minuten", - "guided_cta_hint": "Paddle wickelt deine Zahlung als Merchant of Record ab", + "guided_cta_hint": "Lemon Squeezy wickelt deine Zahlung als Merchant of Record ab", "toast_success": "Zahlung erfolgreich – wir bereiten alles vor.", - "paddle_preparing": "Paddle-Checkout wird vorbereitet…", - "paddle_overlay_ready": "Der Paddle-Checkout läuft jetzt in einem Overlay. Schließe die Zahlung dort ab und kehre anschließend hierher zurück.", - "paddle_ready": "Paddle-Checkout wurde in einem neuen Tab geöffnet. Schließe die Zahlung dort ab und kehre dann hierher zurück.", - "paddle_error": "Der Paddle-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.", - "paddle_not_ready": "Der Paddle-Checkout ist noch nicht bereit. Bitte versuche es in einem Moment erneut.", - "paddle_not_configured": "Dieses Paket ist noch nicht für den Paddle-Checkout konfiguriert. Bitte kontaktiere den Support.", - "paddle_disclaimer": "Paddle wickelt Zahlungen als Merchant of Record ab. Steuern werden automatisch anhand deiner Rechnungsdaten berechnet.", - "pay_with_paddle": "Weiter mit Paddle", + "lemonsqueezy_preparing": "Lemon Squeezy-Checkout wird vorbereitet…", + "lemonsqueezy_overlay_ready": "Der Lemon Squeezy-Checkout läuft jetzt in einem Overlay. Schließe die Zahlung dort ab und kehre anschließend hierher zurück.", + "lemonsqueezy_ready": "Lemon Squeezy-Checkout wurde in einem neuen Tab geöffnet. Schließe die Zahlung dort ab und kehre dann hierher zurück.", + "lemonsqueezy_error": "Der Lemon Squeezy-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.", + "lemonsqueezy_not_ready": "Der Lemon Squeezy-Checkout ist noch nicht bereit. Bitte versuche es in einem Moment erneut.", + "lemonsqueezy_not_configured": "Dieses Paket ist noch nicht für den Lemon Squeezy-Checkout konfiguriert. Bitte kontaktiere den Support.", + "lemonsqueezy_disclaimer": "Lemon Squeezy wickelt Zahlungen als Merchant of Record ab. Steuern werden automatisch anhand deiner Rechnungsdaten berechnet.", + "pay_with_lemonsqueezy": "Weiter mit Lemon Squeezy", "continue_after_payment": "Ich habe die Zahlung abgeschlossen", "no_package_title": "Kein Paket ausgewählt", "no_package_description": "Bitte wähle ein Paket, um zum Checkout zu gelangen.", @@ -747,7 +747,7 @@ "email_followup": "Wir haben dir gerade alle Details per E-Mail geschickt – inklusive Rechnung und den nächsten Schritten.", "hero_badge": "Checkout abgeschlossen", "hero_title": "Weiter geht's im Marketing-Dashboard", - "hero_body": "Wir haben deinen Zugang aktiviert und Paddle synchronisiert. Mit diesen Aufgaben startest du direkt durch.", + "hero_body": "Wir haben deinen Zugang aktiviert und Lemon Squeezy synchronisiert. Mit diesen Aufgaben startest du direkt durch.", "hero_next": "Nutze den Button unten, um in deinen Kundenbereich zu wechseln – diese Übersicht kannst du jederzeit erneut öffnen.", "status_title": "Bestellstatus", "status_subtitle": "Wir schließen die Aktivierung ab und synchronisieren dein Konto.", @@ -756,7 +756,7 @@ "completed": "Bestätigt", "failed": "Aktion nötig" }, - "status_body_processing": "Wir synchronisieren dein Konto mit Paddle. Das kann einen Moment dauern.", + "status_body_processing": "Wir synchronisieren dein Konto mit Lemon Squeezy. Das kann einen Moment dauern.", "status_body_completed": "Alles ist bereit. Dein Konto ist vollständig freigeschaltet.", "status_body_failed": "Wir konnten den Kauf noch nicht bestätigen. Bitte prüfe den Status erneut oder kontaktiere den Support.", "status_manual_hint": "Dauert es zu lange? Du kannst den Status erneut prüfen oder die Seite aktualisieren.", @@ -765,7 +765,7 @@ "status_items": { "payment": { "title": "Zahlung bestätigt", - "body": "Deine Paddle-Zahlung war erfolgreich." + "body": "Deine Lemon Squeezy-Zahlung war erfolgreich." }, "email": { "title": "Beleg versendet", @@ -870,7 +870,7 @@ "Co-Hosts für Moderation & Liveshow hinzufügen", "Offline-Uploads werden automatisch nachgesendet", "Photobooth Connect streamt Fotobox-Fotos (Windows, macOS & Linux)", - "Integrationen über Paddle-Abrechnung und RevenueCat für Apps" + "Integrationen über Lemon Squeezy-Abrechnung und RevenueCat für Apps" ] }, "guest": { @@ -918,7 +918,7 @@ "timeline": [ { "title": "Event vorbereiten", - "body": "Account registrieren, Paket wählen und Branding setzen. Kontingente laufen über Paddle, Mobile-Apps über RevenueCat.", + "body": "Account registrieren, Paket wählen und Branding setzen. Kontingente laufen über Lemon Squeezy, Mobile-Apps über RevenueCat.", "tips": [ "Testevent anlegen, um Upload-Flow vorab zu prüfen", "Trauzeug:innen oder Kolleg:innen als Co-Hosts einladen" @@ -1089,7 +1089,7 @@ }, { "question": "Wie läuft die Bezahlung?", - "answer": "Web-Pakete werden über Paddle abgerechnet (inklusive Rechnung & Steuerhandling). Mobile Abos verwalten wir über RevenueCat." + "answer": "Web-Pakete werden über Lemon Squeezy abgerechnet (inklusive Rechnung & Steuerhandling). Mobile Abos verwalten wir über RevenueCat." }, { "question": "Welche Dateiformate sind erlaubt?", @@ -1172,8 +1172,8 @@ "message_placeholder": "Ein kleines Geschenk für euer Event!", "accept_terms": "Ich habe die Widerrufsbelehrung gelesen: 14 Tage Widerruf ab Kauf, erlischt mit (Teil-)Einlösung.", "accept_terms_required": "Bitte bestätige den Hinweis zum Widerruf.", - "cta": "Weiter mit Paddle", - "processing": "Paddle-Checkout wird geöffnet …", + "cta": "Weiter mit Lemon Squeezy", + "processing": "Lemon Squeezy-Checkout wird geöffnet …", "error_select_tier": "Bitte wähle einen Gutscheinbetrag.", "error_purchaser_email": "Bitte gib eine gültige E-Mail ein.", "error_recipient_email": "Bitte gib eine gültige Empfänger-E-Mail ein.", diff --git a/public/lang/en/legal.json b/public/lang/en/legal.json index 9f433f5..a644f7d 100644 --- a/public/lang/en/legal.json +++ b/public/lang/en/legal.json @@ -10,14 +10,14 @@ "contact": "Contact", "vat_id": "VAT ID: DE123456789", "monetization": "Monetization", - "monetization_desc": "We monetize through Packages (one-time purchases and subscriptions) via Paddle. Prices excl. VAT. Support: support@fotospiel.de", + "monetization_desc": "We monetize through Packages (one-time purchases and subscriptions) via Lemon Squeezy. Prices excl. VAT. Support: support@fotospiel.de", "register_court": "Register Court: District Court Musterstadt", "commercial_register": "Commercial Register: HRB 12345", "datenschutz_intro": "We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.", "responsible": "Responsible: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt", "data_collection": "Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.", "payments": "Payments and Packages", - "payments_desc": "We process payments for Packages via Paddle. Payment data is handled securely and encrypted by Paddle as the merchant of record.", + "payments_desc": "We process payments for Packages via Lemon Squeezy. Payment data is handled securely and encrypted by Lemon Squeezy as the merchant of record.", "data_retention": "Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.", "rights": "Your rights: Information, deletion, objection. Contact us under Contact.", "cookies": "Cookies: Only functional cookies for the PWA.", diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index b0d7fb2..3fe1dbb 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -133,7 +133,7 @@ "faq_q3": "What happens when it expires?", "faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend.", "faq_q4": "Payment secure?", - "faq_a4": "Yes, via Paddle – secure and GDPR compliant.", + "faq_a4": "Yes, via Lemon Squeezy – secure and GDPR compliant.", "final_cta": "Ready for your next event?", "contact_us": "Contact Us", "feature_live_slideshow": "Live Slideshow", @@ -167,7 +167,7 @@ "billing_per_bundle": "per bundle", "more_features": "+{{count}} more features", "feature_overview": "Feature overview", - "order_hint": "Launch instantly – secure Paddle checkout, no hidden fees.", + "order_hint": "Launch instantly – secure Lemon Squeezy checkout, no hidden fees.", "features_label": "Features", "feature_highlights": "Feature Highlights", "detail_labels": { @@ -353,8 +353,8 @@ "currency": { "euro": "€" }, - "paddle_not_configured": "This package is not ready for Paddle checkout. Please contact support.", - "paddle_checkout_failed": "We could not start the Paddle checkout. Please try again later.", + "lemonsqueezy_not_configured": "This package is not ready for Lemon Squeezy checkout. Please contact support.", + "lemonsqueezy_checkout_failed": "We could not start the Lemon Squeezy checkout. Please try again later.", "gift_cta": "Gift a package" }, "blog": { @@ -683,24 +683,24 @@ "free_package_desc": "This package is free. We activate it directly after confirmation.", "activate_package": "Activate Package", "loading_payment": "Payment data is loading...", - "secure_payment_desc": "Secure payment with Paddle.", - "paddle_intro": "Launch the Paddle checkout right here in the wizard—no page changes required.", - "guided_title": "Secure checkout, powered by Paddle", - "guided_body": "We walk you through every step. Paddle acts as merchant of record, handles taxes automatically, and delivers compliant invoices instantly.", - "paddle_partner": "Powered by Paddle", + "secure_payment_desc": "Secure payment with Lemon Squeezy.", + "lemonsqueezy_intro": "Launch the Lemon Squeezy checkout right here in the wizard—no page changes required.", + "guided_title": "Secure checkout, powered by Lemon Squeezy", + "guided_body": "We walk you through every step. Lemon Squeezy acts as merchant of record, handles taxes automatically, and delivers compliant invoices instantly.", + "lemonsqueezy_partner": "Powered by Lemon Squeezy", "trust_secure": "Encrypted payment", "trust_tax": "Automatic tax handling", "trust_support": "Live support within minutes", - "guided_cta_hint": "Securely processed by Paddle as Merchant of Record", + "guided_cta_hint": "Securely processed by Lemon Squeezy as Merchant of Record", "toast_success": "Payment received – setting everything up for you.", - "paddle_preparing": "Preparing Paddle checkout…", - "paddle_overlay_ready": "Paddle checkout is running in a secure overlay. Complete the payment there and then continue here.", - "paddle_ready": "Paddle checkout opened in a new tab. Complete the payment and then continue here.", - "paddle_error": "We could not start the Paddle checkout. Please try again.", - "paddle_not_ready": "Paddle checkout is not ready yet. Please try again in a moment.", - "paddle_not_configured": "This package is not ready for Paddle checkout. Please contact support.", - "paddle_disclaimer": "Paddle processes payments as merchant of record. Taxes are calculated automatically based on your billing details.", - "pay_with_paddle": "Continue with Paddle", + "lemonsqueezy_preparing": "Preparing Lemon Squeezy checkout…", + "lemonsqueezy_overlay_ready": "Lemon Squeezy checkout is running in a secure overlay. Complete the payment there and then continue here.", + "lemonsqueezy_ready": "Lemon Squeezy checkout opened in a new tab. Complete the payment and then continue here.", + "lemonsqueezy_error": "We could not start the Lemon Squeezy checkout. Please try again.", + "lemonsqueezy_not_ready": "Lemon Squeezy checkout is not ready yet. Please try again in a moment.", + "lemonsqueezy_not_configured": "This package is not ready for Lemon Squeezy checkout. Please contact support.", + "lemonsqueezy_disclaimer": "Lemon Squeezy processes payments as merchant of record. Taxes are calculated automatically based on your billing details.", + "pay_with_lemonsqueezy": "Continue with Lemon Squeezy", "continue_after_payment": "I completed the payment", "no_package_title": "No package selected", "no_package_description": "Please choose a package to continue to checkout.", @@ -745,7 +745,7 @@ "email_followup": "We've just sent a confirmation email with your receipt and the next steps.", "hero_badge": "Checkout complete", "hero_title": "You're ready for the Marketing Dashboard", - "hero_body": "We activated your access and synced Paddle. Follow the checklist below to launch your first event.", + "hero_body": "We activated your access and synced Lemon Squeezy. Follow the checklist below to launch your first event.", "hero_next": "Use the button below whenever you're ready to jump into your customer area—this summary is always available.", "status_title": "Purchase status", "status_subtitle": "We are finishing the handoff and syncing your account.", @@ -754,7 +754,7 @@ "completed": "Confirmed", "failed": "Needs attention" }, - "status_body_processing": "We are syncing your account with Paddle. This can take a minute.", + "status_body_processing": "We are syncing your account with Lemon Squeezy. This can take a minute.", "status_body_completed": "Everything is ready. Your account is fully unlocked.", "status_body_failed": "We could not confirm the purchase yet. Please try again or contact support.", "status_manual_hint": "Still waiting? You can re-check the status or refresh the page.", @@ -763,7 +763,7 @@ "status_items": { "payment": { "title": "Payment confirmed", - "body": "Your Paddle payment was successful." + "body": "Your Lemon Squeezy payment was successful." }, "email": { "title": "Receipt sent", @@ -868,7 +868,7 @@ "Add co-hosts for moderation and the live show", "Offline uploads sync automatically once back online", "Photobooth Connect streams booth photos (Windows, macOS & Linux)", - "Billing handled via Paddle, mobile apps through RevenueCat" + "Billing handled via Lemon Squeezy, mobile apps through RevenueCat" ] }, "guest": { @@ -916,7 +916,7 @@ "timeline": [ { "title": "Prepare your event", - "body": "Register, choose a package, and apply your branding. Web payments run through Paddle, mobile apps via RevenueCat.", + "body": "Register, choose a package, and apply your branding. Web payments run through Lemon Squeezy, mobile apps via RevenueCat.", "tips": [ "Create a test event to experience the upload flow", "Invite co-hosts like MCs or colleagues" @@ -1087,7 +1087,7 @@ }, { "question": "How do payments work?", - "answer": "Web packages are billed through Paddle (with invoices and tax handling). Mobile subscriptions are managed via RevenueCat." + "answer": "Web packages are billed through Lemon Squeezy (with invoices and tax handling). Mobile subscriptions are managed via RevenueCat." }, { "question": "Which file formats are supported?", @@ -1170,8 +1170,8 @@ "message_placeholder": "A little something for your event!", "accept_terms": "I have read the withdrawal policy: 14 days from purchase, expires upon (partial) redemption.", "accept_terms_required": "Please confirm the withdrawal note.", - "cta": "Continue with Paddle", - "processing": "Opening Paddle checkout …", + "cta": "Continue with Lemon Squeezy", + "processing": "Opening Lemon Squeezy checkout …", "error_select_tier": "Please select a voucher amount.", "error_purchaser_email": "Please enter a valid email.", "error_recipient_email": "Please enter a valid recipient email.", diff --git a/resources/css/app.css b/resources/css/app.css index b6070e3..1fc2c71 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -34,7 +34,7 @@ @keyframes guestCompassFlyIn { 0% { opacity: 0; - transform: translate(120px, 260px) scale(0.35) rotate(-90deg); + transform: translate(0px, 240px) scale(0.35) rotate(-90deg); } 60% { opacity: 1; @@ -52,6 +52,77 @@ will-change: transform, opacity; } +@keyframes guestCompassFlyOut { + 0% { + opacity: 1; + transform: translate(0px, 0px) scale(1) rotate(0deg); + } + 100% { + opacity: 0; + transform: translate(0px, 240px) scale(0.35) rotate(-90deg); + } +} + +.guest-compass-flyout { + animation: guestCompassFlyOut 520ms cubic-bezier(0.4, 0, 0.2, 1) both; + transform-origin: center; + will-change: transform, opacity; +} + +@keyframes guestTopbarTitleReveal { + 0% { + opacity: 0; + transform: translateY(10px) scale(0.98); + filter: blur(6px); + letter-spacing: 0.05em; + } + 60% { + opacity: 1; + transform: translateY(0px) scale(1.03); + filter: blur(0px); + letter-spacing: 0.01em; + } + 100% { + opacity: 1; + transform: translateY(0px) scale(1); + filter: blur(0px); + letter-spacing: 0em; + } +} + +@keyframes guestTopbarChromaticSplit { + 0% { + text-shadow: none; + } + 40% { + text-shadow: none; + } + 100% { + text-shadow: none; + } +} + +.guest-topbar-title { + position: relative; + display: inline-block; + animation: + guestTopbarTitleReveal 1800ms cubic-bezier(0.2, 0.8, 0.2, 1) both, + guestTopbarChromaticSplit 2200ms ease-out both; + transform-origin: left center; + will-change: transform, opacity, filter; +} + +.guest-topbar-title::after { + content: ''; + display: none; +} + +.guest-topbar-title::after { + content: ''; + position: absolute; + display: none; +} + @source '../views'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 090b5fb..8501fe6 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -557,7 +557,7 @@ export type DataExportSummary = { } | null; }; -export type PaddleTransactionSummary = { +export type LemonSqueezyOrderSummary = { id: string | null; status: string | null; amount: number | null; @@ -1125,7 +1125,7 @@ export function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary { }; } -function normalizePaddleTransaction(entry: JsonValue): PaddleTransactionSummary { +function normalizeLemonSqueezyOrder(entry: JsonValue): LemonSqueezyOrderSummary { const amountValue = entry.amount ?? entry.grand_total ?? (entry.totals && entry.totals.grand_total); const taxValue = entry.tax ?? (entry.totals && entry.totals.tax_total); @@ -2348,8 +2348,8 @@ export type Package = { gallery_days: number | null; max_events_per_year?: number | null; included_package_slug?: string | null; - paddle_price_id?: string | null; - paddle_product_id?: string | null; + lemonsqueezy_variant_id?: string | null; + lemonsqueezy_product_id?: string | null; branding_allowed?: boolean | null; watermark_allowed?: boolean | null; features: string[] | Record | null; @@ -2731,8 +2731,8 @@ export async function downloadTenantDataExport(downloadUrl: string): Promise { @@ -2745,8 +2745,8 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{ if (!response.ok) { const payload = await safeJson(response); - console.error('[API] Failed to load Paddle transactions', response.status, payload); - throw new Error('Failed to load Paddle transactions'); + console.error('[API] Failed to load Lemon Squeezy transactions', response.status, payload); + throw new Error('Failed to load Lemon Squeezy transactions'); } const payload = await safeJson(response) ?? {}; @@ -2754,17 +2754,17 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{ const meta = payload.meta ?? {}; return { - data: entries.map(normalizePaddleTransaction), + data: entries.map(normalizeLemonSqueezyOrder), nextCursor: typeof meta.next === 'string' ? meta.next : null, hasMore: Boolean(meta.has_more), }; } -export async function createTenantPaddleCheckout( +export async function createTenantLemonSqueezyCheckout( packageId: number, urls?: { success_url?: string; return_url?: string } ): Promise<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }> { - const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', { + const response = await authorizedFetch('/api/v1/tenant/packages/lemonsqueezy-checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -2796,15 +2796,15 @@ export async function createTenantBillingPortalSession(): Promise<{ url: string if (!response.ok) { const payload = await safeJson(response); - console.error('[API] Failed to create Paddle portal session', response.status, payload); - throw new Error('Failed to create Paddle portal session'); + console.error('[API] Failed to create Lemon Squeezy portal session', response.status, payload); + throw new Error('Failed to create Lemon Squeezy portal session'); } const payload = await safeJson(response); const url = payload?.url; if (typeof url !== 'string' || url.length === 0) { - throw new Error('Paddle portal session missing URL'); + throw new Error('Lemon Squeezy portal session missing URL'); } return { url }; @@ -2848,13 +2848,13 @@ export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{ export async function completeTenantPackagePurchase(params: { packageId: number; - paddleTransactionId: string; + orderId: string; }): Promise { - const { packageId, paddleTransactionId } = params; + const { packageId, orderId } = params; const payload: Record = { package_id: packageId }; - if (paddleTransactionId) { - payload.paddle_transaction_id = paddleTransactionId; + if (orderId) { + payload.lemonsqueezy_order_id = orderId; } const response = await authorizedFetch('/api/v1/tenant/packages/complete', { diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index a485581..6be5a9e 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -15,7 +15,7 @@ "actions": { "refresh": "Aktualisieren", "exportCsv": "Export als CSV", - "portal": "Im Paddle-Portal verwalten", + "portal": "Im Lemon Squeezy-Portal verwalten", "portalBusy": "Portal wird geöffnet...", "openPackages": "Pakete öffnen", "contactSupport": "Support kontaktieren" @@ -42,7 +42,7 @@ "errors": { "load": "Paketdaten konnten nicht geladen werden.", "more": "Weitere Einträge konnten nicht geladen werden.", - "portal": "Paddle-Portal konnte nicht geöffnet werden." + "portal": "Lemon Squeezy-Portal konnte nicht geöffnet werden." }, "checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.", "checkoutCancelled": "Checkout wurde abgebrochen.", @@ -62,8 +62,11 @@ "checkoutActionBadge": "Aktion nötig", "checkoutActionButton": "Checkout fortsetzen", "checkoutFailureReasons": { - "paddle_failed": "Die Zahlung wurde abgelehnt.", - "paddle_cancelled": "Der Checkout wurde abgebrochen." + "lemonsqueezy_failed": "Die Zahlung wurde abgelehnt.", + "lemonsqueezy_cancelled": "Der Checkout wurde abgebrochen.", + "lemonsqueezy_canceled": "Der Checkout wurde abgebrochen.", + "lemonsqueezy_refunded": "Die Zahlung wurde erstattet.", + "lemonsqueezy_voided": "Die Zahlung wurde storniert." }, "sections": { "invoices": { @@ -125,9 +128,9 @@ } }, "transactions": { - "title": "Paddle-Transaktionen", - "description": "Neueste Paddle-Transaktionen für dieses Kundenkonto.", - "empty": "Noch keine Paddle-Transaktionen.", + "title": "Lemon Squeezy-Transaktionen", + "description": "Neueste Lemon Squeezy-Transaktionen für dieses Kundenkonto.", + "empty": "Noch keine Lemon Squeezy-Transaktionen.", "labels": { "transactionId": "Transaktion {{id}}", "checkoutId": "Checkout-ID: {{id}}", diff --git a/resources/js/admin/i18n/locales/de/onboarding.json b/resources/js/admin/i18n/locales/de/onboarding.json index 79dc9b3..46f850d 100644 --- a/resources/js/admin/i18n/locales/de/onboarding.json +++ b/resources/js/admin/i18n/locales/de/onboarding.json @@ -192,25 +192,25 @@ "failureTitle": "Aktivierung fehlgeschlagen", "errorMessage": "Kostenloses Paket konnte nicht aktiviert werden." }, - "paddle": { - "sectionTitle": "Paddle", - "heading": "Checkout mit Paddle", - "genericError": "Der Paddle-Checkout konnte nicht geöffnet werden. Bitte versuche es erneut.", - "errorTitle": "Paddle-Fehler", - "processing": "Paddle-Checkout wird geöffnet …", - "cta": "Paddle-Checkout öffnen", - "hint": "Es öffnet sich ein neuer Tab über Paddle (Merchant of Record). Schließe dort die Zahlung ab und kehre anschließend zurück." + "lemonsqueezy": { + "sectionTitle": "Lemon Squeezy", + "heading": "Checkout mit Lemon Squeezy", + "genericError": "Der Lemon Squeezy-Checkout konnte nicht geöffnet werden. Bitte versuche es erneut.", + "errorTitle": "Lemon Squeezy-Fehler", + "processing": "Lemon Squeezy-Checkout wird geöffnet …", + "cta": "Lemon Squeezy-Checkout öffnen", + "hint": "Es öffnet sich ein neuer Tab über Lemon Squeezy (Merchant of Record). Schließe dort die Zahlung ab und kehre anschließend zurück." }, "nextStepsTitle": "Nächste Schritte", "nextSteps": [ - "Optional: Abrechnung über Paddle im Billing-Bereich abschließen.", + "Optional: Abrechnung über Lemon Squeezy im Billing-Bereich abschließen.", "Event-Setup durchlaufen und Fotoaufgaben, Team & Galerie konfigurieren.", "Vor dem Go-Live Event-Kontingent prüfen und Gäste-Link teilen." ], "cta": { "billing": { "label": "Abrechnung starten", - "description": "Öffnet den Billing-Bereich mit Paddle- und Kontingent-Optionen.", + "description": "Öffnet den Billing-Bereich mit Lemon Squeezy- und Kontingent-Optionen.", "button": "Zu Billing & Zahlung" }, "setup": { diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 87d876f..e12820e 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -15,7 +15,7 @@ "actions": { "refresh": "Refresh", "exportCsv": "Export CSV", - "portal": "Manage in Paddle", + "portal": "Manage in Lemon Squeezy", "portalBusy": "Opening portal...", "openPackages": "Open packages", "contactSupport": "Contact support" @@ -42,7 +42,7 @@ "errors": { "load": "Unable to load package data.", "more": "Unable to load more entries.", - "portal": "Unable to open the Paddle portal." + "portal": "Unable to open the Lemon Squeezy portal." }, "checkoutSuccess": "Checkout completed. Your package will activate shortly.", "checkoutCancelled": "Checkout was cancelled.", @@ -62,8 +62,11 @@ "checkoutActionBadge": "Action needed", "checkoutActionButton": "Continue checkout", "checkoutFailureReasons": { - "paddle_failed": "The payment was declined.", - "paddle_cancelled": "The checkout was cancelled." + "lemonsqueezy_failed": "The payment was declined.", + "lemonsqueezy_cancelled": "The checkout was cancelled.", + "lemonsqueezy_canceled": "The checkout was cancelled.", + "lemonsqueezy_refunded": "The payment was refunded.", + "lemonsqueezy_voided": "The payment was voided." }, "sections": { "invoices": { @@ -125,9 +128,9 @@ } }, "transactions": { - "title": "Paddle transactions", - "description": "Recent Paddle transactions for this customer account.", - "empty": "No Paddle transactions yet.", + "title": "Lemon Squeezy transactions", + "description": "Recent Lemon Squeezy transactions for this customer account.", + "empty": "No Lemon Squeezy transactions yet.", "labels": { "transactionId": "Transaction {{id}}", "checkoutId": "Checkout ID: {{id}}", diff --git a/resources/js/admin/i18n/locales/en/onboarding.json b/resources/js/admin/i18n/locales/en/onboarding.json index a2186cc..ff256bd 100644 --- a/resources/js/admin/i18n/locales/en/onboarding.json +++ b/resources/js/admin/i18n/locales/en/onboarding.json @@ -192,25 +192,25 @@ "failureTitle": "Activation failed", "errorMessage": "The free package could not be activated." }, - "paddle": { - "sectionTitle": "Paddle", - "heading": "Checkout with Paddle", - "genericError": "The Paddle checkout could not be opened. Please try again.", - "errorTitle": "Paddle error", - "processing": "Opening the Paddle checkout …", - "cta": "Open Paddle checkout", - "hint": "A new tab opens via Paddle (merchant of record). Complete the payment there, then return to continue." + "lemonsqueezy": { + "sectionTitle": "Lemon Squeezy", + "heading": "Checkout with Lemon Squeezy", + "genericError": "The Lemon Squeezy checkout could not be opened. Please try again.", + "errorTitle": "Lemon Squeezy error", + "processing": "Opening the Lemon Squeezy checkout …", + "cta": "Open Lemon Squeezy checkout", + "hint": "A new tab opens via Lemon Squeezy (merchant of record). Complete the payment there, then return to continue." }, "nextStepsTitle": "Next steps", "nextSteps": [ - "Optional: finish billing via Paddle inside the billing area.", + "Optional: finish billing via Lemon Squeezy inside the billing area.", "Complete the event setup and configure photo tasks, team, and gallery.", "Check your event bundle before go-live and share your guest link." ], "cta": { "billing": { "label": "Start billing", - "description": "Opens the billing area with Paddle bundle options.", + "description": "Opens the billing area with Lemon Squeezy bundle options.", "button": "Go to billing" }, "setup": { diff --git a/resources/js/admin/main.tsx b/resources/js/admin/main.tsx index a574aa0..4b6dd4f 100644 --- a/resources/js/admin/main.tsx +++ b/resources/js/admin/main.tsx @@ -4,7 +4,7 @@ import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; import { Toaster } from 'react-hot-toast'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { TamaguiProvider, Theme } from '@tamagui/core'; +import { TamaguiProvider, Theme } from 'tamagui'; import '@tamagui/core/reset.css'; import tamaguiConfig from '../../../tamagui.config'; import { AuthProvider } from './auth/context'; diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index aa952ea..7fb62e3 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -12,10 +12,10 @@ import { ContextHelpLink } from './components/ContextHelpLink'; import { createTenantBillingPortalSession, getTenantPackagesOverview, - getTenantPaddleTransactions, + getTenantLemonSqueezyTransactions, getTenantPackageCheckoutStatus, TenantPackageSummary, - PaddleTransactionSummary, + LemonSqueezyOrderSummary, } from '../api'; import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; @@ -52,7 +52,7 @@ export default function MobileBillingPage() { const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme(); const [packages, setPackages] = React.useState([]); const [activePackage, setActivePackage] = React.useState(null); - const [transactions, setTransactions] = React.useState([]); + const [transactions, setTransactions] = React.useState([]); const [addons, setAddons] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); @@ -78,7 +78,7 @@ export default function MobileBillingPage() { try { const [pkg, trx, addonHistory] = await Promise.all([ getTenantPackagesOverview({ force: true }), - getTenantPaddleTransactions().catch(() => ({ data: [] as PaddleTransactionSummary[] })), + getTenantLemonSqueezyTransactions().catch(() => ({ data: [] as LemonSqueezyOrderSummary[] })), getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })), ]); setPackages(pkg.packages ?? []); @@ -116,7 +116,7 @@ export default function MobileBillingPage() { window.open(url, '_blank', 'noopener'); } } catch (err) { - const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Paddle-Portal nicht öffnen.')); + const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Lemon Squeezy-Portal nicht öffnen.')); toast.error(message); } finally { setPortalBusy(false); @@ -388,7 +388,7 @@ export default function MobileBillingPage() { {t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')} diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index 98720f9..22c153e 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -1465,7 +1465,7 @@ function WatermarkPreview({ background: ADMIN_GRADIENTS.softCard, }} > -
+
- + {entries.map((entry) => { const isResellerCatalog = catalogType === 'reseller'; - const canSelect = isResellerCatalog ? Boolean(entry.pkg.paddle_price_id) : canSelectPackage(entry.isUpgrade, entry.isActive); + const canSelect = isResellerCatalog ? Boolean(entry.pkg.lemonsqueezy_variant_id) : canSelectPackage(entry.isUpgrade, entry.isActive); const label = isResellerCatalog ? canSelect ? t('shop.partner.buy', 'Kaufen') diff --git a/resources/js/admin/mobile/components/MobileShell.tsx b/resources/js/admin/mobile/components/MobileShell.tsx index b3f4e40..3dd3647 100644 --- a/resources/js/admin/mobile/components/MobileShell.tsx +++ b/resources/js/admin/mobile/components/MobileShell.tsx @@ -1,10 +1,8 @@ import React from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { ChevronLeft, Bell, QrCode, ChevronsUpDown, Search } from 'lucide-react'; -import { YStack, XStack } from '@tamagui/stacks'; -import { SizableText as Text } from '@tamagui/text'; +import { YStack, XStack, SizableText as Text, Image } from 'tamagui'; import { Pressable } from '@tamagui/react-native-web-lite'; -import { Image } from '@tamagui/image'; import { useTranslation } from 'react-i18next'; import { useEventContext } from '../../context/EventContext'; import { BottomNav, NavKey } from './BottomNav'; diff --git a/resources/js/admin/mobile/components/Primitives.tsx b/resources/js/admin/mobile/components/Primitives.tsx index 48932d9..da471f6 100644 --- a/resources/js/admin/mobile/components/Primitives.tsx +++ b/resources/js/admin/mobile/components/Primitives.tsx @@ -1,9 +1,6 @@ import React from 'react'; -import { Card } from '@tamagui/card'; -import { YStack, XStack } from '@tamagui/stacks'; -import { SizableText as Text } from '@tamagui/text'; +import { Card, YStack, XStack, SizableText as Text, Tabs, Separator } from 'tamagui'; import { Pressable } from '@tamagui/react-native-web-lite'; -import { Tabs, Separator } from 'tamagui'; import { useAdminTheme } from '../theme'; import { withAlpha } from './colors'; @@ -16,7 +13,9 @@ export function MobileCard({ const { surface, border, shadow, glassSurface, glassBorder, glassShadow } = useAdminTheme(); return ( - + diff --git a/resources/js/admin/mobile/hooks/usePackageCheckout.ts b/resources/js/admin/mobile/hooks/usePackageCheckout.ts index 0bcbf2f..1a78b7d 100644 --- a/resources/js/admin/mobile/hooks/usePackageCheckout.ts +++ b/resources/js/admin/mobile/hooks/usePackageCheckout.ts @@ -2,7 +2,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import toast from 'react-hot-toast'; -import { createTenantPaddleCheckout } from '../../api'; +import { createTenantLemonSqueezyCheckout } from '../../api'; import { adminPath } from '../../constants'; import { getApiErrorMessage } from '../../lib/apiError'; import { storePendingCheckout } from '../lib/billingCheckout'; @@ -33,7 +33,7 @@ export function usePackageCheckout(): { cancelUrl.searchParams.set('checkout', 'cancel'); cancelUrl.searchParams.set('package_id', String(packageId)); - const { checkout_url, checkout_session_id } = await createTenantPaddleCheckout(packageId, { + const { checkout_url, checkout_session_id } = await createTenantLemonSqueezyCheckout(packageId, { success_url: successUrl.toString(), return_url: cancelUrl.toString(), }); diff --git a/resources/js/admin/mobile/theme.ts b/resources/js/admin/mobile/theme.ts index 514b892..dcec723 100644 --- a/resources/js/admin/mobile/theme.ts +++ b/resources/js/admin/mobile/theme.ts @@ -161,7 +161,7 @@ export function useAdminTheme() { infoText: String(theme.blue10?.val ?? ADMIN_COLORS.primaryStrong), danger: String(theme.danger?.val ?? ADMIN_COLORS.danger), backdrop: String(theme.backgroundStrong?.val ?? ADMIN_COLORS.backdrop), - overlay: withAlpha(String(theme.backgroundStrong?.val ?? ADMIN_COLORS.backdrop), 0.6), + overlay: withAlpha(ADMIN_COLORS.backdrop, 0.7), shadow: String(theme.shadowColor?.val ?? 'rgba(15, 23, 42, 0.08)'), glassSurface, glassSurfaceStrong, diff --git a/resources/js/admin/mobile/welcome/WelcomeSummaryPage.tsx b/resources/js/admin/mobile/welcome/WelcomeSummaryPage.tsx index 8c0cfc9..16dc29b 100644 --- a/resources/js/admin/mobile/welcome/WelcomeSummaryPage.tsx +++ b/resources/js/admin/mobile/welcome/WelcomeSummaryPage.tsx @@ -166,7 +166,7 @@ export default function WelcomeSummaryPage() { {(t('summary.nextSteps', { returnObjects: true, defaultValue: [ - 'Optional: finish billing via Paddle inside the billing area.', + 'Optional: finish billing via Lemon Squeezy inside the billing area.', 'Complete the event setup and configure tasks, team, and gallery.', 'Check your event slots before go-live and share your guest link.', ], diff --git a/resources/js/guest-v2/__tests__/HomeScreen.test.tsx b/resources/js/guest-v2/__tests__/HomeScreen.test.tsx index 60c2967..0c8cf53 100644 --- a/resources/js/guest-v2/__tests__/HomeScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/HomeScreen.test.tsx @@ -40,26 +40,55 @@ vi.mock('../services/uploadApi', () => ({ useUploadQueue: () => ({ items: [], loading: false, refresh: vi.fn() }), })); +vi.mock('../services/tasksApi', () => ({ + fetchTasks: vi.fn().mockResolvedValue([ + { + id: 12, + title: 'Capture the dancefloor', + description: 'Find the happiest crew.', + instructions: 'Look for the brightest smiles.', + duration: 5, + emotion: { slug: 'freude', name: 'Joy' }, + }, + ]), +})); + +vi.mock('../services/emotionsApi', () => ({ + fetchEmotions: vi.fn().mockResolvedValue([{ slug: 'freude', name: 'Joy' }]), +})); + +vi.mock('../services/photosApi', () => ({ + fetchGallery: vi.fn().mockResolvedValue({ data: [] }), +})); + vi.mock('@/guest/i18n/useTranslation', () => ({ useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, locale: 'de' }), })); +vi.mock('@/guest/i18n/LocaleContext', () => ({ + useLocale: () => ({ locale: 'de' }), +})); + vi.mock('@/hooks/use-appearance', () => ({ useAppearance: () => ({ resolved: 'light' }), })); +vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({ + useGuestTaskProgress: () => ({ isCompleted: () => false }), +})); + import HomeScreen from '../screens/HomeScreen'; describe('HomeScreen', () => { - it('shows prompt quest content when tasks are enabled', () => { + it('shows task hero content when tasks are enabled', async () => { render( ); - expect(screen.getByText('Prompt quest')).toBeInTheDocument(); - expect(screen.getByText('Start prompt')).toBeInTheDocument(); + expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument(); + expect(screen.getByText("Let's go!")).toBeInTheDocument(); }); it('shows capture-ready content when tasks are disabled', () => { diff --git a/resources/js/guest-v2/components/AmbientBackground.tsx b/resources/js/guest-v2/components/AmbientBackground.tsx index 8da7587..b828035 100644 --- a/resources/js/guest-v2/components/AmbientBackground.tsx +++ b/resources/js/guest-v2/components/AmbientBackground.tsx @@ -16,8 +16,8 @@ export default function AmbientBackground({ children }: AmbientBackgroundProps) position="relative" style={{ backgroundImage: isDark - ? 'radial-gradient(circle at 15% 10%, rgba(255, 79, 216, 0.2), transparent 48%), radial-gradient(circle at 90% 20%, rgba(79, 209, 255, 0.18), transparent 40%), linear-gradient(180deg, rgba(6, 10, 22, 0.96), rgba(10, 15, 31, 1))' - : 'radial-gradient(circle at 15% 10%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 28%, white), transparent 48%), radial-gradient(circle at 90% 20%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 24%, white), transparent 40%), linear-gradient(180deg, var(--guest-background, #FFF8F5), color-mix(in oklab, var(--guest-background, #FFF8F5) 85%, white))', + ? 'radial-gradient(circle at 18% 12%, rgba(255, 110, 110, 0.22), transparent 46%), radial-gradient(circle at 82% 18%, rgba(78, 205, 196, 0.18), transparent 44%), linear-gradient(180deg, rgba(6, 9, 20, 0.96), rgba(10, 14, 28, 1))' + : 'radial-gradient(circle at 18% 12%, color-mix(in oklab, var(--guest-primary, #FF6B6B) 24%, white), transparent 50%), radial-gradient(circle at 82% 18%, color-mix(in oklab, var(--guest-secondary, #4ECDC4) 20%, white), transparent 48%), linear-gradient(180deg, color-mix(in oklab, var(--guest-background, #FFF5F5) 96%, white), color-mix(in oklab, var(--guest-background, #FFF5F5) 72%, white))', backgroundSize: '140% 140%, 140% 140%, 100% 100%', animation: 'guestNightAmbientDrift 18s ease-in-out infinite', }} diff --git a/resources/js/guest-v2/components/AppShell.tsx b/resources/js/guest-v2/components/AppShell.tsx index 6a5544c..840e42c 100644 --- a/resources/js/guest-v2/components/AppShell.tsx +++ b/resources/js/guest-v2/components/AppShell.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { YStack } from '@tamagui/stacks'; -import { Trophy, UploadCloud, Sparkles, Cast, Share2, Compass, Image, Camera, Settings, Home } from 'lucide-react'; +import { Button } from '@tamagui/button'; +import { Sparkles, Share2, Compass, Image, Camera, Settings, Home } from 'lucide-react'; import { useLocation, useNavigate } from 'react-router-dom'; import TopBar from './TopBar'; -import BottomDock from './BottomDock'; import FloatingActionButton from './FloatingActionButton'; -import FabActionSheet from './FabActionSheet'; +import FabActionRing from './FabActionRing'; import CompassHub, { type CompassAction } from './CompassHub'; import AmbientBackground from './AmbientBackground'; import NotificationSheet from './NotificationSheet'; @@ -22,7 +22,7 @@ type AppShellProps = { }; export default function AppShell({ children }: AppShellProps) { - const [sheetOpen, setSheetOpen] = React.useState(false); + const [fabOpen, setFabOpen] = React.useState(false); const [compassOpen, setCompassOpen] = React.useState(false); const [notificationsOpen, setNotificationsOpen] = React.useState(false); const [settingsOpen, setSettingsOpen] = React.useState(false); @@ -38,100 +38,24 @@ export default function AppShell({ children }: AppShellProps) { const showFab = !/\/photo\/\d+/.test(location.pathname); const goTo = (path: string) => () => { - setSheetOpen(false); + setFabOpen(false); setCompassOpen(false); setNotificationsOpen(false); setSettingsOpen(false); - navigate(buildEventPath(token, path)); - }; - - const openSheet = () => { - setCompassOpen(false); - setNotificationsOpen(false); - setSettingsOpen(false); - setSheetOpen(true); + const target = buildEventPath(token, path); + if (location.pathname === target) { + return; + } + navigate(target); }; const openCompass = () => { - setSheetOpen(false); + setFabOpen(false); setNotificationsOpen(false); setSettingsOpen(false); setCompassOpen(true); }; - const actions = [ - { - key: 'upload', - label: t('appShell.actions.upload.label', 'Upload / Take photo'), - description: t('appShell.actions.upload.description', 'Add a moment from your device or camera.'), - icon: , - onPress: goTo('/upload'), - }, - { - key: 'compass', - label: t('appShell.actions.compass.label', 'Compass hub'), - description: t('appShell.actions.compass.description', 'Quick jump to key areas.'), - icon: , - onPress: () => { - setSheetOpen(false); - openCompass(); - }, - }, - tasksEnabled - ? { - key: 'task', - label: t('appShell.actions.task.label', 'Start a task'), - description: t('appShell.actions.task.description', 'Pick a challenge and capture it now.'), - icon: , - onPress: goTo('/tasks'), - } - : null, - { - key: 'live', - label: t('appShell.actions.live.label', 'Live show'), - description: t('appShell.actions.live.description', 'See the real-time highlight stream.'), - icon: , - onPress: () => { - setSheetOpen(false); - setCompassOpen(false); - setNotificationsOpen(false); - setSettingsOpen(false); - if (token) { - navigate(`/show/${encodeURIComponent(token)}`); - } - }, - }, - { - key: 'slideshow', - label: t('appShell.actions.slideshow.label', 'Slideshow'), - description: t('appShell.actions.slideshow.description', 'Lean back and watch the gallery roll.'), - icon: , - onPress: goTo('/slideshow'), - }, - { - key: 'share', - label: t('appShell.actions.share.label', 'Share invite'), - description: t('appShell.actions.share.description', 'Send the event link or QR code.'), - icon: , - onPress: goTo('/share'), - }, - tasksEnabled - ? { - key: 'achievements', - label: t('appShell.actions.achievements.label', 'Achievements'), - description: t('appShell.actions.achievements.description', 'Track your photo streaks.'), - icon: , - onPress: goTo('/achievements'), - } - : null, - ].filter(Boolean) as Array<{ - key: string; - label: string; - description: string; - icon: React.ReactNode; - onPress?: () => void; - }>; - const compassQuadrants: [CompassAction, CompassAction, CompassAction, CompassAction] = [ { key: 'home', @@ -166,6 +90,13 @@ export default function AppShell({ children }: AppShellProps) { }, ]; + const fabActions = compassQuadrants.map((action) => ({ + key: action.key, + label: action.label, + icon: action.icon, + onPress: action.onPress, + })); + return ( @@ -176,23 +107,23 @@ export default function AppShell({ children }: AppShellProps) { right={0} zIndex={1000} style={{ - backgroundColor: isDark ? 'rgba(10, 14, 28, 0.72)' : 'rgba(255, 255, 255, 0.85)', - backdropFilter: 'saturate(160%) blur(18px)', - WebkitBackdropFilter: 'saturate(160%) blur(18px)', + backgroundColor: 'transparent', + backdropFilter: 'saturate(120%) blur(8px)', + WebkitBackdropFilter: 'saturate(120%) blur(8px)', }} > { setNotificationsOpen(false); - setSheetOpen(false); setCompassOpen(false); + setFabOpen(false); setSettingsOpen(true); }} onNotificationsPress={() => { setSettingsOpen(false); - setSheetOpen(false); setCompassOpen(false); + setFabOpen(false); setNotificationsOpen(true); }} notificationCount={notificationCenter?.unreadCount ?? 0} @@ -204,18 +135,58 @@ export default function AppShell({ children }: AppShellProps) { gap="$4" position="relative" zIndex={1} - style={{ paddingTop: '88px', paddingBottom: '128px' }} + style={{ paddingTop: '88px', paddingBottom: '112px' }} > {children} - {showFab ? : null} - - setSheetOpen(next)} - title={t('appShell.fab.title', 'Create a moment')} - actions={actions} - /> + {showFab ? ( + <> + + { + setCompassOpen(false); + setNotificationsOpen(false); + setSettingsOpen(false); + setFabOpen((prev) => !prev); + }} + onLongPress={openCompass} + /> + + + ) : null} + onOpenChange(false); const { resolved } = useAppearance(); const isDark = resolved === 'dark'; + const [visible, setVisible] = React.useState(open); + const [closing, setClosing] = React.useState(false); - if (!open) { + React.useEffect(() => { + if (open) { + setVisible(true); + setClosing(false); + return; + } + + if (!visible) return; + setClosing(true); + const timeout = window.setTimeout(() => { + setVisible(false); + setClosing(false); + }, 520); + + return () => { + window.clearTimeout(timeout); + }; + }, [open, visible]); + + if (!visible) { return null; } return ( - - + - - - + {title} - + {quadrants.map((action, index) => ( + + + {action.label} + + + + ); + })} + + + ); +} diff --git a/resources/js/guest-v2/components/FloatingActionButton.tsx b/resources/js/guest-v2/components/FloatingActionButton.tsx index 69e1d60..ebbb913 100644 --- a/resources/js/guest-v2/components/FloatingActionButton.tsx +++ b/resources/js/guest-v2/components/FloatingActionButton.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Button } from '@tamagui/button'; -import { Plus } from 'lucide-react'; +import { Flower } from 'lucide-react'; import { useAppearance } from '@/hooks/use-appearance'; type FloatingActionButtonProps = { @@ -30,26 +30,27 @@ export default function FloatingActionButton({ onPress, onLongPress }: FloatingA onLongPress?.(); }} position="fixed" - bottom={88} - right={20} + bottom={20} + left="50%" zIndex={1100} - width={56} - height={56} + width={68} + height={68} borderRadius={999} backgroundColor="$primary" borderWidth={0} elevation={4} shadowColor={isDark ? 'rgba(255, 79, 216, 0.5)' : 'rgba(15, 23, 42, 0.2)'} shadowOpacity={0.5} - shadowRadius={18} + shadowRadius={22} shadowOffset={{ width: 0, height: 10 }} style={{ + transform: 'translateX(-50%)', boxShadow: isDark - ? '0 18px 36px rgba(255, 79, 216, 0.35), 0 0 0 6px rgba(255, 79, 216, 0.15)' - : '0 16px 28px rgba(15, 23, 42, 0.18), 0 0 0 6px rgba(255, 255, 255, 0.7)', + ? '0 20px 40px rgba(255, 79, 216, 0.38), 0 0 0 8px rgba(255, 79, 216, 0.16)' + : '0 18px 32px rgba(15, 23, 42, 0.2), 0 0 0 8px rgba(255, 255, 255, 0.7)', }} > - + ); } diff --git a/resources/js/guest-v2/components/TaskHeroCard.tsx b/resources/js/guest-v2/components/TaskHeroCard.tsx new file mode 100644 index 0000000..30f8502 --- /dev/null +++ b/resources/js/guest-v2/components/TaskHeroCard.tsx @@ -0,0 +1,364 @@ +import React from 'react'; +import { YStack, XStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; +import { Button } from '@tamagui/button'; +import { Camera, CheckCircle2, Heart, RefreshCw, Sparkles, Timer as TimerIcon } from 'lucide-react'; +import PhotoFrameTile from './PhotoFrameTile'; +import { useTranslation } from '@/guest/i18n/useTranslation'; +import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '@/guest/lib/emotionTheme'; +import { useAppearance } from '@/hooks/use-appearance'; +import { getBentoSurfaceTokens } from '../lib/bento'; + +type TaskHeroEmotion = EmotionIdentity & { emoji?: string | null }; + +export type TaskHero = { + id: number; + title: string; + description?: string | null; + instructions?: string | null; + duration?: number | null; + emotion?: TaskHeroEmotion | null; +}; + +export type TaskHeroPhoto = { + id: number; + imageUrl: string; + likesCount?: number; +}; + +type TaskHeroCardProps = { + task: TaskHero | null; + loading: boolean; + error?: string | null; + hasSwiped: boolean; + onSwiped: () => void; + onStart: () => void; + onShuffle: () => void; + onViewSimilar: () => void; + onRetry: () => void; + onOpenPhoto: (photoId: number) => void; + isCompleted: boolean; + photos: TaskHeroPhoto[]; + photosLoading: boolean; + photosError?: string | null; +}; + +const SWIPE_THRESHOLD_PX = 40; + +export default function TaskHeroCard({ + task, + loading, + error, + hasSwiped, + onSwiped, + onStart, + onShuffle, + onViewSimilar, + onRetry, + onOpenPhoto, + isCompleted, + photos, + photosLoading, + photosError, +}: TaskHeroCardProps) { + const { t } = useTranslation(); + const heroCardRef = React.useRef(null); + const theme = getEmotionTheme(task?.emotion ?? null); + const emotionIcon = getEmotionIcon(task?.emotion ?? null); + const { resolved } = useAppearance(); + const isDark = resolved === 'dark'; + const bentoSurface = getBentoSurfaceTokens(isDark); + + React.useEffect(() => { + const card = heroCardRef.current; + if (!card) return; + let startX: number | null = null; + let startY: number | null = null; + + const onTouchStart = (event: TouchEvent) => { + const touch = event.touches[0]; + startX = touch.clientX; + startY = touch.clientY; + }; + + const onTouchEnd = (event: TouchEvent) => { + if (startX === null || startY === null) return; + const touch = event.changedTouches[0]; + const deltaX = touch.clientX - startX; + const deltaY = touch.clientY - startY; + if (Math.abs(deltaX) > SWIPE_THRESHOLD_PX && Math.abs(deltaY) < 60) { + if (deltaX < 0) { + onShuffle(); + } else { + onViewSimilar(); + } + onSwiped(); + } + startX = null; + startY = null; + }; + + card.addEventListener('touchstart', onTouchStart, { passive: true }); + card.addEventListener('touchend', onTouchEnd); + return () => { + card.removeEventListener('touchstart', onTouchStart); + card.removeEventListener('touchend', onTouchEnd); + }; + }, [onShuffle, onSwiped, onViewSimilar]); + + if (loading && !task) { + return ( + + + {t('tasks.loading', 'Loading tasks...')} + + + ); + } + + if (error && !task) { + return ( + + {error} + + + ); + } + + if (!task) { + return ( + + {t('tasks.page.emptyTitle', 'No matching task found')} + + ); + } + + return ( + + + + + + {emotionIcon} {task.emotion?.name ?? t('tasks.page.eyebrow', 'New mission')} + + + {task.duration ? ( + + + + {task.duration} min + + + ) : null} + + + + + {task.title} + + {task.description ? ( + + {task.description} + + ) : null} + + + {!hasSwiped ? ( + + {t('tasks.page.swipeHint', 'Swipe for more missions')} + + ) : null} + + {task.instructions ? ( + + + {task.instructions} + + + ) : null} + + {isCompleted ? ( + + + + {t('tasks.page.completedLabel', 'Completed')} + + + ) : null} + + + + + + + {(photosLoading || photosError || photos.length > 0) && ( + + + + {t('tasks.page.inspirationTitle', 'Inspiration')} + + {photosLoading ? ( + + {t('tasks.page.inspirationLoading', 'Loading')} + + ) : null} + + {photosError && photos.length === 0 ? ( + + {photosError} + + ) : photos.length > 0 ? ( + + {photos.map((photo) => ( + + ))} + + + ) : ( + + )} + + )} + + ); +} diff --git a/resources/js/guest-v2/components/TopBar.tsx b/resources/js/guest-v2/components/TopBar.tsx index d25b1eb..b0e3c42 100644 --- a/resources/js/guest-v2/components/TopBar.tsx +++ b/resources/js/guest-v2/components/TopBar.tsx @@ -20,6 +20,11 @@ export default function TopBar({ }: TopBarProps) { const { resolved } = useAppearance(); const isDark = resolved === 'dark'; + const [animationKey, setAnimationKey] = React.useState(0); + + React.useEffect(() => { + setAnimationKey((prev) => prev + 1); + }, [eventName]); return ( {eventName} diff --git a/resources/js/guest-v2/lib/bento.ts b/resources/js/guest-v2/lib/bento.ts new file mode 100644 index 0000000..bea5cf8 --- /dev/null +++ b/resources/js/guest-v2/lib/bento.ts @@ -0,0 +1,24 @@ +export type BentoSurfaceTokens = { + borderColor: string; + borderBottomColor: string; + backgroundColor: string; + shadow: string; +}; + +export function getBentoSurfaceTokens(isDark: boolean): BentoSurfaceTokens { + if (isDark) { + return { + borderColor: 'rgba(255, 255, 255, 0.12)', + borderBottomColor: 'rgba(255, 255, 255, 0.28)', + backgroundColor: 'rgba(14, 20, 34, 0.92)', + shadow: '0 22px 36px rgba(2, 6, 23, 0.55), 0 6px 0 rgba(2, 6, 23, 0.35), inset 0 -4px 0 rgba(255, 255, 255, 0.08)', + }; + } + + return { + borderColor: 'rgba(15, 23, 42, 0.1)', + borderBottomColor: 'rgba(15, 23, 42, 0.18)', + backgroundColor: 'rgba(255, 255, 255, 0.96)', + shadow: '0 22px 34px rgba(15, 23, 42, 0.18), 0 6px 0 rgba(15, 23, 42, 0.08), inset 0 -4px 0 rgba(15, 23, 42, 0.06)', + }; +} diff --git a/resources/js/guest-v2/screens/HomeScreen.tsx b/resources/js/guest-v2/screens/HomeScreen.tsx index 59f08d2..5b989d7 100644 --- a/resources/js/guest-v2/screens/HomeScreen.tsx +++ b/resources/js/guest-v2/screens/HomeScreen.tsx @@ -6,6 +6,7 @@ import { Button } from '@tamagui/button'; import { Camera, Sparkles, Image as ImageIcon, Trophy, Star } from 'lucide-react'; import AppShell from '../components/AppShell'; import PhotoFrameTile from '../components/PhotoFrameTile'; +import TaskHeroCard, { type TaskHero, type TaskHeroPhoto } from '../components/TaskHeroCard'; import { useEventData } from '../context/EventDataContext'; import { buildEventPath } from '../lib/routes'; import { useStaggeredReveal } from '../lib/useStaggeredReveal'; @@ -14,8 +15,13 @@ import { fetchGallery } from '../services/photosApi'; import { useUploadQueue } from '../services/uploadApi'; import { useTranslation } from '@/guest/i18n/useTranslation'; import { useAppearance } from '@/hooks/use-appearance'; +import { useLocale } from '@/guest/i18n/LocaleContext'; +import { fetchTasks, type TaskItem } from '../services/tasksApi'; +import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress'; +import { fetchEmotions } from '../services/emotionsApi'; +import { getBentoSurfaceTokens } from '../lib/bento'; -type ActionRingProps = { +type ActionTileProps = { label: string; icon: React.ReactNode; onPress: () => void; @@ -26,36 +32,47 @@ type GalleryPreview = { imageUrl: string; }; -function ActionRing({ +type TaskPhoto = TaskHeroPhoto & { + taskId?: number | null; +}; + +function ActionTile({ label, icon, onPress, isDark, -}: ActionRingProps & { isDark: boolean }) { +}: ActionTileProps & { isDark: boolean }) { + const surface = getBentoSurfaceTokens(isDark); + return ( - - - + setHasSwiped(true)} + onStart={handleStartTask} + onShuffle={handleShuffle} + onViewSimilar={handleViewSimilar} + onRetry={reloadTasks} + onOpenPhoto={openTaskPhoto} + isCompleted={isCompleted(currentTask?.id)} + photos={taskPhotos} + photosLoading={galleryLoading} + photosError={galleryError} + /> ) : ( = 2 ? 0 : 16} style={{ @@ -346,10 +594,12 @@ export default function HomeScreen() { - + {t('homeV2.galleryPreview.title', 'Gallery preview')} + ) : null} {(cameraState === 'ready' || cameraState === 'starting' || cameraState === 'preview') ? (
diff --git a/resources/js/pages/marketing/Success.tsx b/resources/js/pages/marketing/Success.tsx index 02de38e..c577486 100644 --- a/resources/js/pages/marketing/Success.tsx +++ b/resources/js/pages/marketing/Success.tsx @@ -25,9 +25,9 @@ const GiftSuccess: React.FC = () => { React.useEffect(() => { const params = new URLSearchParams(window.location.search); const checkoutId = params.get('checkout_id') || sessionStorage.getItem('gift_checkout_id'); - const transactionId = params.get('transaction_id'); + const orderId = params.get('order_id'); - fetchGiftVoucherByCheckout(checkoutId || undefined, transactionId || undefined) + fetchGiftVoucherByCheckout(checkoutId || undefined, orderId || undefined) .then((data) => { if (data) { setVoucher(data); diff --git a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx index e9eca9d..1d54db4 100644 --- a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx +++ b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx @@ -27,9 +27,9 @@ interface CheckoutWizardProps { initialStep?: CheckoutStepId; googleProfile?: OAuthProfilePrefill | null; facebookProfile?: OAuthProfilePrefill | null; - paddle?: { - environment?: string | null; - client_token?: string | null; + lemonsqueezy?: { + store_id?: string | null; + test_mode?: boolean | null; } | null; } @@ -290,7 +290,7 @@ export const CheckoutWizard: React.FC = ({ initialStep, googleProfile, facebookProfile, - paddle, + lemonsqueezy, }) => { const [storedGoogleProfile, setStoredGoogleProfile] = useState(() => { if (typeof window === 'undefined') { @@ -382,7 +382,7 @@ export const CheckoutWizard: React.FC = ({ initialStep={initialStep} initialAuthUser={initialAuthUser ?? undefined} initialIsAuthenticated={Boolean(initialAuthUser)} - paddle={paddle ?? null} + lemonsqueezy={lemonsqueezy ?? null} > { diff --git a/resources/js/pages/marketing/checkout/__tests__/PaymentStep.locale.test.ts b/resources/js/pages/marketing/checkout/__tests__/PaymentStep.locale.test.ts index 01e7a44..29c6ba2 100644 --- a/resources/js/pages/marketing/checkout/__tests__/PaymentStep.locale.test.ts +++ b/resources/js/pages/marketing/checkout/__tests__/PaymentStep.locale.test.ts @@ -1,21 +1,21 @@ import { describe, expect, it } from 'vitest'; -import { resolveCheckoutCsrfToken, resolvePaddleLocale } from '../steps/PaymentStep'; +import { resolveCheckoutCsrfToken, resolveCheckoutLocale } from '../steps/PaymentStep'; -describe('resolvePaddleLocale', () => { +describe('resolveCheckoutLocale', () => { it('returns short locale when given region-specific tag', () => { - expect(resolvePaddleLocale('de-DE')).toBe('de'); - expect(resolvePaddleLocale('en-US')).toBe('en'); + expect(resolveCheckoutLocale('de-DE')).toBe('de'); + expect(resolveCheckoutLocale('en-US')).toBe('en'); }); it('falls back to english when locale unsupported', () => { - expect(resolvePaddleLocale('jp')).toBe('en'); - expect(resolvePaddleLocale('xx-YY')).toBe('en'); - expect(resolvePaddleLocale(undefined)).toBe('en'); + expect(resolveCheckoutLocale('jp')).toBe('en'); + expect(resolveCheckoutLocale('xx-YY')).toBe('en'); + expect(resolveCheckoutLocale(undefined)).toBe('en'); }); it('keeps supported locale codes untouched', () => { - expect(resolvePaddleLocale('fr')).toBe('fr'); - expect(resolvePaddleLocale('es')).toBe('es'); + expect(resolveCheckoutLocale('fr')).toBe('fr'); + expect(resolveCheckoutLocale('es')).toBe('es'); }); }); diff --git a/resources/js/pages/marketing/checkout/__tests__/PaymentStep.render.test.tsx b/resources/js/pages/marketing/checkout/__tests__/PaymentStep.render.test.tsx index 1e6bb10..ca5238a 100644 --- a/resources/js/pages/marketing/checkout/__tests__/PaymentStep.render.test.tsx +++ b/resources/js/pages/marketing/checkout/__tests__/PaymentStep.render.test.tsx @@ -11,21 +11,21 @@ vi.mock('@/hooks/useAnalytics', () => ({ const basePackage = { id: 1, price: 49, - paddle_price_id: 'pri_test_123', + lemonsqueezy_variant_id: 'pri_test_123', }; describe('PaymentStep', () => { beforeEach(() => { localStorage.clear(); - window.Paddle = { - Environment: { set: vi.fn() }, - Checkout: { open: vi.fn() }, + window.LemonSqueezy = { + Setup: vi.fn(), + Url: { Open: vi.fn() }, }; }); afterEach(() => { cleanup(); - delete window.Paddle; + delete window.LemonSqueezy; }); it('renders the payment experience without crashing', async () => { diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx index 7f35336..9710f35 100644 --- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx @@ -19,22 +19,26 @@ import { useAnalytics } from '@/hooks/useAnalytics'; type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error'; +type LemonSqueezyEvent = { + event?: string; + data?: Record; +}; + declare global { interface Window { - Paddle?: { - Environment?: { - set: (environment: string) => void; - }; - Initialize?: (options: { token: string }) => void; - Checkout: { - open: (options: Record) => void; + createLemonSqueezy?: () => void; + LemonSqueezy?: { + Setup: (options: { eventHandler?: (event: LemonSqueezyEvent) => void }) => void; + Refresh?: () => void; + Url: { + Open: (url: string) => void; + Close?: () => void; }; }; } } -const PADDLE_SCRIPT_URL = 'https://cdn.paddle.com/paddle/v2/paddle.js'; -const PADDLE_SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pt', 'sv', 'da', 'fi', 'no']; +const LEMON_SCRIPT_URL = 'https://app.lemonsqueezy.com/js/lemon.js'; const PRIMARY_CTA_STYLES = 'min-w-[200px] disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted disabled:hover:text-muted-foreground'; const getCookieValue = (name: string): string | null => { @@ -101,73 +105,50 @@ function buildCheckoutHeaders(): HeadersInit { return headers; } -export function resolvePaddleLocale(rawLocale?: string | null): string { +export function resolveCheckoutLocale(rawLocale?: string | null): string { if (!rawLocale) { return 'en'; } const normalized = rawLocale.toLowerCase(); - - if (PADDLE_SUPPORTED_LOCALES.includes(normalized)) { - return normalized; - } - const short = normalized.split('-')[0]; - if (short && PADDLE_SUPPORTED_LOCALES.includes(short)) { - return short; - } - return 'en'; + return short || 'en'; } -type PaddleEnvironment = 'sandbox' | 'production'; +let lemonLoaderPromise: Promise | null = null; -let paddleLoaderPromise: Promise | null = null; - -function configurePaddle(paddle: typeof window.Paddle | undefined | null, environment: PaddleEnvironment): typeof window.Paddle | null { - if (!paddle) { - return null; - } - - try { - paddle.Environment?.set?.(environment); - } catch (error) { - console.warn('[Paddle] Failed to set environment', error); - } - - return paddle; -} - -async function loadPaddle(environment: PaddleEnvironment): Promise { +async function loadLemonSqueezy(): Promise { if (typeof window === 'undefined') { return null; } - if (window.Paddle) { - return configurePaddle(window.Paddle, environment); + if (window.LemonSqueezy) { + return window.LemonSqueezy; } - if (!paddleLoaderPromise) { - paddleLoaderPromise = new Promise((resolve, reject) => { + if (!lemonLoaderPromise) { + lemonLoaderPromise = new Promise((resolve, reject) => { const script = document.createElement('script'); - script.src = PADDLE_SCRIPT_URL; - script.async = true; - script.onload = () => resolve(window.Paddle ?? null); + script.src = LEMON_SCRIPT_URL; + script.defer = true; + script.onload = () => { + window.createLemonSqueezy?.(); + resolve(window.LemonSqueezy ?? null); + }; script.onerror = (error) => reject(error); document.head.appendChild(script); }).catch((error) => { - console.error('Failed to load Paddle.js', error); - paddleLoaderPromise = null; + console.error('Failed to load Lemon.js', error); + lemonLoaderPromise = null; return null; }); } - const paddle = await paddleLoaderPromise; - - return configurePaddle(paddle, environment); + return lemonLoaderPromise; } -const PaddleCta: React.FC<{ onCheckout: () => Promise; disabled: boolean; isProcessing: boolean; className?: string }> = ({ onCheckout, disabled, isProcessing, className }) => { +const LemonSqueezyCta: React.FC<{ onCheckout: () => Promise; disabled: boolean; isProcessing: boolean; className?: string }> = ({ onCheckout, disabled, isProcessing, className }) => { const { t } = useTranslation('marketing'); return ( @@ -178,7 +159,7 @@ const PaddleCta: React.FC<{ onCheckout: () => Promise; disabled: boolean; onClick={onCheckout} > {isProcessing && } - {t('checkout.payment_step.pay_with_paddle')} + {t('checkout.payment_step.pay_with_lemonsqueezy')} ); }; @@ -189,7 +170,6 @@ export const PaymentStep: React.FC = () => { const { selectedPackage, nextStep, - paddleConfig, authUser, isAuthenticated, goToStep, @@ -199,7 +179,6 @@ export const PaymentStep: React.FC = () => { } = useCheckoutWizard(); const [status, setStatus] = useState('idle'); const [message, setMessage] = useState(''); - const [initialised, setInitialised] = useState(false); const [inlineActive, setInlineActive] = useState(false); const [acceptedTerms, setAcceptedTerms] = useState(false); const [consentError, setConsentError] = useState(null); @@ -220,11 +199,10 @@ export const PaymentStep: React.FC = () => { const [couponError, setCouponError] = useState(null); const [couponNotice, setCouponNotice] = useState(null); const [couponLoading, setCouponLoading] = useState(false); - const paddleRef = useRef(null); - const checkoutContainerRef = useRef(null); - const eventCallbackRef = useRef<(event: Record) => void>(); + const lemonRef = useRef(null); + const eventHandlerRef = useRef<(event: LemonSqueezyEvent) => void>(); + const lastCheckoutIdRef = useRef(null); const hasAutoAppliedCoupon = useRef(false); - const checkoutContainerClass = 'paddle-checkout-container'; const [showWithdrawalModal, setShowWithdrawalModal] = useState(false); const [withdrawalHtml, setWithdrawalHtml] = useState(null); const [withdrawalTitle, setWithdrawalTitle] = useState(null); @@ -235,23 +213,23 @@ export const PaymentStep: React.FC = () => { const [isGiftVoucher, setIsGiftVoucher] = useState(false); const [freeActivationBusy, setFreeActivationBusy] = useState(false); const [pendingConfirmation, setPendingConfirmation] = useState<{ - transactionId: string | null; + orderId: string | null; checkoutId: string | null; } | null>(null); - const paddleLocale = useMemo(() => { + const checkoutLocale = useMemo(() => { const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null); - return resolvePaddleLocale(sourceLocale ?? undefined); + return resolveCheckoutLocale(sourceLocale ?? undefined); }, [i18n.language]); const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]); - const confirmCheckoutSession = useCallback(async (payload: { transactionId: string | null; checkoutId: string | null }) => { + const confirmCheckoutSession = useCallback(async (payload: { orderId: string | null; checkoutId: string | null }) => { if (!checkoutSessionId) { return; } - if (!payload.transactionId && !payload.checkoutId) { + if (!payload.orderId && !payload.checkoutId) { return; } @@ -262,12 +240,12 @@ export const PaymentStep: React.FC = () => { headers: buildCheckoutHeaders(), credentials: 'same-origin', body: JSON.stringify({ - transaction_id: payload.transactionId, + order_id: payload.orderId, checkout_id: payload.checkoutId, }), }); } catch (error) { - console.warn('Failed to confirm Paddle session', error); + console.warn('Failed to confirm Lemon Squeezy session', error); } }, [checkoutSessionId]); @@ -398,14 +376,14 @@ export const PaymentStep: React.FC = () => { body: JSON.stringify({ package_id: selectedPackage.id, accepted_terms: acceptedTerms, - locale: paddleLocale, + locale: checkoutLocale, }), }); const payload = await response.json().catch(() => ({})); if (!response.ok) { - const errorMessage = payload?.errors?.accepted_waiver?.[0] || payload?.message || t('checkout.payment_step.paddle_error'); + const errorMessage = payload?.errors?.accepted_waiver?.[0] || payload?.message || t('checkout.payment_step.lemonsqueezy_error'); setConsentError(errorMessage); toast.error(errorMessage); return; @@ -416,7 +394,7 @@ export const PaymentStep: React.FC = () => { nextStep(); } catch (error) { console.error('Failed to activate free package', error); - const fallbackMessage = t('checkout.payment_step.paddle_error'); + const fallbackMessage = t('checkout.payment_step.lemonsqueezy_error'); setConsentError(fallbackMessage); toast.error(fallbackMessage); } finally { @@ -424,7 +402,7 @@ export const PaymentStep: React.FC = () => { } }; - const startPaddleCheckout = async () => { + const startLemonSqueezyCheckout = async () => { if (!selectedPackage) { return; } @@ -443,43 +421,29 @@ export const PaymentStep: React.FC = () => { return; } - if (!selectedPackage.paddle_price_id) { + if (!selectedPackage.lemonsqueezy_variant_id) { setStatus('error'); - setMessage(t('checkout.payment_step.paddle_not_configured')); + setMessage(t('checkout.payment_step.lemonsqueezy_not_configured')); return; } setPaymentCompleted(false); setStatus('processing'); - setMessage(t('checkout.payment_step.paddle_preparing')); + setMessage(t('checkout.payment_step.lemonsqueezy_preparing')); setInlineActive(false); setCheckoutSessionId(null); try { await refreshCheckoutCsrfToken(); - const inlineSupported = initialised && !!paddleConfig?.client_token; - - if (typeof window !== 'undefined') { - - console.info('[Checkout] Paddle inline status', { - inlineSupported, - initialised, - hasClientToken: Boolean(paddleConfig?.client_token), - environment: paddleConfig?.environment, - paddlePriceId: selectedPackage.paddle_price_id, - }); - } - - const response = await fetch('/paddle/create-checkout', { + const response = await fetch('/lemonsqueezy/create-checkout', { method: 'POST', headers: buildCheckoutHeaders(), credentials: 'same-origin', body: JSON.stringify({ package_id: selectedPackage.id, - locale: paddleLocale, + locale: checkoutLocale, coupon_code: couponPreview?.coupon.code ?? undefined, accepted_terms: acceptedTerms, - inline: inlineSupported, }), }); @@ -498,14 +462,14 @@ export const PaymentStep: React.FC = () => { } if (typeof window !== 'undefined') { - console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody }); + console.info('[Checkout] Lemon Squeezy checkout response', { status: response.status, rawBody }); } let data: { checkout_url?: string; message?: string } | null = null; try { data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null; } catch (parseError) { - console.warn('Failed to parse Paddle checkout payload as JSON', parseError); + console.warn('Failed to parse Lemon Squeezy checkout payload as JSON', parseError); data = null; } @@ -528,73 +492,32 @@ export const PaymentStep: React.FC = () => { } if (!response.ok || !checkoutUrl) { - const message = data?.message || rawBody || 'Unable to create Paddle checkout.'; - if (response.ok && data && (data as { mode?: string }).mode === 'inline') { - checkoutUrl = null; - } else { - throw new Error(message); - } + const message = data?.message || rawBody || 'Unable to create Lemon Squeezy checkout.'; + throw new Error(message); } - if (data && (data as { mode?: string }).mode === 'inline') { - const paddle = paddleRef.current; + if (data && typeof (data as { id?: string }).id === 'string') { + lastCheckoutIdRef.current = (data as { id?: string }).id ?? null; + } - if (!paddle || !paddle.Checkout || typeof paddle.Checkout.open !== 'function') { - throw new Error('Inline Paddle checkout is not available.'); - } - - const inlinePayload: Record = { - items: (data as { items?: unknown[] }).items ?? [], - settings: { - displayMode: 'inline', - frameTarget: checkoutContainerClass, - frameInitialHeight: '550', - frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;', - theme: 'light', - locale: paddleLocale, - }, - }; - - if ((data as { custom_data?: Record }).custom_data) { - inlinePayload.customData = (data as { custom_data?: Record }).custom_data; - } - - if ((data as { customer?: Record }).customer) { - inlinePayload.customer = (data as { customer?: Record }).customer; - } - - if (typeof window !== 'undefined') { - - console.info('[Checkout] Opening inline Paddle checkout', inlinePayload); - } - - paddle.Checkout.open(inlinePayload); + const lemon = await loadLemonSqueezy(); + if (lemon?.Url?.Open) { + lemon.Url.Open(checkoutUrl); setInlineActive(true); setStatus('ready'); - setMessage(t('checkout.payment_step.paddle_overlay_ready')); - if (typeof window !== 'undefined' && checkoutContainerRef.current) { - window.requestAnimationFrame(() => { - const rect = checkoutContainerRef.current?.getBoundingClientRect(); - if (!rect) { - return; - } - const offset = 120; - const target = Math.max(window.scrollY + rect.top - offset, 0); - window.scrollTo({ top: target, behavior: 'smooth' }); - }); - } + setMessage(t('checkout.payment_step.lemonsqueezy_overlay_ready')); return; } window.open(checkoutUrl, '_blank', 'noopener'); setInlineActive(false); setStatus('ready'); - setMessage(t('checkout.payment_step.paddle_ready')); + setMessage(t('checkout.payment_step.lemonsqueezy_ready')); } catch (error) { - console.error('Failed to start Paddle checkout', error); + console.error('Failed to start Lemon Squeezy checkout', error); setStatus('error'); - setMessage(t('checkout.payment_step.paddle_error')); + setMessage(t('checkout.payment_step.lemonsqueezy_error')); setInlineActive(false); setPaymentCompleted(false); } @@ -603,85 +526,50 @@ export const PaymentStep: React.FC = () => { useEffect(() => { let cancelled = false; - const environment = paddleConfig?.environment === 'sandbox' ? 'sandbox' : 'production'; - const clientToken = paddleConfig?.client_token ?? null; - - eventCallbackRef.current = (event) => { - if (!event?.name) { - return; - } - - if (typeof window !== 'undefined') { - - console.debug('[Checkout] Paddle event', event); - } - - if (event.name === 'checkout.completed') { - const transactionId = typeof event?.data?.transaction_id === 'string' ? event.data.transaction_id : null; - const checkoutId = typeof event?.data?.id === 'string' ? event.data.id : null; - setStatus('processing'); - setMessage(t('checkout.payment_step.processing_confirmation')); - setInlineActive(false); - setPaymentCompleted(false); - setPendingConfirmation({ transactionId, checkoutId }); - toast.success(t('checkout.payment_step.toast_success')); - setPaymentCompleted(true); - nextStep(); - } - - if (event.name === 'checkout.closed') { - setStatus('idle'); - setMessage(''); - setInlineActive(false); - } - - if (event.name === 'checkout.error') { - setStatus('error'); - setMessage(t('checkout.payment_step.paddle_error')); - setInlineActive(false); - setPaymentCompleted(false); - } - }; - (async () => { - const paddle = await loadPaddle(environment); + const lemon = await loadLemonSqueezy(); - if (cancelled || !paddle) { + if (cancelled || !lemon) { return; } try { - let inlineReady = false; - if (typeof paddle.Initialize === 'function' && clientToken) { - if (typeof window !== 'undefined') { - - console.info('[Checkout] Initializing Paddle.js', { environment, hasToken: Boolean(clientToken) }); + eventHandlerRef.current = (event) => { + if (!event?.event) { + return; } - paddle.Initialize({ - token: clientToken, - checkout: { - settings: { - displayMode: 'inline', - frameTarget: checkoutContainerClass, - frameInitialHeight: '550', - frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;', - locale: paddleLocale, - }, - }, - eventCallback: (event: Record) => eventCallbackRef.current?.(event), - }); + if (typeof window !== 'undefined') { + + console.debug('[Checkout] Lemon Squeezy event', event); + } - inlineReady = true; - } + if (event.event === 'Checkout.Success') { + const data = event.data as { id?: string; identifier?: string; attributes?: { checkout_id?: string } } | undefined; + const orderId = typeof data?.id === 'string' ? data.id : (typeof data?.identifier === 'string' ? data.identifier : null); + const checkoutId = typeof data?.attributes?.checkout_id === 'string' + ? data?.attributes?.checkout_id + : lastCheckoutIdRef.current; + setStatus('processing'); + setMessage(t('checkout.payment_step.processing_confirmation')); + setInlineActive(false); + setPaymentCompleted(false); + setPendingConfirmation({ orderId, checkoutId }); + toast.success(t('checkout.payment_step.toast_success')); + setPaymentCompleted(true); + nextStep(); + } + }; - paddleRef.current = paddle; - setInitialised(inlineReady); + lemon.Setup({ + eventHandler: (event) => eventHandlerRef.current?.(event), + }); + + lemonRef.current = lemon; } catch (error) { - console.error('Failed to initialize Paddle', error); - setInitialised(false); + console.error('Failed to initialize Lemon.js', error); setStatus('error'); - setMessage(t('checkout.payment_step.paddle_error')); + setMessage(t('checkout.payment_step.lemonsqueezy_error')); setPaymentCompleted(false); } })(); @@ -689,7 +577,7 @@ export const PaymentStep: React.FC = () => { return () => { cancelled = true; }; - }, [nextStep, paddleConfig?.environment, paddleConfig?.client_token, paddleLocale, setPaymentCompleted, t]); + }, [nextStep, setPaymentCompleted, t]); useEffect(() => { setPaymentCompleted(false); @@ -747,7 +635,7 @@ export const PaymentStep: React.FC = () => { setWithdrawalError(null); try { - const response = await fetch(`/api/v1/legal/widerrufsbelehrung?lang=${paddleLocale}`); + const response = await fetch(`/api/v1/legal/widerrufsbelehrung?lang=${checkoutLocale}`); if (!response.ok) { throw new Error(`Failed to load withdrawal page (${response.status})`); } @@ -759,7 +647,7 @@ export const PaymentStep: React.FC = () => { } finally { setWithdrawalLoading(false); } - }, [paddleLocale, t, withdrawalHtml, withdrawalLoading]); + }, [checkoutLocale, t, withdrawalHtml, withdrawalLoading]); if (!selectedPackage) { @@ -842,16 +730,10 @@ export const PaymentStep: React.FC = () => {
); - const PaddleLogo = () => ( + const LemonSqueezyLogo = () => (
- Paddle - {t('checkout.payment_step.paddle_partner')} + Lemon Squeezy + {t('checkout.payment_step.lemonsqueezy_partner')}
); @@ -863,7 +745,7 @@ export const PaymentStep: React.FC = () => {
- +

{t('checkout.payment_step.guided_title')}

{t('checkout.payment_step.guided_body')}

@@ -919,8 +801,8 @@ export const PaymentStep: React.FC = () => {
- { {!inlineActive && (

- {t('checkout.payment_step.paddle_intro')} + {t('checkout.payment_step.lemonsqueezy_intro')}

- { )} -
-

- {t('checkout.payment_step.paddle_disclaimer')} + {t('checkout.payment_step.lemonsqueezy_disclaimer')}

diff --git a/resources/js/pages/marketing/checkout/types.ts b/resources/js/pages/marketing/checkout/types.ts index a523945..964eb85 100644 --- a/resources/js/pages/marketing/checkout/types.ts +++ b/resources/js/pages/marketing/checkout/types.ts @@ -24,8 +24,8 @@ export interface CheckoutPackage { type: 'endcustomer' | 'reseller'; features: string[]; limits?: Record; - paddle_price_id?: string | null; - paddle_product_id?: string | null; + lemonsqueezy_variant_id?: string | null; + lemonsqueezy_product_id?: string | null; activates_immediately?: boolean; [key: string]: unknown; } diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 71c3dc6..85745b5 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -186,9 +186,9 @@ return [ 'deleted' => 'Gelöscht', ], ], - 'paddle_health' => [ + 'lemonsqueezy_health' => [ 'navigation' => [ - 'label' => 'Paddle-Status', + 'label' => 'Lemon Squeezy-Status', ], ], 'integrations_health' => [ @@ -203,7 +203,7 @@ return [ 'unknown' => 'Unbekannt', ], 'heading' => 'Integrationen-Status', - 'help' => 'Operativer Überblick über Paddle/RevenueCat-Webhooks, Queue-Backlog und jüngste Fehler.', + 'help' => 'Operativer Überblick über Lemon Squeezy/RevenueCat-Webhooks, Queue-Backlog und jüngste Fehler.', 'configured' => 'Konfiguriert', 'unconfigured' => 'Nicht konfiguriert', 'last_received' => 'Zuletzt empfangen', diff --git a/resources/lang/de/emails.php b/resources/lang/de/emails.php index 18c1a58..ee11abe 100644 --- a/resources/lang/de/emails.php +++ b/resources/lang/de/emails.php @@ -90,7 +90,7 @@ return [ 'invoice_link' => 'Rechnung öffnen', 'cta' => 'Zum Event-Admin', 'provider' => [ - 'paddle' => 'Paddle', + 'lemonsqueezy' => 'Lemon Squeezy', 'manual' => 'Manuell', 'free' => 'Kostenfrei', ], @@ -123,7 +123,7 @@ return [ 'cta_hint_body' => 'Ihr Angebot bleibt bestehen – Sie können jederzeit nahtlos fortfahren.', 'benefits_title' => 'Was Sie erwartet', 'benefit1' => 'Premium Checkout in wenigen Minuten', - 'benefit2' => 'Sichere Zahlung mit Paddle', + 'benefit2' => 'Sichere Zahlung mit Lemon Squeezy', 'benefit3' => 'Sofortige Aktivierung nach Zahlung', 'benefit4' => 'Support durch das Die Fotospiel.App Team', 'footer' => 'Wir helfen Ihnen gern weiter, falls Fragen offen sind.', diff --git a/resources/lang/de/legal.php b/resources/lang/de/legal.php index 2ddc6e6..10a80ed 100644 --- a/resources/lang/de/legal.php +++ b/resources/lang/de/legal.php @@ -12,14 +12,14 @@ return [ 'contact' => 'Kontakt', 'vat_id' => 'Umsatzsteuer-ID: DE123456789', 'monetization' => 'Monetarisierung', - 'monetization_desc' => 'Wir monetarisieren über Packages (Einmalkäufe und Abos) via Paddle. Preise exkl. MwSt. Support: support@fotospiel.de', + 'monetization_desc' => 'Wir monetarisieren über Packages (Einmalkäufe und Abos) via Lemon Squeezy. Preise exkl. MwSt. Support: support@fotospiel.de', 'register_court' => 'Registergericht: Amtsgericht Musterstadt', 'commercial_register' => 'Handelsregister: HRB 12345', 'datenschutz_intro' => 'Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.', 'responsible' => 'Verantwortlich: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt', 'data_collection' => 'Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.', 'payments' => 'Zahlungen und Packages', - 'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsinformationen werden sicher und verschlüsselt durch Paddle als Merchant of Record verarbeitet.', + 'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über Lemon Squeezy. Zahlungsinformationen werden sicher und verschlüsselt durch Lemon Squeezy als Merchant of Record verarbeitet.', 'data_retention' => 'Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.', 'rights' => 'Ihre Rechte: Auskunft, Löschung, Widerspruch.', 'cookies' => 'Cookies: Nur funktionale Cookies für die PWA.', diff --git a/resources/lang/de/marketing.json b/resources/lang/de/marketing.json index 8ee3f75..722fae6 100644 --- a/resources/lang/de/marketing.json +++ b/resources/lang/de/marketing.json @@ -90,7 +90,7 @@ "faq_q3": "Was passiert bei Ablauf?", "faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.", "faq_q4": "Zahlungssicher?", - "faq_a4": "Ja, via Paddle – sicher und GDPR-konform.", + "faq_a4": "Ja, via Lemon Squeezy – sicher und GDPR-konform.", "final_cta": "Bereit für Ihr nächstes Event?", "contact_us": "Kontaktieren Sie uns", "feature_live_slideshow": "Live-Slideshow", diff --git a/resources/lang/de/marketing.php b/resources/lang/de/marketing.php index d137143..8f2e827 100644 --- a/resources/lang/de/marketing.php +++ b/resources/lang/de/marketing.php @@ -30,7 +30,7 @@ return [ 'faq_q3' => 'Was passiert bei Ablauf?', 'faq_a3' => 'Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.', 'faq_q4' => 'Zahlungssicher?', - 'faq_a4' => 'Ja, via Paddle – sicher und GDPR-konform.', + 'faq_a4' => 'Ja, via Lemon Squeezy – sicher und GDPR-konform.', 'final_cta' => 'Bereit für Ihr nächstes Event?', 'contact_us' => 'Kontaktieren Sie uns', 'feature_live_slideshow' => 'Live-Slideshow', @@ -64,12 +64,12 @@ return [ 'gallery_days_label' => 'Galerie-Tage', 'recommended_usage_window' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.', 'feature_overview' => 'Feature-Überblick', - 'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.', + 'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung über Lemon Squeezy.', 'features_label' => 'Features', 'breakdown_label' => 'Leistungsübersicht', 'limits_label' => 'Limits & Kapazitäten', - 'paddle_not_configured' => 'Dieses Package ist noch nicht für den Paddle-Checkout konfiguriert. Bitte kontaktiere den Support.', - 'paddle_checkout_failed' => 'Der Paddle-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.', + 'lemonsqueezy_not_configured' => 'Dieses Package ist noch nicht für den Lemon Squeezy-Checkout konfiguriert. Bitte kontaktiere den Support.', + 'lemonsqueezy_checkout_failed' => 'Der Lemon Squeezy-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.', 'package_not_found' => 'Dieses Package ist nicht verfügbar. Bitte wähle ein anderes aus.', ], 'nav' => [ diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index e5fa610..541ac31 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -186,9 +186,9 @@ return [ 'deleted' => 'Deleted', ], ], - 'paddle_health' => [ + 'lemonsqueezy_health' => [ 'navigation' => [ - 'label' => 'Paddle health', + 'label' => 'Lemon Squeezy health', ], ], 'integrations_health' => [ @@ -203,7 +203,7 @@ return [ 'unknown' => 'Unknown', ], 'heading' => 'Integrations health', - 'help' => 'Operational snapshot of Paddle/RevenueCat webhooks, queue backlog, and recent failures.', + 'help' => 'Operational snapshot of Lemon Squeezy/RevenueCat webhooks, queue backlog, and recent failures.', 'configured' => 'Configured', 'unconfigured' => 'Unconfigured', 'last_received' => 'Last received', diff --git a/resources/lang/en/emails.php b/resources/lang/en/emails.php index 4f765b0..4f9c641 100644 --- a/resources/lang/en/emails.php +++ b/resources/lang/en/emails.php @@ -89,7 +89,7 @@ return [ 'invoice_link' => 'Open invoice', 'cta' => 'Open Event Admin', 'provider' => [ - 'paddle' => 'Paddle', + 'lemonsqueezy' => 'Lemon Squeezy', 'manual' => 'Manual', 'free' => 'Free', ], @@ -122,7 +122,7 @@ return [ 'cta_hint_body' => 'Your selection stays locked—continue whenever you are ready.', 'benefits_title' => 'What you get', 'benefit1' => 'Premium checkout in minutes', - 'benefit2' => 'Secure payment with Paddle', + 'benefit2' => 'Secure payment with Lemon Squeezy', 'benefit3' => 'Instant activation after payment', 'benefit4' => 'Support from the Die Fotospiel.App team', 'footer' => 'Let us know if you need anything.', diff --git a/resources/lang/en/legal.php b/resources/lang/en/legal.php index 671fd59..216774d 100644 --- a/resources/lang/en/legal.php +++ b/resources/lang/en/legal.php @@ -12,14 +12,14 @@ return [ 'contact' => 'Contact', 'vat_id' => 'VAT ID: DE123456789', 'monetization' => 'Monetization', - 'monetization_desc' => 'We monetize through Packages (one-time purchases and subscriptions) via Paddle. Prices excl. VAT. Support: support@fotospiel.de', + 'monetization_desc' => 'We monetize through Packages (one-time purchases and subscriptions) via Lemon Squeezy. Prices excl. VAT. Support: support@fotospiel.de', 'register_court' => 'Register Court: District Court Musterstadt', 'commercial_register' => 'Commercial Register: HRB 12345', 'datenschutz_intro' => 'We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.', 'responsible' => 'Responsible: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt', 'data_collection' => 'Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.', 'payments' => 'Payments and Packages', - 'payments_desc' => 'We process payments for Packages via Paddle. Payment information is handled securely and encrypted by Paddle as the merchant of record.', + 'payments_desc' => 'We process payments for Packages via Lemon Squeezy. Payment information is handled securely and encrypted by Lemon Squeezy as the merchant of record.', 'data_retention' => 'Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.', 'rights' => 'Your rights: Information, deletion, objection. Contact us under Contact.', 'cookies' => 'Cookies: Only functional cookies for the PWA.', diff --git a/resources/lang/en/marketing.json b/resources/lang/en/marketing.json index d4fd161..37db1eb 100644 --- a/resources/lang/en/marketing.json +++ b/resources/lang/en/marketing.json @@ -91,7 +91,7 @@ "faq_q3": "What happens when it expires?", "faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend it.", "faq_q4": "Payment secure?", - "faq_a4": "Yes, via Paddle – secure and GDPR-compliant.", + "faq_a4": "Yes, via Lemon Squeezy – secure and GDPR-compliant.", "final_cta": "Ready for your next event?", "contact_us": "Contact Us", "feature_live_slideshow": "Live Slideshow", diff --git a/resources/lang/en/marketing.php b/resources/lang/en/marketing.php index c5fae85..01383f2 100644 --- a/resources/lang/en/marketing.php +++ b/resources/lang/en/marketing.php @@ -30,7 +30,7 @@ return [ 'faq_q3' => 'What happens when it expires?', 'faq_a3' => 'The gallery remains readable, but uploads are blocked. Simply extend it.', 'faq_q4' => 'Payment secure?', - 'faq_a4' => 'Yes, via Paddle – secure and GDPR-compliant.', + 'faq_a4' => 'Yes, via Lemon Squeezy – secure and GDPR-compliant.', 'final_cta' => 'Ready for your next event?', 'contact_us' => 'Contact Us', 'feature_live_slideshow' => 'Live Slideshow', @@ -64,12 +64,12 @@ return [ 'max_guests_label' => 'Max. guests', 'gallery_days_label' => 'Gallery days', 'feature_overview' => 'Feature overview', - 'order_hint' => 'Ready to launch instantly – secure Paddle checkout, no hidden fees.', + 'order_hint' => 'Ready to launch instantly – secure Lemon Squeezy checkout, no hidden fees.', 'features_label' => 'Features', 'breakdown_label' => 'At-a-glance', 'limits_label' => 'Limits & Capacity', - 'paddle_not_configured' => 'This package is not ready for Paddle checkout. Please contact support.', - 'paddle_checkout_failed' => 'We could not start the Paddle checkout. Please try again later.', + 'lemonsqueezy_not_configured' => 'This package is not ready for Lemon Squeezy checkout. Please contact support.', + 'lemonsqueezy_checkout_failed' => 'We could not start the Lemon Squeezy checkout. Please try again later.', 'package_not_found' => 'This package is no longer available. Please choose another one.', ], 'nav' => [ diff --git a/routes/api.php b/routes/api.php index e6978d3..2084051 100644 --- a/routes/api.php +++ b/routes/api.php @@ -444,7 +444,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('/purchase', [PackageController::class, 'purchase'])->name('packages.purchase'); Route::post('/complete', [PackageController::class, 'completePurchase'])->name('packages.complete'); Route::post('/free', [PackageController::class, 'assignFree'])->name('packages.free'); - Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout'); + Route::post('/lemonsqueezy-checkout', [PackageController::class, 'createLemonSqueezyCheckout'])->name('packages.lemonsqueezy-checkout'); Route::get('/checkout-session/{session}/status', [PackageController::class, 'checkoutSessionStatus']) ->name('packages.checkout-session.status'); }); diff --git a/routes/testing.php b/routes/testing.php index 16bf72a..b2c0d62 100644 --- a/routes/testing.php +++ b/routes/testing.php @@ -15,9 +15,9 @@ Route::prefix('_testing')->middleware(EnsureE2ETestingAccess::class)->group(func Route::post('/coupons/seed', [TestCouponController::class, 'store'])->name('testing.coupons.seed'); Route::get('/checkout/sessions/latest', [TestCheckoutController::class, 'latest'])->name('testing.checkout.sessions.latest'); - Route::post('/checkout/sessions/{session}/simulate-paddle', [TestCheckoutController::class, 'simulatePaddle']) + Route::post('/checkout/sessions/{session}/simulate-lemonsqueezy', [TestCheckoutController::class, 'simulateLemonSqueezy']) ->whereUuid('session') - ->name('testing.checkout.sessions.simulate-paddle'); + ->name('testing.checkout.sessions.simulate-lemonsqueezy'); Route::get('/events/join-token', [TestEventController::class, 'joinToken'])->name('testing.events.join-token'); Route::post('/guest-events', [TestGuestEventController::class, 'store'])->name('testing.guest-events.store'); diff --git a/routes/web.php b/routes/web.php index 16b262b..b8ba1f4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,11 +7,12 @@ use App\Http\Controllers\CheckoutFacebookController; use App\Http\Controllers\CheckoutGoogleController; use App\Http\Controllers\DashboardController; use App\Http\Controllers\LegalPageController; +use App\Http\Controllers\LemonSqueezyCheckoutController; +use App\Http\Controllers\LemonSqueezyReturnController; +use App\Http\Controllers\LemonSqueezyWebhookController; use App\Http\Controllers\LocaleController; use App\Http\Controllers\Marketing\GiftVoucherPrintController; use App\Http\Controllers\MarketingController; -use App\Http\Controllers\PaddleCheckoutController; -use App\Http\Controllers\PaddleWebhookController; use App\Http\Controllers\ProfileAccountController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileDataExportController; @@ -415,13 +416,13 @@ Route::middleware('auth')->group(function () { Route::post('/checkout/session/{session}/confirm', [CheckoutController::class, 'confirmSession']) ->whereUuid('session') ->name('checkout.session.confirm'); - Route::post('/paddle/create-checkout', [PaddleCheckoutController::class, 'create'])->name('paddle.checkout.create'); + Route::post('/lemonsqueezy/create-checkout', [LemonSqueezyCheckoutController::class, 'create'])->name('lemonsqueezy.checkout.create'); }); -Route::get('/paddle/return', \App\Http\Controllers\PaddleReturnController::class) - ->name('paddle.return'); +Route::get('/lemonsqueezy/return', LemonSqueezyReturnController::class) + ->name('lemonsqueezy.return'); -Route::post('/paddle/webhook', [PaddleWebhookController::class, 'handle']) +Route::post('/lemonsqueezy/webhook', [LemonSqueezyWebhookController::class, 'handle']) ->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class]) - ->middleware('throttle:paddle-webhook') - ->name('paddle.webhook'); + ->middleware('throttle:lemonsqueezy-webhook') + ->name('lemonsqueezy.webhook'); diff --git a/tamagui.config.ts b/tamagui.config.ts index bd0f0ac..555e427 100644 --- a/tamagui.config.ts +++ b/tamagui.config.ts @@ -23,6 +23,8 @@ const tokens = { // ... existing radius tokens ... card: 16, tile: 14, + bento: 24, + bentoLg: 32, pill: 999, }, // ... diff --git a/tests/Feature/Api/GiftVoucherLookupTest.php b/tests/Feature/Api/GiftVoucherLookupTest.php index 1562e63..f3e2bb2 100644 --- a/tests/Feature/Api/GiftVoucherLookupTest.php +++ b/tests/Feature/Api/GiftVoucherLookupTest.php @@ -16,7 +16,7 @@ class GiftVoucherLookupTest extends TestCase 'code' => 'GIFT-TESTCODE', 'amount' => 59.00, 'currency' => 'EUR', - 'paddle_checkout_id' => 'chk_look_123', + 'lemonsqueezy_checkout_id' => 'chk_look_123', 'status' => GiftVoucher::STATUS_ISSUED, ]); diff --git a/tests/Feature/Api/Marketing/CouponPreviewTest.php b/tests/Feature/Api/Marketing/CouponPreviewTest.php index 28d4215..63e7857 100644 --- a/tests/Feature/Api/Marketing/CouponPreviewTest.php +++ b/tests/Feature/Api/Marketing/CouponPreviewTest.php @@ -2,52 +2,34 @@ namespace Tests\Feature\Api\Marketing; +use App\Enums\CouponType; use App\Models\Coupon; use App\Models\Package; use App\Models\Tenant; use App\Models\User; -use App\Services\Paddle\PaddleDiscountService; use Illuminate\Foundation\Testing\RefreshDatabase; -use Mockery; use Tests\TestCase; class CouponPreviewTest extends TestCase { use RefreshDatabase; - protected function tearDown(): void - { - Mockery::close(); - - parent::tearDown(); - } - public function test_guest_can_preview_coupon(): void { $package = Package::factory()->create([ - 'paddle_price_id' => 'pri_test', + 'lemonsqueezy_variant_id' => 'pri_test', 'price' => 100, ]); $coupon = Coupon::factory()->create([ 'code' => 'SAVE20', - 'paddle_discount_id' => 'dsc_123', + 'type' => CouponType::PERCENTAGE, + 'amount' => 20, + 'lemonsqueezy_discount_id' => 'dsc_123', 'per_customer_limit' => null, ]); $coupon->packages()->attach($package); - $this->instance(PaddleDiscountService::class, Mockery::mock(PaddleDiscountService::class, function ($mock) { - $mock->shouldReceive('previewDiscount')->andReturn([ - 'totals' => [ - 'currency_code' => 'EUR', - 'subtotal' => 10000, - 'discount' => 2000, - 'tax' => 0, - 'total' => 8000, - ], - ]); - })); - $response = $this->postJson(route('api.v1.marketing.coupons.preview'), [ 'package_id' => $package->id, 'code' => 'save20', @@ -62,7 +44,7 @@ class CouponPreviewTest extends TestCase public function test_invalid_coupon_returns_validation_error(): void { $package = Package::factory()->create([ - 'paddle_price_id' => 'pri_test_invalid', + 'lemonsqueezy_variant_id' => 'pri_test_invalid', ]); $this->postJson(route('api.v1.marketing.coupons.preview'), [ @@ -75,12 +57,12 @@ class CouponPreviewTest extends TestCase public function test_coupon_with_per_customer_limit_requires_login(): void { $package = Package::factory()->create([ - 'paddle_price_id' => 'pri_test_login', + 'lemonsqueezy_variant_id' => 'pri_test_login', ]); $coupon = Coupon::factory()->create([ 'code' => 'LIMITED', - 'paddle_discount_id' => 'dsc_login', + 'lemonsqueezy_discount_id' => 'dsc_login', 'per_customer_limit' => 1, ]); $coupon->packages()->attach($package); @@ -107,29 +89,20 @@ class CouponPreviewTest extends TestCase ]); $package = Package::factory()->create([ - 'paddle_price_id' => 'pri_test_logged_in', + 'lemonsqueezy_variant_id' => 'pri_test_logged_in', 'price' => 120, ]); $coupon = Coupon::factory()->create([ 'code' => 'LIMITEDTENANT', - 'paddle_discount_id' => 'dsc_logged_in', + 'type' => CouponType::FLAT, + 'amount' => 20, + 'currency' => 'EUR', + 'lemonsqueezy_discount_id' => 'dsc_logged_in', 'per_customer_limit' => 1, ]); $coupon->packages()->attach($package); - $this->instance(PaddleDiscountService::class, Mockery::mock(PaddleDiscountService::class, function ($mock) { - $mock->shouldReceive('previewDiscount')->andReturn([ - 'totals' => [ - 'currency_code' => 'EUR', - 'subtotal' => 12000, - 'discount' => 2000, - 'tax' => 0, - 'total' => 10000, - ], - ]); - })); - $response = $this->actingAs($user)->postJson(route('api.v1.marketing.coupons.preview'), [ 'package_id' => $package->id, 'code' => 'limitedtenant', diff --git a/tests/Feature/Api/Tenant/BillingPortalTest.php b/tests/Feature/Api/Tenant/BillingPortalTest.php index a4a4c95..7a8e958 100644 --- a/tests/Feature/Api/Tenant/BillingPortalTest.php +++ b/tests/Feature/Api/Tenant/BillingPortalTest.php @@ -2,107 +2,48 @@ namespace Tests\Feature\Api\Tenant; -use Illuminate\Http\Client\Request; +use App\Models\Package; +use App\Models\TenantPackage; use Illuminate\Support\Facades\Http; use Tests\Feature\Tenant\TenantTestCase; class BillingPortalTest extends TenantTestCase { - public function test_tenant_can_create_paddle_portal_session(): void + public function test_tenant_can_fetch_lemonsqueezy_portal_url(): void { - Http::fake(function (Request $request) { - $url = $request->url(); + config()->set('lemonsqueezy.base_url', 'https://lemonsqueezy.test'); - if (str_contains($url, '/customers') && $request->method() === 'POST' && ! str_contains($url, '/portal-sessions')) { - return Http::response([ - 'data' => ['id' => 'cus_123'], - ], 200); - } + $package = Package::factory()->reseller()->create(); + TenantPackage::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'package_id' => $package->id, + 'lemonsqueezy_subscription_id' => 'sub_123', + 'active' => true, + ]); - if (str_contains($url, '/portal-sessions') && $request->method() === 'POST') { - return Http::response([ - 'data' => [ + Http::fake([ + 'https://lemonsqueezy.test/subscriptions/sub_123' => Http::response([ + 'data' => [ + 'id' => 'sub_123', + 'attributes' => [ 'urls' => [ - 'general' => [ - 'overview' => 'https://portal.example/overview', - ], + 'customer_portal' => 'https://portal.example/overview', ], ], - ], 200); - } - - return Http::response([], 404); - }); - - $this->tenant->forceFill(['paddle_customer_id' => null])->save(); + ], + ], 200), + ]); $response = $this->authenticatedRequest('POST', '/api/v1/tenant/billing/portal'); $response->assertOk(); $response->assertJsonPath('url', 'https://portal.example/overview'); - - Http::assertSent(function (Request $request): bool { - $url = $request->url(); - - return $request->hasHeader('Paddle-Version', '1') - && str_contains($url, '/portal-sessions') - && $request->body() === '{}'; - }); - - $this->assertDatabaseHas('tenants', [ - 'id' => $this->tenant->id, - 'paddle_customer_id' => 'cus_123', - ]); } - public function test_tenant_can_reuse_existing_paddle_customer_when_customer_already_exists(): void + public function test_portal_returns_404_when_no_active_subscription(): void { - Http::fake(function (Request $request) { - $url = $request->url(); - - if (str_contains($url, '/customers') && $request->method() === 'POST' && ! str_contains($url, '/portal-sessions')) { - return Http::response([ - 'error' => [ - 'type' => 'request_error', - 'code' => 'customer_already_exists', - 'message' => 'Customer already exists.', - ], - ], 409); - } - - if (str_contains($url, '/customers') && $request->method() === 'GET') { - return Http::response([ - 'data' => [ - ['id' => 'cus_existing'], - ], - ], 200); - } - - if (str_contains($url, '/portal-sessions') && $request->method() === 'POST') { - return Http::response([ - 'data' => [ - 'urls' => [ - 'general' => [ - 'overview' => 'https://portal.example/overview', - ], - ], - ], - ], 200); - } - - return Http::response([], 404); - }); - - $this->tenant->forceFill(['paddle_customer_id' => null])->save(); - $response = $this->authenticatedRequest('POST', '/api/v1/tenant/billing/portal'); - $response->assertOk(); - $response->assertJsonPath('url', 'https://portal.example/overview'); - - $this->assertDatabaseHas('tenants', [ - 'id' => $this->tenant->id, - 'paddle_customer_id' => 'cus_existing', - ]); + $response->assertNotFound(); } } diff --git a/tests/Feature/Api/Tenant/BillingTransactionsTest.php b/tests/Feature/Api/Tenant/BillingTransactionsTest.php index 2289ae7..7d9e9f0 100644 --- a/tests/Feature/Api/Tenant/BillingTransactionsTest.php +++ b/tests/Feature/Api/Tenant/BillingTransactionsTest.php @@ -8,7 +8,7 @@ use Tests\Feature\Tenant\TenantTestCase; class BillingTransactionsTest extends TenantTestCase { - public function test_transactions_endpoint_creates_missing_paddle_customer_id(): void + public function test_transactions_endpoint_creates_missing_lemonsqueezy_customer_id(): void { Http::fake(function (Request $request) { $path = parse_url($request->url(), PHP_URL_PATH); @@ -35,7 +35,7 @@ class BillingTransactionsTest extends TenantTestCase return Http::response([], 404); }); - $this->tenant->forceFill(['paddle_customer_id' => null])->save(); + $this->tenant->forceFill(['lemonsqueezy_customer_id' => null])->save(); $response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/transactions'); @@ -44,7 +44,7 @@ class BillingTransactionsTest extends TenantTestCase $this->assertDatabaseHas('tenants', [ 'id' => $this->tenant->id, - 'paddle_customer_id' => 'cus_456', + 'lemonsqueezy_customer_id' => 'cus_456', ]); } } diff --git a/tests/Feature/Checkout/CheckoutAuthTest.php b/tests/Feature/Checkout/CheckoutAuthTest.php index 0c5100d..d1b4b99 100644 --- a/tests/Feature/Checkout/CheckoutAuthTest.php +++ b/tests/Feature/Checkout/CheckoutAuthTest.php @@ -328,9 +328,9 @@ class CheckoutAuthTest extends TestCase ->has('auth') ->has('auth.user') ->has('googleAuth') - ->has('paddle') - ->has('paddle.environment') - ->has('paddle.client_token') + ->has('lemonsqueezy') + ->has('lemonsqueezy.store_id') + ->has('lemonsqueezy.test_mode') ->where('package.id', $package->id) ); } diff --git a/tests/Feature/Checkout/CheckoutSessionStatusTest.php b/tests/Feature/Checkout/CheckoutSessionStatusTest.php index 9395aa1..1fe10b9 100644 --- a/tests/Feature/Checkout/CheckoutSessionStatusTest.php +++ b/tests/Feature/Checkout/CheckoutSessionStatusTest.php @@ -58,7 +58,7 @@ class CheckoutSessionStatusTest extends TestCase $response->assertForbidden(); } - public function test_session_status_recovers_completed_paddle_transaction(): void + public function test_session_status_recovers_completed_lemonsqueezy_order(): void { $tenant = Tenant::factory()->create(); $user = User::factory()->for($tenant)->create([ @@ -74,34 +74,34 @@ class CheckoutSessionStatusTest extends TestCase $session = $sessions->createOrResume($user, $package, [ 'tenant' => $tenant, ]); - $sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); + $sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY); $session->forceFill([ 'provider_metadata' => [ - 'paddle_checkout_id' => 'chk_123', + 'lemonsqueezy_checkout_id' => 'chk_123', ], ])->save(); config()->set([ - 'paddle.api_key' => 'test-key', - 'paddle.base_url' => 'https://paddle.test', - 'paddle.environment' => 'sandbox', + 'lemonsqueezy.api_key' => 'test-key', + 'lemonsqueezy.base_url' => 'https://lemonsqueezy.test', ]); Http::fake([ - 'https://paddle.test/*' => Http::response([ + 'https://lemonsqueezy.test/checkouts/chk_123' => Http::response([ 'data' => [ - [ - 'id' => 'txn_123', - 'status' => 'completed', - 'details' => [ - 'totals' => [ - 'currency_code' => 'EUR', - 'total' => ['amount' => 9900], - ], - ], - 'custom_data' => [ - 'checkout_session_id' => $session->id, - ], + 'id' => 'chk_123', + 'attributes' => [ + 'order_id' => 'ord_123', + ], + ], + ], 200), + 'https://lemonsqueezy.test/orders/ord_123' => Http::response([ + 'data' => [ + 'id' => 'ord_123', + 'attributes' => [ + 'status' => 'paid', + 'currency' => 'EUR', + 'total' => 9900, ], ], ], 200), @@ -128,7 +128,7 @@ class CheckoutSessionStatusTest extends TestCase ]); } - public function test_session_confirm_recovers_completed_paddle_transaction(): void + public function test_session_confirm_recovers_completed_lemonsqueezy_order(): void { $tenant = Tenant::factory()->create(); $user = User::factory()->for($tenant)->create([ @@ -144,27 +144,21 @@ class CheckoutSessionStatusTest extends TestCase $session = $sessions->createOrResume($user, $package, [ 'tenant' => $tenant, ]); - $sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); + $sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY); config()->set([ - 'paddle.api_key' => 'test-key', - 'paddle.base_url' => 'https://paddle.test', - 'paddle.environment' => 'sandbox', + 'lemonsqueezy.api_key' => 'test-key', + 'lemonsqueezy.base_url' => 'https://lemonsqueezy.test', ]); Http::fake([ - 'https://paddle.test/transactions/txn_987' => Http::response([ + 'https://lemonsqueezy.test/orders/ord_987' => Http::response([ 'data' => [ - 'id' => 'txn_987', - 'status' => 'completed', - 'details' => [ - 'totals' => [ - 'currency_code' => 'EUR', - 'total' => ['amount' => 7900], - ], - ], - 'custom_data' => [ - 'checkout_session_id' => $session->id, + 'id' => 'ord_987', + 'attributes' => [ + 'status' => 'paid', + 'currency' => 'EUR', + 'total' => 7900, ], ], ], 200), @@ -176,8 +170,8 @@ class CheckoutSessionStatusTest extends TestCase $this->actingAs($user); $response = $this->postJson(route('checkout.session.confirm', $session), [ - 'transaction_id' => 'txn_987', - 'checkout_id' => 'che_987', + 'order_id' => 'ord_987', + 'checkout_id' => 'chk_987', ]); $response->assertOk() diff --git a/tests/Feature/CheckoutSessionLocalConfirmationTest.php b/tests/Feature/CheckoutSessionLocalConfirmationTest.php index a3c49e3..3fbc0b2 100644 --- a/tests/Feature/CheckoutSessionLocalConfirmationTest.php +++ b/tests/Feature/CheckoutSessionLocalConfirmationTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Models\CheckoutSession; use App\Models\Package; use App\Models\Tenant; use App\Models\User; @@ -24,8 +25,8 @@ class CheckoutSessionLocalConfirmationTest extends TestCase $tenant = Tenant::factory()->create(); $user = User::factory()->for($tenant)->create(); $package = Package::factory()->create([ - 'paddle_price_id' => 'pri_123', - 'paddle_product_id' => 'pro_123', + 'lemonsqueezy_variant_id' => 'pri_123', + 'lemonsqueezy_product_id' => 'pro_123', 'price' => 120, ]); @@ -33,7 +34,7 @@ class CheckoutSessionLocalConfirmationTest extends TestCase $session = $sessions->createOrResume($user, $package, [ 'tenant' => $tenant, ]); - $sessions->selectProvider($session, 'paddle'); + $sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY); $this->actingAs($user); $this->withSession(['_token' => 'test-token']); @@ -41,7 +42,7 @@ class CheckoutSessionLocalConfirmationTest extends TestCase $response = $this->postJson( route('checkout.session.confirm', $session), [ - 'transaction_id' => 'txn_123', + 'order_id' => 'ord_123', 'checkout_id' => 'chk_123', ], [ @@ -60,7 +61,7 @@ class CheckoutSessionLocalConfirmationTest extends TestCase $this->assertDatabaseHas('package_purchases', [ 'tenant_id' => $tenant->id, 'package_id' => $package->id, - 'provider_id' => 'txn_123', + 'provider_id' => 'ord_123', ]); $this->assertDatabaseHas('tenant_packages', [ diff --git a/tests/Feature/Dashboard/DashboardPageTest.php b/tests/Feature/Dashboard/DashboardPageTest.php index 0ecad5d..1502602 100644 --- a/tests/Feature/Dashboard/DashboardPageTest.php +++ b/tests/Feature/Dashboard/DashboardPageTest.php @@ -71,7 +71,7 @@ class DashboardPageTest extends TestCase 'package_id' => $package->id, 'price' => 149.00, 'type' => 'reseller_subscription', - 'provider' => 'paddle', + 'provider' => 'lemonsqueezy', 'purchased_at' => now()->subDay(), ]); diff --git a/tests/Feature/FullUserFlowTest.php b/tests/Feature/FullUserFlowTest.php index 85a81c9..76bef15 100644 --- a/tests/Feature/FullUserFlowTest.php +++ b/tests/Feature/FullUserFlowTest.php @@ -71,7 +71,7 @@ class FullUserFlowTest extends TestCase $this->assertAuthenticated(); $loginResponse->assertRedirect(CheckoutRoutes::wizardUrl($freePackage->id, 'de')); - // Schritt 3: Paid Package Bestellung (Mock Paddle) + // Schritt 3: Paid Package Bestellung (Mock Lemon Squeezy) $paidPackage = Package::factory()->reseller()->create(['price' => 10]); // Simuliere Kauf (GET zu buy.packages, aber da es Redirect ist, prüfe Session oder folge) @@ -92,8 +92,8 @@ class FullUserFlowTest extends TestCase 'tenant_id' => $tenant->id, 'package_id' => $paidPackage->id, 'type' => 'reseller_subscription', - 'provider' => 'paddle', - 'provider_id' => 'paddle_txn_123', + 'provider' => 'lemonsqueezy', + 'provider_id' => 'ord_123', 'price' => 10, 'purchased_at' => now(), ]); @@ -105,7 +105,7 @@ class FullUserFlowTest extends TestCase 'tenant_id' => $tenant->id, 'package_id' => $paidPackage->id, 'type' => 'reseller_subscription', - 'provider' => 'paddle', + 'provider' => 'lemonsqueezy', ]); $this->assertEquals(1, PackagePurchase::where('tenant_id', $tenant->id)->count()); diff --git a/tests/Feature/PaddleCheckoutControllerTest.php b/tests/Feature/LemonSqueezyCheckoutControllerTest.php similarity index 78% rename from tests/Feature/PaddleCheckoutControllerTest.php rename to tests/Feature/LemonSqueezyCheckoutControllerTest.php index 65c5dd6..625afe4 100644 --- a/tests/Feature/PaddleCheckoutControllerTest.php +++ b/tests/Feature/LemonSqueezyCheckoutControllerTest.php @@ -7,12 +7,12 @@ use App\Models\Package; use App\Models\Tenant; use App\Models\User; use App\Services\Coupons\CouponService; -use App\Services\Paddle\PaddleCheckoutService; +use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; use Tests\TestCase; -class PaddleCheckoutControllerTest extends TestCase +class LemonSqueezyCheckoutControllerTest extends TestCase { use RefreshDatabase; @@ -26,20 +26,20 @@ class PaddleCheckoutControllerTest extends TestCase public function test_authenticated_user_can_create_checkout_with_coupon(): void { $tenant = Tenant::factory()->create([ - 'paddle_customer_id' => 'cus_123', + 'lemonsqueezy_customer_id' => 'cus_123', ]); $user = User::factory()->for($tenant)->create(); $package = Package::factory()->create([ - 'paddle_price_id' => 'pri_123', - 'paddle_product_id' => 'pro_123', + 'lemonsqueezy_variant_id' => 'pri_123', + 'lemonsqueezy_product_id' => 'pro_123', 'price' => 120, ]); $coupon = Coupon::factory()->create([ 'code' => 'SAVE15', - 'paddle_discount_id' => 'dsc_123', + 'lemonsqueezy_discount_id' => 'dsc_123', ]); $coupon->packages()->attach($package); @@ -66,18 +66,18 @@ class PaddleCheckoutControllerTest extends TestCase ]); $this->instance(CouponService::class, $couponServiceMock); - $paddleServiceMock = Mockery::mock(PaddleCheckoutService::class); - $paddleServiceMock->shouldReceive('createCheckout') + $checkoutServiceMock = Mockery::mock(LemonSqueezyCheckoutService::class); + $checkoutServiceMock->shouldReceive('createCheckout') ->once() ->andReturn([ 'checkout_url' => 'https://example.com/checkout/test', 'id' => 'chk_123', ]); - $this->instance(PaddleCheckoutService::class, $paddleServiceMock); + $this->instance(LemonSqueezyCheckoutService::class, $checkoutServiceMock); $this->be($user); - $response = $this->postJson(route('paddle.checkout.create'), [ + $response = $this->postJson(route('lemonsqueezy.checkout.create'), [ 'package_id' => $package->id, 'coupon_code' => 'SAVE15', 'accepted_terms' => true, diff --git a/tests/Feature/LemonSqueezyRegisterWebhooksCommandTest.php b/tests/Feature/LemonSqueezyRegisterWebhooksCommandTest.php new file mode 100644 index 0000000..2cc41a1 --- /dev/null +++ b/tests/Feature/LemonSqueezyRegisterWebhooksCommandTest.php @@ -0,0 +1,49 @@ + ['order_created', 'subscription_created'], + 'lemonsqueezy.store_id' => 'store_123', + 'lemonsqueezy.webhook_secret' => 'secret_123', + ]); + + $client = Mockery::mock(LemonSqueezyClient::class); + $client->shouldReceive('post') + ->once() + ->with('/webhooks', Mockery::on(function (array $payload): bool { + $attributes = $payload['data']['attributes'] ?? []; + $store = $payload['data']['relationships']['store']['data']['id'] ?? null; + + return ($attributes['url'] ?? null) === 'https://example.test/lemonsqueezy/webhook' + && ($attributes['events'] ?? []) === ['order_created', 'subscription_created'] + && ($attributes['secret'] ?? null) === 'secret_123' + && $store === 'store_123'; + })) + ->andReturn(['data' => ['id' => 'wh_123']]); + + $this->app->instance(LemonSqueezyClient::class, $client); + + $this->artisan('lemonsqueezy:webhooks:register', [ + '--url' => 'https://example.test/lemonsqueezy/webhook', + ])->assertExitCode(0); + } +} diff --git a/tests/Feature/LemonSqueezyReturnTest.php b/tests/Feature/LemonSqueezyReturnTest.php new file mode 100644 index 0000000..edb15b3 --- /dev/null +++ b/tests/Feature/LemonSqueezyReturnTest.php @@ -0,0 +1,62 @@ + Http::response([ + 'data' => [ + 'id' => 'ord_123', + 'attributes' => [ + 'status' => 'paid', + 'custom_data' => [ + 'success_url' => 'https://fotospiel-app.test/event-admin/mobile/events/slug/photos?addon_success=1', + 'return_url' => 'https://fotospiel-app.test/event-admin/mobile/events/slug/photos', + ], + ], + ], + ], 200), + ]); + + $response = $this->get('/lemonsqueezy/return?order_id=ord_123'); + + $response->assertRedirect('https://fotospiel-app.test/event-admin/mobile/events/slug/photos?addon_success=1'); + } + + public function test_return_redirects_to_return_url_when_not_completed(): void + { + Config::set('lemonsqueezy.api_key', 'test_key'); + Config::set('lemonsqueezy.base_url', 'https://lemonsqueezy.test'); + Config::set('app.url', 'https://fotospiel-app.test'); + + Http::fake([ + 'https://lemonsqueezy.test/orders/ord_456' => Http::response([ + 'data' => [ + 'id' => 'ord_456', + 'attributes' => [ + 'status' => 'failed', + 'custom_data' => [ + 'success_url' => 'https://fotospiel-app.test/event-admin/mobile/events/slug/photos?addon_success=1', + 'return_url' => 'https://fotospiel-app.test/event-admin/mobile/events/slug/photos', + ], + ], + ], + ], 200), + ]); + + $response = $this->get('/lemonsqueezy/return?order_id=ord_456'); + + $response->assertRedirect('https://fotospiel-app.test/event-admin/mobile/events/slug/photos'); + } +} diff --git a/tests/Feature/PaddleSyncPackagesCommandTest.php b/tests/Feature/LemonSqueezySyncPackagesCommandTest.php similarity index 62% rename from tests/Feature/PaddleSyncPackagesCommandTest.php rename to tests/Feature/LemonSqueezySyncPackagesCommandTest.php index 8ab13f2..070004b 100644 --- a/tests/Feature/PaddleSyncPackagesCommandTest.php +++ b/tests/Feature/LemonSqueezySyncPackagesCommandTest.php @@ -2,32 +2,32 @@ namespace Tests\Feature; -use App\Jobs\SyncPackageToPaddle; +use App\Jobs\SyncPackageToLemonSqueezy; use App\Models\Package; use Illuminate\Console\Command; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus as BusFacade; use Tests\TestCase; -class PaddleSyncPackagesCommandTest extends TestCase +class LemonSqueezySyncPackagesCommandTest extends TestCase { use RefreshDatabase; public function test_command_dispatches_jobs_for_packages(): void { Package::factory()->count(2)->create([ - 'paddle_product_id' => 'pro_test', - 'paddle_price_id' => 'pri_test', + 'lemonsqueezy_product_id' => 'pro_test', + 'lemonsqueezy_variant_id' => 'pri_test', ]); BusFacade::fake(); - $this->artisan('paddle:sync-packages', [ + $this->artisan('lemonsqueezy:sync-packages', [ '--dry-run' => true, '--queue' => true, ])->assertExitCode(0); - BusFacade::assertDispatched(SyncPackageToPaddle::class, 2); + BusFacade::assertDispatched(SyncPackageToLemonSqueezy::class, 2); } public function test_command_filters_packages_by_id(): void @@ -37,13 +37,13 @@ class PaddleSyncPackagesCommandTest extends TestCase BusFacade::fake(); - $this->artisan('paddle:sync-packages', [ + $this->artisan('lemonsqueezy:sync-packages', [ '--dry-run' => true, '--queue' => true, '--package' => [$package->id], ])->assertExitCode(0); - BusFacade::assertDispatched(SyncPackageToPaddle::class, function (SyncPackageToPaddle $job) use ($package) { + BusFacade::assertDispatched(SyncPackageToLemonSqueezy::class, function (SyncPackageToLemonSqueezy $job) use ($package) { return $this->getJobPackageId($job) === $package->id; }); } @@ -51,39 +51,39 @@ class PaddleSyncPackagesCommandTest extends TestCase public function test_command_blocks_bulk_sync_with_unmapped_packages(): void { Package::factory()->create([ - 'paddle_product_id' => null, - 'paddle_price_id' => null, + 'lemonsqueezy_product_id' => null, + 'lemonsqueezy_variant_id' => null, ]); BusFacade::fake(); - $this->artisan('paddle:sync-packages', [ + $this->artisan('lemonsqueezy:sync-packages', [ '--dry-run' => true, '--queue' => true, ])->assertExitCode(Command::FAILURE); - BusFacade::assertNotDispatched(SyncPackageToPaddle::class); + BusFacade::assertNotDispatched(SyncPackageToLemonSqueezy::class); } public function test_command_allows_unmapped_packages_when_overridden(): void { Package::factory()->create([ - 'paddle_product_id' => null, - 'paddle_price_id' => null, + 'lemonsqueezy_product_id' => null, + 'lemonsqueezy_variant_id' => null, ]); BusFacade::fake(); - $this->artisan('paddle:sync-packages', [ + $this->artisan('lemonsqueezy:sync-packages', [ '--dry-run' => true, '--queue' => true, '--allow-unmapped' => true, ])->assertExitCode(Command::SUCCESS); - BusFacade::assertDispatched(SyncPackageToPaddle::class, 1); + BusFacade::assertDispatched(SyncPackageToLemonSqueezy::class, 1); } - protected function getJobPackageId(SyncPackageToPaddle $job): int + protected function getJobPackageId(SyncPackageToLemonSqueezy $job): int { $reflection = new \ReflectionClass($job); $property = $reflection->getProperty('packageId'); diff --git a/tests/Feature/LemonSqueezyWebhookControllerTest.php b/tests/Feature/LemonSqueezyWebhookControllerTest.php new file mode 100644 index 0000000..1427d72 --- /dev/null +++ b/tests/Feature/LemonSqueezyWebhookControllerTest.php @@ -0,0 +1,233 @@ + '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]; + } +} diff --git a/tests/Feature/Marketing/WithdrawalConfirmationTest.php b/tests/Feature/Marketing/WithdrawalConfirmationTest.php index 54da5d5..52d55c3 100644 --- a/tests/Feature/Marketing/WithdrawalConfirmationTest.php +++ b/tests/Feature/Marketing/WithdrawalConfirmationTest.php @@ -10,7 +10,7 @@ use App\Models\Tenant; use App\Models\TenantPackage; use App\Models\User; use App\Notifications\Customer\WithdrawalConfirmed; -use App\Services\Paddle\PaddleTransactionService; +use App\Services\LemonSqueezy\LemonSqueezyOrderService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Notification; use Inertia\Testing\AssertableInertia as Assert; @@ -30,8 +30,8 @@ class WithdrawalConfirmationTest extends TestCase $purchase = PackagePurchase::factory()->create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, - 'provider' => 'paddle', - 'provider_id' => 'txn_123', + 'provider' => 'lemonsqueezy', + 'provider_id' => 'ord_123', 'refunded' => false, 'type' => 'endcustomer_event', 'purchased_at' => now()->subDays(2), @@ -60,8 +60,8 @@ class WithdrawalConfirmationTest extends TestCase $purchase = PackagePurchase::factory()->create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, - 'provider' => 'paddle', - 'provider_id' => 'txn_456', + 'provider' => 'lemonsqueezy', + 'provider_id' => 'ord_456', 'refunded' => false, 'type' => 'endcustomer_event', 'purchased_at' => now()->subDays(5), @@ -73,7 +73,7 @@ class WithdrawalConfirmationTest extends TestCase 'active' => true, ]); - $this->mock(PaddleTransactionService::class, function ($mock) { + $this->mock(LemonSqueezyOrderService::class, function ($mock) { $mock->shouldReceive('refund') ->once() ->andReturn([]); @@ -105,8 +105,8 @@ class WithdrawalConfirmationTest extends TestCase $purchase = PackagePurchase::factory()->create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, - 'provider' => 'paddle', - 'provider_id' => 'txn_789', + 'provider' => 'lemonsqueezy', + 'provider_id' => 'ord_789', 'refunded' => false, 'type' => 'endcustomer_event', 'purchased_at' => now()->subDays(3), @@ -120,7 +120,7 @@ class WithdrawalConfirmationTest extends TestCase 'purchased_at' => now(), ]); - $this->mock(PaddleTransactionService::class, function ($mock) { + $this->mock(LemonSqueezyOrderService::class, function ($mock) { $mock->shouldReceive('refund')->never(); }); diff --git a/tests/Feature/Packages/PackageSoftDeleteTest.php b/tests/Feature/Packages/PackageSoftDeleteTest.php index 087bb0f..0189e37 100644 --- a/tests/Feature/Packages/PackageSoftDeleteTest.php +++ b/tests/Feature/Packages/PackageSoftDeleteTest.php @@ -11,7 +11,7 @@ use App\Services\Checkout\CheckoutSessionService; use App\Services\Checkout\CheckoutWebhookService; use App\Services\Coupons\CouponRedemptionService; use App\Services\GiftVouchers\GiftVoucherService; -use App\Services\Paddle\PaddleSubscriptionService; +use App\Services\LemonSqueezy\LemonSqueezySubscriptionService; use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; @@ -70,18 +70,19 @@ class PackageSoftDeleteTest extends TestCase $this->assertTrue($activePackage->is($tenantPackage)); } - public function test_paddle_subscription_event_handles_soft_deleted_package(): void + public function test_lemonsqueezy_subscription_event_handles_soft_deleted_package(): void { $tenant = Tenant::factory()->create(); $package = Package::factory()->reseller()->create([ 'price' => 29.00, + 'lemonsqueezy_variant_id' => 'var_123', ]); $package->delete(); $sessionService = Mockery::mock(CheckoutSessionService::class); $assignmentService = Mockery::mock(CheckoutAssignmentService::class); - $subscriptionService = Mockery::mock(PaddleSubscriptionService::class); + $subscriptionService = Mockery::mock(LemonSqueezySubscriptionService::class); $couponRedemptions = Mockery::mock(CouponRedemptionService::class); $giftVouchers = Mockery::mock(GiftVoucherService::class); @@ -96,20 +97,25 @@ class PackageSoftDeleteTest extends TestCase Carbon::setTestNow(now()); $event = [ - 'event_type' => 'subscription.updated', - 'data' => [ - 'id' => 'sub_123', - 'status' => 'active', + 'meta' => [ + 'event_name' => 'subscription_updated', 'custom_data' => [ 'tenant_id' => $tenant->id, 'package_id' => $package->id, ], - 'next_billing_date' => now()->addMonth()->toIso8601String(), - 'customer_id' => 'cus_456', + ], + 'data' => [ + 'id' => 'sub_123', + 'attributes' => [ + 'status' => 'active', + 'renews_at' => now()->addMonth()->toIso8601String(), + 'customer_id' => 'cus_456', + 'variant_id' => 'var_123', + ], ], ]; - $this->assertTrue($service->handlePaddleEvent($event)); + $this->assertTrue($service->handleLemonSqueezyEvent($event)); $tenantPackage = TenantPackage::where('tenant_id', $tenant->id) ->where('package_id', $package->id) @@ -118,11 +124,11 @@ class PackageSoftDeleteTest extends TestCase $this->assertNotNull($tenantPackage); $this->assertNotNull($tenantPackage->package); $this->assertTrue($tenantPackage->package->is($package)); - $this->assertSame('sub_123', $tenantPackage->paddle_subscription_id); + $this->assertSame('sub_123', $tenantPackage->lemonsqueezy_subscription_id); $this->assertTrue($tenantPackage->active); $tenant->refresh(); $this->assertSame('active', $tenant->subscription_status); - $this->assertSame('cus_456', $tenant->paddle_customer_id); + $this->assertSame('cus_456', $tenant->lemonsqueezy_customer_id); } } diff --git a/tests/Feature/PaddleRegisterWebhooksCommandTest.php b/tests/Feature/PaddleRegisterWebhooksCommandTest.php deleted file mode 100644 index d6dfd09..0000000 --- a/tests/Feature/PaddleRegisterWebhooksCommandTest.php +++ /dev/null @@ -1,44 +0,0 @@ - ['transaction.completed', 'subscription.created'], - ]); - - $client = Mockery::mock(PaddleClient::class); - $client->shouldReceive('post') - ->once() - ->with('/notification-settings', Mockery::on(function (array $payload): bool { - return $payload['destination'] === 'https://example.test/paddle/webhook' - && $payload['subscribed_events'] === ['transaction.completed', 'subscription.created'] - && $payload['traffic_source'] === 'simulation'; - })) - ->andReturn(['data' => ['id' => 'ntfset_123']]); - - $this->app->instance(PaddleClient::class, $client); - - $this->artisan('paddle:webhooks:register', [ - '--url' => 'https://example.test/paddle/webhook', - '--traffic-source' => 'simulation', - ])->assertExitCode(0); - } -} diff --git a/tests/Feature/PaddleReturnTest.php b/tests/Feature/PaddleReturnTest.php deleted file mode 100644 index 1399737..0000000 --- a/tests/Feature/PaddleReturnTest.php +++ /dev/null @@ -1,60 +0,0 @@ - Http::response([ - 'data' => [ - 'id' => 'txn_123', - 'status' => 'completed', - 'custom_data' => [ - 'success_url' => 'https://fotospiel-app.test/event-admin/mobile/events/slug/photos?addon_success=1', - 'cancel_url' => 'https://fotospiel-app.test/event-admin/mobile/events/slug/photos', - ], - ], - ], 200), - ]); - - $response = $this->get('/paddle/return?_ptxn=txn_123'); - - $response->assertRedirect('https://fotospiel-app.test/event-admin/mobile/events/slug/photos?addon_success=1'); - } - - public function test_return_redirects_to_cancel_url_when_not_completed(): void - { - Config::set('paddle.api_key', 'test_key'); - Config::set('paddle.base_url', 'https://paddle.test'); - Config::set('paddle.environment', 'sandbox'); - Config::set('app.url', 'https://fotospiel-app.test'); - - Http::fake([ - 'https://paddle.test/transactions/txn_456' => Http::response([ - 'data' => [ - 'id' => 'txn_456', - 'status' => 'failed', - 'custom_data' => [ - 'success_url' => 'https://fotospiel-app.test/event-admin/mobile/events/slug/photos?addon_success=1', - 'cancel_url' => 'https://fotospiel-app.test/event-admin/mobile/events/slug/photos', - ], - ], - ], 200), - ]); - - $response = $this->get('/paddle/return?_ptxn=txn_456'); - - $response->assertRedirect('https://fotospiel-app.test/event-admin/mobile/events/slug/photos'); - } -} diff --git a/tests/Feature/PaddleWebhookControllerTest.php b/tests/Feature/PaddleWebhookControllerTest.php deleted file mode 100644 index 4ded54a..0000000 --- a/tests/Feature/PaddleWebhookControllerTest.php +++ /dev/null @@ -1,356 +0,0 @@ - 'test_secret']); - - [$tenant, $package, $session] = $this->prepareSession(); - - $payload = [ - 'event_id' => 'evt_123', - '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, - ], - ], - ]; - - $timestamp = time(); - $signature = hash_hmac('sha256', $timestamp.':'.json_encode($payload), 'test_secret'); - $header = sprintf('ts=%s,h1=%s', $timestamp, $signature); - - $response = $this->withHeader('Paddle-Signature', $header) - ->postJson('/paddle/webhook', $payload); - - $response->assertOk()->assertJson(['status' => 'processed']); - - $this->assertDatabaseHas('integration_webhook_events', [ - 'provider' => 'paddle', - 'event_id' => 'evt_123', - 'event_type' => 'transaction.completed', - 'status' => IntegrationWebhookEvent::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, - ], - ], - ]; - - $timestamp = time(); - $signature = hash_hmac('sha256', $timestamp.':'.json_encode($payload), 'test_secret'); - $header = sprintf('ts=%s,h1=%s', $timestamp, $signature); - - $first = $this->withHeader('Paddle-Signature', $header) - ->postJson('/paddle/webhook', $payload); - - $first->assertOk()->assertJson(['status' => 'processed']); - - $second = $this->withHeader('Paddle-Signature', $header) - ->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, - ], - ], - ]; - - $timestamp = time(); - $signature = hash_hmac('sha256', $timestamp.':'.json_encode($payload), 'test_secret'); - $header = sprintf('ts=%s,h1=%s', $timestamp, $signature); - - $response = $this->withHeader('Paddle-Signature', $header) - ->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-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]; - } -} diff --git a/tests/Feature/Profile/ProfilePageTest.php b/tests/Feature/Profile/ProfilePageTest.php index 1b2b88e..306e8d5 100644 --- a/tests/Feature/Profile/ProfilePageTest.php +++ b/tests/Feature/Profile/ProfilePageTest.php @@ -44,7 +44,7 @@ class ProfilePageTest extends TestCase 'package_id' => $package->id, 'price' => 199.00, 'type' => 'reseller_subscription', - 'provider' => 'paddle', + 'provider' => 'lemonsqueezy', 'purchased_at' => now()->subWeek(), ]); diff --git a/tests/Feature/PurchaseConfirmationMailTest.php b/tests/Feature/PurchaseConfirmationMailTest.php index dc876c7..7190817 100644 --- a/tests/Feature/PurchaseConfirmationMailTest.php +++ b/tests/Feature/PurchaseConfirmationMailTest.php @@ -36,12 +36,12 @@ class PurchaseConfirmationMailTest extends TestCase $purchase = PackagePurchase::factory()->create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, - 'provider' => 'paddle', - 'provider_id' => 'txn_123', + 'provider' => 'lemonsqueezy', + 'provider_id' => 'ord_123', 'price' => 59, 'metadata' => [ 'payload' => [ - 'invoice_url' => 'https://paddle.test/invoice/123', + 'receipt_url' => 'https://lemonsqueezy.test/receipt/123', ], 'currency' => 'EUR', ], @@ -52,7 +52,7 @@ class PurchaseConfirmationMailTest extends TestCase $this->assertStringContainsString('Die Fotospiel.App', $html); $this->assertStringContainsString('Classic', $html); - $this->assertStringContainsString('txn_123', $html); - $this->assertStringContainsString('https://paddle.test/invoice/123', $html); + $this->assertStringContainsString('ord_123', $html); + $this->assertStringContainsString('https://lemonsqueezy.test/receipt/123', $html); } } diff --git a/tests/Feature/PurchaseTest.php b/tests/Feature/PurchaseTest.php index b793761..6880544 100644 --- a/tests/Feature/PurchaseTest.php +++ b/tests/Feature/PurchaseTest.php @@ -5,7 +5,7 @@ namespace Tests\Feature; use App\Models\Package; use App\Models\Tenant; use App\Models\User; -use App\Services\Paddle\PaddleCheckoutService; +use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; use Tests\TestCase; @@ -20,12 +20,12 @@ class PurchaseTest extends TestCase parent::tearDown(); } - public function test_create_paddle_checkout_requires_paddle_price(): void + public function test_create_lemonsqueezy_checkout_requires_variant(): void { - [$tenant, $package] = $this->seedTenantWithPackage(includePaddlePrice: false); + [$tenant, $package] = $this->seedTenantWithPackage(includeVariant: false); $this->actingAs($tenant->user); - $response = $this->postJson('/paddle/create-checkout', [ + $response = $this->postJson('/lemonsqueezy/create-checkout', [ 'package_id' => $package->id, 'accepted_terms' => true, ]); @@ -34,12 +34,12 @@ class PurchaseTest extends TestCase ->assertJsonValidationErrors('package_id'); } - public function test_create_paddle_checkout_returns_checkout_url(): void + public function test_create_lemonsqueezy_checkout_returns_checkout_url(): void { - [$tenant, $package] = $this->seedTenantWithPackage(includePaddlePrice: true); + [$tenant, $package] = $this->seedTenantWithPackage(includeVariant: true); $this->actingAs($tenant->user); - $service = Mockery::mock(PaddleCheckoutService::class); + $service = Mockery::mock(LemonSqueezyCheckoutService::class); $service->shouldReceive('createCheckout') ->once() ->with( @@ -52,55 +52,41 @@ class PurchaseTest extends TestCase }) ) ->andReturn([ - 'checkout_url' => 'https://paddle.test/checkout/abc', + 'checkout_url' => 'https://checkout.lemonsqueezy.test/checkout/abc', ]); - $this->app->instance(PaddleCheckoutService::class, $service); + $this->app->instance(LemonSqueezyCheckoutService::class, $service); - $response = $this->postJson('/paddle/create-checkout', [ + $response = $this->postJson('/lemonsqueezy/create-checkout', [ 'package_id' => $package->id, 'accepted_terms' => true, ]); $response->assertOk() ->assertJson([ - 'checkout_url' => 'https://paddle.test/checkout/abc', + 'checkout_url' => 'https://checkout.lemonsqueezy.test/checkout/abc', ]); } - public function test_create_paddle_checkout_inline_returns_items(): void + public function test_create_lemonsqueezy_checkout_requires_terms(): void { - [$tenant, $package] = $this->seedTenantWithPackage(includePaddlePrice: true); + [$tenant, $package] = $this->seedTenantWithPackage(includeVariant: true); $this->actingAs($tenant->user); - $service = Mockery::mock(PaddleCheckoutService::class); + $service = Mockery::mock(LemonSqueezyCheckoutService::class); $service->shouldNotReceive('createCheckout'); - $this->app->instance(PaddleCheckoutService::class, $service); + $this->app->instance(LemonSqueezyCheckoutService::class, $service); - $response = $this->postJson('/paddle/create-checkout', [ + $response = $this->postJson('/lemonsqueezy/create-checkout', [ 'package_id' => $package->id, - 'inline' => true, - 'accepted_terms' => true, + 'accepted_terms' => false, ]); - $response->assertOk() - ->assertJson([ - 'mode' => 'inline', - ]) - ->assertJsonStructure([ - 'mode', - 'items' => [ - ['priceId', 'quantity'], - ], - 'custom_data' => ['tenant_id', 'package_id', 'checkout_session_id'], - ]); - - $payload = $response->json(); - $this->assertSame($package->paddle_price_id, $payload['items'][0]['priceId']); - $this->assertSame(1, $payload['items'][0]['quantity']); + $response->assertStatus(422) + ->assertJsonValidationErrors('accepted_terms'); } - private function seedTenantWithPackage(int $price = 10, string $type = 'endcustomer', bool $includePaddlePrice = true): array + private function seedTenantWithPackage(int $price = 10, string $type = 'endcustomer', bool $includeVariant = true): array { $user = User::factory()->create(['email_verified_at' => now()]); $tenant = Tenant::factory()->create(['user_id' => $user->id]); @@ -110,7 +96,7 @@ class PurchaseTest extends TestCase $package = Package::factory()->create([ 'price' => $price, 'type' => $type, - 'paddle_price_id' => $includePaddlePrice ? 'price_123' : null, + 'lemonsqueezy_variant_id' => $includeVariant ? 'variant_123' : null, ]); return [$tenant, $package]; diff --git a/tests/Feature/SuperAdminAuditLogMutationTest.php b/tests/Feature/SuperAdminAuditLogMutationTest.php index 57209f6..14ac135 100644 --- a/tests/Feature/SuperAdminAuditLogMutationTest.php +++ b/tests/Feature/SuperAdminAuditLogMutationTest.php @@ -54,7 +54,7 @@ class SuperAdminAuditLogMutationTest extends TestCase $this->bootSuperAdminPanel($user); $this->mock(GiftVoucherService::class, function ($mock) use ($voucher): void { - $mock->shouldReceive('issueFromPaddle') + $mock->shouldReceive('issueFromLemonSqueezy') ->once() ->andReturn($voucher); }); diff --git a/tests/Feature/SyncPackageToPaddleJobTest.php b/tests/Feature/SyncPackageToLemonSqueezyJobTest.php similarity index 55% rename from tests/Feature/SyncPackageToPaddleJobTest.php rename to tests/Feature/SyncPackageToLemonSqueezyJobTest.php index ab755fa..bddd100 100644 --- a/tests/Feature/SyncPackageToPaddleJobTest.php +++ b/tests/Feature/SyncPackageToLemonSqueezyJobTest.php @@ -2,14 +2,14 @@ namespace Tests\Feature; -use App\Jobs\SyncPackageToPaddle; +use App\Jobs\SyncPackageToLemonSqueezy; use App\Models\Package; -use App\Services\Paddle\PaddleCatalogService; +use App\Services\LemonSqueezy\LemonSqueezyCatalogService; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; use Tests\TestCase; -class SyncPackageToPaddleJobTest extends TestCase +class SyncPackageToLemonSqueezyJobTest extends TestCase { use RefreshDatabase; @@ -23,13 +23,13 @@ class SyncPackageToPaddleJobTest extends TestCase public function test_job_creates_product_and_price_and_updates_package(): void { $package = Package::factory()->create([ - 'paddle_product_id' => null, - 'paddle_price_id' => null, + 'lemonsqueezy_product_id' => null, + 'lemonsqueezy_variant_id' => null, 'price' => 15.50, 'slug' => 'silver-plan', ]); - $service = Mockery::mock(PaddleCatalogService::class); + $service = Mockery::mock(LemonSqueezyCatalogService::class); $service->shouldReceive('createProduct') ->once() ->withArgs(function ($pkg, $overrides) use ($package) { @@ -47,37 +47,37 @@ class SyncPackageToPaddleJobTest extends TestCase $service->shouldReceive('buildPricePayload') ->andReturn(['payload' => 'price']); - $job = new SyncPackageToPaddle($package->id); + $job = new SyncPackageToLemonSqueezy($package->id); $job->handle($service); $package->refresh(); - $this->assertSame('pro_123', $package->paddle_product_id); - $this->assertSame('pri_123', $package->paddle_price_id); - $this->assertSame('synced', $package->paddle_sync_status); - $this->assertNotNull($package->paddle_synced_at); - $this->assertSame(['payload' => 'product'], $package->paddle_snapshot['payload']['product']); - $this->assertSame(['payload' => 'price'], $package->paddle_snapshot['payload']['price']); + $this->assertSame('pro_123', $package->lemonsqueezy_product_id); + $this->assertSame('pri_123', $package->lemonsqueezy_variant_id); + $this->assertSame('synced', $package->lemonsqueezy_sync_status); + $this->assertNotNull($package->lemonsqueezy_synced_at); + $this->assertSame(['payload' => 'product'], $package->lemonsqueezy_snapshot['payload']['product']); + $this->assertSame(['payload' => 'price'], $package->lemonsqueezy_snapshot['payload']['price']); } - public function test_dry_run_stores_snapshot_without_calling_paddle(): void + public function test_dry_run_stores_snapshot_without_calling_lemonsqueezy(): void { $package = Package::factory()->create([ 'slug' => 'gold-plan', ]); - $service = Mockery::mock(PaddleCatalogService::class); + $service = Mockery::mock(LemonSqueezyCatalogService::class); $service->shouldReceive('buildProductPayload')->andReturn(['payload' => 'product']); $service->shouldReceive('buildPricePayload')->andReturn(['payload' => 'price']); - $job = new SyncPackageToPaddle($package->id, ['dry_run' => true]); + $job = new SyncPackageToLemonSqueezy($package->id, ['dry_run' => true]); $job->handle($service); $package->refresh(); - $this->assertSame('dry-run', $package->paddle_sync_status); - $this->assertTrue($package->paddle_snapshot['dry_run']); - $this->assertSame(['payload' => 'product'], $package->paddle_snapshot['payload']['product']); - $this->assertSame(['payload' => 'price'], $package->paddle_snapshot['payload']['price']); + $this->assertSame('dry-run', $package->lemonsqueezy_sync_status); + $this->assertTrue($package->lemonsqueezy_snapshot['dry_run']); + $this->assertSame(['payload' => 'product'], $package->lemonsqueezy_snapshot['payload']['product']); + $this->assertSame(['payload' => 'price'], $package->lemonsqueezy_snapshot['payload']['price']); } } diff --git a/tests/Feature/Tenant/EventAddonCheckoutTest.php b/tests/Feature/Tenant/EventAddonCheckoutTest.php index 95a2911..5ccfeed 100644 --- a/tests/Feature/Tenant/EventAddonCheckoutTest.php +++ b/tests/Feature/Tenant/EventAddonCheckoutTest.php @@ -17,26 +17,21 @@ class EventAddonCheckoutTest extends TenantTestCase Config::set('package-addons.extra_photos_small', [ 'label' => 'Extra photos (500)', - 'price_id' => 'pri_addon_photos', + 'variant_id' => 'var_addon_photos', 'increments' => ['extra_photos' => 500], ]); - Config::set('paddle.api_key', 'test_key'); - Config::set('paddle.base_url', 'https://paddle.test'); - Config::set('paddle.environment', 'sandbox'); + Config::set('lemonsqueezy.api_key', 'test_key'); + Config::set('lemonsqueezy.base_url', 'https://lemonsqueezy.test'); + Config::set('lemonsqueezy.store_id', 'store_123'); - // Fake Paddle response + // Fake Lemon Squeezy response Http::fake([ - '*/customers' => Http::response([ + 'https://lemonsqueezy.test/checkouts' => Http::response([ 'data' => [ - 'id' => 'ctm_addon_123', - ], - ], 200), - '*/transactions' => Http::response([ - 'data' => [ - 'id' => 'txn_addon_123', - 'checkout' => [ - 'url' => 'https://checkout.paddle.test/abcd', + 'id' => 'chk_addon_123', + 'attributes' => [ + 'url' => 'https://checkout.lemonsqueezy.test/abcd', ], ], ], 200), @@ -73,14 +68,14 @@ class EventAddonCheckoutTest extends TenantTestCase ]); $response->assertOk(); - $response->assertJsonPath('checkout_id', 'txn_addon_123'); + $response->assertJsonPath('checkout_id', 'chk_addon_123'); $this->assertDatabaseHas('event_package_addons', [ 'event_package_id' => $eventPackage->id, 'addon_key' => 'extra_photos_small', 'status' => 'pending', 'quantity' => 2, - 'transaction_id' => 'txn_addon_123', + 'checkout_id' => 'chk_addon_123', ]); $addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first(); diff --git a/tests/Feature/Tenant/TenantBillingPortalTest.php b/tests/Feature/Tenant/TenantBillingPortalTest.php index 1532c03..ac94e32 100644 --- a/tests/Feature/Tenant/TenantBillingPortalTest.php +++ b/tests/Feature/Tenant/TenantBillingPortalTest.php @@ -2,9 +2,10 @@ namespace Tests\Feature\Tenant; -use App\Services\Paddle\Exceptions\PaddleException; -use App\Services\Paddle\PaddleCustomerPortalService; -use App\Services\Paddle\PaddleCustomerService; +use App\Models\Package; +use App\Models\TenantPackage; +use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException; +use App\Services\LemonSqueezy\LemonSqueezySubscriptionService; use Illuminate\Support\Facades\Log; use Mockery; @@ -17,7 +18,7 @@ class TenantBillingPortalTest extends TenantTestCase parent::tearDown(); } - public function test_portal_logs_paddle_error_context(): void + public function test_portal_logs_lemonsqueezy_error_context(): void { $logged = []; Log::listen(function ($event) use (&$logged): void { @@ -28,39 +29,44 @@ class TenantBillingPortalTest extends TenantTestCase ]; }); - $customerService = Mockery::mock(PaddleCustomerService::class); - $customerService->shouldReceive('ensureCustomerId') - ->once() - ->withArgs(fn ($tenant) => $tenant->is($this->tenant)) - ->andReturn('ctm_test_123'); - $this->instance(PaddleCustomerService::class, $customerService); + $package = Package::factory()->reseller()->create(); + TenantPackage::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'package_id' => $package->id, + 'lemonsqueezy_subscription_id' => 'sub_123', + 'active' => true, + ]); - $portalService = Mockery::mock(PaddleCustomerPortalService::class); - $portalService->shouldReceive('createSession') + $subscriptions = Mockery::mock(LemonSqueezySubscriptionService::class); + $subscriptions->shouldReceive('retrieve') ->once() - ->with('ctm_test_123') - ->andThrow(new PaddleException('Paddle request failed with status 404', 404, [ - 'error' => [ - 'code' => 'entity_not_found', - 'message' => 'Not found', + ->with('sub_123') + ->andThrow(new LemonSqueezyException('Not found', 404, [ + 'errors' => [ + [ + 'code' => 'entity_not_found', + 'detail' => 'Not found', + ], + ], + 'meta' => [ + 'request_id' => 'req_123', ], ])); - $this->instance(PaddleCustomerPortalService::class, $portalService); + $this->instance(LemonSqueezySubscriptionService::class, $subscriptions); $response = $this->authenticatedRequest('POST', '/api/v1/tenant/billing/portal'); $response->assertStatus(502) ->assertJson([ - 'message' => 'Failed to create Paddle customer portal session.', + 'message' => 'Failed to fetch Lemon Squeezy subscription portal URL.', ]); $matched = collect($logged)->contains(function (array $entry): bool { return $entry['level'] === 'warning' - && $entry['message'] === 'Failed to create Paddle customer portal session' + && $entry['message'] === 'Failed to fetch Lemon Squeezy subscription portal URL' && ($entry['context']['tenant_id'] ?? null) === $this->tenant->id - && ($entry['context']['paddle_customer_id'] ?? null) === 'ctm_test_123' - && ($entry['context']['paddle_status'] ?? null) === 404 - && ($entry['context']['paddle_error_code'] ?? null) === 'entity_not_found'; + && ($entry['context']['lemonsqueezy_status'] ?? null) === 404 + && (($entry['context']['lemonsqueezy_error']['code'] ?? null) === 'entity_not_found'); }); $this->assertTrue($matched); diff --git a/tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php b/tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php index 77e38de..d590244 100644 --- a/tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php +++ b/tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php @@ -20,14 +20,14 @@ class TenantCheckoutSessionStatusTest extends TenantTestCase 'tenant_id' => $this->tenant->id, 'package_id' => $package->id, 'status' => CheckoutSession::STATUS_FAILED, - 'provider' => CheckoutSession::PROVIDER_PADDLE, + 'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY, 'provider_metadata' => [ - 'paddle_checkout_url' => 'https://checkout.paddle.test/checkout/123', + 'lemonsqueezy_checkout_url' => 'https://checkout.lemonsqueezy.test/checkout/123', ], 'status_history' => [ [ 'status' => CheckoutSession::STATUS_FAILED, - 'reason' => 'paddle_failed', + 'reason' => 'lemonsqueezy_failed', 'at' => now()->toIso8601String(), ], ], @@ -40,7 +40,7 @@ class TenantCheckoutSessionStatusTest extends TenantTestCase $response->assertOk() ->assertJsonPath('status', CheckoutSession::STATUS_FAILED) - ->assertJsonPath('reason', 'paddle_failed') - ->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123'); + ->assertJsonPath('reason', 'lemonsqueezy_failed') + ->assertJsonPath('checkout_url', 'https://checkout.lemonsqueezy.test/checkout/123'); } } diff --git a/tests/Feature/Tenant/TenantPaddleCheckoutTest.php b/tests/Feature/Tenant/TenantLemonSqueezyCheckoutTest.php similarity index 66% rename from tests/Feature/Tenant/TenantPaddleCheckoutTest.php rename to tests/Feature/Tenant/TenantLemonSqueezyCheckoutTest.php index 76fc651..61d49e8 100644 --- a/tests/Feature/Tenant/TenantPaddleCheckoutTest.php +++ b/tests/Feature/Tenant/TenantLemonSqueezyCheckoutTest.php @@ -3,10 +3,10 @@ namespace Tests\Feature\Tenant; use App\Models\Package; -use App\Services\Paddle\PaddleCheckoutService; +use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; use Mockery; -class TenantPaddleCheckoutTest extends TenantTestCase +class TenantLemonSqueezyCheckoutTest extends TenantTestCase { protected function tearDown(): void { @@ -15,14 +15,14 @@ class TenantPaddleCheckoutTest extends TenantTestCase parent::tearDown(); } - public function test_tenant_can_create_paddle_checkout(): void + public function test_tenant_can_create_lemonsqueezy_checkout(): void { $package = Package::factory()->create([ - 'paddle_price_id' => 'pri_test_123', + 'lemonsqueezy_variant_id' => 'pri_test_123', 'price' => 129, ]); - $checkoutService = Mockery::mock(PaddleCheckoutService::class); + $checkoutService = Mockery::mock(LemonSqueezyCheckoutService::class); $checkoutService->shouldReceive('createCheckout') ->once() ->withArgs(function ($tenant, $payloadPackage, array $payload) use ($package) { @@ -35,28 +35,28 @@ class TenantPaddleCheckoutTest extends TenantTestCase && ! empty($payload['metadata']['checkout_session_id']); }) ->andReturn([ - 'checkout_url' => 'https://checkout.paddle.test/checkout/123', + 'checkout_url' => 'https://checkout.lemonsqueezy.test/checkout/123', 'id' => 'chk_test_123', ]); - $this->instance(PaddleCheckoutService::class, $checkoutService); + $this->instance(LemonSqueezyCheckoutService::class, $checkoutService); - $response = $this->authenticatedRequest('POST', '/api/v1/tenant/packages/paddle-checkout', [ + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/packages/lemonsqueezy-checkout', [ 'package_id' => $package->id, ]); $response->assertOk() - ->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123') + ->assertJsonPath('checkout_url', 'https://checkout.lemonsqueezy.test/checkout/123') ->assertJsonStructure(['checkout_session_id']); } - public function test_paddle_checkout_requires_paddle_price_id(): void + public function test_lemonsqueezy_checkout_requires_lemonsqueezy_variant_id(): void { $package = Package::factory()->create([ - 'paddle_price_id' => null, + 'lemonsqueezy_variant_id' => null, 'price' => 129, ]); - $response = $this->authenticatedRequest('POST', '/api/v1/tenant/packages/paddle-checkout', [ + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/packages/lemonsqueezy-checkout', [ 'package_id' => $package->id, ]); diff --git a/tests/Unit/CouponRedemptionServiceTest.php b/tests/Unit/CouponRedemptionServiceTest.php index f8bb6c0..4c6423f 100644 --- a/tests/Unit/CouponRedemptionServiceTest.php +++ b/tests/Unit/CouponRedemptionServiceTest.php @@ -29,7 +29,7 @@ class CouponRedemptionServiceTest extends TestCase ]); $coupon = Coupon::factory()->create([ 'code' => 'SAVE10', - 'paddle_discount_id' => 'dsc_123', + 'lemonsqueezy_discount_id' => 'dsc_123', ]); $coupon->packages()->attach($package); diff --git a/tests/Unit/GiftVoucherCheckoutServiceTest.php b/tests/Unit/GiftVoucherCheckoutServiceTest.php index 39d8f67..b614bfc 100644 --- a/tests/Unit/GiftVoucherCheckoutServiceTest.php +++ b/tests/Unit/GiftVoucherCheckoutServiceTest.php @@ -3,7 +3,7 @@ namespace Tests\Unit; use App\Services\GiftVouchers\GiftVoucherCheckoutService; -use App\Services\Paddle\PaddleClient; +use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; use Tests\TestCase; @@ -15,8 +15,8 @@ class GiftVoucherCheckoutServiceTest extends TestCase public function test_it_lists_tiers_with_checkout_flag(): void { config()->set('gift-vouchers.tiers', [ - ['key' => 'gift-a', 'label' => 'A', 'amount' => 10, 'currency' => 'EUR', 'paddle_price_id' => 'pri_a'], - ['key' => 'gift-b', 'label' => 'B', 'amount' => 20, 'currency' => 'EUR', 'paddle_price_id' => null], + ['key' => 'gift-a', 'label' => 'A', 'amount' => 10, 'currency' => 'EUR', 'lemonsqueezy_variant_id' => 'pri_a'], + ['key' => 'gift-b', 'label' => 'B', 'amount' => 20, 'currency' => 'EUR', 'lemonsqueezy_variant_id' => null], ]); $service = $this->app->make(GiftVoucherCheckoutService::class); @@ -31,26 +31,23 @@ class GiftVoucherCheckoutServiceTest extends TestCase public function test_it_creates_checkout_link_with_metadata(): void { config()->set('gift-vouchers.tiers', [ - ['key' => 'gift-a', 'label' => 'A', 'amount' => 10, 'currency' => 'EUR', 'paddle_price_id' => 'pri_a'], + ['key' => 'gift-a', 'label' => 'A', 'amount' => 10, 'currency' => 'EUR', 'lemonsqueezy_variant_id' => 'pri_a'], ]); - $client = Mockery::mock(PaddleClient::class); - $client->shouldReceive('post') + $checkoutService = Mockery::mock(LemonSqueezyCheckoutService::class); + $checkoutService->shouldReceive('createVariantCheckout') ->once() - ->with('/customers', Mockery::on(function ($payload) { - return $payload['email'] === 'buyer@example.com'; - })) - ->andReturn(['data' => ['id' => 'ctm_123']]); - $client->shouldReceive('post') - ->once() - ->with('/transactions', Mockery::on(function ($payload) { - return $payload['items'][0]['price_id'] === 'pri_a' - && $payload['customer_id'] === 'ctm_123' - && $payload['custom_data']['type'] === 'gift_voucher'; - })) - ->andReturn(['data' => ['checkout' => ['url' => 'https://paddle.test/checkout/123'], 'id' => 'txn_123']]); + ->with('pri_a', Mockery::on(function (array $customData) { + return ($customData['type'] ?? null) === 'gift_voucher' + && ($customData['tier_key'] ?? null) === 'gift-a' + && ($customData['purchaser_email'] ?? null) === 'buyer@example.com' + && ($customData['recipient_email'] ?? null) === 'friend@example.com' + && ($customData['recipient_name'] ?? null) === 'Friend' + && ($customData['message'] ?? null) === 'Hi'; + }), Mockery::type('array')) + ->andReturn(['checkout_url' => 'https://lemonsqueezy.test/checkout/123', 'id' => 'chk_123']); - $this->app->instance(PaddleClient::class, $client); + $this->app->instance(LemonSqueezyCheckoutService::class, $checkoutService); $service = $this->app->make(GiftVoucherCheckoutService::class); @@ -62,7 +59,7 @@ class GiftVoucherCheckoutServiceTest extends TestCase 'message' => 'Hi', ]); - $this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']); - $this->assertSame('txn_123', $checkout['id']); + $this->assertSame('https://lemonsqueezy.test/checkout/123', $checkout['checkout_url']); + $this->assertSame('chk_123', $checkout['id']); } } diff --git a/tests/Unit/GiftVoucherServiceTest.php b/tests/Unit/GiftVoucherServiceTest.php index df4207f..b2091c9 100644 --- a/tests/Unit/GiftVoucherServiceTest.php +++ b/tests/Unit/GiftVoucherServiceTest.php @@ -3,14 +3,12 @@ namespace Tests\Unit; use App\Enums\CouponType; -use App\Jobs\SyncCouponToPaddle; use App\Mail\GiftVoucherIssued; use App\Models\Coupon; use App\Models\GiftVoucher; use App\Models\Package; use App\Services\GiftVouchers\GiftVoucherService; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Mail; use Tests\TestCase; @@ -18,37 +16,38 @@ class GiftVoucherServiceTest extends TestCase { use RefreshDatabase; - public function test_it_issues_voucher_and_coupon_from_paddle_payload(): void + public function test_it_issues_voucher_and_coupon_from_lemonsqueezy_payload(): void { $package = Package::factory()->create([ 'type' => 'endcustomer', - 'paddle_price_id' => 'pri_pkg_001', + 'lemonsqueezy_variant_id' => 'pri_pkg_001', 'price' => 59, ]); $payload = [ - 'id' => 'txn_123', - 'event_type' => 'transaction.completed', - 'currency_code' => 'EUR', - 'totals' => [ - 'grand_total' => [ - 'amount' => 5900, + 'data' => [ + 'id' => 'ord_123', + 'attributes' => [ + 'total' => 5900, + 'currency' => 'EUR', + 'checkout_id' => 'chk_abc', + 'variant_id' => 'pri_pkg_001', + 'user_email' => 'buyer@example.com', ], ], - 'custom_data' => [ - 'type' => 'gift_card', - 'purchaser_email' => 'buyer@example.com', - 'recipient_email' => 'friend@example.com', - 'recipient_name' => 'Friend', - 'message' => 'Happy Day', + 'meta' => [ + 'custom_data' => [ + 'type' => 'gift_card', + 'purchaser_email' => 'buyer@example.com', + 'recipient_email' => 'friend@example.com', + 'recipient_name' => 'Friend', + 'message' => 'Happy Day', + ], ], - 'checkout_id' => 'chk_abc', ]; - Bus::fake([SyncCouponToPaddle::class]); - $service = $this->app->make(GiftVoucherService::class); - $voucher = $service->issueFromPaddle($payload); + $voucher = $service->issueFromLemonSqueezy($payload); $this->assertInstanceOf(GiftVoucher::class, $voucher); $this->assertSame(59.00, (float) $voucher->amount); @@ -56,7 +55,6 @@ class GiftVoucherServiceTest extends TestCase $this->assertSame($voucher->code, $voucher->coupon->code); $this->assertTrue($voucher->expires_at->greaterThan(now()->addYears(4))); $this->assertTrue($voucher->coupon->packages()->whereKey($package->id)->exists()); - Bus::assertDispatched(SyncCouponToPaddle::class); } public function test_redeeming_coupon_marks_voucher_redeemed(): void @@ -71,7 +69,7 @@ class GiftVoucherServiceTest extends TestCase 'type' => CouponType::FLAT, 'amount' => 29, 'currency' => 'EUR', - 'paddle_discount_id' => null, + 'lemonsqueezy_discount_id' => null, ]); $voucher->coupon()->associate($coupon)->save(); @@ -86,45 +84,48 @@ class GiftVoucherServiceTest extends TestCase public function test_it_sends_notifications_to_purchaser_and_recipient_once(): void { Mail::fake(); - Bus::fake([SyncCouponToPaddle::class]); config()->set('gift-vouchers.reminder_days', 0); config()->set('gift-vouchers.expiry_reminder_days', 0); Package::factory()->create([ 'type' => 'endcustomer', - 'paddle_price_id' => 'pri_pkg_001', + 'lemonsqueezy_variant_id' => 'pri_pkg_001', 'price' => 29, ]); $payload = [ - 'id' => 'txn_456', - 'currency_code' => 'EUR', - 'totals' => [ - 'grand_total' => [ - 'amount' => 2900, + 'data' => [ + 'id' => 'ord_456', + 'attributes' => [ + 'total' => 2900, + 'currency' => 'EUR', + 'checkout_id' => 'chk_notif', + 'variant_id' => 'pri_pkg_001', + 'user_email' => 'buyer@example.com', ], ], - 'custom_data' => [ - 'type' => 'gift_voucher', - 'purchaser_email' => 'buyer@example.com', - 'recipient_email' => 'friend@example.com', - 'app_locale' => 'de', + 'meta' => [ + 'custom_data' => [ + 'type' => 'gift_voucher', + 'purchaser_email' => 'buyer@example.com', + 'recipient_email' => 'friend@example.com', + 'app_locale' => 'de', + ], ], - 'checkout_id' => 'chk_notif', ]; $service = $this->app->make(GiftVoucherService::class); - $voucher = $service->issueFromPaddle($payload); + $voucher = $service->issueFromLemonSqueezy($payload); Mail::assertQueued(GiftVoucherIssued::class, 2); $this->assertTrue((bool) ($voucher->metadata['notifications_sent'] ?? false)); // Second call (duplicate webhook) should not resend - $service->issueFromPaddle($payload); + $service->issueFromLemonSqueezy($payload); Mail::assertQueued(GiftVoucherIssued::class, 2); } - public function test_it_resolves_amount_from_tier_by_price_id(): void + public function test_it_resolves_amount_from_tier_by_variant_id(): void { config()->set('gift-vouchers.tiers', [ [ @@ -132,21 +133,25 @@ class GiftVoucherServiceTest extends TestCase 'label' => 'Gift Classic (USD)', 'amount' => 65.00, 'currency' => 'USD', - 'paddle_price_id' => 'pri_usd_123', + 'lemonsqueezy_variant_id' => 'pri_usd_123', ], ]); - Bus::fake([SyncCouponToPaddle::class]); Mail::fake(); $payload = [ - 'id' => 'txn_usd', - 'price_id' => 'pri_usd_123', - 'currency_code' => 'USD', + 'data' => [ + 'id' => 'ord_usd', + 'attributes' => [ + 'variant_id' => 'pri_usd_123', + 'currency' => 'USD', + 'total' => 6500, + ], + ], ]; $service = $this->app->make(GiftVoucherService::class); - $voucher = $service->issueFromPaddle($payload); + $voucher = $service->issueFromLemonSqueezy($payload); $this->assertSame(65.00, (float) $voucher->amount); $this->assertSame('USD', $voucher->currency); diff --git a/tests/Unit/Jobs/SyncPackageAddonToPaddleTest.php b/tests/Unit/Jobs/SyncPackageAddonToLemonSqueezyTest.php similarity index 58% rename from tests/Unit/Jobs/SyncPackageAddonToPaddleTest.php rename to tests/Unit/Jobs/SyncPackageAddonToLemonSqueezyTest.php index 8bc30fd..baebaed 100644 --- a/tests/Unit/Jobs/SyncPackageAddonToPaddleTest.php +++ b/tests/Unit/Jobs/SyncPackageAddonToLemonSqueezyTest.php @@ -2,14 +2,14 @@ namespace Tests\Unit\Jobs; -use App\Jobs\SyncPackageAddonToPaddle; +use App\Jobs\SyncPackageAddonToLemonSqueezy; use App\Models\PackageAddon; -use App\Services\Paddle\PaddleAddonCatalogService; +use App\Services\LemonSqueezy\LemonSqueezyAddonCatalogService; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; use Tests\TestCase; -class SyncPackageAddonToPaddleTest extends TestCase +class SyncPackageAddonToLemonSqueezyTest extends TestCase { use RefreshDatabase; @@ -22,7 +22,7 @@ class SyncPackageAddonToPaddleTest extends TestCase 'metadata' => ['price_eur' => 5], ]); - $service = Mockery::mock(PaddleAddonCatalogService::class); + $service = Mockery::mock(LemonSqueezyAddonCatalogService::class); $service->shouldReceive('createProduct') ->once() ->andReturn(['id' => 'pro_addon_1']); @@ -30,13 +30,13 @@ class SyncPackageAddonToPaddleTest extends TestCase ->once() ->andReturn(['id' => 'pri_addon_1']); - $job = new SyncPackageAddonToPaddle($addon->id); + $job = new SyncPackageAddonToLemonSqueezy($addon->id); $job->handle($service); $addon->refresh(); - $this->assertSame('pri_addon_1', $addon->price_id); - $this->assertEquals('pro_addon_1', $addon->metadata['paddle_product_id']); - $this->assertEquals('synced', $addon->metadata['paddle_sync_status']); + $this->assertSame('pri_addon_1', $addon->variant_id); + $this->assertEquals('pro_addon_1', $addon->metadata['lemonsqueezy_product_id']); + $this->assertEquals('synced', $addon->metadata['lemonsqueezy_sync_status']); } } diff --git a/tests/Unit/PaddleCatalogServiceTest.php b/tests/Unit/LemonSqueezyCatalogServiceTest.php similarity index 87% rename from tests/Unit/PaddleCatalogServiceTest.php rename to tests/Unit/LemonSqueezyCatalogServiceTest.php index c1633ef..b430164 100644 --- a/tests/Unit/PaddleCatalogServiceTest.php +++ b/tests/Unit/LemonSqueezyCatalogServiceTest.php @@ -3,13 +3,13 @@ namespace Tests\Unit; use App\Models\Package; -use App\Services\Paddle\PaddleCatalogService; -use App\Services\Paddle\PaddleClient; +use App\Services\LemonSqueezy\LemonSqueezyCatalogService; +use App\Services\LemonSqueezy\LemonSqueezyClient; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; use Tests\TestCase; -class PaddleCatalogServiceTest extends TestCase +class LemonSqueezyCatalogServiceTest extends TestCase { use RefreshDatabase; @@ -40,7 +40,7 @@ class PaddleCatalogServiceTest extends TestCase ], ]); - $service = new PaddleCatalogService(Mockery::mock(PaddleClient::class)); + $service = new LemonSqueezyCatalogService(Mockery::mock(LemonSqueezyClient::class)); $payload = $service->buildProductPayload($package); @@ -63,7 +63,7 @@ class PaddleCatalogServiceTest extends TestCase 'name' => 'Silver Plan', ]); - $service = new PaddleCatalogService(Mockery::mock(PaddleClient::class)); + $service = new LemonSqueezyCatalogService(Mockery::mock(LemonSqueezyClient::class)); $payload = $service->buildPricePayload($package, 'pro_123'); diff --git a/tests/Unit/LemonSqueezyCheckoutServiceTest.php b/tests/Unit/LemonSqueezyCheckoutServiceTest.php new file mode 100644 index 0000000..752268b --- /dev/null +++ b/tests/Unit/LemonSqueezyCheckoutServiceTest.php @@ -0,0 +1,61 @@ +create([ + 'contact_email' => 'buyer@example.com', + ]); + + $package = Package::factory()->create([ + 'lemonsqueezy_variant_id' => 'pri_123', + ]); + + config()->set('lemonsqueezy.store_id', 'store_123'); + + $client = Mockery::mock(LemonSqueezyClient::class); + $client->shouldReceive('post') + ->once() + ->with('/checkouts', Mockery::on(function (array $payload) use ($tenant, $package) { + $data = $payload['data'] ?? []; + $attributes = $data['attributes'] ?? []; + $custom = $attributes['checkout_data']['custom'] ?? []; + + return ($data['type'] ?? null) === 'checkouts' + && ($data['relationships']['variant']['data']['id'] ?? null) === 'pri_123' + && ($data['relationships']['store']['data']['id'] ?? null) === 'store_123' + && ($custom['tenant_id'] ?? null) === (string) $tenant->id + && ($custom['package_id'] ?? null) === (string) $package->id + && ($custom['source'] ?? null) === 'test' + && ($custom['success_url'] ?? null) === 'https://example.test/success' + && ($custom['return_url'] ?? null) === 'https://example.test/cancel'; + })) + ->andReturn(['data' => ['attributes' => ['url' => 'https://lemonsqueezy.test/checkout/123'], 'id' => 'chk_123']]); + + $this->app->instance(LemonSqueezyClient::class, $client); + + $service = $this->app->make(LemonSqueezyCheckoutService::class); + + $checkout = $service->createCheckout($tenant, $package, [ + 'success_url' => 'https://example.test/success', + 'return_url' => 'https://example.test/cancel', + 'metadata' => ['source' => 'test'], + ]); + + $this->assertSame('https://lemonsqueezy.test/checkout/123', $checkout['checkout_url']); + $this->assertSame('chk_123', $checkout['id']); + } +} diff --git a/tests/Unit/PaddleDiscountServiceTest.php b/tests/Unit/LemonSqueezyDiscountServiceTest.php similarity index 79% rename from tests/Unit/PaddleDiscountServiceTest.php rename to tests/Unit/LemonSqueezyDiscountServiceTest.php index a473b2a..2522acd 100644 --- a/tests/Unit/PaddleDiscountServiceTest.php +++ b/tests/Unit/LemonSqueezyDiscountServiceTest.php @@ -4,13 +4,13 @@ namespace Tests\Unit; use App\Enums\CouponType; use App\Models\Coupon; -use App\Services\Paddle\PaddleClient; -use App\Services\Paddle\PaddleDiscountService; +use App\Services\LemonSqueezy\LemonSqueezyClient; +use App\Services\LemonSqueezy\LemonSqueezyDiscountService; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; use Tests\TestCase; -class PaddleDiscountServiceTest extends TestCase +class LemonSqueezyDiscountServiceTest extends TestCase { use RefreshDatabase; @@ -32,7 +32,7 @@ class PaddleDiscountServiceTest extends TestCase 'amount' => 10, ]); - $service = new TestablePaddleDiscountService(Mockery::mock(PaddleClient::class)); + $service = new TestableLemonSqueezyDiscountService(Mockery::mock(LemonSqueezyClient::class)); $payload = $service->buildPayload($coupon); @@ -50,7 +50,7 @@ class PaddleDiscountServiceTest extends TestCase 'description' => 'Flat discount', ]); - $service = new TestablePaddleDiscountService(Mockery::mock(PaddleClient::class)); + $service = new TestableLemonSqueezyDiscountService(Mockery::mock(LemonSqueezyClient::class)); $payload = $service->buildPayload($coupon); @@ -67,7 +67,7 @@ class PaddleDiscountServiceTest extends TestCase 'description' => 'Percent discount', ]); - $service = new TestablePaddleDiscountService(Mockery::mock(PaddleClient::class)); + $service = new TestableLemonSqueezyDiscountService(Mockery::mock(LemonSqueezyClient::class)); $payload = $service->buildPayload($coupon); @@ -76,7 +76,7 @@ class PaddleDiscountServiceTest extends TestCase } } -class TestablePaddleDiscountService extends PaddleDiscountService +class TestableLemonSqueezyDiscountService extends LemonSqueezyDiscountService { /** * @return array diff --git a/tests/Unit/LemonSqueezyOrderServiceTest.php b/tests/Unit/LemonSqueezyOrderServiceTest.php new file mode 100644 index 0000000..1c3043b --- /dev/null +++ b/tests/Unit/LemonSqueezyOrderServiceTest.php @@ -0,0 +1,52 @@ +shouldReceive('get') + ->once() + ->with('/orders', Mockery::on(function (array $payload) { + return $payload['filter[customer_id]'] === 'ctm_123' + && $payload['sort'] === '-created_at'; + })) + ->andReturn(['data' => [], 'meta' => []]); + + $this->app->instance(LemonSqueezyClient::class, $client); + + $service = $this->app->make(LemonSqueezyOrderService::class); + $service->listForCustomer('ctm_123'); + + $this->assertTrue(true); + } + + public function test_find_by_checkout_id_uses_checkout_then_order_lookup(): void + { + $client = Mockery::mock(LemonSqueezyClient::class); + $client->shouldReceive('get') + ->once() + ->with('/checkouts/chk_123', []) + ->andReturn(['data' => ['attributes' => ['order_id' => 'ord_123']]]); + + $client->shouldReceive('get') + ->once() + ->with('/orders/ord_123', []) + ->andReturn(['data' => ['id' => 'ord_123', 'attributes' => ['status' => 'paid']]]); + + $this->app->instance(LemonSqueezyClient::class, $client); + + $service = $this->app->make(LemonSqueezyOrderService::class); + $order = $service->findByCheckoutId('chk_123'); + + $this->assertIsArray($order); + $this->assertSame('ord_123', $order['id'] ?? null); + } +} diff --git a/tests/Unit/LemonSqueezySyncLoggingConfigTest.php b/tests/Unit/LemonSqueezySyncLoggingConfigTest.php new file mode 100644 index 0000000..585a977 --- /dev/null +++ b/tests/Unit/LemonSqueezySyncLoggingConfigTest.php @@ -0,0 +1,26 @@ +assertIsArray($channel); + $this->assertSame('stack', $channel['driver'] ?? null); + $this->assertNotEmpty($channel['channels'] ?? null); + } + + public function test_lemonsqueezy_sync_file_channel_is_configured(): void + { + $channel = config('logging.channels.lemonsqueezy-sync-file'); + + $this->assertIsArray($channel); + $this->assertSame('daily', $channel['driver'] ?? null); + $this->assertSame('lemonsqueezy-sync.log', basename((string) ($channel['path'] ?? ''))); + } +} diff --git a/tests/Unit/PackageLemonSqueezyLinkTest.php b/tests/Unit/PackageLemonSqueezyLinkTest.php new file mode 100644 index 0000000..d61e2fc --- /dev/null +++ b/tests/Unit/PackageLemonSqueezyLinkTest.php @@ -0,0 +1,31 @@ +create([ + 'lemonsqueezy_product_id' => null, + 'lemonsqueezy_variant_id' => null, + 'lemonsqueezy_sync_status' => null, + 'lemonsqueezy_synced_at' => null, + ]); + + $package->linkLemonSqueezyIds('pro_123', 'pri_123'); + + $package->refresh(); + + $this->assertSame('pro_123', $package->lemonsqueezy_product_id); + $this->assertSame('pri_123', $package->lemonsqueezy_variant_id); + $this->assertSame('linked', $package->lemonsqueezy_sync_status); + $this->assertNotNull($package->lemonsqueezy_synced_at); + } +} diff --git a/tests/Unit/PackagePaddleSyncErrorTest.php b/tests/Unit/PackageLemonSqueezySyncErrorTest.php similarity index 52% rename from tests/Unit/PackagePaddleSyncErrorTest.php rename to tests/Unit/PackageLemonSqueezySyncErrorTest.php index 79a8c76..4609d61 100644 --- a/tests/Unit/PackagePaddleSyncErrorTest.php +++ b/tests/Unit/PackageLemonSqueezySyncErrorTest.php @@ -6,33 +6,33 @@ use App\Models\Package; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; -class PackagePaddleSyncErrorTest extends TestCase +class PackageLemonSqueezySyncErrorTest extends TestCase { use RefreshDatabase; - public function test_paddle_sync_error_message_returns_message_when_present(): void + public function test_lemonsqueezy_sync_error_message_returns_message_when_present(): void { $package = Package::factory()->create([ - 'paddle_snapshot' => [ + 'lemonsqueezy_snapshot' => [ 'error' => [ 'message' => 'Sync failed.', ], ], ]); - $this->assertSame('Sync failed.', $package->paddle_sync_error_message); + $this->assertSame('Sync failed.', $package->lemonsqueezy_sync_error_message); } - public function test_paddle_sync_error_message_returns_null_when_missing(): void + public function test_lemonsqueezy_sync_error_message_returns_null_when_missing(): void { $package = Package::factory()->create([ - 'paddle_snapshot' => [ + 'lemonsqueezy_snapshot' => [ 'product' => [ 'id' => 'pro_123', ], ], ]); - $this->assertNull($package->paddle_sync_error_message); + $this->assertNull($package->lemonsqueezy_sync_error_message); } } diff --git a/tests/Unit/PackagePaddleLinkTest.php b/tests/Unit/PackagePaddleLinkTest.php deleted file mode 100644 index db125d4..0000000 --- a/tests/Unit/PackagePaddleLinkTest.php +++ /dev/null @@ -1,31 +0,0 @@ -create([ - 'paddle_product_id' => null, - 'paddle_price_id' => null, - 'paddle_sync_status' => null, - 'paddle_synced_at' => null, - ]); - - $package->linkPaddleIds('pro_123', 'pri_123'); - - $package->refresh(); - - $this->assertSame('pro_123', $package->paddle_product_id); - $this->assertSame('pri_123', $package->paddle_price_id); - $this->assertSame('linked', $package->paddle_sync_status); - $this->assertNotNull($package->paddle_synced_at); - } -} diff --git a/tests/Unit/PaddleCheckoutServiceTest.php b/tests/Unit/PaddleCheckoutServiceTest.php deleted file mode 100644 index 87fb2f2..0000000 --- a/tests/Unit/PaddleCheckoutServiceTest.php +++ /dev/null @@ -1,67 +0,0 @@ -create([ - 'contact_email' => 'buyer@example.com', - ]); - - $package = Package::factory()->create([ - 'paddle_price_id' => 'pri_123', - ]); - - $client = Mockery::mock(PaddleClient::class); - $customers = Mockery::mock(PaddleCustomerService::class); - - $customers->shouldReceive('ensureCustomerId') - ->once() - ->with($tenant) - ->andReturn('ctm_123'); - - $client->shouldReceive('post') - ->once() - ->with('/transactions', Mockery::on(function (array $payload) use ($tenant, $package) { - return $payload['items'][0]['price_id'] === 'pri_123' - && $payload['customer_id'] === 'ctm_123' - && ($payload['custom_data']['tenant_id'] ?? null) === (string) $tenant->id - && ($payload['custom_data']['package_id'] ?? null) === (string) $package->id - && ($payload['custom_data']['source'] ?? null) === 'test' - && ($payload['custom_data']['success_url'] ?? null) === 'https://example.test/success' - && ($payload['custom_data']['cancel_url'] ?? null) === 'https://example.test/cancel' - && ! isset($payload['metadata']) - && ! isset($payload['success_url']) - && ! isset($payload['cancel_url']) - && ! isset($payload['customer_email']); - })) - ->andReturn(['data' => ['checkout' => ['url' => 'https://paddle.test/checkout/123'], 'id' => 'txn_123']]); - - $this->app->instance(PaddleClient::class, $client); - $this->app->instance(PaddleCustomerService::class, $customers); - - $service = $this->app->make(PaddleCheckoutService::class); - - $checkout = $service->createCheckout($tenant, $package, [ - 'success_url' => 'https://example.test/success', - 'return_url' => 'https://example.test/cancel', - 'metadata' => ['source' => 'test'], - ]); - - $this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']); - $this->assertSame('txn_123', $checkout['id']); - } -} diff --git a/tests/Unit/PaddleSyncLoggingConfigTest.php b/tests/Unit/PaddleSyncLoggingConfigTest.php deleted file mode 100644 index 52154f5..0000000 --- a/tests/Unit/PaddleSyncLoggingConfigTest.php +++ /dev/null @@ -1,26 +0,0 @@ -assertIsArray($channel); - $this->assertSame('stack', $channel['driver'] ?? null); - $this->assertNotEmpty($channel['channels'] ?? null); - } - - public function test_paddle_sync_file_channel_is_configured(): void - { - $channel = config('logging.channels.paddle-sync-file'); - - $this->assertIsArray($channel); - $this->assertSame('daily', $channel['driver'] ?? null); - $this->assertSame('paddle-sync.log', basename((string) ($channel['path'] ?? ''))); - } -} diff --git a/tests/Unit/PaddleTransactionServiceTest.php b/tests/Unit/PaddleTransactionServiceTest.php deleted file mode 100644 index 7361f16..0000000 --- a/tests/Unit/PaddleTransactionServiceTest.php +++ /dev/null @@ -1,66 +0,0 @@ -shouldReceive('get') - ->once() - ->with('/transactions', Mockery::on(function (array $payload) { - return $payload['customer_id'] === 'ctm_123' - && $payload['order_by'] === 'created_at[desc]'; - })) - ->andReturn(['data' => [], 'meta' => ['pagination' => []]]); - - $this->app->instance(PaddleClient::class, $client); - - $service = $this->app->make(PaddleTransactionService::class); - $service->listForCustomer('ctm_123'); - - $this->assertTrue(true); - } - - public function test_find_by_checkout_id_uses_expected_order_by_format(): void - { - $client = Mockery::mock(PaddleClient::class); - $client->shouldReceive('get') - ->once() - ->with('/transactions', Mockery::on(function (array $payload) { - return $payload['checkout_id'] === 'chk_123' - && $payload['order_by'] === 'created_at[desc]'; - })) - ->andReturn(['data' => []]); - - $this->app->instance(PaddleClient::class, $client); - - $service = $this->app->make(PaddleTransactionService::class); - $this->assertNull($service->findByCheckoutId('chk_123')); - } - - public function test_find_by_custom_data_uses_expected_order_by_format(): void - { - $client = Mockery::mock(PaddleClient::class); - $client->shouldReceive('get') - ->once() - ->with('/transactions', Mockery::on(function (array $payload) { - return $payload['order_by'] === 'created_at[desc]' - && $payload['per_page'] === 20; - })) - ->andReturn(['data' => []]); - - $this->app->instance(PaddleClient::class, $client); - - $service = $this->app->make(PaddleTransactionService::class); - $this->assertNull($service->findByCustomData([ - 'checkout_session_id' => 'sess_123', - ])); - } -} diff --git a/tests/Unit/SuperAdminNavigationGroupsTest.php b/tests/Unit/SuperAdminNavigationGroupsTest.php index 14fee01..7b8f68a 100644 --- a/tests/Unit/SuperAdminNavigationGroupsTest.php +++ b/tests/Unit/SuperAdminNavigationGroupsTest.php @@ -33,7 +33,7 @@ class SuperAdminNavigationGroupsTest extends TestCase \App\Filament\Resources\PackageResource::class => 'admin.nav.commercial', \App\Filament\Resources\PhotoboothSettings\PhotoboothSettingResource::class => 'admin.nav.storage', \App\Filament\Resources\PurchaseResource::class => 'admin.nav.billing', - \App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource::class => 'admin.nav.billing', + \App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\TenantLemonSqueezyHealthResource::class => 'admin.nav.billing', \App\Filament\Resources\PurchaseHistoryResource::class => 'admin.nav.commercial', \App\Filament\Resources\EventPurchaseResource::class => 'admin.nav.commercial', \App\Filament\Resources\TenantPackageResource::class => 'admin.nav.commercial', @@ -57,7 +57,7 @@ class SuperAdminNavigationGroupsTest extends TestCase \App\Filament\Resources\PhotoResource::class => DailyOpsCluster::class, \App\Filament\Resources\TenantResource::class => DailyOpsCluster::class, \App\Filament\Resources\PurchaseResource::class => DailyOpsCluster::class, - \App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource::class => DailyOpsCluster::class, + \App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\TenantLemonSqueezyHealthResource::class => DailyOpsCluster::class, \App\Filament\Resources\TenantFeedbackResource::class => DailyOpsCluster::class, \App\Filament\SuperAdmin\Pages\OpsHealthDashboard::class => DailyOpsCluster::class, \App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::class => DailyOpsCluster::class, diff --git a/tests/ui/admin/tenant-onboarding-flow.test.ts b/tests/ui/admin/tenant-onboarding-flow.test.ts index c18eb62..ca8ea49 100644 --- a/tests/ui/admin/tenant-onboarding-flow.test.ts +++ b/tests/ui/admin/tenant-onboarding-flow.test.ts @@ -6,7 +6,7 @@ import { test, expectFixture as expect } from '../helpers/test-fixtures'; * This suite is currently skipped until we have stable seed data and * authentication helpers for Playwright. Once those are in place we can * remove the skip and let the flow exercise the welcome -> packages -> summary - * steps with mocked Paddle APIs. + * steps with mocked Lemon Squeezy APIs. */ test.describe('Tenant Onboarding Welcome Flow', () => { test('redirects unauthenticated users to login', async ({ page }) => { @@ -47,8 +47,8 @@ test.describe('Tenant Onboarding Welcome Flow', () => { await expect(page).toHaveURL(/\/event-admin\/mobile\/welcome\/summary/); await expect(page.getByRole('heading', { name: /Bestellübersicht/i })).toBeVisible(); - // Validate Paddle payment section. - await expect(page.getByRole('heading', { name: /^Paddle$/i })).toBeVisible(); + // Validate billing CTA is present on summary. + await expect(page.getByRole('button', { name: /Go to billing|Zum Billing|Zu Billing/i })).toBeVisible(); // Continue to the setup step without completing a purchase. await page.getByRole('button', { name: /Weiter zum Setup/i }).click(); diff --git a/tests/ui/helpers/test-fixtures.ts b/tests/ui/helpers/test-fixtures.ts index 940aaa2..4ed4975 100644 --- a/tests/ui/helpers/test-fixtures.ts +++ b/tests/ui/helpers/test-fixtures.ts @@ -41,7 +41,7 @@ export type TestingApiFixtures = { getTestMailbox: () => Promise; seedTestCoupons: (definitions?: CouponSeedDefinition[]) => Promise>; getLatestCheckoutSession: (filters?: { email?: string; tenantId?: number; status?: string }) => Promise; - simulatePaddleCompletion: (sessionId: string, overrides?: Partial) => Promise; + simulateLemonSqueezyCompletion: (sessionId: string, overrides?: Partial) => Promise; fetchJoinToken: (params: { eventId?: number; slug?: string; ensureActive?: boolean }) => Promise; }; @@ -58,9 +58,9 @@ export type CheckoutSessionSummary = { created_at: string | null; }; -export type PaddleSimulationOverrides = { +export type LemonSqueezySimulationOverrides = { event_type: string; - transaction_id?: string; + order_id?: string; status?: string; checkout_id?: string; metadata?: Record; @@ -156,10 +156,10 @@ export const test = base.extend({ }); }, - simulatePaddleCompletion: async ({ request }, use) => { - await use(async (sessionId: string, overrides?: Partial) => { + simulateLemonSqueezyCompletion: async ({ request }, use) => { + await use(async (sessionId: string, overrides?: Partial) => { await expectApiSuccess( - request.post(`/api/_testing/checkout/sessions/${sessionId}/simulate-paddle`, { + request.post(`/api/_testing/checkout/sessions/${sessionId}/simulate-lemonsqueezy`, { data: overrides, }) ); diff --git a/tests/ui/purchase/checkout-payment.test.ts b/tests/ui/purchase/checkout-payment.test.ts index 7e28c85..5bcc74a 100644 --- a/tests/ui/purchase/checkout-payment.test.ts +++ b/tests/ui/purchase/checkout-payment.test.ts @@ -5,29 +5,29 @@ const demoTenantCredentials = { password: process.env.E2E_DEMO_TENANT_PASSWORD ?? 'Demo1234!', }; -test.describe('Checkout Payment Step – Paddle flow', () => { - test('opens Paddle checkout and shows success notice', async ({ page }) => { - await page.route('**/paddle/create-checkout', async (route) => { +test.describe('Checkout Payment Step – Lemon Squeezy flow', () => { + test('opens Lemon Squeezy checkout and shows success notice', async ({ page }) => { + await page.route('**/lemonsqueezy/create-checkout', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ - checkout_url: 'https://paddle.test/checkout/success', + checkout_url: 'https://fotospiel.lemonsqueezy.com/checkout/success', }), }); }); - await page.route('https://cdn.paddle.com/paddle/v2/paddle.js', async (route) => { + await page.route('https://app.lemonsqueezy.com/js/lemon.js', async (route) => { await route.fulfill({ status: 200, contentType: 'application/javascript', body: ` - window.Paddle = { - Environment: { set: function(env) { window.__paddleEnv = env; } }, - Initialize: function(opts) { window.__paddleInit = opts; }, - Checkout: { - open: function(config) { - window.__paddleOpenConfig = config; + window.createLemonSqueezy = function() {}; + window.LemonSqueezy = { + Setup: function(options) { window.__lemonEventHandler = options?.eventHandler || null; }, + Url: { + Open: function(url) { + window.__lemonOpenedUrl = url; } } }; @@ -46,7 +46,7 @@ test.describe('Checkout Payment Step – Paddle flow', () => { }; }); - await page.getByRole('button', { name: /Continue with Paddle|Weiter mit Paddle/ }).first().click(); + await page.getByRole('button', { name: /Continue with Lemon Squeezy|Weiter mit Lemon Squeezy/ }).first().click(); await expect( page.locator( @@ -57,7 +57,7 @@ test.describe('Checkout Payment Step – Paddle flow', () => { let mode: 'inline' | 'hosted' | null = null; for (let i = 0; i < 8; i++) { const state = await page.evaluate(() => ({ - inline: Boolean(window.__paddleOpenConfig), + inline: Boolean(window.__lemonOpenedUrl), opened: window.__openedUrls?.length ?? 0, })); @@ -77,19 +77,19 @@ test.describe('Checkout Payment Step – Paddle flow', () => { expect(mode).not.toBeNull(); if (mode === 'inline') { - const inlineConfig = await page.evaluate(() => window.__paddleOpenConfig ?? null); - expect(inlineConfig).not.toBeNull(); + const inlineUrl = await page.evaluate(() => window.__lemonOpenedUrl ?? null); + expect(inlineUrl).not.toBeNull(); } if (mode === 'hosted') { await expect.poll(async () => { return page.evaluate(() => window.__openedUrls?.[0]?.url ?? null); - }).toContain('paddle'); + }).toContain('lemonsqueezy'); } }); - test('shows error state when Paddle checkout creation fails', async ({ page }) => { - await page.route('**/paddle/create-checkout', async (route) => { + test('shows error state when Lemon Squeezy checkout creation fails', async ({ page }) => { + await page.route('**/lemonsqueezy/create-checkout', async (route) => { await route.fulfill({ status: 500, contentType: 'application/json', @@ -97,17 +97,17 @@ test.describe('Checkout Payment Step – Paddle flow', () => { }); }); - await page.route('https://cdn.paddle.com/paddle/v2/paddle.js', async (route) => { + await page.route('https://app.lemonsqueezy.com/js/lemon.js', async (route) => { await route.fulfill({ status: 200, contentType: 'application/javascript', body: ` - window.Paddle = { - Environment: { set: function(env) { window.__paddleEnv = env; } }, - Initialize: function(opts) { window.__paddleInit = opts; }, - Checkout: { - open: function() { - throw new Error('forced paddle failure'); + window.createLemonSqueezy = function() {}; + window.LemonSqueezy = { + Setup: function(options) { window.__lemonEventHandler = options?.eventHandler || null; }, + Url: { + Open: function() { + throw new Error('forced Lemon Squeezy failure'); } } }; @@ -118,10 +118,10 @@ test.describe('Checkout Payment Step – Paddle flow', () => { await openCheckoutPaymentStep(page, demoTenantCredentials); await acceptCheckoutTerms(page); - await page.getByRole('button', { name: /Continue with Paddle|Weiter mit Paddle/ }).first().click(); + await page.getByRole('button', { name: /Continue with Lemon Squeezy|Weiter mit Lemon Squeezy/ }).first().click(); await expect( - page.locator('text=/Paddle-Checkout konnte nicht gestartet werden|Paddle checkout could not be started/i') + page.locator('text=/Lemon Squeezy-Checkout konnte nicht gestartet werden|Lemon Squeezy checkout could not be started/i') ).toBeVisible(); }); }); @@ -169,8 +169,7 @@ async function acceptCheckoutTerms(page: import('@playwright/test').Page) { declare global { interface Window { __openedUrls?: Array<{ url: string; target?: string | null; features?: string | null }>; - __paddleOpenConfig?: { url?: string; items?: Array<{ priceId: string; quantity: number }>; settings?: { displayMode?: string } }; - __paddleEnv?: string; - __paddleInit?: Record; + __lemonOpenedUrl?: string | null; + __lemonEventHandler?: ((event: { event: string; data?: unknown }) => void) | null; } } diff --git a/tests/ui/purchase/paddle-sandbox-checkout.test.ts b/tests/ui/purchase/lemonsqueezy-sandbox-checkout.test.ts similarity index 62% rename from tests/ui/purchase/paddle-sandbox-checkout.test.ts rename to tests/ui/purchase/lemonsqueezy-sandbox-checkout.test.ts index 00a3855..9c152ea 100644 --- a/tests/ui/purchase/paddle-sandbox-checkout.test.ts +++ b/tests/ui/purchase/lemonsqueezy-sandbox-checkout.test.ts @@ -1,11 +1,11 @@ import { expect, test } from '@playwright/test'; -const shouldRun = process.env.E2E_PADDLE_SANDBOX === '1'; +const shouldRun = process.env.E2E_LEMONSQUEEZY_SANDBOX === '1' || process.env.E2E_PADDLE_SANDBOX === '1'; -test.describe('Paddle sandbox checkout (staging)', () => { - test.skip(!shouldRun, 'Set E2E_PADDLE_SANDBOX=1 to run live sandbox checkout on staging.'); +test.describe('Lemon Squeezy sandbox checkout (staging)', () => { + test.skip(!shouldRun, 'Set E2E_LEMONSQUEEZY_SANDBOX=1 to run live sandbox checkout on staging.'); - test('creates Paddle checkout session from packages page', async ({ page }) => { + test('creates Lemon Squeezy checkout session from packages page', async ({ page }) => { const base = process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app'; await page.goto(`${base}/packages`); @@ -23,16 +23,16 @@ test.describe('Paddle sandbox checkout (staging)', () => { } const [requestPromise] = await Promise.all([ - page.waitForRequest('**/paddle/create-checkout'), + page.waitForRequest('**/lemonsqueezy/create-checkout'), checkoutButtons.first().click(), ]); const checkoutRequest = await requestPromise.response(); - expect(checkoutRequest, 'Expected paddle/create-checkout request to resolve').toBeTruthy(); + expect(checkoutRequest, 'Expected lemonsqueezy/create-checkout request to resolve').toBeTruthy(); expect(checkoutRequest!.status()).toBeLessThan(400); const body = await checkoutRequest!.json(); const checkoutUrl = body.checkout_url ?? body.url ?? ''; - expect(checkoutUrl).toContain('paddle'); + expect(checkoutUrl).toContain('lemonsqueezy'); }); }); diff --git a/tests/ui/purchase/paddle-sandbox-full.test.ts b/tests/ui/purchase/lemonsqueezy-sandbox-full.test.ts similarity index 82% rename from tests/ui/purchase/paddle-sandbox-full.test.ts rename to tests/ui/purchase/lemonsqueezy-sandbox-full.test.ts index 7234f33..4ba0da5 100644 --- a/tests/ui/purchase/paddle-sandbox-full.test.ts +++ b/tests/ui/purchase/lemonsqueezy-sandbox-full.test.ts @@ -3,18 +3,18 @@ import fs from 'node:fs/promises'; import { dismissConsentBanner, expectFixture as expect, test } from '../helpers/test-fixtures'; -const shouldRun = process.env.E2E_PADDLE_SANDBOX === '1'; +const shouldRun = process.env.E2E_LEMONSQUEEZY_SANDBOX === '1' || process.env.E2E_PADDLE_SANDBOX === '1'; const baseUrl = process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app'; const locale = process.env.E2E_LOCALE ?? 'de'; const checkoutSlug = locale === 'en' ? 'checkout' : 'bestellen'; const tenantEmail = buildTenantEmail(); const tenantPassword = process.env.E2E_TENANT_PASSWORD ?? null; const sandboxCard = { - number: process.env.E2E_PADDLE_CARD_NUMBER ?? '4242 4242 4242 4242', - expiry: process.env.E2E_PADDLE_CARD_EXPIRY ?? '12/34', - cvc: process.env.E2E_PADDLE_CARD_CVC ?? '123', - name: process.env.E2E_PADDLE_CARD_NAME ?? 'Playwright Tester', - postal: process.env.E2E_PADDLE_CARD_POSTAL ?? '10115', + number: process.env.E2E_LEMONSQUEEZY_CARD_NUMBER ?? process.env.E2E_PADDLE_CARD_NUMBER ?? '4242 4242 4242 4242', + expiry: process.env.E2E_LEMONSQUEEZY_CARD_EXPIRY ?? process.env.E2E_PADDLE_CARD_EXPIRY ?? '12/34', + cvc: process.env.E2E_LEMONSQUEEZY_CARD_CVC ?? process.env.E2E_PADDLE_CARD_CVC ?? '123', + name: process.env.E2E_LEMONSQUEEZY_CARD_NAME ?? process.env.E2E_PADDLE_CARD_NAME ?? 'Playwright Tester', + postal: process.env.E2E_LEMONSQUEEZY_CARD_POSTAL ?? process.env.E2E_PADDLE_CARD_POSTAL ?? '10115', }; test.use({ @@ -27,16 +27,16 @@ test.use({ }, }); -test.describe('Paddle sandbox full flow (staging)', () => { - test.skip(!shouldRun, 'Set E2E_PADDLE_SANDBOX=1 to run live sandbox checkout on staging.'); +test.describe('Lemon Squeezy sandbox full flow (staging)', () => { + test.skip(!shouldRun, 'Set E2E_LEMONSQUEEZY_SANDBOX=1 to run live sandbox checkout on staging.'); test.skip(!tenantEmail || !tenantPassword, 'Set E2E_TENANT_EMAIL and E2E_TENANT_PASSWORD for sandbox flow.'); - test('register, pay via Paddle sandbox, and login to event admin', async ({ page, request }, testInfo) => { - const paddleNetworkLog: string[] = []; + test('register, pay via Lemon Squeezy sandbox, and login to event admin', async ({ page, request }, testInfo) => { + const lemonsqueezyNetworkLog: string[] = []; page.on('response', async (response) => { const url = response.url(); - if (!/paddle/i.test(url)) { + if (!/lemonsqueezy|lemon/i.test(url)) { return; } @@ -52,9 +52,9 @@ test.describe('Paddle sandbox full flow (staging)', () => { } const entry = [`[${status}] ${url}`, bodySnippet].filter(Boolean).join('\n'); - paddleNetworkLog.push(entry); - if (paddleNetworkLog.length > 40) { - paddleNetworkLog.shift(); + lemonsqueezyNetworkLog.push(entry); + if (lemonsqueezyNetworkLog.length > 40) { + lemonsqueezyNetworkLog.shift(); } }); @@ -81,11 +81,11 @@ test.describe('Paddle sandbox full flow (staging)', () => { await expect(termsCheckbox).toBeVisible(); await termsCheckbox.click(); - const checkoutCta = page.getByRole('button', { name: /Weiter mit Paddle|Continue with Paddle/i }).first(); + const checkoutCta = page.getByRole('button', { name: /Weiter mit Lemon Squeezy|Continue with Lemon Squeezy/i }).first(); await expect(checkoutCta).toBeVisible({ timeout: 20000 }); const [apiResponse] = await Promise.all([ - page.waitForResponse((resp) => resp.url().includes('/paddle/create-checkout') && resp.status() < 500), + page.waitForResponse((resp) => resp.url().includes('/lemonsqueezy/create-checkout') && resp.status() < 500), checkoutCta.click(), ]); @@ -101,23 +101,23 @@ test.describe('Paddle sandbox full flow (staging)', () => { const checkoutUrl = extractCheckoutUrl(checkoutPayload, rawBody); if (!inlineMode) { - expect(checkoutUrl).toContain('paddle'); + expect(checkoutUrl).toContain('lemonsqueezy'); } - // Navigate to Paddle hosted checkout and complete payment. + // Navigate to Lemon Squeezy hosted checkout and complete payment. if (inlineMode) { await expect( - page.getByText(/Checkout geöffnet|Checkout opened|Paddle-Checkout/i).first() + page.getByText(/Lemon Squeezy|Checkout geöffnet|Checkout opened/i).first() ).toBeVisible({ timeout: 20_000 }); - await waitForPaddleCardInputs(page, ['input[autocomplete="cc-number"]', 'input[name="cardnumber"]', 'input[name="card_number"]']); + await waitForLemonCardInputs(page, ['input[autocomplete="cc-number"]', 'input[name="cardnumber"]', 'input[name="card_number"]']); } else if (checkoutUrl) { await page.goto(checkoutUrl); - await expect(page).toHaveURL(/paddle/); + await expect(page).toHaveURL(/lemonsqueezy/); } else { - throw new Error(`Missing Paddle checkout URL. Response: ${rawBody}`); + throw new Error(`Missing Lemon Squeezy checkout URL. Response: ${rawBody}`); } - await completeHostedPaddleCheckout(page, sandboxCard); + await completeHostedLemonSqueezyCheckout(page, sandboxCard); await expect .poll( @@ -146,10 +146,10 @@ test.describe('Paddle sandbox full flow (staging)', () => { await expect(page).toHaveURL(/\/event-admin\/mobile\/(dashboard|welcome)/i, { timeout: 30_000 }); await expect(page.getByText(/Dashboard|Willkommen/i)).toBeVisible(); } finally { - if (paddleNetworkLog.length > 0) { - const logPath = testInfo.outputPath('paddle-network-log.txt'); - await fs.writeFile(logPath, paddleNetworkLog.join('\n\n'), 'utf8'); - await testInfo.attach('paddle-network-log', { + if (lemonsqueezyNetworkLog.length > 0) { + const logPath = testInfo.outputPath('lemonsqueezy-network-log.txt'); + await fs.writeFile(logPath, lemonsqueezyNetworkLog.join('\n\n'), 'utf8'); + await testInfo.attach('lemonsqueezy-network-log', { path: logPath, contentType: 'text/plain', }); @@ -232,7 +232,7 @@ async function proceedToAccountStep(page: Page, timeoutMs = 30_000): Promise { @@ -279,7 +279,7 @@ async function completeHostedPaddleCheckout( await payButton.click(); } -async function waitForPaddleCardInputs(page: Page, selectors: string[], timeoutMs = 30_000): Promise { +async function waitForLemonCardInputs(page: Page, selectors: string[], timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -288,13 +288,13 @@ async function waitForPaddleCardInputs(page: Page, selectors: string[], timeoutM } if (await hasAnyText(page, /Something went wrong|try again later/i)) { - throw new Error('Paddle inline checkout returned an error in the iframe.'); + throw new Error('Lemon Squeezy inline checkout returned an error in the iframe.'); } await page.waitForTimeout(500); } - throw new Error('Paddle card inputs did not appear in time.'); + throw new Error('Lemon Squeezy card inputs did not appear in time.'); } async function hasAnySelector(page: Page, selectors: string[]): Promise { @@ -359,7 +359,7 @@ function extractCheckoutUrl(payload: Record | null, rawBody: st } function buildTenantEmail(): string | null { - const rawEmail = process.env.E2E_TENANT_EMAIL ?? process.env.E2E_PADDLE_EMAIL ?? null; + const rawEmail = process.env.E2E_TENANT_EMAIL ?? process.env.E2E_LEMONSQUEEZY_EMAIL ?? process.env.E2E_PADDLE_EMAIL ?? null; if (!rawEmail) { return null; } diff --git a/tests/ui/purchase/standard-package-checkout.test.ts b/tests/ui/purchase/standard-package-checkout.test.ts index daefd66..dd5dd3e 100644 --- a/tests/ui/purchase/standard-package-checkout.test.ts +++ b/tests/ui/purchase/standard-package-checkout.test.ts @@ -2,14 +2,14 @@ import { test, expectFixture as expect } from '../helpers/test-fixtures'; const shouldRun = process.env.E2E_TESTING_API === '1'; -test.describe('Classic package checkout with Paddle completion', () => { +test.describe('Classic package checkout with Lemon Squeezy completion', () => { test.skip(!shouldRun, 'Set E2E_TESTING_API=1 to enable checkout tests that use /api/_testing endpoints.'); test('registers, applies coupon, and reaches confirmation', async ({ page, clearTestMailbox, seedTestCoupons, getLatestCheckoutSession, - simulatePaddleCompletion, + simulateLemonSqueezyCompletion, getTestMailbox, }) => { await clearTestMailbox(); @@ -27,23 +27,20 @@ test.describe('Classic package checkout with Paddle completion', () => { }; }); - await page.route('https://cdn.paddle.com/paddle/v2/paddle.js', async (route) => { + await page.route('https://app.lemonsqueezy.com/js/lemon.js', async (route) => { await route.fulfill({ status: 200, contentType: 'application/javascript', body: ` - window.__paddleEventCallback = null; - window.__paddleInitOptions = null; - window.__paddleCheckoutConfig = null; - window.Paddle = { - Environment: { set() {} }, - Initialize(options) { - window.__paddleInitOptions = options; - window.__paddleEventCallback = options?.eventCallback || null; + window.__lemonEventHandler = null; + window.__lemonOpenedUrl = null; + window.LemonSqueezy = { + Setup(options) { + window.__lemonEventHandler = options?.eventHandler || null; }, - Checkout: { - open(config) { - window.__paddleCheckoutConfig = config; + Url: { + Open(url) { + window.__lemonOpenedUrl = url; }, }, }; @@ -51,14 +48,14 @@ test.describe('Classic package checkout with Paddle completion', () => { }); }); - let paddleRequestPayload: Record | null = null; - await page.route('**/paddle/create-checkout', async (route) => { - paddleRequestPayload = route.request().postDataJSON() as Record; + let lemonsqueezyRequestPayload: Record | null = null; + await page.route('**/lemonsqueezy/create-checkout', async (route) => { + lemonsqueezyRequestPayload = route.request().postDataJSON() as Record; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ - checkout_url: 'https://sandbox.paddle.test/checkout/abc123', + checkout_url: 'https://fotospiel.lemonsqueezy.com/checkout/abc123', }), }); }); @@ -116,12 +113,12 @@ test.describe('Classic package checkout with Paddle completion', () => { await expect(termsCheckbox).toBeVisible(); await termsCheckbox.click(); - await page.getByRole('button', { name: /Weiter mit Paddle|Continue with Paddle/i }).first().click(); + await page.getByRole('button', { name: /Weiter mit Lemon Squeezy|Continue with Lemon Squeezy/i }).first().click(); let checkoutMode: 'inline' | 'hosted' | null = null; for (let i = 0; i < 8; i++) { const state = await page.evaluate(() => ({ - inline: Boolean(window.__paddleCheckoutConfig), + inline: Boolean(window.__lemonOpenedUrl), opened: window.__openedWindows?.length ?? 0, })); @@ -143,11 +140,14 @@ test.describe('Classic package checkout with Paddle completion', () => { if (checkoutMode === 'hosted') { await expect.poll(async () => { return page.evaluate(() => window.__openedWindows?.[0]?.[0] ?? null); - }).toContain('https://sandbox.paddle.test/checkout/abc123'); + }).toContain('https://fotospiel.lemonsqueezy.com/checkout/abc123'); } await page.evaluate(() => { - window.__paddleEventCallback?.({ name: 'checkout.completed' }); + window.__lemonEventHandler?.({ + event: 'Checkout.Success', + data: { id: 'ord_test', attributes: { checkout_id: 'chk_123' } }, + }); }); let session = null; @@ -160,7 +160,7 @@ test.describe('Classic package checkout with Paddle completion', () => { } if (session) { - await simulatePaddleCompletion(session.id); + await simulateLemonSqueezyCompletion(session.id); for (let i = 0; i < 6; i++) { const refreshed = await getLatestCheckoutSession({ email }); @@ -179,8 +179,8 @@ test.describe('Classic package checkout with Paddle completion', () => { page.getByRole('button', { name: /Zum Admin-Bereich|To Admin Area/i }) ).toBeVisible(); - if (paddleRequestPayload) { - expect(paddleRequestPayload['coupon_code']).toBe('PERCENT10'); + if (lemonsqueezyRequestPayload) { + expect(lemonsqueezyRequestPayload['coupon_code']).toBe('PERCENT10'); } const messages = await getTestMailbox(); @@ -191,8 +191,7 @@ test.describe('Classic package checkout with Paddle completion', () => { declare global { interface Window { __openedWindows?: unknown[]; - __paddleEventCallback?: ((event: { name: string }) => void) | null; - __paddleInitOptions?: unknown; - __paddleCheckoutConfig?: unknown; + __lemonEventHandler?: ((event: { event: string; data?: unknown }) => void) | null; + __lemonOpenedUrl?: string | null; } }