- Wired the checkout wizard for Google “comfort login”: added Socialite controller + dependency, new Google env
hooks in config/services.php/.env.example, and updated wizard steps/controllers to store session payloads, attach packages, and surface localized success/error states. - Retooled payment handling for both Stripe and PayPal, adding richer status management in CheckoutController/ PayPalController, fallback flows in the wizard’s PaymentStep.tsx, and fresh feature tests for intent creation, webhooks, and the wizard CTA. - Introduced a consent-aware Matomo analytics stack: new consent context, cookie-banner UI, useAnalytics/ useCtaExperiment hooks, and MatomoTracker component, then instrumented marketing pages (Home, Packages, Checkout) with localized copy and experiment tracking. - Polished package presentation across marketing UIs by centralizing formatting in PresentsPackages, surfacing localized description tables/placeholders, tuning badges/layouts, and syncing guest/marketing translations. - Expanded docs & reference material (docs/prp/*, TODOs, public gallery overview) and added a Playwright smoke test for the hero CTA while reconciling outstanding checklist items.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
211
app/Http/Controllers/CheckoutGoogleController.php
Normal file
211
app/Http/Controllers/CheckoutGoogleController.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Mail\Welcome;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
class CheckoutGoogleController extends Controller
|
||||
{
|
||||
private const SESSION_KEY = 'checkout_google_payload';
|
||||
|
||||
public function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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), '+/', '-_'), '=');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user