- 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:
111
app/Console/Commands/OAuthRotateKeysCommand.php
Normal file
111
app/Console/Commands/OAuthRotateKeysCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Exports/PurchaseHistoryExporter.php
Normal file
37
app/Exports/PurchaseHistoryExporter.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
190
app/Filament/Resources/OAuthClientResource.php
Normal file
190
app/Filament/Resources/OAuthClientResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
189
app/Filament/Resources/PurchaseHistoryResource.php
Normal file
189
app/Filament/Resources/PurchaseHistoryResource.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
61
app/Filament/Widgets/CreditAlertsWidget.php
Normal file
61
app/Filament/Widgets/CreditAlertsWidget.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
55
app/Filament/Widgets/RevenueTrendWidget.php
Normal file
55
app/Filament/Widgets/RevenueTrendWidget.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Filament/Widgets/TopTenantsByRevenue.php
Normal file
53
app/Filament/Widgets/TopTenantsByRevenue.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class EventController extends Controller
|
||||
{
|
||||
@@ -124,9 +125,8 @@ class EventController extends Controller
|
||||
$event = DB::transaction(function () use ($tenant, $eventData, $packageId) {
|
||||
$event = Event::create($eventData);
|
||||
|
||||
// Create EventPackage and PackagePurchase for Free package
|
||||
$package = \App\Models\Package::findOrFail($packageId);
|
||||
$eventPackage = \App\Models\EventPackage::create([
|
||||
\App\Models\EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $packageId,
|
||||
'price' => $package->price,
|
||||
@@ -143,8 +143,9 @@ class EventController extends Controller
|
||||
'metadata' => json_encode(['note' => 'Free package assigned on event creation']),
|
||||
]);
|
||||
|
||||
if ($tenant->activeResellerPackage) {
|
||||
$tenant->incrementUsedEvents();
|
||||
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
||||
throw new HttpException(402, 'Insufficient credits or package allowance.');
|
||||
}
|
||||
|
||||
return $event;
|
||||
|
||||
@@ -22,23 +22,35 @@ use Stripe\PaymentIntent;
|
||||
use Stripe\Stripe;
|
||||
|
||||
use App\Http\Controllers\PayPalController;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
|
||||
class CheckoutController extends Controller
|
||||
{
|
||||
use PresentsPackages;
|
||||
|
||||
public function show(Package $package)
|
||||
{
|
||||
// Alle verfügbaren Pakete laden
|
||||
$packages = Package::all();
|
||||
$googleStatus = session()->pull('checkout_google_status');
|
||||
$googleError = session()->pull('checkout_google_error');
|
||||
|
||||
$packageOptions = Package::orderBy('price')->get()
|
||||
->map(fn (Package $pkg) => $this->presentPackage($pkg))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return Inertia::render('marketing/CheckoutWizardPage', [
|
||||
'package' => $package,
|
||||
'packageOptions' => $packages,
|
||||
'package' => $this->presentPackage($package),
|
||||
'packageOptions' => $packageOptions,
|
||||
'stripePublishableKey' => config('services.stripe.key'),
|
||||
'paypalClientId' => config('services.paypal.client_id'),
|
||||
'privacyHtml' => view('legal.datenschutz-partial')->render(),
|
||||
'auth' => [
|
||||
'user' => Auth::user(),
|
||||
],
|
||||
'googleAuth' => [
|
||||
'status' => $googleStatus,
|
||||
'error' => $googleError,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -97,11 +109,14 @@ class CheckoutController extends Controller
|
||||
'event_default_type' => 'general',
|
||||
]),
|
||||
]);
|
||||
|
||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||
// Package zuweisen
|
||||
$tenant->packages()->attach($package->id, [
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => $package->is_free ? null : now()->addYear(),
|
||||
'is_active' => $package->is_free, // Kostenlose Pakete sofort aktivieren
|
||||
'expires_at' => $this->packageIsFree($package) ? now()->addYear() : now()->addYear(),
|
||||
'active' => $this->packageIsFree($package), // Kostenlose Pakete sofort aktivieren
|
||||
]);
|
||||
|
||||
// E-Mail-Verifizierung senden
|
||||
@@ -241,7 +256,9 @@ class CheckoutController extends Controller
|
||||
'user_id' => Auth::id(),
|
||||
]);
|
||||
|
||||
if ($package->is_free) {
|
||||
$isFreePackage = $this->packageIsFree($package);
|
||||
|
||||
if ($isFreePackage) {
|
||||
\Log::info('Free package detected, returning null client_secret');
|
||||
return response()->json([
|
||||
'client_secret' => null,
|
||||
@@ -305,9 +322,10 @@ class CheckoutController extends Controller
|
||||
|
||||
// Package dem Tenant zuweisen
|
||||
$user->tenant->packages()->attach($package->id, [
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'is_active' => true,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// pending_purchase zurücksetzen
|
||||
@@ -362,9 +380,10 @@ class CheckoutController extends Controller
|
||||
|
||||
// TenantPackage zuweisen (ähnlich Stripe)
|
||||
$user->tenant->packages()->attach($package->id, [
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'is_active' => true,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// pending_purchase zurücksetzen
|
||||
@@ -379,4 +398,15 @@ class CheckoutController extends Controller
|
||||
return redirect('/checkout')->with('error', 'Fehler beim Abschließen der Zahlung: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function packageIsFree(Package $package): bool
|
||||
{
|
||||
if (isset($package->is_free)) {
|
||||
return (bool) $package->is_free;
|
||||
}
|
||||
|
||||
$price = (float) $package->price;
|
||||
|
||||
return $price <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
211
app/Http/Controllers/CheckoutGoogleController.php
Normal file
211
app/Http/Controllers/CheckoutGoogleController.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Mail\Welcome;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
class CheckoutGoogleController extends Controller
|
||||
{
|
||||
private const SESSION_KEY = 'checkout_google_payload';
|
||||
|
||||
public function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'locale' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'package_id' => (int) $validated['package_id'],
|
||||
'locale' => $validated['locale'] ?? app()->getLocale(),
|
||||
];
|
||||
|
||||
$request->session()->put(self::SESSION_KEY, $payload);
|
||||
$request->session()->put('selected_package_id', $payload['package_id']);
|
||||
|
||||
return Socialite::driver('google')
|
||||
->scopes(['email', 'profile'])
|
||||
->with(['prompt' => 'select_account'])
|
||||
->redirect();
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$payload = $request->session()->get(self::SESSION_KEY, []);
|
||||
$packageId = $payload['package_id'] ?? null;
|
||||
|
||||
try {
|
||||
$googleUser = Socialite::driver('google')->user();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Google checkout login failed', ['message' => $e->getMessage()]);
|
||||
$this->flashError($request, __('checkout.google_error_fallback'));
|
||||
return $this->redirectBackToWizard($packageId);
|
||||
}
|
||||
|
||||
$email = $googleUser->getEmail();
|
||||
if (! $email) {
|
||||
$this->flashError($request, __('checkout.google_missing_email'));
|
||||
return $this->redirectBackToWizard($packageId);
|
||||
}
|
||||
|
||||
$user = DB::transaction(function () use ($googleUser, $email) {
|
||||
$existing = User::where('email', $email)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->forceFill([
|
||||
'name' => $googleUser->getName() ?: $existing->name,
|
||||
'pending_purchase' => true,
|
||||
'email_verified_at' => $existing->email_verified_at ?? now(),
|
||||
])->save();
|
||||
|
||||
if (! $existing->tenant) {
|
||||
$this->createTenantForUser($existing, $googleUser->getName(), $email);
|
||||
}
|
||||
|
||||
return $existing->fresh();
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $googleUser->getName(),
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(32)),
|
||||
'pending_purchase' => true,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
$tenant = $this->createTenantForUser($user, $googleUser->getName(), $email);
|
||||
|
||||
try {
|
||||
Mail::to($user)->queue(new Welcome($user));
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to queue welcome mail after Google signup', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return tap($user)->setRelation('tenant', $tenant);
|
||||
});
|
||||
|
||||
if (! $user->tenant) {
|
||||
$this->createTenantForUser($user, $googleUser->getName(), $email);
|
||||
}
|
||||
|
||||
Auth::login($user, true);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
$request->session()->put('checkout_google_status', 'success');
|
||||
|
||||
if ($packageId) {
|
||||
$this->ensurePackageAttached($user, (int) $packageId);
|
||||
}
|
||||
|
||||
return $this->redirectBackToWizard($packageId);
|
||||
}
|
||||
|
||||
private function createTenantForUser(User $user, ?string $displayName, string $email): Tenant
|
||||
{
|
||||
$tenantName = trim($displayName ?: Str::before($email, '@')) ?: 'Fotospiel Tenant';
|
||||
$slugBase = Str::slug($tenantName) ?: 'tenant';
|
||||
$slug = $slugBase;
|
||||
$counter = 1;
|
||||
|
||||
while (Tenant::where('slug', $slug)->exists()) {
|
||||
$slug = $slugBase . '-' . $counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $tenantName,
|
||||
'slug' => $slug,
|
||||
'email' => $email,
|
||||
'contact_email' => $email,
|
||||
'is_active' => true,
|
||||
'is_suspended' => false,
|
||||
'event_credits_balance' => 0,
|
||||
'subscription_tier' => 'free',
|
||||
'subscription_status' => 'free',
|
||||
'subscription_expires_at' => null,
|
||||
'settings' => json_encode([
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#3B82F6',
|
||||
'secondary_color' => '#1F2937',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
'photo_likes_enabled' => false,
|
||||
'event_checklist' => false,
|
||||
'custom_domain' => false,
|
||||
'advanced_analytics' => false,
|
||||
],
|
||||
'custom_domain' => null,
|
||||
'contact_email' => $email,
|
||||
'event_default_type' => 'general',
|
||||
]),
|
||||
]);
|
||||
|
||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function ensurePackageAttached(User $user, int $packageId): void
|
||||
{
|
||||
$tenant = $user->tenant;
|
||||
if (! $tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$package = Package::find($packageId);
|
||||
if (! $package) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($tenant->packages()->where('package_id', $packageId)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant->packages()->attach($packageId, [
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'active' => $package->price <= 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function redirectBackToWizard(?int $packageId): RedirectResponse
|
||||
{
|
||||
if ($packageId) {
|
||||
return redirect()->route('purchase.wizard', ['package' => $packageId]);
|
||||
}
|
||||
|
||||
$firstPackageId = Package::query()->orderBy('price')->value('id');
|
||||
if ($firstPackageId) {
|
||||
return redirect()->route('purchase.wizard', ['package' => $firstPackageId]);
|
||||
}
|
||||
|
||||
return redirect()->route('packages');
|
||||
}
|
||||
|
||||
private function flashError(Request $request, string $message): void
|
||||
{
|
||||
$request->session()->flash('checkout_google_error', $message);
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,12 @@ use League\CommonMark\Extension\Autolink\AutolinkExtension;
|
||||
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
|
||||
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
|
||||
class MarketingController extends Controller
|
||||
{
|
||||
use PresentsPackages;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
Stripe::setApiKey(config('services.stripe.key'));
|
||||
@@ -39,9 +42,12 @@ class MarketingController extends Controller
|
||||
|
||||
public function index()
|
||||
{
|
||||
$packages = Package::where('type', 'endcustomer')->orderBy('price')->get()->map(function ($p) {
|
||||
return $p->append(['features', 'limits']);
|
||||
});
|
||||
$packages = Package::where('type', 'endcustomer')
|
||||
->orderBy('price')
|
||||
->get()
|
||||
->map(fn (Package $package) => $this->presentPackage($package))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return Inertia::render('marketing/Home', compact('packages'));
|
||||
}
|
||||
@@ -484,13 +490,15 @@ class MarketingController extends Controller
|
||||
->orderBy('price')
|
||||
->get()
|
||||
->map(fn (Package $package) => $this->presentPackage($package))
|
||||
->values();
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$resellerPackages = Package::where('type', 'reseller')
|
||||
->orderBy('price')
|
||||
->get()
|
||||
->map(fn (Package $package) => $this->presentPackage($package))
|
||||
->values();
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return Inertia::render('marketing/Packages', [
|
||||
'endcustomerPackages' => $endcustomerPackages,
|
||||
@@ -516,170 +524,4 @@ class MarketingController extends Controller
|
||||
|
||||
return Inertia::render('marketing/Occasions', ['type' => $type]);
|
||||
}
|
||||
|
||||
private function presentPackage(Package $package): array
|
||||
{
|
||||
$package->append('limits');
|
||||
|
||||
$packageArray = $package->toArray();
|
||||
$features = $packageArray['features'] ?? [];
|
||||
$features = $this->normaliseFeatures($features);
|
||||
|
||||
$locale = app()->getLocale();
|
||||
$name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale);
|
||||
$descriptionTemplate = $this->resolveTranslation($package->description_translations ?? null, $package->description ?? '', $locale);
|
||||
|
||||
$replacements = $this->buildPlaceholderReplacements($package);
|
||||
|
||||
$description = trim($this->applyPlaceholders($descriptionTemplate, $replacements));
|
||||
|
||||
$table = $package->description_table ?? [];
|
||||
if (is_string($table)) {
|
||||
$decoded = json_decode($table, true);
|
||||
$table = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$table = array_map(function (array $row) use ($replacements) {
|
||||
return [
|
||||
'title' => trim($this->applyPlaceholders($row['title'] ?? '', $replacements)),
|
||||
'value' => trim($this->applyPlaceholders($row['value'] ?? '', $replacements)),
|
||||
];
|
||||
}, $table);
|
||||
$table = array_values($table);
|
||||
|
||||
$galleryDuration = $replacements['{{gallery_duration}}'] ?? null;
|
||||
|
||||
return [
|
||||
'id' => $package->id,
|
||||
'name' => $name,
|
||||
'slug' => $package->slug,
|
||||
'type' => $package->type,
|
||||
'price' => $package->price,
|
||||
'description' => $description,
|
||||
'description_breakdown' => $table,
|
||||
'gallery_duration_label' => $galleryDuration,
|
||||
'events' => $package->type === 'endcustomer' ? 1 : ($package->max_events_per_year ?? null),
|
||||
'features' => $features,
|
||||
'limits' => $package->limits,
|
||||
'max_photos' => $package->max_photos,
|
||||
'max_guests' => $package->max_guests,
|
||||
'max_tasks' => $package->max_tasks,
|
||||
'gallery_days' => $package->gallery_days,
|
||||
'max_events_per_year' => $package->max_events_per_year,
|
||||
'watermark_allowed' => (bool) $package->watermark_allowed,
|
||||
'branding_allowed' => (bool) $package->branding_allowed,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildPlaceholderReplacements(Package $package): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return [
|
||||
'{{max_photos}}' => $this->formatCount($package->max_photos, [
|
||||
'de' => 'unbegrenzt viele',
|
||||
'en' => 'unlimited',
|
||||
]),
|
||||
'{{max_guests}}' => $this->formatCount($package->max_guests, [
|
||||
'de' => 'beliebig viele',
|
||||
'en' => 'any number of',
|
||||
]),
|
||||
'{{max_tasks}}' => $this->formatCount($package->max_tasks, [
|
||||
'de' => 'individuelle',
|
||||
'en' => 'custom',
|
||||
]),
|
||||
'{{max_events_per_year}}' => $this->formatCount($package->max_events_per_year, [
|
||||
'de' => 'unbegrenzte',
|
||||
'en' => 'unlimited',
|
||||
]),
|
||||
'{{gallery_duration}}' => $this->formatGalleryDuration($package->gallery_days),
|
||||
];
|
||||
}
|
||||
|
||||
private function applyPlaceholders(string $template, array $replacements): string
|
||||
{
|
||||
if ($template === '') {
|
||||
return $template;
|
||||
}
|
||||
|
||||
return str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
}
|
||||
|
||||
private function formatCount(?int $value, array $fallbackByLocale): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
if ($value === null) {
|
||||
return $fallbackByLocale[$locale] ?? reset($fallbackByLocale) ?? '';
|
||||
}
|
||||
|
||||
$decimal = $locale === 'de' ? ',' : '.';
|
||||
$thousands = $locale === 'de' ? '.' : ',';
|
||||
|
||||
return number_format($value, 0, $decimal, $thousands);
|
||||
}
|
||||
|
||||
private function formatGalleryDuration(?int $days): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
if (!$days || $days <= 0) {
|
||||
return $locale === 'en' ? 'permanent' : 'dauerhaft';
|
||||
}
|
||||
|
||||
if ($days % 30 === 0) {
|
||||
$months = (int) ($days / 30);
|
||||
if ($locale === 'en') {
|
||||
return $months === 1 ? '1 month' : $months . ' months';
|
||||
}
|
||||
|
||||
return $months === 1 ? '1 Monat' : $months . ' Monate';
|
||||
}
|
||||
|
||||
return $locale === 'en' ? $days . ' days' : $days . ' Tage';
|
||||
}
|
||||
|
||||
private function normaliseFeatures(mixed $features): array
|
||||
{
|
||||
if (is_string($features)) {
|
||||
$decoded = json_decode($features, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$features = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_array($features)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($features as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$list[] = $value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($key) && (bool) $value) {
|
||||
$list[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($list, fn ($item) => is_string($item) && $item !== '')));
|
||||
}
|
||||
|
||||
private function resolveTranslation(mixed $value, string $fallback, string $locale): string
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$decoded = json_decode($value, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$value = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return trim((string) ($value[$locale] ?? $value['en'] ?? $value['de'] ?? $fallback));
|
||||
}
|
||||
|
||||
return trim((string) ($value ?? $fallback));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\TenantToken;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -22,7 +23,7 @@ class OAuthController extends Controller
|
||||
private const AUTH_CODE_TTL_MINUTES = 5;
|
||||
private const ACCESS_TOKEN_TTL_SECONDS = 3600;
|
||||
private const REFRESH_TOKEN_TTL_DAYS = 30;
|
||||
private const TOKEN_HEADER_KID = 'fotospiel-jwt';
|
||||
private const LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt';
|
||||
|
||||
/**
|
||||
* Authorize endpoint - PKCE flow
|
||||
@@ -286,9 +287,16 @@ class OAuthController extends Controller
|
||||
$storedIp = (string) ($storedRefreshToken->ip_address ?? '');
|
||||
$currentIp = (string) ($request->ip() ?? '');
|
||||
|
||||
if ($storedIp !== '' && $currentIp !== '' && ! hash_equals($storedIp, $currentIp)) {
|
||||
if (config('oauth.refresh_tokens.enforce_ip_binding', true) && ! $this->ipMatches($storedIp, $currentIp)) {
|
||||
$storedRefreshToken->update(['revoked_at' => now()]);
|
||||
|
||||
Log::warning('[OAuth] Refresh token rejected due to IP mismatch', [
|
||||
'client_id' => $request->client_id,
|
||||
'refresh_token_id' => $storedRefreshToken->id,
|
||||
'stored_ip' => $storedIp,
|
||||
'current_ip' => $currentIp,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Refresh token cannot be used from this IP address', 403);
|
||||
}
|
||||
|
||||
@@ -387,7 +395,7 @@ class OAuthController extends Controller
|
||||
int $issuedAt,
|
||||
int $expiresAt
|
||||
): string {
|
||||
[$publicKey, $privateKey] = $this->ensureKeysExist();
|
||||
[$kid, , $privateKey] = $this->getSigningKeyPair();
|
||||
|
||||
$payload = [
|
||||
'iss' => url('/'),
|
||||
@@ -403,47 +411,94 @@ class OAuthController extends Controller
|
||||
'jti' => $jti,
|
||||
];
|
||||
|
||||
return JWT::encode($payload, $privateKey, 'RS256', self::TOKEN_HEADER_KID, ['kid' => self::TOKEN_HEADER_KID]);
|
||||
return JWT::encode($payload, $privateKey, 'RS256', $kid, ['kid' => $kid]);
|
||||
}
|
||||
|
||||
private function ensureKeysExist(): array
|
||||
private function getSigningKeyPair(): array
|
||||
{
|
||||
$publicKeyPath = storage_path('app/public.key');
|
||||
$privateKeyPath = storage_path('app/private.key');
|
||||
$kid = $this->currentKid();
|
||||
[$publicKey, $privateKey] = $this->ensureKeysForKid($kid);
|
||||
|
||||
$publicKey = @file_get_contents($publicKeyPath);
|
||||
$privateKey = @file_get_contents($privateKeyPath);
|
||||
return [$kid, $publicKey, $privateKey];
|
||||
}
|
||||
|
||||
if ($publicKey && $privateKey) {
|
||||
return [$publicKey, $privateKey];
|
||||
private function currentKid(): string
|
||||
{
|
||||
return config('oauth.keys.current_kid', self::LEGACY_TOKEN_HEADER_KID);
|
||||
}
|
||||
|
||||
private function ensureKeysForKid(string $kid): array
|
||||
{
|
||||
$paths = $this->keyPaths($kid);
|
||||
|
||||
if (! File::exists($paths['directory'])) {
|
||||
File::makeDirectory($paths['directory'], 0700, true);
|
||||
}
|
||||
|
||||
$this->generateKeyPair();
|
||||
$this->maybeMigrateLegacyKeys($paths);
|
||||
|
||||
if (! File::exists($paths['public']) || ! File::exists($paths['private'])) {
|
||||
$this->generateKeyPair($paths['directory']);
|
||||
}
|
||||
|
||||
return [
|
||||
file_get_contents($publicKeyPath),
|
||||
file_get_contents($privateKeyPath),
|
||||
File::get($paths['public']),
|
||||
File::get($paths['private']),
|
||||
];
|
||||
}
|
||||
|
||||
private function generateKeyPair(): void
|
||||
private function keyPaths(string $kid): array
|
||||
{
|
||||
$base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||
$directory = $base.DIRECTORY_SEPARATOR.$kid;
|
||||
|
||||
return [
|
||||
'directory' => $directory,
|
||||
'public' => $directory.DIRECTORY_SEPARATOR.'public.key',
|
||||
'private' => $directory.DIRECTORY_SEPARATOR.'private.key',
|
||||
];
|
||||
}
|
||||
|
||||
private function maybeMigrateLegacyKeys(array $paths): void
|
||||
{
|
||||
$legacyPublic = storage_path('app/public.key');
|
||||
$legacyPrivate = storage_path('app/private.key');
|
||||
|
||||
if (! File::exists($paths['public']) && File::exists($legacyPublic)) {
|
||||
File::copy($legacyPublic, $paths['public']);
|
||||
}
|
||||
|
||||
if (! File::exists($paths['private']) && File::exists($legacyPrivate)) {
|
||||
File::copy($legacyPrivate, $paths['private']);
|
||||
}
|
||||
}
|
||||
|
||||
private function generateKeyPair(string $directory): void
|
||||
{
|
||||
$config = [
|
||||
'digest_alg' => OPENSSL_ALGO_SHA256,
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_bits' => 4096,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
];
|
||||
|
||||
$res = openssl_pkey_new($config);
|
||||
if (! $res) {
|
||||
$resource = openssl_pkey_new($config);
|
||||
if (! $resource) {
|
||||
throw new \RuntimeException('Failed to generate key pair');
|
||||
}
|
||||
|
||||
openssl_pkey_export($res, $privKey);
|
||||
$pubKey = openssl_pkey_get_details($res);
|
||||
openssl_pkey_export($resource, $privateKey);
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
$publicKey = $details['key'] ?? null;
|
||||
|
||||
file_put_contents(storage_path('app/private.key'), $privKey);
|
||||
file_put_contents(storage_path('app/public.key'), $pubKey['key']);
|
||||
if (! $publicKey) {
|
||||
throw new \RuntimeException('Failed to extract public key');
|
||||
}
|
||||
|
||||
File::put($directory.DIRECTORY_SEPARATOR.'private.key', $privateKey, true);
|
||||
File::chmod($directory.DIRECTORY_SEPARATOR.'private.key', 0600);
|
||||
|
||||
File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true);
|
||||
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
|
||||
}
|
||||
private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool
|
||||
{
|
||||
@@ -480,6 +535,32 @@ class OAuthController extends Controller
|
||||
return response()->json($response, $status);
|
||||
}
|
||||
|
||||
private function ipMatches(string $storedIp, string $currentIp): bool
|
||||
{
|
||||
if ($storedIp === '' || $currentIp === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hash_equals($storedIp, $currentIp)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! config('oauth.refresh_tokens.allow_subnet_match', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter_var($storedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($currentIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
$storedParts = explode('.', $storedIp);
|
||||
$currentParts = explode('.', $currentIp);
|
||||
|
||||
return $storedParts[0] === $currentParts[0]
|
||||
&& $storedParts[1] === $currentParts[1]
|
||||
&& $storedParts[2] === $currentParts[2];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function base64urlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
|
||||
@@ -15,6 +15,7 @@ use PaypalServerSdkLib\Models\Builders\AmountWithBreakdownBuilder;
|
||||
use PaypalServerSdkLib\Models\Builders\OrderApplicationContextBuilder;
|
||||
use PaypalServerSdkLib\Models\CheckoutPaymentIntent;
|
||||
use App\Services\PayPal\PaypalClientFactory;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class PayPalController extends Controller
|
||||
{
|
||||
@@ -30,11 +31,18 @@ class PayPalController extends Controller
|
||||
public function createOrder(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|exists:tenants,id',
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'tenant_id' => 'nullable|exists:tenants,id',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::findOrFail($request->tenant_id);
|
||||
$tenant = $request->tenant_id
|
||||
? Tenant::findOrFail($request->tenant_id)
|
||||
: optional(Auth::user())->tenant;
|
||||
|
||||
if (! $tenant) {
|
||||
return response()->json(['error' => 'Tenant context required for checkout.'], 422);
|
||||
}
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
|
||||
$ordersController = $this->client->getOrdersController();
|
||||
@@ -156,12 +164,18 @@ class PayPalController extends Controller
|
||||
public function createSubscription(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|exists:tenants,id',
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'plan_id' => 'required', // PayPal plan ID for the package
|
||||
'plan_id' => 'required|string',
|
||||
'tenant_id' => 'nullable|exists:tenants,id',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::findOrFail($request->tenant_id);
|
||||
$tenant = $request->tenant_id
|
||||
? Tenant::findOrFail($request->tenant_id)
|
||||
: optional(Auth::user())->tenant;
|
||||
|
||||
if (! $tenant) {
|
||||
return response()->json(['error' => 'Tenant context required for subscription checkout.'], 422);
|
||||
}
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
|
||||
$ordersController = $this->client->getOrdersController();
|
||||
|
||||
@@ -154,7 +154,7 @@ class PayPalWebhookController extends Controller
|
||||
if ($tenantId) {
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant) {
|
||||
$tenant->update(['subscription_status' => 'cancelled']);
|
||||
$tenant->update(['subscription_status' => 'expired']);
|
||||
// Deactivate TenantPackage
|
||||
TenantPackage::where('tenant_id', $tenantId)->update(['active' => false]);
|
||||
Log::info('PayPal subscription cancelled', ['subscription_id' => $subscriptionId, 'tenant_id' => $tenantId]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
38
app/Policies/OAuthClientPolicy.php
Normal file
38
app/Policies/OAuthClientPolicy.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
||||
23
app/Policies/PurchaseHistoryPolicy.php
Normal file
23
app/Policies/PurchaseHistoryPolicy.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
||||
73
app/Policies/TenantPolicy.php
Normal file
73
app/Policies/TenantPolicy.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
40
app/Providers/AuthServiceProvider.php
Normal file
40
app/Providers/AuthServiceProvider.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
173
app/Support/Concerns/PresentsPackages.php
Normal file
173
app/Support/Concerns/PresentsPackages.php
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user