- 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

@@ -0,0 +1,111 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class OAuthRotateKeysCommand extends Command
{
protected $signature = 'oauth:rotate-keys {--kid=} {--force : Do not prompt for confirmation}';
protected $description = 'Generate a new JWT signing key pair for tenant OAuth tokens.';
public function handle(): int
{
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
$newKid = $this->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);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Exports;
use App\Models\PurchaseHistory;
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
class PurchaseHistoryExporter extends Exporter
{
public static function getModel(): string
{
return PurchaseHistory::class;
}
public static function getColumns(): array
{
return [
ExportColumn::make('tenant.name')->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,
]);
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\OAuthClientResource\Pages;
use App\Models\OAuthClient;
use App\Models\RefreshToken;
use BackedEnum;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class OAuthClientResource extends Resource
{
protected static ?string $model = OAuthClient::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-key';
protected static ?int $navigationSort = 30;
public static function getNavigationGroup(): string
{
return __('admin.nav.security');
}
public static function form(Schema $form): Schema
{
return $form->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'),
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Resources\OAuthClientResource\Pages;
use App\Filament\Resources\OAuthClientResource;
use Filament\Resources\Pages\CreateRecord;
class CreateOAuthClient extends CreateRecord
{
protected static string $resource = OAuthClientResource::class;
protected function getCreatedNotificationTitle(): ?string
{
return __('admin.oauth.notifications.created_title');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\OAuthClientResource\Pages;
use App\Filament\Resources\OAuthClientResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditOAuthClient extends EditRecord
{
protected static string $resource = OAuthClientResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
protected function getSavedNotificationTitle(): ?string
{
return __('admin.oauth.notifications.updated_title');
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\OAuthClientResource\Pages;
use App\Filament\Resources\OAuthClientResource;
use Filament\Resources\Pages\ListRecords;
class ListOAuthClients extends ListRecords
{
protected static string $resource = OAuthClientResource::class;
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\OAuthClientResource\Pages;
use App\Filament\Resources\OAuthClientResource;
use Filament\Resources\Pages\ViewRecord;
class ViewOAuthClient extends ViewRecord
{
protected static string $resource = OAuthClientResource::class;
}

View File

@@ -0,0 +1,189 @@
<?php
namespace App\Filament\Resources;
use App\Exports\PurchaseHistoryExporter;
use App\Filament\Resources\PurchaseHistoryResource\Pages;
use App\Models\PurchaseHistory;
use BackedEnum;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\ExportBulkAction;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class PurchaseHistoryResource extends Resource
{
protected static ?string $model = PurchaseHistory::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-receipt-refund';
protected static ?int $navigationSort = 20;
public static function getNavigationGroup(): string
{
return __('admin.nav.billing');
}
public static function form(Schema $form): Schema
{
return $form->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;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Resources\PurchaseHistoryResource\Pages;
use App\Filament\Resources\PurchaseHistoryResource;
use Filament\Resources\Pages\ListRecords;
class ListPurchaseHistories extends ListRecords
{
protected static string $resource = PurchaseHistoryResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Resources\PurchaseHistoryResource\Pages;
use App\Filament\Resources\PurchaseHistoryResource;
use Filament\Resources\Pages\ViewRecord;
class ViewPurchaseHistory extends ViewRecord
{
protected static string $resource = PurchaseHistoryResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -20,6 +20,7 @@ use Filament\Tables\Columns\IconColumn;
use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelationManager;
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
use Filament\Resources\RelationManagers\RelationGroup;
use Filament\Notifications\Notification;
use UnitEnum;
use BackedEnum;
use Illuminate\Support\Facades\Route;
@@ -56,6 +57,10 @@ class TenantResource extends Resource
->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')

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Filament\Widgets;
use App\Models\PurchaseHistory;
use App\Models\Tenant;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class CreditAlertsWidget extends StatsOverviewWidget
{
protected static ?int $sort = 0;
protected int|string|array $columnSpan = 'full';
protected function getCards(): array
{
$lowBalanceCount = Tenant::query()
->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'),
];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Filament\Widgets;
use App\Models\PurchaseHistory;
use Filament\Widgets\LineChartWidget;
class RevenueTrendWidget extends LineChartWidget
{
protected static ?int $sort = 1;
protected int|string|array $columnSpan = 'full';
public function getHeading(): ?string
{
return __('admin.widgets.revenue_trend.heading');
}
protected function getData(): array
{
$start = now()->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,
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Tenant;
use Filament\Tables;
use Filament\Widgets\TableWidget as BaseWidget;
class TopTenantsByRevenue extends BaseWidget
{
protected static ?string $heading = null;
protected static ?int $sort = 2;
protected ?string $pollingInterval = '120s';
protected function getHeading(): ?string
{
return __('admin.widgets.top_tenants_by_revenue.heading');
}
public function table(Tables\Table $table): Tables\Table
{
return $table
->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);
}
}

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

View File

@@ -25,6 +25,11 @@ class ProcessRevenueCatWebhook implements ShouldQueue
private ?string $eventId;
public int $tries = 5;
public int $backoff = 60;
/**
* @param array<string, mixed> $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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Policies;
use App\Models\OAuthClient;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class OAuthClientPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
return $user->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';
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Policies;
use App\Models\PurchaseHistory;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class PurchaseHistoryPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
return $user->role === 'super_admin';
}
public function view(User $user, PurchaseHistory $purchaseHistory): bool
{
return $user->role === 'super_admin';
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Policies;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class TenantPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->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';
}
}

View File

@@ -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) {

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Providers;
use App\Models\OAuthClient;
use App\Models\PurchaseHistory;
use App\Models\Tenant;
use App\Models\User;
use App\Policies\OAuthClientPolicy;
use App\Policies\PurchaseHistoryPolicy;
use App\Policies\TenantPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
/**
* The policy mappings for the application.
*
* @var array<class-string, class-string>
*/
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;
});
}
}

View File

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

View File

@@ -0,0 +1,173 @@
<?php
namespace App\Support\Concerns;
use App\Models\Package;
trait PresentsPackages
{
protected 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,
];
}
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));
}
}