- 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:
Codex Agent
2025-10-19 11:41:03 +02:00
parent ae9b9160ac
commit a949c8d3af
113 changed files with 5169 additions and 712 deletions

View File

@@ -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;

View File

@@ -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;
}
}

View 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);
}
}

View File

@@ -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));
}
}

View File

@@ -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), '+/', '-_'), '=');

View File

@@ -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();

View File

@@ -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]);

View File

@@ -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);
}
}
}

View File

@@ -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
*/