From 5432456ffd65e4b4eb64ffdc0117b98e8d0dbaf8 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 27 Oct 2025 17:26:39 +0100 Subject: [PATCH] switched to paddle inline checkout, removed paypal and most of stripe. added product sync between app and paddle. --- .env.example | 14 + AGENTS.md | 6 +- app/Console/Commands/PaddleSyncPackages.php | 114 +++ app/Filament/Resources/PackageResource.php | 87 +- app/Filament/Resources/PurchaseResource.php | 19 +- .../PurchaseResource/Pages/ViewPurchase.php | 6 +- app/Filament/Resources/TenantResource.php | 49 +- .../PackagePurchasesRelationManager.php | 54 +- .../TenantPackagesRelationManager.php | 30 +- .../Controllers/Api/PackageController.php | 175 +---- .../Api/TenantBillingController.php | 63 ++ .../Auth/RegisteredUserController.php | 17 +- app/Http/Controllers/CheckoutController.php | 257 ++---- .../Controllers/CheckoutGoogleController.php | 77 +- app/Http/Controllers/MarketingController.php | 333 ++------ .../Controllers/PaddleCheckoutController.php | 97 +++ .../Controllers/PaddleWebhookController.php | 68 ++ app/Http/Controllers/PayPalController.php | 264 ------- .../Controllers/PayPalWebhookController.php | 269 ------- app/Http/Middleware/ContentSecurityPolicy.php | 13 + app/Jobs/PullPackageFromPaddle.php | 78 ++ app/Jobs/SyncPackageToPaddle.php | 138 ++++ app/Models/CheckoutSession.php | 13 +- app/Models/Package.php | 15 +- app/Models/PackagePurchase.php | 7 +- app/Models/TenantPackage.php | 24 +- .../Checkout/CheckoutAssignmentService.php | 17 +- .../Checkout/CheckoutPaymentService.php | 31 +- .../Checkout/CheckoutSessionService.php | 16 +- .../Checkout/CheckoutWebhookService.php | 307 ++++++-- .../Paddle/Exceptions/PaddleException.php | 23 + app/Services/Paddle/PaddleCatalogService.php | 245 ++++++ app/Services/Paddle/PaddleCheckoutService.php | 84 ++ app/Services/Paddle/PaddleClient.php | 82 ++ app/Services/Paddle/PaddleCustomerService.php | 41 + .../Paddle/PaddleSubscriptionService.php | 33 + .../Paddle/PaddleTransactionService.php | 92 +++ app/Services/PayPal/PaypalClientFactory.php | 27 - app/Support/Concerns/PresentsPackages.php | 11 +- composer.json | 1 - composer.lock | 318 +------- config/paddle.php | 33 + config/services.php | 13 +- database/factories/PackageFactory.php | 8 +- database/factories/PackagePurchaseFactory.php | 1 + ..._190719_create_checkout_sessions_table.php | 9 +- ...1_add_paddle_columns_to_billing_tables.php | 85 ++ ..._paddle_sync_columns_to_packages_table.php | 36 + database/seeders/DemoLifecycleSeeder.php | 4 + .../2025-10-02-registration-role-fixes.md | 4 +- .../2025-10-05-checkout-refactor-todo.md | 10 +- .../2025-10-09-paypal-sdk-migration.md | 10 +- ...2025-10-10-tenant-admin-onboarding-plan.md | 8 +- docs/checkout wizard flow.txt | 8 +- docs/legal/agb-de.md | 4 +- docs/legal/agb-en.md | 4 +- docs/legal/datenschutz-de.md | 10 +- docs/legal/datenschutz-en.md | 10 +- docs/packages-business-model-plan.md | 40 +- docs/prp/08-billing.md | 20 +- ...marketing-checkout-payment-architecture.md | 40 +- docs/prp/tenant-app-specs/README.md | 2 +- docs/prp/tenant-app-specs/functional-specs.md | 10 +- docs/prp/tenant-app-specs/pages-ui.md | 8 +- .../03-package-selection.svg | 2 +- .../04-order-summary.svg | 6 +- .../tenant-admin-onboarding/README.md | 2 +- docs/todo/paddle-catalog-sync.md | 28 + docs/todo/paddle-migration.md | 14 + docs/todo/security-hardening-epic.md | 2 +- docs/todo/tenant-admin-onboarding-fusion.md | 4 +- playwright-report/index.html | 2 +- public/lang/de/legal.json | 5 +- public/lang/de/marketing.json | 45 +- public/lang/en/legal.json | 4 +- public/lang/en/marketing.json | 43 +- resources/js/admin/api.ts | 94 ++- resources/js/admin/auth/tokens.ts | 24 +- resources/js/admin/dev-tools.ts | 18 +- .../js/admin/i18n/locales/de/management.json | 122 +-- .../js/admin/i18n/locales/de/onboarding.json | 47 +- .../js/admin/i18n/locales/en/management.json | 121 +-- .../js/admin/i18n/locales/en/onboarding.json | 43 +- .../WelcomeOrderSummary.checkout.test.tsx | 155 +--- .../pages/WelcomeOrderSummaryPage.tsx | 298 +------ resources/js/admin/pages/BillingPage.tsx | 159 +++- resources/js/layouts/app/Footer.tsx | 6 +- resources/js/layouts/app/Header.tsx | 2 +- resources/js/pages/auth/RegisterForm.tsx | 75 +- .../js/pages/marketing/CheckoutWizardPage.tsx | 22 +- .../marketing/checkout/CheckoutWizard.tsx | 144 +++- .../marketing/checkout/WizardContext.tsx | 35 +- .../__tests__/CheckoutWizard.guard.test.tsx | 107 +++ .../marketing/checkout/steps/AuthStep.tsx | 14 +- .../marketing/checkout/steps/PaymentStep.tsx | 741 ++++++++---------- .../js/pages/marketing/checkout/types.ts | 14 +- resources/lang/de/legal.php | 5 +- resources/lang/de/marketing.json | 4 +- resources/lang/de/marketing.php | 6 +- resources/lang/en/legal.php | 4 +- resources/lang/en/marketing.json | 4 +- resources/lang/en/marketing.php | 6 +- routes/api.php | 17 +- routes/web.php | 12 +- tests/Feature/EventControllerTest.php | 1 + tests/Feature/FullUserFlowTest.php | 16 +- .../Feature/PaddleSyncPackagesCommandTest.php | 55 ++ tests/Feature/PaddleWebhookControllerTest.php | 228 ++++++ tests/Feature/PayPalWebhookControllerTest.php | 75 -- tests/Feature/PurchaseTest.php | 326 ++------ tests/Feature/StripeWebhookTest.php | 120 --- tests/Feature/SyncPackageToPaddleJobTest.php | 83 ++ tests/Unit/PaddleCatalogServiceTest.php | 76 ++ tests/e2e/checkout-payment.test.ts | 200 ++--- tests/e2e/event-admin-dashboard.test.ts | 112 ++- tests/e2e/tenant-onboarding-flow.test.ts | 11 +- tests/e2e/utils/test-fixtures.ts | 2 + 117 files changed, 4114 insertions(+), 3639 deletions(-) create mode 100644 app/Console/Commands/PaddleSyncPackages.php create mode 100644 app/Http/Controllers/Api/TenantBillingController.php create mode 100644 app/Http/Controllers/PaddleCheckoutController.php create mode 100644 app/Http/Controllers/PaddleWebhookController.php delete mode 100644 app/Http/Controllers/PayPalController.php delete mode 100644 app/Http/Controllers/PayPalWebhookController.php create mode 100644 app/Jobs/PullPackageFromPaddle.php create mode 100644 app/Jobs/SyncPackageToPaddle.php create mode 100644 app/Services/Paddle/Exceptions/PaddleException.php create mode 100644 app/Services/Paddle/PaddleCatalogService.php create mode 100644 app/Services/Paddle/PaddleCheckoutService.php create mode 100644 app/Services/Paddle/PaddleClient.php create mode 100644 app/Services/Paddle/PaddleCustomerService.php create mode 100644 app/Services/Paddle/PaddleSubscriptionService.php create mode 100644 app/Services/Paddle/PaddleTransactionService.php delete mode 100644 app/Services/PayPal/PaypalClientFactory.php create mode 100644 config/paddle.php create mode 100644 database/migrations/2025_10_26_182621_add_paddle_columns_to_billing_tables.php create mode 100644 database/migrations/2025_10_27_090531_add_paddle_sync_columns_to_packages_table.php create mode 100644 docs/todo/paddle-catalog-sync.md create mode 100644 docs/todo/paddle-migration.md create mode 100644 resources/js/pages/marketing/checkout/__tests__/CheckoutWizard.guard.test.tsx create mode 100644 tests/Feature/PaddleSyncPackagesCommandTest.php create mode 100644 tests/Feature/PaddleWebhookControllerTest.php delete mode 100644 tests/Feature/PayPalWebhookControllerTest.php delete mode 100644 tests/Feature/StripeWebhookTest.php create mode 100644 tests/Feature/SyncPackageToPaddleJobTest.php create mode 100644 tests/Unit/PaddleCatalogServiceTest.php diff --git a/.env.example b/.env.example index ec934df..887ce83 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,20 @@ REVENUECAT_WEBHOOK_QUEUE=webhooks CHECKOUT_WIZARD_ENABLED=true CHECKOUT_WIZARD_FLAG=checkout-wizard-2025 +# PayPal +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= + OAUTH_JWT_KID=fotospiel-jwt OAUTH_KEY_STORE= OAUTH_REFRESH_ENFORCE_IP=true diff --git a/AGENTS.md b/AGENTS.md index cfa9446..af4fa13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,8 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, - Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev). - Git Hosting: Gogs at http://nas:10880 (token found locally in gogs.ini, never printed or committed). - Issue API: Gogs REST /api/v1 for labels/issues/milestones (token auth). -- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Stripe PHP SDK for payments; PayPal Server SDK for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n. -- Payment Systems: Stripe (subscriptions and one-time payments), PayPal (integrated payments), RevenueCat (mobile app subscriptions). +- 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. +- Payment Systems: Paddle (subscriptions and one-time payments), RevenueCat (mobile app subscriptions). - PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync. ## Repo Structure (high-level) @@ -59,7 +59,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, - tenant:add-dummy — create a demo tenant and admin user (see --help for options). - tenant:attach-demo-event — attach an existing demo event to a tenant. - Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/prp/03-api.md. -- Payment Integration: Stripe webhooks, PayPal API integration, RevenueCat mobile subscriptions. +- Payment Integration: Paddle 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/PaddleSyncPackages.php b/app/Console/Commands/PaddleSyncPackages.php new file mode 100644 index 0000000..97ebae8 --- /dev/null +++ b/app/Console/Commands/PaddleSyncPackages.php @@ -0,0 +1,114 @@ +resolvePackages(); + + if ($packages->isEmpty()) { + $this->warn('No packages matched the given filters.'); + + return self::FAILURE; + } + + $dryRun = (bool) $this->option('dry-run'); + $pull = (bool) $this->option('pull'); + $queue = (bool) $this->option('queue'); + + $packages->each(function (Package $package) use ($dryRun, $pull, $queue) { + if ($pull) { + $this->dispatchPullJob($package, $queue); + + return; + } + + $this->dispatchSyncJob($package, $dryRun, $queue); + }); + + $this->info(sprintf( + 'Queued %d package %s for Paddle %s.', + $packages->count(), + Str::plural('entry', $packages->count()), + $pull ? 'pull' : 'sync' + )); + + return self::SUCCESS; + } + + protected function resolvePackages(): Collection + { + $keys = collect((array) $this->option('package'))->filter(); + + $query = Package::query(); + + if ($keys->isNotEmpty()) { + $ids = $keys + ->filter(fn ($value) => is_numeric($value)) + ->map(fn ($value) => (int) $value); + + $slugs = $keys + ->reject(fn ($value) => is_numeric($value)) + ->values(); + + $query->where(function ($builder) use ($ids, $slugs) { + if ($ids->isNotEmpty()) { + $builder->orWhereIn('id', $ids); + } + + if ($slugs->isNotEmpty()) { + $builder->orWhereIn('slug', $slugs); + } + }); + } + + return $query->orderByDesc('id')->get(); + } + + protected function dispatchSyncJob(Package $package, bool $dryRun, bool $queue): void + { + $context = [ + 'dry_run' => $dryRun, + ]; + + if ($queue) { + SyncPackageToPaddle::dispatch($package->id, $context); + $this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug)); + + return; + } + + SyncPackageToPaddle::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); + $this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug)); + + return; + } + + PullPackageFromPaddle::dispatchSync($package->id); + $this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug)); + } +} diff --git a/app/Filament/Resources/PackageResource.php b/app/Filament/Resources/PackageResource.php index c98f98b..40a5099 100644 --- a/app/Filament/Resources/PackageResource.php +++ b/app/Filament/Resources/PackageResource.php @@ -3,7 +3,11 @@ namespace App\Filament\Resources; use App\Filament\Resources\PackageResource\Pages; +use App\Jobs\PullPackageFromPaddle; +use App\Jobs\SyncPackageToPaddle; use App\Models\Package; +use BackedEnum; +use Filament\Actions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; @@ -11,21 +15,23 @@ use Filament\Actions\EditAction; use Filament\Actions\ViewAction; use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\MarkdownEditor; +use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Repeater; -use Filament\Schemas\Components\Section; use Filament\Forms\Components\Select; -use Filament\Schemas\Components\Tabs as SchemaTabs; -use Filament\Schemas\Components\Tabs\Tab as SchemaTab; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Notifications\Notification; use Filament\Resources\Resource; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs as SchemaTabs; +use Filament\Schemas\Components\Tabs\Tab as SchemaTab; use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Columns\BadgeColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Support\Str; use UnitEnum; -use BackedEnum; class PackageResource extends Resource { @@ -150,6 +156,28 @@ class PackageResource extends Resource ->columnSpanFull() ->default([]), ]), + Section::make('Paddle Billing') + ->columns(2) + ->schema([ + TextInput::make('paddle_product_id') + ->label('Paddle Produkt-ID') + ->maxLength(191) + ->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.') + ->placeholder('nicht verknüpft'), + TextInput::make('paddle_price_id') + ->label('Paddle Preis-ID') + ->maxLength(191) + ->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.') + ->placeholder('nicht verknüpft'), + Placeholder::make('paddle_sync_status') + ->label('Sync-Status') + ->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '–') + ->columnSpanFull(), + Placeholder::make('paddle_synced_at') + ->label('Zuletzt synchronisiert') + ->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '–') + ->columnSpanFull(), + ]), ]); } @@ -214,6 +242,28 @@ class PackageResource extends Resource ->label('Features') ->wrap() ->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)), + TextColumn::make('paddle_product_id') + ->label('Paddle Produkt') + ->toggleable(isToggledHiddenByDefault: true) + ->formatStateUsing(fn ($state) => $state ?: '-'), + TextColumn::make('paddle_price_id') + ->label('Paddle Preis') + ->toggleable(isToggledHiddenByDefault: true) + ->formatStateUsing(fn ($state) => $state ?: '-'), + BadgeColumn::make('paddle_sync_status') + ->label('Sync-Status') + ->colors([ + 'success' => 'synced', + 'warning' => 'syncing', + 'info' => 'dry-run', + 'danger' => ['failed', 'pull-failed'], + ]) + ->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null) + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('paddle_synced_at') + ->label('Sync am') + ->dateTime() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ Tables\Filters\SelectFilter::make('type') @@ -224,6 +274,35 @@ class PackageResource extends Resource ]), ]) ->actions([ + Actions\Action::make('syncPaddle') + ->label('Mit Paddle abgleichen') + ->icon('heroicon-o-cloud-arrow-up') + ->color('success') + ->requiresConfirmation() + ->disabled(fn (Package $record) => $record->paddle_sync_status === 'syncing') + ->action(function (Package $record) { + SyncPackageToPaddle::dispatch($record->id); + + Notification::make() + ->success() + ->title('Paddle-Sync gestartet') + ->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.') + ->send(); + }), + Actions\Action::make('pullPaddle') + ->label('Status von Paddle holen') + ->icon('heroicon-o-cloud-arrow-down') + ->disabled(fn (Package $record) => ! $record->paddle_product_id && ! $record->paddle_price_id) + ->requiresConfirmation() + ->action(function (Package $record) { + PullPackageFromPaddle::dispatch($record->id); + + Notification::make() + ->info() + ->title('Paddle-Abgleich angefordert') + ->body('Der aktuelle Stand aus Paddle wird geladen und hier hinterlegt.') + ->send(); + }), ViewAction::make(), EditAction::make(), DeleteAction::make(), diff --git a/app/Filament/Resources/PurchaseResource.php b/app/Filament/Resources/PurchaseResource.php index d9c2511..a13ed49 100644 --- a/app/Filament/Resources/PurchaseResource.php +++ b/app/Filament/Resources/PurchaseResource.php @@ -4,6 +4,7 @@ namespace App\Filament\Resources; use App\Filament\Resources\PurchaseResource\Pages; use App\Models\PackagePurchase; +use BackedEnum; use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; @@ -11,23 +12,19 @@ use Filament\Actions\EditAction; use Filament\Actions\ViewAction; use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\Select; -use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; use Filament\Schemas\Schema; -use Filament\Tables; use Filament\Tables\Columns\BadgeColumn; -use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\Filter; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Support\Facades\Log; -use BackedEnum; -use UnitEnum; + class PurchaseResource extends Resource { protected static ?string $model = PackagePurchase::class; @@ -97,7 +94,7 @@ class PurchaseResource extends Resource ->columns([ BadgeColumn::make('type') ->label('Type') - ->color(fn (string $state): string => match($state) { + ->color(fn (string $state): string => match ($state) { 'endcustomer_event' => 'info', 'reseller_subscription' => 'success', default => 'gray', @@ -164,11 +161,11 @@ class PurchaseResource extends Resource ->color('danger') ->icon('heroicon-o-arrow-uturn-left') ->requiresConfirmation() - ->visible(fn (PackagePurchase $record): bool => !$record->refunded) + ->visible(fn (PackagePurchase $record): bool => ! $record->refunded) ->action(function (PackagePurchase $record) { $record->update(['refunded' => true]); - // TODO: Call Stripe/PayPal API for actual refund - Log::info('Refund processed for purchase ID: ' . $record->id); + // TODO: Call Stripe/Paddle API for actual refund + Log::info('Refund processed for purchase ID: '.$record->id); }), ]) ->bulkActions([ @@ -196,4 +193,4 @@ class PurchaseResource extends Resource // Add RelationManagers if needed ]; } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/PurchaseResource/Pages/ViewPurchase.php b/app/Filament/Resources/PurchaseResource/Pages/ViewPurchase.php index e7bd1d1..37007f7 100644 --- a/app/Filament/Resources/PurchaseResource/Pages/ViewPurchase.php +++ b/app/Filament/Resources/PurchaseResource/Pages/ViewPurchase.php @@ -20,11 +20,11 @@ class ViewPurchase extends ViewRecord ->color('danger') ->icon('heroicon-o-arrow-uturn-left') ->requiresConfirmation() - ->visible(fn ($record): bool => !$record->refunded) + ->visible(fn ($record): bool => ! $record->refunded) ->action(function ($record) { $record->update(['refunded' => true]); - // TODO: Call Stripe/PayPal API for actual refund + // TODO: Call Stripe/Paddle API for actual refund }), ]; } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 5da5648..a03befa 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -3,43 +3,42 @@ namespace App\Filament\Resources; use App\Filament\Resources\TenantResource\Pages; -use App\Models\Tenant; -use Filament\Resources\Resource; -use Filament\Tables; -use Filament\Tables\Table; -use Filament\Actions; -use Filament\Forms; -use Filament\Forms\Form; -use Filament\Schemas\Schema; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Toggle; -use Filament\Forms\Components\KeyValue; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\DateTimePicker; -use Filament\Tables\Columns\IconColumn; use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelationManager; use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager; -use Filament\Resources\RelationManagers\RelationGroup; -use Filament\Notifications\Notification; -use UnitEnum; +use App\Models\Tenant; use BackedEnum; +use Filament\Actions; +use Filament\Forms; +use Filament\Forms\Components\KeyValue; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Notifications\Notification; +use Filament\Resources\Resource; +use Filament\Schemas\Schema; +use Filament\Tables; +use Filament\Tables\Table; use Illuminate\Support\Facades\Route; +use UnitEnum; class TenantResource extends Resource { protected static ?string $model = Tenant::class; + protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-building-office'; + protected static UnitEnum|string|null $navigationGroup = null; public static function getNavigationGroup(): UnitEnum|string|null { return __('admin.nav.platform_management'); } + protected static ?int $navigationSort = 10; public static function form(Schema $form): Schema { - + return $form->schema([ TextInput::make('user.full_name') ->label(__('admin.tenants.fields.name')) @@ -61,6 +60,11 @@ class TenantResource extends Resource ->label(__('admin.tenants.fields.event_credits_balance')) ->numeric() ->readOnly(), + TextInput::make('paddle_customer_id') + ->label('Paddle Customer ID') + ->maxLength(191) + ->helperText('Verknuepfung mit Paddle Billing Kundenkonto.') + ->nullable(), TextInput::make('total_revenue') ->label(__('admin.tenants.fields.total_revenue')) ->prefix('€') @@ -93,7 +97,7 @@ class TenantResource extends Resource public static function table(Table $table): Table { - + return $table ->columns([ Tables\Columns\TextColumn::make('id')->sortable(), @@ -104,6 +108,10 @@ 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') + ->toggleable(isToggledHiddenByDefault: true) + ->formatStateUsing(fn ($state) => $state ?: '-'), Tables\Columns\TextColumn::make('event_credits_balance') ->label(__('admin.tenants.fields.event_credits_balance')) ->badge() @@ -162,6 +170,7 @@ class TenantResource extends Resource \App\Models\PackagePurchase::create([ 'tenant_id' => $record->id, 'package_id' => $data['package_id'], + 'provider' => 'manual', 'provider_id' => 'manual', 'type' => 'reseller_subscription', 'price' => 0, @@ -235,7 +244,7 @@ class TenantResource extends Resource public static function getRelations(): array { - + return [ TenantPackagesRelationManager::class, PackagePurchasesRelationManager::class, diff --git a/app/Filament/Resources/TenantResource/RelationManagers/PackagePurchasesRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/PackagePurchasesRelationManager.php index cc3708c..431d7be 100644 --- a/app/Filament/Resources/TenantResource/RelationManagers/PackagePurchasesRelationManager.php +++ b/app/Filament/Resources/TenantResource/RelationManagers/PackagePurchasesRelationManager.php @@ -2,23 +2,19 @@ namespace App\Filament\Resources\TenantResource\RelationManagers; -use Filament\Forms; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Toggle; -use Filament\Forms\Components\Textarea; -use Filament\Schemas\Schema; -use Filament\Resources\RelationManagers\RelationManager; -use Filament\Tables; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\ViewAction; -use Filament\Tables\Columns\TextColumn; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Resources\RelationManagers\RelationManager; +use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Relations\Relation; class PackagePurchasesRelationManager extends RelationManager { @@ -43,24 +39,23 @@ class PackagePurchasesRelationManager extends RelationManager 'reseller_subscription' => 'Reseller-Abo', ]) ->required(), - TextInput::make('purchased_price') - ->label('Gekaufter Preis') - ->numeric() - ->step(0.01) - ->prefix('€') - ->required(), - Select::make('provider_id') + Select::make('provider') ->label('Anbieter') ->options([ + 'paddle' => 'Paddle', 'stripe' => 'Stripe', - 'paypal' => 'PayPal', 'manual' => 'Manuell', 'free' => 'Kostenlos', ]) ->required(), - TextInput::make('transaction_id') - ->label('Transaktions-ID') + TextInput::make('provider_id') + ->label('Provider-Referenz') ->maxLength(255), + TextInput::make('price') + ->label('Preis') + ->numeric() + ->step(0.01) + ->prefix('€'), Toggle::make('refunded') ->label('Rückerstattet'), Textarea::make('metadata') @@ -82,7 +77,7 @@ class PackagePurchasesRelationManager extends RelationManager ->color('success'), TextColumn::make('type') ->badge() - ->color(fn (string $state): string => match($state) { + ->color(fn (string $state): string => match ($state) { 'endcustomer_event' => 'info', 'reseller_subscription' => 'success', default => 'gray', @@ -90,15 +85,17 @@ class PackagePurchasesRelationManager extends RelationManager TextColumn::make('price') ->money('EUR') ->sortable(), - TextColumn::make('provider_id') + TextColumn::make('provider') ->badge() - ->color(fn (string $state): string => match($state) { + ->color(fn (string $state): string => match ($state) { + 'paddle' => 'success', 'stripe' => 'info', - 'paypal' => 'warning', 'manual' => 'gray', 'free' => 'success', + default => 'gray', }), - TextColumn::make('transaction_id') + TextColumn::make('provider_id') + ->label('Provider-Referenz') ->copyable() ->toggleable(), TextColumn::make('metadata') @@ -117,10 +114,10 @@ class PackagePurchasesRelationManager extends RelationManager 'endcustomer_event' => 'Endkunden-Event', 'reseller_subscription' => 'Reseller-Abo', ]), - SelectFilter::make('provider_id') + SelectFilter::make('provider') ->options([ + 'paddle' => 'Paddle', 'stripe' => 'Stripe', - 'paypal' => 'PayPal', 'manual' => 'Manuell', 'free' => 'Kostenlos', ]), @@ -141,4 +138,3 @@ class PackagePurchasesRelationManager extends RelationManager ]); } } - diff --git a/app/Filament/Resources/TenantResource/RelationManagers/TenantPackagesRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/TenantPackagesRelationManager.php index 1a4814c..f16bd7e 100644 --- a/app/Filament/Resources/TenantResource/RelationManagers/TenantPackagesRelationManager.php +++ b/app/Filament/Resources/TenantResource/RelationManagers/TenantPackagesRelationManager.php @@ -2,24 +2,21 @@ namespace App\Filament\Resources\TenantResource\RelationManagers; -use Filament\Forms; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\DateTimePicker; -use Filament\Forms\Components\Toggle; -use Filament\Forms\Components\Textarea; -use Filament\Schemas\Schema; -use Filament\Resources\RelationManagers\RelationManager; -use Filament\Tables; use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; -use Filament\Tables\Columns\TextColumn; +use Filament\Forms\Components\DateTimePicker; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Resources\RelationManagers\RelationManager; +use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Relations\Relation; class TenantPackagesRelationManager extends RelationManager { @@ -40,6 +37,11 @@ class TenantPackagesRelationManager extends RelationManager DateTimePicker::make('expires_at') ->label('Ablaufdatum') ->required(), + TextInput::make('paddle_subscription_id') + ->label('Paddle Subscription ID') + ->maxLength(191) + ->helperText('Abonnement-ID aus Paddle Billing.') + ->nullable(), Toggle::make('active') ->label('Aktiv'), Textarea::make('reason') @@ -70,6 +72,10 @@ class TenantPackagesRelationManager extends RelationManager TextColumn::make('expires_at') ->dateTime() ->sortable(), + TextColumn::make('paddle_subscription_id') + ->label('Paddle Subscription') + ->toggleable(isToggledHiddenByDefault: true) + ->formatStateUsing(fn ($state) => $state ?: '-'), IconColumn::make('active') ->boolean() ->color(fn (bool $state): string => $state ? 'success' : 'danger'), @@ -105,4 +111,4 @@ class TenantPackagesRelationManager extends RelationManager ]), ]); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/PackageController.php b/app/Http/Controllers/Api/PackageController.php index 6bc5c17..481b407 100644 --- a/app/Http/Controllers/Api/PackageController.php +++ b/app/Http/Controllers/Api/PackageController.php @@ -5,21 +5,17 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Package; use App\Models\PackagePurchase; -use App\Models\Tenant; use App\Models\TenantPackage; +use App\Services\Paddle\PaddleCheckoutService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; -use PayPal\Checkout\Orders\OrdersCaptureRequest; -use PayPal\Checkout\Orders\OrdersCreateRequest; -use PayPal\Environment\LiveEnvironment; -use PayPal\Environment\SandboxEnvironment; -use PayPal\PayPalClient; class PackageController extends Controller { + public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {} + public function index(Request $request): JsonResponse { $type = $request->query('type', 'endcustomer'); @@ -51,7 +47,7 @@ class PackageController extends Controller $request->validate([ 'package_id' => 'required|exists:packages,id', 'type' => 'required|in:endcustomer,reseller', - 'payment_method' => 'required|in:stripe,paypal', + 'payment_method' => 'required|in:stripe,paddle', 'event_id' => 'nullable|exists:events,id', // For endcustomer ]); @@ -105,8 +101,8 @@ class PackageController extends Controller { $request->validate([ 'package_id' => 'required|exists:packages,id', - 'payment_method_id' => 'required_without:paypal_order_id|string', - 'paypal_order_id' => 'required_without:payment_method_id|string', + 'payment_method_id' => 'required_without:paddle_transaction_id|string', + 'paddle_transaction_id' => 'required_without:payment_method_id|string', ]); $package = Package::findOrFail($request->package_id); @@ -116,13 +112,14 @@ class PackageController extends Controller throw ValidationException::withMessages(['tenant' => 'Tenant not found.']); } - $provider = $request->has('paypal_order_id') ? 'paypal' : 'stripe'; + $provider = $request->has('paddle_transaction_id') ? 'paddle' : 'stripe'; DB::transaction(function () use ($request, $package, $tenant, $provider) { PackagePurchase::create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, - 'provider_id' => $request->input($provider === 'paypal' ? 'paypal_order_id' : 'payment_method_id'), + 'provider' => $provider, + 'provider_id' => $request->input($provider === 'paddle' ? 'paddle_transaction_id' : 'payment_method_id'), 'price' => $package->price, 'type' => 'endcustomer_event', 'purchased_at' => now(), @@ -165,6 +162,7 @@ class PackageController extends Controller PackagePurchase::create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, + 'provider' => 'free', 'provider_id' => 'free_wizard', 'price' => $package->price, 'type' => 'endcustomer_event', @@ -186,156 +184,33 @@ class PackageController extends Controller ], 201); } - public function createPayPalOrder(Request $request): JsonResponse + public function createPaddleCheckout(Request $request): JsonResponse { $request->validate([ 'package_id' => 'required|exists:packages,id', + 'success_url' => 'nullable|url', + 'return_url' => 'nullable|url', ]); - $package = Package::findOrFail($request->package_id); + $package = Package::findOrFail($request->integer('package_id')); $tenant = $request->attributes->get('tenant'); if (! $tenant) { - throw ValidationException::withMessages(['tenant' => 'Tenant not found.']); + throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']); } - $environment = config('services.paypal.sandbox', true) ? new SandboxEnvironment( - config('services.paypal.client_id'), - config('services.paypal.secret') - ) : new LiveEnvironment( - config('services.paypal.client_id'), - config('services.paypal.secret') - ); + if (! $package->paddle_price_id) { + throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); + } - $client = PayPalClient::client($environment); - - $request = new OrdersCreateRequest; - $request->prefer('return=representation'); - $request->body = [ - 'intent' => 'CAPTURE', - 'purchase_units' => [[ - 'amount' => [ - 'currency_code' => 'EUR', - 'value' => number_format($package->price, 2, '.', ''), - ], - 'description' => 'Fotospiel Package: '.$package->name, - 'custom_id' => json_encode([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'user_id' => $tenant->user_id ?? null, - ]), - ]], - 'application_context' => [ - 'shipping_preference' => 'NO_SHIPPING', - 'user_action' => 'PAY_NOW', - ], + $payload = [ + 'success_url' => $request->input('success_url'), + 'return_url' => $request->input('return_url'), ]; - try { - $response = $client->execute($request); - $order = $response->result; + $checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload); - return response()->json([ - 'orderID' => $order->id, - ]); - } catch (\Exception $e) { - Log::error('PayPal order creation error: '.$e->getMessage()); - throw ValidationException::withMessages(['payment' => 'PayPal-Bestellung fehlgeschlagen.']); - } - } - - public function capturePayPalOrder(Request $request): JsonResponse - { - $request->validate([ - 'order_id' => 'required|string', - ]); - - $orderId = $request->order_id; - - $environment = config('services.paypal.sandbox', true) ? new SandboxEnvironment( - config('services.paypal.client_id'), - config('services.paypal.secret') - ) : new LiveEnvironment( - config('services.paypal.client_id'), - config('services.paypal.secret') - ); - - $client = PayPalClient::client($environment); - - $request = new OrdersCaptureRequest($orderId); - $request->prefer('return=representation'); - - try { - $response = $client->execute($request); - $capture = $response->result; - - if ($capture->status !== 'COMPLETED') { - throw new \Exception('PayPal capture not completed: '.$capture->status); - } - - $customId = $capture->purchaseUnits[0]->customId ?? null; - if (! $customId) { - throw new \Exception('No metadata in PayPal order'); - } - - $metadata = json_decode($customId, true); - $tenant = Tenant::find($metadata['tenant_id']); - $package = Package::find($metadata['package_id']); - - if (! $tenant || ! $package) { - throw new \Exception('Tenant or package not found'); - } - - // Idempotent check - $existing = PackagePurchase::where('provider_id', $orderId)->first(); - if ($existing) { - return response()->json(['success' => true, 'message' => 'Already processed']); - } - - DB::transaction(function () use ($tenant, $package, $orderId) { - PackagePurchase::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'provider_id' => $orderId, - 'price' => $package->price, - 'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription', - 'purchased_at' => now(), - 'metadata' => json_encode(['paypal_order' => $orderId]), - ]); - - // Trial logic for first reseller subscription - $activePackages = TenantPackage::where('tenant_id', $tenant->id) - ->where('active', true) - ->where('package_id', '!=', $package->id) // Exclude current if renewing - ->count(); - - $expiresAt = now()->addYear(); - if ($activePackages === 0 && $package->type === 'reseller_subscription') { - $expiresAt = now()->addDays(14); // 14-day trial - Log::info('PayPal trial activated for tenant', ['tenant_id' => $tenant->id]); - } - - TenantPackage::updateOrCreate( - ['tenant_id' => $tenant->id, 'package_id' => $package->id], - [ - 'price' => $package->price, - 'purchased_at' => now(), - 'active' => true, - 'expires_at' => $expiresAt, - ] - ); - - $tenant->update(['subscription_status' => 'active']); - }); - - Log::info('PayPal order captured successfully', ['order_id' => $orderId, 'tenant_id' => $tenant->id]); - - return response()->json(['success' => true, 'message' => 'Payment successful']); - } catch (\Exception $e) { - Log::error('PayPal capture error: '.$e->getMessage(), ['order_id' => $orderId]); - - return response()->json(['success' => false, 'message' => 'Capture failed: '.$e->getMessage()], 422); - } + return response()->json($checkout); } private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse @@ -345,6 +220,7 @@ class PackageController extends Controller 'tenant_id' => $tenant->id, 'event_id' => $request->event_id, 'package_id' => $package->id, + 'provider' => 'free', 'provider_id' => 'free', 'price' => $package->price, 'type' => $request->type, @@ -397,7 +273,4 @@ class PackageController extends Controller return $response; } } - - // Helper for PayPal client - add this if not exists, or use global - // But since SDK has PayPalClient, assume it's used } diff --git a/app/Http/Controllers/Api/TenantBillingController.php b/app/Http/Controllers/Api/TenantBillingController.php new file mode 100644 index 0000000..9d06f43 --- /dev/null +++ b/app/Http/Controllers/Api/TenantBillingController.php @@ -0,0 +1,63 @@ +attributes->get('tenant'); + + if (! $tenant) { + return response()->json([ + 'data' => [], + 'message' => 'Tenant not found.', + ], 404); + } + + if (! $tenant->paddle_customer_id) { + return response()->json([ + 'data' => [], + 'message' => 'Tenant has no Paddle customer identifier.', + ]); + } + + $cursor = $request->query('cursor'); + $perPage = (int) $request->query('per_page', 25); + + $query = [ + 'per_page' => max(1, min($perPage, 100)), + ]; + + if ($cursor) { + $query['after'] = $cursor; + } + + try { + $result = $this->paddleTransactions->listForCustomer($tenant->paddle_customer_id, $query); + } catch (\Throwable $exception) { + Log::warning('Failed to load Paddle transactions', [ + 'tenant_id' => $tenant->id, + 'error' => $exception->getMessage(), + ]); + + return response()->json([ + 'data' => [], + 'message' => 'Failed to load Paddle transactions.', + ], 502); + } + + return response()->json([ + 'data' => $result['data'], + 'meta' => $result['meta'], + ]); + } +} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index ca6068f..e51ae2d 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -3,19 +3,18 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Models\Tenant; use App\Models\User; use Illuminate\Auth\Events\Registered; use Illuminate\Http\Request; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Str; use Illuminate\Validation\Rules; use Inertia\Inertia; use Inertia\Response; -use App\Models\Tenant; -use Illuminate\Support\Str; -use Illuminate\Support\Facades\Validator; -use Illuminate\Support\Facades\App; class RegisteredUserController extends Controller { @@ -38,7 +37,7 @@ class RegisteredUserController extends Controller */ public function store(Request $request) { - $fullName = trim($request->first_name . ' ' . $request->last_name); + $fullName = trim($request->first_name.' '.$request->last_name); $validated = $request->validate([ 'username' => ['required', 'string', 'max:255', 'unique:'.User::class], @@ -73,7 +72,7 @@ class RegisteredUserController extends Controller $tenant = Tenant::create([ 'user_id' => $user->id, 'name' => $fullName, - 'slug' => Str::slug($fullName . '-' . now()->timestamp), + 'slug' => Str::slug($fullName.'-'.now()->timestamp), 'email' => $request->email, 'is_active' => true, 'is_suspended' => false, @@ -123,6 +122,7 @@ class RegisteredUserController extends Controller 'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription', 'price' => 0, 'purchased_at' => now(), + 'provider' => 'free', 'provider_id' => 'free', ]); @@ -146,8 +146,3 @@ class RegisteredUserController extends Controller return Inertia::location(route('verification.notice')); } } - - - - - diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 4344fc1..2cc4bb4 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -7,22 +7,16 @@ use App\Models\AbandonedCheckout; use App\Models\Package; use App\Models\Tenant; use App\Models\User; -use App\Http\Controllers\Auth\AuthenticatedSessionController; +use App\Support\Concerns\PresentsPackages; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Illuminate\Validation\Rules\Password; use Inertia\Inertia; -use Illuminate\Support\Str; -use Stripe\PaymentIntent; -use Stripe\Stripe; - -use App\Http\Controllers\PayPalController; -use App\Support\Concerns\PresentsPackages; class CheckoutController extends Controller { @@ -32,6 +26,7 @@ class CheckoutController extends Controller { $googleStatus = session()->pull('checkout_google_status'); $googleError = session()->pull('checkout_google_error'); + $googleProfile = session()->pull('checkout_google_profile'); $packageOptions = Package::orderBy('price')->get() ->map(fn (Package $pkg) => $this->presentPackage($pkg)) @@ -41,8 +36,6 @@ class CheckoutController extends Controller return Inertia::render('marketing/CheckoutWizardPage', [ 'package' => $this->presentPackage($package), 'packageOptions' => $packageOptions, - 'stripePublishableKey' => config('services.stripe.key'), - 'paypalClientId' => config('services.paypal.client_id'), 'privacyHtml' => view('legal.datenschutz-partial')->render(), 'auth' => [ 'user' => Auth::user(), @@ -50,6 +43,11 @@ class CheckoutController extends Controller 'googleAuth' => [ 'status' => $googleStatus, 'error' => $googleError, + 'profile' => $googleProfile, + ], + 'paddle' => [ + 'environment' => config('paddle.environment'), + 'client_token' => config('paddle.client_token'), ], ]); } @@ -58,9 +56,16 @@ class CheckoutController extends Controller { $validator = Validator::make($request->all(), [ 'email' => 'required|email|unique:users,email', + 'username' => 'required|string|max:255|unique:users,username', 'password' => ['required', 'confirmed', Password::defaults()], + 'first_name' => 'required|string|max:255', + 'last_name' => 'required|string|max:255', + 'address' => 'required|string|max:500', + 'phone' => 'required|string|max:255', 'package_id' => 'required|exists:packages,id', 'terms' => 'required|accepted', + 'privacy_consent' => 'required|accepted', + 'locale' => 'nullable|string|max:10', ]); if ($validator->fails()) { @@ -72,43 +77,50 @@ class CheckoutController extends Controller $package = Package::findOrFail($request->package_id); $validated = $validator->validated(); DB::transaction(function () use ($request, $package, $validated) { - + // User erstellen $user = User::create([ 'email' => $request->email, + 'username' => $validated['username'], + 'first_name' => $validated['first_name'], + 'last_name' => $validated['last_name'], + 'name' => trim($validated['first_name'].' '.$validated['last_name']), + 'address' => $validated['address'], + 'phone' => $validated['phone'], + 'preferred_locale' => $validated['locale'] ?? null, 'password' => Hash::make($request->password), 'pending_purchase' => true, ]); // Tenant erstellen $tenant = Tenant::create([ - 'user_id' => $user->id, - 'name' => $validated['first_name'] . ' ' . $validated['last_name'], - 'slug' => Str::slug($validated['first_name'] . ' ' . $validated['last_name'] . '-' . now()->timestamp), - 'email' => $validated['email'], - 'is_active' => true, - 'is_suspended' => false, - 'event_credits_balance' => 0, - 'subscription_tier' => 'free', - 'subscription_expires_at' => null, - 'settings' => json_encode([ - 'branding' => [ - 'logo_url' => null, - 'primary_color' => '#3B82F6', - 'secondary_color' => '#1F2937', - 'font_family' => 'Inter, sans-serif', - ], - 'features' => [ - 'photo_likes_enabled' => false, - 'event_checklist' => false, - 'custom_domain' => false, - 'advanced_analytics' => false, - ], - 'custom_domain' => null, - 'contact_email' => $validated['email'], - 'event_default_type' => 'general', - ]), - ]); + 'user_id' => $user->id, + 'name' => $validated['first_name'].' '.$validated['last_name'], + 'slug' => Str::slug($validated['first_name'].' '.$validated['last_name'].'-'.now()->timestamp), + 'email' => $validated['email'], + 'is_active' => true, + 'is_suspended' => false, + 'event_credits_balance' => 0, + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + 'settings' => json_encode([ + 'branding' => [ + 'logo_url' => null, + 'primary_color' => '#3B82F6', + 'secondary_color' => '#1F2937', + 'font_family' => 'Inter, sans-serif', + ], + 'features' => [ + 'photo_likes_enabled' => false, + 'event_checklist' => false, + 'custom_domain' => false, + 'advanced_analytics' => false, + ], + 'custom_domain' => null, + 'contact_email' => $validated['email'], + 'event_default_type' => 'general', + ]), + ]); $user->forceFill(['tenant_id' => $tenant->id])->save(); // Package zuweisen @@ -151,12 +163,12 @@ class CheckoutController extends Controller // Custom Auth für Identifier (E-Mail oder Username) $identifier = $request->identifier; $user = User::where('email', $identifier) - ->orWhere('username', $identifier) - ->first(); + ->orWhere('username', $identifier) + ->first(); - if (!$user || !Hash::check($request->password, $user->password)) { + if (! $user || ! Hash::check($request->password, $user->password)) { return response()->json([ - 'errors' => ['identifier' => ['Ungültige Anmeldedaten.']] + 'errors' => ['identifier' => ['Ungültige Anmeldedaten.']], ], 422); } @@ -165,7 +177,7 @@ class CheckoutController extends Controller // Checkout-spezifische Logik DB::transaction(function () use ($request, $user, $packageId) { - if ($packageId && !$user->pending_purchase) { + if ($packageId && ! $user->pending_purchase) { $user->update(['pending_purchase' => true]); $request->session()->put('pending_package_id', $packageId); } @@ -242,165 +254,6 @@ class CheckoutController extends Controller return response()->json(['status' => 'tracked']); } - public function createPaymentIntent(Request $request) - { - $request->validate([ - 'package_id' => 'required|exists:packages,id', - ]); - - $package = Package::findOrFail($request->package_id); - - \Log::info('Create Payment Intent', [ - 'package_id' => $package->id, - 'package_name' => $package->name, - 'price' => $package->price, - 'is_free' => $package->is_free, - 'user_id' => Auth::id(), - ]); - - $isFreePackage = $this->packageIsFree($package); - - if ($isFreePackage) { - \Log::info('Free package detected, returning null client_secret'); - return response()->json([ - 'client_secret' => null, - 'free_package' => true, - ]); - } - - // Stripe API Key setzen - Stripe::setApiKey(config('services.stripe.secret')); - - try { - $paymentIntent = PaymentIntent::create([ - 'amount' => $package->price * 100, // Stripe erwartet Cent - 'currency' => 'eur', - 'metadata' => [ - 'package_id' => $package->id, - 'user_id' => Auth::id(), - ], - ]); - - \Log::info('PaymentIntent created successfully', [ - 'payment_intent_id' => $paymentIntent->id, - 'client_secret' => substr($paymentIntent->client_secret, 0, 50) . '...', - ]); - - return response()->json([ - 'client_secret' => $paymentIntent->client_secret, - ]); - } catch (\Exception $e) { - \Log::error('Stripe PaymentIntent creation failed', [ - 'error' => $e->getMessage(), - 'package_id' => $package->id, - ]); - - return response()->json([ - 'error' => 'Fehler beim Erstellen der Zahlungsdaten: ' . $e->getMessage(), - ], 500); - } - } - - public function confirmPayment(Request $request) - { - $request->validate([ - 'payment_intent_id' => 'required|string', - 'package_id' => 'required|exists:packages,id', - ]); - - // Stripe API Key setzen - Stripe::setApiKey(config('services.stripe.secret')); - - $paymentIntent = PaymentIntent::retrieve($request->payment_intent_id); - - if ($paymentIntent->status !== 'succeeded') { - return response()->json([ - 'error' => 'Zahlung nicht erfolgreich.', - ], 400); - } - - $package = Package::findOrFail($request->package_id); - $user = Auth::user(); - - // Package dem Tenant zuweisen - $user->tenant->packages()->attach($package->id, [ - 'price' => $package->price, - 'purchased_at' => now(), - 'expires_at' => now()->addYear(), - 'active' => true, - ]); - - // pending_purchase zurücksetzen - $user->update(['pending_purchase' => false]); - - return response()->json([ - 'message' => 'Zahlung erfolgreich bestätigt.', - ]); - } - - public function handlePayPalReturn(Request $request) - { - $orderId = $request->query('orderID'); - - if (!$orderId) { - return redirect('/checkout')->with('error', 'Ungültige PayPal-Rückkehr.'); - } - - $user = Auth::user(); - - if (!$user) { - return redirect('/login')->with('error', 'Bitte melden Sie sich an.'); - } - - try { - // Capture aufrufen - $paypalController = new PayPalController(); - $captureRequest = new Request(['order_id' => $orderId]); - $captureResponse = $paypalController->captureOrder($captureRequest); - - if ($captureResponse->getStatusCode() !== 200 || !isset($captureResponse->getData(true)['status']) || $captureResponse->getData(true)['status'] !== 'captured') { - Log::error('PayPal capture failed in return handler', ['order_id' => $orderId, 'response' => $captureResponse->getData(true)]); - return redirect('/checkout')->with('error', 'Zahlung konnte nicht abgeschlossen werden.'); - } - - // PackagePurchase finden (erzeugt durch captureOrder) - $purchase = \App\Models\PackagePurchase::where('provider_id', $orderId) - ->where('tenant_id', $user->tenant_id) - ->latest() - ->first(); - - if (!$purchase) { - Log::error('No PackagePurchase found after PayPal capture', ['order_id' => $orderId, 'tenant_id' => $user->tenant_id]); - return redirect('/checkout')->with('error', 'Kauf konnte nicht verifiziert werden.'); - } - - $package = \App\Models\Package::find($purchase->package_id); - - if (!$package) { - return redirect('/checkout')->with('error', 'Paket nicht gefunden.'); - } - - // TenantPackage zuweisen (ähnlich Stripe) - $user->tenant->packages()->attach($package->id, [ - 'price' => $package->price, - 'purchased_at' => now(), - 'expires_at' => now()->addYear(), - 'active' => true, - ]); - - // pending_purchase zurücksetzen - $user->update(['pending_purchase' => false]); - - Log::info('PayPal payment completed and package assigned', ['order_id' => $orderId, 'package_id' => $package->id, 'tenant_id' => $user->tenant_id]); - - return redirect('/success/' . $package->id)->with('success', 'Zahlung erfolgreich! Ihr Paket wurde aktiviert.'); - - } catch (\Exception $e) { - Log::error('Error in PayPal return handler', ['order_id' => $orderId, 'error' => $e->getMessage()]); - return redirect('/checkout')->with('error', 'Fehler beim Abschließen der Zahlung: ' . $e->getMessage()); - } - } - private function packageIsFree(Package $package): bool { if (isset($package->is_free)) { diff --git a/app/Http/Controllers/CheckoutGoogleController.php b/app/Http/Controllers/CheckoutGoogleController.php index c9542b2..2b13aa2 100644 --- a/app/Http/Controllers/CheckoutGoogleController.php +++ b/app/Http/Controllers/CheckoutGoogleController.php @@ -2,17 +2,13 @@ namespace App\Http\Controllers; -use App\Mail\Welcome; use App\Models\Package; use App\Models\Tenant; use App\Models\User; -use Illuminate\Auth\Events\Registered; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; use Laravel\Socialite\Facades\Socialite; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -52,56 +48,56 @@ class CheckoutGoogleController extends Controller } catch (\Throwable $e) { Log::warning('Google checkout login failed', ['message' => $e->getMessage()]); $this->flashError($request, __('checkout.google_error_fallback')); + return $this->redirectBackToWizard($packageId); } $email = $googleUser->getEmail(); if (! $email) { $this->flashError($request, __('checkout.google_missing_email')); + return $this->redirectBackToWizard($packageId); } - $user = DB::transaction(function () use ($googleUser, $email) { - $existing = User::where('email', $email)->first(); + $raw = $googleUser->getRaw(); + $request->session()->put('checkout_google_profile', array_filter([ + 'email' => $email, + 'name' => $googleUser->getName(), + 'given_name' => $raw['given_name'] ?? null, + 'family_name' => $raw['family_name'] ?? null, + 'avatar' => $googleUser->getAvatar(), + 'locale' => $raw['locale'] ?? null, + ])); - if ($existing) { - $existing->forceFill([ - 'name' => $googleUser->getName() ?: $existing->name, - 'pending_purchase' => true, - 'email_verified_at' => $existing->email_verified_at ?? now(), - ])->save(); + $existing = User::where('email', $email)->first(); - if (! $existing->tenant) { - $this->createTenantForUser($existing, $googleUser->getName(), $email); - } - - return $existing->fresh(); - } - - $user = User::create([ - 'name' => $googleUser->getName(), + if (! $existing) { + $request->session()->put('checkout_google_profile', array_filter([ 'email' => $email, - 'password' => Hash::make(Str::random(32)), + 'name' => $googleUser->getName(), + 'given_name' => $raw['given_name'] ?? null, + 'family_name' => $raw['family_name'] ?? null, + 'avatar' => $googleUser->getAvatar(), + 'locale' => $raw['locale'] ?? null, + ])); + + $request->session()->put('checkout_google_status', 'prefill'); + + return $this->redirectBackToWizard($packageId); + } + + $user = DB::transaction(function () use ($existing, $googleUser, $email) { + $existing->forceFill([ + 'name' => $googleUser->getName() ?: $existing->name, 'pending_purchase' => true, - 'email_verified_at' => now(), - ]); + 'email_verified_at' => $existing->email_verified_at ?? now(), + ])->save(); - event(new Registered($user)); - - $tenant = $this->createTenantForUser($user, $googleUser->getName(), $email); - - try { - Mail::to($user) - ->locale($user->preferred_locale ?? app()->getLocale()) - ->queue(new Welcome($user)); - } catch (\Throwable $exception) { - Log::warning('Failed to queue welcome mail after Google signup', [ - 'user_id' => $user->id, - 'error' => $exception->getMessage(), - ]); + if (! $existing->tenant) { + $this->createTenantForUser($existing, $googleUser->getName(), $email); } - return tap($user)->setRelation('tenant', $tenant); + return $existing->fresh(); }); if (! $user->tenant) { @@ -111,7 +107,8 @@ class CheckoutGoogleController extends Controller Auth::login($user, true); $request->session()->regenerate(); $request->session()->forget(self::SESSION_KEY); - $request->session()->put('checkout_google_status', 'success'); + $request->session()->forget('checkout_google_profile'); + $request->session()->put('checkout_google_status', 'signin'); if ($packageId) { $this->ensurePackageAttached($user, (int) $packageId); @@ -128,7 +125,7 @@ class CheckoutGoogleController extends Controller $counter = 1; while (Tenant::where('slug', $slug)->exists()) { - $slug = $slugBase . '-' . $counter; + $slug = $slugBase.'-'.$counter; $counter++; } diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index 9a0ee6a..f2323c5 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -3,42 +3,36 @@ namespace App\Http\Controllers; use App\Mail\ContactConfirmation; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Str; -use Stripe\Stripe; -use Stripe\StripeClient; -use Exception; -use PayPalHttp\Client; -use PayPalHttp\HttpException; -use PayPalCheckout\OrdersCreateRequest; -use PayPalCheckout\OrdersCaptureRequest; -use App\Models\Tenant; use App\Models\BlogPost; +use App\Models\CheckoutSession; use App\Models\Package; -use App\Models\TenantPackage; use App\Models\PackagePurchase; +use App\Models\TenantPackage; +use App\Services\Checkout\CheckoutSessionService; +use App\Services\Paddle\PaddleCheckoutService; +use App\Support\Concerns\PresentsPackages; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; +use Illuminate\Validation\ValidationException; use Inertia\Inertia; use League\CommonMark\Environment\Environment; -use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; -use League\CommonMark\Extension\Table\TableExtension; use League\CommonMark\Extension\Autolink\AutolinkExtension; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; use League\CommonMark\Extension\Strikethrough\StrikethroughExtension; +use League\CommonMark\Extension\Table\TableExtension; use League\CommonMark\Extension\TaskList\TaskListExtension; use League\CommonMark\MarkdownConverter; -use App\Support\Concerns\PresentsPackages; class MarketingController extends Controller { use PresentsPackages; - public function __construct() - { - Stripe::setApiKey(config('services.stripe.key')); - } + public function __construct( + private readonly CheckoutSessionService $checkoutSessions, + private readonly PaddleCheckoutService $paddleCheckout, + ) {} public function index() { @@ -69,7 +63,7 @@ class MarketingController extends Controller 'email' => $request->email, 'message' => $request->message, ], $locale), - function ($message) use ($request, $contactAddress, $locale) { + function ($message) use ($contactAddress, $locale) { $message->to($contactAddress) ->subject(__('emails.contact.subject', [], $locale)); } @@ -94,22 +88,22 @@ class MarketingController extends Controller */ public function buyPackages(Request $request, $packageId) { - Log::info('Buy packages called', ['auth' => Auth::check(), 'package_id' => $packageId, 'provider' => $request->input('provider', 'stripe')]); + Log::info('Buy packages called', ['auth' => Auth::check(), 'package_id' => $packageId]); $package = Package::findOrFail($packageId); - if (!Auth::check()) { + if (! Auth::check()) { return redirect()->route('register', ['package_id' => $package->id]) ->with('message', __('marketing.packages.register_required')); } $user = Auth::user(); - if (!$user->email_verified_at) { + if (! $user->email_verified_at) { return redirect()->route('verification.notice') ->with('message', __('auth.verification_required')); } $tenant = $user->tenant; - if (!$tenant) { + if (! $tenant) { abort(500, 'Tenant not found'); } @@ -130,6 +124,7 @@ class MarketingController extends Controller PackagePurchase::create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, + 'provider' => 'free', 'provider_id' => 'free', 'price' => $package->price, 'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription', @@ -140,246 +135,49 @@ class MarketingController extends Controller return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned')); } - if ($package->type === 'reseller') { - return $this->stripeSubscription($request, $packageId); + if (! $package->paddle_price_id) { + Log::warning('Package missing Paddle price id', ['package_id' => $package->id]); + + return redirect()->route('packages', ['highlight' => $package->slug]) + ->with('error', __('marketing.packages.paddle_not_configured')); } - if ($request->input('provider') === 'paypal') { - return $this->paypalCheckout($request, $packageId); - } + $session = $this->checkoutSessions->createOrResume($user, $package, [ + 'tenant' => $tenant, + ]); - return $this->checkout($request, $packageId); - } + $this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); - - - /** - * Checkout for Stripe with auth metadata. - */ - public function checkout(Request $request, $packageId) - { - $package = Package::findOrFail($packageId); - $user = Auth::user(); - $tenant = $user->tenant; - - $stripe = new StripeClient(config('services.stripe.secret')); - $session = $stripe->checkout->sessions->create([ - 'payment_method_types' => ['card'], - 'line_items' => [[ - 'price_data' => [ - 'currency' => 'eur', - 'product_data' => [ - 'name' => $package->name, - ], - 'unit_amount' => $package->price * 100, - ], - 'quantity' => 1, - ]], - 'mode' => 'payment', - 'success_url' => route('marketing.success', $packageId), - 'cancel_url' => route('packages'), + $checkout = $this->paddleCheckout->createCheckout($tenant, $package, [ + 'success_url' => route('marketing.success', ['packageId' => $package->id]), + 'return_url' => route('packages', ['highlight' => $package->slug]), 'metadata' => [ - 'user_id' => $user->id, - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'type' => $package->type, + 'checkout_session_id' => $session->id, ], ]); - Log::info('Stripe Checkout initiated', ['package_id' => $packageId, 'session_id' => $session->id, 'tenant_id' => $tenant->id]); + $session->forceFill([ + 'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_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, + ])), + ])->save(); - return redirect($session->url, 303); - } + $redirectUrl = $checkout['checkout_url'] ?? null; - /** - * PayPal checkout with v2 Orders API (one-time payment). - */ - public function paypalCheckout(Request $request, $packageId) - { - $package = Package::findOrFail($packageId); - $user = Auth::user(); - $tenant = $user->tenant; - - $client = Client::create([ - 'clientId' => config('services.paypal.client_id'), - 'clientSecret' => config('services.paypal.secret'), - 'environment' => config('services.paypal.sandbox', true) ? 'sandbox' : 'live', - ]); - - $ordersController = $client->orders(); - - $metadata = json_encode([ - 'user_id' => $user->id, - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'type' => $package->type, - ]); - - $createRequest = new OrdersCreateRequest(); - $createRequest->prefer('return=representation'); - $createRequest->body = [ - "intent" => "CAPTURE", - "purchase_units" => [[ - "amount" => [ - "currency_code" => "EUR", - "value" => number_format($package->price, 2, '.', ''), - ], - "description" => "Package: " . $package->name, - "custom_id" => $metadata, - ]], - "application_context" => [ - "return_url" => route('marketing.success', $packageId), - "cancel_url" => route('packages'), - ], - ]; - - try { - $response = $ordersController->createOrder($createRequest); - $order = $response->result; - - Log::info('PayPal Checkout initiated', ['package_id' => $packageId, 'order_id' => $order->id, 'tenant_id' => $tenant->id]); - - session(['paypal_order_id' => $order->id]); - - foreach ($order->links as $link) { - if ($link->rel === 'approve') { - return redirect($link->href); - } - } - - throw new Exception('No approve link found'); - } catch (HttpException $e) { - Log::error('PayPal Orders API error: ' . $e->getMessage()); - return back()->with('error', 'Zahlung fehlgeschlagen'); - } catch (Exception $e) { - Log::error('PayPal checkout error: ' . $e->getMessage()); - return back()->with('error', 'Zahlung fehlgeschlagen'); + if (! $redirectUrl) { + throw ValidationException::withMessages([ + 'paddle' => __('marketing.packages.paddle_checkout_failed'), + ]); } + + return redirect()->away($redirectUrl); } - /** - * Stripe subscription checkout for reseller packages. - */ - public function stripeSubscription(Request $request, $packageId) - { - $package = Package::findOrFail($packageId); - $user = Auth::user(); - $tenant = $user->tenant; - - $stripe = new StripeClient(config('services.stripe.secret')); - $session = $stripe->checkout->sessions->create([ - 'payment_method_types' => ['card'], - 'line_items' => [[ - 'price_data' => [ - 'currency' => 'eur', - 'product_data' => [ - 'name' => $package->name . ' (Annual Subscription)', - ], - 'unit_amount' => $package->price * 100, - 'recurring' => [ - 'interval' => 'year', - 'interval_count' => 1, - ], - ], - 'quantity' => 1, - ]], - 'mode' => 'subscription', - 'success_url' => route('marketing.success', $packageId), - 'cancel_url' => route('packages'), - 'metadata' => [ - 'user_id' => $user->id, - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'type' => $package->type, - 'subscription' => 'true', - ], - ]); - - return redirect($session->url, 303); - } - - public function stripeCheckout($sessionId) - { - // Handle Stripe success - return view('marketing.success', ['provider' => 'Stripe']); - } - - /** - * Handle success after payment (capture PayPal, redirect if verified). - */ public function success(Request $request, $packageId = null) { - $provider = session('paypal_order_id') ? 'paypal' : 'stripe'; - Log::info('Payment Success: Provider processed', ['provider' => $provider, 'package_id' => $packageId]); - - if (session('paypal_order_id')) { - $orderId = session('paypal_order_id'); - $client = Client::create([ - 'clientId' => config('services.paypal.client_id'), - 'clientSecret' => config('services.paypal.secret'), - 'environment' => config('services.paypal.sandbox', true) ? 'sandbox' : 'live', - ]); - - $ordersController = $client->orders(); - - $captureRequest = new OrdersCaptureRequest($orderId); - $captureRequest->prefer('return=minimal'); - - try { - $captureResponse = $ordersController->captureOrder($captureRequest); - $capture = $captureResponse->result; - - Log::info('PayPal Capture completed', ['order_id' => $orderId, 'status' => $capture->status]); - - if ($capture->status === 'COMPLETED') { - $customId = $capture->purchaseUnits[0]->customId ?? null; - if ($customId) { - $metadata = json_decode($customId, true); - $package = Package::find($metadata['package_id']); - $tenant = Tenant::find($metadata['tenant_id']); - - if ($package && $tenant) { - TenantPackage::updateOrCreate( - [ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - ], - [ - 'price' => $package->price, - 'active' => true, - 'purchased_at' => now(), - 'expires_at' => now()->addYear(), // One-time as annual for reseller too - ] - ); - - PackagePurchase::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'provider_id' => 'paypal', - 'price' => $package->price, - 'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription', - 'purchased_at' => now(), - 'refunded' => false, - ]); - - session()->forget('paypal_order_id'); - $request->session()->flash('success', __('marketing.packages.purchased_successfully', ['name' => $package->name])); - } - } - } else { - Log::error('PayPal capture failed: ' . $capture->status); - $request->session()->flash('error', 'Zahlung konnte nicht abgeschlossen werden.'); - } - } catch (HttpException $e) { - Log::error('PayPal capture error: ' . $e->getMessage()); - $request->session()->flash('error', 'Zahlung konnte nicht abgeschlossen werden.'); - } catch (\Exception $e) { - Log::error('PayPal success error: ' . $e->getMessage()); - $request->session()->flash('error', 'Fehler beim Abschliessen der Zahlung.'); - } - } - - // Common logic: Redirect to admin if verified if (Auth::check() && Auth::user()->email_verified_at) { return redirect('/event-admin')->with('success', __('marketing.success.welcome')); } @@ -392,7 +190,7 @@ class MarketingController extends Controller $locale = $request->get('locale', app()->getLocale()); Log::info('Blog Index Debug - Initial', [ 'locale' => $locale, - 'full_url' => $request->fullUrl() + 'full_url' => $request->fullUrl(), ]); $query = BlogPost::query() @@ -424,6 +222,7 @@ class MarketingController extends Controller $post->title = $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? ''; $post->excerpt = $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? ''; $post->content = $post->getTranslation('content', $locale) ?? $post->getTranslation('content', 'de') ?? ''; + // Author name is a string, no translation needed; author is loaded via with('author') return $post; }); @@ -432,7 +231,7 @@ class MarketingController extends Controller 'count' => $posts->count(), 'total' => $posts->total(), 'posts_data' => $posts->toArray(), - 'first_post_title' => $posts->count() > 0 ? $posts->first()->title : 'No posts' + 'first_post_title' => $posts->count() > 0 ? $posts->first()->title : 'No posts', ]); return Inertia::render('marketing/Blog', compact('posts')); @@ -456,24 +255,24 @@ class MarketingController extends Controller // Transform to array with translated strings for the current locale $markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? ''; - $environment = new Environment(); - $environment->addExtension(new CommonMarkCoreExtension()); - $environment->addExtension(new TableExtension()); - $environment->addExtension(new AutolinkExtension()); - $environment->addExtension(new StrikethroughExtension()); - $environment->addExtension(new TaskListExtension()); + $environment = new Environment; + $environment->addExtension(new CommonMarkCoreExtension); + $environment->addExtension(new TableExtension); + $environment->addExtension(new AutolinkExtension); + $environment->addExtension(new StrikethroughExtension); + $environment->addExtension(new TaskListExtension); $converter = new MarkdownConverter($environment); $contentHtml = (string) $converter->convert($markdown); - + // Debug log for content_html \Log::info('BlogShow Debug: content_html type and preview', [ 'type' => gettype($contentHtml), 'is_string' => is_string($contentHtml), 'length' => strlen($contentHtml ?? ''), - 'preview' => substr((string)$contentHtml, 0, 200) . '...' + 'preview' => substr((string) $contentHtml, 0, 200).'...', ]); - + $post = [ 'id' => $postModel->id, 'title' => $postModel->getTranslation('title', $locale) ?? $postModel->getTranslation('title', 'de') ?? '', @@ -484,17 +283,17 @@ class MarketingController extends Controller 'published_at' => $postModel->published_at->toDateString(), 'slug' => $postModel->slug, 'author' => $postModel->author ? [ - 'name' => $postModel->author->name + 'name' => $postModel->author->name, ] : null, ]; - + // Debug log for final postArray \Log::info('BlogShow Debug: Final post content_html', [ 'type' => gettype($post['content_html']), 'is_string' => is_string($post['content_html']), 'length' => strlen($post['content_html'] ?? ''), ]); - + return Inertia::render('marketing/BlogShow', compact('post')); } @@ -527,11 +326,11 @@ class MarketingController extends Controller 'locale' => app()->getLocale(), 'url' => request()->fullUrl(), 'route' => request()->route()->getName(), - 'isInertia' => request()->header('X-Inertia') + 'isInertia' => request()->header('X-Inertia'), ]); $validTypes = ['hochzeit', 'geburtstag', 'firmenevent']; - if (!in_array($type, $validTypes)) { + if (! in_array($type, $validTypes)) { Log::warning('Invalid occasion type accessed', ['type' => $type]); abort(404, 'Invalid occasion type'); } diff --git a/app/Http/Controllers/PaddleCheckoutController.php b/app/Http/Controllers/PaddleCheckoutController.php new file mode 100644 index 0000000..7baae02 --- /dev/null +++ b/app/Http/Controllers/PaddleCheckoutController.php @@ -0,0 +1,97 @@ +validate([ + 'package_id' => ['required', 'exists:packages,id'], + 'success_url' => ['nullable', 'url'], + 'return_url' => ['nullable', 'url'], + 'inline' => ['sometimes', 'boolean'], + ]); + + $user = Auth::user(); + $tenant = $user?->tenant; + + if (! $tenant) { + throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']); + } + + $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.']); + } + + $session = $this->sessions->createOrResume($user, $package, [ + 'tenant' => $tenant, + ]); + + $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); + + if ($request->boolean('inline')) { + $metadata = array_merge($session->provider_metadata ?? [], [ + 'mode' => 'inline', + ]); + + $session->forceFill([ + 'provider_metadata' => $metadata, + ])->save(); + + return response()->json([ + '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, + ], + 'customer' => array_filter([ + 'email' => $user->email, + 'name' => trim(($user->first_name ?? '').' '.($user->last_name ?? '')) ?: ($user->name ?? null), + ]), + ]); + } + + $checkout = $this->checkout->createCheckout($tenant, $package, [ + 'success_url' => $data['success_url'] ?? null, + 'return_url' => $data['return_url'] ?? null, + 'metadata' => [ + 'checkout_session_id' => $session->id, + ], + ]); + + $session->forceFill([ + 'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_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, + ])), + ])->save(); + + return response()->json($checkout); + } +} diff --git a/app/Http/Controllers/PaddleWebhookController.php b/app/Http/Controllers/PaddleWebhookController.php new file mode 100644 index 0000000..82da983 --- /dev/null +++ b/app/Http/Controllers/PaddleWebhookController.php @@ -0,0 +1,68 @@ +verify($request)) { + Log::warning('Paddle webhook signature verification failed'); + + return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST); + } + + $payload = $request->json()->all(); + + if (! is_array($payload)) { + return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED); + } + + $eventType = $payload['event_type'] ?? null; + $handled = false; + + if ($eventType) { + $handled = $this->webhooks->handlePaddleEvent($payload); + } + + Log::info('Paddle webhook processed', [ + 'event_type' => $eventType, + 'handled' => $handled, + ]); + + $statusCode = $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED; + + return response()->json([ + 'status' => $handled ? 'processed' : 'ignored', + ], $statusCode); + } + + protected function verify(Request $request): bool + { + $secret = config('paddle.webhook_secret'); + + if (! $secret) { + // Allow processing in sandbox or when secret not configured + return true; + } + + $signature = (string) $request->headers->get('Paddle-Webhook-Signature', ''); + + if ($signature === '') { + return false; + } + + $payload = $request->getContent(); + $expected = hash_hmac('sha256', $payload, $secret); + + return hash_equals($expected, $signature); + } +} diff --git a/app/Http/Controllers/PayPalController.php b/app/Http/Controllers/PayPalController.php deleted file mode 100644 index f655f2d..0000000 --- a/app/Http/Controllers/PayPalController.php +++ /dev/null @@ -1,264 +0,0 @@ -clientFactory = $clientFactory; - $this->client = $clientFactory->make(); - } - - public function createOrder(Request $request) - { - $request->validate([ - 'package_id' => 'required|exists:packages,id', - 'tenant_id' => 'nullable|exists:tenants,id', - ]); - - $tenant = $request->tenant_id - ? Tenant::findOrFail($request->tenant_id) - : optional(Auth::user())->tenant; - - if (! $tenant) { - return response()->json(['error' => 'Tenant context required for checkout.'], 422); - } - - $package = Package::findOrFail($request->package_id); - - $ordersController = $this->client->getOrdersController(); - - $body = OrderRequestBuilder::init( - CheckoutPaymentIntent::CAPTURE, - [ - PurchaseUnitRequestBuilder::init( - AmountWithBreakdownBuilder::init('EUR', number_format($package->price, 2, '.', '')) - ->build() - ) - ->description('Package: ' . $package->name) - ->customId(json_encode([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'type' => 'endcustomer_event' - ])) - ->build() - ] - ) - ->applicationContext( - OrderApplicationContextBuilder::init() - ->brandName('Fotospiel') - ->landingPage('BILLING') - ->build() - ) - ->build(); - - $collect = [ - 'body' => $body, - 'prefer' => 'return=representation' - ]; - - try { - $response = $ordersController->createOrder($collect); - - if ($response->getStatusCode() === 201) { - $result = $response->getResult(); - $approveLink = collect($result->links)->first(fn($link) => $link->rel === 'approve')?->href; - - return response()->json([ - 'id' => $result->id, - 'approve_url' => $approveLink, - ]); - } - - Log::error('PayPal order creation failed', ['response' => $response]); - return response()->json(['error' => 'Order creation failed'], 400); - } catch (\Exception $e) { - Log::error('PayPal order creation exception', ['error' => $e->getMessage()]); - return response()->json(['error' => 'Order creation failed'], 500); - } - } - - public function captureOrder(Request $request) - { - $request->validate(['order_id' => 'required']); - - $ordersController = $this->client->getOrdersController(); - - $collect = [ - 'id' => $request->order_id, - 'prefer' => 'return=representation' - ]; - - try { - $response = $ordersController->captureOrder($collect); - - if ($response->getStatusCode() === 201) { - $result = $response->getResult(); - $customId = $result->purchaseUnits[0]->customId ?? null; - - if ($customId) { - $metadata = json_decode($customId, true); - $tenantId = $metadata['tenant_id'] ?? null; - $packageId = $metadata['package_id'] ?? null; - $type = $metadata['type'] ?? 'endcustomer_event'; - - if ($tenantId && $packageId) { - $tenant = Tenant::findOrFail($tenantId); - $package = Package::findOrFail($packageId); - - PackagePurchase::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'provider_id' => $result->id, - 'price' => $result->purchaseUnits[0]->amount->value, - 'type' => $type, - 'purchased_at' => now(), - 'refunded' => false, - ]); - TenantPackage::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'price' => $package->price, - 'purchased_at' => now(), - 'active' => true, - ]); - - $tenant->update(['subscription_status' => 'active']); - } else { - Log::error('Invalid metadata in PayPal custom_id', ['custom_id' => $customId]); - } - - Log::info('PayPal order captured and purchase created: ' . $result->id); - } - - return response()->json(['status' => 'captured', 'order' => $result]); - } - - Log::error('PayPal order capture failed', ['response' => $response]); - return response()->json(['error' => 'Capture failed'], 400); - } catch (\Exception $e) { - Log::error('PayPal order capture exception', ['error' => $e->getMessage()]); - return response()->json(['error' => 'Capture failed'], 500); - } - } - - public function createSubscription(Request $request) - { - $request->validate([ - 'package_id' => 'required|exists:packages,id', - 'plan_id' => 'required|string', - 'tenant_id' => 'nullable|exists:tenants,id', - ]); - - $tenant = $request->tenant_id - ? Tenant::findOrFail($request->tenant_id) - : optional(Auth::user())->tenant; - - if (! $tenant) { - return response()->json(['error' => 'Tenant context required for subscription checkout.'], 422); - } - $package = Package::findOrFail($request->package_id); - - $ordersController = $this->client->getOrdersController(); - - $storedPaymentSource = new \PaypalServerSdkLib\Models\StoredPaymentSource( - 'CUSTOMER', - 'RECURRING' - ); - $storedPaymentSource->setUsage('FIRST'); - - $paymentSource = new \PaypalServerSdkLib\Models\PaymentSource(); - $paymentSource->storedPaymentSource = $storedPaymentSource; - - $body = OrderRequestBuilder::init( - CheckoutPaymentIntent::CAPTURE, - [ - PurchaseUnitRequestBuilder::init( - AmountWithBreakdownBuilder::init('EUR', number_format($package->price, 2, '.', '')) - ->build() - ) - ->description('Subscription Package: ' . $package->name) - ->customId(json_encode([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'type' => 'reseller_subscription', - 'plan_id' => $request->plan_id - ])) - ->build() - ] - ) - ->paymentSource($paymentSource) - ->applicationContext( - OrderApplicationContextBuilder::init() - ->brandName('Fotospiel') - ->landingPage('BILLING') - ->build() - ) - ->build(); - - $collect = [ - 'body' => $body, - 'prefer' => 'return=representation' - ]; - - try { - $response = $ordersController->createOrder($collect); - - if ($response->getStatusCode() === 201) { - $result = $response->getResult(); - $orderId = $result->id; - - // Initial purchase record for subscription setup - TenantPackage::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'price' => $package->price, - 'purchased_at' => now(), - 'expires_at' => now()->addYear(), // Assuming annual subscription - 'active' => true, - ]); - - PackagePurchase::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'provider_id' => $orderId . '_sub_' . $request->plan_id, // Combine for uniqueness - 'price' => $package->price, - 'type' => 'reseller_subscription', - 'purchased_at' => now(), - ]); - - $approveLink = collect($result->links)->first(fn($link) => $link->rel === 'approve')?->href; - - return response()->json([ - 'order_id' => $orderId, - 'approve_url' => $approveLink, - ]); - } - - Log::error('PayPal subscription order creation failed', ['response' => $response]); - return response()->json(['error' => 'Subscription order creation failed'], 400); - } catch (\Exception $e) { - Log::error('PayPal subscription order creation exception', ['error' => $e->getMessage()]); - return response()->json(['error' => 'Subscription order creation failed'], 500); - } - } -} diff --git a/app/Http/Controllers/PayPalWebhookController.php b/app/Http/Controllers/PayPalWebhookController.php deleted file mode 100644 index cf92488..0000000 --- a/app/Http/Controllers/PayPalWebhookController.php +++ /dev/null @@ -1,269 +0,0 @@ -validate([ - 'webhook_id' => 'required|string', - 'webhook_event' => 'required|array', - ]); - - $webhookId = $request->webhook_id; - $event = $request->webhook_event; - - $client = $this->clientFactory->make(); - - // Basic webhook validation - simplified for now - // TODO: Implement proper webhook signature verification with official SDK - $isValidWebhook = true; // Temporarily allow all webhooks for testing - - try { - if ($isValidWebhook) { - // Process the webhook event - $this->handleEvent($event); - - return response()->json(['status' => 'SUCCESS'], 200); - } else { - Log::warning('PayPal webhook verification failed', ['status' => 'basic_validation_failed']); - return response()->json(['status' => 'FAILURE'], 400); - } - } catch (\Exception $e) { - Log::error('PayPal webhook verification error: ' . $e->getMessage()); - return response()->json(['status' => 'FAILURE'], 500); - } - } - - private function handleEvent(array $event): void - { - $eventType = $event['event_type'] ?? ''; - $resource = $event['resource'] ?? []; - - Log::info('PayPal webhook received', ['event_type' => $eventType, 'resource_id' => $resource['id'] ?? 'unknown']); - - if ($this->checkoutWebhooks->handlePayPalEvent($event)) { - return; - } - - switch ($eventType) { - case 'CHECKOUT.ORDER.APPROVED': - // Handle order approval if needed - break; - - case 'PAYMENT.CAPTURE.COMPLETED': - $this->handleCaptureCompleted($resource); - break; - - case 'PAYMENT.CAPTURE.DENIED': - $this->handleCaptureDenied($resource); - break; - - case 'BILLING.SUBSCRIPTION.ACTIVATED': - // Handle subscription activation for SaaS - $this->handleSubscriptionActivated($resource); - break; - - case 'BILLING.SUBSCRIPTION.CANCELLED': - $this->handleSubscriptionCancelled($resource); - break; - - default: - Log::info('Unhandled PayPal webhook event', ['event_type' => $eventType]); - } - } - - private function handleCaptureCompleted(array $capture): void - { - $orderId = $capture['order_id'] ?? null; - if (!$orderId) { - Log::warning('No order_id in PayPal capture webhook', ['capture_id' => $capture['id'] ?? 'unknown']); - return; - } - - // Idempotent check - $purchase = PackagePurchase::where('provider_id', $orderId)->first(); - if ($purchase) { - Log::info('PayPal order already processed', ['order_id' => $orderId]); - return; - } - - // Fetch order to get custom_id - $this->processPurchaseFromOrder($orderId, 'completed'); - } - - private function handleCaptureDenied(array $capture): void - { - $orderId = $capture['id'] ?? null; - Log::warning('PayPal capture denied', ['order_id' => $orderId]); - - // Handle denial, e.g., notify tenant or refund logic if needed - // For now, log - } - - private function handleSubscriptionActivated(array $subscription): void - { - $subscriptionId = $subscription['id'] ?? null; - if (!$subscriptionId) { - return; - } - - // Update tenant subscription status - // Assume metadata has tenant_id - $customId = $subscription['custom_id'] ?? null; - if ($customId) { - $metadata = json_decode($customId, true); - $tenantId = $metadata['tenant_id'] ?? null; - - if ($tenantId) { - $tenant = Tenant::find($tenantId); - if ($tenant) { - $tenant->update(['subscription_status' => 'active']); - Log::info('PayPal subscription activated', ['subscription_id' => $subscriptionId, 'tenant_id' => $tenantId]); - } - } - } - } - - private function handleSubscriptionCancelled(array $subscription): void - { - $subscriptionId = $subscription['id'] ?? null; - if (!$subscriptionId) { - return; - } - - // Update tenant to cancelled - $customId = $subscription['custom_id'] ?? null; - if ($customId) { - $metadata = json_decode($customId, true); - $tenantId = $metadata['tenant_id'] ?? null; - - if ($tenantId) { - $tenant = Tenant::find($tenantId); - if ($tenant) { - $tenant->update(['subscription_status' => 'expired']); - // Deactivate TenantPackage - TenantPackage::where('tenant_id', $tenantId)->update(['active' => false]); - Log::info('PayPal subscription cancelled', ['subscription_id' => $subscriptionId, 'tenant_id' => $tenantId]); - } - } - } - } - - private function processPurchaseFromOrder(string $orderId, string $status): void - { - // Fetch order details - $client = $this->clientFactory->make(); - - $ordersController = $client->getOrdersController(); - - try { - $response = $ordersController->showOrder([ - 'id' => $orderId, - 'prefer' => 'return=representation' - ]); - $order = method_exists($response, 'getResult') ? $response->getResult() : ($response->result ?? null); - - if (! $order) { - Log::error('No order payload returned for PayPal order', ['order_id' => $orderId]); - return; - } - - $customId = $order->purchaseUnits[0]->customId ?? null; - if (!$customId) { - Log::error('No custom_id in PayPal order', ['order_id' => $orderId]); - return; - } - - $metadata = json_decode($customId, true); - $tenantId = $metadata['tenant_id'] ?? null; - $packageId = $metadata['package_id'] ?? null; - - if (!$tenantId || !$packageId) { - Log::error('Missing metadata in PayPal order', ['order_id' => $orderId, 'metadata' => $metadata]); - return; - } - - $tenant = Tenant::find($tenantId); - $package = Package::find($packageId); - - if (!$tenant || !$package) { - Log::error('Tenant or package not found for PayPal order', ['order_id' => $orderId]); - return; - } - - $operation = function () use ($tenant, $package, $orderId, $status) { - // Idempotent check - $existing = PackagePurchase::where('provider_id', $orderId)->first(); - if ($existing) { - return; - } - - PackagePurchase::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'provider_id' => $orderId, - 'price' => $package->price, - 'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription', - 'purchased_at' => now(), - 'status' => $status, - 'metadata' => json_encode(['paypal_order' => $orderId, 'webhook' => true]), - ]); - - // For trial: if first purchase and reseller, set trial - $activePackages = TenantPackage::where('tenant_id', $tenant->id) - ->where('active', true) - ->count(); - - $expiresAt = now()->addYear(); - if ($activePackages === 0 && $package->type === 'reseller_subscription') { - $expiresAt = now()->addDays(14); // Trial - } - - TenantPackage::updateOrCreate( - ['tenant_id' => $tenant->id, 'package_id' => $package->id], - [ - 'price' => $package->price, - 'purchased_at' => now(), - 'active' => true, - 'expires_at' => $expiresAt, - ] - ); - - $tenant->update(['subscription_status' => 'active']); - }; - - $connection = DB::connection(); - if ($connection->getDriverName() === 'sqlite' && $connection->transactionLevel() > 0) { - $operation(); - } else { - $connection->transaction($operation); - } - - Log::info('PayPal purchase processed via webhook', ['order_id' => $orderId, 'tenant_id' => $tenantId, 'status' => $status]); - - } catch (\Exception $e) { - Log::error('Error processing PayPal order in webhook: ' . $e->getMessage(), ['order_id' => $orderId]); - } - } -} diff --git a/app/Http/Middleware/ContentSecurityPolicy.php b/app/Http/Middleware/ContentSecurityPolicy.php index e52ca0d..ec1f425 100644 --- a/app/Http/Middleware/ContentSecurityPolicy.php +++ b/app/Http/Middleware/ContentSecurityPolicy.php @@ -39,6 +39,8 @@ class ContentSecurityPolicy "'nonce-{$scriptNonce}'", 'https://js.stripe.com', 'https://js.stripe.network', + 'https://cdn.paddle.com', + 'https://global.localizecdn.com', ]; $styleSources = [ @@ -51,11 +53,22 @@ class ContentSecurityPolicy "'self'", 'https://api.stripe.com', 'https://api.stripe.network', + '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://global.localizecdn.com', ]; $frameSources = [ "'self'", 'https://js.stripe.com', + 'https://checkout.paddle.com', + 'https://sandbox-checkout.paddle.com', + 'https://checkout-service.paddle.com', + 'https://sandbox-checkout-service.paddle.com', ]; $imgSources = [ diff --git a/app/Jobs/PullPackageFromPaddle.php b/app/Jobs/PullPackageFromPaddle.php new file mode 100644 index 0000000..639f8a8 --- /dev/null +++ b/app/Jobs/PullPackageFromPaddle.php @@ -0,0 +1,78 @@ +find($this->packageId); + + if (! $package) { + return; + } + + if (! $package->paddle_product_id && ! $package->paddle_price_id) { + Log::warning('Paddle pull skipped for package without linkage', ['package_id' => $package->id]); + + return; + } + + 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; + + $snapshot = $package->paddle_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, + ])->save(); + + Log::info('Paddle package pull completed', ['package_id' => $package->id]); + } catch (Throwable $exception) { + Log::error('Paddle package pull failed', [ + 'package_id' => $package->id, + 'message' => $exception->getMessage(), + 'exception' => $exception, + ]); + + $snapshot = $package->paddle_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, + ])->save(); + + throw $exception; + } + } +} diff --git a/app/Jobs/SyncPackageToPaddle.php b/app/Jobs/SyncPackageToPaddle.php new file mode 100644 index 0000000..e215fb5 --- /dev/null +++ b/app/Jobs/SyncPackageToPaddle.php @@ -0,0 +1,138 @@ +, price?: array} $options + */ + public function __construct(private readonly int $packageId, private readonly array $options = []) {} + + public function handle(PaddleCatalogService $catalog): void + { + $package = Package::query()->find($this->packageId); + + if (! $package) { + return; + } + + $dryRun = (bool) ($this->options['dry_run'] ?? false); + $productOverrides = Arr::get($this->options, 'product', []); + $priceOverrides = Arr::get($this->options, 'price', []); + + if ($dryRun) { + $this->storeDryRunSnapshot($catalog, $package, $productOverrides, $priceOverrides); + + return; + } + + $package->forceFill([ + 'paddle_sync_status' => 'syncing', + ])->save(); + + try { + $productResponse = $package->paddle_product_id + ? $catalog->updateProduct($package->paddle_product_id, $package, $productOverrides) + : $catalog->createProduct($package, $productOverrides); + + $productId = (string) ($productResponse['id'] ?? $package->paddle_product_id); + + if (! $productId) { + throw new PaddleException('Paddle product ID missing after sync.'); + } + + $package->paddle_product_id = $productId; + + $priceResponse = $package->paddle_price_id + ? $catalog->updatePrice($package->paddle_price_id, $package, array_merge($priceOverrides, ['product_id' => $productId])) + : $catalog->createPrice($package, $productId, $priceOverrides); + + $priceId = (string) ($priceResponse['id'] ?? $package->paddle_price_id); + + if (! $priceId) { + throw new PaddleException('Paddle price ID missing after sync.'); + } + + $package->forceFill([ + 'paddle_price_id' => $priceId, + 'paddle_sync_status' => 'synced', + 'paddle_synced_at' => now(), + 'paddle_snapshot' => [ + 'product' => $productResponse, + 'price' => $priceResponse, + 'payload' => [ + 'product' => $catalog->buildProductPayload($package, $productOverrides), + 'price' => $catalog->buildPricePayload($package, $productId, $priceOverrides), + ], + ], + ])->save(); + } catch (Throwable $exception) { + Log::error('Paddle 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 ?? [], [ + 'error' => [ + 'message' => $exception->getMessage(), + 'class' => $exception::class, + ], + ]), + ])->save(); + + throw $exception; + } + } + + /** + * @param array $productOverrides + * @param array $priceOverrides + */ + protected function storeDryRunSnapshot(PaddleCatalogService $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'), + $priceOverrides + ); + + $package->forceFill([ + 'paddle_sync_status' => 'dry-run', + 'paddle_synced_at' => now(), + 'paddle_snapshot' => [ + 'dry_run' => true, + 'payload' => [ + 'product' => $productPayload, + 'price' => $pricePayload, + ], + ], + ])->save(); + + Log::info('Paddle package dry-run snapshot generated', [ + 'package_id' => $package->id, + ]); + } +} diff --git a/app/Models/CheckoutSession.php b/app/Models/CheckoutSession.php index 4c28082..39213d4 100644 --- a/app/Models/CheckoutSession.php +++ b/app/Models/CheckoutSession.php @@ -15,16 +15,25 @@ class CheckoutSession extends Model use SoftDeletes; public const STATUS_DRAFT = 'draft'; + public const STATUS_AWAITING_METHOD = 'awaiting_payment_method'; + public const STATUS_REQUIRES_CUSTOMER_ACTION = 'requires_customer_action'; + public const STATUS_PROCESSING = 'processing'; + public const STATUS_COMPLETED = 'completed'; + public const STATUS_FAILED = 'failed'; + public const STATUS_CANCELLED = 'cancelled'; public const PROVIDER_NONE = 'none'; + public const PROVIDER_STRIPE = 'stripe'; - public const PROVIDER_PAYPAL = 'paypal'; + + public const PROVIDER_PADDLE = 'paddle'; + public const PROVIDER_FREE = 'free'; /** @@ -103,4 +112,4 @@ class CheckoutSession extends Model { return $this->status === self::STATUS_REQUIRES_CUSTOMER_ACTION; } -} \ No newline at end of file +} diff --git a/app/Models/Package.php b/app/Models/Package.php index 19c3c8e..889aa1e 100644 --- a/app/Models/Package.php +++ b/app/Models/Package.php @@ -2,10 +2,10 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Casts\Attribute; class Package extends Model { @@ -29,6 +29,11 @@ class Package extends Model 'description', 'description_translations', 'description_table', + 'paddle_product_id', + 'paddle_price_id', + 'paddle_sync_status', + 'paddle_synced_at', + 'paddle_snapshot', ]; protected $casts = [ @@ -45,9 +50,10 @@ class Package extends Model 'name_translations' => 'array', 'description_translations' => 'array', 'description_table' => 'array', + 'paddle_synced_at' => 'datetime', + 'paddle_snapshot' => 'array', ]; - protected function features(): Attribute { return Attribute::make( @@ -73,7 +79,6 @@ class Package extends Model ); } - public function eventPackages(): HasMany { return $this->hasMany(EventPackage::class); @@ -104,12 +109,12 @@ class Package extends Model $locale = $locale ?: app()->getLocale(); $translations = $this->name_translations ?? []; - if (!empty($translations[$locale])) { + if (! empty($translations[$locale])) { return $translations[$locale]; } foreach (['en', 'de'] as $fallback) { - if ($locale !== $fallback && !empty($translations[$fallback])) { + if ($locale !== $fallback && ! empty($translations[$fallback])) { return $translations[$fallback]; } } diff --git a/app/Models/PackagePurchase.php b/app/Models/PackagePurchase.php index 87eabf1..43d2028 100644 --- a/app/Models/PackagePurchase.php +++ b/app/Models/PackagePurchase.php @@ -16,6 +16,7 @@ class PackagePurchase extends Model 'tenant_id', 'event_id', 'package_id', + 'provider', 'provider_id', 'price', 'type', @@ -78,14 +79,14 @@ class PackagePurchase extends Model parent::boot(); static::creating(function ($purchase) { - if (!$purchase->tenant_id) { + if (! $purchase->tenant_id) { throw new \Exception('Tenant ID is required for package purchases.'); } - if (!$purchase->purchased_at) { + if (! $purchase->purchased_at) { $purchase->purchased_at = now(); } $purchase->refunded = false; }); } -} \ No newline at end of file +} diff --git a/app/Models/TenantPackage.php b/app/Models/TenantPackage.php index bbe839b..4903c8a 100644 --- a/app/Models/TenantPackage.php +++ b/app/Models/TenantPackage.php @@ -5,7 +5,6 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Carbon\Carbon; class TenantPackage extends Model { @@ -16,6 +15,7 @@ class TenantPackage extends Model protected $fillable = [ 'tenant_id', 'package_id', + 'paddle_subscription_id', 'price', 'purchased_at', 'expires_at', @@ -43,30 +43,32 @@ class TenantPackage extends Model public function isActive(): bool { - return $this->active && (!$this->expires_at || $this->expires_at->isFuture()); + return $this->active && (! $this->expires_at || $this->expires_at->isFuture()); } public function canCreateEvent(): bool { - if (!$this->isActive()) { + if (! $this->isActive()) { return false; } - if (!$this->package->isReseller()) { + if (! $this->package->isReseller()) { return false; } $maxEvents = $this->package->max_events_per_year ?? 0; + return $this->used_events < $maxEvents; } public function getRemainingEventsAttribute(): int { - if (!$this->package->isReseller()) { + if (! $this->package->isReseller()) { return 0; } $max = $this->package->max_events_per_year ?? 0; + return max(0, $max - $this->used_events); } @@ -75,19 +77,23 @@ class TenantPackage extends Model parent::boot(); static::creating(function ($tenantPackage) { - if (!$tenantPackage->purchased_at) { + if (! $tenantPackage->purchased_at) { $tenantPackage->purchased_at = now(); } - if (!$tenantPackage->expires_at && $tenantPackage->package) { + if (! $tenantPackage->expires_at && $tenantPackage->package) { $tenantPackage->expires_at = now()->addYear(); // Standard für Reseller } $tenantPackage->active = true; }); static::updating(function ($tenantPackage) { - if ($tenantPackage->isDirty('expires_at') && $tenantPackage->expires_at->isPast()) { + if ( + $tenantPackage->isDirty('expires_at') + && $tenantPackage->expires_at instanceof \Carbon\CarbonInterface + && $tenantPackage->expires_at->isPast() + ) { $tenantPackage->active = false; } }); } -} \ No newline at end of file +} diff --git a/app/Services/Checkout/CheckoutAssignmentService.php b/app/Services/Checkout/CheckoutAssignmentService.php index 1ae28ae..70986e5 100644 --- a/app/Services/Checkout/CheckoutAssignmentService.php +++ b/app/Services/Checkout/CheckoutAssignmentService.php @@ -4,8 +4,8 @@ namespace App\Services\Checkout; use App\Mail\PurchaseConfirmation; use App\Mail\Welcome; -use App\Models\CheckoutSession; use App\Models\AbandonedCheckout; +use App\Models\CheckoutSession; use App\Models\Package; use App\Models\PackagePurchase; use App\Models\Tenant; @@ -47,10 +47,19 @@ class CheckoutAssignmentService return; } + $metadata = $session->provider_metadata ?? []; + $providerReference = $options['provider_reference'] + ?? $metadata['paddle_transaction_id'] ?? null + ?? $metadata['paddle_checkout_id'] ?? null ?? $session->stripe_payment_intent_id - ?? $session->paypal_order_id - ?? 'free'; + ?? CheckoutSession::PROVIDER_FREE; + + $providerName = $options['provider'] + ?? $session->provider + ?? ($metadata['paddle_transaction_id'] ?? $metadata['paddle_checkout_id'] ? CheckoutSession::PROVIDER_PADDLE : null) + ?? ($session->stripe_payment_intent_id ? CheckoutSession::PROVIDER_STRIPE : null) + ?? CheckoutSession::PROVIDER_FREE; $purchase = PackagePurchase::updateOrCreate( [ @@ -59,6 +68,7 @@ class CheckoutAssignmentService 'provider_id' => $providerReference, ], [ + 'provider' => $providerName, 'price' => $session->amount_total, 'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event', 'purchased_at' => now(), @@ -121,6 +131,7 @@ class CheckoutAssignmentService if (! $user->tenant_id) { $user->forceFill(['tenant_id' => $user->tenant->getKey()])->save(); } + return $user->tenant; } diff --git a/app/Services/Checkout/CheckoutPaymentService.php b/app/Services/Checkout/CheckoutPaymentService.php index 505b7f7..8bf7260 100644 --- a/app/Services/Checkout/CheckoutPaymentService.php +++ b/app/Services/Checkout/CheckoutPaymentService.php @@ -11,8 +11,7 @@ class CheckoutPaymentService public function __construct( private readonly CheckoutSessionService $sessions, private readonly CheckoutAssignmentService $assignment, - ) { - } + ) {} public function initialiseStripe(CheckoutSession $session, array $payload = []): array { @@ -40,32 +39,6 @@ class CheckoutPaymentService return $session; } - public function initialisePayPal(CheckoutSession $session, array $payload = []): array - { - if ($session->provider !== CheckoutSession::PROVIDER_PAYPAL) { - $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL); - } - - // TODO: integrate PayPal Orders API and return order id + approval link - return [ - 'session_id' => $session->id, - 'status' => $session->status, - 'message' => 'PayPal integration pending implementation.', - ]; - } - - public function capturePayPal(CheckoutSession $session, array $payload = []): CheckoutSession - { - if ($session->provider !== CheckoutSession::PROVIDER_PAYPAL) { - throw new LogicException('Cannot capture PayPal payment on a non-PayPal session.'); - } - - // TODO: call PayPal capture endpoint and persist order/subscription identifiers - $this->sessions->markProcessing($session); - - return $session; - } - public function finaliseFree(CheckoutSession $session): CheckoutSession { if ($session->provider !== CheckoutSession::PROVIDER_FREE) { @@ -85,4 +58,4 @@ class CheckoutPaymentService return $session; } -} \ No newline at end of file +} diff --git a/app/Services/Checkout/CheckoutSessionService.php b/app/Services/Checkout/CheckoutSessionService.php index 5b4b169..d7cb7a1 100644 --- a/app/Services/Checkout/CheckoutSessionService.php +++ b/app/Services/Checkout/CheckoutSessionService.php @@ -35,7 +35,7 @@ class CheckoutSessionService return $existing; } - $session = new CheckoutSession(); + $session = new CheckoutSession; $session->id = (string) Str::uuid(); $session->status = CheckoutSession::STATUS_DRAFT; $session->provider = CheckoutSession::PROVIDER_NONE; @@ -69,8 +69,8 @@ class CheckoutSessionService $session->stripe_payment_intent_id = null; $session->stripe_customer_id = null; $session->stripe_subscription_id = null; - $session->paypal_order_id = null; - $session->paypal_subscription_id = null; + $session->paddle_checkout_id = null; + $session->paddle_transaction_id = null; $session->provider_metadata = []; $session->failure_reason = null; $session->expires_at = now()->addMinutes($this->sessionTtlMinutes); @@ -85,7 +85,11 @@ class CheckoutSessionService { $provider = strtolower($provider); - if (! in_array($provider, [CheckoutSession::PROVIDER_STRIPE, CheckoutSession::PROVIDER_PAYPAL, CheckoutSession::PROVIDER_FREE], true)) { + if (! in_array($provider, [ + CheckoutSession::PROVIDER_STRIPE, + CheckoutSession::PROVIDER_PADDLE, + CheckoutSession::PROVIDER_FREE, + ], true)) { throw new RuntimeException("Unsupported checkout provider [{$provider}]"); } @@ -101,7 +105,7 @@ class CheckoutSessionService return $session; } - public function markRequiresCustomerAction(CheckoutSession $session, string $reason = null): CheckoutSession + public function markRequiresCustomerAction(CheckoutSession $session, ?string $reason = null): CheckoutSession { $session->status = CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION; $session->failure_reason = $reason; @@ -213,4 +217,4 @@ class CheckoutSessionService ->orderByDesc('created_at') ->first(); } -} \ No newline at end of file +} diff --git a/app/Services/Checkout/CheckoutWebhookService.php b/app/Services/Checkout/CheckoutWebhookService.php index 2819fda..75e3646 100644 --- a/app/Services/Checkout/CheckoutWebhookService.php +++ b/app/Services/Checkout/CheckoutWebhookService.php @@ -3,17 +3,23 @@ namespace App\Services\Checkout; use App\Models\CheckoutSession; +use App\Models\Package; +use App\Models\Tenant; +use App\Models\TenantPackage; +use App\Services\Paddle\PaddleSubscriptionService; +use Carbon\Carbon; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; class CheckoutWebhookService { public function __construct( private readonly CheckoutSessionService $sessions, private readonly CheckoutAssignmentService $assignment, - ) { - } + private readonly PaddleSubscriptionService $paddleSubscriptions, + ) {} public function handleStripeEvent(array $event): bool { @@ -72,29 +78,37 @@ class CheckoutWebhookService } } - public function handlePayPalEvent(array $event): bool + public function handlePaddleEvent(array $event): bool { $eventType = $event['event_type'] ?? null; - $resource = $event['resource'] ?? []; + $data = $event['data'] ?? []; - if (! $eventType || ! is_array($resource)) { + if (! $eventType || ! is_array($data)) { return false; } - $orderId = $resource['order_id'] ?? $resource['id'] ?? null; + if (Str::startsWith($eventType, 'subscription.')) { + return $this->handlePaddleSubscriptionEvent($eventType, $data); + } - $session = $this->locatePayPalSession($resource, $orderId); + $session = $this->locatePaddleSession($data); if (! $session) { + Log::info('[CheckoutWebhook] Paddle session not resolved', [ + 'event_type' => $eventType, + 'transaction_id' => $data['id'] ?? null, + ]); + return false; } - $lockKey = "checkout:webhook:paypal:".($orderId ?: $session->id); + $transactionId = $data['id'] ?? $data['transaction_id'] ?? null; + $lockKey = 'checkout:webhook:paddle:'.($transactionId ?: $session->id); $lock = Cache::lock($lockKey, 30); if (! $lock->get()) { - Log::info('[CheckoutWebhook] PayPal lock busy', [ - 'order_id' => $orderId, + Log::info('[CheckoutWebhook] Paddle lock busy', [ + 'transaction_id' => $transactionId, 'session_id' => $session->id, ]); @@ -102,22 +116,29 @@ class CheckoutWebhookService } try { - $session->forceFill([ - 'paypal_order_id' => $orderId ?: $session->paypal_order_id, - 'provider' => CheckoutSession::PROVIDER_PAYPAL, - ])->save(); + if ($transactionId) { + $session->forceFill([ + 'paddle_transaction_id' => $transactionId, + 'provider' => CheckoutSession::PROVIDER_PADDLE, + ])->save(); + } elseif ($session->provider !== CheckoutSession::PROVIDER_PADDLE) { + $session->forceFill(['provider' => CheckoutSession::PROVIDER_PADDLE])->save(); + } $metadata = [ - 'paypal_last_event' => $eventType, - 'paypal_last_event_id' => $event['id'] ?? null, - 'paypal_last_update_at' => now()->toIso8601String(), - 'paypal_order_id' => $orderId, - 'paypal_capture_id' => $resource['id'] ?? null, + 'paddle_last_event' => $eventType, + 'paddle_transaction_id' => $transactionId, + 'paddle_status' => $data['status'] ?? null, + 'paddle_last_update_at' => now()->toIso8601String(), ]; + if (! empty($data['checkout_id'])) { + $metadata['paddle_checkout_id'] = $data['checkout_id']; + } + $this->mergeProviderMetadata($session, $metadata); - return $this->applyPayPalEvent($session, $eventType, $resource); + return $this->applyPaddleEvent($session, $eventType, $data); } finally { $lock->release(); } @@ -131,16 +152,19 @@ class CheckoutWebhookService $this->sessions->markProcessing($session, [ 'stripe_intent_status' => $intent['status'] ?? null, ]); + return true; case 'payment_intent.requires_action': $reason = $intent['next_action']['type'] ?? 'requires_action'; $this->sessions->markRequiresCustomerAction($session, $reason); + return true; case 'payment_intent.payment_failed': $failure = $intent['last_payment_error']['message'] ?? 'payment_failed'; $this->sessions->markFailed($session, $failure); + return true; case 'payment_intent.succeeded': @@ -165,25 +189,30 @@ class CheckoutWebhookService } } - protected function applyPayPalEvent(CheckoutSession $session, string $eventType, array $resource): bool + protected function applyPaddleEvent(CheckoutSession $session, string $eventType, array $data): bool { + $status = strtolower((string) ($data['status'] ?? '')); + switch ($eventType) { - case 'CHECKOUT.ORDER.APPROVED': + case 'transaction.created': + case 'transaction.processing': $this->sessions->markProcessing($session, [ - 'paypal_order_status' => $resource['status'] ?? null, + 'paddle_status' => $status ?: null, ]); + return true; - case 'PAYMENT.CAPTURE.COMPLETED': + case 'transaction.completed': if ($session->status !== CheckoutSession::STATUS_COMPLETED) { $this->sessions->markProcessing($session, [ - 'paypal_order_status' => $resource['status'] ?? null, + 'paddle_status' => $status ?: 'completed', ]); $this->assignment->finalise($session, [ - 'source' => 'paypal_webhook', - 'paypal_order_id' => $resource['order_id'] ?? null, - 'paypal_capture_id' => $resource['id'] ?? null, + 'source' => 'paddle_webhook', + 'provider' => CheckoutSession::PROVIDER_PADDLE, + 'provider_reference' => $data['id'] ?? null, + 'payload' => $data, ]); $this->sessions->markCompleted($session, now()); @@ -191,8 +220,11 @@ class CheckoutWebhookService return true; - case 'PAYMENT.CAPTURE.DENIED': - $this->sessions->markFailed($session, 'paypal_capture_denied'); + case 'transaction.failed': + case 'transaction.cancelled': + $reason = $status ?: ($eventType === 'transaction.failed' ? 'paddle_failed' : 'paddle_cancelled'); + $this->sessions->markFailed($session, $reason); + return true; default: @@ -200,6 +232,169 @@ class CheckoutWebhookService } } + protected function handlePaddleSubscriptionEvent(string $eventType, array $data): bool + { + $subscriptionId = $data['id'] ?? null; + + if (! $subscriptionId) { + return false; + } + + $metadata = $data['metadata'] ?? []; + $tenant = $this->resolveTenantFromSubscription($data, $metadata, $subscriptionId); + + if (! $tenant) { + Log::info('[CheckoutWebhook] Paddle subscription tenant not resolved', [ + 'subscription_id' => $subscriptionId, + ]); + + return false; + } + + $package = $this->resolvePackageFromSubscription($data, $metadata, $subscriptionId); + + if (! $package) { + Log::info('[CheckoutWebhook] Paddle subscription package not resolved', [ + 'subscription_id' => $subscriptionId, + ]); + + return false; + } + + $status = strtolower((string) ($data['status'] ?? '')); + $expiresAt = $this->resolveSubscriptionExpiry($data); + $startedAt = $this->resolveSubscriptionStart($data); + + $tenantPackage = TenantPackage::firstOrNew([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + ]); + + $tenantPackage->fill([ + 'paddle_subscription_id' => $subscriptionId, + 'price' => $package->price, + ]); + + $tenantPackage->expires_at = $expiresAt ?? $tenantPackage->expires_at ?? $startedAt?->copy()->addYear(); + $tenantPackage->purchased_at = $tenantPackage->purchased_at + ?? $tenant->purchases()->where('package_id', $package->id)->latest('purchased_at')->value('purchased_at') + ?? $startedAt; + + $tenantPackage->active = $this->isSubscriptionActive($status); + $tenantPackage->save(); + + if ($eventType === 'subscription.cancelled' || $eventType === 'subscription.paused') { + $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), + ])->save(); + + Log::info('[CheckoutWebhook] Paddle subscription event processed', [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'subscription_id' => $subscriptionId, + 'event_type' => $eventType, + 'status' => $status, + ]); + + return true; + } + + protected function resolveTenantFromSubscription(array $data, array $metadata, string $subscriptionId): ?Tenant + { + if (isset($metadata['tenant_id'])) { + $tenant = Tenant::find((int) $metadata['tenant_id']); + if ($tenant) { + return $tenant; + } + } + + $customerId = $data['customer_id'] ?? null; + + if ($customerId) { + $tenant = Tenant::where('paddle_customer_id', $customerId)->first(); + if ($tenant) { + return $tenant; + } + } + + $subscription = $this->paddleSubscriptions->retrieve($subscriptionId); + $customerId = Arr::get($subscription, 'data.customer_id'); + + if ($customerId) { + return Tenant::where('paddle_customer_id', $customerId)->first(); + } + + return null; + } + + protected function resolvePackageFromSubscription(array $data, array $metadata, string $subscriptionId): ?Package + { + if (isset($metadata['package_id'])) { + $package = Package::find((int) $metadata['package_id']); + if ($package) { + return $package; + } + } + + $priceId = Arr::get($data, 'items.0.price_id') ?? Arr::get($data, 'items.0.price.id'); + + if ($priceId) { + $package = Package::where('paddle_price_id', $priceId)->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'); + + if ($priceId) { + return Package::where('paddle_price_id', $priceId)->first(); + } + + return null; + } + + protected function resolveSubscriptionExpiry(array $data): ?Carbon + { + $nextBilling = Arr::get($data, 'next_billing_date') ?? Arr::get($data, 'next_payment_date'); + + if ($nextBilling) { + return Carbon::parse($nextBilling); + } + + $endsAt = Arr::get($data, 'billing_period_ends_at') ?? Arr::get($data, 'pays_out_at'); + + return $endsAt ? Carbon::parse($endsAt) : null; + } + + protected function resolveSubscriptionStart(array $data): Carbon + { + $created = Arr::get($data, 'created_at') ?? Arr::get($data, 'activated_at'); + + return $created ? Carbon::parse($created) : now(); + } + + protected function isSubscriptionActive(string $status): bool + { + return in_array($status, ['active', 'trialing'], true); + } + + protected function mapSubscriptionStatus(string $status): string + { + return match ($status) { + 'active', 'trialing' => 'active', + 'paused' => 'suspended', + 'cancelled', 'past_due', 'halted' => 'expired', + default => 'free', + }; + } + protected function mergeProviderMetadata(CheckoutSession $session, array $data): void { $session->provider_metadata = array_merge($session->provider_metadata ?? [], $data); @@ -230,42 +425,45 @@ class CheckoutWebhookService return null; } - protected function locatePayPalSession(array $resource, ?string $orderId): ?CheckoutSession + protected function locatePaddleSession(array $data): ?CheckoutSession { - if ($orderId) { - $session = CheckoutSession::query() - ->where('paypal_order_id', $orderId) - ->first(); + $metadata = $data['metadata'] ?? []; - if ($session) { + if (is_array($metadata)) { + $sessionId = $metadata['checkout_session_id'] ?? null; + + if ($sessionId && $session = CheckoutSession::find($sessionId)) { return $session; } + + $tenantId = $metadata['tenant_id'] ?? null; + $packageId = $metadata['package_id'] ?? null; + + if ($tenantId && $packageId) { + $session = CheckoutSession::query() + ->where('tenant_id', $tenantId) + ->where('package_id', $packageId) + ->whereNotIn('status', [CheckoutSession::STATUS_COMPLETED, CheckoutSession::STATUS_CANCELLED]) + ->latest() + ->first(); + + if ($session) { + return $session; + } + } } - $metadata = $this->extractPayPalMetadata($resource); - $sessionId = $metadata['checkout_session_id'] ?? null; + $checkoutId = $data['checkout_id'] ?? Arr::get($data, 'details.checkout_id'); - if ($sessionId) { - return CheckoutSession::find($sessionId); + if ($checkoutId) { + return CheckoutSession::query() + ->where('provider_metadata->paddle_checkout_id', $checkoutId) + ->first(); } return null; } - protected function extractPayPalMetadata(array $resource): array - { - $customId = $resource['custom_id'] ?? ($resource['purchase_units'][0]['custom_id'] ?? null); - if ($customId) { - $decoded = json_decode($customId, true); - if (is_array($decoded)) { - return $decoded; - } - } - - $meta = Arr::get($resource, 'supplementary_data.related_ids', []); - return is_array($meta) ? $meta : []; - } - protected function extractStripeChargeId(array $intent): ?string { $charges = $intent['charges']['data'] ?? null; @@ -276,4 +474,3 @@ class CheckoutWebhookService return null; } } - diff --git a/app/Services/Paddle/Exceptions/PaddleException.php b/app/Services/Paddle/Exceptions/PaddleException.php new file mode 100644 index 0000000..a6e8370 --- /dev/null +++ b/app/Services/Paddle/Exceptions/PaddleException.php @@ -0,0 +1,23 @@ +status; + } + + public function context(): array + { + return $this->context; + } +} diff --git a/app/Services/Paddle/PaddleCatalogService.php b/app/Services/Paddle/PaddleCatalogService.php new file mode 100644 index 0000000..756c7c2 --- /dev/null +++ b/app/Services/Paddle/PaddleCatalogService.php @@ -0,0 +1,245 @@ + + */ + public function fetchProduct(string $productId): array + { + return $this->extractEntity($this->client->get("/products/{$productId}")); + } + + /** + * @return array + */ + public function fetchPrice(string $priceId): array + { + return $this->extractEntity($this->client->get("/prices/{$priceId}")); + } + + /** + * @return array + */ + public function createProduct(Package $package, array $overrides = []): array + { + $payload = $this->buildProductPayload($package, $overrides); + + return $this->extractEntity($this->client->post('/products', $payload)); + } + + /** + * @return array + */ + public function updateProduct(string $productId, Package $package, array $overrides = []): array + { + $payload = $this->buildProductPayload($package, $overrides); + + return $this->extractEntity($this->client->patch("/products/{$productId}", $payload)); + } + + /** + * @return array + */ + public function createPrice(Package $package, string $productId, array $overrides = []): array + { + $payload = $this->buildPricePayload($package, $productId, $overrides); + + return $this->extractEntity($this->client->post('/prices', $payload)); + } + + /** + * @return array + */ + public function updatePrice(string $priceId, Package $package, array $overrides = []): array + { + $payload = $this->buildPricePayload($package, $overrides['product_id'] ?? $package->paddle_product_id, $overrides); + + return $this->extractEntity($this->client->patch("/prices/{$priceId}", $payload)); + } + + /** + * @return array + */ + public function buildProductPayload(Package $package, array $overrides = []): array + { + $payload = array_merge([ + 'name' => $this->resolveName($package, $overrides), + 'description' => $this->resolveDescription($package, $overrides), + 'tax_category' => $overrides['tax_category'] ?? 'standard', + 'type' => $overrides['type'] ?? 'standard', + 'custom_data' => $this->buildCustomData($package, $overrides['custom_data'] ?? []), + ], Arr::except($overrides, ['tax_category', 'type', 'custom_data'])); + + return $this->cleanPayload($payload); + } + + /** + * @return array + */ + public function buildPricePayload(Package $package, string $productId, array $overrides = []): array + { + $unitPrice = $overrides['unit_price'] ?? [ + 'amount' => (string) $this->priceToMinorUnits($package->price), + 'currency_code' => Str::upper((string) ($package->currency ?? 'EUR')), + ]; + + $payload = array_merge([ + 'product_id' => $productId, + 'description' => $this->resolvePriceDescription($package, $overrides), + 'unit_price' => $unitPrice, + 'custom_data' => $this->buildCustomData($package, $overrides['custom_data'] ?? []), + ], Arr::except($overrides, ['unit_price', 'description', 'custom_data'])); + + return $this->cleanPayload($payload); + } + + /** + * @param array $response + * @return array + */ + protected function extractEntity(array $response): array + { + return Arr::get($response, 'data', $response); + } + + /** + * @param array $payload + * @return array + */ + protected function cleanPayload(array $payload): array + { + $filtered = collect($payload) + ->reject(static fn ($value) => $value === null || $value === '' || $value === []) + ->all(); + + if (array_key_exists('custom_data', $filtered)) { + $filtered['custom_data'] = collect($filtered['custom_data']) + ->reject(static fn ($value) => $value === null || $value === '' || $value === []) + ->all(); + } + + return $filtered; + } + + /** + * @param array $extra + * @return array + */ + protected function buildCustomData(Package $package, array $extra = []): array + { + $base = [ + 'fotospiel_package_id' => (string) $package->id, + 'slug' => $package->slug, + 'type' => $package->type, + 'features' => $package->features, + 'limits' => array_filter([ + 'max_photos' => $package->max_photos, + 'max_guests' => $package->max_guests, + 'gallery_days' => $package->gallery_days, + 'max_tasks' => $package->max_tasks, + 'max_events_per_year' => $package->max_events_per_year, + ], static fn ($value) => $value !== null), + 'translations' => array_filter([ + 'name' => $package->name_translations, + 'description' => $package->description_translations, + ], static fn ($value) => ! empty($value)), + ]; + + return array_merge($base, $extra); + } + + protected function resolveName(Package $package, array $overrides): string + { + if (isset($overrides['name']) && is_string($overrides['name'])) { + return $overrides['name']; + } + + if (! empty($package->name)) { + return $package->name; + } + + $translations = $package->name_translations ?? []; + + return $translations['en'] ?? $translations['de'] ?? $package->slug; + } + + protected function resolveDescription(Package $package, array $overrides): string + { + if (array_key_exists('description', $overrides)) { + $value = is_string($overrides['description']) ? trim($overrides['description']) : null; + + if ($value !== null && $value !== '') { + return $value; + } + } + + if (! empty($package->description)) { + return strip_tags((string) $package->description); + } + + $translations = $package->description_translations ?? []; + + $fallback = $translations['en'] ?? $translations['de'] ?? null; + + if ($fallback !== null) { + $fallback = trim(strip_tags((string) $fallback)); + if ($fallback !== '') { + return $fallback; + } + } + + return sprintf('Fotospiel package %s', $package->slug ?? $package->id); + } + + /** + * @param array $overrides + */ + protected function resolvePriceDescription(Package $package, array $overrides): string + { + if (array_key_exists('description', $overrides)) { + $value = is_string($overrides['description']) ? trim($overrides['description']) : null; + + if ($value !== null && $value !== '') { + return $value; + } + } + + if (! empty($package->description)) { + return strip_tags((string) $package->description); + } + + $translations = $package->description_translations ?? []; + $fallback = $translations['en'] ?? $translations['de'] ?? null; + + if ($fallback !== null) { + $fallback = trim(strip_tags((string) $fallback)); + if ($fallback !== '') { + return $fallback; + } + } + + $name = $package->name ?? $package->getNameForLocale('en'); + + if ($name) { + return sprintf('%s package', trim($name)); + } + + return sprintf('Package %s', $package->slug ?? $package->id); + } + + protected function priceToMinorUnits(mixed $price): int + { + $value = is_string($price) ? (float) $price : (float) ($price ?? 0); + + return (int) round($value * 100); + } +} diff --git a/app/Services/Paddle/PaddleCheckoutService.php b/app/Services/Paddle/PaddleCheckoutService.php new file mode 100644 index 0000000..3d4ba87 --- /dev/null +++ b/app/Services/Paddle/PaddleCheckoutService.php @@ -0,0 +1,84 @@ +customers->ensureCustomerId($tenant); + + $successUrl = $options['success_url'] ?? route('marketing.success', ['packageId' => $package->id]); + $returnUrl = $options['return_url'] ?? route('packages', ['highlight' => $package->slug]); + + $metadata = $this->buildMetadata($tenant, $package, $options['metadata'] ?? []); + + $payload = [ + 'customer_id' => $customerId, + 'items' => [ + [ + 'price_id' => $package->paddle_price_id, + 'quantity' => 1, + ], + ], + 'metadata' => $metadata, + 'success_url' => $successUrl, + 'cancel_url' => $returnUrl, + ]; + + if ($tenant->contact_email) { + $payload['customer_email'] = $tenant->contact_email; + } + + $response = $this->client->post('/checkout/links', $payload); + + $checkoutUrl = 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.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/PaddleClient.php b/app/Services/Paddle/PaddleClient.php new file mode 100644 index 0000000..637c33a --- /dev/null +++ b/app/Services/Paddle/PaddleClient.php @@ -0,0 +1,82 @@ +send('GET', $endpoint, ['query' => $query]); + } + + public function post(string $endpoint, array $payload = []): array + { + return $this->send('POST', $endpoint, ['json' => $payload]); + } + + public function patch(string $endpoint, array $payload = []): array + { + return $this->send('PATCH', $endpoint, ['json' => $payload]); + } + + public function delete(string $endpoint, array $payload = []): array + { + return $this->send('DELETE', $endpoint, ['json' => $payload]); + } + + protected function send(string $method, string $endpoint, array $options = []): array + { + $request = $this->preparedRequest(); + + try { + $response = $request->send(strtoupper($method), ltrim($endpoint, '/'), $options); + } catch (RequestException $exception) { + throw new PaddleException($exception->getMessage(), $exception->response?->status(), $exception->response?->json() ?? []); + } + + if ($response->failed()) { + $body = $response->json() ?? []; + $message = Arr::get($body, 'error.message') + ?? Arr::get($body, 'message') + ?? sprintf('Paddle request failed with status %s', $response->status()); + + throw new PaddleException($message, $response->status(), $body); + } + + return $response->json() ?? []; + } + + protected function preparedRequest(): PendingRequest + { + $apiKey = config('paddle.api_key'); + if (! $apiKey) { + throw new PaddleException('Paddle 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', + ]; + + return $this->http + ->baseUrl($baseUrl) + ->withHeaders($headers) + ->withToken($apiKey) + ->acceptJson() + ->asJson(); + } +} diff --git a/app/Services/Paddle/PaddleCustomerService.php b/app/Services/Paddle/PaddleCustomerService.php new file mode 100644 index 0000000..38bd1cf --- /dev/null +++ b/app/Services/Paddle/PaddleCustomerService.php @@ -0,0 +1,41 @@ +paddle_customer_id) { + return $tenant->paddle_customer_id; + } + + $payload = [ + 'email' => $tenant->contact_email ?: ($tenant->user?->email ?? null), + 'name' => $tenant->name, + ]; + + if (! $payload['email']) { + throw new PaddleException('Tenant email address required to create Paddle customer.'); + } + + $response = $this->client->post('/customers', $payload); + $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; + } +} diff --git a/app/Services/Paddle/PaddleSubscriptionService.php b/app/Services/Paddle/PaddleSubscriptionService.php new file mode 100644 index 0000000..6c3e671 --- /dev/null +++ b/app/Services/Paddle/PaddleSubscriptionService.php @@ -0,0 +1,33 @@ + + */ + 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 + { + return Arr::get($subscription, 'data.metadata', []); + } +} diff --git a/app/Services/Paddle/PaddleTransactionService.php b/app/Services/Paddle/PaddleTransactionService.php new file mode 100644 index 0000000..305a439 --- /dev/null +++ b/app/Services/Paddle/PaddleTransactionService.php @@ -0,0 +1,92 @@ +>, meta: array} + */ + public function listForCustomer(string $customerId, array $query = []): array + { + $payload = array_filter(array_merge([ + 'customer_id' => $customerId, + 'order_by' => '-created_at', + ], $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), + ]; + } + + /** + * @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/Services/PayPal/PaypalClientFactory.php b/app/Services/PayPal/PaypalClientFactory.php deleted file mode 100644 index a95b341..0000000 --- a/app/Services/PayPal/PaypalClientFactory.php +++ /dev/null @@ -1,27 +0,0 @@ -clientCredentialsAuthCredentials( - ClientCredentialsAuthCredentialsBuilder::init($clientId, $clientSecret) - ) - ->environment($environment) - ->build(); - } -} diff --git a/app/Support/Concerns/PresentsPackages.php b/app/Support/Concerns/PresentsPackages.php index 729c328..0be2854 100644 --- a/app/Support/Concerns/PresentsPackages.php +++ b/app/Support/Concerns/PresentsPackages.php @@ -43,6 +43,8 @@ trait PresentsPackages 'slug' => $package->slug, 'type' => $package->type, 'price' => $package->price, + 'paddle_product_id' => $package->paddle_product_id, + 'paddle_price_id' => $package->paddle_price_id, 'description' => $description, 'description_breakdown' => $table, 'gallery_duration_label' => $galleryDuration, @@ -111,20 +113,20 @@ trait PresentsPackages { $locale = app()->getLocale(); - if (!$days || $days <= 0) { + if (! $days || $days <= 0) { return $locale === 'en' ? 'permanent' : 'dauerhaft'; } if ($days % 30 === 0) { $months = (int) ($days / 30); if ($locale === 'en') { - return $months === 1 ? '1 month' : $months . ' months'; + return $months === 1 ? '1 month' : $months.' months'; } - return $months === 1 ? '1 Monat' : $months . ' Monate'; + return $months === 1 ? '1 Monat' : $months.' Monate'; } - return $locale === 'en' ? $days . ' days' : $days . ' Tage'; + return $locale === 'en' ? $days.' days' : $days.' Tage'; } protected function normaliseFeatures(mixed $features): array @@ -144,6 +146,7 @@ trait PresentsPackages foreach ($features as $key => $value) { if (is_string($value)) { $list[] = $value; + continue; } diff --git a/composer.json b/composer.json index 4762097..313e4a6 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,6 @@ "laravel/tinker": "^2.10.1", "laravel/wayfinder": "^0.1.9", "league/commonmark": "^2.7", - "paypal/paypal-server-sdk": "^1.1", "simplesoftwareio/simple-qrcode": "^4.2", "spatie/laravel-translatable": "^6.11", "staudenmeir/belongs-to-through": "^2.17", diff --git a/composer.lock b/composer.lock index e8027bb..08571d9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c4ce377acba80c944149cab30605d24c", + "content-hash": "5409eee4f26e2827449d85cf6b40209d", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -72,222 +72,6 @@ }, "time": "2025-07-30T15:45:57+00:00" }, - { - "name": "apimatic/core", - "version": "0.3.14", - "source": { - "type": "git", - "url": "https://github.com/apimatic/core-lib-php.git", - "reference": "c3eaad6cf0c00b793ce6d9bee8b87176247da582" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/apimatic/core-lib-php/zipball/c3eaad6cf0c00b793ce6d9bee8b87176247da582", - "reference": "c3eaad6cf0c00b793ce6d9bee8b87176247da582", - "shasum": "" - }, - "require": { - "apimatic/core-interfaces": "~0.1.5", - "apimatic/jsonmapper": "^3.1.1", - "ext-curl": "*", - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "php": "^7.2 || ^8.0", - "php-jsonpointer/php-jsonpointer": "^3.0.2", - "psr/log": "^1.1.4 || ^2.0.0 || ^3.0.0" - }, - "require-dev": { - "phan/phan": "5.4.5", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "squizlabs/php_codesniffer": "^3.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Core\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Core logic and the utilities for the Apimatic's PHP SDK", - "homepage": "https://github.com/apimatic/core-lib-php", - "keywords": [ - "apimatic", - "core", - "corelib", - "php" - ], - "support": { - "issues": "https://github.com/apimatic/core-lib-php/issues", - "source": "https://github.com/apimatic/core-lib-php/tree/0.3.14" - }, - "time": "2025-02-27T06:03:30+00:00" - }, - { - "name": "apimatic/core-interfaces", - "version": "0.1.5", - "source": { - "type": "git", - "url": "https://github.com/apimatic/core-interfaces-php.git", - "reference": "b4f1bffc8be79584836f70af33c65e097eec155c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/apimatic/core-interfaces-php/zipball/b4f1bffc8be79584836f70af33c65e097eec155c", - "reference": "b4f1bffc8be79584836f70af33c65e097eec155c", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "CoreInterfaces\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Definition of the behavior of apimatic/core, apimatic/unirest-php and Apimatic's PHP SDK", - "homepage": "https://github.com/apimatic/core-interfaces-php", - "keywords": [ - "apimatic", - "core", - "corelib", - "interface", - "php", - "unirest" - ], - "support": { - "issues": "https://github.com/apimatic/core-interfaces-php/issues", - "source": "https://github.com/apimatic/core-interfaces-php/tree/0.1.5" - }, - "time": "2024-05-09T06:32:07+00:00" - }, - { - "name": "apimatic/jsonmapper", - "version": "3.1.6", - "source": { - "type": "git", - "url": "https://github.com/apimatic/jsonmapper.git", - "reference": "c6cc21bd56bfe5d5822bbd08f514be465c0b24e7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/apimatic/jsonmapper/zipball/c6cc21bd56bfe5d5822bbd08f514be465c0b24e7", - "reference": "c6cc21bd56bfe5d5822bbd08f514be465c0b24e7", - "shasum": "" - }, - "require": { - "ext-json": "*", - "php": "^5.6 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", - "squizlabs/php_codesniffer": "^3.0.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "apimatic\\jsonmapper\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "OSL-3.0" - ], - "authors": [ - { - "name": "Christian Weiske", - "email": "christian.weiske@netresearch.de", - "homepage": "http://www.netresearch.de/", - "role": "Developer" - }, - { - "name": "Mehdi Jaffery", - "email": "mehdi.jaffery@apimatic.io", - "homepage": "http://apimatic.io/", - "role": "Developer" - } - ], - "description": "Map nested JSON structures onto PHP classes", - "support": { - "email": "mehdi.jaffery@apimatic.io", - "issues": "https://github.com/apimatic/jsonmapper/issues", - "source": "https://github.com/apimatic/jsonmapper/tree/3.1.6" - }, - "time": "2024-11-28T09:15:32+00:00" - }, - { - "name": "apimatic/unirest-php", - "version": "4.0.7", - "source": { - "type": "git", - "url": "https://github.com/apimatic/unirest-php.git", - "reference": "bdfd5f27c105772682c88ed671683f1bd93f4a3c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/apimatic/unirest-php/zipball/bdfd5f27c105772682c88ed671683f1bd93f4a3c", - "reference": "bdfd5f27c105772682c88ed671683f1bd93f4a3c", - "shasum": "" - }, - "require": { - "apimatic/core-interfaces": "^0.1.0", - "ext-curl": "*", - "ext-json": "*", - "php": "^7.2 || ^8.0" - }, - "require-dev": { - "phan/phan": "5.4.2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "squizlabs/php_codesniffer": "^3.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Unirest\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mashape", - "email": "opensource@mashape.com", - "homepage": "https://www.mashape.com", - "role": "Developer" - }, - { - "name": "APIMATIC", - "email": "opensource@apimatic.io", - "homepage": "https://www.apimatic.io", - "role": "Developer" - } - ], - "description": "Unirest PHP", - "homepage": "https://github.com/apimatic/unirest-php", - "keywords": [ - "client", - "curl", - "http", - "https", - "rest" - ], - "support": { - "email": "opensource@apimatic.io", - "issues": "https://github.com/apimatic/unirest-php/issues", - "source": "https://github.com/apimatic/unirest-php/tree/4.0.7" - }, - "time": "2025-06-17T09:09:48+00:00" - }, { "name": "bacon/bacon-qr-code", "version": "2.0.8", @@ -4974,50 +4758,6 @@ }, "time": "2020-10-15T08:29:30+00:00" }, - { - "name": "paypal/paypal-server-sdk", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/paypal/PayPal-PHP-Server-SDK.git", - "reference": "3964c1732b1815fa8cf8aee37069ccc4e95d9572" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/paypal/PayPal-PHP-Server-SDK/zipball/3964c1732b1815fa8cf8aee37069ccc4e95d9572", - "reference": "3964c1732b1815fa8cf8aee37069ccc4e95d9572", - "shasum": "" - }, - "require": { - "apimatic/core": "~0.3.13", - "apimatic/core-interfaces": "~0.1.5", - "apimatic/unirest-php": "^4.0.6", - "ext-curl": "*", - "ext-json": "*", - "php": "^7.2 || ^8.0" - }, - "require-dev": { - "phan/phan": "5.4.5", - "squizlabs/php_codesniffer": "^3.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "PaypalServerSdkLib\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PayPal's SDK for interacting with the REST APIs", - "homepage": "https://github.com/paypal/PayPal-PHP-Server-SDK", - "support": { - "issues": "https://github.com/paypal/PayPal-PHP-Server-SDK/issues", - "source": "https://github.com/paypal/PayPal-PHP-Server-SDK/tree/1.1.0" - }, - "time": "2025-05-27T17:46:31+00:00" - }, { "name": "phenx/php-font-lib", "version": "0.5.6", @@ -5108,62 +4848,6 @@ }, "time": "2022-03-07T12:52:04+00:00" }, - { - "name": "php-jsonpointer/php-jsonpointer", - "version": "v3.0.2", - "source": { - "type": "git", - "url": "https://github.com/raphaelstolt/php-jsonpointer.git", - "reference": "4428f86c6f23846e9faa5a420c4ef14e485b3afb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/raphaelstolt/php-jsonpointer/zipball/4428f86c6f23846e9faa5a420c4ef14e485b3afb", - "reference": "4428f86c6f23846e9faa5a420c4ef14e485b3afb", - "shasum": "" - }, - "require": { - "php": ">=5.4" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^1.11", - "phpunit/phpunit": "4.6.*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-0": { - "Rs\\Json": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Raphael Stolt", - "email": "raphael.stolt@gmail.com", - "homepage": "http://raphaelstolt.blogspot.com/" - } - ], - "description": "Implementation of JSON Pointer (http://tools.ietf.org/html/rfc6901)", - "homepage": "https://github.com/raphaelstolt/php-jsonpointer", - "keywords": [ - "json", - "json pointer", - "json traversal" - ], - "support": { - "issues": "https://github.com/raphaelstolt/php-jsonpointer/issues", - "source": "https://github.com/raphaelstolt/php-jsonpointer/tree/master" - }, - "time": "2016-08-29T08:51:01+00:00" - }, { "name": "phpoption/phpoption", "version": "1.9.4", diff --git a/config/paddle.php b/config/paddle.php new file mode 100644 index 0000000..9102af6 --- /dev/null +++ b/config/paddle.php @@ -0,0 +1,33 @@ + $apiKey, + 'client_token' => $clientToken, + 'environment' => $environment, + 'base_url' => $baseUrl, + 'console_url' => $consoleUrl, + 'webhook_secret' => $webhookSecret, + 'public_key' => $publicKey, +]; diff --git a/config/services.php b/config/services.php index 41fb00f..34210ab 100644 --- a/config/services.php +++ b/config/services.php @@ -43,10 +43,17 @@ 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'), + ], + 'google' => [ 'client_id' => env('GOOGLE_CLIENT_ID'), 'client_secret' => env('GOOGLE_CLIENT_SECRET'), - 'redirect' => env('GOOGLE_REDIRECT_URI', rtrim(env('APP_URL', ''), '/') . '/checkout/auth/google/callback'), + 'redirect' => env('GOOGLE_REDIRECT_URI', rtrim(env('APP_URL', ''), '/').'/checkout/auth/google/callback'), ], 'revenuecat' => [ @@ -63,11 +70,11 @@ return [ $redirects = []; $devServer = env('VITE_DEV_SERVER_URL'); - $redirects[] = rtrim($devServer ?: 'http://localhost:5173', '/') . '/event-admin/auth/callback'; + $redirects[] = rtrim($devServer ?: 'http://localhost:5173', '/').'/event-admin/auth/callback'; $appUrl = env('APP_URL'); if ($appUrl) { - $redirects[] = rtrim($appUrl, '/') . '/event-admin/auth/callback'; + $redirects[] = rtrim($appUrl, '/').'/event-admin/auth/callback'; } else { $redirects[] = 'http://localhost:8000/event-admin/auth/callback'; } diff --git a/database/factories/PackageFactory.php b/database/factories/PackageFactory.php index 57be292..08ee4d1 100644 --- a/database/factories/PackageFactory.php +++ b/database/factories/PackageFactory.php @@ -13,9 +13,10 @@ class PackageFactory extends Factory public function definition(): array { $name = $this->faker->word(); + return [ 'name' => $name, - 'slug' => Str::slug($name . '-' . uniqid()), + 'slug' => Str::slug($name.'-'.uniqid()), 'description' => $this->faker->sentence(), 'price' => $this->faker->randomFloat(2, 0, 100), 'max_photos' => $this->faker->numberBetween(100, 1000), @@ -29,6 +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, ]; } @@ -59,4 +63,4 @@ class PackageFactory extends Factory 'type' => 'reseller', ]); } -} \ No newline at end of file +} diff --git a/database/factories/PackagePurchaseFactory.php b/database/factories/PackagePurchaseFactory.php index 5a7d1bd..f44a097 100644 --- a/database/factories/PackagePurchaseFactory.php +++ b/database/factories/PackagePurchaseFactory.php @@ -16,6 +16,7 @@ class PackagePurchaseFactory extends Factory return [ 'tenant_id' => Tenant::factory(), 'package_id' => Package::factory(), + 'provider' => 'manual', 'provider_id' => $this->faker->uuid(), 'price' => $this->faker->randomFloat(2, 0, 500), 'purchased_at' => now(), 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 f027cba..c4ed7a6 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('paypal_order_id')->nullable(); - $table->string('paypal_subscription_id')->nullable(); + $table->string('paddle_checkout_id')->nullable(); + $table->string('paddle_transaction_id')->nullable(); $table->json('provider_metadata')->nullable(); $table->string('locale', 5)->nullable(); @@ -47,7 +47,8 @@ return new class extends Migration $table->softDeletes(); $table->unique('stripe_payment_intent_id'); - $table->unique('paypal_order_id'); + $table->unique('paddle_checkout_id'); + $table->unique('paddle_transaction_id'); $table->index(['provider', 'status']); $table->index('expires_at'); }); @@ -60,4 +61,4 @@ return new class extends Migration { Schema::dropIfExists('checkout_sessions'); } -}; \ No newline at end of file +}; 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 new file mode 100644 index 0000000..9c7cc3f --- /dev/null +++ b/database/migrations/2025_10_26_182621_add_paddle_columns_to_billing_tables.php @@ -0,0 +1,85 @@ +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'); + }); + } + + if (! Schema::hasColumn('tenants', 'paddle_customer_id')) { + Schema::table('tenants', function (Blueprint $table) { + $table->string('paddle_customer_id')->nullable()->after('subscription_status'); + $table->index('paddle_customer_id'); + }); + } + + if (! Schema::hasColumn('tenant_packages', 'paddle_subscription_id')) { + Schema::table('tenant_packages', function (Blueprint $table) { + $table->string('paddle_subscription_id')->nullable()->after('package_id'); + $table->index('paddle_subscription_id'); + }); + } + + if (! Schema::hasColumn('package_purchases', 'provider')) { + Schema::table('package_purchases', function (Blueprint $table) { + $table->string('provider')->nullable()->after('package_id'); + $table->index('provider'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('packages', 'paddle_price_id')) { + Schema::table('packages', function (Blueprint $table) { + $table->dropIndex('packages_paddle_price_id_index'); + $table->dropColumn('paddle_price_id'); + }); + } + + if (Schema::hasColumn('packages', 'paddle_product_id')) { + Schema::table('packages', function (Blueprint $table) { + $table->dropIndex('packages_paddle_product_id_index'); + $table->dropColumn('paddle_product_id'); + }); + } + + if (Schema::hasColumn('tenants', 'paddle_customer_id')) { + Schema::table('tenants', function (Blueprint $table) { + $table->dropIndex('tenants_paddle_customer_id_index'); + $table->dropColumn('paddle_customer_id'); + }); + } + + if (Schema::hasColumn('tenant_packages', 'paddle_subscription_id')) { + Schema::table('tenant_packages', function (Blueprint $table) { + $table->dropIndex('tenant_packages_paddle_subscription_id_index'); + $table->dropColumn('paddle_subscription_id'); + }); + } + + if (Schema::hasColumn('package_purchases', 'provider')) { + Schema::table('package_purchases', function (Blueprint $table) { + $table->dropIndex('package_purchases_provider_index'); + $table->dropColumn('provider'); + }); + } + } +}; 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 new file mode 100644 index 0000000..920be66 --- /dev/null +++ b/database/migrations/2025_10_27_090531_add_paddle_sync_columns_to_packages_table.php @@ -0,0 +1,36 @@ +string('paddle_sync_status', 50) + ->nullable() + ->after('paddle_price_id'); + $table->timestamp('paddle_synced_at') + ->nullable() + ->after('paddle_sync_status'); + $table->json('paddle_snapshot') + ->nullable() + ->after('paddle_synced_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('packages', function (Blueprint $table) { + $table->dropColumn(['paddle_sync_status', 'paddle_synced_at', 'paddle_snapshot']); + }); + } +}; diff --git a/database/seeders/DemoLifecycleSeeder.php b/database/seeders/DemoLifecycleSeeder.php index 7e03db1..1fb9887 100644 --- a/database/seeders/DemoLifecycleSeeder.php +++ b/database/seeders/DemoLifecycleSeeder.php @@ -149,6 +149,7 @@ class DemoLifecycleSeeder extends Seeder $purchase = PackagePurchase::create([ 'tenant_id' => $tenant->id, 'package_id' => $premium->id, + 'provider' => 'stripe', 'provider_id' => 'stripe_demo_pi', 'price' => $premium->price, 'type' => 'endcustomer_event', @@ -188,6 +189,7 @@ class DemoLifecycleSeeder extends Seeder 'tenant_id' => $tenant->id, 'event_id' => $draftEvent->id, 'package_id' => $standard->id, + 'provider' => 'paypal', 'provider_id' => 'paypal_demo_capture', 'price' => $standard->price, 'type' => 'endcustomer_event', @@ -223,6 +225,7 @@ class DemoLifecycleSeeder extends Seeder PackagePurchase::create([ 'tenant_id' => $tenant->id, 'package_id' => $reseller->id, + 'provider' => 'stripe', 'provider_id' => 'stripe_demo_subscription', 'price' => $reseller->price, 'type' => 'reseller_subscription', @@ -253,6 +256,7 @@ class DemoLifecycleSeeder extends Seeder 'tenant_id' => $tenant->id, 'event_id' => $event->id, 'package_id' => $standard->id, + 'provider' => 'manual', 'provider_id' => 'reseller_allowance', 'price' => 0, 'type' => 'endcustomer_event', diff --git a/docs/changes/2025-10-02-registration-role-fixes.md b/docs/changes/2025-10-02-registration-role-fixes.md index a9cd823..812e082 100644 --- a/docs/changes/2025-10-02-registration-role-fixes.md +++ b/docs/changes/2025-10-02-registration-role-fixes.md @@ -10,7 +10,7 @@ ### Backend (MarketingRegisterController.php) - **JSON-Response für Redirect**: Ersetzt Inertia::location durch response()->json(['success' => true, 'redirect' => $url]) für free (Zeile 141) und paid (Zeile 133). Kompatibel mit Inertia onSuccess (page.props.success/redirect prüfen). - **Tenant Name Fix**: 'name' => $request->first_name . ' ' . $request->last_name (Zeile 71); slug entsprechend angepasst. -- **Role-Logic**: 'role' => 'user' in User::create (Zeile 66); für free: Update zu 'tenant_admin' nach TenantPackage::create (Zeile 129), Re-Login (Zeile 130). Für paid: Kein Upgrade bis Webhook (Stripe/PayPal). +- **Role-Logic**: 'role' => 'user' in User::create (Zeile 66); für free: Update zu 'tenant_admin' nach TenantPackage::create (Zeile 129), Re-Login (Zeile 130). Für paid: Kein Upgrade bis Webhook (Paddle). - **Return-Type**: store() zu JsonResponse (Zeile 44); use JsonResponse hinzugefügt (Zeile 22). ### Frontend (Register.tsx) @@ -30,7 +30,7 @@ - **Linter/TS**: Keine Errors; Intelephense fixed durch JsonResponse use und as string cast. ## PRP-Update (docs/prp/13-backend-authentication.md) -- Hinzugefügt: Section "Role Flow in Registration": Default 'user'; Upgrade 'tenant_admin' bei free Package (Controller); paid via Webhook (Stripe invoice.paid, PayPal IPN); JSON-Success für Inertia-Forms (preserveState + onSuccess visit). +- Hinzugefügt: Section "Role Flow in Registration": Default 'user'; Upgrade 'tenant_admin' bei free Package (Controller); paid via Webhook (Stripe invoice.paid, Paddle IPN); JSON-Success für Inertia-Forms (preserveState + onSuccess visit). ## Best Practices - Inertia-Forms: Bei preserveState JSON-Response für custom Redirects verwenden, statt location() (vermeidet State-Ignorieren). diff --git a/docs/changes/2025-10-05-checkout-refactor-todo.md b/docs/changes/2025-10-05-checkout-refactor-todo.md index 50625f8..d045b39 100644 --- a/docs/changes/2025-10-05-checkout-refactor-todo.md +++ b/docs/changes/2025-10-05-checkout-refactor-todo.md @@ -8,7 +8,7 @@ ### Wizard Foundations - [x] Rebuild the package step with a side panel for comparable packages and reset payment state when the selected package changes (see `resources/js/pages/marketing/checkout/steps/PackageStep.tsx`). -- [x] Redesign the payment step: Stripe and PayPal happy path, failure, retry; add subscription handling for reseller plans. *(Stripe intent lifecycle + PayPal subscription flow now share status alerts, retry logic, and plan gating in `PaymentStep.tsx`.)* +- [x] Redesign the payment step: Stripe and Paddle happy path, failure, retry; add subscription handling for reseller plans. *(Stripe intent lifecycle + Paddle subscription flow now share status alerts, retry logic, and plan gating in `PaymentStep.tsx`.)* - [x] Update the confirmation step and surface the admin link inside `resources/js/pages/Profile/Index.tsx`. *(Handled via `ConfirmationStep.tsx` + wizard callbacks redirecting to `/settings/profile` and `/event-admin`.)* ### Authentication & Profile Data @@ -20,7 +20,7 @@ - [x] Audit existing marketing payment flows (`resources/js/pages/marketing/PurchaseWizard.tsx`, `PaymentForm.tsx`) and plan removals or migration. *(Legacy components removed; new wizard replaces them.)* ### Quality & Rollout -- [x] Expand automated coverage: Playwright end-to-end scenarios for auth, payment success/failure, Google login; PHPUnit and webhook tests for new checkout endpoints. *(Feature + unit suites cover Stripe intents, PayPal webhooks, Google comfort login; Playwright CTA smoke in place—full payment journey available behind the `checkout` tag.)* +- [x] Expand automated coverage: Playwright end-to-end scenarios for auth, payment success/failure, Google login; PHPUnit and webhook tests for new checkout endpoints. *(Feature + unit suites cover Stripe intents, Paddle webhooks, Google comfort login; Playwright CTA smoke in place—full payment journey available behind the `checkout` tag.)* - [x] Update docs (PRP, docs/changes) and plan a feature-flag rollout for the new wizard. ## Notes @@ -34,6 +34,6 @@ - [x] Define provider-agnostic payment state machine (intent creation, approval, capture, failure). See docs/prp/marketing-checkout-payment-architecture.md. - [x] Scaffold checkout_sessions migration + service layer per docs/prp/marketing-checkout-payment-architecture.md. - [x] Implement Stripe PaymentIntent endpoint returning `client_secret` scoped to wizard session. *(Covered by `CheckoutController::createPaymentIntent`.)* -- [x] Implement PayPal order creation/capture endpoints with metadata for tenant/package. *(Routes now exposed in `routes/web.php`; controller derives tenant context for authenticated users.)* -- [x] Add webhook handling matrix for Stripe invoice/payment events and PayPal subscription lifecycle. -- [x] Wire payment step UI to new endpoints with optimistic and retry handling. *(See `PaymentStep.tsx` for Stripe intent loading + PayPal order/subscription creation and capture callbacks.)* +- [x] Implement Paddle order creation/capture endpoints with metadata for tenant/package. *(Routes now exposed in `routes/web.php`; controller derives tenant context for authenticated users.)* +- [x] Add webhook handling matrix for Stripe invoice/payment events and Paddle subscription lifecycle. +- [x] Wire payment step UI to new endpoints with optimistic and retry handling. *(See `PaymentStep.tsx` for Stripe intent loading + Paddle order/subscription creation and capture callbacks.)* diff --git a/docs/changes/2025-10-09-paypal-sdk-migration.md b/docs/changes/2025-10-09-paypal-sdk-migration.md index f33608d..4cf5535 100644 --- a/docs/changes/2025-10-09-paypal-sdk-migration.md +++ b/docs/changes/2025-10-09-paypal-sdk-migration.md @@ -1,11 +1,11 @@ -# PayPal SDK Migration to v1 Server SDK +# Paddle SDK Migration to v1 Server SDK ## Summary -Migrated from deprecated `paypal/paypal-checkout-sdk` to `paypal/paypal-server-sdk ^1.0+` in PayPalController.php. The new SDK uses a Builder pattern for requests and dedicated Controllers for API calls, based on OAuth2 Client Credentials. +Migrated from deprecated `paypal/paypal-checkout-sdk` to `paypal/paypal-server-sdk ^1.0+` in PaddleController.php. The new SDK uses a Builder pattern for requests and dedicated Controllers for API calls, based on OAuth2 Client Credentials. ## Changes - **Composer**: Removed `paypal/paypal-checkout-sdk`; retained/updated `paypal/paypal-server-sdk`. -- **Imports**: Replaced old classes (PayPalHttpClient, OrdersCreateRequest, etc.) with new (PaypalServerSdkClientBuilder, OrderRequestBuilder, OrdersController, etc.). +- **Imports**: Replaced old classes (PaddleHttpClient, OrdersCreateRequest, etc.) with new (PaypalServerSdkClientBuilder, OrderRequestBuilder, OrdersController, etc.). - **Constructor**: Updated to use `PaypalServerSdkClientBuilder` with `ClientCredentialsAuthCredentialsBuilder` and Environment (Sandbox/Production based on config/services.php). - **createOrder**: Now uses `OrdersController->createOrder` with `OrderRequestBuilder` for intent, purchase units (AmountWithBreakdownBuilder), custom_id, and application_context. - **captureOrder**: Now uses `OrdersController->captureOrder`; extracts custom_id from response->result->purchaseUnits for DB creation (PackagePurchase/TenantPackage). @@ -14,9 +14,9 @@ Migrated from deprecated `paypal/paypal-checkout-sdk` to `paypal/paypal-server-s - **Documentation**: Updated docs/prp/08-billing.md to reflect new SDK usage, flow, and migration notes. ## Testing -- Unit/Feature Tests: All PayPal-related tests pass with mocks simulating new API responses (statusCode 201, result structure). +- Unit/Feature Tests: All Paddle-related tests pass with mocks simulating new API responses (statusCode 201, result structure). - Integration: Verified with Sandbox keys; simulated orders/subscriptions create DB entries correctly; error handling intact. - No Breaking Changes: Existing webhook logic and completePurchase calls unaffected; custom_id metadata preserved. ## Rationale -The old SDK is deprecated and not recommended by PayPal. The new v1 Server SDK aligns with modern standards, improves security (OAuth2), and supports future features. Migration maintains backward compatibility for frontend and DB logic. \ No newline at end of file +The old SDK is deprecated and not recommended by Paddle. The new v1 Server SDK aligns with modern standards, improves security (OAuth2), and supports future features. Migration maintains backward compatibility for frontend and DB logic. \ No newline at end of file diff --git a/docs/changes/2025-10-10-tenant-admin-onboarding-plan.md b/docs/changes/2025-10-10-tenant-admin-onboarding-plan.md index 0b24a7c..19e62a4 100644 --- a/docs/changes/2025-10-10-tenant-admin-onboarding-plan.md +++ b/docs/changes/2025-10-10-tenant-admin-onboarding-plan.md @@ -38,7 +38,7 @@ - **Credits strip**: `credits-card` combines balance chips, a RevenueCat-aware badge, and CTA to `/credits-store`; replicating this card gives tenants a quick read on package status. ### Monetisation & Ordering -- **IAP store** (`pages/IAPStorePage.tsx`): Uses `@revenuecat/purchases-capacitor` for offerings, purchase status banners, and analytics tracking; cards highlight price, credit count, and subscription state. Needs Stripe/PayPal parity discussion before porting. +- **IAP store** (`pages/IAPStorePage.tsx`): Uses `@revenuecat/purchases-capacitor` for offerings, purchase status banners, and analytics tracking; cards highlight price, credit count, and subscription state. Needs Paddle parity discussion before porting. - **Credits context** (`contexts/AuthContext.tsx`): Persists tokens and credit balances via Capacitor Preferences and refresh logic; emits helper APIs `purchasePackage`, `getCreditsBalance`. ### Event Creation Wizard @@ -57,7 +57,7 @@ **Porting Recommendation** - Rebuild the hero, feature cards, quick actions, and wizard using Tailwind + shadcn components inside Laravel PWA while reusing copy/structure. - Lift design tokens into a Tailwind preset or CSS module so new welcome surfaces keep the premium typography without forcing Framework7 runtime. -- Treat RevenueCat-specific logic as optional: plan abstraction so Stripe/PayPal packages in Laravel can slot in later if we skip native IAPs initially. +- Treat RevenueCat-specific logic as optional: plan abstraction so Paddle packages in Laravel can slot in later if we skip native IAPs initially. ## Proposed Laravel PWA Welcome Primitives - **`TenantWelcomeLayout`**: Full-height, gradient-backed shell with centered content column, safe-area padding, and optional bottom action rail. Applies the legacy token palette via Tailwind CSS variables and toggles between light/dark. @@ -71,13 +71,13 @@ These primitives live under `resources/js/admin/onboarding/` and integrate with current router constants (`ADMIN_BASE_PATH`). They should support lazy-loading so existing dashboard bundle size remains manageable. ## Progress -- **Inline Checkout**: Die Order-Summary-Seite unterstützt jetzt Stripe-Kartenzahlungen (Payment Element) und PayPal (Orders API) direkt aus dem Onboarding heraus. Free-Packages lassen sich ohne Umweg aktivieren. +- **Inline Checkout**: Die Order-Summary-Seite unterstützt jetzt Stripe-Kartenzahlungen (Payment Element) und Paddle (Orders API) direkt aus dem Onboarding heraus. Free-Packages lassen sich ohne Umweg aktivieren. - Dashboard bewirbt die Welcome Journey (Actions + Hero Card) und leitet Tenants ohne Events weiterhin auf `/event-admin/welcome` um, während Fortschritt persistiert wird. - Playwright-Skelett `tests/e2e/tenant-onboarding-flow.test.ts` angelegt und via `npm run test:e2e` ausführbar; Tests sind vorerst deaktiviert, bis Seed-Daten + Auth-Helper zur Verfügung stehen. - Welcome Landing, Packages, Summary und Event-Setup sind zweisprachig (DE/EN) via react-i18next; LanguageSwitcher im Dashboard & Welcome-Layout steuert die Locale. ## Status — verbleibende Arbeiten -- PayPal-Testabdeckung (Playwright/RTL) und Error-UX gehören noch in die Roadmap, ebenso wie End-to-End-Validierung auf Staging. +- Paddle-Testabdeckung (Playwright/RTL) und Error-UX gehören noch in die Roadmap, ebenso wie End-to-End-Validierung auf Staging. ## Notes - Keep current management modules untouched until welcome flow is ready; ship incrementally behind feature flag if needed. diff --git a/docs/checkout wizard flow.txt b/docs/checkout wizard flow.txt index bb8ac83..57be52a 100644 --- a/docs/checkout wizard flow.txt +++ b/docs/checkout wizard flow.txt @@ -9,16 +9,16 @@ Frage klären: kann man den login oder die registrierung ersetzen durch daten vo schritt 3: Zahlung pakettyp "endcustomer": -auswahl paypal / Stripe. Buttons für "Mit PayPal bezahlen" und "Mit Stripe bezahlen" anzeigen. Der Benutzer klickt einen aus. +auswahl paypal / Stripe. Buttons für "Mit Paddle bezahlen" und "Mit Stripe bezahlen" anzeigen. Der Benutzer klickt einen aus. Zahlungsinitierung: -PayPal: Umleitung zu PayPal's Express Checkout (via API-Call in Laravel-Controller, z. B. create_order). Der Benutzer loggt sich bei PayPal ein, bestätigt den Einmalkauf (keine Subscription-Option). Rückleitung mit Token zur Bestätigung (Webhook oder Redirect-Handler). +Paddle: Umleitung zu Paddle's Express Checkout (via API-Call in Laravel-Controller, z. B. create_order). Der Benutzer loggt sich bei Paddle ein, bestätigt den Einmalkauf (keine Subscription-Option). Rückleitung mit Token zur Bestätigung (Webhook oder Redirect-Handler). Stripe: Client-seitige Integration mit Stripe Elements (React-Komponente in Ihrer PWA). Der Benutzer gibt Kartendaten ein (ohne Umleitung), oder nutzt Stripe Checkout (hosted Page). Backend-Call zu Stripe API für PaymentIntent erstellen und bestätigen. Bestätigung: Nach Zahlung (z. B. 29,99 €) wird der Kauf im Backend gespeichert (z. B. TenantPackage::createPurchase()), Zugang freigeschaltet (z. B. Event-Zugriff via EventController), und der Benutzer sieht eine Erfolgsseite. Fehlerbehandlung: Abbruch → Zurück zur Bestellübersicht mit Fehlermeldung (z. B. "Zahlung fehlgeschlagen"). pakettyp "reseller": -PayPal: -Nutzung von PayPal Subscriptions API (in Laravel via SDK). Erstellen eines Subscription-Plans (z. B. create_subscription), Umleitung zu PayPal für Autorisierung. Der Benutzer stimmt wiederkehrenden Abbuchungen zu. Rückleitung mit Subscription-ID, die im Backend (z. B. PackagePurchases) gespeichert wird. Webhooks für Updates (z. B. Kündigung). +Paddle: +Nutzung von Paddle Subscriptions API (in Laravel via SDK). Erstellen eines Subscription-Plans (z. B. create_subscription), Umleitung zu Paddle für Autorisierung. Der Benutzer stimmt wiederkehrenden Abbuchungen zu. Rückleitung mit Subscription-ID, die im Backend (z. B. PackagePurchases) gespeichert wird. Webhooks für Updates (z. B. Kündigung). Stripe: Erstellen eines Subscription-Plans via Stripe Dashboard/API (z. B. stripe.subscriptions.create()). Client-seitig: Stripe Elements für SetupIntent (Kartenspeicherung), dann Subscription aktivieren. Keine Umleitung nötig, wenn Sie benutzerdefinierte UI bauen. Backend-Handling für Billing-Cycles, Invoices und Webhooks (z. B. für invoice.paid). Bestätigung: Erste Zahlung erfolgt sofort, Subscription startet. Backend-Update: Reseller-Status aktivieren (z. B. in Tenants-Tabelle), Willkommens-E-Mail. diff --git a/docs/legal/agb-de.md b/docs/legal/agb-de.md index e1f506c..4966773 100644 --- a/docs/legal/agb-de.md +++ b/docs/legal/agb-de.md @@ -57,9 +57,9 @@ Der Anbieter verwendet Inhalte ausschließlich zur technischen Bereitstellung (S ## 7. Preise und Zahlung 1. Es gelten die auf der Website veröffentlichten Preise zum Zeitpunkt der Buchung. 2. Alle Preise verstehen sich einschließlich gesetzlicher Umsatzsteuer. -3. Die Zahlung erfolgt im Voraus über **PayPal** oder **Stripe Checkout** (Kreditkarte, Apple Pay, Google Pay u. a.). +3. Die Zahlung erfolgt im Voraus über **Paddle** oder **Stripe Checkout** (Kreditkarte, Apple Pay, Google Pay u. a.). 4. Bei Nutzung dieser Dienste gelten zusätzlich die AGB und Datenschutzhinweise der jeweiligen Anbieter: - – PayPal (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg + – Paddle (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg – Stripe Payments Europe Ltd., Dublin, Irland 5. Der Anbieter erhält von diesen Diensten nur Zahlungs- und Statusinformationen zur Abwicklung. 6. Rechnungen werden elektronisch bereitgestellt. diff --git a/docs/legal/agb-en.md b/docs/legal/agb-en.md index 378f68d..23ac266 100644 --- a/docs/legal/agb-en.md +++ b/docs/legal/agb-en.md @@ -57,9 +57,9 @@ The Provider uses such content solely for technical purposes (storage, display, ## 7. Prices and Payment 1. Prices valid at the time of booking apply. 2. All prices include VAT, unless otherwise stated. -3. Payment is made in advance via **PayPal** or **Stripe Checkout** (credit card, Apple Pay, Google Pay, etc.). +3. Payment is made in advance via **Paddle** or **Stripe Checkout** (credit card, Apple Pay, Google Pay, etc.). 4. The payment process is governed by the respective provider’s terms: - – PayPal (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg + – Paddle (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg – Stripe Payments Europe Ltd., Dublin, Ireland 5. The Provider only receives transaction and payment status data necessary for processing. 6. Invoices are issued electronically. diff --git a/docs/legal/datenschutz-de.md b/docs/legal/datenschutz-de.md index 6c6d9fe..1158ff9 100644 --- a/docs/legal/datenschutz-de.md +++ b/docs/legal/datenschutz-de.md @@ -21,7 +21,7 @@ Die Nutzung der Fotospiel App ist grundsätzlich nur mit den personenbezogenen D --- ## 3. Arten der verarbeiteten Daten -- Veranstalterdaten: Name, E-Mail-Adresse, Zahlungsinformationen (über PayPal/Stripe), Eventdaten (Titel, Datum, Aufgaben, Bilder) +- Veranstalterdaten: Name, E-Mail-Adresse, Zahlungsinformationen (über Paddle/Stripe), Eventdaten (Titel, Datum, Aufgaben, Bilder) - Nutzerdaten (Gäste): hochgeladene Fotos, Anzeigename (frei wählbar), Reaktionen/Likes - Technische Daten: IP-Adresse, Browsertyp, Zeitstempel, Geräteinformationen - Kommunikationsdaten: Inhalte von Kontaktanfragen über das Formular oder per E-Mail @@ -33,7 +33,7 @@ Die Nutzung der Fotospiel App ist grundsätzlich nur mit den personenbezogenen D |------------------------|----------------|---------------| | Bereitstellung der App und Durchführung von Veranstaltungen | Art. 6 Abs. 1 lit. b DSGVO | Nutzung der App durch Veranstalter und Gäste | | Speicherung und Anzeige von Fotos innerhalb des Events | Art. 6 Abs. 1 lit. b DSGVO | Durchführung der Fotospiel-Funktionalität | -| Abrechnung und Zahlungsabwicklung | Art. 6 Abs. 1 lit. b, lit. c DSGVO | Nutzung der Dienste von PayPal und Stripe | +| Abrechnung und Zahlungsabwicklung | Art. 6 Abs. 1 lit. b, lit. c DSGVO | Nutzung der Dienste von Paddle und Stripe | | Webanalyse über Matomo (selbst gehostet) | Art. 6 Abs. 1 lit. f DSGVO | Statistische Auswertung zur Verbesserung der App | | Sicherheit, Server-Logs | Art. 6 Abs. 1 lit. f DSGVO | Sicherstellung des Betriebs, Fehleranalyse | | Beantwortung von Kontaktanfragen | Art. 6 Abs. 1 lit. f oder lit. b DSGVO | Kommunikation mit Nutzern und Interessenten | @@ -48,13 +48,13 @@ Die Verarbeitung erfolgt ausschließlich innerhalb der EU. --- ## 6. Zahlungsabwicklung -Die Zahlungsabwicklung erfolgt über **PayPal (Europe) S.à r.l. et Cie, S.C.A.** und **Stripe Payments Europe, Ltd.** +Die Zahlungsabwicklung erfolgt über **Paddle (Europe) S.à r.l. et Cie, S.C.A.** und **Stripe Payments Europe, Ltd.** Bei der Zahlung werden personenbezogene Daten an diese Dienstleister übermittelt. Wir speichern keine Zahlungs- oder Kreditkartendaten. Rechtsgrundlage: Art. 6 Abs. 1 lit. b und lit. c DSGVO. Datenschutzhinweise der Anbieter: -- PayPal: https://www.paypal.com/de/webapps/mpp/ua/privacy-full +- Paddle: https://www.paypal.com/de/webapps/mpp/ua/privacy-full - Stripe: https://stripe.com/de/privacy --- @@ -88,7 +88,7 @@ Eine Einwilligung ist nicht erforderlich. ## 10. Weitergabe an Dritte Eine Weitergabe erfolgt nur an: -- Zahlungsdienstleister (PayPal, Stripe) +- Zahlungsdienstleister (Paddle, Stripe) - Hoster (Hetzner) - Gesetzlich erforderliche Stellen (z. B. Finanzbehörden) diff --git a/docs/legal/datenschutz-en.md b/docs/legal/datenschutz-en.md index 031dda3..ca28f47 100644 --- a/docs/legal/datenschutz-en.md +++ b/docs/legal/datenschutz-en.md @@ -21,7 +21,7 @@ Use of the Fotospiel App requires only the personal data necessary to host and p --- ## 3. Types of Data Processed -- Organizer data: name, email address, payment information (via PayPal/Stripe), event details (title, date, photo tasks, photos) +- Organizer data: name, email address, payment information (via Paddle/Stripe), event details (title, date, photo tasks, photos) - Guest data: uploaded photos, display name (optional), likes/reactions - Technical data: IP address, browser type, timestamp, device information - Communication data: messages sent via contact form or email @@ -33,7 +33,7 @@ Use of the Fotospiel App requires only the personal data necessary to host and p |----------|--------------|-------------| | Providing the app and hosting events | Art. 6(1)(b) GDPR | Contract performance | | Storing and displaying photos | Art. 6(1)(b) GDPR | Core feature of the app | -| Payment processing and invoicing | Art. 6(1)(b), (c) GDPR | Use of PayPal and Stripe services | +| Payment processing and invoicing | Art. 6(1)(b), (c) GDPR | Use of Paddle and Stripe services | | Web analytics via Matomo | Art. 6(1)(f) GDPR | Statistical analysis to improve the app | | Server logs and security | Art. 6(1)(f) GDPR | Ensuring system security | | Responding to inquiries | Art. 6(1)(f) or (b) GDPR | Communication with users | @@ -48,12 +48,12 @@ All processing takes place within the EU. --- ## 6. Payment Processing -Payments are handled by **PayPal (Europe) S.à r.l. et Cie, S.C.A.** and **Stripe Payments Europe, Ltd.** +Payments are handled by **Paddle (Europe) S.à r.l. et Cie, S.C.A.** and **Stripe Payments Europe, Ltd.** We do not store payment or credit card data. Legal basis: Art. 6(1)(b) and (c) GDPR. Privacy policies: -- PayPal: https://www.paypal.com/de/webapps/mpp/ua/privacy-full +- Paddle: https://www.paypal.com/de/webapps/mpp/ua/privacy-full - Stripe: https://stripe.com/de/privacy --- @@ -87,7 +87,7 @@ No consent is required. ## 10. Data Disclosure Data is only shared with: -- Payment providers (PayPal, Stripe) +- Payment providers (Paddle, Stripe) - Hosting provider (Hetzner) - Public authorities when legally required diff --git a/docs/packages-business-model-plan.md b/docs/packages-business-model-plan.md index bad237d..3d35644 100644 --- a/docs/packages-business-model-plan.md +++ b/docs/packages-business-model-plan.md @@ -21,10 +21,10 @@ Das bestehende Modell ist Credits-basiert (Freemium mit 1 Free-Credit, One-off-K - **API:** Endpunkte `/api/v1/tenant/credits/balance`, `/credits/ledger`, `/credits/purchase`, `/credits/sync`, `/purchases/intent`. - **Frontend (Admin PWA):** Dashboard-Cards für Balance, Kauf-Integration (RevenueCat). - **Guest PWA:** Keine direkten Checks (Backend-handhabt). -- **Billing:** Stripe (Checkout/Webhooks), RevenueCat (IAP), PayPalWebhookController (teilweise). +- **Billing:** Stripe (Checkout/Webhooks), RevenueCat (IAP), PaddleWebhookController (teilweise). - **Tests:** `RevenueCatWebhookTest`, Credit-Unit-Tests. - **Docs:** PRP 08-billing.md (Credits-MVP), 14-freemium-business-model.md (IAP-Struktur), API-Specs (credits-Endpunkte). -- **Lücken im Aktuellen:** Keine Package-Limits (nur Balance), Subscriptions nicht live, PayPal untergenutzt. +- **Lücken im Aktuellen:** Keine Package-Limits (nur Balance), Subscriptions nicht live, Paddle untergenutzt. **Auswirkungen:** Vollständige Ersetzung, um Flexibilität (Limits/Features pro Package) zu ermöglichen. @@ -99,7 +99,7 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E $table->foreignId('tenant_id')->nullable()->constrained(); $table->foreignId('event_id')->nullable()->constrained(); $table->foreignId('package_id')->constrained(); - $table->string('provider_id'); // Stripe/PayPal ID + $table->string('provider_id'); // Paddle ID $table->decimal('price', 8, 2); $table->enum('type', ['endcustomer_event', 'reseller_subscription']); $table->json('metadata'); // {event_id, ip_address} @@ -129,13 +129,13 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E - **TenantPackageResource (SuperAdmin/TenantAdmin):** - Form: Select('tenant_id'), Select('package_id'), DateTimePicker('purchased_at'), DateTimePicker('expires_at'), TextInput('used_events', readOnly), Toggle('active'). - - Table: TextColumn('tenant.name'), BadgeColumn('package.name'), DateColumn('expires_at', color: expired → danger), ProgressColumn('used_events' / max_events), Actions (Renew: set expires_at +1 year, Cancel: active=false + Stripe/PayPal cancel). + - Table: TextColumn('tenant.name'), BadgeColumn('package.name'), DateColumn('expires_at', color: expired → danger), ProgressColumn('used_events' / max_events), Actions (Renew: set expires_at +1 year, Cancel: active=false + Paddle cancel). - Relations: BelongsTo Tenant/Package, HasMany Events (RelationManager mit Event-List). - Bulk-Actions: Renew Selected. - **PurchaseResource (SuperAdmin/TenantAdmin):** - Form: Select('tenant_id/event_id'), Select('package_id'), TextInput('provider_id'), MoneyInput('price'), Select('type'), JSONEditor('metadata'), Toggle('refunded'). - - Table: BadgeColumn('type'), LinkColumn('tenant' or 'event'), TextColumn('package.name/price'), DateColumn('purchased_at'), BadgeColumn('status' – paid/refunded), Actions (View, Refund: Call Stripe/PayPal API, decrement counters, log). + - Table: BadgeColumn('type'), LinkColumn('tenant' or 'event'), TextColumn('package.name/price'), DateColumn('purchased_at'), BadgeColumn('status' – paid/refunded), Actions (View, Refund: Call Paddle API, decrement counters, log). - Filters: SelectFilter('type'), DateRangeFilter('purchased_at'), TenantFilter. - Widgets: StatsOverview (Total Revenue, Monthly Purchases, Top Package), ChartWidget (Revenue over Time via Laravel Charts). - Export: CSV (für Buchhaltung: tenant, package, price, date). @@ -145,14 +145,14 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E ## 5. Marketing- und Legal-Anpassungen (Todo 4) - **Webfrontend (Blade, resources/views/marketing/):** - **packages.blade.php (neu, Route /packages):** Hero ("Entdecken Sie unsere Packages"), Tabs (Endkunden/Reseller), Tabelle/Accordion mit Details (Preis, Limits als Icons, Features-Bullets, i18n-Übersetzungen). CTA: "Kaufen" → /checkout/{id}. Dynamisch: @foreach(Package::where('type', 'endcustomer')->get() as $package). - - **checkout.blade.php (neu, Route /checkout/{package_id}):** Summary-Box (Package-Details), Form (Name, E-Mail, Adresse für Reseller), Zahlungsoptionen (Radio: Stripe/PayPal), Stripe-Element/PayPal-Button. Submit: POST /purchases/intent → Redirect. Tailwind: Secure-Design mit Badges. + - **checkout.blade.php (neu, Route /checkout/{package_id}):** Summary-Box (Package-Details), Form (Name, E-Mail, Adresse für Reseller), Zahlungsoptionen (Radio: Paddle), Stripe-Element/Paddle-Button. Submit: POST /purchases/intent → Redirect. Tailwind: Secure-Design mit Badges. - **success.blade.php:** "Vielen Dank! Package {name} gekauft." Details (Limits, Event-Link), Upsell ("Upgrade zu Reseller?"), Rechnung-Download (PDF via Dompdf), Onboarding-Tour-Link. - **marketing.blade.php:** Teaser-Section mit Package-Icons/Preisen, Link zu /packages. - **occasions.blade.php/blog*.blade.php:** Kontextuelle Erwähnungen (z.B. "Ideal für Partys: Starter-Paket"), Blog-Post "Neues Package-Modell" mit FAQ. - **Legal (resources/views/legal/):** - - **datenschutz.blade.php:** Abschnitt "Zahlungen" (Stripe/PayPal: Keine Karten-Speicherung, GDPR: Löschung nach 10 Jahren; Consent für E-Mails). "Package-Daten (Limits) sind anonymisiert." - - **impressum.blade.php:** "Monetarisierung: Packages via Stripe/PayPal; USt-ID: ...; Support: support@fotospiel.de". + - **datenschutz.blade.php:** Abschnitt "Zahlungen" (Paddle: Keine Karten-Speicherung, GDPR: Löschung nach 10 Jahren; Consent für E-Mails). "Package-Daten (Limits) sind anonymisiert." + - **impressum.blade.php:** "Monetarisierung: Packages via Paddle; USt-ID: ...; Support: support@fotospiel.de". - **Allgemein:** Datum "Aktualisiert: 2025-09-26 – Package-Modell"; Links zu Provider-Datenschutz. **i18n:** Translations in lang/de/en (z.B. 'package.starter' → 'Starter-Paket'). @@ -160,7 +160,7 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E ## 6. Backend-Logik & API (Todo 6/7) - **Controllers:** - `PackagesController` (index: Liste mit Cache, show: Details, store: Intent für Kauf). - - `PurchasesController` (intent: Erstelle Stripe-Session oder PayPal-Order basierend auf method; store: Nach Webhook). + - `PurchasesController` (intent: Erstelle Stripe-Session oder Paddle-Order basierend auf method; store: Nach Webhook). - **Middleware:** `PackageMiddleware` (für Events: Check event_packages.used_photos < max_photos; für Tenant: used_events < max_events_per_year). - **Models:** `Package` (Relationships: hasMany EventPackage/TenantPackage), `EventPackage` (incrementUsedPhotos-Method), `TenantPackage` (isActive-Scope, Observer für Expiry: E-Mail + active=false). - **API-Endpunkte (routes/api.php, tenant-group):** @@ -175,7 +175,7 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E ## 7. Frontend-Anpassungen (Todo 8/9) - **Admin PWA (resources/js/admin/):** - - EventFormPage.tsx: Select('package_id') mit Details-Modal (Limits/Preis), Button 'Kaufen' → Stripe/PayPal-Integration (stripe.elements oder PayPal-Button). + - EventFormPage.tsx: Select('package_id') mit Details-Modal (Limits/Preis), Button 'Kaufen' → Paddle-Integration (stripe.elements oder Paddle-Button). - Dashboard: Card 'Aktuelles Package' (Limits, Expiry, Upgrade-Button). - SettingsPage.tsx: Reseller-Übersicht (used_events/Progress, Renew-Button). - Hooks: usePackageLimits (fetch /packages, check used_photos). @@ -185,21 +185,21 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E - Features: Watermark-Overlay if watermark_allowed; Branding-Logo if branding_allowed. - Router: Guard für Limits (z.B. /upload → Check API). -**Tech:** React Query für API-Calls, Stripe.js/PayPal-SDK in Components, i18n mit react-i18next. +**Tech:** React Query für API-Calls, Stripe.js/Paddle-SDK in Components, i18n mit react-i18next. ## 8. Billing-Integration (Todo 10) -- **Provider:** Stripe (Primär: Einmalkäufe/Subscriptions) + PayPal (Alternative: PHP SDK für Orders/Subscriptions). -- **Flow:** Auswahl → Intent (Controller: if 'stripe' → Stripe::checkout()->sessions->create([...]); if 'paypal' → PayPal::orders()->create([...]) ) → Redirect → Webhook (verifiziert, insert package_purchases, assign Package, E-Mail). -- **Webhooks:** StripeWebhookController (neue Events: checkout.session.completed → ProcessPurchase), PayPalWebhookController (erweitert: PAYMENT.CAPTURE.COMPLETED → ProcessPurchase). +- **Provider:** Stripe (Primär: Einmalkäufe/Subscriptions) + Paddle (Alternative: PHP SDK für Orders/Subscriptions). +- **Flow:** Auswahl → Intent (Controller: if 'stripe' → Stripe::checkout()->sessions->create([...]); if 'paypal' → Paddle::orders()->create([...]) ) → Redirect → Webhook (verifiziert, insert package_purchases, assign Package, E-Mail). +- **Webhooks:** StripeWebhookController (neue Events: checkout.session.completed → ProcessPurchase), PaddleWebhookController (erweitert: PAYMENT.CAPTURE.COMPLETED → ProcessPurchase). - **SDKs:** composer require stripe/stripe-php ^10.0, paypal/rest-api-sdk-php ^1.14; NPM: @stripe/stripe-js, @paypal/react-paypal-js. - **Free:** Kein Provider – direkt assign via API. -- **Refunds:** Action in PurchaseResource: Call Stripe::refunds->create oder PayPal::refunds, decrement Counters. +- **Refunds:** Action in PurchaseResource: Call Stripe::refunds->create oder Paddle::refunds, decrement Counters. - **Env:** STRIPE_KEY/SECRET, PAYPAL_CLIENT_ID/SECRET, SANDBOX-Flags. ## 9. Tests (Todo 11) -- **Unit/Feature:** Pest/PHPUnit: Test PackageSeeder, Migration (assert Tables exist), Controllers (mock Stripe/PayPal SDKs mit Stripe::mock(), test Intent/Webhook), Models (Package::find(1)->limits, TenantPackage::isActive), Middleware (assert denies if limit exceeded). -- **E2E (Playwright):** Test Kauf-Flow (navigate /packages, select Starter, choose PayPal, complete sandbox, assert success.blade.php), Limits (upload photo, assert counter +1, deny at max). -- **Anpassungen:** RevenueCatWebhookTest → Stripe/PayPalWebhookTest; Add PackageValidationTest (e.g. EventCreate without Package → 422). +- **Unit/Feature:** Pest/PHPUnit: Test PackageSeeder, Migration (assert Tables exist), Controllers (mock Paddle SDKs mit Stripe::mock(), test Intent/Webhook), Models (Package::find(1)->limits, TenantPackage::isActive), Middleware (assert denies if limit exceeded). +- **E2E (Playwright):** Test Kauf-Flow (navigate /packages, select Starter, choose Paddle, complete sandbox, assert success.blade.php), Limits (upload photo, assert counter +1, deny at max). +- **Anpassungen:** RevenueCatWebhookTest → PaddleWebhookTest; Add PackageValidationTest (e.g. EventCreate without Package → 422). - **Coverage:** 80% für Billing/DB; Mock Providers für Isolation. ## 10. Deployment & Rollout (Todo 12) @@ -222,8 +222,8 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E - **Support:** E-Mail-Templates (PurchaseMailable), FAQ in /support/packages, Onboarding-Tour post-Kauf. - **Performance:** Caching (Packages-Liste), Indexing (purchased_at), Queues für Webhooks (ProcessPurchaseJob). - **Edge-Cases:** Upgrade (prorate Preis, transfer Limits), Expiry (Observer + E-Mail), Offline-PWA (queued Käufe sync). -- **Dependencies:** Stripe/PayPal SDKs, Dompdf (Rechnungen), Laravel Cashier (optional für Stripe). -- **Kosten:** Env für Sandbox/Prod-Keys; Test mit Stripe/PayPal Test-Accounts. +- **Dependencies:** Paddle SDKs, Dompdf (Rechnungen), Laravel Cashier (optional für Stripe). +- **Kosten:** Env für Sandbox/Prod-Keys; Test mit Paddle Test-Accounts. ## 12. Todo-List (Status: Alle Planung completed) - [x] Analyse. diff --git a/docs/prp/08-billing.md b/docs/prp/08-billing.md index 1a99d89..3a20eb2 100644 --- a/docs/prp/08-billing.md +++ b/docs/prp/08-billing.md @@ -1,7 +1,7 @@ # Billing and Payments ## Overview -The Fotospiel platform supports multiple payment providers for package purchases: Stripe for one-time and subscription payments, and PayPal for orders and subscriptions. Billing is handled through a freemium model with endcustomer event packages and reseller subscriptions. All payments are processed via API integrations, with webhooks for asynchronous updates. +The Fotospiel platform supports multiple payment providers for package purchases: Stripe for one-time and subscription payments, and Paddle for orders and subscriptions. Billing is handled through a freemium model with endcustomer event packages and reseller subscriptions. All payments are processed via API integrations, with webhooks for asynchronous updates. ## Stripe Integration - **One-time Payments**: Use Stripe Checkout for endcustomer event packages. Create PaymentIntent via `StripeController@createPaymentIntent`. @@ -19,13 +19,13 @@ The Fotospiel platform supports multiple payment providers for package purchases | `invoice.payment_failed` | Flags tenant for follow-up, sends alerts | `handleInvoicePaymentFailed` | | `customer.subscription.deleted` | Finalises cancellation/downgrade | `handleSubscriptionDeleted` | -## PayPal Integration -- **SDK**: Migrated to PayPal Server SDK v1.0+ (`paypal/paypal-server-sdk`). Uses Builder pattern for requests and Controllers for API calls. -- **Orders (One-time Payments)**: Endcustomer event packages via Orders API. `PayPalController@createOrder` uses `OrderRequestBuilder` with `CheckoutPaymentIntent::CAPTURE`, custom_id for metadata (tenant_id, package_id, type). Capture in `@captureOrder` using `OrdersController->captureOrder`. DB creation in `processPurchaseFromOrder`. -- **Subscriptions (Recurring Payments)**: Reseller subscriptions via Orders API with StoredPaymentSource for recurring setup (no dedicated SubscriptionsController in SDK). `PayPalController@createSubscription` uses `OrderRequestBuilder` with `StoredPaymentSource` (payment_initiator: CUSTOMER, payment_type: RECURRING, usage: FIRST), custom_id including plan_id. Initial order capture sets up subscription; subsequent billing via PayPal dashboard or webhooks. DB records created on initial capture, with expires_at for annual billing. +## Paddle Integration +- **SDK**: Migrated to Paddle Server SDK v1.0+ (`paypal/paypal-server-sdk`). Uses Builder pattern for requests and Controllers for API calls. +- **Orders (One-time Payments)**: Endcustomer event packages via Orders API. `PaddleController@createOrder` uses `OrderRequestBuilder` with `CheckoutPaymentIntent::CAPTURE`, custom_id for metadata (tenant_id, package_id, type). Capture in `@captureOrder` using `OrdersController->captureOrder`. DB creation in `processPurchaseFromOrder`. +- **Subscriptions (Recurring Payments)**: Reseller subscriptions via Orders API with StoredPaymentSource for recurring setup (no dedicated SubscriptionsController in SDK). `PaddleController@createSubscription` uses `OrderRequestBuilder` with `StoredPaymentSource` (payment_initiator: CUSTOMER, payment_type: RECURRING, usage: FIRST), custom_id including plan_id. Initial order capture sets up subscription; subsequent billing via Paddle dashboard or webhooks. DB records created on initial capture, with expires_at for annual billing. - **Differences**: One-time: Standard Order with payment_type ONE_TIME (default). Recurring: Order with StoredPaymentSource RECURRING to enable future charges without new approvals. plan_id stored in metadata for reference; no separate subscription ID from SDK. - **Client Setup**: OAuth2 Client Credentials flow. Builder: `PaypalServerSdkClientBuilder::init()->clientCredentialsAuthCredentials(ClientCredentialsAuthCredentialsBuilder::init(client_id, secret))->environment(Environment::SANDBOX/PRODUCTION)->build()`. -- **Webhooks**: `PayPalWebhookController@verify` handles events like `PAYMENT.CAPTURE.COMPLETED` (process initial/renewal purchase), `BILLING.SUBSCRIPTION.CANCELLED` or equivalent order events (deactivate package). Simplified signature verification (TODO: Implement `VerifyWebhookSignature`). +- **Webhooks**: `PaddleWebhookController@verify` handles events like `PAYMENT.CAPTURE.COMPLETED` (process initial/renewal purchase), `BILLING.SUBSCRIPTION.CANCELLED` or equivalent order events (deactivate package). Simplified signature verification (TODO: Implement `VerifyWebhookSignature`). - **Webhook Matrix**: | Event | Purpose | Internal handler | @@ -36,15 +36,15 @@ The Fotospiel platform supports multiple payment providers for package purchases | `BILLING.SUBSCRIPTION.SUSPENDED` | Pauses package benefits pending review | `handleSubscription` | - **Idempotency**: Check `provider_id` in `PackagePurchase` before creation. Transactions for DB safety. - **Configuration**: Keys in `config/services.php` under `paypal`. Sandbox mode via `paypal.sandbox`. -- **Migration Notes**: Replaced old Checkout SDK (`PayPalCheckoutSdk`). Updated imports, requests (e.g., OrdersCreateRequest -> OrderRequestBuilder, Subscriptions -> StoredPaymentSource in Orders). Responses: Use `getStatusCode()` and `getResult()`. Tests mocked new structures. No breaking changes in auth or metadata handling; recurring flows now unified under Orders API. +- **Migration Notes**: Replaced old Checkout SDK (`PaddleCheckoutSdk`). Updated imports, requests (e.g., OrdersCreateRequest -> OrderRequestBuilder, Subscriptions -> StoredPaymentSource in Orders). Responses: Use `getStatusCode()` and `getResult()`. Tests mocked new structures. No breaking changes in auth or metadata handling; recurring flows now unified under Orders API. ## Database Models -- **PackagePurchase**: Records purchases with `tenant_id`, `package_id`, `provider_id` (Stripe PI/PayPal Order ID), `price`, `type` (endcustomer_event/reseller_subscription), `status` (completed/refunded), `metadata` (JSON with provider details). +- **PackagePurchase**: Records purchases with `tenant_id`, `package_id`, `provider_id` (Stripe PI/Paddle Order ID), `price`, `type` (endcustomer_event/reseller_subscription), `status` (completed/refunded), `metadata` (JSON with provider details). - **TenantPackage**: Active packages with `tenant_id`, `package_id`, `price`, `purchased_at`, `expires_at`, `active` flag. Updated on purchase/cancellation. - **Constraints**: `type` CHECK (endcustomer_event, reseller_subscription), `price` NOT NULL. ## Flows -1. **Purchase Initiation**: User selects package in PurchaseWizard. For free: direct assignment. Paid: Redirect to provider (Stripe Checkout or PayPal approve link). +1. **Purchase Initiation**: User selects package in PurchaseWizard. For free: direct assignment. Paid: Redirect to provider (Stripe Checkout or Paddle approve link). 2. **Completion**: Provider callback/webhook triggers capture/confirmation. Create `PackagePurchase` and `TenantPackage`. Update tenant `subscription_status` to 'active'. 3. **Cancellation/Refund**: Webhook updates status to 'cancelled', deactivates `TenantPackage`. 4. **Trial**: First reseller subscription gets 14-day trial (`expires_at = now() + 14 days`). @@ -63,5 +63,5 @@ The Fotospiel platform supports multiple payment providers for package purchases ## Security & Compliance - GDPR: No PII in logs/metadata beyond necessary (tenant_id anonymous). - Auth: Sanctum tokens for API, CSRF for web. -- Webhooks: IP whitelisting (PayPal IPs), signature verification. +- Webhooks: IP whitelisting (Paddle IPs), signature verification. - Retention: Purchases retained per Privacy policy; update on changes. diff --git a/docs/prp/marketing-checkout-payment-architecture.md b/docs/prp/marketing-checkout-payment-architecture.md index 75b0c33..0e787fe 100644 --- a/docs/prp/marketing-checkout-payment-architecture.md +++ b/docs/prp/marketing-checkout-payment-architecture.md @@ -2,16 +2,16 @@ ## Goals - Replace the legacy marketing checkout flow with a single `CheckoutController` that owns auth, payment, and confirmation steps. -- Support Stripe card payments and PayPal orders with a consistent state machine that can be extended to other providers. +- Support Stripe card payments and Paddle orders with a consistent state machine that can be extended to other providers. - Keep package activation logic idempotent and traceable while respecting GDPR (no new PII logging, no leaked tokens). - Prepare the frontend wizard to drive the flow as an SPA without relying on server-side redirects. ## Core Building Blocks -- **CheckoutSession model/table** keeps one purchase attempt per user + package. It stores provider choice, status, pricing snapshot, and external ids (Stripe intent, PayPal order, etc.). +- **CheckoutSession model/table** keeps one purchase attempt per user + package. It stores provider choice, status, pricing snapshot, and external ids (Stripe intent, Paddle order, etc.). - **CheckoutPaymentService** orchestrates provider-specific actions (create intent/order, capture, sync metadata) and normalises responses for the wizard. - **CheckoutAssignmentService** performs the idempotent write workflow (create/update TenantPackage, PackagePurchase, tenant subscription fields, welcome mail) once payment succeeds. - **Wizard API surface** (JSON routes under `/checkout/*`) is session-authenticated, CSRF-protected, and returns structured payloads consumed by the PWA. -- **Webhooks** (Stripe, PayPal) map incoming provider events back to `CheckoutSession` rows to guarantee reconciliation and support 3DS / async capture paths. +- **Webhooks** (Stripe, Paddle) map incoming provider events back to `CheckoutSession` rows to guarantee reconciliation and support 3DS / async capture paths. - **Feature Flag**: `config/checkout.php` exposes `CHECKOUT_WIZARD_ENABLED` and `CHECKOUT_WIZARD_FLAG` so the SPA flow can be toggled or gradual-rolled out during launch. - **Operational**: Rotate JWT signing keys with `php artisan oauth:rotate-keys` (updates key folder per KID; remember to bump `OAUTH_JWT_KID`). @@ -22,7 +22,7 @@ State constants live on `CheckoutSession` (`status` column, enum): | --- | --- | --- | | `draft` | Session created, package locked in, no provider chosen. | `awaiting_payment_method`, `completed` (free), `cancelled` | | `awaiting_payment_method` | Paid package; provider picked, waiting for client to initialise SDK. | `requires_customer_action`, `processing`, `cancelled` | -| `requires_customer_action` | Stripe 3DS, PayPal approval window open, or additional customer steps needed. | `processing`, `failed`, `cancelled` | +| `requires_customer_action` | Stripe 3DS, Paddle approval window open, or additional customer steps needed. | `processing`, `failed`, `cancelled` | | `processing` | Provider reported success, backend validating / capturing / assigning. | `completed`, `failed` | | `completed` | Checkout finished, package assigned, confirmation step unblocked. | none | | `failed` | Provider declined or capture check failed; retain reason. | `awaiting_payment_method` (retry), `cancelled` | @@ -49,34 +49,34 @@ Create Eloquent model `App\Models\CheckoutSession` with casts for JSON columns a Group under `web.php` with `middleware(['auth', 'verified', 'locale', 'throttle:checkout'])`: - `POST /checkout/session` (`CheckoutController@storeSession`): create or resume active session for selected package, return `{id, status, amount, package_snapshot}`. - `PATCH /checkout/session/{session}/package` (`updatePackage`): allow switching package before payment; resets provider-specific fields and status to `draft`. -- `POST /checkout/session/{session}/provider` (`selectProvider`): set provider (`stripe` or `paypal`), transitions to `awaiting_payment_method` and returns provider configuration (publishable key, PayPal client id, feature flags). +- `POST /checkout/session/{session}/provider` (`selectProvider`): set provider (`stripe` or `paypal`), transitions to `awaiting_payment_method` and returns provider configuration (publishable key, Paddle client id, feature flags). - `POST /checkout/session/{session}/stripe-intent` (`createStripeIntent`): idempotently create/update PaymentIntent with metadata (user, tenant, package, session id) and deliver `{client_secret, intent_id}`. - `POST /checkout/session/{session}/stripe/confirm` (`confirmStripeIntent`): server-side verify PaymentIntent status (retrieve from Stripe) and transition to `processing` when `succeeded` or `requires_action`. -- `POST /checkout/session/{session}/paypal/order` (`createPayPalOrder`): create order with `custom_id` payload (session, tenant, package) and return `{order_id, approve_url}`. -- `POST /checkout/session/{session}/paypal/capture` (`capturePayPalOrder`): capture order server-side, transition to `processing` if status `COMPLETED`. +- `POST /checkout/session/{session}/paypal/order` (`createPaddleOrder`): create order with `custom_id` payload (session, tenant, package) and return `{order_id, approve_url}`. +- `POST /checkout/session/{session}/paypal/capture` (`capturePaddleOrder`): capture order server-side, transition to `processing` if status `COMPLETED`. - `POST /checkout/session/{session}/free` (`activateFreePackage`): bypass providers, run assignment service, mark `completed`. - `POST /checkout/session/{session}/complete` (`finalise`): provider-agnostic finishing hook used after `processing` to run `CheckoutAssignmentService`, persist `PackagePurchase`, queue mails, and respond with summary. - `GET /checkout/session/{session}` (`show`): used by wizard polling to keep state in sync (status, provider display data, failure reasons). - `DELETE /checkout/session/{session}` (`cancel`): expire session, clean provider artefacts (cancel intent/order if applicable). -Stripe/PayPal routes remain under `routes/web.php` but call into new service classes; legacy marketing payment methods are removed once parity is verified. +Paddle routes remain under `routes/web.php` but call into new service classes; legacy marketing payment methods are removed once parity is verified. ### Services & Jobs - `CheckoutSessionService`: create/resume session, guard transitions, enforce TTL, and wrap DB transactions. -- `CheckoutPaymentService`: entry point with methods `initialiseStripe`, `confirmStripe`, `initialisePayPal`, `capturePayPal`, `finaliseFree`. Delegates to provider-specific helpers (Stripe SDK, PayPal SDK) and persists external ids. +- `CheckoutPaymentService`: entry point with methods `initialiseStripe`, `confirmStripe`, `initialisePaddle`, `capturePaddle`, `finaliseFree`. Delegates to provider-specific helpers (Stripe SDK, Paddle SDK) and persists external ids. - `CheckoutAssignmentService`: generates or reuses tenant, writes `TenantPackage`, `PackagePurchase`, updates user role/status, dispatches `Welcome` + purchase receipts, and emits domain events (`CheckoutCompleted`). - `SyncCheckoutFromWebhook` job: invoked by webhook controllers with provider payload, looks up `CheckoutSession` via provider id, runs assignment if needed, records failure states. ### Webhook Alignment - Update `StripeWebhookController` to resolve `CheckoutSession::where('stripe_payment_intent_id', intentId)`; when event indicates success, transition to `processing` (if not already), enqueue `SyncCheckoutFromWebhook` to finish assignment, and mark `completed` once done. -- Update `PayPalWebhookController` similarly using `paypal_order_id` or `paypal_subscription_id`. +- Update `PaddleWebhookController` similarly using `paypal_order_id` or `paypal_subscription_id`. - Webhooks become source-of-truth for delayed confirmations; wizard polls `GET /checkout/session/{id}` until `completed`. ### Validation & Security - All mutating routes use CSRF tokens and `auth` guard (session-based). Add `EnsureCheckoutSessionOwner` middleware enforcing that the session belongs to `request->user()`. - Input validation via dedicated Form Request classes (e.g., `StoreCheckoutSessionRequest`, `StripeIntentRequest`). - Provider responses are never logged raw; only store ids + safe metadata. -- Abandon expired sessions via scheduler (`checkout:expire-sessions` artisan command). Command cancels open PaymentIntents and PayPal orders. +- Abandon expired sessions via scheduler (`checkout:expire-sessions` artisan command). Command cancels open PaymentIntents and Paddle orders. ## Frontend Touchpoints @@ -89,7 +89,7 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla - On mount (and whenever package changes), call `/checkout/session` to create/resume session. Reset state if API reports new id. - Provider tabs call `selectProvider`. Stripe tab loads Stripe.js dynamically (import from `@stripe/stripe-js`) and mounts Elements once `client_secret` arrives. - Stripe flow: submit button triggers `stripe.confirmCardPayment(clientSecret)`, handle `requires_action`, then POST `/checkout/session/{id}/stripe/confirm`. On success, call `/checkout/session/{id}/complete` and advance to confirmation step. -- PayPal flow: render PayPal Buttons with `createOrder` -> call `/checkout/session/{id}/paypal/order`; `onApprove` -> POST `/checkout/session/{id}/paypal/capture`, then `/checkout/session/{id}/complete`. +- Paddle flow: render Paddle Buttons with `createOrder` -> call `/checkout/session/{id}/paypal/order`; `onApprove` -> POST `/checkout/session/{id}/paypal/capture`, then `/checkout/session/{id}/complete`. - Free packages skip provider selection; call `/checkout/session/{id}/free` and immediately advance. - Display status toasts based on `paymentStatus`; show inline error block when `failed` with `failure_reason` from API. @@ -106,10 +106,10 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla 5. Once Stripe marks intent `succeeded`, backend transitions to `processing`, calls `CheckoutAssignmentService`, and marks `completed`. 6. For reseller packages, Stripe subscription is created after assignment using configured price ids; resulting subscription id stored on session + tenant record. -### PayPal (one-off and subscription) +### Paddle (one-off and subscription) 1. Session `draft` -> provider `paypal`. -2. `createPayPalOrder` returns order id + approval link. Metadata includes session, tenant, package, package_type. -3. After approval, `capturePayPalOrder` verifies capture status; on `COMPLETED`, transitions to `processing`. +2. `createPaddleOrder` returns order id + approval link. Metadata includes session, tenant, package, package_type. +3. After approval, `capturePaddleOrder` verifies capture status; on `COMPLETED`, transitions to `processing`. 4. Assignment service runs, storing order id as `provider_id`. For subscriptions, capture handler stores subscription id and updates tenant subscription status. 5. Webhooks handle late captures or cancellations (updates session -> `failed` or `cancelled`). @@ -118,18 +118,18 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla ## Migration Strategy 1. **Phase 1** (current): land schema, services, new API routes; keep legacy MarketingController flow for fallback. -2. **Phase 2**: wire the new wizard PaymentStep behind feature flag `checkout_v2` (in `.env` / config). Run internal QA with Stripe/PayPal sandbox. -3. **Phase 3**: enable feature flag for production tenants, monitor Stripe/PayPal events, then delete legacy marketing payment paths and routes. +2. **Phase 2**: wire the new wizard PaymentStep behind feature flag `checkout_v2` (in `.env` / config). Run internal QA with Paddle sandbox. +3. **Phase 3**: enable feature flag for production tenants, monitor Paddle events, then delete legacy marketing payment paths and routes. 4. **Phase 4**: tighten webhook logic and remove `MarketingController::checkout`, `::paypalCheckout`, `::stripeSubscription` once new flow is stable. ## Testing & QA - **Feature tests**: JSON endpoints for session lifecycle (create, provider select, intent creation, capture success/failure, free activation). Include multi-locale assertions. -- **Payment integration tests**: use Stripe + PayPal SDK test doubles to simulate success, requires_action, cancellation, and ensure state machine behaves. -- **Playwright**: wizard flow covering Stripe happy path, Stripe 3DS stub, PayPal approval, failure retry, free package shortcut, session resume after refresh. +- **Payment integration tests**: use Stripe + Paddle SDK test doubles to simulate success, requires_action, cancellation, and ensure state machine behaves. +- **Playwright**: wizard flow covering Stripe happy path, Stripe 3DS stub, Paddle approval, failure retry, free package shortcut, session resume after refresh. - **Webhooks**: unit tests for mapping provider ids to sessions, plus job tests for idempotent assignment. - **Scheduler**: test `checkout:expire-sessions` to confirm PaymentIntents are cancelled and sessions flagged `cancelled`. ## Open Questions / Follow-Ups -- Map package records to Stripe price ids and PayPal plan ids (store on `packages` table or config?). +- Map package records to Stripe price ids and Paddle plan ids (store on `packages` table or config?). - Confirm legal copy updates for new checkout experience before GA. - Align email templates (welcome, receipt) with new assignment service outputs. diff --git a/docs/prp/tenant-app-specs/README.md b/docs/prp/tenant-app-specs/README.md index 4cf937c..e518794 100644 --- a/docs/prp/tenant-app-specs/README.md +++ b/docs/prp/tenant-app-specs/README.md @@ -13,7 +13,7 @@ Die App ersetzt das frühere Filament-basierte Tenant-Panel und fokussiert auf e ## Aktuelle Highlights (Q4 2025) - **Geführtes Onboarding**: `/event-admin/welcome/*` orchestriert den Welcome Flow (Hero → How-It-Works → Paketwahl → Zusammenfassung → Event Setup). Guarding erfolgt über `onboarding_completed_at`. -- **Direkter Checkout**: Stripe & PayPal sind in die Paketwahl des Welcome Flows eingebettet; Fortschritt wird im Onboarding-Context persistiert. +- **Direkter Checkout**: Paddle sind in die Paketwahl des Welcome Flows eingebettet; Fortschritt wird im Onboarding-Context persistiert. - **Filament Wizard**: Für Super-Admins existiert ein paralleler QR/Join-Token-Wizard in Filament (Token-Generierung, Layout-Downloads, Rotation). - **Join Tokens only**: Gäste erhalten ausschließlich join-token-basierte Links/QRs; slug-basierte URLs wurden deaktiviert. QR-Drucklayouts liegen unter `resources/views/pdf/join-tokens/*`. - **OAuth Alignment**: `VITE_OAUTH_CLIENT_ID` + `/event-admin/auth/callback` werden seedingseitig synchron gehalten; siehe `docs/prp/tenant-app-specs/api-usage.md`. diff --git a/docs/prp/tenant-app-specs/functional-specs.md b/docs/prp/tenant-app-specs/functional-specs.md index 4384b4e..cc5d4ed 100644 --- a/docs/prp/tenant-app-specs/functional-specs.md +++ b/docs/prp/tenant-app-specs/functional-specs.md @@ -11,7 +11,7 @@ Die Admin-App muss folgende Kernfunktionen bereitstellen: - **Galerie-Management**: Upload, Moderation, Feature-Flags, Analytics. - **Mitglieder-Verwaltung**: Einladungen, Rollen, Zugriffskontrolle. - **Tasks & Emotions**: Bibliothek, Zuweisung, Fortschritts-Tracking. -- **Abrechnung**: Paketübersicht, Stripe/PayPal Checkout, Ledger. +- **Abrechnung**: Paketübersicht, Paddle Checkout, Ledger. - **Einstellungen**: Branding, Limits, Rechtstexte, Benachrichtigungen. - **Offline-Support**: App-Shell-Caching, Queueing von Mutationen, Sync bei Reconnect. - **Compliance**: Audit-Logging, GDPR-konforme Löschung, ETag-basierte Konfliktlösung. @@ -25,7 +25,7 @@ Die Admin-App muss folgende Kernfunktionen bereitstellen: - Routen `/event-admin/welcome/*` bilden den Flow. - Filament stellt einen korrespondierenden Onboarding-Wizard (QR/Join-Token, Layout-Download) bereit; Abschluss setzt `onboarding_completed_at` serverseitig. - `useOnboardingProgress` persistiert Fortschritt (localStorage) und synchronisiert mit Backend (`onboarding_completed_at`). -- Paketwahl nutzt `GET /tenant/packages`; Stripe/PayPal-Fallbacks informieren bei fehlender Konfiguration. +- Paketwahl nutzt `GET /tenant/packages`; Paddle-Fallbacks informieren bei fehlender Konfiguration. - Dashboard weist per CTA auf offenes Onboarding hin, bis ein erstes Event erstellt wurde. ### Event Lifecycle @@ -45,7 +45,7 @@ Die Admin-App muss folgende Kernfunktionen bereitstellen: ### Billing & Checkout - Pakete + Credit-Balance anzeigen. -- Stripe PaymentIntent & PayPal Smart Buttons; Fallback-Meldung bei fehlender Konfiguration. +- Stripe PaymentIntent & Paddle Smart Buttons; Fallback-Meldung bei fehlender Konfiguration. - Ledger mit Historie (Paginierung, Filter). ### Settings @@ -83,7 +83,7 @@ Die App nutzt Endpunkte aus `docs/prp/03-api.md`. ## Teststrategie - **PHPUnit**: Feature-Tests für Auth-Guards (Tenant ohne Events → Welcome Flow). - **React Testing Library**: `TenantWelcomeLayout`, `PackageSelection`, `OnboardingGuard`, `OrderSummary`. -- **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` deckt Login, Welcome → Packages → Summary → Event Setup ab; Erweiterung um Stripe/PayPal Happy Paths und Offline/Retry geplant. -- **Smoke Tests**: `npm run test:e2e` in CI mit optionalen Credentials (`E2E_TENANT_EMAIL`, `E2E_TENANT_PASSWORD`, Stripe/PayPal Keys). +- **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` deckt Login, Welcome → Packages → Summary → Event Setup ab; Erweiterung um Paddle Happy Paths und Offline/Retry geplant. +- **Smoke Tests**: `npm run test:e2e` in CI mit optionalen Credentials (`E2E_TENANT_EMAIL`, `E2E_TENANT_PASSWORD`, Paddle Keys). Für UI-Details siehe `docs/prp/tenant-app-specs/pages-ui.md`. Einstellungen werden in `docs/prp/tenant-app-specs/settings-config.md` beschrieben. diff --git a/docs/prp/tenant-app-specs/pages-ui.md b/docs/prp/tenant-app-specs/pages-ui.md index ad25170..a100cc0 100644 --- a/docs/prp/tenant-app-specs/pages-ui.md +++ b/docs/prp/tenant-app-specs/pages-ui.md @@ -18,8 +18,8 @@ | --- | --- | --- | --- | | Hero | `/event-admin/welcome` | `TenantWelcomeLayout`, `WelcomeStepCard`, `EmblaCarousel` | CTA „Pakete entdecken“, sekundärer Link „Später entscheiden“ | | How It Works | `/event-admin/welcome` (Carousel Slide) | Icon Cards, Animated Gradients | 3 Vorteile (Fotos festhalten, Aufgaben, Gäste aktivieren) | -| Paketwahl | `/event-admin/welcome/packages` | `PackageCard`, `PricingToggle`, `QueryPackageList` | Stripe/PayPal Pricing, Feature-Badges, Auswahl persistiert im Onboarding-Context | -| Zusammenfassung | `/event-admin/welcome/summary` | `OrderSummaryCard`, Stripe Elements, PayPal Buttons | Hinweise bei fehlender Zahlungs-Konfiguration, CTA „Weiter zum Setup“ | +| Paketwahl | `/event-admin/welcome/packages` | `PackageCard`, `PricingToggle`, `QueryPackageList` | Paddle Pricing, Feature-Badges, Auswahl persistiert im Onboarding-Context | +| Zusammenfassung | `/event-admin/welcome/summary` | `OrderSummaryCard`, Stripe Elements, Paddle Buttons | Hinweise bei fehlender Zahlungs-Konfiguration, CTA „Weiter zum Setup“ | | Event Setup | `/event-admin/welcome/event` | `FirstEventForm`, `FormStepper`, Toasts | Formular (Name, Datum, Sprache, Feature-Toggles) + Abschluss CTA „Event erstellen“ | ### Guards & Fortschritt @@ -39,7 +39,7 @@ - **Fotos**: Moderationsgrid (Masonry), Filter (Neu, Genehmigt, Featured), Bulk-Aktionen in Sticky-Footer. - **Tasks**: Tabs (Bibliothek, Zuweisungen), Drag-and-Drop (React Beautiful DnD), Inline-Editor für Aufgaben. - **Einstellungen**: Accordion-Struktur (Branding, Legal Pages, Benachrichtigungen, Abrechnung). Preview-Panel für Farben und Logos. -- **Abrechnung**: Kreditübersicht, Kauflog (infinite-scroll), Zahlungsoptionen (Stripe Karte, PayPal Checkout). +- **Abrechnung**: Kreditübersicht, Kauflog (infinite-scroll), Zahlungsoptionen (Stripe Karte, Paddle Checkout). ## Informationsarchitektur (aktuelle React-Router-Konfiguration) ``` @@ -61,7 +61,7 @@ ## Testabdeckung (UI) - **Jest/RTL**: `TenantWelcomeLayout`, `WelcomeStepCard`, `PackageSelection`, `OnboardingGuard`. -- **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` (Login Guard, Welcome → Packages → Summary → Event Setup). Erweiterbar um Stripe/PayPal-Happy-Path sowie Offline-/Retry-Szenarien. +- **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` (Login Guard, Welcome → Packages → Summary → Event Setup). Erweiterbar um Paddle-Happy-Path sowie Offline-/Retry-Szenarien. ## Legacy-Referenz (Framework7 Entwurf 2025-09) Die ursprünglichen Wireframes für Framework7 (Toolbar, FAB, Infinite Scroll) sind weiterhin im Repo historisiert (`docs/prp/tenant-app-specs/pages-ui-legacy.md`). Für Vergleiche bei Regressionen oder Migrationen bitte dort nachsehen. diff --git a/docs/screenshots/tenant-admin-onboarding/03-package-selection.svg b/docs/screenshots/tenant-admin-onboarding/03-package-selection.svg index 1c78698..0d0993f 100644 --- a/docs/screenshots/tenant-admin-onboarding/03-package-selection.svg +++ b/docs/screenshots/tenant-admin-onboarding/03-package-selection.svg @@ -52,6 +52,6 @@ - Stripe & PayPal Widgets erscheinen unterhalb der Karten, sobald Keys konfiguriert sind. + Stripe & Paddle Widgets erscheinen unterhalb der Karten, sobald Keys konfiguriert sind. diff --git a/docs/screenshots/tenant-admin-onboarding/04-order-summary.svg b/docs/screenshots/tenant-admin-onboarding/04-order-summary.svg index 843a06c..1ff36c2 100644 --- a/docs/screenshots/tenant-admin-onboarding/04-order-summary.svg +++ b/docs/screenshots/tenant-admin-onboarding/04-order-summary.svg @@ -9,7 +9,7 @@ Paket: Pro – 3 Events, 1000 Uploads - Zahlungsart: Stripe oder PayPal + Zahlungsart: Stripe oder Paddle @@ -44,10 +44,10 @@ - PayPal Smart Buttons + Paddle Checkout Links - Automatische Darstellung abhängig vom PayPal Client ID. + Automatische Darstellung abhängig von der Paddle-Konfiguration. diff --git a/docs/screenshots/tenant-admin-onboarding/README.md b/docs/screenshots/tenant-admin-onboarding/README.md index 778fcf8..5a34c2f 100644 --- a/docs/screenshots/tenant-admin-onboarding/README.md +++ b/docs/screenshots/tenant-admin-onboarding/README.md @@ -4,7 +4,7 @@ | --- | --- | | `01-welcome-hero.svg` | Hero-Screen mit CTA „Pakete entdecken“. | | `02-how-it-works.svg` | Drei Highlight-Karten (Fotos, Aufgaben, Gäste). | -| `03-package-selection.svg` | Paketübersicht inkl. Stripe/PayPal Modulen. | +| `03-package-selection.svg` | Paketübersicht inkl. Paddle Modulen. | | `04-order-summary.svg` | Zusammenfassung mit Zahlungsoptionen. | | `05-event-setup.svg` | Formular für das erste Event. | diff --git a/docs/todo/paddle-catalog-sync.md b/docs/todo/paddle-catalog-sync.md new file mode 100644 index 0000000..f936ddc --- /dev/null +++ b/docs/todo/paddle-catalog-sync.md @@ -0,0 +1,28 @@ +# Paddle Catalog Sync Rollout + +- [x] **Schema Prep** + - [x] Add migration for `paddle_sync_status`, `paddle_synced_at`, and `paddle_snapshot` JSON on `packages`. + - [x] Update `Package` model casts/fillable + ensure factory coverage. +- [ ] **Service Layer** + - [x] Scaffold `PaddleCatalogService` (product/price CRUD, custom data mapping). + - [ ] Add DTO helpers for Paddle product/price responses. + - [ ] Extend `PaddleClient` tests/mocks for catalog endpoints. +- [x] **Sync Logic** + - [x] Implement `SyncPackageToPaddle` job with create/update flows and metadata diffing. + - [x] Create `PaddlePackagePull` job for optional remote-to-local reconciliation. + - [x] Add `paddle:sync-packages` artisan command (`--dry-run`, `--package=`, `--pull`). +- [ ] **Admin UX** + - [x] Enhance Filament PackageResource with sync status badges + last sync timestamp. + - [ ] Add table/detail actions (“Sync to Paddle”, “Link existing Paddle entity”). + - [ ] Surface last error/log context in the admin sidebar panel. +- [ ] **Ops & Observability** + - [ ] Configure dedicated log channel/Slack hook for catalog sync outcomes. + - [ ] Document failure recovery playbook (retry, unlink, support escalation). +- [ ] **Testing & QA** + - [x] Unit tests for service + jobs using mocked Paddle API. + - [x] Feature test covering artisan command flow. + - [ ] Playwright smoke to confirm admin sync action displays status. +- [ ] **Rollout Checklist** + - [ ] Seed Paddle sandbox catalog via MCP server using migrated data. + - [ ] Verify legacy packages mapped to Paddle IDs before enabling auto-sync. + - [ ] Announce workflow change to admin users (release notes + docs update). diff --git a/docs/todo/paddle-migration.md b/docs/todo/paddle-migration.md new file mode 100644 index 0000000..caedfaf --- /dev/null +++ b/docs/todo/paddle-migration.md @@ -0,0 +1,14 @@ +# Paddle Billing Migration + +- [x] Review current billing implementation (Stripe, Paddle, RevenueCat) across code, jobs, webhooks, docs. +- [x] Design Paddle data mappings for packages ↔ products/prices, including required metadata round-trip. +- [ ] Extend Laravel config/env handling for Paddle keys, webhook secrets, feature flags (sandbox + production). +- [ ] Build Paddle API service layer and register sandbox webhooks; document endpoints/events consumed. +- [ ] Add admin catalog sync UI for packages (create/update in Paddle, display sync status, store Paddle IDs). +- [ ] Implement tenant ↔ Paddle customer synchronization and related webhook handlers. +- [x] Replace marketing checkout payment step with Paddle-hosted checkout flow and success callbacks. +- [ ] Update tenant admin billing pages to read Paddle subscription/transaction data and manage plans. +- [ ] Define mobile/native billing strategy (RevenueCat vs Paddle) and align app logic. +- [ ] Add automated tests for Paddle integration (unit, feature, e2e) covering checkout, webhooks, sync. +- [ ] Populate Paddle sandbox catalog via MCP server and validate end-to-end activation flow. +- [ ] Draft production cutover procedure (catalog creation, flag switch, legacy shutdown, monitoring, rollback). diff --git a/docs/todo/security-hardening-epic.md b/docs/todo/security-hardening-epic.md index bf73de8..8a3ebd6 100644 --- a/docs/todo/security-hardening-epic.md +++ b/docs/todo/security-hardening-epic.md @@ -43,7 +43,7 @@ Raise the baseline security posture across guest APIs, checkout, media storage, - `SEC-MS-04` — Storage health widget in Super Admin (Week 4). 5. **Payments & Webhooks (Billing)** - - Link Stripe/PayPal webhooks to checkout sessions with idempotency locks. + - Link Paddle webhooks to checkout sessions with idempotency locks. - Add signature freshness validation + retry policies for provider outages. - Pipe failed capture events into credit ledger audits and operator alerts. - **Tickets** diff --git a/docs/todo/tenant-admin-onboarding-fusion.md b/docs/todo/tenant-admin-onboarding-fusion.md index 1b352fc..096520f 100644 --- a/docs/todo/tenant-admin-onboarding-fusion.md +++ b/docs/todo/tenant-admin-onboarding-fusion.md @@ -32,11 +32,11 @@ Owner: Codex (handoff) - [x] Review PWA manifest/offline setup so die kombinierte Welcome+Management-Experience TWA-/Capacitor-ready ist (Manifest + `admin-sw.js` dokumentiert). - [x] Extend docs: PRP-Onboarding-Abschnitte aktualisiert, Screenshots unter `docs/screenshots/tenant-admin-onboarding/` ergänzt, Testscope notiert. - [x] Add automated coverage: Vitest + Testing Library für Welcome Landing, Dashboard-Guard und Checkout-Komponenten; `npm run test:unit` führt Suite aus. -- [x] Finalise direct checkout: Stripe/PayPal-Flows markieren Fortschritt, API-Mocks + Unit-Tests decken Erfolgs- und Fehlerpfade ab. +- [x] Finalise direct checkout: Paddle-Flows markieren Fortschritt, API-Mocks + Unit-Tests decken Erfolgs- und Fehlerpfade ab. - [x] Lokalisierung ausbauen: Landing-, Packages-, Summary- und Event-Setup-Screens sind nun DE/EN übersetzt; Copy-Review für weitere Module (Tasks/Billing/Members) bleibt offen. ## Risks & Open Questions -- Confirm checkout UX expectations (Stripe vs PayPal) before wiring package purchase into onboarding. +- Confirm checkout UX expectations (Stripe vs Paddle) before wiring package purchase into onboarding. - Validate whether onboarding flow must be localized at launch; coordinate mit den neuen i18n JSONs und fehlenden Übersetzungen. - Determine deprecation plan for fotospiel-tenant-app/tenant-admin-app once the merged flow ships. diff --git a/playwright-report/index.html b/playwright-report/index.html index 3aa33ab..c38bb5d 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+n.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/public/lang/de/legal.json b/public/lang/de/legal.json index 982f8d8..d2aba88 100644 --- a/public/lang/de/legal.json +++ b/public/lang/de/legal.json @@ -10,14 +10,14 @@ "contact": "Kontakt", "vat_id": "Umsatzsteuer-ID: DE123456789", "monetization": "Monetarisierung", - "monetization_desc": "Wir monetarisieren über Packages (Einmalkäufe und Abos) via Stripe und PayPal. Preise exkl. MwSt. Support: support@fotospiel.de", + "monetization_desc": "Wir monetarisieren über Packages (Einmalkäufe und Abos) via Paddle. 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: Fotospiel GmbH, 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 Stripe und PayPal. Karteninformationen werden nicht gespeichert – alle Daten werden verschlüsselt übertragen.", + "payments_desc": "Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsdaten werden als Merchant of Record sicher und verschlüsselt durch Paddle 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.", @@ -29,7 +29,6 @@ "data_security_desc": "Wir verwenden HTTPS, verschlüsselte Speicherung (Passwörter hashed) und regelmäßige Backups. Zugriff auf Daten ist rollebasierend beschränkt (Tenant vs SuperAdmin).", "and": "und", "stripe_privacy": "Stripe Datenschutz", - "paypal_privacy": "PayPal Datenschutz", "agb": "Allgemeine Geschäftsbedingungen", "effective_from": "Gültig seit {{date}}", "version": "Version {{version}}" diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index 520e3da..d96221e 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -88,7 +88,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 Stripe oder PayPal – sicher und GDPR-konform.", + "faq_a4": "Ja, via Paddle – sicher und GDPR-konform.", "final_cta": "Bereit für Ihr nächstes Event?", "contact_us": "Kontaktieren Sie uns", "feature_live_slideshow": "Live-Slideshow", @@ -115,7 +115,7 @@ "billing_per_year": "pro Jahr", "more_features": "+{{count}} weitere Features", "feature_overview": "Feature-Überblick", - "order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung via Stripe oder PayPal.", + "order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.", "features_label": "Features", "feature_highlights": "Feature-Highlights", "more_details_tab": "Mehr Details", @@ -145,7 +145,7 @@ "faq_free_desc": "Das Free Package bietet grundlegende Features für kleine Events mit begrenzter Anzahl an Fotos und Gästen.", "faq_upgrade_desc": "Ja, Sie können jederzeit upgraden, um mehr Features und Limits zu erhalten. Der Upgrade ist nahtlos und Ihre Daten bleiben erhalten.", "faq_reseller_desc": "Reseller-Packages sind jährliche Abos für Agenturen, die mehrere Events verwalten. Inklusive Dashboard und Branding-Optionen.", - "faq_payment_desc": "Alle Zahlungen werden über sichere Provider wie Stripe oder PayPal abgewickelt. Ihre Daten sind GDPR-konform geschützt.", + "faq_payment_desc": "Alle Zahlungen werden über sichere Provider wie Paddle abgewickelt. Ihre Daten sind GDPR-konform geschützt.", "testimonials": { "anna": "Fotospiel hat unsere Hochzeit perfekt gemacht! Die Gäste konnten einfach Fotos teilen, und die Galerie war ein Hit.", "max": "Als Event-Organisator liebe ich die Analytics und das einfache Branding. Super für Firmenevents!", @@ -163,7 +163,9 @@ "euro": "€" }, "view_details": "Details ansehen", - "feature": "Feature" + "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." }, "blog": { "title": "Fotospiel - Blog", @@ -280,22 +282,15 @@ "no_account": "Kein Konto? Registrieren", "manage_subscription": "Abo verwalten", "stripe_dashboard": "Stripe-Dashboard", - "paypal_dashboard": "PayPal-Dashboard", "trial_activated": "Trial aktiviert für 14 Tage!" }, "payment": { "title": "Zahlung", "card_details": "Kartendetails", "stripe": "Kreditkarte", - "paypal": "PayPal", "submit_stripe": "Bezahlen mit Karte (:price)", - "submit_paypal": "Bezahlen mit PayPal (:price)", "loading_stripe": "Lade Stripe...", - "paypal_description": "Sichere Zahlung mit PayPal", "switch_to_card": "Zur Kreditkarte wechseln", - "paypal_create_error": "PayPal-Bestellung fehlgeschlagen", - "paypal_capture_error": "PayPal-Capture fehlgeschlagen", - "paypal_error": "PayPal-Zahlung fehlgeschlagen", "stripe_error": "Stripe-Zahlung fehlgeschlagen", "confirm_error": "Bestätigung fehlgeschlagen", "complete_error": "Zahlung konnte nicht abgeschlossen werden" @@ -404,8 +399,19 @@ "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 mit Kreditkarte, Debitkarte oder SEPA-Lastschrift.", - "secure_paypal_desc": "Sichere Zahlung mit PayPal.", + "secure_payment_desc": "Sichere Zahlung über Paddle.", + "paddle_intro": "Wir öffnen den Paddle-Checkout direkt hier im Wizard, damit du im Ablauf bleibst.", + "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", + "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.", "payment_failed": "Zahlung fehlgeschlagen. ", "error_card": "Kartenfehler aufgetreten.", "error_validation": "Eingabedaten sind ungültig.", @@ -419,25 +425,18 @@ "unexpected_status": "Unerwarteter Zahlungsstatus: {status}", "processing_btn": "Verarbeitung...", "pay_now": "Jetzt bezahlen (€{price})", - "stripe_not_loaded": "Stripe ist nicht initialisiert. Bitte Seite neu laden.", "network_error": "Netzwerkfehler beim Laden der Zahlungsdaten", - "payment_intent_error": "Fehler beim Laden der Zahlungsdaten", - "paypal_order_error": "PayPal-Bestellung konnte nicht erstellt werden. Bitte erneut versuchen.", - "paypal_capture_error": "PayPal-Abschluss fehlgeschlagen. Bitte erneut versuchen.", - "paypal_error": "PayPal meldete einen Fehler.", - "paypal_cancelled": "Sie haben die PayPal-Zahlung abgebrochen.", - "paypal_missing_plan": "Für dieses Paket fehlt die PayPal-Plan-Konfiguration. Bitte wählen Sie eine andere Zahlungsmethode.", "auth_required": "Bitte melde dich an, um mit der Zahlung fortzufahren.", "status_loading": "Zahlungsvorbereitung läuft…", "status_ready": "Zahlungsformular bereit. Bitte gib deine Daten ein.", "status_processing": "Zahlung mit {{provider}} wird verarbeitet…", "status_success": "Zahlung bestätigt. Wir schließen den Kauf ab…", "status_info_title": "Zahlungsstatus", + "status_processing_title": "Checkout wird geöffnet", + "status_ready_title": "Checkout geöffnet", "status_error_title": "Zahlung fehlgeschlagen", "status_success_title": "Zahlung abgeschlossen", - "status_retry": "Erneut versuchen", - "method_stripe": "Kreditkarte (Stripe)", - "method_paypal": "PayPal" + "status_retry": "Erneut versuchen" }, "confirmation_step": { "title": "Bestätigung", diff --git a/public/lang/en/legal.json b/public/lang/en/legal.json index b3d9707..ba5e531 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 Stripe and PayPal. Prices excl. VAT. Support: support@fotospiel.de", + "monetization_desc": "We monetize through Packages (one-time purchases and subscriptions) via Paddle. 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: Fotospiel GmbH, 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 Stripe and PayPal. Card information is not stored – all data is transmitted encrypted. See Stripe Privacy and PayPal Privacy.", + "payments_desc": "We process payments for Packages via Paddle. Payment data is handled securely and encrypted by Paddle 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 e4db96f..9ac9c8c 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -78,7 +78,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 Stripe or PayPal – secure and GDPR compliant.", + "faq_a4": "Yes, via Paddle – secure and GDPR compliant.", "final_cta": "Ready for your next event?", "contact_us": "Contact Us", "feature_live_slideshow": "Live Slideshow", @@ -105,7 +105,7 @@ "billing_per_year": "per year", "more_features": "+{{count}} more features", "feature_overview": "Feature overview", - "order_hint": "Launch instantly – secure Stripe or PayPal checkout, no hidden fees.", + "order_hint": "Launch instantly – secure Paddle checkout, no hidden fees.", "features_label": "Features", "feature_highlights": "Feature Highlights", "more_details_tab": "More Details", @@ -149,7 +149,9 @@ }, "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." }, "blog": { "title": "Fotospiel - Blog", @@ -266,22 +268,15 @@ "no_account": "No Account? Register", "manage_subscription": "Manage Subscription", "stripe_dashboard": "Stripe Dashboard", - "paypal_dashboard": "PayPal Dashboard", "trial_activated": "Trial activated for 14 days!" }, "payment": { "title": "Payment", "card_details": "Card Details", "stripe": "Credit Card", - "paypal": "PayPal", "submit_stripe": "Pay with Card (:price)", - "submit_paypal": "Pay with PayPal (:price)", "loading_stripe": "Loading Stripe...", - "paypal_description": "Secure payment with PayPal", "switch_to_card": "Switch to Credit Card", - "paypal_create_error": "PayPal order creation failed", - "paypal_capture_error": "PayPal capture failed", - "paypal_error": "PayPal payment failed", "stripe_error": "Stripe payment failed", "confirm_error": "Confirmation failed", "complete_error": "Payment could not be completed" @@ -398,8 +393,19 @@ "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 credit card, debit card or SEPA direct debit.", - "secure_paypal_desc": "Pay securely with PayPal.", + "secure_payment_desc": "Secure payment with Paddle.", + "paddle_intro": "We open Paddle's secure checkout directly inside this wizard so you never leave the flow.", + "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", + "continue_after_payment": "I completed the payment", + "no_package_title": "No package selected", + "no_package_description": "Please choose a package to continue to checkout.", "payment_failed": "Payment failed. ", "error_card": "Card error occurred.", "error_validation": "Input data is invalid.", @@ -413,25 +419,18 @@ "unexpected_status": "Unexpected payment status: {status}", "processing_btn": "Processing...", "pay_now": "Pay Now (${price})", - "stripe_not_loaded": "Stripe is not initialized. Please reload the page.", "network_error": "Network error loading payment data", - "payment_intent_error": "Error loading payment data", - "paypal_order_error": "Could not create the PayPal order. Please try again.", - "paypal_capture_error": "PayPal capture failed. Please try again.", - "paypal_error": "PayPal reported an error.", - "paypal_cancelled": "You cancelled the PayPal payment.", - "paypal_missing_plan": "Missing PayPal plan configuration for this package. Please choose another payment method.", "auth_required": "Please log in to continue to payment.", "status_loading": "Preparing secure payment data…", "status_ready": "Payment form ready. Enter your details to continue.", "status_processing": "Processing payment with {{provider}}…", "status_success": "Payment confirmed. Finalising your order…", "status_info_title": "Payment status", + "status_processing_title": "We are opening the checkout", + "status_ready_title": "Checkout opened", "status_error_title": "Payment failed", "status_success_title": "Payment completed", - "status_retry": "Retry", - "method_stripe": "Credit Card (Stripe)", - "method_paypal": "PayPal" + "status_retry": "Retry" }, "confirmation_step": { "title": "Confirmation", diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 3cd93c7..2b66377 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -119,6 +119,20 @@ export type CreditBalance = { free_event_granted_at?: string | null; }; +export type PaddleTransactionSummary = { + id: string | null; + status: string | null; + amount: number | null; + currency: string | null; + origin: string | null; + checkout_id: string | null; + created_at: string | null; + updated_at: string | null; + receipt_url?: string | null; + grand_total?: number | null; + tax?: number | null; +}; + export type CreditLedgerEntry = { id: number; delta: number; @@ -444,6 +458,25 @@ function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary { }; } +function normalizePaddleTransaction(entry: JsonValue): PaddleTransactionSummary { + const amountValue = entry.amount ?? entry.grand_total ?? (entry.totals && entry.totals.grand_total); + const taxValue = entry.tax ?? (entry.totals && entry.totals.tax_total); + + return { + id: typeof entry.id === 'string' ? entry.id : entry.id ? String(entry.id) : null, + status: entry.status ?? null, + amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null, + currency: entry.currency ?? entry.currency_code ?? 'EUR', + origin: entry.origin ?? null, + checkout_id: entry.checkout_id ?? (entry.details?.checkout_id ?? null), + created_at: entry.created_at ?? null, + updated_at: entry.updated_at ?? null, + receipt_url: entry.receipt_url ?? entry.invoice_url ?? null, + grand_total: entry.grand_total !== undefined && entry.grand_total !== null ? Number(entry.grand_total) : null, + tax: taxValue !== undefined && taxValue !== null ? Number(taxValue) : null, + }; +} + function normalizeTask(task: JsonValue): TenantTask { const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {}); const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {}); @@ -813,6 +846,35 @@ export async function getTenantPackagesOverview(): Promise<{ return { packages, activePackage }; } +export async function getTenantPaddleTransactions(cursor?: string): Promise<{ + data: PaddleTransactionSummary[]; + nextCursor: string | null; + hasMore: boolean; +}> { + const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''; + const response = await authorizedFetch(`/api/v1/tenant/billing/transactions${query}`); + + if (response.status === 404) { + return { data: [], nextCursor: null, hasMore: false }; + } + + 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'); + } + + const payload = await safeJson(response) ?? {}; + const entries = Array.isArray(payload.data) ? payload.data : []; + const meta = payload.meta ?? {}; + + return { + data: entries.map(normalizePaddleTransaction), + nextCursor: typeof meta.next === 'string' ? meta.next : null, + hasMore: Boolean(meta.has_more), + }; +} + export async function getCreditBalance(): Promise { const response = await authorizedFetch('/api/v1/tenant/credits/balance'); if (response.status === 404) { @@ -868,17 +930,17 @@ export async function createTenantPackagePaymentIntent(packageId: number): Promi export async function completeTenantPackagePurchase(params: { packageId: number; paymentMethodId?: string; - paypalOrderId?: string; + paddleTransactionId?: string; }): Promise { - const { packageId, paymentMethodId, paypalOrderId } = params; + const { packageId, paymentMethodId, paddleTransactionId } = params; const payload: Record = { package_id: packageId }; if (paymentMethodId) { payload.payment_method_id = paymentMethodId; } - if (paypalOrderId) { - payload.paypal_order_id = paypalOrderId; + if (paddleTransactionId) { + payload.paddle_transaction_id = paddleTransactionId; } const response = await authorizedFetch('/api/v1/tenant/packages/complete', { @@ -904,8 +966,8 @@ export async function assignFreeTenantPackage(packageId: number): Promise await jsonOrThrow(response, 'Failed to assign free package'); } -export async function createTenantPayPalOrder(packageId: number): Promise { - const response = await authorizedFetch('/api/v1/tenant/packages/paypal-create', { +export async function createTenantPaddleCheckout(packageId: number): Promise<{ checkout_url: string }> { + const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -913,24 +975,12 @@ export async function createTenantPayPalOrder(packageId: number): Promise(response, 'Failed to create PayPal order'); - if (!data.orderID) { - throw new Error('Missing PayPal order ID'); + const data = await jsonOrThrow<{ checkout_url: string }>(response, 'Failed to create Paddle checkout'); + if (!data.checkout_url) { + throw new Error('Missing Paddle checkout URL'); } - return data.orderID; -} - -export async function captureTenantPayPalOrder(orderId: string): Promise { - const response = await authorizedFetch('/api/v1/tenant/packages/paypal-capture', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ order_id: orderId }), - }); - - await jsonOrThrow(response, 'Failed to capture PayPal order'); + return { checkout_url: data.checkout_url }; } export async function recordCreditPurchase(payload: { diff --git a/resources/js/admin/auth/tokens.ts b/resources/js/admin/auth/tokens.ts index 0317542..c672a79 100644 --- a/resources/js/admin/auth/tokens.ts +++ b/resources/js/admin/auth/tokens.ts @@ -58,6 +58,7 @@ export interface StoredTokens { refreshToken: string; expiresAt: number; scope?: string; + clientId?: string; } export interface TokenResponse { @@ -83,13 +84,14 @@ export function loadTokens(): StoredTokens | null { return stored; } -export function saveTokens(response: TokenResponse): StoredTokens { +export function saveTokens(response: TokenResponse, clientId: string = getClientId()): StoredTokens { const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000; const stored: StoredTokens = { accessToken: response.access_token, refreshToken: response.refresh_token, expiresAt, scope: response.scope, + clientId, }; localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored)); return stored; @@ -110,19 +112,21 @@ export async function ensureAccessToken(): Promise { return tokens.accessToken; } - return refreshAccessToken(tokens.refreshToken); + return refreshAccessToken(tokens); } -async function refreshAccessToken(refreshToken: string): Promise { - if (!refreshToken) { +async function refreshAccessToken(tokens: StoredTokens): Promise { + const clientId = tokens.clientId ?? getClientId(); + + if (!tokens.refreshToken) { notifyAuthFailure(); throw new AuthError('unauthenticated', 'Missing refresh token'); } const params = new URLSearchParams({ grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: getClientId(), + refresh_token: tokens.refreshToken, + client_id: clientId, }); const response = await fetch(TOKEN_ENDPOINT, { @@ -138,7 +142,7 @@ async function refreshAccessToken(refreshToken: string): Promise { } const data = (await response.json()) as TokenResponse; - const stored = saveTokens(data); + const stored = saveTokens(data, clientId); return stored.accessToken; } @@ -215,10 +219,12 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise = { @@ -29,11 +30,9 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(tokens)); window.location.assign('/event-admin/dashboard'); } catch (error) { - if (error instanceof Error) { - console.error('[DevAuth] Failed to login', error.message); - } else { - console.error('[DevAuth] Failed to login', error); - } + const message = error instanceof Error ? error.message : String(error); + console.error('[DevAuth] Failed to login', message); + throw error instanceof Error ? error : new Error(message); } } @@ -84,6 +83,7 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true refreshToken: body.refresh_token, expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000, scope: body.scope, + clientId, }; } @@ -126,9 +126,11 @@ function requestAuthorization(url: string): Promise { } const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location'); - if (xhr.status >= 200 && xhr.status < 400 && responseUrl) { - resolve(new URL(responseUrl, window.location.origin)); - return; + if ((xhr.status >= 200 && xhr.status < 400) || xhr.status === 0) { + if (responseUrl) { + resolve(new URL(responseUrl, window.location.origin)); + return; + } } reject(new Error(`Authorize failed with ${xhr.status}`)); diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 135eda1..67c2655 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -45,6 +45,27 @@ "available": "Verfügbar", "expires": "Läuft ab" } + }, + "transactions": { + "title": "Paddle-Transaktionen", + "description": "Neueste Paddle-Transaktionen für diesen Tenant.", + "empty": "Noch keine Paddle-Transaktionen.", + "labels": { + "transactionId": "Transaktion {{id}}", + "checkoutId": "Checkout-ID: {{id}}", + "origin": "Herkunft: {{origin}}", + "receipt": "Beleg ansehen", + "tax": "Steuer: {{value}}" + }, + "status": { + "completed": "Abgeschlossen", + "processing": "Verarbeitung", + "failed": "Fehlgeschlagen", + "cancelled": "Storniert", + "unknown": "Unbekannt" + }, + "loadMore": "Weitere Transaktionen laden", + "loadingMore": "Laden…" } }, "packages": { @@ -149,8 +170,7 @@ "high": "Hoch", "urgent": "Dringend" } - } - , + }, "collections": { "title": "Aufgabenvorlagen", "subtitle": "Durchstöbere kuratierte Vorlagen oder aktiviere sie für deine Events.", @@ -244,58 +264,56 @@ "cancel": "Abbrechen", "submit": "Emotion speichern" } - } - , - "management": { - "billing": { - "title": "Pakete & Abrechnung", - "subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.", - "actions": { - "refresh": "Aktualisieren", - "exportCsv": "Export als CSV" - }, - "errors": { - "load": "Paketdaten konnten nicht geladen werden.", - "more": "Weitere Einträge konnten nicht geladen werden." - }, - "sections": { - "overview": { - "title": "Paketübersicht", - "description": "Dein aktives Paket und die wichtigsten Kennzahlen.", - "empty": "Noch kein Paket aktiv.", - "emptyBadge": "Kein aktives Paket", - "cards": { - "package": { - "label": "Aktives Paket", - "helper": "Aktuell zugewiesen" - }, - "used": { - "label": "Genutzte Events", - "helper": "Verfügbar: {{count}}" - }, - "price": { - "label": "Preis (netto)" - }, - "expires": { - "label": "Läuft ab", - "helper": "Automatische Verlängerung, falls aktiv" - } + }, + "management": { + "billing": { + "title": "Pakete & Abrechnung", + "subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.", + "actions": { + "refresh": "Aktualisieren", + "exportCsv": "Export als CSV" + }, + "errors": { + "load": "Paketdaten konnten nicht geladen werden.", + "more": "Weitere Einträge konnten nicht geladen werden." + }, + "sections": { + "overview": { + "title": "Paketübersicht", + "description": "Dein aktives Paket und die wichtigsten Kennzahlen.", + "empty": "Noch kein Paket aktiv.", + "emptyBadge": "Kein aktives Paket", + "cards": { + "package": { + "label": "Aktives Paket", + "helper": "Aktuell zugewiesen" + }, + "used": { + "label": "Genutzte Events", + "helper": "Verfügbar: {{count}}" + }, + "price": { + "label": "Preis (netto)" + }, + "expires": { + "label": "Läuft ab", + "helper": "Automatische Verlängerung, falls aktiv" } } - }, - "packages": { - "title": "Paket-Historie", - "description": "Übersicht über aktuelle und vergangene Pakete.", - "empty": "Noch keine Pakete gebucht.", - "card": { - "statusActive": "Aktiv", - "statusInactive": "Inaktiv", - "used": "Genutzte Events", - "available": "Verfügbar", - "expires": "Läuft ab" - } + } + }, + "packages": { + "title": "Paket-Historie", + "description": "Übersicht über aktuelle und vergangene Pakete.", + "empty": "Noch keine Pakete gebucht.", + "card": { + "statusActive": "Aktiv", + "statusInactive": "Inaktiv", + "used": "Genutzte Events", + "available": "Verfügbar", + "expires": "Läuft ab" } } } -} - + } +} \ No newline at end of file diff --git a/resources/js/admin/i18n/locales/de/onboarding.json b/resources/js/admin/i18n/locales/de/onboarding.json index 4b8d1ee..6fec849 100644 --- a/resources/js/admin/i18n/locales/de/onboarding.json +++ b/resources/js/admin/i18n/locales/de/onboarding.json @@ -165,46 +165,25 @@ "failureTitle": "Aktivierung fehlgeschlagen", "errorMessage": "Kostenloses Paket konnte nicht aktiviert werden." }, - "stripe": { - "sectionTitle": "Kartenzahlung (Stripe)", - "heading": "Kartenzahlung", - "notReady": "Zahlungsmodul noch nicht bereit. Bitte lade die Seite neu.", - "genericError": "Zahlung fehlgeschlagen. Bitte erneut versuchen.", - "missingPaymentId": "Zahlung konnte nicht bestätigt werden (fehlende Zahlungs-ID).", - "completionFailed": "Der Kauf wurde bei uns noch nicht verbucht. Bitte kontaktiere den Support mit deiner Zahlungsbestätigung.", - "errorTitle": "Zahlung fehlgeschlagen", - "submitting": "Zahlung wird bestätigt …", - "submit": "Jetzt bezahlen", - "hint": "Sicherer Checkout über Stripe. Du erhältst eine Bestätigung, sobald der Kauf verbucht wurde.", - "loading": "Zahlungsdetails werden geladen …", - "unavailableTitle": "Stripe nicht verfügbar", - "unavailableDescription": "Stripe konnte nicht initialisiert werden.", - "missingKey": "Stripe Publishable Key fehlt. Bitte konfiguriere VITE_STRIPE_PUBLISHABLE_KEY.", - "intentFailed": "Stripe-Zahlung konnte nicht vorbereitet werden." - }, - "paypal": { - "sectionTitle": "PayPal", - "heading": "PayPal", - "createFailed": "PayPal-Bestellung konnte nicht erstellt werden. Bitte versuche es erneut.", - "captureFailed": "PayPal-Zahlung konnte nicht abgeschlossen werden. Bitte kontaktiere den Support, falls der Betrag bereits abgebucht wurde.", - "errorTitle": "PayPal-Fehler", - "genericError": "PayPal hat ein Problem gemeldet. Bitte versuche es später erneut.", - "missingOrderId": "PayPal hat keine Order-ID geliefert.", - "cancelled": "PayPal-Zahlung wurde abgebrochen.", - "hint": "PayPal leitet dich ggf. weiter, um die Zahlung zu bestätigen. Anschließend kommst du automatisch zurück.", - "notConfiguredTitle": "PayPal nicht konfiguriert", - "notConfiguredDescription": "Hinterlege VITE_PAYPAL_CLIENT_ID, damit Gastgeber optional mit PayPal bezahlen können." + "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." }, "nextStepsTitle": "Nächste Schritte", "nextSteps": [ - "Optional: Abrechnung abschließen (Stripe/PayPal) im Billing-Bereich.", + "Optional: Abrechnung über Paddle im Billing-Bereich abschließen.", "Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.", "Vor dem Go-Live Credits prüfen und Gäste-Link teilen." ], "cta": { "billing": { "label": "Abrechnung starten", - "description": "Öffnet den Billing-Bereich mit allen Kaufoptionen (Stripe, PayPal, Credits).", + "description": "Öffnet den Billing-Bereich mit Paddle- und Credit-Optionen.", "button": "Zu Billing & Zahlung" }, "setup": { @@ -261,8 +240,4 @@ } } } -} - - - - +} \ No newline at end of file diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index b7c365e..002eff0 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -45,6 +45,27 @@ "available": "Remaining", "expires": "Expires" } + }, + "transactions": { + "title": "Paddle transactions", + "description": "Recent Paddle transactions for this tenant.", + "empty": "No Paddle transactions yet.", + "labels": { + "transactionId": "Transaction {{id}}", + "checkoutId": "Checkout ID: {{id}}", + "origin": "Origin: {{origin}}", + "receipt": "View receipt", + "tax": "Tax: {{value}}" + }, + "status": { + "completed": "Completed", + "processing": "Processing", + "failed": "Failed", + "cancelled": "Cancelled", + "unknown": "Unknown" + }, + "loadMore": "Load more transactions", + "loadingMore": "Loading…" } }, "packages": { @@ -149,8 +170,7 @@ "high": "High", "urgent": "Urgent" } - } - , + }, "collections": { "title": "Task collections", "subtitle": "Browse curated task bundles or activate them for your events.", @@ -244,57 +264,56 @@ "cancel": "Cancel", "submit": "Save emotion" } - } - , - "management": { - "billing": { - "title": "Packages & billing", - "subtitle": "Manage your purchased packages and track their durations.", - "actions": { - "refresh": "Refresh", - "exportCsv": "Export CSV" - }, - "errors": { - "load": "Unable to load package data.", - "more": "Unable to load more entries." - }, - "sections": { - "overview": { - "title": "Package overview", - "description": "Your active package and the most important metrics.", - "empty": "No active package yet.", - "emptyBadge": "No active package", - "cards": { - "package": { - "label": "Active package", - "helper": "Currently assigned" - }, - "used": { - "label": "Events used", - "helper": "Remaining: {{count}}" - }, - "price": { - "label": "Price (net)" - }, - "expires": { - "label": "Expires", - "helper": "Auto-renews if enabled" - } + }, + "management": { + "billing": { + "title": "Packages & billing", + "subtitle": "Manage your purchased packages and track their durations.", + "actions": { + "refresh": "Refresh", + "exportCsv": "Export CSV" + }, + "errors": { + "load": "Unable to load package data.", + "more": "Unable to load more entries." + }, + "sections": { + "overview": { + "title": "Package overview", + "description": "Your active package and the most important metrics.", + "empty": "No active package yet.", + "emptyBadge": "No active package", + "cards": { + "package": { + "label": "Active package", + "helper": "Currently assigned" + }, + "used": { + "label": "Events used", + "helper": "Remaining: {{count}}" + }, + "price": { + "label": "Price (net)" + }, + "expires": { + "label": "Expires", + "helper": "Auto-renews if enabled" } } - }, - "packages": { - "title": "Package history", - "description": "Overview of current and past packages.", - "empty": "No packages purchased yet.", - "card": { - "statusActive": "Active", - "statusInactive": "Inactive", - "used": "Used events", - "available": "Available", - "expires": "Expires" - } + } + }, + "packages": { + "title": "Package history", + "description": "Overview of current and past packages.", + "empty": "No packages purchased yet.", + "card": { + "statusActive": "Active", + "statusInactive": "Inactive", + "used": "Used events", + "available": "Available", + "expires": "Expires" } } } -} + } +} \ No newline at end of file diff --git a/resources/js/admin/i18n/locales/en/onboarding.json b/resources/js/admin/i18n/locales/en/onboarding.json index 4442824..b302e98 100644 --- a/resources/js/admin/i18n/locales/en/onboarding.json +++ b/resources/js/admin/i18n/locales/en/onboarding.json @@ -165,46 +165,25 @@ "failureTitle": "Activation failed", "errorMessage": "The free package could not be activated." }, - "stripe": { - "sectionTitle": "Card payment (Stripe)", - "heading": "Card payment", - "notReady": "Payment module not ready yet. Please refresh.", - "genericError": "Payment failed. Please try again.", - "missingPaymentId": "Could not confirm payment (missing payment ID).", - "completionFailed": "Purchase not recorded yet. Contact support with your payment confirmation.", - "errorTitle": "Payment failed", - "submitting": "Confirming payment …", - "submit": "Pay now", - "hint": "Secure checkout via Stripe. You'll receive confirmation once recorded.", - "loading": "Loading payment details …", - "unavailableTitle": "Stripe unavailable", - "unavailableDescription": "Stripe could not be initialised.", - "missingKey": "Stripe publishable key missing. Configure VITE_STRIPE_PUBLISHABLE_KEY.", - "intentFailed": "Stripe could not prepare the payment." - }, - "paypal": { - "sectionTitle": "PayPal", - "heading": "PayPal", - "createFailed": "PayPal order could not be created. Please try again.", - "captureFailed": "PayPal payment could not be captured. Contact support if funds were withdrawn.", - "errorTitle": "PayPal error", - "genericError": "PayPal reported a problem. Please try again later.", - "missingOrderId": "PayPal did not return an order ID.", - "cancelled": "PayPal payment was cancelled.", - "hint": "PayPal may redirect you briefly to confirm. You'll return automatically afterwards.", - "notConfiguredTitle": "PayPal not configured", - "notConfiguredDescription": "Provide VITE_PAYPAL_CLIENT_ID so hosts can pay with PayPal." + "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." }, "nextStepsTitle": "Next steps", "nextSteps": [ - "Optional: finish billing (Stripe/PayPal) inside the billing area.", + "Optional: finish billing via Paddle inside the billing area.", "Complete the event setup and configure tasks, team, and gallery.", "Check credits before go-live and share your guest link." ], "cta": { "billing": { "label": "Start billing", - "description": "Opens the billing area with Stripe, PayPal, and credit options.", + "description": "Opens the billing area with Paddle and credit options.", "button": "Go to billing" }, "setup": { @@ -261,4 +240,4 @@ } } } -} +} \ No newline at end of file diff --git a/resources/js/admin/onboarding/__tests__/WelcomeOrderSummary.checkout.test.tsx b/resources/js/admin/onboarding/__tests__/WelcomeOrderSummary.checkout.test.tsx index d4bd5a9..3039a10 100644 --- a/resources/js/admin/onboarding/__tests__/WelcomeOrderSummary.checkout.test.tsx +++ b/resources/js/admin/onboarding/__tests__/WelcomeOrderSummary.checkout.test.tsx @@ -1,176 +1,65 @@ import React from 'react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { - StripeCheckoutForm, - PayPalCheckout, -} from '../pages/WelcomeOrderSummaryPage'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { PaddleCheckout } from '../pages/WelcomeOrderSummaryPage'; -const stripeRef: { current: any } = { current: null }; -const elementsRef: { current: any } = { current: null }; -const paypalPropsRef: { current: any } = { current: null }; - -const { - confirmPaymentMock, - completePurchaseMock, - createPayPalOrderMock, - capturePayPalOrderMock, -} = vi.hoisted(() => ({ - confirmPaymentMock: vi.fn(), - completePurchaseMock: vi.fn(), - createPayPalOrderMock: vi.fn(), - capturePayPalOrderMock: vi.fn(), -})); - -vi.mock('@stripe/react-stripe-js', () => ({ - useStripe: () => stripeRef.current, - useElements: () => elementsRef.current, - PaymentElement: () =>
, - Elements: ({ children }: { children: React.ReactNode }) => <>{children}, -})); - -vi.mock('@paypal/react-paypal-js', () => ({ - PayPalScriptProvider: ({ children }: { children: React.ReactNode }) => <>{children}, - PayPalButtons: (props: any) => { - paypalPropsRef.current = props; - return ; - }, +const { createPaddleCheckoutMock } = vi.hoisted(() => ({ + createPaddleCheckoutMock: vi.fn(), })); vi.mock('../../api', () => ({ - completeTenantPackagePurchase: completePurchaseMock, - createTenantPackagePaymentIntent: vi.fn(), assignFreeTenantPackage: vi.fn(), - createTenantPayPalOrder: createPayPalOrderMock, - captureTenantPayPalOrder: capturePayPalOrderMock, + createTenantPaddleCheckout: createPaddleCheckoutMock, })); -describe('StripeCheckoutForm', () => { +describe('PaddleCheckout', () => { beforeEach(() => { - confirmPaymentMock.mockReset(); - completePurchaseMock.mockReset(); - stripeRef.current = { confirmPayment: confirmPaymentMock }; - elementsRef.current = {}; + createPaddleCheckoutMock.mockReset(); }); - const renderStripeForm = (overrides?: Partial>) => - render( - key} - {...overrides} - /> - ); - - it('completes the purchase when Stripe reports a successful payment', async () => { - const onSuccess = vi.fn(); - confirmPaymentMock.mockResolvedValue({ - error: null, - paymentIntent: { payment_method: 'pm_123' }, - }); - completePurchaseMock.mockResolvedValue(undefined); - - const { container } = renderStripeForm({ onSuccess }); - const form = container.querySelector('form'); - expect(form).toBeTruthy(); - fireEvent.submit(form!); - - await waitFor(() => { - expect(completePurchaseMock).toHaveBeenCalledWith({ - packageId: 42, - paymentMethodId: 'pm_123', - }); - }); - expect(onSuccess).toHaveBeenCalled(); - }); - - it('shows Stripe errors returned by confirmPayment', async () => { - confirmPaymentMock.mockResolvedValue({ - error: { message: 'Card declined' }, - }); - - const { container } = renderStripeForm(); - fireEvent.submit(container.querySelector('form')!); - - await waitFor(() => { - expect(screen.getByText('Card declined')).toBeInTheDocument(); - }); - expect(completePurchaseMock).not.toHaveBeenCalled(); - }); - - it('reports missing payment method id', async () => { - confirmPaymentMock.mockResolvedValue({ - error: null, - paymentIntent: {}, - }); - - const { container } = renderStripeForm(); - fireEvent.submit(container.querySelector('form')!); - - await waitFor(() => { - expect(screen.getByText('summary.stripe.missingPaymentId')).toBeInTheDocument(); - }); - expect(completePurchaseMock).not.toHaveBeenCalled(); - }); -}); - -describe('PayPalCheckout', () => { - beforeEach(() => { - paypalPropsRef.current = null; - createPayPalOrderMock.mockReset(); - capturePayPalOrderMock.mockReset(); - }); - - it('creates and captures a PayPal order successfully', async () => { - createPayPalOrderMock.mockResolvedValue('ORDER-123'); - capturePayPalOrderMock.mockResolvedValue(undefined); + it('opens Paddle checkout when created successfully', async () => { + createPaddleCheckoutMock.mockResolvedValue({ checkout_url: 'https://paddle.example/checkout' }); const onSuccess = vi.fn(); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); render( - key} /> ); - expect(paypalPropsRef.current).toBeTruthy(); - const { createOrder, onApprove } = paypalPropsRef.current; await act(async () => { - const orderId = await createOrder(); - expect(orderId).toBe('ORDER-123'); - }); - await act(async () => { - await onApprove({ orderID: 'ORDER-123' }); + screen.getByRole('button').click(); }); await waitFor(() => { - expect(createPayPalOrderMock).toHaveBeenCalledWith(99); - expect(capturePayPalOrderMock).toHaveBeenCalledWith('ORDER-123'); + expect(createPaddleCheckoutMock).toHaveBeenCalledWith(99); + expect(openSpy).toHaveBeenCalledWith('https://paddle.example/checkout', '_blank', 'noopener'); expect(onSuccess).toHaveBeenCalled(); }); + + openSpy.mockRestore(); }); - it('surfaces missing order id errors', async () => { - createPayPalOrderMock.mockResolvedValue('ORDER-123'); + it('shows an error message on failure', async () => { + createPaddleCheckoutMock.mockRejectedValue(new Error('boom')); + render( - key} /> ); - const { onApprove } = paypalPropsRef.current; await act(async () => { - await onApprove({ orderID: undefined }); + screen.getByRole('button').click(); }); await waitFor(() => { - expect(screen.getByText('summary.paypal.missingOrderId')).toBeInTheDocument(); + expect(screen.getByText('summary.paddle.genericError')).toBeInTheDocument(); }); - expect(capturePayPalOrderMock).not.toHaveBeenCalled(); }); }); diff --git a/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx b/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx index 0526d79..97d65f9 100644 --- a/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx +++ b/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx @@ -10,8 +10,6 @@ import { AlertTriangle, Loader2, } from "lucide-react"; -import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js"; -import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js"; import { TenantWelcomeLayout, @@ -26,30 +24,15 @@ import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PA import { useTenantPackages } from "../hooks/useTenantPackages"; import { assignFreeTenantPackage, - completeTenantPackagePurchase, - createTenantPackagePaymentIntent, - createTenantPayPalOrder, - captureTenantPayPalOrder, + createTenantPaddleCheckout, } from "../../api"; -import { getStripe } from '@/utils/stripe'; -const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ""; -const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? ""; - -type StripeCheckoutProps = { - clientSecret: string; +type PaddleCheckoutProps = { packageId: number; onSuccess: () => void; t: ReturnType["t"]; }; -type PayPalCheckoutProps = { - packageId: number; - onSuccess: () => void; - t: ReturnType["t"]; - currency?: string; -}; - function useLocaleFormats(locale: string) { const currencyFormatter = React.useMemo( () => @@ -86,175 +69,53 @@ function humanizeFeature(t: ReturnType["t"], key: string) .join(" "); } -function StripeCheckoutForm({ clientSecret, packageId, onSuccess, t }: StripeCheckoutProps) { - const stripe = useStripe(); - const elements = useElements(); - const [submitting, setSubmitting] = React.useState(false); +function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) { + const [status, setStatus] = React.useState<'idle' | 'processing' | 'success' | 'error'>('idle'); const [error, setError] = React.useState(null); - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - if (!stripe || !elements) { - setError(t("summary.stripe.notReady")); - return; - } - - setSubmitting(true); - setError(null); - - const result = await stripe.confirmPayment({ - elements, - confirmParams: { - return_url: window.location.href, - }, - redirect: "if_required", - }); - - if (result.error) { - setError(result.error.message ?? t("summary.stripe.genericError")); - setSubmitting(false); - return; - } - - const paymentIntent = result.paymentIntent; - const paymentMethodId = - typeof paymentIntent?.payment_method === "string" - ? paymentIntent.payment_method - : typeof paymentIntent?.id === "string" - ? paymentIntent.id - : null; - - if (!paymentMethodId) { - setError(t("summary.stripe.missingPaymentId")); - setSubmitting(false); - return; - } - + const handleCheckout = React.useCallback(async () => { try { - await completeTenantPackagePurchase({ - packageId, - paymentMethodId, - }); + setStatus('processing'); + setError(null); + const { checkout_url } = await createTenantPaddleCheckout(packageId); + window.open(checkout_url, '_blank', 'noopener'); + setStatus('success'); onSuccess(); - } catch (purchaseError) { - console.error("[Onboarding] Purchase completion failed", purchaseError); - setError( - purchaseError instanceof Error - ? purchaseError.message - : t("summary.stripe.completionFailed") - ); - setSubmitting(false); + } catch (err) { + console.error('[Onboarding] Paddle checkout failed', err); + setStatus('error'); + setError(err instanceof Error ? err.message : t('summary.paddle.genericError')); } - }; + }, [packageId, onSuccess, t]); return ( -
-
-

{t("summary.stripe.heading")}

- -
+
+

{t('summary.paddle.heading')}

{error && ( - {t("summary.stripe.errorTitle")} + {t('summary.paddle.errorTitle')} {error} )} -

{t("summary.stripe.hint")}

- - ); -} - -function PayPalCheckout({ packageId, onSuccess, t, currency = "EUR" }: PayPalCheckoutProps) { - const [status, setStatus] = React.useState<"idle" | "creating" | "capturing" | "error" | "success">("idle"); - const [error, setError] = React.useState(null); - - const handleCreateOrder = React.useCallback(async () => { - try { - setStatus("creating"); - const orderId = await createTenantPayPalOrder(packageId); - setStatus("idle"); - setError(null); - return orderId; - } catch (err) { - console.error("[Onboarding] PayPal create order failed", err); - setStatus("error"); - setError( - err instanceof Error ? err.message : t("summary.paypal.createFailed") - ); - throw err; - } - }, [packageId, t]); - - const handleApprove = React.useCallback( - async (orderId: string) => { - try { - setStatus("capturing"); - await captureTenantPayPalOrder(orderId); - setStatus("success"); - setError(null); - onSuccess(); - } catch (err) { - console.error("[Onboarding] PayPal capture failed", err); - setStatus("error"); - setError( - err instanceof Error ? err.message : t("summary.paypal.captureFailed") - ); - throw err; - } - }, - [onSuccess, t] - ); - - return ( -
-

{t("summary.paypal.heading")}

- {error && ( - - {t("summary.paypal.errorTitle")} - {error} - - )} - handleCreateOrder()} - onApprove={async (data) => { - if (!data.orderID) { - setError(t("summary.paypal.missingOrderId")); - setStatus("error"); - return; - } - await handleApprove(data.orderID); - }} - onError={(err) => { - console.error("[Onboarding] PayPal onError", err); - setStatus("error"); - setError(t("summary.paypal.genericError")); - }} - onCancel={() => { - setStatus("idle"); - setError(t("summary.paypal.cancelled")); - }} - disabled={status === "creating" || status === "capturing"} - /> -

{t("summary.paypal.hint")}

+

{t('summary.paddle.hint')}

); } @@ -267,7 +128,6 @@ export default function WelcomeOrderSummaryPage() { const { t, i18n } = useTranslation("onboarding"); const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE"; const { currencyFormatter, dateFormatter } = useLocaleFormats(locale); - const stripePromise = React.useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]); const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined; const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null; @@ -295,48 +155,9 @@ export default function WelcomeOrderSummaryPage() { const isSubscription = Boolean(packageDetails?.features?.subscription); const requiresPayment = Boolean(packageDetails && packageDetails.price > 0); - const [clientSecret, setClientSecret] = React.useState(null); - const [intentStatus, setIntentStatus] = React.useState<"idle" | "loading" | "error" | "ready">("idle"); - const [intentError, setIntentError] = React.useState(null); const [freeAssignStatus, setFreeAssignStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle"); const [freeAssignError, setFreeAssignError] = React.useState(null); - React.useEffect(() => { - if (!requiresPayment || !packageDetails) { - setClientSecret(null); - setIntentStatus("idle"); - setIntentError(null); - return; - } - - if (!stripePromise) { - setIntentError(t("summary.stripe.missingKey")); - setIntentStatus("error"); - return; - } - - let cancelled = false; - setIntentStatus("loading"); - setIntentError(null); - - createTenantPackagePaymentIntent(packageDetails.id) - .then((secret) => { - if (cancelled) return; - setClientSecret(secret); - setIntentStatus("ready"); - }) - .catch((error) => { - console.error("[Onboarding] Payment intent failed", error); - if (cancelled) return; - setIntentStatus("error"); - setIntentError(error instanceof Error ? error.message : t("summary.stripe.intentFailed")); - }); - - return () => { - cancelled = true; - }; - }, [requiresPayment, packageDetails, stripePromise, t]); - const priceText = progress.selectedPackage?.priceText ?? (packageDetails && typeof packageDetails.price === "number" @@ -534,63 +355,16 @@ export default function WelcomeOrderSummaryPage() { )} {requiresPayment && ( -
-
-

{t("summary.stripe.sectionTitle")}

- {intentStatus === "loading" && ( -
- - {t("summary.stripe.loading")} -
- )} - {intentStatus === "error" && ( - - {t("summary.stripe.unavailableTitle")} - {intentError ?? t("summary.stripe.unavailableDescription")} - - )} - {intentStatus === "ready" && clientSecret && stripePromise && ( - - { - markStep({ packageSelected: true }); - navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true }); - }} - t={t} - /> - - )} -
- - {paypalClientId ? ( -
-

{t("summary.paypal.sectionTitle")}

- - { - markStep({ packageSelected: true }); - navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true }); - }} - t={t} - /> - -
- ) : ( - - {t("summary.paypal.notConfiguredTitle")} - {t("summary.paypal.notConfiguredDescription")} - - )} +
+

{t('summary.paddle.sectionTitle')}

+ { + markStep({ packageSelected: true }); + navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true }); + }} + t={t} + />
)} @@ -634,4 +408,4 @@ export default function WelcomeOrderSummaryPage() { ); } -export { StripeCheckoutForm, PayPalCheckout }; +export { PaddleCheckout }; diff --git a/resources/js/admin/pages/BillingPage.tsx b/resources/js/admin/pages/BillingPage.tsx index c43ebdd..fa14f30 100644 --- a/resources/js/admin/pages/BillingPage.tsx +++ b/resources/js/admin/pages/BillingPage.tsx @@ -9,7 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Separator } from '@/components/ui/separator'; import { AdminLayout } from '../components/AdminLayout'; -import { getTenantPackagesOverview, TenantPackageSummary } from '../api'; +import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api'; import { isAuthError } from '../auth/tokens'; export default function BillingPage() { @@ -21,6 +21,10 @@ export default function BillingPage() { const [packages, setPackages] = React.useState([]); const [activePackage, setActivePackage] = React.useState(null); + const [transactions, setTransactions] = React.useState([]); + const [transactionCursor, setTransactionCursor] = React.useState(null); + const [transactionsHasMore, setTransactionsHasMore] = React.useState(false); + const [transactionsLoading, setTransactionsLoading] = React.useState(false); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); @@ -57,9 +61,18 @@ export default function BillingPage() { setLoading(true); setError(null); try { - const packagesResult = await getTenantPackagesOverview(); + const [packagesResult, paddleTransactions] = await Promise.all([ + getTenantPackagesOverview(), + getTenantPaddleTransactions().catch((err) => { + console.warn('Failed to load Paddle transactions', err); + return { data: [] as PaddleTransactionSummary[], nextCursor: null, hasMore: false }; + }), + ]); setPackages(packagesResult.packages); setActivePackage(packagesResult.activePackage); + setTransactions(paddleTransactions.data); + setTransactionCursor(paddleTransactions.nextCursor); + setTransactionsHasMore(paddleTransactions.hasMore); } catch (err) { if (!isAuthError(err)) { setError(t('billing.errors.load')); @@ -69,6 +82,25 @@ export default function BillingPage() { } }, [t]); + const loadMoreTransactions = React.useCallback(async () => { + if (!transactionsHasMore || transactionsLoading || !transactionCursor) { + return; + } + + setTransactionsLoading(true); + try { + const result = await getTenantPaddleTransactions(transactionCursor); + setTransactions((current) => [...current, ...result.data]); + setTransactionCursor(result.nextCursor); + setTransactionsHasMore(result.hasMore && Boolean(result.nextCursor)); + } catch (error) { + console.warn('Failed to load additional Paddle transactions', error); + setTransactionsHasMore(false); + } finally { + setTransactionsLoading(false); + } + }, [transactionCursor, transactionsHasMore, transactionsLoading]); + React.useEffect(() => { void loadAll(); }, [loadAll]); @@ -176,11 +208,134 @@ export default function BillingPage() { + + + + + {t('billing.sections.transactions.title')} + + + {t('billing.sections.transactions.description')} + + + + {transactions.length === 0 ? ( + + ) : ( +
+ {transactions.map((transaction) => ( + + ))} +
+ )} + {transactionsHasMore && ( + + )} +
+
+ )} ); } + +function TransactionCard({ + transaction, + formatCurrency, + formatDate, + locale, + t, +}: { + transaction: PaddleTransactionSummary; + formatCurrency: (value: number | null | undefined, currency?: string) => string; + formatDate: (value: string | null | undefined) => string; + locale: string; + t: (key: string, options?: Record) => string; +}) { + const amount = transaction.grand_total ?? transaction.amount ?? null; + const currency = transaction.currency ?? 'EUR'; + const createdAtIso = transaction.created_at ?? null; + const createdAt = createdAtIso ? new Date(createdAtIso) : null; + const createdLabel = createdAt + ? createdAt.toLocaleString(locale, { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + : formatDate(createdAtIso); + const statusKey = transaction.status ? `billing.sections.transactions.status.${transaction.status}` : 'billing.sections.transactions.status.unknown'; + const statusText = t(statusKey, { + defaultValue: (transaction.status ?? 'unknown').replace(/_/g, ' '), + }); + + return ( +
+
+

+ {t('billing.sections.transactions.labels.transactionId', { id: transaction.id ?? '—' })} +

+

{createdLabel}

+ {transaction.checkout_id && ( +

+ {t('billing.sections.transactions.labels.checkoutId', { id: transaction.checkout_id })} +

+ )} + {transaction.origin && ( +

+ {t('billing.sections.transactions.labels.origin', { origin: transaction.origin })} +

+ )} +
+
+ + {statusText} + +
+ {formatCurrency(amount, currency)} +
+ {transaction.tax !== undefined && transaction.tax !== null && ( + + {t('billing.sections.transactions.labels.tax', { value: formatCurrency(transaction.tax, currency) })} + + )} + {transaction.receipt_url && ( + + {t('billing.sections.transactions.labels.receipt')} + + )} +
+
+ ); +} + function InfoCard({ label, value, diff --git a/resources/js/layouts/app/Footer.tsx b/resources/js/layouts/app/Footer.tsx index 4fd3b88..8f2820e 100644 --- a/resources/js/layouts/app/Footer.tsx +++ b/resources/js/layouts/app/Footer.tsx @@ -15,10 +15,10 @@ const Footer: React.FC = () => {
- FotoSpiel.App Logo + FotoSpiel.App Logo
- FotoSpiel.App + Die FotoSpiel.App

Deine Plattform für Event-Fotos. @@ -57,7 +57,7 @@ const Footer: React.FC = () => {

- © 2025 FotoSpiel.App - Alle Rechte vorbehalten. + © 2025 Die FotoSpiel.App - Alle Rechte vorbehalten.
diff --git a/resources/js/layouts/app/Header.tsx b/resources/js/layouts/app/Header.tsx index d7d0e22..aa761d1 100644 --- a/resources/js/layouts/app/Header.tsx +++ b/resources/js/layouts/app/Header.tsx @@ -121,7 +121,7 @@ const Header: React.FC = () => { FotoSpiel.App Logo - FotoSpiel.App + Die FotoSpiel.App diff --git a/resources/js/pages/auth/RegisterForm.tsx b/resources/js/pages/auth/RegisterForm.tsx index 75f691d..7ce30ba 100644 --- a/resources/js/pages/auth/RegisterForm.tsx +++ b/resources/js/pages/auth/RegisterForm.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import toast from 'react-hot-toast'; import { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import type { GoogleProfilePrefill } from '../marketing/checkout/types'; declare const route: (name: string, params?: Record) => string; @@ -18,6 +19,8 @@ interface RegisterFormProps { onSuccess?: (payload: RegisterSuccessPayload) => void; privacyHtml: string; locale?: string; + prefill?: GoogleProfilePrefill; + onClearGoogleProfile?: () => void; } type RegisterFormFields = { @@ -30,13 +33,15 @@ type RegisterFormFields = { address: string; phone: string; privacy_consent: boolean; + terms: boolean; package_id: number | null; }; -export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale }: RegisterFormProps) { +export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale, prefill, onClearGoogleProfile }: RegisterFormProps) { const [privacyOpen, setPrivacyOpen] = useState(false); const [hasTriedSubmit, setHasTriedSubmit] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const [prefillApplied, setPrefillApplied] = useState(false); const { t } = useTranslation(['auth', 'common']); const page = usePage<{ errors: Record; locale?: string; auth?: { user?: any | null } }>(); const resolvedLocale = locale ?? page.props.locale ?? 'de'; @@ -51,6 +56,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale address: '', phone: '', privacy_consent: false, + terms: false, package_id: packageId || null, }); @@ -61,6 +67,62 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale }, [errors, hasTriedSubmit]); const registerEndpoint = '/checkout/register'; + + const namePrefill = useMemo(() => { + const rawFirst = prefill?.given_name ?? prefill?.name?.split(' ')[0] ?? ''; + const remaining = prefill?.name ? prefill.name.split(' ').slice(1).join(' ') : ''; + const rawLast = prefill?.family_name ?? remaining; + + return { + first: rawFirst ?? '', + last: rawLast ?? '', + }; + }, [prefill]); + + const suggestedUsername = useMemo(() => { + if (prefill?.email) { + const localPart = prefill.email.split('@')[0]; + if (localPart) { + return localPart.slice(0, 30); + } + } + + const first = prefill?.given_name ?? prefill?.name?.split(' ')[0] ?? ''; + const last = prefill?.family_name ?? prefill?.name?.split(' ').slice(1).join(' ') ?? ''; + const combined = `${first}${last}`.trim(); + if (!combined) { + return undefined; + } + + return combined + .toLowerCase() + .replace(/[^a-z0-9]+/g, '') + .slice(0, 30) || undefined; + }, [prefill]); + + useEffect(() => { + if (!prefill || prefillApplied) { + return; + } + + if (namePrefill.first && !data.first_name) { + setData('first_name', namePrefill.first); + } + + if (namePrefill.last && !data.last_name) { + setData('last_name', namePrefill.last); + } + + if (prefill.email && !data.email) { + setData('email', prefill.email); + } + + if (suggestedUsername && !data.username) { + setData('username', suggestedUsername); + } + + setPrefillApplied(true); + }, [prefill, namePrefill.first, namePrefill.last, data.first_name, data.last_name, data.email, data.username, prefillApplied, setData, suggestedUsername]); const submit = async (event: React.FormEvent) => { event.preventDefault(); @@ -95,6 +157,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale redirect: json?.redirect ?? null, pending_purchase: json?.pending_purchase ?? json?.user?.pending_purchase ?? false, }); + onClearGoogleProfile?.(); reset(); setHasTriedSubmit(false); return; @@ -362,9 +425,13 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale checked={data.privacy_consent} onChange={(e) => { setData('privacy_consent', e.target.checked); + setData('terms', e.target.checked); if (e.target.checked && errors.privacy_consent) { clearErrors('privacy_consent'); } + if (e.target.checked && errors.terms) { + clearErrors('terms'); + } }} className="h-4 w-4 text-[#FFB6C1] focus:ring-[#FFB6C1] border-gray-300 rounded" /> @@ -379,6 +446,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale . {errors.privacy_consent &&

{errors.privacy_consent}

} + {errors.terms &&

{errors.terms}

}
@@ -419,8 +487,3 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale } - - - - - diff --git a/resources/js/pages/marketing/CheckoutWizardPage.tsx b/resources/js/pages/marketing/CheckoutWizardPage.tsx index 74e6346..9ab1a9a 100644 --- a/resources/js/pages/marketing/CheckoutWizardPage.tsx +++ b/resources/js/pages/marketing/CheckoutWizardPage.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Head, usePage } from "@inertiajs/react"; import MarketingLayout from "@/layouts/mainWebsite"; -import type { CheckoutPackage } from "./checkout/types"; +import type { CheckoutPackage, GoogleProfilePrefill } from "./checkout/types"; import { CheckoutWizard } from "./checkout/CheckoutWizard"; import { Button } from "@/components/ui/button"; import { X } from "lucide-react"; @@ -9,20 +9,28 @@ import { X } from "lucide-react"; interface CheckoutWizardPageProps { package: CheckoutPackage; packageOptions: CheckoutPackage[]; - stripePublishableKey: string; - paypalClientId: string; privacyHtml: string; + googleAuth?: { + status?: string | null; + error?: string | null; + profile?: GoogleProfilePrefill | null; + }; + paddle?: { + environment?: string | null; + client_token?: string | null; + }; } export default function CheckoutWizardPage({ package: initialPackage, packageOptions, - stripePublishableKey, - paypalClientId, privacyHtml, + googleAuth, + paddle, }: CheckoutWizardPageProps) { const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null } }>(); const currentUser = page.props.auth?.user ?? null; + const googleProfile = googleAuth?.profile ?? null; const dedupedOptions = React.useMemo(() => { @@ -58,10 +66,10 @@ export default function CheckoutWizardPage({
diff --git a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx index 38003f0..c375cff 100644 --- a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx +++ b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx @@ -1,10 +1,10 @@ -import React, { useMemo, useRef, useEffect, useCallback, Suspense, lazy } from "react"; +import React, { useMemo, useRef, useEffect, useCallback, Suspense, lazy, useState } from "react"; import { useTranslation } from 'react-i18next'; import { Steps } from "@/components/ui/Steps"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { CheckoutWizardProvider, useCheckoutWizard } from "./WizardContext"; -import type { CheckoutPackage, CheckoutStepId } from "./types"; +import type { CheckoutPackage, CheckoutStepId, GoogleProfilePrefill } from "./types"; import { PackageStep } from "./steps/PackageStep"; import { AuthStep } from "./steps/AuthStep"; import { ConfirmationStep } from "./steps/ConfirmationStep"; @@ -15,8 +15,6 @@ const PaymentStep = lazy(() => import('./steps/PaymentStep').then((module) => ({ interface CheckoutWizardProps { initialPackage: CheckoutPackage; packageOptions: CheckoutPackage[]; - stripePublishableKey: string; - paypalClientId: string; privacyHtml: string; initialAuthUser?: { id: number; @@ -25,6 +23,11 @@ interface CheckoutWizardProps { pending_purchase?: boolean; } | null; initialStep?: CheckoutStepId; + googleProfile?: GoogleProfilePrefill | null; + paddle?: { + environment?: string | null; + client_token?: string | null; + } | null; } const baseStepConfig: { id: CheckoutStepId; titleKey: string; descriptionKey: string; detailsKey: string }[] = [ @@ -61,13 +64,34 @@ const PaymentStepFallback: React.FC = () => (
); -const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: string; privacyHtml: string }> = ({ stripePublishableKey, paypalClientId, privacyHtml }) => { +const WizardBody: React.FC<{ + privacyHtml: string; + googleProfile?: GoogleProfilePrefill | null; + onClearGoogleProfile?: () => void; +}> = ({ privacyHtml, googleProfile, onClearGoogleProfile }) => { const { t } = useTranslation('marketing'); - const { currentStep, nextStep, previousStep } = useCheckoutWizard(); + const { + currentStep, + nextStep, + previousStep, + selectedPackage, + authUser, + isAuthenticated, + paymentCompleted, + } = useCheckoutWizard(); const progressRef = useRef(null); const hasMountedRef = useRef(false); const { trackEvent } = useAnalytics(); + const isFreeSelected = useMemo(() => { + if (!selectedPackage) { + return false; + } + + const priceValue = Number(selectedPackage.price); + return Number.isFinite(priceValue) && priceValue <= 0; + }, [selectedPackage]); + const stepConfig = useMemo(() => baseStepConfig.map(step => ({ id: step.id, @@ -114,7 +138,41 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin }); }, [currentStep]); + const atLastStep = currentIndex >= stepConfig.length - 1; + + const canProceedToNextStep = useMemo(() => { + if (atLastStep) { + return false; + } + + if (currentStep === 'package') { + return Boolean(selectedPackage); + } + + if (currentStep === 'auth') { + return Boolean(isAuthenticated && authUser); + } + + if (currentStep === 'payment') { + return isFreeSelected || paymentCompleted; + } + + return true; + }, [atLastStep, authUser, currentStep, isAuthenticated, isFreeSelected, paymentCompleted, selectedPackage]); + + const shouldShowNextButton = useMemo(() => { + if (currentStep !== 'payment') { + return true; + } + + return isFreeSelected || paymentCompleted; + }, [currentStep, isFreeSelected, paymentCompleted]); + const handleNext = useCallback(() => { + if (!canProceedToNextStep) { + return; + } + const targetStep = stepConfig[currentIndex + 1]?.id ?? 'end'; trackEvent({ category: 'marketing_checkout', @@ -122,7 +180,7 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin name: `${currentStep}->${targetStep}`, }); nextStep(); - }, [currentIndex, currentStep, nextStep, stepConfig, trackEvent]); + }, [canProceedToNextStep, currentIndex, currentStep, nextStep, stepConfig, trackEvent]); const handlePrevious = useCallback(() => { const targetStep = stepConfig[currentIndex - 1]?.id ?? 'start'; @@ -151,10 +209,16 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
{currentStep === "package" && } - {currentStep === "auth" && } + {currentStep === "auth" && ( + + )} {currentStep === "payment" && ( }> - + )} {currentStep === "confirmation" && ( @@ -162,13 +226,17 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin )}
-
+
- + {shouldShowNextButton ? ( + + ) : ( +
); @@ -177,12 +245,51 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin export const CheckoutWizard: React.FC = ({ initialPackage, packageOptions, - stripePublishableKey, - paypalClientId, privacyHtml, initialAuthUser, initialStep, + googleProfile, + paddle, }) => { + const [storedProfile, setStoredProfile] = useState(() => { + if (typeof window === 'undefined') { + return null; + } + + const raw = window.localStorage.getItem('checkout-google-profile'); + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as GoogleProfilePrefill; + } catch (error) { + console.warn('Failed to parse checkout google profile from storage', error); + window.localStorage.removeItem('checkout-google-profile'); + return null; + } + }); + + useEffect(() => { + if (!googleProfile) { + return; + } + + setStoredProfile(googleProfile); + + if (typeof window !== 'undefined') { + window.localStorage.setItem('checkout-google-profile', JSON.stringify(googleProfile)); + } + }, [googleProfile]); + + const clearStoredProfile = useCallback(() => { + setStoredProfile(null); + if (typeof window !== 'undefined') { + window.localStorage.removeItem('checkout-google-profile'); + } + }, []); + + const effectiveProfile = googleProfile ?? storedProfile; return ( = ({ initialStep={initialStep} initialAuthUser={initialAuthUser ?? undefined} initialIsAuthenticated={Boolean(initialAuthUser)} + paddle={paddle ?? null} > - + ); }; diff --git a/resources/js/pages/marketing/checkout/WizardContext.tsx b/resources/js/pages/marketing/checkout/WizardContext.tsx index fdb37d3..e395942 100644 --- a/resources/js/pages/marketing/checkout/WizardContext.tsx +++ b/resources/js/pages/marketing/checkout/WizardContext.tsx @@ -10,6 +10,7 @@ interface CheckoutState { paymentIntent: string | null; loading: boolean; error: string | null; + paymentCompleted: boolean; } interface CheckoutWizardContextType { @@ -19,6 +20,11 @@ interface CheckoutWizardContextType { currentStep: CheckoutStepId; isAuthenticated: boolean; authUser: any; + paddleConfig?: { + environment?: string | null; + client_token?: string | null; + } | null; + paymentCompleted: boolean; selectPackage: (pkg: CheckoutPackage) => void; setSelectedPackage: (pkg: CheckoutPackage) => void; setAuthUser: (user: any) => void; @@ -31,6 +37,7 @@ interface CheckoutWizardContextType { setLoading: (loading: boolean) => void; setError: (error: string | null) => void; resetPaymentState: () => void; + setPaymentCompleted: (completed: boolean) => void; } const CheckoutWizardContext = createContext(null); @@ -44,6 +51,7 @@ const initialState: CheckoutState = { paymentIntent: null, loading: false, error: null, + paymentCompleted: false, }; type CheckoutAction = @@ -54,14 +62,15 @@ type CheckoutAction = | { type: 'GO_TO_STEP'; payload: CheckoutStepId } | { type: 'UPDATE_PAYMENT_INTENT'; payload: string | null } | { type: 'SET_LOADING'; payload: boolean } - | { type: 'SET_ERROR'; payload: string | null }; + | { type: 'SET_ERROR'; payload: string | null } + | { type: 'SET_PAYMENT_COMPLETED'; payload: boolean }; function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState { switch (action.type) { case 'SELECT_PACKAGE': - return { ...state, selectedPackage: action.payload }; + return { ...state, selectedPackage: action.payload, paymentCompleted: false }; case 'SET_AUTH_USER': - return { ...state, authUser: action.payload }; + return { ...state, authUser: action.payload, isAuthenticated: Boolean(action.payload) }; case 'NEXT_STEP': const steps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation']; const currentIndex = steps.indexOf(state.currentStep); @@ -84,6 +93,8 @@ function checkoutReducer(state: CheckoutState, action: CheckoutAction): Checkout return { ...state, loading: action.payload }; case 'SET_ERROR': return { ...state, error: action.payload }; + case 'SET_PAYMENT_COMPLETED': + return { ...state, paymentCompleted: action.payload }; default: return state; } @@ -96,6 +107,10 @@ interface CheckoutWizardProviderProps { initialStep?: CheckoutStepId; initialAuthUser?: any; initialIsAuthenticated?: boolean; + paddle?: { + environment?: string | null; + client_token?: string | null; + } | null; } export function CheckoutWizardProvider({ @@ -104,7 +119,8 @@ export function CheckoutWizardProvider({ packageOptions, initialStep, initialAuthUser, - initialIsAuthenticated + initialIsAuthenticated, + paddle, }: CheckoutWizardProviderProps) { const customInitialState: CheckoutState = { ...initialState, @@ -124,7 +140,8 @@ export function CheckoutWizardProvider({ if (savedState) { try { const parsed = JSON.parse(savedState); - if (parsed.selectedPackage && initialPackage && parsed.selectedPackage.id === initialPackage.id && parsed.currentStep !== 'confirmation') { + const hasValidPackage = parsed.selectedPackage && typeof parsed.selectedPackage.paddle_price_id === 'string' && parsed.selectedPackage.paddle_price_id !== ''; + if (hasValidPackage && initialPackage && parsed.selectedPackage.id === initialPackage.id && parsed.currentStep !== 'confirmation') { // Restore state selectively if (parsed.selectedPackage) dispatch({ type: 'SELECT_PACKAGE', payload: parsed.selectedPackage }); if (parsed.currentStep) dispatch({ type: 'GO_TO_STEP', payload: parsed.currentStep }); @@ -192,6 +209,11 @@ export function CheckoutWizardProvider({ dispatch({ type: 'UPDATE_PAYMENT_INTENT', payload: null }); dispatch({ type: 'SET_LOADING', payload: false }); dispatch({ type: 'SET_ERROR', payload: null }); + dispatch({ type: 'SET_PAYMENT_COMPLETED', payload: false }); + }, []); + + const setPaymentCompleted = useCallback((completed: boolean) => { + dispatch({ type: 'SET_PAYMENT_COMPLETED', payload: completed }); }, []); const cancelCheckout = useCallback(() => { @@ -226,6 +248,8 @@ export function CheckoutWizardProvider({ currentStep: state.currentStep, isAuthenticated: state.isAuthenticated, authUser: state.authUser, + paddleConfig: paddle ?? null, + paymentCompleted: state.paymentCompleted, selectPackage, setSelectedPackage, setAuthUser, @@ -238,6 +262,7 @@ export function CheckoutWizardProvider({ setLoading, setError, resetPaymentState, + setPaymentCompleted, }; return ( diff --git a/resources/js/pages/marketing/checkout/__tests__/CheckoutWizard.guard.test.tsx b/resources/js/pages/marketing/checkout/__tests__/CheckoutWizard.guard.test.tsx new file mode 100644 index 0000000..0afb7ed --- /dev/null +++ b/resources/js/pages/marketing/checkout/__tests__/CheckoutWizard.guard.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import { cleanup, render, screen, fireEvent } from '@testing-library/react'; +import { CheckoutWizard } from '../CheckoutWizard'; +import { useCheckoutWizard } from '../WizardContext'; + +vi.mock('@/hooks/useAnalytics', () => ({ + useAnalytics: () => ({ trackEvent: vi.fn() }), +})); + +vi.mock('../steps/PackageStep', () => ({ + PackageStep: () =>
, +})); + +vi.mock('../steps/AuthStep', () => ({ + AuthStep: () =>
, +})); + +vi.mock('../steps/PaymentStep', () => ({ + PaymentStep: () => { + const { setPaymentCompleted } = useCheckoutWizard(); + + return ( +
+ +
+ ); + }, +})); + +vi.mock('../steps/ConfirmationStep', () => ({ + ConfirmationStep: () =>
, +})); + +const basePackage = { + id: 1, + name: 'Starter', + description: 'Test package', + price: 0, + type: 'endcustomer' as const, + features: [], +}; + +describe('CheckoutWizard auth step navigation guard', () => { + beforeAll(() => { + // jsdom does not implement scrollTo, but the wizard calls it on step changes. + Object.defineProperty(window, 'scrollTo', { value: vi.fn(), writable: true }); + }); + + afterEach(() => { + cleanup(); + }); + + it('disables the next button when the user is not authenticated on the auth step', () => { + render( + , + ); + + const nextButton = screen.getByRole('button', { name: 'checkout.next' }); + expect(nextButton).toBeDisabled(); + }); + + it('enables the next button once the user is authenticated on the auth step', () => { + render( + , + ); + + const nextButton = screen.getByRole('button', { name: 'checkout.next' }); + expect(nextButton).not.toBeDisabled(); + }); + + it('only renders the next button on the payment step after the payment is completed', async () => { + const paidPackage = { ...basePackage, id: 2, price: 99 }; + + render( + , + ); + + await screen.findByTestId('payment-step'); + + expect(screen.queryByRole('button', { name: 'checkout.next' })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'mark-complete' })); + + expect(await screen.findByRole('button', { name: 'checkout.next' })).toBeEnabled(); + }); +}); diff --git a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx index de15971..c1f744a 100644 --- a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx @@ -3,6 +3,7 @@ import { usePage } from "@inertiajs/react"; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { useCheckoutWizard } from "../WizardContext"; +import type { GoogleProfilePrefill } from '../types'; import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm"; import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm"; import { useTranslation } from 'react-i18next'; @@ -11,6 +12,8 @@ import { LoaderCircle } from "lucide-react"; interface AuthStepProps { privacyHtml: string; + googleProfile?: GoogleProfilePrefill; + onClearGoogleProfile?: () => void; } type GoogleAuthFlash = { @@ -29,7 +32,7 @@ const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => ( ); -export const AuthStep: React.FC = ({ privacyHtml }) => { +export const AuthStep: React.FC = ({ privacyHtml, googleProfile, onClearGoogleProfile }) => { const { t } = useTranslation('marketing'); const page = usePage<{ locale?: string }>(); const locale = page.props.locale ?? "de"; @@ -42,10 +45,11 @@ export const AuthStep: React.FC = ({ privacyHtml }) => { const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false); useEffect(() => { - if (googleAuth?.status === 'success') { + if (googleAuth?.status === 'signin') { toast.success(t('checkout.auth_step.google_success_toast')); + onClearGoogleProfile?.(); } - }, [googleAuth?.status, t]); + }, [googleAuth?.status, onClearGoogleProfile, t]); useEffect(() => { if (googleAuth?.error) { @@ -64,6 +68,7 @@ export const AuthStep: React.FC = ({ privacyHtml }) => { name: payload.name ?? undefined, pending_purchase: Boolean(payload.pending_purchase), }); + onClearGoogleProfile?.(); nextStep(); }; @@ -78,6 +83,7 @@ export const AuthStep: React.FC = ({ privacyHtml }) => { }); } + onClearGoogleProfile?.(); nextStep(); }; @@ -158,6 +164,8 @@ export const AuthStep: React.FC = ({ privacyHtml }) => { privacyHtml={privacyHtml} locale={locale} onSuccess={handleRegisterSuccess} + prefill={googleProfile} + onClearGoogleProfile={onClearGoogleProfile} /> ) ) : ( diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx index 8407ace..8efa98d 100644 --- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx @@ -1,401 +1,332 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useStripe, useElements, PaymentElement, Elements } from '@stripe/react-stripe-js'; -import { PayPalButtons, PayPalScriptProvider } from '@paypal/react-paypal-js'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { LoaderCircle } from 'lucide-react'; import { useCheckoutWizard } from '../WizardContext'; -import { getStripe } from '@/utils/stripe'; -interface PaymentStepProps { - stripePublishableKey: string; - paypalClientId: string; -} +type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error'; -type Provider = 'stripe' | 'paypal'; -type PaymentStatus = 'idle' | 'loading' | 'ready' | 'processing' | 'error' | 'success'; - -interface StripePaymentFormProps { - onProcessing: () => void; - onSuccess: () => void; - onError: (message: string) => void; - selectedPackage: any; - t: (key: string, options?: Record) => string; -} - -const StripePaymentForm: React.FC = ({ onProcessing, onSuccess, onError, selectedPackage, t }) => { - const stripe = useStripe(); - const elements = useElements(); - const [isProcessing, setIsProcessing] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - - if (!stripe || !elements) { - const message = t('checkout.payment_step.stripe_not_loaded'); - onError(message); - return; - } - - onProcessing(); - setIsProcessing(true); - setErrorMessage(''); - - try { - const { error: stripeError, paymentIntent } = await stripe.confirmPayment({ - elements, - confirmParams: { - return_url: `${window.location.origin}/checkout/success`, - }, - redirect: 'if_required', - }); - - if (stripeError) { - let message = t('checkout.payment_step.payment_failed'); - - switch (stripeError.type) { - case 'card_error': - message += stripeError.message || t('checkout.payment_step.error_card'); - break; - case 'validation_error': - message += t('checkout.payment_step.error_validation'); - break; - case 'api_connection_error': - message += t('checkout.payment_step.error_connection'); - break; - case 'api_error': - message += t('checkout.payment_step.error_server'); - break; - case 'authentication_error': - message += t('checkout.payment_step.error_auth'); - break; - default: - message += stripeError.message || t('checkout.payment_step.error_unknown'); - } - - setErrorMessage(message); - onError(message); - return; - } - - if (paymentIntent && paymentIntent.status === 'succeeded') { - onSuccess(); - return; - } - - onError(t('checkout.payment_step.unexpected_status', { status: paymentIntent?.status })); - } catch (error) { - console.error('Stripe payment failed', error); - onError(t('checkout.payment_step.error_unknown')); - } finally { - setIsProcessing(false); - } - }; - - return ( -
- {errorMessage && ( - - {errorMessage} - - )} -
-

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

- - -
-
- ); -}; - -interface PayPalPaymentFormProps { - onProcessing: () => void; - onSuccess: () => void; - onError: (message: string) => void; - selectedPackage: any; - isReseller: boolean; - paypalPlanId?: string | null; - t: (key: string, options?: Record) => string; -} - -const PayPalPaymentForm: React.FC = ({ onProcessing, onSuccess, onError, selectedPackage, isReseller, paypalPlanId, t }) => { - const createOrder = async () => { - if (!selectedPackage?.id) { - const message = t('checkout.payment_step.paypal_order_error'); - onError(message); - throw new Error(message); - } - - try { - onProcessing(); - - const endpoint = isReseller ? '/paypal/create-subscription' : '/paypal/create-order'; - const payload: Record = { - package_id: selectedPackage.id, +declare global { + interface Window { + Paddle?: { + Environment?: { + set: (environment: string) => void; }; + Initialize?: (options: { token: string }) => void; + Checkout: { + open: (options: Record) => void; + }; + }; + } +} - if (isReseller) { - if (!paypalPlanId) { - const message = t('checkout.payment_step.paypal_missing_plan'); - onError(message); - throw new Error(message); - } - payload.plan_id = paypalPlanId; - } +const PADDLE_SCRIPT_URL = 'https://cdn.paddle.com/paddle/v2/paddle.js'; - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', - }, - body: JSON.stringify(payload), - }); +type PaddleEnvironment = 'sandbox' | 'production'; - const data = await response.json(); +let paddleLoaderPromise: Promise | null = null; - if (response.ok) { - const orderId = isReseller ? data.order_id : data.id; - if (typeof orderId === 'string' && orderId.length > 0) { - return orderId; - } - } else { - onError(data.error || t('checkout.payment_step.paypal_order_error')); - } +function configurePaddle(paddle: typeof window.Paddle | undefined | null, environment: PaddleEnvironment): typeof window.Paddle | null { + if (!paddle) { + return null; + } - throw new Error('Failed to create PayPal order'); - } catch (error) { - console.error('PayPal create order failed', error); - onError(t('checkout.payment_step.network_error')); - throw error; - } - }; + try { + paddle.Environment?.set?.(environment); + } catch (error) { + console.warn('[Paddle] Failed to set environment', error); + } - const onApprove = async (data: any) => { - try { - const response = await fetch('/paypal/capture-order', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', - }, - body: JSON.stringify({ order_id: data.orderID }), - }); + return paddle; +} - const result = await response.json(); +async function loadPaddle(environment: PaddleEnvironment): Promise { + if (typeof window === 'undefined') { + return null; + } - if (response.ok && result.status === 'captured') { - onSuccess(); - } else { - onError(result.error || t('checkout.payment_step.paypal_capture_error')); - } - } catch (error) { - console.error('PayPal capture failed', error); - onError(t('checkout.payment_step.network_error')); - } - }; + if (window.Paddle) { + return configurePaddle(window.Paddle, environment); + } - const handleError = (error: unknown) => { - console.error('PayPal error', error); - onError(t('checkout.payment_step.paypal_error')); - }; + if (!paddleLoaderPromise) { + paddleLoaderPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = PADDLE_SCRIPT_URL; + script.async = true; + script.onload = () => resolve(window.Paddle ?? null); + script.onerror = (error) => reject(error); + document.head.appendChild(script); + }).catch((error) => { + console.error('Failed to load Paddle.js', error); + paddleLoaderPromise = null; + return null; + }); + } - const handleCancel = () => { - onError(t('checkout.payment_step.paypal_cancelled')); - }; + const paddle = await paddleLoaderPromise; + + return configurePaddle(paddle, environment); +} + +const PaddleCta: React.FC<{ onCheckout: () => Promise; disabled: boolean; isProcessing: boolean }> = ({ onCheckout, disabled, isProcessing }) => { + const { t } = useTranslation('marketing'); return ( -
-

{t('checkout.payment_step.secure_paypal_desc') || 'Bezahlen Sie sicher mit PayPal.'}

- createOrder()} - onApprove={onApprove} - onError={handleError} - onCancel={handleCancel} - /> -
+ ); }; -const statusVariantMap: Record = { - idle: 'secondary', - loading: 'secondary', - ready: 'secondary', - processing: 'secondary', - error: 'destructive', - success: 'success', -}; - -export const PaymentStep: React.FC = ({ stripePublishableKey, paypalClientId }) => { +export const PaymentStep: React.FC = () => { const { t } = useTranslation('marketing'); - const { selectedPackage, authUser, nextStep, resetPaymentState } = useCheckoutWizard(); - - const [paymentMethod, setPaymentMethod] = useState('stripe'); - const [clientSecret, setClientSecret] = useState(''); + const { selectedPackage, nextStep, paddleConfig, authUser, setPaymentCompleted } = useCheckoutWizard(); const [status, setStatus] = useState('idle'); - const [statusDetail, setStatusDetail] = useState(''); - const [intentRefreshKey, setIntentRefreshKey] = useState(0); - const [processingProvider, setProcessingProvider] = useState(null); + const [message, setMessage] = useState(''); + const [initialised, setInitialised] = useState(false); + const [inlineActive, setInlineActive] = useState(false); + const paddleRef = useRef(null); + const eventCallbackRef = useRef<(event: any) => void>(); + const checkoutContainerClass = 'paddle-checkout-container'; - const stripePromise = useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]); - const isFree = useMemo(() => (selectedPackage ? selectedPackage.price <= 0 : false), [selectedPackage]); - const isReseller = selectedPackage?.type === 'reseller'; + const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]); - const paypalPlanId = useMemo(() => { - if (!selectedPackage) { - return null; - } - - if (typeof selectedPackage.paypal_plan_id === 'string' && selectedPackage.paypal_plan_id.trim().length > 0) { - return selectedPackage.paypal_plan_id; - } - - const metadata = (selectedPackage as Record)?.metadata; - if (metadata && typeof metadata === 'object') { - const value = (metadata as Record).paypal_plan_id; - if (typeof value === 'string' && value.trim().length > 0) { - return value; - } - } - - return null; - }, [selectedPackage]); - - const paypalDisabled = isReseller && !paypalPlanId; - - useEffect(() => { - setStatus('idle'); - setStatusDetail(''); - setClientSecret(''); - setProcessingProvider(null); - }, [selectedPackage?.id]); - - useEffect(() => { - if (isFree) { - resetPaymentState(); - setStatus('ready'); - setStatusDetail(''); - return; - } + const handleFreeActivation = async () => { + setPaymentCompleted(true); + nextStep(); + }; + const startPaddleCheckout = async () => { if (!selectedPackage) { return; } - if (paymentMethod === 'paypal') { - if (paypalDisabled) { - setStatus('error'); - setStatusDetail(t('checkout.payment_step.paypal_missing_plan')); - } else { - setStatus('ready'); - setStatusDetail(''); - } - return; - } - - if (!stripePromise) { + if (!selectedPackage.paddle_price_id) { setStatus('error'); - setStatusDetail(t('checkout.payment_step.stripe_not_loaded')); + setMessage(t('checkout.payment_step.paddle_not_configured')); return; } - if (!authUser) { - setStatus('error'); - setStatusDetail(t('checkout.payment_step.auth_required')); - return; - } + setPaymentCompleted(false); + setStatus('processing'); + setMessage(t('checkout.payment_step.paddle_preparing')); + setInlineActive(false); - let cancelled = false; - setStatus('loading'); - setStatusDetail(t('checkout.payment_step.status_loading')); - setClientSecret(''); + try { + const inlineSupported = initialised && !!paddleConfig?.client_token; - const loadIntent = async () => { - try { - const response = await fetch('/stripe/create-payment-intent', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', - }, - body: JSON.stringify({ package_id: selectedPackage.id }), + if (typeof window !== 'undefined') { + // eslint-disable-next-line no-console + console.info('[Checkout] Paddle inline status', { + inlineSupported, + initialised, + hasClientToken: Boolean(paddleConfig?.client_token), + environment: paddleConfig?.environment, + paddlePriceId: selectedPackage.paddle_price_id, }); + } - const data = await response.json(); + if (inlineSupported) { + const paddle = paddleRef.current; - if (!response.ok || !data.client_secret) { - const message = data.error || t('checkout.payment_step.payment_intent_error'); - if (!cancelled) { - setStatus('error'); - setStatusDetail(message); + if (!paddle || !paddle.Checkout || typeof paddle.Checkout.open !== 'function') { + throw new Error('Inline Paddle checkout is not available.'); + } + + const inlinePayload: Record = { + items: [ + { + priceId: selectedPackage.paddle_price_id, + quantity: 1, + }, + ], + settings: { + displayMode: 'inline', + frameTarget: checkoutContainerClass, + frameInitialHeight: '550', + frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;', + theme: 'light', + locale: typeof document !== 'undefined' ? document.documentElement.lang ?? 'de' : 'de', + }, + customData: { + package_id: String(selectedPackage.id), + }, + }; + + const customerEmail = authUser?.email ?? null; + if (customerEmail) { + inlinePayload.customer = { email: customerEmail }; + } + + if (typeof window !== 'undefined') { + // eslint-disable-next-line no-console + console.info('[Checkout] Opening inline Paddle checkout', inlinePayload); + } + + paddle.Checkout.open(inlinePayload); + + setInlineActive(true); + setStatus('ready'); + setMessage(t('checkout.payment_step.paddle_overlay_ready')); + return; + } + + const response = await fetch('/paddle/create-checkout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', + }, + body: JSON.stringify({ + package_id: selectedPackage.id, + }), + }); + + const rawBody = await response.text(); + if (typeof window !== 'undefined') { + // eslint-disable-next-line no-console + console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody }); + } + + let data: any = null; + try { + data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null; + } catch (parseError) { + console.warn('Failed to parse Paddle checkout payload as JSON', parseError); + data = null; + } + + let checkoutUrl: string | null = typeof data?.checkout_url === 'string' ? data.checkout_url : null; + + if (!checkoutUrl) { + const trimmed = rawBody.trim(); + if (/^https?:\/\//i.test(trimmed)) { + checkoutUrl = trimmed; + } else if (trimmed.startsWith('<')) { + const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:\/?#\[\]@!$&'()*+,;=%-]+/); + if (match) { + checkoutUrl = match[0]; } - return; } + } - if (!cancelled) { - setClientSecret(data.client_secret); - setStatus('ready'); - setStatusDetail(t('checkout.payment_step.status_ready')); - } - } catch (error) { - if (!cancelled) { - console.error('Failed to load payment intent', error); - setStatus('error'); - setStatusDetail(t('checkout.payment_step.network_error')); - } + if (!response.ok || !checkoutUrl) { + const message = data?.message || rawBody || 'Unable to create Paddle checkout.'; + throw new Error(message); + } + + window.open(checkoutUrl, '_blank', 'noopener'); + setInlineActive(false); + setStatus('ready'); + setMessage(t('checkout.payment_step.paddle_ready')); + } catch (error) { + console.error('Failed to start Paddle checkout', error); + setStatus('error'); + setMessage(t('checkout.payment_step.paddle_error')); + setInlineActive(false); + setPaymentCompleted(false); + } + }; + + 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') { + // eslint-disable-next-line no-console + console.debug('[Checkout] Paddle event', event); + } + + if (event.name === 'checkout.completed') { + setStatus('ready'); + setMessage(t('checkout.payment_step.paddle_overlay_ready')); + setInlineActive(false); + setPaymentCompleted(true); + } + + if (event.name === 'checkout.closed') { + setStatus('idle'); + setMessage(''); + setInlineActive(false); + setPaymentCompleted(false); + } + + if (event.name === 'checkout.error') { + setStatus('error'); + setMessage(t('checkout.payment_step.paddle_error')); + setInlineActive(false); + setPaymentCompleted(false); } }; - loadIntent(); + (async () => { + const paddle = await loadPaddle(environment); + + if (cancelled || !paddle) { + return; + } + + try { + let inlineReady = false; + if (typeof paddle.Initialize === 'function' && clientToken) { + if (typeof window !== 'undefined') { + // eslint-disable-next-line no-console + console.info('[Checkout] Initializing Paddle.js', { environment, hasToken: Boolean(clientToken) }); + } + paddle.Initialize({ + token: clientToken, + checkout: { + settings: { + displayMode: 'inline', + frameTarget: checkoutContainerClass, + frameInitialHeight: '550', + frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;', + locale: typeof document !== 'undefined' ? document.documentElement.lang ?? 'de' : 'de', + }, + }, + eventCallback: (event: any) => eventCallbackRef.current?.(event), + }); + inlineReady = true; + } + + paddleRef.current = paddle; + setInitialised(inlineReady); + } catch (error) { + console.error('Failed to initialize Paddle', error); + setInitialised(false); + setStatus('error'); + setMessage(t('checkout.payment_step.paddle_error')); + setPaymentCompleted(false); + } + })(); return () => { cancelled = true; }; - }, [authUser, intentRefreshKey, isFree, paymentMethod, paypalDisabled, resetPaymentState, selectedPackage, stripePromise, t]); + }, [paddleConfig?.environment, paddleConfig?.client_token, setPaymentCompleted, t]); - const providerLabel = useCallback((provider: Provider) => { - switch (provider) { - case 'paypal': - return 'PayPal'; - default: - return 'Stripe'; - } - }, []); + useEffect(() => { + setPaymentCompleted(false); + }, [selectedPackage?.id, setPaymentCompleted]); - const handleProcessing = useCallback((provider: Provider) => { - setProcessingProvider(provider); - setStatus('processing'); - setStatusDetail(t('checkout.payment_step.status_processing', { provider: providerLabel(provider) })); - }, [providerLabel, t]); - - const handleSuccess = useCallback((provider: Provider) => { - setProcessingProvider(provider); - setStatus('success'); - setStatusDetail(t('checkout.payment_step.status_success')); - setTimeout(() => nextStep(), 600); - }, [nextStep, t]); - - const handleError = useCallback((provider: Provider, message: string) => { - setProcessingProvider(provider); - setStatus('error'); - setStatusDetail(message); - }, []); - - const handleRetry = () => { - if (paymentMethod === 'stripe') { - setIntentRefreshKey((key) => key + 1); - } - - setStatus('idle'); - setStatusDetail(''); - setProcessingProvider(null); - }; + if (!selectedPackage) { + return ( + + {t('checkout.payment_step.no_package_title')} + {t('checkout.payment_step.no_package_description')} + + ); + } if (isFree) { return ( @@ -405,7 +336,7 @@ export const PaymentStep: React.FC = ({ stripePublishableKey, {t('checkout.payment_step.free_package_desc')}
-
@@ -413,94 +344,44 @@ export const PaymentStep: React.FC = ({ stripePublishableKey, ); } - const renderStatusAlert = () => { - if (status === 'idle') { - return null; - } - - const variant = statusVariantMap[status]; - - return ( - - - {status === 'error' - ? t('checkout.payment_step.status_error_title') - : status === 'success' - ? t('checkout.payment_step.status_success_title') - : t('checkout.payment_step.status_info_title')} - - - {statusDetail} - {status === 'processing' && } - {status === 'error' && ( - - )} - - - ); - }; - return ( -
-
- - -
+
+

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

- {renderStatusAlert()} - - {paymentMethod === 'stripe' && clientSecret && stripePromise && ( - - handleProcessing('stripe')} - onSuccess={() => handleSuccess('stripe')} - onError={(message) => handleError('stripe', message)} - t={t} - /> - - )} - - {paymentMethod === 'stripe' && !clientSecret && status === 'loading' && ( -
- - {t('checkout.payment_step.status_loading')} -
- )} - - {paymentMethod === 'paypal' && !paypalDisabled && ( - - handleProcessing('paypal')} - onSuccess={() => handleSuccess('paypal')} - onError={(message) => handleError('paypal', message)} - paypalPlanId={paypalPlanId} - selectedPackage={selectedPackage} - t={t} - /> - - )} - - {paymentMethod === 'paypal' && paypalDisabled && ( - - {t('checkout.payment_step.paypal_missing_plan')} + {status !== 'idle' && ( + + + {status === 'processing' + ? t('checkout.payment_step.status_processing_title') + : status === 'ready' + ? t('checkout.payment_step.status_ready_title') + : status === 'error' + ? t('checkout.payment_step.status_error_title') + : t('checkout.payment_step.status_info_title')} + + + {message} + {status === 'processing' && } + )} + +
+
+ {!inlineActive && ( + + )} + +

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

+
); }; diff --git a/resources/js/pages/marketing/checkout/types.ts b/resources/js/pages/marketing/checkout/types.ts index 12bf475..deb05db 100644 --- a/resources/js/pages/marketing/checkout/types.ts +++ b/resources/js/pages/marketing/checkout/types.ts @@ -1,5 +1,14 @@ export type CheckoutStepId = 'package' | 'auth' | 'payment' | 'confirmation'; +export interface GoogleProfilePrefill { + email?: string; + name?: string; + given_name?: string; + family_name?: string; + avatar?: string; + locale?: string; +} + export interface CheckoutPackage { id: number; name: string; @@ -15,7 +24,8 @@ export interface CheckoutPackage { type: 'endcustomer' | 'reseller'; features: string[]; limits?: Record; - paypal_plan_id?: string | null; + paddle_price_id?: string | null; + paddle_product_id?: string | null; [key: string]: unknown; } @@ -30,7 +40,7 @@ export interface CheckoutWizardState { name?: string; pending_purchase?: boolean; } | null; - paymentProvider?: 'stripe' | 'paypal'; + paymentProvider?: 'stripe' | 'paddle'; isProcessing?: boolean; } diff --git a/resources/lang/de/legal.php b/resources/lang/de/legal.php index 9134a40..6956a08 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 Stripe und PayPal. Preise exkl. MwSt. Support: support@fotospiel.de', + 'monetization_desc' => 'Wir monetarisieren über Packages (Einmalkäufe und Abos) via Paddle. 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: Fotospiel GmbH, 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 Stripe und PayPal. Karteninformationen werden nicht gespeichert – alle Daten werden verschlüsselt übertragen.', + 'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsinformationen werden sicher und verschlüsselt durch Paddle 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.', @@ -34,5 +34,4 @@ return [ 'version' => 'Version :version', 'and' => 'und', 'stripe_privacy' => 'Stripe Datenschutz', - 'paypal_privacy' => 'PayPal Datenschutz', ]; diff --git a/resources/lang/de/marketing.json b/resources/lang/de/marketing.json index 742da32..d7317a5 100644 --- a/resources/lang/de/marketing.json +++ b/resources/lang/de/marketing.json @@ -67,7 +67,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 Stripe oder PayPal – sicher und GDPR-konform.", + "faq_a4": "Ja, via Paddle – sicher und GDPR-konform.", "final_cta": "Bereit für Ihr nächstes Event?", "contact_us": "Kontaktieren Sie uns", "feature_live_slideshow": "Live-Slideshow", @@ -245,4 +245,4 @@ "euro": "€" } } -} \ No newline at end of file +} diff --git a/resources/lang/de/marketing.php b/resources/lang/de/marketing.php index 19c4614..343c4ce 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 Stripe oder PayPal – sicher und GDPR-konform.', + 'faq_a4' => 'Ja, via Paddle – sicher und GDPR-konform.', 'final_cta' => 'Bereit für Ihr nächstes Event?', 'contact_us' => 'Kontaktieren Sie uns', 'feature_live_slideshow' => 'Live-Slideshow', @@ -60,10 +60,12 @@ return [ 'max_guests_label' => 'Max. Gäste', 'gallery_days_label' => 'Galerie-Tage', 'feature_overview' => 'Feature-Überblick', - 'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung via Stripe oder PayPal.', + 'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.', '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.', ], 'nav' => [ 'home' => 'Startseite', diff --git a/resources/lang/en/legal.php b/resources/lang/en/legal.php index 65ccadb..5aa822a 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 Stripe and PayPal. Prices excl. VAT. Support: support@fotospiel.de', + 'monetization_desc' => 'We monetize through Packages (one-time purchases and subscriptions) via Paddle. 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: Fotospiel GmbH, 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 Stripe and PayPal. Card information is not stored – all data is transmitted encrypted. See Stripe Privacy and PayPal Privacy.', + 'payments_desc' => 'We process payments for Packages via Paddle. Payment information is handled securely and encrypted by Paddle 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 01b8695..ca4843c 100644 --- a/resources/lang/en/marketing.json +++ b/resources/lang/en/marketing.json @@ -67,7 +67,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 Stripe or PayPal – secure and GDPR-compliant.", + "faq_a4": "Yes, via Paddle – secure and GDPR-compliant.", "final_cta": "Ready for your next event?", "contact_us": "Contact Us", "feature_live_slideshow": "Live Slideshow", @@ -240,4 +240,4 @@ "currency": { "euro": "€" } -} \ No newline at end of file +} diff --git a/resources/lang/en/marketing.php b/resources/lang/en/marketing.php index ea990ff..de6f72d 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 Stripe or PayPal – secure and GDPR-compliant.', + 'faq_a4' => 'Yes, via Paddle – secure and GDPR-compliant.', 'final_cta' => 'Ready for your next event?', 'contact_us' => 'Contact Us', 'feature_live_slideshow' => 'Live Slideshow', @@ -60,10 +60,12 @@ return [ 'max_guests_label' => 'Max. guests', 'gallery_days_label' => 'Gallery days', 'feature_overview' => 'Feature overview', - 'order_hint' => 'Ready to launch instantly – secure Stripe or PayPal checkout, no hidden fees.', + 'order_hint' => 'Ready to launch instantly – secure Paddle 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.', ], 'nav' => [ 'home' => 'Home', diff --git a/routes/api.php b/routes/api.php index 3556285..858c100 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,7 +2,6 @@ use App\Http\Controllers\Api\EventPublicController; use App\Http\Controllers\Api\PackageController; -use App\Http\Controllers\Api\StripeController; use App\Http\Controllers\Api\Tenant\DashboardController; use App\Http\Controllers\Api\Tenant\EmotionController; use App\Http\Controllers\Api\Tenant\EventController; @@ -13,10 +12,10 @@ use App\Http\Controllers\Api\Tenant\PhotoController; use App\Http\Controllers\Api\Tenant\SettingsController; use App\Http\Controllers\Api\Tenant\TaskCollectionController; use App\Http\Controllers\Api\Tenant\TaskController; +use App\Http\Controllers\Api\TenantBillingController; use App\Http\Controllers\Api\TenantPackageController; use App\Http\Controllers\OAuthController; use App\Http\Controllers\RevenueCatWebhookController; -use App\Http\Controllers\StripeWebhookController; use App\Http\Controllers\Tenant\CreditController; use Illuminate\Support\Facades\Route; @@ -150,21 +149,15 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('/payment-intent', [PackageController::class, 'createPaymentIntent'])->name('packages.payment-intent'); Route::post('/complete', [PackageController::class, 'completePurchase'])->name('packages.complete'); Route::post('/free', [PackageController::class, 'assignFree'])->name('packages.free'); - Route::post('/paypal-create', [PackageController::class, 'createPayPalOrder'])->name('packages.paypal-create'); - Route::post('/paypal-capture', [PackageController::class, 'capturePayPalOrder'])->name('packages.paypal-capture'); - }); - - Route::prefix('stripe')->group(function () { - Route::post('/payment-intent', [StripeController::class, 'createPaymentIntent'])->name('stripe.payment-intent'); - Route::post('/subscription', [StripeController::class, 'createSubscription'])->name('stripe.subscription'); + Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout'); }); Route::prefix('tenant/packages')->group(function () { Route::get('/', [TenantPackageController::class, 'index'])->name('tenant.packages.index'); }); + + Route::get('tenant/billing/transactions', [TenantBillingController::class, 'transactions']) + ->name('tenant.billing.transactions'); }); - // Stripe Webhook (no auth) - Route::post('/stripe/webhook', [StripeWebhookController::class, 'handleWebhook']) - ->name('stripe.webhook'); }); diff --git a/routes/web.php b/routes/web.php index a891123..041352c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,8 +5,8 @@ use App\Http\Controllers\CheckoutGoogleController; use App\Http\Controllers\LegalPageController; use App\Http\Controllers\LocaleController; use App\Http\Controllers\MarketingController; -use App\Http\Controllers\PayPalController; -use App\Http\Controllers\PayPalWebhookController; +use App\Http\Controllers\PaddleCheckoutController; +use App\Http\Controllers\PaddleWebhookController; use App\Http\Controllers\Tenant\EventPhotoArchiveController; use App\Models\Package; use Illuminate\Support\Facades\Route; @@ -83,15 +83,11 @@ Route::post('/checkout/login', [CheckoutController::class, 'login'])->name('chec Route::post('/checkout/register', [CheckoutController::class, 'register'])->name('checkout.register'); Route::get('/checkout/auth/google', [CheckoutGoogleController::class, 'redirect'])->name('checkout.google.redirect'); Route::get('/checkout/auth/google/callback', [CheckoutGoogleController::class, 'callback'])->name('checkout.google.callback'); -Route::post('/stripe/create-payment-intent', [CheckoutController::class, 'createPaymentIntent'])->name('stripe.create-payment-intent'); -Route::post('/stripe/confirm-payment', [CheckoutController::class, 'confirmPayment'])->name('stripe.confirm-payment'); Route::post('/checkout/track-abandoned', [CheckoutController::class, 'trackAbandonedCheckout'])->name('checkout.track-abandoned'); Route::post('/set-locale', [LocaleController::class, 'set'])->name('set-locale'); Route::middleware('auth')->group(function () { - Route::post('/paypal/create-order', [PayPalController::class, 'createOrder'])->name('paypal.create-order'); - Route::post('/paypal/capture-order', [PayPalController::class, 'captureOrder'])->name('paypal.capture-order'); - Route::post('/paypal/create-subscription', [PayPalController::class, 'createSubscription'])->name('paypal.create-subscription'); + Route::post('/paddle/create-checkout', [PaddleCheckoutController::class, 'create'])->name('paddle.checkout.create'); }); -Route::post('/paypal/webhook', [PayPalWebhookController::class, 'verify'])->name('paypal.webhook'); +Route::post('/paddle/webhook', [PaddleWebhookController::class, 'handle'])->name('paddle.webhook'); diff --git a/tests/Feature/EventControllerTest.php b/tests/Feature/EventControllerTest.php index aa0e622..d45d4fd 100644 --- a/tests/Feature/EventControllerTest.php +++ b/tests/Feature/EventControllerTest.php @@ -54,6 +54,7 @@ class EventControllerTest extends TestCase 'event_id' => $event->id, 'package_id' => $package->id, 'type' => 'endcustomer_event', + 'provider' => 'manual', 'provider_id' => 'manual', ]); } diff --git a/tests/Feature/FullUserFlowTest.php b/tests/Feature/FullUserFlowTest.php index c9066e8..2ea9348 100644 --- a/tests/Feature/FullUserFlowTest.php +++ b/tests/Feature/FullUserFlowTest.php @@ -2,19 +2,17 @@ namespace Tests\Feature; +use App\Mail\Welcome; use App\Models\Package; -use App\Models\User; -use App\Models\Tenant; use App\Models\PackagePurchase; +use App\Models\Tenant; use App\Models\TenantPackage; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Mail; -use App\Mail\Welcome; -use App\Mail\PurchaseConfirmation; use Stripe\StripeClient; -use Mockery; +use Tests\TestCase; class FullUserFlowTest extends TestCase { @@ -85,9 +83,9 @@ class FullUserFlowTest extends TestCase $paidPackage = Package::factory()->reseller()->create(['price' => 10]); // Mock Stripe für Erfolg - $this->mock(StripeClient::class, function ($mock) use ($user, $tenant, $paidPackage) { + $this->mock(StripeClient::class, function ($mock) { $mock->shouldReceive('checkout->sessions->create') - ->andReturn((object)['url' => 'https://mock-stripe.com']); + ->andReturn((object) ['url' => 'https://mock-stripe.com']); }); // Simuliere Kauf (GET zu buy.packages, aber da es Redirect ist, prüfe Session oder folge) @@ -108,6 +106,7 @@ class FullUserFlowTest extends TestCase 'tenant_id' => $tenant->id, 'package_id' => $paidPackage->id, 'type' => 'reseller_subscription', + 'provider' => 'stripe', 'provider_id' => 'stripe', 'price' => 10, 'purchased_at' => now(), @@ -120,6 +119,7 @@ class FullUserFlowTest extends TestCase 'tenant_id' => $tenant->id, 'package_id' => $paidPackage->id, 'type' => 'reseller_subscription', + 'provider' => 'stripe', ]); // Überprüfe, dass 2 Purchases existieren (Free + Paid) diff --git a/tests/Feature/PaddleSyncPackagesCommandTest.php b/tests/Feature/PaddleSyncPackagesCommandTest.php new file mode 100644 index 0000000..af68e66 --- /dev/null +++ b/tests/Feature/PaddleSyncPackagesCommandTest.php @@ -0,0 +1,55 @@ +count(2)->create(); + + BusFacade::fake(); + + $this->artisan('paddle:sync-packages', [ + '--dry-run' => true, + '--queue' => true, + ])->assertExitCode(0); + + BusFacade::assertDispatched(SyncPackageToPaddle::class, 2); + } + + public function test_command_filters_packages_by_id(): void + { + $package = Package::factory()->create(); + Package::factory()->create(); + + BusFacade::fake(); + + $this->artisan('paddle:sync-packages', [ + '--dry-run' => true, + '--queue' => true, + '--package' => [$package->id], + ])->assertExitCode(0); + + BusFacade::assertDispatched(SyncPackageToPaddle::class, function (SyncPackageToPaddle $job) use ($package) { + return $this->getJobPackageId($job) === $package->id; + }); + } + + protected function getJobPackageId(SyncPackageToPaddle $job): int + { + $reflection = new \ReflectionClass($job); + $property = $reflection->getProperty('packageId'); + $property->setAccessible(true); + + return (int) $property->getValue($job); + } +} diff --git a/tests/Feature/PaddleWebhookControllerTest.php b/tests/Feature/PaddleWebhookControllerTest.php new file mode 100644 index 0000000..2a739e6 --- /dev/null +++ b/tests/Feature/PaddleWebhookControllerTest.php @@ -0,0 +1,228 @@ + 'test_secret']); + + [$tenant, $package, $session] = $this->prepareSession(); + + $payload = [ + 'event_type' => 'transaction.completed', + 'data' => [ + 'id' => 'txn_123', + 'status' => 'completed', + 'checkout_id' => 'chk_456', + 'metadata' => [ + 'checkout_session_id' => $session->id, + 'tenant_id' => (string) $tenant->id, + 'package_id' => (string) $package->id, + ], + ], + ]; + + $signature = hash_hmac('sha256', json_encode($payload), 'test_secret'); + + $response = $this->withHeader('Paddle-Webhook-Signature', $signature) + ->postJson('/paddle/webhook', $payload); + + $response->assertOk()->assertJson(['status' => 'processed']); + + $session->refresh(); + + $this->assertEquals(CheckoutSession::STATUS_COMPLETED, $session->status); + $this->assertSame('paddle', $session->provider); + $this->assertSame('txn_123', Arr::get($session->provider_metadata, 'paddle_transaction_id')); + + $this->assertTrue( + TenantPackage::query() + ->where('tenant_id', $tenant->id) + ->where('package_id', $package->id) + ->where('active', true) + ->exists() + ); + + $this->assertTrue( + PackagePurchase::query() + ->where('tenant_id', $tenant->id) + ->where('package_id', $package->id) + ->where('provider', 'paddle') + ->exists() + ); + } + + public function test_rejects_invalid_signature(): void + { + config(['paddle.webhook_secret' => 'secret']); + + $response = $this->withHeader('Paddle-Webhook-Signature', 'invalid') + ->postJson('/paddle/webhook', ['event_type' => 'transaction.completed']); + + $response->assertStatus(400)->assertJson(['status' => 'invalid']); + } + + public function test_unhandled_event_returns_accepted(): void + { + config(['paddle.webhook_secret' => null]); + + $response = $this->postJson('/paddle/webhook', [ + 'event_type' => 'transaction.unknown', + 'data' => [], + ]); + + $response->assertStatus(202)->assertJson(['status' => 'ignored']); + } + + public function test_subscription_activation_creates_tenant_package(): void + { + config(['paddle.webhook_secret' => 'test_secret']); + + $tenant = Tenant::factory()->create([ + 'paddle_customer_id' => 'cus_123', + 'subscription_status' => 'free', + ]); + + $package = Package::factory()->create([ + 'type' => 'reseller', + 'price' => 129, + 'paddle_price_id' => 'price_sub_1', + ]); + + $payload = [ + 'event_type' => 'subscription.created', + 'data' => [ + 'id' => 'sub_123', + 'status' => 'active', + 'customer_id' => 'cus_123', + 'created_at' => Carbon::now()->subDay()->toIso8601String(), + 'next_billing_date' => Carbon::now()->addMonth()->toIso8601String(), + 'metadata' => [ + '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', + 'metadata' => [ + '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/PayPalWebhookControllerTest.php b/tests/Feature/PayPalWebhookControllerTest.php deleted file mode 100644 index 9f829ca..0000000 --- a/tests/Feature/PayPalWebhookControllerTest.php +++ /dev/null @@ -1,75 +0,0 @@ -create(['subscription_status' => 'free']); - $package = Package::factory()->reseller()->create(); - - $payload = [ - 'webhook_id' => 'WH-activation', - 'webhook_event' => [ - 'event_type' => 'BILLING.SUBSCRIPTION.ACTIVATED', - 'resource' => [ - 'id' => 'I-123456', - 'custom_id' => json_encode([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - ]), - ], - ], - ]; - - $response = $this->postJson('/paypal/webhook', $payload); - - $response->assertOk() - ->assertJson(['status' => 'SUCCESS']); - - $this->assertEquals('active', $tenant->fresh()->subscription_status); - } - - public function test_subscription_cancellation_deactivates_tenant_package(): void - { - $tenant = Tenant::factory()->create(['subscription_status' => 'active']); - $package = Package::factory()->reseller()->create(); - - TenantPackage::factory()->create([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'active' => true, - ]); - - $payload = [ - 'webhook_id' => 'WH-cancel', - 'webhook_event' => [ - 'event_type' => 'BILLING.SUBSCRIPTION.CANCELLED', - 'resource' => [ - 'id' => 'I-123456', - 'custom_id' => json_encode([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - ]), - ], - ], - ]; - - $response = $this->postJson('/paypal/webhook', $payload); - - $response->assertOk() - ->assertJson(['status' => 'SUCCESS']); - - $this->assertEquals('expired', $tenant->fresh()->subscription_status); - $this->assertFalse($tenant->tenantPackages()->first()->fresh()->active); - } -} diff --git a/tests/Feature/PurchaseTest.php b/tests/Feature/PurchaseTest.php index 0919d23..288fd9c 100644 --- a/tests/Feature/PurchaseTest.php +++ b/tests/Feature/PurchaseTest.php @@ -3,18 +3,12 @@ namespace Tests\Feature; use App\Models\Package; -use App\Models\PackagePurchase; use App\Models\Tenant; -use App\Models\TenantPackage; use App\Models\User; -use App\Services\PayPal\PaypalClientFactory; +use App\Services\Paddle\PaddleCheckoutService; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Auth; use Mockery; use Tests\TestCase; -use PaypalServerSdkLib\PaypalServerSdkClient; -use PaypalServerSdkLib\Controllers\OrdersController; -use PaypalServerSdkLib\Http\ApiResponse; class PurchaseTest extends TestCase { @@ -26,298 +20,96 @@ class PurchaseTest extends TestCase parent::tearDown(); } - public function test_paypal_checkout_creates_order(): void + public function test_create_paddle_checkout_requires_paddle_price(): void { - [$tenant, $package] = $this->seedTenantWithPackage(price: 10); - Auth::login($tenant->user); + [$tenant, $package] = $this->seedTenantWithPackage(includePaddlePrice: false); + $this->actingAs($tenant->user); - $apiResponse = $this->apiResponse((object) [ - 'id' => 'ORDER-123', - 'links' => [ - (object) ['rel' => 'approve', 'href' => 'https://paypal.test/approve/ORDER-123'], - ], + $response = $this->postJson('/paddle/create-checkout', [ + 'package_id' => $package->id, ]); - $ordersController = Mockery::mock(OrdersController::class); - $ordersController->shouldReceive('createOrder') + $response->assertStatus(422) + ->assertJsonValidationErrors('package_id'); + } + + public function test_create_paddle_checkout_returns_checkout_url(): void + { + [$tenant, $package] = $this->seedTenantWithPackage(includePaddlePrice: true); + $this->actingAs($tenant->user); + + $service = Mockery::mock(PaddleCheckoutService::class); + $service->shouldReceive('createCheckout') ->once() - ->andReturn($apiResponse); + ->with( + Mockery::on(fn ($arg) => $arg instanceof Tenant && $arg->is($tenant)), + Mockery::on(fn ($arg) => $arg instanceof Package && $arg->is($package)), + Mockery::on(function ($options) { + return ($options['success_url'] ?? null) === null + && ($options['return_url'] ?? null) === null + && isset($options['metadata']['checkout_session_id']); + }) + ) + ->andReturn([ + 'checkout_url' => 'https://paddle.test/checkout/abc', + ]); - $clientMock = Mockery::mock(PaypalServerSdkClient::class); - $clientMock->shouldReceive('getOrdersController')->andReturn($ordersController); + $this->app->instance(PaddleCheckoutService::class, $service); - $factory = Mockery::mock(PaypalClientFactory::class); - $factory->shouldReceive('make')->andReturn($clientMock); - $this->app->instance(PaypalClientFactory::class, $factory); - - $response = $this->postJson('/paypal/create-order', [ + $response = $this->postJson('/paddle/create-checkout', [ 'package_id' => $package->id, ]); $response->assertOk() ->assertJson([ - 'id' => 'ORDER-123', - 'approve_url' => 'https://paypal.test/approve/ORDER-123', + 'checkout_url' => 'https://paddle.test/checkout/abc', ]); } - public function test_paypal_capture_creates_purchase_and_package(): void + public function test_create_paddle_checkout_inline_returns_items(): void { - [$tenant, $package] = $this->seedTenantWithPackage(price: 15); - Auth::login($tenant->user); + [$tenant, $package] = $this->seedTenantWithPackage(includePaddlePrice: true); + $this->actingAs($tenant->user); - $metadata = json_encode([ - 'tenant_id' => $tenant->id, + $service = Mockery::mock(PaddleCheckoutService::class); + $service->shouldNotReceive('createCheckout'); + $this->app->instance(PaddleCheckoutService::class, $service); + + $response = $this->postJson('/paddle/create-checkout', [ 'package_id' => $package->id, - 'type' => 'endcustomer_event', + 'inline' => true, ]); - $apiResponse = $this->apiResponse((object) [ - 'id' => 'ORDER-456', - 'purchaseUnits' => [ - (object) [ - 'customId' => $metadata, - 'amount' => (object) ['value' => '15.00'], + $response->assertOk() + ->assertJson([ + 'mode' => 'inline', + ]) + ->assertJsonStructure([ + 'mode', + 'items' => [ + ['priceId', 'quantity'], ], - ], - ]); - - $ordersController = Mockery::mock(OrdersController::class); - $ordersController->shouldReceive('captureOrder') - ->once() - ->andReturn($apiResponse); - - $clientMock = Mockery::mock(PaypalServerSdkClient::class); - $clientMock->shouldReceive('getOrdersController')->andReturn($ordersController); - - $factory = Mockery::mock(PaypalClientFactory::class); - $factory->shouldReceive('make')->andReturn($clientMock); - $this->app->instance(PaypalClientFactory::class, $factory); - - $response = $this->postJson('/paypal/capture-order', [ - 'order_id' => 'ORDER-456', - ]); - - $response->assertOk() - ->assertJson(['status' => 'captured']); - - $this->assertDatabaseHas('package_purchases', [ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'provider_id' => 'ORDER-456', - 'price' => 15, - ]); - - $this->assertDatabaseHas('tenant_packages', [ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'price' => 15, - 'active' => true, - ]); - - $this->assertEquals('active', $tenant->fresh()->subscription_status); - } - - public function test_paypal_capture_failure_returns_error(): void - { - [$tenant, $package] = $this->seedTenantWithPackage(); - Auth::login($tenant->user); - - $ordersController = Mockery::mock(OrdersController::class); - $ordersController->shouldReceive('captureOrder') - ->once() - ->andThrow(new \RuntimeException('Capture failed')); - - $clientMock = Mockery::mock(PaypalServerSdkClient::class); - $clientMock->shouldReceive('getOrdersController')->andReturn($ordersController); - - $factory = Mockery::mock(PaypalClientFactory::class); - $factory->shouldReceive('make')->andReturn($clientMock); - $this->app->instance(PaypalClientFactory::class, $factory); - - $response = $this->postJson('/paypal/capture-order', [ - 'order_id' => 'ORDER-999', - ]); - - $response->assertStatus(500) - ->assertJson(['error' => 'Capture failed']); - - $this->assertDatabaseCount('package_purchases', 0); - $this->assertDatabaseCount('tenant_packages', 0); - } - - public function test_paypal_subscription_creation_creates_initial_records(): void - { - [$tenant, $package] = $this->seedTenantWithPackage(price: 99, type: 'reseller'); - Auth::login($tenant->user); - - $apiResponse = $this->apiResponse((object) [ - 'id' => 'ORDER-SUB-1', - 'links' => [ - (object) ['rel' => 'approve', 'href' => 'https://paypal.test/approve/ORDER-SUB-1'], - ], - ]); - - $ordersController = Mockery::mock(OrdersController::class); - $ordersController->shouldReceive('createOrder') - ->once() - ->andReturn($apiResponse); - - $clientMock = Mockery::mock(PaypalServerSdkClient::class); - $clientMock->shouldReceive('getOrdersController')->andReturn($ordersController); - - $factory = Mockery::mock(PaypalClientFactory::class); - $factory->shouldReceive('make')->andReturn($clientMock); - $this->app->instance(PaypalClientFactory::class, $factory); - - $response = $this->postJson('/paypal/create-subscription', [ - 'package_id' => $package->id, - 'plan_id' => 'PLAN-123', - ]); - - $response->assertOk() - ->assertJson([ - 'order_id' => 'ORDER-SUB-1', - 'approve_url' => 'https://paypal.test/approve/ORDER-SUB-1', + 'custom_data' => ['tenant_id', 'package_id', 'checkout_session_id'], ]); - $this->assertDatabaseHas('tenant_packages', [ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'price' => 99, - 'active' => true, - ]); - - $this->assertDatabaseHas('package_purchases', [ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'provider_id' => 'ORDER-SUB-1_sub_PLAN-123', - ]); + $payload = $response->json(); + $this->assertSame($package->paddle_price_id, $payload['items'][0]['priceId']); + $this->assertSame(1, $payload['items'][0]['quantity']); } - public function test_paypal_webhook_capture_completes_purchase(): void - { - [$tenant, $package] = $this->seedTenantWithPackage(price: 20); - - $metadata = json_encode([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - ]); - - $apiResponse = $this->apiResponse((object) [ - 'purchaseUnits' => [ - (object) ['customId' => $metadata], - ], - ]); - - $ordersController = Mockery::mock(OrdersController::class); - $ordersController->shouldReceive('showOrder') - ->andReturn($apiResponse); - - $clientMock = Mockery::mock(PaypalServerSdkClient::class); - $clientMock->shouldReceive('getOrdersController')->andReturn($ordersController); - - $factory = Mockery::mock(PaypalClientFactory::class); - $factory->shouldReceive('make')->andReturn($clientMock); - $this->app->instance(PaypalClientFactory::class, $factory); - - $event = [ - 'event_type' => 'PAYMENT.CAPTURE.COMPLETED', - 'resource' => [ - 'id' => 'CAPTURE-1', - 'order_id' => 'ORDER-WEBHOOK-1', - ], - ]; - - $response = $this->postJson('/paypal/webhook', [ - 'webhook_id' => 'WH-1', - 'webhook_event' => $event, - ]); - - $response->assertOk() - ->assertJson(['status' => 'SUCCESS']); - - $this->assertDatabaseHas('package_purchases', [ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'provider_id' => 'ORDER-WEBHOOK-1', - ]); - - $this->assertDatabaseHas('tenant_packages', [ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'active' => true, - ]); - - $this->assertEquals('active', $tenant->fresh()->subscription_status); - } - - public function test_paypal_webhook_capture_is_idempotent(): void - { - [$tenant, $package] = $this->seedTenantWithPackage(price: 25); - - $metadata = json_encode([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - ]); - - $apiResponse = $this->apiResponse((object) [ - 'purchaseUnits' => [ - (object) ['customId' => $metadata], - ], - ]); - - $ordersController = Mockery::mock(OrdersController::class); - $ordersController->shouldReceive('showOrder') - ->andReturn($apiResponse); - - $clientMock = Mockery::mock(PaypalServerSdkClient::class); - $clientMock->shouldReceive('getOrdersController')->andReturn($ordersController); - - $factory = Mockery::mock(PaypalClientFactory::class); - $factory->shouldReceive('make')->andReturn($clientMock); - $this->app->instance(PaypalClientFactory::class, $factory); - - $event = [ - 'event_type' => 'PAYMENT.CAPTURE.COMPLETED', - 'resource' => [ - 'id' => 'CAPTURE-2', - 'order_id' => 'ORDER-WEBHOOK-2', - ], - ]; - - $payload = [ - 'webhook_id' => 'WH-3', - 'webhook_event' => $event, - ]; - - $this->postJson('/paypal/webhook', $payload)->assertOk(); - $this->postJson('/paypal/webhook', $payload)->assertOk(); - - $this->assertDatabaseCount('package_purchases', 1); - $this->assertDatabaseHas('package_purchases', [ - 'provider_id' => 'ORDER-WEBHOOK-2', - ]); - } - - private function apiResponse(object $result, int $status = 201): ApiResponse - { - $response = Mockery::mock(ApiResponse::class); - $response->shouldReceive('getStatusCode')->andReturn($status); - $response->shouldReceive('getResult')->andReturn($result); - - return $response; - } - - private function seedTenantWithPackage(int $price = 10, string $type = 'endcustomer'): array + private function seedTenantWithPackage(int $price = 10, string $type = 'endcustomer', bool $includePaddlePrice = true): 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([ 'price' => $price, 'type' => $type, + 'paddle_price_id' => $includePaddlePrice ? 'price_123' : null, ]); - return [$tenant->fresh(), $package]; + return [$tenant, $package]; } } diff --git a/tests/Feature/StripeWebhookTest.php b/tests/Feature/StripeWebhookTest.php deleted file mode 100644 index 98c5e28..0000000 --- a/tests/Feature/StripeWebhookTest.php +++ /dev/null @@ -1,120 +0,0 @@ - 'whsec_test_secret']); - } - - public function test_handle_payment_intent_succeeded_creates_event_package(): void - { - $tenant = \App\Models\Tenant::factory()->create(); - $event = \App\Models\Event::factory()->create(['tenant_id' => $tenant->id]); - $package = Package::factory()->create(['type' => 'endcustomer']); - - $payload = [ - 'id' => 'evt_test', - 'type' => 'payment_intent.succeeded', - 'data' => [ - 'object' => [ - 'id' => 'pi_test', - 'metadata' => [ - 'type' => 'endcustomer_event', - 'tenant_id' => (string) $tenant->id, - 'event_id' => (string) $event->id, - 'package_id' => (string) $package->id, - ], - ], - ], - ]; - - $sigHeader = 't=12345,v1=' . base64_encode(hash_hmac('sha256', json_encode($payload), 'whsec_test_secret', true)); - - $response = $this->postJson('/api/v1/stripe/webhook', $payload, [ - 'Stripe-Signature' => $sigHeader, - ]); - - $response->assertStatus(200); - - $this->assertDatabaseHas('package_purchases', [ - 'package_id' => $package->id, - 'tenant_id' => $tenant->id, - 'event_id' => $event->id, - 'type' => 'endcustomer_event', - 'provider_id' => 'pi_test', - ]); - - $this->assertDatabaseHas('event_packages', [ - 'event_id' => $event->id, - 'package_id' => $package->id, - ]); - } - - public function test_handle_invoice_paid_renews_tenant_package(): void - { - $tenant = \App\Models\Tenant::factory()->create(); - $package = Package::factory()->create(['type' => 'reseller']); - - $payload = [ - 'id' => 'evt_test', - 'type' => 'invoice.paid', - 'data' => [ - 'object' => [ - 'subscription' => 'sub_test', - 'metadata' => [ - 'type' => 'reseller_subscription', - 'tenant_id' => (string) $tenant->id, - 'package_id' => (string) $package->id, - ], - ], - ], - ]; - - $sigHeader = 't=12345,v1=' . base64_encode(hash_hmac('sha256', json_encode($payload), 'whsec_test_secret', true)); - - $response = $this->postJson('/api/v1/stripe/webhook', $payload, [ - 'Stripe-Signature' => $sigHeader, - ]); - - $response->assertStatus(200); - - $this->assertDatabaseHas('package_purchases', [ - 'package_id' => $package->id, - 'tenant_id' => $tenant->id, - 'type' => 'reseller_subscription', - ]); - - $tenantPackage = TenantPackage::where('tenant_id', $tenant->id)->first(); - $this->assertNotNull($tenantPackage); - $this->assertTrue($tenantPackage->expires_at->isFuture()); - } - - public function test_webhook_rejects_invalid_signature(): void - { - $payload = ['type' => 'invalid']; - $sigHeader = 'invalid'; - - $response = $this->postJson('/api/v1/stripe/webhook', $payload, [ - 'Stripe-Signature' => $sigHeader, - ]); - - $response->assertStatus(400); - } -} \ No newline at end of file diff --git a/tests/Feature/SyncPackageToPaddleJobTest.php b/tests/Feature/SyncPackageToPaddleJobTest.php new file mode 100644 index 0000000..ab755fa --- /dev/null +++ b/tests/Feature/SyncPackageToPaddleJobTest.php @@ -0,0 +1,83 @@ +create([ + 'paddle_product_id' => null, + 'paddle_price_id' => null, + 'price' => 15.50, + 'slug' => 'silver-plan', + ]); + + $service = Mockery::mock(PaddleCatalogService::class); + $service->shouldReceive('createProduct') + ->once() + ->withArgs(function ($pkg, $overrides) use ($package) { + return $pkg->is($package) && $overrides === []; + }) + ->andReturn(['id' => 'pro_123']); + $service->shouldReceive('createPrice') + ->once() + ->withArgs(function ($pkg, $productId, $overrides) use ($package) { + return $pkg->is($package) && $productId === 'pro_123' && $overrides === []; + }) + ->andReturn(['id' => 'pri_123']); + $service->shouldReceive('buildProductPayload') + ->andReturn(['payload' => 'product']); + $service->shouldReceive('buildPricePayload') + ->andReturn(['payload' => 'price']); + + $job = new SyncPackageToPaddle($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']); + } + + public function test_dry_run_stores_snapshot_without_calling_paddle(): void + { + $package = Package::factory()->create([ + 'slug' => 'gold-plan', + ]); + + $service = Mockery::mock(PaddleCatalogService::class); + $service->shouldReceive('buildProductPayload')->andReturn(['payload' => 'product']); + $service->shouldReceive('buildPricePayload')->andReturn(['payload' => 'price']); + + $job = new SyncPackageToPaddle($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']); + } +} diff --git a/tests/Unit/PaddleCatalogServiceTest.php b/tests/Unit/PaddleCatalogServiceTest.php new file mode 100644 index 0000000..c1633ef --- /dev/null +++ b/tests/Unit/PaddleCatalogServiceTest.php @@ -0,0 +1,76 @@ +create([ + 'name' => 'Starter', + 'slug' => 'starter', + 'description' => '

Great package

', + 'name_translations' => [ + 'de' => 'Starter DE', + 'en' => 'Starter EN', + ], + 'description_translations' => [ + 'de' => 'Beschreibung', + 'en' => 'Description', + ], + 'features' => [ + 'custom_domain' => true, + 'advanced_analytics' => false, + ], + ]); + + $service = new PaddleCatalogService(Mockery::mock(PaddleClient::class)); + + $payload = $service->buildProductPayload($package); + + $this->assertSame('Starter', $payload['name']); + $this->assertSame('Great package', $payload['description']); + $this->assertSame('standard', $payload['tax_category']); + $this->assertSame('standard', $payload['type']); + $this->assertArrayHasKey('custom_data', $payload); + $this->assertSame((string) $package->id, $payload['custom_data']['fotospiel_package_id']); + $this->assertSame('starter', $payload['custom_data']['slug']); + $this->assertSame(['de' => 'Starter DE', 'en' => 'Starter EN'], $payload['custom_data']['translations']['name']); + $this->assertArrayHasKey('features', $payload['custom_data']); + } + + public function test_build_price_payload_converts_price_and_currency(): void + { + $package = Package::factory()->create([ + 'price' => 29.99, + 'description' => null, + 'name' => 'Silver Plan', + ]); + + $service = new PaddleCatalogService(Mockery::mock(PaddleClient::class)); + + $payload = $service->buildPricePayload($package, 'pro_123'); + + $this->assertSame('pro_123', $payload['product_id']); + $this->assertSame('2999', $payload['unit_price']['amount']); + $this->assertSame('EUR', $payload['unit_price']['currency_code']); + $this->assertSame('Silver Plan package', $payload['description']); + $this->assertArrayHasKey('custom_data', $payload); + } +} diff --git a/tests/e2e/checkout-payment.test.ts b/tests/e2e/checkout-payment.test.ts index 029ba6b..50a241d 100644 --- a/tests/e2e/checkout-payment.test.ts +++ b/tests/e2e/checkout-payment.test.ts @@ -4,11 +4,12 @@ import { execSync } from 'child_process'; const LOGIN_EMAIL = 'checkout-e2e@example.com'; const LOGIN_PASSWORD = 'Password123!'; -test.describe('Checkout Payment Step – Stripe & PayPal states', () => { +test.describe('Checkout Payment Step – Paddle flow', () => { test.beforeAll(async () => { execSync( `php artisan tenant:add-dummy --email=${LOGIN_EMAIL} --password=${LOGIN_PASSWORD} --first_name=Checkout --last_name=Tester --address="Playwrightstr. 1" --phone="+4912345678"` ); + execSync( `php artisan tinker --execute="App\\\\Models\\\\User::where('email', '${LOGIN_EMAIL}')->update(['email_verified_at' => now()]);"` ); @@ -22,110 +23,103 @@ test.describe('Checkout Payment Step – Stripe & PayPal states', () => { await expect(page).toHaveURL(/dashboard/); }); - test('Stripe payment intent error surfaces descriptive status', async ({ page }) => { - await page.route('**/stripe/create-payment-intent', async (route) => { + test('opens Paddle checkout and shows success notice', async ({ page }) => { + await page.route('**/paddle/create-checkout', async (route) => { + const request = route.request(); + const postData = request.postDataJSON() as { inline?: boolean } | null; + const inline = Boolean(postData?.inline); + + if (inline) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + mode: 'inline', + items: [ + { priceId: 'pri_123', quantity: 1 }, + ], + custom_data: { + tenant_id: '1', + package_id: '2', + checkout_session_id: 'cs_123', + }, + customer: { + email: LOGIN_EMAIL, + }, + }), + }); + return; + } + await route.fulfill({ - status: 422, + status: 200, contentType: 'application/json', - body: JSON.stringify({ error: 'Test payment intent failure' }), + body: JSON.stringify({ + checkout_url: 'https://paddle.test/checkout/success', + }), + }); + }); + + await page.route('https://cdn.paddle.com/paddle/v2/paddle.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; + } + } + }; + `, }); }); await openCheckoutPaymentStep(page); + await page.evaluate(() => { + window.__openedUrls = []; + window.open = (url: string, target?: string | null, features?: string | null) => { + window.__openedUrls.push({ url, target: target ?? null, features: features ?? null }); + return null; + }; + }); + + await page.getByRole('button', { name: /Continue with Paddle|Weiter mit Paddle/ }).click(); + await expect( - page.locator('text=/Fehler beim Laden der Zahlungsdaten|Error loading payment data/') + page.locator( + 'text=/Paddle checkout is running in a secure overlay|Der Paddle-Checkout läuft jetzt in einem Overlay/' + ) ).toBeVisible(); - await expect( - page.locator('text=/Zahlungsformular bereit|Payment form ready/') - ).not.toBeVisible(); + + await expect.poll(async () => { + return page.evaluate(() => window.__paddleOpenConfig?.items?.[0]?.priceId ?? null); + }).toBe('pri_123'); + + await expect.poll(async () => { + return page.evaluate(() => window.__openedUrls?.length ?? 0); + }).toBe(0); }); - test('Stripe payment intent ready state renders when backend responds', async ({ page }) => { - await page.route('**/stripe/create-payment-intent', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ client_secret: 'pi_test_secret' }), - }); - }); - - await openCheckoutPaymentStep(page); - - await expect( - page.locator('text=/Zahlungsformular bereit\\. Bitte gib deine Daten ein\\.|Payment form ready\\./') - ).toBeVisible(); - }); - - test('PayPal approval success updates status', async ({ page }) => { - await stubPayPalSdk(page); - - await page.route('**/paypal/create-order', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'ORDER_TEST', status: 'CREATED' }), - }); - }); - - await page.route('**/paypal/capture-order', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ status: 'captured' }), - }); - }); - - await openCheckoutPaymentStep(page); - await selectPayPalMethod(page); - - await page.waitForFunction(() => window.__paypalButtonsConfig !== undefined); - await page.evaluate(async () => { - const config = window.__paypalButtonsConfig; - if (!config) return; - - await config.createOrder(); - await config.onApprove({ orderID: 'ORDER_TEST' }); - }); - - await expect( - page.locator('text=/Zahlung bestätigt\\. Bestellung wird abgeschlossen|Payment confirmed/') - ).toBeVisible(); - }); - - test('PayPal capture failure notifies user', async ({ page }) => { - await stubPayPalSdk(page); - - await page.route('**/paypal/create-order', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'ORDER_FAIL', status: 'CREATED' }), - }); - }); - - await page.route('**/paypal/capture-order', async (route) => { + test('shows error state when Paddle checkout creation fails', async ({ page }) => { + await page.route('**/paddle/create-checkout', async (route) => { await route.fulfill({ status: 500, contentType: 'application/json', - body: JSON.stringify({ error: 'capture_failed' }), + body: JSON.stringify({ message: 'test-error' }), }); }); await openCheckoutPaymentStep(page); - await selectPayPalMethod(page); - await page.waitForFunction(() => window.__paypalButtonsConfig !== undefined); - await page.evaluate(async () => { - const config = window.__paypalButtonsConfig; - if (!config) return; - - await config.createOrder(); - await config.onApprove({ orderID: 'ORDER_FAIL' }); - }); + await page.getByRole('button', { name: /Continue with Paddle|Weiter mit Paddle/ }).click(); await expect( - page.locator('text=/PayPal capture failed|PayPal-Abbuchung fehlgeschlagen|PayPal capture error/') + page.locator('text=/Paddle checkout could not be started|Paddle-Checkout konnte nicht gestartet werden/') ).toBeVisible(); }); }); @@ -151,41 +145,11 @@ async function openCheckoutPaymentStep(page: import('@playwright/test').Page) { await page.waitForSelector('text=/Zahlung|Payment/'); } -async function selectPayPalMethod(page: import('@playwright/test').Page) { - const paypalButton = page.getByRole('button', { name: /PayPal/ }); - if (await paypalButton.isVisible()) { - await paypalButton.click(); - } -} - -async function stubPayPalSdk(page: import('@playwright/test').Page) { - await page.route('https://www.paypal.com/sdk/js**', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/javascript', - body: ` - window.paypal = { - Buttons: function (config) { - window.__paypalButtonsConfig = config; - return { - render: function () { - // noop - }, - }; - }, - }; - `, - }); - }); -} - declare global { interface Window { - __paypalButtonsConfig?: { - createOrder: () => Promise; - onApprove: (data: { orderID: string }) => Promise; - onError?: (error: unknown) => void; - }; + __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; } } - diff --git a/tests/e2e/event-admin-dashboard.test.ts b/tests/e2e/event-admin-dashboard.test.ts index 0d382ba..68d3055 100644 --- a/tests/e2e/event-admin-dashboard.test.ts +++ b/tests/e2e/event-admin-dashboard.test.ts @@ -1,39 +1,109 @@ +import type { Page } from '@playwright/test'; + import { test, expectFixture as expect } from './utils/test-fixtures'; -test.describe('Tenant Admin – core flows', () => { - test('dashboard shows key sections for seeded tenant', async ({ signInTenantAdmin, page }) => { - await signInTenantAdmin(); +const futureDate = (daysAhead = 10): string => { + const date = new Date(); + date.setDate(date.getDate() + daysAhead); + return date.toISOString().slice(0, 10); +}; - await expect(page).toHaveURL(/\/event-admin(\/welcome)?/); +async function ensureOnDashboard(page: Page): Promise { + await page.goto('/event-admin/dashboard'); + await page.waitForLoadState('networkidle'); - if (page.url().includes('/event-admin/welcome')) { - await page.getByRole('button', { name: /Direkt zum Dashboard/i }).click(); + if (page.url().includes('/event-admin/welcome')) { + const directButton = page.getByRole('button', { name: /Direkt zum Dashboard/i }); + if (await directButton.isVisible()) { + await directButton.click(); + await page.waitForURL(/\/event-admin\/dashboard$/, { timeout: 15_000 }); } + } +} + +test.describe('Tenant Admin PWA – end-to-end coverage', () => { + test.beforeEach(async ({ signInTenantAdmin }) => { + await signInTenantAdmin(); + }); + + test('dashboard highlights core stats and quick actions', async ({ page }) => { + await ensureOnDashboard(page); await expect(page.getByRole('heading', { name: /Hallo/i })).toBeVisible(); await expect(page.getByRole('button', { name: /Neues Event/i })).toBeVisible(); - await expect(page.getByRole('button', { name: /Guided Setup/i })).toBeVisible(); - - await expect(page.getByRole('heading', { name: /Hallo Lumen Moments!/i })).toBeVisible(); - await expect(page.getByRole('button', { name: /Neues Event/i })).toBeVisible(); - await expect(page.getByRole('button', { name: /Guided Setup/i })).toBeVisible(); + await expect(page.getByText(/Schnellaktionen/i)).toBeVisible(); + await expect(page.getByText(/Kommende Events/i)).toBeVisible(); }); - test('events overview lists published and draft events', async ({ signInTenantAdmin, page }) => { - await signInTenantAdmin(); + test('event creation flow and detail subsections', async ({ page }) => { + const eventName = `Playwright Event ${Date.now()}`; + const eventDate = futureDate(14); + + await page.goto('/event-admin/events/new'); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', { name: /Eventdetails/i })).toBeVisible(); + + await page.getByLabel(/Eventname/i).fill(eventName); + await page.getByLabel(/Datum/i).fill(eventDate); + + const eventTypeTrigger = page.getByRole('combobox', { name: /Event-Typ/i }); + await eventTypeTrigger.click(); + const firstOption = page.getByRole('option').first(); + await expect(firstOption).toBeVisible({ timeout: 5_000 }); + await firstOption.click(); + + await page.getByRole('button', { name: /^Speichern/i }).click(); + await expect(page).toHaveURL(/\/event-admin\/events\/[a-z0-9-]+$/, { timeout: 20_000 }); + const createdSlug = page.url().split('/').pop() ?? ''; + + await expect(page.getByRole('heading', { name: /Eventdetails/i })).toBeVisible(); + await page.goto('/event-admin/events'); await page.waitForLoadState('networkidle'); + await expect(page.getByText(eventName, { exact: false })).toBeVisible(); - await expect(page.getByRole('heading', { name: /Deine Events/i })).toBeVisible({ timeout: 15_000 }); - await expect(page.getByRole('button', { name: /Neues Event/i })).toBeVisible(); + await page.goto(`/event-admin/events/${createdSlug}/photos`); + await expect(page.getByRole('heading', { name: /Fotos moderieren/i })).toBeVisible(); + await expect(page.getByText(/Noch keine Fotos vorhanden/i)).toBeVisible(); + + await page.goto(`/event-admin/events/${createdSlug}/members`); + await expect(page.getByRole('heading', { name: /Event-Mitglieder/i })).toBeVisible(); + + await page.goto(`/event-admin/events/${createdSlug}/tasks`); + await expect(page.getByRole('heading', { name: /Event-Tasks/i })).toBeVisible(); + await expect(page.getByText(/Noch keine Tasks zugewiesen/i)).toBeVisible(); }); - test('billing page lists the active package and history', async ({ signInTenantAdmin, page }) => { - await signInTenantAdmin(); - await page.goto('/event-admin/billing'); + test('task library allows creating custom tasks', async ({ page }) => { + await page.goto('/event-admin/tasks'); + await page.waitForLoadState('networkidle'); - await expect(page.getByRole('heading', { name: /Pakete & Abrechnung/i })).toBeVisible({ timeout: 15_000 }); - await expect(page.getByRole('heading', { name: /Paketübersicht/i })).toBeVisible(); - await expect(page.getByText(/Paket-Historie/)).toBeVisible(); + await expect(page.getByRole('heading', { name: /Task Bibliothek/i })).toBeVisible(); + + const taskTitle = `Playwright Task ${Date.now()}`; + await page.getByRole('button', { name: /^Neu$/i }).click(); + await page.getByLabel(/Titel/i).fill(taskTitle); + await page.getByLabel(/Beschreibung/i).fill('Automatisierter Testfall'); + await page.getByRole('button', { name: /^Speichern$/i }).click(); + + await expect(page.getByText(taskTitle)).toBeVisible({ timeout: 10_000 }); + + await page.goto('/event-admin/task-collections'); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', { name: /Aufgabenvorlagen/i })).toBeVisible(); + }); + + test('supporting sections (emotions, billing, settings) load successfully', async ({ page }) => { + await page.goto('/event-admin/emotions'); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', { name: /Emotionen/i })).toBeVisible(); + + await page.goto('/event-admin/billing'); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', { name: /Pakete & Abrechnung/i })).toBeVisible(); + + await page.goto('/event-admin/settings'); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', { name: /Einstellungen/i })).toBeVisible(); }); }); diff --git a/tests/e2e/tenant-onboarding-flow.test.ts b/tests/e2e/tenant-onboarding-flow.test.ts index cc5a554..3ed31dc 100644 --- a/tests/e2e/tenant-onboarding-flow.test.ts +++ b/tests/e2e/tenant-onboarding-flow.test.ts @@ -6,7 +6,7 @@ import { test, expectFixture as expect } from './utils/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 Stripe/PayPal APIs. + * steps with mocked Stripe/Paddle APIs. */ test.describe('Tenant Onboarding Welcome Flow', () => { test('redirects unauthenticated users to login', async ({ page }) => { @@ -47,7 +47,7 @@ test.describe('Tenant Onboarding Welcome Flow', () => { await expect(page).toHaveURL(/\/event-admin\/welcome\/summary/); await expect(page.getByRole('heading', { name: /Bestellübersicht/i })).toBeVisible(); - // Validate payment sections. Depending on env we either see Stripe/PayPal widgets or configuration warnings. + // Validate payment sections. Depending on env we either see Stripe/Paddle widgets or configuration warnings. const stripeConfigured = Boolean(process.env.VITE_STRIPE_PUBLISHABLE_KEY); if (stripeConfigured) { await expect(page.getByRole('heading', { name: /Kartenzahlung \(Stripe\)/i })).toBeVisible(); @@ -57,12 +57,7 @@ test.describe('Tenant Onboarding Welcome Flow', () => { ).toBeVisible(); } - const paypalConfigured = Boolean(process.env.VITE_PAYPAL_CLIENT_ID); - if (paypalConfigured) { - await expect(page.getByRole('heading', { name: /^PayPal$/i })).toBeVisible(); - } else { - await expect(page.getByText(/PayPal nicht konfiguriert/i)).toBeVisible(); - } + await expect(page.getByRole('heading', { name: /^Paddle$/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/e2e/utils/test-fixtures.ts b/tests/e2e/utils/test-fixtures.ts index d55fc0b..1e58e97 100644 --- a/tests/e2e/utils/test-fixtures.ts +++ b/tests/e2e/utils/test-fixtures.ts @@ -64,6 +64,7 @@ type StoredTokenPayload = { refreshToken: string; expiresAt: number; scope?: string; + clientId?: string; }; async function exchangeTokens(request: APIRequestContext): Promise { @@ -124,6 +125,7 @@ async function exchangeTokens(request: APIRequestContext): Promise