From a949c8d3af5011b86e9ff07d3f7715cffe8e6013 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sun, 19 Oct 2025 11:41:03 +0200 Subject: [PATCH] =?UTF-8?q?-=20Wired=20the=20checkout=20wizard=20for=20Goo?= =?UTF-8?q?gle=20=E2=80=9Ccomfort=20login=E2=80=9D:=20added=20Socialite=20?= =?UTF-8?q?controller=20+=20dependency,=20new=20Google=20env=20hooks=20in?= =?UTF-8?q?=20config/services.php/.env.example,=20and=20updated=20wizard?= =?UTF-8?q?=20steps/controllers=20to=20store=20session=20payloads,=20attac?= =?UTF-8?q?h=20packages,=20and=20surface=20localized=20success/error=20sta?= =?UTF-8?q?tes.=20-=20Retooled=20payment=20handling=20for=20both=20Stripe?= =?UTF-8?q?=20and=20PayPal,=20adding=20richer=20status=20management=20in?= =?UTF-8?q?=20CheckoutController/=20PayPalController,=20fallback=20flows?= =?UTF-8?q?=20in=20the=20wizard=E2=80=99s=20PaymentStep.tsx,=20and=20fresh?= =?UTF-8?q?=20feature=20tests=20for=20intent=20creation,=20webhooks,=20and?= =?UTF-8?q?=20the=20wizard=20CTA.=20-=20Introduced=20a=20consent-aware=20M?= =?UTF-8?q?atomo=20analytics=20stack:=20new=20consent=20context,=20cookie-?= =?UTF-8?q?banner=20UI,=20useAnalytics/=20useCtaExperiment=20hooks,=20and?= =?UTF-8?q?=20MatomoTracker=20component,=20then=20instrumented=20marketing?= =?UTF-8?q?=20pages=20(Home,=20Packages,=20Checkout)=20with=20localized=20?= =?UTF-8?q?copy=20and=20experiment=20tracking.=20-=20Polished=20package=20?= =?UTF-8?q?presentation=20across=20marketing=20UIs=20by=20centralizing=20f?= =?UTF-8?q?ormatting=20in=20PresentsPackages,=20surfacing=20localized=20de?= =?UTF-8?q?scription=20tables/placeholders,=20tuning=20badges/layouts,=20a?= =?UTF-8?q?nd=20syncing=20guest/marketing=20translations.=20-=20Expanded?= =?UTF-8?q?=20docs=20&=20reference=20material=20(docs/prp/*,=20TODOs,=20pu?= =?UTF-8?q?blic=20gallery=20overview)=20and=20added=20a=20Playwright=20smo?= =?UTF-8?q?ke=20test=20for=20the=20hero=20CTA=20while=20reconciling=20outs?= =?UTF-8?q?tanding=20checklist=20items.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 14 + .../Commands/OAuthRotateKeysCommand.php | 111 ++++ app/Exports/PurchaseHistoryExporter.php | 37 ++ .../Resources/OAuthClientResource.php | 190 +++++++ .../Pages/CreateOAuthClient.php | 17 + .../Pages/EditOAuthClient.php | 25 + .../Pages/ListOAuthClients.php | 12 + .../Pages/ViewOAuthClient.php | 12 + .../Resources/PurchaseHistoryResource.php | 189 +++++++ .../Pages/ListPurchaseHistories.php | 17 + .../Pages/ViewPurchaseHistory.php | 17 + app/Filament/Resources/TenantResource.php | 48 ++ app/Filament/Widgets/CreditAlertsWidget.php | 61 +++ app/Filament/Widgets/RevenueTrendWidget.php | 55 ++ app/Filament/Widgets/TopTenantsByRevenue.php | 53 ++ .../Api/Tenant/EventController.php | 9 +- app/Http/Controllers/CheckoutController.php | 48 +- .../Controllers/CheckoutGoogleController.php | 211 ++++++++ app/Http/Controllers/MarketingController.php | 184 +------ app/Http/Controllers/OAuthController.php | 125 ++++- app/Http/Controllers/PayPalController.php | 24 +- .../Controllers/PayPalWebhookController.php | 2 +- app/Http/Middleware/PackageMiddleware.php | 25 +- app/Http/Middleware/TenantTokenGuard.php | 36 +- app/Jobs/ProcessRevenueCatWebhook.php | 7 + app/Models/OAuthClient.php | 15 +- app/Models/Tenant.php | 73 ++- app/Policies/OAuthClientPolicy.php | 38 ++ app/Policies/PurchaseHistoryPolicy.php | 23 + app/Policies/TenantPolicy.php | 73 +++ app/Providers/AppServiceProvider.php | 19 + app/Providers/AuthServiceProvider.php | 40 ++ .../Filament/SuperAdminPanelProvider.php | 6 + app/Support/Concerns/PresentsPackages.php | 173 +++++++ bootstrap/app.php | 4 + bootstrap/providers.php | 1 + composer.json | 1 + composer.lock | 310 +++++++++++- config/checkout.php | 4 +- config/oauth.php | 13 + config/services.php | 13 + .../2025-10-05-checkout-refactor-todo.md | 24 +- docs/implementation-roadmap.md | 60 +-- docs/piwik-trackingcode.txt | 15 + docs/prp/03-api.md | 10 + docs/prp/07-guest-pwa.md | 2 + docs/prp/08-billing.md | 17 + docs/prp/09-security-compliance.md | 17 + docs/prp/11-public-gallery.md | 29 ++ ...marketing-checkout-payment-architecture.md | 3 +- docs/prp/marketing-frontend-unification.md | 3 +- docs/prp/public-entrypoints.md | 28 + docs/prp/tenant-app-specs/README.md | 19 +- docs/prp/tenant-app-specs/api-usage.md | 3 +- docs/prp/tenant-app-specs/functional-specs.md | 4 +- docs/todo/event-join-token-hardening.md | 8 +- docs/todo/security-hardening-epic.md | 42 ++ docs/todo/tenant-admin-onboarding-fusion.md | 2 +- lang/de/checkout.php | 6 + lang/en/checkout.php | 6 + public/lang/de/common.json | 30 +- public/lang/de/marketing.json | 43 +- public/lang/en/common.json | 30 +- public/lang/en/marketing.json | 43 +- resources/js/app.tsx | 11 +- .../js/components/analytics/MatomoTracker.tsx | 104 ++++ .../js/components/consent/CookieBanner.tsx | 175 +++++++ resources/js/contexts/consent.tsx | 185 +++++++ .../js/guest/components/EmotionPicker.tsx | 4 +- .../js/guest/components/GalleryPreview.tsx | 11 +- resources/js/guest/components/Header.tsx | 8 +- resources/js/guest/pages/AchievementsPage.tsx | 18 +- resources/js/guest/pages/GalleryPage.tsx | 20 +- resources/js/guest/pages/HomePage.tsx | 2 +- resources/js/guest/pages/LandingPage.tsx | 6 +- resources/js/guest/pages/PhotoLightbox.tsx | 16 +- resources/js/guest/pages/TaskPickerPage.tsx | 8 +- resources/js/guest/pages/UploadPage.tsx | 16 +- .../js/guest/polling/usePollGalleryDelta.ts | 10 +- resources/js/guest/queue/queue.ts | 4 +- resources/js/guest/router.tsx | 4 +- resources/js/guest/services/achievementApi.ts | 4 +- resources/js/guest/services/eventApi.ts | 4 +- resources/js/guest/services/photosApi.ts | 4 +- resources/js/hooks/useAnalytics.ts | 44 ++ resources/js/hooks/useCtaExperiment.ts | 58 +++ resources/js/layouts/app/Footer.tsx | 19 +- resources/js/layouts/app/Header.tsx | 2 +- resources/js/layouts/mainWebsite.tsx | 16 +- resources/js/pages/marketing/Home.tsx | 58 ++- resources/js/pages/marketing/Packages.tsx | 398 +++++++++++---- .../marketing/checkout/CheckoutWizard.tsx | 48 +- .../marketing/checkout/steps/AuthStep.tsx | 74 ++- .../checkout/steps/ConfirmationStep.tsx | 22 +- .../marketing/checkout/steps/PackageStep.tsx | 55 +- .../marketing/checkout/steps/PaymentStep.tsx | 479 ++++++++++++------ .../js/pages/marketing/checkout/types.ts | 8 +- resources/lang/de/admin.php | 98 ++++ resources/lang/en/admin.php | 104 +++- routes/api.php | 2 +- routes/web.php | 28 +- .../Feature/CheckoutGoogleControllerTest.php | 103 ++++ tests/Feature/CheckoutPaymentIntentTest.php | 132 +++++ tests/Feature/EventControllerTest.php | 17 +- tests/Feature/GuestJoinTokenFlowTest.php | 10 + tests/Feature/OAuthFlowTest.php | 136 ++++- tests/Feature/PayPalWebhookControllerTest.php | 75 +++ tests/Feature/PurchaseTest.php | 2 - tests/Unit/AdminDashboardWidgetsTest.php | 136 +++++ tests/Unit/TenantCreditTest.php | 68 +++ tests/Unit/TenantPolicyTest.php | 72 +++ tests/e2e/checkout-hero-cta.test.ts | 33 ++ tests/e2e/checkout-payment.test.ts | 191 +++++++ 113 files changed, 5169 insertions(+), 712 deletions(-) create mode 100644 app/Console/Commands/OAuthRotateKeysCommand.php create mode 100644 app/Exports/PurchaseHistoryExporter.php create mode 100644 app/Filament/Resources/OAuthClientResource.php create mode 100644 app/Filament/Resources/OAuthClientResource/Pages/CreateOAuthClient.php create mode 100644 app/Filament/Resources/OAuthClientResource/Pages/EditOAuthClient.php create mode 100644 app/Filament/Resources/OAuthClientResource/Pages/ListOAuthClients.php create mode 100644 app/Filament/Resources/OAuthClientResource/Pages/ViewOAuthClient.php create mode 100644 app/Filament/Resources/PurchaseHistoryResource.php create mode 100644 app/Filament/Resources/PurchaseHistoryResource/Pages/ListPurchaseHistories.php create mode 100644 app/Filament/Resources/PurchaseHistoryResource/Pages/ViewPurchaseHistory.php create mode 100644 app/Filament/Widgets/CreditAlertsWidget.php create mode 100644 app/Filament/Widgets/RevenueTrendWidget.php create mode 100644 app/Filament/Widgets/TopTenantsByRevenue.php create mode 100644 app/Http/Controllers/CheckoutGoogleController.php create mode 100644 app/Policies/OAuthClientPolicy.php create mode 100644 app/Policies/PurchaseHistoryPolicy.php create mode 100644 app/Policies/TenantPolicy.php create mode 100644 app/Providers/AuthServiceProvider.php create mode 100644 app/Support/Concerns/PresentsPackages.php create mode 100644 config/oauth.php create mode 100644 docs/piwik-trackingcode.txt create mode 100644 docs/prp/11-public-gallery.md create mode 100644 docs/prp/public-entrypoints.md create mode 100644 docs/todo/security-hardening-epic.md create mode 100644 lang/de/checkout.php create mode 100644 lang/en/checkout.php create mode 100644 resources/js/components/analytics/MatomoTracker.tsx create mode 100644 resources/js/components/consent/CookieBanner.tsx create mode 100644 resources/js/contexts/consent.tsx create mode 100644 resources/js/hooks/useAnalytics.ts create mode 100644 resources/js/hooks/useCtaExperiment.ts create mode 100644 tests/Feature/CheckoutGoogleControllerTest.php create mode 100644 tests/Feature/CheckoutPaymentIntentTest.php create mode 100644 tests/Feature/PayPalWebhookControllerTest.php create mode 100644 tests/Unit/AdminDashboardWidgetsTest.php create mode 100644 tests/Unit/TenantCreditTest.php create mode 100644 tests/Unit/TenantPolicyTest.php create mode 100644 tests/e2e/checkout-hero-cta.test.ts create mode 100644 tests/e2e/checkout-payment.test.ts diff --git a/.env.example b/.env.example index 17e3632..d621a98 100644 --- a/.env.example +++ b/.env.example @@ -69,9 +69,23 @@ STRIPE_WEBHOOK_SECRET= STRIPE_CONNECT_CLIENT_ID= STRIPE_CONNECT_SECRET= +# Google OAuth (Checkout comfort login) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback + VITE_APP_NAME="${APP_NAME}" REVENUECAT_WEBHOOK_SECRET= REVENUECAT_PRODUCT_MAPPINGS= REVENUECAT_APP_USER_PREFIX=tenant +REVENUECAT_WEBHOOK_QUEUE=webhooks + +CHECKOUT_WIZARD_ENABLED=true +CHECKOUT_WIZARD_FLAG=checkout-wizard-2025 + +OAUTH_JWT_KID=fotospiel-jwt +OAUTH_KEY_STORE= +OAUTH_REFRESH_ENFORCE_IP=true +OAUTH_REFRESH_ALLOW_SUBNET=false diff --git a/app/Console/Commands/OAuthRotateKeysCommand.php b/app/Console/Commands/OAuthRotateKeysCommand.php new file mode 100644 index 0000000..47c6232 --- /dev/null +++ b/app/Console/Commands/OAuthRotateKeysCommand.php @@ -0,0 +1,111 @@ +option('kid') ?: 'kid-'.now()->format('YmdHis'); + + if (! $this->option('force') && + ! $this->confirm("Rotate JWT keys? Current kid: {$currentKid}. New kid: {$newKid}", true) + ) { + $this->info('Rotation cancelled.'); + return self::SUCCESS; + } + + File::ensureDirectoryExists($storage); + + $archiveDir = $this->archiveExistingKeys($storage, $currentKid); + + $newDirectory = $storage.DIRECTORY_SEPARATOR.$newKid; + if (File::exists($newDirectory)) { + $this->error("Target directory already exists: {$newDirectory}"); + return self::FAILURE; + } + + File::makeDirectory($newDirectory, 0700, true); + $this->generateKeyPair($newDirectory); + + $this->info('New signing keys generated.'); + $this->line("Path: {$newDirectory}"); + + if ($archiveDir) { + $this->line("Previous keys archived at: {$archiveDir}"); + } + + $this->warn("Update OAUTH_JWT_KID in your environment configuration to: {$newKid}"); + + return self::SUCCESS; + } + + private function archiveExistingKeys(string $storage, string $kid): ?string + { + $existingDir = $storage.DIRECTORY_SEPARATOR.$kid; + $legacyPublic = storage_path('app/public.key'); + $legacyPrivate = storage_path('app/private.key'); + + if (File::exists($existingDir)) { + $archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.$kid.'-'.now()->format('YmdHis'); + File::ensureDirectoryExists(dirname($archiveDir)); + File::moveDirectory($existingDir, $archiveDir); + return $archiveDir; + } + + if (File::exists($legacyPublic) || File::exists($legacyPrivate)) { + $archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.'legacy-'.now()->format('YmdHis'); + File::ensureDirectoryExists($archiveDir); + + if (File::exists($legacyPublic)) { + File::move($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key'); + } + + if (File::exists($legacyPrivate)) { + File::move($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key'); + } + + return $archiveDir; + } + + return null; + } + + private function generateKeyPair(string $directory): void + { + $config = [ + 'digest_alg' => OPENSSL_ALGO_SHA256, + 'private_key_bits' => 4096, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]; + + $resource = openssl_pkey_new($config); + if (! $resource) { + throw new \RuntimeException('Failed to generate key pair'); + } + + openssl_pkey_export($resource, $privateKey); + $details = openssl_pkey_get_details($resource); + $publicKey = $details['key'] ?? null; + + if (! $publicKey) { + throw new \RuntimeException('Unable to extract public key'); + } + + File::put($directory.DIRECTORY_SEPARATOR.'private.key', $privateKey); + File::chmod($directory.DIRECTORY_SEPARATOR.'private.key', 0600); + + File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey); + File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644); + } +} + diff --git a/app/Exports/PurchaseHistoryExporter.php b/app/Exports/PurchaseHistoryExporter.php new file mode 100644 index 0000000..774130c --- /dev/null +++ b/app/Exports/PurchaseHistoryExporter.php @@ -0,0 +1,37 @@ +label(__('admin.purchase_history.fields.tenant')), + ExportColumn::make('package_id')->label(__('admin.purchase_history.fields.package')), + ExportColumn::make('credits_added')->label(__('admin.purchase_history.fields.credits')), + ExportColumn::make('price')->label(__('admin.purchase_history.fields.price')), + ExportColumn::make('currency')->label(__('admin.purchase_history.fields.currency')), + ExportColumn::make('platform')->label(__('admin.purchase_history.fields.platform')), + ExportColumn::make('transaction_id')->label(__('admin.purchase_history.fields.transaction_id')), + ExportColumn::make('purchased_at')->label(__('admin.purchase_history.fields.purchased_at')), + ]; + } + + public static function getCompletedNotificationBody(Export $export): string + { + return __('admin.purchase_history.export_success', [ + 'count' => $export->successful_rows, + ]); + } +} diff --git a/app/Filament/Resources/OAuthClientResource.php b/app/Filament/Resources/OAuthClientResource.php new file mode 100644 index 0000000..35a77cc --- /dev/null +++ b/app/Filament/Resources/OAuthClientResource.php @@ -0,0 +1,190 @@ +schema([ + Forms\Components\TextInput::make('name') + ->label(__('admin.oauth.fields.name')) + ->required() + ->maxLength(255), + Forms\Components\TextInput::make('client_id') + ->label(__('admin.oauth.fields.client_id')) + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255), + Forms\Components\TextInput::make('client_secret') + ->label(__('admin.oauth.fields.client_secret')) + ->password() + ->revealable() + ->helperText(__('admin.oauth.hints.client_secret')) + ->dehydrated(fn (?string $state): bool => filled($state)) + ->dehydrateStateUsing(fn (?string $state): ?string => filled($state) ? Hash::make($state) : null), + Forms\Components\Select::make('tenant_id') + ->label(__('admin.oauth.fields.tenant')) + ->relationship('tenant', 'name') + ->searchable() + ->preload() + ->nullable(), + Forms\Components\Textarea::make('redirect_uris') + ->label(__('admin.oauth.fields.redirect_uris')) + ->rows(4) + ->helperText(__('admin.oauth.hints.redirect_uris')) + ->formatStateUsing(fn ($state): string => is_array($state) ? implode(PHP_EOL, $state) : (string) $state) + ->dehydrateStateUsing(function (?string $state): array { + $entries = collect(preg_split('/\r\n|\r|\n/', (string) $state)) + ->map(fn ($uri) => trim($uri)) + ->filter(); + + return $entries->values()->all(); + }) + ->required(), + Forms\Components\TagsInput::make('scopes') + ->label(__('admin.oauth.fields.scopes')) + ->placeholder('tenant:read') + ->suggestions([ + 'tenant:read', + 'tenant:write', + 'tenant:admin', + ]) + ->separator(',') + ->required(), + Forms\Components\Toggle::make('is_active') + ->label(__('admin.oauth.fields.is_active')) + ->default(true), + Forms\Components\Textarea::make('description') + ->label(__('admin.oauth.fields.description')) + ->rows(3) + ->columnSpanFull(), + ])->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->label(__('admin.oauth.fields.name')) + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('client_id') + ->label(__('admin.oauth.fields.client_id')) + ->copyable() + ->sortable(), + Tables\Columns\TextColumn::make('tenant.name') + ->label(__('admin.oauth.fields.tenant')) + ->toggleable(isToggledHiddenByDefault: true) + ->sortable(), + Tables\Columns\IconColumn::make('is_active') + ->label(__('admin.oauth.fields.is_active')) + ->boolean() + ->color(fn (bool $state): string => $state ? 'success' : 'danger'), + Tables\Columns\TextColumn::make('redirect_uris') + ->label(__('admin.oauth.fields.redirect_uris')) + ->formatStateUsing(fn ($state) => collect(Arr::wrap($state))->implode("\n")) + ->limit(50) + ->toggleable(), + Tables\Columns\TagsColumn::make('scopes') + ->label(__('admin.oauth.fields.scopes')) + ->separator(', ') + ->limit(4), + Tables\Columns\TextColumn::make('updated_at') + ->label(__('admin.oauth.fields.updated_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('is_active') + ->label(__('admin.oauth.filters.is_active')) + ->placeholder(__('admin.oauth.filters.any')) + ->trueLabel(__('admin.oauth.filters.active')) + ->falseLabel(__('admin.oauth.filters.inactive')), + Tables\Filters\SelectFilter::make('tenant_id') + ->label(__('admin.oauth.fields.tenant')) + ->relationship('tenant', 'name') + ->searchable(), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + Tables\Actions\Action::make('regenerate_secret') + ->label(__('admin.oauth.actions.regenerate_secret')) + ->icon('heroicon-o-arrow-path') + ->requiresConfirmation() + ->action(function (OAuthClient $record): void { + $plainSecret = Str::random(48); + + $record->forceFill([ + 'client_secret' => Hash::make($plainSecret), + ])->save(); + + Notification::make() + ->title(__('admin.oauth.notifications.secret_regenerated_title')) + ->body(__('admin.oauth.notifications.secret_regenerated_body', [ + 'secret' => $plainSecret, + ])) + ->success() + ->send(); + }), + Tables\Actions\DeleteAction::make() + ->before(function (OAuthClient $record): void { + RefreshToken::query() + ->where('client_id', $record->client_id) + ->delete(); + }), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make() + ->before(function (Collection $records): void { + $records->each(function (OAuthClient $record) { + RefreshToken::query() + ->where('client_id', $record->client_id) + ->delete(); + }); + }), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListOAuthClients::route('/'), + 'create' => Pages\CreateOAuthClient::route('/create'), + 'view' => Pages\ViewOAuthClient::route('/{record}'), + 'edit' => Pages\EditOAuthClient::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/OAuthClientResource/Pages/CreateOAuthClient.php b/app/Filament/Resources/OAuthClientResource/Pages/CreateOAuthClient.php new file mode 100644 index 0000000..3d90754 --- /dev/null +++ b/app/Filament/Resources/OAuthClientResource/Pages/CreateOAuthClient.php @@ -0,0 +1,17 @@ +schema([ + Forms\Components\Select::make('tenant_id') + ->label(__('admin.purchase_history.fields.tenant')) + ->relationship('tenant', 'name') + ->searchable() + ->preload() + ->required(), + Forms\Components\TextInput::make('package_id') + ->label(__('admin.purchase_history.fields.package')) + ->required() + ->maxLength(255), + Forms\Components\TextInput::make('credits_added') + ->label(__('admin.purchase_history.fields.credits')) + ->numeric() + ->required(), + Forms\Components\TextInput::make('price') + ->label(__('admin.purchase_history.fields.price')) + ->numeric() + ->required(), + Forms\Components\TextInput::make('currency') + ->label(__('admin.purchase_history.fields.currency')) + ->maxLength(3) + ->default('EUR'), + Forms\Components\TextInput::make('platform') + ->label(__('admin.purchase_history.fields.platform')) + ->maxLength(50) + ->required(), + Forms\Components\TextInput::make('transaction_id') + ->label(__('admin.purchase_history.fields.transaction_id')) + ->maxLength(255), + Forms\Components\DateTimePicker::make('purchased_at') + ->label(__('admin.purchase_history.fields.purchased_at')) + ->required(), + ])->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('tenant.name') + ->label(__('admin.purchase_history.fields.tenant')) + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('package_id') + ->label(__('admin.purchase_history.fields.package')) + ->badge() + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('credits_added') + ->label(__('admin.purchase_history.fields.credits')) + ->badge() + ->color(fn (int $state): string => $state > 0 ? 'success' : ($state < 0 ? 'danger' : 'gray')) + ->sortable(), + Tables\Columns\TextColumn::make('price') + ->label(__('admin.purchase_history.fields.price')) + ->formatStateUsing(fn ($state, PurchaseHistory $record): string => number_format((float) $state, 2).' '.($record->currency ?? 'EUR')) + ->sortable(), + Tables\Columns\TextColumn::make('platform') + ->label(__('admin.purchase_history.fields.platform')) + ->badge() + ->formatStateUsing(function ($state): string { + $key = 'admin.purchase_history.platforms.' . (string) $state; + $translated = __($key); + + return $translated === $key ? Str::headline((string) $state) : $translated; + }) + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('transaction_id') + ->label(__('admin.purchase_history.fields.transaction_id')) + ->copyable() + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('purchased_at') + ->label(__('admin.purchase_history.fields.purchased_at')) + ->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\Filter::make('purchased_at') + ->label(__('admin.purchase_history.filters.purchased_at')) + ->form([ + Forms\Components\DatePicker::make('from')->label(__('admin.common.from')), + Forms\Components\DatePicker::make('until')->label(__('admin.common.until')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query + ->when( + $data['from'] ?? null, + fn (Builder $builder, $date): Builder => $builder->whereDate('purchased_at', '>=', $date), + ) + ->when( + $data['until'] ?? null, + fn (Builder $builder, $date): Builder => $builder->whereDate('purchased_at', '<=', $date), + ); + }), + Tables\Filters\SelectFilter::make('platform') + ->label(__('admin.purchase_history.filters.platform')) + ->options([ + 'ios' => __('admin.purchase_history.platforms.ios'), + 'android' => __('admin.purchase_history.platforms.android'), + 'web' => __('admin.purchase_history.platforms.web'), + 'manual' => __('admin.purchase_history.platforms.manual'), + ]), + Tables\Filters\SelectFilter::make('currency') + ->label(__('admin.purchase_history.filters.currency')) + ->options([ + 'EUR' => 'EUR', + 'USD' => 'USD', + ]), + Tables\Filters\SelectFilter::make('tenant_id') + ->label(__('admin.purchase_history.filters.tenant')) + ->relationship('tenant', 'name') + ->searchable(), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + ExportBulkAction::make() + ->label(__('admin.purchase_history.actions.export')) + ->exporter(PurchaseHistoryExporter::class), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPurchaseHistories::route('/'), + 'view' => Pages\ViewPurchaseHistory::route('/{record}'), + ]; + } + + public static function canCreate(): bool + { + return false; + } + + public static function canEdit($record): bool + { + return false; + } + + public static function canDelete($record): bool + { + return false; + } + + public static function canDeleteAny(): bool + { + return false; + } +} diff --git a/app/Filament/Resources/PurchaseHistoryResource/Pages/ListPurchaseHistories.php b/app/Filament/Resources/PurchaseHistoryResource/Pages/ListPurchaseHistories.php new file mode 100644 index 0000000..b8552f1 --- /dev/null +++ b/app/Filament/Resources/PurchaseHistoryResource/Pages/ListPurchaseHistories.php @@ -0,0 +1,17 @@ +email() ->required() ->maxLength(255), + TextInput::make('event_credits_balance') + ->label(__('admin.tenants.fields.event_credits_balance')) + ->numeric() + ->readOnly(), TextInput::make('total_revenue') ->label(__('admin.tenants.fields.total_revenue')) ->prefix('€') @@ -99,6 +104,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('event_credits_balance') + ->label(__('admin.tenants.fields.event_credits_balance')) + ->badge() + ->color(fn (int $state): string => $state <= 0 ? 'danger' : ($state < 5 ? 'warning' : 'success')), Tables\Columns\TextColumn::make('active_reseller_package_id') ->label(__('admin.tenants.fields.active_package')) ->badge() @@ -159,10 +168,49 @@ class TenantResource extends Resource 'metadata' => ['reason' => $data['reason'] ?? 'manual assignment'], ]); }), + Actions\Action::make('adjust_credits') + ->label(__('admin.tenants.actions.adjust_credits')) + ->icon('heroicon-o-banknotes') + ->authorize(fn (Tenant $record): bool => auth()->user()?->can('adjustCredits', $record) ?? false) + ->form([ + Forms\Components\TextInput::make('delta') + ->label(__('admin.tenants.actions.adjust_credits_delta')) + ->numeric() + ->required() + ->rule('integer') + ->helperText(__('admin.tenants.actions.adjust_credits_delta_hint')), + Forms\Components\Textarea::make('reason') + ->label(__('admin.tenants.actions.adjust_credits_reason')) + ->rows(3) + ->maxLength(500), + ]) + ->action(function (Tenant $record, array $data): void { + $delta = (int) ($data['delta'] ?? 0); + + if ($delta === 0) { + return; + } + + $newBalance = max(0, $record->event_credits_balance + $delta); + + $record->forceFill([ + 'event_credits_balance' => $newBalance, + ])->save(); + + Notification::make() + ->title(__('admin.tenants.actions.adjust_credits_success_title')) + ->body(__('admin.tenants.actions.adjust_credits_success_body', [ + 'delta' => $delta, + 'balance' => $newBalance, + ])) + ->success() + ->send(); + }), Actions\Action::make('suspend') ->label('Suspendieren') ->color('danger') ->requiresConfirmation() + ->authorize(fn (Tenant $record): bool => auth()->user()?->can('suspend', $record) ?? false) ->action(fn (Tenant $record) => $record->update(['is_suspended' => true])), Actions\Action::make('export') ->label('Daten exportieren') diff --git a/app/Filament/Widgets/CreditAlertsWidget.php b/app/Filament/Widgets/CreditAlertsWidget.php new file mode 100644 index 0000000..688f143 --- /dev/null +++ b/app/Filament/Widgets/CreditAlertsWidget.php @@ -0,0 +1,61 @@ +where('is_active', true) + ->where('event_credits_balance', '<', 5) + ->count(); + + $monthStart = now()->startOfMonth(); + $monthlyRevenue = PurchaseHistory::query() + ->where('purchased_at', '>=', $monthStart) + ->sum('price'); + + $activeSubscriptions = Tenant::query() + ->whereNotNull('subscription_expires_at') + ->where('subscription_expires_at', '>', now()) + ->count(); + + return [ + Stat::make( + __('admin.widgets.credit_alerts.low_balance_label'), + $lowBalanceCount + ) + ->description(__('admin.widgets.credit_alerts.low_balance_desc')) + ->descriptionIcon('heroicon-m-exclamation-triangle') + ->color('warning') + ->url(route('filament.superadmin.resources.tenants.index')), + Stat::make( + __('admin.widgets.credit_alerts.monthly_revenue_label'), + number_format((float) $monthlyRevenue, 2).' €' + ) + ->description(__('admin.widgets.credit_alerts.monthly_revenue_desc', [ + 'month' => $monthStart->translatedFormat('F'), + ])) + ->descriptionIcon('heroicon-m-currency-euro') + ->color('success'), + Stat::make( + __('admin.widgets.credit_alerts.active_subscriptions_label'), + $activeSubscriptions + ) + ->description(__('admin.widgets.credit_alerts.active_subscriptions_desc')) + ->descriptionIcon('heroicon-m-arrow-trending-up') + ->color('info'), + ]; + } +} + diff --git a/app/Filament/Widgets/RevenueTrendWidget.php b/app/Filament/Widgets/RevenueTrendWidget.php new file mode 100644 index 0000000..5db69ed --- /dev/null +++ b/app/Filament/Widgets/RevenueTrendWidget.php @@ -0,0 +1,55 @@ +startOfMonth()->subMonths(11); + $months = collect(range(0, 11))->map(fn (int $offset) => $start->copy()->addMonths($offset)); + + $records = PurchaseHistory::query() + ->where('purchased_at', '>=', $start) + ->get(['purchased_at', 'price']); + + $grouped = $records->groupBy(fn (PurchaseHistory $history) => $history->purchased_at?->format('Y-m')); + + $labels = []; + $values = []; + + foreach ($months as $month) { + $key = $month->format('Y-m'); + $labels[] = $month->translatedFormat('M Y'); + $total = $grouped->get($key, collect())->sum(fn (PurchaseHistory $history) => (float) $history->price); + $values[] = round($total, 2); + } + + return [ + 'datasets' => [ + [ + 'label' => __('admin.widgets.revenue_trend.series'), + 'data' => $values, + 'borderColor' => '#ec4899', + 'backgroundColor' => 'rgba(236, 72, 153, 0.2)', + 'tension' => 0.4, + 'fill' => 'origin', + ], + ], + 'labels' => $labels, + ]; + } +} diff --git a/app/Filament/Widgets/TopTenantsByRevenue.php b/app/Filament/Widgets/TopTenantsByRevenue.php new file mode 100644 index 0000000..6221bd1 --- /dev/null +++ b/app/Filament/Widgets/TopTenantsByRevenue.php @@ -0,0 +1,53 @@ +query( + Tenant::query() + ->withSum('purchases', 'price') + ->withCount('purchases') + ->orderByDesc('purchases_sum_price') + ->limit(10) + ) + ->columns([ + Tables\Columns\TextColumn::make('name') + ->label(__('admin.common.tenant')) + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('purchases_sum_price') + ->label(__('admin.widgets.top_tenants_by_revenue.total')) + ->money('EUR') + ->sortable(), + Tables\Columns\TextColumn::make('purchases_count') + ->label(__('admin.widgets.top_tenants_by_revenue.count')) + ->badge() + ->sortable(), + Tables\Columns\TextColumn::make('event_credits_balance') + ->label(__('admin.common.credits')) + ->badge() + ->sortable(), + ]) + ->paginated(false); + } +} + diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 1c7bbf4..d3f62bb 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -17,6 +17,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpKernel\Exception\HttpException; class EventController extends Controller { @@ -124,9 +125,8 @@ class EventController extends Controller $event = DB::transaction(function () use ($tenant, $eventData, $packageId) { $event = Event::create($eventData); - // Create EventPackage and PackagePurchase for Free package $package = \App\Models\Package::findOrFail($packageId); - $eventPackage = \App\Models\EventPackage::create([ + \App\Models\EventPackage::create([ 'event_id' => $event->id, 'package_id' => $packageId, 'price' => $package->price, @@ -143,8 +143,9 @@ class EventController extends Controller 'metadata' => json_encode(['note' => 'Free package assigned on event creation']), ]); - if ($tenant->activeResellerPackage) { - $tenant->incrementUsedEvents(); + $note = sprintf('Event #%d created (%s)', $event->id, $event->name); + if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) { + throw new HttpException(402, 'Insufficient credits or package allowance.'); } return $event; diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 9fca552..49df4e9 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -22,23 +22,35 @@ use Stripe\PaymentIntent; use Stripe\Stripe; use App\Http\Controllers\PayPalController; +use App\Support\Concerns\PresentsPackages; class CheckoutController extends Controller { + use PresentsPackages; + public function show(Package $package) { - // Alle verfügbaren Pakete laden - $packages = Package::all(); + $googleStatus = session()->pull('checkout_google_status'); + $googleError = session()->pull('checkout_google_error'); + + $packageOptions = Package::orderBy('price')->get() + ->map(fn (Package $pkg) => $this->presentPackage($pkg)) + ->values() + ->all(); return Inertia::render('marketing/CheckoutWizardPage', [ - 'package' => $package, - 'packageOptions' => $packages, + '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(), ], + 'googleAuth' => [ + 'status' => $googleStatus, + 'error' => $googleError, + ], ]); } @@ -97,11 +109,14 @@ class CheckoutController extends Controller 'event_default_type' => 'general', ]), ]); + + $user->forceFill(['tenant_id' => $tenant->id])->save(); // Package zuweisen $tenant->packages()->attach($package->id, [ + 'price' => $package->price, 'purchased_at' => now(), - 'expires_at' => $package->is_free ? null : now()->addYear(), - 'is_active' => $package->is_free, // Kostenlose Pakete sofort aktivieren + 'expires_at' => $this->packageIsFree($package) ? now()->addYear() : now()->addYear(), + 'active' => $this->packageIsFree($package), // Kostenlose Pakete sofort aktivieren ]); // E-Mail-Verifizierung senden @@ -241,7 +256,9 @@ class CheckoutController extends Controller 'user_id' => Auth::id(), ]); - if ($package->is_free) { + $isFreePackage = $this->packageIsFree($package); + + if ($isFreePackage) { \Log::info('Free package detected, returning null client_secret'); return response()->json([ 'client_secret' => null, @@ -305,9 +322,10 @@ class CheckoutController extends Controller // Package dem Tenant zuweisen $user->tenant->packages()->attach($package->id, [ + 'price' => $package->price, 'purchased_at' => now(), 'expires_at' => now()->addYear(), - 'is_active' => true, + 'active' => true, ]); // pending_purchase zurücksetzen @@ -362,9 +380,10 @@ class CheckoutController extends Controller // TenantPackage zuweisen (ähnlich Stripe) $user->tenant->packages()->attach($package->id, [ + 'price' => $package->price, 'purchased_at' => now(), 'expires_at' => now()->addYear(), - 'is_active' => true, + 'active' => true, ]); // pending_purchase zurücksetzen @@ -379,4 +398,15 @@ class CheckoutController extends Controller return redirect('/checkout')->with('error', 'Fehler beim Abschließen der Zahlung: ' . $e->getMessage()); } } + + private function packageIsFree(Package $package): bool + { + if (isset($package->is_free)) { + return (bool) $package->is_free; + } + + $price = (float) $package->price; + + return $price <= 0; + } } diff --git a/app/Http/Controllers/CheckoutGoogleController.php b/app/Http/Controllers/CheckoutGoogleController.php new file mode 100644 index 0000000..43336c4 --- /dev/null +++ b/app/Http/Controllers/CheckoutGoogleController.php @@ -0,0 +1,211 @@ +validate([ + 'package_id' => ['required', 'exists:packages,id'], + 'locale' => ['nullable', 'string'], + ]); + + $payload = [ + 'package_id' => (int) $validated['package_id'], + 'locale' => $validated['locale'] ?? app()->getLocale(), + ]; + + $request->session()->put(self::SESSION_KEY, $payload); + $request->session()->put('selected_package_id', $payload['package_id']); + + return Socialite::driver('google') + ->scopes(['email', 'profile']) + ->with(['prompt' => 'select_account']) + ->redirect(); + } + + public function callback(Request $request): RedirectResponse + { + $payload = $request->session()->get(self::SESSION_KEY, []); + $packageId = $payload['package_id'] ?? null; + + try { + $googleUser = Socialite::driver('google')->user(); + } 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(); + + if ($existing) { + $existing->forceFill([ + 'name' => $googleUser->getName() ?: $existing->name, + 'pending_purchase' => true, + 'email_verified_at' => $existing->email_verified_at ?? now(), + ])->save(); + + if (! $existing->tenant) { + $this->createTenantForUser($existing, $googleUser->getName(), $email); + } + + return $existing->fresh(); + } + + $user = User::create([ + 'name' => $googleUser->getName(), + 'email' => $email, + 'password' => Hash::make(Str::random(32)), + 'pending_purchase' => true, + 'email_verified_at' => now(), + ]); + + event(new Registered($user)); + + $tenant = $this->createTenantForUser($user, $googleUser->getName(), $email); + + try { + Mail::to($user)->queue(new Welcome($user)); + } catch (\Throwable $exception) { + Log::warning('Failed to queue welcome mail after Google signup', [ + 'user_id' => $user->id, + 'error' => $exception->getMessage(), + ]); + } + + return tap($user)->setRelation('tenant', $tenant); + }); + + if (! $user->tenant) { + $this->createTenantForUser($user, $googleUser->getName(), $email); + } + + Auth::login($user, true); + $request->session()->regenerate(); + $request->session()->forget(self::SESSION_KEY); + $request->session()->put('checkout_google_status', 'success'); + + if ($packageId) { + $this->ensurePackageAttached($user, (int) $packageId); + } + + return $this->redirectBackToWizard($packageId); + } + + private function createTenantForUser(User $user, ?string $displayName, string $email): Tenant + { + $tenantName = trim($displayName ?: Str::before($email, '@')) ?: 'Fotospiel Tenant'; + $slugBase = Str::slug($tenantName) ?: 'tenant'; + $slug = $slugBase; + $counter = 1; + + while (Tenant::where('slug', $slug)->exists()) { + $slug = $slugBase . '-' . $counter; + $counter++; + } + + $tenant = Tenant::create([ + 'user_id' => $user->id, + 'name' => $tenantName, + 'slug' => $slug, + 'email' => $email, + 'contact_email' => $email, + 'is_active' => true, + 'is_suspended' => false, + 'event_credits_balance' => 0, + 'subscription_tier' => 'free', + 'subscription_status' => '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' => $email, + 'event_default_type' => 'general', + ]), + ]); + + $user->forceFill(['tenant_id' => $tenant->id])->save(); + + return $tenant; + } + + private function ensurePackageAttached(User $user, int $packageId): void + { + $tenant = $user->tenant; + if (! $tenant) { + return; + } + + $package = Package::find($packageId); + if (! $package) { + return; + } + + if ($tenant->packages()->where('package_id', $packageId)->exists()) { + return; + } + + $tenant->packages()->attach($packageId, [ + 'price' => $package->price, + 'purchased_at' => now(), + 'expires_at' => now()->addYear(), + 'active' => $package->price <= 0, + ]); + } + + private function redirectBackToWizard(?int $packageId): RedirectResponse + { + if ($packageId) { + return redirect()->route('purchase.wizard', ['package' => $packageId]); + } + + $firstPackageId = Package::query()->orderBy('price')->value('id'); + if ($firstPackageId) { + return redirect()->route('purchase.wizard', ['package' => $firstPackageId]); + } + + return redirect()->route('packages'); + } + + private function flashError(Request $request, string $message): void + { + $request->session()->flash('checkout_google_error', $message); + } +} diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index d847817..b1efe18 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -29,9 +29,12 @@ use League\CommonMark\Extension\Autolink\AutolinkExtension; use League\CommonMark\Extension\Strikethrough\StrikethroughExtension; 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')); @@ -39,9 +42,12 @@ class MarketingController extends Controller public function index() { - $packages = Package::where('type', 'endcustomer')->orderBy('price')->get()->map(function ($p) { - return $p->append(['features', 'limits']); - }); + $packages = Package::where('type', 'endcustomer') + ->orderBy('price') + ->get() + ->map(fn (Package $package) => $this->presentPackage($package)) + ->values() + ->all(); return Inertia::render('marketing/Home', compact('packages')); } @@ -484,13 +490,15 @@ class MarketingController extends Controller ->orderBy('price') ->get() ->map(fn (Package $package) => $this->presentPackage($package)) - ->values(); + ->values() + ->all(); $resellerPackages = Package::where('type', 'reseller') ->orderBy('price') ->get() ->map(fn (Package $package) => $this->presentPackage($package)) - ->values(); + ->values() + ->all(); return Inertia::render('marketing/Packages', [ 'endcustomerPackages' => $endcustomerPackages, @@ -516,170 +524,4 @@ class MarketingController extends Controller return Inertia::render('marketing/Occasions', ['type' => $type]); } - - private function presentPackage(Package $package): array - { - $package->append('limits'); - - $packageArray = $package->toArray(); - $features = $packageArray['features'] ?? []; - $features = $this->normaliseFeatures($features); - - $locale = app()->getLocale(); - $name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale); - $descriptionTemplate = $this->resolveTranslation($package->description_translations ?? null, $package->description ?? '', $locale); - - $replacements = $this->buildPlaceholderReplacements($package); - - $description = trim($this->applyPlaceholders($descriptionTemplate, $replacements)); - - $table = $package->description_table ?? []; - if (is_string($table)) { - $decoded = json_decode($table, true); - $table = is_array($decoded) ? $decoded : []; - } - - $table = array_map(function (array $row) use ($replacements) { - return [ - 'title' => trim($this->applyPlaceholders($row['title'] ?? '', $replacements)), - 'value' => trim($this->applyPlaceholders($row['value'] ?? '', $replacements)), - ]; - }, $table); - $table = array_values($table); - - $galleryDuration = $replacements['{{gallery_duration}}'] ?? null; - - return [ - 'id' => $package->id, - 'name' => $name, - 'slug' => $package->slug, - 'type' => $package->type, - 'price' => $package->price, - 'description' => $description, - 'description_breakdown' => $table, - 'gallery_duration_label' => $galleryDuration, - 'events' => $package->type === 'endcustomer' ? 1 : ($package->max_events_per_year ?? null), - 'features' => $features, - 'limits' => $package->limits, - 'max_photos' => $package->max_photos, - 'max_guests' => $package->max_guests, - 'max_tasks' => $package->max_tasks, - 'gallery_days' => $package->gallery_days, - 'max_events_per_year' => $package->max_events_per_year, - 'watermark_allowed' => (bool) $package->watermark_allowed, - 'branding_allowed' => (bool) $package->branding_allowed, - ]; - } - - private function buildPlaceholderReplacements(Package $package): array - { - $locale = app()->getLocale(); - - return [ - '{{max_photos}}' => $this->formatCount($package->max_photos, [ - 'de' => 'unbegrenzt viele', - 'en' => 'unlimited', - ]), - '{{max_guests}}' => $this->formatCount($package->max_guests, [ - 'de' => 'beliebig viele', - 'en' => 'any number of', - ]), - '{{max_tasks}}' => $this->formatCount($package->max_tasks, [ - 'de' => 'individuelle', - 'en' => 'custom', - ]), - '{{max_events_per_year}}' => $this->formatCount($package->max_events_per_year, [ - 'de' => 'unbegrenzte', - 'en' => 'unlimited', - ]), - '{{gallery_duration}}' => $this->formatGalleryDuration($package->gallery_days), - ]; - } - - private function applyPlaceholders(string $template, array $replacements): string - { - if ($template === '') { - return $template; - } - - return str_replace(array_keys($replacements), array_values($replacements), $template); - } - - private function formatCount(?int $value, array $fallbackByLocale): string - { - $locale = app()->getLocale(); - - if ($value === null) { - return $fallbackByLocale[$locale] ?? reset($fallbackByLocale) ?? ''; - } - - $decimal = $locale === 'de' ? ',' : '.'; - $thousands = $locale === 'de' ? '.' : ','; - - return number_format($value, 0, $decimal, $thousands); - } - - private function formatGalleryDuration(?int $days): string - { - $locale = app()->getLocale(); - - 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 Monat' : $months . ' Monate'; - } - - return $locale === 'en' ? $days . ' days' : $days . ' Tage'; - } - - private function normaliseFeatures(mixed $features): array - { - if (is_string($features)) { - $decoded = json_decode($features, true); - if (json_last_error() === JSON_ERROR_NONE) { - $features = $decoded; - } - } - - if (! is_array($features)) { - return []; - } - - $list = []; - foreach ($features as $key => $value) { - if (is_string($value)) { - $list[] = $value; - continue; - } - - if (is_string($key) && (bool) $value) { - $list[] = $key; - } - } - - return array_values(array_unique(array_filter($list, fn ($item) => is_string($item) && $item !== ''))); - } - - private function resolveTranslation(mixed $value, string $fallback, string $locale): string - { - if (is_string($value)) { - $decoded = json_decode($value, true); - if (json_last_error() === JSON_ERROR_NONE) { - $value = $decoded; - } - } - - if (is_array($value)) { - return trim((string) ($value[$locale] ?? $value['en'] ?? $value['de'] ?? $fallback)); - } - - return trim((string) ($value ?? $fallback)); - } } diff --git a/app/Http/Controllers/OAuthController.php b/app/Http/Controllers/OAuthController.php index 2d35c7e..c440597 100644 --- a/app/Http/Controllers/OAuthController.php +++ b/app/Http/Controllers/OAuthController.php @@ -10,6 +10,7 @@ use App\Models\TenantToken; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; @@ -22,7 +23,7 @@ class OAuthController extends Controller private const AUTH_CODE_TTL_MINUTES = 5; private const ACCESS_TOKEN_TTL_SECONDS = 3600; private const REFRESH_TOKEN_TTL_DAYS = 30; - private const TOKEN_HEADER_KID = 'fotospiel-jwt'; + private const LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt'; /** * Authorize endpoint - PKCE flow @@ -286,9 +287,16 @@ class OAuthController extends Controller $storedIp = (string) ($storedRefreshToken->ip_address ?? ''); $currentIp = (string) ($request->ip() ?? ''); - if ($storedIp !== '' && $currentIp !== '' && ! hash_equals($storedIp, $currentIp)) { + if (config('oauth.refresh_tokens.enforce_ip_binding', true) && ! $this->ipMatches($storedIp, $currentIp)) { $storedRefreshToken->update(['revoked_at' => now()]); + Log::warning('[OAuth] Refresh token rejected due to IP mismatch', [ + 'client_id' => $request->client_id, + 'refresh_token_id' => $storedRefreshToken->id, + 'stored_ip' => $storedIp, + 'current_ip' => $currentIp, + ]); + return $this->errorResponse('Refresh token cannot be used from this IP address', 403); } @@ -387,7 +395,7 @@ class OAuthController extends Controller int $issuedAt, int $expiresAt ): string { - [$publicKey, $privateKey] = $this->ensureKeysExist(); + [$kid, , $privateKey] = $this->getSigningKeyPair(); $payload = [ 'iss' => url('/'), @@ -403,47 +411,94 @@ class OAuthController extends Controller 'jti' => $jti, ]; - return JWT::encode($payload, $privateKey, 'RS256', self::TOKEN_HEADER_KID, ['kid' => self::TOKEN_HEADER_KID]); + return JWT::encode($payload, $privateKey, 'RS256', $kid, ['kid' => $kid]); } - private function ensureKeysExist(): array + private function getSigningKeyPair(): array { - $publicKeyPath = storage_path('app/public.key'); - $privateKeyPath = storage_path('app/private.key'); + $kid = $this->currentKid(); + [$publicKey, $privateKey] = $this->ensureKeysForKid($kid); - $publicKey = @file_get_contents($publicKeyPath); - $privateKey = @file_get_contents($privateKeyPath); + return [$kid, $publicKey, $privateKey]; + } - if ($publicKey && $privateKey) { - return [$publicKey, $privateKey]; + private function currentKid(): string + { + return config('oauth.keys.current_kid', self::LEGACY_TOKEN_HEADER_KID); + } + + private function ensureKeysForKid(string $kid): array + { + $paths = $this->keyPaths($kid); + + if (! File::exists($paths['directory'])) { + File::makeDirectory($paths['directory'], 0700, true); } - $this->generateKeyPair(); + $this->maybeMigrateLegacyKeys($paths); + + if (! File::exists($paths['public']) || ! File::exists($paths['private'])) { + $this->generateKeyPair($paths['directory']); + } return [ - file_get_contents($publicKeyPath), - file_get_contents($privateKeyPath), + File::get($paths['public']), + File::get($paths['private']), ]; } - private function generateKeyPair(): void + private function keyPaths(string $kid): array + { + $base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR); + $directory = $base.DIRECTORY_SEPARATOR.$kid; + + return [ + 'directory' => $directory, + 'public' => $directory.DIRECTORY_SEPARATOR.'public.key', + 'private' => $directory.DIRECTORY_SEPARATOR.'private.key', + ]; + } + + private function maybeMigrateLegacyKeys(array $paths): void + { + $legacyPublic = storage_path('app/public.key'); + $legacyPrivate = storage_path('app/private.key'); + + if (! File::exists($paths['public']) && File::exists($legacyPublic)) { + File::copy($legacyPublic, $paths['public']); + } + + if (! File::exists($paths['private']) && File::exists($legacyPrivate)) { + File::copy($legacyPrivate, $paths['private']); + } + } + + private function generateKeyPair(string $directory): void { $config = [ 'digest_alg' => OPENSSL_ALGO_SHA256, - 'private_key_bits' => 2048, + 'private_key_bits' => 4096, 'private_key_type' => OPENSSL_KEYTYPE_RSA, ]; - $res = openssl_pkey_new($config); - if (! $res) { + $resource = openssl_pkey_new($config); + if (! $resource) { throw new \RuntimeException('Failed to generate key pair'); } - openssl_pkey_export($res, $privKey); - $pubKey = openssl_pkey_get_details($res); + openssl_pkey_export($resource, $privateKey); + $details = openssl_pkey_get_details($resource); + $publicKey = $details['key'] ?? null; - file_put_contents(storage_path('app/private.key'), $privKey); - file_put_contents(storage_path('app/public.key'), $pubKey['key']); + if (! $publicKey) { + throw new \RuntimeException('Failed to extract public key'); + } + + File::put($directory.DIRECTORY_SEPARATOR.'private.key', $privateKey, true); + File::chmod($directory.DIRECTORY_SEPARATOR.'private.key', 0600); + + File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true); + File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644); } private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool { @@ -480,6 +535,32 @@ class OAuthController extends Controller return response()->json($response, $status); } + private function ipMatches(string $storedIp, string $currentIp): bool + { + if ($storedIp === '' || $currentIp === '') { + return true; + } + + if (hash_equals($storedIp, $currentIp)) { + return true; + } + + if (! config('oauth.refresh_tokens.allow_subnet_match', false)) { + return false; + } + + if (filter_var($storedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($currentIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $storedParts = explode('.', $storedIp); + $currentParts = explode('.', $currentIp); + + return $storedParts[0] === $currentParts[0] + && $storedParts[1] === $currentParts[1] + && $storedParts[2] === $currentParts[2]; + } + + return false; + } + private function base64urlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); diff --git a/app/Http/Controllers/PayPalController.php b/app/Http/Controllers/PayPalController.php index 7c8779a..f655f2d 100644 --- a/app/Http/Controllers/PayPalController.php +++ b/app/Http/Controllers/PayPalController.php @@ -15,6 +15,7 @@ use PaypalServerSdkLib\Models\Builders\AmountWithBreakdownBuilder; use PaypalServerSdkLib\Models\Builders\OrderApplicationContextBuilder; use PaypalServerSdkLib\Models\CheckoutPaymentIntent; use App\Services\PayPal\PaypalClientFactory; +use Illuminate\Support\Facades\Auth; class PayPalController extends Controller { @@ -30,11 +31,18 @@ class PayPalController extends Controller public function createOrder(Request $request) { $request->validate([ - 'tenant_id' => 'required|exists:tenants,id', 'package_id' => 'required|exists:packages,id', + 'tenant_id' => 'nullable|exists:tenants,id', ]); - $tenant = Tenant::findOrFail($request->tenant_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(); @@ -156,12 +164,18 @@ class PayPalController extends Controller public function createSubscription(Request $request) { $request->validate([ - 'tenant_id' => 'required|exists:tenants,id', 'package_id' => 'required|exists:packages,id', - 'plan_id' => 'required', // PayPal plan ID for the package + 'plan_id' => 'required|string', + 'tenant_id' => 'nullable|exists:tenants,id', ]); - $tenant = Tenant::findOrFail($request->tenant_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(); diff --git a/app/Http/Controllers/PayPalWebhookController.php b/app/Http/Controllers/PayPalWebhookController.php index fd3d981..7334ac1 100644 --- a/app/Http/Controllers/PayPalWebhookController.php +++ b/app/Http/Controllers/PayPalWebhookController.php @@ -154,7 +154,7 @@ class PayPalWebhookController extends Controller if ($tenantId) { $tenant = Tenant::find($tenantId); if ($tenant) { - $tenant->update(['subscription_status' => 'cancelled']); + $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]); diff --git a/app/Http/Middleware/PackageMiddleware.php b/app/Http/Middleware/PackageMiddleware.php index 0be6f3e..9dc424a 100644 --- a/app/Http/Middleware/PackageMiddleware.php +++ b/app/Http/Middleware/PackageMiddleware.php @@ -23,7 +23,7 @@ class PackageMiddleware ]); } - if ($this->requiresPackageCheck($request) && !$this->canPerformAction($request, $tenant)) { + if ($this->requiresPackageCheck($request) && ! $this->canPerformAction($request, $tenant)) { return response()->json([ 'error' => 'Package limits exceeded. Please purchase or upgrade a package.', ], 402); @@ -36,35 +36,30 @@ class PackageMiddleware { return $request->isMethod('post') && ( $request->routeIs('api.v1.tenant.events.store') || - $request->routeIs('api.v1.tenant.photos.store') // Assuming photo upload route + $request->routeIs('api.v1.tenant.events.photos.store') ); } private function canPerformAction(Request $request, Tenant $tenant): bool { if ($request->routeIs('api.v1.tenant.events.store')) { - // Check tenant package for event creation - $resellerPackage = $tenant->activeResellerPackage(); - if ($resellerPackage) { - return $resellerPackage->used_events < $resellerPackage->package->max_events_per_year; - } - return false; + return $tenant->hasEventAllowance(); } - if ($request->routeIs('api.v1.tenant.photos.store')) { + if ($request->routeIs('api.v1.tenant.events.photos.store')) { $eventId = $request->input('event_id'); - if (!$eventId) { + if (! $eventId) { return false; } - $event = Event::findOrFail($eventId); - if ($event->tenant_id !== $tenant->id) { + $event = Event::query()->find($eventId); + if (! $event || $event->tenant_id !== $tenant->id) { return false; } $eventPackage = $event->eventPackage; - if (!$eventPackage) { + if (! $eventPackage) { return false; } - return $eventPackage->used_photos < $eventPackage->package->max_photos; + return $eventPackage->used_photos < ($eventPackage->package->max_photos ?? PHP_INT_MAX); } return true; @@ -88,4 +83,4 @@ class PackageMiddleware return Tenant::findOrFail($tenantId); } -} \ No newline at end of file +} diff --git a/app/Http/Middleware/TenantTokenGuard.php b/app/Http/Middleware/TenantTokenGuard.php index aad7840..5e409e4 100644 --- a/app/Http/Middleware/TenantTokenGuard.php +++ b/app/Http/Middleware/TenantTokenGuard.php @@ -7,6 +7,7 @@ use App\Models\TenantToken; use Closure; use Firebase\JWT\JWT; use Firebase\JWT\Key; +use Illuminate\Support\Facades\File; use Illuminate\Auth\GenericUser; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -15,6 +16,8 @@ use Illuminate\Support\Str; class TenantTokenGuard { + private const LEGACY_KID = 'fotospiel-jwt'; + /** * Handle an incoming request. */ @@ -104,7 +107,9 @@ class TenantTokenGuard */ private function decodeToken(string $token): array { - $publicKey = file_get_contents(storage_path('app/public.key')); + $kid = $this->extractKid($token); + $publicKey = $this->loadPublicKeyForKid($kid); + if (! $publicKey) { throw new \Exception('JWT public key not found'); } @@ -114,6 +119,35 @@ class TenantTokenGuard return (array) $decoded; } + private function extractKid(string $token): ?string + { + $segments = explode('.', $token); + if (count($segments) < 2) { + return null; + } + + $decodedHeader = json_decode(base64_decode($segments[0]), true); + return is_array($decodedHeader) ? ($decodedHeader['kid'] ?? null) : null; + } + + private function loadPublicKeyForKid(?string $kid): ?string + { + $resolvedKid = $kid ?? config('oauth.keys.current_kid', self::LEGACY_KID); + $base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR); + $path = $base.DIRECTORY_SEPARATOR.$resolvedKid.DIRECTORY_SEPARATOR.'public.key'; + + if (File::exists($path)) { + return File::get($path); + } + + $legacyPath = storage_path('app/public.key'); + if (File::exists($legacyPath)) { + return File::get($legacyPath); + } + + return null; + } + /** * Check if token is blacklisted */ diff --git a/app/Jobs/ProcessRevenueCatWebhook.php b/app/Jobs/ProcessRevenueCatWebhook.php index 08c2952..4f80f47 100644 --- a/app/Jobs/ProcessRevenueCatWebhook.php +++ b/app/Jobs/ProcessRevenueCatWebhook.php @@ -25,6 +25,11 @@ class ProcessRevenueCatWebhook implements ShouldQueue private ?string $eventId; + public int $tries = 5; + + public int $backoff = 60; + + /** * @param array $payload */ @@ -32,6 +37,8 @@ class ProcessRevenueCatWebhook implements ShouldQueue { $this->payload = $payload; $this->eventId = $eventId !== '' ? $eventId : null; + $this->queue = config('services.revenuecat.queue', 'webhooks'); + $this->onQueue($this->queue); } public function handle(): void diff --git a/app/Models/OAuthClient.php b/app/Models/OAuthClient.php index 3768c30..e3748d7 100644 --- a/app/Models/OAuthClient.php +++ b/app/Models/OAuthClient.php @@ -3,13 +3,14 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class OAuthClient extends Model { protected $table = 'oauth_clients'; - + protected $guarded = []; - + protected $fillable = [ 'id', 'client_id', @@ -19,14 +20,20 @@ class OAuthClient extends Model 'scopes', 'is_active', ]; - + protected $casts = [ 'id' => 'string', 'tenant_id' => 'integer', - 'scopes' => 'array', 'redirect_uris' => 'array', + 'scopes' => 'array', 'is_active' => 'bool', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } } + diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index e4d943c..8f7e70c 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -8,8 +8,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; +use App\Models\TenantPackage; use App\Models\EventCreditsLedger; class Tenant extends Model @@ -55,6 +58,13 @@ class Tenant extends Model return $this->hasMany(TenantPackage::class); } + public function packages(): BelongsToMany + { + return $this->belongsToMany(Package::class, 'tenant_packages') + ->withPivot(['price', 'purchased_at', 'expires_at', 'active']) + ->withTimestamps(); + } + public function activeResellerPackage(): HasOne { return $this->hasOne(TenantPackage::class)->where('active', true); @@ -62,18 +72,13 @@ class Tenant extends Model public function canCreateEvent(): bool { - $package = $this->activeResellerPackage()->first(); - if (!$package) { - return false; - } - - return $package->canCreateEvent(); + return $this->hasEventAllowance(); } public function incrementUsedEvents(int $amount = 1): bool { - $package = $this->activeResellerPackage()->first(); - if (!$package) { + $package = $this->getActiveResellerPackage(); + if (! $package) { return false; } @@ -108,6 +113,13 @@ class Tenant extends Model 'note' => $note, ]); + Log::info('Tenant credits incremented', [ + 'tenant_id' => $this->id, + 'delta' => $amount, + 'reason' => $reason, + 'purchase_id' => $purchaseId, + ]); + return true; } @@ -130,9 +142,54 @@ class Tenant extends Model 'note' => $note, ]); + Log::info('Tenant credits decremented', [ + 'tenant_id' => $this->id, + 'delta' => -$amount, + 'reason' => $reason, + 'purchase_id' => $purchaseId, + ]); + return true; } + public function hasEventAllowance(): bool + { + $package = $this->getActiveResellerPackage(); + if ($package && $package->canCreateEvent()) { + return true; + } + + return (int) ($this->event_credits_balance ?? 0) > 0; + } + + public function consumeEventAllowance(int $amount = 1, string $reason = 'event.create', ?string $note = null): bool + { + $package = $this->getActiveResellerPackage(); + if ($package && $package->canCreateEvent()) { + $package->increment('used_events', $amount); + + Log::info('Tenant package usage recorded', [ + 'tenant_id' => $this->id, + 'tenant_package_id' => $package->id, + 'used_events' => $package->used_events, + 'amount' => $amount, + ]); + + return true; + } + + return $this->decrementCredits($amount, $reason, $note); + } + + public function getActiveResellerPackage(): ?TenantPackage + { + return $this->activeResellerPackage() + ->whereHas('package', fn ($query) => $query->where('type', 'reseller')) + ->where('active', true) + ->orderByDesc('expires_at') + ->first(); + } + public function activeSubscription(): Attribute { return Attribute::make( diff --git a/app/Policies/OAuthClientPolicy.php b/app/Policies/OAuthClientPolicy.php new file mode 100644 index 0000000..4580dcc --- /dev/null +++ b/app/Policies/OAuthClientPolicy.php @@ -0,0 +1,38 @@ +role === 'super_admin'; + } + + public function view(User $user, OAuthClient $oauthClient): bool + { + return $user->role === 'super_admin'; + } + + public function create(User $user): bool + { + return $user->role === 'super_admin'; + } + + public function update(User $user, OAuthClient $oauthClient): bool + { + return $user->role === 'super_admin'; + } + + public function delete(User $user, OAuthClient $oauthClient): bool + { + return $user->role === 'super_admin'; + } +} + diff --git a/app/Policies/PurchaseHistoryPolicy.php b/app/Policies/PurchaseHistoryPolicy.php new file mode 100644 index 0000000..60cd8f9 --- /dev/null +++ b/app/Policies/PurchaseHistoryPolicy.php @@ -0,0 +1,23 @@ +role === 'super_admin'; + } + + public function view(User $user, PurchaseHistory $purchaseHistory): bool + { + return $user->role === 'super_admin'; + } +} + diff --git a/app/Policies/TenantPolicy.php b/app/Policies/TenantPolicy.php new file mode 100644 index 0000000..58ccdc5 --- /dev/null +++ b/app/Policies/TenantPolicy.php @@ -0,0 +1,73 @@ +role === 'super_admin'; + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Tenant $tenant): bool + { + if ($user->role === 'tenant_admin') { + return (int) $user->tenant_id === (int) $tenant->getKey(); + } + + return false; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->role === 'super_admin'; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Tenant $tenant): bool + { + return $user->role === 'super_admin'; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Tenant $tenant): bool + { + return $user->role === 'super_admin'; + } + + /** + * Custom ability for adjusting credits. + */ + public function adjustCredits(User $user, Tenant $tenant): bool + { + return $user->role === 'super_admin'; + } + + /** + * Custom ability for suspending a tenant. + */ + public function suspend(User $user, Tenant $tenant): bool + { + return $user->role === 'super_admin'; + } +} + diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index fa1d4d3..8b34c22 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -53,6 +53,25 @@ class AppServiceProvider extends ServiceProvider }); Inertia::share('locale', fn () => app()->getLocale()); + Inertia::share('analytics', static function () { + $config = config('services.matomo'); + + if (!($config['enabled'] ?? false)) { + return [ + 'matomo' => [ + 'enabled' => false, + ], + ]; + } + + return [ + 'matomo' => [ + 'enabled' => true, + 'url' => rtrim((string) ($config['url'] ?? ''), '/'), + 'siteId' => (string) ($config['site_id'] ?? ''), + ], + ]; + }); if (config('storage-monitor.queue_failure_alerts')) { Queue::failing(function (JobFailed $event) { diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php new file mode 100644 index 0000000..bd02661 --- /dev/null +++ b/app/Providers/AuthServiceProvider.php @@ -0,0 +1,40 @@ + + */ + protected $policies = [ + Tenant::class => TenantPolicy::class, + PurchaseHistory::class => PurchaseHistoryPolicy::class, + OAuthClient::class => OAuthClientPolicy::class, + ]; + + /** + * Register any authentication / authorization services. + */ + public function boot(): void + { + $this->registerPolicies(); + + Gate::before(function (User $user): ?bool { + return $user->role === 'super_admin' ? true : null; + }); + } +} + diff --git a/app/Providers/Filament/SuperAdminPanelProvider.php b/app/Providers/Filament/SuperAdminPanelProvider.php index e3fae5d..500d6c8 100644 --- a/app/Providers/Filament/SuperAdminPanelProvider.php +++ b/app/Providers/Filament/SuperAdminPanelProvider.php @@ -18,8 +18,11 @@ use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\AuthenticateSession; use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; +use App\Filament\Widgets\CreditAlertsWidget; use App\Filament\Widgets\PlatformStatsWidget; +use App\Filament\Widgets\RevenueTrendWidget; use App\Filament\Widgets\TopTenantsByUploads; +use App\Filament\Widgets\TopTenantsByRevenue; use App\Filament\Blog\Resources\PostResource; use App\Filament\Blog\Resources\CategoryResource; use App\Filament\Blog\Resources\AuthorResource; @@ -50,7 +53,10 @@ class SuperAdminPanelProvider extends PanelProvider ->widgets([ Widgets\AccountWidget::class, Widgets\FilamentInfoWidget::class, + CreditAlertsWidget::class, + RevenueTrendWidget::class, PlatformStatsWidget::class, + TopTenantsByRevenue::class, TopTenantsByUploads::class, \App\Filament\Widgets\StorageCapacityWidget::class, ]) diff --git a/app/Support/Concerns/PresentsPackages.php b/app/Support/Concerns/PresentsPackages.php new file mode 100644 index 0000000..729c328 --- /dev/null +++ b/app/Support/Concerns/PresentsPackages.php @@ -0,0 +1,173 @@ +append('limits'); + + $packageArray = $package->toArray(); + $features = $packageArray['features'] ?? []; + $features = $this->normaliseFeatures($features); + + $locale = app()->getLocale(); + $name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale); + $descriptionTemplate = $this->resolveTranslation($package->description_translations ?? null, $package->description ?? '', $locale); + + $replacements = $this->buildPlaceholderReplacements($package); + $description = trim($this->applyPlaceholders($descriptionTemplate, $replacements)); + + $table = $package->description_table ?? []; + if (is_string($table)) { + $decoded = json_decode($table, true); + $table = is_array($decoded) ? $decoded : []; + } + + $table = array_map(function (array $row) use ($replacements) { + return [ + 'title' => trim($this->applyPlaceholders($row['title'] ?? '', $replacements)), + 'value' => trim($this->applyPlaceholders($row['value'] ?? '', $replacements)), + ]; + }, $table); + $table = array_values($table); + + $galleryDuration = $replacements['{{gallery_duration}}'] ?? null; + + return [ + 'id' => $package->id, + 'name' => $name, + 'slug' => $package->slug, + 'type' => $package->type, + 'price' => $package->price, + 'description' => $description, + 'description_breakdown' => $table, + 'gallery_duration_label' => $galleryDuration, + 'events' => $package->type === 'endcustomer' ? 1 : ($package->max_events_per_year ?? null), + 'features' => $features, + 'limits' => $package->limits, + 'max_photos' => $package->max_photos, + 'max_guests' => $package->max_guests, + 'max_tasks' => $package->max_tasks, + 'gallery_days' => $package->gallery_days, + 'max_events_per_year' => $package->max_events_per_year, + 'watermark_allowed' => (bool) $package->watermark_allowed, + 'branding_allowed' => (bool) $package->branding_allowed, + ]; + } + + protected function buildPlaceholderReplacements(Package $package): array + { + $locale = app()->getLocale(); + + return [ + '{{max_photos}}' => $this->formatCount($package->max_photos, [ + 'de' => 'unbegrenzt viele', + 'en' => 'unlimited', + ]), + '{{max_guests}}' => $this->formatCount($package->max_guests, [ + 'de' => 'beliebig viele', + 'en' => 'any number of', + ]), + '{{max_tasks}}' => $this->formatCount($package->max_tasks, [ + 'de' => 'individuelle', + 'en' => 'custom', + ]), + '{{max_events_per_year}}' => $this->formatCount($package->max_events_per_year, [ + 'de' => 'unbegrenzte', + 'en' => 'unlimited', + ]), + '{{gallery_duration}}' => $this->formatGalleryDuration($package->gallery_days), + ]; + } + + protected function applyPlaceholders(string $template, array $replacements): string + { + if ($template === '') { + return $template; + } + + return str_replace(array_keys($replacements), array_values($replacements), $template); + } + + protected function formatCount(?int $value, array $fallbackByLocale): string + { + $locale = app()->getLocale(); + + if ($value === null) { + return $fallbackByLocale[$locale] ?? reset($fallbackByLocale) ?? ''; + } + + $decimal = $locale === 'de' ? ',' : '.'; + $thousands = $locale === 'de' ? '.' : ','; + + return number_format($value, 0, $decimal, $thousands); + } + + protected function formatGalleryDuration(?int $days): string + { + $locale = app()->getLocale(); + + 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 Monat' : $months . ' Monate'; + } + + return $locale === 'en' ? $days . ' days' : $days . ' Tage'; + } + + protected function normaliseFeatures(mixed $features): array + { + if (is_string($features)) { + $decoded = json_decode($features, true); + if (json_last_error() === JSON_ERROR_NONE) { + $features = $decoded; + } + } + + if (! is_array($features)) { + return []; + } + + $list = []; + foreach ($features as $key => $value) { + if (is_string($value)) { + $list[] = $value; + continue; + } + + if (is_string($key) && (bool) $value) { + $list[] = $key; + } + } + + return array_values(array_unique(array_filter($list, fn ($item) => is_string($item) && $item !== ''))); + } + + protected function resolveTranslation(mixed $value, string $fallback, string $locale): string + { + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + $value = $decoded; + } + } + + if (is_array($value)) { + return trim((string) ($value[$locale] ?? $value['en'] ?? $value['de'] ?? $fallback)); + } + + return trim((string) ($value ?? $fallback)); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index cf39485..ec1740f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -18,6 +18,9 @@ return Application::configure(basePath: dirname(__DIR__)) commands: __DIR__.'/../routes/console.php', health: '/up', ) + ->withCommands([ + \App\Console\Commands\OAuthRotateKeysCommand::class, + ]) ->withMiddleware(function (Middleware $middleware) { $middleware->alias([ 'tenant.token' => TenantTokenGuard::class, @@ -25,6 +28,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'package.check' => \App\Http\Middleware\PackageMiddleware::class, 'locale' => \App\Http\Middleware\SetLocale::class, 'superadmin.auth' => \App\Http\Middleware\SuperAdminAuth::class, + 'credit.check' => CreditCheckMiddleware::class, ]); $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index aeef057..bb7089d 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,6 +2,7 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\AuthServiceProvider::class, Stephenjude\FilamentBlog\FilamentBlogServiceProvider::class, App\Providers\Filament\SuperAdminPanelProvider::class, App\Providers\Filament\AdminPanelProvider::class, diff --git a/composer.json b/composer.json index eac4c45..a9f72ae 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^12.0", "laravel/sanctum": "^4.2", + "laravel/socialite": "^5.23", "laravel/tinker": "^2.10.1", "laravel/wayfinder": "^0.1.9", "league/commonmark": "^2.7", diff --git a/composer.lock b/composer.lock index 2a2c9bc..3e9be3c 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": "7f7cd01c532ad63b7539234881b1169b", + "content-hash": "79b6c96efab0391868c6ce26689c0ce3", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2962,6 +2962,78 @@ }, "time": "2025-09-22T17:29:40+00:00" }, + { + "name": "laravel/socialite", + "version": "v5.23.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4", + "guzzlehttp/guzzle": "^6.0|^7.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "league/oauth1-client": "^1.11", + "php": "^7.2|^8.0", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", + "phpstan/phpstan": "^1.12.23", + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Socialite": "Laravel\\Socialite\\Facades\\Socialite" + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "homepage": "https://laravel.com", + "keywords": [ + "laravel", + "oauth" + ], + "support": { + "issues": "https://github.com/laravel/socialite/issues", + "source": "https://github.com/laravel/socialite" + }, + "time": "2025-07-23T14:16:08+00:00" + }, { "name": "laravel/tinker", "version": "v2.10.1", @@ -3559,6 +3631,82 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/oauth1-client", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + }, + "time": "2024-12-10T19:59:05+00:00" + }, { "name": "league/uri", "version": "7.5.1", @@ -4696,6 +4844,56 @@ }, "time": "2025-09-24T15:06:41+00:00" }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "paypal/paypal-server-sdk", "version": "1.1.0", @@ -4961,6 +5159,116 @@ ], "time": "2025-08-21T11:53:16+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.47", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/9d6ca36a6c2dd434765b1071b2644a1c683b385d", + "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.47" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2025-10-06T01:07:24+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "2.3.0", diff --git a/config/checkout.php b/config/checkout.php index ccb957c..4bbae5a 100644 --- a/config/checkout.php +++ b/config/checkout.php @@ -1,6 +1,8 @@ (bool) env('CHECKOUT_WIZARD_ENABLED', true), + 'feature_flag' => env('CHECKOUT_WIZARD_FLAG', 'checkout-wizard-2025'), 'session_ttl_minutes' => env('CHECKOUT_SESSION_TTL', 30), 'status_history_max' => env('CHECKOUT_STATUS_HISTORY_MAX', 25), -]; \ No newline at end of file +]; diff --git a/config/oauth.php b/config/oauth.php new file mode 100644 index 0000000..6a538b1 --- /dev/null +++ b/config/oauth.php @@ -0,0 +1,13 @@ + [ + 'current_kid' => env('OAUTH_JWT_KID', 'fotospiel-jwt'), + 'storage_path' => env('OAUTH_KEY_STORE', storage_path('app/oauth-keys')), + ], + 'refresh_tokens' => [ + 'enforce_ip_binding' => env('OAUTH_REFRESH_ENFORCE_IP', true), + 'allow_subnet_match' => env('OAUTH_REFRESH_ALLOW_SUBNET', false), + ], +]; + diff --git a/config/services.php b/config/services.php index cd37268..41fb00f 100644 --- a/config/services.php +++ b/config/services.php @@ -43,6 +43,19 @@ return [ 'sandbox' => env('PAYPAL_SANDBOX', true), ], + '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'), + ], + + 'revenuecat' => [ + 'webhook' => env('REVENUECAT_WEBHOOK_SECRET', ''), + 'product_mappings' => env('REVENUECAT_PRODUCT_MAPPINGS', ''), + 'app_user_prefix' => env('REVENUECAT_APP_USER_PREFIX', 'tenant'), + 'queue' => env('REVENUECAT_WEBHOOK_QUEUE', 'webhooks'), + ], + 'oauth' => [ 'tenant_admin' => [ 'id' => env('VITE_OAUTH_CLIENT_ID', 'tenant-admin-app'), diff --git a/docs/changes/2025-10-05-checkout-refactor-todo.md b/docs/changes/2025-10-05-checkout-refactor-todo.md index 55f5cc8..50625f8 100644 --- a/docs/changes/2025-10-05-checkout-refactor-todo.md +++ b/docs/changes/2025-10-05-checkout-refactor-todo.md @@ -7,21 +7,21 @@ ## Action Items ### Wizard Foundations -- [ ] Rebuild the package step with a side panel for comparable packages and reset payment state when the selected package changes. -- [ ] Redesign the payment step: Stripe and PayPal happy path, failure, retry; add subscription handling for reseller plans. -- [ ] Update the confirmation step and surface the admin link inside `resources/js/pages/Profile/Index.tsx`. +- [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] 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 - [x] Refactor `resources/js/pages/auth/LoginForm.tsx` and `RegisterForm.tsx` to hit the correct routes, surface inline validation errors, and provide success callbacks. -- [ ] Add optional comfort login: Google sign-in and enrichment of missing registration fields via the payment provider, combining the prior step 2/3 concept. +- [x] Add optional comfort login: Google sign-in and enrichment of missing registration fields via the payment provider, combining the prior step 2/3 concept. ### Backend Alignment -- [ ] Implement a dedicated `CheckoutController` plus marketing API routes, migrating any remaining checkout logic out of the marketing controller. -- [ ] Audit existing marketing payment flows (`resources/js/pages/marketing/PurchaseWizard.tsx`, `PaymentForm.tsx`) and plan removals or migration. +- [x] Implement a dedicated `CheckoutController` plus marketing API routes, migrating any remaining checkout logic out of the marketing controller. *(Controller + routes now live in `app/Http/Controllers/CheckoutController.php` / `routes/web.php`.)* +- [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 -- [ ] Expand automated coverage: Playwright end-to-end scenarios for auth, payment success/failure, Google login; PHPUnit and webhook tests for new checkout endpoints. -- [ ] Update docs (PRP, docs/changes) and plan a feature-flag rollout for the new wizard. +- [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] Update docs (PRP, docs/changes) and plan a feature-flag rollout for the new wizard. ## Notes - Wizard auth now uses `/checkout/login` and `/checkout/register` JSON endpoints handled by `CheckoutController`. @@ -33,7 +33,7 @@ ### Payment Integration Plan - [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. -- [ ] Implement Stripe PaymentIntent endpoint returning `client_secret` scoped to wizard session. -- [ ] Implement PayPal order creation/capture endpoints with metadata for tenant/package. -- [ ] Add webhook handling matrix for Stripe invoice/payment events and PayPal subscription lifecycle. -- [ ] Wire payment step UI to new endpoints with optimistic and retry handling. +- [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.)* diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index f0dfffb..bcec610 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -1,14 +1,14 @@ -### Update 2025-09-25 -- Phase 3 credits scope: credit middleware on create/update, rate limiters for OAuth/tenant APIs, RevenueCat webhook + processing job live with idempotency tests. -- Remaining: extend middleware to photo/other credit drains, admin ledger surfaces, document RevenueCat env vars in PRP. +### Update 2025-10-21 +- Phase 3 credit scope delivered: tenant event creation now honours package allowances *and* credit balances (middleware + ledger logging), RevenueCat webhook signature checks ship with queue/backoff + env config, idempotency covered via unit tests. +- Follow-up (separate): evaluate photo upload quota enforcement + SuperAdmin ledger visualisations once package analytics stabilise. # Backend-Erweiterung Implementation Roadmap (Aktualisiert: 2025-09-15 - Fortschritt) ## Implementierungsstand (Aktualisiert: 2025-09-15) Basierend auf aktueller Code-Analyse und Implementierung: - **Phase 1 (Foundation)**: ✅ Vollständig abgeschlossen – Migrationen ausgeführt, Sanctum konfiguriert, OAuthController (PKCE-Flow, JWT), Middleware (TenantTokenGuard, TenantIsolation) implementiert und registriert. - **Phase 2 (Core API)**: ✅ 100% abgeschlossen – EventController (CRUD, Credit-Check, Search, Bulk), PhotoController (Upload, Moderation, Stats, Presigned Upload), **TaskController (CRUD, Event-Assignment, Bulk-Operations, Search)**, **SettingsController (Branding, Features, Custom Domain, Domain-Validation)**, Request/Response Models (EventStoreRequest, PhotoStoreRequest, **TaskStoreRequest, TaskUpdateRequest, SettingsStoreRequest**), Resources (**TaskResource, EventTypeResource**), File Upload Pipeline (local Storage, Thumbnails via ImageHelper), API-Routen erweitert, **Feature-Tests (21 Tests, 100% Coverage)**, **TenantModelTest (11 Unit-Tests)**. -- **Phase 3 (Business Logic)**: 40% implementiert – event_credits_balance Feld vorhanden, Credit-Check in EventController, **Tenant::decrementCredits()/incrementCredits() Methoden**, aber CreditMiddleware, CreditController, Webhooks fehlen. -- **Phase 4 (Admin & Monitoring)**: 20% implementiert – **TenantResource erweitert (credits, features, activeSubscription)**, aber fehlend: subscription_tier Actions, PurchaseHistoryResource, Widgets, Policies. +- **Phase 3 (Business Logic)**: 60% implementiert – event_credits_balance Feld vorhanden, Endpunkt/Controller stehen, Credit-Middleware aktiv, RevenueCat Webhook inkl. Queue/Retries produktionsreif; Token-Rotation folgt. +- **Phase 4 (Admin & Monitoring)**: 45% implementiert – **TenantResource erweitert (credits, features, activeSubscription)**, PurchaseHistory/OAuthClient-Management sowie Dashboard-Widgets fertig; verbleibend sind Advanced Actions (subscription_tier) und erweiterte Monitoring-Policies. **Gesamtaufwand reduziert**: Von 2-3 Wochen auf **4-5 Tage**, da Phase 2 vollständig abgeschlossen und Tests implementiert. @@ -65,31 +65,21 @@ Basierend auf aktueller Code-Analyse und Implementierung: ### Implementierter Fortschritt - [x] Credit-Feld in Tenant-Model mit `event_credits_balance` -- [x] **Tenant::decrementCredits()/incrementCredits() Methoden** implementiert -- [x] Credit-Check in EventController (decrement bei Create) -- [ ] CreditMiddleware für alle Event-Operationen +- [x] **Tenant::decrementCredits()/incrementCredits() Methoden** inkl. Logging implementiert +- [x] Credit-Middleware & Route-Alias greifen vor Event-Create; `Tenant::consumeEventAllowance()` nutzt zuerst Reseller-Pakete, sonst Credits +- [x] RevenueCat-Webhook: Signatur-Validierung, Queue-Konfiguration, Retry (`tries/backoff`) + Produkt-Mapping ### Verbleibende Tasks -1. **Credit-System erweitern (1 Tag)** - - CreditMiddleware für alle Event-Create/Update - - CreditController für Balance, Ledger, History - - Tenant::decrementCredits() Methode mit Logging - -2. **Webhook-Integration (1-2 Tage)** - - RevenueCatController für Purchase-Webhooks - - Signature-Validation, Balance-Update, Subscription-Sync - - Queue-basierte Retry-Logic - -3. **Security Implementation (1 Tag)** - - Rate Limiting: 100/min tenant, 10/min oauth - - Token-Rotation in OAuthController - - IP-Binding für Refresh Tokens +1. **Security Implementation (1 Tag)** + - Rate Limiting: 100/min tenant, 10/min oauth *(aktiv)* + - Token-Rotation in OAuthController *(KID-basierte Schlüssel & `oauth:rotate-keys`)* + - IP-Binding für Refresh Tokens *(konfigurierbar, Subnetzrelax optional)* ### Milestones - [x] Credit-Check funktioniert (Event-Create scheitert bei 0) -- [ ] Webhooks verarbeiten Purchases -- [ ] Rate Limiting aktiv -- [ ] Token-Rotation implementiert +- [x] Webhooks verarbeiten Purchases +- [x] Rate Limiting aktiv +- [x] Token-Rotation implementiert ## Phase 4: Admin & Monitoring (In Arbeit, 4-5 Tage) ### Ziele @@ -99,14 +89,14 @@ Basierend auf aktueller Code-Analyse und Implementierung: ### Implementierter Fortschritt - [x] **TenantResource erweitert**: credits, features, activeSubscription Attribute - [x] **TenantModelTest**: 11 Unit-Tests für Beziehungen (events, photos, purchases), Attribute, Methoden -- [ ] PurchaseHistoryResource, OAuthClientResource, Widgets, Policies +- [x] PurchaseHistoryResource, OAuthClientResource, Widgets, Policies ### Verbleibende Tasks 1. **Filament Resources erweitern (2 Tage)** - - TenantResource: subscription_tier, Actions (add_credits, suspend), RelationsManager - - PurchaseHistoryResource: CRUD, Filter, Export, Refund - - OAuthClientResource: Client-Management - - TenantPolicy mit superadmin before() + - TenantResource: subscription_tier, Actions (add_credits, suspend), RelationsManager *(Credits-Aktion fertig; subscription_tier-Actions noch offen)* + - PurchaseHistoryResource: CRUD, Filter, Export, Refund *(CRUD & Export umgesetzt; Refund via UI noch offen)* + - OAuthClientResource: Client-Management *(implementiert)* + - TenantPolicy mit superadmin before() *(implementiert)* 2. **Dashboard Widgets (1 Tag)** - RevenueChart, TopTenantsByRevenue, CreditAlerts @@ -121,9 +111,9 @@ Basierend auf aktueller Code-Analyse und Implementierung: ### Milestones - [x] TenantResource basis erweitert -- [ ] PurchaseHistoryResource funktioniert -- [ ] Widgets zeigen Stats -- [ ] Policies schützen SuperAdmin +- [x] PurchaseHistoryResource funktioniert +- [x] Widgets zeigen Stats +- [x] Policies schützen SuperAdmin - [ ] >80% Testabdeckung ## Gesamter Zeitplan @@ -133,7 +123,7 @@ Basierend auf aktueller Code-Analyse und Implementierung: | **1** | Foundation | ✅ Abgeschlossen | | **1** | Core API | ✅ Abgeschlossen | | **2** | Business Logic | 40% ⏳ In Arbeit | -| **2** | Admin & Monitoring | 20% 🔄 In Arbeit | +| **2** | Admin & Monitoring | 45% ✅ In Arbeit | **Gesamtdauer:** **4-5 Tage** - Phase 2 vollständig abgeschlossen, Tests implementiert **Kritische Pfade:** Phase 3 (Business Logic) kann sofort starten @@ -157,4 +147,4 @@ Basierend auf aktueller Code-Analyse und Implementierung: 6. **Deployment**: Staging-Environment, Monitoring-Setup **Gesamtkosten:** Ca. 60-100 Stunden (weit reduziert durch bestehende Basis). -**Erwartete Ergebnisse:** Voll funktionsfähige Multi-Tenant API mit Events/Photos, Freemium-Modell bereit für SuperAdmin-Management. \ No newline at end of file +**Erwartete Ergebnisse:** Voll funktionsfähige Multi-Tenant API mit Events/Photos, Freemium-Modell bereit für SuperAdmin-Management. diff --git a/docs/piwik-trackingcode.txt b/docs/piwik-trackingcode.txt new file mode 100644 index 0000000..bb490f6 --- /dev/null +++ b/docs/piwik-trackingcode.txt @@ -0,0 +1,15 @@ + + + diff --git a/docs/prp/03-api.md b/docs/prp/03-api.md index 16d4f26..446faac 100644 --- a/docs/prp/03-api.md +++ b/docs/prp/03-api.md @@ -25,6 +25,16 @@ Guest Polling (no WebSockets in v1) - GET `/events/{token}/photos?since=` — incremental gallery refresh. - Response: `{ data: Photo[], next_cursor?: string, latest_photo_at: ISO8601 }`. - Use `If-None-Match` or `If-Modified-Since` to return `304 Not Modified` when unchanged. +- Legacy slug-based guest endpoints have been removed; tokens are mandatory for public access. Webhooks - Payment provider events, media pipeline status, and deletion callbacks. All signed with shared secret per provider. +- RevenueCat webhook: `POST /api/v1/webhooks/revenuecat` signed via `X-Signature` (HMAC SHA1/256). Dispatches `ProcessRevenueCatWebhook` to credit tenants and sync subscription expiry. + +Public Gallery +- `GET /gallery/{token}`: returns event snapshot + branding colors; responds with `410` once the package gallery window expires. +- `GET /gallery/{token}/photos?cursor=&limit=`: cursor-based pagination of approved photos. Response shape `{ data: Photo[], next_cursor: string|null }`. +- `GET /gallery/{token}/photos/{photo}/download`: streams or redirects to an approved original. Returns `404` if the asset is gone. + +Tenant Admin Downloads +- `GET /tenant/events/{event}/photos/archive`: authenticated ZIP export of all approved photos for an event. Returns `404` when none exist. diff --git a/docs/prp/07-guest-pwa.md b/docs/prp/07-guest-pwa.md index 00889b9..3b75002 100644 --- a/docs/prp/07-guest-pwa.md +++ b/docs/prp/07-guest-pwa.md @@ -30,6 +30,7 @@ Core Features - Masonry grid, lazy-load, pull-to-refresh; open photo lightbox with swipe. - Like (heart) with optimistic UI; share system sheet (URL to CDN variant). - Filters: emotion, featured, mine (local-only tag for items uploaded from this device). + - Public share: host can hand out `https://app.domain/g/{token}`; guests see a themed, read-only gallery with per-photo downloads. - Safety & abuse controls - Rate limits per device and IP; content-length checks; mime/type sniffing. - Upload moderation state: pending → approved/hidden; show local status. @@ -39,6 +40,7 @@ Core Features Screens - Splash/Loading: event lookup + token validation; friendly skeleton. +- Slug-based deep links are no longer accepted; guests must enter or scan a join token QR. - Terms & PIN: legal links, optional PIN input; remember choice per event. - Gallery: grid of approved photos; toolbar with filter, upload, settings. - Upload Picker: camera/library, selection preview, emotion/task tagging. diff --git a/docs/prp/08-billing.md b/docs/prp/08-billing.md index 2462ba7..1a99d89 100644 --- a/docs/prp/08-billing.md +++ b/docs/prp/08-billing.md @@ -9,6 +9,15 @@ The Fotospiel platform supports multiple payment providers for package purchases - **Configuration**: Keys in `config/services.php` under `stripe`. Sandbox mode based on `APP_ENV`. - **Models**: `PackagePurchase` records all transactions with `provider_id` (Stripe PI ID), `status`, `metadata`. - **Frontend**: PurchaseWizard.tsx handles client-side Stripe Elements for card input and confirmation. +- **Webhook Matrix**: + + | Event | Purpose | Internal handler | + | --- | --- | --- | + | `payment_intent.succeeded` | Completes single-event package purchase | `StripeWebhookController@handlePaymentIntentSucceeded` | + | `payment_intent.payment_failed` | Logs failure, triggers recovery emails | `handlePaymentIntentFailed` | + | `invoice.paid` | Confirms subscription renewal, extends package | `handleInvoicePaid` | + | `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. @@ -17,6 +26,14 @@ The Fotospiel platform supports multiple payment providers for package purchases - **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`). +- **Webhook Matrix**: + + | Event | Purpose | Internal handler | + | --- | --- | --- | + | `PAYMENT.CAPTURE.COMPLETED` | Confirms one-time order, activates tenant package | `handleCapture` | + | `BILLING.SUBSCRIPTION.ACTIVATED`, `BILLING.SUBSCRIPTION.UPDATED` | Syncs reseller subscription status/expiry | `handleSubscription` | + | `BILLING.SUBSCRIPTION.CANCELLED`, `BILLING.SUBSCRIPTION.EXPIRED` | Marks package inactive and downgrades tenant | `handleSubscription` | + | `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. diff --git a/docs/prp/09-security-compliance.md b/docs/prp/09-security-compliance.md index 595cc46..b8f61ce 100644 --- a/docs/prp/09-security-compliance.md +++ b/docs/prp/09-security-compliance.md @@ -6,3 +6,20 @@ - Logging: structured, no PII; add request/trace IDs; redact secrets. - GDPR: retention settings per tenant; deletion workflows; legal pages managed via CMS-like resource. - Rate limits: per-tenant, per-user, per-device; protect upload and admin mutations. + +## 2025 Hardening Priorities + +- **Identity & OAuth** — *Owner: Backend Platform* + Track JWT key rotation via `oauth:rotate-keys`, roll out dual-key support (old/new KID overlap), surface refresh-token revocation tooling, and extend IP/device binding rules for long-lived sessions. +- **Guest Join Tokens** — *Owner: Guest Platform* + Hash stored join tokens, add anomaly metrics (usage spikes, stale tokens), and tighten gallery/photo rate limits with visibility in storage dashboards. +- **Public API Resilience** — *Owner: Core API* + Ensure gallery/download endpoints serve signed URLs, expand abuse throttles (token + IP), and document incident response runbooks in ops guides. +- **Media Pipeline & Storage** — *Owner: Media Services* + Introduce antivirus + EXIF scrubbing workers, stream uploads to disk to avoid buffering, and enforce checksum verification during hot→archive transfers with configurable alerts from `StorageHealthService`. +- **Payments & Webhooks** — *Owner: Billing* + Align legacy Stripe hooks with checkout sessions, add idempotency locks/signature expiry checks, and plug failed capture notifications into the credit ledger audit trail. +- **Frontend & CSP** — *Owner: Marketing Frontend* + Replace unsafe-inline allowances (Stripe/Matomo) with nonce or hashed CSP rules, gate analytics injection behind consent, and localise cookie-banner copy that discloses data sharing. + +Progress updates belong in `docs/changes/` and roadmap status in `docs/implementation-roadmap.md`. diff --git a/docs/prp/11-public-gallery.md b/docs/prp/11-public-gallery.md new file mode 100644 index 0000000..7427639 --- /dev/null +++ b/docs/prp/11-public-gallery.md @@ -0,0 +1,29 @@ +# 11 — Public Guest Gallery + +Purpose +- Provide a shareable, mobile-friendly gallery for guests who only need to view and download approved photos. +- Respect existing join-token security and automatically disable access once a package’s gallery duration expires. + +Access Model +- URL pattern: `https:///g/{joinToken}`. Tokens are the same join tokens tenants already issue; revoking or expiring a token immediately locks the gallery. +- Tokens expire when the associated event package’s `gallery_expires_at` passes; guests receive an explanatory message (HTTP `410`). +- Only *approved* photos appear; pending/rejected items remain hidden. + +Client Experience +- Responsive grid with lazy-loaded thumbnails (IntersectionObserver) and infinite scroll/pagination. +- Event branding colours (primary, secondary, background) are applied via CSS custom properties fetched from the API. +- Fullscreen lightbox shows creation timestamp + guest label when available and exposes a single-photo download button (streams the original asset). + +API Touchpoints (see 03 — API Contract for details) +- `GET /api/v1/gallery/{token}` → event snapshot + branding. +- `GET /api/v1/gallery/{token}/photos` → cursor-based pagination of approved photos. +- `GET /api/v1/gallery/{token}/photos/{photo}/download` → single-photo download/redirect. + +Tenant Admin Support +- Filament action “Download all photos” (Event resource) queues a server-side ZIP export via `GET /tenant/events/{event}/photos/archive` for authenticated tenants. +- Only approved photos are included; failed assets are skipped with logging. + +Future Enhancements +- Background job + notification for large ZIP exports (current implementation streams synchronously). +- Optional passcode/PIN layered on top of join tokens for sensitive events. +- Aggregate analytics (views/downloads per photo) presented in Tenant Admin dashboards. diff --git a/docs/prp/marketing-checkout-payment-architecture.md b/docs/prp/marketing-checkout-payment-architecture.md index 914b344..75b0c33 100644 --- a/docs/prp/marketing-checkout-payment-architecture.md +++ b/docs/prp/marketing-checkout-payment-architecture.md @@ -12,6 +12,8 @@ - **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. +- **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`). ## Payment State Machine State constants live on `CheckoutSession` (`status` column, enum): @@ -129,6 +131,5 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla ## Open Questions / Follow-Ups - Map package records to Stripe price ids and PayPal plan ids (store on `packages` table or config?). -- Determine how Google sign-in enrichment (comfort login) feeds the checkout session once implemented. - 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/marketing-frontend-unification.md b/docs/prp/marketing-frontend-unification.md index 8cbe86b..1576797 100644 --- a/docs/prp/marketing-frontend-unification.md +++ b/docs/prp/marketing-frontend-unification.md @@ -40,6 +40,7 @@ Ziel: Vollständige Migration zu Inertia.js für SPA-ähnliche Konsistenz, mit e - **Auth-Integration**: usePage().props.auth für CTAs (z.B. Login-Button im Header). - **Responsive Design**: Tailwind: block md:hidden für Mobile-Carousel in Packages. - **Fonts & Assets**: Vite-Plugin für Fonts/SVGs; preload in Layout. +- **Analytics (Matomo)**: Aktivierung via `.env` (`MATOMO_ENABLED=true`, `MATOMO_URL`, `MATOMO_SITE_ID`). `AppServiceProvider` teilt die Konfiguration als `analytics.matomo`; `MarketingLayout` rendert `MatomoTracker`, der das Snippet aus `/docs/piwik-trackingcode.txt` nur bei erteilter Analyse-Zustimmung lädt, `disableCookies` setzt und bei jedem Inertia-Navigationsevent `trackPageView` sendet. Ein lokalisierter Consent-Banner (DE/EN) übernimmt die DSGVO-konforme Einwilligung und ist über den Footer erneut erreichbar. - **Tests**: E2E mit Playwright (z.B. navigate to /packages, check header/footer presence). ### 5. Diagramm: Layout-Struktur @@ -66,4 +67,4 @@ MarketingLayout.tsx - Styling-Konflikte: CSS-Isolation mit Tailwind-Prefix. - Performance: Code-Splitting für große Pages. -Dieser Plan basiert auf bestehender Struktur (docs/prp/ als Referenz). Nach Umsetzung: Update PRP (docs/prp/01-architecture.md). \ No newline at end of file +Dieser Plan basiert auf bestehender Struktur (docs/prp/ als Referenz). Nach Umsetzung: Update PRP (docs/prp/01-architecture.md). diff --git a/docs/prp/public-entrypoints.md b/docs/prp/public-entrypoints.md new file mode 100644 index 0000000..1c657f3 --- /dev/null +++ b/docs/prp/public-entrypoints.md @@ -0,0 +1,28 @@ +# Public Entry Points + +This overview lists every user-facing URL surface, grouped by persona, and notes authentication/expiry rules. + +## Marketing Site +- `/` — marketing landing page. +- `/packages` — package overview. +- `/checkout/{package}` — checkout wizard (requires logged-in tenant or email login within flow). +- `/blog`, `/contact`, `/impressum`, `/datenschutz`, `/agb` — legal and marketing content. + +## Tenant Admin +- `/event-admin/*` — protected Filament SPA (requires tenant credentials). +- `/tenant/events/{event}/photos/archive` — authenticated ZIP export for approved photos (tenant ownership enforced). + +## Guest PWA (event-bound) +- `/event` — landing for new guests (code entry / QR). +- `/e/{token}` — full guest experience (home, tasks, gallery, upload) gated by join token; token expiry revokes access. +- `/g/{token}` — read-only public gallery (new). Shows approved photos themed by event branding; downloads allowed while token valid and gallery duration active. +- `/setup/{token}` — onboarding/profile setup for guests. + +## API (selected public endpoints) +- `/api/v1/events/{token}` — event metadata for guest PWA. +- `/api/v1/events/{token}/photos` — guest gallery polling (legacy PWA). +- `/api/v1/gallery/{token}` — public gallery metadata (new). +- `/api/v1/gallery/{token}/photos` — public gallery pagination (new). +- `/api/v1/gallery/{token}/photos/{photo}/download` — single photo download (new). + +All other `/api/v1/*` routes require authenticated tenant or super-admin access as documented in `docs/prp/03-api.md`. diff --git a/docs/prp/tenant-app-specs/README.md b/docs/prp/tenant-app-specs/README.md index 1c7dd7f..4cf937c 100644 --- a/docs/prp/tenant-app-specs/README.md +++ b/docs/prp/tenant-app-specs/README.md @@ -1,15 +1,22 @@ # Detaillierte PRP für Tenant Admin App (Capacitor + Framework7) ## Status -- **Aktiv**: Erste Version (2025-09-13) -- **Version**: 1.0.0 -- **Autor**: Sonoma (AI Architect) -- **Supersedes**: docs/prp/06-tenant-admin-pwa.md (erweitert und detailliert) +- **Aktualisiert**: 2025-10-17 (Onboarding Fusion & QR Revamp) +- **Version**: 1.2.0 +- **Autor**: Core Platform Team (Codex) +- **Supersedes**: docs/prp/06-tenant-admin-pwa.md (legacy Framework7 reference) ## Überblick Diese detaillierte Product Requirement Plan (PRP) beschreibt die Spezifikationen für die Tenant Admin App. Die App ist eine store-ready mobile Anwendung, die mit Capacitor für iOS und Trusted Web Activity (TWA) für Android gepackt wird. Die UI basiert auf Framework7 für ein natives Mobile-Erlebnis. Die App ermöglicht Tenant-Admins (z.B. Event-Organisatoren) die vollständige Verwaltung ihrer Events, Galerien, Mitglieder, Einstellungen und Käufe über eine API-first Backend-Integration. -Die App ersetzt das frühere Filament-basierte Tenant-Panel und fokussiert auf Mobile-First-UX mit Offline-Fähigkeiten, Push-Notifications und sicherer Authentifizierung. Sie respektiert das Multi-Tenancy-Modell und GDPR-Anforderungen. +Die App ersetzt das frühere Filament-basierte Tenant-Panel und fokussiert auf eine Mobile-First-UX mit Offline-Fähigkeiten, Push-Notifications und sicherer Authentifizierung. Sie respektiert das Multi-Tenancy-Modell und GDPR-Anforderungen. Seit Oktober 2025 wird das UI in React 19 + Vite + Tailwind/shadcn/ui umgesetzt; die alte Framework7-Schicht bleibt nur als historische Referenz erhalten. + +## 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. +- **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`. ## Kernziele - **Deliverables**: Voll funktionsfähige App mit CRUD-Operationen für Tenant-Ressourcen (Events, Photos, Tasks, etc.). @@ -40,4 +47,4 @@ Diese PRP erweitert die knappe Beschreibung in 06-tenant-admin-pwa.md um: - Detaillierte Settings und Capacitor-Integration. - Mobile-spezifische Features wie Push-Notifications und Offline-Sync. -Für Feedback oder Änderungen: Siehe TODO.md oder Issues. \ No newline at end of file +Für Feedback oder Änderungen: Siehe TODO.md oder Issues. diff --git a/docs/prp/tenant-app-specs/api-usage.md b/docs/prp/tenant-app-specs/api-usage.md index 9dd0a94..37c3319 100644 --- a/docs/prp/tenant-app-specs/api-usage.md +++ b/docs/prp/tenant-app-specs/api-usage.md @@ -22,6 +22,7 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit - **Redirect URI**: Standardmaessig `${origin}/event-admin/auth/callback` (per Vite-Env anpassbar) - **Headers**: `Authorization: Bearer {access_token}` - **Response**: `{ id, email, tenant_id, role, name }` + - **Hinweis**: `client_id` entspricht `VITE_OAUTH_CLIENT_ID`; Seeder `OAuthClientSeeder` synchronisiert Frontend und Backend. ## Dashboard @@ -260,6 +261,7 @@ curl -H "Authorization: Bearer {token}" \ - **VITE_API_URL**: Backend-API-URL (Pflicht) - **VITE_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht, muss mit `config/services.php` übereinstimmen – der Seeder legt damit den Client in `oauth_clients` an) - **VITE_REVENUECAT_PUBLIC_KEY**: Optional fuer In-App-Kaeufe (RevenueCat) +- **REVENUECAT_WEBHOOK_SECRET / REVENUECAT_PRODUCT_MAPPINGS / REVENUECAT_APP_USER_PREFIX / REVENUECAT_WEBHOOK_QUEUE**: Backend-Konfiguration für RevenueCat-Webhooks, siehe `config/services.php`. ### Build & Deploy 1. **Development**: `npm run dev` @@ -272,4 +274,3 @@ curl -H "Authorization: Bearer {token}" \ Für weitere Details siehe die spezifischen Dokumentationsdateien. - diff --git a/docs/prp/tenant-app-specs/functional-specs.md b/docs/prp/tenant-app-specs/functional-specs.md index a6cd8c8..4384b4e 100644 --- a/docs/prp/tenant-app-specs/functional-specs.md +++ b/docs/prp/tenant-app-specs/functional-specs.md @@ -23,12 +23,14 @@ Die Admin-App muss folgende Kernfunktionen bereitstellen: ### Onboarding Journey - 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. - Dashboard weist per CTA auf offenes Onboarding hin, bis ein erstes Event erstellt wurde. ### Event Lifecycle -- Erstellung prüft Paketverfügbarkeit; generiert Join-Token. +- Erstellung prüft Paketverfügbarkeit; generiert Join-Token (EventJoinToken-Service). +- QR-Layouts und Token-Rotation erfolgen über `/event-admin/welcome` bzw. das Filament-Panel; slug-basierte QR-Links wurden deaktiviert. - Bearbeiten erlaubt Statuswechsel, Aufgaben, Emotions, Join-Token-Verwaltung. - Veröffentlichen schaltet Guest-PWA frei; Archivieren respektiert Retention-Policy. diff --git a/docs/todo/event-join-token-hardening.md b/docs/todo/event-join-token-hardening.md index 9a3b6f7..51a2aa9 100644 --- a/docs/todo/event-join-token-hardening.md +++ b/docs/todo/event-join-token-hardening.md @@ -27,12 +27,12 @@ Replace slug-based guest access with opaque, revocable join tokens and provide p - [x] Build “QR & Invites” management UI (list tokens, usage stats, rotate/revoke). - [x] Hook Filament action + PWA screens to call new token endpoints. - [x] Generate five print-ready layouts (PDF/SVG) per token with download options. -- [ ] Deprecate slug-based QR view; link tenants to new flow. +- [x] Deprecate slug-based QR view; link tenants to new flow. ## Phase 4 – Migration & Cleanup -- [ ] Remove slug parameters from public endpoints once traffic confirms token usage. -- [ ] Update documentation (PRP, onboarding guides, runbooks) to reflect token process. -- [ ] Add feature/integration tests covering expiry, rotation, and guest flows. +- [x] Remove slug parameters from public endpoints (legacy slug URLs now return invalid_token). +- [x] Update documentation (PRP, onboarding guides, runbooks) to reflect token process. +- [x] Add feature/integration tests covering expiry, rotation, and guest flows. ## Open Questions - Decide on default token lifetime and rotation cadence. diff --git a/docs/todo/security-hardening-epic.md b/docs/todo/security-hardening-epic.md new file mode 100644 index 0000000..afbd6ab --- /dev/null +++ b/docs/todo/security-hardening-epic.md @@ -0,0 +1,42 @@ +# Security Hardening Epic (Q4 2025) + +## Goal +Raise the baseline security posture across guest APIs, checkout, media storage, and identity flows so the platform can scale multi-tenant traffic with auditable, revocable access. + +## Workstreams + +1. **Identity & OAuth (Backend Platform)** + - Dual-key rollout for JWT signing with rotation runbook and monitoring. + - Refresh-token revocation tooling (per device/IP) and anomaly alerts. + - Device fingerprint/subnet allowances documented and configurable. + +2. **Guest Join Tokens (Guest Platform)** + - Store hashed tokens with irreversible lookups; migrate legacy data. + - Add per-token usage analytics, alerting on spikes or expiry churn. + - Extend gallery/photo rate limits (token + IP) and surface breach telemetry in storage dashboards. + +3. **Public API Resilience (Core API)** + - Serve signed asset URLs instead of raw storage paths; expire appropriately. + - Document incident response runbooks and playbooks for abuse mitigation. + - Add synthetic monitors for `/api/v1/gallery/*` and upload endpoints. + +4. **Media Pipeline & Storage (Media Services)** + - Integrate antivirus/EXIF scrubbers and streaming upload paths to avoid buffering. + - Verify checksum integrity on hot → archive transfers with alert thresholds. + - Surface storage target health (capacity, latency) in Super Admin dashboards. + +5. **Payments & Webhooks (Billing)** + - Link Stripe/PayPal 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. + +6. **Frontend & CSP (Marketing Frontend)** + - Replace `unsafe-inline` allowances with nonce/hash policies for Stripe + Matomo. + - Gate analytics script injection behind consent with localised disclosures. + - Broaden cookie banner layout to surface GDPR/legal copy clearly. + +## Deliverables +- Updated docs (`docs/prp/09-security-compliance.md`, runbooks) with ownership & SLAs. +- Feature flags / configuration toggles for rollouts (JWT KID, signed URLs, CSP nonces). +- Monitoring dashboards + alerting coverage per workstream. +- Integration and Playwright coverage validating the hardened flows. diff --git a/docs/todo/tenant-admin-onboarding-fusion.md b/docs/todo/tenant-admin-onboarding-fusion.md index a6ce189..1b352fc 100644 --- a/docs/todo/tenant-admin-onboarding-fusion.md +++ b/docs/todo/tenant-admin-onboarding-fusion.md @@ -46,7 +46,7 @@ Owner: Codex (handoff) - [x] Rebrand the Filament tenant panel away from “Admin” by adjusting `AdminPanelProvider` (brand name, home URL, navigation visibility) and registering a new onboarding home page. - [x] Build the Filament onboarding wizard (welcome → task package selection → event name → color palette → QR layout) with persisted progress on the tenant record and guards that hide legacy resource menus until completion. - [x] Expose QR invite generation in Filament via a dedicated page/component that reuses the join-token flow from `EventDetailPage.tsx`, ensuring tokens stay in sync between PWA and Filament. -- [ ] Update PRP/docs to cover the new welcome flow, OAuth alignment, Filament onboarding, and QR tooling; add regression notes + tests for the adjusted routes. +- [x] Update PRP/docs to cover die neue Welcome Journey, OAuth-Ausrichtung, Filament-Onboarding und QR-Tooling; Regression Notes + Tests dokumentiert. diff --git a/lang/de/checkout.php b/lang/de/checkout.php new file mode 100644 index 0000000..38f4347 --- /dev/null +++ b/lang/de/checkout.php @@ -0,0 +1,6 @@ + 'Die Google-Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.', + 'google_missing_email' => 'Wir konnten deine Google-E-Mail-Adresse nicht abrufen.', +]; diff --git a/lang/en/checkout.php b/lang/en/checkout.php new file mode 100644 index 0000000..77ccce7 --- /dev/null +++ b/lang/en/checkout.php @@ -0,0 +1,6 @@ + 'We could not complete the Google login. Please try again.', + 'google_missing_email' => 'We could not retrieve your Google email address.', +]; diff --git a/public/lang/de/common.json b/public/lang/de/common.json index 7e2b7e5..b014acf 100644 --- a/public/lang/de/common.json +++ b/public/lang/de/common.json @@ -50,5 +50,33 @@ "email_verify_title": "E-Mail verifizieren", "email_verify_desc": "Bitte überprüfen Sie Ihre E-Mail und klicken Sie auf den Verifizierungslink.", "language_select": "Sprache wählen" + }, + "consent": { + "banner": { + "title": "Wir respektieren deine Privatsphäre", + "body": "Wir verwenden Cookies für notwendige Funktionen und optionale Analysen zur Verbesserung. Du entscheidest, was erlaubt ist.", + "accept": "Alle akzeptieren", + "reject": "Nur notwendige", + "customize": "Individuell auswählen" + }, + "modal": { + "title": "Privatsphäre-Einstellungen", + "description": "Lege fest, wie wir Cookies und ähnliche Technologien nutzen. Du kannst deine Auswahl jederzeit ändern.", + "functional": "Notwendige Cookies", + "functional_desc": "Wichtig für sicheren Login, Spracheinstellungen und die Grundfunktionen der Website.", + "analytics": "Analyse", + "analytics_desc": "Hilft uns mit Matomo, die Nutzung zu verstehen und Angebote zu verbessern.", + "required": "Pflicht", + "save": "Auswahl speichern", + "cancel": "Abbrechen", + "accept_all": "Alle akzeptieren", + "reject_all": "Nur notwendige" + }, + "accessibility": { + "banner_label": "Cookie-Hinweis" + }, + "footer": { + "manage_link": "Cookie-Einstellungen" + } } -} \ No newline at end of file +} diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index 7b66b7b..e5019de 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -12,6 +12,7 @@ "hero_title": "Fotospiel", "hero_description": "Sammle Gastfotos für Events mit QR-Codes. Unsere sichere PWA-Plattform für Gäste und Organisatoren – einfach, mobil und datenschutzkonform. Besser als Konkurrenz, geliebt von Tausenden.", "cta_explore": "Pakete entdecken", + "cta_explore_highlight": "Jetzt loslegen", "hero_image_alt": "Event-Fotos mit QR-Code", "how_title": "So funktioniert es", "step1_title": "Paket wählen", @@ -62,6 +63,7 @@ "hero_title": "Entdecken Sie unsere flexiblen Packages", "hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.", "cta_explore": "Pakete entdecken", + "cta_explore_highlight": "Lieblingspaket sichern", "tab_endcustomer": "Endkunden", "tab_reseller": "Reseller & Agenturen", "section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)", @@ -115,8 +117,16 @@ "feature_overview": "Feature-Überblick", "order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung via Stripe oder PayPal.", "features_label": "Features", + "feature_highlights": "Feature-Highlights", + "more_details_tab": "Mehr Details", + "quick_facts": "Schnelle Fakten", + "quick_facts_hint": "Der schnelle Überblick über die wichtigsten Kennzahlen.", + "more_details_link": "Noch mehr Details anzeigen", + "badge_deep_dive": "Deep Dive", "breakdown_label": "Leistungsübersicht", + "breakdown_label_hint": "Erfahre, wie das Paket im Detail aufgebaut ist.", "limits_label": "Limits & Kapazitäten", + "limits_label_hint": "Alle Kennzahlen auf einen Blick – ideal für Planung und Freigaben.", "for_endcustomers": "Für Endkunden", "for_resellers": "Für Reseller", "details_show": "Details anzeigen", @@ -378,7 +388,12 @@ "next_to_payment": "Weiter zur Zahlung", "switch_to_register": "Registrieren", "switch_to_login": "Anmelden", - "google_coming_soon": "Google-Login kommt bald im Comfort-Delta." + "continue_with_google": "Mit Google fortfahren", + "google_success_toast": "Mit Google angemeldet.", + "google_error_title": "Google-Anmeldung fehlgeschlagen", + "google_missing_package": "Bitte wähle zuerst ein Paket aus, bevor du Google Login nutzt.", + "google_missing_email": "Wir konnten deine Google-E-Mail-Adresse nicht abrufen.", + "google_error_fallback": "Die Google-Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut." }, "payment_step": { "title": "Zahlung", @@ -389,6 +404,7 @@ "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.", "payment_failed": "Zahlung fehlgeschlagen. ", "error_card": "Kartenfehler aufgetreten.", "error_validation": "Eingabedaten sind ungültig.", @@ -404,7 +420,23 @@ "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" + "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_error_title": "Zahlung fehlgeschlagen", + "status_success_title": "Zahlung abgeschlossen", + "status_retry": "Erneut versuchen", + "method_stripe": "Kreditkarte (Stripe)", + "method_paypal": "PayPal" }, "confirmation_step": { "title": "Bestätigung", @@ -427,7 +459,12 @@ "already_logged_in": "Sie sind bereits als {email} eingeloggt.", "switch_to_register": "Registrieren", "switch_to_login": "Anmelden", - "google_coming_soon": "Google-Login kommt bald im Comfort-Delta." + "continue_with_google": "Mit Google fortfahren", + "google_success_toast": "Mit Google angemeldet.", + "google_error_title": "Google-Anmeldung fehlgeschlagen", + "google_missing_package": "Bitte wähle zuerst ein Paket aus, bevor du Google Login nutzt.", + "google_missing_email": "Wir konnten deine Google-E-Mail-Adresse nicht abrufen.", + "google_error_fallback": "Die Google-Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut." } } } diff --git a/public/lang/en/common.json b/public/lang/en/common.json index dc8deb2..4f4293b 100644 --- a/public/lang/en/common.json +++ b/public/lang/en/common.json @@ -50,5 +50,33 @@ "email_verify_title": "Verify Email", "email_verify_desc": "Please check your email and click the verification link.", "language_select": "Language Select" + }, + "consent": { + "banner": { + "title": "We respect your privacy", + "body": "We use cookies for essential features and optional analytics to improve our service. Choose what you allow.", + "accept": "Accept all", + "reject": "Reject non-essential", + "customize": "Customize" + }, + "modal": { + "title": "Privacy settings", + "description": "Adjust how we use cookies and similar technologies. You can update your choice at any time.", + "functional": "Necessary cookies", + "functional_desc": "Required to deliver secure login, remember your language, and keep the site running.", + "analytics": "Analytics", + "analytics_desc": "Helps us understand usage with Matomo so we can improve the experience.", + "required": "Required", + "save": "Save selection", + "cancel": "Cancel", + "accept_all": "Accept all", + "reject_all": "Reject all" + }, + "accessibility": { + "banner_label": "Cookie consent banner" + }, + "footer": { + "manage_link": "Cookie settings" + } } -} \ No newline at end of file +} diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index 13575db..144a3ce 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -4,6 +4,7 @@ "hero_title": "Fotospiel", "hero_description": "Collect guest photos for events with QR codes. Our secure PWA platform for guests and organizers – simple, mobile, and privacy-compliant. Better than competitors, loved by thousands.", "cta_explore": "Discover Packages", + "cta_explore_highlight": "Get started now", "hero_image_alt": "Event photos with QR code", "how_title": "How it works", "step1_title": "Choose Package", @@ -52,6 +53,7 @@ "hero_title": "Discover our flexible Packages", "hero_description": "From free entry to premium features: Tailor your event package to your needs. Simple, secure, and scalable.", "cta_explore": "Discover Packages", + "cta_explore_highlight": "Explore top packages", "tab_endcustomer": "End Customers", "tab_reseller": "Resellers & Agencies", "section_endcustomer": "Packages for End Customers (One-time purchase per event)", @@ -105,8 +107,16 @@ "feature_overview": "Feature overview", "order_hint": "Launch instantly – secure Stripe or PayPal checkout, no hidden fees.", "features_label": "Features", + "feature_highlights": "Feature Highlights", + "more_details_tab": "More Details", + "quick_facts": "Quick Facts", + "quick_facts_hint": "Your at-a-glance snapshot of core limits.", + "more_details_link": "See even more details", + "badge_deep_dive": "Deep Dive", "breakdown_label": "At-a-glance", + "breakdown_label_hint": "Dive deeper into how the package is structured.", "limits_label": "Limits & Capacity", + "limits_label_hint": "Understand the exact limits for planning and approvals.", "for_endcustomers": "For End Customers", "for_resellers": "For Resellers", "details_show": "Show Details", @@ -372,7 +382,12 @@ "next_to_payment": "Next to Payment", "switch_to_register": "Register", "switch_to_login": "Login", - "google_coming_soon": "Google Login coming soon in Comfort-Delta." + "continue_with_google": "Continue with Google", + "google_success_toast": "Signed in with Google.", + "google_error_title": "Google login failed", + "google_missing_package": "Please choose a package before using Google login.", + "google_missing_email": "We could not retrieve your Google email address.", + "google_error_fallback": "We couldn't complete the Google login. Please try again." }, "payment_step": { "title": "Payment", @@ -383,6 +398,7 @@ "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.", "payment_failed": "Payment failed. ", "error_card": "Card error occurred.", "error_validation": "Input data is invalid.", @@ -398,7 +414,23 @@ "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" + "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_error_title": "Payment failed", + "status_success_title": "Payment completed", + "status_retry": "Retry", + "method_stripe": "Credit Card (Stripe)", + "method_paypal": "PayPal" }, "confirmation_step": { "title": "Confirmation", @@ -421,7 +453,12 @@ "already_logged_in": "You are already logged in as {email}.", "switch_to_register": "Register", "switch_to_login": "Login", - "google_coming_soon": "Google Login coming soon in Comfort-Delta." + "continue_with_google": "Continue with Google", + "google_success_toast": "Signed in with Google.", + "google_error_title": "Google login failed", + "google_missing_package": "Please choose a package before using Google login.", + "google_missing_email": "We could not retrieve your Google email address.", + "google_error_fallback": "We couldn't complete the Google login. Please try again." } } } diff --git a/resources/js/app.tsx b/resources/js/app.tsx index cf9c6a1..403b252 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -10,6 +10,7 @@ import i18n from './i18n'; import { Toaster } from 'react-hot-toast'; import { Elements } from '@stripe/react-stripe-js'; import { loadStripe } from '@stripe/stripe-js'; +import { ConsentProvider } from './contexts/consent'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; @@ -42,10 +43,12 @@ createInertiaApp({ root.render( - - - - + + + + + + ); }, diff --git a/resources/js/components/analytics/MatomoTracker.tsx b/resources/js/components/analytics/MatomoTracker.tsx new file mode 100644 index 0000000..4b88328 --- /dev/null +++ b/resources/js/components/analytics/MatomoTracker.tsx @@ -0,0 +1,104 @@ +import { useEffect } from 'react'; +import { usePage } from '@inertiajs/react'; +import { useConsent } from '@/contexts/consent'; + +export type MatomoConfig = { + enabled: boolean; + url?: string; + siteId?: string; +}; + +declare global { + interface Window { + _paq?: any[]; + } +} + +interface MatomoTrackerProps { + config: MatomoConfig | undefined; +} + +const MatomoTracker: React.FC = ({ config }) => { + const page = usePage(); + const { hasConsent } = useConsent(); + const analyticsConsent = hasConsent('analytics'); + + useEffect(() => { + if (!config?.enabled || !config.url || !config.siteId || typeof window === 'undefined') { + return; + } + + const base = config.url.replace(/\/$/, ''); + const scriptSelector = `script[data-matomo="${base}"]`; + + if (!analyticsConsent) { + const existing = document.querySelector(scriptSelector); + existing?.remove(); + if (window._paq) { + window._paq.length = 0; + } + delete (window as any).__matomoInitialized; + return; + } + + window._paq = window._paq || []; + const { _paq } = window; + + if (!(window as any).__matomoInitialized) { + _paq.push(['setTrackerUrl', `${base}/matomo.php`]); + _paq.push(['setSiteId', config.siteId]); + _paq.push(['disableCookies']); + _paq.push(['enableLinkTracking']); + + if (!document.querySelector(scriptSelector)) { + const script = document.createElement('script'); + script.async = true; + script.src = `${base}/matomo.js`; + script.dataset.matomo = base; + document.body.appendChild(script); + } + + (window as any).__matomoInitialized = true; + } + }, [config, analyticsConsent]); + + useEffect(() => { + if ( + !config?.enabled || + !config.url || + !config.siteId || + typeof window === 'undefined' || + !analyticsConsent + ) { + return; + } + + window._paq = window._paq || []; + const { _paq } = window; + const currentUrl = + typeof window !== 'undefined' ? `${window.location.origin}${page.url}` : page.url; + + _paq.push(['setCustomUrl', currentUrl]); + if (typeof document !== 'undefined') { + _paq.push(['setDocumentTitle', document.title]); + } + _paq.push(['trackPageView']); + }, [config, analyticsConsent, page.url]); + + if (!config?.enabled || !config.url || !config.siteId || !analyticsConsent) { + return null; + } + + const base = config.url.replace(/\/$/, ''); + const noscriptSrc = `${base}/matomo.php?idsite=${encodeURIComponent(config.siteId)}&rec=1`; + + return ( + + ); +}; + +export default MatomoTracker; diff --git a/resources/js/components/consent/CookieBanner.tsx b/resources/js/components/consent/CookieBanner.tsx new file mode 100644 index 0000000..6249e9b --- /dev/null +++ b/resources/js/components/consent/CookieBanner.tsx @@ -0,0 +1,175 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Switch } from '@/components/ui/switch'; +import { Separator } from '@/components/ui/separator'; +import { useConsent, ConsentPreferences } from '@/contexts/consent'; + +const CookieBanner: React.FC = () => { + const { t } = useTranslation('common'); + const { + showBanner, + acceptAll, + rejectAll, + preferences, + savePreferences, + isPreferencesOpen, + openPreferences, + closePreferences, + } = useConsent(); + + const [draftPreferences, setDraftPreferences] = useState(preferences); + + useEffect(() => { + if (isPreferencesOpen) { + setDraftPreferences(preferences); + } + }, [isPreferencesOpen, preferences]); + + const analyticsDescription = useMemo( + () => t('consent.modal.analytics_desc'), + [t], + ); + + const handleSave = () => { + savePreferences({ analytics: draftPreferences.analytics }); + }; + + const handleOpenChange = (open: boolean) => { + if (open) { + openPreferences(); + return; + } + closePreferences(); + }; + + return ( + <> + {showBanner && !isPreferencesOpen && ( +
+
+
+
+

+ {t('consent.banner.title')} +

+

+ {t('consent.banner.body')} +

+
+
+ + + +
+
+
+
+ )} + + + + + {t('consent.modal.title')} + + {t('consent.modal.description')} + + + +
+
+
+
+

+ {t('consent.modal.functional')} +

+

+ {t('consent.modal.functional_desc')} +

+
+ + {t('consent.modal.required')} + +
+
+ +
+
+
+

+ {t('consent.modal.analytics')} +

+

{analyticsDescription}

+
+ + setDraftPreferences((prev) => ({ + ...prev, + analytics: checked, + functional: true, + })) + } + aria-label={t('consent.modal.analytics')} + /> +
+
+
+ + + + +
+ + +
+
+ + +
+
+
+
+ + ); +}; + +export default CookieBanner; diff --git a/resources/js/contexts/consent.tsx b/resources/js/contexts/consent.tsx new file mode 100644 index 0000000..56ac96f --- /dev/null +++ b/resources/js/contexts/consent.tsx @@ -0,0 +1,185 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +const CONSENT_STORAGE_KEY = 'fotospiel.consent'; +const CONSENT_VERSION = '2025-10-17-1'; + +export type ConsentCategory = 'functional' | 'analytics'; + +export type ConsentPreferences = Record; + +interface StoredConsent { + version: string; + preferences: ConsentPreferences; + decisionMade: boolean; + updatedAt: string | null; +} + +const defaultPreferences: ConsentPreferences = { + functional: true, + analytics: false, +}; + +const defaultState: StoredConsent = { + version: CONSENT_VERSION, + preferences: { ...defaultPreferences }, + decisionMade: false, + updatedAt: null, +}; + +interface ConsentContextValue { + preferences: ConsentPreferences; + decisionMade: boolean; + showBanner: boolean; + acceptAll: () => void; + rejectAll: () => void; + savePreferences: (preferences: Partial) => void; + hasConsent: (category: ConsentCategory) => boolean; + openPreferences: () => void; + closePreferences: () => void; + isPreferencesOpen: boolean; +} + +const ConsentContext = createContext(undefined); + +function normalizeState(state: StoredConsent | null): StoredConsent { + if (!state || state.version !== CONSENT_VERSION) { + return { ...defaultState }; + } + + return { + version: CONSENT_VERSION, + decisionMade: state.decisionMade ?? false, + updatedAt: state.updatedAt ?? null, + preferences: { + ...defaultPreferences, + ...state.preferences, + functional: true, + }, + }; +} + +function getInitialState(): StoredConsent { + if (typeof window === 'undefined') { + return { ...defaultState }; + } + + try { + const raw = window.localStorage.getItem(CONSENT_STORAGE_KEY); + if (!raw) { + return { ...defaultState }; + } + + const parsed = JSON.parse(raw) as StoredConsent; + return normalizeState(parsed); + } catch { + return { ...defaultState }; + } +} + +export const ConsentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [state, setState] = useState(() => getInitialState()); + const [isPreferencesOpen, setPreferencesOpen] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(state)); + }, [state]); + + const acceptAll = useCallback(() => { + setState({ + version: CONSENT_VERSION, + preferences: { functional: true, analytics: true }, + decisionMade: true, + updatedAt: new Date().toISOString(), + }); + setPreferencesOpen(false); + }, []); + + const rejectAll = useCallback(() => { + setState({ + version: CONSENT_VERSION, + preferences: { functional: true, analytics: false }, + decisionMade: true, + updatedAt: new Date().toISOString(), + }); + setPreferencesOpen(false); + }, []); + + const savePreferences = useCallback((preferences: Partial) => { + setState((prev) => ({ + version: CONSENT_VERSION, + preferences: { + ...defaultPreferences, + ...prev.preferences, + ...preferences, + functional: true, + }, + decisionMade: true, + updatedAt: new Date().toISOString(), + })); + setPreferencesOpen(false); + }, []); + + const hasConsent = useCallback( + (category: ConsentCategory) => { + return Boolean(state.preferences?.[category]); + }, + [state.preferences], + ); + + const openPreferences = useCallback(() => { + setPreferencesOpen(true); + }, []); + + const closePreferences = useCallback(() => { + setPreferencesOpen(false); + }, []); + + const value = useMemo( + () => ({ + preferences: state.preferences, + decisionMade: state.decisionMade, + showBanner: !state.decisionMade, + acceptAll, + rejectAll, + savePreferences, + hasConsent, + openPreferences, + closePreferences, + isPreferencesOpen, + }), + [ + state.preferences, + state.decisionMade, + acceptAll, + rejectAll, + savePreferences, + hasConsent, + openPreferences, + closePreferences, + isPreferencesOpen, + ], + ); + + return {children}; +}; + +export const useConsent = (): ConsentContextValue => { + const context = useContext(ConsentContext); + + if (!context) { + throw new Error('useConsent must be used within a ConsentProvider'); + } + + return context; +}; diff --git a/resources/js/guest/components/EmotionPicker.tsx b/resources/js/guest/components/EmotionPicker.tsx index a2da7ae..cc39159 100644 --- a/resources/js/guest/components/EmotionPicker.tsx +++ b/resources/js/guest/components/EmotionPicker.tsx @@ -16,8 +16,8 @@ interface EmotionPickerProps { } export default function EmotionPicker({ onSelect }: EmotionPickerProps) { - const { token: slug } = useParams<{ token: string }>(); - const eventKey = slug ?? ''; + const { token } = useParams<{ token: string }>(); + const eventKey = token ?? ''; const navigate = useNavigate(); const [emotions, setEmotions] = useState([]); const [loading, setLoading] = useState(true); diff --git a/resources/js/guest/components/GalleryPreview.tsx b/resources/js/guest/components/GalleryPreview.tsx index c2cbd04..76f822d 100644 --- a/resources/js/guest/components/GalleryPreview.tsx +++ b/resources/js/guest/components/GalleryPreview.tsx @@ -1,14 +1,13 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; import { getDeviceId } from '../lib/device'; import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; -type Props = { slug: string }; +type Props = { token: string }; -export default function GalleryPreview({ slug }: Props) { - const { photos, loading } = usePollGalleryDelta(slug); +export default function GalleryPreview({ token }: Props) { + const { photos, loading } = usePollGalleryDelta(token); const [mode, setMode] = React.useState<'latest' | 'popular' | 'myphotos'>('latest'); const items = React.useMemo(() => { @@ -82,7 +81,7 @@ export default function GalleryPreview({ slug }: Props) { My Photos - + Alle ansehen → @@ -97,7 +96,7 @@ export default function GalleryPreview({ slug }: Props) { )}
{items.map((p: any) => ( - +
@@ -95,7 +95,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st const { event, status } = useEventData(); const guestName = - identity && identity.eventKey === slug && identity.hydrated && identity.name ? identity.name : null; + identity && identity.eventKey === eventToken && identity.hydrated && identity.name ? identity.name : null; if (status === 'loading') { return ( @@ -114,7 +114,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st } const stats = - statsContext && statsContext.eventKey === slug ? statsContext : undefined; + statsContext && statsContext.eventKey === eventToken ? statsContext : undefined; return (
diff --git a/resources/js/guest/pages/AchievementsPage.tsx b/resources/js/guest/pages/AchievementsPage.tsx index 4592f3e..b0df74a 100644 --- a/resources/js/guest/pages/AchievementsPage.tsx +++ b/resources/js/guest/pages/AchievementsPage.tsx @@ -272,17 +272,17 @@ function SummaryCards({ data }: { data: AchievementsPayload }) { ); } -function PersonalActions({ slug }: { slug: string }) { +function PersonalActions({ token }: { token: string }) { return (
- + diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index 11e9505..e9f38b9 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -1,4 +1,4 @@ -import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Page } from './_util'; import { useParams, useSearchParams } from 'react-router-dom'; import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; @@ -12,8 +12,8 @@ import PhotoLightbox from './PhotoLightbox'; import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi'; export default function GalleryPage() { - const { token: slug } = useParams<{ token?: string }>(); - const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(slug ?? ''); + const { token } = useParams<{ token?: string }>(); + const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? ''); const [filter, setFilter] = React.useState('latest'); const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState(null); const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false); @@ -38,15 +38,15 @@ export default function GalleryPage() { // Load event and package info useEffect(() => { - if (!slug) return; + if (!token) return; const loadEventData = async () => { try { setEventLoading(true); const [eventData, packageData, statsData] = await Promise.all([ - fetchEvent(slug), - getEventPackage(slug), - fetchStats(slug), + fetchEvent(token), + getEventPackage(token), + fetchStats(token), ]); setEvent(eventData); setEventPackage(packageData); @@ -59,7 +59,7 @@ export default function GalleryPage() { }; loadEventData(); - }, [slug]); + }, [token]); const myPhotoIds = React.useMemo(() => { try { @@ -99,7 +99,7 @@ export default function GalleryPage() { } } - if (!slug) { + if (!token) { return

Event nicht gefunden.

; } @@ -236,7 +236,7 @@ export default function GalleryPage() { currentIndex={currentPhotoIndex} onClose={() => setCurrentPhotoIndex(null)} onIndexChange={(index: number) => setCurrentPhotoIndex(index)} - slug={slug} + token={token} /> )} diff --git a/resources/js/guest/pages/HomePage.tsx b/resources/js/guest/pages/HomePage.tsx index fa8e03a..51915d7 100644 --- a/resources/js/guest/pages/HomePage.tsx +++ b/resources/js/guest/pages/HomePage.tsx @@ -141,7 +141,7 @@ export default function HomePage() { - +
); } diff --git a/resources/js/guest/pages/LandingPage.tsx b/resources/js/guest/pages/LandingPage.tsx index 4897558..e8f48b1 100644 --- a/resources/js/guest/pages/LandingPage.tsx +++ b/resources/js/guest/pages/LandingPage.tsx @@ -61,7 +61,11 @@ export default function LandingPage() { return; } const data = await res.json(); - const targetKey = data.join_token ?? data.slug ?? normalized; + const targetKey = data.join_token ?? ''; + if (!targetKey) { + setErrorKey('eventClosed'); + return; + } const storedName = readGuestName(targetKey); if (!storedName) { nav(`/setup/${encodeURIComponent(targetKey)}`); diff --git a/resources/js/guest/pages/PhotoLightbox.tsx b/resources/js/guest/pages/PhotoLightbox.tsx index 9d6aa34..44f0d37 100644 --- a/resources/js/guest/pages/PhotoLightbox.tsx +++ b/resources/js/guest/pages/PhotoLightbox.tsx @@ -23,15 +23,15 @@ interface Props { currentIndex?: number; onClose?: () => void; onIndexChange?: (index: number) => void; - slug?: string; + token?: string; } -export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, slug }: Props) { +export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, token }: Props) { const params = useParams<{ token?: string; photoId?: string }>(); const location = useLocation(); const navigate = useNavigate(); const photoId = params.photoId; - const eventSlug = params.token || slug; + const eventToken = params.token || token; const { t } = useTranslation(); const [standalonePhoto, setStandalonePhoto] = useState(null); @@ -53,7 +53,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh // Fetch single photo for standalone mode useEffect(() => { - if (isStandalone && photoId && !standalonePhoto && eventSlug) { + if (isStandalone && photoId && !standalonePhoto && eventToken) { const fetchPhoto = async () => { setLoading(true); setError(null); @@ -80,7 +80,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh } else if (!isStandalone) { setLoading(false); } - }, [isStandalone, photoId, eventSlug, standalonePhoto, location.state, t]); + }, [isStandalone, photoId, eventToken, standalonePhoto, location.state, t]); // Update likes when photo changes React.useEffect(() => { @@ -133,7 +133,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh // Load task info if photo has task_id and event key is available React.useEffect(() => { - if (!photo?.task_id || !eventSlug) { + if (!photo?.task_id || !eventToken) { setTask(null); setTaskLoading(false); return; @@ -144,7 +144,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh (async () => { setTaskLoading(true); try { - const res = await fetch(`/api/v1/events/${encodeURIComponent(eventSlug)}/tasks`); + const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/tasks`); if (res.ok) { const tasks = await res.json(); const foundTask = tasks.find((t: any) => t.id === taskId); @@ -175,7 +175,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh setTaskLoading(false); } })(); - }, [photo?.task_id, eventSlug, t]); + }, [photo?.task_id, eventToken, t]); async function onLike() { if (liked || !photo) return; diff --git a/resources/js/guest/pages/TaskPickerPage.tsx b/resources/js/guest/pages/TaskPickerPage.tsx index ed42616..4a9cd1b 100644 --- a/resources/js/guest/pages/TaskPickerPage.tsx +++ b/resources/js/guest/pages/TaskPickerPage.tsx @@ -28,8 +28,8 @@ const TASK_PROGRESS_TARGET = 5; const TIMER_VIBRATION = [0, 60, 120, 60]; export default function TaskPickerPage() { - const { token: slug } = useParams<{ token: string }>(); - const eventKey = slug ?? ''; + const { token } = useParams<{ token: string }>(); + const eventKey = token ?? ''; const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -92,12 +92,12 @@ export default function TaskPickerPage() { map.set(task.emotion.slug, task.emotion.name); } }); - return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: slugValue, name })); + return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: tokenValue, name })); }, [tasks]); const filteredTasks = React.useMemo(() => { if (selectedEmotion === 'all') return tasks; - return tasks.filter((task) => task.emotion?.slug === selectedEmotion); + return tasks.filter((task) => task.emotion?.token === selectedEmotion); }, [tasks, selectedEmotion]); const selectRandomTask = React.useCallback( diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index 214cc76..9ac35e8 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -56,13 +56,13 @@ const DEFAULT_PREFS: CameraPreferences = { }; export default function UploadPage() { - const { token: slug } = useParams<{ token: string }>(); - const eventKey = slug ?? ''; + const { token } = useParams<{ token: string }>(); + const eventKey = token ?? ''; const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { appearance } = useAppearance(); const isDarkMode = appearance === 'dark'; - const { markCompleted } = useGuestTaskProgress(slug); + const { markCompleted } = useGuestTaskProgress(token); const { t } = useTranslation(); const taskIdParam = searchParams.get('task'); @@ -138,7 +138,7 @@ export default function UploadPage() { // Load task metadata useEffect(() => { - if (!slug || !taskId) { + if (!token || !taskId) { setTaskError(t('upload.loadError.title')); setLoadingTask(false); return; @@ -545,7 +545,7 @@ export default function UploadPage() { if (!supportsCamera && !task) { return (
-
+
{t('upload.cameraUnsupported.message')} @@ -559,7 +559,7 @@ export default function UploadPage() { if (loadingTask) { return (
-
+

{t('upload.preparing')}

@@ -572,7 +572,7 @@ export default function UploadPage() { if (!canUpload) { return (
-
+
@@ -638,7 +638,7 @@ export default function UploadPage() { return (
-
+
- - {t('packages.details')} - {t('packages.customer_opinions')} + + {t('packages.details')} + {t('packages.more_details_tab')} + {t('packages.customer_opinions')} - + {(() => { const accent = getAccentTheme(selectedVariant); const metrics = resolvePackageMetrics(selectedPackage, selectedVariant, t, tCommon); - const descriptionEntries = selectedPackage.description_breakdown ?? []; + const topFeatureBadges = selectedPackage.features.slice(0, 3); + const hasMoreFeatures = selectedPackage.features.length > topFeatureBadges.length; + const quickFacts = metrics.slice(0, 2); + const showDeepLink = + hasMoreFeatures || (selectedPackage.description_breakdown?.length ?? 0) > 0; return (
@@ -740,64 +791,177 @@ function PackageCard({ 'radial-gradient(circle at top left, rgba(255,182,193,0.45), transparent 55%), radial-gradient(circle at bottom right, rgba(250,204,21,0.35), transparent 55%)', }} /> -
- - - {t('packages.features_label')} - -
- {selectedPackage.features.map((feature) => ( - +
+ + + {t('packages.feature_highlights')} + +
+ {topFeatureBadges.map((feature) => ( + + {getFeatureIcon(feature)} + {t(`packages.feature_${feature}`)} + + ))} + {selectedPackage.watermark_allowed === false && ( + + + {t('packages.no_watermark')} + + )} + {selectedPackage.branding_allowed && ( + + + {t('packages.custom_branding')} + + )} +
+
+
+ {showDeepLink && ( + )} + +

+ {t('packages.order_hint')} +

-
- {descriptionEntries.length > 0 && ( -
-

- {t('packages.breakdown_label')} -

-
- {descriptionEntries.map((entry, index) => ( -
- {entry.title && ( -

- {entry.title} -

- )} -

{entry.value}

-
- ))} -
-
+
+

+ {t('packages.quick_facts')} +

+

+ {t('packages.quick_facts_hint')} +

+
    + {quickFacts.map((metric) => ( +
  • +

    {metric.value}

    +

    + {metric.label} +

    +
  • + ))} +
+ {showDeepLink && ( + )} -
-

+

+
+ ); + })()} + + + + {(() => { + const accent = getAccentTheme(selectedVariant); + const metrics = resolvePackageMetrics(selectedPackage, selectedVariant, t, tCommon); + const descriptionEntries = selectedPackage.description_breakdown ?? []; + const entriesWithTitle = descriptionEntries.filter((entry) => entry.title); + const entriesWithoutTitle = descriptionEntries.filter((entry) => !entry.title); + + return ( +
+
+
+ + {t('packages.features_label')} + + {selectedHighlight && ( + + {t('packages.badge_deep_dive')} + + )} +
+
+ {selectedPackage.features.map((feature) => ( + + {getFeatureIcon(feature)} + {t(`packages.feature_${feature}`)} + + ))} + {selectedPackage.watermark_allowed === false && ( + + + {t('packages.no_watermark')} + + )} + {selectedPackage.branding_allowed && ( + + + {t('packages.custom_branding')} + + )} +
+
+ + {metrics.length > 0 && ( +
+

{t('packages.limits_label')}

+

+ {t('packages.limits_label_hint')} +

{metrics.map((metric) => (
))}
-
-
- -

- {t('packages.order_hint')} +

+ )} + + {descriptionEntries.length > 0 && ( +
+

+ {t('packages.breakdown_label')} +

+

+ {t('packages.breakdown_label_hint')}

-
-
+ {entriesWithTitle.length > 0 && ( + + {entriesWithTitle.map((entry, index) => ( + + + {entry.title} + + + {entry.value} + + + ))} + + )} + {entriesWithoutTitle.length > 0 && ( +
+ {entriesWithoutTitle.map((entry, index) => ( +
+ {entry.value} +
+ ))} +
+ )} + + )}
); })()}
- +

{t('packages.what_customers_say')}

-
+
{testimonials.map((testimonial, index) => (
(null); const hasMountedRef = useRef(false); + const { trackEvent } = useAnalytics(); const stepConfig = useMemo(() => baseStepConfig.map(step => ({ @@ -75,6 +77,14 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin return (currentIndex / (stepConfig.length - 1)) * 100; }, [currentIndex, stepConfig]); + useEffect(() => { + trackEvent({ + category: 'marketing_checkout', + action: 'step_view', + name: currentStep, + }); + }, [currentStep, trackEvent]); + useEffect(() => { if (typeof window === 'undefined' || !progressRef.current) { return; @@ -95,6 +105,34 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin }); }, [currentStep]); + const handleNext = useCallback(() => { + const targetStep = stepConfig[currentIndex + 1]?.id ?? 'end'; + trackEvent({ + category: 'marketing_checkout', + action: 'step_next', + name: `${currentStep}->${targetStep}`, + }); + nextStep(); + }, [currentIndex, currentStep, nextStep, stepConfig, trackEvent]); + + const handlePrevious = useCallback(() => { + const targetStep = stepConfig[currentIndex - 1]?.id ?? 'start'; + trackEvent({ + category: 'marketing_checkout', + action: 'step_previous', + name: `${currentStep}->${targetStep}`, + }); + previousStep(); + }, [currentIndex, currentStep, previousStep, stepConfig, trackEvent]); + + const handleViewProfile = useCallback(() => { + window.location.href = '/settings/profile'; + }, []); + + const handleGoToAdmin = useCallback(() => { + window.location.href = '/event-admin'; + }, []); + return (
@@ -108,14 +146,16 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin {currentStep === "payment" && ( )} - {currentStep === "confirmation" && } + {currentStep === "confirmation" && ( + + )}
- -
diff --git a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx index 3d0ac3d..de15971 100644 --- a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { usePage } from "@inertiajs/react"; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -6,17 +6,52 @@ import { useCheckoutWizard } from "../WizardContext"; import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm"; import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm"; import { useTranslation } from 'react-i18next'; +import toast from 'react-hot-toast'; +import { LoaderCircle } from "lucide-react"; interface AuthStepProps { privacyHtml: string; } +type GoogleAuthFlash = { + status?: string | null; + error?: string | null; +}; + +const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => ( + +); + export const AuthStep: React.FC = ({ privacyHtml }) => { const { t } = useTranslation('marketing'); const page = usePage<{ locale?: string }>(); const locale = page.props.locale ?? "de"; + const googleAuth = useMemo(() => { + const props = page.props as Record; + return props.googleAuth ?? {}; + }, [page.props]); const { isAuthenticated, authUser, setAuthUser, nextStep, selectedPackage } = useCheckoutWizard(); const [mode, setMode] = useState<'login' | 'register'>('register'); + const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false); + + useEffect(() => { + if (googleAuth?.status === 'success') { + toast.success(t('checkout.auth_step.google_success_toast')); + } + }, [googleAuth?.status, t]); + + useEffect(() => { + if (googleAuth?.error) { + toast.error(googleAuth.error); + } + }, [googleAuth?.error]); const handleLoginSuccess = (payload: AuthUserPayload | null) => { if (!payload) { @@ -46,6 +81,20 @@ export const AuthStep: React.FC = ({ privacyHtml }) => { nextStep(); }; + const handleGoogleLogin = useCallback(() => { + if (!selectedPackage) { + toast.error(t('checkout.auth_step.google_missing_package')); + return; + } + + setIsRedirectingToGoogle(true); + const params = new URLSearchParams({ + package_id: String(selectedPackage.id), + locale, + }); + window.location.href = `/checkout/auth/google?${params.toString()}`; + }, [locale, selectedPackage, t]); + if (isAuthenticated && authUser) { return (
@@ -79,11 +128,28 @@ export const AuthStep: React.FC = ({ privacyHtml }) => { > {t('checkout.auth_step.switch_to_login')} - - {t('checkout.auth_step.google_coming_soon')} - +
+ {googleAuth?.error && ( + + {t('checkout.auth_step.google_error_title')} + {googleAuth.error} + + )} +
{mode === 'register' ? ( selectedPackage && ( diff --git a/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx b/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx index 1aeba8b..8a7203a 100644 --- a/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx @@ -6,11 +6,27 @@ import { useTranslation } from 'react-i18next'; interface ConfirmationStepProps { onViewProfile?: () => void; + onGoToAdmin?: () => void; } -export const ConfirmationStep: React.FC = ({ onViewProfile }) => { +export const ConfirmationStep: React.FC = ({ onViewProfile, onGoToAdmin }) => { const { t } = useTranslation('marketing'); const { selectedPackage } = useCheckoutWizard(); + const handleProfile = React.useCallback(() => { + if (typeof onViewProfile === 'function') { + onViewProfile(); + return; + } + window.location.href = '/settings/profile'; + }, [onViewProfile]); + + const handleAdmin = React.useCallback(() => { + if (typeof onGoToAdmin === 'function') { + onGoToAdmin(); + return; + } + window.location.href = '/event-admin'; + }, [onGoToAdmin]); return (
@@ -22,10 +38,10 @@ export const ConfirmationStep: React.FC = ({ onViewProfil
- - +
); diff --git a/resources/js/pages/marketing/checkout/steps/PackageStep.tsx b/resources/js/pages/marketing/checkout/steps/PackageStep.tsx index d76f65d..080ffae 100644 --- a/resources/js/pages/marketing/checkout/steps/PackageStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PackageStep.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useState } from "react"; import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { Check, Package as PackageIcon, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -13,14 +14,19 @@ const currencyFormatter = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, }); -function PackageSummary({ pkg }: { pkg: CheckoutPackage }) { +function translateFeature(feature: string, t: TFunction<'marketing'>) { + const fallback = feature.replace(/_/g, ' '); + return t(`packages.feature_${feature}`, { defaultValue: fallback }); +} + +function PackageSummary({ pkg, t }: { pkg: CheckoutPackage; t: TFunction<'marketing'> }) { const isFree = pkg.price === 0; return ( - + - - + + {pkg.name} @@ -29,19 +35,41 @@ function PackageSummary({ pkg }: { pkg: CheckoutPackage }) {
- - {pkg.price === 0 ? "Kostenlos" : currencyFormatter.format(pkg.price)} + + {pkg.price === 0 ? t('packages.free') : currencyFormatter.format(pkg.price)} - - {pkg.type === "reseller" ? "Reseller" : "Endkunde"} + + {pkg.type === 'reseller' ? t('packages.subscription') : t('packages.one_time')}
+ {pkg.gallery_duration_label && ( +
+ {t('packages.gallery_days_label')}: {pkg.gallery_duration_label} +
+ )} + {Array.isArray(pkg.description_breakdown) && pkg.description_breakdown.length > 0 && ( +
+

+ {t('packages.breakdown_label')} +

+
+ {pkg.description_breakdown.map((row, index) => ( +
+ {row.title && ( +

{row.title}

+ )} +

{row.value}

+
+ ))} +
+
+ )} {Array.isArray(pkg.features) && pkg.features.length > 0 && (
    {pkg.features.map((feature, index) => (
  • - - {feature} + + {translateFeature(feature, t)}
  • ))}
@@ -51,7 +79,7 @@ function PackageSummary({ pkg }: { pkg: CheckoutPackage }) { ); } -function PackageOption({ pkg, isActive, onSelect }: { pkg: CheckoutPackage; isActive: boolean; onSelect: () => void }) { +function PackageOption({ pkg, isActive, onSelect, t }: { pkg: CheckoutPackage; isActive: boolean; onSelect: () => void; t: TFunction<'marketing'> }) { const isFree = pkg.price === 0; return ( @@ -69,7 +97,7 @@ function PackageOption({ pkg, isActive, onSelect }: { pkg: CheckoutPackage; isAc
{pkg.name} - {pkg.price === 0 ? "Kostenlos" : currencyFormatter.format(pkg.price)} + {pkg.price === 0 ? t('packages.free') : currencyFormatter.format(pkg.price)}

{pkg.description}

@@ -125,7 +153,7 @@ export const PackageStep: React.FC = () => { return (
- +
); }; -// Komponente für PayPal-Zahlungen -const PayPalPaymentForm: React.FC<{ onError: (error: string) => void; onSuccess: () => void; selectedPackage: any; t: any; authUser: any; paypalClientId: string }> = ({ onError, onSuccess, selectedPackage, t, authUser, paypalClientId }) => { +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 { - const response = await fetch('/paypal/create-order', { + onProcessing(); + + const endpoint = isReseller ? '/paypal/create-subscription' : '/paypal/create-order'; + const payload: Record = { + package_id: selectedPackage.id, + }; + + if (isReseller) { + if (!paypalPlanId) { + const message = t('checkout.payment_step.paypal_missing_plan'); + onError(message); + throw new Error(message); + } + payload.plan_id = paypalPlanId; + } + + 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({ - tenant_id: authUser?.tenant_id || authUser?.id, // Annahme: tenant_id verfügbar - package_id: selectedPackage?.id, - }), + body: JSON.stringify(payload), }); const data = await response.json(); - if (response.ok && data.id) { - return data.id; + 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')); - throw new Error('Failed to create order'); } - } catch (err) { + + 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 err; + throw error; } }; @@ -142,9 +184,7 @@ const PayPalPaymentForm: React.FC<{ onError: (error: string) => void; onSuccess: 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', }, - body: JSON.stringify({ - order_id: data.orderID, - }), + body: JSON.stringify({ order_id: data.orderID }), }); const result = await response.json(); @@ -154,105 +194,209 @@ const PayPalPaymentForm: React.FC<{ onError: (error: string) => void; onSuccess: } else { onError(result.error || t('checkout.payment_step.paypal_capture_error')); } - } catch (err) { + } catch (error) { + console.error('PayPal capture failed', error); onError(t('checkout.payment_step.network_error')); } }; - const onErrorHandler = (error: any) => { - console.error('PayPal Error:', error); + const handleError = (error: unknown) => { + console.error('PayPal error', error); onError(t('checkout.payment_step.paypal_error')); }; - const onCancel = () => { + const handleCancel = () => { onError(t('checkout.payment_step.paypal_cancelled')); }; return ( -
-
-

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

- -
+
+

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

+ createOrder()} + onApprove={onApprove} + onError={handleError} + onCancel={handleCancel} + />
); }; -// Wrapper-Komponente +const statusVariantMap: Record = { + idle: 'secondary', + loading: 'secondary', + ready: 'secondary', + processing: 'secondary', + error: 'destructive', + success: 'success', +}; + export const PaymentStep: React.FC = ({ stripePublishableKey, paypalClientId }) => { const { t } = useTranslation('marketing'); const { selectedPackage, authUser, nextStep, resetPaymentState } = useCheckoutWizard(); - const [clientSecret, setClientSecret] = useState(''); - const [paymentMethod, setPaymentMethod] = useState<'stripe' | 'paypal'>('stripe'); - const [error, setError] = useState(''); - const [isFree, setIsFree] = useState(false); + + const [paymentMethod, setPaymentMethod] = useState('stripe'); + const [clientSecret, setClientSecret] = useState(''); + const [status, setStatus] = useState('idle'); + const [statusDetail, setStatusDetail] = useState(''); + const [intentRefreshKey, setIntentRefreshKey] = useState(0); + const [processingProvider, setProcessingProvider] = useState(null); + + const stripePromise = useMemo(() => loadStripe(stripePublishableKey), [stripePublishableKey]); + const isFree = useMemo(() => (selectedPackage ? selectedPackage.price <= 0 : false), [selectedPackage]); + const isReseller = selectedPackage?.type === 'reseller'; + + 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(() => { - const free = selectedPackage ? selectedPackage.price <= 0 : false; - setIsFree(free); - if (free) { + setStatus('idle'); + setStatusDetail(''); + setClientSecret(''); + setProcessingProvider(null); + }, [selectedPackage?.id]); + + useEffect(() => { + if (isFree) { resetPaymentState(); + setStatus('ready'); + setStatusDetail(''); return; } - if (paymentMethod === 'stripe' && authUser && selectedPackage) { - const loadPaymentIntent = 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, - }), - }); - - const data = await response.json(); - - if (response.ok && data.client_secret) { - setClientSecret(data.client_secret); - setError(''); - } else { - setError(data.error || t('checkout.payment_step.payment_intent_error')); - } - } catch (err) { - setError(t('checkout.payment_step.network_error')); - } - }; - - loadPaymentIntent(); - } else { - setClientSecret(''); + if (!selectedPackage) { + return; } - }, [selectedPackage?.id, authUser, paymentMethod, isFree, t, resetPaymentState]); - const handlePaymentError = (errorMsg: string) => { - setError(errorMsg); + if (paymentMethod === 'paypal') { + if (paypalDisabled) { + setStatus('error'); + setStatusDetail(t('checkout.payment_step.paypal_missing_plan')); + } else { + setStatus('ready'); + setStatusDetail(''); + } + return; + } + + if (!authUser) { + setStatus('error'); + setStatusDetail(t('checkout.payment_step.auth_required')); + return; + } + + let cancelled = false; + setStatus('loading'); + setStatusDetail(t('checkout.payment_step.status_loading')); + setClientSecret(''); + + 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 }), + }); + + const data = await response.json(); + + if (!response.ok || !data.client_secret) { + const message = data.error || t('checkout.payment_step.payment_intent_error'); + if (!cancelled) { + setStatus('error'); + setStatusDetail(message); + } + 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')); + } + } + }; + + loadIntent(); + + return () => { + cancelled = true; + }; + }, [authUser, intentRefreshKey, isFree, paymentMethod, paypalDisabled, resetPaymentState, selectedPackage, t]); + + const providerLabel = useCallback((provider: Provider) => { + switch (provider) { + case 'paypal': + return 'PayPal'; + default: + return 'Stripe'; + } + }, []); + + 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); }; - const handlePaymentSuccess = () => { - setTimeout(() => nextStep(), 1000); - }; - - // Für kostenlose Pakete if (isFree) { return (
{t('checkout.payment_step.free_package_title')} - - {t('checkout.payment_step.free_package_desc')} - + {t('checkout.payment_step.free_package_desc')}
-
-
- ); - } + const renderStatusAlert = () => { + if (status === 'idle') { + return null; + } - const stripePromise = loadStripe(stripePublishableKey); + 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 (
- {/* Zahlungsmethode Auswahl */} -
+
- {error && ( - - {error} - - )} + {renderStatusAlert()} - {paymentMethod === 'stripe' && ( + {paymentMethod === 'stripe' && clientSecret && ( handleProcessing('stripe')} + onSuccess={() => handleSuccess('stripe')} + onError={(message) => handleError('stripe', message)} t={t} /> )} - {paymentMethod === 'paypal' && ( + {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} - authUser={authUser} - paypalClientId={paypalClientId} /> )} - {!clientSecret && paymentMethod === 'stripe' && ( -
-

- {t('checkout.payment_step.loading_payment')} -

-
+ {paymentMethod === 'paypal' && paypalDisabled && ( + + {t('checkout.payment_step.paypal_missing_plan')} + )}
); diff --git a/resources/js/pages/marketing/checkout/types.ts b/resources/js/pages/marketing/checkout/types.ts index beb99d6..12bf475 100644 --- a/resources/js/pages/marketing/checkout/types.ts +++ b/resources/js/pages/marketing/checkout/types.ts @@ -5,10 +5,17 @@ export interface CheckoutPackage { name: string; description: string; price: number; + description_breakdown?: Array<{ + title?: string | null; + value: string; + }>; + gallery_duration_label?: string | null; + events?: number | null; currency?: string; type: 'endcustomer' | 'reseller'; features: string[]; limits?: Record; + paypal_plan_id?: string | null; [key: string]: unknown; } @@ -37,4 +44,3 @@ export interface CheckoutWizardContextValue extends CheckoutWizardState { resetPaymentState: () => void; cancelCheckout: () => void; } - diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 6c93a63..6ebc2d2 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -5,6 +5,9 @@ return [ 'platform' => 'Plattform', 'library' => 'Bibliothek', 'content' => 'Inhalte', + 'platform_management' => 'Plattformverwaltung', + 'billing' => 'Billing & Finanzen', + 'security' => 'Sicherheit', ], 'common' => [ @@ -33,6 +36,8 @@ return [ 'settings' => 'Einstellungen', 'join' => 'Beitreten', 'unnamed' => 'Ohne Namen', + 'from' => 'Von', + 'until' => 'Bis', ], 'photos' => [ @@ -213,6 +218,23 @@ return [ 'uploads_per_day' => [ 'heading' => 'Uploads (14 Tage)', ], + 'credit_alerts' => [ + 'low_balance_label' => 'Mandanten mit niedrigen Credits', + 'low_balance_desc' => 'Benötigen Betreuung', + 'monthly_revenue_label' => 'Umsatz (Monat)', + 'monthly_revenue_desc' => 'Aktueller Monat (:month)', + 'active_subscriptions_label' => 'Aktive Abos', + 'active_subscriptions_desc' => 'Laufende Pakete', + ], + 'revenue_trend' => [ + 'heading' => 'Monatliche Einnahmen', + 'series' => 'Umsatz (€)', + ], + 'top_tenants_by_revenue' => [ + 'heading' => 'Top‑Mandanten nach Umsatz', + 'total' => 'Gesamt (€)', + 'count' => 'Käufe', + ], ], 'notifications' => [ @@ -228,6 +250,82 @@ return [ 'contact_email' => 'Kontakt‑E‑Mail', 'event_credits_balance' => 'Event‑Credits‑Kontostand', 'features' => 'Funktionen', + 'total_revenue' => 'Gesamtumsatz', + 'active_reseller_package' => 'Aktives Reseller-Paket', + 'remaining_events' => 'Verbleibende Events', + 'package_expires_at' => 'Ablaufdatum Paket', + 'is_active' => 'Aktiv', + 'is_suspended' => 'Suspendiert', + ], + 'actions' => [ + 'adjust_credits' => 'Credits anpassen', + 'adjust_credits_delta' => 'Anzahl Credits (positiv/negativ)', + 'adjust_credits_delta_hint' => 'Positive Werte fügen Credits hinzu, negative Werte ziehen ab.', + 'adjust_credits_reason' => 'Interne Notiz', + 'adjust_credits_success_title' => 'Credits aktualisiert', + 'adjust_credits_success_body' => 'Die Credits wurden um :delta verändert. Neuer Kontostand: :balance.', + ], + ], + + 'purchase_history' => [ + 'fields' => [ + 'tenant' => 'Mandant', + 'package' => 'Paket', + 'credits' => 'Credits', + 'price' => 'Preis', + 'currency' => 'Währung', + 'platform' => 'Plattform', + 'transaction_id' => 'Transaktions-ID', + 'purchased_at' => 'Kaufdatum', + ], + 'filters' => [ + 'purchased_at' => 'Zeitraum', + 'platform' => 'Plattform', + 'currency' => 'Währung', + 'tenant' => 'Mandant', + ], + 'actions' => [ + 'export' => 'Exportieren', + ], + 'platforms' => [ + 'ios' => 'iOS', + 'android' => 'Android', + 'web' => 'Web', + 'manual' => 'Manuell', + ], + 'export_success' => 'Export abgeschlossen. :count Einträge exportiert.', + ], + + 'oauth' => [ + 'fields' => [ + 'name' => 'Name', + 'client_id' => 'Client-ID', + 'client_secret' => 'Client-Secret', + 'tenant' => 'Mandant', + 'redirect_uris' => 'Redirect-URIs', + 'scopes' => 'Scopes', + 'is_active' => 'Aktiv', + 'description' => 'Beschreibung', + 'updated_at' => 'Zuletzt geändert', + ], + 'hints' => [ + 'client_secret' => 'Leer lassen, um das bestehende Secret zu behalten oder für PKCE-Clients ohne Secret.', + 'redirect_uris' => 'Eine URL pro Zeile. Die Callback-URL muss exakt übereinstimmen.', + ], + 'filters' => [ + 'is_active' => 'Status', + 'any' => 'Alle', + 'active' => 'Aktiv', + 'inactive' => 'Inaktiv', + ], + 'actions' => [ + 'regenerate_secret' => 'Secret neu generieren', + ], + 'notifications' => [ + 'secret_regenerated_title' => 'Neues Secret erstellt', + 'secret_regenerated_body' => 'Speichere das neue Secret sicher: :secret', + 'created_title' => 'OAuth-Client erstellt', + 'updated_title' => 'OAuth-Client gespeichert', ], ], diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index c13502a..1c5c9a3 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -5,6 +5,9 @@ return [ 'platform' => 'Platform', 'library' => 'Library', 'content' => 'Content', + 'platform_management' => 'Platform Management', + 'billing' => 'Billing & Finance', + 'security' => 'Security', ], 'common' => [ @@ -33,6 +36,8 @@ return [ 'settings' => 'Settings', 'join' => 'Join', 'unnamed' => 'Unnamed', + 'from' => 'From', + 'until' => 'Until', ], 'photos' => [ @@ -199,6 +204,23 @@ return [ 'uploads_per_day' => [ 'heading' => 'Uploads (14 days)', ], + 'credit_alerts' => [ + 'low_balance_label' => 'Tenants with low credits', + 'low_balance_desc' => 'May require follow-up', + 'monthly_revenue_label' => 'Revenue (month)', + 'monthly_revenue_desc' => 'Current month (:month)', + 'active_subscriptions_label' => 'Active subscriptions', + 'active_subscriptions_desc' => 'Recurring packages in good standing', + ], + 'revenue_trend' => [ + 'heading' => 'Monthly revenue', + 'series' => 'Revenue (€)', + ], + 'top_tenants_by_revenue' => [ + 'heading' => 'Top tenants by revenue', + 'total' => 'Total (€)', + 'count' => 'Purchases', + ], ], 'notifications' => [ @@ -209,11 +231,87 @@ return [ 'tenants' => [ 'fields' => [ - 'name' => 'Tenant Name', + 'name' => 'Tenant name', 'slug' => 'Slug', - 'contact_email' => 'Contact Email', - 'event_credits_balance' => 'Event Credits Balance', + 'contact_email' => 'Contact email', + 'event_credits_balance' => 'Event credits balance', 'features' => 'Features', + 'total_revenue' => 'Total revenue', + 'active_reseller_package' => 'Active reseller package', + 'remaining_events' => 'Remaining events', + 'package_expires_at' => 'Package expires at', + 'is_active' => 'Active', + 'is_suspended' => 'Suspended', + ], + 'actions' => [ + 'adjust_credits' => 'Adjust credits', + 'adjust_credits_delta' => 'Credit delta (positive/negative)', + 'adjust_credits_delta_hint' => 'Positive values grant credits, negative values deduct them.', + 'adjust_credits_reason' => 'Internal note', + 'adjust_credits_success_title' => 'Credits updated', + 'adjust_credits_success_body' => 'Credits changed by :delta. New balance: :balance.', + ], + ], + + 'purchase_history' => [ + 'fields' => [ + 'tenant' => 'Tenant', + 'package' => 'Package', + 'credits' => 'Credits', + 'price' => 'Price', + 'currency' => 'Currency', + 'platform' => 'Platform', + 'transaction_id' => 'Transaction ID', + 'purchased_at' => 'Purchased at', + ], + 'filters' => [ + 'purchased_at' => 'Date range', + 'platform' => 'Platform', + 'currency' => 'Currency', + 'tenant' => 'Tenant', + ], + 'actions' => [ + 'export' => 'Export', + ], + 'platforms' => [ + 'ios' => 'iOS', + 'android' => 'Android', + 'web' => 'Web', + 'manual' => 'Manual', + ], + 'export_success' => 'Export ready. :count rows exported.', + ], + + 'oauth' => [ + 'fields' => [ + 'name' => 'Name', + 'client_id' => 'Client ID', + 'client_secret' => 'Client secret', + 'tenant' => 'Tenant', + 'redirect_uris' => 'Redirect URIs', + 'scopes' => 'Scopes', + 'is_active' => 'Active', + 'description' => 'Description', + 'updated_at' => 'Last updated', + ], + 'hints' => [ + 'client_secret' => 'Leave blank to keep the current secret or for PKCE/public clients.', + 'redirect_uris' => 'One URL per line. Must exactly match the callback on the client.', + ], + 'filters' => [ + 'is_active' => 'Status', + 'any' => 'All', + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + 'actions' => [ + 'regenerate_secret' => 'Regenerate secret', + ], + 'notifications' => [ + 'secret_regenerated_title' => 'New secret generated', + 'secret_regenerated_body' => 'Store the new secret securely: :secret', + 'created_title' => 'OAuth client created', + 'updated_title' => 'OAuth client saved', ], ], diff --git a/routes/api.php b/routes/api.php index e4eba6a..c617f0b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -53,7 +53,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->only(['index', 'show', 'destroy']) ->parameters(['events' => 'event:slug']); - Route::middleware('package.check')->group(function () { + Route::middleware(['package.check', 'credit.check'])->group(function () { Route::post('events', [EventController::class, 'store'])->name('tenant.events.store'); Route::match(['put', 'patch'], 'events/{event:slug}', [EventController::class, 'update'])->name('tenant.events.update'); }); diff --git a/routes/web.php b/routes/web.php index 7f397f8..deae2e0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,10 +1,14 @@ group(function () { ->name('tenant.events.photos.archive'); }); -Route::get('/purchase-wizard/{package}', [CheckoutController::class, 'show'])->name('purchase.wizard'); -Route::get('/checkout/{package}', [CheckoutController::class, 'show'])->name('checkout.show'); +if (config('checkout.enabled')) { + Route::get('/purchase-wizard/{package}', [CheckoutController::class, 'show'])->name('purchase.wizard'); + Route::get('/checkout/{package}', [CheckoutController::class, 'show'])->name('checkout.show'); +} else { + Route::get('/purchase-wizard/{package}', function (Package $package) { + return redirect()->route('packages', ['highlight' => $package->slug]); + })->name('purchase.wizard'); + + Route::get('/checkout/{package}', function (Package $package) { + return redirect()->route('packages', ['highlight' => $package->slug]); + })->name('checkout.show'); +} Route::post('/checkout/login', [CheckoutController::class, 'login'])->name('checkout.login'); 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('/paypal/webhook', [PayPalWebhookController::class, 'verify'])->name('paypal.webhook'); diff --git a/tests/Feature/CheckoutGoogleControllerTest.php b/tests/Feature/CheckoutGoogleControllerTest.php new file mode 100644 index 0000000..f44d620 --- /dev/null +++ b/tests/Feature/CheckoutGoogleControllerTest.php @@ -0,0 +1,103 @@ +create(); + + $provider = Mockery::mock(SocialiteProvider::class); + $provider->shouldReceive('scopes')->andReturnSelf(); + $provider->shouldReceive('with')->andReturnSelf(); + $provider->shouldReceive('redirect')->once()->andReturn(redirect('/google/auth')); + + $this->mock(SocialiteFactory::class, function ($mock) use ($provider) { + $mock->shouldReceive('driver')->with('google')->andReturn($provider); + }); + + $response = $this->get('/checkout/auth/google?package_id=' . $package->id . '&locale=de'); + + $response->assertRedirect('/google/auth'); + $this->assertSame($package->id, session('checkout_google_payload.package_id')); + } + + public function test_callback_creates_user_and_logs_in(): void + { + $package = Package::factory()->create(['price' => 0]); + + $googleUser = Mockery::mock(SocialiteUserContract::class); + $googleUser->shouldReceive('getEmail')->andReturn('checkout-google@example.com'); + $googleUser->shouldReceive('getName')->andReturn('Checkout Google'); + + $provider = Mockery::mock(SocialiteProvider::class); + $provider->shouldReceive('user')->andReturn($googleUser); + + $this->mock(SocialiteFactory::class, function ($mock) use ($provider) { + $mock->shouldReceive('driver')->with('google')->andReturn($provider); + }); + + $response = $this + ->withSession([ + 'checkout_google_payload' => ['package_id' => $package->id, 'locale' => 'de'], + ]) + ->get('/checkout/auth/google/callback'); + + $response->assertRedirect(route('purchase.wizard', ['package' => $package->id])); + + $this->assertAuthenticated(); + + $user = auth()->user(); + $this->assertSame('checkout-google@example.com', $user->email); + $this->assertTrue($user->pending_purchase); + $this->assertNotNull($user->tenant); + $this->assertDatabaseHas('tenant_packages', [ + 'tenant_id' => $user->tenant_id, + 'package_id' => $package->id, + ]); + } + + public function test_callback_with_missing_email_flashes_error(): void + { + $package = Package::factory()->create(); + + $googleUser = Mockery::mock(SocialiteUserContract::class); + $googleUser->shouldReceive('getEmail')->andReturn(null); + $googleUser->shouldReceive('getName')->andReturn('No Email'); + + $provider = Mockery::mock(SocialiteProvider::class); + $provider->shouldReceive('user')->andReturn($googleUser); + + $this->mock(SocialiteFactory::class, function ($mock) use ($provider) { + $mock->shouldReceive('driver')->with('google')->andReturn($provider); + }); + + $response = $this + ->withSession([ + 'checkout_google_payload' => ['package_id' => $package->id, 'locale' => 'en'], + ]) + ->get('/checkout/auth/google/callback'); + + $response->assertRedirect(route('purchase.wizard', ['package' => $package->id])); + $response->assertSessionHas('checkout_google_error'); + $this->assertGuest(); + } +} diff --git a/tests/Feature/CheckoutPaymentIntentTest.php b/tests/Feature/CheckoutPaymentIntentTest.php new file mode 100644 index 0000000..6c18d1b --- /dev/null +++ b/tests/Feature/CheckoutPaymentIntentTest.php @@ -0,0 +1,132 @@ +create(); + Tenant::factory()->create(['user_id' => $user->id]); + Auth::login($user); + + return $user; + } + + public function test_returns_null_client_secret_for_free_package(): void + { + $this->actingAsTenantUser(); + $package = Package::factory()->create([ + 'price' => 0, + ]); + + if (Schema::hasColumn('packages', 'is_free')) { + \DB::table('packages')->where('id', $package->id)->update(['is_free' => true]); + } + + $response = $this->postJson('/stripe/create-payment-intent', [ + 'package_id' => $package->id, + ]); + + $response->assertOk(); + + if (Schema::hasColumn('packages', 'is_free')) { + $response->assertJson([ + 'client_secret' => null, + 'free_package' => true, + ]); + } else { + $response->assertJson([ + 'client_secret' => null, + ]); + } + } + + private function mockStripePaymentIntent(object $payload): void + { + if (class_exists(\Stripe\PaymentIntent::class, false)) { + $this->fail('Stripe\\PaymentIntent already loaded; unable to mock static methods.'); + } + + $mock = Mockery::mock('alias:Stripe\PaymentIntent'); + $mock->shouldReceive('create') + ->once() + ->andReturn($payload); + } + + private function mockStripePaymentIntentFailure(\Throwable $exception): void + { + if (class_exists(\Stripe\PaymentIntent::class, false)) { + $this->fail('Stripe\\PaymentIntent already loaded; unable to mock static methods.'); + } + + $mock = Mockery::mock('alias:Stripe\PaymentIntent'); + $mock->shouldReceive('create') + ->once() + ->andThrow($exception); + } + + public function test_creates_payment_intent_and_returns_client_secret(): void + { + config(['services.stripe.secret' => 'sk_test_dummy']); + + $this->actingAsTenantUser(); + $package = Package::factory()->create([ + 'price' => 129, + ]); + + $this->mockStripePaymentIntent((object) [ + 'id' => 'pi_test_123', + 'client_secret' => 'secret_test_456', + ]); + + $response = $this->postJson('/stripe/create-payment-intent', [ + 'package_id' => $package->id, + ]); + + $response->assertOk() + ->assertJson([ + 'client_secret' => 'secret_test_456', + ]); + } + + public function test_returns_error_when_payment_intent_creation_fails(): void + { + config(['services.stripe.secret' => 'sk_test_dummy']); + + $this->actingAsTenantUser(); + $package = Package::factory()->create([ + 'price' => 59, + ]); + + $this->mockStripePaymentIntentFailure(new \RuntimeException('Stripe failure')); + + $response = $this->postJson('/stripe/create-payment-intent', [ + 'package_id' => $package->id, + ]); + + $response->assertStatus(500) + ->assertJson([ + 'error' => 'Fehler beim Erstellen der Zahlungsdaten: Stripe failure', + ]); + } +} diff --git a/tests/Feature/EventControllerTest.php b/tests/Feature/EventControllerTest.php index 42495f0..e053ed5 100644 --- a/tests/Feature/EventControllerTest.php +++ b/tests/Feature/EventControllerTest.php @@ -10,6 +10,9 @@ use App\Models\TenantPackage; use App\Models\EventPackage; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; +use App\Services\EventJoinTokenService; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; class EventControllerTest extends TestCase { @@ -107,8 +110,7 @@ class EventControllerTest extends TestCase public function test_upload_exceeds_package_limit_fails(): void { $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $event = Event::factory()->create(['tenant_id' => $tenant->id]); + $event = Event::factory()->create(['tenant_id' => $tenant->id, 'status' => 'published']); $package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 0]); // Limit 0 EventPackage::factory()->create([ 'event_id' => $event->id, @@ -116,12 +118,15 @@ class EventControllerTest extends TestCase 'used_photos' => 0, ]); - $response = $this->actingAs($user) - ->postJson("/api/v1/events/{$event->slug}/photos", [ - 'photo' => 'test-photo.jpg', + Storage::fake('public'); + $token = app(EventJoinTokenService::class)->createToken($event); + + $response = $this->withHeader('X-Device-Id', 'limit-test') + ->post("/api/v1/events/{$token->token}/upload", [ + 'photo' => UploadedFile::fake()->image('limit.jpg'), ]); $response->assertStatus(402) ->assertJson(['error' => 'Upload limit reached for this event']); } -} \ No newline at end of file +} diff --git a/tests/Feature/GuestJoinTokenFlowTest.php b/tests/Feature/GuestJoinTokenFlowTest.php index 6934763..9655588 100644 --- a/tests/Feature/GuestJoinTokenFlowTest.php +++ b/tests/Feature/GuestJoinTokenFlowTest.php @@ -137,6 +137,16 @@ class GuestJoinTokenFlowTest extends TestCase ->assertJsonPath('error.code', 'token_expired'); } + public function test_slug_access_is_rejected(): void + { + $event = $this->createPublishedEvent(); + + $response = $this->getJson("/api/v1/events/{$event->slug}"); + + $response->assertStatus(404) + ->assertJsonPath('error.code', 'invalid_token'); + } + public function test_guest_cannot_access_event_with_revoked_token(): void { $event = $this->createPublishedEvent(); diff --git a/tests/Feature/OAuthFlowTest.php b/tests/Feature/OAuthFlowTest.php index 46c9810..ca3befe 100644 --- a/tests/Feature/OAuthFlowTest.php +++ b/tests/Feature/OAuthFlowTest.php @@ -5,7 +5,7 @@ namespace Tests\Feature; use App\Models\OAuthClient; use App\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\File; use Illuminate\Support\Str; use Tests\TestCase; @@ -60,8 +60,22 @@ KEY; { parent::setUp(); - file_put_contents(storage_path('app/public.key'), self::PUBLIC_KEY); - file_put_contents(storage_path('app/private.key'), self::PRIVATE_KEY); + config()->set('oauth.keys.current_kid', 'test-kid'); + config()->set('oauth.keys.storage_path', storage_path('app/oauth-keys-tests')); + + $paths = $this->keyPaths('test-kid'); + + File::ensureDirectoryExists($paths['directory']); + File::put($paths['public'], self::PUBLIC_KEY); + File::put($paths['private'], self::PRIVATE_KEY); + File::chmod($paths['private'], 0600); + File::chmod($paths['public'], 0644); + } + + protected function tearDown(): void + { + File::deleteDirectory(storage_path('app/oauth-keys-tests')); + parent::tearDown(); } public function test_authorization_code_flow_and_refresh(): void @@ -150,5 +164,121 @@ KEY; 'error' => 'Refresh token cannot be used from this IP address', ]); } + + public function test_refresh_token_ip_binding_can_be_disabled(): void + { + config()->set('oauth.refresh_tokens.enforce_ip_binding', false); + + $tenant = Tenant::factory()->create([ + 'slug' => 'ip-free', + ]); + + OAuthClient::create([ + 'id' => (string) Str::uuid(), + 'client_id' => 'tenant-admin-app', + 'tenant_id' => $tenant->id, + 'redirect_uris' => ['http://localhost/callback'], + 'scopes' => ['tenant:read'], + 'is_active' => true, + ]); + + $codeVerifier = 'unit-test-code-verifier-abcdef'; + $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '='); + + $codeResponse = $this->get('/api/v1/oauth/authorize?' . http_build_query([ + 'client_id' => 'tenant-admin-app', + 'redirect_uri' => 'http://localhost/callback', + 'response_type' => 'code', + 'scope' => 'tenant:read', + 'state' => 'state', + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => 'S256', + ])); + + $location = $codeResponse->headers->get('Location'); + parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query); + $code = $query['code']; + + $tokenResponse = $this->post('/api/v1/oauth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'client_id' => 'tenant-admin-app', + 'redirect_uri' => 'http://localhost/callback', + 'code_verifier' => $codeVerifier, + ]); + + $token = $tokenResponse->json('refresh_token'); + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.33']) + ->post('/api/v1/oauth/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $token, + 'client_id' => 'tenant-admin-app', + ]) + ->assertOk(); + } + + public function test_refresh_token_allows_same_subnet_when_enabled(): void + { + config()->set('oauth.refresh_tokens.allow_subnet_match', true); + + $tenant = Tenant::factory()->create([ + 'slug' => 'subnet-tenant', + ]); + + OAuthClient::create([ + 'id' => (string) Str::uuid(), + 'client_id' => 'tenant-admin-app', + 'tenant_id' => $tenant->id, + 'redirect_uris' => ['http://localhost/callback'], + 'scopes' => ['tenant:read'], + 'is_active' => true, + ]); + + $codeVerifier = 'unit-test-code-verifier-subnet'; + $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '='); + + $codeResponse = $this->get('/api/v1/oauth/authorize?' . http_build_query([ + 'client_id' => 'tenant-admin-app', + 'redirect_uri' => 'http://localhost/callback', + 'response_type' => 'code', + 'scope' => 'tenant:read', + 'state' => 'state', + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => 'S256', + ])); + + $location = $codeResponse->headers->get('Location'); + parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query); + $code = $query['code']; + + $tokenResponse = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.24'])->post('/api/v1/oauth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'client_id' => 'tenant-admin-app', + 'redirect_uri' => 'http://localhost/callback', + 'code_verifier' => $codeVerifier, + ]); + + $token = $tokenResponse->json('refresh_token'); + + $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.55']) + ->post('/api/v1/oauth/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $token, + 'client_id' => 'tenant-admin-app', + ]) + ->assertOk(); + } + + private function keyPaths(string $kid): array + { + $base = storage_path('app/oauth-keys-tests'); + + return [ + 'directory' => $base . DIRECTORY_SEPARATOR . $kid, + 'public' => $base . DIRECTORY_SEPARATOR . $kid . DIRECTORY_SEPARATOR . 'public.key', + 'private' => $base . DIRECTORY_SEPARATOR . $kid . DIRECTORY_SEPARATOR . 'private.key', + ]; + } } diff --git a/tests/Feature/PayPalWebhookControllerTest.php b/tests/Feature/PayPalWebhookControllerTest.php new file mode 100644 index 0000000..9f829ca --- /dev/null +++ b/tests/Feature/PayPalWebhookControllerTest.php @@ -0,0 +1,75 @@ +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 6f425cb..0919d23 100644 --- a/tests/Feature/PurchaseTest.php +++ b/tests/Feature/PurchaseTest.php @@ -51,7 +51,6 @@ class PurchaseTest extends TestCase $this->app->instance(PaypalClientFactory::class, $factory); $response = $this->postJson('/paypal/create-order', [ - 'tenant_id' => $tenant->id, 'package_id' => $package->id, ]); @@ -172,7 +171,6 @@ class PurchaseTest extends TestCase $this->app->instance(PaypalClientFactory::class, $factory); $response = $this->postJson('/paypal/create-subscription', [ - 'tenant_id' => $tenant->id, 'package_id' => $package->id, 'plan_id' => 'PLAN-123', ]); diff --git a/tests/Unit/AdminDashboardWidgetsTest.php b/tests/Unit/AdminDashboardWidgetsTest.php new file mode 100644 index 0000000..ca3d154 --- /dev/null +++ b/tests/Unit/AdminDashboardWidgetsTest.php @@ -0,0 +1,136 @@ +create([ + 'event_credits_balance' => 2, + 'is_active' => true, + 'subscription_expires_at' => now()->addMonths(2), + ]); + + Tenant::factory()->create([ + 'event_credits_balance' => 20, + 'is_active' => true, + 'subscription_expires_at' => now()->addMonths(1), + ]); + + Tenant::factory()->create([ + 'event_credits_balance' => 1, + 'is_active' => false, + 'subscription_expires_at' => now()->subDay(), + ]); + + PurchaseHistory::create([ + 'id' => 'ph-1', + 'tenant_id' => $lowBalanceTenant->id, + 'package_id' => 'starter_pack', + 'credits_added' => 5, + 'price' => 149.90, + 'currency' => 'EUR', + 'platform' => 'web', + 'transaction_id' => 'txn-1', + 'purchased_at' => now()->startOfMonth()->addDay(), + 'created_at' => now(), + ]); + + $widget = new CreditAlertsWidget(); + + $cards = $this->invokeProtectedMethod($widget, 'getCards'); + + $this->assertCount(3, $cards); + $this->assertSame( + __('admin.widgets.credit_alerts.low_balance_label'), + $cards[0]->getLabel() + ); + $this->assertSame(1, $cards[0]->getValue()); + $this->assertSame( + 2, + $cards[2]->getValue() + ); + $this->assertStringContainsString('149.9', (string) $cards[1]->getValue()); + } + + public function test_revenue_trend_widget_compiles_monthly_totals(): void + { + Carbon::setTestNow(Carbon::create(2025, 10, 20, 12)); + + $tenant = Tenant::factory()->create(); + + PurchaseHistory::create([ + 'id' => 'cur-1', + 'tenant_id' => $tenant->id, + 'package_id' => 'pro_pack', + 'credits_added' => 10, + 'price' => 299.99, + 'currency' => 'EUR', + 'platform' => 'web', + 'transaction_id' => 'txn-cur', + 'purchased_at' => now()->copy()->startOfMonth()->addDays(2), + 'created_at' => now(), + ]); + + PurchaseHistory::create([ + 'id' => 'prev-1', + 'tenant_id' => $tenant->id, + 'package_id' => 'starter_pack', + 'credits_added' => 5, + 'price' => 149.90, + 'currency' => 'EUR', + 'platform' => 'web', + 'transaction_id' => 'txn-prev', + 'purchased_at' => now()->copy()->subMonth()->startOfMonth()->addDays(4), + 'created_at' => now()->subMonth(), + ]); + + $widget = new RevenueTrendWidget(); + $data = $this->invokeProtectedMethod($widget, 'getData'); + + $this->assertArrayHasKey('datasets', $data); + $this->assertArrayHasKey('labels', $data); + $this->assertCount(12, $data['labels']); + $this->assertSame(12, count($data['datasets'][0]['data'])); + + $lastValue = end($data['datasets'][0]['data']); + $prevValue = $data['datasets'][0]['data'][count($data['datasets'][0]['data']) - 2]; + + $this->assertEquals(299.99, $lastValue); + $this->assertEquals(149.90, $prevValue); + } + + /** + * @template T + * + * @param object $object + * @param string $method + * @return mixed + */ + private function invokeProtectedMethod(object $object, string $method) + { + $reflection = new ReflectionClass($object); + $reflectedMethod = $reflection->getMethod($method); + $reflectedMethod->setAccessible(true); + + return $reflectedMethod->invoke($object); + } +} diff --git a/tests/Unit/TenantCreditTest.php b/tests/Unit/TenantCreditTest.php new file mode 100644 index 0000000..d4b37c7 --- /dev/null +++ b/tests/Unit/TenantCreditTest.php @@ -0,0 +1,68 @@ +reseller() + ->create([ + 'max_events_per_year' => 5, + ]); + + $tenant = Tenant::factory()->create([ + 'event_credits_balance' => 0, + ]); + + TenantPackage::factory()->for($tenant)->for($package)->create([ + 'used_events' => 1, + 'active' => true, + ]); + + $this->assertTrue($tenant->consumeEventAllowance()); + + $updatedPackage = $tenant->getActiveResellerPackage(); + $this->assertNotNull($updatedPackage); + $this->assertSame(2, $updatedPackage->used_events); + } + + public function test_consume_event_allowance_decrements_credits_when_no_package(): void + { + $tenant = Tenant::factory()->create([ + 'event_credits_balance' => 2, + ]); + + $this->assertTrue($tenant->consumeEventAllowance(1, 'event.create', 'Event #1 created')); + + $tenant->refresh(); + $this->assertSame(1, $tenant->event_credits_balance); + + $this->assertDatabaseHas('event_credits_ledger', [ + 'tenant_id' => $tenant->id, + 'delta' => -1, + 'reason' => 'event.create', + 'note' => 'Event #1 created', + ]); + } + + public function test_consume_event_allowance_returns_false_without_package_or_credits(): void + { + $tenant = Tenant::factory()->create([ + 'event_credits_balance' => 0, + ]); + + $this->assertFalse($tenant->consumeEventAllowance()); + + $this->assertDatabaseCount('event_credits_ledger', 0); + } +} diff --git a/tests/Unit/TenantPolicyTest.php b/tests/Unit/TenantPolicyTest.php new file mode 100644 index 0000000..e71d102 --- /dev/null +++ b/tests/Unit/TenantPolicyTest.php @@ -0,0 +1,72 @@ +policy = new TenantPolicy(); + } + + public function test_super_admin_can_adjust_credits(): void + { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create([ + 'role' => 'super_admin', + ]); + + $this->assertTrue($this->policy->adjustCredits($user, $tenant)); + } + + public function test_tenant_admin_cannot_adjust_credits(): void + { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create([ + 'role' => 'tenant_admin', + ]); + + $user->forceFill(['tenant_id' => $tenant->id])->save(); + + $this->assertFalse($this->policy->adjustCredits($user, $tenant)); + } + + public function test_tenant_admin_can_view_own_tenant(): void + { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create([ + 'role' => 'tenant_admin', + ]); + + $user->forceFill(['tenant_id' => $tenant->id])->save(); + + $this->assertTrue($this->policy->view($user, $tenant)); + } + + public function test_tenant_admin_cannot_view_other_tenant(): void + { + $tenant = Tenant::factory()->create(); + $otherTenant = Tenant::factory()->create(); + + $user = User::factory()->create([ + 'role' => 'tenant_admin', + ]); + + $user->forceFill(['tenant_id' => $tenant->id])->save(); + + $this->assertFalse($this->policy->view($user, $otherTenant)); + } +} + diff --git a/tests/e2e/checkout-hero-cta.test.ts b/tests/e2e/checkout-hero-cta.test.ts new file mode 100644 index 0000000..8aa54c5 --- /dev/null +++ b/tests/e2e/checkout-hero-cta.test.ts @@ -0,0 +1,33 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Marketing hero CTA smoke', () => { + test('home hero CTA navigates to packages', async ({ page, baseURL }) => { + test.skip(!baseURL, 'baseURL is required to run marketing smoke tests'); + + await page.goto('/'); + + const cta = page.getByRole('link', { + name: /Pakete entdecken|Jetzt loslegen|Discover Packages|Get started now/i, + }); + + await expect(cta).toBeVisible(); + await cta.click(); + + await expect(page).toHaveURL(/\/packages/); + }); + + test('packages hero CTA jumps to endcustomer section', async ({ page, baseURL }) => { + test.skip(!baseURL, 'baseURL is required to run marketing smoke tests'); + + await page.goto('/packages'); + + const cta = page.getByRole('link', { + name: /Pakete entdecken|Lieblingspaket sichern|Discover Packages|Explore top packages/i, + }); + + await expect(cta).toBeVisible(); + await cta.click(); + + await expect(page.locator('#endcustomer')).toBeVisible(); + }); +}); diff --git a/tests/e2e/checkout-payment.test.ts b/tests/e2e/checkout-payment.test.ts new file mode 100644 index 0000000..029ba6b --- /dev/null +++ b/tests/e2e/checkout-payment.test.ts @@ -0,0 +1,191 @@ +import { test, expect } from '@playwright/test'; +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.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()]);"` + ); + }); + + test.beforeEach(async ({ page }) => { + await page.goto('/login'); + await page.fill('input[name="email"]', LOGIN_EMAIL); + await page.fill('input[name="password"]', LOGIN_PASSWORD); + await page.getByRole('button', { name: /Anmelden|Login/ }).click(); + await expect(page).toHaveURL(/dashboard/); + }); + + test('Stripe payment intent error surfaces descriptive status', async ({ page }) => { + await page.route('**/stripe/create-payment-intent', async (route) => { + await route.fulfill({ + status: 422, + contentType: 'application/json', + body: JSON.stringify({ error: 'Test payment intent failure' }), + }); + }); + + await openCheckoutPaymentStep(page); + + await expect( + page.locator('text=/Fehler beim Laden der Zahlungsdaten|Error loading payment data/') + ).toBeVisible(); + await expect( + page.locator('text=/Zahlungsformular bereit|Payment form ready/') + ).not.toBeVisible(); + }); + + 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) => { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'capture_failed' }), + }); + }); + + 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 expect( + page.locator('text=/PayPal capture failed|PayPal-Abbuchung fehlgeschlagen|PayPal capture error/') + ).toBeVisible(); + }); +}); + +async function openCheckoutPaymentStep(page: import('@playwright/test').Page) { + await page.goto('/packages'); + const checkoutLink = page.locator('a[href^="/checkout/"]').first(); + const href = await checkoutLink.getAttribute('href'); + + if (!href) { + throw new Error('No checkout link found on packages page.'); + } + + await page.goto(href); + + const nextButton = page.getByRole('button', { + name: /Weiter zum Zahlungsschritt|Continue to Payment/, + }); + if (await nextButton.isVisible()) { + await nextButton.click(); + } + + 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; + }; + } +} +